@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,119 @@
1
+ // work-io.mjs — the file, record and argv helpers the scripts/work tools and the work checks share.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseYaml } from '../../engine/yaml.mjs';
5
+ import { list } from '../lib/list.mjs';
6
+ import { renameOver } from '../api/fs/rename-over.mjs';
7
+ import { underWorktrees } from '../lib/worktree-exclude.mjs';
8
+ import { valueAfter } from '../lib/cli-arg.mjs';
9
+ import { isLiteWorkRoot, workPathAllowed } from '../lib/work-edition.mjs';
10
+ export { slash } from '../lib/path-key.mjs';
11
+
12
+ /** How many directories below its start a record walk descends (features/<f>/ui/<r> is 3). */
13
+ const RECORD_DEPTH = 12;
14
+ /** Directories a record walk never enters: a record's own files and the kernel's evidence hold no records. */
15
+ const RECORD_SKIP = Object.freeze(['node_modules', 'assets', 'evidence', 'runs', '_derived', 'kernel-evidence', 'kernel-strays', 'kernel-approvals']);
16
+ /** The share of a keyed #FF00FF rectangle that must be key-coloured for it to count as a slot. */
17
+ export const SLOT_FILL_MIN = 0.98;
18
+
19
+ export { list };
20
+ export { sha256 as sha256Of, sha256File } from '../../engine/digest.mjs';
21
+ export { isFile, isDir } from '../lib/fs-kind.mjs';
22
+ /** A record state label folded for comparison ('default' when absent). */
23
+ export const stateKey = (s) => String(s ?? 'default').trim().toLowerCase();
24
+ /** A YAML file's document; throws when the file is unreadable or does not parse. */
25
+ export const readYaml = (file) => parseYaml(fs.readFileSync(file, 'utf8'));
26
+ /** A YAML file's document, or null when it is unreadable or does not parse. */
27
+ export const readYamlOrNull = (file) => { try { return readYaml(file); } catch { return null; } };
28
+ /** The value after `name` in argv, or null when `name` is absent or last. */
29
+ export const flag = (args, name) => valueAfter(args, name);
30
+ /** Every value after an occurrence of `name` in argv. */
31
+ export const flags = (args, name) => args.flatMap((a, i) => (a === name && i + 1 < args.length ? [args[i + 1]] : []));
32
+
33
+ /** A record's assets, record.assets before ui.assets, one per path. */
34
+ export function assetsOf(record) {
35
+ const byPath = new Map();
36
+ for (const a of [...list(record?.assets), ...list(record?.ui?.assets)]) if (typeof a?.path === 'string' && !byPath.has(a.path)) byPath.set(a.path, a);
37
+ return [...byPath.values()];
38
+ }
39
+
40
+ /** A `ui.<id>:<path>` image reference: {id, path}, or null for any other reference. */
41
+ export function parseUiRef(ref) {
42
+ const m = /^(ui\.[^:]+):(.+)$/.exec(String(ref ?? ''));
43
+ return m ? { id: m[1], path: m[2] } : null;
44
+ }
45
+
46
+ /** The Work root enclosing `dir`: the nearest workspace or `.starciwork`; an enclosing lite `.starciwork` owns its whole tree. */
47
+ export function workRootOf(dir) {
48
+ let workspaceRoot = null;
49
+ for (let at = path.resolve(dir), prev = null; ; prev = at, at = path.dirname(at)) {
50
+ // A product worktree lives at <repo>/.starciwork/worktrees/<wf>/<op> (DESIGN §16.7): the `.starciwork` holding the
51
+ // worktrees container is never the Work root of a path inside one of them.
52
+ if (path.basename(at) === '.starciwork' && prev && path.basename(prev) === 'worktrees') return workspaceRoot;
53
+ if (path.basename(at) === '.starciwork') return isLiteWorkRoot(at) ? at : workspaceRoot ?? at;
54
+ if (!workspaceRoot && fs.existsSync(path.join(at, 'workspace.yaml'))) workspaceRoot = at;
55
+ if (path.dirname(at) === at) return workspaceRoot;
56
+ }
57
+ }
58
+
59
+ /** Every index.yaml under `root` (itself included), at most RECORD_DEPTH directories down, never inside RECORD_SKIP. */
60
+ export function indexFilesUnder(root) {
61
+ const out = [];
62
+ const workRoot = workRootOf(root);
63
+ const walk = (dir, depth) => {
64
+ if (!workPathAllowed(workRoot, dir, { dir: true })) return;
65
+ let entries = [];
66
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
67
+ for (const e of entries) {
68
+ const full = path.join(dir, e.name);
69
+ if (!workPathAllowed(workRoot, full, { dir: e.isDirectory() })) continue;
70
+ if (e.isDirectory()) { if (depth < RECORD_DEPTH && !RECORD_SKIP.includes(e.name) && !underWorktrees(root, full)) walk(full, depth + 1); }
71
+ else if (e.name === 'index.yaml') out.push(full);
72
+ }
73
+ };
74
+ walk(root, 0);
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Replace a record file whole: the text is written beside it and renamed over it, so a reader or a crash never
80
+ * sees half a record. A rename Windows refuses while a reader holds the file is retried; nothing is written in place.
81
+ */
82
+ export function writeRecordFile(file, text) {
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
85
+ fs.writeFileSync(tmp, text);
86
+ renameOver(tmp, file);
87
+ }
88
+
89
+ /**
90
+ * The `<status|question|apply>` main the owner-review tools share (draw-review.mjs, brand-direction.mjs): the command
91
+ * is argv[0], the subject is `targetFlag`'s value (`--ui`, `--work`), `apply` runs with `--receipt`/`--write`.
92
+ * `status`/`question`/`apply` return the handler's answer - {exitCode, text} to refuse, else {result, text} (text
93
+ * carries its trailing newline): status and apply print `result` as JSON under --json, else the human text;
94
+ * question's answer is always the JSON question. `tag` is the error prefix.
95
+ */
96
+ export function reviewMain(argv, { targetFlag, usage, tag, status, question, apply }) {
97
+ const [command, ...args] = argv;
98
+ const json = args.includes('--json');
99
+ const target = flag(args, targetFlag);
100
+ if (!['status', 'question', 'apply'].includes(command) || !target) return { exitCode: 2, text: usage };
101
+ try {
102
+ if (command === 'status') {
103
+ const s = status(target, args);
104
+ if ('exitCode' in s) return s;
105
+ return { exitCode: 0, text: json ? `${JSON.stringify(s.result, null, 2)}\n` : s.text };
106
+ }
107
+ if (command === 'question') {
108
+ const q = question(target, args);
109
+ return 'exitCode' in q ? q : { exitCode: 0, text: `${JSON.stringify(q.result, null, 2)}\n` };
110
+ }
111
+ const receipt = flag(args, '--receipt');
112
+ if (!receipt) return { exitCode: 2, text: usage };
113
+ const r = apply(target, receipt, args);
114
+ if ('exitCode' in r) return r;
115
+ return { exitCode: 0, text: json ? `${JSON.stringify(r.result, null, 2)}\n` : r.text };
116
+ } catch (error) {
117
+ return { exitCode: 1, text: `${tag}: ${error.message}\n` };
118
+ }
119
+ }
@@ -105,7 +105,7 @@ const databasePatches = (root) => {
105
105
  /** The first webhook installs only the lite inbox claim and transactional event publisher, shared by all providers. */
106
106
  const handoffPlatformFiles = (root, values) => {
107
107
  const planned = [];
108
- for (const capability of ["inbox", "event-bus"]) {
108
+ for (const capability of ["inbox", "idempotency", "event-bus"]) {
109
109
  const base = `be/src/modules/platform/${capability}`;
110
110
  if (fs.existsSync(path.join(root, base, "index.ts"))) continue;
111
111
  const folder = path.join(TEMPLATES_DIR, "be", "inbox", "platform", capability);
@@ -236,7 +236,7 @@ export function addLiteWebhookInbox({
236
236
  /create\s+table\s+public\.event_bus\b/i.test(fs.readFileSync(path.join(migrationsDir, file), "utf8")),
237
237
  );
238
238
  if (!hasHandoffStorage)
239
- planned[0].text = `${template("platform/migration.sql.tpl", renderValues)}\n${planned[0].text}`;
239
+ planned[0].text = `${template("platform/migration.sql.tpl", renderValues)}\n${template("platform/client-operation-outcomes.sql.tpl", renderValues)}\n${planned[0].text}`;
240
240
  const databaseSql = "be/src/modules/platform/database/database.sql.ts";
241
241
  if (!fs.existsSync(path.join(root, ...databaseSql.split("/")))) {
242
242
  planned.push({
@@ -6,12 +6,16 @@ import { TEMPLATES_DIR } from '../sync/index.mjs';
6
6
  import { ScaffoldError, pascalOf } from './service.mjs';
7
7
  import { refuseFullAdapterInLite, refuseInEdition } from './edition-gate.mjs';
8
8
  import { selectLiteWebhookEntries } from './add-inbox-lite.mjs';
9
+ import { LITE_TEMPLATED_TOPICS, selectLiteQueueEntries } from './add-queue-lite.mjs';
9
10
  import { ensureLiteCli } from './add-cli-lite.mjs';
11
+ import { isMigrationName, migrationStamp } from '../runtime/scripts/hfs/rules/database-migrations.mjs';
12
+ import { patternFilePath } from './pattern-file-slots.mjs';
10
13
 
11
14
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
12
15
  const VARIABLE = /<([A-Za-z][A-Za-z0-9]*)>/g;
13
16
  const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
14
17
  const PLATFORM_PREFIX = 'src/modules/platform/';
18
+ const platformEntry = (entry) => entry.path.startsWith(PLATFORM_PREFIX) || entry.shared === true;
15
19
  const TOPICS_DIR = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'patterns', 'be');
16
20
  const PATTERN_TEMPLATES = path.join(TEMPLATES_DIR, 'be', 'patterns');
17
21
  const camelOf = (name) => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
@@ -38,12 +42,12 @@ export function addBuiltinLiteCli({ noun, name, repo, repoRoot, manifest }) {
38
42
 
39
43
  const readTopic = (topic) => ({ id: topic, doc: parseYaml(fs.readFileSync(path.join(TOPICS_DIR, `${topic}.yaml`), 'utf8')) });
40
44
 
41
- function readTree(topic) {
45
+ function readTree(topic, edition) {
42
46
  const file = path.join(TOPICS_DIR, `${topic}.yaml`);
43
47
  if (!fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_TOPIC_MISSING', `the pattern topic ${topic} (knowledge/patterns/be/${topic}.yaml) is not carried by this package`);
44
48
  const doc = parseYaml(fs.readFileSync(file, 'utf8'));
45
49
  if (!Array.isArray(doc?.files)) throw new ScaffoldError('HFS_ADD_TREE_MISSING', `knowledge/patterns/be/${topic}.yaml has no files tree`);
46
- return doc.files;
50
+ return edition === 'lite' && Array.isArray(doc.liteFiles) ? doc.liteFiles : doc.files;
47
51
  }
48
52
 
49
53
  const selectLiteApiEntries = entries => entries.map(entry => {
@@ -115,14 +119,14 @@ export const fillKnownForms = (text, forms) => text.replace(PLACEHOLDER, (whole,
115
119
  function treesOf({ topics, repo, noun, spec }) {
116
120
  const liteApi = noun === 'api' && repo.edition === 'lite';
117
121
  return new Map([...topics].map((topic) => {
118
- const entries = repo.edition === 'lite' ? selectLiteWebhookEntries(readTree(topic)) : readTree(topic);
122
+ const entries = repo.edition === 'lite' ? selectLiteQueueEntries(topic, selectLiteWebhookEntries(readTree(topic, repo.edition))) : readTree(topic);
119
123
  return [topic, liteApi && topic === spec.topic ? selectLiteApiEntries(entries) : entries];
120
124
  }));
121
125
  }
122
126
 
123
127
  function collectSlots(entries, platform, slots) {
124
128
  for (const entry of entries) {
125
- if (entry.path.startsWith(PLATFORM_PREFIX) === platform && entry.optional !== true && entry.slot) slots.add(entry.slot);
129
+ if (platformEntry(entry) === platform && entry.optional !== true && entry.slot) slots.add(entry.slot);
126
130
  }
127
131
  }
128
132
 
@@ -134,13 +138,14 @@ function validateWrittenSlots({ specs, topics, trees, manifest, repo, noun }) {
134
138
  refuseInEdition({ resolver: createSlotResolver(manifest, repo), slotIds: [...slots], command: `add ${noun}` });
135
139
  }
136
140
 
137
- function planEntry(entry, { topic, instance, shared, beRoot, withStamp, forms }) {
141
+ function planEntry(entry, { topic, instance, shared, beRoot, repoRoot, withStamp, forms }) {
138
142
  if (entry.optional === true) return null;
139
143
  const target = fillPath(entry.path, withStamp);
140
144
  if (target === null) return null;
141
- const absolute = path.join(beRoot, ...target.split('/'));
145
+ const absolute = path.join(repoRoot, ...patternFilePath(target, entry.root).split('/'));
142
146
  // A platform capability is whole or absent: its index prevents regeneration of all its files.
143
- if (!instance && fs.existsSync(path.join(beRoot, ...target.split('/').slice(0, 4), 'index.ts'))) return null;
147
+ const owner = entry.owner ?? target.split('/').slice(0, 4).join('/');
148
+ if (!instance && fs.existsSync(path.join(beRoot, ...owner.split('/'), 'index.ts'))) return null;
144
149
  if ((!instance || shared) && fs.existsSync(absolute)) return null;
145
150
  if (!entry.template) throw new ScaffoldError('HFS_ADD_TEMPLATE_MISSING', `${topic}.yaml lists ${entry.path} without a template, so it cannot be generated`);
146
151
  const templateFile = path.join(PATTERN_TEMPLATES, ...entry.template.split('/'));
@@ -155,29 +160,51 @@ function appendEntries(entries, context, planned) {
155
160
  }
156
161
  }
157
162
 
163
+ /** Planned app migrations append after every active migration, before any generated file is written. */
164
+ function requireMigrationOrder(repoRoot, planned) {
165
+ const directory = path.join(repoRoot, 'supabase', 'migrations');
166
+ const migrations = planned.filter(file => path.dirname(file.absolute) === directory);
167
+ if (migrations.length === 0) return;
168
+ const existing = fs.existsSync(directory) ? fs.readdirSync(directory) : [];
169
+ let latest = existing.filter(name => isMigrationName(name) && migrationStamp(name.slice(0, 14)) !== null)
170
+ .map(name => name.slice(0, 14)).sort().at(-1);
171
+ for (const file of migrations.sort((left, right) => left.target.localeCompare(right.target))) {
172
+ const name = path.basename(file.absolute);
173
+ const stamp = name.slice(0, 14);
174
+ if (!isMigrationName(name) || migrationStamp(stamp) === null)
175
+ throw new ScaffoldError('HFS_ADD_MIGRATION_ORDER', `generated migration ${name} needs a valid UTC timestamp and migration name`);
176
+ if (latest && stamp <= latest)
177
+ throw new ScaffoldError('HFS_ADD_MIGRATION_ORDER', `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
178
+ latest = stamp;
179
+ }
180
+ }
181
+
158
182
  /** Renders all topic-owned paths and bodies before any instance file is written. */
159
183
  export function planKind(context) {
160
184
  const { noun, name, spec, params, repo, options, now } = context;
161
185
  const specs = [noun, ...(spec.also ?? [])].map((each) => [each, params.addKinds[each]]);
162
186
  const patterns = [...new Set(specs.flatMap(([, eachSpec]) => eachSpec.patterns))];
163
187
  const topics = new Set([...specs.map(([, eachSpec]) => eachSpec.topic), ...topicsOfPatterns(patterns)]);
164
- refuseFullAdapterInLite({ edition: repo.edition, topics: [...topics].map(readTopic), command: `add ${noun}` });
188
+ refuseFullAdapterInLite({ edition: repo.edition, topics: [...topics].map(readTopic), command: `add ${noun}`, liteTemplated: LITE_TEMPLATED_TOPICS });
165
189
  const trees = treesOf({ topics, repo, noun, spec });
166
190
  validateWrittenSlots({ ...context, specs, topics, trees });
167
191
  const values = Object.assign({}, ...specs.map(([each, eachSpec]) => variablesOf({ spec: eachSpec, noun: each, name, options })), variablesOf({ spec, noun, name, options }));
168
- const withStamp = { ...values, epochMs13: String(now()).padStart(13, '0').slice(0, 13) };
192
+ const timestamp = now();
193
+ const stampOf = value => new Date(value).toISOString().replace(/\D/g, '').slice(0, 14);
194
+ const withStamp = { ...values, epochMs13: String(timestamp).padStart(13, '0').slice(0, 13), stamp: stampOf(timestamp), nextStamp: stampOf(timestamp + 1000) };
169
195
  const forms = formsOf(withStamp);
170
196
  const beRoot = path.join(context.repoRoot, 'be');
171
197
  const planned = [];
172
198
  for (const [each, eachSpec] of specs) {
173
- const entries = trees.get(eachSpec.topic).filter(entry => !entry.path.startsWith(PLATFORM_PREFIX));
174
- appendEntries(entries, { topic: eachSpec.topic, instance: true, shared: each !== noun, beRoot, withStamp, forms }, planned);
199
+ const entries = trees.get(eachSpec.topic).filter(entry => !platformEntry(entry));
200
+ appendEntries(entries, { topic: eachSpec.topic, instance: true, shared: each !== noun, beRoot, repoRoot: context.repoRoot, withStamp, forms }, planned);
175
201
  }
176
202
  for (const topic of topics) {
177
- const entries = trees.get(topic).filter(entry => entry.path.startsWith(PLATFORM_PREFIX));
178
- appendEntries(entries, { topic, instance: false, shared: false, beRoot, withStamp, forms }, planned);
203
+ const entries = trees.get(topic).filter(platformEntry);
204
+ appendEntries(entries, { topic, instance: false, shared: false, beRoot, repoRoot: context.repoRoot, withStamp, forms }, planned);
179
205
  }
180
206
  const clash = planned.filter(file => file.instance && fs.existsSync(file.absolute)).map(file => file.target);
181
207
  if (clash.length) throw new ScaffoldError('HFS_ADD_EXISTS', `${noun} ${name} already exists: ${clash.join(', ')}`);
208
+ requireMigrationOrder(context.repoRoot, planned);
182
209
  return { values, forms, beRoot, patterns, planned };
183
210
  }
@@ -0,0 +1,372 @@
1
+ // `starci app add queue|job` in a lite app (ruling:R212/case-10): the queue is full's BullMQ adapter on the declared Redis, so lite
2
+ // generates full's queue and jobs trees with three differences. The outbox and job tables are a Supabase migration instead of the
3
+ // TypeORM entity, connection and migration; the SQL names the public schema; and the queue options read the Redis password of
4
+ // its sealed secret. The verb also declares redis in hfs.json sides.be.backing with its bullmq client, and renders the one
5
+ // declared Redis into every docker-swarm environment of the stack: the stateful component, its backup and restore record, the
6
+ // sealed redis-password member and its grants, the AOF service on a named volume and an internal network, and the images lock.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
10
+ import { dbTypesPath, emitDbTypes } from '../emit/db-types.mjs';
11
+ import { configuredRecipients } from '../runtime/scripts/hfs/secret-init.mjs';
12
+ import { materializeDeclaredPlaceholders } from '../runtime/scripts/hfs/sealed-members.mjs';
13
+ import { REDIS_IMAGE, TEMPLATES_DIR, redisDevScripts, render } from '../sync/index.mjs';
14
+ import { byCodeUnit } from '../report/order.mjs';
15
+ import { withSqlIdentifierError } from './add-database-lite.mjs';
16
+ import { bundledPins, jsonText, packageJsonText } from './app.mjs';
17
+ import { registerLiteExports } from './lite-exports.mjs';
18
+ import { ScaffoldError } from './service.mjs';
19
+ import { YamlBlockEdit } from './yaml-block-edit.mjs';
20
+
21
+ /** The pattern topics whose lite templates this package carries; `starci app add` refuses the other lite-adapted topics. */
22
+ export const LITE_TEMPLATED_TOPICS = Object.freeze(['jobs', 'queues']);
23
+
24
+ const LITE_TEMPLATES = Object.freeze({
25
+ 'queues/platform/queue.index.ts.tpl': 'queues/platform-lite/queue.index.ts.tpl',
26
+ 'queues/platform/queue.options.ts.tpl': 'queues/platform-lite/queue.options.ts.tpl',
27
+ 'queues/platform/queue.config.ts.tpl': 'queues/platform-lite/queue.config.ts.tpl',
28
+ 'queues/platform/bullmq-queue-transport.client.ts.tpl': 'queues/platform-lite/bullmq-queue-transport.client.ts.tpl',
29
+ 'queues/platform/bullmq-queue-transport.client.spec.ts.tpl': 'queues/platform-lite/bullmq-queue-transport.client.spec.ts.tpl',
30
+ 'queues/platform/queue-transport.port.ts.tpl': 'queues/platform-lite/queue-transport.port.ts.tpl',
31
+ // The error family also refuses a mark Redis has not persisted (QUEUE_NOT_PERSISTED, QUEUE_JOB_LOST): R276 runs the lite Redis with AOF.
32
+ 'queues/platform/errors/queue.error.ts.tpl': 'queues/platform-lite/queue.error.ts.tpl',
33
+ 'queues/platform/messages/queue.messages.ts.tpl': 'queues/platform-lite/queue.messages.ts.tpl',
34
+ 'queues/platform/queue-relay.service.ts.tpl': 'queues/platform-lite/queue-relay.service.ts.tpl',
35
+ 'queues/platform/queue-relay.service.spec.ts.tpl': 'queues/platform-lite/queue-relay.service.spec.ts.tpl',
36
+ 'queues/platform/queue.module.ts.tpl': 'queues/platform-lite/queue.module.ts.tpl',
37
+ 'queues/platform/persistence.queue.sql.ts.tpl': 'queues/platform-lite/persistence.queue.sql.ts.tpl',
38
+ 'jobs/platform/jobs.index.ts.tpl': 'jobs/platform-lite/jobs.index.ts.tpl',
39
+ 'jobs/platform/persistence.jobs.sql.ts.tpl': 'jobs/platform-lite/persistence.jobs.sql.ts.tpl',
40
+ });
41
+
42
+ /** The TypeORM-only files of a platform capability: lite's schema authority is supabase/migrations (be.persistence lite overlay). */
43
+ const TYPEORM_ONLY = /\/persistence\/(?:connection\.ts$|entities\/|migrations\/)/;
44
+
45
+ /** The e2e proof slot lite forbids (R212); a lite pattern proves itself with optional integration specs (the topic's liteAdapter proof). */
46
+ const E2E_SLOT = 'be.tests.e2e';
47
+
48
+ /**
49
+ * The lite entries of a queue or jobs topic tree: no TypeORM schema file or e2e proof, and the lite body where the package carries one.
50
+ */
51
+ export const selectLiteQueueEntries = (topic, entries) => (LITE_TEMPLATED_TOPICS.includes(topic)
52
+ ? [...entries.filter((entry) => !TYPEORM_ONLY.test(entry.path) && entry.slot !== E2E_SLOT)
53
+ .map((entry) => (LITE_TEMPLATES[entry.template] ? { ...entry, template: LITE_TEMPLATES[entry.template] } : entry))]
54
+ : entries);
55
+
56
+ const MIGRATION = /^(\d{14})_[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.sql$/;
57
+ const REDIS_PURPOSE = 'The declared Redis of the queue (hfs.json sides.be.backing) - one instance with AOF persistence (appendonly yes) on the named volume redis-data, on the internal backing network only, reached by the api and worker services as redis:6379 with the password of its sealed secret.';
58
+ const MEMBER = 'redis-password';
59
+ const quoted = (value) => JSON.stringify(value);
60
+ const read = (file) => fs.readFileSync(file, 'utf8');
61
+ const template = (relative) => read(path.join(TEMPLATES_DIR, ...relative.split('/'))).replace(/\r\n/g, '\n');
62
+
63
+ const DATABASE_ERROR = 'be/src/modules/platform/database/errors/database.error.ts';
64
+
65
+ /** Skeleton files the lite starter leaves out and the queue capabilities use, each from its skeleton template. */
66
+ const SKELETON_FILES = Object.freeze([
67
+ ['be/src/modules/platform/database/database.sql.ts', 'skeleton-lite/src/modules/platform/database/database.sql.ts'],
68
+ ]);
69
+
70
+ /** The sql tag of the outbox and claim statements (with its identifier error) and the in-order relay of lite's starter platform. */
71
+ function skeletonFiles(repoRoot) {
72
+ const created = SKELETON_FILES.filter(([relative]) => !fs.existsSync(path.join(repoRoot, ...relative.split('/'))))
73
+ .map(([relative, source]) => ({ relative, text: render(template(`be/${source}`), {}) }));
74
+ const errorFile = path.join(repoRoot, ...DATABASE_ERROR.split('/'));
75
+ const before = fs.existsSync(errorFile) ? read(errorFile).replace(/\r\n/g, '\n') : null;
76
+ const after = before === null ? null : withSqlIdentifierError(before);
77
+ return { created, patches: after === before ? [] : [{ file: errorFile, text: after }] };
78
+ }
79
+
80
+ /** The capability tables a platform capability the verb adds for the first time needs, as one forward-only Supabase migration. */
81
+ function migrationFiles({ repoRoot, beRoot, patterns, now }) {
82
+ const sections = [
83
+ ['queue-outbox', 'queue', 'queues/platform-lite/queue-outbox.sql.tpl', 'queue'],
84
+ ['jobs', 'fenced-job', 'jobs/platform-lite/jobs.sql.tpl', 'jobs'],
85
+ ].filter(([, pattern, , capability]) => patterns.includes(pattern) && !fs.existsSync(path.join(beRoot, 'src', 'modules', 'platform', capability, 'index.ts')));
86
+ if (!sections.length) return [];
87
+ const folder = path.join(repoRoot, 'supabase', 'migrations');
88
+ const migrations = fs.existsSync(folder) ? fs.readdirSync(folder).filter((file) => MIGRATION.test(file)).sort(byCodeUnit) : [];
89
+ const stamp = new Date(now()).toISOString().replace(/\D/g, '').slice(0, 14);
90
+ const latest = migrations.at(-1)?.slice(0, 14);
91
+ if (latest && stamp <= latest) throw new ScaffoldError('HFS_ADD_MIGRATION_ORDER', `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
92
+ const text = sections.map(([, , source]) => template(`be/patterns/${source}`)).join('\n');
93
+ return [{ relative: `supabase/migrations/${stamp}_${sections.map(([name]) => name).join('-')}.sql`, text }];
94
+ }
95
+
96
+ /** The queue imports bullmq, which loads ioredis as an optional peer and cannot connect without it. */
97
+ const QUEUE_CLIENTS = Object.freeze(['bullmq', 'ioredis']);
98
+
99
+ /**
100
+ * hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients at their canon pins, and the managed
101
+ * development scripts of the declared Redis that `starci app sync` renders for it. Each is added when it is missing, so a Redis an
102
+ * earlier capability declared (a cache on ioredis) still gains bullmq; a file already complete is left alone.
103
+ */
104
+ function declarationPatches(repoRoot, project) {
105
+ const file = path.join(repoRoot, 'hfs.json');
106
+ const declaration = JSON.parse(read(file));
107
+ declaration.sides.be.backing = [...new Set([...(declaration.sides.be.backing ?? []), 'redis'])].sort(byCodeUnit);
108
+ const packageFile = path.join(repoRoot, 'package.json');
109
+ const pkg = JSON.parse(read(packageFile));
110
+ const pins = bundledPins();
111
+ const missing = QUEUE_CLIENTS.filter((name) => typeof pkg.dependencies?.[name] !== 'string');
112
+ for (const name of missing) if (!pins[name]) throw new ScaffoldError('HFS_SCAFFOLD_PIN_MISSING', `${name} has no canon pin in knowledge/hfs/canon-pins.yaml`);
113
+ if (missing.length)
114
+ pkg.dependencies = Object.fromEntries(Object.entries({ ...pkg.dependencies, ...Object.fromEntries(missing.map((name) => [name, pins[name].version])) }).sort(([a], [b]) => a.localeCompare(b)));
115
+ pkg.scripts = { ...pkg.scripts, ...redisDevScripts(project) };
116
+ return [{ file, text: jsonText(declaration) }, { file: packageFile, text: packageJsonText(pkg) }]
117
+ .filter((patch) => patch.text !== read(patch.file));
118
+ }
119
+
120
+ const redisComponent = `redis:
121
+ role: stateful
122
+ image: ${REDIS_IMAGE}
123
+ compose: infra/swarm/stack.yaml
124
+ volumes: [redis-data]
125
+ purpose: ${quoted(REDIS_PURPOSE)}
126
+ required: true
127
+ `;
128
+
129
+ /** The back-end app kinds that run the queue (its relay, workers and schedulers); a cli app only writes the outbox. */
130
+ const QUEUE_APP_KINDS = Object.freeze(['api', 'worker']);
131
+
132
+ /** The stack services of the back-end apps that run the queue: each reaches the declared Redis. */
133
+ const queueServicesOf = (repo) => (repo.sides?.be?.apps ?? []).filter((app) => QUEUE_APP_KINDS.includes(app.kind)).map((app) => app.name);
134
+
135
+ /** The application-stacks.yaml additions of one docker-swarm environment, applied to its text and to its document. */
136
+ function declareEnvironment(edit, name, environment, { project, consumers }) {
137
+ const model = edit.value.environments[name];
138
+ edit.append(['environments', name, 'components'], 'redis: {replicas: 1, port: 6379}\n');
139
+ model.components.redis = { replicas: 1, port: 6379 };
140
+ const [backup] = environment.backup ?? [];
141
+ const recovery = `.starcistacks/${name}/infra/swarm/recovery/redis.yaml`;
142
+ const entry = { component: 'redis', target: 'Owner-provisioned encrypted off-host storage for the Redis volume (its AOF and RDB files)', retention: backup?.retention ?? 'Thirty daily snapshots', owner: backup?.owner ?? 'Product operator', restoreProof: recovery };
143
+ edit.append(['environments', name, 'backup'], `- component: redis\n target: ${quoted(entry.target)}\n retention: ${quoted(entry.retention)}\n owner: ${quoted(entry.owner)}\n restoreProof: ${entry.restoreProof}\n`);
144
+ model.backup.push(entry);
145
+ const [secret] = environment.secrets ?? [];
146
+ const runtimeName = `${project}-${name}-${MEMBER}-v1`;
147
+ const member = { name: MEMBER, runtimeName, version: 1, where: `secrets/${MEMBER}`, recipientPolicy: secret?.recipientPolicy ?? "This app's own age recipient configured in .sops.yaml", keyCustody: secret?.keyCustody ?? "SOPS_AGE_KEY in the host's untracked .claude/secret.env" };
148
+ edit.append(['environments', name, 'secrets'], `- name: ${MEMBER}\n runtimeName: ${quoted(runtimeName)}\n version: 1\n where: secrets/${MEMBER}\n recipientPolicy: ${quoted(member.recipientPolicy)}\n keyCustody: ${quoted(member.keyCustody)}\n`);
149
+ model.secrets.push(member);
150
+ const deploy = environment.deploy;
151
+ if (Array.isArray(deploy?.volumes)) {
152
+ edit.append(['environments', name, 'deploy', 'volumes'], `- ${quoted(`${project}-${name}-redis-data`)}\n`);
153
+ model.deploy.volumes.push(`${project}-${name}-redis-data`);
154
+ }
155
+ const grants = deploy?.secrets?.grants;
156
+ if (Array.isArray(grants)) {
157
+ for (const service of [...consumers, 'redis']) {
158
+ const at = grants.findIndex((grant) => grant?.service === service);
159
+ if (at >= 0) {
160
+ edit.append(['environments', name, 'deploy', 'secrets', 'grants', { item: 'service', is: service }, 'from'], `- member: ${MEMBER}\n`);
161
+ model.deploy.secrets.grants[at].from.push({ member: MEMBER });
162
+ } else {
163
+ edit.append(['environments', name, 'deploy', 'secrets', 'grants'], `- service: ${service}\n from:\n - member: ${MEMBER}\n`);
164
+ model.deploy.secrets.grants.push({ service, from: [{ member: MEMBER }] });
165
+ }
166
+ }
167
+ }
168
+ return { recovery, runtimeName };
169
+ }
170
+
171
+ /** The services of one Swarm model that run the queue; a model with none of them cannot reach the Redis it would gain. */
172
+ function queueServicesIn(file, relative, consumers) {
173
+ const services = parseYaml(read(file))?.services ?? {};
174
+ const present = consumers.filter((name) => services[name]);
175
+ if (!present.length) throw new ScaffoldError('HFS_ADD_STACK_SHAPE', `${relative} has no service of a back-end api or worker app (${consumers.join(', ') || 'none declared'}); add the declared Redis by hand`);
176
+ return present;
177
+ }
178
+
179
+ const QUEUE_ENVIRONMENT = Object.freeze({ QUEUE_REDIS_HOST: 'redis', QUEUE_REDIS_PORT: '6379', QUEUE_REDIS_PASSWORD_FILE: `/run/secrets/${MEMBER}` });
180
+ const QUEUE_ENVIRONMENT_TEXT = `QUEUE_REDIS_HOST: redis\nQUEUE_REDIS_PORT: "6379"\nQUEUE_REDIS_PASSWORD_FILE: /run/secrets/${MEMBER}\n`;
181
+ const SECRET_TEXT = `- source: ${MEMBER}\n target: ${MEMBER}\n`;
182
+
183
+ /** Adds `block` under the service's `key`, or opens the key with it when the service has none. */
184
+ function appendToService(edit, service, key, block, apply) {
185
+ if (edit.value.services[service][key] === undefined) {
186
+ edit.append(['services', service], `${key}:\n${block.replace(/\n$/, '').split('\n').map((line) => ` ${line}`).join('\n')}\n`);
187
+ edit.value.services[service][key] = apply(undefined);
188
+ } else {
189
+ edit.append(['services', service, key], block);
190
+ edit.value.services[service][key] = apply(edit.value.services[service][key]);
191
+ }
192
+ }
193
+
194
+ /** One service that runs the queue reaches the Redis: its address and password file, the secret and the internal network. */
195
+ function wireQueueService(edit, service) {
196
+ appendToService(edit, service, 'environment', QUEUE_ENVIRONMENT_TEXT, (environment) => Object.assign(environment ?? {}, QUEUE_ENVIRONMENT));
197
+ appendToService(edit, service, 'secrets', SECRET_TEXT, (secrets) => [...(secrets ?? []), { source: MEMBER, target: MEMBER }]);
198
+ edit.extendFlow(['services', service, 'networks'], 'backing');
199
+ edit.value.services[service].networks.push('backing');
200
+ }
201
+
202
+ /** The Swarm model additions: every service that runs the queue reaches the Redis on an internal network with the shared secret, the Redis persists AOF. */
203
+ function stackPatch(file, relative, { name, project, runtimeName, consumers }) {
204
+ const edit = new YamlBlockEdit(read(file), relative);
205
+ const model = edit.value;
206
+ for (const service of consumers) wireQueueService(edit, service);
207
+ const constraints = model.services[consumers[0]].deploy?.placement?.constraints ?? [];
208
+ const redis = {
209
+ image: REDIS_IMAGE,
210
+ command: ['sh', '-c', `exec redis-server --appendonly yes --appendfsync everysec --requirepass "$$(cat /run/secrets/${MEMBER})"`],
211
+ secrets: [{ source: MEMBER, target: MEMBER }],
212
+ volumes: ['redis-data:/data'],
213
+ networks: ['backing'],
214
+ healthcheck: { test: ['CMD-SHELL', `REDISCLI_AUTH="$$(cat /run/secrets/${MEMBER})" redis-cli ping | grep -q PONG`], interval: '30s', timeout: '5s', start_period: '10s', retries: 3 },
215
+ deploy: {
216
+ replicas: 1,
217
+ placement: { constraints },
218
+ update_config: { parallelism: 1, order: 'stop-first', failure_action: 'rollback', monitor: '30s' },
219
+ rollback_config: { parallelism: 1, order: 'stop-first' },
220
+ restart_policy: { condition: 'on-failure', delay: '5s' },
221
+ },
222
+ };
223
+ edit.append(['services'], [
224
+ 'redis:',
225
+ ` image: ${REDIS_IMAGE}`,
226
+ ` command: ${JSON.stringify(redis.command)}`,
227
+ ' secrets:',
228
+ ` - source: ${MEMBER}`,
229
+ ` target: ${MEMBER}`,
230
+ ' volumes:',
231
+ ' - redis-data:/data',
232
+ ' networks: [backing]',
233
+ ' healthcheck:',
234
+ ` test: ${JSON.stringify(redis.healthcheck.test)}`,
235
+ ' interval: 30s',
236
+ ' timeout: 5s',
237
+ ' start_period: 10s',
238
+ ' retries: 3',
239
+ ' deploy:',
240
+ ' replicas: 1',
241
+ ' placement:',
242
+ ` constraints: ${JSON.stringify(constraints)}`,
243
+ ' update_config: { parallelism: 1, order: stop-first, failure_action: rollback, monitor: 30s }',
244
+ ' rollback_config: { parallelism: 1, order: stop-first }',
245
+ ' restart_policy: { condition: on-failure, delay: 5s }',
246
+ '',
247
+ ].join('\n'));
248
+ model.services.redis = redis;
249
+ edit.append(['networks'], 'backing:\n driver: overlay\n internal: true\n');
250
+ model.networks.backing = { driver: 'overlay', internal: true };
251
+ edit.append(['secrets'], `${MEMBER}:\n external: true\n name: ${quoted(runtimeName)}\n`);
252
+ model.secrets[MEMBER] = { external: true, name: runtimeName };
253
+ edit.append(['volumes'], `redis-data:\n external: true\n name: ${quoted(`${project}-${name}-redis-data`)}\n`);
254
+ model.volumes['redis-data'] = { external: true, name: `${project}-${name}-redis-data` };
255
+ return { file, text: edit.text() };
256
+ }
257
+
258
+ function imagesLockPatch(file, relative) {
259
+ const edit = new YamlBlockEdit(read(file), relative);
260
+ edit.append(['images'], `redis: ${REDIS_IMAGE}\n`);
261
+ edit.value.images.redis = REDIS_IMAGE;
262
+ return { file, text: edit.text() };
263
+ }
264
+
265
+ const recoveryRecord = `component: "redis"
266
+ status: "pending-owner-restore-proof"
267
+ executedAt: null
268
+ plan: "Restore the encrypted redis-data volume backup in isolation, start redis-server with --appendonly yes on it and verify the queued and failed BullMQ jobs of every queue."
269
+ ownerAction: "Replace this record with the backup hash, isolated destination, timestamp and verification receipt before production."
270
+ `;
271
+
272
+ const KEY_ROWS = [
273
+ '| `QUEUE_REDIS_HOST` | API queue transport: the declared Redis service `redis` on the internal backing network |',
274
+ '| `QUEUE_REDIS_PORT` | API queue transport |',
275
+ '| `QUEUE_REDIS_PASSWORD` | API queue transport and the redis service (`requirepass`); reads `QUEUE_REDIS_PASSWORD_FILE` at `/run/secrets/redis-password`; holds no trailing newline |',
276
+ ];
277
+
278
+ /** The key roster gains the queue keys after the last row of its first table. */
279
+ function keysPatch(file) {
280
+ const lines = read(file).replace(/\r\n/g, '\n').split('\n');
281
+ const first = lines.findIndex((line) => line.startsWith('|'));
282
+ if (first < 0) return null;
283
+ let last = first;
284
+ while (lines[last + 1]?.startsWith('|')) last += 1;
285
+ lines.splice(last + 1, 0, ...KEY_ROWS);
286
+ return { file, text: lines.join('\n') };
287
+ }
288
+
289
+ /** Every stack file the declared Redis adds to or changes, planned before anything is written. */
290
+ function stackFiles(repoRoot, { project, consumers }) {
291
+ const manifestRelative = '.starcistacks/application-stacks.yaml';
292
+ const manifestFile = path.join(repoRoot, ...manifestRelative.split('/'));
293
+ if (!fs.existsSync(manifestFile)) return { patches: [], created: [], environments: [] };
294
+ const manifest = parseYaml(read(manifestFile));
295
+ if (manifest?.components?.redis) return { patches: [], created: [], environments: [] };
296
+ const swarm = Object.entries(manifest?.environments ?? {}).filter(([, environment]) => environment?.runtime === 'docker-swarm');
297
+ const edit = new YamlBlockEdit(read(manifestFile), manifestRelative);
298
+ edit.append(['components'], redisComponent);
299
+ edit.value.components.redis = { role: 'stateful', image: REDIS_IMAGE, compose: 'infra/swarm/stack.yaml', volumes: ['redis-data'], purpose: REDIS_PURPOSE, required: true };
300
+ const patches = [];
301
+ const created = [];
302
+ for (const [name, environment] of swarm) {
303
+ const compose = `.starcistacks/${name}/${environment.composeFiles?.[0] ?? 'infra/swarm/stack.yaml'}`;
304
+ const composeFile = path.join(repoRoot, ...compose.split('/'));
305
+ const services = queueServicesIn(composeFile, compose, consumers);
306
+ const { recovery, runtimeName } = declareEnvironment(edit, name, environment, { project, consumers: services });
307
+ patches.push(stackPatch(composeFile, compose, { name, project, runtimeName, consumers: services }));
308
+ const lock = environment.release?.imagesLock;
309
+ if (lock && fs.existsSync(path.join(repoRoot, ...lock.split('/')))) patches.push(imagesLockPatch(path.join(repoRoot, ...lock.split('/')), lock));
310
+ const keys = path.join(repoRoot, '.starcistacks', name, 'runtime', 'env', 'KEYS.md');
311
+ if (fs.existsSync(keys)) patches.push(keysPatch(keys));
312
+ if (!fs.existsSync(path.join(repoRoot, ...recovery.split('/')))) created.push({ relative: recovery, text: recoveryRecord });
313
+ }
314
+ patches.unshift({ file: manifestFile, text: edit.text() });
315
+ return { patches: patches.filter(Boolean), created, environments: swarm.map(([name]) => name) };
316
+ }
317
+
318
+ /**
319
+ * Plans the lite additions of `add queue|job` before any file is written; returns null outside lite or for another noun. The
320
+ * plan's apply() writes them and returns the app-relative paths it created or changed. A new migration regenerates the
321
+ * database types through `emitTypes` (the local Supabase stack, as `add table`); false is the `--no-types` seam.
322
+ */
323
+ export function planLiteQueue({ repoRoot, repo, noun, patterns, now, emitTypes = emitDbTypes }) {
324
+ if (repo.edition !== 'lite' || !['queue', 'job'].includes(noun)) return null;
325
+ const beRoot = path.join(repoRoot, 'be');
326
+ const migrations = migrationFiles({ repoRoot, beRoot, patterns, now });
327
+ const database = skeletonFiles(repoRoot);
328
+ const declaration = declarationPatches(repoRoot, repo.project);
329
+ const stack = stackFiles(repoRoot, { project: repo.project, consumers: queueServicesOf(repo) });
330
+ const created = [...migrations, ...database.created, ...stack.created];
331
+ const patches = [...database.patches, ...declaration, ...stack.patches];
332
+ const clashes = created.filter((entry) => fs.existsSync(path.join(repoRoot, ...entry.relative.split('/')))).map((entry) => entry.relative);
333
+ if (clashes.length) throw new ScaffoldError('HFS_ADD_EXISTS', `add ${noun} would overwrite ${clashes.join(', ')}`);
334
+ return {
335
+ apply() {
336
+ for (const entry of created) {
337
+ const target = path.join(repoRoot, ...entry.relative.split('/'));
338
+ fs.mkdirSync(path.dirname(target), { recursive: true });
339
+ fs.writeFileSync(target, entry.text);
340
+ }
341
+ for (const patch of patches) fs.writeFileSync(patch.file, patch.text);
342
+ const exported = registerLiteExports({ root: repoRoot, generator: 'queue' });
343
+ const sealed = sealRedisMembers(repoRoot, stack.environments);
344
+ const changed = patches.map((patch) => path.relative(repoRoot, patch.file).split(path.sep).join('/'));
345
+ const types = migrations.length && typeof emitTypes === 'function' ? writeTypes(repoRoot, emitTypes) : [];
346
+ return [...new Set([...created.map((entry) => entry.relative), ...changed, ...exported, ...sealed, ...types])];
347
+ },
348
+ };
349
+ }
350
+
351
+ /** The SQL of the new capabilities names public.queue and public.jobs, which the generated database types must declare. */
352
+ function writeTypes(repoRoot, emitTypes) {
353
+ const generated = emitTypes({ root: repoRoot });
354
+ if (typeof generated !== 'string') return [];
355
+ const target = path.join(repoRoot, ...dbTypesPath.split('/'));
356
+ fs.mkdirSync(path.dirname(target), { recursive: true });
357
+ fs.writeFileSync(target, generated);
358
+ return [dbTypesPath];
359
+ }
360
+
361
+ /** Seals a placeholder redis-password member to the app's configured recipient; without one, `starci app secret init` seals it. */
362
+ function sealRedisMembers(repoRoot, environments) {
363
+ if (!environments.length || !fs.existsSync(path.join(repoRoot, '.sops.yaml'))) return [];
364
+ const recipients = (env) => configuredRecipients(repoRoot, path.join(repoRoot, '.starcistacks', env, 'secrets', `${MEMBER}.enc`));
365
+ let single;
366
+ try { single = environments.every((env) => recipients(env).length === 1); } catch { single = false; }
367
+ if (!single) return [];
368
+ return materializeDeclaredPlaceholders(repoRoot, {
369
+ recipientsForFile: (file) => configuredRecipients(repoRoot, file),
370
+ only: (member) => member.slug === MEMBER && environments.includes(member.env),
371
+ }).members;
372
+ }