@happyvertical/sql 0.102.2 → 0.102.4
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/AGENT.md +1 -1
- package/README.md +99 -4
- package/dist/chunks/{duckdb-DfikVgov.js → duckdb-LMyfKZGi.js} +3 -3
- package/dist/chunks/{duckdb-DfikVgov.js.map → duckdb-LMyfKZGi.js.map} +1 -1
- package/dist/chunks/postgres-dialect-Bfswr4Vs.js +2728 -0
- package/dist/chunks/postgres-dialect-Bfswr4Vs.js.map +1 -0
- package/dist/chunks/{sqlite-Cz2NMiDs.js → sqlite-0PMv1va2.js} +5 -5
- package/dist/chunks/{sqlite-Cz2NMiDs.js.map → sqlite-0PMv1va2.js.map} +1 -1
- package/dist/chunks/{sqlite-connection-cache-BAakj2HO.js → sqlite-connection-cache-ChWav9iY.js} +2 -2
- package/dist/chunks/{sqlite-connection-cache-BAakj2HO.js.map → sqlite-connection-cache-ChWav9iY.js.map} +1 -1
- package/dist/chunks/{sqlite-native-ByGwGEbS.js → sqlite-native--z4BnolL.js} +4 -4
- package/dist/chunks/{sqlite-native-ByGwGEbS.js.map → sqlite-native--z4BnolL.js.map} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -1365
- package/dist/index.js.map +1 -1
- package/dist/pglite.d.ts +150 -0
- package/dist/pglite.d.ts.map +1 -0
- package/dist/pglite.js +339 -0
- package/dist/pglite.js.map +1 -0
- package/dist/postgres.d.ts.map +1 -1
- package/dist/shared/postgres-dialect.d.ts +153 -0
- package/dist/shared/postgres-dialect.d.ts.map +1 -0
- package/dist/shared/types.d.ts +4 -4
- package/metadata.json +1 -0
- package/package.json +13 -2
- package/dist/chunks/connection-cache-Da5UcY5u.js +0 -1305
- package/dist/chunks/connection-cache-Da5UcY5u.js.map +0 -1
- package/dist/chunks/types-CwWJ-yju.js +0 -139
- package/dist/chunks/types-CwWJ-yju.js.map +0 -1
|
@@ -0,0 +1,2728 @@
|
|
|
1
|
+
import { DatabaseError } from "@happyvertical/utils";
|
|
2
|
+
//#region src/shared/utils.ts
|
|
3
|
+
/**
|
|
4
|
+
* Shared SQL utilities that work in both browser and Node.js environments
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Formats a database error to include its useful non-secret details.
|
|
8
|
+
*
|
|
9
|
+
* Database drivers often include additional properties beyond just `message`:
|
|
10
|
+
* - PostgreSQL: code, detail, hint, severity
|
|
11
|
+
* - DuckDB: may include additional context
|
|
12
|
+
* - SQLite/LibSQL: may include errno, code
|
|
13
|
+
*
|
|
14
|
+
* This function extracts an allowlist of driver properties and redacts
|
|
15
|
+
* credential-shaped text. When parameter values are available, prefer
|
|
16
|
+
* {@link wrapDatabaseError}; it also redacts values echoed by the driver.
|
|
17
|
+
*
|
|
18
|
+
* @param error - The caught error object
|
|
19
|
+
* @returns Formatted error string with all available error details
|
|
20
|
+
*/
|
|
21
|
+
function formatDbError(error) {
|
|
22
|
+
return formatDbErrorWithSecrets(error, []);
|
|
23
|
+
}
|
|
24
|
+
var DRIVER_ERROR_FIELDS = [
|
|
25
|
+
"code",
|
|
26
|
+
"detail",
|
|
27
|
+
"hint",
|
|
28
|
+
"severity",
|
|
29
|
+
"errno"
|
|
30
|
+
];
|
|
31
|
+
var SENSITIVE_CONTEXT_KEY = /^(?:args?|parameters?|params?|queries|query|sql|values?)$/i;
|
|
32
|
+
var CREDENTIAL_KEY_PART = /^(?:credential|credentials|passphrase|password|pwd|secret|token)$/i;
|
|
33
|
+
var CREDENTIAL_KEY_QUALIFIER = /^(?:access|api|auth|client|private|refresh|service|signing)$/i;
|
|
34
|
+
var DATABASE_USER_KEY = /^(?:user(?:name)?|(?:database|db|pg|postgres|postgresql)[._-]?user(?:name)?)$/i;
|
|
35
|
+
var DATABASE_URL_USERINFO = /\b((?:https?|libsql|postgres|postgresql):\/\/)[^\s/@]+@/giu;
|
|
36
|
+
var DATABASE_URL = /\b(?:https?|libsql|postgres|postgresql):\/\/[^\s"'`<>()]+/giu;
|
|
37
|
+
var CREDENTIAL_ASSIGNMENT = /(?<![\p{L}\p{N}_-])(["']?)([\p{L}\p{N}_.-]+(?: key)?)\1(\s*[:=]\s*)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s,;}\]]+)/giu;
|
|
38
|
+
var BEARER_CREDENTIAL = /\bbearer\s+[^\s,;]+/giu;
|
|
39
|
+
var SQL_NUMERIC_LITERAL = /(?<![\p{L}\p{N}_$])[-+]?(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)(?:e[-+]?\d(?:_?\d)*)?(?![\p{L}\p{N}_$])/giu;
|
|
40
|
+
var POSTGRES_KEY_VALUE = /(\bKey\s*\([^\r\n)]*\)\s*=\s*\()([\s\S]*?)(\)\s*(?:already exists|is duplicated)\b)/giu;
|
|
41
|
+
var POSTGRES_FAILING_ROW = /(\bFailing row contains\s*\()([\s\S]*)(\)(?:\.|$))/giu;
|
|
42
|
+
var CAST_INPUT_VALUE = /(\bwith value\s+)([\s\S]*?)(\s+(?:can't|cannot)\s+be cast\b)/giu;
|
|
43
|
+
function isCredentialKey(key) {
|
|
44
|
+
if (DATABASE_USER_KEY.test(key)) return true;
|
|
45
|
+
const parts = key.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
46
|
+
if (parts.length === 1 && /^(?:auth|credential|key|pass)$/i.test(parts[0])) return true;
|
|
47
|
+
if (parts.some((part) => CREDENTIAL_KEY_PART.test(part))) return true;
|
|
48
|
+
return parts.some((part, index) => /^(?:key|auth)$/i.test(part) && index > 0 && CREDENTIAL_KEY_QUALIFIER.test(parts[index - 1]));
|
|
49
|
+
}
|
|
50
|
+
function toDriverError(error) {
|
|
51
|
+
return error && (typeof error === "object" || typeof error === "function") ? error : {};
|
|
52
|
+
}
|
|
53
|
+
function redactUrl(value) {
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(value);
|
|
56
|
+
if (url.username) url.username = "[redacted]";
|
|
57
|
+
if (url.password) url.password = "[redacted]";
|
|
58
|
+
for (const key of url.searchParams.keys()) if (isCredentialKey(key)) url.searchParams.set(key, "[redacted]");
|
|
59
|
+
if (url.hash) url.hash = "[redacted]";
|
|
60
|
+
return url.toString();
|
|
61
|
+
} catch {
|
|
62
|
+
return value.replace(/\/\/[^/@\s]+@/u, "//[redacted]@");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function redactKnownValue(input, value) {
|
|
66
|
+
if (!value || !input.includes(value)) return input;
|
|
67
|
+
if (!/^[\p{L}\p{N}_-]+$/u.test(value)) return input.split(value).join("[redacted]");
|
|
68
|
+
const identifierCharacter = /^[\p{L}\p{N}_-]$/u;
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
let redacted = "";
|
|
71
|
+
let match = input.indexOf(value);
|
|
72
|
+
while (match !== -1) {
|
|
73
|
+
const end = match + value.length;
|
|
74
|
+
const before = [...input.slice(Math.max(0, match - 2), match)].at(-1);
|
|
75
|
+
const afterCodePoint = input.codePointAt(end);
|
|
76
|
+
const after = afterCodePoint === void 0 ? void 0 : String.fromCodePoint(afterCodePoint);
|
|
77
|
+
const bounded = (!before || !identifierCharacter.test(before)) && (!after || !identifierCharacter.test(after));
|
|
78
|
+
redacted += input.slice(cursor, match);
|
|
79
|
+
redacted += bounded ? "[redacted]" : value;
|
|
80
|
+
cursor = end;
|
|
81
|
+
match = input.indexOf(value, cursor);
|
|
82
|
+
}
|
|
83
|
+
return redacted + input.slice(cursor);
|
|
84
|
+
}
|
|
85
|
+
function redactDatabaseErrorText(value, knownSecrets) {
|
|
86
|
+
let redacted = value.replace(DATABASE_URL_USERINFO, "$1[redacted]@").replace(DATABASE_URL, redactUrl).replace(CREDENTIAL_ASSIGNMENT, (assignment, quote, key, delimiter) => isCredentialKey(key) ? `${quote}${key}${quote}${delimiter}[redacted]` : assignment).replace(BEARER_CREDENTIAL, "Bearer [redacted]").replace(POSTGRES_KEY_VALUE, (_match, prefix, _value, suffix) => `${prefix}[redacted]${suffix}`).replace(POSTGRES_FAILING_ROW, (_match, prefix, _value, suffix) => `${prefix}[redacted]${suffix}`).replace(CAST_INPUT_VALUE, (_match, prefix, _value, suffix) => `${prefix}[redacted]${suffix}`);
|
|
87
|
+
for (const secret of [...new Set(knownSecrets)].sort((left, right) => right.length - left.length)) redacted = redactKnownValue(redacted, secret);
|
|
88
|
+
return redacted;
|
|
89
|
+
}
|
|
90
|
+
function collectSensitiveValues(value, output, seen) {
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
if (value) output.add(value);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
96
|
+
output.add(String(value));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
100
|
+
seen.add(value);
|
|
101
|
+
try {
|
|
102
|
+
if (value instanceof Date) {
|
|
103
|
+
output.add(value.toISOString());
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (ArrayBuffer.isView(value)) {
|
|
107
|
+
const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
108
|
+
if (bytes.length > 0) {
|
|
109
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
110
|
+
output.add([...bytes].join(","));
|
|
111
|
+
output.add(hex);
|
|
112
|
+
output.add(`\\x${hex}`);
|
|
113
|
+
output.add(`\\x${hex.toUpperCase()}`);
|
|
114
|
+
output.add(`<Buffer ${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(" ")}>`);
|
|
115
|
+
if (typeof Buffer !== "undefined") output.add(Buffer.from(bytes).toString("base64"));
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const serialized = JSON.stringify(value);
|
|
124
|
+
if (serialized && serialized !== "{}" && serialized !== "[]") output.add(serialized);
|
|
125
|
+
} catch {}
|
|
126
|
+
let descriptors;
|
|
127
|
+
try {
|
|
128
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const [key, descriptor] of Object.entries(descriptors)) if (key !== "length" && "value" in descriptor) collectSensitiveValues(descriptor.value, output, seen);
|
|
133
|
+
}
|
|
134
|
+
function collectSqlLiterals(sql, output) {
|
|
135
|
+
if (sql) output.add(sql);
|
|
136
|
+
for (const match of sql.matchAll(/'((?:''|[^'])*)'/gu)) {
|
|
137
|
+
const literal = match[1].replaceAll("''", "'");
|
|
138
|
+
if (literal) output.add(literal);
|
|
139
|
+
}
|
|
140
|
+
for (const match of sql.matchAll(/\$\$([\s\S]*?)\$\$/gu)) if (match[1]) output.add(match[1]);
|
|
141
|
+
for (const match of sql.matchAll(/\$([A-Za-z_][A-Za-z0-9_]*)\$([\s\S]*?)\$\1\$/gu)) if (match[2]) output.add(match[2]);
|
|
142
|
+
for (const match of sql.matchAll(SQL_NUMERIC_LITERAL)) {
|
|
143
|
+
output.add(match[0]);
|
|
144
|
+
output.add(match[0].replaceAll("_", ""));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function contextSecrets(context) {
|
|
148
|
+
const output = /* @__PURE__ */ new Set();
|
|
149
|
+
for (const [key, value] of Object.entries(context)) if (/^(?:args?|parameters?|params?|values?)$/i.test(key)) collectSensitiveValues(value, output, /* @__PURE__ */ new Set());
|
|
150
|
+
else if (/^(?:queries|query|sql)$/i.test(key) && typeof value === "string") collectSqlLiterals(value, output);
|
|
151
|
+
return [...output];
|
|
152
|
+
}
|
|
153
|
+
function sanitizeContextValue(value, knownSecrets, seen) {
|
|
154
|
+
if (typeof value === "string") return redactDatabaseErrorText(value, knownSecrets);
|
|
155
|
+
if (!value || typeof value !== "object") return value;
|
|
156
|
+
if (seen.has(value)) return "[circular]";
|
|
157
|
+
seen.add(value);
|
|
158
|
+
if (Array.isArray(value)) return value.map((entry) => sanitizeContextValue(entry, knownSecrets, seen));
|
|
159
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, isCredentialKey(key) ? "[redacted]" : sanitizeContextValue(entry, knownSecrets, seen)]));
|
|
160
|
+
}
|
|
161
|
+
function sanitizeDatabaseErrorContext(context, knownSecrets) {
|
|
162
|
+
return Object.fromEntries(Object.entries(context).map(([key, value]) => [key, SENSITIVE_CONTEXT_KEY.test(key) || isCredentialKey(key) ? "[redacted]" : sanitizeContextValue(value, knownSecrets, /* @__PURE__ */ new WeakSet())]));
|
|
163
|
+
}
|
|
164
|
+
function formatDbErrorWithSecrets(error, knownSecrets) {
|
|
165
|
+
const dbError = toDriverError(error);
|
|
166
|
+
const parts = [];
|
|
167
|
+
if (dbError.message) parts.push(redactDatabaseErrorText(dbError.message, knownSecrets));
|
|
168
|
+
if (dbError.code) parts.push(`code=${redactDatabaseErrorText(dbError.code, knownSecrets)}`);
|
|
169
|
+
if (dbError.detail) parts.push(`detail=${redactDatabaseErrorText(dbError.detail, knownSecrets)}`);
|
|
170
|
+
if (dbError.hint) parts.push(`hint=${redactDatabaseErrorText(dbError.hint, knownSecrets)}`);
|
|
171
|
+
if (dbError.severity) parts.push(`severity=${redactDatabaseErrorText(dbError.severity, knownSecrets)}`);
|
|
172
|
+
if (dbError.errno !== void 0) parts.push(`errno=${dbError.errno}`);
|
|
173
|
+
return parts.length > 0 ? parts.join(", ") : redactDatabaseErrorText(String(error), knownSecrets);
|
|
174
|
+
}
|
|
175
|
+
function safeDatabaseCause(error, knownSecrets) {
|
|
176
|
+
const dbError = toDriverError(error);
|
|
177
|
+
const message = dbError.message ? redactDatabaseErrorText(dbError.message, knownSecrets) : formatDbErrorWithSecrets(error, knownSecrets);
|
|
178
|
+
const cause = new Error(message);
|
|
179
|
+
cause.name = dbError.name ? redactDatabaseErrorText(dbError.name, knownSecrets) : "DatabaseDriverError";
|
|
180
|
+
for (const field of DRIVER_ERROR_FIELDS) {
|
|
181
|
+
const value = dbError[field];
|
|
182
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") Object.defineProperty(cause, field, {
|
|
183
|
+
configurable: true,
|
|
184
|
+
enumerable: true,
|
|
185
|
+
value: typeof value === "string" ? redactDatabaseErrorText(value, knownSecrets) : value,
|
|
186
|
+
writable: true
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return cause;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Wraps an engine failure in a serializable, secret-safe DatabaseError.
|
|
193
|
+
*
|
|
194
|
+
* Raw SQL and parameter values are replaced in context. The native `cause` is
|
|
195
|
+
* a sanitized driver-error snapshot rather than the original object so normal
|
|
196
|
+
* Error inspection cannot bypass the same redaction applied to messages and
|
|
197
|
+
* JSON serialization.
|
|
198
|
+
*/
|
|
199
|
+
function wrapDatabaseError(message, error, context = {}) {
|
|
200
|
+
const knownSecrets = contextSecrets(context);
|
|
201
|
+
const originalError = formatDbErrorWithSecrets(error, knownSecrets);
|
|
202
|
+
return new DatabaseError(`${message}: ${originalError}`, {
|
|
203
|
+
...sanitizeDatabaseErrorContext(context, knownSecrets),
|
|
204
|
+
originalError
|
|
205
|
+
}, safeDatabaseCause(error, knownSecrets));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Resolves the column list for a multi-row INSERT and rejects batches whose
|
|
209
|
+
* records disagree on it.
|
|
210
|
+
*
|
|
211
|
+
* A batch INSERT emits one column list for the whole statement, so it can only
|
|
212
|
+
* come from the first record. That leaves two ways for a later record to be
|
|
213
|
+
* written wrongly, both of them silent:
|
|
214
|
+
*
|
|
215
|
+
* - Its keys are the same but in a different insertion order. Callers that
|
|
216
|
+
* bound values with `Object.values(record)` bound them by position, so the
|
|
217
|
+
* values cross-assigned into each other's columns. Projecting each record
|
|
218
|
+
* through the returned column list — `keys.map((key) => record[key])` — is
|
|
219
|
+
* what makes key order irrelevant, and every caller must do it.
|
|
220
|
+
* - Its key set genuinely differs. An extra key has no column to go to and a
|
|
221
|
+
* missing one has no value, so the record cannot be expressed by this
|
|
222
|
+
* statement at all. Guessing (dropping the extra, writing NULL for the
|
|
223
|
+
* missing) is what makes the failure invisible, so this throws instead.
|
|
224
|
+
*
|
|
225
|
+
* A key whose value is `undefined` counts as absent. Adapters that serialize
|
|
226
|
+
* records first drop such keys before this point while the rest pass them
|
|
227
|
+
* through, so comparing raw key lists would make the same batch throw on some
|
|
228
|
+
* adapters and silently write NULL on others. Only the comparison ignores them;
|
|
229
|
+
* the returned column list is untouched, so each adapter keeps its own
|
|
230
|
+
* single-record handling of `undefined`.
|
|
231
|
+
*
|
|
232
|
+
* @param table - Table being inserted into, for error context
|
|
233
|
+
* @param records - Non-empty batch, as the adapter will bind it
|
|
234
|
+
* @returns Column list taken from the first record
|
|
235
|
+
* @throws DatabaseError if any later record's key set differs from the first's
|
|
236
|
+
*/
|
|
237
|
+
function resolveInsertColumns(table, records) {
|
|
238
|
+
const definedKeys = (record) => new Set(Object.keys(record).filter((key) => record[key] !== void 0));
|
|
239
|
+
const keys = Object.keys(records[0]);
|
|
240
|
+
const expected = definedKeys(records[0]);
|
|
241
|
+
for (let index = 1; index < records.length; index++) {
|
|
242
|
+
const recordKeys = definedKeys(records[index]);
|
|
243
|
+
const missing = [...expected].filter((key) => !recordKeys.has(key));
|
|
244
|
+
const extra = [...recordKeys].filter((key) => !expected.has(key));
|
|
245
|
+
if (missing.length === 0 && extra.length === 0) continue;
|
|
246
|
+
const problems = [missing.length > 0 ? `missing ${missing.join(", ")}` : "", extra.length > 0 ? `unexpected ${extra.join(", ")}` : ""].filter(Boolean);
|
|
247
|
+
throw new DatabaseError(`Batch insert records must all have the same keys; record ${index} has ${problems.join(" and ")} relative to record 0 (${[...expected].join(", ")})`, {
|
|
248
|
+
table,
|
|
249
|
+
recordIndex: index,
|
|
250
|
+
expectedKeys: keys,
|
|
251
|
+
recordKeys: [...recordKeys],
|
|
252
|
+
missing,
|
|
253
|
+
extra,
|
|
254
|
+
hint: "A batch INSERT writes one column list for every row, taken from the first record, so record 0 defines the columns even when it is the one that looks wrong. A key whose value is undefined counts as absent. Split records with differing keys into separate insert() calls, or fill the gaps explicitly with null."
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return keys;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Map of valid SQL operators for use in WHERE clauses
|
|
261
|
+
*/
|
|
262
|
+
var VALID_OPERATORS = {
|
|
263
|
+
"=": "=",
|
|
264
|
+
">": ">",
|
|
265
|
+
">=": ">=",
|
|
266
|
+
"<": "<",
|
|
267
|
+
"<=": "<=",
|
|
268
|
+
"!=": "!=",
|
|
269
|
+
like: "LIKE",
|
|
270
|
+
contains: "CONTAINS",
|
|
271
|
+
in: "IN",
|
|
272
|
+
"not in": "NOT IN"
|
|
273
|
+
};
|
|
274
|
+
function isSimpleSqlIdentifier(field) {
|
|
275
|
+
return /^[a-zA-Z0-9_.]+$/.test(field);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Marks a condition key as caller-authored SQL text.
|
|
279
|
+
*
|
|
280
|
+
* A fixed, non-secret sentinel rather than a random per-process token. A random
|
|
281
|
+
* token has to travel inside the key, which means it reaches every place a key
|
|
282
|
+
* is echoed — error messages, logs, each adapter's `DatabaseError` context — and
|
|
283
|
+
* one disclosure would let a caller mint raw keys for the life of the process.
|
|
284
|
+
* A registry of minted expressions avoids that but is not referentially
|
|
285
|
+
* transparent: it would make `raw('revenue >')` in one query change what the
|
|
286
|
+
* plain key `'revenue >'` means in every other one.
|
|
287
|
+
*
|
|
288
|
+
* The NUL delimiters cannot appear in an identifier or in SQL a developer would
|
|
289
|
+
* write, so the marker never collides with a legitimate key.
|
|
290
|
+
*/
|
|
291
|
+
var RAW_KEY_PREFIX = "\0hv-sql-raw\0";
|
|
292
|
+
/**
|
|
293
|
+
* Marks a WHERE-clause key as SQL expression text rather than an identifier.
|
|
294
|
+
*
|
|
295
|
+
* {@link buildWhere} validates every condition key as a plain identifier. Wrap a
|
|
296
|
+
* key in `raw()` to opt one condition out of that check:
|
|
297
|
+
*
|
|
298
|
+
* ```typescript
|
|
299
|
+
* buildWhere({
|
|
300
|
+
* status: 'paid', // validated as an identifier
|
|
301
|
+
* [raw('SUM(total) >')]: 100, // caller-authored SQL, not validated
|
|
302
|
+
* });
|
|
303
|
+
* ```
|
|
304
|
+
*
|
|
305
|
+
* Never build the argument from end-user input. `raw()` is an assertion that
|
|
306
|
+
* the caller, not the request, authored this SQL.
|
|
307
|
+
*
|
|
308
|
+
* The returned key carries a marker, so it is a distinct string from the bare
|
|
309
|
+
* expression: minting `raw('revenue >')` never changes what the plain key
|
|
310
|
+
* `'revenue >'` means anywhere else.
|
|
311
|
+
*
|
|
312
|
+
* The marker is a fixed sentinel, not a secret. It stops an expression key being
|
|
313
|
+
* used by accident — the shape of the key no longer grants raw access, a call to
|
|
314
|
+
* `raw()` does — but a caller that controls a whole key string verbatim,
|
|
315
|
+
* including the NUL-delimited marker, can still reproduce it. Validate at your
|
|
316
|
+
* own trust boundary; do not rely on this as the only barrier against hostile
|
|
317
|
+
* input.
|
|
318
|
+
*
|
|
319
|
+
* Enforcement is at runtime: `WhereClause` keys are plain `string`, so a
|
|
320
|
+
* computed key widens and TypeScript will not reject an unmarked expression key
|
|
321
|
+
* at the call site.
|
|
322
|
+
*
|
|
323
|
+
* A trailing operator is recognised only if it is one of `=`, `!=`, `>`, `>=`,
|
|
324
|
+
* `<`, `<=`, `like`, `contains`, `in`, `not in`. Any other trailing token is
|
|
325
|
+
* treated as part of the expression and `=` is appended, so
|
|
326
|
+
* `raw('name ILIKE')` silently emits `name ILIKE = $1`, which the database then
|
|
327
|
+
* rejects. Fold an unsupported operator into the expression instead:
|
|
328
|
+
* `raw('LOWER(name) like')`.
|
|
329
|
+
*
|
|
330
|
+
* @param expression - SQL field or expression text, optionally ending in a
|
|
331
|
+
* supported operator (for example `SUM(total) >`)
|
|
332
|
+
* @returns A branded key for use in a {@link WhereClause}
|
|
333
|
+
* @throws If the expression is empty
|
|
334
|
+
*/
|
|
335
|
+
function raw(expression) {
|
|
336
|
+
const trimmed = unwrapRawKey(expression).key.trim();
|
|
337
|
+
if (!trimmed) throw new Error("raw() requires a non-empty SQL expression");
|
|
338
|
+
return `${RAW_KEY_PREFIX}${trimmed}`;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Splits the raw marker off a condition key.
|
|
342
|
+
* @internal
|
|
343
|
+
*/
|
|
344
|
+
function unwrapRawKey(fullKey) {
|
|
345
|
+
return fullKey.startsWith(RAW_KEY_PREFIX) ? {
|
|
346
|
+
key: fullKey.slice(12),
|
|
347
|
+
isRaw: true
|
|
348
|
+
} : {
|
|
349
|
+
key: fullKey,
|
|
350
|
+
isRaw: false
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function parseConditionKey(fullKey) {
|
|
354
|
+
const trimmed = fullKey.trim();
|
|
355
|
+
const lower = trimmed.toLowerCase();
|
|
356
|
+
for (const operator of [
|
|
357
|
+
"not in",
|
|
358
|
+
"contains",
|
|
359
|
+
"like",
|
|
360
|
+
"in",
|
|
361
|
+
"!=",
|
|
362
|
+
">=",
|
|
363
|
+
"<=",
|
|
364
|
+
">",
|
|
365
|
+
"<",
|
|
366
|
+
"="
|
|
367
|
+
]) if (lower.endsWith(` ${operator}`)) return {
|
|
368
|
+
field: trimmed.slice(0, -(operator.length + 1)).trim(),
|
|
369
|
+
operator
|
|
370
|
+
};
|
|
371
|
+
return {
|
|
372
|
+
field: trimmed,
|
|
373
|
+
operator: "="
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Builds a single condition for a WHERE clause
|
|
378
|
+
* @internal
|
|
379
|
+
*/
|
|
380
|
+
var buildCondition = (fullKey, value, currIndex, adapterType) => {
|
|
381
|
+
const { key, isRaw } = unwrapRawKey(fullKey);
|
|
382
|
+
const { field, operator } = parseConditionKey(key);
|
|
383
|
+
if (!isRaw && !isSimpleSqlIdentifier(field)) throw new Error(`Invalid SQL identifier: ${field}. Condition keys must be plain identifiers; wrap developer-authored SQL expressions in raw().`);
|
|
384
|
+
const sqlOperator = VALID_OPERATORS[operator] || "=";
|
|
385
|
+
const values = [];
|
|
386
|
+
let sql;
|
|
387
|
+
if (sqlOperator === "CONTAINS") {
|
|
388
|
+
if (!adapterType) throw new Error("CONTAINS requires an adapter type so buildWhere can preserve case-sensitive semantics");
|
|
389
|
+
if (typeof value !== "string") throw new Error("CONTAINS requires a string value");
|
|
390
|
+
const placeholder = `$${currIndex.value++}`;
|
|
391
|
+
sql = `${adapterType === "sqlite" ? "instr" : "strpos"}(${field}, ${placeholder}) > 0`;
|
|
392
|
+
values.push(value);
|
|
393
|
+
} else if (value === null) sql = `${field} IS ${sqlOperator === "=" ? "NULL" : "NOT NULL"}`;
|
|
394
|
+
else if ((sqlOperator === "IN" || sqlOperator === "NOT IN") && Array.isArray(value)) {
|
|
395
|
+
if (value.length === 0) throw new Error(`${sqlOperator} requires at least one value`);
|
|
396
|
+
sql = `${field} ${sqlOperator} (${value.map(() => `$${currIndex.value++}`).join(", ")})`;
|
|
397
|
+
values.push(...value);
|
|
398
|
+
} else if (value instanceof Date) {
|
|
399
|
+
if (adapterType === "duckdb" || adapterType === "json") sql = `${field} ${sqlOperator} CAST($${currIndex.value++} AS TIMESTAMP)${sqlOperator === "LIKE" ? " ESCAPE '\\'" : ""}`;
|
|
400
|
+
else sql = `${field} ${sqlOperator} $${currIndex.value++}${sqlOperator === "LIKE" ? " ESCAPE '\\'" : ""}`;
|
|
401
|
+
values.push(value.toISOString());
|
|
402
|
+
} else {
|
|
403
|
+
sql = `${field} ${sqlOperator} $${currIndex.value++}${sqlOperator === "LIKE" ? " ESCAPE '\\'" : ""}`;
|
|
404
|
+
values.push(value);
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
sql,
|
|
408
|
+
values
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Builds a SQL WHERE clause with parameterized values and flexible operators
|
|
413
|
+
*
|
|
414
|
+
* @param where - Conditions as object (AND-only) or 2D array (OR/AND compound)
|
|
415
|
+
* @param startIndex - Starting index for parameter numbering (default: 1)
|
|
416
|
+
* @param adapterType - Database adapter type. Controls Date and `contains`
|
|
417
|
+
* handling:
|
|
418
|
+
* - `'duckdb'` / `'json'`: Wraps Date values in `CAST($N AS TIMESTAMP)` to
|
|
419
|
+
* prevent DuckDB ANY-type inference issues.
|
|
420
|
+
* - `'sqlite'` / `'postgres'` / `undefined`: Passes ISO strings directly
|
|
421
|
+
* (these adapters handle ISO timestamp strings natively).
|
|
422
|
+
* - Required for `contains`, whose case-sensitive literal substring SQL is
|
|
423
|
+
* emitted with adapter-specific functions.
|
|
424
|
+
*
|
|
425
|
+
* Every condition key is validated as a plain SQL identifier, with or without
|
|
426
|
+
* an operator suffix. To use expression text as a key, wrap it in {@link raw}:
|
|
427
|
+
*
|
|
428
|
+
* ```typescript
|
|
429
|
+
* buildWhere({ [raw('LOWER(status) =')]: 'paid' });
|
|
430
|
+
* ```
|
|
431
|
+
*
|
|
432
|
+
* `raw()` is the only way to reach expression text, so mapping untrusted input
|
|
433
|
+
* into a condition key throws rather than emitting attacker-controlled SQL.
|
|
434
|
+
*
|
|
435
|
+
* @returns Object containing the SQL clause and array of values
|
|
436
|
+
* @throws If a key that is not wrapped in {@link raw} is not a plain identifier
|
|
437
|
+
*
|
|
438
|
+
* @example Basic Usage (Object format - AND-only):
|
|
439
|
+
* ```typescript
|
|
440
|
+
* buildWhere({
|
|
441
|
+
* 'status': 'active', // equals operator is default
|
|
442
|
+
* 'price >': 100, // greater than
|
|
443
|
+
* 'stock <=': 5, // less than or equal
|
|
444
|
+
* 'category in': ['A', 'B'], // IN clause for arrays
|
|
445
|
+
* 'name like': '%shirt%', // LIKE pattern; backslash escapes `%` / `_`
|
|
446
|
+
* 'notes contains': '100% off' // literal, case-sensitive substring
|
|
447
|
+
* });
|
|
448
|
+
* ```
|
|
449
|
+
*
|
|
450
|
+
* @example 2D Array Format (OR/AND compound logic):
|
|
451
|
+
* ```typescript
|
|
452
|
+
* // WHERE (status = 'active' AND price > 100) OR (status = 'pending' AND priority = 'high')
|
|
453
|
+
* buildWhere([
|
|
454
|
+
* [{ status: 'active' }, { 'price >': 100 }],
|
|
455
|
+
* [{ status: 'pending' }, { priority: 'high' }]
|
|
456
|
+
* ]);
|
|
457
|
+
* ```
|
|
458
|
+
*
|
|
459
|
+
* @example NULL Handling:
|
|
460
|
+
* ```typescript
|
|
461
|
+
* buildWhere({
|
|
462
|
+
* 'deleted_at': null, // becomes "deleted_at IS NULL"
|
|
463
|
+
* 'updated_at !=': null, // becomes "updated_at IS NOT NULL"
|
|
464
|
+
* 'status': 'active' // regular comparison
|
|
465
|
+
* });
|
|
466
|
+
* ```
|
|
467
|
+
*
|
|
468
|
+
* @example Common Patterns:
|
|
469
|
+
* ```typescript
|
|
470
|
+
* // Price range
|
|
471
|
+
* buildWhere({
|
|
472
|
+
* 'price >=': 10,
|
|
473
|
+
* 'price <': 100
|
|
474
|
+
* });
|
|
475
|
+
*
|
|
476
|
+
* // Date filtering
|
|
477
|
+
* buildWhere({
|
|
478
|
+
* 'created_at >': startDate,
|
|
479
|
+
* 'created_at <=': endDate,
|
|
480
|
+
* 'deleted_at': null
|
|
481
|
+
* });
|
|
482
|
+
*
|
|
483
|
+
* // Search with LIKE
|
|
484
|
+
* buildWhere({
|
|
485
|
+
* 'title like': '%search%',
|
|
486
|
+
* 'description like': '%search%',
|
|
487
|
+
* 'status': 'published'
|
|
488
|
+
* });
|
|
489
|
+
*
|
|
490
|
+
* // Multiple values with IN
|
|
491
|
+
* buildWhere({
|
|
492
|
+
* 'role in': ['admin', 'editor'],
|
|
493
|
+
* 'active': true,
|
|
494
|
+
* 'last_login !=': null
|
|
495
|
+
* });
|
|
496
|
+
* ```
|
|
497
|
+
*
|
|
498
|
+
* The function handles:
|
|
499
|
+
* - Standard comparisons (=, >, >=, <, <=, !=)
|
|
500
|
+
* - NULL checks (IS NULL, IS NOT NULL)
|
|
501
|
+
* - IN clauses for arrays
|
|
502
|
+
* - LIKE for pattern matching with an explicit backslash escape character
|
|
503
|
+
* - Case-sensitive literal substring matching with `contains`
|
|
504
|
+
* - Object format: Multiple conditions combined with AND
|
|
505
|
+
* - 2D array format: Inner arrays ANDed, outer array ORed
|
|
506
|
+
*/
|
|
507
|
+
var buildWhere = (where, startIndex = 1, adapterType) => {
|
|
508
|
+
let sql = "";
|
|
509
|
+
const values = [];
|
|
510
|
+
const currIndex = { value: startIndex };
|
|
511
|
+
if (Array.isArray(where)) {
|
|
512
|
+
if (where.length === 0) return {
|
|
513
|
+
sql: "",
|
|
514
|
+
values: []
|
|
515
|
+
};
|
|
516
|
+
const orGroups = [];
|
|
517
|
+
for (const andGroup of where) {
|
|
518
|
+
if (!Array.isArray(andGroup) || andGroup.length === 0) continue;
|
|
519
|
+
const andConditions = [];
|
|
520
|
+
for (const conditionObj of andGroup) for (const [fullKey, value] of Object.entries(conditionObj)) {
|
|
521
|
+
const result = buildCondition(fullKey, value, currIndex, adapterType);
|
|
522
|
+
andConditions.push(result.sql);
|
|
523
|
+
values.push(...result.values);
|
|
524
|
+
}
|
|
525
|
+
if (andConditions.length > 0) orGroups.push(`(${andConditions.join(" AND ")})`);
|
|
526
|
+
}
|
|
527
|
+
if (orGroups.length > 0) sql = `WHERE ${orGroups.join(" OR ")}`;
|
|
528
|
+
return {
|
|
529
|
+
sql,
|
|
530
|
+
values
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (where && Object.keys(where).length > 0) {
|
|
534
|
+
sql = "WHERE ";
|
|
535
|
+
for (const [fullKey, value] of Object.entries(where)) {
|
|
536
|
+
const result = buildCondition(fullKey, value, currIndex, adapterType);
|
|
537
|
+
if (sql !== "WHERE ") sql += " AND ";
|
|
538
|
+
sql += result.sql;
|
|
539
|
+
values.push(...result.values);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
sql,
|
|
544
|
+
values
|
|
545
|
+
};
|
|
546
|
+
};
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region src/schema-manager.ts
|
|
549
|
+
/**
|
|
550
|
+
* Manages schema initialization from JSON manifests with dependency resolution
|
|
551
|
+
*
|
|
552
|
+
* Handles topological sorting of schema dependencies, table creation,
|
|
553
|
+
* index and trigger setup, and version tracking to avoid redundant re-initialization.
|
|
554
|
+
*/
|
|
555
|
+
var DatabaseSchemaManager = class DatabaseSchemaManager {
|
|
556
|
+
static initializationLock = /* @__PURE__ */ new Map();
|
|
557
|
+
initializedSchemas = /* @__PURE__ */ new Set();
|
|
558
|
+
schemaVersions = /* @__PURE__ */ new Map();
|
|
559
|
+
/**
|
|
560
|
+
* Initializes database schemas from a JSON manifest with dependency resolution
|
|
561
|
+
*
|
|
562
|
+
* Schemas are topologically sorted by their declared dependencies and
|
|
563
|
+
* initialized in order. Supports legacy raw-SQL schemas via the `schema` option,
|
|
564
|
+
* JSON manifests via the `manifest` option, and schema overrides for extending
|
|
565
|
+
* base schemas.
|
|
566
|
+
*
|
|
567
|
+
* @param db - Database interface to execute DDL against
|
|
568
|
+
* @param options - Schema initialization options (manifest, overrides, force, debug)
|
|
569
|
+
* @returns Result with lists of initialized/skipped schemas, errors, and execution time
|
|
570
|
+
*/
|
|
571
|
+
async initializeSchemas(db, options) {
|
|
572
|
+
const startTime = Date.now();
|
|
573
|
+
const result = {
|
|
574
|
+
initialized: [],
|
|
575
|
+
skipped: [],
|
|
576
|
+
errors: [],
|
|
577
|
+
executionTime: 0
|
|
578
|
+
};
|
|
579
|
+
try {
|
|
580
|
+
let schemas = {};
|
|
581
|
+
if (options.schema) {
|
|
582
|
+
console.log("[schema] Legacy SQL schema provided, converting to manifest format");
|
|
583
|
+
if (db.syncSchema) {
|
|
584
|
+
await db.syncSchema(options.schema);
|
|
585
|
+
result.initialized.push("legacy-sql");
|
|
586
|
+
}
|
|
587
|
+
result.executionTime = Date.now() - startTime;
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
if (options.manifest) schemas = { ...options.manifest.schemas };
|
|
591
|
+
if (options.overrides) schemas = {
|
|
592
|
+
...schemas,
|
|
593
|
+
...options.overrides
|
|
594
|
+
};
|
|
595
|
+
if (Object.keys(schemas).length === 0) {
|
|
596
|
+
console.warn("[schema] No schemas provided for initialization");
|
|
597
|
+
result.executionTime = Date.now() - startTime;
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
const dependencyGraph = this.buildDependencyGraph(schemas);
|
|
601
|
+
const initializationOrder = this.resolveDependencies(dependencyGraph);
|
|
602
|
+
if (options.debug) console.log("[schema] Initialization order:", initializationOrder);
|
|
603
|
+
for (const schemaName of initializationOrder) {
|
|
604
|
+
const schema = schemas[schemaName];
|
|
605
|
+
if (!schema) continue;
|
|
606
|
+
try {
|
|
607
|
+
await this.initializeSchema(db, schemaName, schema, {
|
|
608
|
+
force: options.force,
|
|
609
|
+
debug: options.debug
|
|
610
|
+
});
|
|
611
|
+
result.initialized.push(schemaName);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
614
|
+
result.errors.push({
|
|
615
|
+
schema: schemaName,
|
|
616
|
+
error: errorMessage
|
|
617
|
+
});
|
|
618
|
+
if (options.debug) console.error(`[schema] Failed to initialize ${schemaName}:`, error);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
result.executionTime = Date.now() - startTime;
|
|
622
|
+
if (options.debug) console.log("[schema] Initialization complete:", result);
|
|
623
|
+
return result;
|
|
624
|
+
} catch (error) {
|
|
625
|
+
result.executionTime = Date.now() - startTime;
|
|
626
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
627
|
+
result.errors.push({
|
|
628
|
+
schema: "dependency-resolution",
|
|
629
|
+
error: errorMessage
|
|
630
|
+
});
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Initialize a single schema
|
|
636
|
+
*/
|
|
637
|
+
async initializeSchema(db, schemaName, schema, options) {
|
|
638
|
+
const { tableName, version } = schema;
|
|
639
|
+
const lockKey = `${schemaName}:${tableName}`;
|
|
640
|
+
if (DatabaseSchemaManager.initializationLock.has(lockKey)) {
|
|
641
|
+
await DatabaseSchemaManager.initializationLock.get(lockKey);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (!options.force && this.isSchemaUpToDate(schemaName, version)) {
|
|
645
|
+
if (options.debug) console.log(`[schema] Skipping ${schemaName} - already up to date`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const initPromise = this.performSchemaInitialization(db, schemaName, schema, options);
|
|
649
|
+
DatabaseSchemaManager.initializationLock.set(lockKey, initPromise);
|
|
650
|
+
try {
|
|
651
|
+
await initPromise;
|
|
652
|
+
this.markSchemaInitialized(schemaName, version);
|
|
653
|
+
} finally {
|
|
654
|
+
DatabaseSchemaManager.initializationLock.delete(lockKey);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Perform the actual schema initialization
|
|
659
|
+
*/
|
|
660
|
+
async performSchemaInitialization(db, schemaName, schema, options) {
|
|
661
|
+
const { tableName, columns, indexes, triggers } = schema;
|
|
662
|
+
if (options.debug) console.log(`[schema] Initializing ${schemaName} (${tableName})`);
|
|
663
|
+
if (!await db.tableExists(tableName)) {
|
|
664
|
+
await this.createTable(db, schema);
|
|
665
|
+
for (const index of indexes) await this.createIndex(db, tableName, index);
|
|
666
|
+
for (const trigger of triggers) await this.createTrigger(db, trigger);
|
|
667
|
+
} else if (options.force) {
|
|
668
|
+
await db.query(`DROP TABLE IF EXISTS ${tableName}`);
|
|
669
|
+
await this.createTable(db, schema);
|
|
670
|
+
for (const index of indexes) await this.createIndex(db, tableName, index);
|
|
671
|
+
for (const trigger of triggers) await this.createTrigger(db, trigger);
|
|
672
|
+
} else await this.updateSchemaIfNeeded(db, schema);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Create table from schema definition
|
|
676
|
+
*/
|
|
677
|
+
async createTable(db, schema) {
|
|
678
|
+
const { tableName, columns, foreignKeys } = schema;
|
|
679
|
+
const columnDefinitions = [];
|
|
680
|
+
for (const [columnName, columnDef] of Object.entries(columns)) {
|
|
681
|
+
let def = `${columnName} ${columnDef.type}`;
|
|
682
|
+
if (columnDef.primaryKey) def += " PRIMARY KEY";
|
|
683
|
+
if (columnDef.unique && !columnDef.primaryKey) def += " UNIQUE";
|
|
684
|
+
if (columnDef.notNull) def += " NOT NULL";
|
|
685
|
+
if (columnDef.defaultValue !== void 0) def += ` DEFAULT ${columnDef.defaultValue}`;
|
|
686
|
+
if (columnDef.check) def += ` CHECK (${columnDef.check})`;
|
|
687
|
+
columnDefinitions.push(def);
|
|
688
|
+
}
|
|
689
|
+
for (const fk of foreignKeys) {
|
|
690
|
+
let fkDef = `FOREIGN KEY (${fk.column}) REFERENCES ${fk.referencesTable}(${fk.referencesColumn})`;
|
|
691
|
+
if (fk.onDelete) fkDef += ` ON DELETE ${fk.onDelete}`;
|
|
692
|
+
if (fk.onUpdate) fkDef += ` ON UPDATE ${fk.onUpdate}`;
|
|
693
|
+
columnDefinitions.push(fkDef);
|
|
694
|
+
}
|
|
695
|
+
const createTableSQL = `CREATE TABLE ${tableName} (\n ${columnDefinitions.join(",\n ")}\n)`;
|
|
696
|
+
await db.query(createTableSQL);
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Create index from definition
|
|
700
|
+
*/
|
|
701
|
+
async createIndex(db, tableName, index) {
|
|
702
|
+
const uniqueClause = index.unique ? "UNIQUE " : "";
|
|
703
|
+
const whereClause = index.where ? ` WHERE ${index.where}` : "";
|
|
704
|
+
const createIndexSQL = `CREATE ${uniqueClause}INDEX ${index.name} ON ${tableName} (${index.columns.join(", ")})${whereClause}`;
|
|
705
|
+
await db.query(createIndexSQL);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Create trigger from definition
|
|
709
|
+
*/
|
|
710
|
+
async createTrigger(db, trigger) {
|
|
711
|
+
const conditionClause = trigger.condition ? ` WHEN ${trigger.condition}` : "";
|
|
712
|
+
const createTriggerSQL = `CREATE TRIGGER ${trigger.name} ${trigger.when} ${trigger.event} ON ${trigger.table}${conditionClause} BEGIN ${trigger.body} END`;
|
|
713
|
+
await db.query(createTriggerSQL);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Update schema if changes are detected
|
|
717
|
+
*/
|
|
718
|
+
async updateSchemaIfNeeded(_db, schema) {
|
|
719
|
+
console.log(`[schema] Schema update logic not yet implemented for ${schema.tableName}`);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Build dependency graph from schemas
|
|
723
|
+
*/
|
|
724
|
+
buildDependencyGraph(schemas) {
|
|
725
|
+
const graph = /* @__PURE__ */ new Map();
|
|
726
|
+
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
727
|
+
const dependencies = schema.dependencies.filter((dep) => Object.values(schemas).some((s) => s.tableName === dep));
|
|
728
|
+
graph.set(schemaName, dependencies.map((dep) => Object.entries(schemas).find(([_, s]) => s.tableName === dep)?.[0] || dep));
|
|
729
|
+
}
|
|
730
|
+
return graph;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Resolve dependencies using topological sort
|
|
734
|
+
*/
|
|
735
|
+
resolveDependencies(graph) {
|
|
736
|
+
const resolved = [];
|
|
737
|
+
const visited = /* @__PURE__ */ new Set();
|
|
738
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
739
|
+
const visit = (node) => {
|
|
740
|
+
if (visiting.has(node)) throw new Error(`Circular dependency detected involving ${node}`);
|
|
741
|
+
if (visited.has(node)) return;
|
|
742
|
+
visiting.add(node);
|
|
743
|
+
const dependencies = graph.get(node) || [];
|
|
744
|
+
for (const dep of dependencies) if (graph.has(dep)) visit(dep);
|
|
745
|
+
visiting.delete(node);
|
|
746
|
+
visited.add(node);
|
|
747
|
+
resolved.push(node);
|
|
748
|
+
};
|
|
749
|
+
for (const node of graph.keys()) if (!visited.has(node)) visit(node);
|
|
750
|
+
return resolved;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Check if schema is up to date
|
|
754
|
+
*/
|
|
755
|
+
isSchemaUpToDate(schemaName, version) {
|
|
756
|
+
return this.initializedSchemas.has(schemaName) && this.schemaVersions.get(schemaName) === version;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Mark schema as initialized
|
|
760
|
+
*/
|
|
761
|
+
markSchemaInitialized(schemaName, version) {
|
|
762
|
+
this.initializedSchemas.add(schemaName);
|
|
763
|
+
this.schemaVersions.set(schemaName, version);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Reset initialization state (for testing)
|
|
767
|
+
*/
|
|
768
|
+
reset() {
|
|
769
|
+
this.initializedSchemas.clear();
|
|
770
|
+
this.schemaVersions.clear();
|
|
771
|
+
DatabaseSchemaManager.initializationLock.clear();
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/shared/alter-utils.ts
|
|
776
|
+
/**
|
|
777
|
+
* Generates an ALTER TABLE ADD COLUMN statement
|
|
778
|
+
*
|
|
779
|
+
* @param table - Table name
|
|
780
|
+
* @param column - Column definition with name
|
|
781
|
+
* @param dialect - Database dialect
|
|
782
|
+
* @returns SQL statement string
|
|
783
|
+
*
|
|
784
|
+
* @example
|
|
785
|
+
* ```typescript
|
|
786
|
+
* const sql = generateAddColumnStatement('users', {
|
|
787
|
+
* name: 'email',
|
|
788
|
+
* type: 'TEXT',
|
|
789
|
+
* unique: true,
|
|
790
|
+
* notNull: false
|
|
791
|
+
* }, 'sqlite');
|
|
792
|
+
* // Returns: "ALTER TABLE users ADD COLUMN email TEXT UNIQUE"
|
|
793
|
+
* ```
|
|
794
|
+
*/
|
|
795
|
+
function generateAddColumnStatement(table, column, _dialect) {
|
|
796
|
+
const parts = [column.name, column.type];
|
|
797
|
+
if (column.primaryKey) parts.push("PRIMARY KEY");
|
|
798
|
+
if (column.notNull) parts.push("NOT NULL");
|
|
799
|
+
if (column.unique) parts.push("UNIQUE");
|
|
800
|
+
if (column.defaultValue !== void 0) {
|
|
801
|
+
const defaultClause = generateDefaultClause(column.type, column.defaultValue);
|
|
802
|
+
parts.push(`DEFAULT ${defaultClause}`);
|
|
803
|
+
}
|
|
804
|
+
if (column.check) parts.push(`CHECK (${column.check})`);
|
|
805
|
+
if (column.foreignKey) {
|
|
806
|
+
const fk = column.foreignKey;
|
|
807
|
+
const fkParts = [`REFERENCES ${fk.table}(${fk.column})`];
|
|
808
|
+
if (fk.onDelete) fkParts.push(`ON DELETE ${fk.onDelete}`);
|
|
809
|
+
if (fk.onUpdate) fkParts.push(`ON UPDATE ${fk.onUpdate}`);
|
|
810
|
+
parts.push(fkParts.join(" "));
|
|
811
|
+
}
|
|
812
|
+
return `ALTER TABLE ${table} ADD COLUMN ${parts.join(" ")}`;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Generates a DEFAULT clause for a column definition
|
|
816
|
+
*
|
|
817
|
+
* Handles proper formatting and escaping of default values based on column type.
|
|
818
|
+
*
|
|
819
|
+
* @param columnType - SQL column type (TEXT, INTEGER, BOOLEAN, etc.)
|
|
820
|
+
* @param defaultValue - Default value to format
|
|
821
|
+
* @returns Formatted default value string
|
|
822
|
+
*
|
|
823
|
+
* @example
|
|
824
|
+
* ```typescript
|
|
825
|
+
* generateDefaultClause('TEXT', 'hello'); // Returns: "'hello'"
|
|
826
|
+
* generateDefaultClause('INTEGER', 42); // Returns: "42"
|
|
827
|
+
* generateDefaultClause('BOOLEAN', true); // Returns: "true"
|
|
828
|
+
* generateDefaultClause('TEXT', null); // Returns: "NULL"
|
|
829
|
+
* ```
|
|
830
|
+
*/
|
|
831
|
+
function generateDefaultClause(columnType, defaultValue) {
|
|
832
|
+
if (defaultValue === null) return "NULL";
|
|
833
|
+
const typeUpper = columnType.toUpperCase();
|
|
834
|
+
if (typeUpper.includes("TEXT") || typeUpper.includes("CHAR") || typeUpper.includes("VARCHAR") || typeUpper.includes("STRING")) {
|
|
835
|
+
if (defaultValue === "") return "''";
|
|
836
|
+
return `'${String(defaultValue).replace(/'/g, "''")}'`;
|
|
837
|
+
}
|
|
838
|
+
if (typeUpper.includes("BOOL")) return defaultValue ? "true" : "false";
|
|
839
|
+
if (typeUpper.includes("INT") || typeUpper.includes("REAL") || typeUpper.includes("FLOAT") || typeUpper.includes("DOUBLE") || typeUpper.includes("NUMERIC") || typeUpper.includes("DECIMAL")) return String(defaultValue);
|
|
840
|
+
if (typeUpper.includes("JSON")) {
|
|
841
|
+
if (typeof defaultValue === "string") return `'${defaultValue.replace(/'/g, "''")}'`;
|
|
842
|
+
return `'${JSON.stringify(defaultValue).replace(/'/g, "''")}'`;
|
|
843
|
+
}
|
|
844
|
+
if (typeUpper.includes("DATE") || typeUpper.includes("TIME") || typeUpper.includes("TIMESTAMP")) {
|
|
845
|
+
if (defaultValue instanceof Date) return `'${defaultValue.toISOString()}'`;
|
|
846
|
+
return `'${String(defaultValue).replace(/'/g, "''")}'`;
|
|
847
|
+
}
|
|
848
|
+
if (typeof defaultValue === "string") return `'${defaultValue.replace(/'/g, "''")}'`;
|
|
849
|
+
return String(defaultValue);
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Generates a CREATE INDEX statement
|
|
853
|
+
*
|
|
854
|
+
* @param table - Table name
|
|
855
|
+
* @param index - Index definition
|
|
856
|
+
* @returns SQL CREATE INDEX statement
|
|
857
|
+
*
|
|
858
|
+
* @example
|
|
859
|
+
* ```typescript
|
|
860
|
+
* const sql = generateCreateIndexStatement('users', {
|
|
861
|
+
* name: 'idx_users_email',
|
|
862
|
+
* columns: ['email'],
|
|
863
|
+
* unique: true
|
|
864
|
+
* });
|
|
865
|
+
* // Returns: "CREATE UNIQUE INDEX idx_users_email ON users (email)"
|
|
866
|
+
* ```
|
|
867
|
+
*/
|
|
868
|
+
function generateCreateIndexStatement(table, index) {
|
|
869
|
+
const parts = ["CREATE"];
|
|
870
|
+
if (index.unique) parts.push("UNIQUE");
|
|
871
|
+
parts.push("INDEX");
|
|
872
|
+
parts.push(index.name);
|
|
873
|
+
parts.push(`ON ${table}`);
|
|
874
|
+
const columns = index.columns.join(", ");
|
|
875
|
+
parts.push(`(${columns})`);
|
|
876
|
+
if (index.where) parts.push(`WHERE ${index.where}`);
|
|
877
|
+
return parts.join(" ");
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Validates a table name to prevent SQL injection
|
|
881
|
+
*
|
|
882
|
+
* The argument must be a primitive string. `RegExp.prototype.test` coerces its
|
|
883
|
+
* argument with `toString`, so an object whose `toString` returns a valid name
|
|
884
|
+
* on the call the validator makes and a different one on the call the caller's
|
|
885
|
+
* `${table}` interpolation makes would pass here and then reach SQL. Rejecting
|
|
886
|
+
* non-strings up front closes that gap: the value validated is the value
|
|
887
|
+
* interpolated, because a primitive string cannot observe how many times it is
|
|
888
|
+
* read.
|
|
889
|
+
*
|
|
890
|
+
* @param tableName - Table name to validate
|
|
891
|
+
* @throws Error if the value is not a string or contains invalid characters
|
|
892
|
+
*/
|
|
893
|
+
function validateTableName(tableName) {
|
|
894
|
+
if (typeof tableName !== "string" || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) throw new Error(`Invalid table name: ${tableName}. Table names must be a string that starts with a letter or underscore and contains only alphanumeric characters and underscores.`);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Validates a column name to prevent SQL injection
|
|
898
|
+
*
|
|
899
|
+
* Rejects non-strings for the same reason as {@link validateTableName}: a value
|
|
900
|
+
* whose `toString` differs between the validation read and the interpolation
|
|
901
|
+
* read would otherwise slip a different identifier into SQL than the one
|
|
902
|
+
* checked here.
|
|
903
|
+
*
|
|
904
|
+
* @param columnName - Column name to validate
|
|
905
|
+
* @throws Error if the value is not a string or contains invalid characters
|
|
906
|
+
*/
|
|
907
|
+
function validateColumnName(columnName) {
|
|
908
|
+
if (typeof columnName !== "string" || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) throw new Error(`Invalid column name: ${columnName}. Column names must be a string that starts with a letter or underscore and contains only alphanumeric characters and underscores.`);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Validates every identifier in a column list to prevent SQL injection
|
|
912
|
+
*
|
|
913
|
+
* Column names reaching the CRUD methods come from `Object.keys(data)` and the
|
|
914
|
+
* caller's `conflictColumns`, both of which are interpolated into SQL. The
|
|
915
|
+
* accepted shape has no character that can end an identifier. Adapters may
|
|
916
|
+
* render a validated name bare, or quote it under an adapter-specific contract.
|
|
917
|
+
* PostgreSQL upsert uses {@link quotePostgresColumnName}, which canonicalizes
|
|
918
|
+
* the name before quoting so its established unquoted case-folding semantics
|
|
919
|
+
* do not change.
|
|
920
|
+
*
|
|
921
|
+
* @param columnNames - Column names to validate
|
|
922
|
+
* @throws Error if any column name contains invalid characters
|
|
923
|
+
*/
|
|
924
|
+
function validateColumnNames(columnNames) {
|
|
925
|
+
for (const columnName of columnNames) validateColumnName(columnName);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Quotes a validated column name using PostgreSQL's historic case semantics.
|
|
929
|
+
*
|
|
930
|
+
* PostgreSQL folds every unquoted identifier to lowercase. The CRUD API has
|
|
931
|
+
* always accepted mixed-case inputs while rendering them unquoted, so quoting
|
|
932
|
+
* the caller's spelling directly would silently change which physical column
|
|
933
|
+
* is addressed. Validate the existing plain-identifier contract first, then
|
|
934
|
+
* lowercase before quoting: `mixedCase` still addresses `mixedcase`, while a
|
|
935
|
+
* reserved word such as `end` is no longer parsed as SQL syntax.
|
|
936
|
+
*
|
|
937
|
+
* @param columnName - Plain column name to render for PostgreSQL
|
|
938
|
+
* @returns A lowercase, double-quoted PostgreSQL identifier
|
|
939
|
+
* @throws Error if the column name is outside the CRUD identifier contract
|
|
940
|
+
*/
|
|
941
|
+
function quotePostgresColumnName(columnName) {
|
|
942
|
+
validateColumnName(columnName);
|
|
943
|
+
return `"${columnName.toLowerCase()}"`;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Escapes an identifier for interpolation inside double quotes
|
|
947
|
+
*
|
|
948
|
+
* For the positions that already quote the identifier — the DuckDB and JSON
|
|
949
|
+
* upsert statements, which quote to match their schema generator — quoting is
|
|
950
|
+
* what makes the identifier safe, so the only thing that has to be handled is
|
|
951
|
+
* a quote character inside the name. Doubling it is the SQL escape.
|
|
952
|
+
*
|
|
953
|
+
* This is deliberately more permissive than {@link validateColumnName}: those
|
|
954
|
+
* adapters accept column names that are not plain identifiers (`Full Name`,
|
|
955
|
+
* `user-id`, `2024_total`), which matters most for the JSON adapter, whose
|
|
956
|
+
* columns come straight from the keys of whatever JSON it was pointed at.
|
|
957
|
+
* Rejecting them would break working code to fix an injection that quoting
|
|
958
|
+
* already prevents.
|
|
959
|
+
*
|
|
960
|
+
* @param identifier - Identifier to place inside double quotes
|
|
961
|
+
* @returns The identifier with embedded quotes doubled
|
|
962
|
+
* @throws Error if the identifier is empty or contains a NUL byte
|
|
963
|
+
*/
|
|
964
|
+
function escapeQuotedIdentifier(identifier) {
|
|
965
|
+
const value = String(identifier);
|
|
966
|
+
if (value.length === 0 || value.includes("\0")) throw new Error(`Invalid column name: ${JSON.stringify(value)}. Column names must be non-empty and must not contain a NUL byte.`);
|
|
967
|
+
return value.replaceAll("\"", "\"\"");
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Escapes a value for interpolation inside a single-quoted SQL string literal
|
|
971
|
+
*
|
|
972
|
+
* The DuckDB-backed adapters read JSON through `read_json_auto('<path>')`, and
|
|
973
|
+
* a file path is not something an identifier validator can check — paths
|
|
974
|
+
* legitimately contain spaces, dots and, on Linux and macOS, quotes. Doubling
|
|
975
|
+
* the quote is the SQL escape, and it is what keeps a file named `x'.json`
|
|
976
|
+
* from ending the literal and starting a statement.
|
|
977
|
+
*
|
|
978
|
+
* @param value - Value to place inside single quotes
|
|
979
|
+
* @returns The value with embedded single quotes doubled
|
|
980
|
+
* @throws Error if the value contains a NUL byte
|
|
981
|
+
*/
|
|
982
|
+
function escapeStringLiteral(value) {
|
|
983
|
+
const text = String(value);
|
|
984
|
+
if (text.includes("\0")) throw new Error(`Invalid SQL string literal: ${JSON.stringify(text)}. Values must not contain a NUL byte.`);
|
|
985
|
+
return text.replaceAll("'", "''");
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Validates an index name to prevent SQL injection
|
|
989
|
+
*
|
|
990
|
+
* @param indexName - Index name to validate
|
|
991
|
+
* @throws Error if index name contains invalid characters
|
|
992
|
+
*/
|
|
993
|
+
function validateIndexName(indexName) {
|
|
994
|
+
if (typeof indexName !== "string" || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(indexName)) throw new Error(`Invalid index name: ${indexName}. Index names must be a string that starts with a letter or underscore and contains only alphanumeric characters and underscores.`);
|
|
995
|
+
}
|
|
996
|
+
//#endregion
|
|
997
|
+
//#region src/shared/connection-cache.ts
|
|
998
|
+
var CACHED_VALIDATION_CAPABILITY = Symbol.for("@happyvertical/sql/connection-cache-cached-validation/v1");
|
|
999
|
+
/** Race-safe async resource cache used by database adapters. */
|
|
1000
|
+
var ConnectionCache = class {
|
|
1001
|
+
[CACHED_VALIDATION_CAPABILITY] = true;
|
|
1002
|
+
#connections = /* @__PURE__ */ new Map();
|
|
1003
|
+
#pending = /* @__PURE__ */ new Map();
|
|
1004
|
+
#evictions = /* @__PURE__ */ new Map();
|
|
1005
|
+
#requests = /* @__PURE__ */ new Map();
|
|
1006
|
+
#clearing = /* @__PURE__ */ new Map();
|
|
1007
|
+
#failedClosures = /* @__PURE__ */ new Map();
|
|
1008
|
+
async getOrCreate(key, options, create, close, validateCached) {
|
|
1009
|
+
validateDatabaseCacheOptions(options);
|
|
1010
|
+
const request = key && options.cache !== false ? this.#registerRequest(key) : void 0;
|
|
1011
|
+
try {
|
|
1012
|
+
this.#throwIfCancelled(request);
|
|
1013
|
+
if (key && options.clearCache) {
|
|
1014
|
+
await this.evict(key, close);
|
|
1015
|
+
this.#throwIfCancelled(request);
|
|
1016
|
+
}
|
|
1017
|
+
if (key) this.#throwIfFailedClosure(key);
|
|
1018
|
+
if (!key || options.cache === false) return create();
|
|
1019
|
+
while (true) {
|
|
1020
|
+
if (this.#evictions.has(key)) {
|
|
1021
|
+
await this.#waitForEviction(key);
|
|
1022
|
+
this.#throwIfCancelled(request);
|
|
1023
|
+
}
|
|
1024
|
+
this.#throwIfFailedClosure(key);
|
|
1025
|
+
const cached = this.#connections.get(key);
|
|
1026
|
+
if (cached !== void 0) {
|
|
1027
|
+
if (!validateCached) return cached;
|
|
1028
|
+
const isValid = await validateCached(cached);
|
|
1029
|
+
this.#throwIfCancelled(request);
|
|
1030
|
+
if (isValid && this.#connections.get(key) === cached && !this.#evictions.has(key)) return cached;
|
|
1031
|
+
if (!isValid && this.#connections.get(key) === cached && !this.#evictions.has(key)) {
|
|
1032
|
+
await this.evict(key, close);
|
|
1033
|
+
this.#throwIfCancelled(request);
|
|
1034
|
+
}
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
let pending = this.#pending.get(key);
|
|
1038
|
+
if (!pending) {
|
|
1039
|
+
this.#throwIfCancelled(request);
|
|
1040
|
+
pending = {
|
|
1041
|
+
invalidated: false,
|
|
1042
|
+
promise: create()
|
|
1043
|
+
};
|
|
1044
|
+
this.#pending.set(key, pending);
|
|
1045
|
+
}
|
|
1046
|
+
let connection;
|
|
1047
|
+
try {
|
|
1048
|
+
connection = await pending.promise;
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (this.#pending.get(key) === pending) this.#pending.delete(key);
|
|
1051
|
+
throw error;
|
|
1052
|
+
}
|
|
1053
|
+
if (pending.invalidated || this.#evictions.has(key)) {
|
|
1054
|
+
if (this.#evictions.has(key)) await this.#waitForEviction(key);
|
|
1055
|
+
this.#throwIfCancelled(request);
|
|
1056
|
+
if (pending.invalidated === "clear") throw this.#clearError();
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
this.#throwIfCancelled(request);
|
|
1060
|
+
if (this.#pending.get(key) === pending) this.#pending.delete(key);
|
|
1061
|
+
this.#connections.set(key, connection);
|
|
1062
|
+
return connection;
|
|
1063
|
+
}
|
|
1064
|
+
} finally {
|
|
1065
|
+
if (request && key) this.#finishRequest(key, request);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async evict(key, close, invalidation = "evict") {
|
|
1069
|
+
const previous = this.#evictions.get(key);
|
|
1070
|
+
const state = previous?.state ?? {
|
|
1071
|
+
invalidated: /* @__PURE__ */ new Set(),
|
|
1072
|
+
invalidation
|
|
1073
|
+
};
|
|
1074
|
+
if (invalidation === "clear") {
|
|
1075
|
+
state.invalidation = "clear";
|
|
1076
|
+
for (const pending of state.invalidated) pending.invalidated = "clear";
|
|
1077
|
+
}
|
|
1078
|
+
const eviction = (previous?.promise ?? Promise.resolve()).then(async () => {
|
|
1079
|
+
const resources = new Set(this.#failedClosures.get(key)?.resources ?? []);
|
|
1080
|
+
this.#failedClosures.delete(key);
|
|
1081
|
+
const cached = this.#connections.get(key);
|
|
1082
|
+
this.#connections.delete(key);
|
|
1083
|
+
if (cached) resources.add(cached);
|
|
1084
|
+
const pending = this.#pending.get(key);
|
|
1085
|
+
this.#pending.delete(key);
|
|
1086
|
+
if (pending) {
|
|
1087
|
+
state.invalidated.add(pending);
|
|
1088
|
+
pending.invalidated = state.invalidation;
|
|
1089
|
+
const initialized = await pending.promise.catch(() => void 0);
|
|
1090
|
+
if (initialized !== void 0) resources.add(initialized);
|
|
1091
|
+
}
|
|
1092
|
+
const closeResults = await Promise.allSettled([...resources].map(async (resource) => {
|
|
1093
|
+
await close(resource);
|
|
1094
|
+
return resource;
|
|
1095
|
+
}));
|
|
1096
|
+
const failures = closeResults.filter((result) => result.status === "rejected");
|
|
1097
|
+
if (failures.length) {
|
|
1098
|
+
const resourceList = [...resources];
|
|
1099
|
+
const failedResources = /* @__PURE__ */ new Set();
|
|
1100
|
+
closeResults.forEach((result, index) => {
|
|
1101
|
+
if (result.status === "rejected") failedResources.add(resourceList[index]);
|
|
1102
|
+
});
|
|
1103
|
+
const error = failures.length === 1 ? failures[0].reason : new AggregateError(failures.map((failure) => failure.reason), "Multiple database connections failed to close");
|
|
1104
|
+
this.#failedClosures.set(key, {
|
|
1105
|
+
error,
|
|
1106
|
+
resources: failedResources
|
|
1107
|
+
});
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
const chain = {
|
|
1112
|
+
promise: eviction,
|
|
1113
|
+
state
|
|
1114
|
+
};
|
|
1115
|
+
this.#evictions.set(key, chain);
|
|
1116
|
+
try {
|
|
1117
|
+
await eviction;
|
|
1118
|
+
} finally {
|
|
1119
|
+
if (this.#evictions.get(key) === chain) this.#evictions.delete(key);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
async clear(close) {
|
|
1123
|
+
const keys = /* @__PURE__ */ new Set([
|
|
1124
|
+
...this.#connections.keys(),
|
|
1125
|
+
...this.#pending.keys(),
|
|
1126
|
+
...this.#evictions.keys(),
|
|
1127
|
+
...this.#requests.keys(),
|
|
1128
|
+
...this.#clearing.keys(),
|
|
1129
|
+
...this.#failedClosures.keys()
|
|
1130
|
+
]);
|
|
1131
|
+
const failures = (await Promise.allSettled([...keys].map((key) => this.#clearKey(key, close)))).filter((result) => result.status === "rejected");
|
|
1132
|
+
if (failures.length === 1) throw failures[0].reason;
|
|
1133
|
+
if (failures.length > 1) throw new AggregateError(failures.map((failure) => failure.reason), "Multiple database connection cache entries failed to clear");
|
|
1134
|
+
}
|
|
1135
|
+
forget(connection) {
|
|
1136
|
+
for (const [key, cached] of this.#connections) if (cached === connection) this.#connections.delete(key);
|
|
1137
|
+
}
|
|
1138
|
+
/** @internal Test-only visibility; includes every retained namespace. */
|
|
1139
|
+
get size() {
|
|
1140
|
+
return this.#connections.size + this.#pending.size + this.#evictions.size + this.#requests.size + this.#clearing.size + this.#failedClosures.size;
|
|
1141
|
+
}
|
|
1142
|
+
async #clearKey(key, close) {
|
|
1143
|
+
this.#clearing.set(key, (this.#clearing.get(key) ?? 0) + 1);
|
|
1144
|
+
try {
|
|
1145
|
+
this.#cancelRequests(key);
|
|
1146
|
+
await this.evict(key, close, "clear");
|
|
1147
|
+
while (this.#requests.get(key)?.size) {
|
|
1148
|
+
this.#cancelRequests(key);
|
|
1149
|
+
await Promise.all([...this.#requests.get(key) ?? []].map((request) => request.done));
|
|
1150
|
+
}
|
|
1151
|
+
} finally {
|
|
1152
|
+
const remaining = (this.#clearing.get(key) ?? 1) - 1;
|
|
1153
|
+
if (remaining === 0) this.#clearing.delete(key);
|
|
1154
|
+
else this.#clearing.set(key, remaining);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
#cancelRequests(key) {
|
|
1158
|
+
for (const request of this.#requests.get(key) ?? []) request.cancelled = true;
|
|
1159
|
+
}
|
|
1160
|
+
#clearError() {
|
|
1161
|
+
return /* @__PURE__ */ new Error("Database connection initialization was cancelled by cache clear");
|
|
1162
|
+
}
|
|
1163
|
+
#finishRequest(key, request) {
|
|
1164
|
+
const requests = this.#requests.get(key);
|
|
1165
|
+
requests?.delete(request);
|
|
1166
|
+
if (requests?.size === 0) this.#requests.delete(key);
|
|
1167
|
+
request.finish();
|
|
1168
|
+
}
|
|
1169
|
+
#registerRequest(key) {
|
|
1170
|
+
let finish = () => {};
|
|
1171
|
+
const done = new Promise((resolve) => {
|
|
1172
|
+
finish = resolve;
|
|
1173
|
+
});
|
|
1174
|
+
const request = {
|
|
1175
|
+
cancelled: this.#clearing.has(key),
|
|
1176
|
+
done,
|
|
1177
|
+
finish
|
|
1178
|
+
};
|
|
1179
|
+
const requests = this.#requests.get(key) ?? /* @__PURE__ */ new Set();
|
|
1180
|
+
requests.add(request);
|
|
1181
|
+
this.#requests.set(key, requests);
|
|
1182
|
+
return request;
|
|
1183
|
+
}
|
|
1184
|
+
#throwIfCancelled(request) {
|
|
1185
|
+
if (request?.cancelled) throw this.#clearError();
|
|
1186
|
+
}
|
|
1187
|
+
#throwIfFailedClosure(key) {
|
|
1188
|
+
const failure = this.#failedClosures.get(key);
|
|
1189
|
+
if (failure) throw failure.error;
|
|
1190
|
+
}
|
|
1191
|
+
async #waitForEviction(key) {
|
|
1192
|
+
while (true) {
|
|
1193
|
+
const eviction = this.#evictions.get(key);
|
|
1194
|
+
if (!eviction) return;
|
|
1195
|
+
await eviction.promise;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
/**
|
|
1200
|
+
* Adds cached-resource validation to a shared cache created by an older module.
|
|
1201
|
+
*
|
|
1202
|
+
* JSON connection caches live on `globalThis`, so a compatible older SDK copy
|
|
1203
|
+
* can create the shared object before this module loads. Mutating that object
|
|
1204
|
+
* preserves one cache for both module copies while upgrading its four-argument
|
|
1205
|
+
* `getOrCreate` method to the current validation contract.
|
|
1206
|
+
*/
|
|
1207
|
+
function enableCachedValidation(cache) {
|
|
1208
|
+
if (cache[CACHED_VALIDATION_CAPABILITY]) return cache;
|
|
1209
|
+
const legacyGetOrCreate = cache.getOrCreate.bind(cache);
|
|
1210
|
+
const legacyClear = cache.clear.bind(cache);
|
|
1211
|
+
const locks = /* @__PURE__ */ new Map();
|
|
1212
|
+
let clearGeneration = 0;
|
|
1213
|
+
let activeClear;
|
|
1214
|
+
const runLocked = async (key, work) => {
|
|
1215
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
1216
|
+
let release;
|
|
1217
|
+
const current = new Promise((resolve) => {
|
|
1218
|
+
release = resolve;
|
|
1219
|
+
});
|
|
1220
|
+
const chain = previous.then(() => current, () => current);
|
|
1221
|
+
locks.set(key, chain);
|
|
1222
|
+
await previous.catch(() => {});
|
|
1223
|
+
try {
|
|
1224
|
+
return await work();
|
|
1225
|
+
} finally {
|
|
1226
|
+
release();
|
|
1227
|
+
if (locks.get(key) === chain) locks.delete(key);
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
Object.defineProperty(cache, "getOrCreate", {
|
|
1231
|
+
configurable: true,
|
|
1232
|
+
value: async (key, options, create, close, validateCached) => {
|
|
1233
|
+
if (!key || options.cache === false) return legacyGetOrCreate(key, options, create, close);
|
|
1234
|
+
const requestGeneration = clearGeneration;
|
|
1235
|
+
const throwIfCleared = () => {
|
|
1236
|
+
if (activeClear || requestGeneration !== clearGeneration) throw new Error("Database connection initialization was cancelled by cache clear");
|
|
1237
|
+
};
|
|
1238
|
+
return runLocked(key, async () => {
|
|
1239
|
+
throwIfCleared();
|
|
1240
|
+
let requestOptions = options;
|
|
1241
|
+
while (true) {
|
|
1242
|
+
throwIfCleared();
|
|
1243
|
+
const candidate = await legacyGetOrCreate(key, requestOptions, create, close);
|
|
1244
|
+
throwIfCleared();
|
|
1245
|
+
if (!validateCached) return candidate;
|
|
1246
|
+
const isValid = await validateCached(candidate);
|
|
1247
|
+
throwIfCleared();
|
|
1248
|
+
if (isValid) {
|
|
1249
|
+
const confirmed = await legacyGetOrCreate(key, {
|
|
1250
|
+
...requestOptions,
|
|
1251
|
+
clearCache: false
|
|
1252
|
+
}, create, close);
|
|
1253
|
+
throwIfCleared();
|
|
1254
|
+
if (confirmed === candidate) return candidate;
|
|
1255
|
+
} else await cache.evict(key, close);
|
|
1256
|
+
requestOptions = {
|
|
1257
|
+
...options,
|
|
1258
|
+
clearCache: false
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
},
|
|
1263
|
+
writable: true
|
|
1264
|
+
});
|
|
1265
|
+
Object.defineProperty(cache, "clear", {
|
|
1266
|
+
configurable: true,
|
|
1267
|
+
value: async (close) => {
|
|
1268
|
+
if (activeClear) return activeClear;
|
|
1269
|
+
clearGeneration += 1;
|
|
1270
|
+
const clearing = (async () => {
|
|
1271
|
+
let clearError;
|
|
1272
|
+
let clearFailed = false;
|
|
1273
|
+
try {
|
|
1274
|
+
await legacyClear(close);
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
clearFailed = true;
|
|
1277
|
+
clearError = error;
|
|
1278
|
+
}
|
|
1279
|
+
while (locks.size > 0) await Promise.allSettled([...locks.values()]);
|
|
1280
|
+
if (clearFailed) throw clearError;
|
|
1281
|
+
})();
|
|
1282
|
+
activeClear = clearing;
|
|
1283
|
+
try {
|
|
1284
|
+
await clearing;
|
|
1285
|
+
} finally {
|
|
1286
|
+
if (activeClear === clearing) activeClear = void 0;
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
writable: true
|
|
1290
|
+
});
|
|
1291
|
+
Object.defineProperty(cache, CACHED_VALIDATION_CAPABILITY, {
|
|
1292
|
+
configurable: false,
|
|
1293
|
+
enumerable: false,
|
|
1294
|
+
value: true,
|
|
1295
|
+
writable: false
|
|
1296
|
+
});
|
|
1297
|
+
return cache;
|
|
1298
|
+
}
|
|
1299
|
+
function validateDatabaseCacheOptions(options) {
|
|
1300
|
+
if (options.dbid === "") throw new Error("Database cache dbid must be a non-empty string");
|
|
1301
|
+
}
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/shared/transaction-lock.ts
|
|
1304
|
+
/**
|
|
1305
|
+
* How long a queued transaction waits for the connection before giving up.
|
|
1306
|
+
*
|
|
1307
|
+
* A transaction that legitimately runs longer than this will make the ones
|
|
1308
|
+
* behind it fail rather than stall indefinitely. Raise it with the adapter's
|
|
1309
|
+
* `transactionQueueTimeout` option if that is the wrong trade for a workload.
|
|
1310
|
+
*/
|
|
1311
|
+
var DEFAULT_TRANSACTION_QUEUE_TIMEOUT_MS = 3e4;
|
|
1312
|
+
/**
|
|
1313
|
+
* Creates a lock that serializes transactions on one connection.
|
|
1314
|
+
*
|
|
1315
|
+
* SQLite, DuckDB and the JSON adapter each drive a single connection, and a
|
|
1316
|
+
* connection can only be in one transaction at a time. `transaction()` used to
|
|
1317
|
+
* issue `BEGIN` on that shared connection with no serialization and no record
|
|
1318
|
+
* of whether one was already open, so two overlapping calls corrupted each
|
|
1319
|
+
* other: the second `BEGIN` threw, its `catch` ran `ROLLBACK`, and that
|
|
1320
|
+
* rollback landed on the *first* transaction — committing part of its work,
|
|
1321
|
+
* discarding the rest, and rejecting its promise. Nesting was not required;
|
|
1322
|
+
* two concurrent requests were enough, which made it reachable from any server
|
|
1323
|
+
* handling more than one at a time.
|
|
1324
|
+
*
|
|
1325
|
+
* The lock spans the whole `BEGIN` … `COMMIT`/`ROLLBACK` sequence, so a queued
|
|
1326
|
+
* transaction starts only once the previous one has ended. It is a
|
|
1327
|
+
* promise-chain mutex, the same shape as the `withNullAwareUpsertLock` the
|
|
1328
|
+
* SQLite adapter already used for the narrower upsert case.
|
|
1329
|
+
*
|
|
1330
|
+
* Re-entrant calls must not come through here. A nested `tx.transaction()`
|
|
1331
|
+
* arrives via the transaction-scoped interface, which re-enters under a
|
|
1332
|
+
* `SAVEPOINT` (SQLite) or refuses (DuckDB, JSON) without ever reaching the
|
|
1333
|
+
* top-level `transaction()`; routing one here instead would make it wait on a
|
|
1334
|
+
* lock its own caller is holding.
|
|
1335
|
+
*
|
|
1336
|
+
* @param adapter - Adapter name, for the timeout error
|
|
1337
|
+
* @param timeoutMs - Ceiling on how long to wait for a turn
|
|
1338
|
+
*/
|
|
1339
|
+
function createTransactionLock(adapter, timeoutMs = DEFAULT_TRANSACTION_QUEUE_TIMEOUT_MS) {
|
|
1340
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new DatabaseError("transactionQueueTimeout must be a positive, finite number of milliseconds", {
|
|
1341
|
+
adapter,
|
|
1342
|
+
timeoutMs
|
|
1343
|
+
});
|
|
1344
|
+
let tail = Promise.resolve();
|
|
1345
|
+
let held = false;
|
|
1346
|
+
const acquire = async () => {
|
|
1347
|
+
const previous = tail;
|
|
1348
|
+
let release = () => {};
|
|
1349
|
+
const current = new Promise((resolve) => {
|
|
1350
|
+
release = resolve;
|
|
1351
|
+
});
|
|
1352
|
+
tail = previous.then(() => current, () => current);
|
|
1353
|
+
let timer;
|
|
1354
|
+
try {
|
|
1355
|
+
await new Promise((resolve, reject) => {
|
|
1356
|
+
timer = setTimeout(() => {
|
|
1357
|
+
reject(new DatabaseError(`Timed out after ${timeoutMs}ms waiting for the ${adapter} connection's current transaction to finish. This connection runs one transaction at a time. Usually one of: a beginTransaction() handle that was never committed or rolled back; a top-level db.* call that opens its own transaction being made inside a transaction callback, which waits on the connection its own caller holds (use the tx-scoped method instead); or simply more queued transactions than transactionQueueTimeout allows for.`, {
|
|
1358
|
+
adapter,
|
|
1359
|
+
timeoutMs
|
|
1360
|
+
}));
|
|
1361
|
+
}, timeoutMs);
|
|
1362
|
+
previous.then(resolve, resolve);
|
|
1363
|
+
});
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
release();
|
|
1366
|
+
throw error;
|
|
1367
|
+
} finally {
|
|
1368
|
+
if (timer) clearTimeout(timer);
|
|
1369
|
+
}
|
|
1370
|
+
let released = false;
|
|
1371
|
+
held = true;
|
|
1372
|
+
return () => {
|
|
1373
|
+
if (released) return;
|
|
1374
|
+
released = true;
|
|
1375
|
+
held = false;
|
|
1376
|
+
release();
|
|
1377
|
+
};
|
|
1378
|
+
};
|
|
1379
|
+
const run = async (work) => {
|
|
1380
|
+
const release = await acquire();
|
|
1381
|
+
try {
|
|
1382
|
+
return await work();
|
|
1383
|
+
} finally {
|
|
1384
|
+
release();
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
return {
|
|
1388
|
+
acquire,
|
|
1389
|
+
get held() {
|
|
1390
|
+
return held;
|
|
1391
|
+
},
|
|
1392
|
+
run
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
//#endregion
|
|
1396
|
+
//#region src/shared/postgres-dialect.ts
|
|
1397
|
+
/**
|
|
1398
|
+
* PostgreSQL dialect logic shared by every adapter that speaks to a Postgres
|
|
1399
|
+
* server: the `pg` adapter (`../postgres`) and the PGlite adapter
|
|
1400
|
+
* (`../pglite`).
|
|
1401
|
+
*
|
|
1402
|
+
* Everything here runs on a {@link PostgresQueryExecutor}, so the SQL the two
|
|
1403
|
+
* adapters emit is the same code rather than two copies that drift. The module
|
|
1404
|
+
* must stay browser-safe: no `node:` built-ins and no import of `pg`, because
|
|
1405
|
+
* the PGlite adapter's import graph reaches it.
|
|
1406
|
+
*/
|
|
1407
|
+
/**
|
|
1408
|
+
* Operator for the given distance metric in pgvector
|
|
1409
|
+
*/
|
|
1410
|
+
function vectorOperator(metric) {
|
|
1411
|
+
switch (metric) {
|
|
1412
|
+
case "cosine": return "<=>";
|
|
1413
|
+
case "l2": return "<->";
|
|
1414
|
+
case "ip": return "<#>";
|
|
1415
|
+
default: return "<=>";
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
function getMaxPostgresParameterIndex(sql) {
|
|
1419
|
+
let maxIndex = 0;
|
|
1420
|
+
for (const match of sql.matchAll(/\$(\d+)/g)) {
|
|
1421
|
+
const index = Number(match[1]);
|
|
1422
|
+
if (Number.isSafeInteger(index) && index > maxIndex) maxIndex = index;
|
|
1423
|
+
}
|
|
1424
|
+
return maxIndex;
|
|
1425
|
+
}
|
|
1426
|
+
function usesSingleArrayParameter(sql) {
|
|
1427
|
+
const identifier = String.raw`(?:"[^"]+"|[a-z_][\w$]*)`;
|
|
1428
|
+
const qualifiedIdentifier = String.raw`${identifier}(?:\s*\.\s*${identifier})?`;
|
|
1429
|
+
const arrayType = String.raw`${qualifiedIdentifier}(?:\s*\([^)]*\))?\s*\[\]`;
|
|
1430
|
+
return new RegExp(String.raw`\$1\s*::\s*${arrayType}`, "i").test(sql) || new RegExp(String.raw`\bCAST\s*\(\s*\$1\s+AS\s+${arrayType}\s*\)`, "i").test(sql) || /\b(?:ANY|ALL|SOME)\s*\(\s*\$1\s*\)/i.test(sql);
|
|
1431
|
+
}
|
|
1432
|
+
function normalizeRawQueryValues(sql, values) {
|
|
1433
|
+
if (values.length !== 1 || !Array.isArray(values[0])) return values;
|
|
1434
|
+
const valuesArray = values[0];
|
|
1435
|
+
const maxParameterIndex = getMaxPostgresParameterIndex(sql);
|
|
1436
|
+
if (maxParameterIndex === 1 && usesSingleArrayParameter(sql)) return values;
|
|
1437
|
+
if (maxParameterIndex === 0 || maxParameterIndex === valuesArray.length) return valuesArray;
|
|
1438
|
+
return values;
|
|
1439
|
+
}
|
|
1440
|
+
function rewriteQuestionMarkPlaceholders(sql) {
|
|
1441
|
+
let output = "";
|
|
1442
|
+
let count = 0;
|
|
1443
|
+
let index = 0;
|
|
1444
|
+
const copyQuoted = (quote, escaped = false) => {
|
|
1445
|
+
output += quote;
|
|
1446
|
+
index += 1;
|
|
1447
|
+
while (index < sql.length) {
|
|
1448
|
+
const char = sql[index];
|
|
1449
|
+
output += char;
|
|
1450
|
+
index += 1;
|
|
1451
|
+
if (escaped && char === "\\" && index < sql.length) {
|
|
1452
|
+
output += sql[index];
|
|
1453
|
+
index += 1;
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
if (char === quote) {
|
|
1457
|
+
if (sql[index] === quote) {
|
|
1458
|
+
output += sql[index];
|
|
1459
|
+
index += 1;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
break;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
const copyUntil = (end) => {
|
|
1467
|
+
const endIndex = sql.indexOf(end, index + end.length);
|
|
1468
|
+
if (endIndex === -1) {
|
|
1469
|
+
output += sql.slice(index);
|
|
1470
|
+
index = sql.length;
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
output += sql.slice(index, endIndex + end.length);
|
|
1474
|
+
index = endIndex + end.length;
|
|
1475
|
+
};
|
|
1476
|
+
while (index < sql.length) {
|
|
1477
|
+
const char = sql[index];
|
|
1478
|
+
const next = sql[index + 1];
|
|
1479
|
+
if ((char === "E" || char === "e") && next === "'") {
|
|
1480
|
+
output += char;
|
|
1481
|
+
index += 1;
|
|
1482
|
+
copyQuoted("'", true);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
if (char === "'") {
|
|
1486
|
+
copyQuoted("'");
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
if (char === "\"") {
|
|
1490
|
+
copyQuoted("\"");
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (char === "-" && next === "-") {
|
|
1494
|
+
const endIndex = sql.indexOf("\n", index + 2);
|
|
1495
|
+
if (endIndex === -1) {
|
|
1496
|
+
output += sql.slice(index);
|
|
1497
|
+
index = sql.length;
|
|
1498
|
+
} else {
|
|
1499
|
+
output += sql.slice(index, endIndex + 1);
|
|
1500
|
+
index = endIndex + 1;
|
|
1501
|
+
}
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
if (char === "/" && next === "*") {
|
|
1505
|
+
copyUntil("*/");
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (char === "$") {
|
|
1509
|
+
const tag = sql.slice(index).match(/^\$[A-Za-z_][\w$]*\$|^\$\$/);
|
|
1510
|
+
if (tag) {
|
|
1511
|
+
copyUntil(tag[0]);
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
if (char === "?" && next !== "|" && next !== "&") {
|
|
1516
|
+
count += 1;
|
|
1517
|
+
output += `$${count}`;
|
|
1518
|
+
index += 1;
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
output += char;
|
|
1522
|
+
index += 1;
|
|
1523
|
+
}
|
|
1524
|
+
return {
|
|
1525
|
+
sql: output,
|
|
1526
|
+
count
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
function usesSingleQuestionArrayParameter(sql) {
|
|
1530
|
+
const identifier = String.raw`(?:"[^"]+"|[a-z_][\w$]*)`;
|
|
1531
|
+
const qualifiedIdentifier = String.raw`${identifier}(?:\s*\.\s*${identifier})?`;
|
|
1532
|
+
const arrayType = String.raw`${qualifiedIdentifier}(?:\s*\([^)]*\))?\s*\[\]`;
|
|
1533
|
+
return new RegExp(String.raw`\?\s*::\s*${arrayType}`, "i").test(sql) || new RegExp(String.raw`\bCAST\s*\(\s*\?\s+AS\s+${arrayType}\s*\)`, "i").test(sql) || /\b(?:ANY|ALL|SOME)\s*\(\s*\?\s*\)/i.test(sql);
|
|
1534
|
+
}
|
|
1535
|
+
function normalizeQuestionMarkQueryValues(sql, values, placeholderCount) {
|
|
1536
|
+
if (values.length !== 1 || !Array.isArray(values[0])) return values;
|
|
1537
|
+
if (placeholderCount === 1 && usesSingleQuestionArrayParameter(sql)) return values;
|
|
1538
|
+
if (placeholderCount === values[0].length) return values[0];
|
|
1539
|
+
return values;
|
|
1540
|
+
}
|
|
1541
|
+
function normalizePostgresRawQuery(sql, values) {
|
|
1542
|
+
if (getMaxPostgresParameterIndex(sql) > 0) return {
|
|
1543
|
+
sql,
|
|
1544
|
+
values: normalizeRawQueryValues(sql, values)
|
|
1545
|
+
};
|
|
1546
|
+
const rewritten = rewriteQuestionMarkPlaceholders(sql);
|
|
1547
|
+
const placeholderCount = rewritten.count;
|
|
1548
|
+
const queryValues = normalizeQuestionMarkQueryValues(sql, values, placeholderCount);
|
|
1549
|
+
if (placeholderCount > 0 && placeholderCount === queryValues.length) return {
|
|
1550
|
+
sql: rewritten.sql,
|
|
1551
|
+
values: queryValues
|
|
1552
|
+
};
|
|
1553
|
+
return {
|
|
1554
|
+
sql,
|
|
1555
|
+
values: normalizeRawQueryValues(sql, values)
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* pgvector index operator class for CREATE INDEX
|
|
1560
|
+
*/
|
|
1561
|
+
function vectorOpsClass(metric) {
|
|
1562
|
+
switch (metric) {
|
|
1563
|
+
case "cosine": return "vector_cosine_ops";
|
|
1564
|
+
case "l2": return "vector_l2_ops";
|
|
1565
|
+
case "ip": return "vector_ip_ops";
|
|
1566
|
+
default: return "vector_cosine_ops";
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Convert a number[] to a pgvector literal string: '[0.1,0.2,0.3]'
|
|
1571
|
+
*/
|
|
1572
|
+
function toVectorLiteral(embedding) {
|
|
1573
|
+
if (embedding.length === 0) throw new DatabaseError("Vector embedding must not be empty", {});
|
|
1574
|
+
for (let i = 0; i < embedding.length; i++) if (!Number.isFinite(embedding[i])) throw new DatabaseError("Vector embedding contains invalid value (NaN or Infinity)", {
|
|
1575
|
+
index: i,
|
|
1576
|
+
value: embedding[i]
|
|
1577
|
+
});
|
|
1578
|
+
return `[${embedding.join(",")}]`;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Creates vector capabilities backed by pgvector for a PostgreSQL pool.
|
|
1582
|
+
*
|
|
1583
|
+
* @param pool - PostgreSQL connection pool
|
|
1584
|
+
* @returns VectorCapabilities implementation
|
|
1585
|
+
*/
|
|
1586
|
+
/**
|
|
1587
|
+
* `type` is interpolated into `USING <type>`, which cannot be parameterized and
|
|
1588
|
+
* is not an identifier the quoting helpers cover. `VectorIndexOptions.type`
|
|
1589
|
+
* constrains it at compile time only, so anything reaching this at runtime —
|
|
1590
|
+
* a JavaScript caller, or a `filter as VectorIndexOptions` on parsed JSON — has
|
|
1591
|
+
* to be checked against the values pgvector actually offers.
|
|
1592
|
+
*/
|
|
1593
|
+
var VECTOR_INDEX_TYPES = /* @__PURE__ */ new Set(["hnsw", "ivfflat"]);
|
|
1594
|
+
/**
|
|
1595
|
+
* `metric` needs the same treatment as `type`, for a less obvious reason.
|
|
1596
|
+
*
|
|
1597
|
+
* It never reaches SQL directly — `vectorOpsClass`/`vectorOperator` map it
|
|
1598
|
+
* through a closed switch with a safe default, so an unknown metric produces a
|
|
1599
|
+
* valid operator class. But it is also concatenated into the *generated index
|
|
1600
|
+
* name*, which is then interpolated into a quoted identifier, so a metric
|
|
1601
|
+
* carrying a `"` still ends that identifier. The safe-default mapping is what
|
|
1602
|
+
* makes this easy to miss: the part of the metric that looks dangerous is
|
|
1603
|
+
* handled, and the part that is actually dangerous is somewhere else.
|
|
1604
|
+
*/
|
|
1605
|
+
var VECTOR_METRICS = /* @__PURE__ */ new Set([
|
|
1606
|
+
"cosine",
|
|
1607
|
+
"l2",
|
|
1608
|
+
"ip"
|
|
1609
|
+
]);
|
|
1610
|
+
function createVectorCapabilities(pool) {
|
|
1611
|
+
return {
|
|
1612
|
+
async ensureColumn(table, column, dimensions) {
|
|
1613
|
+
if (!Number.isInteger(dimensions) || dimensions <= 0) throw new DatabaseError("Vector dimensions must be a positive integer", { dimensions });
|
|
1614
|
+
validateTableName(table);
|
|
1615
|
+
validateColumnName(column);
|
|
1616
|
+
try {
|
|
1617
|
+
await pool.query("CREATE EXTENSION IF NOT EXISTS vector");
|
|
1618
|
+
await pool.query(`ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "${column}" vector(${dimensions})`);
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
throw new DatabaseError("Failed to ensure vector column", {
|
|
1621
|
+
table,
|
|
1622
|
+
column,
|
|
1623
|
+
dimensions,
|
|
1624
|
+
originalError: formatDbError(e)
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
async ensureIndex(table, column, options) {
|
|
1629
|
+
validateTableName(table);
|
|
1630
|
+
validateColumnName(column);
|
|
1631
|
+
const metric = options?.metric || "cosine";
|
|
1632
|
+
const indexType = options?.type || "hnsw";
|
|
1633
|
+
if (!VECTOR_INDEX_TYPES.has(indexType)) throw new DatabaseError("Unsupported vector index type", {
|
|
1634
|
+
table,
|
|
1635
|
+
column,
|
|
1636
|
+
type: indexType,
|
|
1637
|
+
supported: [...VECTOR_INDEX_TYPES]
|
|
1638
|
+
});
|
|
1639
|
+
if (!VECTOR_METRICS.has(metric)) throw new DatabaseError("Unsupported vector metric", {
|
|
1640
|
+
table,
|
|
1641
|
+
column,
|
|
1642
|
+
metric,
|
|
1643
|
+
supported: [...VECTOR_METRICS]
|
|
1644
|
+
});
|
|
1645
|
+
const opsClass = vectorOpsClass(metric);
|
|
1646
|
+
const indexName = `idx_${table}_${column}_${metric}_${indexType}`;
|
|
1647
|
+
try {
|
|
1648
|
+
await pool.query(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${table}" USING ${indexType} ("${column}" ${opsClass})`);
|
|
1649
|
+
} catch (e) {
|
|
1650
|
+
throw new DatabaseError("Failed to ensure vector index", {
|
|
1651
|
+
table,
|
|
1652
|
+
column,
|
|
1653
|
+
indexName,
|
|
1654
|
+
originalError: formatDbError(e)
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
async upsertVector(table, where, column, embedding) {
|
|
1659
|
+
validateTableName(table);
|
|
1660
|
+
validateColumnName(column);
|
|
1661
|
+
const keys = Object.keys(where);
|
|
1662
|
+
validateColumnNames(keys);
|
|
1663
|
+
const values = Object.values(where);
|
|
1664
|
+
const conditions = keys.map((key, i) => `"${key}" IS NOT DISTINCT FROM $${i + 1}`).join(" AND ");
|
|
1665
|
+
const vectorParam = `$${values.length + 1}`;
|
|
1666
|
+
try {
|
|
1667
|
+
await pool.query(`UPDATE "${table}" SET "${column}" = ${vectorParam}::vector WHERE ${conditions}`, [...values, toVectorLiteral(embedding)]);
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
throw new DatabaseError("Failed to upsert vector", {
|
|
1670
|
+
table,
|
|
1671
|
+
column,
|
|
1672
|
+
where,
|
|
1673
|
+
originalError: formatDbError(e)
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
},
|
|
1677
|
+
async search(table, column, embedding, options) {
|
|
1678
|
+
validateTableName(table);
|
|
1679
|
+
validateColumnName(column);
|
|
1680
|
+
const metric = options?.metric || "cosine";
|
|
1681
|
+
const limit = options?.limit || 10;
|
|
1682
|
+
const op = vectorOperator(metric);
|
|
1683
|
+
const queryParams = [toVectorLiteral(embedding)];
|
|
1684
|
+
let whereClause = `"${column}" IS NOT NULL`;
|
|
1685
|
+
if (options?.where) {
|
|
1686
|
+
whereClause += ` AND (${options.where})`;
|
|
1687
|
+
if (options.params) queryParams.push(...options.params);
|
|
1688
|
+
}
|
|
1689
|
+
const limitParam = `$${queryParams.length + 1}`;
|
|
1690
|
+
queryParams.push(limit);
|
|
1691
|
+
const sql = `SELECT *, ("${column}" ${op} $1::vector) AS distance FROM "${table}" WHERE ${whereClause} ORDER BY "${column}" ${op} $1::vector LIMIT ${limitParam}`;
|
|
1692
|
+
try {
|
|
1693
|
+
return (await pool.query(sql, queryParams)).rows.map((row) => ({
|
|
1694
|
+
...row,
|
|
1695
|
+
id: row.id,
|
|
1696
|
+
distance: Number.parseFloat(row.distance)
|
|
1697
|
+
}));
|
|
1698
|
+
} catch (e) {
|
|
1699
|
+
throw new DatabaseError("Failed to execute vector search", {
|
|
1700
|
+
table,
|
|
1701
|
+
column,
|
|
1702
|
+
metric,
|
|
1703
|
+
limit,
|
|
1704
|
+
originalError: formatDbError(e)
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Keeps the first PostgreSQL statement failure attached to a transaction.
|
|
1712
|
+
*
|
|
1713
|
+
* PostgreSQL reports every later statement in an aborted transaction as
|
|
1714
|
+
* 25P02. Replacing the first failure with that generic state error hides the
|
|
1715
|
+
* actionable code and diagnostics from callers, so the tracker rethrows the
|
|
1716
|
+
* first failure whenever PostgreSQL reports 25P02, and exposes the recorded
|
|
1717
|
+
* failure so a swallowed statement error cannot make COMMIT's implicit
|
|
1718
|
+
* rollback look successful: PostgreSQL answers COMMIT on an aborted
|
|
1719
|
+
* transaction with a ROLLBACK tag and no error.
|
|
1720
|
+
*
|
|
1721
|
+
* @param isStatementError - Recognizes the driver's server-reported error
|
|
1722
|
+
* class (`pg`'s or PGlite's `DatabaseError`). Anything else is an
|
|
1723
|
+
* unconfirmed failure — it may be the transport, not the statement.
|
|
1724
|
+
*/
|
|
1725
|
+
function createTransactionErrorTracker(isStatementError) {
|
|
1726
|
+
let firstError;
|
|
1727
|
+
let firstUnconfirmedError;
|
|
1728
|
+
const pending = /* @__PURE__ */ new Set();
|
|
1729
|
+
const isRollbackToSavepoint = (query) => {
|
|
1730
|
+
const sql = typeof query === "string" ? query : typeof query === "object" && query !== null && "text" in query && typeof query.text === "string" ? query.text : void 0;
|
|
1731
|
+
if (sql === void 0) return false;
|
|
1732
|
+
let statement = sql.trimStart();
|
|
1733
|
+
while (statement.startsWith("--") || statement.startsWith("/*")) {
|
|
1734
|
+
if (statement.startsWith("--")) {
|
|
1735
|
+
const newline = statement.search(/[\r\n]/);
|
|
1736
|
+
if (newline === -1) return false;
|
|
1737
|
+
statement = statement.slice(newline + 1).trimStart();
|
|
1738
|
+
continue;
|
|
1739
|
+
}
|
|
1740
|
+
const commentEnd = statement.indexOf("*/", 2);
|
|
1741
|
+
if (commentEnd === -1) return false;
|
|
1742
|
+
statement = statement.slice(commentEnd + 2).trimStart();
|
|
1743
|
+
}
|
|
1744
|
+
return /^ROLLBACK\s+TO(?:\s+SAVEPOINT)?\s+/i.test(statement);
|
|
1745
|
+
};
|
|
1746
|
+
const errorCode = (error) => {
|
|
1747
|
+
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
|
|
1748
|
+
};
|
|
1749
|
+
/** Records a failure and returns the error the caller should see. */
|
|
1750
|
+
const preserve = (error) => {
|
|
1751
|
+
if (errorCode(error) === "25P02" && firstError !== void 0) return firstError;
|
|
1752
|
+
if (errorCode(error) === "25P02" && firstUnconfirmedError !== void 0) return firstUnconfirmedError;
|
|
1753
|
+
if (isStatementError(error)) firstError ??= error;
|
|
1754
|
+
else firstUnconfirmedError ??= error;
|
|
1755
|
+
return error;
|
|
1756
|
+
};
|
|
1757
|
+
/**
|
|
1758
|
+
* Records that a statement succeeded. A successful rollback to a savepoint
|
|
1759
|
+
* restores the transaction to the state at that savepoint, so a recorded
|
|
1760
|
+
* statement failure has been contained.
|
|
1761
|
+
*/
|
|
1762
|
+
const confirm = (query) => {
|
|
1763
|
+
if (isRollbackToSavepoint(query)) firstError = void 0;
|
|
1764
|
+
firstUnconfirmedError = void 0;
|
|
1765
|
+
};
|
|
1766
|
+
const track = (operation, query) => {
|
|
1767
|
+
const tracked = operation.then((result) => {
|
|
1768
|
+
confirm(query);
|
|
1769
|
+
return result;
|
|
1770
|
+
}).catch((error) => {
|
|
1771
|
+
throw preserve(error);
|
|
1772
|
+
});
|
|
1773
|
+
pending.add(tracked);
|
|
1774
|
+
tracked.finally(() => pending.delete(tracked)).catch(() => {});
|
|
1775
|
+
return tracked;
|
|
1776
|
+
};
|
|
1777
|
+
return {
|
|
1778
|
+
pending,
|
|
1779
|
+
preserve,
|
|
1780
|
+
confirm,
|
|
1781
|
+
track,
|
|
1782
|
+
clearError: () => {
|
|
1783
|
+
firstError = void 0;
|
|
1784
|
+
},
|
|
1785
|
+
drain: async () => {
|
|
1786
|
+
while (pending.size > 0) await Promise.allSettled([...pending]);
|
|
1787
|
+
},
|
|
1788
|
+
getError: () => firstError,
|
|
1789
|
+
getUnconfirmedError: () => firstUnconfirmedError
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Creates the dialect: every statement-level method plus nested-transaction
|
|
1794
|
+
* and schema-introspection builders, all bound to `host`.
|
|
1795
|
+
*/
|
|
1796
|
+
function createPostgresDialect(host) {
|
|
1797
|
+
let savepointSequence = 0;
|
|
1798
|
+
/**
|
|
1799
|
+
* Serializes a value for database storage
|
|
1800
|
+
* Converts objects and arrays to JSON strings
|
|
1801
|
+
* Converts Dates to ISO strings
|
|
1802
|
+
* Passes through primitives unchanged
|
|
1803
|
+
*/
|
|
1804
|
+
const serializeValue = (value) => {
|
|
1805
|
+
if (value === null || value === void 0) return value;
|
|
1806
|
+
if (value instanceof Date) return value.toISOString();
|
|
1807
|
+
if (ArrayBuffer.isView(value)) return value;
|
|
1808
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1809
|
+
return value;
|
|
1810
|
+
};
|
|
1811
|
+
/**
|
|
1812
|
+
* Serializes all values in an object for database storage
|
|
1813
|
+
*/
|
|
1814
|
+
const serializeRecord = (record) => {
|
|
1815
|
+
const serialized = {};
|
|
1816
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1817
|
+
if (value === void 0) continue;
|
|
1818
|
+
serialized[key] = serializeValue(value);
|
|
1819
|
+
}
|
|
1820
|
+
return serialized;
|
|
1821
|
+
};
|
|
1822
|
+
let serverVersionNumPromise;
|
|
1823
|
+
const nullsNotDistinctIndexCache = /* @__PURE__ */ new Map();
|
|
1824
|
+
const hasNullConflictValue = (conflictColumns, data) => conflictColumns.some((col) => data[col] === null);
|
|
1825
|
+
const validateUpsertConflictColumns = (table, conflictColumns, serializedData) => {
|
|
1826
|
+
const missingColumns = conflictColumns.filter((col) => !(col in serializedData));
|
|
1827
|
+
if (missingColumns.length > 0) throw new DatabaseError("Conflict columns missing from data", {
|
|
1828
|
+
table,
|
|
1829
|
+
conflictColumns,
|
|
1830
|
+
missingColumns,
|
|
1831
|
+
availableColumns: Object.keys(serializedData),
|
|
1832
|
+
hint: "All columns specified in ON CONFLICT must be present in the data being inserted. Undefined values should be replaced with null or an appropriate default."
|
|
1833
|
+
});
|
|
1834
|
+
};
|
|
1835
|
+
const getServerVersionNum = async (executor) => {
|
|
1836
|
+
serverVersionNumPromise ??= executor.query("SHOW server_version_num").then((result) => Number(result.rows[0]?.server_version_num) || 0).catch((error) => {
|
|
1837
|
+
serverVersionNumPromise = void 0;
|
|
1838
|
+
throw error;
|
|
1839
|
+
});
|
|
1840
|
+
return serverVersionNumPromise;
|
|
1841
|
+
};
|
|
1842
|
+
const hasNativeNullsNotDistinctIndex = async (executor, table, conflictColumns) => {
|
|
1843
|
+
if (await getServerVersionNum(executor) < 15e4) return false;
|
|
1844
|
+
const cacheKey = `${table}:${conflictColumns.join(",")}`;
|
|
1845
|
+
let cached = nullsNotDistinctIndexCache.get(cacheKey);
|
|
1846
|
+
if (!cached) {
|
|
1847
|
+
cached = executor.query(`
|
|
1848
|
+
SELECT 1
|
|
1849
|
+
FROM pg_index i
|
|
1850
|
+
WHERE i.indrelid = to_regclass($1)
|
|
1851
|
+
AND i.indisunique
|
|
1852
|
+
AND i.indnullsnotdistinct
|
|
1853
|
+
AND ARRAY(
|
|
1854
|
+
SELECT a.attname::text
|
|
1855
|
+
FROM unnest(i.indkey) WITH ORDINALITY AS cols(attnum, ord)
|
|
1856
|
+
JOIN pg_attribute a
|
|
1857
|
+
ON a.attrelid = i.indrelid
|
|
1858
|
+
AND a.attnum = cols.attnum
|
|
1859
|
+
ORDER BY cols.ord
|
|
1860
|
+
) = $2::text[]
|
|
1861
|
+
LIMIT 1
|
|
1862
|
+
`, [table, conflictColumns]).then((result) => result.rows.length > 0).catch(() => {
|
|
1863
|
+
nullsNotDistinctIndexCache.delete(cacheKey);
|
|
1864
|
+
return false;
|
|
1865
|
+
});
|
|
1866
|
+
nullsNotDistinctIndexCache.set(cacheKey, cached);
|
|
1867
|
+
}
|
|
1868
|
+
return cached;
|
|
1869
|
+
};
|
|
1870
|
+
const buildPostgresUpsertParts = (conflictColumns, serializedData) => {
|
|
1871
|
+
const keys = Object.keys(serializedData);
|
|
1872
|
+
const columns = keys.map(quotePostgresColumnName);
|
|
1873
|
+
return {
|
|
1874
|
+
columns,
|
|
1875
|
+
values: Object.values(serializedData),
|
|
1876
|
+
placeholders: keys.map((_, i) => `$${i + 1}`).join(", "),
|
|
1877
|
+
updateSet: columns.map((column, i) => `${column} = $${i + 1}`).join(", "),
|
|
1878
|
+
conflict: conflictColumns.map(quotePostgresColumnName).join(", ")
|
|
1879
|
+
};
|
|
1880
|
+
};
|
|
1881
|
+
const executeStandardPostgresUpsert = async (executor, table, conflictColumns, serializedData) => {
|
|
1882
|
+
const { columns, values, placeholders, updateSet, conflict } = buildPostgresUpsertParts(conflictColumns, serializedData);
|
|
1883
|
+
const sql = `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders}) ON CONFLICT(${conflict}) DO UPDATE SET ${updateSet}`;
|
|
1884
|
+
return {
|
|
1885
|
+
operation: "upsert",
|
|
1886
|
+
affected: (await executor.query(sql, values)).rowCount ?? 0
|
|
1887
|
+
};
|
|
1888
|
+
};
|
|
1889
|
+
const executeNullAwarePostgresUpsert = async (executor, table, conflictColumns, serializedData) => {
|
|
1890
|
+
const { columns, values, placeholders, updateSet } = buildPostgresUpsertParts(conflictColumns, serializedData);
|
|
1891
|
+
const lockKey = conflictColumns.map((col) => `${col}:${JSON.stringify(serializedData[col])}`).join("|");
|
|
1892
|
+
await executor.query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))", [`@happyvertical/sql.upsert:${table}`, lockKey]);
|
|
1893
|
+
const whereStart = values.length + 1;
|
|
1894
|
+
const whereClause = conflictColumns.map((col, i) => `${quotePostgresColumnName(col)} IS NOT DISTINCT FROM $${whereStart + i}`).join(" AND ");
|
|
1895
|
+
const whereValues = conflictColumns.map((col) => serializedData[col]);
|
|
1896
|
+
const updateSql = `UPDATE ${table} SET ${updateSet} WHERE ${whereClause}`;
|
|
1897
|
+
const updateResult = await executor.query(updateSql, [...values, ...whereValues]);
|
|
1898
|
+
if ((updateResult.rowCount ?? 0) > 0) return {
|
|
1899
|
+
operation: "upsert",
|
|
1900
|
+
affected: updateResult.rowCount ?? 0
|
|
1901
|
+
};
|
|
1902
|
+
const insertSql = `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`;
|
|
1903
|
+
return {
|
|
1904
|
+
operation: "upsert",
|
|
1905
|
+
affected: (await executor.query(insertSql, values)).rowCount ?? 0
|
|
1906
|
+
};
|
|
1907
|
+
};
|
|
1908
|
+
const executePostgresUpsert = async (executor, table, conflictColumns, serializedData, options, acquireTransaction) => {
|
|
1909
|
+
validateUpsertConflictColumns(table, conflictColumns, serializedData);
|
|
1910
|
+
if (options?.nullsDistinct || !hasNullConflictValue(conflictColumns, serializedData)) return executeStandardPostgresUpsert(executor, table, conflictColumns, serializedData);
|
|
1911
|
+
if (await hasNativeNullsNotDistinctIndex(host.probeExecutor(executor), table, conflictColumns)) return executeStandardPostgresUpsert(executor, table, conflictColumns, serializedData);
|
|
1912
|
+
if (!acquireTransaction) return executeNullAwarePostgresUpsert(executor, table, conflictColumns, serializedData);
|
|
1913
|
+
return host.runInTransaction((transactionExecutor) => executeNullAwarePostgresUpsert(transactionExecutor, table, conflictColumns, serializedData));
|
|
1914
|
+
};
|
|
1915
|
+
/**
|
|
1916
|
+
* Parses a tagged template literal into a SQL query and values
|
|
1917
|
+
*
|
|
1918
|
+
* @param strings - Template strings
|
|
1919
|
+
* @param vars - Variables to interpolate into the query
|
|
1920
|
+
* @returns Object with SQL query and values array
|
|
1921
|
+
*/
|
|
1922
|
+
const parseTemplate = (strings, ...vars) => {
|
|
1923
|
+
let sql = strings[0];
|
|
1924
|
+
const values = [];
|
|
1925
|
+
for (let i = 0; i < vars.length; i++) {
|
|
1926
|
+
values.push(vars[i]);
|
|
1927
|
+
sql += `$${i + 1}${strings[i + 1]}`;
|
|
1928
|
+
}
|
|
1929
|
+
return {
|
|
1930
|
+
sql,
|
|
1931
|
+
values
|
|
1932
|
+
};
|
|
1933
|
+
};
|
|
1934
|
+
/**
|
|
1935
|
+
* Builds the complete set of query methods bound to one PostgreSQL client.
|
|
1936
|
+
*
|
|
1937
|
+
* The pool-backed interface and every transaction-scoped interface are
|
|
1938
|
+
* instantiated from this one factory, so there is exactly one implementation
|
|
1939
|
+
* of each method. The transaction interfaces used to be hand-maintained
|
|
1940
|
+
* copies of these bodies, and both had drifted:
|
|
1941
|
+
*
|
|
1942
|
+
* - they called the client bare, so a failure inside a transaction threw the
|
|
1943
|
+
* raw `pg` error rather than `DatabaseError`, and `instanceof` answered
|
|
1944
|
+
* differently depending on whether the call was in a transaction. `pg`
|
|
1945
|
+
* exports its own class also named `DatabaseError`, so nothing a developer
|
|
1946
|
+
* would check by eye showed the difference (#1115);
|
|
1947
|
+
* - they re-exposed `tableExists` and `syncSchema` from the enclosing scope,
|
|
1948
|
+
* which close over the *pool*. Both therefore ran on a different connection
|
|
1949
|
+
* than the transaction, so `tx.tableExists()` could not see a table created
|
|
1950
|
+
* in the same transaction and `tx.syncSchema()` committed its DDL
|
|
1951
|
+
* immediately, surviving a rollback (#1111).
|
|
1952
|
+
*
|
|
1953
|
+
* Binding a client is the only difference between the two, so it is the only
|
|
1954
|
+
* thing this factory parameterizes.
|
|
1955
|
+
*
|
|
1956
|
+
* @param executor - Pool or checked-out client every statement runs on
|
|
1957
|
+
* @param inTransaction - Whether `executor` is already inside a transaction
|
|
1958
|
+
* @param serialize - Applied to each record before `insert`/`update` builds
|
|
1959
|
+
* its SQL
|
|
1960
|
+
* @param clearTransactionError - Clears a statement error after a savepoint
|
|
1961
|
+
* has successfully restored the transaction to a usable state
|
|
1962
|
+
*/
|
|
1963
|
+
const createClientMethods = (executor, inTransaction, serialize, clearTransactionError = () => {}) => {
|
|
1964
|
+
/**
|
|
1965
|
+
* Inserts one or more records into a table
|
|
1966
|
+
*
|
|
1967
|
+
* @param table - Table name
|
|
1968
|
+
* @param data - Single record or array of records to insert
|
|
1969
|
+
* @returns Promise resolving to operation result
|
|
1970
|
+
* @throws Error if the insert operation fails
|
|
1971
|
+
*
|
|
1972
|
+
* @example Single record insert:
|
|
1973
|
+
* ```typescript
|
|
1974
|
+
* await db.insert('users', {
|
|
1975
|
+
* name: 'John Doe',
|
|
1976
|
+
* email: 'john@example.com'
|
|
1977
|
+
* });
|
|
1978
|
+
* ```
|
|
1979
|
+
*
|
|
1980
|
+
* @example Multiple record insert:
|
|
1981
|
+
* ```typescript
|
|
1982
|
+
* await db.insert('users', [
|
|
1983
|
+
* { name: 'John', email: 'john@example.com' },
|
|
1984
|
+
* { name: 'Jane', email: 'jane@example.com' }
|
|
1985
|
+
* ]);
|
|
1986
|
+
* ```
|
|
1987
|
+
*/
|
|
1988
|
+
const insert = async (table, data) => {
|
|
1989
|
+
validateTableName(table);
|
|
1990
|
+
if (Array.isArray(data)) {
|
|
1991
|
+
if (data.length === 0) return {
|
|
1992
|
+
operation: "insert",
|
|
1993
|
+
affected: 0
|
|
1994
|
+
};
|
|
1995
|
+
const serializedRecords = data.map((record) => serialize(record));
|
|
1996
|
+
const keys = resolveInsertColumns(table, serializedRecords);
|
|
1997
|
+
validateColumnNames(keys);
|
|
1998
|
+
const placeholders = serializedRecords.map((_, i) => `(${keys.map((_, j) => `$${i * keys.length + j + 1}`).join(", ")})`).join(", ");
|
|
1999
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES ${placeholders}`;
|
|
2000
|
+
const values = serializedRecords.flatMap((row) => keys.map((key) => row[key]));
|
|
2001
|
+
return {
|
|
2002
|
+
operation: "insert",
|
|
2003
|
+
affected: (await executor.query(query, values)).rowCount ?? 0
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
const serializedData = serialize(data);
|
|
2007
|
+
const keys = Object.keys(serializedData);
|
|
2008
|
+
validateColumnNames(keys);
|
|
2009
|
+
const values = Object.values(serializedData);
|
|
2010
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
|
|
2011
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
2012
|
+
return {
|
|
2013
|
+
operation: "insert",
|
|
2014
|
+
affected: (await executor.query(query, values)).rowCount ?? 0
|
|
2015
|
+
};
|
|
2016
|
+
};
|
|
2017
|
+
/**
|
|
2018
|
+
* Retrieves a single record matching the where criteria
|
|
2019
|
+
*
|
|
2020
|
+
* @param table - Table name
|
|
2021
|
+
* @param where - Criteria to match records
|
|
2022
|
+
* @returns Promise resolving to query result
|
|
2023
|
+
*/
|
|
2024
|
+
const get = async (table, where) => {
|
|
2025
|
+
validateTableName(table);
|
|
2026
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2027
|
+
if (!whereClause) throw new DatabaseError("GET requires at least one WHERE condition to prevent returning an arbitrary record", { table });
|
|
2028
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2029
|
+
try {
|
|
2030
|
+
return (await executor.query(query, values)).rows[0] || null;
|
|
2031
|
+
} catch (e) {
|
|
2032
|
+
throw new DatabaseError("Failed to retrieve record from table", {
|
|
2033
|
+
table,
|
|
2034
|
+
sql: query,
|
|
2035
|
+
values,
|
|
2036
|
+
originalError: formatDbError(e)
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
/**
|
|
2041
|
+
* Retrieves multiple records matching the where criteria
|
|
2042
|
+
*
|
|
2043
|
+
* @param table - Table name
|
|
2044
|
+
* @param where - Criteria to match records
|
|
2045
|
+
* @returns Promise resolving to array of records
|
|
2046
|
+
*/
|
|
2047
|
+
const list = async (table, where) => {
|
|
2048
|
+
validateTableName(table);
|
|
2049
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2050
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2051
|
+
try {
|
|
2052
|
+
return (await executor.query(query, values)).rows;
|
|
2053
|
+
} catch (e) {
|
|
2054
|
+
throw new DatabaseError("Failed to list records from table", {
|
|
2055
|
+
table,
|
|
2056
|
+
sql: query,
|
|
2057
|
+
values,
|
|
2058
|
+
originalError: formatDbError(e)
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
/**
|
|
2063
|
+
* Updates records matching the where criteria
|
|
2064
|
+
*
|
|
2065
|
+
* @param table - Table name
|
|
2066
|
+
* @param where - Criteria to match records to update
|
|
2067
|
+
* @param data - New data to set
|
|
2068
|
+
* @returns Promise resolving to operation result
|
|
2069
|
+
*/
|
|
2070
|
+
const update = async (table, where, data) => {
|
|
2071
|
+
validateTableName(table);
|
|
2072
|
+
const serializedData = serialize(data);
|
|
2073
|
+
const keys = Object.keys(serializedData);
|
|
2074
|
+
validateColumnNames(keys);
|
|
2075
|
+
const values = Object.values(serializedData);
|
|
2076
|
+
const setClause = keys.map((key, i) => `${key} = $${i + 1}`).join(", ");
|
|
2077
|
+
const { sql: whereClause, values: whereValues } = buildWhere(where, values.length + 1, "postgres");
|
|
2078
|
+
if (!whereClause) throw new DatabaseError("UPDATE requires at least one WHERE condition to prevent accidental update of all records", { table });
|
|
2079
|
+
const sql = `UPDATE ${table} SET ${setClause} ${whereClause}`;
|
|
2080
|
+
try {
|
|
2081
|
+
return {
|
|
2082
|
+
operation: "update",
|
|
2083
|
+
affected: (await executor.query(sql, [...values, ...whereValues])).rowCount ?? 0
|
|
2084
|
+
};
|
|
2085
|
+
} catch (e) {
|
|
2086
|
+
throw new DatabaseError("Failed to update records in table", {
|
|
2087
|
+
table,
|
|
2088
|
+
sql,
|
|
2089
|
+
values: [...values, ...whereValues],
|
|
2090
|
+
originalError: formatDbError(e)
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
/**
|
|
2095
|
+
* Inserts a record or updates it if it already exists (UPSERT)
|
|
2096
|
+
*
|
|
2097
|
+
* @param table - Table name
|
|
2098
|
+
* @param conflictColumns - Columns that define the uniqueness constraint
|
|
2099
|
+
* @param data - Data to insert or update
|
|
2100
|
+
* @returns Promise resolving to operation result
|
|
2101
|
+
* @throws Error if the upsert operation fails
|
|
2102
|
+
*/
|
|
2103
|
+
const upsert = async (table, conflictColumns, data, options) => {
|
|
2104
|
+
validateTableName(table);
|
|
2105
|
+
const serializedData = serializeRecord(data);
|
|
2106
|
+
const conflictCols = [...conflictColumns];
|
|
2107
|
+
validateColumnNames([...conflictCols, ...Object.keys(serializedData)]);
|
|
2108
|
+
try {
|
|
2109
|
+
return await executePostgresUpsert(executor, table, conflictCols, serializedData, options, !inTransaction);
|
|
2110
|
+
} catch (e) {
|
|
2111
|
+
if (e instanceof DatabaseError && e.message === "Conflict columns missing from data") throw e;
|
|
2112
|
+
throw new DatabaseError("Failed to upsert record into table", {
|
|
2113
|
+
table,
|
|
2114
|
+
values: data,
|
|
2115
|
+
conflictColumns,
|
|
2116
|
+
originalError: formatDbError(e)
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
/**
|
|
2121
|
+
* Gets a record matching the where criteria or inserts it if not found
|
|
2122
|
+
*
|
|
2123
|
+
* @param table - Table name
|
|
2124
|
+
* @param where - Criteria to match existing record
|
|
2125
|
+
* @returns Promise resolving to the query result or insert result
|
|
2126
|
+
*/
|
|
2127
|
+
const getOrInsert = async (table, where, data) => {
|
|
2128
|
+
validateTableName(table);
|
|
2129
|
+
const result = await get(table, where);
|
|
2130
|
+
if (result) return result;
|
|
2131
|
+
await insert(table, data);
|
|
2132
|
+
const inserted = await get(table, where);
|
|
2133
|
+
if (!inserted) throw new DatabaseError("Failed to insert and retrieve record", {
|
|
2134
|
+
table,
|
|
2135
|
+
where,
|
|
2136
|
+
data
|
|
2137
|
+
});
|
|
2138
|
+
return inserted;
|
|
2139
|
+
};
|
|
2140
|
+
/**
|
|
2141
|
+
* Deletes records from a table matching the where criteria
|
|
2142
|
+
*
|
|
2143
|
+
* @param table - Table name
|
|
2144
|
+
* @param where - Criteria to match records for deletion
|
|
2145
|
+
* @returns Promise resolving to operation result with count of deleted rows
|
|
2146
|
+
* @throws Error if the delete operation fails
|
|
2147
|
+
*/
|
|
2148
|
+
const deleteRecords = async (table, where) => {
|
|
2149
|
+
validateTableName(table);
|
|
2150
|
+
if (Object.keys(where).length === 0) throw new DatabaseError("DELETE requires at least one WHERE condition to prevent accidental deletion of all records", { table });
|
|
2151
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2152
|
+
try {
|
|
2153
|
+
return {
|
|
2154
|
+
operation: "delete",
|
|
2155
|
+
affected: (await executor.query(`DELETE FROM ${table} ${whereClause}`, values)).rowCount ?? 0
|
|
2156
|
+
};
|
|
2157
|
+
} catch (e) {
|
|
2158
|
+
throw new DatabaseError("Failed to delete records from table", {
|
|
2159
|
+
table,
|
|
2160
|
+
where,
|
|
2161
|
+
originalError: formatDbError(e)
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
};
|
|
2165
|
+
/**
|
|
2166
|
+
* Counts records in a table matching the where criteria
|
|
2167
|
+
*
|
|
2168
|
+
* @param table - Table name
|
|
2169
|
+
* @param where - Criteria to match records (optional, counts all if omitted)
|
|
2170
|
+
* @returns Promise resolving to count of matching records
|
|
2171
|
+
* @throws Error if the count operation fails
|
|
2172
|
+
*/
|
|
2173
|
+
const count = async (table, where) => {
|
|
2174
|
+
validateTableName(table);
|
|
2175
|
+
try {
|
|
2176
|
+
if (!where || Object.keys(where).length === 0) {
|
|
2177
|
+
const result = await executor.query(`SELECT COUNT(*) as count FROM ${table}`);
|
|
2178
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2179
|
+
}
|
|
2180
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2181
|
+
const result = await executor.query(`SELECT COUNT(*) as count FROM ${table} ${whereClause}`, values);
|
|
2182
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2183
|
+
} catch (e) {
|
|
2184
|
+
throw new DatabaseError("Failed to count records in table", {
|
|
2185
|
+
table,
|
|
2186
|
+
where,
|
|
2187
|
+
originalError: formatDbError(e)
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
/**
|
|
2192
|
+
* Creates a table-specific interface for simplified table operations
|
|
2193
|
+
*
|
|
2194
|
+
* @param tableName - Table name
|
|
2195
|
+
* @returns TableMethods interface for the specified table
|
|
2196
|
+
*/
|
|
2197
|
+
const table = (tableName) => {
|
|
2198
|
+
return {
|
|
2199
|
+
insert: (data) => insert(tableName, data),
|
|
2200
|
+
get: (data) => get(tableName, data),
|
|
2201
|
+
list: (data) => list(tableName, data)
|
|
2202
|
+
};
|
|
2203
|
+
};
|
|
2204
|
+
/**
|
|
2205
|
+
* Executes a SQL query using template literals and returns a single value
|
|
2206
|
+
*
|
|
2207
|
+
* @param strings - Template strings
|
|
2208
|
+
* @param vars - Variables to interpolate into the query
|
|
2209
|
+
* @returns Promise resolving to a single value (first column of first row)
|
|
2210
|
+
*/
|
|
2211
|
+
const pluck = async (strings, ...vars) => {
|
|
2212
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2213
|
+
try {
|
|
2214
|
+
const firstRow = (await executor.query(sql, values)).rows[0];
|
|
2215
|
+
if (!firstRow) return null;
|
|
2216
|
+
return Object.values(firstRow)[0];
|
|
2217
|
+
} catch (e) {
|
|
2218
|
+
throw new DatabaseError("Failed to execute pluck query", {
|
|
2219
|
+
sql,
|
|
2220
|
+
values,
|
|
2221
|
+
originalError: formatDbError(e)
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
/**
|
|
2226
|
+
* Executes a SQL query using template literals and returns a single row
|
|
2227
|
+
*
|
|
2228
|
+
* @param strings - Template strings
|
|
2229
|
+
* @param vars - Variables to interpolate into the query
|
|
2230
|
+
* @returns Promise resolving to a single result record or null
|
|
2231
|
+
*/
|
|
2232
|
+
const single = async (strings, ...vars) => {
|
|
2233
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2234
|
+
try {
|
|
2235
|
+
return (await executor.query(sql, values)).rows[0] || null;
|
|
2236
|
+
} catch (e) {
|
|
2237
|
+
throw new DatabaseError("Failed to execute single query", {
|
|
2238
|
+
sql,
|
|
2239
|
+
values,
|
|
2240
|
+
originalError: formatDbError(e)
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
/**
|
|
2245
|
+
* Executes a SQL query using template literals and returns multiple rows
|
|
2246
|
+
*
|
|
2247
|
+
* @param strings - Template strings
|
|
2248
|
+
* @param vars - Variables to interpolate into the query
|
|
2249
|
+
* @returns Promise resolving to array of result records
|
|
2250
|
+
*/
|
|
2251
|
+
const many = async (strings, ...vars) => {
|
|
2252
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2253
|
+
try {
|
|
2254
|
+
return (await executor.query(sql, values)).rows;
|
|
2255
|
+
} catch (e) {
|
|
2256
|
+
throw new DatabaseError("Failed to execute many query", {
|
|
2257
|
+
sql,
|
|
2258
|
+
values,
|
|
2259
|
+
originalError: formatDbError(e)
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
};
|
|
2263
|
+
/**
|
|
2264
|
+
* Executes a SQL query using template literals without returning results
|
|
2265
|
+
*
|
|
2266
|
+
* @param strings - Template strings
|
|
2267
|
+
* @param vars - Variables to interpolate into the query
|
|
2268
|
+
* @returns Promise that resolves when the query completes
|
|
2269
|
+
*/
|
|
2270
|
+
const execute = async (strings, ...vars) => {
|
|
2271
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2272
|
+
try {
|
|
2273
|
+
await executor.query(sql, values);
|
|
2274
|
+
} catch (e) {
|
|
2275
|
+
throw new DatabaseError("Failed to execute query", {
|
|
2276
|
+
sql,
|
|
2277
|
+
values,
|
|
2278
|
+
originalError: formatDbError(e)
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
/**
|
|
2283
|
+
* Executes a raw SQL query with parameterized values
|
|
2284
|
+
*
|
|
2285
|
+
* Uses PostgreSQL-native placeholders ($1, $2, ...). Legacy ? placeholders
|
|
2286
|
+
* are converted only when the placeholder count matches the supplied values,
|
|
2287
|
+
* so Postgres operators such as JSONB ? remain intact.
|
|
2288
|
+
*
|
|
2289
|
+
* @param sql - SQL query string
|
|
2290
|
+
* @param values - Variables to use as parameters
|
|
2291
|
+
* @returns Promise resolving to query result with rows and count
|
|
2292
|
+
*/
|
|
2293
|
+
const query = async (sql, ...values) => {
|
|
2294
|
+
const query = normalizePostgresRawQuery(sql, values);
|
|
2295
|
+
try {
|
|
2296
|
+
const result = await executor.query(query.sql, query.values);
|
|
2297
|
+
return {
|
|
2298
|
+
rows: result.rows,
|
|
2299
|
+
rowCount: result.rowCount ?? 0
|
|
2300
|
+
};
|
|
2301
|
+
} catch (e) {
|
|
2302
|
+
throw wrapDatabaseError("Failed to execute raw query", e, {
|
|
2303
|
+
sql: query.sql,
|
|
2304
|
+
values: query.values
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
};
|
|
2308
|
+
/**
|
|
2309
|
+
* Checks if a table exists in the database
|
|
2310
|
+
*
|
|
2311
|
+
* @param tableName - Name of the table to check
|
|
2312
|
+
* @returns Promise resolving to boolean indicating if the table exists
|
|
2313
|
+
*/
|
|
2314
|
+
const tableExists = async (tableName) => {
|
|
2315
|
+
return (await executor.query(`SELECT EXISTS (
|
|
2316
|
+
SELECT 1
|
|
2317
|
+
FROM information_schema.tables
|
|
2318
|
+
WHERE table_name = $1
|
|
2319
|
+
AND table_schema = (
|
|
2320
|
+
SELECT namespace.nspname
|
|
2321
|
+
FROM pg_catalog.pg_class AS relation
|
|
2322
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2323
|
+
ON namespace.oid = relation.relnamespace
|
|
2324
|
+
WHERE relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1))
|
|
2325
|
+
)
|
|
2326
|
+
)`, [tableName])).rows[0].exists;
|
|
2327
|
+
};
|
|
2328
|
+
/**
|
|
2329
|
+
* Synchronizes database schema with provided SQL DDL
|
|
2330
|
+
* Creates tables if they don't exist and adds missing columns
|
|
2331
|
+
* Also executes CREATE INDEX statements
|
|
2332
|
+
*
|
|
2333
|
+
* @param schema - SQL schema definition with CREATE TABLE and CREATE INDEX statements
|
|
2334
|
+
* @returns Promise that resolves when schema is synchronized
|
|
2335
|
+
*/
|
|
2336
|
+
/**
|
|
2337
|
+
* Runs a step whose failure `syncSchema` deliberately tolerates.
|
|
2338
|
+
*
|
|
2339
|
+
* Logging and continuing is self-contained on the pool. Inside a
|
|
2340
|
+
* transaction it is not: a failed statement puts PostgreSQL in the aborted
|
|
2341
|
+
* state, and `COMMIT` on an aborted transaction *succeeds* — it reports a
|
|
2342
|
+
* ROLLBACK command tag and no error. The caller is told the transaction
|
|
2343
|
+
* committed while every write in it was discarded, which is the silent
|
|
2344
|
+
* data loss this whole epic is about, re-entering through the schema path.
|
|
2345
|
+
*
|
|
2346
|
+
* A savepoint scopes the failure to the step, which is what "log it and
|
|
2347
|
+
* continue" was always supposed to mean.
|
|
2348
|
+
*/
|
|
2349
|
+
const runTolerated = async (work) => {
|
|
2350
|
+
if (!inTransaction) return work();
|
|
2351
|
+
savepointSequence += 1;
|
|
2352
|
+
const name = `hv_sync_${savepointSequence}`;
|
|
2353
|
+
await executor.query(`SAVEPOINT ${name}`);
|
|
2354
|
+
try {
|
|
2355
|
+
const result = await work();
|
|
2356
|
+
await executor.query(`RELEASE SAVEPOINT ${name}`);
|
|
2357
|
+
return result;
|
|
2358
|
+
} catch (error) {
|
|
2359
|
+
await executor.query(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
2360
|
+
clearTransactionError();
|
|
2361
|
+
await executor.query(`RELEASE SAVEPOINT ${name}`);
|
|
2362
|
+
throw error;
|
|
2363
|
+
}
|
|
2364
|
+
};
|
|
2365
|
+
const syncSchema = async (schema) => {
|
|
2366
|
+
const commands = schema.trim().split(";").filter((command) => command.trim() !== "");
|
|
2367
|
+
const createIndexRegex = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?"?(?<indexName>\w+)"?\s+ON\s+"?(?<tableName>\w+)"?\s*(?:USING\s+"?\w+"?\s*)?\([^)]+\)/i;
|
|
2368
|
+
const parseCreateIndex = (command) => {
|
|
2369
|
+
const { indexName, tableName } = command.match(createIndexRegex)?.groups ?? {};
|
|
2370
|
+
return indexName && tableName ? {
|
|
2371
|
+
indexName,
|
|
2372
|
+
tableName
|
|
2373
|
+
} : null;
|
|
2374
|
+
};
|
|
2375
|
+
const indexTargets = [];
|
|
2376
|
+
for (const command of commands) {
|
|
2377
|
+
const indexMatch = parseCreateIndex(command.trim());
|
|
2378
|
+
if (indexMatch) indexTargets.push(indexMatch);
|
|
2379
|
+
}
|
|
2380
|
+
const existingIndexes = /* @__PURE__ */ new Set();
|
|
2381
|
+
if (indexTargets.length > 0) {
|
|
2382
|
+
const result = await executor.query(`SELECT target.table_name, target.index_name
|
|
2383
|
+
FROM unnest($1::text[], $2::text[])
|
|
2384
|
+
AS target(table_name, index_name)
|
|
2385
|
+
JOIN pg_catalog.pg_class AS relation
|
|
2386
|
+
ON relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident(target.table_name))
|
|
2387
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2388
|
+
ON namespace.oid = relation.relnamespace
|
|
2389
|
+
JOIN pg_catalog.pg_indexes AS existing
|
|
2390
|
+
ON existing.schemaname = namespace.nspname
|
|
2391
|
+
AND existing.tablename = target.table_name
|
|
2392
|
+
AND existing.indexname = target.index_name`, [indexTargets.map((target) => target.tableName), indexTargets.map((target) => target.indexName)]);
|
|
2393
|
+
for (const row of result.rows) existingIndexes.add(`${row.table_name}\u0000${row.index_name}`);
|
|
2394
|
+
}
|
|
2395
|
+
for (const command of commands) {
|
|
2396
|
+
const trimmedCommand = command.trim();
|
|
2397
|
+
const tableMatch = trimmedCommand.match(/CREATE TABLE (IF NOT EXISTS )?"?(\w+)"? \(([\s\S]+)\)/i);
|
|
2398
|
+
if (tableMatch) {
|
|
2399
|
+
const tableName = tableMatch[2];
|
|
2400
|
+
const columns = tableMatch[3].trim().split(",\n");
|
|
2401
|
+
if (!await tableExists(tableName)) await executor.query(trimmedCommand);
|
|
2402
|
+
else for (const column of columns) {
|
|
2403
|
+
const columnDef = column.trim();
|
|
2404
|
+
if (/^(?:CONSTRAINT\s+(?:"(?:[^"]|"")*"|\w+)\s+)?(?:(?:PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK)\b|EXCLUDE(?=\s+(?:USING\b|\()))/i.test(columnDef)) continue;
|
|
2405
|
+
const columnMatch = columnDef.match(/^("(?:[^"]|"")*"|\w+)\s+\w+[^,]*/);
|
|
2406
|
+
if (columnMatch) {
|
|
2407
|
+
const identifier = columnMatch[1];
|
|
2408
|
+
const columnName = identifier.startsWith("\"") ? identifier.slice(1, -1).replace(/""/g, "\"") : identifier;
|
|
2409
|
+
if (columnName.toUpperCase() === "PRIMARY" || columnName.toUpperCase() === "FOREIGN" || columnName.toUpperCase() === "UNIQUE" || columnName.toUpperCase() === "CHECK" || columnName.toUpperCase() === "CONSTRAINT") continue;
|
|
2410
|
+
try {
|
|
2411
|
+
await runTolerated(async () => {
|
|
2412
|
+
if (!(await executor.query(`SELECT EXISTS (
|
|
2413
|
+
SELECT 1 FROM information_schema.columns
|
|
2414
|
+
WHERE table_name = $1
|
|
2415
|
+
AND column_name = $2
|
|
2416
|
+
AND table_schema = (
|
|
2417
|
+
SELECT namespace.nspname
|
|
2418
|
+
FROM pg_catalog.pg_class AS relation
|
|
2419
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2420
|
+
ON namespace.oid = relation.relnamespace
|
|
2421
|
+
WHERE relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1))
|
|
2422
|
+
)
|
|
2423
|
+
)`, [tableName, columnName])).rows[0].exists) {
|
|
2424
|
+
const alterCommand = `ALTER TABLE "${tableName}" ADD COLUMN ${columnDef}`;
|
|
2425
|
+
await executor.query(alterCommand);
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
console.error(`Error adding column ${columnName} to ${tableName}:`, error);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
const indexMatch = parseCreateIndex(trimmedCommand);
|
|
2436
|
+
if (indexMatch) {
|
|
2437
|
+
const { indexName, tableName: indexTableName } = indexMatch;
|
|
2438
|
+
const indexKey = `${indexTableName}\u0000${indexName}`;
|
|
2439
|
+
if (!existingIndexes.has(indexKey)) try {
|
|
2440
|
+
await runTolerated(() => executor.query(trimmedCommand));
|
|
2441
|
+
existingIndexes.add(indexKey);
|
|
2442
|
+
} catch (error) {
|
|
2443
|
+
console.warn(`Warning: Failed to create index ${indexName} on ${indexTableName}:`, error instanceof Error ? error.message : String(error));
|
|
2444
|
+
}
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
if (trimmedCommand.toUpperCase().startsWith("CREATE ") && !trimmedCommand.toUpperCase().includes("CREATE TABLE")) try {
|
|
2448
|
+
await runTolerated(() => executor.query(trimmedCommand));
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
console.warn(`Warning: DDL statement may have failed:`, error instanceof Error ? error.message : String(error));
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
return {
|
|
2455
|
+
insert,
|
|
2456
|
+
get,
|
|
2457
|
+
list,
|
|
2458
|
+
update,
|
|
2459
|
+
upsert,
|
|
2460
|
+
getOrInsert,
|
|
2461
|
+
delete: deleteRecords,
|
|
2462
|
+
count,
|
|
2463
|
+
table,
|
|
2464
|
+
many,
|
|
2465
|
+
single,
|
|
2466
|
+
pluck,
|
|
2467
|
+
execute,
|
|
2468
|
+
query,
|
|
2469
|
+
oo: many,
|
|
2470
|
+
oO: single,
|
|
2471
|
+
ox: pluck,
|
|
2472
|
+
xx: execute,
|
|
2473
|
+
tableExists,
|
|
2474
|
+
syncSchema
|
|
2475
|
+
};
|
|
2476
|
+
};
|
|
2477
|
+
/**
|
|
2478
|
+
* Builds the `transaction` exposed on a transaction-scoped interface.
|
|
2479
|
+
*
|
|
2480
|
+
* Re-entering runs the callback under a SAVEPOINT on the *same* connection,
|
|
2481
|
+
* which is what callers assume nesting already did. Re-exposing the top-level
|
|
2482
|
+
* `transaction` here (the previous behaviour) checked out a second pooled
|
|
2483
|
+
* connection and began an independent transaction, so the nested work could
|
|
2484
|
+
* not see the enclosing transaction's uncommitted rows, and if the enclosing
|
|
2485
|
+
* transaction held a lock the nested one needed, the two deadlocked in a way
|
|
2486
|
+
* PostgreSQL cannot detect: the outer connection is blocked on a promise
|
|
2487
|
+
* rather than on a lock, so `deadlock_timeout` never fires and the process
|
|
2488
|
+
* hangs until some unrelated timeout.
|
|
2489
|
+
*
|
|
2490
|
+
* @param executor - Error-preserving executor bound to the enclosing client
|
|
2491
|
+
* @param scopeFor - Builds the interface handed to the nested callback
|
|
2492
|
+
*/
|
|
2493
|
+
const createNestedTransaction = (executor, scopeFor, clearTransactionError, scopes) => {
|
|
2494
|
+
const rootScope = { tail: Promise.resolve() };
|
|
2495
|
+
const runScope = async (scope, callback) => {
|
|
2496
|
+
savepointSequence += 1;
|
|
2497
|
+
const name = `hv_sp_${savepointSequence}`;
|
|
2498
|
+
await executor.query(`SAVEPOINT ${name}`);
|
|
2499
|
+
try {
|
|
2500
|
+
const result = await callback(scopes ? scopeFor() : scopeFor(bind(scope)));
|
|
2501
|
+
await scope.tail;
|
|
2502
|
+
await executor.query(`RELEASE SAVEPOINT ${name}`);
|
|
2503
|
+
return result;
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
await scope.tail;
|
|
2506
|
+
try {
|
|
2507
|
+
await executor.query(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
2508
|
+
clearTransactionError();
|
|
2509
|
+
await executor.query(`RELEASE SAVEPOINT ${name}`);
|
|
2510
|
+
} catch {}
|
|
2511
|
+
throw error;
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
const bind = (bound) => {
|
|
2515
|
+
const nested = async (callback) => {
|
|
2516
|
+
const parentScope = bound ?? scopes?.getStore() ?? rootScope;
|
|
2517
|
+
const scope = { tail: Promise.resolve() };
|
|
2518
|
+
const current = parentScope.tail.then(() => scopes ? scopes.run(scope, () => runScope(scope, callback)) : runScope(scope, callback));
|
|
2519
|
+
parentScope.tail = current.then(() => void 0, () => void 0);
|
|
2520
|
+
return current;
|
|
2521
|
+
};
|
|
2522
|
+
nested.drain = () => rootScope.tail;
|
|
2523
|
+
return nested;
|
|
2524
|
+
};
|
|
2525
|
+
return bind();
|
|
2526
|
+
};
|
|
2527
|
+
return {
|
|
2528
|
+
serializeRecord,
|
|
2529
|
+
createClientMethods,
|
|
2530
|
+
createNestedTransaction
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Builds `getTableSchema` on top of a dialect's statement methods.
|
|
2535
|
+
*/
|
|
2536
|
+
function createGetTableSchema(methods) {
|
|
2537
|
+
const { many, tableExists } = methods;
|
|
2538
|
+
/**
|
|
2539
|
+
* Retrieves the schema information for a table
|
|
2540
|
+
*
|
|
2541
|
+
* @param table - Table name
|
|
2542
|
+
* @returns Promise resolving to table schema info or null if table doesn't exist
|
|
2543
|
+
* @throws Error if the query fails
|
|
2544
|
+
*/
|
|
2545
|
+
const getTableSchema = async (table) => {
|
|
2546
|
+
validateTableName(table);
|
|
2547
|
+
try {
|
|
2548
|
+
if (!await tableExists(table)) return null;
|
|
2549
|
+
const columnRows = await many`
|
|
2550
|
+
SELECT
|
|
2551
|
+
column_name,
|
|
2552
|
+
data_type,
|
|
2553
|
+
is_nullable,
|
|
2554
|
+
column_default
|
|
2555
|
+
FROM information_schema.columns
|
|
2556
|
+
WHERE table_name = ${table}
|
|
2557
|
+
AND table_schema = (
|
|
2558
|
+
SELECT namespace.nspname
|
|
2559
|
+
FROM pg_catalog.pg_class AS relation
|
|
2560
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2561
|
+
ON namespace.oid = relation.relnamespace
|
|
2562
|
+
WHERE relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident(${table}))
|
|
2563
|
+
)
|
|
2564
|
+
ORDER BY ordinal_position
|
|
2565
|
+
`;
|
|
2566
|
+
const pkRows = await many`
|
|
2567
|
+
SELECT kcu.column_name
|
|
2568
|
+
FROM information_schema.table_constraints tc
|
|
2569
|
+
JOIN information_schema.key_column_usage kcu
|
|
2570
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
2571
|
+
AND tc.table_schema = kcu.table_schema
|
|
2572
|
+
WHERE tc.table_name = ${table}
|
|
2573
|
+
AND tc.table_schema = (
|
|
2574
|
+
SELECT namespace.nspname
|
|
2575
|
+
FROM pg_catalog.pg_class AS relation
|
|
2576
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2577
|
+
ON namespace.oid = relation.relnamespace
|
|
2578
|
+
WHERE relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident(${table}))
|
|
2579
|
+
)
|
|
2580
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
2581
|
+
`;
|
|
2582
|
+
const pkColumns = new Set(pkRows.map((row) => row.column_name));
|
|
2583
|
+
const columns = {};
|
|
2584
|
+
for (const row of columnRows) {
|
|
2585
|
+
const colName = row.column_name;
|
|
2586
|
+
columns[colName] = {
|
|
2587
|
+
type: row.data_type,
|
|
2588
|
+
primaryKey: pkColumns.has(colName),
|
|
2589
|
+
notNull: row.is_nullable === "NO",
|
|
2590
|
+
defaultValue: row.column_default
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
const indexRows = await many`
|
|
2594
|
+
SELECT
|
|
2595
|
+
indexname,
|
|
2596
|
+
indexdef
|
|
2597
|
+
FROM pg_indexes
|
|
2598
|
+
WHERE tablename = ${table}
|
|
2599
|
+
AND schemaname = (
|
|
2600
|
+
SELECT namespace.nspname
|
|
2601
|
+
FROM pg_catalog.pg_class AS relation
|
|
2602
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2603
|
+
ON namespace.oid = relation.relnamespace
|
|
2604
|
+
WHERE relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident(${table}))
|
|
2605
|
+
)
|
|
2606
|
+
AND indexname NOT LIKE '%_pkey'
|
|
2607
|
+
`;
|
|
2608
|
+
const indexes = [];
|
|
2609
|
+
for (const row of indexRows) {
|
|
2610
|
+
const indexName = row.indexname;
|
|
2611
|
+
const indexDef = row.indexdef;
|
|
2612
|
+
const colMatch = indexDef.match(/\(([^)]+)\)/);
|
|
2613
|
+
const indexColumns = colMatch ? colMatch[1].split(",").map((col) => col.trim()) : [];
|
|
2614
|
+
const isUnique = indexDef.toUpperCase().includes("UNIQUE");
|
|
2615
|
+
indexes.push({
|
|
2616
|
+
name: indexName,
|
|
2617
|
+
columns: indexColumns,
|
|
2618
|
+
unique: isUnique
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
const fkRows = await many`
|
|
2622
|
+
SELECT
|
|
2623
|
+
kcu.column_name,
|
|
2624
|
+
ccu.table_name AS foreign_table_name,
|
|
2625
|
+
ccu.column_name AS foreign_column_name,
|
|
2626
|
+
rc.delete_rule,
|
|
2627
|
+
rc.update_rule
|
|
2628
|
+
FROM information_schema.table_constraints AS tc
|
|
2629
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
2630
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
2631
|
+
AND tc.table_schema = kcu.table_schema
|
|
2632
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
2633
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
2634
|
+
AND ccu.table_schema = tc.table_schema
|
|
2635
|
+
JOIN information_schema.referential_constraints AS rc
|
|
2636
|
+
ON rc.constraint_name = tc.constraint_name
|
|
2637
|
+
AND rc.constraint_schema = tc.table_schema
|
|
2638
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
2639
|
+
AND tc.table_name = ${table}
|
|
2640
|
+
AND tc.table_schema = (
|
|
2641
|
+
SELECT namespace.nspname
|
|
2642
|
+
FROM pg_catalog.pg_class AS relation
|
|
2643
|
+
JOIN pg_catalog.pg_namespace AS namespace
|
|
2644
|
+
ON namespace.oid = relation.relnamespace
|
|
2645
|
+
WHERE relation.oid = pg_catalog.to_regclass(pg_catalog.quote_ident(${table}))
|
|
2646
|
+
)
|
|
2647
|
+
`;
|
|
2648
|
+
const foreignKeys = [];
|
|
2649
|
+
for (const fkRow of fkRows) foreignKeys.push({
|
|
2650
|
+
column: fkRow.column_name,
|
|
2651
|
+
referencesTable: fkRow.foreign_table_name,
|
|
2652
|
+
referencesColumn: fkRow.foreign_column_name,
|
|
2653
|
+
onDelete: fkRow.delete_rule,
|
|
2654
|
+
onUpdate: fkRow.update_rule
|
|
2655
|
+
});
|
|
2656
|
+
return {
|
|
2657
|
+
tableName: table,
|
|
2658
|
+
columns,
|
|
2659
|
+
indexes,
|
|
2660
|
+
foreignKeys
|
|
2661
|
+
};
|
|
2662
|
+
} catch (e) {
|
|
2663
|
+
throw new DatabaseError("Failed to retrieve table schema", {
|
|
2664
|
+
table,
|
|
2665
|
+
originalError: formatDbError(e)
|
|
2666
|
+
});
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2669
|
+
return getTableSchema;
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* Builds the `alterTable` operations on top of a statement executor.
|
|
2673
|
+
*/
|
|
2674
|
+
function createAlterTable(client) {
|
|
2675
|
+
return {
|
|
2676
|
+
/**
|
|
2677
|
+
* Adds a new column to an existing table
|
|
2678
|
+
*
|
|
2679
|
+
* @param table - Table name
|
|
2680
|
+
* @param column - Column definition with name
|
|
2681
|
+
* @returns Promise that resolves when column is added
|
|
2682
|
+
* @throws Error if the alter operation fails
|
|
2683
|
+
*/
|
|
2684
|
+
addColumn: async (table, column) => {
|
|
2685
|
+
validateTableName(table);
|
|
2686
|
+
validateColumnName(column.name);
|
|
2687
|
+
let sql;
|
|
2688
|
+
try {
|
|
2689
|
+
sql = generateAddColumnStatement(table, column, "postgres");
|
|
2690
|
+
await client.query(sql);
|
|
2691
|
+
} catch (e) {
|
|
2692
|
+
throw wrapDatabaseError("Failed to add column to table", e, {
|
|
2693
|
+
table,
|
|
2694
|
+
column: column.name,
|
|
2695
|
+
...sql ? { sql } : {}
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
/**
|
|
2700
|
+
* Adds a new index to an existing table
|
|
2701
|
+
*
|
|
2702
|
+
* @param table - Table name
|
|
2703
|
+
* @param index - Index definition
|
|
2704
|
+
* @returns Promise that resolves when index is created
|
|
2705
|
+
* @throws Error if the create index operation fails
|
|
2706
|
+
*/
|
|
2707
|
+
addIndex: async (table, index) => {
|
|
2708
|
+
validateTableName(table);
|
|
2709
|
+
validateIndexName(index.name);
|
|
2710
|
+
for (const col of index.columns) validateColumnName(col);
|
|
2711
|
+
let sql;
|
|
2712
|
+
try {
|
|
2713
|
+
sql = generateCreateIndexStatement(table, index);
|
|
2714
|
+
await client.query(sql);
|
|
2715
|
+
} catch (e) {
|
|
2716
|
+
throw wrapDatabaseError("Failed to create index on table", e, {
|
|
2717
|
+
table,
|
|
2718
|
+
index: index.name,
|
|
2719
|
+
...sql ? { sql } : {}
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
//#endregion
|
|
2726
|
+
export { parseConditionKey as C, wrapDatabaseError as D, unwrapRawKey as E, formatDbError as S, resolveInsertColumns as T, validateColumnNames as _, createVectorCapabilities as a, DatabaseSchemaManager as b, createTransactionLock as c, validateDatabaseCacheOptions as d, escapeQuotedIdentifier as f, validateColumnName as g, generateCreateIndexStatement as h, createTransactionErrorTracker as i, ConnectionCache as l, generateAddColumnStatement as m, createGetTableSchema as n, normalizePostgresRawQuery as o, escapeStringLiteral as p, createPostgresDialect as r, DEFAULT_TRANSACTION_QUEUE_TIMEOUT_MS as s, createAlterTable as t, enableCachedValidation as u, validateIndexName as v, raw as w, buildWhere as x, validateTableName as y };
|
|
2727
|
+
|
|
2728
|
+
//# sourceMappingURL=postgres-dialect-Bfswr4Vs.js.map
|