@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.
- package/CHANGELOG.md +85 -0
- package/README.md +43 -1
- package/db/drift.mjs +8 -6
- package/db/image.mjs +9 -4
- package/db/migrate.mjs +3 -3
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
- package/runtime/knowledge/hfs/rules.yaml +30 -13
- package/runtime/knowledge/hfs/slots.yaml +63 -37
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/queues.yaml +2 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +17 -17
- package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
- package/runtime/scripts/api/sops/lib.mjs +30 -0
- package/runtime/scripts/api/sops/update-keys.mjs +20 -0
- package/runtime/scripts/gates/starcistacks.mjs +6 -2
- package/runtime/scripts/guards/push-scan.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
- package/runtime/scripts/hfs/db-errors.mjs +2 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
- package/runtime/scripts/hfs/db-squash.mjs +514 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
- package/runtime/scripts/hfs/example-secrets.mjs +4 -3
- package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
- package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
- package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
- package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
- package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
- package/runtime/scripts/hfs/sealed-members.mjs +10 -6
- package/runtime/scripts/hfs/secret-files.mjs +16 -0
- package/runtime/scripts/hfs/secret-init.mjs +120 -21
- package/runtime/scripts/hfs/secret-output.mjs +2 -1
- package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
- package/runtime/scripts/hfs/secret-sops.mjs +108 -9
- package/runtime/scripts/hfs/secret-standard.mjs +369 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +140 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
- package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
- package/runtime/scripts/lib/sealed-member.mjs +36 -0
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +6 -4
- package/stack/age-install.mjs +4 -3
- package/stack/build.mjs +1 -1
- package/stack/config.mjs +2 -1
- package/stack/context.mjs +32 -2
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +8 -5
- package/stack/sealed.mjs +9 -8
- package/stack/server-key.mjs +296 -0
- package/stack/ssh-session.mjs +7 -1
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +36 -10
- package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
- package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
- package/templates/app/gitignore +2 -1
- package/templates/app/gitignore-lite +3 -1
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
- package/templates/be/inbox/events.json.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
- package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
- package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
- package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
- package/templates/be/inbox/platform/migration.sql.tpl +97 -34
- package/templates/be/inbox/service.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
- package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
- package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
- package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
- package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
- package/templates/app/db-image/db/Dockerfile +0 -8
- 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
|
|
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
|
-
|
|
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
|
-
|
|
366
|
+
archive();
|
|
327
367
|
return 0;
|
|
328
368
|
} catch (error) { return dbFailure('baseline', error, stderr); }
|
|
329
369
|
}
|
|
@@ -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)
|
|
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
|
|
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)
|
|
108
|
-
|
|
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
|
|