Skill v1.0.1
currentAutomated scan100/100+1 new
version: "1.0.1" name: db/orm-and-safety description: Where the generated ORM lands (database.orm.package), and the two per-project database opt-outs that live next to the code they affect, not in forge.yaml — //forge:no-orm in internal/db to skip the generated ORM, and -- forge:allow-destructive in a migration to allow one destructive change — plus how the database itself is derived, and the lint rules for a migration file that cannot run as one transaction (CONCURRENTLY, enum values).
ORM opt-out and migration-safety directives
forge.yaml has no database.driver, database.migrations_dir, database.seed, or allowed_destructive — each was derived, defaulted, or moved to the code it affects.
The database is derived
A service project with db/migrations/ has a postgres database and that directory is where migrations live. Delete the directory and the project has no database: no ORM, no migrations feature. forge project features shows the reason for each.
Skipping the ORM: //forge:no-orm
Some projects hand-write their data layer and must not get generated entity structs over it. Put the marker in the doc comment of internal/db, with the reason:
// Package db is the hand-owned repository layer.////forge:no-orm: the repository is hand-written; entities are not projectedpackage db
Why a marker, not a forge.yaml key. The exemption describes a property of internal/db, so a reader of that package sees it, with its reason, where //forge:exclude-contract: <why> already works. It opts out of the ORM projection only — migrations, codegen and the database stay on.
With no ORM there is no generic CRUD to wire, so every rpc is custom on the server: a CRUD-named one (CreateTask over a tasks table) gets a pb-through stub and a scaffold test row like any other, for your data layer to implement. To keep the ORM and opt ONE rpc out, mark it // forge:custom instead (forge project annotations --kind method).
forge reads it from non-test, non-generated .go files directly in internal/db/. A line that merely mentions it in prose, a _gen.go file, a _test.go file, or another package does not count. forge project features shows orm off with (//forge:no-orm in internal/db). With database.orm.package set, the marker is read in THAT directory instead.
Placing the ORM: database.orm.package
The one ORM setting forge.yaml does carry, because it is a property of the PROJECT (where generated code goes), not of a package that already exists:
database:orm:package: internal/store # default internal/db
Every projected file — <entity>_orm_gen.go, store_gen.go, the scaffold-once <entity>_repo_ext.go seams — lands in that directory and declares its package (package store). Everything compiled against the ORM follows it: the CRUD wiring imports it (as db, the qualifier every generated CRUD file uses), compose.go provides store.Store / store.<Entity>Store Deps fields, the test factories, forge project shapes --kind store, and the drift guard. The name-collision refusal and //forge:no-orm read that package, not internal/db. The _gen.go files open with Go's // Code generated by forge. DO NOT EDIT. banner, so golangci-lint and gopls treat them as generated with no exclusion of yours; the _repo_ext.go seams are yours and carry no banner.
Use it when internal/db is already a data layer of yours whose names (a Chat alias, a Repo, a Store) collide with the projection's, and you are porting onto the generated store one table at a time: the two packages coexist, and nothing about your internal/db has to change first. Moving the key reclaims forge's previous projection from the old directory; your _repo_ext.go seams there are yours and stay.
Allowing one destructive migration: -- forge:allow-destructive
forge lint --migration-safety refuses DROP COLUMN, DROP TABLE and TRUNCATE. When the change is intentional, mark that migration file with a SQL comment line:
-- forge:allow-destructive: the column was never read; removed after the 2026-09 releaseALTER TABLE daemons DROP COLUMN last_heartbeat;
- It is per file and in the file: the exemption sits next to the SQL it excuses. There
is no forge.yaml allowlist and no second spelling (-- forge-safety: allow-destructive is gone and no longer silences anything).
forge project auditlists every migration that carries it (allow_destructive_files).- NOT NULL findings have their own separate directive,
-- forge:allow-unsafe-not-null.
Editing an applied migration. forge does not checksum migrations, so adding the comment to an already-merged file does not disturb forge. Whether your migration RUNNER records a hash of the file is the runner's business — check it before touching a merged file, and never change the statements themselves.
A migration file runs as one transaction
The migrator (and AutoMigrate, and testkit) sends a migration file to postgres as ONE query. A query holding several statements runs as one implicit transaction, so two kinds of file pass review, fail at deploy, and leave the schema dirty. forge lint --migration-safety refuses both, at the statement's line.
- `non-transactional-statement` — a statement postgres refuses inside a transaction block
(SQLSTATE 25001) shares its file with another statement. CREATE/DROP INDEX CONCURRENTLY, REINDEX … CONCURRENTLY (and REINDEX SCHEMA|DATABASE|SYSTEM), ALTER TABLE … DETACH PARTITION … CONCURRENTLY, VACUUM, a table-less CLUSTER, and the database, tablespace, ALTER SYSTEM and subscription statements. Give each one a file of its own (forge db migration new <name>): a lone statement runs outside any transaction block.
- `enum-value-used-before-commit` — a file uses an enum value it also adds
(ALTER TYPE … ADD VALUE 'x', then 'x' in an UPDATE, INSERT or DEFAULT). A value added in a transaction cannot be used until it commits (SQLSTATE 55P04). Move the ADD VALUE to an earlier migration of its own. A value added to a type the same file creates is fine.
Both read statements, not the database. When postgres does run the file as one transaction (a subscription created with slot_name = NONE; a string that only shares an added enum value's spelling), say so in the file with -- forge:allow-in-transaction: <why>. forge lint --show-suppressed lists it.
ALTER TYPE … ADD VALUE on its own is not refused: postgres has allowed it inside a transaction since 12, and every postgres forge provisions or tests against is 16 or newer.
Entity field types
The entity struct's field names and types are Go's, and lossless against the column:
- Names spell initialisms in capitals:
id→ID,org_id→OrgID,
api_key → APIKey (a plural stays: user_ids → UserIds). The <E>Field* and <E>Constraint* constants follow.
- An integer is as wide as its column:
INTEGER→int32,SMALLINT→
int16, BIGINT → int64.
JSONB/JSONisjson.RawMessage— the document's bytes, nil for NULL
(jsonb[] stays []string).
COMMENT ON COLUMN t.c IS 'forge:go-type=<type>'declares another type for
one column: a named enum over an integer, map[string]any over json, time.Duration over a bigint, or <import path>[;<pkg>].<Type>. The type must scan and bind by Bun's rules. forge generates no wire conversion for such a column: a CRUD rpc carrying it fails generate naming it.