@starci/hfs 4.10.1 → 4.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +40 -1
  3. package/db/drift.mjs +19 -16
  4. package/db/image.mjs +13 -0
  5. package/db/migrate.mjs +3 -3
  6. package/db/postgres.mjs +1 -1
  7. package/package.json +2 -1
  8. package/runtime/knowledge/hfs/canon-pins.yaml +23 -8
  9. package/runtime/knowledge/hfs/rules.yaml +54 -13
  10. package/runtime/knowledge/hfs/slots.yaml +67 -36
  11. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  12. package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
  13. package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
  14. package/runtime/knowledge/patterns/be/queues.yaml +14 -14
  15. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  16. package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
  17. package/runtime/modules/kernel/failure-codes.yaml +28 -18
  18. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  19. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  20. package/runtime/scripts/guards/push-scan.mjs +1 -1
  21. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  22. package/runtime/scripts/hfs/db-baseline-verb.mjs +94 -22
  23. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  24. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  25. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  26. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  27. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  28. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  29. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  30. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  31. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  32. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  33. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  34. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  35. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  36. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  40. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  41. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  42. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  44. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  45. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +3 -4
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  47. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  48. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  49. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  50. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  52. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  53. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  54. package/runtime/scripts/hfs/secret-sops.mjs +93 -9
  55. package/runtime/scripts/hfs/secret-standard.mjs +315 -0
  56. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  57. package/runtime/scripts/hfs/secret.mjs +110 -46
  58. package/runtime/scripts/hfs/slots.mjs +8 -1
  59. package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
  60. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  61. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  62. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  63. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  64. package/runtime/scripts/lib/swarm-stack.mjs +4 -6
  65. package/scaffold/add-inbox-lite.mjs +2 -2
  66. package/scaffold/add-queue-lite.mjs +24 -5
  67. package/scaffold/app.mjs +2 -2
  68. package/src/main.mjs +5 -4
  69. package/stack/build.mjs +3 -1
  70. package/stack/config.mjs +2 -1
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +4 -3
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/target-binding.mjs +2 -1
  75. package/sync/hygiene.mjs +1 -1
  76. package/sync/index.mjs +47 -12
  77. package/templates/app/db-bootstrap/db/bootstrap.sql +9 -14
  78. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  79. package/templates/app/gitignore +2 -1
  80. package/templates/app/gitignore-lite +3 -1
  81. package/templates/app/package-scripts/db.json +2 -1
  82. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
  83. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  84. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
  85. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -1
  87. package/templates/app/skeleton-lite/README.md +2 -2
  88. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  89. package/templates/be/inbox/events.json.tpl +1 -1
  90. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  91. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  92. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  93. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  94. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  95. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  96. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  97. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  98. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  99. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  100. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  101. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  102. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  103. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  104. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  105. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  106. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  107. package/templates/be/patterns/jobs/platform/jobs.log-events.ts.tpl +2 -0
  108. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  109. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  110. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +65 -0
  111. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
  112. package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
  113. package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
  114. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
  115. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  118. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  119. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  120. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  121. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  122. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
  123. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
  124. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  125. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  126. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  127. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  128. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  129. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  130. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  131. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  132. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  133. 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 {
@@ -42,19 +44,38 @@ export function dbAppRoot(cwd, opts) {
42
44
  return root;
43
45
  }
44
46
 
45
- /** Immediate SQL files only, never secrets or credential files. */
47
+ /** A refusal that lists every offending file with the rule it broke, one distinct `<file>: <reason>` line each. */
48
+ export const offendersError = (heading, offenders) => new DbVerbError(`${heading}:\n${[...new Set(offenders)].map((line) => ` ${line}`).join('\n')}`);
49
+
50
+ /** One rule finding as an offender line: its file, its message and its code. */
51
+ const findingLine = (item) => `${item.message.startsWith(item.path) ? item.message : `${item.path}: ${item.message}`} (${item.code})`;
52
+
53
+ /**
54
+ * Why one SQL filename cannot join its chain, or null. The active chain holds R213 (`<ts14>_<kebab>.sql`, a valid UTC
55
+ * stamp; a stamp after now is no breach). The archive is frozen history (R257): it needs only the leading stamp that
56
+ * orders it, so snake_case names, impossible times and shared stamps of a retired chain stay as they were committed.
57
+ */
58
+ function chainProblem(directory, name) {
59
+ const match = /^(\d{14})_/.exec(name);
60
+ if (directory === MIGRATIONS_ARCHIVE_DIR) return match ? null : 'an archived name starts with its 14-digit stamp and an underscore (R257)';
61
+ if (!isMigrationName(name)) return 'name is not <ts14>_<kebab>.sql (R213)';
62
+ if (migrationStamp(match[1]) === null) return `stamp ${match[1]} is no valid UTC time (R213)`;
63
+ return null;
64
+ }
65
+
66
+ /** Immediate SQL files only, never secrets or credential files, sorted by full name; every offender is named before the refusal. */
46
67
  export function dbSqlFiles(root, directory = MIGRATIONS_DIR) {
47
68
  const dir = path.join(root, directory);
48
69
  if (!fs.existsSync(dir)) return [];
49
- return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith('.sql')).map((entry) => {
50
- const match = /^(\d{14})_/.exec(entry.name);
51
- if (!isMigrationName(entry.name) || !match || migrationStamp(match[1]) === null) throw new DbVerbError('migration filenames must carry valid UTC stamps');
52
- return { file: `${directory}/${entry.name}`, name: entry.name, stamp: match[1] };
53
- }).sort((left, right) => left.name < right.name ? -1 : Number(left.name > right.name));
70
+ const sqlNames = fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith('.sql')).map((entry) => entry.name);
71
+ const offenders = sqlNames.flatMap((name) => { const problem = chainProblem(directory, name); return problem ? [`${directory}/${name}: ${problem}`] : []; });
72
+ if (offenders.length) throw offendersError('migration filenames break their chain rules', offenders.sort());
73
+ return sqlNames.map((name) => ({ file: `${directory}/${name}`, name, stamp: name.slice(0, 14) }))
74
+ .sort((left, right) => left.name < right.name ? -1 : Number(left.name > right.name));
54
75
  }
55
76
 
56
77
  /** The database of `--url-env` (default DATABASE_URL): the URL without its password and the password for PGPASSWORD. */
57
- function connectionOf(env, urlEnv) {
78
+ export function connectionOf(env, urlEnv) {
58
79
  if (!/^[A-Z][A-Z0-9_]*$/.test(urlEnv)) throw new DbVerbError('--url-env must name an UPPER_CASE environment variable');
59
80
  let url;
60
81
  try { url = new URL(String(env[urlEnv] ?? '')); } catch { throw new DbVerbError(`${urlEnv} must hold a postgres connection URL`); }
@@ -68,7 +89,7 @@ const HISTORY = 'supabase_migrations.schema_migrations';
68
89
  const sqlLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
69
90
 
70
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. */
71
- function commandOf(op, url) {
92
+ export function commandOf(op, url) {
72
93
  const psql = ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', url];
73
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`]];
74
95
  if (op.kind === 'dump') return ['pg_dump', ['--schema-only', '--schema', 'public', '--dbname', url]];
@@ -103,12 +124,17 @@ function baselinePlan(root, opts, now) {
103
124
  const active = dbSqlFiles(root);
104
125
  if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
105
126
  const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
106
- const oldStamps = [...active, ...archived].map((entry) => entry.stamp);
107
- const last = oldStamps.toSorted((left, right) => left.localeCompare(right)).at(-1);
127
+ const archivedNames = new Set(archived.map((entry) => entry.name));
128
+ const twice = active.filter((entry) => archivedNames.has(entry.name)).map((entry) => `${entry.file}: also archived as ${MIGRATIONS_ARCHIVE_DIR}/${entry.name}; a migration is active or archived, never both (R257)`);
129
+ if (twice.length) throw offendersError('baseline would overwrite an existing migration', twice);
130
+ const chain = [...active, ...archived].sort((left, right) => left.stamp < right.stamp ? -1 : Number(left.stamp > right.stamp) || (left.name < right.name ? -1 : Number(left.name > right.name)));
131
+ // The new version sorts after every file; the header names the last valid stamp, the boundary R257 judges.
132
+ const boundary = chain.at(-1);
133
+ const last = chain.filter((entry) => migrationStamp(entry.stamp) !== null).at(-1)?.stamp ?? boundary.stamp;
108
134
  const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
109
- if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= last) throw new DbVerbError('--stamp must be a valid UTC version after the archive boundary');
135
+ if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= boundary.stamp) throw new DbVerbError(`--stamp must be a valid UTC version after the archive boundary ${boundary.stamp} (${boundary.file})`);
110
136
  const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
111
- if (fs.existsSync(path.join(root, file)) || active.some((entry) => fs.existsSync(path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name)))) throw new DbVerbError('baseline would overwrite an existing migration');
137
+ if (fs.existsSync(path.join(root, file))) throw new DbVerbError(`baseline would overwrite an existing migration: ${file}`);
112
138
  return { active, last, stamp, file };
113
139
  }
114
140
 
@@ -116,7 +142,7 @@ const EXCLUDED_SCHEMAS = new Set(['information_schema', '_analytics', '_realtime
116
142
  const ROLE_OPTIONS = new Set(['canlogin', 'inherit', 'superuser', 'createdb', 'createrole', 'isreplication', 'bypassrls', 'connectionlimit']);
117
143
  const identifier = (name) => `"${name.replaceAll('"', '""')}"`;
118
144
  const literal = (value) => `'${value.replaceAll("'", "''")}'`;
119
- 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 ?? '');
120
146
 
121
147
  /** Inventory source paths only; no credential, generated or vendor tree is traversed. */
122
148
  function baselineFiles(root) {
@@ -255,12 +281,19 @@ function baselineStatement(state, stmt, sql) {
255
281
 
256
282
  function baselineMetadata(root, plan, files) {
257
283
  const queues = databaseQueueDeclarations({ repoRoot: root, files, all: files });
258
- if (queues.findings.length) throw new DbVerbError('baseline requires valid migration-owned queues, grants and cron declarations');
284
+ if (queues.findings.length) throw offendersError('baseline requires valid migration-owned queues, grants and cron declarations', queues.findings.map(findingLine));
259
285
  const state = { roles: new Map(), extensions: new Map() };
286
+ const offenders = [];
260
287
  for (const migration of plan.active) {
261
288
  const text = fs.readFileSync(path.join(root, migration.file), 'utf8');
262
- for (const entry of parsed(text).stmts ?? []) baselineStatement(state, entry.stmt, statementSql(text, entry));
289
+ try {
290
+ for (const entry of parsed(text).stmts ?? []) baselineStatement(state, entry.stmt, statementSql(text, entry));
291
+ } catch (error) {
292
+ if (!(error instanceof DbVerbError)) throw error;
293
+ offenders.push(`${migration.file}: ${error.message}`);
294
+ }
263
295
  }
296
+ if (offenders.length) throw offendersError('baseline cannot rebuild the active chain from a schema-only dump', offenders);
264
297
  return { ...queues, roles: [...state.roles.values()].flat().join(''), extensions: [...state.extensions.values()].join('') };
265
298
  }
266
299
 
@@ -271,17 +304,57 @@ function metadataSql(metadata) {
271
304
  return `${queueSql}${grants}${cron}`;
272
305
  }
273
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
+
274
336
  /** Numeric CLI exit code, injectable IO/clock/CLI; neither files nor history change without --apply. */
275
337
  export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), now = () => new Date(), ...ports } = {}) {
276
338
  try {
277
- 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');
278
342
  const urlEnv = opts['url-env'] ?? 'DATABASE_URL';
279
343
  const root = dbAppRoot(cwd, opts);
280
344
  const plan = baselinePlan(root, opts, now);
281
345
  const files = baselineFiles(root);
282
- if (baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git }).length) throw new DbVerbError('baseline sources and archives must match committed migration history');
346
+ const sourceFindings = baselineSourceFindings({ repoRoot: root, files, all: files, git: ports.git });
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
+ }
283
356
  const metadata = baselineMetadata(root, plan, files);
284
- 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);
285
358
  const schema = runDbPostgres(root, { kind: 'dump' }, ports, urlEnv);
286
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');
287
360
  const repairs = [...plan.active.map((entry) => repairOp(entry.stamp, 'reverted')), repairOp(plan.stamp, 'applied', 'baseline')];
@@ -289,9 +362,8 @@ export function dbBaselineMain(argv, { cwd = invocationDir(), stdout = (text) =>
289
362
  for (const op of repairs) stdout(`${repairLine(op)}\n`);
290
363
  if (!opts.apply) return 0;
291
364
  for (const op of repairs) runDbPostgres(root, op, ports, urlEnv);
292
- fs.mkdirSync(path.join(root, MIGRATIONS_ARCHIVE_DIR), { recursive: true });
293
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' });
294
- for (const entry of plan.active) fs.renameSync(path.join(root, entry.file), path.join(root, MIGRATIONS_ARCHIVE_DIR, entry.name));
366
+ archive();
295
367
  return 0;
296
368
  } catch (error) { return dbFailure('baseline', error, stderr); }
297
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