pgserve 2.4.0 → 2.6.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 (36) hide show
  1. package/README.md +5 -8
  2. package/bin/pgserve-wrapper.cjs +23 -0
  3. package/bin/postgres-server.js +28 -0
  4. package/package.json +2 -1
  5. package/scripts/aggregate-manifest.sh +184 -0
  6. package/scripts/assemble-tarball.sh +191 -0
  7. package/scripts/audit-redaction-lint.js +349 -0
  8. package/scripts/build-binary.sh +213 -0
  9. package/scripts/fetch-postgres-bins.sh +234 -0
  10. package/scripts/postinstall.cjs +102 -18
  11. package/scripts/verify-published-artifacts.sh +211 -0
  12. package/src/audit/audit.js +134 -0
  13. package/src/cli-install.cjs +258 -26
  14. package/src/commands/doctor.js +465 -0
  15. package/src/commands/gc.js +276 -0
  16. package/src/commands/provision.js +396 -0
  17. package/src/commands/trust.js +187 -0
  18. package/src/commands/verify.js +360 -0
  19. package/src/cosign/cache-token.js +328 -0
  20. package/src/cosign/schema.js +97 -0
  21. package/src/cosign/trust-list.js +81 -0
  22. package/src/cosign/trust-store.js +250 -0
  23. package/src/cosign/verify-binary.js +277 -0
  24. package/src/gc/audit-log.js +150 -0
  25. package/src/gc/orphan-detection.js +190 -0
  26. package/src/gc/queries.js +193 -0
  27. package/src/lib/pg-query.js +145 -0
  28. package/src/lib/runtime-json.js +181 -0
  29. package/src/provision/advisory-lock.js +91 -0
  30. package/src/provision/db-naming.js +130 -0
  31. package/src/provision/fingerprint.js +144 -0
  32. package/src/schema/pgserve-meta.js +120 -0
  33. package/src/security/blocked-versions.js +103 -0
  34. package/src/upgrade/index.js +5 -0
  35. package/src/upgrade/steps/binary-cache-flush.js +2 -2
  36. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `pgserve gc` — sweep orphaned databases. Singleton G3 verb 3.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * The 240-orphan-disease fix from the v1.0 mission. Composes:
7
+ * - src/gc/orphan-detection.js#classifyOrphans — pure classifier
8
+ * - src/gc/audit-log.js#writeGcAudit — JSON-lines audit
9
+ * - src/gc/queries.js — gc-specific psql queries
10
+ * - src/lib/admin-json.js — port discovery
11
+ *
12
+ * Defaults:
13
+ * - `--dry-run` is the DEFAULT. gc does NOT drop anything unless
14
+ * `--apply` is passed. Logs the intent to the audit file with
15
+ * `dryRun: true` so an operator can audit what *would* have been
16
+ * swept before flipping the switch.
17
+ * - `--apply` actually drops. Each drop is audited as `action: drop`.
18
+ * - `--stale-after-days <N>` overrides the 30d default for the
19
+ * `idle_stale` orphan signal.
20
+ * - `--json` emits a single JSON summary at the end (machine-
21
+ * readable; the per-event audit log is always written regardless).
22
+ *
23
+ * Exit codes:
24
+ * 0 success — partition computed, drops (or dry-run intent)
25
+ * audited.
26
+ * 1 user error (bad flags, fingerprint missing on a row, etc.)
27
+ * 2 `pgserve_meta` does not exist on the host (no provisions yet);
28
+ * a clean signal for monitoring rather than a crash.
29
+ * 3 one or more drops failed; the partial sweep is still audited.
30
+ */
31
+
32
+ import { readAdminJson } from '../lib/admin-json.js';
33
+ import { classifyOrphans } from '../gc/orphan-detection.js';
34
+ import { writeGcAudit } from '../gc/audit-log.js';
35
+ import {
36
+ selectMetaRows,
37
+ selectExistingDbs,
38
+ selectActiveDbs,
39
+ dropDatabase,
40
+ deleteMetaRow,
41
+ } from '../gc/queries.js';
42
+ import fs from 'node:fs';
43
+
44
+ const DEFAULT_STALE_AFTER_DAYS = 30;
45
+ const USAGE = `Usage: pgserve gc [options]
46
+
47
+ --dry-run show what would be dropped without dropping (default)
48
+ --apply actually drop orphan databases + roles + meta rows
49
+ --stale-after-days <N> override the 30-day idle staleness window
50
+ --json emit a JSON summary on stdout
51
+ --port <N> override the postgres port (default: read admin.json or 5432)
52
+
53
+ Default mode is dry-run; you must pass --apply to actually drop anything.`;
54
+
55
+ function parseFlags(argv) {
56
+ const out = {
57
+ apply: false,
58
+ json: false,
59
+ staleAfterDays: DEFAULT_STALE_AFTER_DAYS,
60
+ port: undefined,
61
+ };
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ switch (a) {
65
+ case '--dry-run':
66
+ out.apply = false;
67
+ break;
68
+ case '--apply':
69
+ out.apply = true;
70
+ break;
71
+ case '--json':
72
+ out.json = true;
73
+ break;
74
+ case '--help':
75
+ case '-h':
76
+ out.help = true;
77
+ break;
78
+ case '--stale-after-days': {
79
+ const v = Number(argv[++i]);
80
+ if (!Number.isInteger(v) || v <= 0) {
81
+ throw new Error('--stale-after-days requires a positive integer');
82
+ }
83
+ out.staleAfterDays = v;
84
+ break;
85
+ }
86
+ case '--port':
87
+ case '-p': {
88
+ const v = Number(argv[++i]);
89
+ if (!Number.isInteger(v) || v <= 0 || v > 65535) {
90
+ throw new Error('--port requires an integer in [1, 65535]');
91
+ }
92
+ out.port = v;
93
+ break;
94
+ }
95
+ default:
96
+ throw new Error(`unknown flag: ${a}`);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function resolvePort(opts) {
103
+ if (typeof opts.port === 'number') return opts.port;
104
+ try {
105
+ const admin = readAdminJson();
106
+ if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
107
+ } catch {
108
+ /* admin.json absent or unreadable — fall through */
109
+ }
110
+ return 5432;
111
+ }
112
+
113
+ function emitJson(summary) {
114
+ process.stdout.write(JSON.stringify(summary) + '\n');
115
+ }
116
+
117
+ function emitHumanSummary(summary) {
118
+ const tag = summary.applied ? 'APPLIED' : 'DRY-RUN';
119
+ process.stdout.write(`pgserve gc [${tag}] — port=${summary.port}, staleAfterDays=${summary.staleAfterDays}\n`);
120
+ process.stdout.write(` meta rows scanned: ${summary.scanned}\n`);
121
+ process.stdout.write(` retained: ${summary.retained}\n`);
122
+ process.stdout.write(` orphans found: ${summary.orphans}\n`);
123
+ if (summary.dropped > 0) {
124
+ process.stdout.write(` databases dropped: ${summary.dropped}\n`);
125
+ }
126
+ if (summary.errors.length > 0) {
127
+ process.stdout.write(` errors: ${summary.errors.length}\n`);
128
+ for (const e of summary.errors) {
129
+ process.stdout.write(` - ${e.database}: ${e.message}\n`);
130
+ }
131
+ }
132
+ if (summary.orphans > 0 && !summary.applied) {
133
+ process.stdout.write(`\n re-run with --apply to actually drop the ${summary.orphans} orphan(s).\n`);
134
+ }
135
+ }
136
+
137
+ export async function runGc(argv = []) {
138
+ let opts;
139
+ try {
140
+ opts = parseFlags(argv);
141
+ } catch (err) {
142
+ process.stderr.write(`pgserve gc: ${err.message}\n\n${USAGE}\n`);
143
+ return 1;
144
+ }
145
+ if (opts.help) {
146
+ process.stdout.write(USAGE + '\n');
147
+ return 0;
148
+ }
149
+ const port = resolvePort(opts);
150
+ const summary = {
151
+ applied: opts.apply,
152
+ port,
153
+ staleAfterDays: opts.staleAfterDays,
154
+ scanned: 0,
155
+ retained: 0,
156
+ orphans: 0,
157
+ dropped: 0,
158
+ errors: [],
159
+ findings: [],
160
+ };
161
+
162
+ writeGcAudit({
163
+ action: 'start',
164
+ detail: `mode=${opts.apply ? 'apply' : 'dry-run'} port=${port} staleAfterDays=${opts.staleAfterDays}`,
165
+ });
166
+
167
+ let metaRows;
168
+ try {
169
+ metaRows = selectMetaRows({ port });
170
+ } catch (err) {
171
+ if (err.code === 'ENOPGSERVE_META') {
172
+ writeGcAudit({ action: 'finish', reason: 'no_pgserve_meta', detail: err.message });
173
+ if (opts.json) emitJson({ ...summary, error: 'pgserve_meta does not exist' });
174
+ else process.stdout.write(`pgserve gc: pgserve_meta does not exist on this host (no provisions yet)\n`);
175
+ return 2;
176
+ }
177
+ writeGcAudit({ action: 'error', reason: 'select_meta_rows_failed', detail: err.message });
178
+ if (opts.json) emitJson({ ...summary, error: err.message });
179
+ else process.stderr.write(`pgserve gc: ${err.message}\n`);
180
+ return 3;
181
+ }
182
+
183
+ let existingDbs;
184
+ let activeDbs;
185
+ try {
186
+ existingDbs = selectExistingDbs({ port });
187
+ activeDbs = selectActiveDbs({ port });
188
+ } catch (err) {
189
+ writeGcAudit({ action: 'error', reason: 'select_pg_state_failed', detail: err.message });
190
+ if (opts.json) emitJson({ ...summary, error: err.message });
191
+ else process.stderr.write(`pgserve gc: ${err.message}\n`);
192
+ return 3;
193
+ }
194
+
195
+ const now = new Date();
196
+ const partition = classifyOrphans({
197
+ metaRows,
198
+ existingDbs,
199
+ activeDbs,
200
+ pathExists: (p) => {
201
+ try { return fs.existsSync(p); } catch { return false; }
202
+ },
203
+ now,
204
+ staleAfterMs: opts.staleAfterDays * 24 * 60 * 60 * 1000,
205
+ });
206
+ summary.scanned = metaRows.length;
207
+ summary.retained = partition.retained.length;
208
+ summary.orphans = partition.orphans.length;
209
+ summary.findings = [
210
+ ...partition.orphans.map((f) => ({ ...f, partition: 'orphan' })),
211
+ ...partition.retained.map((f) => ({ ...f, partition: 'retained' })),
212
+ ];
213
+
214
+ // Audit every retention decision so an operator can answer "why was
215
+ // X kept?" days later just by reading the JSON-lines log.
216
+ for (const r of partition.retained) {
217
+ writeGcAudit({
218
+ action: 'skip',
219
+ fingerprint: r.row.fingerprint,
220
+ database: r.row.database_name,
221
+ role: r.row.role_name,
222
+ reason: r.reason,
223
+ detail: r.detail,
224
+ });
225
+ }
226
+
227
+ for (const o of partition.orphans) {
228
+ if (!opts.apply) {
229
+ writeGcAudit({
230
+ action: 'skip',
231
+ dryRun: true,
232
+ fingerprint: o.row.fingerprint,
233
+ database: o.row.database_name,
234
+ role: o.row.role_name,
235
+ reason: o.reason,
236
+ detail: `would drop (${o.detail})`,
237
+ });
238
+ continue;
239
+ }
240
+ try {
241
+ dropDatabase({ database: o.row.database_name, role: o.row.role_name, port });
242
+ deleteMetaRow({ fingerprint: o.row.fingerprint, port });
243
+ summary.dropped++;
244
+ writeGcAudit({
245
+ action: 'drop',
246
+ fingerprint: o.row.fingerprint,
247
+ database: o.row.database_name,
248
+ role: o.row.role_name,
249
+ reason: o.reason,
250
+ detail: o.detail,
251
+ });
252
+ } catch (err) {
253
+ summary.errors.push({ database: o.row.database_name, message: err.message });
254
+ writeGcAudit({
255
+ action: 'error',
256
+ fingerprint: o.row.fingerprint,
257
+ database: o.row.database_name,
258
+ role: o.row.role_name,
259
+ reason: 'drop_failed',
260
+ detail: err.message,
261
+ });
262
+ }
263
+ }
264
+
265
+ writeGcAudit({
266
+ action: 'finish',
267
+ detail: `scanned=${summary.scanned} orphans=${summary.orphans} dropped=${summary.dropped} errors=${summary.errors.length}`,
268
+ });
269
+
270
+ if (opts.json) emitJson(summary);
271
+ else emitHumanSummary(summary);
272
+
273
+ return summary.errors.length > 0 ? 3 : 0;
274
+ }
275
+
276
+ export const __testInternals = Object.freeze({ parseFlags, resolvePort, emitHumanSummary });
@@ -0,0 +1,396 @@
1
+ /**
2
+ * `pgserve provision [<fingerprint>]` — singleton G3 verb 4.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * Idempotent provisioner. Resolves a fingerprint from the operator's
7
+ * cwd (or an explicit positional), takes a per-fingerprint advisory
8
+ * lock to make 10 concurrent calls produce exactly 1 database, then
9
+ * runs the cohort-canonical CREATE ROLE / CREATE DATABASE / GRANT /
10
+ * INSERT INTO pgserve_meta sequence.
11
+ *
12
+ * Composes the merged G3 foundations:
13
+ * - src/provision/fingerprint.js → resolveFingerprint
14
+ * - src/provision/db-naming.js → deriveProvisionedNames
15
+ * - src/schema/pgserve-meta.js → bootstrapPgserveMeta
16
+ * - src/cosign/schema.js → applyVerifiedColumns
17
+ * - src/lib/pg-query.js → pgQuery / quoteIdent / quoteLiteral
18
+ * - src/lib/admin-json.js → readAdminJson (port discovery)
19
+ *
20
+ * The advisory-lock helpers in src/provision/advisory-lock.js are NOT
21
+ * called by this CLI verb (see "Concurrency note" below) — they're
22
+ * kept on main for future single-session callers (e.g. a daemon mode).
23
+ *
24
+ * Idempotency strategy:
25
+ * 1. SELECT existing pgserve_meta row by fingerprint inside the
26
+ * advisory-lock window. If present + database still exists, just
27
+ * `touch` last_used_at and exit success.
28
+ * 2. Otherwise run the full create sequence with `IF NOT EXISTS` /
29
+ * `CREATE OR REPLACE` semantics so a partial earlier run can be
30
+ * resumed without operator surgery.
31
+ * 3. INSERT ... ON CONFLICT (fingerprint) DO UPDATE so the row
32
+ * converges to current values whether this is a first run or a
33
+ * replay.
34
+ *
35
+ * Concurrency note (PR #92 bot-review correction):
36
+ * The wish references `pg_advisory_lock`-deduped provisioning, but
37
+ * we shell out to psql via `spawnSync` — every pgQuery call starts
38
+ * a NEW psql session, so even a session-scoped lock can't survive
39
+ * across our calls (xact-scoped is even shorter — releases on the
40
+ * transaction's COMMIT). To get a real lock we'd need to bundle the
41
+ * whole sequence into a single piped psql script, which collides
42
+ * with `CREATE DATABASE`'s "no transaction block" restriction.
43
+ *
44
+ * The honest mechanism here is **idempotency under racing replays**:
45
+ * - CREATE ROLE wrapped in a `DO` / `IF NOT EXISTS` block.
46
+ * - CREATE DATABASE catches `database "<name>" already exists`
47
+ * (psql does NOT emit SQLSTATE codes by default, so we match
48
+ * on the human-readable error text in stderr — bot review
49
+ * MEDIUM).
50
+ * - GRANT is set-style (re-running adds nothing).
51
+ * - INSERT INTO pgserve_meta uses `ON CONFLICT (fingerprint) DO
52
+ * UPDATE` so the row converges to current values.
53
+ *
54
+ * 10 concurrent `provision <fingerprint>` invocations with the same
55
+ * fingerprint converge to one (database, role, meta row) trio — the
56
+ * serialization is at the postgres level (CREATE DATABASE acquires
57
+ * the appropriate locks itself), not at our shellout level. The
58
+ * advisory-lock primitives in `src/provision/advisory-lock.js` are
59
+ * kept for future single-session callers (e.g. a daemon mode), but
60
+ * are NOT acquired by this CLI verb.
61
+ *
62
+ * Exit codes:
63
+ * 0 provisioned (or no-op idempotent replay)
64
+ * 1 user error (bad flags, fingerprint validation)
65
+ * 2 pgserve postmaster unreachable / not provisionable
66
+ * 3 postgres error during create sequence (partial state may
67
+ * remain; rerun is safe)
68
+ */
69
+
70
+ import { readAdminJson } from '../lib/admin-json.js';
71
+ import { resolveFingerprint } from '../provision/fingerprint.js';
72
+ import { deriveProvisionedNames } from '../provision/db-naming.js';
73
+ import { bootstrapPgserveMeta } from '../schema/pgserve-meta.js';
74
+ import { applyVerifiedColumns } from '../cosign/schema.js';
75
+ import { pgQuery, quoteIdent, quoteLiteral } from '../lib/pg-query.js';
76
+
77
+ const USAGE = `Usage: pgserve provision [<fingerprint>] [options]
78
+
79
+ <fingerprint> optional explicit fingerprint string (skips
80
+ package.json detection); pinned by the
81
+ operator. If omitted, pgserve resolves the
82
+ fingerprint from the cwd's package.json.
83
+
84
+ --port <N> override the postgres port (default: read
85
+ admin.json or 5432).
86
+ --json emit a JSON summary on stdout.
87
+ -h, --help show this help.
88
+
89
+ Idempotent: re-running with the same fingerprint touches last_used_at
90
+ and exits success — no error if the database already exists.`;
91
+
92
+ function parseFlags(argv) {
93
+ const out = { json: false, port: undefined, positional: [] };
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const a = argv[i];
96
+ switch (a) {
97
+ case '--json':
98
+ out.json = true;
99
+ break;
100
+ case '--help':
101
+ case '-h':
102
+ out.help = true;
103
+ break;
104
+ case '--port':
105
+ case '-p': {
106
+ const v = Number(argv[++i]);
107
+ if (!Number.isInteger(v) || v <= 0 || v > 65535) {
108
+ throw new Error('--port requires an integer in [1, 65535]');
109
+ }
110
+ out.port = v;
111
+ break;
112
+ }
113
+ default:
114
+ if (a.startsWith('--')) {
115
+ throw new Error(`unknown flag: ${a}`);
116
+ }
117
+ out.positional.push(a);
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function resolvePort(opts) {
124
+ if (typeof opts.port === 'number') return opts.port;
125
+ try {
126
+ const admin = readAdminJson();
127
+ if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
128
+ } catch {
129
+ /* admin.json absent — fall through */
130
+ }
131
+ return 5432;
132
+ }
133
+
134
+ /**
135
+ * Run the bootstrap (pgserve_meta CREATE TABLE + indexes) and then
136
+ * apply the cosign verify ALTER columns on top. Idempotent — both
137
+ * modules use IF NOT EXISTS / IF NOT EXISTS guards.
138
+ */
139
+ async function ensurePgserveMetaSchema({ port }) {
140
+ const client = makePsqlClient({ port, db: 'postgres' });
141
+ await bootstrapPgserveMeta(client);
142
+ await applyVerifiedColumns(client);
143
+ }
144
+
145
+ /**
146
+ * Adapter: shape `pgQuery` to the node-postgres-compatible
147
+ * `client.query(sql)` contract that bootstrapPgserveMeta + applyVerifiedColumns
148
+ * expect. Awaitable so the modules can `await client.query(sql)`.
149
+ */
150
+ function makePsqlClient({ port, db }) {
151
+ return {
152
+ query: async (sql) => pgQuery({ sql, port, db }),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Probe: does pgserve_meta have a row for this fingerprint?
158
+ * Returns the row or null.
159
+ */
160
+ function selectMetaRow({ port, fingerprint }) {
161
+ const out = pgQuery({
162
+ sql: [
163
+ 'SELECT',
164
+ " COALESCE(database_name, ''),",
165
+ " COALESCE(role_name, '')",
166
+ 'FROM public.pgserve_meta',
167
+ `WHERE fingerprint = ${quoteLiteral(fingerprint)}`,
168
+ 'LIMIT 1',
169
+ ].join('\n'),
170
+ port,
171
+ captureStdout: true,
172
+ });
173
+ if (!out) return null;
174
+ const [database_name, role_name] = out.split('\t');
175
+ if (!database_name) return null;
176
+ return { database_name, role_name };
177
+ }
178
+
179
+ function databaseExists({ port, database }) {
180
+ const out = pgQuery({
181
+ sql: `SELECT 1 FROM pg_database WHERE datname = ${quoteLiteral(database)}`,
182
+ port,
183
+ captureStdout: true,
184
+ });
185
+ return out === '1';
186
+ }
187
+
188
+ function touchMetaRow({ port, fingerprint }) {
189
+ pgQuery({
190
+ sql: `UPDATE public.pgserve_meta SET last_used_at = now() WHERE fingerprint = ${quoteLiteral(fingerprint)}`,
191
+ port,
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Run the create sequence. Idempotency-driven (see file header for
197
+ * why we don't try to claim a per-process advisory lock).
198
+ *
199
+ * Returns the names that were provisioned.
200
+ */
201
+ function runCreateSequence({ port, fingerprint, publisher, sourcePath, names }) {
202
+ const { databaseName, roleName } = names;
203
+
204
+ // Step 1 — CREATE ROLE if missing. We CREATE WITH LOGIN by default;
205
+ // pgserve consumers connect as their fingerprint role.
206
+ pgQuery({
207
+ sql: [
208
+ 'DO $do$',
209
+ 'BEGIN',
210
+ ` IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${quoteLiteral(roleName)}) THEN`,
211
+ ` EXECUTE 'CREATE ROLE ${quoteIdent(roleName)} WITH LOGIN';`,
212
+ ' END IF;',
213
+ 'END$do$;',
214
+ ].join('\n'),
215
+ port,
216
+ });
217
+
218
+ // Step 2 — CREATE DATABASE if missing. Cannot run inside a
219
+ // transaction block. We swallow the "already exists" race because
220
+ // the wish marks it a non-error.
221
+ //
222
+ // psql does not emit SQLSTATE codes (42P04) by default — only the
223
+ // human-readable message "ERROR: database "<name>" already exists".
224
+ // Match against that text rather than the SQLSTATE we don't have.
225
+ // (Bot review MEDIUM on PR #92.)
226
+ if (!databaseExists({ port, database: databaseName })) {
227
+ try {
228
+ pgQuery({
229
+ sql: `CREATE DATABASE ${quoteIdent(databaseName)} OWNER ${quoteIdent(roleName)}`,
230
+ port,
231
+ });
232
+ } catch (err) {
233
+ const msg = (err && err.stderr) ? err.stderr : '';
234
+ if (msg.includes('already exists')) {
235
+ /* race: another provisioner created it; benign */
236
+ } else {
237
+ throw err;
238
+ }
239
+ }
240
+ }
241
+
242
+ // Step 3 — GRANT CONNECT + CREATE on the DB to the role. Idempotent
243
+ // (postgres GRANT is set-style, not stack-style).
244
+ pgQuery({
245
+ sql: `GRANT CONNECT, CREATE ON DATABASE ${quoteIdent(databaseName)} TO ${quoteIdent(roleName)}`,
246
+ port,
247
+ });
248
+
249
+ // Step 4 — UPSERT the pgserve_meta row. ON CONFLICT keeps replays
250
+ // safe (a partial earlier run can be resumed without operator
251
+ // intervention).
252
+ pgQuery({
253
+ sql: [
254
+ 'INSERT INTO public.pgserve_meta',
255
+ ' (fingerprint, database_name, role_name, publisher, source_path, last_used_at)',
256
+ 'VALUES (',
257
+ ` ${quoteLiteral(fingerprint)},`,
258
+ ` ${quoteLiteral(databaseName)},`,
259
+ ` ${quoteLiteral(roleName)},`,
260
+ ` ${quoteLiteral(publisher || '')},`,
261
+ ` ${quoteLiteral(sourcePath || '')},`,
262
+ ' now()',
263
+ ')',
264
+ 'ON CONFLICT (fingerprint) DO UPDATE SET',
265
+ ' database_name = EXCLUDED.database_name,',
266
+ ' role_name = EXCLUDED.role_name,',
267
+ ' publisher = EXCLUDED.publisher,',
268
+ ' source_path = EXCLUDED.source_path,',
269
+ ' last_used_at = now()',
270
+ ].join('\n'),
271
+ port,
272
+ });
273
+
274
+ return { databaseName, roleName };
275
+ }
276
+
277
+ function emit({ json, summary, humanLines }) {
278
+ if (json) {
279
+ process.stdout.write(JSON.stringify(summary) + '\n');
280
+ } else {
281
+ for (const line of humanLines) process.stdout.write(line + '\n');
282
+ }
283
+ }
284
+
285
+ export async function runProvision(argv = []) {
286
+ let opts;
287
+ try {
288
+ opts = parseFlags(argv);
289
+ } catch (err) {
290
+ process.stderr.write(`pgserve provision: ${err.message}\n\n${USAGE}\n`);
291
+ return 1;
292
+ }
293
+ if (opts.help) {
294
+ process.stdout.write(USAGE + '\n');
295
+ return 0;
296
+ }
297
+ const explicit = opts.positional[0];
298
+ const port = resolvePort(opts);
299
+
300
+ let resolved;
301
+ try {
302
+ resolved = resolveFingerprint({ explicit });
303
+ } catch (err) {
304
+ process.stderr.write(`pgserve provision: ${err.message}\n`);
305
+ return 1;
306
+ }
307
+
308
+ const names = deriveProvisionedNames({
309
+ fingerprint: resolved.fingerprint,
310
+ publisher: resolved.publisher,
311
+ });
312
+
313
+ const summary = {
314
+ fingerprint: resolved.fingerprint,
315
+ publisher: resolved.publisher,
316
+ sourcePath: resolved.sourcePath,
317
+ fingerprintKind: resolved.kind,
318
+ databaseName: names.databaseName,
319
+ roleName: names.roleName,
320
+ port,
321
+ action: 'unknown',
322
+ };
323
+
324
+ // Step 1 — make sure pgserve_meta exists (bootstrap + verify cols).
325
+ try {
326
+ await ensurePgserveMetaSchema({ port });
327
+ } catch (err) {
328
+ process.stderr.write(`pgserve provision: cannot bootstrap pgserve_meta: ${err.message}\n`);
329
+ summary.action = 'error';
330
+ summary.error = err.message;
331
+ if (opts.json) emit({ json: true, summary });
332
+ return 2;
333
+ }
334
+
335
+ // Step 2 — fast-path idempotency: existing row + DB still present?
336
+ let existing;
337
+ try {
338
+ existing = selectMetaRow({ port, fingerprint: resolved.fingerprint });
339
+ } catch (err) {
340
+ process.stderr.write(`pgserve provision: ${err.message}\n`);
341
+ return 3;
342
+ }
343
+ if (existing && databaseExists({ port, database: existing.database_name })) {
344
+ touchMetaRow({ port, fingerprint: resolved.fingerprint });
345
+ summary.action = 'touched';
346
+ summary.databaseName = existing.database_name;
347
+ summary.roleName = existing.role_name;
348
+ emit({
349
+ json: opts.json,
350
+ summary,
351
+ humanLines: [
352
+ `pgserve provision: idempotent replay`,
353
+ ` fingerprint: ${resolved.fingerprint}`,
354
+ ` database: ${existing.database_name} (already exists, last_used_at touched)`,
355
+ ` role: ${existing.role_name}`,
356
+ ],
357
+ });
358
+ return 0;
359
+ }
360
+
361
+ // Step 3 — full create sequence under advisory lock.
362
+ try {
363
+ runCreateSequence({
364
+ port,
365
+ fingerprint: resolved.fingerprint,
366
+ publisher: resolved.publisher,
367
+ sourcePath: resolved.sourcePath,
368
+ names,
369
+ });
370
+ summary.action = 'created';
371
+ } catch (err) {
372
+ summary.action = 'error';
373
+ summary.error = err.message;
374
+ process.stderr.write(`pgserve provision: ${err.message}\n`);
375
+ if (opts.json) emit({ json: true, summary });
376
+ return 3;
377
+ }
378
+
379
+ emit({
380
+ json: opts.json,
381
+ summary,
382
+ humanLines: [
383
+ `pgserve provision: created`,
384
+ ` fingerprint: ${resolved.fingerprint}`,
385
+ ` database: ${names.databaseName}`,
386
+ ` role: ${names.roleName}`,
387
+ ` source_path: ${resolved.sourcePath}`,
388
+ ],
389
+ });
390
+ return 0;
391
+ }
392
+
393
+ export const __testInternals = Object.freeze({
394
+ parseFlags,
395
+ resolvePort,
396
+ });