pgserve 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/pgserve-wrapper.cjs +9 -4
  2. package/bin/postgres-server.js +170 -631
  3. package/config/logrotate.d/pgserve +47 -0
  4. package/config/pgaudit.conf +31 -0
  5. package/package.json +3 -2
  6. package/scripts/audit-redaction-lint.js +349 -0
  7. package/scripts/test-npx.sh +32 -10
  8. package/src/audit/audit.js +134 -0
  9. package/src/cli-install.cjs +340 -100
  10. package/src/commands/uninstall.js +241 -0
  11. package/src/commands/verify.js +360 -0
  12. package/src/cosign/cache-token.js +328 -0
  13. package/src/cosign/schema.js +97 -0
  14. package/src/cosign/trust-list.js +81 -0
  15. package/src/cosign/verify-binary.js +277 -0
  16. package/src/index.js +11 -44
  17. package/src/lib/admin-json.js +202 -0
  18. package/src/lib/pm2-args.js +119 -0
  19. package/src/lib/runtime-json.js +181 -0
  20. package/src/lib/socket-dir.js +69 -0
  21. package/src/postgres.js +64 -5
  22. package/src/upgrade/index.js +5 -0
  23. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
  24. package/src/admin-client.js +0 -223
  25. package/src/audit.js +0 -168
  26. package/src/cluster.js +0 -654
  27. package/src/control-db.js +0 -330
  28. package/src/daemon-control.js +0 -468
  29. package/src/daemon-shared.js +0 -18
  30. package/src/daemon-tcp.js +0 -297
  31. package/src/daemon.js +0 -709
  32. package/src/dashboard.js +0 -217
  33. package/src/fingerprint.js +0 -479
  34. package/src/gc.js +0 -351
  35. package/src/pg-wire.js +0 -869
  36. package/src/protocol.js +0 -389
  37. package/src/restore.js +0 -574
  38. package/src/router.js +0 -546
  39. package/src/sdk.js +0 -137
  40. package/src/stats-collector.js +0 -453
  41. package/src/stats-dashboard.js +0 -401
  42. package/src/sync.js +0 -335
  43. package/src/tenancy.js +0 -75
  44. package/src/tokens.js +0 -102
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `<socketDir>/runtime.json` — runtime discovery file owned by the
3
+ * `autopg serve` postmaster wrapper (cutover wish G19).
4
+ *
5
+ * Schema:
6
+ * {
7
+ * socketDir: "<absolute path>",
8
+ * port: <integer>, // postgres TCP port
9
+ * pid: <integer>, // postmaster pid
10
+ * autopgPid: <integer>, // `autopg serve` wrapper pid
11
+ * schemaVersion: 1
12
+ * }
13
+ *
14
+ * Cohort contract — there is **no `supervisor` field**. The supervisor
15
+ * (pm2 / systemd-user / launchd / external) is recorded once at install
16
+ * time in `~/.autopg/admin.json`. Mixing the two creates a synchronization
17
+ * problem (which file is authoritative when the postmaster restarts under
18
+ * a new pid?). `writeRuntimeJson()` rejects records carrying a `supervisor`
19
+ * key so the contract can't drift via a future copy-paste.
20
+ *
21
+ * Lifecycle:
22
+ * - `writeRuntimeJson()` after the postmaster greets healthy.
23
+ * - `clearRuntimeJson()` on graceful shutdown (SIGTERM / SIGINT).
24
+ * - On crash the file is left in place. Consumers detect a stale record
25
+ * via `process.kill(record.autopgPid, 0)` (no-signal probe).
26
+ *
27
+ * Atomic semantics: write to `<file>.tmp.<pid>`, then `fs.renameSync()`.
28
+ * Mode 0644 so unprivileged peers can `cat <socketDir>/runtime.json`
29
+ * without sudo — the file carries no secrets, only public discovery info.
30
+ */
31
+
32
+ import fs from 'fs';
33
+ import path from 'path';
34
+
35
+ export const RUNTIME_FILE_NAME = 'runtime.json';
36
+ export const RUNTIME_FILE_MODE = 0o644;
37
+ export const RUNTIME_SCHEMA_VERSION = 1;
38
+
39
+ export function getRuntimeFilePath(socketDir) {
40
+ if (typeof socketDir !== 'string' || socketDir.length === 0) {
41
+ throw new TypeError('runtime-json: socketDir must be a non-empty string');
42
+ }
43
+ return path.join(socketDir, RUNTIME_FILE_NAME);
44
+ }
45
+
46
+ function isPlainObject(v) {
47
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
48
+ }
49
+
50
+ function validateRecord(record) {
51
+ if (!isPlainObject(record)) {
52
+ throw new TypeError('runtime-json: record must be an object');
53
+ }
54
+ if (typeof record.socketDir !== 'string' || record.socketDir.length === 0) {
55
+ throw new TypeError('runtime-json: socketDir must be a non-empty string');
56
+ }
57
+ if (!Number.isInteger(record.port) || record.port < 1 || record.port > 65535) {
58
+ throw new TypeError(`runtime-json: port must be an integer in [1, 65535]; got ${record.port}`);
59
+ }
60
+ if (!Number.isInteger(record.pid) || record.pid < 1) {
61
+ throw new TypeError(`runtime-json: pid must be a positive integer; got ${record.pid}`);
62
+ }
63
+ if (!Number.isInteger(record.autopgPid) || record.autopgPid < 1) {
64
+ throw new TypeError(`runtime-json: autopgPid must be a positive integer; got ${record.autopgPid}`);
65
+ }
66
+ if (Object.prototype.hasOwnProperty.call(record, 'supervisor')) {
67
+ throw new TypeError(
68
+ 'runtime-json: refusing to write `supervisor` into runtime.json — that field '
69
+ + 'lives only in `~/.autopg/admin.json` (cohort contract).',
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read `<socketDir>/runtime.json`. Returns the parsed object on success,
76
+ * `null` when the file is missing or unreadable. Never throws — callers
77
+ * treat "missing" and "broken" identically and fall back to admin.json.
78
+ */
79
+ export function readRuntimeJson(socketDir) {
80
+ let file;
81
+ try {
82
+ file = getRuntimeFilePath(socketDir);
83
+ } catch {
84
+ return null;
85
+ }
86
+ let raw;
87
+ try {
88
+ raw = fs.readFileSync(file, 'utf8');
89
+ } catch {
90
+ return null;
91
+ }
92
+ try {
93
+ const parsed = JSON.parse(raw);
94
+ return isPlainObject(parsed) ? parsed : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Atomic write of the runtime discovery record. Validates shape, refuses
102
+ * a `supervisor` key (the cohort contract — that field belongs in
103
+ * admin.json), ensures the parent directory exists, and stamps
104
+ * `schemaVersion: 1` if the caller didn't.
105
+ */
106
+ export function writeRuntimeJson(input = {}) {
107
+ if (!isPlainObject(input)) {
108
+ throw new TypeError('runtime-json: writeRuntimeJson expects an object argument');
109
+ }
110
+ // Reject `supervisor` from the input directly — destructuring would
111
+ // silently drop it and that's a contract failure we want to surface.
112
+ if (Object.prototype.hasOwnProperty.call(input, 'supervisor')) {
113
+ throw new TypeError(
114
+ 'runtime-json: refusing to write `supervisor` into runtime.json — that field '
115
+ + 'lives only in `~/.autopg/admin.json` (cohort contract).',
116
+ );
117
+ }
118
+ const { socketDir, port, pid, autopgPid, schemaVersion = RUNTIME_SCHEMA_VERSION } = input;
119
+ const record = { socketDir, port, pid, autopgPid, schemaVersion };
120
+ validateRecord(record);
121
+
122
+ if (!fs.existsSync(socketDir)) {
123
+ fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
124
+ }
125
+
126
+ const file = getRuntimeFilePath(socketDir);
127
+ const tmp = `${file}.tmp.${process.pid}`;
128
+ const json = `${JSON.stringify(record, null, 2)}\n`;
129
+ fs.writeFileSync(tmp, json, { mode: RUNTIME_FILE_MODE });
130
+ fs.renameSync(tmp, file);
131
+ fs.chmodSync(file, RUNTIME_FILE_MODE);
132
+ return record;
133
+ }
134
+
135
+ /**
136
+ * Best-effort delete of `<socketDir>/runtime.json`. Used during graceful
137
+ * shutdown so consumers immediately observe "no live postmaster" instead
138
+ * of seeing a stale-pid record they have to probe with `process.kill()`.
139
+ *
140
+ * Returns `true` when the file was removed, `false` when it was already
141
+ * gone or removal failed. Never throws — graceful shutdown must not
142
+ * regress because of a permission glitch on the runtime file.
143
+ */
144
+ export function clearRuntimeJson(socketDir) {
145
+ let file;
146
+ try {
147
+ file = getRuntimeFilePath(socketDir);
148
+ } catch {
149
+ return false;
150
+ }
151
+ try {
152
+ fs.unlinkSync(file);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Returns true when the runtime record points at a process that's alive
161
+ * on this host. `process.kill(pid, 0)` is a no-signal probe — it raises
162
+ * ESRCH when the pid is gone and EPERM when we can't signal a foreign
163
+ * uid (still alive, just not ours). Treat EPERM as "alive" so cross-uid
164
+ * supervisors (e.g. an operator probing a system-installed pgserve)
165
+ * don't false-negative.
166
+ */
167
+ export function isLiveRuntime(record) {
168
+ if (!isPlainObject(record)) return false;
169
+ // process.kill(pid, 0) accepts a process group sentinel for pid <= 0
170
+ // (pid 0 = caller's group, pid -1 = every signalable process). Neither
171
+ // is a meaningful "live postmaster" answer, so reject anything below 1
172
+ // before we touch the syscall.
173
+ const pid = record.autopgPid;
174
+ if (!Number.isInteger(pid) || pid < 1) return false;
175
+ try {
176
+ process.kill(pid, 0);
177
+ return true;
178
+ } catch (err) {
179
+ return err && err.code === 'EPERM';
180
+ }
181
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Canonical pgserve socket-dir resolver.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
5
+ *
6
+ * Postgres backend listens on a Unix socket inside this directory plus TCP
7
+ * 5432. The directory is also where `pgserve` records its `.s.PGSQL.<port>`
8
+ * socket file so off-the-shelf libpq clients connecting via
9
+ * `psql -h <socketDir>` (no `-p`) succeed against the systemd / freedesktop
10
+ * convention path. CI runners and minimal containers without
11
+ * `$XDG_RUNTIME_DIR` get `/tmp/pgserve` as the documented fallback.
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+
17
+ export const SOCKET_DIR_NAME = 'pgserve';
18
+ export const SOCKET_DIR_MODE = 0o700;
19
+
20
+ /**
21
+ * Resolve the canonical socket directory.
22
+ *
23
+ * Preferred: `$XDG_RUNTIME_DIR/pgserve` (systemd / freedesktop convention).
24
+ * Fallback: `/tmp/pgserve` (CI runners and minimal containers without XDG).
25
+ *
26
+ * Pure function — does not touch the filesystem. Use `ensureSocketDir()`
27
+ * to create the directory with the correct permissions.
28
+ */
29
+ export function resolveSocketDir() {
30
+ const xdg = process.env.XDG_RUNTIME_DIR;
31
+ const base = xdg && xdg.length > 0 ? xdg : '/tmp';
32
+ return path.join(base, SOCKET_DIR_NAME);
33
+ }
34
+
35
+ /**
36
+ * Ensure the socket directory exists with mode 0700 and is writable.
37
+ *
38
+ * Returns the resolved path. Throws if the directory exists but is not a
39
+ * directory, or if creation fails for any reason other than EEXIST.
40
+ *
41
+ * The mode is enforced via fs.chmodSync after creation — `mkdirSync(mode)`
42
+ * is honored only when the directory does not already exist.
43
+ */
44
+ export function ensureSocketDir(dir = resolveSocketDir()) {
45
+ fs.mkdirSync(dir, { recursive: true, mode: SOCKET_DIR_MODE });
46
+ fs.chmodSync(dir, SOCKET_DIR_MODE);
47
+
48
+ const stat = fs.statSync(dir);
49
+ if (!stat.isDirectory()) {
50
+ throw new Error(
51
+ `pgserve: socket dir path exists but is not a directory: ${dir}`,
52
+ );
53
+ }
54
+
55
+ // Validate writability by touching a sentinel file. Avoids surfacing the
56
+ // real-world failure ("postgres can't bind socket") at the postmaster
57
+ // boot step where the diagnostic is much harder to trace.
58
+ const probe = path.join(dir, `.writable-${process.pid}-${Date.now()}`);
59
+ try {
60
+ fs.writeFileSync(probe, '');
61
+ fs.unlinkSync(probe);
62
+ } catch (err) {
63
+ throw new Error(
64
+ `pgserve: socket dir not writable (${dir}): ${err.message}`,
65
+ );
66
+ }
67
+
68
+ return dir;
69
+ }
package/src/postgres.js CHANGED
@@ -548,6 +548,15 @@ export class PostgresManager extends EventEmitter {
548
548
  this.binaries = null;
549
549
  this.creatingDatabases = new Map(); // Track in-progress creations
550
550
  this.socketDir = null; // Unix socket directory for faster local connections
551
+ // pgserve singleton (v2.4): callers that own the socket directory (e.g.
552
+ // `pgserve postmaster` invoked under pm2 or a systemd-user unit) pass an
553
+ // explicit, externally-managed path so libpq peers connecting via
554
+ // `psql -h $XDG_RUNTIME_DIR/pgserve` reach a stable, well-known socket.
555
+ // When unset we fall back to the legacy per-pid `os.tmpdir()` path so
556
+ // foreground / daemon / cluster modes keep working unchanged.
557
+ this.explicitSocketDir = typeof options.socketDir === 'string' && options.socketDir.length > 0
558
+ ? options.socketDir
559
+ : null;
551
560
  this.adminPool = null; // Connection pool for database admin operations
552
561
  this.useRam = options.useRam || false; // Use /dev/shm for true RAM storage (Linux only)
553
562
  this.isTrueRam = false; // Tracks if we're actually using RAM storage
@@ -634,9 +643,24 @@ export class PostgresManager extends EventEmitter {
634
643
 
635
644
  // Create Unix socket directory (Linux/macOS only, Windows uses TCP)
636
645
  if (os.platform() !== 'win32') {
637
- this.socketDir = path.join(os.tmpdir(), `pgserve-sock-${process.pid}-${Date.now()}`);
638
- if (!fs.existsSync(this.socketDir)) {
639
- fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
646
+ if (this.explicitSocketDir) {
647
+ // pgserve singleton (v2.4): operator-supplied socket dir is created
648
+ // and chmoded by the install path (`pgserve install`); we only
649
+ // refuse to start when it doesn't exist so the operator's intent
650
+ // surfaces cleanly instead of postgres bailing on bind() with a
651
+ // cryptic libpq error.
652
+ if (!fs.existsSync(this.explicitSocketDir)) {
653
+ throw new Error(
654
+ `pgserve: socketDir does not exist: ${this.explicitSocketDir}. `
655
+ + `Run \`pgserve install\` to create it (or pass --socket-dir <existing-dir>).`,
656
+ );
657
+ }
658
+ this.socketDir = this.explicitSocketDir;
659
+ } else {
660
+ this.socketDir = path.join(os.tmpdir(), `pgserve-sock-${process.pid}-${Date.now()}`);
661
+ if (!fs.existsSync(this.socketDir)) {
662
+ fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
663
+ }
640
664
  }
641
665
  }
642
666
 
@@ -894,6 +918,33 @@ export class PostgresManager extends EventEmitter {
894
918
  ...gucArgs,
895
919
  ];
896
920
 
921
+ // pgserve singleton (v2.4): the bun-side audit-log writer is gone.
922
+ // Audit moves to postgres-native logging — `pgaudit` if the .so is
923
+ // bundled with the embedded postgres distribution, `log_statement`
924
+ // as a portable fallback otherwise. The wish ships the contract;
925
+ // shipping the pgaudit binary is a separate cohort task. Either way
926
+ // pm2 captures stderr to `<configDir>/logs/autopg-server-error.log`
927
+ // and `logrotate.d/pgserve` rotates it.
928
+ const pgauditPath = path.join(this.binaries.libDir, '..', 'lib', 'postgresql', 'pgaudit.so');
929
+ if (fs.existsSync(pgauditPath)) {
930
+ pgArgs.push('-c', 'shared_preload_libraries=pgaudit');
931
+ pgArgs.push('-c', 'pgaudit.log=all');
932
+ pgArgs.push('-c', 'pgaudit.log_catalog=off');
933
+ appliedGucs.shared_preload_libraries = 'pgaudit';
934
+ appliedGucs['pgaudit.log'] = 'all';
935
+ } else {
936
+ // Fallback: postgres-native log_statement='all' captures every
937
+ // query without the pgaudit-specific class taxonomy. Operators
938
+ // get an audit trail today; the cohort can swap to pgaudit later
939
+ // by dropping the .so into the embedded-postgres bundle.
940
+ pgArgs.push('-c', 'log_statement=all');
941
+ appliedGucs.log_statement = 'all';
942
+ this.logger?.warn(
943
+ { pgauditPath },
944
+ 'pgaudit.so not found in embedded postgres lib dir; falling back to log_statement=all for audit',
945
+ );
946
+ }
947
+
897
948
  // Enable Unix socket for faster local connections (Linux/macOS)
898
949
  // Windows falls back to TCP only
899
950
  if (this.socketDir) {
@@ -1574,8 +1625,16 @@ export class PostgresManager extends EventEmitter {
1574
1625
  }
1575
1626
  }
1576
1627
 
1577
- // Clean up socket directory
1578
- if (this.socketDir) {
1628
+ // Clean up socket directory.
1629
+ // pgserve singleton (v2.4): when the caller supplied an explicit
1630
+ // socket dir (operator-owned canonical path under
1631
+ // `$XDG_RUNTIME_DIR/pgserve` or `/tmp/pgserve`), the install path
1632
+ // owns the directory's lifecycle — postgres unlinks its own
1633
+ // `.s.PGSQL.<port>` + `.lock` files on graceful shutdown, and
1634
+ // tearing the directory down here would race with operator tooling
1635
+ // (pm2 restarts, doctor --fix, etc.). Only sweep the legacy
1636
+ // per-pid `os.tmpdir()/pgserve-sock-*` form we generated ourselves.
1637
+ if (this.socketDir && !this.explicitSocketDir) {
1579
1638
  try {
1580
1639
  fs.rmSync(this.socketDir, { recursive: true, force: true });
1581
1640
  this.logger.debug({ socketDir: this.socketDir }, 'Cleaned up socket directory');
@@ -20,11 +20,16 @@ import * as plpgsqlResolve from './steps/plpgsql-resolve.js';
20
20
  import * as envRefresh from './steps/env-refresh.js';
21
21
  import * as consumerSignal from './steps/consumer-signal.js';
22
22
  import * as healthValidate from './steps/health-validate.js';
23
+ import * as cosignMetaMigration from './steps/cosign-meta-migration.js';
23
24
 
24
25
  export const STEPS = [
25
26
  { name: 'port-reconcile', impl: portReconcile },
26
27
  { name: 'binary-cache-flush', impl: binaryCacheFlush },
27
28
  { name: 'plpgsql-resolve', impl: plpgsqlResolve },
29
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
30
+ // Adds the additive `verified_*` columns to `pgserve_meta`. Runs after
31
+ // plpgsql-resolve so the extension is available; idempotent per-DB.
32
+ { name: 'cosign-meta-migration', impl: cosignMetaMigration },
28
33
  { name: 'env-refresh', impl: envRefresh },
29
34
  { name: 'consumer-signal', impl: consumerSignal },
30
35
  { name: 'health-validate', impl: healthValidate },
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Step — pgserve_meta cosign columns (additive).
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
5
+ *
6
+ * Adds `verified_at`, `verified_identity`, `verified_tier` to every
7
+ * `pgserve_meta` table the upgrade step finds. The schema delta is
8
+ * additive (Decision P4) — pre-cosign rows continue to work, columns are
9
+ * NULL until Group 3's `pgserve provision` writes them.
10
+ *
11
+ * Runs idempotently: `ADD COLUMN IF NOT EXISTS` plus a guarded DO-block
12
+ * for the CHECK constraint. Re-running on an already-migrated host is a
13
+ * no-op. If `pgserve_meta` does not exist (fresh install before G3 has
14
+ * provisioned anything) the step is a SKIP.
15
+ */
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ import { getMigrationStatements } from '../../cosign/schema.js';
20
+
21
+ export const name = 'cosign-meta-migration';
22
+ const CANONICAL_PORT = 5432;
23
+ const SYSTEM_DBS = new Set(['template0', 'template1']);
24
+
25
+ // PR #79 fix: previous implementation used execSync with a template string +
26
+ // JSON.stringify(sql). The migration SQL contains `DO $$ ... $$` blocks; bash
27
+ // expands `$$` to its PID, corrupting the SQL before psql sees it. Switch to
28
+ // spawnSync (shell:false) with the SQL fed through stdin — no shell parsing,
29
+ // no expansion, no injection surface.
30
+ function pgQuery({ db, sql, captureStdout = false, port = CANONICAL_PORT }) {
31
+ const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
32
+ const result = spawnSync(
33
+ 'psql',
34
+ ['-h', '127.0.0.1', '-p', String(port), '-U', 'postgres', '-d', db, '-At', '-f', '-'],
35
+ { env, input: sql, stdio: ['pipe', 'pipe', 'pipe'] }
36
+ );
37
+ if (result.status !== 0) {
38
+ const stderr = (result.stderr || Buffer.from('')).toString();
39
+ const err = new Error(`psql exited ${result.status}: ${stderr.trim()}`);
40
+ err.status = result.status;
41
+ err.stderr = stderr;
42
+ throw err;
43
+ }
44
+ const stdout = (result.stdout || Buffer.from('')).toString();
45
+ return captureStdout ? stdout.trim() : stdout;
46
+ }
47
+
48
+ function listUserDbs() {
49
+ const out = pgQuery({
50
+ db: 'postgres',
51
+ sql: "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname",
52
+ captureStdout: true,
53
+ });
54
+ return out ? out.split('\n').filter(Boolean).filter((d) => !SYSTEM_DBS.has(d)) : [];
55
+ }
56
+
57
+ function pgserveMetaExists(db) {
58
+ const out = pgQuery({
59
+ db,
60
+ sql: "SELECT to_regclass('public.pgserve_meta') IS NOT NULL",
61
+ captureStdout: true,
62
+ });
63
+ return out === 't' || out === 'true';
64
+ }
65
+
66
+ export async function plan() {
67
+ let dbs;
68
+ try {
69
+ dbs = listUserDbs();
70
+ } catch (err) {
71
+ return `cannot enumerate DBs: ${err.message}`;
72
+ }
73
+ if (dbs.length === 0) return 'no user DBs — skip';
74
+ const targets = [];
75
+ for (const db of dbs) {
76
+ try {
77
+ if (pgserveMetaExists(db)) targets.push(db);
78
+ } catch {
79
+ // Skip silently — DB might be unreachable, listed but not connectable.
80
+ }
81
+ }
82
+ if (targets.length === 0) return 'no DB hosts pgserve_meta yet — skip';
83
+ return `would apply additive cosign columns to pgserve_meta in: ${targets.join(', ')}`;
84
+ }
85
+
86
+ export async function execute({ log, warn }) {
87
+ let dbs;
88
+ try {
89
+ dbs = listUserDbs();
90
+ } catch (err) {
91
+ return { status: 'FAIL', detail: `cannot enumerate DBs: ${err.message}` };
92
+ }
93
+ if (dbs.length === 0) return { status: 'SKIP', detail: 'no user DBs to migrate' };
94
+
95
+ const statements = getMigrationStatements();
96
+ let migrated = 0;
97
+ let skipped = 0;
98
+ for (const db of dbs) {
99
+ let exists;
100
+ try {
101
+ exists = pgserveMetaExists(db);
102
+ } catch (err) {
103
+ warn(`[cosign-meta-migration] ${db}: cannot probe pgserve_meta — ${err.message}`);
104
+ skipped++;
105
+ continue;
106
+ }
107
+ if (!exists) {
108
+ skipped++;
109
+ continue;
110
+ }
111
+ try {
112
+ for (const sql of statements) {
113
+ pgQuery({ db, sql });
114
+ }
115
+ log(`[cosign-meta-migration] ${db}: applied ${statements.length} idempotent statement(s)`);
116
+ migrated++;
117
+ } catch (err) {
118
+ warn(`[cosign-meta-migration] ${db}: failed — ${err.message}`);
119
+ skipped++;
120
+ }
121
+ }
122
+ return { status: 'OK', detail: `migrated ${migrated} DB(s), skipped ${skipped}` };
123
+ }