@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.
- package/CHANGELOG.md +12 -0
- package/README.md +13 -6
- package/emit/contracts.mjs +4 -3
- package/emit/form-bounds-reader.ts.partial +58 -0
- package/emit/form-bounds.mjs +155 -0
- package/emit/graphql-input-bounds.mjs +97 -0
- package/emit/input-bounds.mjs +44 -0
- package/emit/operation-schemas.mjs +137 -0
- package/emit/operations-worker.mjs +2 -2
- package/emit/operations.mjs +52 -16
- package/emit/schema-worker.mjs +17 -4
- package/emit/type-schema.mjs +17 -5
- package/package.json +9 -2
- package/release/gate.mjs +18 -23
- package/release/index.mjs +1 -1
- package/release/push.mjs +4 -5
- package/runtime/engine/admission.mjs +331 -0
- package/runtime/engine/canonical-json.mjs +14 -0
- package/runtime/engine/db/blob.mjs +380 -0
- package/runtime/engine/db/event-compact.mjs +73 -0
- package/runtime/engine/db/ledger-open.mjs +38 -0
- package/runtime/engine/db/ledger-paths.mjs +87 -0
- package/runtime/engine/db/ledger.mjs +1068 -0
- package/runtime/engine/db/machine-connection.mjs +150 -0
- package/runtime/engine/db/machine-open.mjs +60 -0
- package/runtime/engine/db/machine-schema.mjs +105 -0
- package/runtime/engine/db/machine.mjs +1317 -0
- package/runtime/engine/db/provider-reservations.mjs +127 -0
- package/runtime/engine/db/provision-authority.mjs +76 -0
- package/runtime/engine/db/provision-lock.mjs +29 -0
- package/runtime/engine/db/provision-operations.mjs +146 -0
- package/runtime/engine/db/ref-value.mjs +67 -0
- package/runtime/engine/db/schema/machine.sql +920 -0
- package/runtime/engine/db/schema/runtime.sql +1113 -0
- package/runtime/engine/refuse.mjs +11 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/design-truths.yaml +149 -0
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/rules.yaml +269 -48
- package/runtime/knowledge/hfs/slots.yaml +113 -53
- package/runtime/knowledge/patterns/be/api.yaml +46 -20
- package/runtime/knowledge/patterns/be/event-bus.yaml +19 -12
- package/runtime/knowledge/patterns/be/idempotency.yaml +265 -0
- package/runtime/knowledge/patterns/be/jobs.yaml +17 -12
- package/runtime/knowledge/patterns/be/projections.yaml +2 -2
- package/runtime/knowledge/patterns/be/queues.yaml +57 -22
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +326 -27
- package/runtime/knowledge/patterns/be/saga.yaml +2 -2
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +249 -0
- package/runtime/modules/kernel/failure-codes.yaml +211 -52
- package/runtime/modules/kernel/hook-input.yaml +6 -0
- package/runtime/modules/kernel/protected-zone.yaml +81 -0
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/kernel/roles.yaml +405 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +10 -2
- package/runtime/packages/cli/src/catalog.generated.mjs +46 -59
- package/runtime/packages/grammar/scripts/build-stamp.mjs +98 -0
- package/runtime/scripts/agent/codex-app-server.mjs +79 -0
- package/runtime/scripts/agent/codex-mirror-source.mjs +34 -0
- package/runtime/scripts/agent/host-agents.mjs +80 -0
- package/runtime/scripts/agent/launch-trust-policy.mjs +53 -0
- package/runtime/scripts/agent/trust-launch.mjs +191 -0
- package/runtime/scripts/agent/trust.mjs +523 -0
- package/runtime/scripts/api/fs/rename-over.mjs +21 -0
- package/runtime/scripts/api/fs/temp-path.mjs +8 -0
- package/runtime/scripts/api/fs/write-json-file.mjs +10 -0
- package/runtime/scripts/api/git/diff-names.mjs +8 -0
- package/runtime/scripts/api/git/head-time.mjs +10 -0
- package/runtime/scripts/api/git/index-lock.mjs +78 -0
- package/runtime/scripts/api/git/is-ancestor.mjs +5 -0
- package/runtime/scripts/api/git/log.mjs +5 -0
- package/runtime/scripts/api/git/merge-base-query.mjs +5 -0
- package/runtime/scripts/api/git/merge-tree.mjs +5 -0
- package/runtime/scripts/api/git/rev-list.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse.mjs +13 -0
- package/runtime/scripts/api/git/status-query.mjs +5 -0
- package/runtime/scripts/api/git/worktree-list-query.mjs +5 -0
- package/runtime/scripts/api/node/spawn-node.mjs +6 -0
- package/runtime/scripts/api/npm/dist-tag.mjs +8 -0
- package/runtime/scripts/api/npm/lib.mjs +32 -0
- package/runtime/scripts/api/npm/pack-dry-run.mjs +9 -0
- package/runtime/scripts/api/npm/run-npm.mjs +6 -0
- package/runtime/scripts/api/npm/view.mjs +30 -0
- package/runtime/scripts/api/npm/whoami.mjs +8 -0
- package/runtime/scripts/api/process/hide-child-windows.mjs +31 -0
- package/runtime/scripts/api/process/kill-tree.mjs +22 -0
- package/runtime/scripts/api/process/lib.mjs +125 -0
- package/runtime/scripts/api/process/probe-guard-command.mjs +74 -0
- package/runtime/scripts/api/process/process-list.mjs +28 -0
- package/runtime/scripts/api/process/system-tool.mjs +37 -0
- package/runtime/scripts/gates/canon-digest.mjs +81 -0
- package/runtime/scripts/gates/canon-pins.mjs +16 -0
- package/runtime/scripts/gates/commit-proof.mjs +53 -0
- package/runtime/scripts/gates/gate-input.mjs +98 -0
- package/runtime/scripts/gates/gate.mjs +669 -0
- package/runtime/scripts/gates/grammar-dist.mjs +206 -0
- package/runtime/scripts/gates/runtime-package-selection.mjs +27 -0
- package/runtime/scripts/gates/test-world-run.mjs +254 -0
- package/runtime/scripts/gates/type-impact.mjs +277 -0
- package/runtime/scripts/gates/type-program.mjs +113 -0
- package/runtime/scripts/guards/assigned-command.mjs +12 -0
- package/runtime/scripts/guards/call-rights.mjs +47 -0
- package/runtime/scripts/guards/command-guard.mjs +468 -0
- package/runtime/scripts/guards/command-policy.mjs +342 -0
- package/runtime/scripts/guards/critic-reach.mjs +82 -0
- package/runtime/scripts/guards/deps-guard.mjs +150 -0
- package/runtime/scripts/guards/env-dump-verdict.mjs +89 -0
- package/runtime/scripts/guards/git-pathspec.mjs +225 -0
- package/runtime/scripts/guards/git-policy.mjs +355 -0
- package/runtime/scripts/guards/guards-root.mjs +27 -0
- package/runtime/scripts/guards/hook-install.mjs +343 -0
- package/runtime/scripts/guards/hook-io.mjs +76 -0
- package/runtime/scripts/guards/hook-verdict.mjs +12 -0
- package/runtime/scripts/guards/install-verdict.mjs +41 -0
- package/runtime/scripts/guards/kernel-seat.mjs +63 -0
- package/runtime/scripts/guards/launch-verdict.mjs +74 -0
- package/runtime/scripts/guards/op-starci.mjs +16 -0
- package/runtime/scripts/guards/op-write-scope.mjs +27 -0
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +39 -0
- package/runtime/scripts/guards/process-kill-verdict.mjs +106 -0
- package/runtime/scripts/guards/protected-zone.mjs +56 -0
- package/runtime/scripts/guards/refusals.mjs +27 -0
- package/runtime/scripts/guards/release-definition.mjs +185 -0
- package/runtime/scripts/guards/release-push-gate.mjs +72 -0
- package/runtime/scripts/guards/release-record.mjs +58 -0
- package/runtime/scripts/guards/release-suite-mode.mjs +15 -0
- package/runtime/scripts/guards/rights.mjs +268 -0
- package/runtime/scripts/guards/seat-tools.mjs +47 -0
- package/runtime/scripts/guards/shell-commands.mjs +328 -0
- package/runtime/scripts/guards/supervisor-seat.mjs +80 -0
- package/runtime/scripts/guards/verdict-process.mjs +81 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +6 -4
- package/runtime/scripts/hfs/coverage-scope.mjs +28 -1
- package/runtime/scripts/hfs/db-restamp-verb.mjs +54 -14
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
- package/runtime/scripts/hfs/rules/a15-findings.mjs +4 -1
- package/runtime/scripts/hfs/rules/bypass-markers.mjs +22 -7
- package/runtime/scripts/hfs/rules/database-migrations.mjs +69 -14
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +9 -7
- package/runtime/scripts/hfs/rules/database-queues.mjs +68 -18
- package/runtime/scripts/hfs/rules/edition.mjs +56 -12
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +1 -10
- package/runtime/scripts/hfs/rules/fe-stack.mjs +12 -7
- package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +75 -9
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +15 -4
- package/runtime/scripts/hfs/rules/text-integrity.mjs +73 -0
- package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +74 -0
- package/runtime/scripts/hfs/runtime-rules/release-notes.mjs +44 -0
- package/runtime/scripts/hfs/sealed-members.mjs +3 -3
- package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
- package/runtime/scripts/hfs/slots.mjs +2 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -5
- package/runtime/scripts/hfs/stacks-gate.mjs +28 -7
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/app-data.mjs +11 -0
- package/runtime/scripts/lib/bypass-markers.mjs +272 -75
- package/runtime/scripts/lib/check-format.mjs +7 -0
- package/runtime/scripts/lib/cli-arg.mjs +120 -0
- package/runtime/scripts/lib/css-rules.mjs +220 -0
- package/runtime/scripts/lib/dom-tree.mjs +4 -0
- package/runtime/scripts/lib/escape.mjs +16 -0
- package/runtime/scripts/lib/event-items.mjs +22 -0
- package/runtime/scripts/lib/failure-code-template-errors.mjs +161 -0
- package/runtime/scripts/lib/git-dir.mjs +120 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +377 -0
- package/runtime/scripts/lib/guard-command.mjs +24 -0
- package/runtime/scripts/lib/hook-limits.mjs +5 -0
- package/runtime/scripts/lib/interrupt-cleanup.mjs +38 -0
- package/runtime/scripts/lib/js-lex.mjs +200 -0
- package/runtime/scripts/lib/json-formats.mjs +0 -1
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/loosening-budgets.mjs +44 -0
- package/runtime/scripts/lib/loosening-config.mjs +361 -0
- package/runtime/scripts/lib/loosening-state.mjs +223 -0
- package/runtime/scripts/lib/menu-parts.mjs +11 -0
- package/runtime/scripts/lib/normalize.mjs +25 -0
- package/runtime/scripts/lib/op-gate.mjs +15 -0
- package/runtime/scripts/lib/redact.mjs +208 -0
- package/runtime/scripts/lib/runtime-location.mjs +21 -0
- package/runtime/scripts/lib/runtime-package-inventory.mjs +21 -0
- package/runtime/scripts/lib/runtime-package-manifests.mjs +84 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +99 -0
- package/runtime/scripts/lib/seat-env.mjs +43 -0
- package/runtime/scripts/lib/secret-patterns.mjs +58 -9
- package/runtime/scripts/lib/shell-source.mjs +4 -0
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/sqlite.mjs +21 -0
- package/runtime/scripts/lib/swarm-stack.mjs +2 -1
- package/runtime/scripts/lib/tar-files.mjs +39 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/runtime/scripts/lib/worktree-exclude.mjs +35 -0
- package/runtime/scripts/lib/worktree-record.mjs +14 -0
- package/runtime/scripts/machine/artifact-hold.mjs +52 -0
- package/runtime/scripts/machine/ask-receipts.mjs +113 -0
- package/runtime/scripts/machine/evidence-store.mjs +223 -0
- package/runtime/scripts/machine/home.mjs +216 -0
- package/runtime/scripts/machine/host-lock.mjs +184 -0
- package/runtime/scripts/machine/lock-recovery.mjs +55 -0
- package/runtime/scripts/machine/roles-contract.mjs +90 -0
- package/runtime/scripts/machine/runtime-change.mjs +20 -0
- package/runtime/scripts/machine/workflow-tree.mjs +122 -0
- package/runtime/scripts/reconciler/checklist-items.mjs +7 -0
- package/runtime/scripts/reconciler/guard-row.mjs +25 -0
- package/runtime/scripts/work/asset-slot.mjs +284 -0
- package/runtime/scripts/work/brand/brand-colour.mjs +125 -0
- package/runtime/scripts/work/brand/brand-tokens.mjs +167 -0
- package/runtime/scripts/work/brand/brand-value-source.mjs +106 -0
- package/runtime/scripts/work/brand/brand.mjs +892 -0
- package/runtime/scripts/work/draw/draw-dna-anatomy.mjs +60 -0
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +256 -0
- package/runtime/scripts/work/draw/draw-dna-selector.mjs +38 -0
- package/runtime/scripts/work/draw/draw-dna.mjs +483 -0
- package/runtime/scripts/work/draw/draw-layer.mjs +305 -0
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +237 -0
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +14 -0
- package/runtime/scripts/work/draw/draw-rationale.mjs +353 -0
- package/runtime/scripts/work/draw/draw-source.mjs +412 -0
- package/runtime/scripts/work/draw/draw-taste.mjs +278 -0
- package/runtime/scripts/work/draw/draw-typecheck.mjs +72 -0
- package/runtime/scripts/work/draw/rationale-findings.mjs +205 -0
- package/runtime/scripts/work/draw-grammar.mjs +175 -0
- package/runtime/scripts/work/draw-render-page.mjs +188 -0
- package/runtime/scripts/work/draw-render.mjs +626 -0
- package/runtime/scripts/work/grammar-proposal.mjs +279 -0
- package/runtime/scripts/work/png.mjs +237 -0
- package/runtime/scripts/work/render-tools.mjs +64 -0
- package/runtime/scripts/work/work-io.mjs +119 -0
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-plan.mjs +40 -13
- package/scaffold/add-queue-lite.mjs +372 -0
- package/scaffold/add.mjs +5 -2
- package/scaffold/app.mjs +1 -2
- package/scaffold/edition-gate.mjs +11 -7
- package/scaffold/lite-exports.mjs +10 -0
- package/scaffold/pattern-file-slots.mjs +33 -0
- package/scaffold/test-world.mjs +1 -1
- package/scaffold/yaml-block-edit.mjs +117 -0
- package/src/main.mjs +5 -4
- package/stack/deploy.mjs +7 -3
- package/stack/post-deploy.mjs +48 -0
- package/sync/index.mjs +10 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +1 -1
- package/templates/app/codegen-graphql.mjs.partial +59 -12
- package/templates/app/hooks/husky/pre-commit +2 -2
- package/templates/app/hooks/husky/pre-push +10 -3
- package/templates/app/package-scripts/redis-dev.json +3 -0
- package/templates/app/package-scripts-lite/package.json +1 -0
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +36 -8
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +10 -9
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/postgres-init.sh +21 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +1 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +47 -5
- package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +5 -3
- package/templates/app/skeleton/README.md +2 -0
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/README.md +1 -1
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +18 -0
- package/templates/be/inbox/platform/event-bus/errors/event-bus.error.ts.tpl +16 -0
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +10 -0
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +15 -3
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +11 -3
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +2 -0
- package/templates/be/inbox/platform/event-bus/messages/event-bus.messages.ts.tpl +7 -0
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +2 -2
- package/templates/be/inbox/platform/idempotency/errors/idempotency.error.ts.tpl +19 -0
- package/templates/be/inbox/platform/idempotency/index.ts.tpl +2 -0
- package/templates/be/inbox/platform/idempotency/messages/idempotency.messages.ts.tpl +13 -0
- package/templates/be/inbox/platform/inbox/client-operation.policy.spec.ts.tpl +32 -0
- package/templates/be/inbox/platform/inbox/client-operation.policy.ts.tpl +17 -0
- package/templates/be/inbox/platform/inbox/errors/inbox.error.ts.tpl +22 -0
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +21 -0
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +334 -11
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +55 -4
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -0
- package/templates/be/inbox/platform/inbox/messages/inbox.messages.ts.tpl +15 -0
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +33 -0
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +26 -0
- package/templates/be/inbox/platform/inbox/request-fingerprint.policy.spec.ts.tpl +76 -0
- package/templates/be/inbox/platform/inbox/request-fingerprint.policy.ts.tpl +36 -0
- package/templates/be/inbox/platform/migration.sql.tpl +1 -0
- package/templates/be/inbox/service.spec.ts.tpl +4 -4
- package/templates/be/inbox/service.ts.tpl +3 -1
- package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -2
- package/templates/be/inbox/webhook-door.ts.tpl +3 -2
- package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +3 -0
- package/templates/be/patterns/event-bus/platform/event-bus.contracts.ts.tpl +10 -0
- package/templates/be/patterns/event-bus/platform/event-bus.port.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-bus.service.spec.ts.tpl +16 -3
- package/templates/be/patterns/event-bus/platform/event-bus.service.ts.tpl +11 -3
- package/templates/be/patterns/event-bus/platform/event-envelope.policy.spec.ts.tpl +139 -50
- package/templates/be/patterns/event-bus/platform/event-envelope.policy.ts.tpl +25 -2
- package/templates/be/patterns/event-bus/platform/event-relay.service.spec.ts.tpl +139 -78
- package/templates/be/patterns/event-bus/platform/event-relay.service.ts.tpl +10 -6
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +262 -159
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +32 -5
- package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +2 -0
- package/templates/be/patterns/event-bus/platform/persistence/entities/event-outbox.entity.ts.tpl +5 -1
- package/templates/be/patterns/event-bus/platform/persistence/event-bus.rows.ts.tpl +2 -0
- package/templates/be/patterns/event-bus/platform/persistence/event-bus.sql.ts.tpl +4 -4
- package/templates/be/patterns/event-bus/platform/persistence/migrations/1789800009000-create-event-outbox.ts.tpl +1 -0
- package/templates/be/patterns/idempotency/platform/idempotency/errors/idempotency.error.ts.tpl +19 -0
- package/templates/be/patterns/idempotency/platform/idempotency/index.ts.tpl +2 -0
- package/templates/be/patterns/idempotency/platform/idempotency/messages/idempotency.messages.ts.tpl +13 -0
- package/templates/be/patterns/idempotency/platform/inbox/client-operation.policy.spec.ts.tpl +32 -0
- package/templates/be/patterns/idempotency/platform/inbox/client-operation.policy.ts.tpl +17 -0
- package/templates/be/patterns/idempotency/platform/inbox/client-outcome-backfill.client.spec.ts.tpl +66 -0
- package/templates/be/patterns/idempotency/platform/inbox/client-outcome-backfill.client.ts.tpl +24 -0
- package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-backfill.client.spec.ts.tpl +60 -0
- package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-backfill.client.ts.tpl +12 -0
- package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-release.client.spec.ts.tpl +49 -0
- package/templates/be/patterns/idempotency/platform/inbox/delivery-claim-release.client.ts.tpl +12 -0
- package/templates/be/patterns/idempotency/platform/inbox/effectful.decorators.ts.tpl +7 -0
- package/templates/be/patterns/idempotency/platform/inbox/errors/inbox.error.ts.tpl +22 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.decorators.ts.tpl +17 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.module-definition.ts.tpl +7 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.module.ts.tpl +28 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.options.ts.tpl +7 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +44 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.service.spec.ts.tpl +388 -0
- package/templates/be/patterns/idempotency/platform/inbox/inbox.service.ts.tpl +77 -0
- package/templates/be/patterns/idempotency/platform/inbox/index.ts.tpl +10 -0
- package/templates/be/patterns/idempotency/platform/inbox/messages/inbox.messages.ts.tpl +15 -0
- package/templates/be/patterns/idempotency/platform/inbox/persistence/connection.ts.tpl +9 -0
- package/templates/be/patterns/idempotency/platform/inbox/persistence/entities/inbox-claim.entity.ts.tpl +25 -0
- package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.rows.ts.tpl +33 -0
- package/templates/be/patterns/idempotency/platform/inbox/persistence/inbox.sql.ts.tpl +39 -0
- package/templates/be/patterns/idempotency/platform/inbox/persistence/migrations/1789800005000-create-inbox-claims.ts.tpl +21 -0
- package/templates/be/patterns/idempotency/platform/inbox/persistence/migrations/1792800000000-client-operation-outcomes.ts.tpl +21 -0
- package/templates/be/patterns/idempotency/platform/inbox/request-fingerprint.policy.spec.ts.tpl +76 -0
- package/templates/be/patterns/idempotency/platform/inbox/request-fingerprint.policy.ts.tpl +36 -0
- package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +1 -0
- package/templates/be/patterns/jobs/platform/job-claim.service.spec.ts.tpl +4 -3
- package/templates/be/patterns/jobs/platform/job-claim.service.ts.tpl +2 -0
- package/templates/be/patterns/jobs/platform/job-runner.service.spec.ts.tpl +20 -2
- package/templates/be/patterns/jobs/platform/job-runner.service.ts.tpl +1 -0
- package/templates/be/patterns/jobs/platform/jobs.contracts.ts.tpl +4 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.index.ts.tpl +10 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +33 -0
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +203 -18
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.ts.tpl +78 -22
- package/templates/be/patterns/queues/platform/errors/queue.error.ts.tpl +19 -0
- package/templates/be/patterns/queues/platform/messages/queue.messages.ts.tpl +13 -0
- package/templates/be/patterns/queues/platform/persistence.migration.ts.tpl +1 -0
- package/templates/be/patterns/queues/platform/persistence.queue-outbox.entity.ts.tpl +4 -0
- package/templates/be/patterns/queues/platform/persistence.queue.rows.ts.tpl +2 -0
- package/templates/be/patterns/queues/platform/persistence.queue.sql.ts.tpl +4 -4
- package/templates/be/patterns/queues/platform/queue-outbox.service.spec.ts.tpl +14 -2
- package/templates/be/patterns/queues/platform/queue-outbox.service.ts.tpl +5 -2
- package/templates/be/patterns/queues/platform/queue-relay.service.spec.ts.tpl +72 -74
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue-transport.port.ts.tpl +23 -8
- package/templates/be/patterns/queues/platform/queue-worker.service.spec.ts.tpl +29 -8
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +7 -3
- package/templates/be/patterns/queues/platform/queue.contracts.ts.tpl +5 -0
- package/templates/be/patterns/queues/platform/queue.index.ts.tpl +2 -0
- package/templates/be/patterns/queues/platform/queue.log-events.ts.tpl +4 -0
- package/templates/be/patterns/queues/platform/queue.module.ts.tpl +2 -1
- package/templates/be/patterns/queues/platform/queue.port.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/bullmq-queue-transport.client.spec.ts.tpl +417 -0
- package/templates/be/patterns/queues/platform-lite/bullmq-queue-transport.client.ts.tpl +172 -0
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +12 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +32 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +148 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-transport.port.ts.tpl +163 -0
- package/templates/be/patterns/queues/platform-lite/queue.config.ts.tpl +17 -0
- package/templates/be/patterns/queues/platform-lite/queue.error.ts.tpl +25 -0
- package/templates/be/patterns/queues/platform-lite/queue.index.ts.tpl +8 -0
- package/templates/be/patterns/queues/platform-lite/queue.messages.ts.tpl +21 -0
- package/templates/be/patterns/queues/platform-lite/queue.module.ts.tpl +74 -0
- package/templates/be/patterns/queues/platform-lite/queue.options.ts.tpl +38 -0
- package/templates/be/patterns/queues/queue.queue.ts.tpl +2 -2
- package/templates/be/patterns/reactors/application.contracts.ts.tpl +2 -0
- package/templates/be/patterns/reactors/consumer.ts.tpl +5 -1
- package/templates/be/platform/graphql/graphql.contracts.ts +2 -1
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +6 -1
- package/templates/be/skeleton/apps/__app__/src/main.ts +7 -4
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +4 -0
- package/templates/be/skeleton/apps/cli/src/main.ts +7 -4
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http/errors/http.error.ts +22 -0
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +235 -0
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +85 -0
- package/templates/be/skeleton/src/modules/platform/http/graphql-call.mapper.spec.ts +92 -0
- package/templates/be/skeleton/src/modules/platform/http/graphql-call.mapper.ts +52 -0
- package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/http/http.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/http/http.port.ts +40 -0
- package/templates/be/skeleton/src/modules/platform/http/index.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/messages/http.messages.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/ids/ids.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/ids/ids.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/ids/ids.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/ids/ids.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/ids/ids.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/ids/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/ids/system-ids.service.spec.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/ids/system-ids.service.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation-request.policy.spec.ts +59 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation-request.policy.ts +21 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation.contracts.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation.policy.spec.ts +150 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation.policy.ts +42 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation.port.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation.service.spec.ts +23 -0
- package/templates/be/skeleton/src/modules/platform/logging/correlation.service.ts +28 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +14 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +39 -6
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +15 -3
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +17 -2
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +13 -3
- package/templates/be/skeleton/src/modules/platform/logging/nest-logger.service.spec.ts +69 -0
- package/templates/be/skeleton/src/modules/platform/logging/nest-logger.service.ts +48 -0
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/record.policy.spec.ts +12 -0
- package/templates/be/skeleton/src/modules/platform/primitives/record.policy.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +6 -1
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +14 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/be/test-world/world/test-apps.options.ts.tpl +2 -0
- package/templates/be/test-world/world/test-world.config.ts.tpl +1 -0
- package/upgrade/check.mjs +12 -135
- package/upgrade/file-state.mjs +1 -16
- package/upgrade/index.mjs +34 -361
- package/runtime/scripts/lib/check-code-loosening.mjs +0 -128
- package/runtime/scripts/lib/gate-loosening.mjs +0 -292
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +0 -89
- package/templates/app/upgrade-full/.starcistacks/dev/README.md +0 -16
- package/templates/app/upgrade-full/.starcistacks/dev/infra/compose/api.yaml +0 -13
- package/templates/app/upgrade-full/.starcistacks/dev/infra/compose/compose.yaml +0 -3
- package/templates/app/upgrade-full/.starcistacks/dev/infra/compose/postgres.yaml +0 -12
- package/templates/app/upgrade-full/.starcistacks/dev/infra/metadata.json +0 -7
- package/templates/be/skeleton-lite/apps/api/src/main.ts +0 -26
- package/templates/be/upgrade-full/apps/cli/src/app.module.ts +0 -12
- package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +0 -7
- package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +0 -5
- package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +0 -9
- package/templates/be/upgrade-full/apps/cli/src/main.ts +0 -29
- package/templates/be/upgrade-full/src/features/cli/cli.module.ts +0 -7
- 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({
|
package/scaffold/add-plan.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|