pgserve 1.2.0 → 2.0.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.
Files changed (45) hide show
  1. package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
  2. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
  3. package/.genie/wishes/pgserve-v2/WISH.md +442 -0
  4. package/.genie/wishes/release-system-genie-pattern/WISH.md +9 -9
  5. package/.genie/wishes/release-system-genie-pattern/validation.md +43 -10
  6. package/.github/workflows/ci.yml +10 -6
  7. package/.github/workflows/release.yml +1 -1
  8. package/.github/workflows/version.yml +4 -4
  9. package/CHANGELOG.md +150 -0
  10. package/Makefile +12 -12
  11. package/README.md +216 -10
  12. package/bin/pgserve-wrapper.cjs +3 -3
  13. package/bin/{pglite-server.js → postgres-server.js} +258 -1
  14. package/bun.lock +0 -3
  15. package/ecosystem.config.cjs +3 -3
  16. package/eslint.config.js +2 -0
  17. package/knip.json +1 -1
  18. package/package.json +4 -5
  19. package/scripts/test-bun-self-heal.sh +10 -10
  20. package/src/admin-client.js +171 -0
  21. package/src/audit.js +168 -0
  22. package/src/control-db.js +313 -0
  23. package/src/daemon-control.js +408 -0
  24. package/src/daemon-shared.js +18 -0
  25. package/src/daemon-tcp.js +296 -0
  26. package/src/daemon.js +629 -0
  27. package/src/fingerprint.js +453 -0
  28. package/src/gc.js +351 -0
  29. package/src/index.js +31 -0
  30. package/src/protocol.js +131 -0
  31. package/src/router.js +8 -0
  32. package/src/sdk.js +137 -0
  33. package/src/tenancy.js +75 -0
  34. package/src/tokens.js +102 -0
  35. package/tests/audit.test.js +189 -0
  36. package/tests/benchmarks/runner.js +430 -754
  37. package/tests/control-db.test.js +285 -0
  38. package/tests/daemon-fingerprint-integration.test.js +111 -0
  39. package/tests/daemon-pr24-regression.test.js +198 -0
  40. package/tests/fingerprint.test.js +249 -0
  41. package/tests/fixtures/240-orphan-seed.sql +30 -0
  42. package/tests/orphan-cleanup.test.js +390 -0
  43. package/tests/sdk.test.js +71 -0
  44. package/tests/tcp-listen.test.js +368 -0
  45. package/tests/tenancy.test.js +403 -0
package/src/gc.js ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * pgserve GC — 3-layer lifecycle sweep (Group 5).
3
+ *
4
+ * Decides which user databases to reap based on:
5
+ * 1. `persist=true` — exempt from GC, audited as `db_persist_honored`.
6
+ * 2. Liveness — if `liveness_pid` points at a running process, slide
7
+ * `last_connection_at` forward to "now" (the peer is alive, the row is
8
+ * a heartbeat) and never reap.
9
+ * 3. TTL — peer is gone AND `now - last_connection_at > ttlMs` (default
10
+ * 24h) → `DROP DATABASE`, delete the meta row, audit reap event.
11
+ *
12
+ * Audit reap event is `db_reaped_liveness` when the row had a non-null
13
+ * liveness_pid that is now dead, otherwise `db_reaped_ttl` (the row never
14
+ * registered a liveness_pid — pure idle expiry).
15
+ *
16
+ * `installSweepTriggers(daemon, …)` wires the three call sites:
17
+ * - boot: a single sweep right after the daemon is listening, with a
18
+ * summary log line so operators see GC activity at startup.
19
+ * - hourly `setInterval` (configurable via `intervalMs`).
20
+ * - on-connect sampling: subscribe to the daemon's `'accept'` event and
21
+ * fire `gcSweep` async at rate 1/N where `N = max(1, dbCount/10)`. The
22
+ * listener never awaits the sweep, so accept latency is unaffected.
23
+ */
24
+
25
+ import { audit, AUDIT_EVENTS } from './audit.js';
26
+ import { forEachReapable, deleteMetaRow, touchLastConnection } from './control-db.js';
27
+
28
+ const TTL_MS_DEFAULT = 24 * 60 * 60 * 1000;
29
+ const HOURLY_MS = 60 * 60 * 1000;
30
+
31
+ /**
32
+ * Default liveness probe — POSIX `kill(pid, 0)` returns 0 if the process is
33
+ * alive, throws ESRCH if gone, EPERM if owned by another user (still alive).
34
+ *
35
+ * @param {number|null|undefined} pid
36
+ * @returns {boolean}
37
+ */
38
+ function defaultIsProcessAlive(pid) {
39
+ if (!Number.isInteger(pid) || pid <= 0) return false;
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch (err) {
44
+ return err.code === 'EPERM';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} GcSweepOptions
50
+ * @property {{query: Function}} adminClient — pgserve admin DB connection
51
+ * @property {{adminPool: any, createdDatabases?: Set<string>}} [pgManager] —
52
+ * optional; used to evict from the in-process createdDatabases cache after
53
+ * a successful DROP. Tests can omit; gcSweep always falls back to the
54
+ * adminClient's `query()` for the actual DROP.
55
+ * @property {number|Date} [now]
56
+ * @property {number} [ttlMs] — defaults to 24h
57
+ * @property {boolean} [dryRun] — when true, never DROP / DELETE / audit reap
58
+ * @property {(pid: number|null|undefined) => boolean} [isProcessAlive]
59
+ * @property {{warn?: Function, info?: Function, error?: Function, debug?: Function}} [logger]
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} GcSweepResult
64
+ * @property {number} examined
65
+ * @property {number} reaped
66
+ * @property {number} kept
67
+ * @property {number} persistSkipped
68
+ * @property {number} aliveSkipped
69
+ * @property {string[]} reapedNames
70
+ */
71
+
72
+ /**
73
+ * Run one GC sweep. Returns counts so callers can log a summary or assert
74
+ * in tests.
75
+ *
76
+ * @param {GcSweepOptions} opts
77
+ * @returns {Promise<GcSweepResult>}
78
+ */
79
+ export async function gcSweep({
80
+ adminClient,
81
+ pgManager = null,
82
+ now = new Date(),
83
+ ttlMs = TTL_MS_DEFAULT,
84
+ dryRun = false,
85
+ isProcessAlive = defaultIsProcessAlive,
86
+ logger,
87
+ } = {}) {
88
+ if (!adminClient) throw new Error('gcSweep: adminClient required');
89
+
90
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
91
+ if (!Number.isFinite(nowMs)) throw new Error('gcSweep: now must be Date or numeric ms');
92
+
93
+ const result = {
94
+ examined: 0,
95
+ reaped: 0,
96
+ kept: 0,
97
+ persistSkipped: 0,
98
+ aliveSkipped: 0,
99
+ reapedNames: [],
100
+ };
101
+
102
+ // Snapshot so we don't iterate while we DELETE — pg's async iterator
103
+ // protocols vary across drivers, but materialising 240 rows is cheap and
104
+ // sidesteps any cursor-vs-DELETE quirks.
105
+ const candidates = [];
106
+ for await (const row of forEachReapable(adminClient)) {
107
+ candidates.push(row);
108
+ }
109
+
110
+ for (const row of candidates) {
111
+ result.examined += 1;
112
+
113
+ // Persist=true rows never appear from forEachReapable (the query filters
114
+ // them out), but if the schema changes that contract we still defend
115
+ // here — and emit the audit event the wish promises.
116
+ if (row.persist) {
117
+ result.persistSkipped += 1;
118
+ result.kept += 1;
119
+ if (!dryRun) {
120
+ audit(AUDIT_EVENTS.DB_PERSIST_HONORED, {
121
+ database: row.databaseName,
122
+ fingerprint: row.fingerprint,
123
+ });
124
+ }
125
+ continue;
126
+ }
127
+
128
+ const livenessPid = row.livenessPid;
129
+ const hadLivenessPid = Number.isInteger(livenessPid) && livenessPid > 0;
130
+ const alive = hadLivenessPid && isProcessAlive(livenessPid);
131
+
132
+ if (alive) {
133
+ result.aliveSkipped += 1;
134
+ result.kept += 1;
135
+ if (!dryRun) {
136
+ // Slide the window: an alive process means the row is effectively
137
+ // current, even if the pgserve_meta last_connection_at value lags.
138
+ try {
139
+ await touchLastConnection(adminClient, {
140
+ databaseName: row.databaseName,
141
+ livenessPid,
142
+ });
143
+ } catch (err) {
144
+ logger?.warn?.(
145
+ { err: err?.message || String(err), database: row.databaseName },
146
+ 'gcSweep: touchLastConnection failed for live row (non-fatal)',
147
+ );
148
+ }
149
+ }
150
+ continue;
151
+ }
152
+
153
+ const lastMs = row.lastConnectionAt instanceof Date
154
+ ? row.lastConnectionAt.getTime()
155
+ : Number(row.lastConnectionAt);
156
+ const ageMs = Number.isFinite(lastMs) ? nowMs - lastMs : Infinity;
157
+
158
+ if (ageMs <= ttlMs) {
159
+ result.kept += 1;
160
+ continue;
161
+ }
162
+
163
+ if (dryRun) {
164
+ result.reaped += 1;
165
+ result.reapedNames.push(row.databaseName);
166
+ continue;
167
+ }
168
+
169
+ try {
170
+ await dropDatabaseSafely(adminClient, row.databaseName, logger);
171
+ pgManager?.createdDatabases?.delete(row.databaseName);
172
+ await deleteMetaRow(adminClient, row.databaseName);
173
+ const reapEvent = hadLivenessPid
174
+ ? AUDIT_EVENTS.DB_REAPED_LIVENESS
175
+ : AUDIT_EVENTS.DB_REAPED_TTL;
176
+ audit(reapEvent, {
177
+ database: row.databaseName,
178
+ fingerprint: row.fingerprint,
179
+ last_connection_at: row.lastConnectionAt instanceof Date
180
+ ? row.lastConnectionAt.toISOString()
181
+ : row.lastConnectionAt,
182
+ liveness_pid: livenessPid ?? null,
183
+ age_ms: Number.isFinite(ageMs) ? ageMs : null,
184
+ });
185
+ result.reaped += 1;
186
+ result.reapedNames.push(row.databaseName);
187
+ } catch (err) {
188
+ logger?.error?.(
189
+ { err: err?.message || String(err), database: row.databaseName },
190
+ 'gcSweep: failed to reap database',
191
+ );
192
+ }
193
+ }
194
+
195
+ return result;
196
+ }
197
+
198
+ async function dropDatabaseSafely(adminClient, databaseName, logger) {
199
+ const escaped = `"${databaseName.replace(/"/g, '""')}"`;
200
+ // Terminate any lingering backends so DROP DATABASE doesn't refuse with
201
+ // 55006 (object_in_use). The peer's pgserve daemon socket is already gone
202
+ // (liveness dead) but Postgres can hold idle backends a while longer.
203
+ try {
204
+ await adminClient.query(
205
+ `SELECT pg_terminate_backend(pid)
206
+ FROM pg_stat_activity
207
+ WHERE datname = $1 AND pid <> pg_backend_pid()`,
208
+ [databaseName],
209
+ );
210
+ } catch (err) {
211
+ logger?.debug?.(
212
+ { err: err?.message || String(err), database: databaseName },
213
+ 'gcSweep: pg_terminate_backend failed (non-fatal)',
214
+ );
215
+ }
216
+ await adminClient.query(`DROP DATABASE IF EXISTS ${escaped}`);
217
+ }
218
+
219
+ /**
220
+ * Wire the three sweep call sites onto a running daemon.
221
+ *
222
+ * Returns a `{stop()}` handle so tests (and `daemon.stop()`) can detach.
223
+ *
224
+ * @param {object} daemon — PgserveDaemon instance
225
+ * @param {object} [opts]
226
+ * @param {{query: Function}} [opts.adminClient] — defaults to daemon._adminClient
227
+ * @param {number} [opts.intervalMs] — hourly default; pass 0 to disable
228
+ * @param {number} [opts.ttlMs]
229
+ * @param {(pid: number) => boolean} [opts.isProcessAlive]
230
+ * @param {() => Promise<number>|number} [opts.getDbCount] — defaults to a
231
+ * COUNT(*) query against pgserve_meta
232
+ * @param {boolean} [opts.bootSweep=true]
233
+ * @returns {{stop: () => Promise<void>, sweep: () => Promise<GcSweepResult>}}
234
+ */
235
+ export function installSweepTriggers(daemon, opts = {}) {
236
+ const adminClient = opts.adminClient || daemon._adminClient;
237
+ if (!adminClient) {
238
+ throw new Error('installSweepTriggers: daemon has no admin client');
239
+ }
240
+ const intervalMs = opts.intervalMs == null ? HOURLY_MS : opts.intervalMs;
241
+ const ttlMs = opts.ttlMs == null ? TTL_MS_DEFAULT : opts.ttlMs;
242
+ const logger = daemon.logger;
243
+ const pgManager = daemon.pgManager;
244
+ const isProcessAlive = opts.isProcessAlive || defaultIsProcessAlive;
245
+ const getDbCount = opts.getDbCount || (async () => {
246
+ try {
247
+ const r = await adminClient.query('SELECT count(*)::int AS n FROM pgserve_meta');
248
+ return r.rows?.[0]?.n ?? 0;
249
+ } catch {
250
+ return 0;
251
+ }
252
+ });
253
+
254
+ let stopped = false;
255
+ let inflight = false;
256
+ let lastDbCount = 0;
257
+
258
+ const runSweep = async () => {
259
+ if (stopped) return null;
260
+ if (inflight) return null;
261
+ inflight = true;
262
+ try {
263
+ const res = await gcSweep({
264
+ adminClient,
265
+ pgManager,
266
+ now: new Date(),
267
+ ttlMs,
268
+ isProcessAlive,
269
+ logger,
270
+ });
271
+ lastDbCount = Math.max(0, lastDbCount - res.reaped);
272
+ return res;
273
+ } catch (err) {
274
+ logger?.error?.(
275
+ { err: err?.message || String(err) },
276
+ 'gcSweep failed',
277
+ );
278
+ return null;
279
+ } finally {
280
+ inflight = false;
281
+ }
282
+ };
283
+
284
+ let timer = null;
285
+ if (intervalMs > 0) {
286
+ timer = setInterval(() => {
287
+ void runSweep();
288
+ }, intervalMs);
289
+ if (typeof timer.unref === 'function') timer.unref();
290
+ }
291
+
292
+ const acceptListener = () => {
293
+ // Sample 1/N where N = max(1, ceil(dbCount/10)). Always async and
294
+ // detached so accept latency isn't blocked.
295
+ const n = Math.max(1, Math.ceil(lastDbCount / 10));
296
+ if (n === 1 || Math.random() * n < 1) {
297
+ setImmediate(() => {
298
+ if (stopped) return;
299
+ // Refresh count opportunistically before each sweep so on-connect
300
+ // sampling tracks the live row count without polling.
301
+ Promise.resolve(getDbCount())
302
+ .then((c) => { lastDbCount = Number(c) || 0; })
303
+ .then(runSweep)
304
+ .catch(() => { /* swallowed by runSweep */ });
305
+ });
306
+ }
307
+ };
308
+ daemon.on?.('accept', acceptListener);
309
+
310
+ const handle = {
311
+ sweep: runSweep,
312
+ async stop() {
313
+ stopped = true;
314
+ if (timer) {
315
+ clearInterval(timer);
316
+ timer = null;
317
+ }
318
+ daemon.off?.('accept', acceptListener);
319
+ },
320
+ };
321
+
322
+ if (opts.bootSweep !== false) {
323
+ // Boot sweep + count refresh + summary log. Detached so we don't block
324
+ // start() — the daemon is already listening at this point.
325
+ setImmediate(async () => {
326
+ try {
327
+ lastDbCount = Number(await getDbCount()) || 0;
328
+ const res = await runSweep();
329
+ if (res) {
330
+ logger?.info?.(
331
+ {
332
+ examined: res.examined,
333
+ reaped: res.reaped,
334
+ kept: res.kept,
335
+ persist_skipped: res.persistSkipped,
336
+ alive_skipped: res.aliveSkipped,
337
+ },
338
+ 'pgserve GC: boot sweep complete',
339
+ );
340
+ }
341
+ } catch (err) {
342
+ logger?.warn?.(
343
+ { err: err?.message || String(err) },
344
+ 'pgserve GC: boot sweep failed',
345
+ );
346
+ }
347
+ });
348
+ }
349
+
350
+ return handle;
351
+ }
package/src/index.js CHANGED
@@ -13,6 +13,37 @@ export { RestoreManager } from './restore.js';
13
13
  export { Dashboard } from './dashboard.js';
14
14
  export { StatsCollector } from './stats-collector.js';
15
15
  export { StatsDashboard } from './stats-dashboard.js';
16
+ export {
17
+ PgserveDaemon,
18
+ startDaemon,
19
+ stopDaemon,
20
+ resolveControlSocketDir,
21
+ resolveControlSocketPath,
22
+ resolvePidLockPath,
23
+ resolveLibpqCompatPath,
24
+ acquirePidLock,
25
+ isProcessAlive,
26
+ } from './daemon.js';
27
+ export {
28
+ buildDaemonArgs,
29
+ daemonClientOptions,
30
+ ensureDaemon,
31
+ probeDaemon,
32
+ resolveBundledCliBin,
33
+ } from './sdk.js';
34
+ export {
35
+ derivePackageFingerprint,
36
+ deriveScriptFingerprint,
37
+ fingerprintFromCred,
38
+ findNearestPackageJson,
39
+ readPackageName,
40
+ readPersistFlag,
41
+ } from './fingerprint.js';
42
+ export {
43
+ hashToken,
44
+ mintToken,
45
+ parseTcpAuth,
46
+ } from './tokens.js';
16
47
 
17
48
  // Default export
18
49
  export { startMultiTenantServer as default } from './router.js';
package/src/protocol.js CHANGED
@@ -133,6 +133,137 @@ export function extractDatabaseName(data) {
133
133
  }
134
134
  }
135
135
 
136
+ /**
137
+ * Extract `application_name` from a startup message buffer. Returns null when
138
+ * absent or when the buffer is malformed (callers fall back to no-auth).
139
+ *
140
+ * @param {Buffer} data
141
+ * @returns {string|null}
142
+ */
143
+ export function extractApplicationName(data) {
144
+ try {
145
+ const params = parseStartupMessage(data, /* fastPath */ false);
146
+ return typeof params.application_name === 'string' ? params.application_name : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Return a new startup-message buffer with the `database` parameter replaced
154
+ * by `newDbName`. All other parameters (and their order) are preserved by
155
+ * default; pass `dropParams: ['application_name', ...]` to strip noisy
156
+ * fields the daemon would rather not forward to PG verbatim. The 4-byte
157
+ * length prefix at the start of the buffer is recomputed.
158
+ *
159
+ * Group 6 uses this on TCP-authenticated connections so a peer that presents
160
+ * a token for fingerprint X is forced into fingerprint X's database, even
161
+ * if the libpq client requested a different one.
162
+ *
163
+ * @param {Buffer} data — original startup message
164
+ * @param {string} newDbName
165
+ * @param {{dropParams?: string[]}} [opts]
166
+ * @returns {Buffer}
167
+ */
168
+ export function rewriteDatabaseName(data, newDbName, opts = {}) {
169
+ if (!Buffer.isBuffer(data)) throw new Error('rewriteDatabaseName: buffer required');
170
+ if (typeof newDbName !== 'string' || newDbName.length === 0) {
171
+ throw new Error('rewriteDatabaseName: non-empty newDbName required');
172
+ }
173
+ const length = data.readInt32BE(0);
174
+ const version = data.readInt32BE(4);
175
+ const drop = new Set(opts.dropParams || []);
176
+
177
+ // Walk parameters; build a list of (key, value) pairs replacing 'database'.
178
+ const pairs = [];
179
+ let offset = 8;
180
+ let sawDatabase = false;
181
+ while (offset < length - 1) {
182
+ const keyEnd = data.indexOf(0, offset);
183
+ if (keyEnd === -1 || keyEnd >= length) break;
184
+ const key = data.toString('utf8', offset, keyEnd);
185
+ offset = keyEnd + 1;
186
+ const valueEnd = data.indexOf(0, offset);
187
+ if (valueEnd === -1 || valueEnd >= length) break;
188
+ const value = data.toString('utf8', offset, valueEnd);
189
+ offset = valueEnd + 1;
190
+ if (drop.has(key)) continue;
191
+ if (key === 'database') {
192
+ pairs.push(['database', newDbName]);
193
+ sawDatabase = true;
194
+ } else {
195
+ pairs.push([key, value]);
196
+ }
197
+ }
198
+ if (!sawDatabase) pairs.push(['database', newDbName]);
199
+
200
+ // Compute new buffer size: 4 (length) + 4 (version) + sum(key+1 + value+1) + 1 (terminator).
201
+ let bodyLen = 0;
202
+ for (const [k, v] of pairs) {
203
+ bodyLen += Buffer.byteLength(k, 'utf8') + 1 + Buffer.byteLength(v, 'utf8') + 1;
204
+ }
205
+ const total = 4 + 4 + bodyLen + 1;
206
+ const out = Buffer.alloc(total);
207
+ out.writeInt32BE(total, 0);
208
+ out.writeInt32BE(version, 4);
209
+ let cur = 8;
210
+ for (const [k, v] of pairs) {
211
+ cur += out.write(k, cur, 'utf8');
212
+ out[cur++] = 0;
213
+ cur += out.write(v, cur, 'utf8');
214
+ out[cur++] = 0;
215
+ }
216
+ out[cur++] = 0; // final terminator
217
+ return out;
218
+ }
219
+
220
+ /**
221
+ * Build a PostgreSQL ErrorResponse (`'E'`) frame.
222
+ *
223
+ * Used by the daemon to reject cross-fingerprint connection attempts
224
+ * with SQLSTATE `28P01 invalid_authorization_specification` before the
225
+ * peer's startup message ever reaches the underlying PG instance.
226
+ *
227
+ * Frame layout (PG protocol v3):
228
+ * 'E' (1 byte) | length (4 bytes, includes itself) | <fields...> | '\0'
229
+ *
230
+ * Each field: type-byte | utf8 string | '\0'
231
+ * Required fields per PG docs: 'S' (Severity), 'C' (SQLSTATE), 'M' (Message).
232
+ * 'V' (localized severity, server >= 9.6) is included for parity with the
233
+ * frames real Postgres emits — psql / pg drivers parse both transparently.
234
+ *
235
+ * @param {{severity?: string, sqlstate: string, message: string}} args
236
+ * @returns {Buffer}
237
+ */
238
+ export function buildErrorResponse({ severity = 'FATAL', sqlstate, message }) {
239
+ if (typeof sqlstate !== 'string' || sqlstate.length !== 5) {
240
+ throw new TypeError('buildErrorResponse: sqlstate must be a 5-character string');
241
+ }
242
+ if (typeof message !== 'string' || message.length === 0) {
243
+ throw new TypeError('buildErrorResponse: message must be a non-empty string');
244
+ }
245
+ const field = (typeChar, value) => {
246
+ const valBytes = Buffer.byteLength(value, 'utf8');
247
+ const buf = Buffer.alloc(1 + valBytes + 1);
248
+ buf.writeUInt8(typeChar.charCodeAt(0), 0);
249
+ buf.write(value, 1, 'utf8');
250
+ buf.writeUInt8(0, 1 + valBytes);
251
+ return buf;
252
+ };
253
+ const body = Buffer.concat([
254
+ field('S', severity),
255
+ field('V', severity),
256
+ field('C', sqlstate),
257
+ field('M', message),
258
+ Buffer.from([0]),
259
+ ]);
260
+ const frameLength = 4 + body.length;
261
+ const header = Buffer.alloc(5);
262
+ header.writeUInt8(0x45, 0); // 'E'
263
+ header.writeUInt32BE(frameLength, 1);
264
+ return Buffer.concat([header, body]);
265
+ }
266
+
136
267
  // Pre-allocated buffer pool for startup message parsing (avoids allocation per connection)
137
268
  const STARTUP_BUFFER_SIZE = 8192; // Max startup message is typically < 1KB
138
269
  const bufferPool = [];
package/src/router.js CHANGED
@@ -12,6 +12,14 @@
12
12
  * - Memory mode (default) or persistent storage
13
13
  *
14
14
  * PERFORMANCE: Uses Bun.listen() and Bun.connect() for 2-3x throughput improvement
15
+ *
16
+ * v2 NOTE: The MultiTenantRouter is the **direct-embed** path — callers that
17
+ * spawn their own PostgresManager and bind a TCP port get a per-pid Unix
18
+ * socket from `pgManager.getSocketPath()` (preserved by PR #24). The new
19
+ * **daemon** path (`src/daemon.js`) binds a singleton control socket at
20
+ * `$XDG_RUNTIME_DIR/pgserve/control.sock` and is the v2 default for the
21
+ * `pgserve daemon` CLI subcommand. Both paths coexist; direct-embed callers
22
+ * are not affected by daemon mode.
15
23
  */
16
24
 
17
25
  import fs from 'fs';
package/src/sdk.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Public SDK helpers for applications that want to consume the singleton
3
+ * pgserve daemon without shelling out themselves.
4
+ *
5
+ * The intended flow is:
6
+ * 1. App calls ensureDaemon() during install/startup.
7
+ * 2. App connects with daemonClientOptions().
8
+ * 3. pgserve derives the app identity from the Unix-socket peer creds and
9
+ * routes it to that app's fingerprinted database.
10
+ */
11
+
12
+ import { spawn } from 'child_process';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { fileURLToPath } from 'url';
16
+ import {
17
+ isProcessAlive,
18
+ resolveControlSocketDir,
19
+ resolveControlSocketPath,
20
+ resolveLibpqCompatPath,
21
+ resolvePidLockPath,
22
+ } from './daemon.js';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ export function probeDaemon({ controlSocketDir = resolveControlSocketDir() } = {}) {
27
+ const socketPath = resolveControlSocketPath(controlSocketDir);
28
+ const libpqSocketPath = resolveLibpqCompatPath(controlSocketDir);
29
+ const pidLockPath = resolvePidLockPath(controlSocketDir);
30
+ const socketPresent = fs.existsSync(socketPath);
31
+ const libpqSocketPresent = fs.existsSync(libpqSocketPath);
32
+ let pid = null;
33
+
34
+ try {
35
+ const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
36
+ const parsed = Number.parseInt(raw, 10);
37
+ if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
38
+ } catch {
39
+ // Missing/unreadable pid file means no live daemon can be trusted.
40
+ }
41
+
42
+ const pidAlive = pid !== null && isProcessAlive(pid);
43
+ const running = pidAlive && socketPresent && libpqSocketPresent;
44
+ return {
45
+ running,
46
+ pid: pidAlive ? pid : null,
47
+ socketPresent,
48
+ libpqSocketPresent,
49
+ controlSocketDir,
50
+ controlSocketPath: socketPath,
51
+ libpqSocketPath,
52
+ pidLockPath,
53
+ reason: running ? null : explainProbeMiss({ pid, pidAlive, socketPresent, libpqSocketPresent }),
54
+ };
55
+ }
56
+
57
+ function explainProbeMiss({ pid, pidAlive, socketPresent, libpqSocketPresent }) {
58
+ if (pid === null && !socketPresent && !libpqSocketPresent) return 'no daemon';
59
+ if (pid !== null && !pidAlive) return 'stale pid';
60
+ if (!socketPresent) return 'control socket missing';
61
+ if (!libpqSocketPresent) return 'libpq socket missing';
62
+ return 'not running';
63
+ }
64
+
65
+ export function daemonClientOptions({
66
+ controlSocketDir = resolveControlSocketDir(),
67
+ database = 'postgres',
68
+ username = 'postgres',
69
+ } = {}) {
70
+ return {
71
+ host: controlSocketDir,
72
+ port: 5432,
73
+ database,
74
+ username,
75
+ password: '',
76
+ };
77
+ }
78
+
79
+ export function buildDaemonArgs({
80
+ dataDir,
81
+ ram = false,
82
+ logLevel,
83
+ noProvision = false,
84
+ listens = [],
85
+ pgvector = false,
86
+ } = {}) {
87
+ const args = ['daemon'];
88
+ if (dataDir) args.push('--data', dataDir);
89
+ if (ram) args.push('--ram');
90
+ if (logLevel) args.push('--log', logLevel);
91
+ if (noProvision) args.push('--no-provision');
92
+ if (pgvector) args.push('--pgvector');
93
+ for (const listen of Array.isArray(listens) ? listens : [listens]) {
94
+ if (listen) args.push('--listen', String(listen));
95
+ }
96
+ return args;
97
+ }
98
+
99
+ export async function ensureDaemon(options = {}) {
100
+ const controlSocketDir = options.controlSocketDir || resolveControlSocketDir();
101
+ const initial = probeDaemon({ controlSocketDir });
102
+ if (initial.running) return initial;
103
+
104
+ const bin = options.bin || resolveBundledCliBin();
105
+ const env = { ...process.env, ...envForControlSocketDir(controlSocketDir), ...(options.env || {}) };
106
+ const child = spawn(bin, buildDaemonArgs(options), {
107
+ detached: true,
108
+ stdio: 'ignore',
109
+ env,
110
+ });
111
+ child.unref();
112
+
113
+ const timeoutMs = options.timeoutMs || 16000;
114
+ const deadline = Date.now() + timeoutMs;
115
+ while (Date.now() < deadline) {
116
+ const state = probeDaemon({ controlSocketDir });
117
+ if (state.running) return state;
118
+ await new Promise((resolve) => setTimeout(resolve, 250));
119
+ }
120
+
121
+ const state = probeDaemon({ controlSocketDir });
122
+ const err = new Error(`pgserve daemon did not become ready within ${timeoutMs}ms (${state.reason})`);
123
+ err.code = 'EPGSERVE_DAEMON_TIMEOUT';
124
+ err.state = state;
125
+ throw err;
126
+ }
127
+
128
+ export function resolveBundledCliBin() {
129
+ return path.join(__dirname, '..', 'bin', 'pgserve-wrapper.cjs');
130
+ }
131
+
132
+ function envForControlSocketDir(controlSocketDir) {
133
+ if (path.basename(controlSocketDir) !== 'pgserve') {
134
+ throw new Error('ensureDaemon: controlSocketDir must be a pgserve runtime directory ending in /pgserve');
135
+ }
136
+ return { XDG_RUNTIME_DIR: path.dirname(controlSocketDir) };
137
+ }