@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,388 @@
|
|
|
1
|
+
import { BATCH_ROWS } from "@modules/platform/database"
|
|
2
|
+
import { IdempotencyErrorCode } from "@modules/platform/idempotency"
|
|
3
|
+
import { Test } from "@nestjs/testing"
|
|
4
|
+
import type { ClientOperation } from "./inbox.port"
|
|
5
|
+
import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
6
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
7
|
+
import { CLAIM_MANAGERS } from "./inbox.decorators"
|
|
8
|
+
import { PostgresInbox } from "./inbox.service"
|
|
9
|
+
import { InboxError, InboxErrorCode } from "./errors/inbox.error"
|
|
10
|
+
import { requestFingerprint } from "./request-fingerprint.policy"
|
|
11
|
+
import { CLAIM_CLIENT, CLAIM_EVENT, RELEASE_EVENT, SAVE_CLIENT_OUTCOME, SWEEP_CLIENTS } from "./persistence/inbox.sql"
|
|
12
|
+
|
|
13
|
+
const runClient = <T>(
|
|
14
|
+
inbox: PostgresInbox,
|
|
15
|
+
request: ClientOperation,
|
|
16
|
+
manager: ReturnType<typeof mockEntityManager>,
|
|
17
|
+
effect: () => Promise<T>,
|
|
18
|
+
): Promise<T> => fakeTransaction(manager).em.transaction((tx) => inbox.runClient(request, tx, effect))
|
|
19
|
+
|
|
20
|
+
const REQUEST = {
|
|
21
|
+
principalId: "person-1",
|
|
22
|
+
operation: "checkout.place",
|
|
23
|
+
idempotencyKey: "key-1",
|
|
24
|
+
request: { total: 2400 },
|
|
25
|
+
}
|
|
26
|
+
const OUTCOME = { kind: "ok", value: { orderId: "order-1" } }
|
|
27
|
+
|
|
28
|
+
const build = async (manager: ReturnType<typeof mockEntityManager>) => {
|
|
29
|
+
const clock = new FakeClock("2026-02-03T04:05:06.000Z")
|
|
30
|
+
const moduleRef = await Test.createTestingModule({
|
|
31
|
+
providers: [
|
|
32
|
+
PostgresInbox,
|
|
33
|
+
{ provide: CLAIM_MANAGERS, useValue: [manager] },
|
|
34
|
+
{ provide: CLOCK, useValue: clock },
|
|
35
|
+
],
|
|
36
|
+
}).compile()
|
|
37
|
+
return moduleRef.get(PostgresInbox)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("PostgresInbox claim", () => {
|
|
41
|
+
it("answers true for the first claim, stamped with the clock", async () => {
|
|
42
|
+
const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "e-1" }]] })
|
|
43
|
+
const inbox = await build(manager)
|
|
44
|
+
|
|
45
|
+
await expect(inbox.claim("payments", "e-1")).resolves.toBe(true)
|
|
46
|
+
expect(manager.query).toHaveBeenCalledWith(CLAIM_EVENT, [
|
|
47
|
+
"payments",
|
|
48
|
+
"e-1",
|
|
49
|
+
new FakeClock("2026-02-03T04:05:06.000Z").now(),
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("uses the supplied transaction manager for the claim query without reaching the configured manager", async () => {
|
|
54
|
+
const configured = mockEntityManager()
|
|
55
|
+
const tx = fakeTransaction(mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "e-1" }]] }))
|
|
56
|
+
const inbox = await build(configured)
|
|
57
|
+
|
|
58
|
+
await expect(tx.em.transaction((manager) => inbox.claim("payments", "e-1", manager))).resolves.toBe(true)
|
|
59
|
+
|
|
60
|
+
const params = ["payments", "e-1", new FakeClock("2026-02-03T04:05:06.000Z").now()]
|
|
61
|
+
expect(tx.em.query).toHaveBeenCalledWith(CLAIM_EVENT, params)
|
|
62
|
+
expect(configured.query).not.toHaveBeenCalled()
|
|
63
|
+
expect(tx.committedWrites).toEqual([{ method: "query", args: [CLAIM_EVENT, params] }])
|
|
64
|
+
expect(tx.commits).toBe(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("answers false for a duplicate through the supplied manager without falling back to the configured manager", async () => {
|
|
68
|
+
const configured = mockEntityManager()
|
|
69
|
+
const supplied = mockEntityManager({ query: [CLAIM_EVENT, []] })
|
|
70
|
+
const inbox = await build(configured)
|
|
71
|
+
|
|
72
|
+
await expect(inbox.claim("payments", "e-1", supplied)).resolves.toBe(false)
|
|
73
|
+
|
|
74
|
+
expect(supplied.query).toHaveBeenCalledWith(CLAIM_EVENT, [
|
|
75
|
+
"payments",
|
|
76
|
+
"e-1",
|
|
77
|
+
new FakeClock("2026-02-03T04:05:06.000Z").now(),
|
|
78
|
+
])
|
|
79
|
+
expect(configured.query).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("propagates the supplied manager query error so its transaction rolls back without root fallback", async () => {
|
|
83
|
+
const failure = new Error("claim statement failed")
|
|
84
|
+
const configured = mockEntityManager()
|
|
85
|
+
const tx = fakeTransaction(mockEntityManager({ query: [CLAIM_EVENT, []] }))
|
|
86
|
+
tx.em.query.mockRejectedValueOnce(failure)
|
|
87
|
+
const inbox = await build(configured)
|
|
88
|
+
|
|
89
|
+
await expect(tx.em.transaction((manager) => inbox.claim("payments", "e-1", manager))).rejects.toBe(failure)
|
|
90
|
+
|
|
91
|
+
expect(configured.query).not.toHaveBeenCalled()
|
|
92
|
+
expect(tx.rollbacks).toBe(1)
|
|
93
|
+
expect(tx.commits).toBe(0)
|
|
94
|
+
expect(tx.committedWrites).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("answers false when the event was claimed before", async () => {
|
|
98
|
+
const inbox = await build(mockEntityManager({ query: [CLAIM_EVENT, []] }))
|
|
99
|
+
|
|
100
|
+
await expect(inbox.claim("payments", "e-1")).resolves.toBe(false)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("PostgresInbox release", () => {
|
|
105
|
+
it("deletes the claim of the event", async () => {
|
|
106
|
+
const manager = mockEntityManager({ query: [RELEASE_EVENT, []] })
|
|
107
|
+
const inbox = await build(manager)
|
|
108
|
+
|
|
109
|
+
await expect(inbox.release("payments", "e-1")).resolves.toBeUndefined()
|
|
110
|
+
expect(manager.query).toHaveBeenCalledWith(RELEASE_EVENT, ["payments", "e-1"])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("PostgresInbox runClient admission", () => {
|
|
115
|
+
it("refuses a manager outside an active transaction before claiming or applying an effect", async () => {
|
|
116
|
+
const inbox = await build(mockEntityManager())
|
|
117
|
+
const manager = mockEntityManager()
|
|
118
|
+
await expect(inbox.runClient(REQUEST, manager, () => Promise.resolve(OUTCOME))).rejects.toMatchObject({
|
|
119
|
+
code: InboxErrorCode.TransactionRequired,
|
|
120
|
+
})
|
|
121
|
+
expect(manager.query).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("refuses an existing incomplete claim instead of applying the effect again", async () => {
|
|
125
|
+
const manager = mockEntityManager({
|
|
126
|
+
query: [
|
|
127
|
+
CLAIM_CLIENT,
|
|
128
|
+
[{ request_fingerprint: requestFingerprint(REQUEST.request), outcome: {}, incomplete: true }],
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
const inbox = await build(mockEntityManager())
|
|
132
|
+
let calls = 0
|
|
133
|
+
await expect(
|
|
134
|
+
runClient(inbox, REQUEST, manager, () => {
|
|
135
|
+
calls += 1
|
|
136
|
+
return Promise.resolve(OUTCOME)
|
|
137
|
+
}),
|
|
138
|
+
).rejects.toMatchObject({ code: InboxErrorCode.OutcomeMissing })
|
|
139
|
+
expect(calls).toBe(0)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe("PostgresInbox runClient persistence", () => {
|
|
144
|
+
it("claims before its callback and returns the persisted outcome through the same transaction", async () => {
|
|
145
|
+
const configured = mockEntityManager()
|
|
146
|
+
const manager = mockEntityManager()
|
|
147
|
+
const fingerprint = requestFingerprint(REQUEST.request)
|
|
148
|
+
manager.query
|
|
149
|
+
.mockResolvedValueOnce([{ request_fingerprint: fingerprint, outcome: null }])
|
|
150
|
+
.mockResolvedValueOnce([{ outcome: { value: OUTCOME } }])
|
|
151
|
+
const transaction = fakeTransaction(manager)
|
|
152
|
+
const inbox = await build(configured)
|
|
153
|
+
|
|
154
|
+
const outcome = await transaction.em.transaction((tx) =>
|
|
155
|
+
inbox.runClient(REQUEST, tx, () => {
|
|
156
|
+
expect(transaction.em.query).toHaveBeenCalledTimes(1)
|
|
157
|
+
return Promise.resolve(OUTCOME)
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
expect(outcome).toEqual(OUTCOME)
|
|
162
|
+
expect(configured.query).not.toHaveBeenCalled()
|
|
163
|
+
expect(transaction.em.query).toHaveBeenNthCalledWith(1, CLAIM_CLIENT, [
|
|
164
|
+
"client:person-1:checkout.place",
|
|
165
|
+
"key-1",
|
|
166
|
+
fingerprint,
|
|
167
|
+
new FakeClock("2026-02-03T04:05:06.000Z").now(),
|
|
168
|
+
])
|
|
169
|
+
expect(transaction.em.query).toHaveBeenNthCalledWith(2, SAVE_CLIENT_OUTCOME, [
|
|
170
|
+
"client:person-1:checkout.place",
|
|
171
|
+
"key-1",
|
|
172
|
+
JSON.stringify({ value: OUTCOME }),
|
|
173
|
+
])
|
|
174
|
+
expect(transaction.commits).toBe(1)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe("PostgresInbox runClient replay", () => {
|
|
179
|
+
it.each([OUTCOME, { kind: "refused", code: "ORDER_EMPTY_CART" }, null])(
|
|
180
|
+
"replays the stored JSON outcome %p without running the callback",
|
|
181
|
+
async (outcome) => {
|
|
182
|
+
const manager = mockEntityManager({
|
|
183
|
+
query: [
|
|
184
|
+
CLAIM_CLIENT,
|
|
185
|
+
[
|
|
186
|
+
{
|
|
187
|
+
request_fingerprint: requestFingerprint(REQUEST.request),
|
|
188
|
+
outcome: { value: outcome },
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
const inbox = await build(mockEntityManager())
|
|
194
|
+
let calls = 0
|
|
195
|
+
|
|
196
|
+
await expect(
|
|
197
|
+
runClient(inbox, REQUEST, manager, () => {
|
|
198
|
+
calls += 1
|
|
199
|
+
return Promise.resolve(outcome)
|
|
200
|
+
}),
|
|
201
|
+
).resolves.toEqual(outcome)
|
|
202
|
+
|
|
203
|
+
expect(calls).toBe(0)
|
|
204
|
+
expect(manager.query).toHaveBeenCalledTimes(1)
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe("PostgresInbox runClient key and request validation", () => {
|
|
210
|
+
it.each(["a-different-fingerprint", null])(
|
|
211
|
+
"refuses a reused key carrying fingerprint %p before any callback",
|
|
212
|
+
async (fingerprint) => {
|
|
213
|
+
const manager = mockEntityManager({
|
|
214
|
+
query: [CLAIM_CLIENT, [{ request_fingerprint: fingerprint, outcome: { value: OUTCOME } }]],
|
|
215
|
+
})
|
|
216
|
+
const inbox = await build(mockEntityManager())
|
|
217
|
+
let calls = 0
|
|
218
|
+
|
|
219
|
+
await expect(
|
|
220
|
+
runClient(inbox, REQUEST, manager, () => {
|
|
221
|
+
calls += 1
|
|
222
|
+
return Promise.resolve(OUTCOME)
|
|
223
|
+
}),
|
|
224
|
+
).rejects.toMatchObject({
|
|
225
|
+
code: IdempotencyErrorCode.KeyReused,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(calls).toBe(0)
|
|
229
|
+
expect(manager.query).toHaveBeenCalledTimes(1)
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
it.each([
|
|
234
|
+
{ ...REQUEST, principalId: " " },
|
|
235
|
+
{ ...REQUEST, principalId: "a:b" },
|
|
236
|
+
{ ...REQUEST, operation: " " },
|
|
237
|
+
{ ...REQUEST, operation: "a:b" },
|
|
238
|
+
{ ...REQUEST, idempotencyKey: " " },
|
|
239
|
+
{ ...REQUEST, idempotencyKey: "a".repeat(65) },
|
|
240
|
+
{ ...REQUEST, request: { unsupported: undefined } },
|
|
241
|
+
])("refuses an invalid scope, key or request before querying %p", async (request) => {
|
|
242
|
+
const manager = mockEntityManager()
|
|
243
|
+
const inbox = await build(manager)
|
|
244
|
+
|
|
245
|
+
await expect(runClient(inbox, request, manager, () => Promise.resolve(OUTCOME))).rejects.toMatchObject({
|
|
246
|
+
code: IdempotencyErrorCode.RequestInvalid,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(manager.query).not.toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe("PostgresInbox runClient rollback", () => {
|
|
254
|
+
it("keeps the exact key and rejects a failed callback by rolling the claim back with the domain transaction", async () => {
|
|
255
|
+
const manager = mockEntityManager({
|
|
256
|
+
query: [
|
|
257
|
+
CLAIM_CLIENT,
|
|
258
|
+
[
|
|
259
|
+
{
|
|
260
|
+
request_fingerprint: requestFingerprint(REQUEST.request),
|
|
261
|
+
outcome: null,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
],
|
|
265
|
+
})
|
|
266
|
+
const transaction = fakeTransaction(manager)
|
|
267
|
+
const inbox = await build(mockEntityManager())
|
|
268
|
+
const failure = new InboxError({ code: InboxErrorCode.OutcomeMissing })
|
|
269
|
+
|
|
270
|
+
await expect(
|
|
271
|
+
transaction.em.transaction((tx) =>
|
|
272
|
+
inbox.runClient({ ...REQUEST, idempotencyKey: " key-1 " }, tx, () => Promise.reject(failure)),
|
|
273
|
+
),
|
|
274
|
+
).rejects.toBe(failure)
|
|
275
|
+
|
|
276
|
+
expect(transaction.em.query).toHaveBeenCalledWith(CLAIM_CLIENT, [
|
|
277
|
+
"client:person-1:checkout.place",
|
|
278
|
+
" key-1 ",
|
|
279
|
+
requestFingerprint(REQUEST.request),
|
|
280
|
+
new FakeClock("2026-02-03T04:05:06.000Z").now(),
|
|
281
|
+
])
|
|
282
|
+
expect(transaction.rollbacks).toBe(1)
|
|
283
|
+
expect(transaction.committedWrites).toEqual([])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("propagates a database claim failure without calling the callback", async () => {
|
|
287
|
+
const manager = mockEntityManager()
|
|
288
|
+
const failure = new Error("claim failed")
|
|
289
|
+
manager.query.mockRejectedValueOnce(failure)
|
|
290
|
+
const inbox = await build(mockEntityManager())
|
|
291
|
+
let calls = 0
|
|
292
|
+
|
|
293
|
+
await expect(
|
|
294
|
+
runClient(inbox, REQUEST, manager, () => {
|
|
295
|
+
calls += 1
|
|
296
|
+
return Promise.resolve(OUTCOME)
|
|
297
|
+
}),
|
|
298
|
+
).rejects.toBe(failure)
|
|
299
|
+
|
|
300
|
+
expect(calls).toBe(0)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe("PostgresInbox runClient outcome validation", () => {
|
|
305
|
+
it("refuses a non-JSON callback outcome and rolls back its claim", async () => {
|
|
306
|
+
const transaction = fakeTransaction(
|
|
307
|
+
mockEntityManager({
|
|
308
|
+
query: [
|
|
309
|
+
CLAIM_CLIENT,
|
|
310
|
+
[
|
|
311
|
+
{
|
|
312
|
+
request_fingerprint: requestFingerprint(REQUEST.request),
|
|
313
|
+
outcome: null,
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
],
|
|
317
|
+
}),
|
|
318
|
+
)
|
|
319
|
+
const inbox = await build(mockEntityManager())
|
|
320
|
+
|
|
321
|
+
await expect(
|
|
322
|
+
transaction.em.transaction((tx) => inbox.runClient(REQUEST, tx, () => Promise.resolve(undefined))),
|
|
323
|
+
).rejects.toMatchObject({
|
|
324
|
+
code: InboxErrorCode.OutcomeInvalid,
|
|
325
|
+
cause: { code: IdempotencyErrorCode.RequestInvalid },
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
expect(transaction.em.query).toHaveBeenCalledTimes(1)
|
|
329
|
+
expect(transaction.rollbacks).toBe(1)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
describe("PostgresInbox runClient write failures", () => {
|
|
334
|
+
it("refuses missing claim and saved rows rather than committing an unrecorded result", async () => {
|
|
335
|
+
for (const responses of [
|
|
336
|
+
[[]],
|
|
337
|
+
[[{ request_fingerprint: requestFingerprint(REQUEST.request), outcome: null }], []],
|
|
338
|
+
]) {
|
|
339
|
+
const manager = mockEntityManager()
|
|
340
|
+
for (const response of responses) manager.query.mockResolvedValueOnce(response)
|
|
341
|
+
const transaction = fakeTransaction(manager)
|
|
342
|
+
const inbox = await build(mockEntityManager())
|
|
343
|
+
|
|
344
|
+
await expect(
|
|
345
|
+
transaction.em.transaction((tx) => inbox.runClient(REQUEST, tx, () => Promise.resolve(OUTCOME))),
|
|
346
|
+
).rejects.toMatchObject({
|
|
347
|
+
code: InboxErrorCode.OutcomeMissing,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
expect(transaction.rollbacks).toBe(1)
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("rolls back the claim and effect when saving the outcome fails", async () => {
|
|
355
|
+
const manager = mockEntityManager()
|
|
356
|
+
const failure = new Error("outcome write failed")
|
|
357
|
+
manager.query
|
|
358
|
+
.mockResolvedValueOnce([{ request_fingerprint: requestFingerprint(REQUEST.request), outcome: null }])
|
|
359
|
+
.mockRejectedValueOnce(failure)
|
|
360
|
+
const transaction = fakeTransaction(manager)
|
|
361
|
+
const inbox = await build(mockEntityManager())
|
|
362
|
+
|
|
363
|
+
await expect(
|
|
364
|
+
transaction.em.transaction((tx) => inbox.runClient(REQUEST, tx, () => Promise.resolve(OUTCOME))),
|
|
365
|
+
).rejects.toBe(failure)
|
|
366
|
+
|
|
367
|
+
expect(transaction.rollbacks).toBe(1)
|
|
368
|
+
expect(transaction.committedWrites).toEqual([])
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
describe("PostgresInbox sweepClients", () => {
|
|
373
|
+
it.each([
|
|
374
|
+
{ rows: [], expected: 0 },
|
|
375
|
+
{ rows: [{ deleted_count: 0 }], expected: 0 },
|
|
376
|
+
{ rows: [{ deleted_count: 17 }], expected: 17 },
|
|
377
|
+
])("returns the bounded deleted row count $expected through the supplied manager", async ({ rows, expected }) => {
|
|
378
|
+
const configured = mockEntityManager()
|
|
379
|
+
const manager = mockEntityManager({ query: [SWEEP_CLIENTS, rows] })
|
|
380
|
+
const inbox = await build(configured)
|
|
381
|
+
const cutoff = new FakeClock("2026-01-01T00:00:00.000Z").now()
|
|
382
|
+
|
|
383
|
+
await expect(inbox.sweepClients(cutoff, manager)).resolves.toBe(expected)
|
|
384
|
+
|
|
385
|
+
expect(manager.query).toHaveBeenCalledWith(SWEEP_CLIENTS, [cutoff, BATCH_ROWS])
|
|
386
|
+
expect(configured.query).not.toHaveBeenCalled()
|
|
387
|
+
})
|
|
388
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { BATCH_ROWS } from "@modules/platform/database"
|
|
2
|
+
import { Injectable } from "@nestjs/common"
|
|
3
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
4
|
+
import type { Clock } from "@modules/platform/clock"
|
|
5
|
+
import type { EntityManager } from "typeorm"
|
|
6
|
+
import { InjectClaimManagers } from "./inbox.decorators"
|
|
7
|
+
import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
|
|
8
|
+
import { InboxError, InboxErrorCode } from "./errors/inbox.error"
|
|
9
|
+
import { clientSource } from "./client-operation.policy"
|
|
10
|
+
import type { ClientOperation, ClientOperationEffect, Inbox } from "./inbox.port"
|
|
11
|
+
import { canonicalJson, requestFingerprint } from "./request-fingerprint.policy"
|
|
12
|
+
import type { ClaimClientRow, InboxEventRow, InboxSweepRow, SavedClientOutcomeRow } from "./persistence/inbox.rows"
|
|
13
|
+
import { CLAIM_CLIENT, CLAIM_EVENT, RELEASE_EVENT, SAVE_CLIENT_OUTCOME, SWEEP_CLIENTS } from "./persistence/inbox.sql"
|
|
14
|
+
|
|
15
|
+
@Injectable()
|
|
16
|
+
/** The Inbox adapter over the claims table: the unique (source, event id) pair decides who claims first. */
|
|
17
|
+
export class PostgresInbox implements Inbox {
|
|
18
|
+
constructor(
|
|
19
|
+
@InjectClaimManagers() private readonly managers: readonly [EntityManager],
|
|
20
|
+
@InjectClock() private readonly clock: Clock,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
/** Claims through the caller transaction when supplied, otherwise through the app's configured manager. */
|
|
24
|
+
async claim(source: string, eventId: string, tx?: EntityManager): Promise<boolean> {
|
|
25
|
+
const manager = tx ?? this.managers[0]
|
|
26
|
+
const rows: Array<InboxEventRow> = await manager.query(CLAIM_EVENT, [source, eventId, this.clock.now()])
|
|
27
|
+
return rows.length > 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Deletes the claim so a redelivery is processed again. */
|
|
31
|
+
async release(source: string, eventId: string): Promise<void> {
|
|
32
|
+
const [manager] = this.managers
|
|
33
|
+
await manager.query(RELEASE_EVENT, [source, eventId])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs once in the domain transaction, returning the persisted JSON outcome on both the first call and every retry. */
|
|
37
|
+
async runClient<T>(request: ClientOperation, tx: EntityManager, effect: ClientOperationEffect<T>): Promise<T> {
|
|
38
|
+
if (tx.queryRunner?.isTransactionActive !== true)
|
|
39
|
+
throw new InboxError({ code: InboxErrorCode.TransactionRequired })
|
|
40
|
+
const source = clientSource(request)
|
|
41
|
+
const fingerprint = requestFingerprint(request.request)
|
|
42
|
+
const rows: Array<ClaimClientRow<T>> = await tx.query(CLAIM_CLIENT, [
|
|
43
|
+
source,
|
|
44
|
+
request.idempotencyKey,
|
|
45
|
+
fingerprint,
|
|
46
|
+
this.clock.now(),
|
|
47
|
+
])
|
|
48
|
+
const claim = rows[0]
|
|
49
|
+
if (!claim) throw new InboxError({ code: InboxErrorCode.OutcomeMissing })
|
|
50
|
+
if (claim.request_fingerprint !== fingerprint)
|
|
51
|
+
throw new IdempotencyError({ code: IdempotencyErrorCode.KeyReused })
|
|
52
|
+
if (claim.incomplete) throw new InboxError({ code: InboxErrorCode.OutcomeMissing })
|
|
53
|
+
if (claim.outcome !== null) return claim.outcome.value
|
|
54
|
+
|
|
55
|
+
const value = await effect()
|
|
56
|
+
let outcome: string
|
|
57
|
+
try {
|
|
58
|
+
outcome = canonicalJson({ value })
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
throw new InboxError({ code: InboxErrorCode.OutcomeInvalid, cause })
|
|
61
|
+
}
|
|
62
|
+
const saved: Array<SavedClientOutcomeRow<T>> = await tx.query(SAVE_CLIENT_OUTCOME, [
|
|
63
|
+
source,
|
|
64
|
+
request.idempotencyKey,
|
|
65
|
+
outcome,
|
|
66
|
+
])
|
|
67
|
+
const recorded = saved[0]
|
|
68
|
+
if (!recorded) throw new InboxError({ code: InboxErrorCode.OutcomeMissing })
|
|
69
|
+
return recorded.outcome.value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A row lock and SKIP LOCKED keep retention from competing with a live retry or another sweep worker. */
|
|
73
|
+
async sweepClients(cutoff: Date, tx: EntityManager): Promise<number> {
|
|
74
|
+
const rows: Array<InboxSweepRow> = await tx.query(SWEEP_CLIENTS, [cutoff, BATCH_ROWS])
|
|
75
|
+
return rows[0]?.deleted_count ?? 0
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { inboxEntities, inboxMigrations } from "./persistence/connection"
|
|
2
|
+
export { INBOX, InjectInbox } from "./inbox.decorators"
|
|
3
|
+
export { Effectful } from "./effectful.decorators"
|
|
4
|
+
export { InboxModule } from "./inbox.module"
|
|
5
|
+
export type { Inbox } from "./inbox.port"
|
|
6
|
+
export { INBOX_ERROR_KINDS } from "./errors/inbox.error"
|
|
7
|
+
export { INBOX_MESSAGES } from "./messages/inbox.messages"
|
|
8
|
+
export { backfillClientOutcome } from "./client-outcome-backfill.client"
|
|
9
|
+
export { backfillDeliveryClaim } from "./delivery-claim-backfill.client"
|
|
10
|
+
export { releaseDeliveryClaim } from "./delivery-claim-release.client"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MessageBundle } from "@modules/platform/i18n"
|
|
2
|
+
|
|
3
|
+
/** Display text of the inbox codes. */
|
|
4
|
+
export const INBOX_MESSAGES: MessageBundle = {
|
|
5
|
+
vi: {
|
|
6
|
+
"errors.INBOX_OUTCOME_INVALID": "Kết quả thao tác không phải dữ liệu JSON thuần nên không thể lưu.",
|
|
7
|
+
"errors.INBOX_OUTCOME_MISSING": "Không lưu được kết quả thao tác.",
|
|
8
|
+
"errors.INBOX_TRANSACTION_REQUIRED": "Thao tác này cần một giao dịch đang hoạt động.",
|
|
9
|
+
},
|
|
10
|
+
en: {
|
|
11
|
+
"errors.INBOX_OUTCOME_INVALID": "The operation outcome is not plain JSON data, so it cannot be stored.",
|
|
12
|
+
"errors.INBOX_OUTCOME_MISSING": "The operation outcome could not be recorded.",
|
|
13
|
+
"errors.INBOX_TRANSACTION_REQUIRED": "This operation requires an active transaction.",
|
|
14
|
+
},
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { InboxClaimEntity } from "./entities/inbox-claim.entity"
|
|
2
|
+
import { CreateInboxClaims1789800005000 } from "./migrations/1789800005000-create-inbox-claims"
|
|
3
|
+
import { ClientOperationOutcomes1792800000000 } from "./migrations/1792800000000-client-operation-outcomes"
|
|
4
|
+
|
|
5
|
+
/** The entities of the inbox capability, for the connection that holds them. */
|
|
6
|
+
export const inboxEntities = [InboxClaimEntity]
|
|
7
|
+
|
|
8
|
+
/** The migrations of the inbox capability, in the order they run. */
|
|
9
|
+
export const inboxMigrations = [CreateInboxClaims1789800005000, ClientOperationOutcomes1792800000000]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
2
|
+
|
|
3
|
+
@Entity("inbox_claims")
|
|
4
|
+
/** One row per event a consumer or webhook has claimed: the pair (source, event id) is unique. */
|
|
5
|
+
export class InboxClaimEntity {
|
|
6
|
+
/** The consumer queue or webhook the event arrived through. */
|
|
7
|
+
@PrimaryColumn({ name: "source", type: "varchar", length: 200 })
|
|
8
|
+
source!: string
|
|
9
|
+
|
|
10
|
+
/** The event id the provider or producer supplied. */
|
|
11
|
+
@PrimaryColumn({ name: "event_id", type: "varchar", length: 200 })
|
|
12
|
+
eventId!: string
|
|
13
|
+
|
|
14
|
+
/** When the event was claimed. */
|
|
15
|
+
@Column({ name: "claimed_at", type: "timestamptz" })
|
|
16
|
+
claimedAt!: Date
|
|
17
|
+
|
|
18
|
+
/** The canonical JSON request's SHA-256; null for provider and broker deliveries. */
|
|
19
|
+
@Column({ name: "request_fingerprint", type: "varchar", length: 64, nullable: true })
|
|
20
|
+
requestFingerprint!: string | null
|
|
21
|
+
|
|
22
|
+
/** A completed client operation's JSON envelope, including expected refusals. */
|
|
23
|
+
@Column({ name: "outcome", type: "jsonb", nullable: true })
|
|
24
|
+
outcome!: object | null
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The JSON envelope makes a completed null value distinct from a pending SQL-null outcome. */
|
|
2
|
+
export interface ClientOutcomeRow<T> {
|
|
3
|
+
/** The operation's complete JSON result, including expected refusals. */
|
|
4
|
+
readonly value: T
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** The row returned by the atomic client claim. */
|
|
8
|
+
export interface ClaimClientRow<T> {
|
|
9
|
+
/** The digest recorded by the first operation under this scoped key. */
|
|
10
|
+
readonly request_fingerprint: string | null
|
|
11
|
+
/** The saved result, or null only for a claim inserted by this transaction. */
|
|
12
|
+
readonly outcome: ClientOutcomeRow<T> | null
|
|
13
|
+
/** An existing row had no completed outcome, so it must never run an effect again. */
|
|
14
|
+
readonly incomplete: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The persisted outcome returned to the first caller. */
|
|
18
|
+
export interface SavedClientOutcomeRow<T> {
|
|
19
|
+
/** The normalized JSON result as the database stores it. */
|
|
20
|
+
readonly outcome: ClientOutcomeRow<T>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A claimed or deleted delivery key returned by inbox persistence. */
|
|
24
|
+
export interface InboxEventRow {
|
|
25
|
+
/** The event or client key owned by the row. */
|
|
26
|
+
readonly event_id: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The bounded retention batch's aggregate result. */
|
|
30
|
+
export interface InboxSweepRow {
|
|
31
|
+
/** Number of completed client claims removed by this transaction. */
|
|
32
|
+
readonly deleted_count: number
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sql } from "@modules/platform/database"
|
|
2
|
+
|
|
3
|
+
/** Claims the event ($1 source, $2 event id, $3 instant); answers one row when this call won and none when it was claimed before. */
|
|
4
|
+
export const CLAIM_EVENT = sql`INSERT INTO inbox_claims (source, event_id, claimed_at) VALUES ($1, $2, $3)
|
|
5
|
+
ON CONFLICT (source, event_id) DO NOTHING RETURNING event_id`
|
|
6
|
+
|
|
7
|
+
/** Gives a delivery claim back; client outcomes can expire only through the retention API. */
|
|
8
|
+
export const RELEASE_EVENT = sql`DELETE FROM inbox_claims WHERE source = $1 AND event_id = $2 AND request_fingerprint IS NULL`
|
|
9
|
+
|
|
10
|
+
/** A conflicting insert locks the existing row and returns its committed fingerprint and outcome after the winner commits. */
|
|
11
|
+
export const CLAIM_CLIENT = sql`INSERT INTO inbox_claims (source, event_id, request_fingerprint, claimed_at)
|
|
12
|
+
VALUES ($1, $2, $3, $4)
|
|
13
|
+
ON CONFLICT (source, event_id) DO UPDATE SET outcome = COALESCE(inbox_claims.outcome, '{}'::jsonb)
|
|
14
|
+
RETURNING request_fingerprint, outcome, (outcome IS NOT NULL AND (jsonb_typeof(outcome) <> 'object' OR NOT (outcome ? 'value'))) AS incomplete`
|
|
15
|
+
|
|
16
|
+
/** The JSON envelope distinguishes a completed null result from an uncompleted claim in this transaction. */
|
|
17
|
+
export const SAVE_CLIENT_OUTCOME = sql`WITH saved AS (
|
|
18
|
+
UPDATE inbox_claims SET outcome = $3::jsonb
|
|
19
|
+
WHERE source = $1 AND event_id = $2
|
|
20
|
+
RETURNING outcome
|
|
21
|
+
)
|
|
22
|
+
SELECT outcome FROM saved LIMIT 1`
|
|
23
|
+
|
|
24
|
+
/** Retention never deletes delivery rows or an incomplete claim; its bounded batch skips rows held by active retries. */
|
|
25
|
+
export const SWEEP_CLIENTS = sql`WITH expired AS (
|
|
26
|
+
SELECT source, event_id FROM inbox_claims
|
|
27
|
+
WHERE source LIKE 'client:%' AND request_fingerprint IS NOT NULL AND outcome IS NOT NULL AND jsonb_typeof(outcome) = 'object' AND outcome ? 'value' AND claimed_at < $1
|
|
28
|
+
ORDER BY claimed_at, source, event_id LIMIT $2 FOR UPDATE SKIP LOCKED
|
|
29
|
+
), deleted AS (
|
|
30
|
+
DELETE FROM inbox_claims AS claims USING expired
|
|
31
|
+
WHERE claims.source = expired.source AND claims.event_id = expired.event_id
|
|
32
|
+
RETURNING claims.event_id
|
|
33
|
+
)
|
|
34
|
+
SELECT count(*)::int AS deleted_count FROM deleted`
|
|
35
|
+
|
|
36
|
+
/** Imports an already completed legacy operation without replacing a key another migration has already transferred. */
|
|
37
|
+
export const BACKFILL_CLIENT_OUTCOME = sql`INSERT INTO inbox_claims (source, event_id, request_fingerprint, claimed_at, outcome)
|
|
38
|
+
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
39
|
+
ON CONFLICT (source, event_id) DO NOTHING`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MigrationInterface, QueryRunner } from "typeorm"
|
|
2
|
+
|
|
3
|
+
/** Creates the table the inbox claims events in. */
|
|
4
|
+
export class CreateInboxClaims1789800005000 implements MigrationInterface {
|
|
5
|
+
name = "CreateInboxClaims1789800005000"
|
|
6
|
+
|
|
7
|
+
/** Creates the claims table. */
|
|
8
|
+
async up(queryRunner: QueryRunner): Promise<void> {
|
|
9
|
+
await queryRunner.query(`CREATE TABLE inbox_claims (
|
|
10
|
+
source varchar(200) NOT NULL,
|
|
11
|
+
event_id varchar(200) NOT NULL,
|
|
12
|
+
claimed_at timestamptz NOT NULL,
|
|
13
|
+
PRIMARY KEY (source, event_id)
|
|
14
|
+
)`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Drops the claims table. */
|
|
18
|
+
async down(queryRunner: QueryRunner): Promise<void> {
|
|
19
|
+
await queryRunner.query(`DROP TABLE inbox_claims`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MigrationInterface, QueryRunner } from "typeorm"
|
|
2
|
+
|
|
3
|
+
/** Extends the one shared inbox to retain a client operation's request identity and outcome. */
|
|
4
|
+
export class ClientOperationOutcomes1792800000000 implements MigrationInterface {
|
|
5
|
+
name = "ClientOperationOutcomes1792800000000"
|
|
6
|
+
|
|
7
|
+
/** Adds nullable columns so existing delivery claims retain their meaning. */
|
|
8
|
+
async up(queryRunner: QueryRunner): Promise<void> {
|
|
9
|
+
await queryRunner.query(`ALTER TABLE inbox_claims
|
|
10
|
+
ADD COLUMN request_fingerprint varchar(64),
|
|
11
|
+
ADD COLUMN outcome jsonb`)
|
|
12
|
+
await queryRunner.query(`CREATE INDEX inbox_claims_client_retention_idx ON inbox_claims (claimed_at, source, event_id)
|
|
13
|
+
WHERE source LIKE 'client:%' AND request_fingerprint IS NOT NULL AND outcome IS NOT NULL`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Removes the client outcome extension. */
|
|
17
|
+
async down(queryRunner: QueryRunner): Promise<void> {
|
|
18
|
+
await queryRunner.query(`DROP INDEX inbox_claims_client_retention_idx`)
|
|
19
|
+
await queryRunner.query(`ALTER TABLE inbox_claims DROP COLUMN outcome, DROP COLUMN request_fingerprint`)
|
|
20
|
+
}
|
|
21
|
+
}
|