@starci/hfs 4.11.0 → 4.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -10,10 +10,12 @@ import { baselineMarkers, baselineSourceFindings } from './rules/database-baseli
10
10
  import { databaseQueueDeclarations } from './rules/database-queues.mjs';
11
11
  import { MIGRATIONS_DIR, MIGRATIONS_ARCHIVE_DIR } from './rules/database-constants.mjs';
12
12
  import { dbPlanMain } from './db-plan-verb.mjs';
13
+ import { DbVerbError } from './db-errors.mjs';
14
+ import { squashBaseline } from './db-squash.mjs';
13
15
 
14
16
  await pg.loadModule();
15
17
 
16
- export class DbVerbError extends Error {}
18
+ export { DbVerbError };
17
19
 
18
20
  /** Strict shared options; unknown flags never reach the database CLI. */
19
21
  export function dbOptions(argv, allowed) {
@@ -22,7 +24,7 @@ export function dbOptions(argv, allowed) {
22
24
  const [flag, inline] = argv[index].split(/=(.*)/s);
23
25
  const name = flag.slice(2);
24
26
  if (!flag.startsWith('--') || !allowed.includes(name) || Object.hasOwn(opts, name)) throw new DbVerbError('unknown or duplicate database option');
25
- if (['apply', 'json'].includes(name)) {
27
+ if (['apply', 'json', 'squash'].includes(name)) {
26
28
  if (inline !== undefined && !['true', 'false'].includes(inline)) throw new DbVerbError(`${flag} expects true or false`);
27
29
  opts[name] = inline !== 'false';
28
30
  } else {
@@ -43,7 +45,7 @@ export function dbAppRoot(cwd, opts) {
43
45
  }
44
46
 
45
47
  /** A refusal that lists every offending file with the rule it broke, one distinct `<file>: <reason>` line each. */
46
- const offendersError = (heading, offenders) => new DbVerbError(`${heading}:\n${[...new Set(offenders)].map((line) => ` ${line}`).join('\n')}`);
48
+ export const offendersError = (heading, offenders) => new DbVerbError(`${heading}:\n${[...new Set(offenders)].map((line) => ` ${line}`).join('\n')}`);
47
49
 
48
50
  /** One rule finding as an offender line: its file, its message and its code. */
49
51
  const findingLine = (item) => `${item.message.startsWith(item.path) ? item.message : `${item.path}: ${item.message}`} (${item.code})`;
@@ -73,7 +75,7 @@ export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
73
75
  }
74
76
 
75
77
  /** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
76
- function connectionOf(env, urlEnv) {
78
+ export function connectionOf(env, urlEnv) {
77
79
  if (!/^[A-Z][A-Z0-9_]*$/.test(urlEnv)) throw new DbVerbError('--url-env must name an UPPER_CASE environment variable');
78
80
  let url;
79
81
  try { url = new URL(String(env[urlEnv] ?? '')); } catch { throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`); }
@@ -87,7 +89,7 @@ const HISTORY = 'supabase_migrations.schema_migrations';
87
89
  const sqlLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
88
90
 
89
91
  /** The psql and pg_dump invocation of one operation: a JSON rows query, a schema-only dump, or one history row applied or reverted. */
90
- function commandOf(op, url) {
92
+ export function commandOf(op, url) {
91
93
  const psql = ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', url];
92
94
  if (op.kind === 'query') return ['psql', [...psql, '-A', '-t', '-c', `select coalesce(json_agg(row_to_json(q)), '[]'::json) from (${op.sql.replace(/;\s*$/, '')}) q`]];
93
95
  if (op.kind === 'dump') return ['pg_dump', ['--schema-only', '--schema', 'public', '--dbname', url]];
@@ -140,7 +142,7 @@ const EXCLUDED_SCHEMAS = new Set(['information_schema', '_analytics', '_realtime
140
142
  const ROLE_OPTIONS = new Set(['canlogin', 'inherit', 'superuser', 'createdb', 'createrole', 'isreplication', 'bypassrls', 'connectionlimit']);
141
143
  const identifier = (name) => `"${name.replaceAll('"', '""')}"`;
142
144
  const literal = (value) => `'${value.replaceAll("'", "''")}'`;
143
- const excludedSchema = (name) => EXCLUDED_SCHEMAS.has(name) || /^(?:pg_|timescaledb_|_timescaledb_)/.test(name ?? '');
145
+ export const excludedSchema = (name) => EXCLUDED_SCHEMAS.has(name) || /^(?:pg_|timescaledb_|_timescaledb_)/.test(name ?? '');
144
146
 
145
147
  /** Inventory source paths only; no credential, generated or vendor tree is traversed. */
146
148
  function baselineFiles(root) {
@@ -302,18 +304,57 @@ function metadataSql(metadata) {
302
304
  return `${queueSql}${grants}${cron}`;
303
305
  }
304
306
 
307
+ /**
308
+ * The squash source holds the whole active chain: its history records every active stamp and nothing else. Only the history is read; the
309
+ * queues and cron jobs a plan also compares are what the squash itself drops or carries from the database.
310
+ */
311
+ function sourceHistory(root, plan, ports, urlEnv) {
312
+ const rows = (sql) => JSON.parse(runDbPostgres(root, { kind: 'query', sql }, ports, urlEnv));
313
+ if (!rows("select pg_catalog.to_regclass('supabase_migrations.schema_migrations') is not null as present")[0]?.present) throw new DbVerbError(`the source records no migration history (${HISTORY}); apply its chain with starci app db migrate`);
314
+ const recorded = new Set(rows(`select version from ${HISTORY}`).map((row) => String(row.version)));
315
+ const active = new Set(plan.active.map((entry) => entry.stamp));
316
+ const offenders = [...[...active].filter((stamp) => !recorded.has(stamp)).map((stamp) => `${stamp}: active, not applied to the source`), ...[...recorded].filter((stamp) => !active.has(stamp)).map((stamp) => `${stamp}: applied to the source, not in the active chain`)];
317
+ if (offenders.length) throw offendersError('baseline --squash reads a source with exactly the active chain applied', offenders.sort());
318
+ }
319
+
320
+ /**
321
+ * A baseline reads a database whose history, queues and cron jobs equal the repository's. The plan's own refusal (already free of
322
+ * database output) is passed through as the reason; a drift names each inventory that differs, by its counts.
323
+ */
324
+ function cleanPlan(root, urlEnv, ports) {
325
+ let out = '';
326
+ let err = '';
327
+ const code = dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: (text) => { out += text; }, stderr: (text) => { err += text; } });
328
+ if (code === 0) return;
329
+ const heading = 'baseline requires a clean database plan with every migration already applied';
330
+ if (code !== 1) throw new DbVerbError(`${heading}; the plan refused: ${err.trim().replace(/^starci app db plan: /, '')}`);
331
+ const drift = Object.entries(JSON.parse(out)).filter(([, item]) => item && typeof item === 'object' && (item.missing || item.unexpected || item.changed))
332
+ .map(([kind, item]) => `${kind}: missing ${item.missing}, unexpected ${item.unexpected}, changed ${item.changed}`);
333
+ throw offendersError(`${heading}; the plan found drift`, drift);
334
+ }
335
+
305
336
  /** Numeric CLI exit code, injectable IO/clock/CLI; neither files nor history change without --apply. */
306
337
  export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), now = () => new Date(), ...ports } = {}) {
307
338
  try {
308
- const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env']);
339
+ const opts = dbOptions(argv, ['cwd', 'stamp', 'apply', 'url-env', 'squash', 'drop-extension', 'drop-pgmq-queue', 'seed-tables', 'image']);
340
+ if (!opts.squash && ['drop-extension', 'drop-pgmq-queue', 'seed-tables', 'image'].some((name) => Object.hasOwn(opts, name))) throw new DbVerbError('--drop-extension, --drop-pgmq-queue, --seed-tables and --image belong to --squash');
341
+ if (opts.squash && !opts['url-env']) throw new DbVerbError('baseline --squash reads a database the whole active chain is applied to: pass --url-env NAME');
309
342
  const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
310
343
  const root = dbAppRoot(cwd, opts);
311
344
  const plan = baselinePlan(root, opts, now);
312
345
  const files = baselineFiles(root);
313
346
  const sourceFindings = baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git });
314
347
  if (sourceFindings.length) throw offendersError('baseline sources and archives must match committed migration history', sourceFindings.map(findingLine));
348
+ const archive = () => {
349
+ fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
350
+ for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
351
+ };
352
+ if (opts.squash) {
353
+ const precondition = (runPostgres) => sourceHistory(root, plan, { ...ports, runPostgres }, urlEnv);
354
+ return squashBaseline({ root, opts, plan, urlEnv, connection: connectionOf(ports.env ?? process.env, urlEnv), commandOf, precondition, managedSchema: excludedSchema, archive, stdout });
355
+ }
315
356
  const metadata = baselineMetadata(root, plan, files);
316
- if (dbPlanMain(['--json', '--url-env', urlEnv], { ...ports, cwd: root, stdout: () => {}, stderr: () => {} }) !== 0) throw new DbVerbError('baseline requires a clean database plan with every migration already applied');
357
+ cleanPlan(root, urlEnv, ports);
317
358
  const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
318
359
  if (!schema.trim() || baselineMarkers(schema).length || /\bvault\.(?:create_secret|update_secret)\s*\(|\bpassword\s+'[^']+'/i.test(schema)) throw new DbVerbError('schema dump is empty or contains unsupported secret/baseline statements');
319
360
  const repairs = [...plan.active.map((entry) => repairOp(entry.stamp, 'reverted')), repairOp(plan.stamp, 'applied', 'baseline')];
@@ -321,9 +362,8 @@ export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) =>
321
362
  for (const op of repairs) stdout(`${repairLine(op)}\n`);
322
363
  if (!opts.apply) return 0;
323
364
  for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
324
- fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
325
365
  fs.writeFileSync(path.join(root, plan.file), `-- starci:baseline ${plan.last}\n${metadata.extensions}${metadata.roles}${schema.replace(/^\s+/, '')}\n${metadataSql(metadata)}`, { flag: 'wx' });
326
- for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
366
+ archive();
327
367
  return 0;
328
368
  } catch (error) { return dbFailure('baseline', error, stderr); }
329
369
  }
@@ -0,0 +1,2 @@
1
+ // The refusal every `starci app db` verb module throws; dbFailure (db-baseline-verb.mjs) prints its message, any other error is withheld.
2
+ export class DbVerbError extends Error {}
@@ -5,7 +5,7 @@ import { sha256 } from '../../engine/digest.mjs';
5
5
  import pg from 'libpg-query';
6
6
  import { invocationDir } from '../lib/roots.mjs';
7
7
  import { sqlStringParts, walkSqlAst as walk, sqlParser, sqlCallArgument as argument, sqlStringLiteral as literal } from '../lib/sql-ast.mjs';
8
- import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure } from './db-baseline-verb.mjs';
8
+ import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbPostgres, dbFailure, offendersError } from './db-baseline-verb.mjs';
9
9
 
10
10
  await pg.loadModule();
11
11
 
@@ -75,6 +75,7 @@ function conditionalBlock(item) {
75
75
  && !(key === 'PLpgSQL_stmt_return' && item[key].lineno === undefined));
76
76
  }
77
77
 
78
+ /** Inventories one DO block; returns the body-relative line of each EXECUTE, whose SQL is built at run time and so never inventoried. */
78
79
  function inventoryDo(state, node) {
79
80
  const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
80
81
  if (option('language') && option('language') !== 'plpgsql') throw new DbVerbError('database plan requires statically readable migration blocks');
@@ -85,29 +86,40 @@ function inventoryDo(state, node) {
85
86
  try { tree = pg.parsePlPgSQLSync(`create function starci_plan_probe() returns void language plpgsql as ${delimiter}${body}${delimiter};`); }
86
87
  catch { throw new DbVerbError('migration block could not be inventoried safely'); }
87
88
  const statements = [];
89
+ const dynamic = [];
88
90
  let conditional = false;
89
91
  walk(tree, (item) => {
90
92
  conditional ||= conditionalBlock(item);
91
- if (item.PLpgSQL_stmt_dynexecute) throw new DbVerbError('dynamic migration blocks cannot prove database inventory');
93
+ if (item.PLpgSQL_stmt_dynexecute) dynamic.push(item.PLpgSQL_stmt_dynexecute.lineno);
92
94
  if (item.PLpgSQL_expr?.parseMode === 0) statements.push(parseSql(item.PLpgSQL_expr.query));
93
95
  });
94
96
  if (conditional && statements.some((statement) => ownershipCalls(statement).length)) throw new DbVerbError('conditional migration blocks cannot prove database inventory');
95
97
  for (const statement of statements) inventoryTree(state, statement);
98
+ return dynamic;
96
99
  }
97
100
 
101
+ /** The 1-based line of `text` at libpg_query's byte offset `location`. */
102
+ const lineAt = (text, location) => Buffer.from(text).subarray(0, location).toString().split('\n').length;
103
+
98
104
  function repositoryState(root) {
99
105
  const migrations = dbSqlFiles(root);
100
106
  const state = { migrations: new Map(migrations.map((entry) => [entry.stamp, null])), cron: new Map(), queues: new Map() };
101
107
  if (state.migrations.size !== migrations.length) throw new DbVerbError('active migrations must have unique versions');
108
+ // Every EXECUTE of every active migration is named (file:line) before the refusal.
109
+ const dynamic = [];
102
110
  for (const entry of migrations) {
103
- const tree = parseSql(fs.readFileSync(path.join(root, entry.file), 'utf8'));
111
+ const text = fs.readFileSync(path.join(root, entry.file), 'utf8');
112
+ const tree = parseSql(text);
104
113
  for (const statement of tree.stmts ?? []) {
105
114
  // Creating a function does not execute its body. Inert SQL strings never declare remote state.
106
115
  if (statement.stmt.CreateFunctionStmt) continue;
107
- if (statement.stmt.DoStmt) inventoryDo(state, statement.stmt.DoStmt);
108
- else inventoryTree(state, statement.stmt);
116
+ if (!statement.stmt.DoStmt) { inventoryTree(state, statement.stmt); continue; }
117
+ // The body opens on the line of its quote (the `as` DefElem), so a body line maps to that line plus its offset.
118
+ const opening = lineAt(text, statement.stmt.DoStmt.args?.find((item) => item.DefElem?.defname === 'as')?.DefElem?.location ?? statement.stmt_location ?? 0);
119
+ for (const line of inventoryDo(state, statement.stmt.DoStmt)) dynamic.push(`${entry.file}:${opening + line - 1}: EXECUTE builds its SQL at run time`);
109
120
  }
110
121
  }
122
+ if (dynamic.length) throw offendersError('dynamic migration blocks cannot prove database inventory', dynamic);
111
123
  return state;
112
124
  }
113
125