pgserve 1.1.7 → 1.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
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",
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,50 +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
- const { execSync } = await import('node:child_process');
970
- const pgVersion = execSync(`${this.binaries.postgres} --version`, { encoding: 'utf-8' }).trim();
971
- const majorMatch = pgVersion.match(/PostgreSQL (\d+)/);
972
- const pgMajor = majorMatch ? majorMatch[1] : '17';
973
-
974
- // Detect architecture — fail explicitly on unsupported platforms
975
- const nodeArch = os.arch();
976
- let arch;
977
- if (nodeArch === 'x64') arch = 'amd64';
978
- else if (nodeArch === 'arm64') arch = 'arm64';
979
- else {
980
- this.logger.warn({ arch: nodeArch }, 'Unsupported architecture for pgvector auto-install. Supported: x64, arm64');
981
- 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));
982
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
+ }
983
1106
 
984
- // Download prebuilt pgvector .deb from apt.postgresql.org (HTTPS)
985
- // Version 0.8.1-2 update when new releases ship
986
- const debUrl = `https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-${pgMajor}-pgvector_0.8.1-2.pgdg%2B1_${arch}.deb`;
987
- 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
+ }
988
1124
 
989
- const res = await fetch(debUrl);
990
- if (!res.ok) throw new Error(`Download failed: ${res.status}`);
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...');
991
1130
 
992
- const buffer = Buffer.from(await res.arrayBuffer());
1131
+ const res = await fetch(debUrl);
1132
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
993
1133
 
994
- // Extract .deb (it's an ar archive containing data.tar.xz)
995
- const tmpDir = path.join(os.tmpdir(), `pgserve-pgvector-${process.pid}-${Date.now()}`);
996
- fs.mkdirSync(tmpDir, { recursive: true });
997
- const debPath = path.join(tmpDir, 'pgvector.deb');
998
- fs.writeFileSync(debPath, buffer);
1134
+ const buffer = Buffer.from(await res.arrayBuffer());
999
1135
 
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);
1141
+
1142
+ try {
1000
1143
  // Use dpkg-deb or ar to extract
1001
1144
  try {
1002
1145
  execSync(`dpkg-deb -x ${debPath} ${tmpDir}/extracted`, { stdio: 'pipe' });
@@ -1006,12 +1149,13 @@ export class PostgresManager {
1006
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' });
1007
1150
  }
1008
1151
 
1009
- // Copy .so file
1152
+ // Copy .so file — fail loudly if missing so we don't silently ship broken
1010
1153
  const soSrc = path.join(tmpDir, 'extracted', 'usr', 'lib', 'postgresql', pgMajor, 'lib', 'vector.so');
1011
- if (fs.existsSync(soSrc)) {
1012
- fs.copyFileSync(soSrc, vectorSo);
1013
- 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}`);
1014
1156
  }
1157
+ fs.copyFileSync(soSrc, vectorSo);
1158
+ this.logger.info({ path: vectorSo }, 'Installed vector.so');
1015
1159
 
1016
1160
  // Copy extension SQL + control files
1017
1161
  const extSrc = path.join(tmpDir, 'extracted', 'usr', 'share', 'postgresql', pgMajor, 'extension');
@@ -1037,29 +1181,55 @@ export class PostgresManager {
1037
1181
  this.logger.info('Patched vector.control with absolute module path');
1038
1182
  }
1039
1183
 
1040
- // 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
1041
1196
  fs.rmSync(tmpDir, { recursive: true, force: true });
1042
- this.logger.info('pgvector extension installed successfully');
1043
- } catch (error) {
1044
- this.logger.warn({ err: error.message }, 'Failed to install pgvector extension files (non-fatal)');
1045
1197
  }
1046
1198
  }
1047
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
+
1048
1216
  /**
1049
1217
  * Enable pgvector extension on a database
1050
- * 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.
1051
1223
  * @param {string} dbName - Database name to enable pgvector on
1052
1224
  */
1053
1225
  async enablePgvectorExtension(dbName) {
1054
- // Ensure extension files are installed first
1226
+ // Ensure extension files are installed first (proactive path)
1055
1227
  await this.ensurePgvectorFiles();
1056
1228
 
1057
1229
  const { SQL } = await import('bun');
1058
- let dbPool = null;
1059
1230
 
1060
- try {
1061
- // Create temporary connection to the specific database
1062
- dbPool = new SQL({
1231
+ const tryCreateExtension = async () => {
1232
+ const dbPool = new SQL({
1063
1233
  hostname: '127.0.0.1',
1064
1234
  port: this.port,
1065
1235
  database: dbName,
@@ -1069,17 +1239,47 @@ export class PostgresManager {
1069
1239
  idleTimeout: 5,
1070
1240
  connectionTimeout: 5,
1071
1241
  });
1242
+ try {
1243
+ await dbPool.unsafe('CREATE EXTENSION IF NOT EXISTS vector');
1244
+ } finally {
1245
+ await dbPool.close().catch(() => {});
1246
+ }
1247
+ };
1072
1248
 
1073
- // Enable pgvector extension
1074
- await dbPool.unsafe('CREATE EXTENSION IF NOT EXISTS vector');
1249
+ try {
1250
+ await tryCreateExtension();
1075
1251
  this.logger.info({ dbName }, 'pgvector extension enabled');
1252
+ return;
1076
1253
  } catch (error) {
1077
- // Log but don't fail database creation - pgvector might not be available
1078
- this.logger.warn({ dbName, err: error.message }, 'Failed to enable pgvector extension (non-fatal)');
1079
- } finally {
1080
- // Always close the temporary connection
1081
- if (dbPool) {
1082
- 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
+ );
1083
1283
  }
1084
1284
  }
1085
1285
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Regression test for pgvector auto-installer PG-major detection.
3
+ *
4
+ * `postgres --version` prints `postgres (PostgreSQL) 18.2`, so the regex that
5
+ * extracts the major version must tolerate the closing `)` between the
6
+ * product name and the number. An earlier pattern `/PostgreSQL (\d+)/`
7
+ * expected a digit immediately after `PostgreSQL ` and silently fell back to
8
+ * a hard-coded `'17'` default on PG14+, causing the wrong pgvector .deb to be
9
+ * downloaded and a later "incompatible library version mismatch" when
10
+ * `CREATE EXTENSION vector` was executed against a PG18 server.
11
+ *
12
+ * This test pins the corrected regex so the regression can't sneak back in.
13
+ */
14
+
15
+ import { test, expect, describe } from 'bun:test';
16
+ import { pgvectorMetaMatches } from '../src/postgres.js';
17
+
18
+ // Keep this in sync with `_detectPgMajor()` in src/postgres.js
19
+ const PG_VERSION_REGEX = /PostgreSQL\)?\s+(\d+)/;
20
+
21
+ function detectMajor(versionString) {
22
+ const match = versionString.match(PG_VERSION_REGEX);
23
+ return match ? match[1] : null;
24
+ }
25
+
26
+ describe('PG major version detection for pgvector auto-install', () => {
27
+ test('parses "postgres (PostgreSQL) X.Y" format (actual postgres --version output)', () => {
28
+ expect(detectMajor('postgres (PostgreSQL) 18.2')).toBe('18');
29
+ expect(detectMajor('postgres (PostgreSQL) 17.4')).toBe('17');
30
+ expect(detectMajor('postgres (PostgreSQL) 16.0')).toBe('16');
31
+ expect(detectMajor('postgres (PostgreSQL) 14.11')).toBe('14');
32
+ });
33
+
34
+ test('parses pre-release labels', () => {
35
+ expect(detectMajor('postgres (PostgreSQL) 18.2-beta.1')).toBe('18');
36
+ expect(detectMajor('postgres (PostgreSQL) 18devel')).toBe('18');
37
+ });
38
+
39
+ test('parses bare "PostgreSQL X" format (no parentheses)', () => {
40
+ expect(detectMajor('PostgreSQL 18.2')).toBe('18');
41
+ expect(detectMajor('PostgreSQL 17')).toBe('17');
42
+ });
43
+
44
+ test('returns null on unparseable input so caller can fail loudly', () => {
45
+ expect(detectMajor('')).toBeNull();
46
+ expect(detectMajor('not postgres')).toBeNull();
47
+ expect(detectMajor('mysql 8.0')).toBeNull();
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
+ });