@starci/hfs 4.9.9 → 4.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.
Files changed (241) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +20 -8
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +289 -0
  5. package/db/dump.mjs +12 -11
  6. package/db/entity-list.mjs +9 -0
  7. package/db/migrate.mjs +84 -260
  8. package/db/new.mjs +46 -0
  9. package/db/post-cutover.mjs +13 -17
  10. package/db/postgres.mjs +80 -0
  11. package/emit/contracts.mjs +5 -9
  12. package/lint/run.mjs +12 -2
  13. package/package.json +5 -3
  14. package/release/gate.mjs +82 -31
  15. package/release/index.mjs +19 -8
  16. package/release/push.mjs +4 -4
  17. package/release/records.mjs +54 -0
  18. package/release/urgent.mjs +129 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
  20. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  21. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  22. package/runtime/knowledge/hfs/rules.yaml +250 -157
  23. package/runtime/knowledge/hfs/slots.yaml +199 -174
  24. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  25. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  26. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  27. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  28. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  29. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  30. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  31. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  32. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  33. package/runtime/modules/kernel/command-policy.yaml +1 -1
  34. package/runtime/modules/kernel/failure-codes.yaml +86 -115
  35. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  36. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  37. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  39. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  40. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  41. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  43. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  44. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  45. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  46. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  47. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  48. package/runtime/scripts/hfs/check.mjs +8 -8
  49. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  50. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  51. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  52. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  53. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  54. package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
  55. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  56. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  57. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  58. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  59. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  60. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  61. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  62. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  63. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  64. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  65. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  66. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  67. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  68. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  69. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  70. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  71. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  72. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  73. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  74. package/runtime/scripts/hfs/slots.mjs +8 -2
  75. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  76. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  77. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  78. package/scaffold/add-app.mjs +2 -10
  79. package/scaffold/add-cli-lite.mjs +6 -6
  80. package/scaffold/add-compose.mjs +1 -1
  81. package/scaffold/add-inbox-lite.mjs +4 -8
  82. package/scaffold/add-plan.mjs +1 -1
  83. package/scaffold/add-queue-lite.mjs +16 -24
  84. package/scaffold/add-table.mjs +34 -26
  85. package/scaffold/add.mjs +2 -2
  86. package/scaffold/app.mjs +19 -86
  87. package/scaffold/lite-exports.mjs +1 -1
  88. package/src/main.mjs +24 -22
  89. package/stack/cli.mjs +10 -2
  90. package/stack/deploy.mjs +21 -4
  91. package/stack/target-binding.mjs +0 -60
  92. package/stack/urgent-floor.mjs +147 -0
  93. package/sync/ci-commands.mjs +3 -1
  94. package/sync/index.mjs +32 -8
  95. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  96. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  97. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  98. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  99. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  100. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  101. package/templates/app/gitignore-lite +0 -3
  102. package/templates/app/package-scripts/db.json +9 -0
  103. package/templates/app/package-scripts/package.json +0 -1
  104. package/templates/app/package-scripts-lite/package.json +1 -1
  105. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  106. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  107. package/templates/app/skeleton/README.md +1 -1
  108. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  109. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  110. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  111. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  112. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  113. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  114. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  115. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  116. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  117. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  118. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  119. package/templates/app/skeleton-lite/README.md +11 -11
  120. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  121. package/templates/app/tool-config-lite/prettierignore +0 -1
  122. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  123. package/templates/be/inbox/database-client.ts.tpl +13 -4
  124. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  125. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  126. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  127. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  128. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  129. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  130. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  131. package/templates/be/inbox/service.ts.tpl +1 -2
  132. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  133. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  134. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  135. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  136. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  137. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  138. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  139. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  140. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  141. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  142. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  143. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  144. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  145. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  146. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  147. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  148. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  149. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  150. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  151. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  152. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  153. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  154. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  155. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  156. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  157. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  158. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  159. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  160. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  161. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  162. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  163. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  164. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  165. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
  166. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  167. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  168. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  169. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  170. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  171. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  172. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  173. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  174. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  175. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  176. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  177. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  185. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  186. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  187. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  188. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  189. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  190. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  191. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  192. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  193. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  194. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  195. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  196. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  197. package/templates/be/table/entities.index.ts.tpl +4 -0
  198. package/templates/be/table/entity.ts.tpl +21 -0
  199. package/templates/be/table/index.ts.tpl +1 -0
  200. package/templates/be/table/module.ts.tpl +1 -1
  201. package/templates/be/table/service.api.ts.tpl +10 -11
  202. package/templates/be/table/service.ts.tpl +5 -6
  203. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  204. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  205. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  207. package/db/local-migrate.mjs +0 -194
  208. package/db/reset.mjs +0 -125
  209. package/emit/db-types.mjs +0 -271
  210. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  211. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  212. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  213. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  214. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  215. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  216. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  217. package/scaffold/supabase-ports.mjs +0 -26
  218. package/templates/app/package-scripts/supabase.json +0 -7
  219. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  220. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  221. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  222. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  223. package/templates/be/inbox/sql.ts.tpl +0 -16
  224. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  225. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  226. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  227. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  228. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  229. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  230. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  231. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  232. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  233. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  234. package/templates/be/table/rows.ts.tpl +0 -4
  235. package/templates/be/table/sql.api.ts.tpl +0 -7
  236. package/templates/be/table/sql.ts.tpl +0 -4
  237. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  238. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  239. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  240. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  241. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.10.0 - 2026-10-29
4
+
5
+ Breaking for lite apps (ruling lite-self-hosted-postgres-passport-minio; the migration note is contexts/project/lite.md "Moving an app off Supabase").
6
+
7
+ - Changed: the lite edition leaves Supabase. `starci app db` runs against a self-hosted PostgreSQL named by `--url-env <NAME>` (default `DATABASE_URL`) through psql and pg_dump (`db/postgres.mjs`): `migrate` applies each `db/migrations/<ts14>_<kebab>.sql` with its history row in one transaction (the `supabase_migrations.schema_migrations` layout, so a restored history carries over), runs `db/bootstrap.sql` first on an empty database and needs no `STARCI_DB_ARMED` on the loopback; `dump` writes roles, schema and data with pg_dumpall and pg_dump; `plan`, `baseline`, `restamp` and `post-cutover` query and repair the history through psql. New `db new <kebab-name>`. Removed: `db reset`, `--linked`, `--local`, `--target-binding` of the db verbs, the bundled Supabase CLI wrappers and the generated database types (`emit/db-types.mjs`, `app check --db-types`, `add --no-types`).
8
+ - Changed: the lite templates. Managed: the db package scripts (`db:create|start|stop|reset` of the pinned `supabase/postgres` image, `db:new`, `db:push`, `db:plan`, `db:dump`), `scripts/codegen.mjs` (GraphQL only), `ci.yml` (no database-types job), `db-deploy.yml` (`starci app db migrate --url-env DATABASE_URL`), `.gitignore`, `.prettierignore` and `db/bootstrap.sql`; `starci app sync --write` deletes a leftover `supabase/types/` of a lite app. Scaffolded: `db/` instead of `supabase/`, a `db` service in both Swarm stacks with its volume, backup and sealed password, `backing.db`, the passport (Google OAuth with state and PKCE, email and password with bcryptjs, own ES256 tokens and JWKS, the refresh cookie) and storage (S3 client, presigned PUT and GET) integrations, the identity guard on the own-token verifier, `platform/database` entities option, `PRIMARY_ENTITIES` and the `callDbFunction`, `advisoryXactLock` and `notify` helpers, and the front end's `modules/auth` over the app's own auth doors (`auth-door.ts`, `token-store.ts`).
9
+ - Changed: the checks. `LITE_DB_MIGRATION` (R332) refuses a `supabase/` tree or SQL outside `db/`; the database rules read `db/migrations` and hfs.json `db` (`forceRls`, `forceRlsExemptions`, `appRole`, `urlEnv`, `image`) and drop the config.toml, browser-access and realtime-publication checks; R276 admits the lite `db` component of image `supabase/postgres`; `backing` entries are `{provider: postgres, component}`; the architecture machine's identity check (`identity.mjs`) holds R329 and R260; SQL table ownership reads the tables the migrations create; hfs.json `sides.be.dbFunctions` joins `ruleParams.be.dbFunctions`.
10
+ - Changed: `starci app add table` in lite writes a TypeORM entity (`persistence/entities/<table>.entity.ts`, `@Entity({ schema: "public", name })`) and the capability's `persistence/entities/index.ts` (`<c>Entities`), exports it from the capability entry and names it in `PRIMARY_ENTITIES` of `platform/database/entities.ts`; the service reads through `requireOwnedEntity` of `platform/database` (the entity's query builder, named parameters) and inserts with `save`. No `<name>.rows.ts` or `<name>.sql.ts` is written (ruling lite-persistence-typeorm-entities). The machine `schema-owner` (R35) checks that lite shape: the entity list in `persistence/entities/index.ts` and its name in `PRIMARY_ENTITIES`.
11
+ - Changed: the lite queue, jobs and webhook generators write no SQL text outside platform/database (R334): their statements are functions of the generated db migrations (job_claim, job_advance, job_complete, job_fail, queue_write, queue_waiting, queue_mark_sent, inbox_claim_event, inbox_claim_client, inbox_save_client_outcome, inbox_sweep_clients, event_bus_write) called through `callDbFunction`, listed with their reasons in the canon `ruleParams.be.dbFunctions`; no `persistence/*.sql.ts` or `*.rows.ts` is written, and the webhook receipt insert is `webhookReceiptInsert(table)` of `platform/database`. The storage integration is `be.integrations.object-storage` (`src/modules/integrations/object-storage/`, `parseObjectStorageConfig`, `ObjectStorageOptions`, options member `objectStorage`). `db/entity-list.mjs` reads the lite entity list at `be/src/modules/platform/database/entities.ts`, where the skeleton keeps it.
12
+ - Changed: the bundled lite pattern knowledge (event-bus, idempotency, jobs, projections, queues, reactors, realtime, saga, webhooks) names db migrations and the local db service instead of Supabase; the command policy points `supabase` at `starci app db` and the db scripts.
13
+ - Changed: the bundled slot manifest 3.9.0 replaces the lite `app.supabase*` slots with `app.db` (`db/migrations`, `db/migrations/archive`, `db/seed.sql`, the managed `db/bootstrap.sql`), `be.integrations.supabase` with `be.integrations.passport` and `be.integrations.object-storage`, removes `fe.route.callback`, sets lite `schemaAuthority: sql`, gives lite TypeORM entities in `be.persistence`, adds `ruleParams.be.dbFunctions`, and moves the S3 SDKs to `integrations/object-storage` and the identity libraries to `integrations/passport` in `infraOwners` (`@supabase/*`, `minio` and `bcrypt` nowhere); the bundled rule catalog retires R216, R217, R219, R254 and R287 and adds R328-R334; the bundled canon pins name hfs 4.10.0, eslint-canon-be 3.7.0, eslint-canon-fe 9.5.0 and the Grammar family 0.15.11, pin `@nestjs/passport` 11.0.5 (CommonJS on the Nest 11 line), `passport` 0.7.0, `passport-google-oauth20` 2.0.0, `passport-local` 1.0.0, `bcryptjs` 3.0.3 and their types (tokens are signed and verified with `jose`; `@nestjs/jwt` stays allowed in `integrations/passport` but unpinned), and drop `@supabase/ssr`, `@supabase/supabase-js` and the `supabase` CLI.
14
+
15
+
16
+ ## 4.9.11 - 2026-10-29
17
+
18
+ - Added: `starci app db drift [--url-env <NAME>] [--json]` (`db/drift.mjs`, owner ruling 2026-10-29: lite tables are mapped by TypeORM entities while `db/migrations/*.sql` stays the only DDL and `synchronize` is false). It builds TypeORM's metadata from `be/src/platform/database/entities.ts` with the app's own typescript, typeorm and pg (a `DataSource` that never connects), applies `db/bootstrap.sql` and every active migration to a scratch container of the app's `components.db` image (or the empty database `--url-env` names), and reports every missing table or column, type mismatch and nullability mismatch as `BE_ENTITY_SCHEMA_DRIFT` (R333 in the bundled rule catalog, with its failure-code entry). No Docker, no app packages or an unreadable target is no verdict (`DB_DRIFT_NEEDS_DATABASE`, exit 2), never a green; a migration that does not apply to an empty database is `DB_DRIFT_MIGRATION_FAILED`. An app with an entity list gets a `db-drift` row in its release gate (default or declared gates) and `npx starci app db drift` after `npm run db:lint` in its managed CI's build job. Specs: `src/db-drift.spec.mjs` (real TypeORM metadata over a fixture app, stubbed schema) and `src/db-drift-docker.spec.mjs` (a real postgres:17-alpine scratch database where Docker runs, else the no-verdict answer); typeorm 0.3.31 and pg 8.23.1 are dev dependencies for them.
19
+
20
+ - Changed: the bundled slot manifest gives the model libraries their owners in `ruleParams.be.infraOwners` (`@starci/ai-router` to `integrations/ai-gateway`, `@langchain/*` to `integrations/ai`; `openai`, `langchain`, `@langchain/langgraph`, `@langchain/community` and the provider SDKs nowhere) and lists R326 and R327 on `be.integrations`; the bundled rule catalog carries R326 `BE_AI_KEY_CUSTODY` and R327 `BE_AI_GATEWAY_CLIENT`; the bundled canon pins name hfs 4.9.11, eslint-canon-be 3.6.8, eslint-canon-fe 9.4.9, the Grammar family 0.15.10 (grammar, eslint-grammar-core, eslint-grammar-offset-pop), and pin `@langchain/core` 1.3.1 and `@langchain/openai` 1.8.0.
21
+ - Added: the urgent release (owner ruling 2026-10-29, "deploy gấp không cần ci cd gì hết"). `starci app release push --urgent --reason "<text>" [--execute]` runs only the fast local checks among the release gates (`app-check`, `typecheck`, `lint`, `test`; the managed `npm run lint` narrowed to `starci app lint --changed` over the source files changed since the previous release tag), reusing a green row the gate already recorded for HEAD (same command, unchanged log; every gate row now lands in `<git common dir>/starci-release/<head12>/rows.json`), then tags HEAD (annotated), pushes the default branch and the tag in one atomic push through the hook (dry run first, no candidate CI) and writes `<head>.urgent.json` naming the role, the reason, each check with its verdict and the commit (`release/urgent.mjs`, `release/records.mjs`). A missing reason is `RELEASE_URGENT_REASON_MISSING`, a red check `RELEASE_URGENT_CHECK_RED` (nothing tagged or pushed).
22
+ - Added: `starci app release gate --backfill <tag> [--execute]` runs the full gate on the urgent release's own commit (checked out at HEAD; `RELEASE_BACKFILL_TAG_UNKNOWN`, `RELEASE_BACKFILL_NOT_ON_TAG`, `RELEASE_BACKFILL_NOT_URGENT` otherwise). Green writes its L4 record (`promotedFrom` the urgent record) and marks the urgent record promoted, with no new tag and no push; red records the verdict on the urgent record and files one fix ticket through the CLI's ticket queue (`RELEASE_BACKFILL_RED`), changing nothing on the remote or on prod.
23
+ - Added: the safety floor of `starci app stack deploy --urgent [--db-url-env <NAME>] [--backup-out <dir>]` (`stack/urgent-floor.mjs`), every failed step a refusal with its own code: the pushed urgent record of `--source-sha` (`STACK_URGENT_NO_RECORD`, `STACK_URGENT_NOT_PUSHED`), a declared database (`STACK_URGENT_NO_DATABASE`), the full public verify (`STACK_URGENT_SKIP_PUBLIC`), a fresh full database backup encrypted to the app's recipients (`STACK_URGENT_BACKUP_FAILED`) whose receipt matches the archive (`STACK_URGENT_BACKUP_UNVERIFIED`) and which a test decrypt with `SOPS_AGE_KEY_FILE` opens with every part (`STACK_URGENT_DECRYPT_UNAVAILABLE`, `STACK_URGENT_DECRYPT_FAILED`), an accepted previous release to roll back to (`STACK_URGENT_NO_ROLLBACK_TARGET`), `db migrate` previewed then executed (`STACK_URGENT_MIGRATE_PREVIEW_RED`, `STACK_URGENT_MIGRATE_FAILED`), then the release with its readiness verify and automatic rollback (`STACK_URGENT_RED_ROLLED_BACK`, `STACK_URGENT_ROLLBACK_FAILED`, `STACK_URGENT_DEPLOY_NOT_STARTED`). A refused floor exits 2 after printing the steps that led to it. `db dump` and `db migrate` export `dumpDatabase` and `migrateDatabase` for it.
24
+ - Added: slot manifest 3.8.0 adds the optional `fe.dev-server-generated` slot (`apps/*/AGENTS.md`, which `next dev` rewrites) and the slot field `rewrittenBy`; the release gate's clean-tree check (`dirtyPaths`, now over `--untracked-files=all`) passes over an untracked file such a slot owns, and over nothing else.
25
+ - Changed: the ESLint child of `starci app lint` and `starci app check` runs with `--max-old-space-size` from `STARCI_ESLINT_HEAP_MB`, which the CLI sets from the eslint memory of ops/liveness.yaml (8192); unset, it keeps node's default.
26
+ - Changed: the lite boot proof's worker has its own budget from its spawn (`STARCI_BOOT_PROOF_TIMEOUT_MS`, which the CLI sets from `heavy.bootProofTimeoutSeconds`), is retried once when it runs out, and a second timeout is `BOOT_PROOF_TIMEOUT` (R272, a warning: no verdict on a loaded host), never `BE_APP_BOOT_PROOF_MISSING`.
27
+ - Changed: the managed `stack-deploy.yml` passes the workflow token (`GH_TOKEN`, `actions: read`) to the deploy step, which reads the CI verdict of the tag's commit a production deploy waits for.
28
+
29
+ ## 4.9.10 - 2026-10-29
30
+
31
+ - Fixed: the back-end skeleton's `OriginGuard` (`platform/http-security`, BE-AUTH-5) refuses a state-changing request (`POST`, `PUT`, `PATCH`, `DELETE`) that names no usable origin, with `HTTP_SECURITY_ORIGIN_REJECTED` (403). It admitted a request with neither `Origin` nor `Referer`, which a session-cookie mutation must never be. `Referer` stands in only when `Origin` is absent, so a foreign or `null` `Origin` is never rescued by an allowed `Referer`; safe methods are not judged.
32
+ - Added: the `ServerToServer` marker of `platform/http-security` exempts a door that proves its caller itself from the origin check. `@Public({ reason: PublicReason.SignedWebhook })` (signature verified first, BE-WEBHOOK-2) and `@Credential({ kind })` (a header credential `CredentialGuard` authenticates) apply it, in full and lite, so the generated webhook doors keep answering providers that send no `Origin`. Both decorators, and `@RateLimit`, are typed with the new `DoorDecorator` of `platform/composition`.
33
+ - Added: `@RateLimit(tier, { redirect })` (BE-AUTH-4): a redirect-only door (an OAuth callback answering `303 /sign-in?auth=error`) names its error redirect, and a caller over the limit gets that 303 from `RateLimitGuard` instead of a 429 body. The redirect must be a same-site path (one leading `/`), refused with `HTTP_SECURITY_REQUEST_INVALID` when the decorator is applied. `ErrorsFilter` leaves a response a guard already sent as it is.
34
+ - Changed: the unit specs of `OriginGuard`, `RateLimitGuard` and `ErrorsFilter` cover the new paths at 100 per file; the bundled canon pins name hfs 4.9.10, eslint-canon-be 3.6.7, eslint-canon-fe 9.4.8, eslint-grammar-core 0.15.9 and eslint-grammar-offset-pop 0.15.9.
35
+
3
36
  ## 4.9.9 - 2026-10-29
4
37
 
5
38
  - Fixed: the back-end skeleton's `RateLimitGuard` counts in a store every replica shares (BE-AUTH-4), not a process-local map. Each hit is one upsert of the caller's window (tier and address) in the primary database, answering its hits; a hit that opens a fresh window prunes up to 100 ended windows (`FOR UPDATE SKIP LOCKED`). `platform/http-security` owns the table `http_security_rate_windows`: in full through its `persistence/` entity and migration (`httpSecurityEntities`, `httpSecurityMigrations`, registered on the primary connection by the api and cli apps), in lite in the Supabase baseline with forced RLS and `app_be` policies. The lite starter now ships `platform/database/database.sql.ts` (with `DATABASE_IDENTIFIER_REJECTED`) and exports `sql`, `InjectPrimaryEntityManager` and `PRIMARY_ENTITY_MANAGER`, which the guard and its spec consume; a lite starter leaves out a platform capability's TypeORM schema files. Needs `@starci/eslint-canon-be` 3.6.5, which admits `http-security` as a persistence capability.
package/README.md CHANGED
@@ -20,6 +20,7 @@ starci app upgrade [--plan]
20
20
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
21
21
  starci app explain <path> [--cwd <dir>] [--json]
22
22
  starci app emit [--cwd <dir>]
23
+ starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
23
24
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
24
25
  starci app new spec <file>.service.ts [--cwd <dir>]
25
26
  starci app new image [--cwd <dir>]
@@ -54,9 +55,9 @@ Lite is the smaller edition of the same HFS standard, not another profile or eng
54
55
  the same slot manifest, rule catalog, lint canons, grammar, security rules and Docker rules apply, while edition fields filter
55
56
  out the test world and event machinery.
56
57
 
57
- `starci app scaffold <name> --edition lite` creates the minimum single-api, single-Next-app Supabase product. It generates the
58
- `app.supabase.types` artifact from the app's local Supabase stack; if that generation cannot run, scaffolding fails and removes
59
- the app it began instead of leaving a partial tree.
58
+ `starci app scaffold <name> --edition lite` creates the minimum single-api, single-Next-app product on its own PostgreSQL (`db/`,
59
+ the `db` service of its stack), its Passport identity and its S3 storage; a failed step removes the app it began instead of leaving a
60
+ partial tree. `starci app db new|plan|migrate|dump|post-cutover --url-env <NAME>` drives that database (default `DATABASE_URL`).
60
61
 
61
62
  The lite `app.starciwork` slot is optional when the app has no brand records. When present, it holds only `.gitignore`,
62
63
  optional `workspace.yaml` metadata and `brand/**` (R212): the existing brand record, drawing records, references and assets.
@@ -66,19 +67,30 @@ and `workspace.yaml` and retains `brand/` only when present.
66
67
 
67
68
  The lite additions are deliberately narrow:
68
69
 
69
- - `starci app add table <name> [--fe] [--no-types]` adds the migration and matching back-end capability. `--fe` also adds the table's
70
+ - `starci app add table <name> [--fe]` adds the migration and matching back-end capability. `--fe` also adds the table's
70
71
  api feature over GraphQL (get-<singular> query, create-<singular> mutation, the GraphQL server capability on first use) and, in
71
72
  every product Next app, one operation file per operation under `modules/api/graphql` and its `useQuery*Swr` / `useMutate*Swr`
72
- hook under `hooks/swr/api/graphql`; never a Server Action or a Supabase reader. Types regenerate by default, while `--no-types` is
73
- the explicit opt-out.
73
+ hook under `hooks/swr/api/graphql`; never a Server Action or a database reader.
74
74
  - `starci app add cli <group>` creates the optional cli app on first use, including its built-in migration and seed groups, then adds
75
75
  the requested group without full-edition specs.
76
76
  - `starci app add app <name>` adds another Next app in either edition: the edition's app skeleton plus the shared app core
77
77
  (modules/api, auth, toast, redux, the providers and the hooks), one fe structure for lite and full (owner ruling 2026-10-13).
78
78
  - An `add` or `new` whose target slot is unavailable in lite stops before writing with `<verb> <noun>: full edition only`.
79
79
 
80
- `starci app emit` continues to emit the enabled back-end snapshots and also writes `supabase/types/database.types.ts` when
81
- the `app.supabase.types` slot is enabled. The local Supabase stack must be running for generation.
80
+ `starci app emit` emits the enabled back-end contract snapshots; a lite app generates no database types.
81
+
82
+ `starci app db drift` holds a lite app's TypeORM entities to the schema its migrations build (R333, `BE_ENTITY_SCHEMA_DRIFT`; `db/drift.mjs`).
83
+ It loads the entity list `be/src/modules/platform/database/entities.ts` with the app's own `typescript`, `typeorm` and `pg` (its `be/tsconfig.json`
84
+ path aliases resolved) and builds TypeORM's metadata through a postgres `DataSource` with `synchronize` false that never connects. It applies
85
+ `db/bootstrap.sql`, when present, and every active `db/migrations/*.sql`, each file in one transaction, to a scratch database: a throwaway
86
+ container of the image `.starcistacks/application-stacks.yaml` declares for `components.db`, or with `--url-env <NAME>` the database that
87
+ variable names, which must hold no relation in `public` yet (`DB_DRIFT_TARGET_NOT_EMPTY`). It reads the result from
88
+ `information_schema.columns` and `pg_catalog` and reports each entity column whose table or column is missing, whose type differs once
89
+ both are spelled as `format_type` names (an enum matches an enum of the name the entity gives), or whose nullability differs; a column no
90
+ entity maps is fine. Exit 0 is green, 1 is drift or a migration that does not apply to an empty database (`DB_DRIFT_MIGRATION_FAILED`),
91
+ and 2 is no verdict: a host without Docker or without the app's packages gets `DB_DRIFT_NEEDS_DATABASE` (needs a database), never a
92
+ green. An app with an entity list runs it as the `db-drift` row of `starci app release gate` and in the database step of its managed CI's
93
+ build job.
82
94
 
83
95
  Input bounds stay with the backend DTO: `MaxLength`, `ArrayMaxSize`, `Min` and `Max` become GraphQL `@inputBounds`
84
96
  input-field directives or JSON Schema constraints on the REST operation table's DTO properties. Codegen derives
@@ -1,11 +1,10 @@
1
- // backup-receipt.mjs - encrypted database backup custody, shared by dump and reset; tool output never enters diagnostics.
1
+ // backup-receipt.mjs - encrypted database backup custody of `starci app db dump`; tool output never enters diagnostics.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { parseYaml } from '../runtime/engine/yaml.mjs';
6
- import { readDeclaration } from '../release/index.mjs';
7
- import { resolveTarget, runCli as migrationCli, targetArgs } from './migrate.mjs';
8
6
  import { failure, refusal } from '../stack/errors.mjs';
7
+ import { DEFAULT_DB_URL_ENV, dbTarget, pgTool, queryRows as psqlRows } from './postgres.mjs';
9
8
 
10
9
  export const RECEIPT_SCHEMA = 'starci/db-backup@1';
11
10
  export function sha256(file) {
@@ -20,10 +19,9 @@ export function sha256(file) {
20
19
  }
21
20
  const inside = (root, file) => { const rel = path.relative(root, file); return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel)); };
22
21
 
23
- /** Backup/restore SQL can hold row values, so even the migration runner's scrubbed stderr must be withheld. */
24
- export function runCli(session, args) {
25
- try { return migrationCli(session, args); }
26
- catch { throw failure('DB_BACKUP_CLI_FAILED', `supabase ${args.slice(0, 2).join(' ')} failed; output withheld`); }
22
+ /** Backup SQL can hold row values, so a client tool's stderr is withheld whole. */
23
+ export function runPgTool(session, command, args) {
24
+ return pgTool(session.ctx, session.target, command, args, { withhold: true, code: 'DB_BACKUP_CLI_FAILED' });
27
25
  }
28
26
 
29
27
  /** Resolves existing ancestors before checking containment, including symlinked output directories and Git worktree marker files. */
@@ -40,27 +38,17 @@ export function outsideRepository(root, output) {
40
38
  return resolved;
41
39
  }
42
40
 
43
- /** Uses migrate's linked/password/URL resolution; local is explicitly selected and never silently defaults to linked. */
41
+ /** The database of `--url-env` (default DATABASE_URL) and the project name the archive carries. */
44
42
  export function backupSession(ctx, options) {
45
- const database = readDeclaration(ctx.cwd).database ?? {};
46
- const selections = [options.linked === true, options.local === true, options.urlEnv !== undefined].filter(Boolean).length;
47
- if (selections !== 1) throw refusal('DB_USAGE', 'select exactly one of --linked, --local or --url-env');
48
- const target = options.local ? { kind: 'local', ref: null, env: {}, secrets: [], how: 'local database' }
49
- : resolveTarget(ctx, { projectRef: null, password: null, ...database }, options);
50
- if (target.kind === 'linked' && !/^[a-z0-9]{10,40}$/u.test(target.ref)) throw refusal('DB_NOT_LINKED', 'linked project ref is invalid');
43
+ const target = dbTarget(ctx, options.urlEnv ?? DEFAULT_DB_URL_ENV);
51
44
  const project = path.basename(ctx.cwd).replace(/[^A-Za-z0-9_-]/gu, '-') || 'database';
52
- return { ctx, target, database, project };
45
+ return { ctx, target, project };
53
46
  }
54
47
 
55
- export const backupArgs = (session) => session.target.kind === 'local' ? ['--local', '--workdir', session.ctx.cwd] : targetArgs(session.target, session.ctx.cwd);
56
-
57
- /** Queries via the same injected Supabase runner; malformed output is withheld. */
48
+ /** One read-only query through psql; failures and malformed output are withheld. */
58
49
  export function queryRows(session, sql) {
59
- let rows;
60
- try { rows = JSON.parse(runCli(session, ['db', 'query', sql, '--output', 'json', '--agent', 'no', ...backupArgs(session)]).stdout); }
50
+ try { return psqlRows(session.ctx, session.target, sql, { withhold: true }); }
61
51
  catch { throw failure('DB_BACKUP_QUERY_FAILED', 'database inventory query failed; output withheld'); }
62
- if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) throw failure('DB_BACKUP_QUERY_FAILED', 'database inventory is not a rows array');
63
- return rows;
64
52
  }
65
53
 
66
54
  /** Executes local tar/age through ctx.spawn, the same seam used by the stack's age installer; never relays tool errors containing data. */
@@ -102,28 +90,3 @@ export function encryptFile(ctx, input, output, recipients) {
102
90
  return checkArchive(output);
103
91
  }
104
92
 
105
- /** Validates a complete, recent, full-data receipt and its encrypted bytes before any destructive reset call. */
106
- export function validateReceipt(session, receiptPath) {
107
- if (!receiptPath) throw refusal('DB_RESET_BACKUP_MISSING', '--backup must name an encrypted backup receipt');
108
- let receipt;
109
- try { receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch { throw refusal('DB_RESET_BACKUP_INVALID', 'backup receipt is missing or invalid JSON'); }
110
- const valid = receipt && receipt.schema === RECEIPT_SCHEMA && receipt.target === 'linked' && receipt.schemaOnly === false
111
- && typeof receipt.project === 'string' && typeof receipt.ref === 'string' && typeof receipt.file === 'string'
112
- && path.basename(receipt.file) === receipt.file && receipt.file.endsWith('.age') && Number.isSafeInteger(receipt.bytes) && receipt.bytes >= 100
113
- && /^[a-f0-9]{64}$/u.test(receipt.sha256 ?? '') && Array.isArray(receipt.parts)
114
- && ['roles.sql', 'schema.sql', 'auth-storage-schema.sql', 'data.sql', 'auth-storage-data.sql', 'rowcounts.json'].every((part) => receipt.parts.includes(part))
115
- && Number.isSafeInteger(receipt.rowcounts?.tables) && receipt.rowcounts.tables >= 0 && /^\d+$/u.test(receipt.rowcounts?.rows ?? '')
116
- && typeof receipt.at === 'string' && Number.isFinite(Date.parse(receipt.at));
117
- if (!valid) throw refusal('DB_RESET_BACKUP_INVALID', 'backup receipt is not a full starci/db-backup@1 linked backup');
118
- if (receipt.ref !== session.target.ref) throw refusal('DB_RESET_BACKUP_PROJECT', 'backup targets a different project ref');
119
- const maxAge = session.database.reset?.backupMaxAgeHours ?? 24;
120
- if (!Number.isFinite(maxAge) || maxAge <= 0) throw refusal('DB_CONFIG_INVALID', 'database.reset.backupMaxAgeHours must be positive');
121
- const age = session.ctx.now() - Date.parse(receipt.at);
122
- if (age < 0 || age > maxAge * 3600000) throw refusal('DB_RESET_BACKUP_STALE', 'backup is future-dated or older than the allowed backup age');
123
- const file = path.resolve(path.dirname(receiptPath), receipt.file);
124
- if (!fs.existsSync(file)) throw refusal('DB_RESET_BACKUP_ARCHIVE_MISSING', 'backup archive is absent');
125
- try {
126
- if (checkArchive(file) !== receipt.bytes || sha256(file) !== receipt.sha256) throw new Error('mismatch');
127
- } catch { throw refusal('DB_RESET_BACKUP_HASH', 'backup archive size, header or sha256 differs from the receipt'); }
128
- return receipt;
129
- }
package/db/drift.mjs ADDED
@@ -0,0 +1,289 @@
1
+ // drift.mjs - `starci app db drift`: the TypeORM entities of an app against the schema its SQL migrations build.
2
+ // The migrations (db/migrations/*.sql, after db/bootstrap.sql when present) are the only DDL and `synchronize` is false, so an entity only
3
+ // maps tables that already exist and can drift from them. The verb builds TypeORM's metadata from the app's entity list
4
+ // (be/src/modules/platform/database/entities.ts, compiled in-process with the app's own typescript and loaded with the app's own typeorm) through a
5
+ // DataSource that never connects, applies the migrations to a scratch database (a throwaway container of the image the app's
6
+ // .starcistacks declares for its db component, or the empty database named by --url-env), reads that schema from
7
+ // information_schema.columns and pg_catalog, and reports every entity column whose table or column is missing, whose type differs (both
8
+ // normalised to PostgreSQL's format_type names) or whose nullability differs, under BE_ENTITY_SCHEMA_DRIFT. Database columns no entity maps
9
+ // are fine. Exit 0 is green, 1 is drift (or a migration that does not apply to an empty database), 2 is a refusal: no verdict, which is
10
+ // also what a host without Docker gets ("needs a database"), never a green.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import Module, { createRequire } from 'node:module';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { runVerb, readDeclaration } from '../release/index.mjs';
16
+ import { failure, refusal } from '../stack/errors.mjs';
17
+ import { firstLine, tool } from '../stack/context.mjs';
18
+ import { ENTITY_LIST, hasEntityList } from './entity-list.mjs';
19
+
20
+ export const DRIFT_CODE = 'BE_ENTITY_SCHEMA_DRIFT';
21
+ export const MIGRATIONS_DIR = 'db/migrations';
22
+ export const BOOTSTRAP_FILE = 'db/bootstrap.sql';
23
+ const NEEDS_DATABASE = 'DB_DRIFT_NEEDS_DATABASE';
24
+ const KEY = /^[A-Z][A-Z0-9_]*$/u;
25
+ const MIGRATION = /^\d{14}_[a-z0-9][a-z0-9_-]*\.sql$/u;
26
+ const READY_TIMEOUT_MS = 120_000;
27
+ const READY_POLL_MS = 500;
28
+
29
+ const needsDatabase = (reason) => refusal(NEEDS_DATABASE, `needs a database: ${reason}`);
30
+
31
+ /* ---- the entity side */
32
+
33
+ /** The be/tsconfig.json `paths` as [prefix, absolute target prefix] pairs (only `name/*` -> `dir/*` forms, the canon's). */
34
+ function aliases(ts, root) {
35
+ const file = path.join(root, 'be', 'tsconfig.json');
36
+ if (!fs.existsSync(file)) return [];
37
+ const { config } = ts.readConfigFile(file, ts.sys.readFile);
38
+ const options = config?.compilerOptions ?? {};
39
+ const base = path.resolve(path.dirname(file), options.baseUrl ?? '.');
40
+ return Object.entries(options.paths ?? {}).flatMap(([pattern, targets]) => (pattern.endsWith('/*') && targets?.[0]?.endsWith('/*')
41
+ ? [[pattern.slice(0, -1), path.resolve(base, targets[0].slice(0, -1))]] : []));
42
+ }
43
+
44
+ /** The source file a relative or aliased specifier names (exact, .ts, .js -> .ts, /index.ts), or null for a package. */
45
+ function sourceFile(specifier, from, pairs) {
46
+ const alias = pairs.find(([prefix]) => specifier.startsWith(prefix));
47
+ const target = alias ? path.join(alias[1], specifier.slice(alias[0].length))
48
+ : specifier.startsWith('.') ? path.resolve(path.dirname(from), specifier) : null;
49
+ if (!target) return null;
50
+ const candidates = [target, `${target}.ts`, target.replace(/\.js$/u, '.ts'), path.join(target, 'index.ts')];
51
+ return candidates.find((file) => fs.existsSync(file) && fs.statSync(file).isFile()) ?? null;
52
+ }
53
+
54
+ /**
55
+ * Requires the entity list with a `.ts` loader that transpiles each source file with the app's typescript (CommonJS, legacy decorators and
56
+ * their design metadata, as Nest compiles them) and points every relative or aliased require at its source file. The previous loader is
57
+ * restored afterwards.
58
+ */
59
+ function requireEntityList(ts, appRequire, root) {
60
+ const pairs = aliases(ts, root);
61
+ const previous = Module._extensions['.ts'];
62
+ Module._extensions['.ts'] = (module, filename) => {
63
+ const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { fileName: filename, compilerOptions: {
64
+ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, experimentalDecorators: true, emitDecoratorMetadata: true,
65
+ esModuleInterop: true, useDefineForClassFields: false } });
66
+ const code = outputText.replace(/require\("([^"]+)"\)/gu, (call, specifier) => {
67
+ const file = sourceFile(specifier, filename, pairs);
68
+ return file ? `require(${JSON.stringify(file)})` : call;
69
+ });
70
+ module._compile(code, filename);
71
+ };
72
+ try { return appRequire(path.join(root, ENTITY_LIST)); }
73
+ finally {
74
+ if (previous) Module._extensions['.ts'] = previous;
75
+ else delete Module._extensions['.ts'];
76
+ }
77
+ }
78
+
79
+ /** The entity classes an entity-list module exports: every class, directly or inside an exported array, once. */
80
+ const entityClasses = (exports) => [...new Set(Object.values(exports ?? {}).flat(Infinity).filter((value) => typeof value === 'function'))];
81
+
82
+ /** PostgreSQL spellings TypeORM's normalizeType passes through unchanged, mapped to the name format_type answers. */
83
+ const PG_ALIASES = new Map([['serial', 'integer'], ['serial4', 'integer'], ['bigserial', 'bigint'], ['serial8', 'bigint'], ['smallserial', 'smallint'],
84
+ ['serial2', 'smallint'], ['bool', 'boolean'], ['int', 'integer'], ['float', 'double precision'], ['dec', 'numeric']]);
85
+
86
+ /** One entity column as the comparison reads it: { entity, schema, table, column, type, enumName, nullable, view }. */
87
+ function entityColumn(driver, metadata, column) {
88
+ const base = String(driver.normalizeType(column)).toLowerCase();
89
+ const type = `${PG_ALIASES.get(base) ?? base}${column.isArray ? '[]' : ''}`;
90
+ return { entity: metadata.name, schema: metadata.schema ?? driver.options.schema ?? 'public', table: metadata.tableName, column: column.databaseName,
91
+ type, enumName: column.enumName ?? null, nullable: column.isNullable === true, view: metadata.tableType === 'view' };
92
+ }
93
+
94
+ /**
95
+ * The columns of every entity the app's entity list names, from TypeORM's own metadata: a postgres DataSource with synchronize false is
96
+ * built and its metadata derived (buildMetadatas), never initialized, so no connection is opened. typeorm, its pg driver and typescript are
97
+ * the app's (resolved from be/); an app without them gets no verdict.
98
+ */
99
+ export async function loadEntityColumns(appRoot) {
100
+ const root = path.resolve(appRoot);
101
+ const appRequire = createRequire(path.join(root, 'be', 'package.json'));
102
+ const load = (name) => {
103
+ try { return appRequire(name); } catch { throw needsDatabase(`the app's ${name} is not installed (npm ci first)`); }
104
+ };
105
+ const ts = load('typescript');
106
+ load('reflect-metadata');
107
+ const { DataSource } = load('typeorm');
108
+ load('pg');
109
+ const entities = entityClasses(requireEntityList(ts, appRequire, root));
110
+ if (entities.length === 0) return [];
111
+ const source = new DataSource({ type: 'postgres', synchronize: false, migrationsRun: false, entities });
112
+ try { await source.buildMetadatas(); } catch (error) { throw refusal('DB_DRIFT_ENTITIES_INVALID', `TypeORM refuses the entity list: ${firstLine(error.message)}`); }
113
+ return source.entityMetadatas.flatMap((metadata) => metadata.columns.filter((column) => !column.isVirtual && !column.isVirtualProperty)
114
+ .map((column) => entityColumn(source.driver, metadata, column)));
115
+ }
116
+
117
+ /* ---- the schema side */
118
+
119
+ /** Every column of the scratch schema with its format_type spelling (enums as `enum`, domains as their base type) and nullability. */
120
+ export const SCHEMA_SQL = `select c.table_schema as "schema", c.table_name as "table", c.column_name as "column", c.is_nullable = 'YES' as "nullable",
121
+ case when t.typtype = 'e' then 'enum' when e.typtype = 'e' then 'enum[]' when t.typtype = 'd' then pg_catalog.format_type(t.typbasetype, null)
122
+ else pg_catalog.format_type(a.atttypid, null) end as "type",
123
+ case when t.typtype = 'e' then t.typname when e.typtype = 'e' then e.typname end as "enumName",
124
+ cl.relkind in ('v', 'm') as "view"
125
+ from information_schema.columns c
126
+ join pg_catalog.pg_namespace n on n.nspname = c.table_schema
127
+ join pg_catalog.pg_class cl on cl.relnamespace = n.oid and cl.relname = c.table_name
128
+ join pg_catalog.pg_attribute a on a.attrelid = cl.oid and a.attname = c.column_name and a.attnum > 0 and not a.attisdropped
129
+ join pg_catalog.pg_type t on t.oid = a.atttypid
130
+ left join pg_catalog.pg_type e on e.oid = t.typelem and t.typcategory = 'A'
131
+ where c.table_schema not in ('pg_catalog', 'information_schema')`;
132
+
133
+ const PUBLIC_RELATIONS_SQL = `select count(*)::int as "count" from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace
134
+ where n.nspname = 'public' and c.relkind in ('r', 'p', 'v', 'm', 'f')`;
135
+
136
+ /** The SQL files the scratch database receives, in order: db/bootstrap.sql when present, then the active migrations by stamp. */
137
+ export function migrationFiles(root) {
138
+ const dir = path.join(root, MIGRATIONS_DIR);
139
+ if (!fs.existsSync(dir)) throw refusal('DB_DRIFT_NO_MIGRATIONS', `${MIGRATIONS_DIR}/ is missing: the migrations are the schema the entities are checked against`);
140
+ const migrations = fs.readdirSync(dir).filter((name) => MIGRATION.test(name)).sort().map((name) => `${MIGRATIONS_DIR}/${name}`);
141
+ return [...(fs.existsSync(path.join(root, BOOTSTRAP_FILE)) ? [BOOTSTRAP_FILE] : []), ...migrations];
142
+ }
143
+
144
+ /** The image of the app's db component (.starcistacks/application-stacks.yaml components.db.image), the one the scratch container runs. */
145
+ export function scratchImage(root) {
146
+ const image = readDeclaration(root)?.components?.db?.image;
147
+ if (typeof image !== 'string' || image.trim() === '') throw refusal('DB_DRIFT_NO_IMAGE', 'declare components.db.image in .starcistacks/application-stacks.yaml (the scratch database runs the app\'s own db image) or pass --url-env');
148
+ return image.trim();
149
+ }
150
+
151
+ /** Starts a throwaway container of `image` on a loopback port; returns { connection, stop }. Docker missing or failing is no verdict. */
152
+ function startScratch(ctx, image) {
153
+ const docker = (args, env) => tool(ctx, 'docker', args, { env: { ...ctx.env, ...env } });
154
+ const probe = docker(['version', '--format', '{{.Server.Version}}']);
155
+ if (probe.error || probe.status !== 0) throw needsDatabase('Docker is not available on this host; start it, or pass --url-env naming an empty database');
156
+ const password = randomBytes(18).toString('hex');
157
+ const name = `starci-db-drift-${randomBytes(6).toString('hex')}`;
158
+ const run = docker(['run', '-d', '--rm', '--name', name, '--label', 'starci.db-drift=1', '-e', 'POSTGRES_PASSWORD', '-p', '127.0.0.1::5432', image], { POSTGRES_PASSWORD: password });
159
+ const stop = () => { docker(['rm', '-f', name]); };
160
+ if (run.status !== 0) { stop(); throw needsDatabase(`docker could not start ${image}: ${firstLine(run.stderr)}`); }
161
+ const port = docker(['port', name, '5432/tcp']);
162
+ const match = /:(\d+)\s*$/mu.exec(String(port.stdout ?? '').split('\n')[0]);
163
+ if (port.status !== 0 || !match) { stop(); throw needsDatabase('docker published no port for the scratch database'); }
164
+ return { connection: { host: '127.0.0.1', port: Number(match[1]), user: 'postgres', password, database: 'postgres' }, stop, how: `scratch container of ${image}` };
165
+ }
166
+
167
+ /** Connects with the app's pg, retrying while a fresh container is still initialising; gives up after READY_TIMEOUT_MS. */
168
+ async function connect(ctx, Client, connection, retry) {
169
+ const started = ctx.now();
170
+ for (;;) {
171
+ const client = new Client(connection);
172
+ client.on('error', () => {});
173
+ try {
174
+ await client.connect();
175
+ await client.query('select 1');
176
+ return client;
177
+ } catch (error) {
178
+ await client.end().catch(() => {});
179
+ if (!retry || ctx.now() - started > READY_TIMEOUT_MS) throw needsDatabase(`the database did not accept a connection (${firstLine(error.message)})`);
180
+ await ctx.sleep(READY_POLL_MS);
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The columns of the migrated schema: a scratch database (a container of the app's db image, or the empty database `urlEnv` names) receives
187
+ * db/bootstrap.sql and every active migration, each file in one transaction, then is read with SCHEMA_SQL. A file that does not apply to an
188
+ * empty database is a red result of its own (DB_DRIFT_MIGRATION_FAILED). The container is removed in every case.
189
+ */
190
+ export async function migratedSchema(ctx, appRoot, { urlEnv } = {}) {
191
+ const root = path.resolve(appRoot);
192
+ const files = migrationFiles(root);
193
+ let Client;
194
+ try { ({ Client } = createRequire(path.join(root, 'be', 'package.json'))('pg')); } catch { throw needsDatabase('the app\'s pg is not installed (npm ci first)'); }
195
+ let scratch;
196
+ let secrets = [];
197
+ if (urlEnv !== undefined) {
198
+ if (!KEY.test(urlEnv)) throw refusal('DB_USAGE', '--url-env must name an UPPER_CASE environment variable');
199
+ const value = ctx.env[urlEnv];
200
+ if (typeof value !== 'string' || !/^postgres(?:ql)?:\/\//u.test(value)) throw needsDatabase(`${urlEnv} holds no postgres connection URL`);
201
+ secrets = [value];
202
+ scratch = { connection: { connectionString: value }, stop: () => {}, how: `the database ${urlEnv} names` };
203
+ } else scratch = startScratch(ctx, scratchImage(root));
204
+ const scrub = (text) => secrets.reduce((out, secret) => out.replaceAll(secret, '[value]'), String(text));
205
+ let client;
206
+ try {
207
+ client = await connect(ctx, Client, scratch.connection, urlEnv === undefined);
208
+ if (urlEnv !== undefined) {
209
+ const { rows: [{ count }] } = await client.query(PUBLIC_RELATIONS_SQL);
210
+ if (count > 0) throw refusal('DB_DRIFT_TARGET_NOT_EMPTY', `the database ${urlEnv} names already holds ${count} relation(s) in public; drift migrates an empty scratch database itself`);
211
+ }
212
+ for (const file of files) {
213
+ try { await client.query(fs.readFileSync(path.join(root, file), 'utf8')); }
214
+ catch (error) { throw failure('DB_DRIFT_MIGRATION_FAILED', `${file} does not apply to an empty database: ${scrub(firstLine(error.message))}`); }
215
+ }
216
+ const { rows } = await client.query(SCHEMA_SQL);
217
+ return { columns: rows.map((row) => ({ ...row, type: String(row.type).toLowerCase() })), files, how: scratch.how };
218
+ } finally {
219
+ await client?.end().catch(() => {});
220
+ scratch.stop();
221
+ }
222
+ }
223
+
224
+ /* ---- the comparison */
225
+
226
+ const key = (...parts) => parts.join('\u0000');
227
+
228
+ /**
229
+ * The drift findings of `entityColumns` against `schemaColumns` ({schema, table, column, type, enumName, nullable, view} each): a missing
230
+ * table once per entity, then per column a missing column, a type mismatch (an enum matches an enum, by name when the entity names one) and,
231
+ * outside views, a nullability mismatch. Schema columns no entity maps are not findings.
232
+ */
233
+ export function compareSchema(entityColumns, schemaColumns) {
234
+ const tables = new Set(schemaColumns.map((column) => key(column.schema, column.table)));
235
+ const columns = new Map(schemaColumns.map((column) => [key(column.schema, column.table, column.column), column]));
236
+ const findings = [];
237
+ const missingTables = new Set();
238
+ const found = (column, kind, message, extra = {}) => findings.push({ code: DRIFT_CODE, level: 'error', kind, entity: column.entity,
239
+ schema: column.schema, table: column.table, column: column.column, message: `${column.entity} ${column.schema}.${column.table}${kind === 'missing-table' ? '' : `.${column.column}`}: ${message}`, ...extra });
240
+ for (const column of entityColumns) {
241
+ const table = key(column.schema, column.table);
242
+ if (!tables.has(table)) {
243
+ if (!missingTables.has(table)) found(column, 'missing-table', 'the table is missing from the migrated schema');
244
+ missingTables.add(table);
245
+ continue;
246
+ }
247
+ const actual = columns.get(key(column.schema, column.table, column.column));
248
+ if (!actual) { found(column, 'missing-column', 'the column is missing from the migrated schema'); continue; }
249
+ const sameEnum = column.type === actual.type && actual.type.startsWith('enum') && (!column.enumName || column.enumName === actual.enumName);
250
+ const typeMatches = actual.type.startsWith('enum') ? sameEnum : column.type === actual.type;
251
+ if (!typeMatches) {
252
+ const name = (type, enumName) => (type.startsWith('enum') && enumName ? `${type} ${enumName}` : type);
253
+ found(column, 'type-mismatch', `type mismatch: entity ${name(column.type, column.enumName)}, schema ${name(actual.type, actual.enumName)}`,
254
+ { expected: column.type, actual: actual.type });
255
+ }
256
+ if (!actual.view && !column.view && column.nullable !== actual.nullable) {
257
+ found(column, 'nullable-mismatch', `nullability mismatch: entity ${column.nullable ? 'nullable' : 'not null'}, schema ${actual.nullable ? 'nullable' : 'not null'}`,
258
+ { expected: column.nullable, actual: actual.nullable });
259
+ }
260
+ }
261
+ return findings;
262
+ }
263
+
264
+ /**
265
+ * `starci app db drift`. options: { urlEnv, entityColumns(root), schema(ctx, root, { urlEnv }) }, the last two seams for specs. An app with
266
+ * no entity list is green with nothing to compare.
267
+ */
268
+ export async function driftCheck(ctx, options = {}) {
269
+ const root = path.resolve(ctx.cwd);
270
+ if (!hasEntityList(root)) return { ok: true, lines: [`no entity list (${ENTITY_LIST}): nothing to compare`], data: { entities: 0, columns: 0, findings: [] } };
271
+ const entityColumns = await (options.entityColumns ?? loadEntityColumns)(root);
272
+ const schema = await (options.schema ?? migratedSchema)(ctx, root, { urlEnv: options.urlEnv });
273
+ const findings = compareSchema(entityColumns, schema.columns);
274
+ const entities = new Set(entityColumns.map((column) => column.entity)).size;
275
+ const head = `${entities} entit${entities === 1 ? 'y' : 'ies'}, ${entityColumns.length} column(s) against ${schema.files?.length ?? 0} SQL file(s) applied to ${schema.how ?? 'the schema'}`;
276
+ const lines = [head, ...findings.map((finding) => `${DRIFT_CODE} ${finding.message}`),
277
+ findings.length === 0 ? 'green: every entity column matches the migrated schema' : `red: ${findings.length} drift finding(s)`];
278
+ return { ok: findings.length === 0, lines, data: { entities, columns: entityColumns.length, files: schema.files ?? [], findings } };
279
+ }
280
+
281
+ /** Entry of the verb: `starci app db drift [--url-env NAME] [--json] [--cwd <dir>]`. */
282
+ export function dbDriftMain(argv, io = {}) {
283
+ return runVerb({ label: 'starci app db drift', name: 'db drift', code: 'DB_USAGE', io, argv,
284
+ spec: { boolean: ['--json'], value: ['--url-env'] },
285
+ run: (ctx, flags, positional) => {
286
+ if (positional.length) throw refusal('DB_USAGE', 'drift takes flags only');
287
+ return driftCheck(ctx, { urlEnv: flags['url-env'] });
288
+ } });
289
+ }
package/db/dump.mjs CHANGED
@@ -1,17 +1,18 @@
1
- // dump.mjs - full Supabase backup, encrypted outside Git; every plaintext part is removed even when a tool fails.
1
+ // dump.mjs - `starci app db dump`: a full backup of the database --url-env names (pg_dumpall roles, pg_dump schema and data),
2
+ // encrypted outside Git; every plaintext part is removed even when a tool fails.
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { runVerb } from '../release/index.mjs';
5
6
  import { failure, refusal } from '../stack/errors.mjs';
6
- import { RECEIPT_SCHEMA, outsideRepository, backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, sha256, runCli } from './backup-receipt.mjs';
7
+ import { RECEIPT_SCHEMA, outsideRepository, backupSession, backupRecipients, backupTool, encryptFile, queryRows, sha256, runPgTool } from './backup-receipt.mjs';
7
8
 
8
- /** Counts every ordinary table with quoted identifiers in a read-only transaction, including auth and storage. */
9
+ /** Counts every ordinary table with quoted identifiers, every schema but the catalogs (auth included). */
9
10
  export function rowcounts(session) {
10
11
  const tables = queryRows(session, "select schemaname, tablename from pg_catalog.pg_tables where schemaname not in ('pg_catalog', 'information_schema') order by schemaname, tablename;");
11
12
  const quote = (name) => `"${String(name).replaceAll('"', '""')}"`;
12
13
  return tables.map((table) => {
13
14
  if (typeof table.schemaname !== 'string' || typeof table.tablename !== 'string') throw failure('DB_BACKUP_QUERY_FAILED', 'invalid table inventory');
14
- const rows = queryRows(session, `begin read only; select count(*)::text as count from ${quote(table.schemaname)}.${quote(table.tablename)}; commit;`);
15
+ const rows = queryRows(session, `select count(*)::text as count from ${quote(table.schemaname)}.${quote(table.tablename)}`);
15
16
  if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count))) throw failure('DB_BACKUP_QUERY_FAILED', 'invalid rowcount');
16
17
  return { schema: table.schemaname, table: table.tablename, count: String(rows[0].count) };
17
18
  });
@@ -31,7 +32,7 @@ export function archiveParts(ctx, scratch, parts) {
31
32
  }
32
33
 
33
34
  /** Writes only ciphertext and its receipt permanently; a receipt is published only after header, size and hash checks. */
34
- function dumpDatabase(ctx, options = {}) {
35
+ export function dumpDatabase(ctx, options = {}) {
35
36
  if (!options.out) throw refusal('DB_USAGE', '--out is required');
36
37
  const out = outsideRepository(ctx.cwd, path.resolve(ctx.cwd, options.out));
37
38
  const session = backupSession(ctx, options);
@@ -48,15 +49,15 @@ function dumpDatabase(ctx, options = {}) {
48
49
  let complete = false;
49
50
  try {
50
51
  fs.mkdirSync(scratch, { mode: 0o700 });
51
- const jobs = [['roles.sql', ['--role-only']], ['schema.sql', []], ['auth-storage-schema.sql', ['--schema', 'auth,storage']]];
52
- if (!options.schemaOnly) jobs.push(['data.sql', ['--data-only']], ['auth-storage-data.sql', ['--data-only', '--schema', 'auth,storage']]);
53
- for (const [file, flags] of jobs) runCli(session, ['db', 'dump', ...flags, '--file', path.join(scratch, file), ...backupArgs(session)]);
52
+ const jobs = [['roles.sql', 'pg_dumpall', ['--roles-only', '--no-role-passwords']], ['schema.sql', 'pg_dump', ['--schema-only']]];
53
+ if (!options.schemaOnly) jobs.push(['data.sql', 'pg_dump', ['--data-only']]);
54
+ for (const [file, command, flags] of jobs) runPgTool(session, command, [...flags, '--file', path.join(scratch, file), '--dbname', session.target.url]);
54
55
  const counts = rowcounts(session);
55
56
  fs.writeFileSync(path.join(scratch, 'rowcounts.json'), `${JSON.stringify(counts, null, 2)}\n`, { mode: 0o600 });
56
57
  const parts = [...jobs.map(([file]) => file), 'rowcounts.json'];
57
58
  const tar = archiveParts(ctx, scratch, parts);
58
59
  const bytes = encryptFile(ctx, tar, archive, recipients);
59
- const receipt = { schema: RECEIPT_SCHEMA, project: session.project, target: session.target.kind, ref: session.target.ref ?? null,
60
+ const receipt = { schema: RECEIPT_SCHEMA, project: session.project, target: session.target.kind, ref: session.target.urlEnv,
60
61
  at, file: path.basename(archive), bytes, sha256: sha256(archive), parts,
61
62
  rowcounts: { tables: counts.length, rows: counts.reduce((sum, row) => sum + BigInt(row.count), 0n).toString(), tablesByName: counts }, schemaOnly: options.schemaOnly === true };
62
63
  fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
@@ -70,9 +71,9 @@ function dumpDatabase(ctx, options = {}) {
70
71
 
71
72
  export function dbDumpMain(argv, io = {}) {
72
73
  return runVerb({ label: 'starci app db dump', name: 'db dump', code: 'DB_USAGE', io, argv,
73
- spec: { boolean: ['--linked', '--local', '--schema-only', '--json'], value: ['--url-env', '--out'] },
74
+ spec: { boolean: ['--schema-only', '--json'], value: ['--url-env', '--out'] },
74
75
  run: (ctx, flags, positional) => {
75
76
  if (positional.length) throw refusal('DB_USAGE', 'dump takes flags only');
76
- return dumpDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], out: flags.out, schemaOnly: flags['schema-only'] });
77
+ return dumpDatabase(ctx, { urlEnv: flags['url-env'], out: flags.out, schemaOnly: flags['schema-only'] });
77
78
  } });
78
79
  }
@@ -0,0 +1,9 @@
1
+ // entity-list.mjs - where an app keeps its TypeORM entity list, read by `starci app db drift` and by the release gate that runs it.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /** The app's one entity list: it re-exports the arrays each capability's persistence/entities/index.ts exports. */
6
+ export const ENTITY_LIST = 'be/src/modules/platform/database/entities.ts';
7
+
8
+ /** True when the app at `root` keeps an entity list: the apps whose entities `starci app db drift` checks and whose release gate runs it. */
9
+ export const hasEntityList = (root) => fs.existsSync(path.join(root, ENTITY_LIST));