@starci/hfs 4.11.0 → 4.11.8

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 (123) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +40 -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 +29 -12
  9. package/runtime/knowledge/hfs/slots.yaml +48 -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/gates/starcistacks.mjs +6 -2
  16. package/runtime/scripts/guards/push-scan.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  18. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  19. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  20. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  21. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  22. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  23. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  24. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  25. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  27. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  28. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  29. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  30. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  31. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  32. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  33. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  34. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  36. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  38. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  41. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  42. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  44. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  45. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  46. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  47. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  48. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  49. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  50. package/runtime/scripts/hfs/secret-sops.mjs +93 -9
  51. package/runtime/scripts/hfs/secret-standard.mjs +315 -0
  52. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  53. package/runtime/scripts/hfs/secret.mjs +110 -46
  54. package/runtime/scripts/hfs/slots.mjs +8 -1
  55. package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
  56. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  57. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  58. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  59. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  60. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  61. package/scaffold/add-inbox-lite.mjs +2 -2
  62. package/scaffold/app.mjs +2 -2
  63. package/src/main.mjs +5 -4
  64. package/stack/build.mjs +1 -1
  65. package/stack/config.mjs +2 -1
  66. package/stack/docker-transport.mjs +2 -1
  67. package/stack/remote.mjs +4 -3
  68. package/stack/sealed.mjs +9 -8
  69. package/stack/target-binding.mjs +2 -1
  70. package/sync/hygiene.mjs +1 -1
  71. package/sync/index.mjs +33 -10
  72. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  73. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  74. package/templates/app/gitignore +2 -1
  75. package/templates/app/gitignore-lite +3 -1
  76. package/templates/app/package-scripts/db.json +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  78. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  79. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  80. package/templates/app/skeleton-lite/README.md +2 -2
  81. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  82. package/templates/be/inbox/events.json.tpl +1 -1
  83. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  84. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  85. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  86. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  87. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  88. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  89. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  90. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  91. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  92. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  93. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  94. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  95. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  96. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  97. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  98. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  99. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  100. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  101. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  102. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  103. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  104. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  105. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  106. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  107. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  108. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  109. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  110. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  111. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
  112. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
  113. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  114. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  116. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  117. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  118. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  119. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  120. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  121. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  122. package/templates/app/db-image/db/Dockerfile +0 -8
  123. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
package/stack/remote.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // `bash -s` fail with `set: pipefail`). Key material lives in memory and in a private temp directory only; it is never in argv (only the key
9
9
  // FILE path is), in a line, in a result or in an error message.
10
10
  import fs from 'node:fs';
11
+ import { isLegacyName, memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
11
12
  import os from 'node:os';
12
13
  import path from 'node:path';
13
14
  import { tool } from './context.mjs';
@@ -78,7 +79,7 @@ function openMember(slug, read) {
78
79
 
79
80
  function opsAccount(ctx, config) {
80
81
  const slug = config.remote.sealed.ops;
81
- const map = openMember(slug, () => ctx.sops.decrypt(path.join(config.secretsDir, `${slug}.enc`), 'dotenv'));
82
+ const map = openMember(slug, () => ctx.sops.decrypt(memberFile(config.secretsDir, slug), isLegacyName(memberFile(config.secretsDir, slug)) ? 'dotenv' : 'yaml'));
82
83
  for (const name of ['VPS_HOST', 'VPS_USER']) {
83
84
  const value = map[name];
84
85
  if (typeof value !== 'string' || !ACCOUNT.test(value) || value.startsWith('-')) throw refusal('STACK_REMOTE_OPS', `${slug} must hold ${name} (letters, digits, . _ @ -)`);
@@ -87,7 +88,7 @@ function opsAccount(ctx, config) {
87
88
  }
88
89
 
89
90
  function binaryMember(ctx, config, slug, names) {
90
- return unwrap(openMember(slug, () => ctx.sops.decryptBinary(path.join(config.secretsDir, `${slug}.enc`))), names);
91
+ return unwrap(openMember(slug, () => ctx.sops.decryptBinary(memberFile(config.secretsDir, slug))), names);
91
92
  }
92
93
 
93
94
  const withNewline = (text) => `${text.trim()}\n`;
@@ -159,7 +160,7 @@ function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
159
160
  const { sealed } = config.remote;
160
161
  if (config.nativeTarget) return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
161
162
  if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
162
- const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
163
+ const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(memberFile(config.secretsDir, slug)));
163
164
  if (!present) return ctx;
164
165
  return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
165
166
  }
package/stack/sealed.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // service is granted (the shared half of the release tools). No value is ever written to disk, printed or put in an argument list:
3
3
  // callers receive `value` and must keep it in memory. Config objects (non-secret files mounted into services) are versioned by content.
4
4
  import { createHash, createHmac } from 'node:crypto';
5
+ import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { secretRoots } from '../runtime/scripts/hfs/secret-files.mjs';
@@ -65,10 +66,10 @@ export function requireAgeIdentity(ctx, config, purpose) {
65
66
  if (!hasAgeIdentity(ctx, config)) throw refusal('STACK_AGE_IDENTITY_MISSING', `${purpose} needs the sealed members, and no age identity is available: ${AGE_HINT}`);
66
67
  }
67
68
 
68
- /** Opens one sealed member (`<secretsDir>/<member>.enc`) into a { KEY: value } map. Names the member only on failure. */
69
+ /** Opens one sealed member (`<secretsDir>/<member>.enc.yaml`, or a legacy `.enc`) into a { KEY: value } map. Names the member only on failure. */
69
70
  export function openMember(ctx, config, member) {
70
71
  try {
71
- const map = readMap(path.join(config.secretsDir, `${member}.enc`), ctx.sops);
72
+ const map = readMap(memberFile(config.secretsDir, member), ctx.sops);
72
73
  return Object.fromEntries(Object.entries(map).map(([key, value]) => [key, typeof value === 'string' && !value.includes(String.fromCodePoint(0)) ? lfText(value) : value]));
73
74
  } catch {
74
75
  throw refusal('STACK_SECRET_MEMBER_UNREADABLE', `the sealed member ${member} cannot be opened${hasAgeIdentity(ctx, config) ? ' (is the age identity a recipient of this member?)' : `: no age identity is available, ${AGE_HINT}`}`);
@@ -116,13 +117,13 @@ function sealGrants(config, memberOf, versionKey) {
116
117
  }
117
118
 
118
119
  function sealSettings(config, memberOf) {
119
- const block = config.secrets.settings;
120
- if (block === null) return {};
121
- const map = memberOf(block.member);
122
120
  const settings = {};
123
- for (const key of block.keys.filter((name) => present(map, name))) {
124
- if (UNSAFE_SETTING.test(map[key])) throw refusal('STACK_SETTING_INVALID', `${key} must be a plain one-line value`);
125
- settings[key] = map[key];
121
+ for (const block of config.secrets.settings) {
122
+ const map = memberOf(block.member);
123
+ for (const key of block.keys.filter((name) => present(map, name))) {
124
+ if (UNSAFE_SETTING.test(map[key])) throw refusal('STACK_SETTING_INVALID', `${key} must be a plain one-line value`);
125
+ settings[key] = map[key];
126
+ }
126
127
  }
127
128
  return settings;
128
129
  }
@@ -1,5 +1,6 @@
1
1
  // Public endpoint assertions for admitted native consumers; this module never opens custody.
2
2
  import fs from 'node:fs';
3
+ import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
3
4
  import path from 'node:path';
4
5
  import { refusal } from './errors.mjs';
5
6
 
@@ -52,7 +53,7 @@ export function bindStackTarget(ctx, config, binding) {
52
53
  || !config.remote.sealed || ctx.env.DOCKER_HOST || ctx.env.DOCKER_CONTEXT || ctx.env.GITHUB_ACTIONS
53
54
  || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) denied();
54
55
  for (const member of Object.values(config.remote.sealed)) {
55
- const file = path.join(config.secretsDir, `${member}.enc`);
56
+ const file = memberFile(config.secretsDir, member);
56
57
  assertBoundCustody(config.repoRoot, file);
57
58
  }
58
59
  return { ...config, nativeTarget: binding };
package/sync/hygiene.mjs CHANGED
@@ -58,7 +58,7 @@ export function hygieneFindings(files, ignored) {
58
58
  if (file.startsWith(WORK) && ignored.has(file)) {
59
59
  findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
60
60
  }
61
- if (file.startsWith(STACKS) && !file.endsWith('.enc') && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
61
+ if (file.startsWith(STACKS) && !/\.enc(?:\.yaml)?$/.test(file) && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
62
62
  if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
63
63
  else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
64
64
  }
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,7 @@ 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';
43
44
 
44
45
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
45
46
  const NODE_MAJOR = 22;
@@ -58,9 +59,10 @@ export class SyncError extends Error {
58
59
  }
59
60
 
60
61
  // 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' });
62
+ // package.json, section = the whole file but the one marked section a database verb owns (db/bootstrap.sql, whose squash section
63
+ // `starci app db baseline --squash` writes). A template that can hold a comment opens with {{header}}; a file whose exact content is
64
+ // specified (JSON, the eslint one-liner) has no header.
65
+ const MODE_OF_GROUP = Object.freeze({ 'package-scripts': 'scripts', 'package-scripts-lite': 'scripts', 'db-bootstrap': 'section' });
64
66
  const GLOB_CHARACTERS = new Set('*?<>{}[]');
65
67
  const literalFile = file => file.length >= 2 && !file.endsWith('/') && ![...file].some(character => GLOB_CHARACTERS.has(character));
66
68
 
@@ -158,13 +160,22 @@ function trailingNewline(text) {
158
160
  }
159
161
 
160
162
  /**
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.
163
+ * 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
164
+ * Docker Official postgres image by digest, to which the Dockerfile adds PGVECTOR_PACKAGE from PGDG and the PGMQ release.
163
165
  */
164
166
  export const DB_BASE_IMAGE = 'postgres:17.11@sha256:31ba9c94a03ada9988f2bb493a22a75eef3a185884533e5cdbcd498ea835b08f';
165
167
  /** The PGDG package of pgvector at the exact version the db image installs (the one extension the image does not ship). */
166
168
  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. */
169
+ /**
170
+ * The pgmq release the db image installs for the event bus (ruling:lite-pgmq-bus-bullmq-jobs): its source tarball, verified by sha256
171
+ * when Docker adds it. PGDG does not package pgmq; it is SQL objects only, so the Dockerfile copies its control and script files.
172
+ */
173
+ export const PGMQ = Object.freeze({
174
+ version: '1.13.1',
175
+ source: 'https://github.com/pgmq/pgmq/archive/refs/tags/v1.13.1.tar.gz',
176
+ sha256: '67221f288f42f95c002a64df40f7d660e765e32df8ada0f2672ba915a14827f7',
177
+ });
178
+ /** The local tag the development db container and the drift scratch database run, built from the app's .starcistacks/images/db/Dockerfile. */
168
179
  export const dbLocalImage = project => `${project}-db:local`;
169
180
  /** The digest-pinned image of a lite app's declared Redis, in every environment that runs it (ruling:R212/case-10). */
170
181
  export const REDIS_IMAGE = 'redis:7@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f';
@@ -385,10 +396,13 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
385
396
  project: app.project,
386
397
  appBinary: APP_BINARY,
387
398
  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).
399
+ // 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
400
  dbImage: dbLocalImage(app.project),
390
401
  dbBaseImage: DB_BASE_IMAGE,
391
402
  pgvectorPackage: PGVECTOR_PACKAGE,
403
+ pgmqVersion: PGMQ.version,
404
+ pgmqSource: PGMQ.source,
405
+ pgmqSha256: PGMQ.sha256,
392
406
  // A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
393
407
  backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
394
408
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
@@ -472,9 +486,10 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
472
486
  });
473
487
  }
474
488
 
475
- // The part of a file sync manages: all of it, the marked block, or the scripts block as text.
489
+ // 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
490
  function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
477
491
  if (mode === 'file') return text;
492
+ if (mode === 'section') return bootstrapSection(text).head;
478
493
  if (mode === 'scripts') {
479
494
  const scripts = parseScripts(text);
480
495
  return scripts ? scriptsText(scripts) : null;
@@ -524,6 +539,7 @@ export function writtenContent(target, existing) {
524
539
  }
525
540
  return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
526
541
  }
542
+ if (target.mode === 'section') return `${target.content}${bootstrapSection(existing ?? '').section ?? ''}`;
527
543
  if (target.mode === 'block' && existing) {
528
544
  const current = managedPart('block', existing, target.markers);
529
545
  if (current !== null) return existing.replace(current, () => target.content);
@@ -571,6 +587,12 @@ export async function renderRepo(root, { presets, parseYaml, notices, manifest =
571
587
 
572
588
  /** 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
589
  const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
590
+ /** The files a lite app's sync retires, each with the reason --check prints. */
591
+ const LITE_RETIRED_FILES = Object.freeze({
592
+ [RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
593
+ });
594
+ /** Why a retired file goes: a retired file's own reason, else the folder's. */
595
+ const retiredReason = (file) => LITE_RETIRED_FILES[file] ?? 'the lite edition generates no database types';
574
596
 
575
597
  /** 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
598
  export function retiredFiles(root, hfs) {
@@ -584,6 +606,7 @@ export function retiredFiles(root, hfs) {
584
606
  else found.push(rel);
585
607
  };
586
608
  for (const dir of LITE_RETIRED_DIRS) walk(dir);
609
+ for (const file of Object.keys(LITE_RETIRED_FILES)) walk(file);
587
610
  return found;
588
611
  }
589
612
 
@@ -602,7 +625,7 @@ function syncOptions(argv) {
602
625
  function reportSyncCheck(root, targets, out, retired = []) {
603
626
  const results = checkTargets(root, targets);
604
627
  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`);
628
+ for (const file of retired) out(`HFS_SYNC_DRIFT ${file}: retired, ${retiredReason(file)}; run "npx starci app sync --write" to delete it`);
606
629
  for (const result of bad) {
607
630
  const found = result.actualHash ? `, found ${result.actualHash.slice(0, 12)}` : '';
608
631
  const where = result.difference ? ` (line ${result.difference.line}: expected ${JSON.stringify(result.difference.expected)}, found ${JSON.stringify(result.difference.actual)})` : '';
@@ -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
  }
@@ -1,9 +1,21 @@
1
1
  import { injector } from "@modules/platform/composition"
2
2
  import type { TypedParameterDecorator } from "@modules/platform/composition"
3
- import type { EventBus } from "./event-bus.port"
3
+ import { MODULE_OPTIONS_TOKEN } from "./event-bus.module-definition"
4
+ import type { EventBusOptions } from "./event-bus.options"
5
+ import type { EventBus, EventConsumerRegistry } from "./event-bus.port"
4
6
 
5
7
  /** Token of the transactional EventBus publisher. */
6
8
  export const EVENT_BUS: unique symbol = Symbol("platform.event-bus")
7
9
 
10
+ /** Token of the registry reactors subscribe their consumers to. */
11
+ export const EVENT_CONSUMER_REGISTRY: unique symbol = Symbol("platform.event-bus.consumers")
12
+
8
13
  /** Injects the EventBus port. Parameter type: EventBus. */
9
14
  export const InjectEventBus = (): TypedParameterDecorator<EventBus> => injector<EventBus>(EVENT_BUS)
15
+
16
+ /** Injects the consumer registry. Parameter type: EventConsumerRegistry. */
17
+ export const InjectEventConsumerRegistry = (): TypedParameterDecorator<EventConsumerRegistry> =>
18
+ injector<EventConsumerRegistry>(EVENT_CONSUMER_REGISTRY)
19
+
20
+ /** Injects the module's options. Parameter type: EventBusOptions. */
21
+ export const InjectEventBusOptions = (): TypedParameterDecorator<EventBusOptions> => injector<EventBusOptions>(MODULE_OPTIONS_TOKEN)
@@ -0,0 +1,9 @@
1
+ /** Log events of the event bus. */
2
+ export enum EventBusLogEvent {
3
+ /** A consumer threw; the message reappears after its visibility timeout. */
4
+ ConsumerFailed = "event-bus.consumer.failed",
5
+ /** A delivery ran out of deliveries or was malformed and moved to its consumer's dead-letter queue. */
6
+ DeliveryDead = "event-bus.delivery.dead",
7
+ /** A read of a consumer's queue failed; the loop reads again after its idle pause. */
8
+ ReadFailed = "event-bus.read.failed",
9
+ }
@@ -1,6 +1,6 @@
1
1
  import { ConfigurableModuleBuilder } from "@nestjs/common"
2
2
  import type { EventBusOptions } from "./event-bus.options"
3
3
 
4
- export const { ConfigurableModuleClass, OPTIONS_TYPE } = new ConfigurableModuleBuilder<EventBusOptions>()
4
+ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = new ConfigurableModuleBuilder<EventBusOptions>()
5
5
  .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal }))
6
6
  .build()
@@ -1,19 +1,25 @@
1
1
  import { Module } from "@nestjs/common"
2
2
  import type { DynamicModule } from "@nestjs/common"
3
- import { EVENT_BUS } from "./event-bus.decorators"
3
+ import { EVENT_BUS, EVENT_CONSUMER_REGISTRY } from "./event-bus.decorators"
4
4
  import { ConfigurableModuleClass, OPTIONS_TYPE } from "./event-bus.module-definition"
5
5
  import { EventBusService } from "./event-bus.service"
6
+ import { EventConsumerService } from "./event-consumer.service"
6
7
 
7
8
  @Module({})
8
- /** Provides the transactional lite EventBus publisher, shared by domain intake services. */
9
+ /** Provides the lite event bus on pgmq: the transactional publisher and the read loops of the consumer groups. */
9
10
  export class EventBusModule extends ConfigurableModuleClass {
10
- /** Registers the publishing capability once per app. */
11
+ /** Registers the capability once per app. */
11
12
  static register(options: typeof OPTIONS_TYPE): DynamicModule {
12
13
  const base = super.register(options)
13
14
  return {
14
15
  ...base,
15
- providers: [...(base.providers ?? []), { provide: EVENT_BUS, useClass: EventBusService }],
16
- exports: [EVENT_BUS],
16
+ providers: [
17
+ ...(base.providers ?? []),
18
+ { provide: EVENT_BUS, useClass: EventBusService },
19
+ EventConsumerService,
20
+ { provide: EVENT_CONSUMER_REGISTRY, useExisting: EventConsumerService },
21
+ ],
22
+ exports: [EVENT_BUS, EVENT_CONSUMER_REGISTRY],
17
23
  }
18
24
  }
19
25
  }