pgserve 2.5.0 → 2.6.1

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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pure orphan classification for `pgserve gc`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * Splits the gc verb into two halves:
7
+ * 1. (this module) decide which `pgserve_meta` rows refer to dead
8
+ * consumers and which are still in use
9
+ * 2. (gc verb) DROP the orphans + audit-log every drop
10
+ *
11
+ * Keeping classification pure means the gc verb can be tested
12
+ * deterministically (feed in synthetic input → assert the partition)
13
+ * without spinning up postgres, and the rules can be exercised at high
14
+ * fan-out by callers that want a `pgserve gc --dry-run`.
15
+ *
16
+ * Orphan signals (any one is sufficient):
17
+ * 1. The DB row exists in pgserve_meta but the database itself does
18
+ * not exist in `pg_database` — leftover row from a manual DROP.
19
+ * 2. The row's `source_path` is set and that path no longer exists
20
+ * on the filesystem — the consumer directory was removed.
21
+ * 3. The row's `last_used_at` is older than `staleAfterMs` AND the
22
+ * database has zero active connections in `pg_stat_activity`.
23
+ * The "no connections" guard prevents gc from dropping a DB that
24
+ * a long-running consumer is actively using; a missing entry in
25
+ * the activity map is treated as zero connections.
26
+ *
27
+ * Retention signals (none of the above + an explicit "in use" hit):
28
+ * - `last_used_at` is within the staleness window, OR
29
+ * - the database has at least one active connection.
30
+ *
31
+ * Inputs:
32
+ * - `metaRows` array of pgserve_meta row objects: { fingerprint,
33
+ * database_name, role_name, source_path, last_used_at }
34
+ * - `existingDbs` Set<string> of DB names from `pg_database`
35
+ * - `activeDbs` Set<string> of DB names that have ≥1 row in
36
+ * pg_stat_activity (or a Map<string, number> if
37
+ * callers want to record the count too — Set-like
38
+ * access is what we use)
39
+ * - `pathExists(path)` callback returning truthy when the path is on
40
+ * disk. Caller injects fs.existsSync or a mock.
41
+ * - `now` Date — usually `new Date()`; injectable for tests.
42
+ * - `staleAfterMs` ms threshold past `last_used_at` to declare
43
+ * an idle DB stale. Default: 30 days.
44
+ *
45
+ * Outputs:
46
+ * { orphans: [...], retained: [...] }
47
+ * each row in `orphans` has `reason: 'missing_db' | 'missing_path' |
48
+ * 'idle_stale'`; each row in `retained` has `reason: 'active' |
49
+ * 'recent' | 'unknown_meta'`. The `unknown_meta` bucket exists for
50
+ * rows that are missing `last_used_at` entirely — we never DROP one
51
+ * of those without an operator decision.
52
+ */
53
+
54
+ const DEFAULT_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
55
+
56
+ /**
57
+ * @typedef {Object} MetaRow
58
+ * @property {string} fingerprint
59
+ * @property {string} database_name
60
+ * @property {string} role_name
61
+ * @property {string=} source_path
62
+ * @property {string|Date=} last_used_at
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} OrphanFinding
67
+ * @property {MetaRow} row
68
+ * @property {'missing_db'|'missing_path'|'idle_stale'} reason
69
+ * @property {string=} detail
70
+ */
71
+
72
+ /**
73
+ * @typedef {Object} RetainedFinding
74
+ * @property {MetaRow} row
75
+ * @property {'active'|'recent'|'unknown_meta'} reason
76
+ * @property {string=} detail
77
+ */
78
+
79
+ function asTime(t) {
80
+ if (!t) return null;
81
+ if (t instanceof Date) {
82
+ const ms = t.getTime();
83
+ return Number.isFinite(ms) ? ms : null;
84
+ }
85
+ if (typeof t === 'string') {
86
+ const ms = Date.parse(t);
87
+ return Number.isFinite(ms) ? ms : null;
88
+ }
89
+ if (typeof t === 'number' && Number.isFinite(t)) return t;
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Classify a single meta row. Exposed for unit tests so callers can
95
+ * exercise individual signals without composing a full input set.
96
+ *
97
+ * @returns {OrphanFinding | RetainedFinding}
98
+ */
99
+ export function classifyRow(row, ctx) {
100
+ if (!row || typeof row.database_name !== 'string' || row.database_name.length === 0) {
101
+ throw new TypeError('classifyRow: row must have a non-empty database_name');
102
+ }
103
+ const {
104
+ existingDbs,
105
+ activeDbs,
106
+ pathExists,
107
+ now,
108
+ staleAfterMs,
109
+ } = ctx;
110
+
111
+ // 1. database is gone but the meta row is still here
112
+ if (!existingDbs.has(row.database_name)) {
113
+ return {
114
+ row,
115
+ reason: 'missing_db',
116
+ detail: `${row.database_name} not in pg_database`,
117
+ };
118
+ }
119
+
120
+ // 2. consumer directory was removed
121
+ if (typeof row.source_path === 'string' && row.source_path.length > 0) {
122
+ if (!pathExists(row.source_path)) {
123
+ return {
124
+ row,
125
+ reason: 'missing_path',
126
+ detail: `source_path ${row.source_path} no longer exists`,
127
+ };
128
+ }
129
+ }
130
+
131
+ // 3. idle + stale
132
+ const lastUsedMs = asTime(row.last_used_at);
133
+ if (lastUsedMs == null) {
134
+ return {
135
+ row,
136
+ reason: 'unknown_meta',
137
+ detail: 'last_used_at is missing or unparseable; refusing to gc without operator review',
138
+ };
139
+ }
140
+ const ageMs = now.getTime() - lastUsedMs;
141
+ const isStale = ageMs >= staleAfterMs;
142
+ const isActive = activeDbs.has(row.database_name);
143
+
144
+ if (isActive) {
145
+ return { row, reason: 'active', detail: `${row.database_name} has ≥1 active connection` };
146
+ }
147
+ if (isStale) {
148
+ return {
149
+ row,
150
+ reason: 'idle_stale',
151
+ detail: `last_used_at is ${Math.floor(ageMs / (24 * 60 * 60 * 1000))}d old, no active connections`,
152
+ };
153
+ }
154
+ return { row, reason: 'recent', detail: `last_used_at is within ${Math.floor(staleAfterMs / (24 * 60 * 60 * 1000))}d window` };
155
+ }
156
+
157
+ const ORPHAN_REASONS = new Set(['missing_db', 'missing_path', 'idle_stale']);
158
+
159
+ /**
160
+ * Partition all rows into orphans + retained.
161
+ * @param {object} args
162
+ * @param {MetaRow[]} args.metaRows
163
+ * @param {Set<string>} args.existingDbs
164
+ * @param {Set<string>} args.activeDbs
165
+ * @param {(p: string) => boolean} [args.pathExists]
166
+ * @param {Date} [args.now]
167
+ * @param {number} [args.staleAfterMs]
168
+ * @returns {{ orphans: OrphanFinding[], retained: RetainedFinding[] }}
169
+ */
170
+ export function classifyOrphans(args = {}) {
171
+ const {
172
+ metaRows = [],
173
+ existingDbs = new Set(),
174
+ activeDbs = new Set(),
175
+ pathExists = () => true,
176
+ now = new Date(),
177
+ staleAfterMs = DEFAULT_STALE_AFTER_MS,
178
+ } = args;
179
+ const ctx = { existingDbs, activeDbs, pathExists, now, staleAfterMs };
180
+ const orphans = [];
181
+ const retained = [];
182
+ for (const row of metaRows) {
183
+ const finding = classifyRow(row, ctx);
184
+ if (ORPHAN_REASONS.has(finding.reason)) orphans.push(finding);
185
+ else retained.push(finding);
186
+ }
187
+ return { orphans, retained };
188
+ }
189
+
190
+ export const __testInternals = Object.freeze({ DEFAULT_STALE_AFTER_MS, asTime });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * psql query helpers for `pgserve gc`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
5
+ * (the `pgserve gc` orchestration layer).
6
+ *
7
+ * This module composes the shared psql primitive (`pgQuery` /
8
+ * `quoteIdent` / `quoteLiteral` from `src/lib/pg-query.js`) into the
9
+ * specific SELECT / DROP queries gc runs against `pgserve_meta` +
10
+ * `pg_database` + `pg_stat_activity`.
11
+ *
12
+ * History: PR #91 inlined its own `pgQuery`; PR #92 extracted the
13
+ * canonical primitive to `src/lib/pg-query.js`; the
14
+ * `autopg-distribution-cutover-finalize` G2 dedup PR (#94) collapsed
15
+ * the gc-side copy into a re-exporter at `src/gc/pg-queries.js`. This
16
+ * file is the G2-followup destination — gc-specific helpers only,
17
+ * primitives consumed via direct imports from `src/lib/pg-query.js`.
18
+ *
19
+ * DROP DATABASE caveat: postgres refuses `DROP DATABASE <db>` when
20
+ * sessions are connected. We `pg_terminate_backend()` everything
21
+ * targeting the doomed database first, then DROP. The kill step is
22
+ * gated behind explicit `--apply` at the CLI layer; this module just
23
+ * exposes the primitives.
24
+ */
25
+
26
+ import { pgQuery, quoteIdent, quoteLiteral, PG_QUERY_DEFAULTS } from '../lib/pg-query.js';
27
+ import { PGSERVE_META_TABLE } from '../schema/pgserve-meta.js';
28
+
29
+ const DEFAULT_PORT = PG_QUERY_DEFAULTS.port;
30
+ const DEFAULT_DB = PG_QUERY_DEFAULTS.db;
31
+
32
+ const SYSTEM_DBS = new Set(['template0', 'template1', 'postgres']);
33
+
34
+ /**
35
+ * SELECT every row from `pgserve_meta`. Returns an array of plain
36
+ * objects matching the table shape from `src/schema/pgserve-meta.js`.
37
+ * Throws ENOPGSERVE_META if the table doesn't exist (caller decides
38
+ * whether that's a "no provisions yet" warn vs. a hard fail).
39
+ */
40
+ export function selectMetaRows({ port = DEFAULT_PORT } = {}) {
41
+ // Single-line tab-separated form so a missing/null column still
42
+ // parses cleanly. ARRAY_AGG would lose null distinction.
43
+ const exists = pgQuery({
44
+ db: DEFAULT_DB,
45
+ port,
46
+ sql: `SELECT to_regclass('public.${PGSERVE_META_TABLE}') IS NOT NULL`,
47
+ captureStdout: true,
48
+ });
49
+ if (exists.trim() !== 't') {
50
+ const err = new Error(`pgserve_meta does not exist on this host`);
51
+ err.code = 'ENOPGSERVE_META';
52
+ throw err;
53
+ }
54
+ const out = pgQuery({
55
+ db: DEFAULT_DB,
56
+ port,
57
+ sql: [
58
+ 'SELECT',
59
+ " COALESCE(fingerprint, ''),",
60
+ " COALESCE(database_name, ''),",
61
+ " COALESCE(role_name, ''),",
62
+ " COALESCE(publisher, ''),",
63
+ " COALESCE(source_path, ''),",
64
+ " COALESCE(to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'), '')",
65
+ `FROM public.${PGSERVE_META_TABLE}`,
66
+ 'ORDER BY fingerprint',
67
+ ].join('\n'),
68
+ captureStdout: true,
69
+ });
70
+ if (!out) return [];
71
+ return out.split('\n').filter(Boolean).map((line) => {
72
+ const [fingerprint, database_name, role_name, publisher, source_path, last_used_at] = line.split('\t');
73
+ return {
74
+ fingerprint: fingerprint || '',
75
+ database_name: database_name || '',
76
+ role_name: role_name || '',
77
+ publisher: publisher || '',
78
+ source_path: source_path || '',
79
+ // Empty string → undefined so orphan-detection's `unknown_meta`
80
+ // bucket triggers correctly when last_used_at was NULL on disk.
81
+ last_used_at: last_used_at && last_used_at.length > 0 ? last_used_at : undefined,
82
+ };
83
+ });
84
+ }
85
+
86
+ /**
87
+ * SELECT every non-template database name from pg_database. Excludes
88
+ * the postgres-system DBs ('template0', 'template1', 'postgres').
89
+ */
90
+ export function selectExistingDbs({ port = DEFAULT_PORT } = {}) {
91
+ const out = pgQuery({
92
+ db: DEFAULT_DB,
93
+ port,
94
+ sql: 'SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname',
95
+ captureStdout: true,
96
+ });
97
+ if (!out) return new Set();
98
+ const arr = out.split('\n').filter(Boolean).filter((d) => !SYSTEM_DBS.has(d));
99
+ return new Set(arr);
100
+ }
101
+
102
+ /**
103
+ * Return the set of DB names that have ≥1 active connection in
104
+ * pg_stat_activity (excluding our own connection).
105
+ */
106
+ export function selectActiveDbs({ port = DEFAULT_PORT } = {}) {
107
+ const out = pgQuery({
108
+ db: DEFAULT_DB,
109
+ port,
110
+ sql: [
111
+ 'SELECT DISTINCT datname',
112
+ 'FROM pg_stat_activity',
113
+ 'WHERE datname IS NOT NULL',
114
+ ' AND pid <> pg_backend_pid()',
115
+ ].join('\n'),
116
+ captureStdout: true,
117
+ });
118
+ if (!out) return new Set();
119
+ return new Set(out.split('\n').filter(Boolean));
120
+ }
121
+
122
+ /**
123
+ * Terminate every backend connected to `database`, then `DROP DATABASE`
124
+ * + `DROP ROLE IF EXISTS`. Caller is responsible for orphan
125
+ * classification — this is the dumb side. We commit each drop in its
126
+ * own implicit transaction (psql -At -f - is autocommit) so a partial
127
+ * sweep leaves a consistent audit log.
128
+ *
129
+ * Returns `{ database, role }` for the audit writer.
130
+ */
131
+ export function dropDatabase({ database, role, port = DEFAULT_PORT } = {}) {
132
+ if (typeof database !== 'string' || database.length === 0 || SYSTEM_DBS.has(database)) {
133
+ throw new Error(`dropDatabase: refusing to drop "${database}" (empty or system DB)`);
134
+ }
135
+ // 1. Disconnect everything from the doomed DB. SELECT-with-side-
136
+ // effect form so we can run it from any other DB.
137
+ pgQuery({
138
+ db: DEFAULT_DB,
139
+ port,
140
+ sql: [
141
+ 'SELECT pg_terminate_backend(pid)',
142
+ 'FROM pg_stat_activity',
143
+ `WHERE datname = ${quoteLiteral(database)}`,
144
+ ' AND pid <> pg_backend_pid()',
145
+ ].join('\n'),
146
+ });
147
+ // 2. DROP DATABASE — postgres rejects parameterized DDL, so we have
148
+ // to interpolate. database_name comes from pgserve_meta (which we
149
+ // write ourselves via deriveProvisionedNames) and SYSTEM_DBS is
150
+ // guarded above; the identifier surface is constrained.
151
+ pgQuery({
152
+ db: DEFAULT_DB,
153
+ port,
154
+ sql: `DROP DATABASE IF EXISTS ${quoteIdent(database)}`,
155
+ });
156
+ // 3. DROP ROLE — best-effort; a role may be shared across multiple
157
+ // DBs in older non-cohort installs, so we use IF EXISTS and don't
158
+ // fail the gc run if it can't be dropped (other DBs depend on it).
159
+ if (typeof role === 'string' && role.length > 0) {
160
+ try {
161
+ pgQuery({
162
+ db: DEFAULT_DB,
163
+ port,
164
+ sql: `DROP ROLE IF EXISTS ${quoteIdent(role)}`,
165
+ });
166
+ } catch {
167
+ /* role may be in use elsewhere; not fatal for gc's drop step */
168
+ }
169
+ }
170
+ return { database, role };
171
+ }
172
+
173
+ /**
174
+ * DELETE the row from `pgserve_meta`. Caller invokes after a successful
175
+ * `dropDatabase` so the next gc run doesn't re-find the same orphan.
176
+ */
177
+ export function deleteMetaRow({ fingerprint, port = DEFAULT_PORT } = {}) {
178
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
179
+ throw new TypeError('deleteMetaRow: fingerprint must be a non-empty string');
180
+ }
181
+ pgQuery({
182
+ db: DEFAULT_DB,
183
+ port,
184
+ sql: `DELETE FROM public.${PGSERVE_META_TABLE} WHERE fingerprint = ${quoteLiteral(fingerprint)}`,
185
+ });
186
+ }
187
+
188
+ export const __testInternals = Object.freeze({
189
+ quoteIdent,
190
+ quoteLiteral,
191
+ SYSTEM_DBS,
192
+ DEFAULT_PORT,
193
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Shared psql shellout primitive.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
5
+ * (foundation for the gc + provision orchestration verbs).
6
+ *
7
+ * Why psql shellout vs. node-postgres:
8
+ * - matches the existing pattern in
9
+ * `src/upgrade/steps/cosign-meta-migration.js` (PR #79).
10
+ * - avoids the runtime cost of loading the `pg` driver in a CLI
11
+ * verb that runs once and exits.
12
+ * - no shell expansion: SQL goes through stdin, not a template
13
+ * string, so postgres-style `$$` blocks survive intact.
14
+ *
15
+ * Caller-supplied `db` defaults to `postgres`. The connection-discovery
16
+ * layer (admin.json + runtime.json) is the caller's responsibility —
17
+ * this module does not assume a host shape.
18
+ */
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ export const PG_QUERY_DEFAULTS = Object.freeze({
23
+ port: 5432,
24
+ host: '127.0.0.1',
25
+ user: 'postgres',
26
+ db: 'postgres',
27
+ });
28
+
29
+ /**
30
+ * Resolve the PGPASSWORD value for a psql shell-out. Precedence:
31
+ *
32
+ * 1. caller-supplied `password` (explicit override; tests, callers
33
+ * that read from a known-secret store)
34
+ * 2. `process.env.PGPASSWORD` (operator-set env override; this is
35
+ * what `.pgpass`-style hosts already use today via shell wrappers)
36
+ * 3. literal `'postgres'` — matches the bootstrap password
37
+ * `pgserve install` configures on the local-loopback postgres.
38
+ * Required for fresh-install hosts where the env is unset and
39
+ * no `.pgpass` file exists yet (CV-1, 2026-05-09).
40
+ *
41
+ * Hardcoding the literal `'postgres'` fallback was deliberately AVOIDED
42
+ * in PR #92 (bot-review HIGH) on the reasoning that it would defeat
43
+ * `.pgpass` / peer-auth integration. CV-1 reframed that tradeoff:
44
+ *
45
+ * - peer-auth hosts use `host=<unix-socket-path>` which postgres
46
+ * treats as auth-method `peer` (or `trust` depending on `pg_hba`),
47
+ * so the PGPASSWORD value is ignored entirely; the literal default
48
+ * does no harm there.
49
+ * - `.pgpass`-using hosts can still override with explicit
50
+ * `PGPASSWORD=…` shell env, OR pass `password` explicitly through
51
+ * the function call. Their upstream wrapping is unchanged.
52
+ * - Fresh-install + TCP-loopback (the path CV-1 exercises) gets the
53
+ * literal that `pgserve install` already configured, instead of
54
+ * a `password authentication failed for user "postgres"` FATAL.
55
+ */
56
+ export function resolvePgPassword({ password, envPassword = process.env.PGPASSWORD } = {}) {
57
+ if (password !== undefined) return password;
58
+ if (envPassword !== undefined) return envPassword;
59
+ return 'postgres';
60
+ }
61
+
62
+ /**
63
+ * Run a single SQL statement (or batch) via psql, fed through stdin
64
+ * (no shell expansion). Throws on non-zero exit. Returns stdout
65
+ * (trimmed when `captureStdout`).
66
+ *
67
+ * @param {object} args
68
+ * @param {string} args.sql SQL to execute
69
+ * @param {string} [args.db='postgres'] target database
70
+ * @param {number} [args.port=5432] postgres port
71
+ * @param {string} [args.host='127.0.0.1'] postgres host
72
+ * @param {string} [args.user='postgres'] postgres user
73
+ * @param {string} [args.password] PGPASSWORD; resolves via
74
+ * `resolvePgPassword()` —
75
+ * caller > env > 'postgres'
76
+ * @param {boolean} [args.captureStdout=false] trim + return stdout
77
+ * @returns {string} stdout (trimmed when `captureStdout`)
78
+ */
79
+ export function pgQuery({
80
+ sql,
81
+ db = PG_QUERY_DEFAULTS.db,
82
+ port = PG_QUERY_DEFAULTS.port,
83
+ host = PG_QUERY_DEFAULTS.host,
84
+ user = PG_QUERY_DEFAULTS.user,
85
+ password,
86
+ captureStdout = false,
87
+ } = {}) {
88
+ if (typeof sql !== 'string' || sql.length === 0) {
89
+ throw new TypeError('pgQuery: sql must be a non-empty string');
90
+ }
91
+ // PGPASSWORD is always set; defaults to 'postgres' on fresh-install
92
+ // hosts where neither env nor caller supplied one (CV-1 fix). See
93
+ // `resolvePgPassword` docstring for the precedence rules + the
94
+ // updated reasoning about .pgpass / peer-auth host coexistence.
95
+ const env = { ...process.env, PGPASSWORD: resolvePgPassword({ password }) };
96
+ const result = spawnSync(
97
+ 'psql',
98
+ [
99
+ '-h', host,
100
+ '-p', String(port),
101
+ '-U', user,
102
+ '-d', db,
103
+ // -At: tuples-only + unaligned. -F: explicit tab field separator
104
+ // (psql defaults to '|' for unaligned mode; callers split rows on
105
+ // '\t' so the separator MUST be '\t'). Bot review CRITICAL.
106
+ '-At', '-F', '\t',
107
+ // ON_ERROR_STOP=1: in script mode (-f), psql by default continues
108
+ // past statement errors and STILL exits 0. A failed CREATE
109
+ // DATABASE / GRANT could otherwise be invisible to the caller.
110
+ // Bot review CRITICAL P1.
111
+ '-v', 'ON_ERROR_STOP=1',
112
+ '-f', '-',
113
+ ],
114
+ { env, input: sql, stdio: ['pipe', 'pipe', 'pipe'] },
115
+ );
116
+ if (result.status !== 0) {
117
+ const stderr = (result.stderr || Buffer.from('')).toString();
118
+ const err = new Error(`psql exited ${result.status}: ${stderr.trim()}`);
119
+ err.status = result.status;
120
+ err.stderr = stderr;
121
+ throw err;
122
+ }
123
+ const stdout = (result.stdout || Buffer.from('')).toString();
124
+ return captureStdout ? stdout.trim() : stdout;
125
+ }
126
+
127
+ /**
128
+ * Postgres identifier quoting: wrap in "..." and escape internal ".
129
+ * Used by callers that interpolate identifiers (DDL doesn't accept
130
+ * parameter binding for table / role / database names).
131
+ */
132
+ export function quoteIdent(name) {
133
+ return `"${String(name).replace(/"/g, '""')}"`;
134
+ }
135
+
136
+ /**
137
+ * Postgres literal quoting: wrap in '...' and escape internal '.
138
+ * Use this for any string literal that ends up in a DDL string;
139
+ * regular DML should bind parameters instead, but psql's stdin form
140
+ * doesn't accept those, so the safe path for our shellout is to
141
+ * always quoteLiteral defensively.
142
+ */
143
+ export function quoteLiteral(value) {
144
+ return `'${String(value).replace(/'/g, "''")}'`;
145
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `pg_advisory_lock` key derivation for `pgserve provision`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * The wish locks the concurrency story: 10 simultaneous `pgserve
7
+ * provision <fingerprint>` calls must produce exactly 1 database. The
8
+ * server-side coordination is `pg_advisory_lock(key)`; this module
9
+ * derives the integer key from a fingerprint string deterministically.
10
+ *
11
+ * Why two forms:
12
+ * - `pg_advisory_lock(bigint)` — single 64-bit key
13
+ * - `pg_advisory_lock(int4, int4)` — two 32-bit keys (older
14
+ * postgres major versions on hosts the cohort still supports)
15
+ *
16
+ * Both are derived from the same sha256 of the fingerprint, so two
17
+ * provision processes against the same fingerprint will always pick
18
+ * the same lock regardless of which advisory-lock variant they call.
19
+ *
20
+ * Key derivation:
21
+ * - sha256(fingerprint) → 32 bytes
22
+ * - bigint key: reinterpret the first 8 bytes as a signed 64-bit
23
+ * integer, big-endian. The bigint form is what we
24
+ * recommend; the int4 form is provided for parity with
25
+ * callers that need it.
26
+ * - int4 pair: high 32 bits → key1, low 32 bits → key2 (both
27
+ * signed). This matches postgres's two-arg overload.
28
+ *
29
+ * Pure function: no postgres I/O, no network, no globals.
30
+ */
31
+
32
+ import crypto from 'node:crypto';
33
+
34
+ const PGSERVE_NAMESPACE_TAG = 'pgserve-provision-v1:';
35
+
36
+ function sha256Bytes(input) {
37
+ return crypto.createHash('sha256').update(input, 'utf8').digest();
38
+ }
39
+
40
+ /**
41
+ * Derive the bigint advisory-lock key from a fingerprint string.
42
+ * Returned as a JS BigInt; postgres bigint accepts any signed 64-bit
43
+ * integer (range: -2^63 .. 2^63 - 1).
44
+ * @returns {BigInt}
45
+ */
46
+ export function deriveBigintKey(fingerprint) {
47
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
48
+ throw new TypeError('deriveBigintKey: fingerprint must be a non-empty string');
49
+ }
50
+ const bytes = sha256Bytes(PGSERVE_NAMESPACE_TAG + fingerprint);
51
+ // Read first 8 bytes big-endian, signed.
52
+ const key = bytes.readBigInt64BE(0);
53
+ return key;
54
+ }
55
+
56
+ /**
57
+ * Derive the (int4, int4) advisory-lock key pair. Returns plain Numbers
58
+ * in the signed 32-bit range so the caller can pass them straight to
59
+ * pg-cstring/pg-promise without BigInt coercion plumbing.
60
+ * @returns {{ key1: number, key2: number }}
61
+ */
62
+ export function deriveInt4Pair(fingerprint) {
63
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
64
+ throw new TypeError('deriveInt4Pair: fingerprint must be a non-empty string');
65
+ }
66
+ const bytes = sha256Bytes(PGSERVE_NAMESPACE_TAG + fingerprint);
67
+ // High 32 bits → key1; low 32 bits → key2; both signed.
68
+ const key1 = bytes.readInt32BE(0);
69
+ const key2 = bytes.readInt32BE(4);
70
+ return { key1, key2 };
71
+ }
72
+
73
+ /**
74
+ * Convenience: returns the SQL fragment that acquires the lock with
75
+ * pg_advisory_xact_lock. pgserve provision wraps a transaction around
76
+ * its CREATE DATABASE / CREATE ROLE / INSERT, and xact-scoped locks
77
+ * release automatically when that transaction commits or rolls back —
78
+ * no risk of a dangling session-scoped lock surviving a crashed
79
+ * provision.
80
+ *
81
+ * Returns: `{ sql: '...', params: [BigInt] }`
82
+ */
83
+ export function buildAdvisoryLockSql(fingerprint) {
84
+ const key = deriveBigintKey(fingerprint);
85
+ return {
86
+ sql: 'SELECT pg_advisory_xact_lock($1::bigint)',
87
+ params: [key],
88
+ };
89
+ }
90
+
91
+ export const __testInternals = Object.freeze({ sha256Bytes, PGSERVE_NAMESPACE_TAG });