@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.
- package/CHANGELOG.md +76 -0
- package/README.md +40 -1
- package/db/drift.mjs +19 -16
- package/db/image.mjs +13 -0
- package/db/migrate.mjs +3 -3
- package/db/postgres.mjs +1 -1
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +23 -8
- package/runtime/knowledge/hfs/rules.yaml +54 -13
- package/runtime/knowledge/hfs/slots.yaml +67 -36
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
- package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
- package/runtime/knowledge/patterns/be/queues.yaml +14 -14
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +28 -18
- package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
- 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 +94 -22
- 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/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 +3 -4
- 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 +93 -9
- package/runtime/scripts/hfs/secret-standard.mjs +315 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +110 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
- 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 +4 -6
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-queue-lite.mjs +24 -5
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +5 -4
- package/stack/build.mjs +3 -1
- package/stack/config.mjs +2 -1
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +4 -3
- package/stack/sealed.mjs +9 -8
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +47 -12
- package/templates/app/db-bootstrap/db/bootstrap.sql +9 -14
- 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 +2 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +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/jobs.log-events.ts.tpl +2 -0
- 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 +65 -0
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
- package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
- 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 +37 -2
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
- 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/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/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
- 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 {
|
|
@@ -42,19 +44,38 @@ export function dbAppRoot(cwd, opts) {
|
|
|
42
44
|
return root;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
/**
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
107
|
-
const
|
|
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 <=
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
+
archive();
|
|
295
367
|
return 0;
|
|
296
368
|
} catch (error) { return dbFailure('baseline', error, stderr); }
|
|
297
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
|
|