pgserve 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.genie/AGENTS.md CHANGED
@@ -5,6 +5,8 @@ description: Global agents (orchestration, QA, analysis, maintenance)
5
5
  github_url: https://github.com/namastexlabs/automagik-genie/tree/main/.genie
6
6
  ---
7
7
 
8
+ > **Shared rules in `~/.claude/rules/agent-bible.md`. Read it.**
9
+
8
10
  # Base Genie Agents
9
11
 
10
12
  **Global agents available across all collectives.**
@@ -11,6 +11,8 @@ forge:
11
11
  OPENCODE: {}
12
12
  ---
13
13
 
14
+ > **Shared rules in `~/.claude/rules/agent-bible.md`. Read it.**
15
+
14
16
  ## Framework Reference
15
17
 
16
18
  This agent uses the universal prompting framework documented in AGENTS.md §Prompting Standards Framework:
package/AGENTS.md CHANGED
@@ -1,3 +1,5 @@
1
+ > **Shared rules in `~/.claude/rules/agent-bible.md`. Read it.**
2
+
1
3
  # Genie Agent Framework
2
4
 
3
5
  ## Core Identity
@@ -53,8 +53,113 @@ if (!bunPath) {
53
53
  process.exit(1);
54
54
  }
55
55
 
56
+ // Pre-flight health check: verify bun can actually execute.
57
+ //
58
+ // When pgserve is installed via `bun install` (as a global or transitive dep),
59
+ // the nested `bun` npm package's postinstall can be skipped, leaving
60
+ // `@oven/bun-<platform>/bin/bun` empty. The bun stub at `node_modules/bun/bin/bun`
61
+ // then exits instantly with:
62
+ // Error: Bun's postinstall script was not run.
63
+ //
64
+ // pglite-server.js's TCP readiness poll can't distinguish this from a slow
65
+ // startup, so users see a confusing 30s timeout. Detect the specific error
66
+ // here, attempt the documented self-heal once (`node install.js`), and retry.
67
+ // If self-heal also fails, surface the real error instead of hanging later.
68
+ ensureBunHealthy(bunPath);
69
+
56
70
  const scriptPath = path.join(__dirname, 'pglite-server.js');
57
71
 
72
+ /**
73
+ * Verify the selected bun binary can execute. If it fails with the known
74
+ * "postinstall script was not run" signature, attempt a one-shot repair via
75
+ * the bun npm package's install.js. Throws (with a useful message) rather
76
+ * than letting pglite-server.js hang on the TCP readiness poll for 30s.
77
+ */
78
+ function ensureBunHealthy(bunExe) {
79
+ const probe = probeBun(bunExe);
80
+ if (probe.ok) return;
81
+
82
+ // Only attempt self-heal for the specific postinstall-not-run failure.
83
+ // Any other failure (corrupt binary, unsupported glibc, etc.) is surfaced
84
+ // as-is rather than silently papered over.
85
+ if (!isPostinstallMissingError(probe.output)) {
86
+ console.error('Error: bun runtime at', bunExe, 'failed to execute:');
87
+ console.error(probe.output || '(no output)');
88
+ process.exit(1);
89
+ }
90
+
91
+ const installJs = findBunInstallJs(bunExe);
92
+ if (!installJs) {
93
+ console.error('Error: bun runtime at', bunExe, 'is missing its platform binary,');
94
+ console.error('and the recovery script (node_modules/bun/install.js) could not be located.');
95
+ console.error('');
96
+ console.error('Try reinstalling pgserve, or run the fix manually:');
97
+ console.error(' cd <node_modules>/bun && node install.js');
98
+ process.exit(1);
99
+ }
100
+
101
+ console.error('[pgserve] bun runtime missing platform binary; attempting self-heal...');
102
+ try {
103
+ execSync(`node ${JSON.stringify(installJs)}`, { stdio: 'inherit' });
104
+ } catch {
105
+ // fall through to second probe
106
+ }
107
+
108
+ const second = probeBun(bunExe);
109
+ if (second.ok) {
110
+ console.error('[pgserve] bun runtime recovered.');
111
+ return;
112
+ }
113
+
114
+ console.error('Error: bun runtime still broken after self-heal attempt.');
115
+ console.error(second.output || '(no output)');
116
+ console.error('');
117
+ console.error('Manual fix:');
118
+ console.error(` cd ${path.dirname(path.dirname(installJs))}/bun && node install.js`);
119
+ console.error('');
120
+ console.error('Upstream bug: https://github.com/namastexlabs/pgserve/issues/22');
121
+ process.exit(1);
122
+ }
123
+
124
+ function probeBun(bunExe) {
125
+ try {
126
+ const out = execSync(`${JSON.stringify(bunExe)} --version`, {
127
+ stdio: ['ignore', 'pipe', 'pipe'],
128
+ timeout: 10000,
129
+ encoding: 'utf8'
130
+ });
131
+ return { ok: true, output: out };
132
+ } catch (err) {
133
+ const output = [err.stderr, err.stdout, err.message]
134
+ .filter(Boolean).map(String).join('\n');
135
+ return { ok: false, output };
136
+ }
137
+ }
138
+
139
+ function isPostinstallMissingError(output) {
140
+ return typeof output === 'string' &&
141
+ /Bun's postinstall script was not run/i.test(output);
142
+ }
143
+
144
+ function findBunInstallJs(bunExe) {
145
+ // Walk up from the bun binary toward a `bun` package dir containing install.js.
146
+ // Matches the wrapper's own location list - bun is always nested under a
147
+ // `bun` package directory (or its `bin/` subdir).
148
+ let cursor = path.dirname(path.resolve(bunExe));
149
+ for (let i = 0; i < 6; i++) {
150
+ const candidate = path.join(cursor, 'install.js');
151
+ if (fs.existsSync(candidate) && fs.existsSync(path.join(cursor, 'package.json'))) {
152
+ return candidate;
153
+ }
154
+ const nested = path.join(cursor, 'bun', 'install.js');
155
+ if (fs.existsSync(nested)) return nested;
156
+ const parent = path.dirname(cursor);
157
+ if (parent === cursor) break;
158
+ cursor = parent;
159
+ }
160
+ return null;
161
+ }
162
+
58
163
  // Platform-specific spawning strategy:
59
164
  // - Windows: Use pipes for explicit handle control (prevents EBUSY errors)
60
165
  // - Unix: Use inherit for simplicity (works fine)
package/knip.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "entry": ["src/index.js", "bin/pglite-server.js", "bin/pgserve-wrapper.cjs"],
4
4
  "project": ["src/**/*.js", "bin/**/*.js", "bin/**/*.cjs"],
5
5
  "ignore": ["tests/**", "helpers/**", "scripts/**"],
6
- "ignoreBinaries": ["scripts/test-npx.sh", "make"],
6
+ "ignoreBinaries": ["scripts/test-npx.sh", "scripts/test-bun-self-heal.sh", "make"],
7
7
  "ignoreDependencies": ["bun"],
8
8
  "ignoreExportsUsedInFile": true
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -19,7 +19,8 @@
19
19
  "lint:fix": "eslint src/ bin/ --fix",
20
20
  "deadcode": "knip",
21
21
  "test:npx": "scripts/test-npx.sh",
22
- "prepublishOnly": "npm run lint && npm run deadcode && npm run test:npx",
22
+ "test:bun-self-heal": "scripts/test-bun-self-heal.sh",
23
+ "prepublishOnly": "npm run lint && npm run deadcode && npm run test:npx && npm run test:bun-self-heal",
23
24
  "prepare": "husky"
24
25
  },
25
26
  "keywords": [
@@ -0,0 +1,163 @@
1
+ #!/bin/bash
2
+ # Regression test for https://github.com/namastexlabs/pgserve/issues/22
3
+ #
4
+ # When pgserve is installed via `bun install`, the nested `bun` npm package's
5
+ # postinstall can be skipped, leaving @oven/bun-<platform>/bin/bun empty.
6
+ # The bun stub then refuses to run with "Bun's postinstall script was not run".
7
+ # pgserve-wrapper.cjs must detect this and self-heal via `node install.js`.
8
+ #
9
+ # This test stages a synthetic broken install tree, runs the wrapper, and
10
+ # asserts that it recovers and spawns pglite-server.
11
+
12
+ set -e
13
+
14
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
15
+ WRAPPER="$REPO_ROOT/bin/pgserve-wrapper.cjs"
16
+
17
+ if [ ! -f "$WRAPPER" ]; then
18
+ echo "✗ wrapper not found: $WRAPPER"
19
+ exit 1
20
+ fi
21
+
22
+ # Use a real bun binary as the "recovered" payload so the healthy-path
23
+ # assertion is meaningful. Falls back to any bun on PATH.
24
+ REAL_BUN="${BUN_BIN:-$(command -v bun || true)}"
25
+ if [ -z "$REAL_BUN" ] || [ ! -x "$REAL_BUN" ]; then
26
+ echo "✗ bun runtime not found on PATH (set BUN_BIN to override)"
27
+ exit 1
28
+ fi
29
+
30
+ FIXTURE=$(mktemp -d)
31
+ trap "rm -rf $FIXTURE" EXIT
32
+
33
+ mkdir -p "$FIXTURE/node_modules/bun/bin"
34
+ mkdir -p "$FIXTURE/node_modules/@oven/bun-linux-x64/bin" # empty, simulating the bug
35
+ mkdir -p "$FIXTURE/node_modules/.bin"
36
+ mkdir -p "$FIXTURE/node_modules/pgserve/bin"
37
+
38
+ cp "$WRAPPER" "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs"
39
+
40
+ # Stub pglite-server so we can detect a successful spawn without needing
41
+ # postgres binaries in the fixture.
42
+ cat > "$FIXTURE/node_modules/pgserve/bin/pglite-server.js" <<'EOF'
43
+ console.log("pglite-server-spawned");
44
+ process.exit(0);
45
+ EOF
46
+
47
+ # Fake bun install.js: copies the real bun into the expected @oven location,
48
+ # mirroring what the real postinstall does.
49
+ cat > "$FIXTURE/node_modules/bun/install.js" <<EOF
50
+ const fs = require('fs');
51
+ const path = require('path');
52
+ const dst = path.resolve(__dirname, '..', '@oven', 'bun-linux-x64', 'bin', 'bun');
53
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
54
+ fs.copyFileSync('$REAL_BUN', dst);
55
+ fs.chmodSync(dst, 0o755);
56
+ console.log('[test] install.js populated', dst);
57
+ EOF
58
+ echo '{"name":"bun","version":"1.3.12"}' > "$FIXTURE/node_modules/bun/package.json"
59
+
60
+ # Broken bun stub: prints the postinstall error unless the @oven binary exists.
61
+ cat > "$FIXTURE/node_modules/bun/bin/bun" <<'EOF'
62
+ #!/bin/sh
63
+ SELF=$(readlink -f "$0")
64
+ TARGET="$(dirname "$SELF")/../../@oven/bun-linux-x64/bin/bun"
65
+ if [ ! -x "$TARGET" ]; then
66
+ echo "Error: Bun's postinstall script was not run." >&2
67
+ echo "" >&2
68
+ echo "To fix this, run the postinstall script manually:" >&2
69
+ echo " cd node_modules/bun && node install.js" >&2
70
+ exit 1
71
+ fi
72
+ exec "$TARGET" "$@"
73
+ EOF
74
+ chmod +x "$FIXTURE/node_modules/bun/bin/bun"
75
+
76
+ ln -s ../bun/bin/bun "$FIXTURE/node_modules/.bin/bun"
77
+
78
+ echo "=== Testing self-heal on broken install ==="
79
+ OUTPUT=$(node "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs" 2>&1)
80
+ EXIT=$?
81
+
82
+ if [ $EXIT -ne 0 ]; then
83
+ echo "✗ wrapper exited non-zero: $EXIT"
84
+ echo "$OUTPUT"
85
+ exit 1
86
+ fi
87
+
88
+ if ! echo "$OUTPUT" | grep -q "attempting self-heal"; then
89
+ echo "✗ wrapper did not attempt self-heal"
90
+ echo "$OUTPUT"
91
+ exit 1
92
+ fi
93
+
94
+ if ! echo "$OUTPUT" | grep -q "bun runtime recovered"; then
95
+ echo "✗ wrapper did not report recovery"
96
+ echo "$OUTPUT"
97
+ exit 1
98
+ fi
99
+
100
+ if ! echo "$OUTPUT" | grep -q "pglite-server-spawned"; then
101
+ echo "✗ pglite-server was not spawned after self-heal"
102
+ echo "$OUTPUT"
103
+ exit 1
104
+ fi
105
+
106
+ echo "✓ self-heal path: wrapper detected, repaired, and spawned pglite-server"
107
+
108
+ echo ""
109
+ echo "=== Testing healthy path is unaffected ==="
110
+ OUTPUT=$(node "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs" 2>&1)
111
+ EXIT=$?
112
+
113
+ if [ $EXIT -ne 0 ]; then
114
+ echo "✗ wrapper exited non-zero on healthy path: $EXIT"
115
+ echo "$OUTPUT"
116
+ exit 1
117
+ fi
118
+
119
+ if echo "$OUTPUT" | grep -q "self-heal\|recovered"; then
120
+ echo "✗ wrapper logged self-heal messages on a healthy install"
121
+ echo "$OUTPUT"
122
+ exit 1
123
+ fi
124
+
125
+ if ! echo "$OUTPUT" | grep -q "pglite-server-spawned"; then
126
+ echo "✗ pglite-server was not spawned on healthy path"
127
+ echo "$OUTPUT"
128
+ exit 1
129
+ fi
130
+
131
+ echo "✓ healthy path: wrapper was silent and spawned pglite-server directly"
132
+
133
+ echo ""
134
+ echo "=== Testing non-postinstall errors surface raw ==="
135
+ # Replace stub with one that emits an unrelated error.
136
+ cat > "$FIXTURE/node_modules/bun/bin/bun" <<'EOF'
137
+ #!/bin/sh
138
+ echo "Error: GLIBC_2.99 not found (libc mismatch)" >&2
139
+ exit 127
140
+ EOF
141
+ chmod +x "$FIXTURE/node_modules/bun/bin/bun"
142
+
143
+ # Clear the @oven healed binary so the stub is what runs.
144
+ rm -f "$FIXTURE/node_modules/@oven/bun-linux-x64/bin/bun"
145
+
146
+ OUTPUT=$(node "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs" 2>&1 || true)
147
+
148
+ if echo "$OUTPUT" | grep -q "self-heal"; then
149
+ echo "✗ wrapper tried self-heal for a non-postinstall error"
150
+ echo "$OUTPUT"
151
+ exit 1
152
+ fi
153
+
154
+ if ! echo "$OUTPUT" | grep -q "GLIBC_2.99"; then
155
+ echo "✗ wrapper did not surface the real error message"
156
+ echo "$OUTPUT"
157
+ exit 1
158
+ fi
159
+
160
+ echo "✓ unrelated-error path: wrapper surfaced the raw error without self-heal"
161
+
162
+ echo ""
163
+ echo "=== bun self-heal test PASSED ==="
package/src/postgres.js CHANGED
@@ -385,6 +385,27 @@ function buildCommand(cmd, libDir) {
385
385
  return cmd;
386
386
  }
387
387
 
388
+ /**
389
+ * Compare a persisted pgvector install metadata record against the currently
390
+ * detected PG major and postgres binary path. Returns `true` only when the
391
+ * metadata is a plain object, has the expected shape, and matches the
392
+ * runtime environment. Used by the pgvector auto-heal path to decide whether
393
+ * an already-present `vector.so` is safe to reuse or must be replaced.
394
+ *
395
+ * Exported for unit tests — keep this pure (no I/O, no `this`).
396
+ *
397
+ * @param {unknown} meta - Value parsed from `vector.meta.json`, or null.
398
+ * @param {{pgMajor: string, postgresPath: string}} runtime - Current env.
399
+ * @returns {boolean}
400
+ */
401
+ export function pgvectorMetaMatches(meta, runtime) {
402
+ if (!meta || typeof meta !== 'object') return false;
403
+ if (typeof meta.pgMajor !== 'string' || meta.pgMajor !== runtime.pgMajor) return false;
404
+ // postgresPath is optional in older metadata — only compare when present.
405
+ if (meta.postgresPath && meta.postgresPath !== runtime.postgresPath) return false;
406
+ return true;
407
+ }
408
+
388
409
  export class PostgresManager {
389
410
  constructor(options = {}) {
390
411
  this.dataDir = options.dataDir || null; // null = memory mode (temp dir)
@@ -953,62 +974,172 @@ export class PostgresManager {
953
974
  return;
954
975
  }
955
976
 
977
+ const paths = this._pgvectorPaths();
978
+
979
+ let pgMajor;
980
+ try {
981
+ pgMajor = await this._detectPgMajor();
982
+ } catch (error) {
983
+ this.logger.warn({ err: error.message }, 'Failed to detect PG major version for pgvector install (non-fatal)');
984
+ return;
985
+ }
986
+
987
+ // Proactive staleness check: if vector.so and vector.control both exist,
988
+ // trust them ONLY if the sidecar metadata file matches the current PG
989
+ // major and the current postgres binary path. Any mismatch — including a
990
+ // missing metadata file from a pre-auto-heal install — triggers a clean
991
+ // reinstall. This is what heals existing deployments that were shipped
992
+ // with the regex bug: on first run after upgrading pgserve, the stale
993
+ // PG17 .so will be detected (no metadata → mismatch) and replaced.
994
+ const filesPresent = fs.existsSync(paths.vectorSo) && fs.existsSync(paths.vectorControl);
995
+ if (filesPresent) {
996
+ const meta = this._readPgvectorMeta(paths.vectorMeta);
997
+ if (pgvectorMetaMatches(meta, { pgMajor, postgresPath: this.binaries.postgres })) {
998
+ return;
999
+ }
1000
+ this.logger.warn(
1001
+ {
1002
+ detectedPgMajor: pgMajor,
1003
+ metaPgMajor: meta?.pgMajor ?? null,
1004
+ metaPresent: meta !== null,
1005
+ vectorMeta: paths.vectorMeta,
1006
+ },
1007
+ 'pgvector install metadata missing or mismatched — auto-healing stale install'
1008
+ );
1009
+ this._removePgvectorFiles(paths);
1010
+ } else {
1011
+ this.logger.info('pgvector extension files not found — downloading prebuilt binary...');
1012
+ }
1013
+
1014
+ try {
1015
+ await this._installPgvectorFromDeb({ pgMajor, ...paths });
1016
+ } catch (error) {
1017
+ this.logger.warn({ err: error.message }, 'Failed to install pgvector extension files (non-fatal)');
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * Compute the canonical pgvector file paths for this PG install.
1023
+ * Extracted so proactive install, reactive heal, and cleanup all agree.
1024
+ */
1025
+ _pgvectorPaths() {
956
1026
  const libDir = this.binaries.libDir;
957
1027
  const binDir = this.binaries.binDir;
958
1028
  const extDir = path.join(path.dirname(binDir), 'share', 'postgresql', 'extension');
959
- const vectorSo = path.join(libDir, 'vector.so');
960
- const vectorControl = path.join(extDir, 'vector.control');
1029
+ return {
1030
+ libDir,
1031
+ extDir,
1032
+ vectorSo: path.join(libDir, 'vector.so'),
1033
+ vectorControl: path.join(extDir, 'vector.control'),
1034
+ vectorMeta: path.join(libDir, 'vector.meta.json'),
1035
+ };
1036
+ }
961
1037
 
962
- // Already installed
963
- if (fs.existsSync(vectorSo) && fs.existsSync(vectorControl)) return;
1038
+ /**
1039
+ * Parse `postgres --version` output and return the major version string.
1040
+ * Throws on unparseable output so callers can fail loudly instead of
1041
+ * silently downloading the wrong pgvector .deb.
1042
+ */
1043
+ async _detectPgMajor() {
1044
+ // `postgres --version` output is `postgres (PostgreSQL) 18.2`, so the
1045
+ // regex must tolerate the `)` that separates the product name from the
1046
+ // version number. The previous pattern `/PostgreSQL (\d+)/` expected a
1047
+ // digit immediately after `PostgreSQL ` and silently fell back to '17'
1048
+ // on PG 14+, causing the wrong pgvector .deb to be downloaded and a
1049
+ // later "incompatible library version mismatch" at CREATE EXTENSION time.
1050
+ const { execSync } = await import('node:child_process');
1051
+ const pgVersion = execSync(`${this.binaries.postgres} --version`, { encoding: 'utf-8' }).trim();
1052
+ const majorMatch = pgVersion.match(/PostgreSQL\)?\s+(\d+)/);
1053
+ if (!majorMatch) {
1054
+ throw new Error(`Could not detect PostgreSQL major version from: ${JSON.stringify(pgVersion)}`);
1055
+ }
1056
+ this.logger.debug({ pgMajor: majorMatch[1], pgVersion }, 'Detected PostgreSQL major version');
1057
+ return majorMatch[1];
1058
+ }
964
1059
 
965
- this.logger.info('pgvector extension files not found — downloading prebuilt binary...');
1060
+ /**
1061
+ * Read and parse the pgvector install metadata sidecar, if present.
1062
+ * Returns null on missing file or any parse error (caller treats both as
1063
+ * "unknown, needs reinstall").
1064
+ */
1065
+ _readPgvectorMeta(metaPath) {
1066
+ try {
1067
+ if (!fs.existsSync(metaPath)) return null;
1068
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
1069
+ } catch {
1070
+ return null;
1071
+ }
1072
+ }
966
1073
 
1074
+ /**
1075
+ * Write the pgvector install metadata sidecar. Best-effort — failure to
1076
+ * write metadata should not crash the install; it just means the next
1077
+ * startup will trigger a re-heal (idempotent).
1078
+ */
1079
+ _writePgvectorMeta(metaPath, data) {
967
1080
  try {
968
- // Detect PG major version from the postgres binary.
969
- // `postgres --version` output is `postgres (PostgreSQL) 18.2`, so the
970
- // regex must tolerate the `)` that separates the product name from the
971
- // version number. The previous pattern `/PostgreSQL (\d+)/` expected a
972
- // digit immediately after `PostgreSQL ` and silently fell back to '17'
973
- // on PG 14+, causing the wrong pgvector .deb to be downloaded and a
974
- // later "incompatible library version mismatch" at CREATE EXTENSION time.
975
- const { execSync } = await import('node:child_process');
976
- const pgVersion = execSync(`${this.binaries.postgres} --version`, { encoding: 'utf-8' }).trim();
977
- const majorMatch = pgVersion.match(/PostgreSQL\)?\s+(\d+)/);
978
- if (!majorMatch) {
979
- throw new Error(
980
- `Could not detect PostgreSQL major version from: ${JSON.stringify(pgVersion)}`
981
- );
982
- }
983
- const pgMajor = majorMatch[1];
984
- this.logger.info({ pgMajor, pgVersion }, 'Detected PostgreSQL major version for pgvector install');
985
-
986
- // Detect architecture — fail explicitly on unsupported platforms
987
- const nodeArch = os.arch();
988
- let arch;
989
- if (nodeArch === 'x64') arch = 'amd64';
990
- else if (nodeArch === 'arm64') arch = 'arm64';
991
- else {
992
- this.logger.warn({ arch: nodeArch }, 'Unsupported architecture for pgvector auto-install. Supported: x64, arm64');
993
- return;
1081
+ fs.writeFileSync(metaPath, JSON.stringify(data, null, 2));
1082
+ } catch (error) {
1083
+ this.logger.warn({ err: error.message, metaPath }, 'Failed to write pgvector metadata sidecar');
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Remove all pgvector files (vector.so, vector.meta.json, and any
1089
+ * vector*.sql / vector.control files in the extension dir) so that a
1090
+ * subsequent install starts from a clean slate. Used by the auto-heal
1091
+ * paths — never call this while PG is mid-transaction.
1092
+ */
1093
+ _removePgvectorFiles(paths) {
1094
+ const { libDir, extDir, vectorSo, vectorMeta } = paths;
1095
+ const toRemove = [vectorSo, vectorMeta];
1096
+ if (fs.existsSync(extDir)) {
1097
+ for (const f of fs.readdirSync(extDir)) {
1098
+ if (f.startsWith('vector')) toRemove.push(path.join(extDir, f));
994
1099
  }
1100
+ }
1101
+ for (const p of toRemove) {
1102
+ try { fs.rmSync(p, { force: true }); } catch { /* ignore */ }
1103
+ }
1104
+ this.logger.info({ libDir, extDir }, 'Removed stale pgvector files');
1105
+ }
995
1106
 
996
- // Download prebuilt pgvector .deb from apt.postgresql.org (HTTPS)
997
- // Version 0.8.1-2 update when new releases ship
998
- const debUrl = `https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-${pgMajor}-pgvector_0.8.1-2.pgdg%2B1_${arch}.deb`;
999
- this.logger.info({ url: debUrl }, 'Downloading pgvector...');
1107
+ /**
1108
+ * Download + extract + install pgvector from apt.postgresql.org for the
1109
+ * given PG major. Writes a metadata sidecar on success so future starts
1110
+ * can detect staleness without re-downloading.
1111
+ */
1112
+ async _installPgvectorFromDeb({ pgMajor, extDir, vectorSo, vectorControl, vectorMeta }) {
1113
+ const { execSync } = await import('node:child_process');
1114
+
1115
+ // Detect architecture — fail explicitly on unsupported platforms
1116
+ const nodeArch = os.arch();
1117
+ let arch;
1118
+ if (nodeArch === 'x64') arch = 'amd64';
1119
+ else if (nodeArch === 'arm64') arch = 'arm64';
1120
+ else {
1121
+ this.logger.warn({ arch: nodeArch }, 'Unsupported architecture for pgvector auto-install. Supported: x64, arm64');
1122
+ return;
1123
+ }
1124
+
1125
+ // Download prebuilt pgvector .deb from apt.postgresql.org (HTTPS)
1126
+ // Version 0.8.1-2 — update when new releases ship
1127
+ const pgvectorVersion = '0.8.1-2';
1128
+ const debUrl = `https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-${pgMajor}-pgvector_${pgvectorVersion}.pgdg%2B1_${arch}.deb`;
1129
+ this.logger.info({ url: debUrl, pgMajor }, 'Downloading pgvector...');
1000
1130
 
1001
- const res = await fetch(debUrl);
1002
- if (!res.ok) throw new Error(`Download failed: ${res.status}`);
1131
+ const res = await fetch(debUrl);
1132
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
1003
1133
 
1004
- const buffer = Buffer.from(await res.arrayBuffer());
1134
+ const buffer = Buffer.from(await res.arrayBuffer());
1005
1135
 
1006
- // Extract .deb (it's an ar archive containing data.tar.xz)
1007
- const tmpDir = path.join(os.tmpdir(), `pgserve-pgvector-${process.pid}-${Date.now()}`);
1008
- fs.mkdirSync(tmpDir, { recursive: true });
1009
- const debPath = path.join(tmpDir, 'pgvector.deb');
1010
- fs.writeFileSync(debPath, buffer);
1136
+ // Extract .deb (it's an ar archive containing data.tar.xz)
1137
+ const tmpDir = path.join(os.tmpdir(), `pgserve-pgvector-${process.pid}-${Date.now()}`);
1138
+ fs.mkdirSync(tmpDir, { recursive: true });
1139
+ const debPath = path.join(tmpDir, 'pgvector.deb');
1140
+ fs.writeFileSync(debPath, buffer);
1011
1141
 
1142
+ try {
1012
1143
  // Use dpkg-deb or ar to extract
1013
1144
  try {
1014
1145
  execSync(`dpkg-deb -x ${debPath} ${tmpDir}/extracted`, { stdio: 'pipe' });
@@ -1018,12 +1149,13 @@ export class PostgresManager {
1018
1149
  execSync(`cd ${tmpDir} && ar x pgvector.deb && tar xf data.tar.* -C ${tmpDir}/extracted 2>/dev/null || tar xf data.tar.xz -C ${tmpDir}/extracted`, { stdio: 'pipe' });
1019
1150
  }
1020
1151
 
1021
- // Copy .so file
1152
+ // Copy .so file — fail loudly if missing so we don't silently ship broken
1022
1153
  const soSrc = path.join(tmpDir, 'extracted', 'usr', 'lib', 'postgresql', pgMajor, 'lib', 'vector.so');
1023
- if (fs.existsSync(soSrc)) {
1024
- fs.copyFileSync(soSrc, vectorSo);
1025
- this.logger.info({ path: vectorSo }, 'Installed vector.so');
1154
+ if (!fs.existsSync(soSrc)) {
1155
+ throw new Error(`Extracted .deb missing expected vector.so at ${soSrc}`);
1026
1156
  }
1157
+ fs.copyFileSync(soSrc, vectorSo);
1158
+ this.logger.info({ path: vectorSo }, 'Installed vector.so');
1027
1159
 
1028
1160
  // Copy extension SQL + control files
1029
1161
  const extSrc = path.join(tmpDir, 'extracted', 'usr', 'share', 'postgresql', pgMajor, 'extension');
@@ -1049,29 +1181,55 @@ export class PostgresManager {
1049
1181
  this.logger.info('Patched vector.control with absolute module path');
1050
1182
  }
1051
1183
 
1052
- // Cleanup
1184
+ // Write metadata sidecar so future starts can detect staleness
1185
+ this._writePgvectorMeta(vectorMeta, {
1186
+ pgMajor,
1187
+ pgvectorVersion,
1188
+ sourceUrl: debUrl,
1189
+ postgresPath: this.binaries.postgres,
1190
+ installedAt: new Date().toISOString(),
1191
+ });
1192
+
1193
+ this.logger.info({ pgMajor, pgvectorVersion }, 'pgvector extension installed successfully');
1194
+ } finally {
1195
+ // Always clean up tmpdir, even on failure
1053
1196
  fs.rmSync(tmpDir, { recursive: true, force: true });
1054
- this.logger.info('pgvector extension installed successfully');
1055
- } catch (error) {
1056
- this.logger.warn({ err: error.message }, 'Failed to install pgvector extension files (non-fatal)');
1057
1197
  }
1058
1198
  }
1059
1199
 
1200
+ /**
1201
+ * Tear down an existing pgvector install and reinstall from scratch.
1202
+ * Called reactively when CREATE EXTENSION surfaces an ABI mismatch —
1203
+ * this is the last-resort heal for deployments that somehow bypassed
1204
+ * the proactive staleness check (e.g. metadata file got corrupted, or
1205
+ * the files were placed by an older pgserve that didn't write metadata).
1206
+ */
1207
+ async _healStalePgvector() {
1208
+ if (!this.binaries?.libDir || os.platform() !== 'linux') return;
1209
+ const paths = this._pgvectorPaths();
1210
+ this._removePgvectorFiles(paths);
1211
+ // _doEnsurePgvectorFiles is serialized via _pgvectorInstallPromise;
1212
+ // this call goes through the mutex wrapper to stay race-safe.
1213
+ await this.ensurePgvectorFiles();
1214
+ }
1215
+
1060
1216
  /**
1061
1217
  * Enable pgvector extension on a database
1062
- * Creates a temporary connection to the specific database to run CREATE EXTENSION
1218
+ * Creates a temporary connection to the specific database to run CREATE EXTENSION.
1219
+ * If the CREATE hits an ABI mismatch (stale vector.so from an older pgserve
1220
+ * install that shipped the wrong PG major), auto-heal the install and retry
1221
+ * once. This is the reactive safety net for deployments that already have a
1222
+ * broken vector.so on disk when this version of pgserve first starts.
1063
1223
  * @param {string} dbName - Database name to enable pgvector on
1064
1224
  */
1065
1225
  async enablePgvectorExtension(dbName) {
1066
- // Ensure extension files are installed first
1226
+ // Ensure extension files are installed first (proactive path)
1067
1227
  await this.ensurePgvectorFiles();
1068
1228
 
1069
1229
  const { SQL } = await import('bun');
1070
- let dbPool = null;
1071
1230
 
1072
- try {
1073
- // Create temporary connection to the specific database
1074
- dbPool = new SQL({
1231
+ const tryCreateExtension = async () => {
1232
+ const dbPool = new SQL({
1075
1233
  hostname: '127.0.0.1',
1076
1234
  port: this.port,
1077
1235
  database: dbName,
@@ -1081,17 +1239,47 @@ export class PostgresManager {
1081
1239
  idleTimeout: 5,
1082
1240
  connectionTimeout: 5,
1083
1241
  });
1242
+ try {
1243
+ await dbPool.unsafe('CREATE EXTENSION IF NOT EXISTS vector');
1244
+ } finally {
1245
+ await dbPool.close().catch(() => {});
1246
+ }
1247
+ };
1084
1248
 
1085
- // Enable pgvector extension
1086
- await dbPool.unsafe('CREATE EXTENSION IF NOT EXISTS vector');
1249
+ try {
1250
+ await tryCreateExtension();
1087
1251
  this.logger.info({ dbName }, 'pgvector extension enabled');
1252
+ return;
1088
1253
  } catch (error) {
1089
- // Log but don't fail database creation - pgvector might not be available
1090
- this.logger.warn({ dbName, err: error.message }, 'Failed to enable pgvector extension (non-fatal)');
1091
- } finally {
1092
- // Always close the temporary connection
1093
- if (dbPool) {
1094
- await dbPool.close().catch(() => {});
1254
+ const msg = error?.message || '';
1255
+ // Postgres surfaces stale .so as "incompatible library version" or
1256
+ // "version mismatch" depending on the nature of the ABI break.
1257
+ // PG_MODULE_MAGIC mismatches show the same symptoms.
1258
+ const abiMismatch = /version mismatch|incompatible library version|PG_MODULE_MAGIC/i.test(msg);
1259
+ if (!abiMismatch) {
1260
+ this.logger.warn({ dbName, err: msg }, 'Failed to enable pgvector extension (non-fatal)');
1261
+ return;
1262
+ }
1263
+
1264
+ this.logger.warn(
1265
+ { dbName, err: msg },
1266
+ 'pgvector ABI mismatch detected — auto-healing stale install and retrying'
1267
+ );
1268
+ try {
1269
+ await this._healStalePgvector();
1270
+ } catch (healError) {
1271
+ this.logger.error({ dbName, err: healError.message }, 'pgvector auto-heal failed during reinstall');
1272
+ return;
1273
+ }
1274
+
1275
+ try {
1276
+ await tryCreateExtension();
1277
+ this.logger.info({ dbName }, 'pgvector auto-heal successful — extension enabled');
1278
+ } catch (retryError) {
1279
+ this.logger.error(
1280
+ { dbName, err: retryError.message },
1281
+ 'pgvector still failing after auto-heal — manual intervention required'
1282
+ );
1095
1283
  }
1096
1284
  }
1097
1285
  }
@@ -13,8 +13,9 @@
13
13
  */
14
14
 
15
15
  import { test, expect, describe } from 'bun:test';
16
+ import { pgvectorMetaMatches } from '../src/postgres.js';
16
17
 
17
- // Keep this in sync with `ensurePgvectorFiles()` in src/postgres.js
18
+ // Keep this in sync with `_detectPgMajor()` in src/postgres.js
18
19
  const PG_VERSION_REGEX = /PostgreSQL\)?\s+(\d+)/;
19
20
 
20
21
  function detectMajor(versionString) {
@@ -46,3 +47,83 @@ describe('PG major version detection for pgvector auto-install', () => {
46
47
  expect(detectMajor('mysql 8.0')).toBeNull();
47
48
  });
48
49
  });
50
+
51
+ /**
52
+ * Staleness detection tests for the pgvector auto-heal path.
53
+ *
54
+ * `pgvectorMetaMatches` decides whether an already-present vector.so on
55
+ * disk can be trusted (return true → reuse) or must be torn down and
56
+ * reinstalled (return false → heal). Getting this wrong in either
57
+ * direction is a production bug:
58
+ *
59
+ * - False positive (matches when it shouldn't) → stale PG17 .so stays on
60
+ * disk, CREATE EXTENSION dies with "incompatible library version" on
61
+ * PG18, brain-ingest blows up mid-run.
62
+ * - False negative (doesn't match when it should) → pgserve re-downloads
63
+ * pgvector on every start, wasting bandwidth and triggering
64
+ * apt.postgresql.org rate limits.
65
+ *
66
+ * These tests pin the exact matching semantics so the auto-heal doesn't
67
+ * silently regress.
68
+ */
69
+ describe('pgvectorMetaMatches — pgvector install staleness detection', () => {
70
+ const RUNTIME = {
71
+ pgMajor: '18',
72
+ postgresPath: '/home/user/.pgserve/bin/linux-x64/bin/postgres',
73
+ };
74
+
75
+ test('matches when metadata pgMajor and postgresPath agree with runtime', () => {
76
+ const meta = {
77
+ pgMajor: '18',
78
+ pgvectorVersion: '0.8.1-2',
79
+ postgresPath: '/home/user/.pgserve/bin/linux-x64/bin/postgres',
80
+ installedAt: '2026-04-10T18:00:00.000Z',
81
+ };
82
+ expect(pgvectorMetaMatches(meta, RUNTIME)).toBe(true);
83
+ });
84
+
85
+ test('matches when postgresPath is absent (older metadata format)', () => {
86
+ const meta = { pgMajor: '18', pgvectorVersion: '0.8.1-2' };
87
+ expect(pgvectorMetaMatches(meta, RUNTIME)).toBe(true);
88
+ });
89
+
90
+ test('rejects when pgMajor differs — this is the PG17→PG18 regression we are healing', () => {
91
+ const stalePg17Meta = {
92
+ pgMajor: '17',
93
+ pgvectorVersion: '0.8.1-2',
94
+ postgresPath: '/home/user/.pgserve/bin/linux-x64/bin/postgres',
95
+ };
96
+ expect(pgvectorMetaMatches(stalePg17Meta, RUNTIME)).toBe(false);
97
+ });
98
+
99
+ test('rejects when postgresPath points at a different binary (pgserve upgraded)', () => {
100
+ const meta = {
101
+ pgMajor: '18',
102
+ postgresPath: '/opt/old-pgserve/bin/postgres',
103
+ };
104
+ expect(pgvectorMetaMatches(meta, RUNTIME)).toBe(false);
105
+ });
106
+
107
+ test('rejects null metadata (pre-auto-heal install without sidecar)', () => {
108
+ // This is the case that heals every existing broken deployment: they
109
+ // have vector.so on disk but no vector.meta.json, so match returns
110
+ // false → reinstall fires.
111
+ expect(pgvectorMetaMatches(null, RUNTIME)).toBe(false);
112
+ });
113
+
114
+ test('rejects non-object metadata (corrupted sidecar)', () => {
115
+ expect(pgvectorMetaMatches('18', RUNTIME)).toBe(false);
116
+ expect(pgvectorMetaMatches(42, RUNTIME)).toBe(false);
117
+ expect(pgvectorMetaMatches([], RUNTIME)).toBe(false);
118
+ });
119
+
120
+ test('rejects metadata missing pgMajor field', () => {
121
+ expect(pgvectorMetaMatches({ pgvectorVersion: '0.8.1' }, RUNTIME)).toBe(false);
122
+ });
123
+
124
+ test('rejects metadata where pgMajor is not a string', () => {
125
+ // JSON could hand us a number — match must be strict about type to
126
+ // avoid `18 == '18'` false positives masking a corrupted file.
127
+ expect(pgvectorMetaMatches({ pgMajor: 18 }, RUNTIME)).toBe(false);
128
+ });
129
+ });