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 +1 -1
- package/src/postgres.js +253 -53
- package/tests/pg-version-regex.test.js +129 -0
package/package.json
CHANGED
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
|
-
|
|
960
|
-
|
|
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
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
990
|
-
|
|
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
|
-
|
|
1131
|
+
const res = await fetch(debUrl);
|
|
1132
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
993
1133
|
|
|
994
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1074
|
-
await
|
|
1249
|
+
try {
|
|
1250
|
+
await tryCreateExtension();
|
|
1075
1251
|
this.logger.info({ dbName }, 'pgvector extension enabled');
|
|
1252
|
+
return;
|
|
1076
1253
|
} catch (error) {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
+
});
|