@starci/hfs 4.9.11 → 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 (219) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +7 -9
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +1 -1
  5. package/db/dump.mjs +11 -10
  6. package/db/entity-list.mjs +1 -1
  7. package/db/migrate.mjs +83 -259
  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/package.json +1 -1
  13. package/release/gate.mjs +3 -15
  14. package/runtime/knowledge/hfs/canon-pins.yaml +44 -24
  15. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  16. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  17. package/runtime/knowledge/hfs/rules.yaml +177 -155
  18. package/runtime/knowledge/hfs/slots.yaml +164 -172
  19. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  20. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  21. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  22. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  23. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  24. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  25. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  26. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  28. package/runtime/modules/kernel/command-policy.yaml +1 -1
  29. package/runtime/modules/kernel/failure-codes.yaml +65 -115
  30. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  31. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  33. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  34. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  36. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  37. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  38. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  40. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  41. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  42. package/runtime/scripts/hfs/check.mjs +6 -6
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  45. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  46. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  47. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  48. package/runtime/scripts/hfs/manifest-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  50. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  52. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  53. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  54. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  55. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  56. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  57. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  58. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  60. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  61. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  62. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  63. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  64. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  65. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  66. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  67. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  68. package/runtime/scripts/hfs/slots.mjs +8 -2
  69. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  70. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  71. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  72. package/scaffold/add-app.mjs +2 -10
  73. package/scaffold/add-cli-lite.mjs +6 -6
  74. package/scaffold/add-compose.mjs +1 -1
  75. package/scaffold/add-inbox-lite.mjs +4 -8
  76. package/scaffold/add-plan.mjs +1 -1
  77. package/scaffold/add-queue-lite.mjs +16 -24
  78. package/scaffold/add-table.mjs +34 -26
  79. package/scaffold/add.mjs +2 -2
  80. package/scaffold/app.mjs +19 -86
  81. package/scaffold/lite-exports.mjs +1 -1
  82. package/src/main.mjs +19 -20
  83. package/stack/target-binding.mjs +0 -60
  84. package/stack/urgent-floor.mjs +2 -2
  85. package/sync/index.mjs +32 -8
  86. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  87. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  88. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  89. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  90. package/templates/app/gitignore-lite +0 -3
  91. package/templates/app/package-scripts/db.json +9 -0
  92. package/templates/app/package-scripts/package.json +0 -1
  93. package/templates/app/package-scripts-lite/package.json +1 -1
  94. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  95. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  96. package/templates/app/skeleton/README.md +1 -1
  97. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  98. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  99. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  100. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  101. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  102. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  103. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  104. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  105. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  106. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  107. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  108. package/templates/app/skeleton-lite/README.md +11 -11
  109. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  110. package/templates/app/tool-config-lite/prettierignore +0 -1
  111. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  112. package/templates/be/inbox/database-client.ts.tpl +13 -4
  113. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  114. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  115. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  116. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  117. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  118. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  119. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  120. package/templates/be/inbox/service.ts.tpl +1 -2
  121. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  122. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  123. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  124. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  125. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  126. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  127. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  128. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  129. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  130. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  131. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  132. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  133. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  134. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  136. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  138. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  139. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  140. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  143. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  144. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +6 -6
  145. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  149. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  150. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  151. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  158. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  159. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  160. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  161. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  162. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  163. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  164. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  165. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  166. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  167. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  168. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  169. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  170. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  171. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  172. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  173. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  174. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  175. package/templates/be/table/entities.index.ts.tpl +4 -0
  176. package/templates/be/table/entity.ts.tpl +21 -0
  177. package/templates/be/table/index.ts.tpl +1 -0
  178. package/templates/be/table/module.ts.tpl +1 -1
  179. package/templates/be/table/service.api.ts.tpl +10 -11
  180. package/templates/be/table/service.ts.tpl +5 -6
  181. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  182. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  183. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  184. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  185. package/db/local-migrate.mjs +0 -194
  186. package/db/reset.mjs +0 -125
  187. package/emit/db-types.mjs +0 -271
  188. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  189. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  190. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  191. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  192. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  193. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  194. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  195. package/scaffold/supabase-ports.mjs +0 -26
  196. package/templates/app/package-scripts/supabase.json +0 -7
  197. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  198. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  199. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  200. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  201. package/templates/be/inbox/sql.ts.tpl +0 -16
  202. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  203. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  204. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  205. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  206. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  207. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  208. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  209. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  210. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  211. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  212. package/templates/be/table/rows.ts.tpl +0 -4
  213. package/templates/be/table/sql.api.ts.tpl +0 -7
  214. package/templates/be/table/sql.ts.tpl +0 -4
  215. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  216. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  217. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  218. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  219. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
@@ -1,20 +1,20 @@
1
1
  # {{project}}
2
2
 
3
- One StarCi lite app: a Nest API in `be/`, one Next web app in `fe/`, and Supabase as the schema authority.
3
+ One StarCi lite app: a Nest API in `be/`, one Next web app in `fe/`, and its own PostgreSQL with SQL migrations in `db/` as the schema authority.
4
4
 
5
5
  ## Overview
6
6
 
7
- The starter keeps one deployable API and one web app, with the same code structure as a full app: the web app calls the API over GraphQL (`fe/apps/web/src/modules/api`, SWR hooks under `fe/apps/web/src/hooks/swr`) and uses Supabase only for Auth in the browser (`fe/apps/web/src/modules/auth`); every data read and write goes through the API, which verifies the access token.
7
+ The starter keeps one deployable API and one web app, with the same code structure as a full app: the web app calls the API over GraphQL (`fe/apps/web/src/modules/api`, SWR hooks under `fe/apps/web/src/hooks/swr`) and signs in only through the API's own auth doors (`fe/apps/web/src/modules/auth`, which holds the access token in memory while the refresh token stays in an httpOnly cookie); every data read and write goes through the API, which verifies the access token against its own keys.
8
8
 
9
9
  ## Stack
10
10
 
11
- TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, Supabase for Auth and PostgreSQL, npm workspaces, and Turbo for front-end tasks.
11
+ TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service, image supabase/postgres), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
12
12
 
13
13
  ## Repository layout
14
14
 
15
- - `supabase/`: local configuration, forward-only SQL migrations, seed data, and generated database types.
16
- - `be/`: the API, shared platform capabilities, verified identity admission, and privileged Supabase integrations.
17
- - `fe/`: the web app; the only Supabase client is the Auth client of `fe/apps/web/src/modules/auth`, data goes through `fe/apps/web/src/modules/api`.
15
+ - `db/`: forward-only SQL migrations, seed data, and generated database types.
16
+ - `be/`: the API, shared platform capabilities, verified identity admission, and the passport and storage integrations.
17
+ - `fe/`: the web app; it holds no credential, signs in through `fe/apps/web/src/modules/auth` and reaches data through `fe/apps/web/src/modules/api`.
18
18
  - `.starcistacks/`: value-free environment metadata and encrypted-secret custody.
19
19
  - `.starciwork/`: `.gitignore`, `.starciwork/workspace.yaml` project metadata and repo roles, and optional brand drawing records, references, and assets.
20
20
 
@@ -24,7 +24,7 @@ Declare `ci.github.repository` and `ci.github.defaultBranch` in `.starcistacks/a
24
24
 
25
25
  ## Development
26
26
 
27
- Every development dependency runs in Docker Linux containers. Start local Supabase with `npm run db:start` through the Supabase CLI; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies run on Docker Swarm, except Supabase, which is a hosted service; Supabase pgvector owns vector search.
27
+ Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the pinned `db` image, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://supabase_admin@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
28
28
 
29
29
  ```sh
30
30
  npm install
@@ -36,17 +36,17 @@ npm run build:be
36
36
  npm run build:fe
37
37
  ```
38
38
 
39
- The API reads `PORT`, `HTTP_SECURITY_ALLOWED_ORIGINS`, `PRIMARY_DB_URL`, `PRIMARY_DB_SCHEMA`, `SUPABASE_URL`, and `SUPABASE_SERVICE_ROLE_KEY`. The web app reads `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, and, once the first GraphQL operation exists, `NEXT_PUBLIC_GRAPHQL_URL`. Missing configuration stops the relevant process with a named error; no template supplies credential defaults.
39
+ The API reads `PORT`, `HTTP_SECURITY_ALLOWED_ORIGINS`, `PRIMARY_DB_URL`, `PRIMARY_DB_SCHEMA`, the Google OAuth keys `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` and `GOOGLE_CALLBACK_URL`, its own token keys `AUTH_JWT_ISSUER`, `AUTH_JWT_AUDIENCE`, `AUTH_JWT_KEY_ID` and `AUTH_JWT_PRIVATE_KEY`, and the storage keys `STORAGE_ENDPOINT`, `STORAGE_REGION`, `STORAGE_BUCKET`, `STORAGE_ACCESS_KEY_ID` and `STORAGE_SECRET_ACCESS_KEY`. The database tooling reads `DATABASE_URL`. The web app reads `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_API_URL`, and, once the first GraphQL operation exists, `NEXT_PUBLIC_GRAPHQL_URL`. Missing configuration stops the relevant process with a named error; no template supplies credential defaults.
40
40
 
41
- Production browser build values live in `.starcistacks/application-stacks.yaml` under `environments.production.deploy.images.web.buildArgs.values`. Before building deployment images, replace the placeholders with your site's public origin, your Supabase project's public URL, and its public anonymous key. These values reach every browser; keep the service role key in sealed runtime custody.
41
+ Production browser build values live in `.starcistacks/application-stacks.yaml` under `environments.production.deploy.images.web.buildArgs.values`. Before building deployment images, replace the placeholders with your site's and your API's public origins. These values reach every browser; every credential stays in sealed runtime custody.
42
42
 
43
- `npm test` runs the back end's unit specs with per-file coverage. Optional integration specs run with `npm run test:integration` against the local Supabase stack (`npm run db:start`); a lite app has no e2e world.
43
+ `npm test` runs the back end's unit specs with per-file coverage. Optional integration specs run with `npm run test:integration` against the local database (`npm run db:start`, then `npm run db:push`); a lite app has no e2e world.
44
44
 
45
45
  The public runtime template tracks only sealed member names. Scaffolding creates this app's own age recipient and encrypts generated placeholder values into the declared `.starcistacks/<environment>/secrets/*.enc` members. Keep the matching host age identity in untracked local custody, then use `starci app secret set` to replace each placeholder with an actual value before deployment. Product apps retain sealed member custody. Runtime specs use disposable copies with ephemeral identities and remove them afterwards.
46
46
 
47
47
  ## Database changes
48
48
 
49
- Create a forward-only migration under `supabase/migrations`, keep each table's RLS policies and grants beside the table, and run `npm run contract:emit` to refresh `supabase/types/database.types.ts`. `starci app add table <name> --fe` adds the table with its GraphQL feature on the API and its operations and SWR hooks in the web app; `npm run codegen:graphql` regenerates the web app's wire types from `be/contracts/<app>/schema.graphql`.
49
+ Create a forward-only migration with `npm run db:new -- <kebab-name>` (it lands in `db/migrations`), keep each table's RLS policies and grants beside the table, apply it with `npm run db:push`, and run `npm run contract:emit` to refresh `db/types/database.types.ts`. `starci app add table <name> --fe` adds the table with its GraphQL feature on the API and its operations and SWR hooks in the web app; `npm run codegen:graphql` regenerates the web app's wire types from `be/contracts/<app>/schema.graphql`.
50
50
 
51
51
  ## Work
52
52
 
@@ -6,7 +6,7 @@ begin
6
6
  end;
7
7
  $$;
8
8
 
9
- -- The attributes a Supabase migration role may change: SUPERUSER and BYPASSRLS stay as create role set them (the role migration owner is no superuser).
9
+ -- The attributes a migration may change: SUPERUSER and BYPASSRLS stay as create role set them.
10
10
  alter role app_be login noinherit nocreatedb nocreaterole;
11
11
 
12
12
  create schema if not exists private;
@@ -24,6 +24,26 @@ begin
24
24
  end;
25
25
  $$;
26
26
 
27
+ -- The rotating refresh sessions of integrations/passport (auth.users comes from db/bootstrap.sql).
28
+ -- One row per issued refresh token, stored by its sha256 only. A family is one sign-in; rotation revokes the presented row
29
+ -- and inserts its successor, and presenting a revoked row revokes the whole family (reuse detection).
30
+ create table auth.refresh_sessions (
31
+ id uuid primary key default gen_random_uuid(),
32
+ family_id uuid not null,
33
+ user_id uuid not null references auth.users (id) on delete cascade,
34
+ token_hash char(64) not null unique,
35
+ expires_at timestamptz not null,
36
+ revoked_at timestamptz,
37
+ created_at timestamptz not null default now()
38
+ );
39
+
40
+ create index refresh_sessions_family_idx on auth.refresh_sessions (family_id);
41
+
42
+ grant usage on schema auth to app_be;
43
+ grant select, insert, update on table auth.users to app_be;
44
+ grant select, insert on table auth.identities to app_be;
45
+ grant select, insert, update on table auth.refresh_sessions to app_be;
46
+
27
47
  create table public.profiles (
28
48
  id uuid primary key references auth.users (id) on delete cascade,
29
49
  display_name text not null check (char_length(display_name) between 1 and 120),
@@ -1,2 +1 @@
1
1
  {{> app/tool-config/prettierignore}}
2
- supabase/types/
@@ -1,15 +1,19 @@
1
1
  import { mockEntityManager } from "@starci/jest-preset"
2
2
  import type { EntityManager } from "typeorm"
3
- import { sql } from "./database.sql"
4
3
  import { DatabaseErrorCode } from "./errors/database.error"
5
- import { recordWebhookDelivery } from "./webhook-inbox.client"
4
+ import { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"
6
5
 
7
- // The caller owns the conflict-safe pending-row insert; an opaque function call stands in for it here.
8
- const INSERT = sql`SELECT id FROM record_spec_inbox($1, $2, $3::jsonb)`
6
+ const INSERT = webhookReceiptInsert("spec_inbox")
9
7
  const PAYLOAD = { id: "delivery-1" }
10
8
 
11
9
  const record = (manager: EntityManager, deliveryId: string | undefined) =>
12
- recordWebhookDelivery({ manager, insert: INSERT, provider: "spec", deliveryId, payload: PAYLOAD })
10
+ recordWebhookDelivery({ manager, table: "spec_inbox", provider: "spec", deliveryId, payload: PAYLOAD })
11
+
12
+ describe("webhookReceiptInsert", () => {
13
+ it("refuses a table name that is not a lowercase identifier", () => {
14
+ expect(() => webhookReceiptInsert("spec; drop table x")).toThrow(TypeError)
15
+ })
16
+ })
13
17
 
14
18
  describe("recordWebhookDelivery", () => {
15
19
  it("records a new delivery through the caller's transaction manager", async () => {
@@ -1,10 +1,10 @@
1
1
  import type { EntityManager } from "typeorm"
2
2
  import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
3
- import type { SqlText } from "./database.sql"
4
3
 
5
4
  interface RecordWebhookDelivery {
6
5
  readonly manager: EntityManager
7
- readonly insert: SqlText
6
+ /** The provider inbox table under public, created by its db migration. */
7
+ readonly table: string
8
8
  readonly provider: string
9
9
  readonly deliveryId: string | undefined
10
10
  readonly payload: object
@@ -12,13 +12,22 @@ interface RecordWebhookDelivery {
12
12
 
13
13
  type WebhookReceiptId = string | undefined
14
14
 
15
+ /** A provider inbox table name: a lowercase identifier, never a value. */
16
+ const TABLE = /^[a-z_][a-z0-9_]*$/
17
+
18
+ /** The conflict-safe insert of one pending delivery into `public.<table>`, answering its id only to the winning transaction. */
19
+ export const webhookReceiptInsert = (table: string): string => {
20
+ if (!TABLE.test(table)) throw new TypeError(`${table} is not a provider inbox table name`)
21
+ return `INSERT INTO public.${table} (provider, delivery_id, payload) VALUES ($1, $2, $3::jsonb) ON CONFLICT (provider, delivery_id) DO NOTHING RETURNING id`
22
+ }
23
+
15
24
  /**
16
25
  * Records one pending delivery using the intake's transaction manager and returns its durable receipt identity.
17
26
  * Business processing and the completion marker belong to the reactor or job that consumes the committed row.
18
27
  */
19
28
  export const recordWebhookDelivery = async ({
20
29
  manager,
21
- insert,
30
+ table,
22
31
  provider,
23
32
  deliveryId,
24
33
  payload,
@@ -27,7 +36,7 @@ export const recordWebhookDelivery = async ({
27
36
  if (!normalizedDeliveryId) {
28
37
  throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
29
38
  }
30
- const rows: Array<{ readonly id: string }> = await manager.query(insert, [
39
+ const rows: Array<{ readonly id: string }> = await manager.query(webhookReceiptInsert(table), [
31
40
  provider,
32
41
  normalizedDeliveryId,
33
42
  JSON.stringify(payload),
@@ -16,3 +16,50 @@ on public.inbox for delete to app_be
16
16
  using (source like 'client:%' and request_fingerprint is not null and outcome is not null);
17
17
 
18
18
  grant update, delete on table public.inbox to app_be;
19
+
20
+ -- The client claim, its saved outcome and the bounded retention sweep, called through callDbFunction of platform/database (R334).
21
+ -- A conflicting claim locks the existing row and returns its committed fingerprint and outcome after the winner commits.
22
+ create function public.inbox_claim_client(p_source text, p_event_id text, p_fingerprint varchar, p_claimed_at timestamptz)
23
+ returns table (request_fingerprint varchar, outcome jsonb, incomplete boolean)
24
+ language sql
25
+ as $$
26
+ insert into public.inbox as i (source, event_id, request_fingerprint, claimed_at)
27
+ values (p_source, p_event_id, p_fingerprint, p_claimed_at)
28
+ on conflict (source, event_id) do update set outcome = coalesce(i.outcome, '{}'::jsonb)
29
+ returning i.request_fingerprint, i.outcome,
30
+ (i.outcome is not null and (jsonb_typeof(i.outcome) <> 'object' or not (i.outcome ? 'value')))
31
+ $$;
32
+
33
+ create function public.inbox_save_client_outcome(p_source text, p_event_id text, p_outcome jsonb)
34
+ returns table (outcome jsonb)
35
+ language sql
36
+ as $$
37
+ update public.inbox as i set outcome = p_outcome
38
+ where i.source = p_source and i.event_id = p_event_id
39
+ returning i.outcome
40
+ $$;
41
+
42
+ -- Retention never deletes delivery rows or an incomplete claim; its bounded batch skips rows held by active retries.
43
+ create function public.inbox_sweep_clients(p_cutoff timestamptz, p_limit integer)
44
+ returns table (deleted_count integer)
45
+ language sql
46
+ as $$
47
+ with expired as (
48
+ select source, event_id from public.inbox
49
+ where source like 'client:%' and request_fingerprint is not null and outcome is not null
50
+ and jsonb_typeof(outcome) = 'object' and outcome ? 'value' and claimed_at < p_cutoff
51
+ order by claimed_at, source, event_id limit p_limit for update skip locked
52
+ ), deleted as (
53
+ delete from public.inbox as claims using expired
54
+ where claims.source = expired.source and claims.event_id = expired.event_id
55
+ returning claims.event_id
56
+ )
57
+ select count(*)::integer from deleted
58
+ $$;
59
+
60
+ revoke all on function public.inbox_claim_client(text, text, varchar, timestamptz) from public;
61
+ grant execute on function public.inbox_claim_client(text, text, varchar, timestamptz) to app_be;
62
+ revoke all on function public.inbox_save_client_outcome(text, text, jsonb) from public;
63
+ grant execute on function public.inbox_save_client_outcome(text, text, jsonb) to app_be;
64
+ revoke all on function public.inbox_sweep_clients(timestamptz, integer) from public;
65
+ grant execute on function public.inbox_sweep_clients(timestamptz, integer) to app_be;
@@ -3,7 +3,9 @@ import { fakeTransaction, mockEntityManager } from "@starci/jest-preset"
3
3
  import { EventBusErrorCode } from "./errors/event-bus.error"
4
4
  import { BaseEvent } from "./event-bus.contracts"
5
5
  import { EventBusService } from "./event-bus.service"
6
- import { INSERT_OUTBOX_EVENT } from "./persistence/event-bus.sql"
6
+
7
+ // The statement callDbFunction of platform/database sends for event_bus_write.
8
+ const INSERT_OUTBOX_EVENT = "SELECT * FROM event_bus_write($1, $2, $3, $4)"
7
9
 
8
10
  class ReceiptRecordedEvent extends BaseEvent {
9
11
  readonly eventId = "receipt-1"
@@ -4,7 +4,7 @@ import { isCorrelationId } from "@modules/platform/logging"
4
4
  import { EventBusError, EventBusErrorCode } from "./errors/event-bus.error"
5
5
  import type { BaseEvent, EventEnvelope } from "./event-bus.contracts"
6
6
  import type { EventBus } from "./event-bus.port"
7
- import { INSERT_OUTBOX_EVENT } from "./persistence/event-bus.sql"
7
+ import { callDbFunction } from "@modules/platform/database"
8
8
 
9
9
  @Injectable()
10
10
  /** Writes a receipt event into the same transaction as its inbox receipt, without running any consumer. */
@@ -18,6 +18,6 @@ export class EventBusService implements EventBus {
18
18
  eventName: event.eventName,
19
19
  payload: event.payload,
20
20
  }
21
- await tx.query(INSERT_OUTBOX_EVENT, [event.eventId, event.eventName, JSON.stringify(envelope), correlationId])
21
+ await callDbFunction(tx, "event_bus_write", [event.eventId, event.eventName, JSON.stringify(envelope), correlationId])
22
22
  }
23
23
  }
@@ -7,7 +7,12 @@ import { CLOCK } from "@modules/platform/clock"
7
7
  import { InboxError, InboxErrorCode } from "./errors/inbox.error"
8
8
  import { requestFingerprint } from "./request-fingerprint.policy"
9
9
  import { PostgresInbox } from "./inbox.service"
10
- import { CLAIM_CLIENT, CLAIM_EVENT, SAVE_CLIENT_OUTCOME, SWEEP_CLIENTS } from "./persistence/inbox.sql"
10
+
11
+ // The statements callDbFunction of platform/database sends for each inbox function.
12
+ const CLAIM_EVENT = "SELECT * FROM inbox_claim_event($1, $2)"
13
+ const CLAIM_CLIENT = "SELECT * FROM inbox_claim_client($1, $2, $3, $4)"
14
+ const SAVE_CLIENT_OUTCOME = "SELECT * FROM inbox_save_client_outcome($1, $2, $3)"
15
+ const SWEEP_CLIENTS = "SELECT * FROM inbox_sweep_clients($1, $2)"
11
16
 
12
17
  const runClient = <T>(
13
18
  inbox: PostgresInbox,
@@ -2,14 +2,46 @@ import { Injectable } from "@nestjs/common"
2
2
  import type { EntityManager } from "typeorm"
3
3
  import { InjectClock } from "@modules/platform/clock"
4
4
  import type { Clock } from "@modules/platform/clock"
5
- import { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
5
+ import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction } from "@modules/platform/database"
6
6
  import type { ClientOperation, ClientOperationEffect, Inbox, InboxEventId } from "./inbox.port"
7
7
  import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
8
8
  import { InboxError, InboxErrorCode } from "./errors/inbox.error"
9
9
  import { clientSource } from "./client-operation.policy"
10
10
  import { canonicalJson, requestFingerprint } from "./request-fingerprint.policy"
11
- import type { ClaimClientRow, InboxEventRow, InboxSweepRow, SavedClientOutcomeRow } from "./persistence/inbox.rows"
12
- import { CLAIM_CLIENT, CLAIM_EVENT, SAVE_CLIENT_OUTCOME, SWEEP_CLIENTS } from "./persistence/inbox.sql"
11
+
12
+ /** The JSON envelope makes a completed null value distinct from a pending SQL-null outcome. */
13
+ interface ClientOutcomeRow<T> {
14
+ /** The operation's complete JSON result, including expected refusals. */
15
+ readonly value: T
16
+ }
17
+
18
+ /** The row returned by the atomic client claim. */
19
+ interface ClaimClientRow<T> {
20
+ /** The digest recorded by the first operation under this scoped key. */
21
+ readonly request_fingerprint: string | null
22
+ /** The saved result, or null only for a claim inserted by this transaction. */
23
+ readonly outcome: ClientOutcomeRow<T> | null
24
+ /** An existing row had no completed outcome, so it must never run an effect again. */
25
+ readonly incomplete: boolean
26
+ }
27
+
28
+ /** The persisted outcome returned to the first caller. */
29
+ interface SavedClientOutcomeRow<T> {
30
+ /** The normalized JSON result as the database stores it. */
31
+ readonly outcome: ClientOutcomeRow<T>
32
+ }
33
+
34
+ /** A claimed or deleted delivery key returned by inbox persistence. */
35
+ interface InboxEventRow {
36
+ /** The event or client key owned by the row. */
37
+ readonly event_id: string
38
+ }
39
+
40
+ /** The bounded retention batch's aggregate result. */
41
+ interface InboxSweepRow {
42
+ /** Number of completed client claims removed by this transaction. */
43
+ readonly deleted_count: number
44
+ }
13
45
 
14
46
  @Injectable()
15
47
  /** Claims a delivery with the receipt transaction, so a failure can be retried without losing the delivery. */
@@ -22,7 +54,7 @@ export class PostgresInbox implements Inbox {
22
54
  if (!normalizedEventId) {
23
55
  throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
24
56
  }
25
- const rows: Array<InboxEventRow> = await tx.query(CLAIM_EVENT, [source, normalizedEventId])
57
+ const rows = await callDbFunction<InboxEventRow>(tx, "inbox_claim_event", [source, normalizedEventId])
26
58
  return rows.length > 0
27
59
  }
28
60
 
@@ -32,7 +64,7 @@ export class PostgresInbox implements Inbox {
32
64
  throw new InboxError({ code: InboxErrorCode.TransactionRequired })
33
65
  const source = clientSource(request)
34
66
  const fingerprint = requestFingerprint(request.request)
35
- const rows: Array<ClaimClientRow<T>> = await tx.query(CLAIM_CLIENT, [
67
+ const rows = await callDbFunction<ClaimClientRow<T>>(tx, "inbox_claim_client", [
36
68
  source,
37
69
  request.idempotencyKey,
38
70
  fingerprint,
@@ -52,7 +84,7 @@ export class PostgresInbox implements Inbox {
52
84
  } catch (cause) {
53
85
  throw new InboxError({ code: InboxErrorCode.OutcomeInvalid, cause })
54
86
  }
55
- const saved: Array<SavedClientOutcomeRow<T>> = await tx.query(SAVE_CLIENT_OUTCOME, [
87
+ const saved = await callDbFunction<SavedClientOutcomeRow<T>>(tx, "inbox_save_client_outcome", [
56
88
  source,
57
89
  request.idempotencyKey,
58
90
  outcome,
@@ -64,7 +96,7 @@ export class PostgresInbox implements Inbox {
64
96
 
65
97
  /** A row lock and SKIP LOCKED keep retention from competing with a live retry or another sweep worker. */
66
98
  async sweepClients(cutoff: Date, tx: EntityManager): Promise<number> {
67
- const rows: Array<InboxSweepRow> = await tx.query(SWEEP_CLIENTS, [cutoff, BATCH_ROWS])
99
+ const rows = await callDbFunction<InboxSweepRow>(tx, "inbox_sweep_clients", [cutoff, BATCH_ROWS])
68
100
  return rows[0]?.deleted_count ?? 0
69
101
  }
70
102
  }
@@ -47,3 +47,25 @@ with check (event_name <> '');
47
47
 
48
48
  revoke all on table public.event_bus from public, anon, authenticated;
49
49
  grant select, insert, update on table public.event_bus to app_be;
50
+
51
+ -- The claim of one delivery and the typed envelope write, called through callDbFunction of platform/database (R334).
52
+ create function public.inbox_claim_event(p_source text, p_event_id text)
53
+ returns table (event_id text)
54
+ language sql
55
+ as $$
56
+ insert into public.inbox as i (source, event_id) values (p_source, p_event_id)
57
+ on conflict (source, event_id) do nothing
58
+ returning i.event_id
59
+ $$;
60
+
61
+ create function public.event_bus_write(p_event_id text, p_event_name text, p_payload jsonb, p_correlation_id varchar)
62
+ returns void
63
+ language sql
64
+ as $$
65
+ insert into public.event_bus (event_id, event_name, payload, correlation_id) values (p_event_id, p_event_name, p_payload, p_correlation_id)
66
+ $$;
67
+
68
+ revoke all on function public.inbox_claim_event(text, text) from public;
69
+ grant execute on function public.inbox_claim_event(text, text) to app_be;
70
+ revoke all on function public.event_bus_write(text, text, jsonb, varchar) from public;
71
+ grant execute on function public.event_bus_write(text, text, jsonb, varchar) to app_be;
@@ -1,13 +1,13 @@
1
1
  import { fakeInbox, fakeTransaction, mockEntityManager, recordingEventBus } from "@starci/jest-preset"
2
2
  import { Test } from "@nestjs/testing"
3
- import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
3
+ import { PRIMARY_ENTITY_MANAGER, webhookReceiptInsert } from "@modules/platform/database"
4
4
  import { @@Provider@@DeliveryRecordedEvent } from "@modules/events/@@provider@@"
5
5
  import { EVENT_BUS } from "@modules/platform/event-bus"
6
6
  import { INBOX } from "@modules/platform/inbox"
7
- import { INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
8
7
  import { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
9
8
 
10
9
  const DELIVERY = { id: "delivery-1" }
10
+ const INSERT_@@providerUpper@@_INBOX = webhookReceiptInsert("@@table@@")
11
11
 
12
12
  const build = async (fresh: boolean, recorded: ReadonlyArray<{ readonly id: string }> = [{ id: "inbox-1" }]) => {
13
13
  const entityManager = mockEntityManager({ query: [INSERT_@@providerUpper@@_INBOX, recorded] })
@@ -6,7 +6,6 @@ import { InjectEventBus } from "@modules/platform/event-bus"
6
6
  import type { EventBus } from "@modules/platform/event-bus"
7
7
  import { InjectInbox } from "@modules/platform/inbox"
8
8
  import type { Inbox } from "@modules/platform/inbox"
9
- import { INSERT_@@providerUpper@@_INBOX } from "./persistence/@@provider@@-inbox.sql"
10
9
 
11
10
  interface @@Provider@@Delivery {
12
11
  readonly id: string
@@ -34,7 +33,7 @@ export class @@Provider@@InboxService {
34
33
  if (!(await this.inbox.claim("webhook.@@provider@@", request.eventId, manager))) return
35
34
  const receiptId = await recordWebhookDelivery({
36
35
  manager,
37
- insert: INSERT_@@providerUpper@@_INBOX,
36
+ table: "@@table@@",
38
37
  provider: "@@provider@@",
39
38
  deliveryId: request.eventId,
40
39
  payload: request.delivery,
@@ -0,0 +1,156 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { FakeClock, mockEntityManager } from "@starci/jest-preset"
3
+ import type { MockEntityManager } from "@starci/jest-preset"
4
+ import { CLOCK } from "@modules/platform/clock"
5
+ import { JobsError, JobsErrorCode } from "./errors/jobs.error"
6
+ import type { ClaimedJob } from "./jobs.contracts"
7
+ import { JOBS_MANAGERS } from "./jobs.decorators"
8
+ import { JobClaimService } from "./job-claim.service"
9
+
10
+ // The statements callDbFunction of platform/database sends for each job function.
11
+ const CLAIM_JOB = "SELECT * FROM job_claim($1, $2, $3, $4, $5, $6)"
12
+ const ADVANCE_JOB = "SELECT * FROM job_advance($1, $2, $3, $4)"
13
+ const COMPLETE_JOB = "SELECT * FROM job_complete($1, $2, $3)"
14
+ const FAIL_JOB = "SELECT * FROM job_fail($1, $2, $3, $4)"
15
+
16
+ const AT = "2026-02-03T04:05:06.000Z"
17
+
18
+ const build = async (manager: MockEntityManager) => {
19
+ const moduleRef = await Test.createTestingModule({
20
+ providers: [
21
+ JobClaimService,
22
+ { provide: JOBS_MANAGERS, useValue: [manager] },
23
+ { provide: CLOCK, useValue: new FakeClock(AT) },
24
+ ],
25
+ }).compile()
26
+ return { claims: moduleRef.get(JobClaimService) }
27
+ }
28
+
29
+ const buildWithoutConnection = async () => {
30
+ const moduleRef = await Test.createTestingModule({
31
+ providers: [
32
+ JobClaimService,
33
+ { provide: JOBS_MANAGERS, useValue: [] },
34
+ { provide: CLOCK, useValue: new FakeClock(AT) },
35
+ ],
36
+ }).compile()
37
+ return { claims: moduleRef.get(JobClaimService) }
38
+ }
39
+
40
+ const claimed: ClaimedJob = { correlationId: "request-1", jobId: "j-1", kind: "mail", fencingToken: 3, currentStep: null, payload: { id: "m-1" } }
41
+
42
+ describe("JobClaimService", () => {
43
+ describe("claim", () => {
44
+ it("claims the delivery in one statement and returns the job with the bumped token", async () => {
45
+ const manager = mockEntityManager({
46
+ query: [
47
+ CLAIM_JOB,
48
+ [{ id: "j-1", kind: "mail", fencing_token: "3", current_step: null, payload: { id: "m-1" } }],
49
+ ],
50
+ })
51
+ const { claims } = await build(manager)
52
+
53
+ const job = await claims.claim({
54
+ kind: "mail",
55
+ jobKey: "k-1",
56
+ correlationId: "request-1",
57
+ payload: { id: "m-1" },
58
+ workerId: "w-1",
59
+ leaseMs: 60000,
60
+ })
61
+
62
+ expect(job).toEqual(claimed)
63
+ expect(manager.query).toHaveBeenCalledWith(CLAIM_JOB, [
64
+ "mail",
65
+ "k-1",
66
+ "w-1",
67
+ new Date("2026-02-03T04:06:06.000Z"),
68
+ JSON.stringify({ id: "m-1" }),
69
+ new Date(AT),
70
+ ])
71
+ })
72
+
73
+ it("answers null when the job is done or another worker holds a live claim", async () => {
74
+ const manager = mockEntityManager({ query: [CLAIM_JOB, []] })
75
+ const { claims } = await build(manager)
76
+
77
+ await expect(
78
+ claims.claim({ correlationId: "request-1", kind: "mail", jobKey: "k-1", payload: {}, workerId: "w-1", leaseMs: 1000 }),
79
+ ).resolves.toBeNull()
80
+ })
81
+ })
82
+
83
+ describe("guarded writes", () => {
84
+ it("records a step, completes and fails under the token the caller holds", async () => {
85
+ const manager = mockEntityManager({
86
+ query: [
87
+ [ADVANCE_JOB, [{ id: "j-1" }]],
88
+ [COMPLETE_JOB, [{ id: "j-1" }]],
89
+ [FAIL_JOB, [{ id: "j-1" }]],
90
+ ],
91
+ })
92
+ const { claims } = await build(manager)
93
+
94
+ await claims.advance({ jobId: "j-1", expectedFencingToken: 3, step: "send" })
95
+ await claims.complete({ jobId: "j-1", expectedFencingToken: 3 })
96
+ await claims.fail({ jobId: "j-1", expectedFencingToken: 3, reason: "boom" })
97
+
98
+ expect(manager.query).toHaveBeenNthCalledWith(1, ADVANCE_JOB, ["j-1", 3, "send", new Date(AT)])
99
+ expect(manager.query).toHaveBeenNthCalledWith(2, COMPLETE_JOB, ["j-1", 3, new Date(AT)])
100
+ expect(manager.query).toHaveBeenNthCalledWith(3, FAIL_JOB, ["j-1", 3, "boom", new Date(AT)])
101
+ })
102
+
103
+ it("throws JobFencedOut when a write finds no row at its token: a newer worker owns the job", async () => {
104
+ const manager = mockEntityManager({
105
+ query: [
106
+ [ADVANCE_JOB, []],
107
+ [COMPLETE_JOB, []],
108
+ [FAIL_JOB, []],
109
+ ],
110
+ })
111
+ const { claims } = await build(manager)
112
+
113
+ const attempts = [
114
+ claims.advance({ jobId: "j-1", expectedFencingToken: 2, step: "send" }),
115
+ claims.complete({ jobId: "j-1", expectedFencingToken: 2 }),
116
+ claims.fail({ jobId: "j-1", expectedFencingToken: 2, reason: "boom" }),
117
+ ]
118
+
119
+ for (const attempt of attempts) {
120
+ await expect(attempt).rejects.toMatchObject({
121
+ code: JobsErrorCode.FencedOut,
122
+ params: { jobId: "j-1", token: 2 },
123
+ })
124
+ await expect(attempt).rejects.toBeInstanceOf(JobsError)
125
+ }
126
+ })
127
+ })
128
+
129
+ describe("runKey", () => {
130
+ it("includes the job, the step and the fencing token, so a zombie's key differs from the live worker's", async () => {
131
+ const { claims } = await build(mockEntityManager())
132
+
133
+ expect(claims.runKey(claimed, "send")).toBe("j-1:send:3")
134
+ expect(claims.runKey({ ...claimed, fencingToken: 4 }, "send")).toBe("j-1:send:4")
135
+ })
136
+
137
+ it("refuses a step name that carries the separator, because the key would be ambiguous", async () => {
138
+ const { claims } = await build(mockEntityManager())
139
+
140
+ expect(() => claims.runKey(claimed, "send:twice")).toThrow(JobsError)
141
+ expect(() => claims.runKey(claimed, "send:twice")).toThrow(JobsErrorCode.RunKeyInvalid)
142
+ })
143
+ })
144
+
145
+ describe("registration", () => {
146
+ it("refuses to claim with no connection to hold the job table", async () => {
147
+ const { claims } = await buildWithoutConnection()
148
+
149
+ await expect(
150
+ claims.claim({ correlationId: "request-1", kind: "mail", jobKey: "k-1", payload: {}, workerId: "w-1", leaseMs: 1000 }),
151
+ ).rejects.toMatchObject({
152
+ code: JobsErrorCode.ConnectionMissing,
153
+ })
154
+ })
155
+ })
156
+ })