@nakedev/go-scaffold 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -130,8 +130,11 @@ function patchMakefile(makefilePath, opts) {
130
130
  // Without it, .env.example's own `APP_ENV=development # prod: production`
131
131
  // reaches `export` as a bare `#` and prints an error on every run.
132
132
  const loadEnv = "@set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a;";
133
- const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both\n" +
134
- "dev:\n" +
133
+ const targets = "\n# run both API + worker in one terminal — Ctrl+C kills both.\n" +
134
+ "#\n" +
135
+ "# migrate-up first, for the same reason `run` does it: cmd/api refuses to\n" +
136
+ "# boot against a database behind the migrations it embeds.\n" +
137
+ "dev: migrate-up\n" +
135
138
  `\t${loadEnv} \\\n` +
136
139
  "\t(trap 'kill 0' SIGINT SIGTERM; \\\n" +
137
140
  "\t go run ./cmd/api & \\\n" +
@@ -113,7 +113,11 @@ function patchMainGoForAuth(mainGoPath, w) {
113
113
  const importLine = `"${goModule}/internal/app/user"`;
114
114
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
115
115
  const modelImportLine = `usermodel "${goModule}/internal/app/user/adapters/outbound/postgres"`;
116
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
116
+ // Only the development AutoMigrate list ever used this alias, so it is only
117
+ // an import where that list still exists — see the guard further down.
118
+ if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
119
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
120
+ }
117
121
  if (w.worker) {
118
122
  const queueImportLine = `"${goModule}/internal/platform/queue"`;
119
123
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, queueImportLine, queueImportLine);
@@ -166,7 +170,13 @@ function patchMainGoForAuth(mainGoPath, w) {
166
170
  '\treturn fmt.Errorf("create schema user_svc: %w", err)',
167
171
  "}",
168
172
  ].join("\n");
169
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
173
+ // The development AutoMigrate bootstrap is gone from the template, and with
174
+ // it the schema/model markers. A project scaffolded before that still has
175
+ // them, and still wants its tables registered there — so these stay, guarded
176
+ // by the marker's presence rather than deleted. New projects skip them.
177
+ if ((0, marker_patch_1.hasMarker)(content, SCHEMA_MARKER)) {
178
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
179
+ }
170
180
  const migrateLines = [
171
181
  "&usermodel.User{},",
172
182
  "&usermodel.UserEmail{},",
@@ -178,8 +188,10 @@ function patchMainGoForAuth(mainGoPath, w) {
178
188
  // live in Redis, so reset/verification can share the user transaction.
179
189
  migrateLines.push("&usermodel.AuthToken{},");
180
190
  migrateLines.push("&usermodel.MFAEnrollment{},", "&usermodel.MFAChallenge{},", "&usermodel.MFARecoveryCode{},");
181
- for (const line of migrateLines) {
182
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
191
+ if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
192
+ for (const line of migrateLines) {
193
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
194
+ }
183
195
  }
184
196
  // Keep auth construction inside the feature package. The root only chooses
185
197
  // shared infrastructure and registers the resulting handler.
@@ -39,10 +39,11 @@ function mainGoLines(patch) {
39
39
  return {
40
40
  importLine: `"${patch.goModule}/internal/app/${patch.modulePath}"`,
41
41
  modelImportLine: `${modelAlias} "${patch.goModule}/internal/app/${patch.modulePath}/adapters/outbound/postgres"`,
42
- // Development schema bootstrap creates tables but not the schema they live in — see
43
- // the comment on go-scaffold:schemas in main.go.hbs. One Exec per schema,
42
+ // Legacy only: projects generated before the development AutoMigrate
43
+ // bootstrap was removed still have the schema marker, and their tables are
44
+ // created by GORM rather than by the migration. One Exec per schema,
44
45
  // guarded by its own sentinel so two modules sharing a schema name only
45
- // ever produce one line (not expected today, but cheap to keep safe).
46
+ // ever produce one line.
46
47
  schemaLines: [
47
48
  `if err := db.Exec("CREATE SCHEMA IF NOT EXISTS ${patch.schemaName}").Error; err != nil {`,
48
49
  `\treturn fmt.Errorf("create schema ${patch.schemaName}: %w", err)`,
@@ -73,7 +74,9 @@ function assertMainGoPatchable(mainGoPath) {
73
74
  throw new Error(`${mainGoPath} not found — this doesn't look like a go-scaffold project`);
74
75
  }
75
76
  const content = fs_extra_1.default.readFileSync(mainGoPath, "utf8");
76
- const missing = [IMPORT_MARKER, SCHEMA_MARKER, MODEL_MARKER, ROUTE_MARKER].filter((m) => !(0, marker_patch_1.hasMarker)(content, m));
77
+ // Not SCHEMA_MARKER/MODEL_MARKER: those belong to the development
78
+ // AutoMigrate bootstrap, which newer projects do not have.
79
+ const missing = [IMPORT_MARKER, ROUTE_MARKER].filter((m) => !(0, marker_patch_1.hasMarker)(content, m));
77
80
  if (missing.length) {
78
81
  throw new Error(`cmd/api/wiring.go is missing the marker comment${missing.length > 1 ? "s" : ""} this command patches at:\n` +
79
82
  missing.map((m) => ` ${m}`).join("\n") +
@@ -94,9 +97,17 @@ function patchMainGo(mainGoPath, patch) {
94
97
  // folder was deleted (main.go still wired) is a no-op, not a dup that
95
98
  // panics gin at startup.
96
99
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
97
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
98
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaLines, schemaSentinel);
99
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine, migrateLine);
100
+ // The development AutoMigrate bootstrap is gone from the template, and with
101
+ // it the schema/model markers. A project scaffolded before that still has
102
+ // them, and still wants its tables registered there — so these stay, guarded
103
+ // by the marker's presence rather than deleted. New projects skip them.
104
+ if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
105
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
106
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, migrateLine, migrateLine);
107
+ }
108
+ if ((0, marker_patch_1.hasMarker)(content, SCHEMA_MARKER)) {
109
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaLines, schemaSentinel);
110
+ }
100
111
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, ROUTE_MARKER, routeLine, routeLine);
101
112
  content = (0, marker_patch_1.removeLines)(content, [UNUSED_API_LINE]);
102
113
  fs_extra_1.default.writeFileSync(mainGoPath, content);
@@ -167,16 +167,27 @@ function patchMainGoForRbac(mainGoPath, goModule, store, worker) {
167
167
  const importLine = `"${goModule}/internal/app/role"`;
168
168
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, importLine, importLine);
169
169
  const modelImportLine = `rolepostgres "${goModule}/internal/app/role/adapters/outbound/postgres"`;
170
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
170
+ // Only the development AutoMigrate list ever used this alias.
171
+ if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
172
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, IMPORT_MARKER, modelImportLine, modelImportLine);
173
+ }
171
174
  const schemaBlock = [
172
175
  'if err := db.Exec("CREATE SCHEMA IF NOT EXISTS role_svc").Error; err != nil {',
173
176
  '\treturn fmt.Errorf("create schema role_svc: %w", err)',
174
177
  "}",
175
178
  ].join("\n");
176
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
179
+ // The development AutoMigrate bootstrap is gone from the template, and with
180
+ // it the schema/model markers. A project scaffolded before that still has
181
+ // them, and still wants its tables registered there — so these stay, guarded
182
+ // by the marker's presence rather than deleted. New projects skip them.
183
+ if ((0, marker_patch_1.hasMarker)(content, SCHEMA_MARKER)) {
184
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS role_svc");
185
+ }
177
186
  const migrateLines = ["&rolepostgres.Role{},", "&rolepostgres.Permission{},", "&rolepostgres.RolePermission{},"];
178
- for (const line of migrateLines) {
179
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
187
+ if ((0, marker_patch_1.hasMarker)(content, MODEL_MARKER)) {
188
+ for (const line of migrateLines) {
189
+ content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, MODEL_MARKER, line, line);
190
+ }
180
191
  }
181
192
  const wiring = { goModule, queueBackend: "river", store, worker };
182
193
  const authRouteLine = (0, auth_patcher_1.authHandlerLineFor)(wiring);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,8 +17,8 @@ CREATE TABLE user_svc.auth_tokens (
17
17
  last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
18
18
  );
19
19
 
20
- -- Named to match model.AuthToken's own `index:` tags exactly, so the
21
- -- development bootstrap and production migration describe the same indexes.
20
+ -- Named to match model.AuthToken's own `index:` tags exactly, so the Go model
21
+ -- and the schema that actually exists describe the same indexes.
22
22
  CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
23
23
  CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
24
24
  CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
@@ -329,10 +329,13 @@ go-scaffold add auth --store postgres --browser-topology same-site --defaults --
329
329
 
330
330
  - APP_ENV is the only environment gate and accepts development or production.
331
331
  Do not reintroduce an AUTO_MIGRATE setting.
332
- - Development may use the convenience db.AutoMigrate(...) bootstrap.
333
- Production must never bootstrap or mutate schema at API startup. Apply
334
- versioned SQL with make migrate-up; the startup version check must fail
335
- fast for a behind or dirty database.
332
+ - The schema comes from migrations/ in every environment, development
333
+ included. Apply it with make migrate-up; the startup version check fails
334
+ fast for a behind or dirty database. Never bootstrap or mutate schema at API
335
+ startup, and do not add a db.AutoMigrate(...) convenience for development:
336
+ GORM builds tables from the Go structs, so it produces none of the CHECK
337
+ constraints, foreign keys or seeded rows the SQL carries, and it silently
338
+ adds columns to an already-migrated database.
336
339
  - Use generate migration for schema changes outside a new module, write and
337
340
  review both migration directions, and test them against a disposable DB.
338
341
  - Keep SQL, Redis, queue, mail, and other external resources under the
@@ -64,6 +64,15 @@ jobs:
64
64
  migrate -path migrations -database "$DB_DSN" up
65
65
  fi
66
66
 
67
+ # up -> down -all -> up. The only check that catches a down.sql that has
68
+ # stopped reversing its up.sql, which is otherwise discovered during a
69
+ # rollback — the worst moment to find out. Runs against the test database
70
+ # and leaves it migrated up for the steps below.
71
+ - name: Verify migrations reverse cleanly
72
+ env:
73
+ TEST_DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
74
+ run: make migrate-verify
75
+
67
76
  - name: Test (required PostgreSQL integration tests cannot skip)
68
77
  env:
69
78
  TEST_DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
@@ -160,14 +160,46 @@ endpoint is production-ready.
160
160
 
161
161
  - `APP_ENV` is the single environment gate and accepts only `development` or
162
162
  `production`. Do not add a parallel flag such as `AUTO_MIGRATE`.
163
- - Development may use the convenience `db.AutoMigrate(...)` path for the
164
- models registered by the generator. Production must never bootstrap or
165
- mutate schema at API startup: apply versioned SQL with
166
- `make migrate-up`, then let the startup migration-version check fail fast if
167
- the database is behind or dirty.
163
+ - **The schema comes from `migrations/` in every environment, development
164
+ included.** `cmd/api/wiring.go` runs the migration-version check and nothing
165
+ else: apply versioned SQL with `make migrate-up`, and let the check fail
166
+ fast when the database is behind or dirty. Never bootstrap or mutate schema
167
+ at API startup, and do not reintroduce a `db.AutoMigrate(...)` convenience
168
+ for development — GORM builds tables from the Go structs, so it writes none
169
+ of the CHECK constraints, none of the foreign keys and none of the seeded
170
+ rows a migration carries, and it is additive over an already-migrated
171
+ database, so a column a model has and a migration does not shows up on one
172
+ developer's machine and nowhere else.
168
173
  - Any schema change is a reviewed pair of versioned files in `migrations/`.
169
174
  Use `go-scaffold generate migration <name>` for changes outside a new
170
175
  module, write both directions, and test them against a disposable database.
176
+ Write the SQL first and the Go struct second: the SQL is what exists, the
177
+ struct is Go's view of it, and doing it the other way round feels finished
178
+ while the database still has nothing.
179
+ - **A destructive change takes two deploys, never one.** During a rolling
180
+ deploy the old binary and the new one serve traffic at the same time, so a
181
+ migration has to be readable by the version of the code still running.
182
+ `CheckMigrationVersion` is built for that order — a database ahead of the
183
+ binary is a warning, behind it is fatal — which only works if migrations go
184
+ out first and stay backward compatible.
185
+
186
+ | change | deploys |
187
+ |---|---|
188
+ | add a nullable column, add a table, add an index | 1 |
189
+ | add `NOT NULL` with a `DEFAULT` | 1 — but it rewrites the table, so check its size first |
190
+ | rename a column | **2** |
191
+ | drop a column or a table | **2** |
192
+ | change a column's type | **2** |
193
+
194
+ The two are *expand* then *contract*: add the new shape and have the code
195
+ write both and prefer the new one; deploy that everywhere; only then drop the
196
+ old shape. `DROP COLUMN` in one round pulls the floor out from under the pods
197
+ still serving requests.
198
+ - `make migrate-verify` (up → down -all → up) runs in CI and is the only thing
199
+ that catches a `down.sql` which has quietly stopped reversing its `up.sql`.
200
+ Without it that is found during a rollback, which is the worst moment to find
201
+ out. Never run it against a database whose data matters — `down -all` means
202
+ it.
171
203
  - Keep `*sql.DB`, Redis, queue, mail, and other external resources owned by
172
204
  their composition root. Close them on every exit path and preserve the
173
205
  signal-driven graceful shutdown; do not call `os.Exit` from a goroutine or
@@ -39,7 +39,12 @@ ENV_FILE ?= .env
39
39
 
40
40
  .PHONY: tools run build test fmt vet lint tidy db-create db-drop migrate-up migrate-up-test migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
41
41
 
42
- run:
42
+ # migrate-up first, because cmd/api refuses to boot against a database that is
43
+ # behind the migrations it embeds. The schema is the migrations' in every
44
+ # environment — this is the step that would otherwise be a db.AutoMigrate
45
+ # bootstrap, except it applies the same SQL production gets, constraints and
46
+ # seeded rows and all.
47
+ run: migrate-up
43
48
  @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; go run ./cmd/api
44
49
 
45
50
  build:
@@ -115,26 +120,41 @@ db-drop:
115
120
  exit 1; \
116
121
  fi
117
122
 
123
+ # A project has no migrations until its first `generate module` or `add auth`,
124
+ # and `migrate up` treats an empty directory as an error rather than as nothing
125
+ # to do. `run` depends on this target, so without the guard the very first
126
+ # `make run` of a fresh scaffold fails on a database that is already correct.
118
127
  migrate-up:
119
- @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" up
128
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
129
+ if ! ls migrations/*.up.sql >/dev/null 2>&1; then echo "no migrations yet — nothing to apply"; exit 0; fi; \
130
+ migrate -path migrations -database "$$DB_DSN" up
120
131
 
121
132
  # same migration files, applied to TEST_DB_DSN instead of DB_DSN — run this
122
133
  # once after `make db-create DB_NAME=..._test` so repository_test.go files
123
134
  # see real tables instead of skipping (or failing, if REQUIRE_TEST_DB=true).
124
135
  migrate-up-test:
125
- @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$TEST_DB_DSN" up
136
+ @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
137
+ if ! ls migrations/*.up.sql >/dev/null 2>&1; then echo "no migrations yet — nothing to apply"; exit 0; fi; \
138
+ migrate -path migrations -database "$$TEST_DB_DSN" up
126
139
 
127
140
  migrate-down:
128
141
  @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; migrate -path migrations -database "$$DB_DSN" down 1
129
142
 
130
- # runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
131
- # (one that no longer reverses cleanly) before you actually need a rollback.
132
- # point DB_DSN at a throwaway/test database first — this drops every table.
143
+ # runs up -> down-to-zero -> up to catch a bit-rotted down.sql (one that no
144
+ # longer reverses cleanly) before you actually need a rollback.
145
+ #
146
+ # TEST_DB_DSN, not DB_DSN: this drops every table, and it used to say "point
147
+ # DB_DSN at a throwaway database first" — advice its own recipe made impossible
148
+ # to follow, because the .env sourced on the line below overwrites whatever
149
+ # DB_DSN the caller set on the way in. The test database is the one that is
150
+ # meant to be destroyed, so it is the one named here.
133
151
  migrate-verify:
134
152
  @set -a; [ -f $(ENV_FILE) ] && . ./$(ENV_FILE); set +a; \
135
- migrate -path migrations -database "$$DB_DSN" up && \
136
- migrate -path migrations -database "$$DB_DSN" down -all && \
137
- migrate -path migrations -database "$$DB_DSN" up
153
+ if ! ls migrations/*.up.sql >/dev/null 2>&1; then echo "no migrations yet — nothing to apply"; exit 0; fi; \
154
+ : "$${TEST_DB_DSN:?TEST_DB_DSN is required — migrate-verify drops every table and will not guess a database}"; \
155
+ migrate -path migrations -database "$$TEST_DB_DSN" up && \
156
+ migrate -path migrations -database "$$TEST_DB_DSN" down -all && \
157
+ migrate -path migrations -database "$$TEST_DB_DSN" up
138
158
  {{#if openapiDocs}}
139
159
 
140
160
  # docs/openapi.yaml is hand-written and split across sibling files via relative
@@ -83,7 +83,7 @@ go mod tidy
83
83
  {{#if worker}}{{#if (eq queue "river")}}make river-migrate # create River's job tables before queued mail
84
84
  {{else}}# start Redis and set REDIS_URL before running the worker
85
85
  {{/if}}{{/if}}
86
- make run # APP_ENV=development enables the convenience schema bootstrap
86
+ make run # needs `make migrate-up` first — the schema is the migrations'
87
87
  ```
88
88
 
89
89
  `make run`/`make test`/`make migrate-up`/`make migrate-down` load `.env` if
@@ -154,9 +154,9 @@ migrate -path migrations -database "$DB_DSN" down 1
154
154
  migrate create -ext sql -dir migrations -seq=false add_something # same as `go-scaffold generate migration`, if you'd rather not use the CLI
155
155
  ```
156
156
 
157
- **dev:** `APP_ENV=development` enables the convenience table bootstrap.
158
- **prod:** `APP_ENV=production` never bootstraps or mutates the schema; run
159
- `migrate up` as a separate deploy step versioned and rollback-capable.
157
+ **Every environment:** the schema comes from `migrations/`. Nothing
158
+ bootstraps or mutates it at startup; run `migrate up` as its own step —
159
+ versioned and rollback-capable and the app refuses to boot until you have.
160
160
 
161
161
  In production, the app checks the DB's applied migration version
162
162
  against the migration files baked into the binary (embedded at build time)
@@ -306,7 +306,7 @@ Before the first deploy of a release:
306
306
 
307
307
  | | |
308
308
  |---|---|
309
- | `APP_ENV` | `production` | production disables schema bootstrap and requires the applied migration version |
309
+ | `APP_ENV` | `production` | production hides internal error details; the applied migration version is required in every environment |
310
310
  | migrations | `migrate -path migrations -database "$DB_DSN" up` as its own step, before the new binaries roll |
311
311
  | `TRUSTED_PROXIES` | the CIDRs of your ingress, or the auth rate limiter keys on a header anyone can send |
312
312
  | `COOKIE_SECURE` | `true` · `COOKIE_SAMESITE=none` as well if your frontend is on a different site |
@@ -54,19 +54,21 @@ func run() error {
54
54
  }()
55
55
  // go-scaffold:platform-init
56
56
 
57
- if !cfg.IsProd() {
58
- // Development boot creates tables but never the schema they live in — each
59
- // domain gets its own (see the outbound postgres model's TableName), so it has to exist
60
- // before the bootstrap runs. Production runs the versioned SQL migrations
61
- // instead, so this entire branch is skipped there.
62
- // go-scaffold:schemas
63
-
64
- if err := db.AutoMigrate(
65
- // go-scaffold:models
66
- ); err != nil {
67
- return fmt.Errorf("migrate: %w", err)
68
- }
69
- } else if err := database.CheckMigrationVersion(db); err != nil {
57
+ // The schema comes from migrations/ in every environment, development
58
+ // included: `make migrate-up` applies it, and this refuses to boot when the
59
+ // database is behind or dirty.
60
+ //
61
+ // There is deliberately no db.AutoMigrate bootstrap for development. GORM
62
+ // builds tables from the Go structs, so it writes none of the CHECK
63
+ // constraints, none of the foreign keys and none of the seeded rows a
64
+ // migration carries — a schema that only looks like production's, on which
65
+ // a seeded catalogue is simply empty. It is also additive over an
66
+ // already-migrated database, so a column a model has and a migration does
67
+ // not appears on the developer's machine and nowhere else.
68
+ //
69
+ // Every migration creates the schema its tables live in, so nothing here
70
+ // needs to.
71
+ if err := database.CheckMigrationVersion(db); err != nil {
70
72
  return fmt.Errorf("migration version check: %w", err)
71
73
  }
72
74
 
@@ -76,6 +76,14 @@ func CheckMigrationVersion(db *gorm.DB) error {
76
76
  }
77
77
  }
78
78
 
79
+ // A scaffold that has not generated a module or added auth yet embeds no
80
+ // migrations, so there is no schema_migrations table to read and nothing
81
+ // to compare against. That is a project with no schema, not a project
82
+ // whose schema is behind.
83
+ if latest == 0 {
84
+ return nil
85
+ }
86
+
79
87
  var version int
80
88
  var dirty bool
81
89
  row := db.Raw("SELECT version, dirty FROM schema_migrations").Row()
@@ -143,9 +143,12 @@ wrong. See `patterns.md`, "Filtered Lists".
143
143
 
144
144
  **Decision:** PostgreSQL + GORM, schema managed by
145
145
  [golang-migrate](https://github.com/golang-migrate/migrate).
146
- **Rationale:** `APP_ENV=development` allows a convenience table bootstrap;
147
- `APP_ENV=production` runs only after `migrate up` has applied the separate,
148
- versioned, rollback-capable SQL migrations.
146
+ **Rationale:** one source of schema in every environment — the separate,
147
+ versioned, rollback-capable SQL migrations, applied by `migrate up` before the
148
+ binary starts. A development-only `db.AutoMigrate` convenience would build
149
+ tables from the Go structs instead, dropping the CHECK constraints, foreign
150
+ keys and seeded rows the SQL carries, and adding columns to an
151
+ already-migrated database where no test would see the difference.
149
152
 
150
153
  {{#if docker}}- `docker-compose.yml` provides the local Postgres instance
151
154
  {{/if}}
@@ -50,8 +50,8 @@ must import the specific boundary package it belongs to.
50
50
  kept in `domain/`.
51
51
  - `TableName()` returns a schema-qualified name (`order_svc.orders`, not
52
52
  `orders`) — every domain gets its own Postgres schema, created by its own
53
- migration (`CREATE SCHEMA IF NOT EXISTS`) and by `cmd/api/wiring.go` before
54
- the development table bootstrap runs. A cross-domain FK is still fine — see
53
+ migration (`CREATE SCHEMA IF NOT EXISTS`), which is the only thing that
54
+ creates it. A cross-domain FK is still fine — see
55
55
  the FK rules below — this only stops one domain's table from silently
56
56
  colliding with another's.
57
57
 
@@ -159,8 +159,8 @@ if this project has one):
159
159
  associations / belongs-to — that's what keeps one domain package from
160
160
  importing another.
161
161
  2. **Declare the FK constraint in migration SQL**
162
- (`REFERENCES ... ON DELETE ...`), not a GORM tag — the development bootstrap
163
- doesn't create the constraint, which would make dev and prod schemas diverge.
162
+ (`REFERENCES ... ON DELETE ...`), not a GORM tag — a GORM tag describes the
163
+ constraint to Go and to nothing that runs, so the database would not have it.
164
164
  3. **Map the FK error to the right status** in the outbound adapter with
165
165
  `dberr.IsForeignKey`, then let the inbound adapter render it — inserting a
166
166
  reference to a missing parent, or deleting a parent that still has children,
@@ -40,8 +40,9 @@
40
40
  {{/if}}{{#if worker}}{{#if (eq queue "river")}}- Run `make river-migrate` before relying on queued jobs in a fresh database;
41
41
  run `make river-migrate-test` as well when `REQUIRE_TEST_DB=true` runs the
42
42
  River worker round-trip test.{{/if}}{{/if}}
43
- - `APP_ENV=development` enables the convenience table bootstrap;
44
- `APP_ENV=production` requires `migrate up` as a deploy step instead
43
+ - The schema is the migrations', in every environment: `migrate up` applies
44
+ it and the startup version check refuses to boot without it. There is no
45
+ development table bootstrap
45
46
  {{#if worker}}{{#if (eq queue "river")}}- River uses the database/sql driver and polls for jobs instead of receiving
46
47
  PostgreSQL LISTEN/NOTIFY wakeups; this is deliberate for a shared GORM pool.
47
48
  {{/if}}{{/if}}