@starci/hfs 4.7.31 → 4.7.33

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 (473) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +13 -6
  3. package/emit/contracts.mjs +4 -3
  4. package/emit/form-bounds-reader.ts.partial +58 -0
  5. package/emit/form-bounds.mjs +155 -0
  6. package/emit/graphql-input-bounds.mjs +97 -0
  7. package/emit/input-bounds.mjs +44 -0
  8. package/emit/operation-schemas.mjs +137 -0
  9. package/emit/operations-worker.mjs +2 -2
  10. package/emit/operations.mjs +52 -16
  11. package/emit/schema-worker.mjs +17 -4
  12. package/emit/type-schema.mjs +17 -5
  13. package/package.json +9 -2
  14. package/release/gate.mjs +18 -23
  15. package/release/index.mjs +1 -1
  16. package/release/push.mjs +4 -5
  17. package/runtime/engine/admission.mjs +331 -0
  18. package/runtime/engine/canonical-json.mjs +14 -0
  19. package/runtime/engine/db/blob.mjs +380 -0
  20. package/runtime/engine/db/event-compact.mjs +73 -0
  21. package/runtime/engine/db/ledger-open.mjs +38 -0
  22. package/runtime/engine/db/ledger-paths.mjs +87 -0
  23. package/runtime/engine/db/ledger.mjs +1068 -0
  24. package/runtime/engine/db/machine-connection.mjs +150 -0
  25. package/runtime/engine/db/machine-open.mjs +60 -0
  26. package/runtime/engine/db/machine-schema.mjs +105 -0
  27. package/runtime/engine/db/machine.mjs +1317 -0
  28. package/runtime/engine/db/provider-reservations.mjs +127 -0
  29. package/runtime/engine/db/provision-authority.mjs +76 -0
  30. package/runtime/engine/db/provision-lock.mjs +29 -0
  31. package/runtime/engine/db/provision-operations.mjs +146 -0
  32. package/runtime/engine/db/ref-value.mjs +67 -0
  33. package/runtime/engine/db/schema/machine.sql +920 -0
  34. package/runtime/engine/db/schema/runtime.sql +1113 -0
  35. package/runtime/engine/refuse.mjs +11 -0
  36. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  37. package/runtime/knowledge/hfs/design-truths.yaml +149 -0
  38. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  39. package/runtime/knowledge/hfs/rules.yaml +269 -48
  40. package/runtime/knowledge/hfs/slots.yaml +113 -53
  41. package/runtime/knowledge/patterns/be/api.yaml +46 -20
  42. package/runtime/knowledge/patterns/be/event-bus.yaml +19 -12
  43. package/runtime/knowledge/patterns/be/idempotency.yaml +265 -0
  44. package/runtime/knowledge/patterns/be/jobs.yaml +17 -12
  45. package/runtime/knowledge/patterns/be/projections.yaml +2 -2
  46. package/runtime/knowledge/patterns/be/queues.yaml +57 -22
  47. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  48. package/runtime/knowledge/patterns/be/realtime.yaml +326 -27
  49. package/runtime/knowledge/patterns/be/saga.yaml +2 -2
  50. package/runtime/knowledge/sonar-gate.yaml +1 -1
  51. package/runtime/modules/kernel/command-policy.yaml +249 -0
  52. package/runtime/modules/kernel/failure-codes.yaml +211 -52
  53. package/runtime/modules/kernel/hook-input.yaml +6 -0
  54. package/runtime/modules/kernel/protected-zone.yaml +81 -0
  55. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  56. package/runtime/modules/kernel/roles.yaml +405 -0
  57. package/runtime/modules/models/registry.yaml +3 -1
  58. package/runtime/modules/models/runtimes.yaml +2 -0
  59. package/runtime/modules/schemas/application-stacks.schema.yaml +10 -2
  60. package/runtime/packages/cli/src/catalog.generated.mjs +46 -59
  61. package/runtime/packages/grammar/scripts/build-stamp.mjs +98 -0
  62. package/runtime/scripts/agent/codex-app-server.mjs +79 -0
  63. package/runtime/scripts/agent/codex-mirror-source.mjs +34 -0
  64. package/runtime/scripts/agent/host-agents.mjs +80 -0
  65. package/runtime/scripts/agent/launch-trust-policy.mjs +53 -0
  66. package/runtime/scripts/agent/trust-launch.mjs +191 -0
  67. package/runtime/scripts/agent/trust.mjs +523 -0
  68. package/runtime/scripts/api/fs/rename-over.mjs +21 -0
  69. package/runtime/scripts/api/fs/temp-path.mjs +8 -0
  70. package/runtime/scripts/api/fs/write-json-file.mjs +10 -0
  71. package/runtime/scripts/api/git/diff-names.mjs +8 -0
  72. package/runtime/scripts/api/git/head-time.mjs +10 -0
  73. package/runtime/scripts/api/git/index-lock.mjs +78 -0
  74. package/runtime/scripts/api/git/is-ancestor.mjs +5 -0
  75. package/runtime/scripts/api/git/log.mjs +5 -0
  76. package/runtime/scripts/api/git/merge-base-query.mjs +5 -0
  77. package/runtime/scripts/api/git/merge-tree.mjs +5 -0
  78. package/runtime/scripts/api/git/rev-list.mjs +5 -0
  79. package/runtime/scripts/api/git/rev-parse.mjs +13 -0
  80. package/runtime/scripts/api/git/status-query.mjs +5 -0
  81. package/runtime/scripts/api/git/worktree-list-query.mjs +5 -0
  82. package/runtime/scripts/api/node/spawn-node.mjs +6 -0
  83. package/runtime/scripts/api/npm/dist-tag.mjs +8 -0
  84. package/runtime/scripts/api/npm/lib.mjs +32 -0
  85. package/runtime/scripts/api/npm/pack-dry-run.mjs +9 -0
  86. package/runtime/scripts/api/npm/run-npm.mjs +6 -0
  87. package/runtime/scripts/api/npm/view.mjs +30 -0
  88. package/runtime/scripts/api/npm/whoami.mjs +8 -0
  89. package/runtime/scripts/api/process/hide-child-windows.mjs +31 -0
  90. package/runtime/scripts/api/process/kill-tree.mjs +22 -0
  91. package/runtime/scripts/api/process/lib.mjs +125 -0
  92. package/runtime/scripts/api/process/probe-guard-command.mjs +74 -0
  93. package/runtime/scripts/api/process/process-list.mjs +28 -0
  94. package/runtime/scripts/api/process/system-tool.mjs +37 -0
  95. package/runtime/scripts/gates/canon-digest.mjs +81 -0
  96. package/runtime/scripts/gates/canon-pins.mjs +16 -0
  97. package/runtime/scripts/gates/commit-proof.mjs +53 -0
  98. package/runtime/scripts/gates/gate-input.mjs +98 -0
  99. package/runtime/scripts/gates/gate.mjs +669 -0
  100. package/runtime/scripts/gates/grammar-dist.mjs +206 -0
  101. package/runtime/scripts/gates/runtime-package-selection.mjs +27 -0
  102. package/runtime/scripts/gates/test-world-run.mjs +254 -0
  103. package/runtime/scripts/gates/type-impact.mjs +277 -0
  104. package/runtime/scripts/gates/type-program.mjs +113 -0
  105. package/runtime/scripts/guards/assigned-command.mjs +12 -0
  106. package/runtime/scripts/guards/call-rights.mjs +47 -0
  107. package/runtime/scripts/guards/command-guard.mjs +468 -0
  108. package/runtime/scripts/guards/command-policy.mjs +342 -0
  109. package/runtime/scripts/guards/critic-reach.mjs +82 -0
  110. package/runtime/scripts/guards/deps-guard.mjs +150 -0
  111. package/runtime/scripts/guards/env-dump-verdict.mjs +89 -0
  112. package/runtime/scripts/guards/git-pathspec.mjs +225 -0
  113. package/runtime/scripts/guards/git-policy.mjs +355 -0
  114. package/runtime/scripts/guards/guards-root.mjs +27 -0
  115. package/runtime/scripts/guards/hook-install.mjs +343 -0
  116. package/runtime/scripts/guards/hook-io.mjs +76 -0
  117. package/runtime/scripts/guards/hook-verdict.mjs +12 -0
  118. package/runtime/scripts/guards/install-verdict.mjs +41 -0
  119. package/runtime/scripts/guards/kernel-seat.mjs +63 -0
  120. package/runtime/scripts/guards/launch-verdict.mjs +74 -0
  121. package/runtime/scripts/guards/op-starci.mjs +16 -0
  122. package/runtime/scripts/guards/op-write-scope.mjs +27 -0
  123. package/runtime/scripts/guards/orca-self-lifecycle.mjs +39 -0
  124. package/runtime/scripts/guards/process-kill-verdict.mjs +106 -0
  125. package/runtime/scripts/guards/protected-zone.mjs +56 -0
  126. package/runtime/scripts/guards/refusals.mjs +27 -0
  127. package/runtime/scripts/guards/release-definition.mjs +185 -0
  128. package/runtime/scripts/guards/release-push-gate.mjs +72 -0
  129. package/runtime/scripts/guards/release-record.mjs +58 -0
  130. package/runtime/scripts/guards/release-suite-mode.mjs +15 -0
  131. package/runtime/scripts/guards/rights.mjs +268 -0
  132. package/runtime/scripts/guards/seat-tools.mjs +47 -0
  133. package/runtime/scripts/guards/shell-commands.mjs +328 -0
  134. package/runtime/scripts/guards/supervisor-seat.mjs +80 -0
  135. package/runtime/scripts/guards/verdict-process.mjs +81 -0
  136. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  137. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  138. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  139. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  140. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  141. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  142. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  143. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  144. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  145. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
  146. package/runtime/scripts/hfs/check.mjs +6 -4
  147. package/runtime/scripts/hfs/coverage-scope.mjs +28 -1
  148. package/runtime/scripts/hfs/db-restamp-verb.mjs +54 -14
  149. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  150. package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
  151. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  152. package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
  153. package/runtime/scripts/hfs/rules/a15-findings.mjs +4 -1
  154. package/runtime/scripts/hfs/rules/bypass-markers.mjs +22 -7
  155. package/runtime/scripts/hfs/rules/database-migrations.mjs +69 -14
  156. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +9 -7
  157. package/runtime/scripts/hfs/rules/database-queues.mjs +68 -18
  158. package/runtime/scripts/hfs/rules/edition.mjs +56 -12
  159. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +1 -10
  160. package/runtime/scripts/hfs/rules/fe-stack.mjs +12 -7
  161. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +1 -1
  162. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +75 -9
  163. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +1 -1
  164. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +15 -4
  165. package/runtime/scripts/hfs/rules/text-integrity.mjs +73 -0
  166. package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +74 -0
  167. package/runtime/scripts/hfs/runtime-rules/release-notes.mjs +44 -0
  168. package/runtime/scripts/hfs/sealed-members.mjs +3 -3
  169. package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
  170. package/runtime/scripts/hfs/slots.mjs +2 -1
  171. package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -5
  172. package/runtime/scripts/hfs/stacks-gate.mjs +28 -7
  173. package/runtime/scripts/hfs/view.mjs +2 -1
  174. package/runtime/scripts/lib/app-data.mjs +11 -0
  175. package/runtime/scripts/lib/bypass-markers.mjs +272 -75
  176. package/runtime/scripts/lib/check-format.mjs +7 -0
  177. package/runtime/scripts/lib/cli-arg.mjs +120 -0
  178. package/runtime/scripts/lib/css-rules.mjs +220 -0
  179. package/runtime/scripts/lib/dom-tree.mjs +4 -0
  180. package/runtime/scripts/lib/escape.mjs +16 -0
  181. package/runtime/scripts/lib/event-items.mjs +22 -0
  182. package/runtime/scripts/lib/failure-code-template-errors.mjs +161 -0
  183. package/runtime/scripts/lib/git-dir.mjs +120 -0
  184. package/runtime/scripts/lib/grammar-vendor-parity.mjs +377 -0
  185. package/runtime/scripts/lib/guard-command.mjs +24 -0
  186. package/runtime/scripts/lib/hook-limits.mjs +5 -0
  187. package/runtime/scripts/lib/interrupt-cleanup.mjs +38 -0
  188. package/runtime/scripts/lib/js-lex.mjs +200 -0
  189. package/runtime/scripts/lib/json-formats.mjs +0 -1
  190. package/runtime/scripts/lib/language.mjs +94 -1
  191. package/runtime/scripts/lib/loosening-budgets.mjs +44 -0
  192. package/runtime/scripts/lib/loosening-config.mjs +361 -0
  193. package/runtime/scripts/lib/loosening-state.mjs +223 -0
  194. package/runtime/scripts/lib/menu-parts.mjs +11 -0
  195. package/runtime/scripts/lib/normalize.mjs +25 -0
  196. package/runtime/scripts/lib/op-gate.mjs +15 -0
  197. package/runtime/scripts/lib/redact.mjs +208 -0
  198. package/runtime/scripts/lib/runtime-location.mjs +21 -0
  199. package/runtime/scripts/lib/runtime-package-inventory.mjs +21 -0
  200. package/runtime/scripts/lib/runtime-package-manifests.mjs +84 -0
  201. package/runtime/scripts/lib/runtime-publication-tags.mjs +99 -0
  202. package/runtime/scripts/lib/seat-env.mjs +43 -0
  203. package/runtime/scripts/lib/secret-patterns.mjs +58 -9
  204. package/runtime/scripts/lib/shell-source.mjs +4 -0
  205. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  206. package/runtime/scripts/lib/sqlite.mjs +21 -0
  207. package/runtime/scripts/lib/swarm-stack.mjs +2 -1
  208. package/runtime/scripts/lib/tar-files.mjs +39 -0
  209. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  210. package/runtime/scripts/lib/worktree-exclude.mjs +35 -0
  211. package/runtime/scripts/lib/worktree-record.mjs +14 -0
  212. package/runtime/scripts/machine/artifact-hold.mjs +52 -0
  213. package/runtime/scripts/machine/ask-receipts.mjs +113 -0
  214. package/runtime/scripts/machine/evidence-store.mjs +223 -0
  215. package/runtime/scripts/machine/home.mjs +216 -0
  216. package/runtime/scripts/machine/host-lock.mjs +184 -0
  217. package/runtime/scripts/machine/lock-recovery.mjs +55 -0
  218. package/runtime/scripts/machine/roles-contract.mjs +90 -0
  219. package/runtime/scripts/machine/runtime-change.mjs +20 -0
  220. package/runtime/scripts/machine/workflow-tree.mjs +122 -0
  221. package/runtime/scripts/reconciler/checklist-items.mjs +7 -0
  222. package/runtime/scripts/reconciler/guard-row.mjs +25 -0
  223. package/runtime/scripts/work/asset-slot.mjs +284 -0
  224. package/runtime/scripts/work/brand/brand-colour.mjs +125 -0
  225. package/runtime/scripts/work/brand/brand-tokens.mjs +167 -0
  226. package/runtime/scripts/work/brand/brand-value-source.mjs +106 -0
  227. package/runtime/scripts/work/brand/brand.mjs +892 -0
  228. package/runtime/scripts/work/draw/draw-dna-anatomy.mjs +60 -0
  229. package/runtime/scripts/work/draw/draw-dna-findings.mjs +256 -0
  230. package/runtime/scripts/work/draw/draw-dna-selector.mjs +38 -0
  231. package/runtime/scripts/work/draw/draw-dna.mjs +483 -0
  232. package/runtime/scripts/work/draw/draw-layer.mjs +305 -0
  233. package/runtime/scripts/work/draw/draw-rationale-page.mjs +237 -0
  234. package/runtime/scripts/work/draw/draw-rationale-ref.mjs +14 -0
  235. package/runtime/scripts/work/draw/draw-rationale.mjs +353 -0
  236. package/runtime/scripts/work/draw/draw-source.mjs +412 -0
  237. package/runtime/scripts/work/draw/draw-taste.mjs +278 -0
  238. package/runtime/scripts/work/draw/draw-typecheck.mjs +72 -0
  239. package/runtime/scripts/work/draw/rationale-findings.mjs +205 -0
  240. package/runtime/scripts/work/draw-grammar.mjs +175 -0
  241. package/runtime/scripts/work/draw-render-page.mjs +188 -0
  242. package/runtime/scripts/work/draw-render.mjs +626 -0
  243. package/runtime/scripts/work/grammar-proposal.mjs +279 -0
  244. package/runtime/scripts/work/png.mjs +237 -0
  245. package/runtime/scripts/work/render-tools.mjs +64 -0
  246. package/runtime/scripts/work/work-io.mjs +119 -0
  247. package/scaffold/add-inbox-lite.mjs +2 -2
  248. package/scaffold/add-plan.mjs +40 -13
  249. package/scaffold/add-queue-lite.mjs +372 -0
  250. package/scaffold/add.mjs +5 -2
  251. package/scaffold/app.mjs +1 -2
  252. package/scaffold/edition-gate.mjs +11 -7
  253. package/scaffold/lite-exports.mjs +10 -0
  254. package/scaffold/pattern-file-slots.mjs +33 -0
  255. package/scaffold/test-world.mjs +1 -1
  256. package/scaffold/yaml-block-edit.mjs +117 -0
  257. package/src/main.mjs +5 -4
  258. package/stack/deploy.mjs +7 -3
  259. package/stack/post-deploy.mjs +48 -0
  260. package/sync/index.mjs +10 -0
  261. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +1 -1
  262. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +1 -1
  263. package/templates/app/codegen-graphql.mjs.partial +59 -12
  264. package/templates/app/hooks/husky/pre-commit +2 -2
  265. package/templates/app/hooks/husky/pre-push +10 -3
  266. package/templates/app/package-scripts/redis-dev.json +3 -0
  267. package/templates/app/package-scripts-lite/package.json +1 -0
  268. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +36 -8
  269. package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
  270. package/templates/app/skeleton/.starcistacks/production/README.md +10 -9
  271. package/templates/app/skeleton/.starcistacks/production/infra/swarm/postgres-init.sh +21 -0
  272. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +1 -1
  273. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +1 -0
  274. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +47 -5
  275. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +5 -3
  276. package/templates/app/skeleton/README.md +2 -0
  277. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -0
  278. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  279. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  280. package/templates/app/skeleton-lite/README.md +1 -1
  281. package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +18 -0
  282. package/templates/be/inbox/platform/event-bus/errors/event-bus.error.ts.tpl +16 -0
  283. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +10 -0
  284. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +1 -1
  285. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +15 -3
  286. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +11 -3
  287. package/templates/be/inbox/platform/event-bus/index.ts.tpl +2 -0
  288. package/templates/be/inbox/platform/event-bus/messages/event-bus.messages.ts.tpl +7 -0
  289. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +2 -2
  290. package/templates/be/inbox/platform/idempotency/errors/idempotency.error.ts.tpl +19 -0
  291. package/templates/be/inbox/platform/idempotency/index.ts.tpl +2 -0
  292. package/templates/be/inbox/platform/idempotency/messages/idempotency.messages.ts.tpl +13 -0
  293. package/templates/be/inbox/platform/inbox/client-operation.policy.spec.ts.tpl +32 -0
  294. package/templates/be/inbox/platform/inbox/client-operation.policy.ts.tpl +17 -0
  295. package/templates/be/inbox/platform/inbox/errors/inbox.error.ts.tpl +22 -0
  296. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +21 -0
  297. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +334 -11
  298. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +55 -4
  299. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -0
  300. package/templates/be/inbox/platform/inbox/messages/inbox.messages.ts.tpl +15 -0
  301. package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +33 -0
  302. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +26 -0
  303. package/templates/be/inbox/platform/inbox/request-fingerprint.policy.spec.ts.tpl +76 -0
  304. package/templates/be/inbox/platform/inbox/request-fingerprint.policy.ts.tpl +36 -0
  305. package/templates/be/inbox/platform/migration.sql.tpl +1 -0
  306. package/templates/be/inbox/service.spec.ts.tpl +4 -4
  307. package/templates/be/inbox/service.ts.tpl +3 -1
  308. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -2
  309. package/templates/be/inbox/webhook-door.ts.tpl +3 -2
  310. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +3 -0
  311. package/templates/be/patterns/event-bus/platform/event-bus.contracts.ts.tpl +10 -0
  312. package/templates/be/patterns/event-bus/platform/event-bus.port.ts.tpl +2 -2
  313. package/templates/be/patterns/event-bus/platform/event-bus.service.spec.ts.tpl +16 -3
  314. package/templates/be/patterns/event-bus/platform/event-bus.service.ts.tpl +11 -3
  315. package/templates/be/patterns/event-bus/platform/event-envelope.policy.spec.ts.tpl +139 -50
  316. package/templates/be/patterns/event-bus/platform/event-envelope.policy.ts.tpl +25 -2
  317. package/templates/be/patterns/event-bus/platform/event-relay.service.spec.ts.tpl +139 -78
  318. package/templates/be/patterns/event-bus/platform/event-relay.service.ts.tpl +10 -6
  319. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +262 -159
  320. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +32 -5
  321. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +2 -0
  322. package/templates/be/patterns/event-bus/platform/persistence/entities/event-outbox.entity.ts.tpl +5 -1
  323. package/templates/be/patterns/event-bus/platform/persistence/event-bus.rows.ts.tpl +2 -0
  324. package/templates/be/patterns/event-bus/platform/persistence/event-bus.sql.ts.tpl +4 -4
  325. package/templates/be/patterns/event-bus/platform/persistence/migrations/1789800009000-create-event-outbox.ts.tpl +1 -0
  326. package/templates/be/patterns/idempotency/platform/idempotency/errors/idempotency.error.ts.tpl +19 -0
  327. package/templates/be/patterns/idempotency/platform/idempotency/index.ts.tpl +2 -0
  328. package/templates/be/patterns/idempotency/platform/idempotency/messages/idempotency.messages.ts.tpl +13 -0
  329. package/templates/be/patterns/idempotency/platform/inbox/client-operation.policy.spec.ts.tpl +32 -0
  330. package/templates/be/patterns/idempotency/platform/inbox/client-operation.policy.ts.tpl +17 -0
  331. package/templates/be/patterns/idempotency/platform/inbox/client-outcome-backfill.client.spec.ts.tpl +66 -0
  332. package/templates/be/patterns/idempotency/platform/inbox/client-outcome-backfill.client.ts.tpl +24 -0
  333. package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-backfill.client.spec.ts.tpl +60 -0
  334. package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-backfill.client.ts.tpl +12 -0
  335. package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-release.client.spec.ts.tpl +49 -0
  336. package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-release.client.ts.tpl +12 -0
  337. package/templates/be/patterns/idempotency/platform/inbox/effectful.decorators.ts.tpl +7 -0
  338. package/templates/be/patterns/idempotency/platform/inbox/errors/inbox.error.ts.tpl +22 -0
  339. package/templates/be/patterns/idempotency/platform/inbox/inbox.decorators.ts.tpl +17 -0
  340. package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +7 -0
  341. package/templates/be/patterns/idempotency/platform/inbox/inbox.module.ts.tpl +28 -0
  342. package/templates/be/patterns/idempotency/platform/inbox/inbox.options.ts.tpl +7 -0
  343. package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +44 -0
  344. package/templates/be/patterns/idempotency/platform/inbox/inbox.service.spec.ts.tpl +388 -0
  345. package/templates/be/patterns/idempotency/platform/inbox/inbox.service.ts.tpl +77 -0
  346. package/templates/be/patterns/idempotency/platform/inbox/index.ts.tpl +10 -0
  347. package/templates/be/patterns/idempotency/platform/inbox/messages/inbox.messages.ts.tpl +15 -0
  348. package/templates/be/patterns/idempotency/platform/inbox/persistence/connection.ts.tpl +9 -0
  349. package/templates/be/patterns/idempotency/platform/inbox/persistence/entities/inbox-claim.entity.ts.tpl +25 -0
  350. package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.rows.ts.tpl +33 -0
  351. package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.sql.ts.tpl +39 -0
  352. package/templates/be/patterns/idempotency/platform/inbox/persistence/migrations/1789800005000-create-inbox-claims.ts.tpl +21 -0
  353. package/templates/be/patterns/idempotency/platform/inbox/persistence/migrations/1792800000000-client-operation-outcomes.ts.tpl +21 -0
  354. package/templates/be/patterns/idempotency/platform/inbox/request-fingerprint.policy.spec.ts.tpl +76 -0
  355. package/templates/be/patterns/idempotency/platform/inbox/request-fingerprint.policy.ts.tpl +36 -0
  356. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +1 -0
  357. package/templates/be/patterns/jobs/platform/job-claim.service.spec.ts.tpl +4 -3
  358. package/templates/be/patterns/jobs/platform/job-claim.service.ts.tpl +2 -0
  359. package/templates/be/patterns/jobs/platform/job-runner.service.spec.ts.tpl +20 -2
  360. package/templates/be/patterns/jobs/platform/job-runner.service.ts.tpl +1 -0
  361. package/templates/be/patterns/jobs/platform/jobs.contracts.ts.tpl +4 -0
  362. package/templates/be/patterns/jobs/platform-lite/jobs.index.ts.tpl +10 -0
  363. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
  364. package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +33 -0
  365. package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +203 -18
  366. package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.ts.tpl +78 -22
  367. package/templates/be/patterns/queues/platform/errors/queue.error.ts.tpl +19 -0
  368. package/templates/be/patterns/queues/platform/messages/queue.messages.ts.tpl +13 -0
  369. package/templates/be/patterns/queues/platform/persistence.migration.ts.tpl +1 -0
  370. package/templates/be/patterns/queues/platform/persistence.queue-outbox.entity.ts.tpl +4 -0
  371. package/templates/be/patterns/queues/platform/persistence.queue.rows.ts.tpl +2 -0
  372. package/templates/be/patterns/queues/platform/persistence.queue.sql.ts.tpl +4 -4
  373. package/templates/be/patterns/queues/platform/queue-outbox.service.spec.ts.tpl +14 -2
  374. package/templates/be/patterns/queues/platform/queue-outbox.service.ts.tpl +5 -2
  375. package/templates/be/patterns/queues/platform/queue-relay.service.spec.ts.tpl +72 -74
  376. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +1 -1
  377. package/templates/be/patterns/queues/platform/queue-transport.port.ts.tpl +23 -8
  378. package/templates/be/patterns/queues/platform/queue-worker.service.spec.ts.tpl +29 -8
  379. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +7 -3
  380. package/templates/be/patterns/queues/platform/queue.contracts.ts.tpl +5 -0
  381. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +2 -0
  382. package/templates/be/patterns/queues/platform/queue.log-events.ts.tpl +4 -0
  383. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +2 -1
  384. package/templates/be/patterns/queues/platform/queue.port.ts.tpl +1 -1
  385. package/templates/be/patterns/queues/platform-lite/bullmq-queue-transport.client.spec.ts.tpl +417 -0
  386. package/templates/be/patterns/queues/platform-lite/bullmq-queue-transport.client.ts.tpl +172 -0
  387. package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +12 -0
  388. package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +32 -0
  389. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +148 -0
  390. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +51 -0
  391. package/templates/be/patterns/queues/platform-lite/queue-transport.port.ts.tpl +163 -0
  392. package/templates/be/patterns/queues/platform-lite/queue.config.ts.tpl +17 -0
  393. package/templates/be/patterns/queues/platform-lite/queue.error.ts.tpl +25 -0
  394. package/templates/be/patterns/queues/platform-lite/queue.index.ts.tpl +8 -0
  395. package/templates/be/patterns/queues/platform-lite/queue.messages.ts.tpl +21 -0
  396. package/templates/be/patterns/queues/platform-lite/queue.module.ts.tpl +74 -0
  397. package/templates/be/patterns/queues/platform-lite/queue.options.ts.tpl +38 -0
  398. package/templates/be/patterns/queues/queue.queue.ts.tpl +2 -2
  399. package/templates/be/patterns/reactors/application.contracts.ts.tpl +2 -0
  400. package/templates/be/patterns/reactors/consumer.ts.tpl +5 -1
  401. package/templates/be/platform/graphql/graphql.contracts.ts +2 -1
  402. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
  403. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +6 -1
  404. package/templates/be/skeleton/apps/__app__/src/main.ts +7 -4
  405. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +4 -0
  406. package/templates/be/skeleton/apps/cli/src/main.ts +7 -4
  407. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -1
  408. package/templates/be/skeleton/src/modules/platform/http/errors/http.error.ts +22 -0
  409. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +235 -0
  410. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +85 -0
  411. package/templates/be/skeleton/src/modules/platform/http/graphql-call.mapper.spec.ts +92 -0
  412. package/templates/be/skeleton/src/modules/platform/http/graphql-call.mapper.ts +52 -0
  413. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +9 -0
  414. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +7 -0
  415. package/templates/be/skeleton/src/modules/platform/http/http.module.ts +19 -0
  416. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +2 -0
  417. package/templates/be/skeleton/src/modules/platform/http/http.port.ts +40 -0
  418. package/templates/be/skeleton/src/modules/platform/http/index.ts +7 -0
  419. package/templates/be/skeleton/src/modules/platform/http/messages/http.messages.ts +15 -0
  420. package/templates/be/skeleton/src/modules/platform/ids/ids.decorators.ts +9 -0
  421. package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +7 -0
  422. package/templates/be/skeleton/src/modules/platform/ids/ids.module.ts +19 -0
  423. package/templates/be/skeleton/src/modules/platform/ids/ids.options.ts +2 -0
  424. package/templates/be/skeleton/src/modules/platform/ids/ids.port.ts +5 -0
  425. package/templates/be/skeleton/src/modules/platform/ids/index.ts +4 -0
  426. package/templates/be/skeleton/src/modules/platform/ids/system-ids.service.spec.ts +15 -0
  427. package/templates/be/skeleton/src/modules/platform/ids/system-ids.service.ts +10 -0
  428. package/templates/be/skeleton/src/modules/platform/logging/correlation-request.policy.spec.ts +59 -0
  429. package/templates/be/skeleton/src/modules/platform/logging/correlation-request.policy.ts +21 -0
  430. package/templates/be/skeleton/src/modules/platform/logging/correlation.contracts.ts +16 -0
  431. package/templates/be/skeleton/src/modules/platform/logging/correlation.policy.spec.ts +150 -0
  432. package/templates/be/skeleton/src/modules/platform/logging/correlation.policy.ts +42 -0
  433. package/templates/be/skeleton/src/modules/platform/logging/correlation.port.ts +15 -0
  434. package/templates/be/skeleton/src/modules/platform/logging/correlation.service.spec.ts +23 -0
  435. package/templates/be/skeleton/src/modules/platform/logging/correlation.service.ts +28 -0
  436. package/templates/be/skeleton/src/modules/platform/logging/index.ts +14 -1
  437. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +39 -6
  438. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +15 -3
  439. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +17 -2
  440. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +13 -1
  441. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +13 -3
  442. package/templates/be/skeleton/src/modules/platform/logging/nest-logger.service.spec.ts +69 -0
  443. package/templates/be/skeleton/src/modules/platform/logging/nest-logger.service.ts +48 -0
  444. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  445. package/templates/be/skeleton/src/modules/platform/primitives/record.policy.spec.ts +12 -0
  446. package/templates/be/skeleton/src/modules/platform/primitives/record.policy.ts +3 -0
  447. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +6 -1
  448. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +4 -0
  449. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  450. package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +14 -1
  451. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +12 -0
  452. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  453. package/templates/be/test-world/world/test-apps.options.ts.tpl +2 -0
  454. package/templates/be/test-world/world/test-world.config.ts.tpl +1 -0
  455. package/upgrade/check.mjs +12 -135
  456. package/upgrade/file-state.mjs +1 -16
  457. package/upgrade/index.mjs +34 -361
  458. package/runtime/scripts/lib/check-code-loosening.mjs +0 -128
  459. package/runtime/scripts/lib/gate-loosening.mjs +0 -292
  460. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +0 -89
  461. package/templates/app/upgrade-full/.starcistacks/dev/README.md +0 -16
  462. package/templates/app/upgrade-full/.starcistacks/dev/infra/compose/api.yaml +0 -13
  463. package/templates/app/upgrade-full/.starcistacks/dev/infra/compose/compose.yaml +0 -3
  464. package/templates/app/upgrade-full/.starcistacks/dev/infra/compose/postgres.yaml +0 -12
  465. package/templates/app/upgrade-full/.starcistacks/dev/infra/metadata.json +0 -7
  466. package/templates/be/skeleton-lite/apps/api/src/main.ts +0 -26
  467. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +0 -12
  468. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +0 -7
  469. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +0 -5
  470. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +0 -9
  471. package/templates/be/upgrade-full/apps/cli/src/main.ts +0 -29
  472. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +0 -7
  473. package/templates/be/upgrade-full/src/features/cli/migrate/subs/run.cli.ts +0 -23
@@ -0,0 +1,17 @@
1
+ import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
2
+ import type { ClientOperation } from "./inbox.port"
3
+
4
+ /** Builds the authenticated operation scope after checking that its separators and bounded key are unambiguous. */
5
+ export const clientSource = (request: ClientOperation): string => {
6
+ if (
7
+ !request.principalId.trim() ||
8
+ request.principalId.includes(":") ||
9
+ !request.operation.trim() ||
10
+ request.operation.includes(":") ||
11
+ !request.idempotencyKey.trim() ||
12
+ request.idempotencyKey.length > 64
13
+ ) {
14
+ throw new IdempotencyError({ code: IdempotencyErrorCode.RequestInvalid })
15
+ }
16
+ return `client:${request.principalId}:${request.operation}`
17
+ }
@@ -0,0 +1,22 @@
1
+ import { DomainError } from "@modules/platform/errors"
2
+ import type { ErrorKind } from "@modules/platform/errors"
3
+
4
+ /** Stable refusals and integrity errors of the shared client inbox. */
5
+ export enum InboxErrorCode {
6
+ /** The caller supplied a manager outside an active domain transaction. */
7
+ TransactionRequired = "INBOX_TRANSACTION_REQUIRED",
8
+ /** The effect returned a value that is not plain JSON data, so no outcome can be stored or replayed. */
9
+ OutcomeInvalid = "INBOX_OUTCOME_INVALID",
10
+ /** The atomic claim or outcome write returned no completed row. */
11
+ OutcomeMissing = "INBOX_OUTCOME_MISSING",
12
+ }
13
+
14
+ /** The shared transport mapping of inbox errors. */
15
+ export const INBOX_ERROR_KINDS: Record<InboxErrorCode, ErrorKind> = {
16
+ [InboxErrorCode.OutcomeInvalid]: "internal",
17
+ [InboxErrorCode.OutcomeMissing]: "internal",
18
+ [InboxErrorCode.TransactionRequired]: "internal",
19
+ }
20
+
21
+ /** The error family of the inbox owner. */
22
+ export class InboxError extends DomainError<InboxErrorCode> {}
@@ -1,5 +1,22 @@
1
1
  import type { EntityManager } from "typeorm"
2
2
 
3
+ /** The authenticated scope and complete normalized body of a client operation; the key is never part of the body. */
4
+ export interface ClientOperation {
5
+ /** The authenticated principal, mapped by the transport rather than supplied by its client. */
6
+ readonly principalId: string
7
+ /** A stable operation name; neither it nor the principal may contain a colon. */
8
+ readonly operation: string
9
+ /** The client's nonempty key, at most 64 characters. */
10
+ readonly idempotencyKey: string
11
+ /** JSON data that identifies every client-controlled input affecting the result. */
12
+ readonly request: unknown
13
+ }
14
+
15
+ /** The domain work whose JSON result commits with the client receipt. */
16
+ export interface ClientOperationEffect<T> {
17
+ (): Promise<T>
18
+ }
19
+
3
20
  /** The signed provider header may omit its delivery identity; the adapter refuses that delivery. */
4
21
  export type InboxEventId = string | undefined
5
22
 
@@ -7,4 +24,8 @@ export type InboxEventId = string | undefined
7
24
  export interface Inbox {
8
25
  /** Claims once per source and event id; a rolled-back transaction gives the claim back automatically. */
9
26
  claim(source: string, eventId: InboxEventId, tx: EntityManager): Promise<boolean>
27
+ /** Claims, applies and records one JSON outcome inside the caller transaction; retries replay it. */
28
+ runClient<T>(request: ClientOperation, tx: EntityManager, effect: ClientOperationEffect<T>): Promise<T>
29
+ /** Deletes one bounded batch of completed client claims older than the cutoff. */
30
+ sweepClients(cutoff: Date, tx: EntityManager): Promise<number>
10
31
  }
@@ -1,20 +1,44 @@
1
+ import { IdempotencyErrorCode } from "@modules/platform/idempotency"
1
2
  import { Test } from "@nestjs/testing"
2
- import { fakeTransaction, mockEntityManager } from "@starci/jest-preset"
3
- import { DatabaseErrorCode } from "@modules/platform/database"
3
+ import type { ClientOperation } from "./inbox.port"
4
+ import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
5
+ import { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
6
+ import { CLOCK } from "@modules/platform/clock"
7
+ import { InboxError, InboxErrorCode } from "./errors/inbox.error"
8
+ import { requestFingerprint } from "./request-fingerprint.policy"
4
9
  import { PostgresInbox } from "./inbox.service"
5
- import { CLAIM_EVENT } from "./persistence/inbox.sql"
10
+ import { CLAIM_CLIENT, CLAIM_EVENT, SAVE_CLIENT_OUTCOME, SWEEP_CLIENTS } from "./persistence/inbox.sql"
6
11
 
7
- describe("PostgresInbox", () => {
8
- const build = async () => {
9
- const module = await Test.createTestingModule({ providers: [PostgresInbox] }).compile()
10
- return module.get(PostgresInbox)
11
- }
12
+ const runClient = <T>(
13
+ inbox: PostgresInbox,
14
+ request: ClientOperation,
15
+ manager: ReturnType<typeof mockEntityManager>,
16
+ effect: () => Promise<T>,
17
+ ): Promise<T> => fakeTransaction(manager).em.transaction((tx) => inbox.runClient(request, tx, effect))
12
18
 
19
+ const REQUEST = {
20
+ principalId: "person-1",
21
+ operation: "checkout.place",
22
+ idempotencyKey: "key-1",
23
+ request: { total: 2400 },
24
+ }
25
+ const OUTCOME = { kind: "ok", value: { orderId: "order-1" } }
26
+
27
+ const build = async () => {
28
+ const module = await Test.createTestingModule({
29
+ providers: [PostgresInbox, { provide: CLOCK, useValue: new FakeClock("2026-02-03T04:05:06.000Z") }],
30
+ }).compile()
31
+ return module.get(PostgresInbox)
32
+ }
33
+
34
+ describe("PostgresInbox delivery claims", () => {
13
35
  it("claims a normalized delivery id through the caller's transaction", async () => {
14
36
  const subject = await build()
15
37
  const transaction = fakeTransaction(mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] }))
16
38
 
17
- const fresh = await transaction.em.transaction((manager) => subject.claim("webhook:spec", " delivery-1 ", manager))
39
+ const fresh = await transaction.em.transaction((manager) =>
40
+ subject.claim("webhook:spec", " delivery-1 ", manager),
41
+ )
18
42
 
19
43
  expect(fresh).toBe(true)
20
44
  expect(transaction.em.query).toHaveBeenCalledWith(CLAIM_EVENT, ["webhook:spec", "delivery-1"])
@@ -29,13 +53,312 @@ describe("PostgresInbox", () => {
29
53
 
30
54
  expect(manager.query).toHaveBeenCalledTimes(1)
31
55
  })
56
+ })
57
+
58
+ describe("PostgresInbox delivery rollback and validation", () => {
59
+ it("rolls the claim back when later work in the receipt transaction fails", async () => {
60
+ const subject = await build()
61
+ const transaction = fakeTransaction(mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] }))
62
+ const failure = new DatabaseError({ code: DatabaseErrorCode.IdentifierRejected })
63
+
64
+ await expect(
65
+ transaction.em.transaction(async (manager) => {
66
+ expect(await subject.claim("webhook:spec", "delivery-1", manager)).toBe(true)
67
+ throw failure
68
+ }),
69
+ ).rejects.toBe(failure)
70
+
71
+ expect(transaction.commits).toBe(0)
72
+ expect(transaction.rollbacks).toBe(1)
73
+ expect(transaction.committedWrites).toEqual([])
74
+ expect(transaction.rolledBackWrites).toHaveLength(1)
75
+ })
32
76
 
33
- it.each([["no", undefined], ["a blank", " "]])("refuses %s delivery id before writing a claim", async (_label, eventId) => {
77
+ it.each([
78
+ ["no", undefined],
79
+ ["a blank", " "],
80
+ ])("refuses %s delivery id before writing a claim", async (_label, eventId) => {
34
81
  const subject = await build()
35
82
  const manager = mockEntityManager()
36
83
 
37
- await expect(subject.claim("webhook:spec", eventId, manager)).rejects.toMatchObject({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
84
+ await expect(subject.claim("webhook:spec", eventId, manager)).rejects.toMatchObject({
85
+ code: DatabaseErrorCode.WebhookDeliveryIdRequired,
86
+ })
87
+
88
+ expect(manager.query).not.toHaveBeenCalled()
89
+ })
90
+ })
91
+
92
+ describe("PostgresInbox client transaction admission", () => {
93
+ it("refuses a manager outside an active transaction before claiming or applying an effect", async () => {
94
+ const inbox = await build()
95
+ const manager = mockEntityManager()
96
+ await expect(inbox.runClient(REQUEST, manager, () => Promise.resolve(OUTCOME))).rejects.toMatchObject({
97
+ code: InboxErrorCode.TransactionRequired,
98
+ })
99
+ expect(manager.query).not.toHaveBeenCalled()
100
+ })
101
+
102
+ it("refuses an existing incomplete claim instead of applying the effect again", async () => {
103
+ const manager = mockEntityManager({
104
+ query: [
105
+ CLAIM_CLIENT,
106
+ [{ request_fingerprint: requestFingerprint(REQUEST.request), outcome: {}, incomplete: true }],
107
+ ],
108
+ })
109
+ const inbox = await build()
110
+ let calls = 0
111
+ await expect(
112
+ runClient(inbox, REQUEST, manager, () => {
113
+ calls += 1
114
+ return Promise.resolve(OUTCOME)
115
+ }),
116
+ ).rejects.toMatchObject({ code: InboxErrorCode.OutcomeMissing })
117
+ expect(calls).toBe(0)
118
+ })
119
+ })
120
+
121
+ describe("PostgresInbox client outcome persistence", () => {
122
+ it("claims before its callback and returns the persisted outcome through the same transaction", async () => {
123
+ const manager = mockEntityManager()
124
+ const fingerprint = requestFingerprint(REQUEST.request)
125
+ manager.query
126
+ .mockResolvedValueOnce([{ request_fingerprint: fingerprint, outcome: null }])
127
+ .mockResolvedValueOnce([{ outcome: { value: OUTCOME } }])
128
+ const transaction = fakeTransaction(manager)
129
+ const inbox = await build()
130
+
131
+ const outcome = await transaction.em.transaction((tx) =>
132
+ inbox.runClient(REQUEST, tx, () => {
133
+ expect(transaction.em.query).toHaveBeenCalledTimes(1)
134
+ return Promise.resolve(OUTCOME)
135
+ }),
136
+ )
137
+
138
+ expect(outcome).toEqual(OUTCOME)
139
+ expect(transaction.em.query).toHaveBeenNthCalledWith(1, CLAIM_CLIENT, [
140
+ "client:person-1:checkout.place",
141
+ "key-1",
142
+ fingerprint,
143
+ new FakeClock("2026-02-03T04:05:06.000Z").now(),
144
+ ])
145
+ expect(transaction.em.query).toHaveBeenNthCalledWith(2, SAVE_CLIENT_OUTCOME, [
146
+ "client:person-1:checkout.place",
147
+ "key-1",
148
+ JSON.stringify({ value: OUTCOME }),
149
+ ])
150
+ expect(transaction.commits).toBe(1)
151
+ })
152
+ })
153
+
154
+ describe("PostgresInbox client replay and fingerprint identity", () => {
155
+ it.each([OUTCOME, { kind: "refused", code: "ORDER_EMPTY_CART" }, null])(
156
+ "replays the stored JSON outcome %p without running the callback",
157
+ async (outcome) => {
158
+ const manager = mockEntityManager({
159
+ query: [
160
+ CLAIM_CLIENT,
161
+ [
162
+ {
163
+ request_fingerprint: requestFingerprint(REQUEST.request),
164
+ outcome: { value: outcome },
165
+ },
166
+ ],
167
+ ],
168
+ })
169
+ const inbox = await build()
170
+ let calls = 0
171
+
172
+ await expect(
173
+ runClient(inbox, REQUEST, manager, () => {
174
+ calls += 1
175
+ return Promise.resolve(outcome)
176
+ }),
177
+ ).resolves.toEqual(outcome)
178
+
179
+ expect(calls).toBe(0)
180
+ expect(manager.query).toHaveBeenCalledTimes(1)
181
+ },
182
+ )
183
+
184
+ it.each(["a-different-fingerprint", null])(
185
+ "refuses a reused key carrying fingerprint %p before any callback",
186
+ async (fingerprint) => {
187
+ const manager = mockEntityManager({
188
+ query: [CLAIM_CLIENT, [{ request_fingerprint: fingerprint, outcome: { value: OUTCOME } }]],
189
+ })
190
+ const inbox = await build()
191
+ let calls = 0
192
+
193
+ await expect(
194
+ runClient(inbox, REQUEST, manager, () => {
195
+ calls += 1
196
+ return Promise.resolve(OUTCOME)
197
+ }),
198
+ ).rejects.toMatchObject({
199
+ code: IdempotencyErrorCode.KeyReused,
200
+ })
201
+
202
+ expect(calls).toBe(0)
203
+ expect(manager.query).toHaveBeenCalledTimes(1)
204
+ },
205
+ )
206
+ })
207
+
208
+ describe("PostgresInbox client request validation", () => {
209
+ it.each([
210
+ { ...REQUEST, principalId: " " },
211
+ { ...REQUEST, principalId: "a:b" },
212
+ { ...REQUEST, operation: " " },
213
+ { ...REQUEST, operation: "a:b" },
214
+ { ...REQUEST, idempotencyKey: " " },
215
+ { ...REQUEST, idempotencyKey: "a".repeat(65) },
216
+ { ...REQUEST, request: { unsupported: undefined } },
217
+ ])("refuses an invalid scope, key or request before querying %p", async (request) => {
218
+ const manager = mockEntityManager()
219
+ const inbox = await build()
220
+
221
+ await expect(runClient(inbox, request, manager, () => Promise.resolve(OUTCOME))).rejects.toMatchObject({
222
+ code: IdempotencyErrorCode.RequestInvalid,
223
+ })
38
224
 
39
225
  expect(manager.query).not.toHaveBeenCalled()
40
226
  })
41
227
  })
228
+
229
+ describe("PostgresInbox client effect rollback", () => {
230
+ it("keeps the exact key and rejects a failed callback by rolling the claim back with the domain transaction", async () => {
231
+ const manager = mockEntityManager({
232
+ query: [
233
+ CLAIM_CLIENT,
234
+ [
235
+ {
236
+ request_fingerprint: requestFingerprint(REQUEST.request),
237
+ outcome: null,
238
+ },
239
+ ],
240
+ ],
241
+ })
242
+ const transaction = fakeTransaction(manager)
243
+ const inbox = await build()
244
+ const failure = new InboxError({ code: InboxErrorCode.OutcomeMissing })
245
+
246
+ await expect(
247
+ transaction.em.transaction((tx) =>
248
+ inbox.runClient({ ...REQUEST, idempotencyKey: " key-1 " }, tx, () => Promise.reject(failure)),
249
+ ),
250
+ ).rejects.toBe(failure)
251
+
252
+ expect(transaction.em.query).toHaveBeenCalledWith(CLAIM_CLIENT, [
253
+ "client:person-1:checkout.place",
254
+ " key-1 ",
255
+ requestFingerprint(REQUEST.request),
256
+ new FakeClock("2026-02-03T04:05:06.000Z").now(),
257
+ ])
258
+ expect(transaction.rollbacks).toBe(1)
259
+ expect(transaction.committedWrites).toEqual([])
260
+ })
261
+ })
262
+
263
+ describe("PostgresInbox client claim and serialization failures", () => {
264
+ it("propagates a database claim failure without calling the callback", async () => {
265
+ const manager = mockEntityManager()
266
+ const failure = new Error("claim failed")
267
+ manager.query.mockRejectedValueOnce(failure)
268
+ const inbox = await build()
269
+ let calls = 0
270
+
271
+ await expect(
272
+ runClient(inbox, REQUEST, manager, () => {
273
+ calls += 1
274
+ return Promise.resolve(OUTCOME)
275
+ }),
276
+ ).rejects.toBe(failure)
277
+
278
+ expect(calls).toBe(0)
279
+ })
280
+
281
+ it("refuses a non-JSON callback outcome and rolls back its claim", async () => {
282
+ const transaction = fakeTransaction(
283
+ mockEntityManager({
284
+ query: [
285
+ CLAIM_CLIENT,
286
+ [
287
+ {
288
+ request_fingerprint: requestFingerprint(REQUEST.request),
289
+ outcome: null,
290
+ },
291
+ ],
292
+ ],
293
+ }),
294
+ )
295
+ const inbox = await build()
296
+
297
+ await expect(
298
+ transaction.em.transaction((tx) => inbox.runClient(REQUEST, tx, () => Promise.resolve(undefined))),
299
+ ).rejects.toMatchObject({
300
+ code: InboxErrorCode.OutcomeInvalid,
301
+ cause: { code: IdempotencyErrorCode.RequestInvalid },
302
+ })
303
+
304
+ expect(transaction.em.query).toHaveBeenCalledTimes(1)
305
+ expect(transaction.rollbacks).toBe(1)
306
+ })
307
+ })
308
+
309
+ describe("PostgresInbox client incomplete database responses", () => {
310
+ it("refuses missing claim and saved rows rather than committing an unrecorded result", async () => {
311
+ for (const responses of [
312
+ [[]],
313
+ [[{ request_fingerprint: requestFingerprint(REQUEST.request), outcome: null }], []],
314
+ ]) {
315
+ const manager = mockEntityManager()
316
+ for (const response of responses) manager.query.mockResolvedValueOnce(response)
317
+ const transaction = fakeTransaction(manager)
318
+ const inbox = await build()
319
+
320
+ await expect(
321
+ transaction.em.transaction((tx) => inbox.runClient(REQUEST, tx, () => Promise.resolve(OUTCOME))),
322
+ ).rejects.toMatchObject({
323
+ code: InboxErrorCode.OutcomeMissing,
324
+ })
325
+
326
+ expect(transaction.rollbacks).toBe(1)
327
+ }
328
+ })
329
+ })
330
+
331
+ describe("PostgresInbox client outcome write rollback", () => {
332
+ it("rolls back the claim and effect when saving the outcome fails", async () => {
333
+ const manager = mockEntityManager()
334
+ const failure = new Error("outcome write failed")
335
+ manager.query
336
+ .mockResolvedValueOnce([{ request_fingerprint: requestFingerprint(REQUEST.request), outcome: null }])
337
+ .mockRejectedValueOnce(failure)
338
+ const transaction = fakeTransaction(manager)
339
+ const inbox = await build()
340
+
341
+ await expect(
342
+ transaction.em.transaction((tx) => inbox.runClient(REQUEST, tx, () => Promise.resolve(OUTCOME))),
343
+ ).rejects.toBe(failure)
344
+
345
+ expect(transaction.rollbacks).toBe(1)
346
+ expect(transaction.committedWrites).toEqual([])
347
+ })
348
+ })
349
+
350
+ describe("PostgresInbox client retention", () => {
351
+ it.each([
352
+ { rows: [], expected: 0 },
353
+ { rows: [{ deleted_count: 0 }], expected: 0 },
354
+ { rows: [{ deleted_count: 17 }], expected: 17 },
355
+ ])("returns the bounded deleted row count $expected through the supplied manager", async ({ rows, expected }) => {
356
+ const manager = mockEntityManager({ query: [SWEEP_CLIENTS, rows] })
357
+ const inbox = await build()
358
+ const cutoff = new FakeClock("2026-01-01T00:00:00.000Z").now()
359
+
360
+ await expect(inbox.sweepClients(cutoff, manager)).resolves.toBe(expected)
361
+
362
+ expect(manager.query).toHaveBeenCalledWith(SWEEP_CLIENTS, [cutoff, BATCH_ROWS])
363
+ })
364
+ })
@@ -1,19 +1,70 @@
1
1
  import { Injectable } from "@nestjs/common"
2
2
  import type { EntityManager } from "typeorm"
3
- import { DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
4
- import type { Inbox, InboxEventId } from "./inbox.port"
5
- import { CLAIM_EVENT } from "./persistence/inbox.sql"
3
+ import { InjectClock } from "@modules/platform/clock"
4
+ import type { Clock } from "@modules/platform/clock"
5
+ import { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
6
+ import type { ClientOperation, ClientOperationEffect, Inbox, InboxEventId } from "./inbox.port"
7
+ import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
8
+ import { InboxError, InboxErrorCode } from "./errors/inbox.error"
9
+ import { clientSource } from "./client-operation.policy"
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"
6
13
 
7
14
  @Injectable()
8
15
  /** Claims a delivery with the receipt transaction, so a failure can be retried without losing the delivery. */
9
16
  export class PostgresInbox implements Inbox {
17
+ constructor(@InjectClock() private readonly clock: Clock) {}
18
+
10
19
  /** The first claim wins; the same source and delivery id becomes a no-op on redelivery. */
11
20
  async claim(source: string, eventId: InboxEventId, tx: EntityManager): Promise<boolean> {
12
21
  const normalizedEventId = eventId?.trim()
13
22
  if (!normalizedEventId) {
14
23
  throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
15
24
  }
16
- const rows: Array<object> = await tx.query(CLAIM_EVENT, [source, normalizedEventId])
25
+ const rows: Array<InboxEventRow> = await tx.query(CLAIM_EVENT, [source, normalizedEventId])
17
26
  return rows.length > 0
18
27
  }
28
+
29
+ /** Runs once in the domain transaction, returning the persisted JSON outcome on both the first call and every retry. */
30
+ async runClient<T>(request: ClientOperation, tx: EntityManager, effect: ClientOperationEffect<T>): Promise<T> {
31
+ if (tx.queryRunner?.isTransactionActive !== true)
32
+ throw new InboxError({ code: InboxErrorCode.TransactionRequired })
33
+ const source = clientSource(request)
34
+ const fingerprint = requestFingerprint(request.request)
35
+ const rows: Array<ClaimClientRow<T>> = await tx.query(CLAIM_CLIENT, [
36
+ source,
37
+ request.idempotencyKey,
38
+ fingerprint,
39
+ this.clock.now(),
40
+ ])
41
+ const claim = rows[0]
42
+ if (!claim) throw new InboxError({ code: InboxErrorCode.OutcomeMissing })
43
+ if (claim.request_fingerprint !== fingerprint)
44
+ throw new IdempotencyError({ code: IdempotencyErrorCode.KeyReused })
45
+ if (claim.incomplete) throw new InboxError({ code: InboxErrorCode.OutcomeMissing })
46
+ if (claim.outcome !== null) return claim.outcome.value
47
+
48
+ const value = await effect()
49
+ let outcome: string
50
+ try {
51
+ outcome = canonicalJson({ value })
52
+ } catch (cause) {
53
+ throw new InboxError({ code: InboxErrorCode.OutcomeInvalid, cause })
54
+ }
55
+ const saved: Array<SavedClientOutcomeRow<T>> = await tx.query(SAVE_CLIENT_OUTCOME, [
56
+ source,
57
+ request.idempotencyKey,
58
+ outcome,
59
+ ])
60
+ const recorded = saved[0]
61
+ if (!recorded) throw new InboxError({ code: InboxErrorCode.OutcomeMissing })
62
+ return recorded.outcome.value
63
+ }
64
+
65
+ /** A row lock and SKIP LOCKED keep retention from competing with a live retry or another sweep worker. */
66
+ async sweepClients(cutoff: Date, tx: EntityManager): Promise<number> {
67
+ const rows: Array<InboxSweepRow> = await tx.query(SWEEP_CLIENTS, [cutoff, BATCH_ROWS])
68
+ return rows[0]?.deleted_count ?? 0
69
+ }
19
70
  }
@@ -1,3 +1,5 @@
1
1
  export { INBOX, InjectInbox } from "./inbox.decorators"
2
2
  export { InboxModule } from "./inbox.module"
3
3
  export type { Inbox, InboxEventId } from "./inbox.port"
4
+ export { INBOX_ERROR_KINDS } from "./errors/inbox.error"
5
+ export { INBOX_MESSAGES } from "./messages/inbox.messages"
@@ -0,0 +1,15 @@
1
+ import type { MessageBundle } from "@modules/platform/i18n"
2
+
3
+ /** Display text of the inbox codes. */
4
+ export const INBOX_MESSAGES: MessageBundle = {
5
+ vi: {
6
+ "errors.INBOX_OUTCOME_INVALID": "Kết quả thao tác không phải dữ liệu JSON thuần nên không thể lưu.",
7
+ "errors.INBOX_OUTCOME_MISSING": "Không lưu được kết quả thao tác.",
8
+ "errors.INBOX_TRANSACTION_REQUIRED": "Thao tác này cần một giao dịch đang hoạt động.",
9
+ },
10
+ en: {
11
+ "errors.INBOX_OUTCOME_INVALID": "The operation outcome is not plain JSON data, so it cannot be stored.",
12
+ "errors.INBOX_OUTCOME_MISSING": "The operation outcome could not be recorded.",
13
+ "errors.INBOX_TRANSACTION_REQUIRED": "This operation requires an active transaction.",
14
+ },
15
+ }
@@ -0,0 +1,33 @@
1
+ /** The JSON envelope makes a completed null value distinct from a pending SQL-null outcome. */
2
+ export interface ClientOutcomeRow<T> {
3
+ /** The operation's complete JSON result, including expected refusals. */
4
+ readonly value: T
5
+ }
6
+
7
+ /** The row returned by the atomic client claim. */
8
+ export interface ClaimClientRow<T> {
9
+ /** The digest recorded by the first operation under this scoped key. */
10
+ readonly request_fingerprint: string | null
11
+ /** The saved result, or null only for a claim inserted by this transaction. */
12
+ readonly outcome: ClientOutcomeRow<T> | null
13
+ /** An existing row had no completed outcome, so it must never run an effect again. */
14
+ readonly incomplete: boolean
15
+ }
16
+
17
+ /** The persisted outcome returned to the first caller. */
18
+ export interface SavedClientOutcomeRow<T> {
19
+ /** The normalized JSON result as the database stores it. */
20
+ readonly outcome: ClientOutcomeRow<T>
21
+ }
22
+
23
+ /** A claimed or deleted delivery key returned by inbox persistence. */
24
+ export interface InboxEventRow {
25
+ /** The event or client key owned by the row. */
26
+ readonly event_id: string
27
+ }
28
+
29
+ /** The bounded retention batch's aggregate result. */
30
+ export interface InboxSweepRow {
31
+ /** Number of completed client claims removed by this transaction. */
32
+ readonly deleted_count: number
33
+ }
@@ -5,3 +5,29 @@ export const CLAIM_EVENT = sql`INSERT INTO public.inbox (source, event_id)
5
5
  VALUES ($1, $2)
6
6
  ON CONFLICT (source, event_id) DO NOTHING
7
7
  RETURNING event_id`
8
+
9
+ /** A conflicting insert locks the existing row and returns its committed fingerprint and outcome after the winner commits. */
10
+ export const CLAIM_CLIENT = sql`INSERT INTO public.inbox (source, event_id, request_fingerprint, claimed_at)
11
+ VALUES ($1, $2, $3, $4)
12
+ ON CONFLICT (source, event_id) DO UPDATE SET outcome = COALESCE(inbox.outcome, '{}'::jsonb)
13
+ RETURNING request_fingerprint, outcome, (outcome IS NOT NULL AND (jsonb_typeof(outcome) <> 'object' OR NOT (outcome ? 'value'))) AS incomplete`
14
+
15
+ /** The JSON envelope distinguishes a completed null result from an uncompleted claim in this transaction. */
16
+ export const SAVE_CLIENT_OUTCOME = sql`WITH saved AS (
17
+ UPDATE public.inbox SET outcome = $3::jsonb
18
+ WHERE source = $1 AND event_id = $2
19
+ RETURNING outcome
20
+ )
21
+ SELECT outcome FROM saved LIMIT 1`
22
+
23
+ /** Retention never deletes delivery rows or an incomplete claim; its bounded batch skips rows held by active retries. */
24
+ export const SWEEP_CLIENTS = sql`WITH expired AS (
25
+ SELECT source, event_id FROM public.inbox
26
+ WHERE source LIKE 'client:%' AND request_fingerprint IS NOT NULL AND outcome IS NOT NULL AND jsonb_typeof(outcome) = 'object' AND outcome ? 'value' AND claimed_at < $1
27
+ ORDER BY claimed_at, source, event_id LIMIT $2 FOR UPDATE SKIP LOCKED
28
+ ), deleted AS (
29
+ DELETE FROM public.inbox AS claims USING expired
30
+ WHERE claims.source = expired.source AND claims.event_id = expired.event_id
31
+ RETURNING claims.event_id
32
+ )
33
+ SELECT count(*)::int AS deleted_count FROM deleted`