@happyvertical/sql 0.102.2 → 0.102.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/AGENT.md +1 -1
  2. package/README.md +99 -4
  3. package/dist/chunks/{duckdb-DfikVgov.js → duckdb-LMyfKZGi.js} +3 -3
  4. package/dist/chunks/{duckdb-DfikVgov.js.map → duckdb-LMyfKZGi.js.map} +1 -1
  5. package/dist/chunks/postgres-dialect-Bfswr4Vs.js +2728 -0
  6. package/dist/chunks/postgres-dialect-Bfswr4Vs.js.map +1 -0
  7. package/dist/chunks/{sqlite-Cz2NMiDs.js → sqlite-0PMv1va2.js} +5 -5
  8. package/dist/chunks/{sqlite-Cz2NMiDs.js.map → sqlite-0PMv1va2.js.map} +1 -1
  9. package/dist/chunks/{sqlite-connection-cache-BAakj2HO.js → sqlite-connection-cache-ChWav9iY.js} +2 -2
  10. package/dist/chunks/{sqlite-connection-cache-BAakj2HO.js.map → sqlite-connection-cache-ChWav9iY.js.map} +1 -1
  11. package/dist/chunks/{sqlite-native-ByGwGEbS.js → sqlite-native--z4BnolL.js} +4 -4
  12. package/dist/chunks/{sqlite-native-ByGwGEbS.js.map → sqlite-native--z4BnolL.js.map} +1 -1
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +163 -1365
  16. package/dist/index.js.map +1 -1
  17. package/dist/pglite.d.ts +150 -0
  18. package/dist/pglite.d.ts.map +1 -0
  19. package/dist/pglite.js +339 -0
  20. package/dist/pglite.js.map +1 -0
  21. package/dist/postgres.d.ts.map +1 -1
  22. package/dist/shared/postgres-dialect.d.ts +153 -0
  23. package/dist/shared/postgres-dialect.d.ts.map +1 -0
  24. package/dist/shared/types.d.ts +4 -4
  25. package/metadata.json +1 -0
  26. package/package.json +13 -2
  27. package/dist/chunks/connection-cache-Da5UcY5u.js +0 -1305
  28. package/dist/chunks/connection-cache-Da5UcY5u.js.map +0 -1
  29. package/dist/chunks/types-CwWJ-yju.js +0 -139
  30. package/dist/chunks/types-CwWJ-yju.js.map +0 -1
@@ -1,1305 +0,0 @@
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
- export { raw as _, escapeStringLiteral as a, wrapDatabaseError as b, quotePostgresColumnName as c, validateIndexName as d, validateTableName as f, parseConditionKey as g, formatDbError as h, escapeQuotedIdentifier as i, validateColumnName as l, buildWhere as m, enableCachedValidation as n, generateAddColumnStatement as o, DatabaseSchemaManager as p, validateDatabaseCacheOptions as r, generateCreateIndexStatement as s, ConnectionCache as t, validateColumnNames as u, resolveInsertColumns as v, unwrapRawKey as y };
1304
-
1305
- //# sourceMappingURL=connection-cache-Da5UcY5u.js.map