@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
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// app db baseline --squash: the whole active chain becomes ONE baseline read from a database the chain is applied to (--url-env), for a
|
|
2
|
+
// cutover whose production starts on an empty database. Nothing is rebuilt from migration SQL: the source's schema is dumped with its
|
|
3
|
+
// privileges, the objects of the extensions the target image lacks (--drop-extension) and every function or view whose body reaches their
|
|
4
|
+
// schemas are left out by name, the role facts a per-database dump loses go to a marked section of db/bootstrap.sql, and the reference rows
|
|
5
|
+
// of --seed-tables go to db/seed.sql. The verb proves its own output before it writes anything: a scratch container of the app's db image
|
|
6
|
+
// replays bootstrap + baseline + seed (the seed twice) and the same projection read back from the replay must equal the source's, or it
|
|
7
|
+
// refuses with the differences. The source is only read; its migration history is never changed.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { DB_DOCKERFILE, DB_IMAGE_CONTEXT } from '../../../db/image.mjs';
|
|
13
|
+
import { DbVerbError } from './db-errors.mjs';
|
|
14
|
+
import { BOOTSTRAP_FILE, BOOTSTRAP_SQUASH_BEGIN, BOOTSTRAP_SQUASH_END, SEED_FILE, bootstrapSection } from './rules/database-constants.mjs';
|
|
15
|
+
|
|
16
|
+
/** The extensions a lite app's db image does not ship, dropped unless --drop-extension names others (pgmq stays: it is the lite event bus). */
|
|
17
|
+
export const DEFAULT_DROPPED_EXTENSIONS = Object.freeze(['pg_net', 'pg_cron']);
|
|
18
|
+
export const SEED_HEADER = '-- Generated by starci app db baseline --squash';
|
|
19
|
+
// Roles of a hosted platform (Supabase) that a self-hosted lite database never has; a grant naming one is platform state, not the app's.
|
|
20
|
+
const PLATFORM_ROLE = /^(?:supabase_\w+|authenticator|dashboard_user|pgbouncer)$/;
|
|
21
|
+
// Role-level settings carried to the bootstrap; any other setting of a carried role is refused, so no secret travels.
|
|
22
|
+
const ROLE_SETTINGS = new Set(['search_path', 'statement_timeout', 'lock_timeout', 'idle_in_transaction_session_timeout']);
|
|
23
|
+
const QUERY_OPTIONS = '-c search_path= -c TimeZone=UTC -c DateStyle=ISO -c IntervalStyle=postgres -c extra_float_digits=3';
|
|
24
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
25
|
+
const REPLAY_DB = 'starci_squash_replay';
|
|
26
|
+
const READY_TIMEOUT_MS = 120_000;
|
|
27
|
+
const DIFF_LIMIT = 40;
|
|
28
|
+
|
|
29
|
+
const ident = (name) => `"${String(name).replaceAll('"', '""')}"`;
|
|
30
|
+
const literal = (value) => `'${String(value).replaceAll("'", "''")}'`;
|
|
31
|
+
const unquote = (name) => (/^".*"$/s.test(name) ? name.slice(1, -1).replaceAll('""', '"') : name);
|
|
32
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
34
|
+
const sorted = (items) => [...items].sort((left, right) => (left < right ? -1 : Number(left > right)));
|
|
35
|
+
|
|
36
|
+
/** A comma list flag as distinct names, or `fallback` when the flag is absent. */
|
|
37
|
+
export const flagList = (value, fallback) => (value === undefined ? [...fallback] : [...new Set(String(value).split(',').map((item) => item.trim()).filter(Boolean))]);
|
|
38
|
+
|
|
39
|
+
/* ---- docker: every client tool runs in a container of the db image, so the host needs Docker and no PostgreSQL client */
|
|
40
|
+
|
|
41
|
+
function docker(args, { input, env = {}, secrets = [] } = {}) {
|
|
42
|
+
const result = spawnSync('docker', args, { input, env: { ...process.env, ...env }, encoding: 'utf8', maxBuffer: 1 << 30, timeout: 600_000, windowsHide: true });
|
|
43
|
+
const scrub = (text) => secrets.filter(Boolean).reduce((out, secret) => out.replaceAll(secret, '[value]'), String(text ?? ''));
|
|
44
|
+
return { ok: !result.error && result.status === 0, stdout: String(result.stdout ?? ''), stderr: scrub(result.stderr || result.error?.message) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const firstLines = (text, count = 4) => String(text).trim().split('\n').filter(Boolean).slice(0, count).join(' | ');
|
|
48
|
+
|
|
49
|
+
/** A throwaway container of `image` that can reach the host as host.docker.internal; refused without Docker. */
|
|
50
|
+
function startScratch(image) {
|
|
51
|
+
if (!docker(['version', '--format', '{{.Server.Version}}']).ok) throw new DbVerbError('baseline --squash needs Docker for its scratch replay; start it');
|
|
52
|
+
const password = randomBytes(18).toString('hex');
|
|
53
|
+
const name = `starci-db-squash-${randomBytes(6).toString('hex')}`;
|
|
54
|
+
const scratch = { name, password, image, stop: () => docker(['rm', '-f', name]) };
|
|
55
|
+
const started = docker(['run', '-d', '--rm', '--name', name, '--label', 'starci.db-squash=1', '--add-host', 'host.docker.internal:host-gateway', '-e', 'POSTGRES_PASSWORD', image], { env: { POSTGRES_PASSWORD: password } });
|
|
56
|
+
if (!started.ok) { scratch.stop(); throw new DbVerbError(`docker could not start ${image}: ${firstLines(started.stderr, 1)}`); }
|
|
57
|
+
const since = Date.now();
|
|
58
|
+
// TCP, not the socket: the image's init server listens on the socket only, and restarts before it serves TCP.
|
|
59
|
+
while (!docker(['exec', '-e', 'PGPASSWORD', name, 'psql', '-X', '-h', '127.0.0.1', '-U', 'postgres', '-Atqc', 'select 1'], { env: { PGPASSWORD: password } }).ok) {
|
|
60
|
+
if (Date.now() - since > READY_TIMEOUT_MS) { scratch.stop(); throw new DbVerbError(`the scratch database of ${image} did not start`); }
|
|
61
|
+
sleep(500);
|
|
62
|
+
}
|
|
63
|
+
return scratch;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The app's own db image, built from its managed Dockerfile (DB_DOCKERFILE); its id names it. */
|
|
67
|
+
function buildImage(root) {
|
|
68
|
+
if (!docker(['version', '--format', '{{.Server.Version}}']).ok) throw new DbVerbError('baseline --squash needs Docker for its scratch replay; start it');
|
|
69
|
+
const build = docker(['build', '--quiet', '--file', path.join(root, DB_DOCKERFILE), path.join(root, DB_IMAGE_CONTEXT)]);
|
|
70
|
+
const image = /sha256:[a-f0-9]{64}/.exec(build.stdout)?.[0];
|
|
71
|
+
if (!build.ok || !image) throw new DbVerbError(`docker could not build ${DB_DOCKERFILE}: ${firstLines(build.stderr, 2)}`);
|
|
72
|
+
return image;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Where a target's client tools run: `docker exec` in the scratch container, or a one-shot container of its image on the host's network. */
|
|
76
|
+
const inScratch = (scratch) => ({ head: ['exec', '-i'], ref: scratch.name });
|
|
77
|
+
const onHostNetwork = (scratch) => ({ head: ['run', '--rm', '-i', '--network', 'host', '--label', 'starci.db-squash=1'], ref: scratch.image });
|
|
78
|
+
|
|
79
|
+
const scratchTarget = (scratch, database) => ({ url: `postgresql://postgres@127.0.0.1:5432/${database}`, password: scratch.password, label: `the scratch ${database}`, client: inScratch(scratch) });
|
|
80
|
+
|
|
81
|
+
function exec(target, tool, args, { input, query = false } = {}) {
|
|
82
|
+
const env = { PGPASSWORD: target.password, ...(query ? { PGOPTIONS: QUERY_OPTIONS } : {}) };
|
|
83
|
+
const result = docker([...target.client.head, '-e', 'PGPASSWORD', ...(query ? ['-e', 'PGOPTIONS'] : []), target.client.ref, tool, ...args], { input, env, secrets: [target.password, target.url] });
|
|
84
|
+
if (!result.ok) throw new DbVerbError(`${tool} against ${target.label} failed: ${firstLines(result.stderr)}`);
|
|
85
|
+
return result.stdout;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const psqlArgs = (target) => ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-d', target.url];
|
|
89
|
+
const queryJson = (target, sql) => JSON.parse(exec(target, 'psql', [...psqlArgs(target), '-A', '-t', '-c', sql], { query: true }));
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The source as a container reaches it. A remote host is reached from the scratch container. A loopback host is the host's own: Docker
|
|
93
|
+
* Desktop reaches it from the scratch container as host.docker.internal, a Linux engine only from a container on the host's network (its
|
|
94
|
+
* bridge gateway never reaches a port bound to 127.0.0.1, as the app's own dev database is). The first route psql connects on is kept;
|
|
95
|
+
* when none does, the verb refuses with each route's failure.
|
|
96
|
+
*/
|
|
97
|
+
export function sourceTarget(scratch, connection, label) {
|
|
98
|
+
const password = connection.env.PGPASSWORD ?? '';
|
|
99
|
+
const url = new URL(connection.url);
|
|
100
|
+
if (!LOCAL_HOSTS.has(url.hostname)) return { url: url.toString(), password, label, client: inScratch(scratch) };
|
|
101
|
+
const gateway = new URL(connection.url);
|
|
102
|
+
gateway.hostname = 'host.docker.internal';
|
|
103
|
+
const routes = [{ url: gateway.toString(), password, label, client: inScratch(scratch) }, { url: url.toString(), password, label, client: onHostNetwork(scratch) }];
|
|
104
|
+
const failures = [];
|
|
105
|
+
for (const route of routes) {
|
|
106
|
+
try { exec(route, 'psql', [...psqlArgs(route), '-At', '-c', 'select 1']); return route; } catch (error) { failures.push(error.message); }
|
|
107
|
+
}
|
|
108
|
+
throw new DbVerbError(`no container of ${scratch.image} reaches ${label}:\n${failures.map((line) => ` ${line}`).join('\n')}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The runPostgres port of db-baseline-verb.mjs, served by the target's container: the history precondition needs no host psql. */
|
|
112
|
+
export function targetRunner(target, commandOf) {
|
|
113
|
+
return (root, op) => {
|
|
114
|
+
const [tool, args] = commandOf(op, target.url);
|
|
115
|
+
return { status: 0, stdout: exec(target, tool, args) };
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ---- the source facts: one read-only catalog query */
|
|
120
|
+
|
|
121
|
+
const notMember = (classid, column) => `not exists (select 1 from pg_catalog.pg_depend d where d.classid = '${classid}'::pg_catalog.regclass and d.objid = ${column} and d.deptype = 'e')`;
|
|
122
|
+
const userSchema = (column) => `${column} !~ '^pg_' and ${column} <> 'information_schema'`;
|
|
123
|
+
const FACTS_SQL = `select pg_catalog.json_build_object(
|
|
124
|
+
'user', current_user,
|
|
125
|
+
'roles', (select coalesce(json_agg(json_build_object('name', r.rolname, 'super', r.rolsuper, 'replication', r.rolreplication, 'login', r.rolcanlogin,
|
|
126
|
+
'inherit', r.rolinherit, 'createdb', r.rolcreatedb, 'createrole', r.rolcreaterole, 'bypassrls', r.rolbypassrls, 'connlimit', r.rolconnlimit) order by r.rolname), '[]')
|
|
127
|
+
from pg_catalog.pg_roles r where r.rolname !~ '^pg_'),
|
|
128
|
+
'members', (select coalesce(json_agg(json_build_object('role', r.rolname, 'member', m.rolname) order by r.rolname, m.rolname), '[]')
|
|
129
|
+
from pg_catalog.pg_auth_members a join pg_catalog.pg_roles r on r.oid = a.roleid join pg_catalog.pg_roles m on m.oid = a.member),
|
|
130
|
+
'settings', (select coalesce(json_agg(json_build_object('role', r.rolname, 'config', s.setconfig) order by r.rolname, s.setdatabase), '[]')
|
|
131
|
+
from pg_catalog.pg_db_role_setting s join pg_catalog.pg_roles r on r.oid = s.setrole
|
|
132
|
+
where s.setdatabase in (0, (select d.oid from pg_catalog.pg_database d where d.datname = current_database()))),
|
|
133
|
+
'extensions', (select coalesce(json_agg(json_build_object('name', e.extname, 'schema', n.nspname, 'members', (select coalesce(json_agg(m.nspname order by m.nspname), '[]')
|
|
134
|
+
from pg_catalog.pg_depend d join pg_catalog.pg_namespace m on m.oid = d.objid
|
|
135
|
+
where d.classid = 'pg_catalog.pg_namespace'::pg_catalog.regclass and d.refobjid = e.oid and d.deptype = 'e')) order by e.extname), '[]')
|
|
136
|
+
from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace),
|
|
137
|
+
'schemas', (select coalesce(json_agg(n.nspname order by n.nspname), '[]') from pg_catalog.pg_namespace n where ${userSchema('n.nspname')}),
|
|
138
|
+
'routines', (select coalesce(json_agg(json_build_object('schema', n.nspname, 'name', p.proname, 'kind', p.prokind, 'types', pg_catalog.oidvectortypes(p.proargtypes),
|
|
139
|
+
'args', pg_catalog.pg_get_function_identity_arguments(p.oid),
|
|
140
|
+
'text', concat_ws(' ', p.prosrc, pg_catalog.pg_get_function_arguments(p.oid), pg_catalog.pg_get_function_result(p.oid))) order by n.nspname, p.proname, p.oid), '[]')
|
|
141
|
+
from pg_catalog.pg_proc p join pg_catalog.pg_namespace n on n.oid = p.pronamespace where ${userSchema('n.nspname')} and ${notMember('pg_catalog.pg_proc', 'p.oid')}),
|
|
142
|
+
'views', (select coalesce(json_agg(json_build_object('schema', n.nspname, 'name', c.relname, 'kind', c.relkind, 'text', pg_catalog.pg_get_viewdef(c.oid)) order by n.nspname, c.relname), '[]')
|
|
143
|
+
from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
144
|
+
where c.relkind in ('v', 'm') and ${userSchema('n.nspname')} and ${notMember('pg_catalog.pg_class', 'c.oid')}))`;
|
|
145
|
+
|
|
146
|
+
const QUEUES_SQL = `select coalesce(json_agg(json_build_object('name', m.queue_name, 'partitioned', m.is_partitioned, 'unlogged', m.is_unlogged) order by m.queue_name), '[]') from pgmq.meta m`;
|
|
147
|
+
|
|
148
|
+
/** Whether a queue name matches one --drop-pgmq-queue entry: a name, or a pattern where `*` matches any run of characters. */
|
|
149
|
+
export const queueDropped = (name, patterns) => patterns.some((pattern) => new RegExp(`^${pattern.split('*').map(escape).join('.*')}$`).test(name));
|
|
150
|
+
|
|
151
|
+
/* ---- the dump as entries */
|
|
152
|
+
|
|
153
|
+
const ENTRY = /^-- (?:Data for )?Name: (.*); Type: (.*); Schema: (.*); Owner: (.*)$/;
|
|
154
|
+
const LOOSE_SET = /^SET default_(?:tablespace|table_access_method) = .*;$/;
|
|
155
|
+
|
|
156
|
+
/** A plain pg_dump as { preamble, entries: [{ name, type, schema, body }] }; psql's \restrict guard and the version comments are dropped. */
|
|
157
|
+
export function dumpEntries(text) {
|
|
158
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n').filter((line) => !/^\\(?:un)?restrict\b/.test(line));
|
|
159
|
+
const starts = lines.flatMap((line, index) => (ENTRY.test(line) && lines[index - 1] === '--' && lines[index + 1] === '--' ? [index] : []));
|
|
160
|
+
const preamble = lines.slice(0, starts.length ? starts[0] - 1 : lines.length).filter((line) => /^(?:SET|SELECT) /.test(line));
|
|
161
|
+
const entries = starts.map((start, index) => {
|
|
162
|
+
const [, name, type, schema] = ENTRY.exec(lines[start]);
|
|
163
|
+
const end = index + 1 < starts.length ? starts[index + 1] - 1 : lines.length;
|
|
164
|
+
const body = lines.slice(start + 2, end).filter((line) => {
|
|
165
|
+
if (!LOOSE_SET.test(line)) return true;
|
|
166
|
+
if (!preamble.includes(line)) preamble.push(line);
|
|
167
|
+
return false;
|
|
168
|
+
}).join('\n').replace(/\n--\n-- PostgreSQL database dump complete\n--\s*$/, '').trim();
|
|
169
|
+
return { name, type, schema, body };
|
|
170
|
+
});
|
|
171
|
+
return { preamble, entries };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const entryKey = (entry) => `${entry.type}|${entry.schema}|${entry.name}`;
|
|
175
|
+
const renderEntry = (entry) => `--\n-- Name: ${entry.name}; Type: ${entry.type}; Schema: ${entry.schema}; Owner: -\n--\n\n${entry.body}\n\n\n`;
|
|
176
|
+
|
|
177
|
+
const ROLE_NAMES = /\b(?:TO|FROM|FOR ROLE|SESSION AUTHORIZATION)\s+((?:"(?:[^"]|"")+"|[\w$]+)(?:\s*,\s*(?:"(?:[^"]|"")+"|[\w$]+))*)/gi;
|
|
178
|
+
/** The roles a statement grants to, revokes from, acts for or switches to. */
|
|
179
|
+
const rolesOf = (text) => [...String(text).matchAll(ROLE_NAMES)].flatMap((match) => match[1].split(/\s*,\s*/).map(unquote));
|
|
180
|
+
|
|
181
|
+
/* ---- the projection: what a squash of one database writes */
|
|
182
|
+
|
|
183
|
+
/** The names a bootstrap's own SQL creates: its schemas, roles and publications. */
|
|
184
|
+
export function bootstrapDeclares(head) {
|
|
185
|
+
const created = (kind) => new Set([...head.matchAll(new RegExp(`create\\s+${kind}\\s+(?:if\\s+not\\s+exists\\s+)?("(?:[^"]|"")+"|[\\w$]+)`, 'gi'))].map((match) => unquote(match[1])));
|
|
186
|
+
return { schemas: created('schema'), roles: created('role'), publications: created('publication') };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ownerOf(entry) {
|
|
190
|
+
if (entry.schema !== '-') return { schema: entry.schema };
|
|
191
|
+
const target = /^(SCHEMA|EXTENSION) (.+)$/.exec(entry.name);
|
|
192
|
+
if (entry.type === 'SCHEMA') return { schema: unquote(entry.name) };
|
|
193
|
+
if (entry.type === 'EXTENSION') return { extension: unquote(entry.name) };
|
|
194
|
+
if (['ACL', 'COMMENT'].includes(entry.type) && target) return target[1] === 'SCHEMA' ? { schema: unquote(target[2]), schemaLevel: true } : { extension: unquote(target[2]) };
|
|
195
|
+
if (entry.type === 'DEFAULT ACL') return { global: true };
|
|
196
|
+
if (entry.type === 'PUBLICATION') return { publication: unquote(entry.name) };
|
|
197
|
+
if (entry.type === 'EVENT TRIGGER') return { eventTrigger: unquote(/EXECUTE FUNCTION\s+("(?:[^"]|"")+"|[\w$]+)\./i.exec(entry.body)?.[1] ?? '') };
|
|
198
|
+
return { database: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function routineKeys(routine) {
|
|
202
|
+
const kind = { p: 'PROCEDURE', a: 'AGGREGATE' }[routine.kind] ?? 'FUNCTION';
|
|
203
|
+
const signature = `${routine.name}(${routine.args})`;
|
|
204
|
+
return [`${kind}|${routine.schema}|${routine.name}(${routine.types})`, `ACL|${routine.schema}|${kind} ${signature}`, `COMMENT|${routine.schema}|${kind} ${signature}`];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function viewKeys(view) {
|
|
208
|
+
const kind = view.kind === 'm' ? 'MATERIALIZED VIEW' : 'VIEW';
|
|
209
|
+
return [`${kind}|${view.schema}|${view.name}`, `ACL|${view.schema}|TABLE ${view.name}`, `COMMENT|${view.schema}|${kind} ${view.name}`, `MATERIALIZED VIEW DATA|${view.schema}|${view.name}`];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Which schemas are the app's, which belong to a dropped extension, and which objects reach the dropped ones. */
|
|
213
|
+
function squashScope(facts, config) {
|
|
214
|
+
const kept = facts.extensions.filter((extension) => !config.dropped.includes(extension.name));
|
|
215
|
+
const keptHomes = new Set(kept.map((extension) => extension.schema));
|
|
216
|
+
const droppedSchemas = new Set();
|
|
217
|
+
for (const extension of facts.extensions.filter((item) => config.dropped.includes(item.name))) {
|
|
218
|
+
for (const member of extension.members) droppedSchemas.add(member);
|
|
219
|
+
if (!['public', 'pg_catalog'].includes(extension.schema) && !keptHomes.has(extension.schema) && !config.bootstrap.schemas.has(extension.schema)) droppedSchemas.add(extension.schema);
|
|
220
|
+
}
|
|
221
|
+
const appSchemas = new Set(facts.schemas.filter((schema) => !config.managedSchema(schema) && !droppedSchemas.has(schema) && !config.bootstrap.schemas.has(schema) && !keptHomes.has(schema)));
|
|
222
|
+
// A kept extension's own schema (pgmq) is created by the baseline before its extension; grants on it follow in the baseline's tail.
|
|
223
|
+
const extensionHomes = new Set([...keptHomes].filter((schema) => !['public', 'pg_catalog'].includes(schema) && !config.bootstrap.schemas.has(schema)));
|
|
224
|
+
const reaches = droppedSchemas.size ? new RegExp(`(?<![\\w$."])"?(?:${sorted(droppedSchemas).map(escape).join('|')})"?\\s*\\.`, 'i') : null;
|
|
225
|
+
const excluded = [
|
|
226
|
+
...facts.routines.filter((item) => appSchemas.has(item.schema) && reaches?.test(item.text)).map((item) => ({ label: `${item.schema}.${item.name}(${item.args})`, schema: item.schema, name: item.name, keys: routineKeys(item), call: true })),
|
|
227
|
+
...facts.views.filter((item) => appSchemas.has(item.schema) && reaches?.test(item.text)).map((item) => ({ label: `${item.schema}.${item.name}`, schema: item.schema, name: item.name, keys: viewKeys(item), call: false })),
|
|
228
|
+
];
|
|
229
|
+
return { kept, appSchemas, extensionHomes, droppedSchemas, reaches, excluded };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Kept entries naming a dropped schema or an excluded object: each pair is a refusal line. */
|
|
233
|
+
function danglingReferences(entries, scope) {
|
|
234
|
+
const lines = [];
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
const where = `${entry.type} ${entry.schema === '-' ? '' : `${entry.schema}.`}${entry.name}`;
|
|
237
|
+
const schema = scope.reaches?.exec(entry.body);
|
|
238
|
+
if (schema) lines.push(`${where} references the dropped schema ${schema[0].replace(/[\s.]+$/, '')}`);
|
|
239
|
+
for (const object of scope.excluded) {
|
|
240
|
+
const qualified = new RegExp(`(?<![\\w$])"?${escape(object.schema)}"?\\."?${escape(object.name)}"?(?![\\w$])`, 'i');
|
|
241
|
+
const bare = object.call && /^(?:FUNCTION|PROCEDURE|VIEW|MATERIALIZED VIEW|TRIGGER|POLICY)$/.test(entry.type) ? new RegExp(`(?<![\\w$."])${escape(object.name)}\\s*\\(`, 'i') : null;
|
|
242
|
+
if (qualified.test(entry.body) || bare?.test(entry.body)) lines.push(`${where} references the excluded ${object.label}`);
|
|
243
|
+
}
|
|
244
|
+
for (const match of entry.body.matchAll(/\bpgmq\s*\.\s*\w+\s*\(\s*'((?:[^']|'')+)'/gi)) {
|
|
245
|
+
const queue = match[1].replaceAll("''", "'");
|
|
246
|
+
if (scope.droppedQueues.has(queue)) lines.push(`${where} uses the dropped pgmq queue ${queue}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function roleSql(role, existing) {
|
|
253
|
+
const attributes = [role.login ? 'login' : 'nologin', role.inherit ? 'inherit' : 'noinherit', role.createdb ? 'createdb' : 'nocreatedb',
|
|
254
|
+
role.createrole ? 'createrole' : 'nocreaterole', role.bypassrls ? 'bypassrls' : 'nobypassrls', `connection limit ${role.connlimit}`].join(' ');
|
|
255
|
+
return [` if not exists (select 1 from pg_catalog.pg_roles where rolname = ${literal(role.name)}) then create role ${ident(role.name)}; end if;`,
|
|
256
|
+
existing ? null : `alter role ${ident(role.name)} with ${attributes};`];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function settingSql(role, entry, refusals) {
|
|
260
|
+
const at = entry.indexOf('=');
|
|
261
|
+
const [key, value] = [entry.slice(0, at), entry.slice(at + 1)];
|
|
262
|
+
if (!ROLE_SETTINGS.has(key)) { refusals.push(`role ${role} carries the setting ${key}, which a squash does not carry (allowed: ${sorted(ROLE_SETTINGS).join(', ')})`); return null; }
|
|
263
|
+
if (key !== 'search_path') return `alter role ${ident(role)} set ${key} = ${literal(value)};`;
|
|
264
|
+
if (!/^[\w$", ]+$/.test(value)) { refusals.push(`role ${role} has a search_path the squash cannot restate: ${value}`); return null; }
|
|
265
|
+
return `alter role ${ident(role)} set search_path = ${value};`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The bootstrap section: carried roles, their memberships and settings, schema-level grants on bootstrap schemas, database-wide default privileges. */
|
|
269
|
+
function sectionText({ facts, carried, bootstrapRoles, schemaGrants, defaultGrants, refusals }) {
|
|
270
|
+
const roles = facts.roles.filter((role) => carried.has(role.name));
|
|
271
|
+
const create = roles.map((role) => roleSql(role, false));
|
|
272
|
+
const known = new Set([...carried, ...bootstrapRoles]);
|
|
273
|
+
const members = facts.members.filter((item) => known.has(item.role) && known.has(item.member)).map((item) => `grant ${ident(item.role)} to ${ident(item.member)};`);
|
|
274
|
+
const settings = facts.settings.filter((item) => known.has(item.role)).flatMap((item) => item.config.map((entry) => settingSql(item.role, entry, refusals))).filter(Boolean);
|
|
275
|
+
const body = [
|
|
276
|
+
'-- The source\'s app roles (passwords are set out of band), their memberships and role-level settings, the grants on schemas this bootstrap',
|
|
277
|
+
'-- creates, and the database-wide default privileges: a per-database dump keeps none of them.',
|
|
278
|
+
...(create.length ? ['do $starci_squash$', 'begin', ...create.map(([line]) => line), 'end', '$starci_squash$;', ...create.map(([, line]) => line)] : []),
|
|
279
|
+
...members, ...settings, ...schemaGrants, ...defaultGrants,
|
|
280
|
+
];
|
|
281
|
+
return `${BOOTSTRAP_SQUASH_BEGIN}\n${body.join('\n')}\n${BOOTSTRAP_SQUASH_END}\n`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Partition one dump: baseline entries, statements for the bootstrap section and the baseline tail, and what was left out and why. */
|
|
285
|
+
function partition(dump, facts, scope, config) {
|
|
286
|
+
const excludedKeys = new Map(scope.excluded.flatMap((object) => object.keys.map((key) => [key, object])));
|
|
287
|
+
const report = { extensions: [], objects: new Set(), eventTriggers: [], platformSchemas: new Map(), platformStatements: 0, ownerGrants: 0, lostGrants: [] };
|
|
288
|
+
const kept = [];
|
|
289
|
+
const bootstrapAcl = [];
|
|
290
|
+
const extensionAcl = [];
|
|
291
|
+
const globalAcl = [];
|
|
292
|
+
for (const entry of dump.entries) {
|
|
293
|
+
const owner = ownerOf(entry);
|
|
294
|
+
if (owner.extension !== undefined) {
|
|
295
|
+
if (config.dropped.includes(owner.extension) || owner.extension === 'plpgsql') { if (entry.type === 'EXTENSION') report.extensions.push(owner.extension); }
|
|
296
|
+
else kept.push(entry);
|
|
297
|
+
} else if (owner.global) globalAcl.push(entry);
|
|
298
|
+
else if (owner.publication !== undefined) {
|
|
299
|
+
// An empty publication (Supabase's supabase_realtime) is platform state: lite realtime is LISTEN/NOTIFY plus Redis.
|
|
300
|
+
const publishes = /\bFOR\s+(?:ALL\s+)?TABLES?\b/i.test(entry.body) || dump.entries.some((other) => other.type.startsWith('PUBLICATION TABLE') && other.name.startsWith(`${entry.name} `));
|
|
301
|
+
if (publishes && !config.bootstrap.publications.has(owner.publication)) kept.push(entry);
|
|
302
|
+
else if (!publishes) report.objects.add(`publication ${owner.publication} (publishes no table)`);
|
|
303
|
+
}
|
|
304
|
+
else if (owner.eventTrigger !== undefined) { if (scope.appSchemas.has(owner.eventTrigger)) kept.push(entry); else report.eventTriggers.push(unquote(entry.name)); }
|
|
305
|
+
else if (owner.database || scope.appSchemas.has(owner.schema)) {
|
|
306
|
+
const object = excludedKeys.get(entryKey(entry));
|
|
307
|
+
if (object) report.objects.add(`${object.label} (reaches ${sorted(scope.droppedSchemas).join(', ')})`);
|
|
308
|
+
else kept.push(entry);
|
|
309
|
+
} else if (scope.extensionHomes.has(owner.schema)) {
|
|
310
|
+
if (entry.type === 'SCHEMA') kept.push(entry);
|
|
311
|
+
else if (entry.type === 'ACL') extensionAcl.push({ entry, schemaLevel: false });
|
|
312
|
+
} else if (config.bootstrap.schemas.has(owner.schema) && entry.type === 'ACL') bootstrapAcl.push({ entry, schemaLevel: owner.schemaLevel === true });
|
|
313
|
+
else {
|
|
314
|
+
report.platformSchemas.set(owner.schema, (report.platformSchemas.get(owner.schema) ?? 0) + 1);
|
|
315
|
+
if (entry.type === 'ACL') report.lostGrants.push(...entry.body.split('\n').filter((line) => /^(?:GRANT|REVOKE)\b/.test(line)).map((line) => ({ line, schema: owner.schema })));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { kept, bootstrapAcl, extensionAcl, globalAcl, report };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One database projected to the files a squash writes; the same function reads the source and the replay. */
|
|
322
|
+
function project(target, config) {
|
|
323
|
+
const facts = queryJson(target, FACTS_SQL);
|
|
324
|
+
const scope = squashScope(facts, config);
|
|
325
|
+
const queues = facts.extensions.some((extension) => extension.name === 'pgmq') && !config.dropped.includes('pgmq') ? queryJson(target, QUEUES_SQL) : [];
|
|
326
|
+
scope.droppedQueues = new Set(queues.filter((queue) => queueDropped(queue.name, config.droppedQueues)).map((queue) => queue.name));
|
|
327
|
+
const keptQueues = queues.filter((queue) => !scope.droppedQueues.has(queue.name));
|
|
328
|
+
const dump = dumpEntries(exec(target, 'pg_dump', ['--schema-only', '--no-owner', '-d', target.url]));
|
|
329
|
+
const parts = partition(dump, facts, scope, config);
|
|
330
|
+
const refusals = danglingReferences(parts.kept, scope);
|
|
331
|
+
const roleNames = new Set(facts.roles.map((role) => role.name));
|
|
332
|
+
const platform = new Set(facts.roles.filter((role) => (role.super && role.name !== facts.user) || PLATFORM_ROLE.test(role.name)).map((role) => role.name));
|
|
333
|
+
const base = new Set([...config.bootstrap.roles, facts.user, 'postgres', 'public', 'PUBLIC']);
|
|
334
|
+
const statementLines = (body) => body.split('\n');
|
|
335
|
+
const naming = (line) => rolesOf(line).filter((role) => roleNames.has(role) && !base.has(role) && !platform.has(role));
|
|
336
|
+
// A statement naming a platform role is the platform's own state and is left out; the rest stays as dumped.
|
|
337
|
+
const keepLine = (line) => { const platformRole = rolesOf(line).some((role) => platform.has(role)); if (platformRole) parts.report.platformStatements += 1; return !platformRole; };
|
|
338
|
+
// Every replayed object is owned by the migrating role, so a grant to it, spelled out on a source object a platform role owns, is implied.
|
|
339
|
+
const owners = new Set([facts.user, 'postgres']);
|
|
340
|
+
const ownerGrant = (entry, line) => entry.type === 'ACL' && /^GRANT\b/.test(line) && rolesOf(line).every((role) => owners.has(role));
|
|
341
|
+
const entries = parts.kept.flatMap((entry) => {
|
|
342
|
+
if (!['ACL', 'DEFAULT ACL'].includes(entry.type)) return [entry];
|
|
343
|
+
const body = statementLines(entry.body).filter((line) => {
|
|
344
|
+
if (!keepLine(line)) return false;
|
|
345
|
+
if (!ownerGrant(entry, line)) return true;
|
|
346
|
+
parts.report.ownerGrants += 1;
|
|
347
|
+
return false;
|
|
348
|
+
}).join('\n').trim();
|
|
349
|
+
return body ? [{ ...entry, body }] : [];
|
|
350
|
+
});
|
|
351
|
+
const carried = new Set(entries.flatMap((entry) => naming(entry.body)));
|
|
352
|
+
const aclLines = (items) => items.flatMap((item) => statementLines(item.entry?.body ?? item.body).filter((line) => /^(?:GRANT|REVOKE|ALTER DEFAULT PRIVILEGES)\b/.test(line) && keepLine(line)).map((line) => ({ line, schemaLevel: item.schemaLevel })));
|
|
353
|
+
const queueTable = new RegExp(`\\bpgmq\\.[aq]_(?:${[...scope.droppedQueues].map(escape).join('|') || '(?!)'})\\b`);
|
|
354
|
+
const outside = [...aclLines(parts.bootstrapAcl), ...aclLines(parts.extensionAcl)].filter((item) => naming(item.line).length && !queueTable.test(item.line));
|
|
355
|
+
for (const item of outside) for (const role of naming(item.line)) carried.add(role);
|
|
356
|
+
const defaultGrants = aclLines(parts.globalAcl).map((item) => item.line);
|
|
357
|
+
for (const line of defaultGrants) for (const role of naming(line)) carried.add(role);
|
|
358
|
+
parts.report.lostGrants = parts.report.lostGrants.filter((lost) => naming(lost.line).length).map((lost) => lost.line);
|
|
359
|
+
for (const role of facts.roles.filter((item) => carried.has(item.name) && (item.super || item.replication))) refusals.push(`role ${role.name} is a superuser or replication role; a squash carries no such role`);
|
|
360
|
+
const section = sectionText({ facts, carried, bootstrapRoles: config.bootstrap.roles, schemaGrants: outside.filter((item) => item.schemaLevel).map((item) => item.line), defaultGrants, refusals });
|
|
361
|
+
for (const queue of keptQueues.filter((item) => item.partitioned)) refusals.push(`pgmq queue ${queue.name} is partitioned; a squash recreates plain and unlogged queues only`);
|
|
362
|
+
const creates = keptQueues.filter((queue) => !queue.partitioned).map((queue) => `SELECT pgmq.${queue.unlogged ? 'create_unlogged' : 'create'}(${literal(queue.name)});`);
|
|
363
|
+
const tail = [...creates, ...outside.filter((item) => !item.schemaLevel).map((item) => item.line)];
|
|
364
|
+
parts.report.droppedQueues = sorted(scope.droppedQueues);
|
|
365
|
+
parts.report.queues = keptQueues.map((queue) => queue.name);
|
|
366
|
+
const seed = config.seedTables.length ? seedText(target, config.seedTables, scope, refusals) : null;
|
|
367
|
+
return { facts, scope, dump, entries, tail, section, seed, carried, report: parts.report, refusals };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/* ---- the seed */
|
|
371
|
+
|
|
372
|
+
function seedFactsSql(tables) {
|
|
373
|
+
const pairs = tables.map(([schema, table]) => `(${literal(schema)}, ${literal(table)})`).join(', ');
|
|
374
|
+
return `select coalesce(json_agg(json_build_object('schema', n.nspname, 'table', c.relname,
|
|
375
|
+
'unique', exists (select 1 from pg_catalog.pg_index i where i.indrelid = c.oid and i.indisunique and i.indpred is null),
|
|
376
|
+
'references', (select coalesce(jsonb_agg(distinct jsonb_build_array(rn.nspname, rc.relname)), '[]') from pg_catalog.pg_constraint f
|
|
377
|
+
join pg_catalog.pg_class rc on rc.oid = f.confrelid join pg_catalog.pg_namespace rn on rn.oid = rc.relnamespace where f.conrelid = c.oid and f.contype = 'f' and f.confrelid <> c.oid),
|
|
378
|
+
'columns', (select json_agg(json_build_object('name', a.attname, 'identity', a.attidentity, 'sequence', pg_catalog.pg_get_serial_sequence(pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(c.relname), a.attname) is not null)
|
|
379
|
+
order by a.attnum) from pg_catalog.pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped and a.attgenerated = '')) order by n.nspname, c.relname), '[]')
|
|
380
|
+
from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
381
|
+
where c.relkind in ('r', 'p') and (n.nspname, c.relname) in (${pairs})`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Seed tables in foreign-key order (a referenced table first), ties by name; a cycle among them is refused. */
|
|
385
|
+
function seedOrder(tables, refusals) {
|
|
386
|
+
const name = (table) => `${table.schema}.${table.table}`;
|
|
387
|
+
const pending = new Map(tables.map((table) => [name(table), table]));
|
|
388
|
+
const order = [];
|
|
389
|
+
while (pending.size) {
|
|
390
|
+
const ready = sorted([...pending.keys()].filter((key) => pending.get(key).references.every(([schema, table]) => !pending.has(`${schema}.${table}`) || `${schema}.${table}` === key)));
|
|
391
|
+
if (!ready.length) { refusals.push(`seed tables reference each other in a cycle: ${sorted(pending.keys()).join(', ')}`); return order; }
|
|
392
|
+
for (const key of ready) { order.push(pending.get(key)); pending.delete(key); }
|
|
393
|
+
}
|
|
394
|
+
return order;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function seedText(target, requested, scope, refusals) {
|
|
398
|
+
const pairs = requested.map((name) => (name.includes('.') ? name.split('.') : ['public', name]));
|
|
399
|
+
const facts = queryJson(target, seedFactsSql(pairs));
|
|
400
|
+
const found = new Set(facts.map((table) => `${table.schema}.${table.table}`));
|
|
401
|
+
for (const [schema, table] of pairs) {
|
|
402
|
+
if (!found.has(`${schema}.${table}`)) refusals.push(`seed table ${schema}.${table} does not exist in the source`);
|
|
403
|
+
else if (!scope.appSchemas.has(schema)) refusals.push(`seed table ${schema}.${table} is outside the app schemas (${sorted(scope.appSchemas).join(', ')})`);
|
|
404
|
+
}
|
|
405
|
+
for (const table of facts.filter((item) => !item.unique)) refusals.push(`seed table ${table.schema}.${table.table} has no primary key or unique constraint, so its rows cannot be seeded idempotently`);
|
|
406
|
+
const blocks = seedOrder(facts, refusals).map((table) => {
|
|
407
|
+
const qualified = `${ident(table.schema)}.${ident(table.table)}`;
|
|
408
|
+
const columns = table.columns.map((column) => ident(column.name)).join(', ');
|
|
409
|
+
const overriding = table.columns.some((column) => column.identity === 'a') ? 'overriding system value ' : '';
|
|
410
|
+
const values = `pg_catalog.concat_ws(', ', ${table.columns.map((column) => `pg_catalog.quote_nullable(${ident(column.name)})`).join(', ')})`;
|
|
411
|
+
const rows = queryJson(target, `select coalesce(json_agg(line order by line collate "C"), '[]') from (select 'insert into ${qualified.replaceAll("'", "''")} (${columns.replaceAll("'", "''")}) ${overriding}values (' || ${values} || ') on conflict do nothing;' as line from ${qualified}) q`);
|
|
412
|
+
const sequences = table.columns.filter((column) => column.sequence).map((column) => `select pg_catalog.setval(pg_catalog.pg_get_serial_sequence(${literal(qualified)}, ${literal(column.name)}), coalesce(max(${ident(column.name)}), 0) + 1, false) from ${qualified};`);
|
|
413
|
+
return { name: `${table.schema}.${table.table}`, count: rows.length, sql: [`-- ${table.schema}.${table.table}: ${rows.length} row(s)`, ...rows, ...sequences].join('\n') };
|
|
414
|
+
});
|
|
415
|
+
return { counts: blocks.map((block) => [block.name, block.count]), text: `${SEED_HEADER} from the reference rows of ${blocks.map((block) => block.name).join(', ')}; rerun the verb to regenerate.\n${blocks.map((block) => `${block.sql}\n`).join('\n')}` };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* ---- rendering, proof and writing */
|
|
419
|
+
|
|
420
|
+
function baselineText(projection, last, urlEnv) {
|
|
421
|
+
const header = [`-- starci:baseline ${last}`,
|
|
422
|
+
`-- Generated by starci app db baseline --squash from the database ${urlEnv} names: the schema of ${sorted(projection.scope.appSchemas).join(', ')} with its privileges,`,
|
|
423
|
+
`-- without the extensions ${sorted(projection.report.extensions).join(', ') || '(none)'} and the objects that reach them. Roles and database-wide grants live in ${BOOTSTRAP_FILE}.`];
|
|
424
|
+
const tail = projection.tail.length ? [renderEntry({ name: 'pgmq queues and grants on objects outside the app schemas', type: 'ACL', schema: '-', body: projection.tail.join('\n') })] : [];
|
|
425
|
+
return `${header.join('\n')}\n\n${projection.dump.preamble.join('\n')}\n\n${[...projection.entries.map(renderEntry), ...tail].join('')}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The differences between the source's projection and the replay's, as `-`/`+` lines; none means the replay is the source minus the drops. */
|
|
429
|
+
function projectionDiff(source, replay) {
|
|
430
|
+
const lines = [];
|
|
431
|
+
const keyed = (projection) => new Map(projection.entries.map((entry) => [entryKey(entry), entry.body]));
|
|
432
|
+
const [want, have] = [keyed(source), keyed(replay)];
|
|
433
|
+
for (const [key, body] of want) {
|
|
434
|
+
if (!have.has(key)) lines.push(`- baseline ${key}`);
|
|
435
|
+
else if (have.get(key) !== body) lines.push(`~ baseline ${key}: ${firstDifference(body, have.get(key))}`);
|
|
436
|
+
}
|
|
437
|
+
for (const key of have.keys()) if (!want.has(key)) lines.push(`+ baseline ${key}`);
|
|
438
|
+
const compare = (label, left, right) => { if (left !== right) lines.push(`~ ${label}: ${firstDifference(left ?? '', right ?? '')}`); };
|
|
439
|
+
compare('baseline tail grants', source.tail.join('\n'), replay.tail.join('\n'));
|
|
440
|
+
compare(BOOTSTRAP_FILE, source.section, replay.section);
|
|
441
|
+
compare(SEED_FILE, source.seed?.text, replay.seed?.text);
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function firstDifference(left, right) {
|
|
446
|
+
const [want, have] = [left.split('\n'), right.split('\n')];
|
|
447
|
+
const index = want.findIndex((line, at) => line !== have[at]);
|
|
448
|
+
const at = index < 0 ? want.length : index;
|
|
449
|
+
return `line ${at + 1} source ${JSON.stringify(want[at] ?? '(end)')}, replay ${JSON.stringify(have[at] ?? '(end)')}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function replay(scratch, files) {
|
|
453
|
+
const admin = scratchTarget(scratch, 'postgres');
|
|
454
|
+
exec(admin, 'psql', [...psqlArgs(admin), '-c', `create database ${REPLAY_DB}`]);
|
|
455
|
+
const target = scratchTarget(scratch, REPLAY_DB);
|
|
456
|
+
for (const [label, text] of files) {
|
|
457
|
+
try { exec(target, 'psql', [...psqlArgs(target), '-1', '-f', '-'], { input: text }); }
|
|
458
|
+
catch (error) { throw new DbVerbError(`the replay of ${label} failed on ${scratch.image}: ${error.message}`); }
|
|
459
|
+
}
|
|
460
|
+
return target;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function printReport(stdout, projection, config, files) {
|
|
464
|
+
const { report } = projection;
|
|
465
|
+
stdout(`app schemas: ${sorted(projection.scope.appSchemas).join(', ')}\n`);
|
|
466
|
+
stdout(`dropped extensions: ${config.dropped.map((name) => (report.extensions.includes(name) ? name : `${name} (not installed)`)).join(', ')}\n`);
|
|
467
|
+
for (const label of sorted(report.objects)) stdout(`excluded ${label}\n`);
|
|
468
|
+
if (report.queues.length) stdout(`pgmq queues kept: ${report.queues.join(', ')}\n`);
|
|
469
|
+
for (const name of report.droppedQueues) stdout(`dropped pgmq queue ${name}\n`);
|
|
470
|
+
for (const name of sorted(report.eventTriggers)) stdout(`excluded event trigger ${name} (its function is outside the app schemas)\n`);
|
|
471
|
+
for (const [schema, count] of sorted(report.platformSchemas.keys()).map((key) => [key, report.platformSchemas.get(key)])) stdout(`left out ${count} entr${count === 1 ? 'y' : 'ies'} of schema ${schema} (not the app's)\n`);
|
|
472
|
+
if (report.platformStatements) stdout(`left out ${report.platformStatements} grant statement(s) naming platform roles\n`);
|
|
473
|
+
if (report.ownerGrants) stdout(`left out ${report.ownerGrants} grant statement(s) to the migrating role, which owns every replayed object\n`);
|
|
474
|
+
for (const line of report.lostGrants) stdout(`left out a grant to a carried role on a schema the target lacks: ${line}\n`);
|
|
475
|
+
stdout(`bootstrap section: roles ${sorted(projection.carried).join(', ') || '(none)'}; ${projection.section.split('\n').length - 2} line(s)\n`);
|
|
476
|
+
for (const [name, count] of projection.seed?.counts ?? []) stdout(`seed ${name}: ${count} row(s)\n`);
|
|
477
|
+
for (const [file, text] of files) stdout(`${file}: ${text.split('\n').length} line(s)\n`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* `starci app db baseline --squash`: plan is db-baseline-verb.mjs's baselinePlan, connection the source of --url-env, precondition the
|
|
482
|
+
* history check (the source holds exactly the active chain) run through the given runPostgres port, throwing its refusal, and archive
|
|
483
|
+
* the move of the active chain. Dry run by default; `apply` writes the baseline, the bootstrap section and the seed, then archives.
|
|
484
|
+
*/
|
|
485
|
+
export function squashBaseline({ root, opts, plan, urlEnv, connection, commandOf, precondition, managedSchema, archive, stdout }) {
|
|
486
|
+
if (!opts.image && !fs.existsSync(path.join(root, DB_DOCKERFILE))) throw new DbVerbError(`baseline --squash replays on the app's db image: ${DB_DOCKERFILE} is missing (run "npx starci app sync --write") and no --image names one`);
|
|
487
|
+
const bootstrapPath = path.join(root, BOOTSTRAP_FILE);
|
|
488
|
+
if (!fs.existsSync(bootstrapPath)) throw new DbVerbError(`baseline --squash needs ${BOOTSTRAP_FILE}; run "npx starci app sync --write" first`);
|
|
489
|
+
const head = bootstrapSection(fs.readFileSync(bootstrapPath, 'utf8').replace(/\r\n/g, '\n')).head;
|
|
490
|
+
const seedPath = path.join(root, SEED_FILE);
|
|
491
|
+
const seedTables = flagList(opts['seed-tables'], []);
|
|
492
|
+
if (seedTables.length && fs.existsSync(seedPath) && !fs.readFileSync(seedPath, 'utf8').startsWith(SEED_HEADER)) throw new DbVerbError(`${SEED_FILE} exists and was not generated by the squash; fold its rows into the source and remove it, or omit --seed-tables`);
|
|
493
|
+
const config = { dropped: flagList(opts['drop-extension'], DEFAULT_DROPPED_EXTENSIONS), droppedQueues: flagList(opts['drop-pgmq-queue'], []), seedTables, bootstrap: bootstrapDeclares(head), managedSchema };
|
|
494
|
+
const image = opts.image ?? buildImage(root);
|
|
495
|
+
const scratch = startScratch(image);
|
|
496
|
+
try {
|
|
497
|
+
const source = sourceTarget(scratch, connection, `the database ${urlEnv} names`);
|
|
498
|
+
precondition(targetRunner(source, commandOf));
|
|
499
|
+
const projected = project(source, config);
|
|
500
|
+
if (projected.refusals.length) throw new DbVerbError(`baseline --squash cannot carry the source:\n${[...new Set(projected.refusals)].map((line) => ` ${line}`).join('\n')}`);
|
|
501
|
+
const files = [[BOOTSTRAP_FILE, `${head}${projected.section}`], [plan.file, baselineText(projected, plan.last, urlEnv)], ...(projected.seed ? [[SEED_FILE, projected.seed.text]] : [])];
|
|
502
|
+
const replayTarget = replay(scratch, [...files, ...(projected.seed ? [[`${SEED_FILE} (second run)`, projected.seed.text]] : [])]);
|
|
503
|
+
const replayed = project(replayTarget, config);
|
|
504
|
+
const diff = projectionDiff(projected, replayed);
|
|
505
|
+
stdout(`${opts.apply ? 'apply' : 'preview'} squash baseline ${plan.file}; archive ${plan.active.length} migrations; the source is read only\n`);
|
|
506
|
+
printReport(stdout, projected, config, files);
|
|
507
|
+
if (diff.length) throw new DbVerbError(`the replay on ${image} differs from the source minus the dropped extensions:\n${diff.slice(0, DIFF_LIMIT).map((line) => ` ${line}`).join('\n')}${diff.length > DIFF_LIMIT ? `\n ... ${diff.length - DIFF_LIMIT} more` : ''}`);
|
|
508
|
+
stdout(`proof: bootstrap + baseline + seed (twice) replayed on ${image}; schema, grants, roles and seed rows equal the source minus ${config.dropped.join(', ')}\n`);
|
|
509
|
+
if (!opts.apply) return 0;
|
|
510
|
+
for (const [file, text] of files) fs.writeFileSync(path.join(root, file), text, { flag: file === plan.file ? 'wx' : 'w' });
|
|
511
|
+
archive();
|
|
512
|
+
return 0;
|
|
513
|
+
} finally { scratch.stop(); }
|
|
514
|
+
}
|
|
@@ -69,18 +69,6 @@ function extraPackageProblems(side, entries) {
|
|
|
69
69
|
return [];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/** `sides.be.queueOutbox`: the app's own queue outbox ({table, enqueue, queueParameter}), which a pg_cron tick may enqueue through (R258); it needs the redis backing the relay hands rows to. */
|
|
73
|
-
function queueOutboxProblems(side, value) {
|
|
74
|
-
const declared = value.queueOutbox;
|
|
75
|
-
if (declared === undefined) return [];
|
|
76
|
-
if (side !== 'be') return ['queueOutbox belongs to the be side'];
|
|
77
|
-
const name = (item) => typeof item === 'string' && /^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/.test(item);
|
|
78
|
-
const bad = [];
|
|
79
|
-
if (!isPlainObject(declared) || Object.keys(declared).sort().join() !== 'enqueue,queueParameter,table' || !name(declared.table) || !name(declared.enqueue) || !/^[a-z_][a-z0-9_]*$/.test(String(declared.queueParameter))) bad.push('sides.be.queueOutbox must be {table: <schema>.<table>, enqueue: <schema>.<function>, queueParameter: <parameter name>}');
|
|
80
|
-
if (!(value.backing ?? []).includes('redis')) bad.push('sides.be.queueOutbox needs sides.be.backing to declare redis, which the relay hands its rows to');
|
|
81
|
-
return bad;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
72
|
function grammarProblems(side, grammar) {
|
|
85
73
|
if (grammar === undefined) return [];
|
|
86
74
|
if (side !== 'fe') return ['grammar belongs to the fe side'];
|
|
@@ -91,8 +79,8 @@ function declarationSideProblems(side, value, names) {
|
|
|
91
79
|
const bad = [];
|
|
92
80
|
const at = `sides.${side}`;
|
|
93
81
|
if (!isPlainObject(value)) return [`${at} must be an object`];
|
|
94
|
-
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', '
|
|
95
|
-
if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions))) bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions with unique names`);
|
|
82
|
+
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
83
|
+
if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions, { bare: true }))) bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions, each schema-qualified (schema.name, never pg_catalog) or a bare pg built-in of the canon's list, with unique names`);
|
|
96
84
|
bad.push(...appShapeProblems(value.apps, at, side, names));
|
|
97
85
|
if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
98
86
|
if (value.backing !== undefined && side !== 'be') bad.push('backing belongs to the be side');
|
|
@@ -100,7 +88,7 @@ function declarationSideProblems(side, value, names) {
|
|
|
100
88
|
bad.push(...patternShapeProblems(value, at, NAME), ...kindShapeProblems(value, at, NAME));
|
|
101
89
|
if (isBadUniqueList(value.reads, (read) => typeof read === 'string' && read.length > 0)) bad.push(`${at}.reads must be a unique list of paths`);
|
|
102
90
|
if (value.connections !== undefined) bad.push(...connectionProblems(side, value));
|
|
103
|
-
bad.push(...extraPackageProblems(side, value.extraPackages), ...grammarProblems(side, value.grammar)
|
|
91
|
+
bad.push(...extraPackageProblems(side, value.extraPackages), ...grammarProblems(side, value.grammar));
|
|
104
92
|
return bad;
|
|
105
93
|
}
|
|
106
94
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Runtime examples keep names only; checks receive disposable copies with fresh sealed members.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import { memberFile } from '../lib/sealed-member.mjs';
|
|
3
4
|
import { safeRemove } from '../api/fs/safe-remove.mjs';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -20,7 +21,7 @@ import { generateAppIdentity } from './secret-bootstrap-crypto.mjs';
|
|
|
20
21
|
import { declaredSealedMembers, materializeDeclaredPlaceholders } from './sealed-members.mjs';
|
|
21
22
|
|
|
22
23
|
const SKIPPED = new Set(['.git', '.claude', '.secrets', '.aws', '.codex', '.agents', 'node_modules', '.next', '.turbo', 'dist', 'coverage']);
|
|
23
|
-
const PATH_RULE = String.raw`\.starcistacks[/\\][^/\\]+[/\\]secrets[/\\][^/\\]+\.enc
|
|
24
|
+
const PATH_RULE = String.raw`\.starcistacks[/\\][^/\\]+[/\\]secrets[/\\][^/\\]+\.enc(?:\.yaml)?$`;
|
|
24
25
|
|
|
25
26
|
function writePublicRecipient(repoRoot, recipient) {
|
|
26
27
|
fs.writeFileSync(path.join(repoRoot, '.sops.yaml'), stringifyYaml({
|
|
@@ -38,7 +39,7 @@ function writePublicRecipient(repoRoot, recipient) {
|
|
|
38
39
|
export async function materializeExample(repoRoot, { createIdentity = generateAppIdentity, sops } = {}) {
|
|
39
40
|
const declared = declaredSealedMembers(repoRoot);
|
|
40
41
|
if (!declared.length) return { members: [] };
|
|
41
|
-
if (declared.some(({ env, slug }) => fs.existsSync(path.join(repoRoot, '.starcistacks', env, 'secrets', slug
|
|
42
|
+
if (declared.some(({ env, slug }) => fs.existsSync(memberFile(path.join(repoRoot, '.starcistacks', env, 'secrets'), slug))))
|
|
42
43
|
throw new Error('example materialization requires a disposable copy without existing sealed members');
|
|
43
44
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-example-age-'));
|
|
44
45
|
try {
|
|
@@ -116,7 +117,7 @@ function safeExampleSource(source, { preserveCoverage = false } = {}) {
|
|
|
116
117
|
const stat = fs.lstatSync(source);
|
|
117
118
|
if (stat.isSymbolicLink()) return false;
|
|
118
119
|
if (SKIPPED.has(name) && !(preserveCoverage && name === 'coverage')) return false;
|
|
119
|
-
if (
|
|
120
|
+
if (/\.enc(?:\.yaml)?$/u.test(name) || /\.(?:agekey|pem|key)$/iu.test(name)) return false;
|
|
120
121
|
if (['secret.env', 'secrets.env', 'keys.txt'].includes(name)) return false;
|
|
121
122
|
if (name === '.env.example' || name.endsWith('.env.example')) return true;
|
|
122
123
|
if (name === '.env' || name.startsWith('.env.') || name.endsWith('.env')) return false;
|