@starci/hfs 4.11.0 → 4.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
package/sync/index.mjs CHANGED
@@ -30,7 +30,7 @@ import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs
30
30
  import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
31
31
  import { readDeclaredSonarKey } from './sonar-key.mjs';
32
32
  import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
33
- import { DB_DOCKERFILE, DB_IMAGE_APP } from '../db/image.mjs';
33
+ import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.mjs';
34
34
  import { byCodeUnit } from '../report/order.mjs';
35
35
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
36
36
  import { ciWorkflowCommands } from './ci-commands.mjs';
@@ -40,6 +40,8 @@ import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
40
40
  import { LIST } from '../runtime/scripts/lib/loosening-config.mjs';
41
41
  import { readTruths } from '../runtime/scripts/lib/loosening-state.mjs';
42
42
  import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
43
+ import { bootstrapSection } from '../runtime/scripts/hfs/rules/database-constants.mjs';
44
+ import { SERVER_AGE_IDENTITY } from '../stack/server-key.mjs';
43
45
 
44
46
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
45
47
  const NODE_MAJOR = 22;
@@ -58,9 +60,10 @@ export class SyncError extends Error {
58
60
  }
59
61
 
60
62
  // mode: file = whole file, block = the marked block inside a file the app also writes to, scripts = the `scripts` block of
61
- // package.json. A template that can hold a comment opens with {{header}}; a file whose exact content is specified (JSON, the
62
- // eslint one-liner) has no header.
63
- const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts', 'package-scripts-lite': 'scripts' });
63
+ // package.json, section = the whole file but the one marked section a database verb owns (db/bootstrap.sql, whose squash section
64
+ // `starci app db baseline --squash` writes). A template that can hold a comment opens with {{header}}; a file whose exact content is
65
+ // specified (JSON, the eslint one-liner) has no header.
66
+ const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts', 'package-scripts-lite': 'scripts', 'db-bootstrap': 'section' });
64
67
  const GLOB_CHARACTERS = new Set('*?<>{}[]');
65
68
  const literalFile = file => file.length >= 2 && !file.endsWith('/') && ![...file].some(character => GLOB_CHARACTERS.has(character));
66
69
 
@@ -158,13 +161,22 @@ function trailingNewline(text) {
158
161
  }
159
162
 
160
163
  /**
161
- * The base image of a lite app's managed db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
162
- * Docker Official postgres image by digest, to which the Dockerfile adds only PGVECTOR_PACKAGE from PGDG.
164
+ * The base image of a lite app's managed .starcistacks/images/db/Dockerfile (contexts/stack/pins.yaml images postgres, ruling:lite-official-postgres-image): the
165
+ * Docker Official postgres image by digest, to which the Dockerfile adds PGVECTOR_PACKAGE from PGDG and the PGMQ release.
163
166
  */
164
167
  export const DB_BASE_IMAGE = 'postgres:17.11@sha256:31ba9c94a03ada9988f2bb493a22a75eef3a185884533e5cdbcd498ea835b08f';
165
168
  /** The PGDG package of pgvector at the exact version the db image installs (the one extension the image does not ship). */
166
169
  export const PGVECTOR_PACKAGE = 'postgresql-17-pgvector=0.8.7-1.pgdg13+1';
167
- /** The local tag the development db container and the drift scratch database run, built from the app's db/Dockerfile. */
170
+ /**
171
+ * The pgmq release the db image installs for the event bus (ruling:lite-pgmq-bus-bullmq-jobs): its source tarball, verified by sha256
172
+ * when Docker adds it. PGDG does not package pgmq; it is SQL objects only, so the Dockerfile copies its control and script files.
173
+ */
174
+ export const PGMQ = Object.freeze({
175
+ version: '1.13.1',
176
+ source: 'https://github.com/pgmq/pgmq/archive/refs/tags/v1.13.1.tar.gz',
177
+ sha256: '67221f288f42f95c002a64df40f7d660e765e32df8ada0f2672ba915a14827f7',
178
+ });
179
+ /** The local tag the development db container and the drift scratch database run, built from the app's .starcistacks/images/db/Dockerfile. */
168
180
  export const dbLocalImage = project => `${project}-db:local`;
169
181
  /** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
170
182
  export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
@@ -385,10 +397,13 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
385
397
  project: app.project,
386
398
  appBinary: APP_BINARY,
387
399
  appScripts: appScripts(app, source),
388
- // A lite app's own database runs in development as one container of the image its db/Dockerfile builds (ruling:lite-official-postgres-image).
400
+ // A lite app's own database runs in development as one container of the image its .starcistacks/images/db/Dockerfile builds (ruling:lite-db-image-in-stacks).
389
401
  dbImage: dbLocalImage(app.project),
390
402
  dbBaseImage: DB_BASE_IMAGE,
391
403
  pgvectorPackage: PGVECTOR_PACKAGE,
404
+ pgmqVersion: PGMQ.version,
405
+ pgmqSource: PGMQ.source,
406
+ pgmqSha256: PGMQ.sha256,
392
407
  // A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
393
408
  backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
394
409
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
@@ -398,6 +413,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
398
413
  // The typed lint reads the types the fe packages declare (their dist, turbo.json: lint dependsOn ^build), so the packages are built first.
399
414
  lintPrefix: packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && ',
400
415
  nodeMajor: String(NODE_MAJOR),
416
+ // The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
417
+ serverAgeIdentity: SERVER_AGE_IDENTITY,
401
418
  sonarKey: sonarKey ?? app.project,
402
419
  sonarHostLine: sonarHostLine(quality.sonar),
403
420
  sonarExclusions: sonarScope(presets).exclusions.join(','),
@@ -472,9 +489,10 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
472
489
  });
473
490
  }
474
491
 
475
- // The part of a file sync manages: all of it, the marked block, or the scripts block as text.
492
+ // The part of a file sync manages: all of it, all of it but the verb's section, the marked block, or the scripts block as text.
476
493
  function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
477
494
  if (mode === 'file') return text;
495
+ if (mode === 'section') return bootstrapSection(text).head;
478
496
  if (mode === 'scripts') {
479
497
  const scripts = parseScripts(text);
480
498
  return scripts ? scriptsText(scripts) : null;
@@ -524,6 +542,7 @@ export function writtenContent(target, existing) {
524
542
  }
525
543
  return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
526
544
  }
545
+ if (target.mode === 'section') return `${target.content}${bootstrapSection(existing ?? '').section ?? ''}`;
527
546
  if (target.mode === 'block' && existing) {
528
547
  const current = managedPart('block', existing, target.markers);
529
548
  if (current !== null) return existing.replace(current, () => target.content);
@@ -571,6 +590,12 @@ export async function renderRepo(root, { presets, parseYaml, notices, manifest =
571
590
 
572
591
  /** The folders a lite app's sync retires (ruling lite-self-hosted-postgres-passport-minio): the generated Supabase row types nothing reads any more. */
573
592
  const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
593
+ /** The files a lite app's sync retires, each with the reason --check prints. */
594
+ const LITE_RETIRED_FILES = Object.freeze({
595
+ [RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
596
+ });
597
+ /** Why a retired file goes: a retired file's own reason, else the folder's. */
598
+ const retiredReason = (file) => LITE_RETIRED_FILES[file] ?? 'the lite edition generates no database types';
574
599
 
575
600
  /** The app-relative files of the retired folders that still exist under `root` of a lite app; sync --write deletes them, --check reports them. */
576
601
  export function retiredFiles(root, hfs) {
@@ -584,6 +609,7 @@ export function retiredFiles(root, hfs) {
584
609
  else found.push(rel);
585
610
  };
586
611
  for (const dir of LITE_RETIRED_DIRS) walk(dir);
612
+ for (const file of Object.keys(LITE_RETIRED_FILES)) walk(file);
587
613
  return found;
588
614
  }
589
615
 
@@ -602,7 +628,7 @@ function syncOptions(argv) {
602
628
  function reportSyncCheck(root, targets, out, retired = []) {
603
629
  const results = checkTargets(root, targets);
604
630
  const bad = results.filter(item => item.status !== 'ok');
605
- for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired, the lite edition generates no database types; run "npx starci app sync --write" to delete it`);
631
+ for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired, ${retiredReason(file)}; run "npx starci app sync --write" to delete it`);
606
632
  for (const result of bad) {
607
633
  const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
608
634
  const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
@@ -1,6 +1,8 @@
1
1
  {{header}}
2
2
  # Builds the image of every app on its release tag, so a Dockerfile that no longer builds fails that release.
3
3
  # It never pushes: publishing an image is the product's decision, not this file's.
4
+ # When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
5
+ # arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
4
6
  name: images
5
7
 
6
8
  on:
@@ -7,7 +7,9 @@
7
7
  # operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
8
8
  # The environment input names a protected GitHub environment (required reviewers) that holds:
9
9
  # vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
10
- # secrets SOPS_AGE_KEY (the age identity of .sops.yaml), VPS_SSH_KEY (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
10
+ # secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
11
+ # both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
12
+ # (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
11
13
  # Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
12
14
  # waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
13
15
  name: deploy stack
@@ -0,0 +1,58 @@
1
+ {{header}}
2
+ # Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
3
+ # stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
4
+ # identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
5
+ # owned by root; it never echoes the identity and never overwrites one.
6
+ # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
7
+ # vars VPS_HOST, VPS_USER (the swarm manager)
8
+ # secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
9
+ # VPS_KNOWN_HOSTS (pinned host keys)
10
+ name: restore stack key
11
+
12
+ on:
13
+ workflow_dispatch:
14
+ inputs:
15
+ environment:
16
+ description: Protected environment holding the deploy transport and the server identity backup
17
+ required: true
18
+ type: environment
19
+ recipient:
20
+ description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
21
+ required: true
22
+ type: string
23
+
24
+ permissions:
25
+ contents: read
26
+
27
+ concurrency:
28
+ group: stack-${{ inputs.environment }}
29
+ cancel-in-progress: false
30
+
31
+ jobs:
32
+ restore:
33
+ runs-on: ubuntu-latest
34
+ environment: ${{ inputs.environment }}
35
+ timeout-minutes: 10
36
+ steps:
37
+ - name: write the identity on the server
38
+ env:
39
+ RECIPIENT: ${{ inputs.recipient }}
40
+ AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
41
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
42
+ VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
43
+ VPS_HOST: ${{ vars.VPS_HOST }}
44
+ VPS_USER: ${{ vars.VPS_USER }}
45
+ shell: bash
46
+ run: |
47
+ set -euo pipefail
48
+ [[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
49
+ [ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
50
+ umask 077
51
+ dir="$(mktemp -d)"
52
+ trap 'rm -rf "$dir"' EXIT
53
+ printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
+ printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
+ # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
+ remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
+ printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
+ -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
@@ -1,6 +1,8 @@
1
1
  {{header}}
2
2
  # Builds each image declared by the lite app. The API image is required; a front-end image is emitted only when declared.
3
3
  # It runs on the release tag, so every image is built there, and never pushes.
4
+ # When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
5
+ # arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
4
6
  name: images
5
7
 
6
8
  on:
@@ -7,7 +7,9 @@
7
7
  # operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
8
8
  # The environment input names a protected GitHub environment (required reviewers) that holds:
9
9
  # vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
10
- # secrets SOPS_AGE_KEY (the age identity of .sops.yaml), VPS_SSH_KEY (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
10
+ # secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
11
+ # both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
12
+ # (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
11
13
  # Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
12
14
  # waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
13
15
  name: deploy stack
@@ -0,0 +1,58 @@
1
+ {{header}}
2
+ # Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
3
+ # stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
4
+ # identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
5
+ # owned by root; it never echoes the identity and never overwrites one.
6
+ # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
7
+ # vars VPS_HOST, VPS_USER (the swarm manager)
8
+ # secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
9
+ # VPS_KNOWN_HOSTS (pinned host keys)
10
+ name: restore stack key
11
+
12
+ on:
13
+ workflow_dispatch:
14
+ inputs:
15
+ environment:
16
+ description: Protected environment holding the deploy transport and the server identity backup
17
+ required: true
18
+ type: environment
19
+ recipient:
20
+ description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
21
+ required: true
22
+ type: string
23
+
24
+ permissions:
25
+ contents: read
26
+
27
+ concurrency:
28
+ group: stack-${{ inputs.environment }}
29
+ cancel-in-progress: false
30
+
31
+ jobs:
32
+ restore:
33
+ runs-on: ubuntu-latest
34
+ environment: ${{ inputs.environment }}
35
+ timeout-minutes: 10
36
+ steps:
37
+ - name: write the identity on the server
38
+ env:
39
+ RECIPIENT: ${{ inputs.recipient }}
40
+ AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
41
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
42
+ VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
43
+ VPS_HOST: ${{ vars.VPS_HOST }}
44
+ VPS_USER: ${{ vars.VPS_USER }}
45
+ shell: bash
46
+ run: |
47
+ set -euo pipefail
48
+ [[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
49
+ [ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
50
+ umask 077
51
+ dir="$(mktemp -d)"
52
+ trap 'rm -rf "$dir"' EXIT
53
+ printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
+ printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
+ # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
+ remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
+ printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
+ -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
@@ -1,16 +1,18 @@
1
1
  -- Managed by starci app sync (slot app.db.bootstrap); do not edit: run "npx starci app sync --write".
2
2
  -- The pre-migration bootstrap of a lite app's self-hosted PostgreSQL (ruling lite-self-hosted-postgres-passport-minio), run on the official
3
- -- postgres image of db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
3
+ -- postgres image of .starcistacks/images/db/Dockerfile (ruling lite-official-postgres-image). Migrations written for Supabase reference objects GoTrue and Supabase
4
4
  -- Storage created: the browser roles, the `auth` schema (auth.users, auth.identities, auth.sessions, auth.uid/jwt/role) and the `storage`
5
5
  -- schema (storage.buckets, storage.objects). A lite app runs neither GoTrue nor storage-api: auth is the app's own (integrations/passport) and
6
- -- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and nothing else; nothing
7
- -- else of Supabase (supabase_admin, the `extensions` schema, the realtime publication) exists. It is idempotent; `starci app db migrate`
8
- -- applies it as the database owner before the first migration of an empty database.
6
+ -- storage is the S3 endpoint of integrations/object-storage. This file gives the migrations those objects, EMPTY, and the `extensions` schema
7
+ -- every allowed extension is created in (`create extension ... with schema extensions`, the app role's search_path is `public, extensions`);
8
+ -- nothing else of Supabase (supabase_admin, the realtime publication) exists. It is idempotent; `starci app db migrate` applies it as the
9
+ -- database owner before the first migration of an empty database.
9
10
  --
10
11
  -- auth.users keeps the GoTrue column set so restored rows and foreign keys from public tables stay valid; encrypted_password holds bcrypt hashes.
11
12
  -- auth.uid(), auth.role() and auth.jwt() read the request.jwt.claims setting; the API connects as app_be and sets none, so they return NULL and
12
13
  -- every policy that names them denies the browser roles, which have no door in lite.
13
14
 
15
+ create schema if not exists extensions;
14
16
  create schema if not exists auth;
15
17
  create schema if not exists storage;
16
18
 
@@ -23,6 +25,7 @@ end
23
25
  $roles$;
24
26
 
25
27
  grant usage on schema auth, storage to anon, authenticated, service_role;
28
+ grant usage on schema extensions to anon, authenticated, service_role;
26
29
 
27
30
  -- auth.users: the full GoTrue column set (a copy an earlier database carries is extended in place).
28
31
  create table if not exists auth.users (id uuid primary key);
@@ -0,0 +1,18 @@
1
+ # Managed by starci app sync (slot app.db.image); do not edit: run "npx starci app sync --write".
2
+ # The image of the db service of a lite app, kept with the stacks (rulings lite-official-postgres-image, lite-pgmq-bus-bullmq-jobs and
3
+ # lite-db-image-in-stacks): the Docker Official postgres image, pinned by digest, plus pgvector from the PGDG repository the image
4
+ # already configures and pgmq, the event bus, from its pinned release source verified by sha256 (pgmq is SQL objects only, so its
5
+ # control and script files are copied and nothing is compiled; it installs into its own schema pgmq). The contrib extensions
6
+ # (pgcrypto, pg_trgm, unaccent, uuid-ossp, btree_gist) ship in the image. The stack's db service, `npm run db:create`,
7
+ # `starci app db drift` and `starci app db baseline --squash` all build this file, with this folder as the build context.
8
+ FROM {{dbBaseImage}}
9
+ ADD --checksum=sha256:{{pgmqSha256}} {{pgmqSource}} /tmp/pgmq.tar.gz
10
+ RUN apt-get update \
11
+ && apt-get install -y --no-install-recommends {{pgvectorPackage}} \
12
+ && rm -rf /var/lib/apt/lists/* \
13
+ && tar -xzf /tmp/pgmq.tar.gz -C /tmp \
14
+ && extension="$(pg_config --sharedir)/extension" \
15
+ && cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/pgmq.control "$extension/" \
16
+ && cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/sql/pgmq.sql "$extension/pgmq--{{pgmqVersion}}.sql" \
17
+ && cp /tmp/pgmq-{{pgmqVersion}}/pgmq-extension/sql/pgmq--*--*.sql "$extension/" \
18
+ && rm -rf /tmp/pgmq.tar.gz /tmp/pgmq-{{pgmqVersion}}
@@ -7,7 +7,7 @@ schema.gql
7
7
  .next/
8
8
  next-env.d.ts
9
9
  # .starcistacks custody at the app root: modules/schemas/stacks-layout.yaml custody.gitignoreRules, in order. Only the
10
- # declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed *.enc are tracked.
10
+ # declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed members (*.enc.yaml, legacy *.enc) are tracked.
11
11
  .starcistacks/**
12
12
  !.starcistacks/**/
13
13
  !.starcistacks/**/KEYS.md
@@ -32,6 +32,7 @@ next-env.d.ts
32
32
  .starcistacks/*/infra/terraform/*.tfstate.*
33
33
  .starcistacks/*/infra/terraform/files/
34
34
  .starcistacks/*/infra/terraform/*.tar.gz
35
+ !.starcistacks/**/secrets/*.enc.yaml
35
36
  !.starcistacks/**/*.enc
36
37
 
37
38
  # Host-local agent credentials and app age identity never enter Git.
@@ -7,12 +7,13 @@ schema.gql
7
7
  .next/
8
8
  next-env.d.ts
9
9
  # .starcistacks custody at the app root: modules/schemas/stacks-layout.yaml custody.gitignoreRules, in order. Only the
10
- # declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions and sealed *.enc are tracked.
10
+ # declaration, runbooks, key lists, seeds, the rendered environment, the infra definitions, the db image and sealed members (*.enc.yaml, legacy *.enc) are tracked.
11
11
  .starcistacks/**
12
12
  !.starcistacks/**/
13
13
  !.starcistacks/**/KEYS.md
14
14
  !.starcistacks/**/.gitkeep
15
15
  !.starcistacks/application-stacks.yaml
16
+ !.starcistacks/images/db/Dockerfile
16
17
  !.starcistacks/*/README.md
17
18
  !.starcistacks/*/environment.json
18
19
  !.starcistacks/*/seeds/**
@@ -32,6 +33,7 @@ next-env.d.ts
32
33
  .starcistacks/*/infra/terraform/*.tfstate.*
33
34
  .starcistacks/*/infra/terraform/files/
34
35
  .starcistacks/*/infra/terraform/*.tar.gz
36
+ !.starcistacks/**/secrets/*.enc.yaml
35
37
  !.starcistacks/**/*.enc
36
38
 
37
39
  # Host-local agent credentials and app age identity never enter Git.
@@ -1,4 +1,4 @@
1
- "db:image": "docker build --tag {{dbImage}} db",
1
+ "db:image": "docker build --tag {{dbImage}} .starcistacks/images/db",
2
2
  "db:create": "npm run db:image && docker create --name {{project}}-dev-db -p 127.0.0.1:54322:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -v {{project}}-dev-db-data:/var/lib/postgresql/data {{dbImage}}",
3
3
  "db:start": "docker start {{project}}-dev-db",
4
4
  "db:stop": "docker stop {{project}}-dev-db",
@@ -29,7 +29,7 @@ components:
29
29
  volumes: [db-data]
30
30
  image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
31
31
  compose: infra/swarm/stack.yaml
32
- purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from db/Dockerfile, migrated by starci app db migrate.
32
+ purpose: The app's own PostgreSQL, the official postgres image plus pgvector built from .starcistacks/images/db/Dockerfile, migrated by starci app db migrate.
33
33
  required: true
34
34
  ingress:
35
35
  role: ingress
@@ -1,6 +1,6 @@
1
1
  # Development stack
2
2
 
3
- Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from `db/Dockerfile`) starts with `npm run db:start` and takes the migrations with `npm run db:push`; storage is the S3-compatible MinIO the environment names. Every other declared dependency, such as Redis, starts through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model; Compose is an implementation detail. The app services may run in the local Docker Swarm model or through a declared native development profile while dependencies stay in Docker.
3
+ Every development dependency runs in Docker Linux containers. The `db` service (the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile`) starts with `npm run db:start` and takes the migrations with `npm run db:push`; storage is the S3-compatible MinIO the environment names. Every other declared dependency, such as Redis, starts through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model; Compose is an implementation detail. The app services may run in the local Docker Swarm model or through a declared native development profile while dependencies stay in Docker.
4
4
  The application image references contain illustrative all-zero digests. Replace them with verified published release digests in the manifest, model and `.starcistacks/dev/infra/swarm/releases/images.lock.yaml` before starting the container model. Seal the local database connection and the integration credentials; a container cannot use the host's loopback address without owner-configured networking. Local source development remains available through the app scripts.
5
5
 
6
6
  The runtime template tracks no encrypted members. Scaffolding generates the six declared members with placeholder values encrypted to this app's own recipient in `.sops.yaml`. Replace those values with `starci app secret set dev` using the matching host age identity in untracked local custody. Existing generated envelopes are decryptable with that app identity and can be updated in place. Each Docker secret contains the corresponding key's real value, and the API receives it through its declared mounted file.
@@ -1,7 +1,7 @@
1
1
  # Production stack
2
2
 
3
3
  The tracked model declares Docker Swarm application services and one digest-pinned Caddy ingress.
4
- The `db` service (the official postgres image plus pgvector, built from `db/Dockerfile` and pushed with the release images) supplies PostgreSQL on a named volume; sign-in is the API's own (Passport) and storage is the S3-compatible MinIO the environment names. The app and its dependencies run on Docker Swarm. A Redis service runs only when `hfs.json` `sides.be.backing` declares it for a capability that needs it: one stateful replica with a digest-pinned image, a named volume with a backup entry and its password as a sealed member granted as a Swarm secret (Redis runs `--appendonly yes` with the volume at `/data`). The scaffold declares none; `starci app add queue|job` declares Redis and renders it here.
4
+ The `db` service (the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile` and pushed with the release images) supplies PostgreSQL on a named volume; sign-in is the API's own (Passport) and storage is the S3-compatible MinIO the environment names. The app and its dependencies run on Docker Swarm. A Redis service runs only when `hfs.json` `sides.be.backing` declares it for a capability that needs it: one stateful replica with a digest-pinned image, a named volume with a backup entry and its password as a sealed member granted as a Swarm secret (Redis runs `--appendonly yes` with the volume at `/data`). The scaffold declares none; `starci app add queue|job` declares Redis and renders it here.
5
5
  The model is a deployment declaration. It contains no owner credentials, real DNS receipt, completed release or restore proof. Public records under `.starcistacks/production/infra/swarm/` explicitly mark those owner-run operations as pending.
6
6
  Its application image references contain illustrative all-zero digests; they identify no published image and must be replaced before deployment.
7
7
 
@@ -8,7 +8,7 @@ The starter keeps one deployable API and one web app, with the same code structu
8
8
 
9
9
  ## Stack
10
10
 
11
- TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from `db/Dockerfile`), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
11
+ TypeScript everywhere, NestJS in `be/`, Next.js with next-intl in `fe/`, PostgreSQL (the `db` service: the official postgres image plus pgvector, built from `.starcistacks/images/db/Dockerfile`), Passport for Google and password sign-in, S3-compatible storage (MinIO), npm workspaces, and Turbo for front-end tasks.
12
12
 
13
13
  ## Repository layout
14
14
 
@@ -24,7 +24,7 @@ Declare `ci.github.repository` and `ci.github.defaultBranch` in `.starcistacks/a
24
24
 
25
25
  ## Development
26
26
 
27
- Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image `db/Dockerfile` builds, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://postgres@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
27
+ Every development dependency runs in Docker Linux containers. Create the local database once with `npm run db:create`, start it with `npm run db:start` (one container of the image `.starcistacks/images/db/Dockerfile` builds, on 127.0.0.1:54322), export `DATABASE_URL=postgresql://postgres@127.0.0.1:54322/postgres` and apply `db/bootstrap.sql` and the migrations with `npm run db:push`; start any other declared dependency through its managed `<dep>:create`, `<dep>:start` and `<dep>:stop` scripts or the declared container model. Compose is an implementation detail. Configure the local environment and start the API and web app using the package scripts for the declared development profile. In production, the app and its dependencies, the database included, run on Docker Swarm; pgvector owns vector search.
28
28
 
29
29
  ```sh
30
30
  npm install
@@ -3,6 +3,8 @@ import type { ParsedEvent } from "@modules/platform/event-bus"
3
3
 
4
4
  /** A durable receipt reference; processing reads the provider payload after the intake commits. */
5
5
  export interface @@Provider@@DeliveryRecordedPayload {
6
+ /** The business key a consumer's inbox claims: the receipt, once per consumer however often it is delivered. */
7
+ readonly dedupeKey: string
6
8
  readonly receiptId: string
7
9
  }
8
10
 
@@ -20,17 +22,17 @@ export class @@Provider@@DeliveryRecordedEvent extends BaseEvent {
20
22
  super()
21
23
  }
22
24
 
23
- /** The receipt identity is stable across relay retries and distinct from a consumer's inbox source. */
24
- static create(payload: @@Provider@@DeliveryRecordedPayload): @@Provider@@DeliveryRecordedEvent {
25
- return new @@Provider@@DeliveryRecordedEvent(payload.receiptId, payload)
25
+ /** The receipt identity is stable across redeliveries and distinct from a consumer's inbox source. */
26
+ static create(payload: Omit<@@Provider@@DeliveryRecordedPayload, "dedupeKey">): @@Provider@@DeliveryRecordedEvent {
27
+ return new @@Provider@@DeliveryRecordedEvent(payload.receiptId, { dedupeKey: payload.receiptId, receiptId: payload.receiptId })
26
28
  }
27
29
 
28
- /** Reads an outbox envelope; malformed deliveries never reach a processing consumer. */
30
+ /** Reads the event envelope; malformed deliveries never reach a processing consumer. */
29
31
  static parse(envelope: unknown): ParsedEvent<@@Provider@@DeliveryRecordedEvent> {
30
32
  if (typeof envelope !== "object" || envelope === null || !("eventId" in envelope) || !("payload" in envelope)) return null
31
33
  const { eventId, payload } = envelope
32
34
  if (typeof eventId !== "string" || !eventId.trim() || typeof payload !== "object" || payload === null || !("receiptId" in payload)) return null
33
35
  if (typeof payload.receiptId !== "string" || !payload.receiptId.trim()) return null
34
- return new @@Provider@@DeliveryRecordedEvent(eventId, { receiptId: payload.receiptId })
36
+ return new @@Provider@@DeliveryRecordedEvent(eventId, { dedupeKey: payload.receiptId, receiptId: payload.receiptId })
35
37
  }
36
38
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "events": {
3
3
  "@@provider@@.delivery-recorded": {
4
- "payload": { "receiptId": "string" },
4
+ "payload": { "dedupeKey": "string", "receiptId": "string" },
5
5
  "version": 1
6
6
  }
7
7
  },
@@ -1,23 +1,38 @@
1
+ /**
2
+ * The suffix of a consumer's dead-letter queue: a delivery past its consumer's maxDeliveries moves from `<topic>__<consumer>` to
3
+ * `<topic>__<consumer>__dead`, which an operator reads and replays (BE-EVENT-BUS-4).
4
+ */
5
+ export const DEAD_QUEUE_SUFFIX = "__dead"
6
+
7
+ /** The deliveries a consumer gets before its message moves to the dead-letter queue, when its subscription names none. */
8
+ export const DEFAULT_MAX_DELIVERIES = 5
9
+
10
+ /** What every event payload carries: the business key a consumer's inbox claims, stable across redelivery and replay. */
11
+ export interface EventPayload {
12
+ /** The business identity of the fact (an order id, a receipt id): the consumer claims (consumer, dedupeKey) once. */
13
+ readonly dedupeKey: string
14
+ }
15
+
1
16
  /** The base of each typed event in modules/events: its stable identity, name and payload. */
2
17
  export abstract class BaseEvent {
3
- /** A receiver uses this stable identity to deduplicate redelivery. */
18
+ /** The stable identity of this event, carried for logs and tracing. */
4
19
  abstract readonly eventId: string
5
- /** The event name listed by the service's vendored event contract. */
20
+ /** The event name listed by the service's vendored event contract; it is the pgmq topic the event is sent to. */
6
21
  abstract readonly eventName: string
7
- /** The fact that a reactor or job processes after commit. */
8
- abstract readonly payload: object
22
+ /** The fact that a reactor processes after commit, with its business dedupe key. */
23
+ abstract readonly payload: EventPayload
9
24
  }
10
25
 
11
26
  /** Parsing either returns the event or refuses a mismatched envelope. */
12
27
  export type ParsedEvent<Event extends BaseEvent> = Event | null
13
28
 
14
- /** The class side of a typed event, shared by publishers and later consumers. */
29
+ /** The class side of a typed event, shared by publishers and consumers. */
15
30
  export interface EventClass<Event extends BaseEvent> {
16
31
  /** The contract's event name. */
17
32
  readonly eventName: string
18
33
  /** The contract's payload version. */
19
34
  readonly version: number
20
- /** Reads the event from its durable envelope. */
35
+ /** Reads the event from its envelope. */
21
36
  parse(envelope: unknown): ParsedEvent<Event>
22
37
  }
23
38
 
@@ -25,21 +40,25 @@ export interface EventClass<Event extends BaseEvent> {
25
40
  export interface EventDelivery<Event extends BaseEvent> {
26
41
  /** The request id carried explicitly from intake to delivery. */
27
42
  readonly correlationId: string
28
- /** The consumer's idempotency key. */
43
+ /** The stable identity of the event. */
29
44
  readonly eventId: string
45
+ /** The consumer's queue, `<topic>__<consumer>`: the first half of the inbox claim. */
46
+ readonly consumer: string
47
+ /** The payload's business key: the second half of the inbox claim. */
48
+ readonly dedupeKey: string
30
49
  /** The typed event. */
31
50
  readonly event: Event
32
- /** The number of attempts, including this one. */
51
+ /** The number of deliveries, including this one. */
33
52
  readonly attempt: number
34
53
  }
35
54
 
36
55
  /** Explicit request context carried independently of the business payload. */
37
56
  export interface EventMetadata {
38
- /** The originating request or promoted legacy event identity. */
57
+ /** The originating request or promoted event identity. */
39
58
  readonly correlationId: string
40
59
  }
41
60
 
42
- /** The typed event's durable envelope, read only after the intake transaction commits. */
61
+ /** The typed event's envelope, sent once to its topic in the publisher's transaction and routed to every bound consumer queue. */
43
62
  export interface EventEnvelope {
44
63
  /** The originating request context, independent of the business payload. */
45
64
  readonly metadata: EventMetadata
@@ -48,5 +67,33 @@ export interface EventEnvelope {
48
67
  /** The contract's event name. */
49
68
  readonly eventName: string
50
69
  /** The data of the fact. */
51
- readonly payload: object
70
+ readonly payload: EventPayload
71
+ }
72
+
73
+ /** One message of a consumer's queue, as event_bus_read returns it. */
74
+ export interface EventMessage {
75
+ /** The pgmq message id within its queue. */
76
+ readonly msgId: string
77
+ /** How many times the message has been read, including this read. */
78
+ readonly readCt: number
79
+ /** The envelope as the publisher sent it; untrusted until parsed. */
80
+ readonly message: unknown
81
+ }
82
+
83
+ /** What a reactor's subscription may tune; every field is a literal of the subscription. */
84
+ export interface EventSubscriptionOptions {
85
+ /** The deliveries before the message moves to the dead-letter queue. Default DEFAULT_MAX_DELIVERIES. */
86
+ readonly maxDeliveries?: number
87
+ }
88
+
89
+ /** A delivery a consumer gave up on, as its dead-letter queue holds it. */
90
+ export interface DeadLetter {
91
+ /** The message id in the dead-letter queue: what requeue takes. */
92
+ readonly id: string
93
+ /** Why it was moved: its deliveries ran out, or its envelope was malformed. */
94
+ readonly reason: string
95
+ /** The deliveries it had. */
96
+ readonly readCount: number
97
+ /** The envelope as it was read. */
98
+ readonly message: unknown
52
99
  }