@starci/hfs 4.11.12 → 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 (56) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +1 -1
  4. package/db/postgres.mjs +38 -3
  5. package/package.json +1 -1
  6. package/release/index.mjs +6 -5
  7. package/release/lint-baseline.mjs +123 -0
  8. package/release/urgent.mjs +19 -4
  9. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  10. package/runtime/knowledge/hfs/rules.yaml +3 -3
  11. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  12. package/runtime/scripts/api/sops/lib.mjs +25 -3
  13. package/runtime/scripts/api/sops/seal.mjs +4 -3
  14. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  15. package/runtime/scripts/hfs/secret-init.mjs +15 -0
  16. package/runtime/scripts/hfs/secret-sops.mjs +65 -33
  17. package/runtime/scripts/lib/sops-envelope.mjs +36 -11
  18. package/scaffold/add-compose.mjs +3 -0
  19. package/scaffold/add-inbox-lite.mjs +45 -23
  20. package/scaffold/lite-exports.mjs +13 -0
  21. package/src/main.mjs +5 -5
  22. package/stack/age-install.mjs +10 -2
  23. package/stack/cli.mjs +5 -3
  24. package/stack/context.mjs +2 -2
  25. package/stack/deploy.mjs +6 -2
  26. package/stack/remote.mjs +29 -8
  27. package/stack/sealed.mjs +15 -11
  28. package/stack/server-key.mjs +31 -7
  29. package/stack/urgent-floor.mjs +101 -7
  30. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
  31. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
  32. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  33. package/templates/be/inbox/entity.ts.tpl +29 -0
  34. package/templates/be/inbox/index.ts.tpl +1 -0
  35. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  36. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  37. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  38. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  39. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  40. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  41. package/templates/be/inbox/service.ts.tpl +13 -13
  42. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  43. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  44. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  45. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  46. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  47. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  48. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  49. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  50. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
  51. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  52. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  53. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  54. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  55. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  56. package/templates/be/inbox/database-client.ts.tpl +0 -45
package/stack/context.mjs CHANGED
@@ -57,7 +57,7 @@ const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
57
57
  * stdout, stderr text writers
58
58
  * fetch the HTTP seam of verify (url, init) -> Response
59
59
  * pipe (source, sink) -> Promise<{ source, sink, stderr }>: one process's stdout streamed into another's stdin (realPipe)
60
- * sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops`, else on first use `io.openSops()` or the app's own sops/age tools
60
+ * sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops`, else on first use `io.openSops()` or the app's own sops/age tools with the age identity decryptionIdentity finds (`io.hostIdentity` the harness CLI's secret.env)
61
61
  */
62
62
  export function stackContext(io = {}) {
63
63
  const cwd = io.cwd ?? process.cwd();
@@ -78,7 +78,7 @@ export function stackContext(io = {}) {
78
78
  pipe: io.pipe ?? realPipe,
79
79
  /** The secret seam: the app's own sops/age tools, created on first use so verbs that open no custody never need them. */
80
80
  get sops() {
81
- sops ??= io.openSops?.() ?? defaultSops(env, cwd);
81
+ sops ??= io.openSops?.() ?? defaultSops(env, cwd, io.platform ?? process.platform, undefined, io.hostIdentity ?? null);
82
82
  return sops;
83
83
  },
84
84
  });
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();
package/stack/remote.mjs CHANGED
@@ -64,24 +64,36 @@ function sshCommandArgv(sshCmd) {
64
64
  return parts;
65
65
  }
66
66
 
67
- /** A sealed binary member is the file itself; a JSON object around it ({ data | <names> }, the re-sealed shape of `starci app secret rename`) is unwrapped. */
68
- function unwrap(text, names) {
67
+ /**
68
+ * The key each sealed SSH member of `deploy.remote.sealed` holds (contexts/project/stacks.md PROJECT-STACKS-5): the one owner of
69
+ * these names. `sshKey` names the member whose `privateKey` is the PEM private key, `knownHosts` the member whose `knownHosts` is
70
+ * the pinned known_hosts text.
71
+ */
72
+ export const SEALED_SSH_KEYS = Object.freeze({ sshKey: 'privateKey', knownHosts: 'knownHosts' });
73
+
74
+ /** A legacy binary member is the file itself; a JSON object around it ({ data | <key> }, the re-sealed shape of `starci app secret rename`) is unwrapped. */
75
+ function unwrap(text, key) {
69
76
  const body = lfOnly(text);
70
77
  const trimmed = body.trim();
71
78
  if (!trimmed.startsWith('{')) return body;
72
79
  let parsed;
73
80
  try { parsed = JSON.parse(trimmed); } catch { return body; }
74
- for (const name of ['data', ...names]) if (typeof parsed[name] === 'string') return lfOnly(parsed[name]);
81
+ for (const name of ['data', key]) if (typeof parsed[name] === 'string') return lfOnly(parsed[name]);
75
82
  return body;
76
83
  }
77
84
 
85
+ /** The sops format a sealed member is read as: a `.enc.yaml` member is YAML, a legacy `.enc` one dotenv. */
86
+ const memberFormat = (file) => (isLegacyName(file) ? 'dotenv' : 'yaml');
87
+
88
+ /** Opens one member through the sops seam; a failure is refused naming the member and the seam's own reason (format, recipients, identity). */
78
89
  function openMember(slug, read) {
79
90
  try { return read(); } catch (error) { throw refusal('STACK_REMOTE_SEALED', `cannot open the sealed member ${slug}: ${error.message}`); }
80
91
  }
81
92
 
82
93
  function opsAccount(ctx, config) {
83
94
  const slug = config.remote.sealed.ops;
84
- const map = openMember(slug, () => ctx.sops.decrypt(memberFile(config.secretsDir, slug), isLegacyName(memberFile(config.secretsDir, slug)) ? 'dotenv' : 'yaml'));
95
+ const file = memberFile(config.secretsDir, slug);
96
+ const map = openMember(slug, () => ctx.sops.decrypt(file, memberFormat(file)));
85
97
  for (const name of ['VPS_HOST', 'VPS_USER']) {
86
98
  const value = map[name];
87
99
  if (typeof value !== 'string' || !ACCOUNT.test(value) || value.startsWith('-')) throw refusal('STACK_REMOTE_OPS', `${slug} must hold ${name} (letters, digits, . _ @ -)`);
@@ -89,8 +101,17 @@ function opsAccount(ctx, config) {
89
101
  return `${map.VPS_USER}@${map.VPS_HOST}`;
90
102
  }
91
103
 
92
- function binaryMember(ctx, config, slug, names) {
93
- return unwrap(openMember(slug, () => ctx.sops.decryptBinary(memberFile(config.secretsDir, slug))), names);
104
+ /**
105
+ * The text a sealed SSH member holds under `key` (SEALED_SSH_KEYS): a `.enc.yaml` member is read as YAML and the key taken from
106
+ * its map; a legacy `.enc` member is the sops binary or JSON envelope it was sealed as.
107
+ */
108
+ function sshMember(ctx, config, slug, key) {
109
+ const file = memberFile(config.secretsDir, slug);
110
+ if (isLegacyName(file)) return unwrap(openMember(slug, () => ctx.sops.decryptBinary(file)), key);
111
+ const map = openMember(slug, () => ctx.sops.decrypt(file, 'yaml'));
112
+ if (typeof map[key] !== 'string' || map[key] === '')
113
+ throw refusal('STACK_REMOTE_SEALED', `the sealed member ${slug} holds no ${key} (it holds ${Object.keys(map).join(', ') || 'no key'}): seal it with starci app secret set ${config.env ?? '<env>'} ${slug} ${key} --from-file ${key}=<file>`);
114
+ return lfOnly(map[key]);
94
115
  }
95
116
 
96
117
  const withNewline = (text) => `${text.trim()}\n`;
@@ -107,9 +128,9 @@ export function openSealedSession(ctx, config) {
107
128
  const account = opsAccount(ctx, config);
108
129
  if (config.nativeTarget && account !== `${config.nativeTarget.user}@${config.nativeTarget.host}`)
109
130
  throw refusal('STACK_REMOTE_OPS', 'the sealed SSH account differs from the accepted native target');
110
- const key = binaryMember(ctx, config, sealed.sshKey, ['privateKey', 'key']);
131
+ const key = sshMember(ctx, config, sealed.sshKey, SEALED_SSH_KEYS.sshKey);
111
132
  if (!key.includes('PRIVATE KEY')) throw refusal('STACK_REMOTE_KEY', `${sealed.sshKey} does not hold a PEM private key`);
112
- const known = binaryMember(ctx, config, sealed.knownHosts, ['knownHosts', 'known_hosts']).trim();
133
+ const known = sshMember(ctx, config, sealed.knownHosts, SEALED_SSH_KEYS.knownHosts).trim();
113
134
  assertStackAccount(config.nativeTarget, account, known);
114
135
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-ssh-'));
115
136
  const keyFile = path.join(dir, 'id');
package/stack/sealed.mjs CHANGED
@@ -5,7 +5,6 @@ import { createHash, createHmac } from 'node:crypto';
5
5
  import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
- import { secretRoots } from '../runtime/scripts/hfs/secret-files.mjs';
9
8
  import { readMap } from '../runtime/scripts/hfs/secret-store.mjs';
10
9
  import { refusal } from './errors.mjs';
11
10
 
@@ -52,27 +51,32 @@ function secretRuntimeName(stack, key, value, versionKey) {
52
51
  return name;
53
52
  }
54
53
 
55
- const AGE_IDENTITY_ENV = /^SOPS_AGE_KEY(?:_FILE)?$/iu;
56
- const AGE_HINT = 'load the age identity into SOPS_AGE_KEY (the secret.env of the owner machine, or the CI secret) and retry';
54
+ const AGE_HINT = 'add SOPS_AGE_KEY to .claude/secret.env of the app or the harness secret.env of the owner machine (the CI secret in a workflow) and retry';
57
55
 
58
- /** True when an age identity is reachable: SOPS_AGE_KEY or SOPS_AGE_KEY_FILE in the environment, the repository's .claude/secret.env or the per-user keys.txt. */
59
- export function hasAgeIdentity(ctx, config) {
60
- const named = Object.entries(ctx.env).some(([name, value]) => AGE_IDENTITY_ENV.test(name) && value);
61
- return named || fs.existsSync(path.join(config.repoRoot, '.claude', 'secret.env')) || fs.existsSync(secretRoots(ctx.env).identity);
56
+ /**
57
+ * True when the sops seam found an age identity (secret-init.mjs decryptionIdentity, the one lookup every decrypt uses). A seam
58
+ * that reports no `identity` (a spec double) decides for itself.
59
+ */
60
+ export function hasAgeIdentity(ctx) {
61
+ const { identity } = ctx.sops;
62
+ return identity === undefined || identity.source !== null;
62
63
  }
63
64
 
64
65
  /** Refuses up front, before any work, when sealed custody must be opened and no age identity is reachable. */
65
66
  export function requireAgeIdentity(ctx, config, purpose) {
66
- if (!hasAgeIdentity(ctx, config)) throw refusal('STACK_AGE_IDENTITY_MISSING', `${purpose} needs the sealed members, and no age identity is available: ${AGE_HINT}`);
67
+ if (!hasAgeIdentity(ctx)) throw refusal('STACK_AGE_IDENTITY_MISSING', `${purpose} needs the sealed members, and no age identity is available (looked in ${ctx.sops.identity.looked.join(', ')}): ${AGE_HINT}`);
67
68
  }
68
69
 
69
- /** Opens one sealed member (`<secretsDir>/<member>.enc.yaml`, or a legacy `.enc`) into a { KEY: value } map. Names the member only on failure. */
70
+ /**
71
+ * Opens one sealed member (`<secretsDir>/<member>.enc.yaml`, or a legacy `.enc`) into a { KEY: value } map. A failure is refused
72
+ * naming the member and the seam's reason (its format, recipients and the identity tried); no value.
73
+ */
70
74
  export function openMember(ctx, config, member) {
71
75
  try {
72
76
  const map = readMap(memberFile(config.secretsDir, member), ctx.sops);
73
77
  return Object.fromEntries(Object.entries(map).map(([key, value]) => [key, typeof value === 'string' && !value.includes(String.fromCodePoint(0)) ? lfText(value) : value]));
74
- } catch {
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}`}`);
78
+ } catch (error) {
79
+ throw refusal('STACK_SECRET_MEMBER_UNREADABLE', `the sealed member ${member} cannot be opened: ${error.message}${hasAgeIdentity(ctx) ? '' : `; ${AGE_HINT}`}`);
76
80
  }
77
81
  }
78
82
 
@@ -42,6 +42,22 @@ const RECIPIENT = /^age1[a-z0-9]+$/u;
42
42
  /** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
43
43
  const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
44
44
 
45
+ /**
46
+ * The directories, after root's PATH, where age-keygen is looked for: sudo's secure_path (AlmaLinux 10, RHEL) leaves out
47
+ * /usr/local/bin, where the static installer puts it, so the presence check and every call resolve it the same way, as root.
48
+ */
49
+ export const AGE_KEYGEN_DIRS = ['/usr/local/bin', '/usr/bin', '/bin'];
50
+
51
+ /** The shell lines that set k to age-keygen's absolute path as root sees it, or exit 127 naming it missing. Every root script holds them. */
52
+ export const RESOLVE_AGE_KEYGEN = [
53
+ 'k="$(command -v age-keygen 2>/dev/null || true)"',
54
+ `for d in ${AGE_KEYGEN_DIRS.join(' ')}; do if [ -z "$k" ] && [ -x "$d/age-keygen" ]; then k="$d/age-keygen"; fi; done`,
55
+ 'if [ -z "$k" ]; then echo "age-keygen is not installed" >&2; exit 127; fi',
56
+ ].join('\n');
57
+
58
+ /** The root script that only resolves age-keygen and prints its path: the presence check, run exactly as the calls run. */
59
+ export const ageKeygenScript = () => ['set -eu', RESOLVE_AGE_KEYGEN, String.raw`printf '%s\n' "$k"`].join('\n');
60
+
45
61
  /**
46
62
  * The GitHub Environment secret that holds an environment's server identity. It carries the name the deploy workflows read
47
63
  * (secrets.SOPS_AGE_KEY), scoped to the Environment <env>: for a job bound to <env> it overrides a repository secret of that name.
@@ -54,24 +70,25 @@ export function ensureIdentityScript(file) {
54
70
  return [
55
71
  'set -eu',
56
72
  'umask 077',
73
+ RESOLVE_AGE_KEYGEN,
57
74
  `f=${quoted}`,
58
75
  'if [ -e "$f" ] || [ -L "$f" ]; then',
59
76
  ' if [ -L "$f" ] || [ ! -f "$f" ]; then echo "the identity path is not an ordinary file" >&2; exit 4; fi',
60
77
  ' echo "identity kept" >&2',
61
78
  'else',
62
79
  ' mkdir -p "$(dirname "$f")"',
63
- ' age-keygen -o "$f" >/dev/null 2>&1',
80
+ ' "$k" -o "$f" >/dev/null 2>&1',
64
81
  ' echo "identity created" >&2',
65
82
  'fi',
66
83
  'chown root:root "$f"',
67
84
  'chmod 600 "$f"',
68
- 'age-keygen -y "$f"',
85
+ '"$k" -y "$f"',
69
86
  ].join('\n');
70
87
  }
71
88
 
72
89
  /** The shell that prints only the public key of `file`, or exits 5 when there is none. */
73
90
  export function publicKeyScript(file) {
74
- return ['set -eu', `f=${shellQuote(file)}`, '[ -f "$f" ] || exit 5', 'age-keygen -y "$f"'].join('\n');
91
+ return ['set -eu', RESOLVE_AGE_KEYGEN, `f=${shellQuote(file)}`, '[ -f "$f" ] || exit 5', '"$k" -y "$f"'].join('\n');
75
92
  }
76
93
 
77
94
  /** The shell that swaps the staged `<file>.next` in, keeping the old identity as `retired`, then prints only the new public key. */
@@ -79,6 +96,7 @@ export function swapIdentityScript(file, retired) {
79
96
  return [
80
97
  'set -eu',
81
98
  'umask 077',
99
+ RESOLVE_AGE_KEYGEN,
82
100
  `f=${shellQuote(file)}`,
83
101
  `r=${shellQuote(retired)}`,
84
102
  'n="$f.next"',
@@ -87,7 +105,7 @@ export function swapIdentityScript(file, retired) {
87
105
  'mv "$f" "$r"',
88
106
  'mv "$n" "$f"',
89
107
  'chmod 600 "$f" "$r"',
90
- 'age-keygen -y "$f"',
108
+ '"$k" -y "$f"',
91
109
  ].join('\n');
92
110
  }
93
111
 
@@ -112,14 +130,20 @@ function declaredRemote(repoRoot, env) {
112
130
  return remote;
113
131
  }
114
132
 
115
- /** Ensures age-keygen on the server: stack prepare's installer owns the install (with --execute); without it the exact commands. */
133
+ /** True when root resolves age-keygen the way every server-key script does (ageKeygenScript). */
134
+ const hasKeygen = (run) => asRoot(run, ageKeygenScript()).status === 0;
135
+
136
+ /**
137
+ * Ensures age-keygen on the server, checked as root exactly as it is called: stack prepare's installer owns the install (with
138
+ * --execute); without it the exact commands.
139
+ */
116
140
  function ensureTool(run, execute, lines) {
117
- if (run('command -v age-keygen').status === 0) return true;
141
+ if (hasKeygen(run)) return true;
118
142
  const install = `install age on the server (one of: ${Object.values(AGE_INSTALL).join('; ')})`;
119
143
  if (!execute) { lines.push(`dry run: age-keygen is missing on the server; --execute installs it with stack prepare's installer, or ${install}`); return false; }
120
144
  const problems = [];
121
145
  installAge(run, { note: (text) => lines.push(text), bad: (text) => problems.push(text) });
122
- if (problems.length || run('command -v age-keygen').status !== 0)
146
+ if (problems.length || !hasKeygen(run))
123
147
  throw refusal('STACK_SERVER_KEY_NO_AGE', `age-keygen is not on the server and could not be installed (${problems.join('; ') || 'still missing'}): ${install}`);
124
148
  return true;
125
149
  }
@@ -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 };
@@ -53,6 +53,6 @@ jobs:
53
53
  printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
54
  printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
55
  # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
- remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
56
+ remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'PATH="$PATH:/usr/local/bin"; umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
57
  printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
58
  -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
@@ -53,6 +53,6 @@ jobs:
53
53
  printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
54
  printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
55
  # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
- remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
56
+ remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'PATH="$PATH:/usr/local/bin"; umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
57
  printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
58
  -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
@@ -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()