@starci/hfs 4.9.9 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +20 -8
  3. package/db/backup-receipt.mjs +10 -47
  4. package/db/drift.mjs +289 -0
  5. package/db/dump.mjs +12 -11
  6. package/db/entity-list.mjs +9 -0
  7. package/db/migrate.mjs +84 -260
  8. package/db/new.mjs +46 -0
  9. package/db/post-cutover.mjs +13 -17
  10. package/db/postgres.mjs +80 -0
  11. package/emit/contracts.mjs +5 -9
  12. package/lint/run.mjs +12 -2
  13. package/package.json +5 -3
  14. package/release/gate.mjs +82 -31
  15. package/release/index.mjs +19 -8
  16. package/release/push.mjs +4 -4
  17. package/release/records.mjs +54 -0
  18. package/release/urgent.mjs +129 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
  20. package/runtime/knowledge/hfs/design-truths.yaml +1 -1
  21. package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
  22. package/runtime/knowledge/hfs/rules.yaml +250 -157
  23. package/runtime/knowledge/hfs/slots.yaml +199 -174
  24. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  25. package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
  26. package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
  27. package/runtime/knowledge/patterns/be/projections.yaml +7 -5
  28. package/runtime/knowledge/patterns/be/queues.yaml +8 -8
  29. package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
  30. package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
  31. package/runtime/knowledge/patterns/be/saga.yaml +3 -3
  32. package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
  33. package/runtime/modules/kernel/command-policy.yaml +1 -1
  34. package/runtime/modules/kernel/failure-codes.yaml +86 -115
  35. package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
  36. package/runtime/scripts/hfs/app-wiring.mjs +23 -2
  37. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
  39. package/runtime/scripts/hfs/architecture/index.mjs +2 -3
  40. package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
  41. package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
  43. package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
  44. package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
  45. package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
  46. package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
  47. package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
  48. package/runtime/scripts/hfs/check.mjs +8 -8
  49. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  50. package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
  51. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
  52. package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
  53. package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
  54. package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
  55. package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
  56. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  57. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  58. package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
  59. package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
  60. package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
  61. package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
  62. package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
  63. package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
  64. package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
  65. package/runtime/scripts/hfs/rules/database.mjs +36 -39
  66. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  67. package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
  68. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
  69. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
  70. package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
  71. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  72. package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
  73. package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
  74. package/runtime/scripts/hfs/slots.mjs +8 -2
  75. package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
  76. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  77. package/runtime/scripts/lib/swarm-stack.mjs +7 -4
  78. package/scaffold/add-app.mjs +2 -10
  79. package/scaffold/add-cli-lite.mjs +6 -6
  80. package/scaffold/add-compose.mjs +1 -1
  81. package/scaffold/add-inbox-lite.mjs +4 -8
  82. package/scaffold/add-plan.mjs +1 -1
  83. package/scaffold/add-queue-lite.mjs +16 -24
  84. package/scaffold/add-table.mjs +34 -26
  85. package/scaffold/add.mjs +2 -2
  86. package/scaffold/app.mjs +19 -86
  87. package/scaffold/lite-exports.mjs +1 -1
  88. package/src/main.mjs +24 -22
  89. package/stack/cli.mjs +10 -2
  90. package/stack/deploy.mjs +21 -4
  91. package/stack/target-binding.mjs +0 -60
  92. package/stack/urgent-floor.mjs +147 -0
  93. package/sync/ci-commands.mjs +3 -1
  94. package/sync/index.mjs +32 -8
  95. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
  96. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
  97. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
  98. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
  99. package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
  100. package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
  101. package/templates/app/gitignore-lite +0 -3
  102. package/templates/app/package-scripts/db.json +9 -0
  103. package/templates/app/package-scripts/package.json +0 -1
  104. package/templates/app/package-scripts-lite/package.json +1 -1
  105. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  106. package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
  107. package/templates/app/skeleton/README.md +1 -1
  108. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
  109. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
  110. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
  111. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
  112. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
  113. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
  114. package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
  115. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
  116. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
  117. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
  118. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
  119. package/templates/app/skeleton-lite/README.md +11 -11
  120. package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
  121. package/templates/app/tool-config-lite/prettierignore +0 -1
  122. package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
  123. package/templates/be/inbox/database-client.ts.tpl +13 -4
  124. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
  125. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
  126. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
  127. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
  128. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
  129. package/templates/be/inbox/platform/migration.sql.tpl +22 -0
  130. package/templates/be/inbox/service.spec.ts.tpl +2 -2
  131. package/templates/be/inbox/service.ts.tpl +1 -2
  132. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
  133. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
  134. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
  135. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
  136. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
  137. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
  138. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
  139. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
  140. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
  141. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
  142. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
  143. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
  144. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
  145. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  146. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
  147. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
  148. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
  149. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
  150. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
  151. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
  152. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
  153. package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
  154. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
  155. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
  156. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
  157. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
  158. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
  159. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
  160. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
  161. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
  162. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
  163. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
  164. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
  165. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
  166. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
  167. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
  168. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
  169. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
  170. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
  171. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
  172. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
  173. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
  174. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
  175. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
  176. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
  177. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
  178. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
  179. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
  180. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
  181. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
  182. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
  183. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
  184. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
  185. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
  186. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
  187. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
  188. package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
  189. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
  190. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  191. package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
  192. package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
  193. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
  194. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  195. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
  196. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
  197. package/templates/be/table/entities.index.ts.tpl +4 -0
  198. package/templates/be/table/entity.ts.tpl +21 -0
  199. package/templates/be/table/index.ts.tpl +1 -0
  200. package/templates/be/table/module.ts.tpl +1 -1
  201. package/templates/be/table/service.api.ts.tpl +10 -11
  202. package/templates/be/table/service.ts.tpl +5 -6
  203. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
  204. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
  205. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
  206. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
  207. package/db/local-migrate.mjs +0 -194
  208. package/db/reset.mjs +0 -125
  209. package/emit/db-types.mjs +0 -271
  210. package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
  211. package/runtime/scripts/api/supabase/database-push.mjs +0 -5
  212. package/runtime/scripts/api/supabase/database-query.mjs +0 -5
  213. package/runtime/scripts/api/supabase/lib.mjs +0 -55
  214. package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
  215. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
  216. package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
  217. package/scaffold/supabase-ports.mjs +0 -26
  218. package/templates/app/package-scripts/supabase.json +0 -7
  219. package/templates/app/skeleton-lite/supabase/config.toml +0 -48
  220. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
  221. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
  222. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
  223. package/templates/be/inbox/sql.ts.tpl +0 -16
  224. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
  225. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
  226. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
  227. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
  228. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
  229. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
  230. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
  231. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
  232. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
  233. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
  234. package/templates/be/table/rows.ts.tpl +0 -4
  235. package/templates/be/table/sql.api.ts.tpl +0 -7
  236. package/templates/be/table/sql.ts.tpl +0 -4
  237. package/templates/fe/auth/supabase-client.ts.tpl +0 -38
  238. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
  239. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
  240. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
  241. /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
@@ -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
+ })
@@ -0,0 +1,114 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { EntityManager } from "typeorm"
3
+ import { InjectClock } from "@modules/platform/clock"
4
+ import type { Clock } from "@modules/platform/clock"
5
+ import { JobsError, JobsErrorCode } from "./errors/jobs.error"
6
+ import { callDbFunction } from "@modules/platform/database"
7
+ import { isRunKey } from "./jobs.contracts"
8
+ import type {
9
+ AdvanceWrite,
10
+ ClaimedJob,
11
+ ClaimParams,
12
+ ClaimResult,
13
+ FailWrite,
14
+ GuardedWrite,
15
+ RunKey,
16
+ } from "./jobs.contracts"
17
+ import { InjectJobsManagers } from "./jobs.decorators"
18
+ import type { JobClaims } from "./jobs.port"
19
+
20
+ /** The record job_claim returns (a bigint arrives as text). */
21
+ interface ClaimedRecord {
22
+ /** The id of the job row. */
23
+ readonly id: string
24
+ /** The kind of the job. */
25
+ readonly kind: string
26
+ /** The fencing token after the claim. */
27
+ readonly fencing_token: string
28
+ /** The last recorded step, or null. */
29
+ readonly current_step: string | null
30
+ /** The payload, as the database returns the jsonb value. */
31
+ readonly payload: object
32
+ }
33
+
34
+ @Injectable()
35
+ /**
36
+ * The one owner of the job row, through the job_* functions of its migration (R334). A claim is one atomic upsert that bumps the fencing token in the store, so two workers can never
37
+ * hold the same token; every other write is `WHERE fencing_token = <expected>`, so a worker that lost its claim (a zombie) changes
38
+ * nothing and learns it from `JobFencedOut`.
39
+ */
40
+ export class JobClaimService implements JobClaims {
41
+ constructor(
42
+ @InjectJobsManagers() private readonly managers: ReadonlyArray<EntityManager>,
43
+ @InjectClock() private readonly clock: Clock,
44
+ ) {}
45
+
46
+ /** Claims the delivery; null when the job is done or another worker holds a claim that has not expired. */
47
+ async claim(params: ClaimParams): Promise<ClaimResult> {
48
+ const now = this.clock.now()
49
+ const rows = await callDbFunction<ClaimedRecord>(this.writer(), "job_claim", [
50
+ params.kind,
51
+ params.jobKey,
52
+ params.workerId,
53
+ new Date(now.getTime() + params.leaseMs),
54
+ JSON.stringify(params.payload),
55
+ now,
56
+ ])
57
+ const row = rows[0]
58
+ if (row === undefined) return null
59
+ // The durable delivery supplies the same request id on every retry; the job fence identifies a claim separately.
60
+ return {
61
+ jobId: row.id,
62
+ correlationId: params.correlationId,
63
+ kind: row.kind,
64
+ fencingToken: Number(row.fencing_token),
65
+ currentStep: row.current_step,
66
+ payload: row.payload,
67
+ }
68
+ }
69
+
70
+ /** Records a finished step under the token the caller holds. */
71
+ advance(write: AdvanceWrite): Promise<void> {
72
+ return this.guarded("job_advance", [write.jobId, write.expectedFencingToken, write.step, this.clock.now()], write)
73
+ }
74
+
75
+ /** Completes the job under the token the caller holds. */
76
+ complete(write: GuardedWrite): Promise<void> {
77
+ return this.guarded("job_complete", [write.jobId, write.expectedFencingToken, this.clock.now()], write)
78
+ }
79
+
80
+ /** Marks the job failed under the token the caller holds, so BullMQ can retry it. */
81
+ fail(write: FailWrite): Promise<void> {
82
+ return this.guarded("job_fail", [write.jobId, write.expectedFencingToken, write.reason, this.clock.now()], write)
83
+ }
84
+
85
+ /** The idempotency key of one step of this claim: it includes the token, so a zombie's repeat is a different key than the live worker's. */
86
+ runKey(job: ClaimedJob, step: string): RunKey {
87
+ const key = `${job.jobId}:${step}:${job.fencingToken}`
88
+ if (!isRunKey(key))
89
+ throw new JobsError({ code: JobsErrorCode.RunKeyInvalid, params: { jobId: job.jobId, step } })
90
+ return key
91
+ }
92
+
93
+ /** The entity manager of the connection that holds the job table. */
94
+ private writer(): EntityManager {
95
+ const [manager] = this.managers
96
+ if (manager === undefined) throw new JobsError({ code: JobsErrorCode.ConnectionMissing })
97
+ return manager
98
+ }
99
+
100
+ /** Runs a guarded function that updates only when the token still matches; no row back means a newer worker owns the job. */
101
+ private async guarded(
102
+ fn: "job_advance" | "job_complete" | "job_fail",
103
+ parameters: Array<unknown>,
104
+ write: GuardedWrite,
105
+ ): Promise<void> {
106
+ const rows = await callDbFunction<object>(this.writer(), fn, parameters)
107
+ if (rows.length === 0) {
108
+ throw new JobsError({
109
+ code: JobsErrorCode.FencedOut,
110
+ params: { jobId: write.jobId, token: write.expectedFencingToken },
111
+ })
112
+ }
113
+ }
114
+ }
@@ -42,3 +42,54 @@ with check (kind is not null);
42
42
 
43
43
  revoke all on table public.jobs from public, anon, authenticated;
44
44
  grant select, insert, update on table public.jobs to app_be;
45
+
46
+ -- The claim and the guarded writes, called through callDbFunction of platform/database (R334). A claim inserts a new key with
47
+ -- token 1 or claims a failed or expired one again, bumping the token; a done job or a live claim returns no row. Every other
48
+ -- write matches only the token the caller holds, so a worker that lost its claim changes nothing and gets no row back.
49
+ create function public.job_claim(p_kind varchar, p_job_key varchar, p_worker varchar, p_lease_end timestamptz, p_payload jsonb, p_now timestamptz)
50
+ returns table (id uuid, kind varchar, fencing_token bigint, current_step varchar, payload jsonb)
51
+ language sql
52
+ as $$
53
+ insert into public.jobs as j (kind, job_key, status, fencing_token, claimed_by, lease_expires_at, payload, created_at, updated_at)
54
+ values (p_kind, p_job_key, 'running', 1, p_worker, p_lease_end, p_payload, p_now, p_now)
55
+ on conflict (job_key) do update
56
+ set fencing_token = j.fencing_token + 1, claimed_by = p_worker, lease_expires_at = p_lease_end, status = 'running', error = null, updated_at = p_now
57
+ where j.status = 'failed' or (j.status = 'running' and j.lease_expires_at < p_now)
58
+ returning j.id, j.kind, j.fencing_token, j.current_step, j.payload
59
+ $$;
60
+
61
+ create function public.job_advance(p_id uuid, p_token bigint, p_step varchar, p_now timestamptz)
62
+ returns table (id uuid)
63
+ language sql
64
+ as $$
65
+ update public.jobs as j set current_step = p_step, updated_at = p_now
66
+ where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
67
+ returning j.id
68
+ $$;
69
+
70
+ create function public.job_complete(p_id uuid, p_token bigint, p_now timestamptz)
71
+ returns table (id uuid)
72
+ language sql
73
+ as $$
74
+ update public.jobs as j set status = 'done', lease_expires_at = null, updated_at = p_now
75
+ where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
76
+ returning j.id
77
+ $$;
78
+
79
+ create function public.job_fail(p_id uuid, p_token bigint, p_reason text, p_now timestamptz)
80
+ returns table (id uuid)
81
+ language sql
82
+ as $$
83
+ update public.jobs as j set status = 'failed', error = p_reason, lease_expires_at = null, updated_at = p_now
84
+ where j.id = p_id and j.fencing_token = p_token and j.status = 'running'
85
+ returning j.id
86
+ $$;
87
+
88
+ revoke all on function public.job_claim(varchar, varchar, varchar, timestamptz, jsonb, timestamptz) from public;
89
+ grant execute on function public.job_claim(varchar, varchar, varchar, timestamptz, jsonb, timestamptz) to app_be;
90
+ revoke all on function public.job_advance(uuid, bigint, varchar, timestamptz) from public;
91
+ grant execute on function public.job_advance(uuid, bigint, varchar, timestamptz) to app_be;
92
+ revoke all on function public.job_complete(uuid, bigint, timestamptz) from public;
93
+ grant execute on function public.job_complete(uuid, bigint, timestamptz) to app_be;
94
+ revoke all on function public.job_fail(uuid, bigint, text, timestamptz) from public;
95
+ grant execute on function public.job_fail(uuid, bigint, text, timestamptz) to app_be;
@@ -0,0 +1,62 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
3
+ import { CLOCK } from "@modules/platform/clock"
4
+ import { QueueOutboxService } from "./queue-outbox.service"
5
+
6
+ // The statement callDbFunction of platform/database sends for queue_write.
7
+ const INSERT_QUEUE_ROW = "SELECT * FROM queue_write($1, $2, $3, $4)"
8
+ import { QueueErrorCode } from "./errors/queue.error"
9
+
10
+ const AT = "2026-02-03T04:05:06.000Z"
11
+
12
+ const build = async () => {
13
+ const moduleRef = await Test.createTestingModule({
14
+ providers: [QueueOutboxService, { provide: CLOCK, useValue: new FakeClock(AT) }],
15
+ }).compile()
16
+ return { outbox: moduleRef.get(QueueOutboxService) }
17
+ }
18
+
19
+ describe("QueueOutboxService", () => {
20
+ describe("write", () => {
21
+ it("writes the job as a row of the outbox of the transaction it is given", async () => {
22
+ const { outbox } = await build()
23
+ const tx = fakeTransaction(mockEntityManager({ query: [INSERT_QUEUE_ROW, []] }))
24
+
25
+ await tx.em.transaction((manager) => outbox.write(manager, "mail", { id: "m-1" }, "request-1"))
26
+
27
+ expect(tx.em.query).toHaveBeenCalledWith(INSERT_QUEUE_ROW, [
28
+ "mail",
29
+ JSON.stringify({ id: "m-1" }),
30
+ new Date(AT),
31
+ "request-1",
32
+ ])
33
+ expect(tx.commits).toBe(1)
34
+ })
35
+
36
+ it("leaves no row behind when the transaction rolls back", async () => {
37
+ const { outbox } = await build()
38
+ const tx = fakeTransaction(mockEntityManager({ query: [INSERT_QUEUE_ROW, []] }))
39
+ const failure = new Error("the change failed")
40
+
41
+ await expect(
42
+ tx.em.transaction(async (manager) => {
43
+ await outbox.write(manager, "mail", { id: "m-1" }, "request-1")
44
+ return Promise.reject(failure)
45
+ }),
46
+ ).rejects.toBe(failure)
47
+
48
+ expect(tx.rollbacks).toBe(1)
49
+ expect(tx.committedWrites).toEqual([])
50
+ })
51
+
52
+ it("rejects an unsafe correlation id before writing any SQL", async () => {
53
+ const { outbox } = await build()
54
+ const tx = fakeTransaction(mockEntityManager())
55
+ await expect(
56
+ tx.em.transaction((manager) => outbox.write(manager, "mail", {}, "bad\nid")),
57
+ ).rejects.toMatchObject({ code: QueueErrorCode.CorrelationInvalid })
58
+ expect(tx.em.query).not.toHaveBeenCalled()
59
+ expect(tx.rollbacks).toBe(1)
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,20 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { EntityManager } from "typeorm"
3
+ import { InjectClock } from "@modules/platform/clock"
4
+ import type { Clock } from "@modules/platform/clock"
5
+ import { isCorrelationId } from "@modules/platform/logging"
6
+ import type { QueueOutbox } from "./queue.port"
7
+ import { QueueError, QueueErrorCode } from "./errors/queue.error"
8
+ import { callDbFunction } from "@modules/platform/database"
9
+
10
+ @Injectable()
11
+ /** The writing side of the queues: a job is a row of the outbox of the caller's transaction, so it exists exactly when the change that needs it commits. */
12
+ export class QueueOutboxService implements QueueOutbox {
13
+ constructor(@InjectClock() private readonly clock: Clock) {}
14
+
15
+ /** Writes the job in the outbox of the transaction `tx`; the relay adds it to BullMQ after the commit. */
16
+ async write(tx: EntityManager, queue: string, payload: object, correlationId: string): Promise<void> {
17
+ if (!isCorrelationId(correlationId)) throw new QueueError({ code: QueueErrorCode.CorrelationInvalid })
18
+ await callDbFunction(tx, "queue_write", [queue, JSON.stringify(payload), this.clock.now(), correlationId])
19
+ }
20
+ }
@@ -30,3 +30,38 @@ with check (queue is not null);
30
30
 
31
31
  revoke all on table public.queue from public, anon, authenticated;
32
32
  grant select, insert, update on table public.queue to app_be;
33
+
34
+ -- The outbox write, the relay's locked read of the waiting rows and its mark, called through callDbFunction of platform/database
35
+ -- (R334). The read locks the rows it returns for the caller's transaction and skips rows another relay holds.
36
+ create function public.queue_write(p_queue varchar, p_payload jsonb, p_created_at timestamptz, p_correlation_id varchar)
37
+ returns void
38
+ language sql
39
+ as $$
40
+ insert into public.queue (queue, payload, created_at, correlation_id) values (p_queue, p_payload, p_created_at, p_correlation_id)
41
+ $$;
42
+
43
+ create function public.queue_waiting(p_batch integer)
44
+ returns table (id uuid, queue varchar, payload jsonb, "correlationId" varchar)
45
+ language sql
46
+ as $$
47
+ select q.id, q.queue, q.payload, coalesce(q.correlation_id, q.id::text)
48
+ from public.queue as q
49
+ where q.sent_at is null
50
+ order by q.created_at, q.id
51
+ limit p_batch
52
+ for update skip locked
53
+ $$;
54
+
55
+ create function public.queue_mark_sent(p_ids uuid[], p_sent_at timestamptz)
56
+ returns void
57
+ language sql
58
+ as $$
59
+ update public.queue set sent_at = p_sent_at where id = any(p_ids)
60
+ $$;
61
+
62
+ revoke all on function public.queue_write(varchar, jsonb, timestamptz, varchar) from public;
63
+ grant execute on function public.queue_write(varchar, jsonb, timestamptz, varchar) to app_be;
64
+ revoke all on function public.queue_waiting(integer) from public;
65
+ grant execute on function public.queue_waiting(integer) to app_be;
66
+ revoke all on function public.queue_mark_sent(uuid[], timestamptz) from public;
67
+ grant execute on function public.queue_mark_sent(uuid[], timestamptz) to app_be;
@@ -9,7 +9,10 @@ import { OutboxLogEvent } from "@modules/platform/outbox"
9
9
  import type { QueueOptions } from "./queue.options"
10
10
  import { QueueRelayService } from "./queue-relay.service"
11
11
  import type { QueueTransport } from "./queue-transport.port"
12
- import { MARK_ROWS_SENT, SELECT_WAITING_ROWS } from "./persistence/queue.sql"
12
+
13
+ // The statements callDbFunction of platform/database sends for the relay's functions.
14
+ const SELECT_WAITING_ROWS = "SELECT * FROM queue_waiting($1)"
15
+ const MARK_ROWS_SENT = "SELECT * FROM queue_mark_sent($1, $2)"
13
16
 
14
17
  const AT = "2026-02-03T04:05:06.000Z"
15
18
 
@@ -9,8 +9,19 @@ import { OutboxRelayPolicy } from "@modules/platform/outbox"
9
9
  import { InjectQueueOptions, InjectQueueRelayManagers, InjectQueueTransport } from "./queue.decorators"
10
10
  import type { QueueOptions } from "./queue.options"
11
11
  import type { QueueTransport } from "./queue-transport.port"
12
- import { MARK_ROWS_SENT, SELECT_WAITING_ROWS } from "./persistence/queue.sql"
13
- import type { QueueRow } from "./persistence/queue.rows"
12
+ import { callDbFunction } from "@modules/platform/database"
13
+
14
+ /** One waiting row queue_waiting returns. */
15
+ interface QueueRow {
16
+ /** The correlation id recorded by the producer's transaction. */
17
+ readonly correlationId: string
18
+ /** The row id: it is the BullMQ job id, so a repeated relay pass adds nothing. */
19
+ readonly id: string
20
+ /** The queue the job goes to. */
21
+ readonly queue: string
22
+ /** The payload, as the database returns the jsonb value. */
23
+ readonly payload: object
24
+ }
14
25
 
15
26
  @Injectable()
16
27
  /**
@@ -32,8 +43,8 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
32
43
  super()
33
44
  }
34
45
 
35
- protected select(tx: EntityManager): Promise<Array<QueueRow>> {
36
- return tx.query(SELECT_WAITING_ROWS, [this.options.relayBatch])
46
+ protected async select(tx: EntityManager): Promise<Array<QueueRow>> {
47
+ return [...(await callDbFunction<QueueRow>(tx, "queue_waiting", [this.options.relayBatch]))]
37
48
  }
38
49
 
39
50
  protected async deliver(rows: ReadonlyArray<QueueRow>): Promise<void> {
@@ -42,7 +53,7 @@ export class QueueRelayService extends OutboxRelayPolicy<QueueRow> {
42
53
  }
43
54
 
44
55
  protected async mark(tx: EntityManager, rows: ReadonlyArray<QueueRow>): Promise<void> {
45
- await tx.query(MARK_ROWS_SENT, [rows.map((row) => row.id), this.clock.now()])
56
+ await callDbFunction(tx, "queue_mark_sent", [rows.map((row) => row.id), this.clock.now()])
46
57
  }
47
58
 
48
59
  protected idle(): Promise<void> {