@starci/hfs 4.11.13 → 4.11.15

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 (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/db/migrate.mjs +1 -1
  3. package/db/postgres.mjs +38 -3
  4. package/package.json +1 -1
  5. package/release/index.mjs +4 -3
  6. package/release/lint-baseline.mjs +123 -0
  7. package/release/urgent.mjs +19 -4
  8. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  9. package/runtime/knowledge/hfs/rules.yaml +3 -3
  10. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  11. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  12. package/scaffold/add-compose.mjs +3 -0
  13. package/scaffold/add-inbox-lite.mjs +45 -23
  14. package/scaffold/lite-exports.mjs +13 -0
  15. package/src/main.mjs +2 -2
  16. package/stack/cli.mjs +5 -3
  17. package/stack/deploy.mjs +6 -2
  18. package/stack/urgent-floor.mjs +101 -7
  19. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  20. package/templates/be/inbox/entity.ts.tpl +29 -0
  21. package/templates/be/inbox/index.ts.tpl +1 -0
  22. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  23. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  24. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  25. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  26. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  27. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  28. package/templates/be/inbox/service.ts.tpl +13 -13
  29. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  30. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  31. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  32. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  33. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  34. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  35. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  36. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  37. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
  38. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  39. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  40. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  41. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  42. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  43. package/templates/be/inbox/database-client.ts.tpl +0 -45
@@ -13,6 +13,10 @@ const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
13
13
  const timestampOf = (now) =>
14
14
  new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
15
15
  const upperOf = (name) => name.replaceAll("-", "_").toUpperCase();
16
+ const camelOf = (name) => {
17
+ const pascal = pascalOf(name);
18
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
19
+ };
16
20
  const snakeOf = (name) => name.replaceAll("-", "_");
17
21
 
18
22
  const template = (relative, values) => {
@@ -72,8 +76,6 @@ const databasePatches = (root) => {
72
76
  "lite add webhook needs the scaffolded platform/database capability",
73
77
  );
74
78
  let index = fs.readFileSync(indexFile, "utf8").replace(/\r\n/g, "\n");
75
- if (!index.includes('export { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"'))
76
- index = `${index.trimEnd()}\nexport { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"\n`;
77
79
  if (!index.split("\n").some((line) => line.startsWith("export { ") && line.includes("DatabaseErrorCode") && line.endsWith(' } from "./errors/database.error"')))
78
80
  index = `${index.trimEnd()}\nexport { DatabaseError, DatabaseErrorCode } from "./errors/database.error"\n`;
79
81
  let error = withSqlIdentifierError(fs.readFileSync(errorFile, "utf8").replace(/\r\n/g, "\n"));
@@ -107,7 +109,15 @@ const handoffPlatformFiles = (root, values) => {
107
109
  const planned = [];
108
110
  for (const capability of ["inbox", "idempotency", "event-bus"]) {
109
111
  const base = `be/src/modules/platform/${capability}`;
110
- if (fs.existsSync(path.join(root, base, "index.ts"))) continue;
112
+ if (fs.existsSync(path.join(root, base, "index.ts"))) {
113
+ // The provider intake claims through the EntityInbox port; an inbox written before it cannot serve a new provider.
114
+ if (capability === "inbox" && !fs.readFileSync(path.join(root, base, "index.ts"), "utf8").includes("InjectEntityInbox"))
115
+ throw new ScaffoldError(
116
+ "HFS_ADD_INBOX_ENTITY_PORT",
117
+ `${base} predates the EntityInbox port (InjectEntityInbox, claimRow) the ${values.provider} intake claims through; bring it to the inbox of this hfs first`,
118
+ );
119
+ continue;
120
+ }
111
121
  const folder = path.join(TEMPLATES_DIR, "be", "inbox", "platform", capability);
112
122
  for (const file of filesBelow(folder)) {
113
123
  const relative = path.relative(folder, file).replaceAll("\\", "/");
@@ -149,6 +159,28 @@ const eventFiles = (root, provider, values) => {
149
159
  return { planned, patches };
150
160
  };
151
161
 
162
+ /** The lite entity list (BE-PERSISTENCE-3), where the provider inbox's `<provider>InboxEntities` is spread once. */
163
+ const PRIMARY_ENTITIES_FILE = ["be", "src", "modules", "platform", "database", "entities.ts"];
164
+ const PRIMARY_ENTITIES = /(export const PRIMARY_ENTITIES[^=]*=\s*\[)([\s\S]*?)(\])/;
165
+
166
+ /** entities.ts with the receipt entity list imported from the provider inbox barrel and spread into PRIMARY_ENTITIES. */
167
+ const primaryEntitiesPatch = (root, provider, list) => {
168
+ const file = path.join(root, ...PRIMARY_ENTITIES_FILE);
169
+ const relative = PRIMARY_ENTITIES_FILE.join("/");
170
+ if (!fs.existsSync(file))
171
+ throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which is missing`);
172
+ const text = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
173
+ if (!PRIMARY_ENTITIES.test(text))
174
+ throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which declares no PRIMARY_ENTITIES array`);
175
+ if (new RegExp(String.raw`\.\.\.${list}\b`).test(text)) return { file, text };
176
+ const withList = text.replace(PRIMARY_ENTITIES, (all, head, items, tail) =>
177
+ `${head}${[...items.split(",").map((item) => item.trim()).filter(Boolean), `...${list}`].join(", ")}${tail}`);
178
+ const lines = withList.split("\n");
179
+ const lastImport = lines.reduce((at, line, index) => (line.startsWith("import ") ? index : at), -1);
180
+ lines.splice(lastImport + 1, 0, `import { ${list} } from "@modules/domain/${provider}-inbox"`);
181
+ return { file, text: lines.join("\n") };
182
+ };
183
+
152
184
  const eventPatternPatch = (root) => {
153
185
  const file = path.join(root, "hfs.json");
154
186
  const declaration = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -190,6 +222,7 @@ export function addLiteWebhookInbox({
190
222
  ...values,
191
223
  provider,
192
224
  Provider,
225
+ providerCamel: camelOf(provider),
193
226
  providerUpper: upperOf(provider),
194
227
  table,
195
228
  };
@@ -221,6 +254,14 @@ export function addLiteWebhookInbox({
221
254
  relative: `${base}/${provider}-inbox.service.spec.ts`,
222
255
  text: template("service.spec.ts.tpl", renderValues),
223
256
  },
257
+ {
258
+ relative: `${base}/persistence/entities/${provider}-inbox.entity.ts`,
259
+ text: template("entity.ts.tpl", renderValues),
260
+ },
261
+ {
262
+ relative: `${base}/persistence/entities/index.ts`,
263
+ text: template("entities.index.ts.tpl", renderValues),
264
+ },
224
265
  {
225
266
  relative: `be/src/modules/events/${provider}/${provider}-delivery-recorded.event.ts`,
226
267
  text: template("delivery-recorded.event.ts.tpl", renderValues),
@@ -242,26 +283,6 @@ export function addLiteWebhookInbox({
242
283
  text: render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", file), "utf8"), {}),
243
284
  });
244
285
  }
245
- const databaseClient = path.join(
246
- root,
247
- "be",
248
- "src",
249
- "modules",
250
- "platform",
251
- "database",
252
- "webhook-inbox.client.ts",
253
- );
254
- if (!fs.existsSync(databaseClient))
255
- planned.push(
256
- {
257
- relative: "be/src/modules/platform/database/webhook-inbox.client.ts",
258
- text: template("database-client.ts.tpl", renderValues),
259
- },
260
- {
261
- relative: "be/src/modules/platform/database/webhook-inbox.client.spec.ts",
262
- text: template("database-client.spec.ts.tpl", renderValues),
263
- },
264
- );
265
286
  const clashes = planned
266
287
  .filter((entry) => fs.existsSync(path.join(root, ...entry.relative.split("/"))))
267
288
  .map((entry) => entry.relative);
@@ -272,6 +293,7 @@ export function addLiteWebhookInbox({
272
293
  );
273
294
  const patches = [
274
295
  ...databasePatches(root),
296
+ primaryEntitiesPatch(root, provider, `${renderValues.providerCamel}InboxEntities`),
275
297
  ...events.patches,
276
298
  eventPatternPatch(root),
277
299
  ];
@@ -108,6 +108,19 @@ const REGISTRATIONS = Object.freeze({
108
108
  source: "./webhook-signature.service",
109
109
  names: ["WebhookSignatureService"],
110
110
  },
111
+ {
112
+ // The webhook door types its request with the correlation the logging middleware stamps on it.
113
+ file: "be/src/modules/platform/logging/index.ts",
114
+ source: "./correlation.contracts",
115
+ names: ["CorrelationRequest"],
116
+ type: true,
117
+ },
118
+ {
119
+ // The lite event bus refuses an envelope whose correlation id is not one the logging capability issues.
120
+ file: "be/src/modules/platform/logging/index.ts",
121
+ source: "./correlation.policy",
122
+ names: ["isCorrelationId"],
123
+ },
111
124
  ],
112
125
  "webhook-full": [
113
126
  {
package/src/main.mjs CHANGED
@@ -116,11 +116,11 @@ starci app db migrate [--execute] [--url-env <NAME>] [--json] [--cwd <dir>]
116
116
  starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--url-env <NAME>] [--json] [--cwd <dir>]
117
117
  starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
118
118
  starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
119
- starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
119
+ starci app release push [--execute] [--remote <name>] [--urgent --reason <text> [--lint-baseline <tag>]] [--json] [--cwd <dir>]
120
120
  starci app upgrade [--plan] [--cwd <dir>]
121
121
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
122
122
  starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
123
- starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
123
+ starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--first-start --db-service <service>] [--execute] [--json] [--cwd <dir>]
124
124
  starci app explain <path> [--cwd <dir>] [--json]
125
125
  starci app emit [--cwd <dir>]
126
126
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
package/stack/cli.mjs CHANGED
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
14
14
 
15
15
  export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
16
16
 
17
- const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent']);
18
- const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
17
+ const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start']);
18
+ const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service']);
19
19
  const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
20
20
 
21
21
  /** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
@@ -72,6 +72,7 @@ const OPTIONS = {
72
72
  images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
73
73
  out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
74
74
  urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
75
+ firstStart: flags['first-start'] === true, dbService: flags['db-service'],
75
76
  }),
76
77
  converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
77
78
  verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
@@ -126,7 +127,8 @@ export async function stackMain(argv, io = {}) {
126
127
  if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
127
128
  throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
128
129
  const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
129
- if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env and --backup-out belong to deploy');
130
+ if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'] || flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env, --backup-out, --first-start and --db-service belong to deploy');
131
+ if (!flags.urgent && (flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--first-start and --db-service are the first start of an empty database under the urgent floor: add --urgent');
130
132
  if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
131
133
  const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
132
134
  const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
package/stack/deploy.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
4
4
  // Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
5
5
  // An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
6
- // backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails.
6
+ // backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails; its
7
+ // first start (`firstStart`) proves the database empty and migrates it inside its own container, a `first-start` step between edge and converge.
7
8
  //
8
9
  // `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
9
10
  // module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
@@ -197,6 +198,9 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
197
198
  const failed = await runSteps([
198
199
  ['deploy', () => stackDeploy(ctx, config, path.join(out, 'stack.yaml'))],
199
200
  ['edge', async () => d.reconcileEdge(ctx, config, path.join(out, 'stack.yaml'))],
201
+ // The first start of a self-hosted database (urgent-floor.mjs): its schema is bootstrapped and migrated once the database runs, before the
202
+ // services that need it are waited for.
203
+ ...(options.firstStartStep ? [['first-start', options.firstStartStep]] : []),
200
204
  ['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
201
205
  ...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
202
206
  ['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
@@ -245,7 +249,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
245
249
  };
246
250
  if (options.urgent === true) {
247
251
  return await urgentDeploy(ctx, config, options, {
248
- render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: () => deployExecute(ctx, config, options, d, out, cleanups), sha256: fileSha256,
252
+ render, dryRun, openStore: () => openStore(ctx, config, options, d, cleanups), deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, cleanups), sha256: fileSha256,
249
253
  }, d.floor);
250
254
  }
251
255
  if (options.execute !== true) return await dryRun();
@@ -10,13 +10,23 @@
10
10
  // deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
11
11
  // STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
12
12
  // The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
13
+ // The first start (`--first-start --db-service <service>`, owner ruling 2026-10-29) is the floor of a self-hosted database the release itself
14
+ // starts: there is nothing to back up and nothing reaches it before the stack runs it, so instead of backup, decrypt and migrate before the deploy
15
+ // the release deploys and, between edge and converge, the floor waits for the database service's container to accept connections, proves the
16
+ // database empty (no migration history, no table in public) and runs migrate preview then execute inside that container (db/bootstrap.sql first,
17
+ // as the --db-url-env role). Every other step holds: record, rollback target, readiness verify, automatic rollback. Each failure is its own code:
18
+ // STACK_FIRST_START_USAGE (no --db-service), STACK_FIRST_START_NO_DATABASE (no container accepted connections in time),
19
+ // STACK_FIRST_START_NOT_EMPTY (a history or a public table: use the plain urgent floor), STACK_FIRST_START_MIGRATE_RED (preview or execute red);
20
+ // each rolls the release back to the previous one; the migrations a red first start applied stay in a database no release used before.
13
21
  // `deps` are the collaborators (backup, decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
14
22
  import fs from 'node:fs';
15
23
  import os from 'node:os';
16
24
  import path from 'node:path';
17
25
  import { readDeclaration } from '../release/index.mjs';
18
26
  import { releaseDir } from '../release/gate.mjs';
27
+ import { DEFAULT_DB_URL_ENV, dbTarget, inContainer, queryRows } from '../db/postgres.mjs';
19
28
  import { readUrgent } from '../release/records.mjs';
29
+ import { lines as splitLines, tool } from './context.mjs';
20
30
  import { refusal } from './errors.mjs';
21
31
 
22
32
  const AGE_HEADER = 'age-encryption.org/v1\n';
@@ -54,7 +64,28 @@ async function defaultMigrate(ctx, { target, execute }) {
54
64
  return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
55
65
  }
56
66
 
57
- export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate });
67
+ /** How long a first start waits for the database service's container to accept connections. */
68
+ export const FIRST_START_WAIT_SECONDS = 180;
69
+
70
+ /** The running container of `service` whose server accepts connections (pg_isready), polled until `seconds` pass; undefined when none did. */
71
+ async function defaultDbContainer(ctx, config, { service, seconds }) {
72
+ const deadline = ctx.now() + seconds * 1000;
73
+ for (;;) {
74
+ const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
75
+ const id = splitLines(found.stdout)[0];
76
+ if (id && tool(ctx, 'docker', ['exec', id, 'pg_isready', '-q']).status === 0) return id;
77
+ if (ctx.now() >= deadline) return undefined;
78
+ await new Promise((resolve) => { setTimeout(resolve, 3000); });
79
+ }
80
+ }
81
+
82
+ /** Whether the target database is empty: no migration history table and no table in public. */
83
+ async function defaultEmptiness(ctx, target) {
84
+ const [row] = queryRows(ctx, target, "select to_regclass('supabase_migrations.schema_migrations') is not null as history, (select count(*)::int from pg_tables where schemaname = 'public') as public_tables", { withhold: true });
85
+ return { empty: row?.history === false && row?.public_tables === 0, history: row?.history === true, publicTables: Number(row?.public_tables ?? -1) };
86
+ }
87
+
88
+ export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate, dbContainer: defaultDbContainer, emptiness: defaultEmptiness });
58
89
 
59
90
  /** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
60
91
  const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
@@ -83,6 +114,11 @@ function preconditions(ctx, config, options) {
83
114
  if (!record) throw refusal('STACK_URGENT_NO_RECORD', `${options.sourceSha} has no urgent record: push it with starci app release push --urgent --reason "<text>" --execute first`);
84
115
  if (record.pushed !== true) throw refusal('STACK_URGENT_NOT_PUSHED', `the urgent release ${record.tag} of ${options.sourceSha.slice(0, 10)} was never pushed`);
85
116
  if (!readDeclaration(config.repoRoot ?? ctx.cwd).database) throw refusal('STACK_URGENT_NO_DATABASE', 'the floor backs up and migrates the database the `database:` block declares, and this app declares none');
117
+ if (options.firstStart === true) {
118
+ // A first start backs nothing up, so it needs no identity for a test decrypt; it needs the service its database runs as.
119
+ if (!options.dbService) throw refusal('STACK_FIRST_START_USAGE', '--first-start names the stack service of the database it bootstraps: add --db-service <service>');
120
+ return { record, identity: null };
121
+ }
86
122
  const identity = ctx.env.SOPS_AGE_KEY_FILE;
87
123
  if (!identity || !fs.existsSync(identity)) throw refusal('STACK_URGENT_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
88
124
  return { record, identity };
@@ -122,6 +158,57 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
122
158
  return null;
123
159
  }
124
160
 
161
+ /**
162
+ * The `first-start` step of a first start's deploy (between edge and converge): the database container, the proof it is empty, migrate preview
163
+ * then execute inside it. Returns { ok, lines, code } where code names the failure.
164
+ */
165
+ async function firstStartStep(ctx, config, options, f) {
166
+ const service = `${config.stack}_${options.dbService}`;
167
+ const container = await f.dbContainer(ctx, config, { service: options.dbService, seconds: FIRST_START_WAIT_SECONDS });
168
+ if (!container) return { ok: false, code: 'STACK_FIRST_START_NO_DATABASE', lines: [`first start: no running container of ${service} accepted connections within ${FIRST_START_WAIT_SECONDS}s`] };
169
+ const target = inContainer(dbTarget(ctx, options.dbUrlEnv ?? DEFAULT_DB_URL_ENV), container);
170
+ const state = await f.emptiness(ctx, target);
171
+ if (!state.empty) {
172
+ const found = state.history ? 'a migration history' : `${state.publicTables < 0 ? 'an unreadable count of' : state.publicTables} table(s) in public`;
173
+ return { ok: false, code: 'STACK_FIRST_START_NOT_EMPTY', lines: [`first start refused: the database of ${service} holds ${found}; a database with data takes the plain urgent floor (backup first)`] };
174
+ }
175
+ const lines = [`first start: the database of ${service} is empty (no migration history, no table in public): nothing to back up`];
176
+ for (const execute of [false, true]) {
177
+ let result;
178
+ try { result = await f.migrate(ctx, { target: { target }, execute }); } catch (error) { result = { ok: false, lines: [String(error?.message ?? error)] }; }
179
+ lines.push(...(result.lines ?? []).map((line) => `first start: migrate${execute ? '' : ' preview'}: ${line}`));
180
+ if (!result.ok) return { ok: false, code: 'STACK_FIRST_START_MIGRATE_RED', lines: [...lines, `first start: the migration${execute ? '' : ' preview'} is red`] };
181
+ }
182
+ return { ok: true, lines };
183
+ }
184
+
185
+ /**
186
+ * A first start: the rollback target, then the deploy whose `first-start` step bootstraps and migrates the empty database. Returns the dry-run
187
+ * plan or a refused result, or { deployed } for the verdicts it shares with the plain floor.
188
+ */
189
+ async function firstStartDeploy(ctx, config, options, run, f, lines) {
190
+ if (options.execute !== true) {
191
+ const plan = await run.dryRun();
192
+ return { ...plan, lines: [...lines, `floor (never skipped), first start of ${config.stack}_${options.dbService}: rollback target, deploy, then before converge: wait for the database, prove it empty, migrate preview then execute inside its container; readiness verify and automatic rollback`, ...plan.lines] };
193
+ }
194
+ const { releaseId } = await run.render();
195
+ lines.push(`floor: release ${releaseId} rendered`);
196
+ const store = await run.openStore();
197
+ if (!(await store.current())) return refused(lines, 'STACK_URGENT_NO_ROLLBACK_TARGET', 'no accepted previous release is stored on the manager, so a red first start could not roll back; nothing was deployed');
198
+ let code = null;
199
+ const deployed = await run.deployExecute({
200
+ firstStartStep: async () => {
201
+ const step = await firstStartStep(ctx, config, options, f);
202
+ code = step.code ?? null;
203
+ return step;
204
+ },
205
+ });
206
+ if (!deployed.ok && code) {
207
+ return refused([...lines, ...deployed.lines], code, `the first start failed; ${deployed.data?.rolledBack ? `the release was rolled back to ${deployed.data.previous}` : 'the rollback did not converge: follow the stack runbook, Rollback'}`, deployed.data);
208
+ }
209
+ return { deployed };
210
+ }
211
+
125
212
  /**
126
213
  * An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
127
214
  * automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
@@ -131,13 +218,20 @@ export async function urgentDeploy(ctx, config, options, run, deps = {}) {
131
218
  const f = { ...FLOOR_DEPS, ...deps };
132
219
  const { record, identity } = preconditions(ctx, config, options);
133
220
  const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
134
- if (options.execute !== true) {
135
- const plan = await run.dryRun();
136
- return { ...plan, lines: [...lines, 'floor (never skipped): fresh encrypted database backup, receipt verify, test decrypt, rollback target, migrate preview then execute, deploy with readiness verify and automatic rollback', ...plan.lines] };
221
+ let deployed;
222
+ if (options.firstStart === true) {
223
+ const first = await firstStartDeploy(ctx, config, options, run, f, lines);
224
+ if (!first.deployed) return { ...first, data: { ...first.data, urgent: record.tag, firstStart: true } };
225
+ deployed = first.deployed;
226
+ } else {
227
+ if (options.execute !== true) {
228
+ const plan = await run.dryRun();
229
+ return { ...plan, lines: [...lines, 'floor (never skipped): fresh encrypted database backup, receipt verify, test decrypt, rollback target, migrate preview then execute, deploy with readiness verify and automatic rollback', ...plan.lines] };
230
+ }
231
+ const stop = await guard(ctx, config, options, { f, run, identity, lines });
232
+ if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
233
+ deployed = await run.deployExecute();
137
234
  }
138
- const stop = await guard(ctx, config, options, { f, run, identity, lines });
139
- if (stop) return { ...stop, data: { ...stop.data, urgent: record.tag } };
140
- const deployed = await run.deployExecute();
141
235
  const all = [...lines, ...deployed.lines];
142
236
  if (deployed.ok) return { ...deployed, lines: all, data: { ...deployed.data, urgent: record.tag } };
143
237
  const data = { ...deployed.data, urgent: record.tag };
@@ -0,0 +1,4 @@
1
+ import { @@Provider@@InboxEntity } from "./@@provider@@-inbox.entity"
2
+
3
+ /** The entities of the @@provider@@-inbox capability, named in PRIMARY_ENTITIES of platform/database/entities.ts. */
4
+ export const @@providerCamel@@InboxEntities = [@@Provider@@InboxEntity]
@@ -0,0 +1,29 @@
1
+ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
2
+
3
+ @Entity({ schema: "public", name: "@@table@@" })
4
+ /** One @@provider@@ delivery receipt, a row of public.@@table@@ from its inbox migration in db/migrations; a reactor or job consumes it after commit. */
5
+ export class @@Provider@@InboxEntity {
6
+ /** The receipt id the winning claim answers. */
7
+ @PrimaryGeneratedColumn("uuid", { name: "id" })
8
+ id!: string
9
+
10
+ /** The provider the receipt belongs to; the table admits only @@provider@@. */
11
+ @Column({ name: "provider", type: "text" })
12
+ provider!: string
13
+
14
+ /** The provider's delivery id, unique with the provider. */
15
+ @Column({ name: "delivery_id", type: "text" })
16
+ deliveryId!: string
17
+
18
+ /** When the delivery was recorded; the database sets it. */
19
+ @Column({ name: "received_at", type: "timestamptz", insert: false, update: false })
20
+ receivedAt!: Date
21
+
22
+ /** The verified delivery body the consumer reads; it carries no credential. */
23
+ @Column({ name: "payload", type: "jsonb" })
24
+ payload!: object
25
+
26
+ /** When the consumer finished the delivery; null while it is pending. */
27
+ @Column({ name: "processed_at", type: "timestamptz", nullable: true })
28
+ processedAt!: Date | null
29
+ }
@@ -1,2 +1,3 @@
1
1
  export { @@Provider@@InboxModule } from "./@@provider@@-inbox.module"
2
2
  export { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
3
+ export { @@providerCamel@@InboxEntities } from "./persistence/entities/index"
@@ -1,9 +1,12 @@
1
1
  import { injector } from "@modules/platform/composition"
2
2
  import type { TypedParameterDecorator } from "@modules/platform/composition"
3
- import type { Inbox } from "./inbox.port"
3
+ import type { EntityInbox, Inbox } from "./inbox.port"
4
4
 
5
- /** Token of the shared, transaction-owned inbox claims. */
5
+ /** Token of the shared, transaction-owned inbox claims; its provider is an EntityInbox too. */
6
6
  export const INBOX: unique symbol = Symbol("platform.inbox")
7
7
 
8
8
  /** Injects the Inbox port. Parameter type: Inbox. */
9
9
  export const InjectInbox = (): TypedParameterDecorator<Inbox> => injector<Inbox>(INBOX)
10
+
11
+ /** Injects the Inbox port of a capability whose provider receipt is an entity row. Parameter type: EntityInbox. */
12
+ export const InjectEntityInbox = (): TypedParameterDecorator<EntityInbox> => injector<EntityInbox>(INBOX)
@@ -1,4 +1,4 @@
1
- import type { EntityManager } from "typeorm"
1
+ import type { EntityManager, EntityTarget, ObjectLiteral, QueryDeepPartialEntity } from "typeorm"
2
2
 
3
3
  /** The authenticated scope and complete normalized body of a client operation; the key is never part of the body. */
4
4
  export interface ClientOperation {
@@ -27,3 +27,31 @@ export interface Inbox {
27
27
  /** Deletes one bounded batch of completed client claims older than the cutoff. */
28
28
  sweepClients(cutoff: Date, tx: EntityManager): Promise<number>
29
29
  }
30
+
31
+ /** A provider's receipt row: its entity, mapping a table with an `id` column, and the complete row, delivery key and payload included. */
32
+ export interface InboxEntityReceipt<Row extends ObjectLiteral> {
33
+ /** The provider's receipt entity. */
34
+ readonly entity: EntityTarget<Row>
35
+ /**
36
+ * The row the receipt holds, built from the delivery id the claim normalized (trimmed, never missing), so the receipt keys
37
+ * exactly what was claimed; the deferred consumer reads it, so it carries no credential.
38
+ */
39
+ readonly row: (deliveryId: string) => QueryDeepPartialEntity<Row>
40
+ }
41
+
42
+ /** The id of the receipt row a winning claim inserted; undefined when the delivery was a repeat or its receipt already existed. */
43
+ export type InboxReceiptId = string | undefined
44
+
45
+ /** The inbox of a capability whose provider receipt is an entity row, so it records the receipt without raw SQL. */
46
+ export interface EntityInbox extends Inbox {
47
+ /**
48
+ * Claims once per source and event id, then inserts the receipt row, both in the caller transaction. Answers the id the
49
+ * winning insert returned; a repeated delivery, or a receipt row that already exists, inserts nothing and answers undefined.
50
+ */
51
+ claimRow<Row extends ObjectLiteral>(
52
+ source: string,
53
+ eventId: InboxEventId,
54
+ tx: EntityManager,
55
+ receipt: InboxEntityReceipt<Row>,
56
+ ): Promise<InboxReceiptId>
57
+ }
@@ -1,7 +1,7 @@
1
1
  import { IdempotencyErrorCode } from "@modules/platform/idempotency"
2
2
  import { Test } from "@nestjs/testing"
3
3
  import type { ClientOperation } from "./inbox.port"
4
- import { FakeClock, fakeTransaction, mockEntityManager } from "@starci/jest-preset"
4
+ import { FakeClock, fakeTransaction, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
5
5
  import { BATCH_ROWS, DatabaseError, DatabaseErrorCode } from "@modules/platform/database"
6
6
  import { CLOCK } from "@modules/platform/clock"
7
7
  import { InboxError, InboxErrorCode } from "./errors/inbox.error"
@@ -29,6 +29,15 @@ const REQUEST = {
29
29
  }
30
30
  const OUTCOME = { kind: "ok", value: { orderId: "order-1" } }
31
31
 
32
+ /** A provider receipt entity as a capability maps its inbox table. */
33
+ class SpecReceiptEntity {
34
+ id!: string
35
+ provider!: string
36
+ deliveryId!: string
37
+ }
38
+
39
+ const RECEIPT = { entity: SpecReceiptEntity, row: (deliveryId: string) => ({ provider: "spec", deliveryId }) }
40
+
32
41
  const build = async () => {
33
42
  const module = await Test.createTestingModule({
34
43
  providers: [PostgresInbox, { provide: CLOCK, useValue: new FakeClock("2026-02-03T04:05:06.000Z") }],
@@ -94,6 +103,61 @@ describe("PostgresInbox delivery rollback and validation", () => {
94
103
  })
95
104
  })
96
105
 
106
+ describe("PostgresInbox entity receipts", () => {
107
+ it("claims the trimmed delivery id, then inserts the receipt row it keys in the caller's transaction and answers its id", async () => {
108
+ const subject = await build()
109
+ const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
110
+ const [insert] = queueQueryBuilders(manager, [{ execute: { raw: [{ id: "receipt-1" }] } }])
111
+
112
+ await expect(subject.claimRow("webhook:spec", " delivery-1 ", manager, RECEIPT)).resolves.toBe("receipt-1")
113
+
114
+ expect(manager.query).toHaveBeenCalledWith(CLAIM_EVENT, ["webhook:spec", "delivery-1"])
115
+ expect(manager.createQueryBuilder).toHaveBeenCalledWith(SpecReceiptEntity, "receipt")
116
+ expect(insert?.callsOf("values")).toEqual([[{ provider: "spec", deliveryId: "delivery-1" }]])
117
+ expect(insert?.callsOf("orIgnore")).toEqual([[]])
118
+ expect(insert?.callsOf("returning")).toEqual([["id"]])
119
+ })
120
+
121
+ it("inserts no receipt for a delivery already claimed", async () => {
122
+ const subject = await build()
123
+ const manager = mockEntityManager({ query: [CLAIM_EVENT, []] })
124
+
125
+ await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).resolves.toBeUndefined()
126
+
127
+ expect(manager.createQueryBuilder).not.toHaveBeenCalled()
128
+ })
129
+
130
+ it("answers undefined when the receipt row already exists", async () => {
131
+ const subject = await build()
132
+ const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
133
+ queueQueryBuilders(manager, [{ execute: { raw: [] } }])
134
+
135
+ await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).resolves.toBeUndefined()
136
+ })
137
+
138
+ it("refuses a returned receipt row without a text id", async () => {
139
+ const subject = await build()
140
+ const manager = mockEntityManager({ query: [CLAIM_EVENT, [{ event_id: "delivery-1" }]] })
141
+ queueQueryBuilders(manager, [{ execute: { raw: [{ id: 7 }] } }])
142
+
143
+ await expect(subject.claimRow("webhook:spec", "delivery-1", manager, RECEIPT)).rejects.toMatchObject({
144
+ code: DatabaseErrorCode.ReturningRowRejected,
145
+ })
146
+ })
147
+
148
+ it("refuses a missing delivery id before claiming or inserting", async () => {
149
+ const subject = await build()
150
+ const manager = mockEntityManager()
151
+
152
+ await expect(subject.claimRow("webhook:spec", undefined, manager, RECEIPT)).rejects.toMatchObject({
153
+ code: DatabaseErrorCode.WebhookDeliveryIdRequired,
154
+ })
155
+
156
+ expect(manager.query).not.toHaveBeenCalled()
157
+ expect(manager.createQueryBuilder).not.toHaveBeenCalled()
158
+ })
159
+ })
160
+
97
161
  describe("PostgresInbox client transaction admission", () => {
98
162
  it("refuses a manager outside an active transaction before claiming or applying an effect", async () => {
99
163
  const inbox = await build()
@@ -1,9 +1,17 @@
1
1
  import { Injectable } from "@nestjs/common"
2
- import type { EntityManager } from "typeorm"
2
+ import type { EntityManager, ObjectLiteral } from "typeorm"
3
3
  import { InjectClock } from "@modules/platform/clock"
4
4
  import type { Clock } from "@modules/platform/clock"
5
- import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction } from "@modules/platform/database"
6
- import type { ClientOperation, ClientOperationEffect, Inbox, InboxEventId } from "./inbox.port"
5
+ import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction, returnedRows, rowShape } from "@modules/platform/database"
6
+ import type {
7
+ ClientOperation,
8
+ ClientOperationEffect,
9
+ EntityInbox,
10
+ Inbox,
11
+ InboxEntityReceipt,
12
+ InboxEventId,
13
+ InboxReceiptId,
14
+ } from "./inbox.port"
7
15
  import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
8
16
  import { InboxError, InboxErrorCode } from "./errors/inbox.error"
9
17
  import { clientSource } from "./client-operation.policy"
@@ -43,19 +51,43 @@ interface InboxSweepRow {
43
51
  readonly deleted_count: number
44
52
  }
45
53
 
54
+ /** The row a receipt insert returns to the transaction that won it. */
55
+ const isReceiptRow = rowShape({ id: "string" })
56
+
57
+ /** The provider's delivery id, trimmed; a missing or blank one is refused before anything is claimed or written. */
58
+ const deliveryIdOf = (eventId: InboxEventId): string => {
59
+ const deliveryId = eventId?.trim()
60
+ if (!deliveryId) throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
61
+ return deliveryId
62
+ }
63
+
46
64
  @Injectable()
47
65
  /** Claims a delivery with the receipt transaction, so a failure can be retried without losing the delivery. */
48
- export class PostgresInbox implements Inbox {
66
+ export class PostgresInbox implements Inbox, EntityInbox {
49
67
  constructor(@InjectClock() private readonly clock: Clock) {}
50
68
 
51
69
  /** The first claim wins; the same source and delivery id becomes a no-op on redelivery. */
52
70
  async claim(source: string, eventId: InboxEventId, tx: EntityManager): Promise<boolean> {
53
- const normalizedEventId = eventId?.trim()
54
- if (!normalizedEventId) {
55
- throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
56
- }
57
- const rows = await callDbFunction<InboxEventRow>(tx, "public.inbox_claim_event", [source, normalizedEventId])
58
- return rows.length > 0
71
+ return this.claimDelivery(source, deliveryIdOf(eventId), tx)
72
+ }
73
+
74
+ /** The claim first; only its winner inserts the receipt row, and a row that already exists is left as it is. */
75
+ async claimRow<Row extends ObjectLiteral>(
76
+ source: string,
77
+ eventId: InboxEventId,
78
+ tx: EntityManager,
79
+ receipt: InboxEntityReceipt<Row>,
80
+ ): Promise<InboxReceiptId> {
81
+ const deliveryId = deliveryIdOf(eventId)
82
+ if (!(await this.claimDelivery(source, deliveryId, tx))) return undefined
83
+ const inserted = await tx
84
+ .createQueryBuilder(receipt.entity, "receipt")
85
+ .insert()
86
+ .values(receipt.row(deliveryId))
87
+ .orIgnore()
88
+ .returning("id")
89
+ .execute()
90
+ return returnedRows(inserted.raw, isReceiptRow)[0]?.id
59
91
  }
60
92
 
61
93
  /** Runs once in the domain transaction, returning the persisted JSON outcome on both the first call and every retry. */
@@ -99,4 +131,10 @@ export class PostgresInbox implements Inbox {
99
131
  const rows = await callDbFunction<InboxSweepRow>(tx, "public.inbox_sweep_clients", [cutoff, BATCH_ROWS])
100
132
  return rows[0]?.deleted_count ?? 0
101
133
  }
134
+
135
+ /** Claims a normalized delivery id; an answered row means this transaction holds the first claim. */
136
+ private async claimDelivery(source: string, deliveryId: string, tx: EntityManager): Promise<boolean> {
137
+ const rows = await callDbFunction<InboxEventRow>(tx, "public.inbox_claim_event", [source, deliveryId])
138
+ return rows.length > 0
139
+ }
102
140
  }