@starci/hfs 4.11.0 → 4.11.8

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 (123) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +40 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +29 -12
  9. package/runtime/knowledge/hfs/slots.yaml +48 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  16. package/runtime/scripts/guards/push-scan.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  18. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  19. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  20. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  21. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  22. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  23. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  24. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  25. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  27. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  28. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  29. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  30. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  31. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  32. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  33. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  34. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  36. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  38. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  41. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  42. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  44. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  45. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  46. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  47. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  48. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  49. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  50. package/runtime/scripts/hfs/secret-sops.mjs +93 -9
  51. package/runtime/scripts/hfs/secret-standard.mjs +315 -0
  52. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  53. package/runtime/scripts/hfs/secret.mjs +110 -46
  54. package/runtime/scripts/hfs/slots.mjs +8 -1
  55. package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
  56. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  57. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  58. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  59. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  60. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  61. package/scaffold/add-inbox-lite.mjs +2 -2
  62. package/scaffold/app.mjs +2 -2
  63. package/src/main.mjs +5 -4
  64. package/stack/build.mjs +1 -1
  65. package/stack/config.mjs +2 -1
  66. package/stack/docker-transport.mjs +2 -1
  67. package/stack/remote.mjs +4 -3
  68. package/stack/sealed.mjs +9 -8
  69. package/stack/target-binding.mjs +2 -1
  70. package/sync/hygiene.mjs +1 -1
  71. package/sync/index.mjs +33 -10
  72. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  73. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  74. package/templates/app/gitignore +2 -1
  75. package/templates/app/gitignore-lite +3 -1
  76. package/templates/app/package-scripts/db.json +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  78. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  79. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  80. package/templates/app/skeleton-lite/README.md +2 -2
  81. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  82. package/templates/be/inbox/events.json.tpl +1 -1
  83. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  84. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  85. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  86. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  87. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  88. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  89. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  90. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  91. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  92. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  93. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  94. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  95. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  96. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  97. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  98. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  99. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  100. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  101. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  102. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  103. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  104. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  105. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  106. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  107. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  108. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  109. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  110. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  111. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
  112. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
  113. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  114. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  116. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  117. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  118. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  119. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  120. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  121. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  122. package/templates/app/db-image/db/Dockerfile +0 -8
  123. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -7,7 +7,7 @@
7
7
  # catalog READ this file through scripts/hfs/slots.mjs; none of them hardcodes a path.
8
8
  # Shape: modules/schemas/hfs-slots.schema.yaml. The repository side is hfs.json: modules/schemas/hfs-repo.schema.yaml.
9
9
  schema: starci/hfs-slots@3
10
- version: 3.11.0
10
+ version: 3.13.0
11
11
 
12
12
  versioning:
13
13
  # MAJOR.MINOR.PATCH of this manifest. A repository pins only the MAJOR in hfs.json ("hfs": 3).
@@ -55,6 +55,9 @@ versioning:
55
55
  # 3.11.0 (ruling:lite-official-postgres-image, 2026-10-29) is, like 3.9.0, a change for lite only: it ADDS the managed app.db.image slot
56
56
  # (db/Dockerfile, written by `starci app sync --write`), narrows ruleParams.db.extensions to the extensions that image carries, and gives
57
57
  # @nestjs/schedule and cron one owner, platform/jobs (R335).
58
+ # 3.13.0 (rulings lite-pgmq-bus-bullmq-jobs and lite-db-image-in-stacks, 2026-10-29) is a change for lite only: ruleParams.db.extensions
59
+ # admits pgmq, the event bus, ruleParams.db.queueOutbox is retired with the pg_cron half of R258, the lite event-bus functions replace
60
+ # event_bus_write in ruleParams.be.dbFunctions, and app.db.image moves to .starcistacks/images/db/Dockerfile (db/ keeps schema and data).
58
61
 
59
62
  # Field meaning, per slot.
60
63
  # id stable name, never changes meaning inside a major.
@@ -129,8 +132,8 @@ testValues: [unit-beside, e2e, none]
129
132
  # event bus, saga, reactors, projection, realtime, webhooks, fenced job), declared in hfs.json the same way, and the same unit
130
133
  # specs (unitRoles, logicRoles, 100 per file, @starci/jest-preset doubles, builders). What differs is ADAPTERS and DELIVERY
131
134
  # (ruling:R212/case-10): schema authority in db/migrations, the queue port on BullMQ over the Redis hfs.json
132
- # sides.be.backing declares (as in full, fed by the Postgres outbox), event-bus ports on pgmq-backed topics or in-process
133
- # delivery, cache and locks on Postgres or the declared Redis, schedules as platform/jobs ticks enqueueing through the queue
135
+ # sides.be.backing declares (as in full, fed by the Postgres outbox), event-bus ports on pgmq topics sent in the publisher's
136
+ # transaction (ruling:lite-pgmq-bus-bullmq-jobs), cache and locks on Postgres or the declared Redis, schedules as platform/jobs ticks enqueueing through the queue
134
137
  # outbox (R335), storage on S3-compatible MinIO (integrations/object-storage) and auth through integrations/passport. Realtime uses SSE or the socket.io gateway
135
138
  # in the api process, which scales horizontally and stateless (memory hub for one replica or tests; the Redis hub and @socket.io/redis-adapter when Redis is declared, else the Postgres hub and @socket.io/postgres-adapter, for N replicas; websocket only, no sticky sessions). Integration and provider sandbox contract specs are optional;
136
139
  # declared patterns owe their integration proofs (R284). Lite has no e2e world. Production uses declared docker-swarm under
@@ -209,11 +212,9 @@ ruleParams:
209
212
  db:
210
213
  # R259: btree_gist supplies UUID equality for the booking interval exclusion constraint.
211
214
  # R259: the extensions the db image carries (ruling:lite-official-postgres-image): the official postgres image's contrib set and pgvector
212
- # from PGDG; unaccent serves Vietnamese-content search functions. No pg_cron, pg_net, pgmq or supabase_vault.
213
- extensions: [pgcrypto, pg_trgm, vector, uuid-ossp, unaccent, btree_gist]
214
- # R258 DB_CRON_OWNED: THE queue outbox of a lite schema (the relay hands its unsent rows to BullMQ, ruling:R212/case-10). A pg_cron
215
- # job enqueues only by inserting one row into this table, writing only these columns, one of which is `queue`.
216
- queueOutbox: {table: public.queue, columns: [queue, payload, created_at, correlation_id]}
215
+ # from PGDG; unaccent serves Vietnamese-content search functions; pgmq is the event bus (ruling:lite-pgmq-bus-bullmq-jobs), installed
216
+ # from its pinned release. No pg_cron, pg_net or supabase_vault.
217
+ extensions: [pgcrypto, pg_trgm, vector, uuid-ossp, unaccent, btree_gist, pgmq]
217
218
  common:
218
219
  # HFS_SIZE_GROWTH: a file above soft may not grow against its parent commit; a new file stays within soft. Slot
219
220
  # budgets (component.tsx 300, hooks 200, modules 400) are stricter than this cap where they apply.
@@ -429,29 +430,36 @@ ruleParams:
429
430
  # logic roles and spec doubles are full's (owner ruling 2026-10-12 (owner:lite-production-swarm), with the Work boundary amended by ruling:R212/case-4). infraOwners: full's owners,
430
431
  # bullmq in platform/queue and ioredis/redis in integrations/cache and integrations/redis (ruling:R212/case-10: the queue is
431
432
  # Redis in both editions; a backing library is legal only when hfs.json sides.be.backing declares its service, which
432
- # HFS_BACKING_UNDECLARED checks), except Kafka, which is owned nowhere (platform/event-bus runs pgmq queues through
433
+ # HFS_BACKING_UNDECLARED checks), except Kafka, which is owned nowhere (platform/event-bus runs pgmq topics through
433
434
  # the shared EntityManager); realtime is the same socket.io gateway as full, run inside the api process, scaled horizontally and stateless (hub redis when
434
435
  # Redis is declared, else hub postgres with @socket.io/postgres-adapter; no sticky sessions).
435
436
  # R334: the database functions code may call through platform/database's callDbFunction, advisoryXactLock and notify helpers,
436
- # each {name, reason}; the canon lists the pg built-ins it uses itself, and an app adds its own in hfs.json sides.be.dbFunctions.
437
+ # each {name, reason}; a pg built-in is listed bare (called in pg_catalog), every other function schema-qualified (public.job_claim),
438
+ # and an app adds its own, schema-qualified, in hfs.json sides.be.dbFunctions.
437
439
  dbFunctions:
438
440
  - {name: pg_advisory_xact_lock, reason: "advisoryXactLock: a transaction-scoped lock TypeORM has no API for"}
439
441
  - {name: hashtextextended, reason: "advisoryXactLock: the 64-bit key of a text lock name"}
440
442
  - {name: pg_notify, reason: "notify: LISTEN/NOTIFY of the Postgres realtime hub"}
441
443
  - {name: to_regproc, reason: "callDbFunction: whether an optional database function exists"}
442
- - {name: job_claim, reason: "platform/jobs (add job, lite): the fenced claim upsert of jobs.sql"}
443
- - {name: job_advance, reason: "platform/jobs (add job, lite): the token-guarded step write of jobs.sql"}
444
- - {name: job_complete, reason: "platform/jobs (add job, lite): the token-guarded completion of jobs.sql"}
445
- - {name: job_fail, reason: "platform/jobs (add job, lite): the token-guarded failure of jobs.sql"}
446
- - {name: job_tick_claim, reason: "platform/jobs (add job, lite): the once-per-minute claim of a scheduler tick, jobs.sql (R335)"}
447
- - {name: queue_write, reason: "platform/queue (add queue, lite): the outbox insert of queue-outbox.sql"}
448
- - {name: queue_waiting, reason: "platform/queue (add queue, lite): the relay read FOR UPDATE SKIP LOCKED of queue-outbox.sql"}
449
- - {name: queue_mark_sent, reason: "platform/queue (add queue, lite): the relay mark of queue-outbox.sql"}
450
- - {name: inbox_claim_event, reason: "platform/inbox (add webhook, lite): the conflict-safe delivery claim"}
451
- - {name: inbox_claim_client, reason: "platform/inbox (add webhook, lite): the client claim upsert returning its committed outcome"}
452
- - {name: inbox_save_client_outcome, reason: "platform/inbox (add webhook, lite): the saved client outcome"}
453
- - {name: inbox_sweep_clients, reason: "platform/inbox (add webhook, lite): the bounded retention sweep FOR UPDATE SKIP LOCKED"}
454
- - {name: event_bus_write, reason: "platform/event-bus (add webhook, lite): the typed envelope insert of the outbox"}
444
+ - {name: gen_random_uuid, reason: "callDbFunction: a random uuid from the database (PG13+ core, pg_catalog) where a token is minted in the transaction"}
445
+ - {name: public.job_claim, reason: "platform/jobs (add job, lite): the fenced claim upsert of jobs.sql"}
446
+ - {name: public.job_advance, reason: "platform/jobs (add job, lite): the token-guarded step write of jobs.sql"}
447
+ - {name: public.job_complete, reason: "platform/jobs (add job, lite): the token-guarded completion of jobs.sql"}
448
+ - {name: public.job_fail, reason: "platform/jobs (add job, lite): the token-guarded failure of jobs.sql"}
449
+ - {name: public.job_tick_claim, reason: "platform/jobs (add job, lite): the once-per-minute claim of a scheduler tick, jobs.sql (R335)"}
450
+ - {name: public.queue_write, reason: "platform/queue (add queue, lite): the outbox insert of queue-outbox.sql"}
451
+ - {name: public.queue_waiting, reason: "platform/queue (add queue, lite): the relay read FOR UPDATE SKIP LOCKED of queue-outbox.sql"}
452
+ - {name: public.queue_mark_sent, reason: "platform/queue (add queue, lite): the relay mark of queue-outbox.sql"}
453
+ - {name: public.inbox_claim_event, reason: "platform/inbox (add webhook, lite): the conflict-safe delivery claim"}
454
+ - {name: public.inbox_claim_client, reason: "platform/inbox (add webhook, lite): the client claim upsert returning its committed outcome"}
455
+ - {name: public.inbox_save_client_outcome, reason: "platform/inbox (add webhook, lite): the saved client outcome"}
456
+ - {name: public.inbox_sweep_clients, reason: "platform/inbox (add webhook, lite): the bounded retention sweep FOR UPDATE SKIP LOCKED"}
457
+ - {name: public.event_bus_send, reason: "platform/event-bus (add webhook, lite): the send of a typed envelope to its pgmq topic in the publisher's transaction"}
458
+ - {name: public.event_bus_read, reason: "platform/event-bus (add webhook, lite): a consumer's pgmq read with a visibility timeout"}
459
+ - {name: public.event_bus_archive, reason: "platform/event-bus (add webhook, lite): the archive of a handled message"}
460
+ - {name: public.event_bus_bury, reason: "platform/event-bus (add webhook, lite): the move of an exhausted or malformed message to its consumer's dead-letter queue"}
461
+ - {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
462
+ - {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
455
463
 
456
464
  lite:
457
465
  schemaAuthority: sql
@@ -870,7 +878,7 @@ slots:
870
878
  # The db service supplies the database; schedules are platform/jobs ticks (R335); production has no Compose or host cron.
871
879
  lite:
872
880
  requires: [application-stacks.yaml]
873
- allows: [application-stacks.yaml, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/infra/metadata.json",
881
+ allows: [application-stacks.yaml, images/db/Dockerfile, "<env>/README.md", "<env>/environment.json", "<env>/infra/swarm/**", "<env>/infra/metadata.json",
874
882
  "<env>/runtime/{config,env}/**", "<env>/runtime/env/KEYS.md", "<env>/secrets/<slug>.enc", "<env>/secrets/.gitkeep", "<env>/seeds/**"]
875
883
  forbids: ["<env>/runtime/files/**", "**/*.enc outside <env>/secrets/", DESIGN.md, deployment.json, "k8s/ at root"]
876
884
  rules: [HFS_STACKS_SHAPE, HFS_STACKS_IN_SIDE, HFS_PLAINTEXT_SECRET, HFS_IDENTITY_CUSTODY]
@@ -931,7 +939,7 @@ slots:
931
939
  rules: [HFS_PLAINTEXT_SECRET]
932
940
 
933
941
  # ----- app root: the database of a lite app (ruling:lite-self-hosted-postgres-passport-minio): a self-hosted PostgreSQL in the app's
934
- # stack (the `db` service, built from the managed db/Dockerfile: the official postgres image plus pgvector, ruling:lite-official-postgres-image), migrated by
942
+ # stack (the `db` service, built from the managed .starcistacks/images/db/Dockerfile: the official postgres image plus pgvector and pgmq, ruling:lite-db-image-in-stacks), migrated by
935
943
  # `starci app db migrate --url-env <NAME>`. A full app keeps its TypeORM migrations under be/ and has no such folder.
936
944
  - id: app.db
937
945
  profiles: [app]
@@ -984,10 +992,11 @@ slots:
984
992
  - id: app.db.image
985
993
  profiles: [app]
986
994
  editions: [lite]
987
- # The managed image of the db service (templates/app/db-image, ruling:lite-official-postgres-image): FROM the digest-pinned official
988
- # postgres image plus postgresql-17-pgvector from PGDG. The stack's db service, the local db container and the scratch database
989
- # of `starci app db drift` all build it.
990
- path: "db/Dockerfile"
995
+ # The managed image of the db service (templates/app/db-image, rulings lite-official-postgres-image and lite-db-image-in-stacks): FROM
996
+ # the digest-pinned official postgres image plus postgresql-17-pgvector from PGDG and the sha256-verified pgmq release. It lives with
997
+ # the stacks; the stack's db service, the local db container, the scratch database of `starci app db drift` and the squash replay
998
+ # all build it.
999
+ path: ".starcistacks/images/db/Dockerfile"
991
1000
  presence: required
992
1001
  tracked: tracked
993
1002
  managedBy: db-image
@@ -998,9 +1007,11 @@ slots:
998
1007
  - id: app.db.bootstrap
999
1008
  profiles: [app]
1000
1009
  editions: [lite]
1001
- # The managed pre-migration bootstrap (templates/app/db-bootstrap): the auth and storage schemas, auth.users with the GoTrue column
1002
- # set, auth.uid/role/jwt stubs and the NOLOGIN browser roles, so migrations written for a hosted Supabase apply to the db service's
1003
- # official postgres image (nothing else of Supabase: no supabase_admin, extensions schema or realtime publication). `starci app db migrate` applies it before the first migration of an empty database.
1010
+ # The managed pre-migration bootstrap (templates/app/db-bootstrap): the extensions schema every allowed extension is created in, the
1011
+ # auth and storage schemas, auth.users with the GoTrue column set, auth.uid/role/jwt stubs and the NOLOGIN browser roles, so migrations
1012
+ # written for a hosted Supabase apply to the db service's official postgres image (nothing else of Supabase: no supabase_admin or
1013
+ # realtime publication). `starci app db migrate` applies it before the first migration of an empty database; sync renders all of it
1014
+ # but the one marked section `starci app db baseline --squash` writes (the source's app roles and the grants a per-database dump loses).
1004
1015
  path: "db/bootstrap.sql"
1005
1016
  presence: required
1006
1017
  tracked: tracked
@@ -1785,7 +1796,7 @@ slots:
1785
1796
  - id: be.platform.event-bus
1786
1797
  since: 3.1.0
1787
1798
  profiles: [be]
1788
- editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite implements the event ports with outbox and pgmq; full uses its Kafka adapter."
1799
+ editionWhy: "ruling:R212/case-2 (owner:lite-production-swarm) (with the Work boundary amended by ruling:R212/case-4): lite implements the event ports on pgmq (ruling:lite-pgmq-bus-bullmq-jobs); full uses its Kafka adapter."
1789
1800
  path: "src/modules/platform/event-bus/"
1790
1801
  presence: opt-in
1791
1802
  pattern: event-bus # the ONE durable event transport: transactional outbox, relay, Kafka, inbox, retry and dead letter (knowledge/patterns/be/event-bus.yaml)
@@ -1794,10 +1805,10 @@ slots:
1794
1805
  owner: true
1795
1806
  lite:
1796
1807
  why: >-
1797
- the same EventPublisher and EventConsumer ports on pgmq queues: the outbox relay sends each committed row to the
1798
- pgmq queue of every consumer group of its topic through the shared EntityManager, a consumer reads with a visibility
1799
- timeout, retries by letting it lapse and archives the message on dead letter (read_ct over the budget); a single api may
1800
- deliver in process. No Kafka (knowledge/patterns/be/event-bus.yaml liteAdapter)
1808
+ the EventBus and EventConsumer ports on pgmq (ruling:lite-pgmq-bus-bullmq-jobs): publish sends the envelope to its
1809
+ topic in the publisher's transaction, so no outbox or relay runs; each consumer group reads its own queue bound to the
1810
+ event's name with a visibility timeout, archives a handled message, lets a failed one reappear and buries one past its
1811
+ maxDeliveries in its __dead queue (BE-EVENT-BUS-6). Jobs stay on BullMQ (platform/queue). No Kafka (knowledge/patterns/be/event-bus.yaml liteAdapter)
1801
1812
  requires: [index.ts, event-bus.module.ts, event-bus.port.ts, event-bus.contracts.ts, event-bus.service.ts, event-bus.service.spec.ts]
1802
1813
  tests: unit-beside
1803
1814
  coverage: required
@@ -288,19 +288,21 @@ files:
288
288
  role: "The consumed event's e2e flow; titles event-bus/<scenario>: ... prove the pattern."
289
289
  spec: event-bus/atomic-with-transaction, event-bus/relay-only-after-commit, event-bus/redelivery-then-dead-letter, event-bus/duplicate-delivery-is-a-noop
290
290
  rules: [BE_PATTERN_SPEC_MISSING]
291
- # Edition lite (R212): the same ports and rules, a self-hosted Postgres adapter behind them. ruling:R212/case-10 moved the lite queue to Redis
292
- # because full's queue is Redis; full's event bus is Kafka, so the lite event-bus adapter below is unchanged by that ruling.
291
+ # Edition lite (R212): the same ports and rules, a self-hosted Postgres adapter behind them. ruling:lite-pgmq-bus-bullmq-jobs makes pgmq the
292
+ # lite event bus and keeps jobs on BullMQ (platform/queue).
293
293
  liteAdapter:
294
294
  summary: >-
295
- Under lite the event bus keeps typed events, the vendored contract, publish(event, tx, correlationId) in the domain transaction, the outbox,
296
- the inbox and thin consumers; Kafka is replaced by pgmq-backed topics, or by in-process delivery after commit for a back end
297
- with a single api app. Each consumer group of a topic has its own pgmq queue `<service>__<event>__<group>`, created by a
298
- db migration, named by a literal `<GROUP>_TOPIC_QUEUE` constant under platform/event-bus (its owner, R258) and
299
- granted only to the application role, so each group keeps its own cursor and dead letter.
295
+ Under lite the event bus keeps typed events, the vendored contract, publish(event, tx, correlationId) in the domain transaction,
296
+ the inbox and thin consumers; Kafka is replaced by pgmq topics in the db service. Publish sends the envelope to its topic
297
+ (the event name) in the publisher's transaction, so no outbox or relay stands between them. Each consumer of an event has its
298
+ own pgmq queue `<topic>__<consumer>` and dead-letter queue `<topic>__<consumer>__dead`, created and bound to the event's name
299
+ by a db migration and named by a literal `<TOPIC>_<CONSUMER>_TOPIC_QUEUE` constant under platform/event-bus (its owner, R258);
300
+ every payload carries a business dedupeKey the consumer inbox claims, and subscriptions are static (R336); the application
301
+ role reaches pgmq only through the security-definer event_bus_* functions of the migration (contexts/code/be/event-bus.md BE-EVENT-BUS-6).
300
302
  ports:
301
- - {port: EventPublisher (platform/event-bus), adapter: the same outbox row in the domain transaction}
302
- - {port: relay, adapter: "fans each committed event out with pgmq.send to one queue per consumer group of its topic, marking the row in the same transaction"}
303
- - {port: EventConsumer (platform/event-bus), adapter: "pgmq.read with a visibility timeout, the inbox claim on the event id, pgmq.delete on success, pgmq.archive as the dead letter"}
303
+ - {port: EventBus.publish (platform/event-bus), adapter: "event_bus_send: pgmq.send_topic of the envelope in the domain transaction"}
304
+ - {port: EventConsumer (platform/event-bus), adapter: "event_bus_read with a visibility timeout, the inbox claim on (consumer, dedupeKey) in the dispatched command, event_bus_archive on success, a failed delivery left to reappear, event_bus_bury into <queue>__dead past maxDeliveries (default 5)"}
305
+ - {port: EventBus.deadLetters and requeue (platform/event-bus), adapter: "event_bus_dead_letters and event_bus_requeue over a consumer's dead-letter queue"}
304
306
  forbids: [kafkajs]
305
307
  proof: >-
306
308
  Unit specs as full; declaring event-bus requires integration specs titled `event-bus/<scenario>: ...` for every declared
@@ -98,12 +98,11 @@ rules:
98
98
  write: 'order-expiry.queue.ts declares scheduler { every: 5 minutes } for the expire-orders job; the processor dispatches ExpireOrdersCommand.'
99
99
  - id: case-2
100
100
  when: A lite app sends booking reminders every fifteen minutes
101
- write: "A migration declares private.booking_reminder_tick() as one insert into public.queue (queue, payload, created_at) values ('booking_reminder', jsonb_build_object('tick', true), now()) and schedules it with cron.schedule('booking_reminder_tick', '*/15 * * * *', ...); the send-reminder processor is the queue's."
101
+ write: "platform/jobs declares a booking_reminder tick every fifteen minutes (BE-JOBS-6): each firing claims its slot and writes one booking_reminder row through the queue outbox; the send-reminder processor is the queue's."
102
102
  verification:
103
103
  automated:
104
104
  - BE_INFRA_OWNER
105
105
  - DB_PGMQ_DECLARED
106
- - DB_CRON_OWNED
107
106
  manual:
108
107
  - Confirm the cadence and the job's idempotence.
109
108
  relatedExamples: [queue]
@@ -277,7 +276,7 @@ liteAdapter:
277
276
  its append-only file (WAITAOF) and still holds it, so a job Redis loses inside the at most one second fsync window leaves
278
277
  its row waiting and is added again; that is why a lite queue does not lose a job. Without a declared Redis there is no queue
279
278
  pattern: the work is a direct call inside the request, or a declared replicated-job running a cli command. pgmq
280
- is not a queue transport. `starci app add queue|job` writes this adapter in a lite app, with the outbox and job
279
+ is the event bus, not a queue transport (ruling:lite-pgmq-bus-bullmq-jobs). `starci app add queue|job` writes this adapter in a lite app, with the outbox and job
281
280
  tables as one db migration, the Redis declaration and the persisted Redis of every docker-swarm environment.
282
281
  ports:
283
282
  - {port: QueueOutbox (platform/queue), adapter: "the outbox row written through the caller's tx by queue_write, and the relay's queue_waiting and queue_mark_sent, functions of the queue migration in db/migrations called through callDbFunction of platform/database (R334)"}
@@ -158,7 +158,7 @@ files:
158
158
  liteAdapter:
159
159
  summary: >-
160
160
  Under lite a reactor is unchanged: a thin consumer of another context's events that dispatches one command. Its deliveries
161
- come from the lite event bus (its own pgmq queue per consumer group).
161
+ come from the lite event bus (its own pgmq queue per consumer group, bound to the event's topic).
162
162
  ports:
163
163
  - {port: EventConsumer delivery, adapter: "the reactor's pgmq queue (see event-bus.yaml liteAdapter)"}
164
164
  proof: >-
@@ -1827,17 +1827,6 @@ DB_BASELINE_SHAPE:
1827
1827
 
1828
1828
 
1829
1829
 
1830
- DB_CRON_OWNED:
1831
- title: "A uniquely owned migration cron job only enqueues through the queue outbox"
1832
- title_vi: "Lịch pg_cron thiếu chủ sở hữu hoặc thực hiện nghiệp vụ"
1833
- meaning_vi: "Tên lịch bị trùng, hằng hàng đợi hoặc hàm tick không tồn tại, lệnh thực hiện DML nghiệp vụ hay HTTP thay vì chỉ chèn một dòng của hằng hàng đợi vào bảng outbox của ruleParams.db.queueOutbox (chỉ các cột đã khai báo), lịch đang chạy vẫn gọi pgmq.send, hoặc cùng tick còn được lập lịch ở ứng dụng."
1834
- causes_vi:
1835
- - "Vi phạm R258: pg_cron chỉ đưa việc vào hàng đợi qua outbox, mỗi tick có đúng một nơi lập lịch và được hủy lịch khi loại bỏ."
1836
- - "Lịch đang chạy (hoặc tick hiện tại của nó) vẫn gửi bằng pgmq.send; pgmq.send chỉ hợp lệ trong thân gốc của lịch cũ."
1837
- nextStep_vi: "Đặt lịch trong migration với tên duy nhất, chèn đúng một dòng của hằng hàng đợi vào outbox hàng đợi (trực tiếp hoặc qua một hàm *_tick chỉ làm việc đó), bỏ lịch trùng và gọi cron.unschedule khi xóa tick."
1838
- owner: op-retry
1839
- kind: check-finding
1840
-
1841
1830
  DB_DEFINER_REVOKE:
1842
1831
  title: "Exposed security definer functions revoke browser execution"
1843
1832
  title_vi: "Function security definer chưa thu hồi quyền trình duyệt"
@@ -1909,14 +1898,14 @@ DB_MIGRATION_SHAPE:
1909
1898
  kind: check-finding
1910
1899
 
1911
1900
  DB_PGMQ_DECLARED:
1912
- title: "A queue constant keeps a pgmq queue, a pgmq queue has no event-bus owner, or a pgmq object is declared or granted unsafely"
1913
- title_vi: "Hằng hàng đợi còn hàng đợi pgmq, hàng đợi pgmq không có chủ event-bus, hoặc đối tượng pgmq khai báo hay cấp quyền sai"
1914
- meaning_vi: "Hàng đợi là BullMQ trên Redis (ruling:R212/case-10), nhưng các migration đang hoạt động vẫn để lại một hàng đợi pgmq trùng tên một hằng hàng đợi trong modules/queues, hoặc một hàng đợi pgmq không được hằng literal *_TOPIC_QUEUE nào trong platform/event-bus đặt tên (hay một hằng như vậy không có hàng đợi pgmq); hoặc pgmq.create không có tên literal trong migration, tên hằng không phải literal, hay quyền trên đối tượng pgmq vượt vai trò ứng dụng."
1901
+ title: "A queue constant keeps a pgmq queue, a pgmq queue has no event-bus owner, or a pgmq queue, binding or grant is declared unsafely"
1902
+ title_vi: "Hằng hàng đợi còn hàng đợi pgmq, hàng đợi pgmq không có chủ event-bus, hoặc hàng đợi, liên kết topic hay quyền pgmq khai báo sai"
1903
+ meaning_vi: "pgmq là event bus và job chạy trên BullMQ (ruling:lite-pgmq-bus-bullmq-jobs), nhưng các migration đang hoạt động vẫn để lại một hàng đợi pgmq trùng tên một hằng hàng đợi trong modules/queues, hoặc một hàng đợi pgmq không được hằng literal *_TOPIC_QUEUE nào trong platform/event-bus đặt tên (hay một hằng như vậy không có hàng đợi pgmq); hoặc pgmq.create hay pgmq.bind_topic không có tên literal trong migration, liên kết một hàng đợi chưa được tạo, tên hằng không phải literal, hay quyền trên đối tượng pgmq vượt vai trò ứng dụng."
1915
1904
  causes_vi:
1916
- - "Vi phạm R258: hàng đợi của app vẫn chạy trên Supabase Queues (pgmq) thay vì BullMQ trên Redis."
1917
- - "Một hàng đợi pgmq bị bỏ lại hoặc đổi tên mà không có chủ event-bus."
1905
+ - "Vi phạm R258: một job của app chạy trên pgmq thay vì BullMQ trên Redis."
1906
+ - "Một hàng đợi pgmq bị bỏ lại hoặc đổi tên mà không có chủ event-bus, hoặc một liên kết topic trỏ tới hàng đợi chưa tạo."
1918
1907
  - "Migration, mã nguồn hoặc quyền truy cập khai báo đối tượng pgmq ngoài migration tĩnh hoặc cho vai trò khác vai trò ứng dụng."
1919
- nextStep_vi: "Chuyển hàng đợi sang BullMQ trên Redis đã khai báo, rồi xóa hàng đợi pgmq bằng pgmq.drop_queue trong một migration mới; hàng đợi topic của event bus được đặt tên bằng một hằng literal *_TOPIC_QUEUE trong platform/event-bus; chỉ cấp quyền trên q_<queue>, a_<queue> cho vai trò ứng dụng."
1908
+ nextStep_vi: "Chuyển job sang BullMQ trên Redis đã khai báo, rồi xóa hàng đợi pgmq bằng pgmq.drop_queue trong một migration mới; hàng đợi của một nhóm consumer được đặt tên bằng một hằng literal *_TOPIC_QUEUE trong platform/event-bus, tạo bằng pgmq.create rồi liên kết với tên event bằng pgmq.bind_topic trong cùng migration; vai trò ứng dụng đi qua các hàm event_bus_* thay vì nhận quyền trên schema pgmq."
1920
1909
  owner: op-retry
1921
1910
  kind: check-finding
1922
1911
 
@@ -5601,6 +5590,17 @@ BE_PERSISTENCE_NO_RAW_SQL:
5601
5590
  owner: op-retry
5602
5591
  kind: check-finding
5603
5592
 
5593
+ BE_EVENT_BUS_LITE_SHAPE:
5594
+ title: "A lite event payload without dedupeKey, or a subscription that is not static"
5595
+ title_vi: "Payload event của bản lite thiếu dedupeKey, hoặc đăng ký consumer không tĩnh"
5596
+ meaning_vi: "File `<path>` khai một event (lớp kế thừa BaseEvent trong modules/events) mà kiểu payload không có `dedupeKey`, hoặc gọi `subscribe` với tên hàng đợi không phải hằng `*_TOPIC_QUEUE` import từ `@modules/platform/event-bus`, hay với options không phải object literal của giá trị literal."
5597
+ causes_vi:
5598
+ - "Vi phạm R336: event bus pgmq giao ít nhất một lần và không theo thứ tự, nên inbox của consumer claim (consumer, dedupeKey); thiếu dedupeKey thì không khử trùng được."
5599
+ - "Một đăng ký tính tên hàng đợi lúc chạy, nên không ai thấy được nó trong hằng và migration (R258)."
5600
+ nextStep_vi: "Thêm `readonly dedupeKey: string` (khoá nghiệp vụ, như id đơn hàng) vào kiểu payload hoặc kế thừa `EventPayload`; khai hằng `<TOPIC>_<CONSUMER>_TOPIC_QUEUE` trong platform/event-bus, tạo hàng đợi, hàng đợi `__dead` và bind_topic trong migration, rồi subscribe bằng hằng đó."
5601
+ owner: op-retry
5602
+ kind: check-finding
5603
+
5604
5604
  BE_JOBS_SCHEDULER_OWNER:
5605
5605
  title: "A schedule outside the platform/jobs tick scheduler"
5606
5606
  title_vi: "Lịch chạy định kỳ nằm ngoài bộ lập lịch tick của platform/jobs"
@@ -85,7 +85,7 @@ properties:
85
85
  command: {type: string, minLength: 1}
86
86
  backing:
87
87
  description: >-
88
- The self-hosted database of a lite app: its db component (the image its managed db/Dockerfile builds), a Docker container in
88
+ The self-hosted database of a lite app: its db component (the image its managed .starcistacks/images/db/Dockerfile builds), a Docker container in
89
89
  development and a Swarm service in production. Every other dependency is a Docker component too.
90
90
  type: object
91
91
  additionalProperties:
@@ -855,5 +855,7 @@ $defs:
855
855
  - {type: integer, minimum: 1}
856
856
  - {type: string, pattern: '^[1-9][0-9]*$'}
857
857
  where: {type: string, minLength: 1}
858
- recipientPolicy: {type: string, minLength: 1, description: "Which age recipient the envelope is encrypted to: the one shared master recipient every project uses."}
858
+ member: {type: string, pattern: '^[a-z0-9][a-z0-9-]*$', description: "The provider member holding this key (`secrets/<member>`, the slug of where); with key, the entry declares one key of it."}
859
+ key: {type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: "The one key of the member this entry declares; materialized as its own file, read by the app as <KEY>_FILE."}
860
+ recipientPolicy: {type: string, minLength: 1, description: "Which age recipients the envelope is encrypted to: its environment's rule of the app's managed .sops.yaml."}
859
861
  keyCustody: {type: string, minLength: 1, description: "Where the shared decrypting identity is held; docs/host-secrets.md owns host custody and its execution prerequisites."}
@@ -329,7 +329,10 @@ const checkTrackedCustody = ({ repo, root, add }) => {
329
329
  // infra/** is tracked source, but a value file inside it (.env*, *.env, *.key, *.pem, *.tfvars)
330
330
  // is plaintext custody (inc-5360513a96b3: infra/compose/.env.generated was trackable).
331
331
  const infraValue = parts.length > 3 && parts[2] === 'infra' && isInfrastructureValueFile(base) && !base.endsWith('.enc') && !base.endsWith('.example');
332
- if ((custodyArea && !base.endsWith('.enc') && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
332
+ // An app's sealed member is `<env>/secrets/<member>.enc.yaml` (ruling app-secrets-one-standard, scripts/lib/sealed-member.mjs);
333
+ // runtime custody keeps `<name>.<fmt>.enc`.
334
+ const appMember = parts.length === 4 && parts[2] === 'secrets' && base.endsWith('.enc.yaml');
335
+ if ((custodyArea && !base.endsWith('.enc') && !appMember && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
333
336
  add('refuse', 'STACKS_PLAINTEXT_TRACKED', file, /\.enc\.(?:ya?ml|json|env)$/u.test(base)
334
337
  ? `${base} is named the wrong way round: a custody file is <name>.<fmt>.enc (rename it to ${base.replace(/\.enc\.(ya?ml|json|env)$/u, '.$1.enc')}) and every read states the format (scripts/api/sops/exec-env.mjs)`
335
338
  : 'a custody member is tracked in plaintext; untrack it (git rm --cached), rotate the value and commit only its .enc twin');
@@ -363,7 +366,8 @@ const checkIgnoreRules = ({ repo, root, add }) => {
363
366
  }
364
367
  };
365
368
  // Custody layout (modules/schemas/stacks-layout.yaml custody, the product repositories' secrets-guard).
366
- const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory());
369
+ // The images folder holds the managed db image of a lite app (slot app.db.image), never custody or an environment.
370
+ const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name !== 'images');
367
371
  const checkCustodyLayout = ({ repo, own, add }) => {
368
372
  if (!own.rooted) return;
369
373
  const root = STACK_ROOT;
@@ -54,7 +54,7 @@ function diffScanner(files = [], { encText = null, keepHit = null } = {}) {
54
54
  if (raw.startsWith('+++ ')) {
55
55
  flushEnc();
56
56
  file = raw.slice(4).replace(/^b\//, '');
57
- if (/\.enc$/i.test(file)) enc = { file, line: null, added: [], findings: [] };
57
+ if (/\.enc(?:\.yaml)?$/i.test(file)) enc = { file, line: null, added: [], findings: [] };
58
58
  return;
59
59
  }
60
60
  const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { isSealedName, memberOfName } from '../lib/sealed-member.mjs';
2
3
  import { list } from '../lib/list.mjs';
3
4
  import { parseYaml } from '../../engine/yaml.mjs';
4
5
 
@@ -70,7 +71,7 @@ function entries(dir, fs) {
70
71
  export function sealedKeyNames(root, { fs }) {
71
72
  const out = [], base = path.join(root, '.starcistacks');
72
73
  for (const env of entries(base, fs).filter((entry) => entry.isDirectory())) {
73
- for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
74
+ for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && isSealedName(entry.name))) {
74
75
  const file = `.starcistacks/${env.name}/secrets/${member.name}`;
75
76
  const source = fs.readFileSync(path.join(root, file), 'utf8');
76
77
  const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
@@ -79,7 +80,7 @@ export function sealedKeyNames(root, { fs }) {
79
80
  const doc = parseYaml(source);
80
81
  keys = doc && typeof doc === 'object' && !Array.isArray(doc) ? Object.keys(doc).filter((key) => key !== 'sops' && !key.startsWith('sops_')) : [];
81
82
  }
82
- for (const key of keys) out.push({ env: env.name, member: member.name.slice(0, -4), key, file });
83
+ for (const key of keys) out.push({ env: env.name, member: memberOfName(member.name), key, file });
83
84
  }
84
85
  }
85
86
  return out;
@@ -10,10 +10,12 @@ import { baselineMarkers, baselineSourceFindings } from './rules/database-baseli
10
10
  import { databaseQueueDeclarations } from './rules/database-queues.mjs';
11
11
  import { MIGRATIONS_DIR, MIGRATIONS_ARCHIVE_DIR } from './rules/database-constants.mjs';
12
12
  import { dbPlanMain } from './db-plan-verb.mjs';
13
+ import { DbVerbError } from './db-errors.mjs';
14
+ import { squashBaseline } from './db-squash.mjs';
13
15
 
14
16
  await pg.loadModule();
15
17
 
16
- export class DbVerbError extends Error {}
18
+ export { DbVerbError };
17
19
 
18
20
  /** Strict shared options; unknown flags never reach the database CLI. */
19
21
  export function dbOptions(argv, allowed) {
@@ -22,7 +24,7 @@ export function dbOptions(argv, allowed) {
22
24
  const [flag, inline] = argv[index].split(/=(.*)/s);
23
25
  const name = flag.slice(2);
24
26
  if (!flag.startsWith('--') || !allowed.includes(name) || Object.hasOwn(opts, name)) throw new DbVerbError('unknown or duplicate database option');
25
- if (['apply', 'json'].includes(name)) {
27
+ if (['apply', 'json', 'squash'].includes(name)) {
26
28
  if (inline !== undefined && !['true', 'false'].includes(inline)) throw new DbVerbError(`${flag} expects true or false`);
27
29
  opts[name] = inline !== 'false';
28
30
  } else {
@@ -43,7 +45,7 @@ export function dbAppRoot(cwd, opts) {
43
45
  }
44
46
 
45
47
  /** A refusal that lists every offending file with the rule it broke, one distinct `<file>: <reason>` line each. */
46
- const offendersError = (heading, offenders) => new DbVerbError(`${heading}:\n${[...new Set(offenders)].map((line) => ` ${line}`).join('\n')}`);
48
+ export const offendersError = (heading, offenders) => new DbVerbError(`${heading}:\n${[...new Set(offenders)].map((line) => ` ${line}`).join('\n')}`);
47
49
 
48
50
  /** One rule finding as an offender line: its file, its message and its code. */
49
51
  const findingLine = (item) => `${item.message.startsWith(item.path) ? item.message : `${item.path}: ${item.message}`} (${item.code})`;
@@ -73,7 +75,7 @@ export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
73
75
  }
74
76
 
75
77
  /** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
76
- function connectionOf(env, urlEnv) {
78
+ export function connectionOf(env, urlEnv) {
77
79
  if (!/^[A-Z][A-Z0-9_]*$/.test(urlEnv)) throw new DbVerbError('--url-env must name an UPPER_CASE environment variable');
78
80
  let url;
79
81
  try { url = new URL(String(env[urlEnv] ?? '')); } catch { throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`); }
@@ -87,7 +89,7 @@ const HISTORY = 'supabase_migrations.schema_migrations';
87
89
  const sqlLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
88
90
 
89
91
  /** The psql and pg_dump invocation of one operation: a JSON rows query, a schema-only dump, or one history row applied or reverted. */
90
- function commandOf(op, url) {
92
+ export function commandOf(op, url) {
91
93
  const psql = ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', url];
92
94
  if (op.kind === 'query') return ['psql', [...psql, '-A', '-t', '-c', `select coalesce(json_agg(row_to_json(q)), '[]'::json) from (${op.sql.replace(/;\s*$/, '')}) q`]];
93
95
  if (op.kind === 'dump') return ['pg_dump', ['--schema-only', '--schema', 'public', '--dbname', url]];
@@ -140,7 +142,7 @@ const EXCLUDED_SCHEMAS = new Set(['information_schema', '_analytics', '_realtime
140
142
  const ROLE_OPTIONS = new Set(['canlogin', 'inherit', 'superuser', 'createdb', 'createrole', 'isreplication', 'bypassrls', 'connectionlimit']);
141
143
  const identifier = (name) => `"${name.replaceAll('"', '""')}"`;
142
144
  const literal = (value) => `'${value.replaceAll("'", "''")}'`;
143
- const excludedSchema = (name) => EXCLUDED_SCHEMAS.has(name) || /^(?:pg_|timescaledb_|_timescaledb_)/.test(name ?? '');
145
+ export const excludedSchema = (name) => EXCLUDED_SCHEMAS.has(name) || /^(?:pg_|timescaledb_|_timescaledb_)/.test(name ?? '');
144
146
 
145
147
  /** Inventory source paths only; no credential, generated or vendor tree is traversed. */
146
148
  function baselineFiles(root) {
@@ -302,18 +304,57 @@ function metadataSql(metadata) {
302
304
  return `${queueSql}${grants}${cron}`;
303
305
  }
304
306
 
307
+ /**
308
+ * The squash source holds the whole active chain: its history records every active stamp and nothing else. Only the history is read; the
309
+ * queues and cron jobs a plan also compares are what the squash itself drops or carries from the database.
310
+ */
311
+ function sourceHistory(root, plan, ports, urlEnv) {
312
+ const rows = (sql) => JSON.parse(runDbPostgres(root, { kind: 'query', sql }, ports, urlEnv));
313
+ if (!rows("select pg_catalog.to_regclass('supabase_migrations.schema_migrations') is not null as present")[0]?.present) throw new DbVerbError(`the source records no migration history (${HISTORY}); apply its chain with starci app db migrate`);
314
+ const recorded = new Set(rows(`select version from ${HISTORY}`).map((row) => String(row.version)));
315
+ const active = new Set(plan.active.map((entry) => entry.stamp));
316
+ const offenders = [...[...active].filter((stamp) => !recorded.has(stamp)).map((stamp) => `${stamp}: active, not applied to the source`), ...[...recorded].filter((stamp) => !active.has(stamp)).map((stamp) => `${stamp}: applied to the source, not in the active chain`)];
317
+ if (offenders.length) throw offendersError('baseline --squash reads a source with exactly the active chain applied', offenders.sort());
318
+ }
319
+
320
+ /**
321
+ * A baseline reads a database whose history, queues and cron jobs equal the repository's. The plan's own refusal (already free of
322
+ * database output) is passed through as the reason; a drift names each inventory that differs, by its counts.
323
+ */
324
+ function cleanPlan(root, urlEnv, ports) {
325
+ let out = '';
326
+ let err = '';
327
+ const code = dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: (text) => { out += text; }, stderr: (text) => { err += text; } });
328
+ if (code === 0) return;
329
+ const heading = 'baseline requires a clean database plan with every migration already applied';
330
+ if (code !== 1) throw new DbVerbError(`${heading}; the plan refused: ${err.trim().replace(/^starci app db plan: /, '')}`);
331
+ const drift = Object.entries(JSON.parse(out)).filter(([, item]) => item && typeof item === 'object' && (item.missing || item.unexpected || item.changed))
332
+ .map(([kind, item]) => `${kind}: missing ${item.missing}, unexpected ${item.unexpected}, changed ${item.changed}`);
333
+ throw offendersError(`${heading}; the plan found drift`, drift);
334
+ }
335
+
305
336
  /** Numeric CLI exit code, injectable IO/clock/CLI; neither files nor history change without --apply. */
306
337
  export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), now = () => new Date(), ...ports } = {}) {
307
338
  try {
308
- const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env']);
339
+ const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env', 'squash', 'drop-extension', 'drop-pgmq-queue', 'seed-tables', 'image']);
340
+ if (!opts.squash && ['drop-extension', 'drop-pgmq-queue', 'seed-tables', 'image'].some((name) => Object.hasOwn(opts, name))) throw new DbVerbError('--drop-extension, --drop-pgmq-queue, --seed-tables and --image belong to --squash');
341
+ if (opts.squash && !opts['url-env']) throw new DbVerbError('baseline --squash reads a database the whole active chain is applied to: pass --url-env NAME');
309
342
  const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
310
343
  const root = dbAppRoot(cwd, opts);
311
344
  const plan = baselinePlan(root, opts, now);
312
345
  const files = baselineFiles(root);
313
346
  const sourceFindings = baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git });
314
347
  if (sourceFindings.length) throw offendersError('baseline sources and archives must match committed migration history', sourceFindings.map(findingLine));
348
+ const archive = () => {
349
+ fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
350
+ for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
351
+ };
352
+ if (opts.squash) {
353
+ const precondition = (runPostgres) => sourceHistory(root, plan, { ...ports, runPostgres }, urlEnv);
354
+ return squashBaseline({ root, opts, plan, urlEnv, connection: connectionOf(ports.env ?? process.env, urlEnv), commandOf, precondition, managedSchema: excludedSchema, archive, stdout });
355
+ }
315
356
  const metadata = baselineMetadata(root, plan, files);
316
- if (dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with every migration already applied');
357
+ cleanPlan(root, urlEnv, ports);
317
358
  const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
318
359
  if (!schema.trim() || baselineMarkers(schema).length || /\bvault\.(?:create_secret|update_secret)\s*\(|\bpassword\s+'[^']+'/i.test(schema)) throw new DbVerbError('schema dump is empty or contains unsupported secret/baseline statements');
319
360
  const repairs = [...plan.active.map((entry) => repairOp(entry.stamp, 'reverted')), repairOp(plan.stamp, 'applied', 'baseline')];
@@ -321,9 +362,8 @@ export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) =>
321
362
  for (const op of repairs) stdout(`${repairLine(op)}\n`);
322
363
  if (!opts.apply) return 0;
323
364
  for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
324
- fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
325
365
  fs.writeFileSync(path.join(root, plan.file), `-- starci:baseline ${plan.last}\n${metadata.extensions}${metadata.roles}${schema.replace(/^\s+/, '')}\n${metadataSql(metadata)}`, { flag: 'wx' });
326
- for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
366
+ archive();
327
367
  return 0;
328
368
  } catch (error) { return dbFailure('baseline', error, stderr); }
329
369
  }
@@ -0,0 +1,2 @@
1
+ // The refusal every `starci app db` verb module throws; dbFailure (db-baseline-verb.mjs) prints its message, any other error is withheld.
2
+ export class DbVerbError extends Error {}
@@ -5,7 +5,7 @@ import { sha256 } from '../../engine/digest.mjs';
5
5
  import pg from 'libpg-query';
6
6
  import { invocationDir } from '../lib/roots.mjs';
7
7
  import { sqlStringParts, walkSqlAst as walk, sqlParser, sqlCallArgument as argument, sqlStringLiteral as literal } from '../lib/sql-ast.mjs';
8
- import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure } from './db-baseline-verb.mjs';
8
+ import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure, offendersError } from './db-baseline-verb.mjs';
9
9
 
10
10
  await pg.loadModule();
11
11
 
@@ -75,6 +75,7 @@ function conditionalBlock(item) {
75
75
  && !(key === 'PLpgSQL_stmt_return' && item[key].lineno === undefined));
76
76
  }
77
77
 
78
+ /** Inventories one DO block; returns the body-relative line of each EXECUTE, whose SQL is built at run time and so never inventoried. */
78
79
  function inventoryDo(state, node) {
79
80
  const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
80
81
  if (option('language') && option('language') !== 'plpgsql') throw new DbVerbError('database plan requires statically readable migration blocks');
@@ -85,29 +86,40 @@ function inventoryDo(state, node) {
85
86
  try { tree = pg.parsePlPgSQLSync(`create function starci_plan_probe() returns void language plpgsql as ${delimiter}${body}${delimiter};`); }
86
87
  catch { throw new DbVerbError('migration block could not be inventoried safely'); }
87
88
  const statements = [];
89
+ const dynamic = [];
88
90
  let conditional = false;
89
91
  walk(tree, (item) => {
90
92
  conditional ||= conditionalBlock(item);
91
- if (item.PLpgSQL_stmt_dynexecute) throw new DbVerbError('dynamic migration blocks cannot prove database inventory');
93
+ if (item.PLpgSQL_stmt_dynexecute) dynamic.push(item.PLpgSQL_stmt_dynexecute.lineno);
92
94
  if (item.PLpgSQL_expr?.parseMode === 0) statements.push(parseSql(item.PLpgSQL_expr.query));
93
95
  });
94
96
  if (conditional && statements.some((statement) => ownershipCalls(statement).length)) throw new DbVerbError('conditional migration blocks cannot prove database inventory');
95
97
  for (const statement of statements) inventoryTree(state, statement);
98
+ return dynamic;
96
99
  }
97
100
 
101
+ /** The 1-based line of `text` at libpg_query's byte offset `location`. */
102
+ const lineAt = (text, location) => Buffer.from(text).subarray(0, location).toString().split('\n').length;
103
+
98
104
  function repositoryState(root) {
99
105
  const migrations = dbSqlFiles(root);
100
106
  const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map() };
101
107
  if (state.migrations.size !== migrations.length) throw new DbVerbError('active migrations must have unique versions');
108
+ // Every EXECUTE of every active migration is named (file:line) before the refusal.
109
+ const dynamic = [];
102
110
  for (const entry of migrations) {
103
- const tree = parseSql(fs.readFileSync(path.join(root, entry.file), 'utf8'));
111
+ const text = fs.readFileSync(path.join(root, entry.file), 'utf8');
112
+ const tree = parseSql(text);
104
113
  for (const statement of tree.stmts ?? []) {
105
114
  // Creating a function does not execute its body. Inert SQL strings never declare remote state.
106
115
  if (statement.stmt.CreateFunctionStmt) continue;
107
- if (statement.stmt.DoStmt) inventoryDo(state, statement.stmt.DoStmt);
108
- else inventoryTree(state, statement.stmt);
116
+ if (!statement.stmt.DoStmt) { inventoryTree(state, statement.stmt); continue; }
117
+ // The body opens on the line of its quote (the `as` DefElem), so a body line maps to that line plus its offset.
118
+ const opening = lineAt(text, statement.stmt.DoStmt.args?.find((item) => item.DefElem?.defname === 'as')?.DefElem?.location ?? statement.stmt_location ?? 0);
119
+ for (const line of inventoryDo(state, statement.stmt.DoStmt)) dynamic.push(`${entry.file}:${opening + line - 1}: EXECUTE builds its SQL at run time`);
109
120
  }
110
121
  }
122
+ if (dynamic.length) throw offendersError('dynamic migration blocks cannot prove database inventory', dynamic);
111
123
  return state;
112
124
  }
113
125