@happyvertical/sql 0.80.0 → 0.80.1
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/dist/backup/index.js +210 -188
- package/dist/backup/index.js.map +1 -1
- package/dist/chunks/alter-utils-K7tiuj5L.js +135 -0
- package/dist/chunks/alter-utils-K7tiuj5L.js.map +1 -0
- package/dist/chunks/duckdb-DykBBzd6.js +1056 -0
- package/dist/chunks/duckdb-DykBBzd6.js.map +1 -0
- package/dist/chunks/duckdb-schema-utils-B-XBP8QF.js +115 -0
- package/dist/chunks/duckdb-schema-utils-B-XBP8QF.js.map +1 -0
- package/dist/chunks/postgres-cli-DzZcXSIG.js +272 -0
- package/dist/chunks/postgres-cli-DzZcXSIG.js.map +1 -0
- package/dist/chunks/schema-manager-a8RlxIE2.js +450 -0
- package/dist/chunks/schema-manager-a8RlxIE2.js.map +1 -0
- package/dist/chunks/sqlite-YgU9LCVy.js +1045 -0
- package/dist/chunks/sqlite-YgU9LCVy.js.map +1 -0
- package/dist/chunks/sqlite-native-BGtpx7Ro.js +902 -0
- package/dist/chunks/sqlite-native-BGtpx7Ro.js.map +1 -0
- package/dist/cli/claude-context.js +17 -17
- package/dist/cli/claude-context.js.map +1 -1
- package/dist/index.js +3244 -35
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/chunks/duckdb-B6m78QdL.js +0 -924
- package/dist/chunks/duckdb-B6m78QdL.js.map +0 -1
- package/dist/chunks/index-CzdYvRFp.js +0 -3692
- package/dist/chunks/index-CzdYvRFp.js.map +0 -1
- package/dist/chunks/postgres-cli-Cr6bBZ3q.js +0 -209
- package/dist/chunks/postgres-cli-Cr6bBZ3q.js.map +0 -1
- package/dist/chunks/sqlite-CPCNeJIH.js +0 -937
- package/dist/chunks/sqlite-CPCNeJIH.js.map +0 -1
- package/dist/chunks/sqlite-native-DqVPKNUu.js +0 -1129
- package/dist/chunks/sqlite-native-DqVPKNUu.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,36 +1,3245 @@
|
|
|
1
|
-
import "
|
|
2
|
-
import {
|
|
3
|
-
import { a
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
d2 as databaseNameFromUrl,
|
|
20
|
-
o as default,
|
|
21
|
-
e2 as dropPostgresDatabase,
|
|
22
|
-
f2 as dumpPostgresDatabase,
|
|
23
|
-
p as escapeSqlValue,
|
|
24
|
-
f as formatDbError,
|
|
25
|
-
q as getDatabase,
|
|
26
|
-
i2 as isLocalDatabaseUrl,
|
|
27
|
-
p2 as postgresEnvFromUrl,
|
|
28
|
-
r2 as redactDatabaseUrl,
|
|
29
|
-
r as resolveSchemas,
|
|
30
|
-
g as restorePostgresDatabase,
|
|
31
|
-
s as runDoctor,
|
|
32
|
-
t as syncSchema,
|
|
33
|
-
u as tableExists,
|
|
34
|
-
w as validateColumnName
|
|
1
|
+
import { i as parseConditionKey, n as buildWhere, r as formatDbError, t as DatabaseSchemaManager } from "./chunks/schema-manager-a8RlxIE2.js";
|
|
2
|
+
import { t as convertUniqueIndexesToInlineConstraints } from "./chunks/duckdb-schema-utils-B-XBP8QF.js";
|
|
3
|
+
import { a as dropPostgresDatabase, c as postgresEnvFromUrl, i as databaseNameFromUrl, l as redactDatabaseUrl, n as assertCanImportDatabase, o as dumpPostgresDatabase, r as createPostgresDatabase, s as isLocalDatabaseUrl, t as assertCanExportDatabase, u as restorePostgresDatabase } from "./chunks/postgres-cli-DzZcXSIG.js";
|
|
4
|
+
import { a as validateTableName$1, i as validateIndexName, n as generateCreateIndexStatement, r as validateColumnName$1, t as generateAddColumnStatement } from "./chunks/alter-utils-K7tiuj5L.js";
|
|
5
|
+
import { DatabaseError, loadEnvConfig } from "@happyvertical/utils";
|
|
6
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
7
|
+
import { basename, extname, join } from "node:path";
|
|
8
|
+
import { Pool } from "pg";
|
|
9
|
+
//#region \0rolldown/runtime.js
|
|
10
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __exportAll = (all, no_symbols) => {
|
|
12
|
+
let target = {};
|
|
13
|
+
for (var name in all) __defProp(target, name, {
|
|
14
|
+
get: all[name],
|
|
15
|
+
enumerable: true
|
|
16
|
+
});
|
|
17
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
18
|
+
return target;
|
|
35
19
|
};
|
|
36
|
-
//#
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/aggregate.ts
|
|
22
|
+
var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["ASC", "DESC"]);
|
|
23
|
+
var VALID_BUCKET_UNITS = /* @__PURE__ */ new Set([
|
|
24
|
+
"minute",
|
|
25
|
+
"hour",
|
|
26
|
+
"day",
|
|
27
|
+
"week",
|
|
28
|
+
"month",
|
|
29
|
+
"quarter",
|
|
30
|
+
"year"
|
|
31
|
+
]);
|
|
32
|
+
function validateSqlIdentifier(identifier) {
|
|
33
|
+
if (!/^[a-zA-Z0-9_.]+$/.test(identifier)) throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
34
|
+
return identifier;
|
|
35
|
+
}
|
|
36
|
+
function normalizeAdapter(adapterType) {
|
|
37
|
+
return adapterType ?? "sqlite";
|
|
38
|
+
}
|
|
39
|
+
function placeholder(index) {
|
|
40
|
+
return `$${index}`;
|
|
41
|
+
}
|
|
42
|
+
function bucketExpr(adapterType, unit, column) {
|
|
43
|
+
if (!VALID_BUCKET_UNITS.has(unit)) throw new Error(`Invalid aggregate bucket unit: ${unit}`);
|
|
44
|
+
const col = validateSqlIdentifier(column);
|
|
45
|
+
const adapter = normalizeAdapter(adapterType);
|
|
46
|
+
if (adapter === "postgres" || adapter === "duckdb" || adapter === "json") return `date_trunc('${unit}', ${col})`;
|
|
47
|
+
switch (unit) {
|
|
48
|
+
case "minute": return `strftime('%Y-%m-%d %H:%M:00', ${col})`;
|
|
49
|
+
case "hour": return `strftime('%Y-%m-%d %H:00:00', ${col})`;
|
|
50
|
+
case "day": return `strftime('%Y-%m-%d 00:00:00', ${col})`;
|
|
51
|
+
case "week": return `strftime('%Y-%m-%d 00:00:00', date(${col}, printf('-%d days', (CAST(strftime('%w', ${col}) AS INTEGER) + 6) % 7)))`;
|
|
52
|
+
case "month": return `strftime('%Y-%m-01 00:00:00', ${col})`;
|
|
53
|
+
case "quarter": return `strftime('%Y-%m-%d 00:00:00', date(strftime('%Y-', ${col}) || printf('%02d', ((CAST(strftime('%m', ${col}) AS INTEGER) - 1) / 3) * 3 + 1) || '-01'))`;
|
|
54
|
+
case "year": return `strftime('%Y-01-01 00:00:00', ${col})`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function aggregateExpression(expr) {
|
|
58
|
+
const normalizedFn = String(expr.fn).toLowerCase();
|
|
59
|
+
const fn = validateSqlIdentifier(String(expr.fn)).toUpperCase();
|
|
60
|
+
if (normalizedFn === "count" && !expr.column) {
|
|
61
|
+
if (expr.distinct) throw new Error(`Aggregate ${expr.fn} with distinct requires a column`);
|
|
62
|
+
return "COUNT(*)";
|
|
63
|
+
}
|
|
64
|
+
if (!expr.column) throw new Error(`Aggregate ${expr.fn} requires a column`);
|
|
65
|
+
const col = validateSqlIdentifier(expr.column);
|
|
66
|
+
return `${fn}(${expr.distinct ? "DISTINCT " : ""}${col})`;
|
|
67
|
+
}
|
|
68
|
+
function selectExpression(expr, adapterType) {
|
|
69
|
+
if ("column" in expr && !("fn" in expr) && !("bucket" in expr)) return {
|
|
70
|
+
expression: validateSqlIdentifier(expr.column),
|
|
71
|
+
alias: validateSqlIdentifier(expr.as ?? expr.column),
|
|
72
|
+
groupingCandidate: true
|
|
73
|
+
};
|
|
74
|
+
if ("bucket" in expr) return {
|
|
75
|
+
expression: bucketExpr(adapterType, expr.bucket, expr.column),
|
|
76
|
+
alias: validateSqlIdentifier(expr.as),
|
|
77
|
+
groupingCandidate: true
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
expression: aggregateExpression(expr),
|
|
81
|
+
alias: validateSqlIdentifier(expr.as),
|
|
82
|
+
groupingCandidate: false
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function validateWhereShape(where) {
|
|
86
|
+
if (!where) return;
|
|
87
|
+
const validateKey = (key) => {
|
|
88
|
+
const { field } = parseConditionKey(key);
|
|
89
|
+
if (field) validateSqlIdentifier(field);
|
|
90
|
+
};
|
|
91
|
+
if (Array.isArray(where)) {
|
|
92
|
+
for (const andGroup of where) for (const condition of andGroup) for (const key of Object.keys(condition)) validateKey(key);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const key of Object.keys(where)) validateKey(key);
|
|
96
|
+
}
|
|
97
|
+
function expandHavingKey(key, expressionByAlias) {
|
|
98
|
+
const parsed = parseConditionKey(key);
|
|
99
|
+
return `${expressionByAlias.get(parsed.field) ?? validateSqlIdentifier(parsed.field)} ${parsed.operator}`;
|
|
100
|
+
}
|
|
101
|
+
function expandHaving(having, expressionByAlias) {
|
|
102
|
+
const expandCondition = (condition) => Object.fromEntries(Object.entries(condition).map(([key, value]) => [expandHavingKey(key, expressionByAlias), value]));
|
|
103
|
+
if (Array.isArray(having)) return having.map((andGroup) => andGroup.map((condition) => expandCondition(condition)));
|
|
104
|
+
return expandCondition(having);
|
|
105
|
+
}
|
|
106
|
+
function buildHaving(having, expressionByAlias, startIndex, adapterType) {
|
|
107
|
+
if (!having) return {
|
|
108
|
+
sql: "",
|
|
109
|
+
values: []
|
|
110
|
+
};
|
|
111
|
+
const built = buildWhere(expandHaving(having, expressionByAlias), startIndex, adapterType);
|
|
112
|
+
return {
|
|
113
|
+
sql: built.sql ? built.sql.replace(/^WHERE/, "HAVING") : "",
|
|
114
|
+
values: built.values
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function buildOrderBy(orderBy) {
|
|
118
|
+
if (!orderBy) return "";
|
|
119
|
+
return `ORDER BY ${(Array.isArray(orderBy) ? orderBy : [orderBy]).map((item) => {
|
|
120
|
+
const [field, direction = "ASC"] = item.trim().split(/\s+/);
|
|
121
|
+
const normalizedDirection = direction.toUpperCase();
|
|
122
|
+
if (!VALID_DIRECTIONS.has(normalizedDirection)) throw new Error(`Invalid aggregate order direction: ${direction}`);
|
|
123
|
+
return `${validateSqlIdentifier(field)} ${normalizedDirection}`;
|
|
124
|
+
}).join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
function buildAggregate(spec, startIndex = 1, adapterType) {
|
|
127
|
+
const from = validateSqlIdentifier(spec.from);
|
|
128
|
+
if (spec.select.length === 0) throw new Error("AggregateSpec.select must contain at least one expression");
|
|
129
|
+
validateWhereShape(spec.where);
|
|
130
|
+
const expressionByAlias = /* @__PURE__ */ new Map();
|
|
131
|
+
const groupingAliases = [];
|
|
132
|
+
const selectSql = spec.select.map((expr) => {
|
|
133
|
+
const selected = selectExpression(expr, adapterType);
|
|
134
|
+
expressionByAlias.set(selected.alias, selected.expression);
|
|
135
|
+
if (selected.groupingCandidate) groupingAliases.push(selected.alias);
|
|
136
|
+
return `${selected.expression} AS ${selected.alias}`;
|
|
137
|
+
});
|
|
138
|
+
const where = spec.where ? buildWhere(spec.where, startIndex, adapterType) : {
|
|
139
|
+
sql: "",
|
|
140
|
+
values: []
|
|
141
|
+
};
|
|
142
|
+
let nextParamIndex = startIndex + where.values.length;
|
|
143
|
+
const groupByItems = spec.groupBy ?? groupingAliases;
|
|
144
|
+
const groupBySql = groupByItems.length > 0 ? `GROUP BY ${groupByItems.map((item) => expressionByAlias.has(item) ? expressionByAlias.get(item) : validateSqlIdentifier(item)).join(", ")}` : "";
|
|
145
|
+
const having = buildHaving(spec.having, expressionByAlias, nextParamIndex, adapterType);
|
|
146
|
+
nextParamIndex += having.values.length;
|
|
147
|
+
const orderBySql = buildOrderBy(spec.orderBy);
|
|
148
|
+
const pagingValues = [];
|
|
149
|
+
const pagingSql = [];
|
|
150
|
+
if (spec.limit !== void 0) {
|
|
151
|
+
pagingSql.push(`LIMIT ${placeholder(nextParamIndex++)}`);
|
|
152
|
+
pagingValues.push(spec.limit);
|
|
153
|
+
} else if (spec.offset !== void 0 && normalizeAdapter(adapterType) === "sqlite") pagingSql.push("LIMIT -1");
|
|
154
|
+
if (spec.offset !== void 0) {
|
|
155
|
+
pagingSql.push(`OFFSET ${placeholder(nextParamIndex++)}`);
|
|
156
|
+
pagingValues.push(spec.offset);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
sql: [
|
|
160
|
+
`SELECT ${selectSql.join(", ")}`,
|
|
161
|
+
`FROM ${from}`,
|
|
162
|
+
where.sql,
|
|
163
|
+
groupBySql,
|
|
164
|
+
having.sql,
|
|
165
|
+
orderBySql,
|
|
166
|
+
pagingSql.join(" ")
|
|
167
|
+
].filter(Boolean).join(" "),
|
|
168
|
+
values: [
|
|
169
|
+
...where.values,
|
|
170
|
+
...having.values,
|
|
171
|
+
...pagingValues
|
|
172
|
+
]
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/doctor.ts
|
|
177
|
+
/**
|
|
178
|
+
* Database integrity-check framework.
|
|
179
|
+
*
|
|
180
|
+
* The runner is a thin loop over user-supplied checks; the value here is
|
|
181
|
+
* the reusable check builders (expected tables, unique columns, FK-like
|
|
182
|
+
* relationships) that codify the common shapes of integrity bugs that
|
|
183
|
+
* sneak past schema migrations.
|
|
184
|
+
*
|
|
185
|
+
* No SMRT awareness — checks operate on the `DatabaseInterface` from this
|
|
186
|
+
* package and on plain table/column names.
|
|
187
|
+
*
|
|
188
|
+
* **Postgres-only today.** The bundled check builders and
|
|
189
|
+
* `listPublicTables` use Postgres syntax (`pg_tables`, `::int`/`::text`
|
|
190
|
+
* casts). The shape is portable in principle — `DoctorContext` carries a
|
|
191
|
+
* generic `DatabaseInterface` so apps can add engine-aware checks — but
|
|
192
|
+
* the out-of-the-box runner expects a Postgres connection. `runDoctor`
|
|
193
|
+
* throws early if pointed at a non-Postgres URL rather than failing
|
|
194
|
+
* mid-execution with a confusing `pg_tables`-doesn't-exist error.
|
|
195
|
+
*/
|
|
196
|
+
/**
|
|
197
|
+
* Execute every check against the public-schema tables of `db` and
|
|
198
|
+
* partition the results by severity. Returns the aggregate; callers
|
|
199
|
+
* format/exit as they see fit.
|
|
200
|
+
*
|
|
201
|
+
* The current table set is enumerated once and shared across checks so
|
|
202
|
+
* each check can cheaply decide whether its target tables exist.
|
|
203
|
+
*/
|
|
204
|
+
async function runDoctor(options) {
|
|
205
|
+
assertPostgresAdapter(options.db);
|
|
206
|
+
const tables = await listPublicTables(options.db);
|
|
207
|
+
const issues = [];
|
|
208
|
+
for (const check of options.checks) {
|
|
209
|
+
const checkIssues = await check({
|
|
210
|
+
db: options.db,
|
|
211
|
+
tables
|
|
212
|
+
});
|
|
213
|
+
issues.push(...checkIssues);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
failures: issues.filter((issue) => issue.level === "fail"),
|
|
217
|
+
warnings: issues.filter((issue) => issue.level === "warn"),
|
|
218
|
+
issues
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Throw early if the supplied database isn't Postgres. The check builders
|
|
223
|
+
* and `listPublicTables` use Postgres syntax; pointing them at SQLite or
|
|
224
|
+
* DuckDB would fail mid-execution with a confusing "table pg_tables does
|
|
225
|
+
* not exist" error from the adapter. Fail fast with a useful message
|
|
226
|
+
* instead.
|
|
227
|
+
*/
|
|
228
|
+
function assertPostgresAdapter(db) {
|
|
229
|
+
const url = String(db.url ?? "");
|
|
230
|
+
if (!url.startsWith("postgres://") && !url.startsWith("postgresql://")) throw new Error(`runDoctor requires a Postgres database connection. Got URL: ${url ? redactDatabaseUrl(url) : "(empty)"}. The bundled check builders use Postgres-specific syntax (pg_tables, ::int/::text casts).`);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Emit a 'warn' for each table in `expected` that's missing.
|
|
234
|
+
*
|
|
235
|
+
* Marked as `warn` rather than `fail` because a missing table often
|
|
236
|
+
* indicates a migration that hasn't been run yet, not active data
|
|
237
|
+
* corruption — callers can promote to fail by mapping the issues.
|
|
238
|
+
*/
|
|
239
|
+
function checkExpectedTables(expected) {
|
|
240
|
+
return async ({ tables }) => {
|
|
241
|
+
const issues = [];
|
|
242
|
+
for (const table of expected) if (!tables.has(table)) issues.push({
|
|
243
|
+
level: "warn",
|
|
244
|
+
message: `Missing expected table: ${table}`
|
|
245
|
+
});
|
|
246
|
+
return issues;
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Emit a 'fail' for each value that appears more than once in
|
|
251
|
+
* `(table, column)` (ignoring NULL and empty-string values).
|
|
252
|
+
*
|
|
253
|
+
* Useful for catching slug/key collisions that the schema didn't
|
|
254
|
+
* declare a UNIQUE constraint for (or where the constraint was added
|
|
255
|
+
* after data already existed).
|
|
256
|
+
*/
|
|
257
|
+
function checkUniqueColumn(spec) {
|
|
258
|
+
return async ({ db, tables }) => {
|
|
259
|
+
if (!tables.has(spec.table)) return [];
|
|
260
|
+
return (await db.query(`
|
|
261
|
+
SELECT ${quoteIdentifier(spec.column)} AS value, count(*)::text AS count
|
|
262
|
+
FROM ${quoteIdentifier(spec.table)}
|
|
263
|
+
WHERE NULLIF(trim(${quoteIdentifier(spec.column)}::text), '') IS NOT NULL
|
|
264
|
+
GROUP BY ${quoteIdentifier(spec.column)}
|
|
265
|
+
HAVING count(*) > 1
|
|
266
|
+
ORDER BY count DESC, ${quoteIdentifier(spec.column)}
|
|
267
|
+
`)).rows.map((row) => ({
|
|
268
|
+
level: "fail",
|
|
269
|
+
message: `Duplicate ${spec.label} "${String(row.value)}" in ${spec.table} (${countValueFromRow(row.count)} rows).`
|
|
270
|
+
}));
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Emit a 'fail' counting orphan rows where the child column points at a
|
|
275
|
+
* parent row that doesn't exist (FK-shaped check, but doesn't require an
|
|
276
|
+
* actual FK constraint to be declared — useful for soft references like
|
|
277
|
+
* tag-id-or-slug lookup columns).
|
|
278
|
+
*/
|
|
279
|
+
function checkRelationship(spec) {
|
|
280
|
+
return async ({ db, tables }) => {
|
|
281
|
+
if (!tables.has(spec.childTable) || !tables.has(spec.parentTable)) return [];
|
|
282
|
+
const parentColumn = spec.parentColumn ?? "id";
|
|
283
|
+
const orphanCount = countValueFromRow((await db.query(`
|
|
284
|
+
SELECT count(*)::text AS count
|
|
285
|
+
FROM ${quoteIdentifier(spec.childTable)} child
|
|
286
|
+
LEFT JOIN ${quoteIdentifier(spec.parentTable)} parent
|
|
287
|
+
ON parent.${quoteIdentifier(parentColumn)} = child.${quoteIdentifier(spec.childColumn)}
|
|
288
|
+
WHERE NULLIF(trim(child.${quoteIdentifier(spec.childColumn)}::text), '') IS NOT NULL
|
|
289
|
+
AND parent.${quoteIdentifier(parentColumn)} IS NULL
|
|
290
|
+
`)).rows[0]?.count);
|
|
291
|
+
if (orphanCount === "0") return [];
|
|
292
|
+
return [{
|
|
293
|
+
level: "fail",
|
|
294
|
+
message: `${spec.label}: ${orphanCount} orphaned row${orphanCount === "1" ? "" : "s"}.`
|
|
295
|
+
}];
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async function listPublicTables(db) {
|
|
299
|
+
const result = await db.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename");
|
|
300
|
+
return new Set(result.rows.map((row) => String(row.tablename ?? "")));
|
|
301
|
+
}
|
|
302
|
+
function quoteIdentifier(value) {
|
|
303
|
+
return `"${value.replace(/"/gu, "\"\"")}"`;
|
|
304
|
+
}
|
|
305
|
+
function countValueFromRow(value) {
|
|
306
|
+
if (typeof value === "string") return value;
|
|
307
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
308
|
+
return "0";
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/shared/types.ts
|
|
312
|
+
/**
|
|
313
|
+
* Resolves a SchemasOption to its concrete value.
|
|
314
|
+
*
|
|
315
|
+
* Adapters that need schemas (JSON, DuckDB) call this to unwrap the
|
|
316
|
+
* lazy function. Adapters that don't (Postgres, SQLite) simply ignore
|
|
317
|
+
* the `schemas` option without calling this.
|
|
318
|
+
*/
|
|
319
|
+
function resolveSchemas(schemas) {
|
|
320
|
+
if (!schemas) return void 0;
|
|
321
|
+
if (typeof schemas === "function") return schemas();
|
|
322
|
+
return schemas;
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/json.ts
|
|
326
|
+
var json_exports = /* @__PURE__ */ __exportAll({
|
|
327
|
+
clearConnectionCache: () => clearConnectionCache,
|
|
328
|
+
getDatabase: () => getDatabase$2
|
|
329
|
+
});
|
|
330
|
+
/**
|
|
331
|
+
* Connection cache for in-memory DuckDB instances keyed by data directory URL
|
|
332
|
+
* Enables sharing of in-memory databases across multiple getDatabase() calls
|
|
333
|
+
* with the same data directory.
|
|
334
|
+
*
|
|
335
|
+
* Uses globalThis to ensure cache is shared across all module instances,
|
|
336
|
+
* fixing the lost update bug in monorepos with workspace symlinks.
|
|
337
|
+
*
|
|
338
|
+
* @see https://github.com/happyvertical/sdk/issues/678
|
|
339
|
+
*/
|
|
340
|
+
globalThis.__haveSqlMemoryConnectionCache ??= /* @__PURE__ */ new Map();
|
|
341
|
+
var memoryConnectionCache = globalThis.__haveSqlMemoryConnectionCache;
|
|
342
|
+
/**
|
|
343
|
+
* Pending connection promises to handle concurrent getDatabase() calls
|
|
344
|
+
* Prevents creating duplicate connections when parallel calls happen.
|
|
345
|
+
*
|
|
346
|
+
* Uses globalThis to ensure cache is shared across all module instances.
|
|
347
|
+
*
|
|
348
|
+
* @see https://github.com/happyvertical/sdk/issues/678
|
|
349
|
+
*/
|
|
350
|
+
globalThis.__haveSqlPendingConnections ??= /* @__PURE__ */ new Map();
|
|
351
|
+
var pendingConnections$1 = globalThis.__haveSqlPendingConnections;
|
|
352
|
+
/**
|
|
353
|
+
* Clears all cached connections
|
|
354
|
+
* Useful for testing to ensure test isolation
|
|
355
|
+
*/
|
|
356
|
+
function clearConnectionCache() {
|
|
357
|
+
memoryConnectionCache.clear();
|
|
358
|
+
pendingConnections$1.clear();
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Creates a JSON database connection using DuckDB in-memory engine
|
|
362
|
+
*
|
|
363
|
+
* This adapter uses DuckDB as a query engine for JSON files, storing everything
|
|
364
|
+
* in-memory and writing changes back to JSON files. No WAL files or persistent
|
|
365
|
+
* database files are created.
|
|
366
|
+
*
|
|
367
|
+
* @param options - JSON database options
|
|
368
|
+
* @returns Promise resolving to a DuckDB connection
|
|
369
|
+
*/
|
|
370
|
+
async function createJSONConnection(options) {
|
|
371
|
+
const { url, autoRegister = true, eagerLoadTables = true } = options;
|
|
372
|
+
const schemas = resolveSchemas(options.schemas) ?? {};
|
|
373
|
+
if (!url) throw new DatabaseError("url is required for JSON adapter", { options });
|
|
374
|
+
const pendingJSONFiles = /* @__PURE__ */ new Map();
|
|
375
|
+
const inferredSchemaTables = /* @__PURE__ */ new Set();
|
|
376
|
+
try {
|
|
377
|
+
const { DuckDBInstance } = await import(
|
|
378
|
+
/* @vite-ignore */
|
|
379
|
+
"@duckdb/node-api"
|
|
380
|
+
);
|
|
381
|
+
const connection = await (await DuckDBInstance.create(":memory:")).connect();
|
|
382
|
+
try {
|
|
383
|
+
await mkdir(url, { recursive: true });
|
|
384
|
+
} catch (_error) {}
|
|
385
|
+
if (autoRegister) await loadJSONTables(connection, url, schemas, pendingJSONFiles, eagerLoadTables, inferredSchemaTables);
|
|
386
|
+
return {
|
|
387
|
+
connection,
|
|
388
|
+
pendingJSONFiles,
|
|
389
|
+
inferredSchemaTables
|
|
390
|
+
};
|
|
391
|
+
} catch (error) {
|
|
392
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
393
|
+
throw new DatabaseError(`Failed to create JSON database connection: ${errorMessage}`, {
|
|
394
|
+
url,
|
|
395
|
+
originalError: errorMessage
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Loads JSON data into an existing table
|
|
401
|
+
*
|
|
402
|
+
* This function only loads data - it assumes the table already exists with proper schema.
|
|
403
|
+
* Used after DDL creates the table to load persisted JSON data.
|
|
404
|
+
*
|
|
405
|
+
* @param connection - DuckDB connection
|
|
406
|
+
* @param tableName - Name of the table to load data into
|
|
407
|
+
* @param filePath - Path to the JSON file
|
|
408
|
+
*/
|
|
409
|
+
async function loadJSONData(connection, tableName, filePath) {
|
|
410
|
+
try {
|
|
411
|
+
const { readFile } = await import("node:fs/promises");
|
|
412
|
+
const jsonContent = await readFile(filePath, "utf-8");
|
|
413
|
+
const records = JSON.parse(jsonContent);
|
|
414
|
+
if (Array.isArray(records) && records.length > 0) {
|
|
415
|
+
await insertRecordsWithCast(connection, tableName, records);
|
|
416
|
+
console.log(`[json-adapter] Loaded ${records.length} records into ${tableName} from JSON`);
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
console.warn(`[json-adapter] Could not load data for ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Scans the data directory and registers JSON files for deferred loading
|
|
424
|
+
*
|
|
425
|
+
* Unlike the DuckDB adapter which creates views, this creates actual tables
|
|
426
|
+
* so that indexes can be added.
|
|
427
|
+
*
|
|
428
|
+
* Schema resolution priority:
|
|
429
|
+
* 1. Explicit schemas provided via options.schemas - table created immediately
|
|
430
|
+
* 2. Schema file (.schema.sql) in data directory - table created with schema
|
|
431
|
+
* 3. All other tables - DEFERRED until syncSchema() provides DDL
|
|
432
|
+
*
|
|
433
|
+
* IMPORTANT: Tables without explicit schemas are NOT auto-created from JSON.
|
|
434
|
+
* This ensures UNIQUE constraints from syncSchema() are in place before data
|
|
435
|
+
* is loaded, which is required for UPSERT operations to work correctly.
|
|
436
|
+
*
|
|
437
|
+
* For users who want auto-inference, use the inferSchemaFromJSON() method
|
|
438
|
+
* after database creation.
|
|
439
|
+
*
|
|
440
|
+
* @param connection - DuckDB connection
|
|
441
|
+
* @param dataDir - Directory containing JSON files
|
|
442
|
+
* @param providedSchemas - Explicit schema definitions provided by caller (optional)
|
|
443
|
+
* @param pendingJSONFiles - Map to store JSON files pending deferred loading
|
|
444
|
+
* @param eagerLoadTables - Whether to eagerly create tables with inferred schemas
|
|
445
|
+
* @param inferredSchemaTables - Set to track tables created with inferred schemas
|
|
446
|
+
*/
|
|
447
|
+
async function loadJSONTables(connection, dataDir, providedSchemas = {}, pendingJSONFiles = /* @__PURE__ */ new Map(), eagerLoadTables = true, inferredSchemaTables = /* @__PURE__ */ new Set()) {
|
|
448
|
+
try {
|
|
449
|
+
const jsonFiles = (await readdir(dataDir)).filter((file) => extname(file).toLowerCase() === ".json");
|
|
450
|
+
for (const file of jsonFiles) {
|
|
451
|
+
const filePath = join(dataDir, file);
|
|
452
|
+
const tableName = basename(file, ".json");
|
|
453
|
+
const schema = providedSchemas[tableName];
|
|
454
|
+
if (schema) {
|
|
455
|
+
console.log(`[json-adapter] Creating ${tableName} with provided schema definition`);
|
|
456
|
+
await createTableFromSchema(connection, tableName, schema);
|
|
457
|
+
await loadJSONData(connection, tableName, filePath);
|
|
458
|
+
} else {
|
|
459
|
+
const schemaPath = join(dataDir, `${tableName}.schema.sql`);
|
|
460
|
+
const { readFile, access } = await import("node:fs/promises");
|
|
461
|
+
const { constants } = await import("node:fs");
|
|
462
|
+
let schemaExists = false;
|
|
463
|
+
try {
|
|
464
|
+
await access(schemaPath, constants.F_OK);
|
|
465
|
+
schemaExists = true;
|
|
466
|
+
} catch {
|
|
467
|
+
schemaExists = false;
|
|
468
|
+
}
|
|
469
|
+
if (schemaExists) try {
|
|
470
|
+
const schemaDDL = await readFile(schemaPath, "utf-8");
|
|
471
|
+
await connection.run(schemaDDL);
|
|
472
|
+
await loadJSONData(connection, tableName, filePath);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.warn(`[json-adapter] Could not load ${tableName} with schema file: ${error instanceof Error ? error.message : String(error)}`);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
if (eagerLoadTables) try {
|
|
478
|
+
await connection.run(`CREATE TABLE IF NOT EXISTS "${tableName}" AS SELECT * FROM read_json_auto('${filePath}')`);
|
|
479
|
+
inferredSchemaTables.add(tableName);
|
|
480
|
+
console.log(`[json-adapter] Eagerly created ${tableName} with inferred schema (no UNIQUE constraints)`);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.warn(`[json-adapter] Could not eagerly load ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
483
|
+
}
|
|
484
|
+
else console.log(`[json-adapter] Deferring ${tableName} - will load after syncSchema() creates table`);
|
|
485
|
+
pendingJSONFiles.set(tableName, filePath);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (error.code !== "ENOENT") {
|
|
491
|
+
console.error("[json-adapter] Error loading JSON tables:", error);
|
|
492
|
+
throw new DatabaseError(`Failed to load JSON tables from ${dataDir}`, {
|
|
493
|
+
dataDir,
|
|
494
|
+
originalError: error instanceof Error ? error.message : String(error),
|
|
495
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Converts DuckDB-specific type representations to JavaScript types
|
|
502
|
+
*
|
|
503
|
+
* Handles three cases:
|
|
504
|
+
* 1. JavaScript BigInt values → converted to numbers
|
|
505
|
+
* 2. DuckDB hugeint objects ({ hugeint: number }) → converted to strings
|
|
506
|
+
* 3. DuckDB timestamp objects ({ micros: number }) → converted to Date objects
|
|
507
|
+
*
|
|
508
|
+
* DuckDB represents HUGEINT (128-bit integers) as objects with a single
|
|
509
|
+
* property called 'hugeint'. When UUID strings (stored as TEXT) are
|
|
510
|
+
* exported to JSON via COPY TO, DuckDB sometimes converts them to hugeint
|
|
511
|
+
* representation. This function converts them back to strings.
|
|
512
|
+
*
|
|
513
|
+
* DuckDB represents TIMESTAMP values as objects with a single property
|
|
514
|
+
* called 'micros' containing microseconds since Unix epoch. This function
|
|
515
|
+
* converts them to JavaScript Date objects for proper round-trip serialization.
|
|
516
|
+
*
|
|
517
|
+
* @param obj - Object that may contain BigInt, hugeint, or timestamp values
|
|
518
|
+
* @returns Object with DuckDB types converted to JavaScript types
|
|
519
|
+
*/
|
|
520
|
+
function convertBigInts(obj) {
|
|
521
|
+
if (obj === null || obj === void 0) return obj;
|
|
522
|
+
if (typeof obj === "bigint") return Number(obj);
|
|
523
|
+
if (typeof obj === "object" && !Array.isArray(obj) && "hugeint" in obj && Object.keys(obj).length === 1) return String(obj.hugeint);
|
|
524
|
+
if (typeof obj === "object" && !Array.isArray(obj) && "micros" in obj && Object.keys(obj).length === 1 && (typeof obj.micros === "number" || typeof obj.micros === "bigint")) {
|
|
525
|
+
const milliseconds = (typeof obj.micros === "bigint" ? Number(obj.micros) : obj.micros) / 1e3;
|
|
526
|
+
return new Date(milliseconds);
|
|
527
|
+
}
|
|
528
|
+
if (Array.isArray(obj)) return obj.map(convertBigInts);
|
|
529
|
+
if (typeof obj === "object") {
|
|
530
|
+
const result = {};
|
|
531
|
+
for (const key in obj) result[key] = convertBigInts(obj[key]);
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
return obj;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Creates a table from a schema definition with proper typing
|
|
538
|
+
*
|
|
539
|
+
* DuckDB has issues with type inference when DEFAULT values are empty strings.
|
|
540
|
+
* This function explicitly casts all DEFAULT values to their column types to
|
|
541
|
+
* prevent DuckDB from inferring ANY type.
|
|
542
|
+
*
|
|
543
|
+
* Additionally, DuckDB has a known limitation (issue #12684) where ON CONFLICT
|
|
544
|
+
* clauses fail with UNIQUE INDEX but work with inline UNIQUE constraints.
|
|
545
|
+
* This function converts UNIQUE indexes to inline constraints for compatibility.
|
|
546
|
+
*
|
|
547
|
+
* @param connection - DuckDB connection
|
|
548
|
+
* @param tableName - Name of the table to create
|
|
549
|
+
* @param schema - Schema definition with DDL and indexes
|
|
550
|
+
*/
|
|
551
|
+
async function createTableFromSchema(connection, tableName, schema) {
|
|
552
|
+
const ddlLines = schema.ddl.split("\n");
|
|
553
|
+
const createTableEnd = ddlLines.findIndex((line) => {
|
|
554
|
+
const trimmed = line.trim();
|
|
555
|
+
return trimmed === ")" || trimmed.startsWith(");");
|
|
556
|
+
});
|
|
557
|
+
if (createTableEnd === -1) throw new DatabaseError("Invalid schema DDL - no closing parenthesis", {
|
|
558
|
+
tableName,
|
|
559
|
+
ddl: schema.ddl
|
|
560
|
+
});
|
|
561
|
+
let createTableSQL = ddlLines.slice(0, createTableEnd + 1).join("\n");
|
|
562
|
+
createTableSQL = createTableSQL.replace(/\b(\w+)\s+(TEXT|VARCHAR)\s+DEFAULT\s+''/g, "$1 $2 DEFAULT CAST('' AS $2)");
|
|
563
|
+
createTableSQL = createTableSQL.replace(/\b(\w+)\s+(TEXT|VARCHAR)\s+DEFAULT\s+NULL/g, "$1 $2 DEFAULT CAST(NULL AS $2)");
|
|
564
|
+
const transformed = convertUniqueIndexesToInlineConstraints(createTableSQL, schema.indexes);
|
|
565
|
+
createTableSQL = transformed.ddl;
|
|
566
|
+
const indexesToCreate = transformed.indexes;
|
|
567
|
+
try {
|
|
568
|
+
await connection.run(createTableSQL);
|
|
569
|
+
let existingColumns = /* @__PURE__ */ new Set();
|
|
570
|
+
try {
|
|
571
|
+
const columns = (await connection.runAndReadAll(`DESCRIBE ${tableName}`)).getRowObjects();
|
|
572
|
+
existingColumns = new Set(columns.map((col) => col.column_name.toLowerCase()));
|
|
573
|
+
console.log(`[json-adapter] Created ${tableName} with schema:`, { columns: columns.map((col) => ({
|
|
574
|
+
name: col.column_name,
|
|
575
|
+
type: col.column_type,
|
|
576
|
+
null: col.null
|
|
577
|
+
})) });
|
|
578
|
+
} catch (error) {
|
|
579
|
+
console.warn(`[json-adapter] Could not verify schema for ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
580
|
+
}
|
|
581
|
+
if (schema.fields && existingColumns.size > 0) {
|
|
582
|
+
const fieldEntries = schema.fields instanceof Map ? Array.from(schema.fields.entries()) : Object.entries(schema.fields);
|
|
583
|
+
for (const [fieldName, fieldDef] of fieldEntries) {
|
|
584
|
+
const columnName = fieldName.toLowerCase();
|
|
585
|
+
if (!existingColumns.has(columnName)) {
|
|
586
|
+
const sqlType = fieldDef?.type || "TEXT";
|
|
587
|
+
try {
|
|
588
|
+
await connection.run(`ALTER TABLE ${tableName} ADD COLUMN "${fieldName}" ${sqlType}`);
|
|
589
|
+
console.log(`[json-adapter] Added missing column '${fieldName}' (${sqlType}) to ${tableName}`);
|
|
590
|
+
} catch (alterError) {
|
|
591
|
+
console.warn(`[json-adapter] Could not add column '${fieldName}' to ${tableName}: ${alterError instanceof Error ? alterError.message : String(alterError)}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (indexesToCreate && indexesToCreate.length > 0) for (const indexSQL of indexesToCreate) try {
|
|
597
|
+
await connection.run(indexSQL);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
console.warn(`[json-adapter] Failed to create index: ${error instanceof Error ? error.message : String(error)}`);
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
throw new DatabaseError("Failed to create table from schema", {
|
|
603
|
+
tableName,
|
|
604
|
+
sql: createTableSQL,
|
|
605
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Inserts records with proper CAST handling for empty strings
|
|
611
|
+
*
|
|
612
|
+
* This helper avoids using read_json() with auto_detect, which causes DuckDB
|
|
613
|
+
* to re-infer column types from data (causing ANY type for empty strings).
|
|
614
|
+
* Instead, it uses parameterized INSERT with CAST to maintain table schema.
|
|
615
|
+
*
|
|
616
|
+
* @param connection - DuckDB connection
|
|
617
|
+
* @param tableName - Name of the table to insert into
|
|
618
|
+
* @param records - Array of records to insert
|
|
619
|
+
*/
|
|
620
|
+
async function insertRecordsWithCast(connection, tableName, records) {
|
|
621
|
+
if (records.length === 0) return;
|
|
622
|
+
const keys = Object.keys(records[0]);
|
|
623
|
+
const values = [];
|
|
624
|
+
let paramIdx = 1;
|
|
625
|
+
const placeholders = records.map((record) => {
|
|
626
|
+
return `(${keys.map((key) => {
|
|
627
|
+
const value = record[key];
|
|
628
|
+
if (value === null) return "NULL";
|
|
629
|
+
else if (value === "" && typeof value === "string") {
|
|
630
|
+
values.push(value);
|
|
631
|
+
return `CAST($${paramIdx++} AS TEXT)`;
|
|
632
|
+
} else if (value instanceof Date) {
|
|
633
|
+
values.push(value.toISOString());
|
|
634
|
+
return `CAST($${paramIdx++} AS TIMESTAMP)`;
|
|
635
|
+
} else if (Array.isArray(value)) {
|
|
636
|
+
values.push(JSON.stringify(value));
|
|
637
|
+
return `CAST($${paramIdx++} AS JSON)`;
|
|
638
|
+
} else if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
|
|
639
|
+
values.push(JSON.stringify(value));
|
|
640
|
+
return `CAST($${paramIdx++} AS JSON)`;
|
|
641
|
+
} else {
|
|
642
|
+
values.push(value);
|
|
643
|
+
return `$${paramIdx++}`;
|
|
644
|
+
}
|
|
645
|
+
}).join(", ")})`;
|
|
646
|
+
}).join(", ");
|
|
647
|
+
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES ${placeholders}`;
|
|
648
|
+
await connection.run(sql, values);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Validates that a table name is valid (alphanumeric + underscores only)
|
|
652
|
+
*
|
|
653
|
+
* @param table - Table name to validate
|
|
654
|
+
* @throws DatabaseError if table name is invalid
|
|
655
|
+
*/
|
|
656
|
+
function validateTableName(table) {
|
|
657
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new DatabaseError(`Invalid table name: ${table}`, { table });
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Creates a JSON database adapter using DuckDB in-memory engine
|
|
661
|
+
*
|
|
662
|
+
* This adapter provides SQL query capabilities over JSON files without creating
|
|
663
|
+
* persistent database files or WAL files. All data is stored in-memory during
|
|
664
|
+
* runtime and written back to JSON files based on the write strategy.
|
|
665
|
+
*
|
|
666
|
+
* @param options - JSON database options
|
|
667
|
+
* @returns Database interface for JSON files
|
|
668
|
+
*/
|
|
669
|
+
async function getDatabase$2(options) {
|
|
670
|
+
const cacheKey = options.dbid || `json:${options.url}:${options.writeStrategy || "immediate"}:${options.autoRegister !== false}`;
|
|
671
|
+
if (options.clearCache) {
|
|
672
|
+
memoryConnectionCache.delete(cacheKey);
|
|
673
|
+
pendingConnections$1.delete(cacheKey);
|
|
674
|
+
}
|
|
675
|
+
const cached = memoryConnectionCache.get(cacheKey);
|
|
676
|
+
if (cached) return cached;
|
|
677
|
+
const pending = pendingConnections$1.get(cacheKey);
|
|
678
|
+
if (pending) return pending;
|
|
679
|
+
const connectionPromise = (async () => {
|
|
680
|
+
const { connection, pendingJSONFiles, inferredSchemaTables } = await createJSONConnection(options);
|
|
681
|
+
const writeStrategy = options.writeStrategy || "immediate";
|
|
682
|
+
const { url } = options;
|
|
683
|
+
/**
|
|
684
|
+
* Inserts one or more records into a table
|
|
685
|
+
*
|
|
686
|
+
* @param table - Table name
|
|
687
|
+
* @param data - Single record or array of records to insert
|
|
688
|
+
* @returns Promise resolving to operation result
|
|
689
|
+
* @throws Error if the insert operation fails
|
|
690
|
+
*/
|
|
691
|
+
const insert = async (table, data) => {
|
|
692
|
+
validateTableName(table);
|
|
693
|
+
if (writeStrategy === "none") throw new DatabaseError("Cannot insert: write strategy is set to none (read-only mode)", {
|
|
694
|
+
table,
|
|
695
|
+
writeStrategy
|
|
696
|
+
});
|
|
697
|
+
const records = Array.isArray(data) ? data : [data];
|
|
698
|
+
if (records.length === 0) return {
|
|
699
|
+
operation: "insert",
|
|
700
|
+
affected: 0
|
|
701
|
+
};
|
|
702
|
+
const keys = Object.keys(records[0]);
|
|
703
|
+
const values = [];
|
|
704
|
+
let paramIdx = 1;
|
|
705
|
+
const placeholders = records.map((record) => {
|
|
706
|
+
return `(${keys.map((key) => {
|
|
707
|
+
const value = record[key];
|
|
708
|
+
if (value === null) return "NULL";
|
|
709
|
+
else if (value === "" && typeof value === "string") {
|
|
710
|
+
values.push(value);
|
|
711
|
+
return `CAST($${paramIdx++} AS TEXT)`;
|
|
712
|
+
} else if (value instanceof Date) {
|
|
713
|
+
values.push(value.toISOString());
|
|
714
|
+
return `CAST($${paramIdx++} AS TIMESTAMP)`;
|
|
715
|
+
} else if (Array.isArray(value)) {
|
|
716
|
+
values.push(JSON.stringify(value));
|
|
717
|
+
return `CAST($${paramIdx++} AS JSON)`;
|
|
718
|
+
} else if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
|
|
719
|
+
values.push(JSON.stringify(value));
|
|
720
|
+
return `CAST($${paramIdx++} AS JSON)`;
|
|
721
|
+
} else {
|
|
722
|
+
values.push(value);
|
|
723
|
+
return `$${paramIdx++}`;
|
|
724
|
+
}
|
|
725
|
+
}).join(", ")})`;
|
|
726
|
+
}).join(", ");
|
|
727
|
+
const sql = `INSERT INTO "${table}" (${keys.join(", ")}) VALUES ${placeholders}`;
|
|
728
|
+
try {
|
|
729
|
+
await connection.run(sql, values);
|
|
730
|
+
const affected = records.length;
|
|
731
|
+
if (writeStrategy === "immediate") await exportTableToJSON(connection, table, url);
|
|
732
|
+
return {
|
|
733
|
+
operation: "insert",
|
|
734
|
+
affected
|
|
735
|
+
};
|
|
736
|
+
} catch (e) {
|
|
737
|
+
throw new DatabaseError("Failed to insert records into table", {
|
|
738
|
+
table,
|
|
739
|
+
sql,
|
|
740
|
+
values,
|
|
741
|
+
originalError: formatDbError(e)
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* Retrieves a single record matching the where criteria
|
|
747
|
+
*
|
|
748
|
+
* @param table - Table name
|
|
749
|
+
* @param where - Criteria to match records
|
|
750
|
+
* @returns Promise resolving to matching record or null if not found
|
|
751
|
+
* @throws Error if the query fails
|
|
752
|
+
*/
|
|
753
|
+
const get = async (table, where) => {
|
|
754
|
+
validateTableName(table);
|
|
755
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "json");
|
|
756
|
+
const sql = `SELECT * FROM "${table}" ${whereClause} LIMIT 1`;
|
|
757
|
+
try {
|
|
758
|
+
const rows = (await connection.runAndReadAll(sql, values)).getRowObjects();
|
|
759
|
+
return rows.length > 0 ? convertBigInts(rows[0]) : null;
|
|
760
|
+
} catch (e) {
|
|
761
|
+
throw new DatabaseError("Failed to retrieve record from table", {
|
|
762
|
+
table,
|
|
763
|
+
sql,
|
|
764
|
+
values,
|
|
765
|
+
originalError: formatDbError(e)
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
/**
|
|
770
|
+
* Retrieves multiple records matching the where criteria
|
|
771
|
+
*
|
|
772
|
+
* @param table - Table name
|
|
773
|
+
* @param where - Criteria to match records
|
|
774
|
+
* @returns Promise resolving to array of matching records
|
|
775
|
+
* @throws Error if the query fails
|
|
776
|
+
*/
|
|
777
|
+
const list = async (table, where) => {
|
|
778
|
+
validateTableName(table);
|
|
779
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "json");
|
|
780
|
+
const sql = `SELECT * FROM "${table}" ${whereClause}`;
|
|
781
|
+
try {
|
|
782
|
+
return convertBigInts((await connection.runAndReadAll(sql, values)).getRowObjects());
|
|
783
|
+
} catch (e) {
|
|
784
|
+
throw new DatabaseError("Failed to list records from table", {
|
|
785
|
+
table,
|
|
786
|
+
sql,
|
|
787
|
+
values,
|
|
788
|
+
originalError: formatDbError(e)
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* Updates records matching the where criteria
|
|
794
|
+
*
|
|
795
|
+
* @param table - Table name
|
|
796
|
+
* @param where - Criteria to match records to update
|
|
797
|
+
* @param data - New data to set
|
|
798
|
+
* @returns Promise resolving to operation result
|
|
799
|
+
* @throws Error if the update operation fails
|
|
800
|
+
*/
|
|
801
|
+
const update = async (table, where, data) => {
|
|
802
|
+
validateTableName(table);
|
|
803
|
+
if (writeStrategy === "none") throw new DatabaseError("Cannot update: write strategy is set to none (read-only mode)", {
|
|
804
|
+
table,
|
|
805
|
+
writeStrategy
|
|
806
|
+
});
|
|
807
|
+
const keys = Object.keys(data);
|
|
808
|
+
const setClause = keys.map((key, idx) => `${key} = $${idx + 1}`).join(", ");
|
|
809
|
+
const { sql: whereClause, values: whereValues } = buildWhere(where, keys.length + 1, "json");
|
|
810
|
+
const dataValues = Object.values(data).map((value) => value instanceof Date ? value.toISOString() : value);
|
|
811
|
+
const sql = `UPDATE "${table}" SET ${setClause} ${whereClause}`;
|
|
812
|
+
const values = [...dataValues, ...whereValues];
|
|
813
|
+
try {
|
|
814
|
+
await connection.run(sql, values);
|
|
815
|
+
if (writeStrategy === "immediate") await exportTableToJSON(connection, table, url);
|
|
816
|
+
return {
|
|
817
|
+
operation: "update",
|
|
818
|
+
affected: 1
|
|
819
|
+
};
|
|
820
|
+
} catch (e) {
|
|
821
|
+
throw new DatabaseError("Failed to update records in table", {
|
|
822
|
+
table,
|
|
823
|
+
sql,
|
|
824
|
+
values,
|
|
825
|
+
originalError: formatDbError(e)
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
const buildJSONValueExpression = (value, values, paramIdx) => {
|
|
830
|
+
if (value === null) return "NULL";
|
|
831
|
+
if (value === "" && typeof value === "string") {
|
|
832
|
+
values.push(value);
|
|
833
|
+
return `CAST($${paramIdx.value++} AS TEXT)`;
|
|
834
|
+
}
|
|
835
|
+
if (value instanceof Date) {
|
|
836
|
+
values.push(value.toISOString());
|
|
837
|
+
return `CAST($${paramIdx.value++} AS TIMESTAMP)`;
|
|
838
|
+
}
|
|
839
|
+
if (Array.isArray(value)) {
|
|
840
|
+
values.push(JSON.stringify(value));
|
|
841
|
+
return `CAST($${paramIdx.value++} AS JSON)`;
|
|
842
|
+
}
|
|
843
|
+
if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
|
|
844
|
+
values.push(JSON.stringify(value));
|
|
845
|
+
return `CAST($${paramIdx.value++} AS JSON)`;
|
|
846
|
+
}
|
|
847
|
+
values.push(value);
|
|
848
|
+
return `$${paramIdx.value++}`;
|
|
849
|
+
};
|
|
850
|
+
const executeNullAwareJSONUpsert = async (table, conflictColumns, data) => {
|
|
851
|
+
const conflictWhere = Object.fromEntries(conflictColumns.map((col) => [col, data[col]]));
|
|
852
|
+
if (!await get(table, conflictWhere)) return insert(table, data);
|
|
853
|
+
const keys = Object.keys(data);
|
|
854
|
+
const values = [];
|
|
855
|
+
const paramIdx = { value: 1 };
|
|
856
|
+
const assignments = keys.map((key) => {
|
|
857
|
+
return `"${key}" = ${buildJSONValueExpression(data[key], values, paramIdx)}`;
|
|
858
|
+
});
|
|
859
|
+
const { sql: whereClause, values: whereValues } = buildWhere(conflictWhere, paramIdx.value, "json");
|
|
860
|
+
const sql = `UPDATE "${table}" SET ${assignments.join(", ")} ${whereClause}`;
|
|
861
|
+
try {
|
|
862
|
+
await connection.run(sql, [...values, ...whereValues]);
|
|
863
|
+
if (writeStrategy === "immediate") await exportTableToJSON(connection, table, url);
|
|
864
|
+
return {
|
|
865
|
+
operation: "upsert",
|
|
866
|
+
affected: 1
|
|
867
|
+
};
|
|
868
|
+
} catch (e) {
|
|
869
|
+
throw new DatabaseError("Failed to upsert record into table", {
|
|
870
|
+
table,
|
|
871
|
+
sql,
|
|
872
|
+
values: [...values, ...whereValues],
|
|
873
|
+
conflictColumns,
|
|
874
|
+
originalError: formatDbError(e)
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
/**
|
|
879
|
+
* Inserts a record or updates it if it already exists (UPSERT)
|
|
880
|
+
*
|
|
881
|
+
* @param table - Table name
|
|
882
|
+
* @param conflictColumns - Columns that define the uniqueness constraint
|
|
883
|
+
* @param data - Data to insert or update
|
|
884
|
+
* @returns Promise resolving to operation result
|
|
885
|
+
* @throws Error if the upsert operation fails
|
|
886
|
+
*/
|
|
887
|
+
const upsert = async (table, conflictColumns, data, options) => {
|
|
888
|
+
validateTableName(table);
|
|
889
|
+
if (writeStrategy === "none") throw new DatabaseError("Cannot upsert: write strategy is set to none (read-only mode)", {
|
|
890
|
+
table,
|
|
891
|
+
writeStrategy
|
|
892
|
+
});
|
|
893
|
+
const missingColumns = conflictColumns.filter((col) => !(col in data));
|
|
894
|
+
if (missingColumns.length > 0) throw new DatabaseError("Conflict columns missing from data", {
|
|
895
|
+
table,
|
|
896
|
+
conflictColumns,
|
|
897
|
+
missingColumns,
|
|
898
|
+
availableColumns: Object.keys(data),
|
|
899
|
+
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."
|
|
900
|
+
});
|
|
901
|
+
if (!options?.nullsDistinct && conflictColumns.some((col) => data[col] === null)) return executeNullAwareJSONUpsert(table, conflictColumns, data);
|
|
902
|
+
const keys = Object.keys(data);
|
|
903
|
+
const dataValues = Object.values(data);
|
|
904
|
+
const placeholders = [];
|
|
905
|
+
const values = [];
|
|
906
|
+
let paramIdx = 1;
|
|
907
|
+
for (const value of dataValues) if (value === null) placeholders.push("NULL");
|
|
908
|
+
else if (value === "" && typeof value === "string") {
|
|
909
|
+
placeholders.push(`CAST($${paramIdx} AS TEXT)`);
|
|
910
|
+
values.push(value);
|
|
911
|
+
paramIdx++;
|
|
912
|
+
} else if (value instanceof Date) {
|
|
913
|
+
placeholders.push(`CAST($${paramIdx} AS TIMESTAMP)`);
|
|
914
|
+
values.push(value.toISOString());
|
|
915
|
+
paramIdx++;
|
|
916
|
+
} else if (Array.isArray(value)) {
|
|
917
|
+
placeholders.push(`CAST($${paramIdx} AS JSON)`);
|
|
918
|
+
values.push(JSON.stringify(value));
|
|
919
|
+
paramIdx++;
|
|
920
|
+
} else if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
|
|
921
|
+
placeholders.push(`CAST($${paramIdx} AS JSON)`);
|
|
922
|
+
values.push(JSON.stringify(value));
|
|
923
|
+
paramIdx++;
|
|
924
|
+
} else {
|
|
925
|
+
placeholders.push(`$${paramIdx}`);
|
|
926
|
+
values.push(value);
|
|
927
|
+
paramIdx++;
|
|
928
|
+
}
|
|
929
|
+
const updateSetParts = [];
|
|
930
|
+
for (let i = 0; i < keys.length; i++) {
|
|
931
|
+
const key = keys[i];
|
|
932
|
+
const value = dataValues[i];
|
|
933
|
+
if (value === null) updateSetParts.push(`${key} = NULL`);
|
|
934
|
+
else if (value === "" && typeof value === "string") {
|
|
935
|
+
updateSetParts.push(`${key} = CAST($${paramIdx} AS TEXT)`);
|
|
936
|
+
values.push(value);
|
|
937
|
+
paramIdx++;
|
|
938
|
+
} else if (value instanceof Date) {
|
|
939
|
+
updateSetParts.push(`${key} = CAST($${paramIdx} AS TIMESTAMP)`);
|
|
940
|
+
values.push(value.toISOString());
|
|
941
|
+
paramIdx++;
|
|
942
|
+
} else if (Array.isArray(value)) {
|
|
943
|
+
updateSetParts.push(`${key} = CAST($${paramIdx} AS JSON)`);
|
|
944
|
+
values.push(JSON.stringify(value));
|
|
945
|
+
paramIdx++;
|
|
946
|
+
} else if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
|
|
947
|
+
updateSetParts.push(`${key} = CAST($${paramIdx} AS JSON)`);
|
|
948
|
+
values.push(JSON.stringify(value));
|
|
949
|
+
paramIdx++;
|
|
950
|
+
} else {
|
|
951
|
+
updateSetParts.push(`${key} = $${paramIdx}`);
|
|
952
|
+
values.push(value);
|
|
953
|
+
paramIdx++;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const conflict = conflictColumns.map((col) => `"${col}"`).join(", ");
|
|
957
|
+
const quotedKeys = keys.map((key) => `"${key}"`).join(", ");
|
|
958
|
+
const quotedUpdateSetParts = keys.map((key, i) => {
|
|
959
|
+
const part = updateSetParts[i];
|
|
960
|
+
return `"${key}" = ${part.substring(part.indexOf("=") + 1).trim()}`;
|
|
961
|
+
});
|
|
962
|
+
const sql = `INSERT INTO "${table}" (${quotedKeys}) VALUES (${placeholders.join(", ")}) ON CONFLICT(${conflict}) DO UPDATE SET ${quotedUpdateSetParts.join(", ")}`;
|
|
963
|
+
try {
|
|
964
|
+
await connection.run(sql, values);
|
|
965
|
+
if (writeStrategy === "immediate") await exportTableToJSON(connection, table, url);
|
|
966
|
+
return {
|
|
967
|
+
operation: "upsert",
|
|
968
|
+
affected: 1
|
|
969
|
+
};
|
|
970
|
+
} catch (e) {
|
|
971
|
+
console.error("UPSERT failed:", {
|
|
972
|
+
table,
|
|
973
|
+
sql,
|
|
974
|
+
values,
|
|
975
|
+
valueTypes: values.map((v) => `${typeof v} (${v})`),
|
|
976
|
+
conflictColumns,
|
|
977
|
+
error: formatDbError(e)
|
|
978
|
+
});
|
|
979
|
+
throw new DatabaseError("Failed to upsert record into table", {
|
|
980
|
+
table,
|
|
981
|
+
sql,
|
|
982
|
+
values,
|
|
983
|
+
conflictColumns,
|
|
984
|
+
originalError: formatDbError(e)
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
/**
|
|
989
|
+
* Gets a record matching the where criteria or inserts it if not found
|
|
990
|
+
*
|
|
991
|
+
* @param table - Table name
|
|
992
|
+
* @param where - Criteria to match existing record
|
|
993
|
+
* @param data - Data to insert if no record found
|
|
994
|
+
* @returns Promise resolving to the record (either retrieved or newly inserted)
|
|
995
|
+
* @throws Error if the operation fails
|
|
996
|
+
*/
|
|
997
|
+
const getOrInsert = async (table, where, data) => {
|
|
998
|
+
const result = await get(table, where);
|
|
999
|
+
if (result) return result;
|
|
1000
|
+
await insert(table, data);
|
|
1001
|
+
const inserted = await get(table, where);
|
|
1002
|
+
if (!inserted) throw new DatabaseError("Failed to insert and retrieve record", {
|
|
1003
|
+
table,
|
|
1004
|
+
where,
|
|
1005
|
+
data
|
|
1006
|
+
});
|
|
1007
|
+
return inserted;
|
|
1008
|
+
};
|
|
1009
|
+
/**
|
|
1010
|
+
* Deletes records from a table matching the where criteria
|
|
1011
|
+
*
|
|
1012
|
+
* @param table - Table name
|
|
1013
|
+
* @param where - Criteria to match records for deletion
|
|
1014
|
+
* @returns Promise resolving to operation result with count of deleted rows
|
|
1015
|
+
* @throws Error if the delete operation fails
|
|
1016
|
+
*/
|
|
1017
|
+
const deleteRecords = async (table, where) => {
|
|
1018
|
+
validateTableName(table);
|
|
1019
|
+
if (writeStrategy === "none") throw new DatabaseError("Cannot delete: write strategy is set to none (read-only mode)", {
|
|
1020
|
+
table,
|
|
1021
|
+
writeStrategy
|
|
1022
|
+
});
|
|
1023
|
+
if (Object.keys(where).length === 0) throw new DatabaseError("DELETE requires at least one WHERE condition to prevent accidental deletion of all records", { table });
|
|
1024
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "json");
|
|
1025
|
+
const sql = `DELETE FROM "${table}" ${whereClause}`;
|
|
1026
|
+
try {
|
|
1027
|
+
await connection.run(sql, values);
|
|
1028
|
+
if (writeStrategy === "immediate") await exportTableToJSON(connection, table, url);
|
|
1029
|
+
return {
|
|
1030
|
+
operation: "delete",
|
|
1031
|
+
affected: 1
|
|
1032
|
+
};
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
throw new DatabaseError("Failed to delete records from table", {
|
|
1035
|
+
table,
|
|
1036
|
+
sql,
|
|
1037
|
+
values,
|
|
1038
|
+
originalError: formatDbError(e)
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
/**
|
|
1043
|
+
* Counts records in a table matching the where criteria
|
|
1044
|
+
*
|
|
1045
|
+
* @param table - Table name
|
|
1046
|
+
* @param where - Criteria to match records (optional, counts all if omitted)
|
|
1047
|
+
* @returns Promise resolving to count of matching records
|
|
1048
|
+
* @throws Error if the count operation fails
|
|
1049
|
+
*/
|
|
1050
|
+
const count = async (table, where) => {
|
|
1051
|
+
validateTableName(table);
|
|
1052
|
+
try {
|
|
1053
|
+
if (!where || Object.keys(where).length === 0) {
|
|
1054
|
+
const rows = (await connection.runAndReadAll(`SELECT COUNT(*) as count FROM "${table}"`)).getRowObjects();
|
|
1055
|
+
return Number(rows[0]?.count) || 0;
|
|
1056
|
+
}
|
|
1057
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "json");
|
|
1058
|
+
const sql = `SELECT COUNT(*) as count FROM "${table}" ${whereClause}`;
|
|
1059
|
+
const rows = (await connection.runAndReadAll(sql, values)).getRowObjects();
|
|
1060
|
+
return Number(rows[0]?.count) || 0;
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
throw new DatabaseError("Failed to count records in table", {
|
|
1063
|
+
table,
|
|
1064
|
+
where,
|
|
1065
|
+
originalError: formatDbError(e)
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
/**
|
|
1070
|
+
* Checks if a table exists in the database
|
|
1071
|
+
*
|
|
1072
|
+
* @param tableName - Name of the table to check
|
|
1073
|
+
* @returns Promise resolving to boolean indicating if the table exists
|
|
1074
|
+
*/
|
|
1075
|
+
const tableExists = async (tableName) => {
|
|
1076
|
+
try {
|
|
1077
|
+
await connection.runAndReadAll(`SELECT * FROM ${tableName} LIMIT 1`);
|
|
1078
|
+
return true;
|
|
1079
|
+
} catch {
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
/**
|
|
1084
|
+
* Exports a table to JSON and schema files
|
|
1085
|
+
*
|
|
1086
|
+
* Exports two files:
|
|
1087
|
+
* 1. {table}.json - Table data with columns cast appropriately
|
|
1088
|
+
* 2. {table}.schema.sql - CREATE TABLE statement to preserve constraints
|
|
1089
|
+
*
|
|
1090
|
+
* Note: JSON columns are NOT cast to TEXT to preserve object structure.
|
|
1091
|
+
* Other columns are cast to TEXT to prevent DuckDB from converting UUIDs
|
|
1092
|
+
* and other TEXT values to hugeint representation in JSON output.
|
|
1093
|
+
*
|
|
1094
|
+
* @param connection - DuckDB connection
|
|
1095
|
+
* @param table - Table name
|
|
1096
|
+
* @param dataDir - Directory to write files
|
|
1097
|
+
*/
|
|
1098
|
+
const exportTableToJSON = async (connection, table, dataDir) => {
|
|
1099
|
+
const filePath = join(dataDir, `${table}.json`);
|
|
1100
|
+
const schemaPath = join(dataDir, `${table}.schema.sql`);
|
|
1101
|
+
try {
|
|
1102
|
+
const rows = (await connection.runAndReadAll(`SELECT sql FROM sqlite_master WHERE type='table' AND name='${table}'`)).getRowObjects();
|
|
1103
|
+
if (rows.length > 0 && rows[0].sql) {
|
|
1104
|
+
const { writeFile } = await import("node:fs/promises");
|
|
1105
|
+
await writeFile(schemaPath, rows[0].sql, "utf-8");
|
|
1106
|
+
}
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
console.warn(`[json-adapter] Could not export schema for ${table}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1109
|
+
}
|
|
1110
|
+
const columnInfo = (await connection.runAndReadAll(`SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${table}' ORDER BY ordinal_position`)).getRowObjects();
|
|
1111
|
+
if (columnInfo.length === 0) {
|
|
1112
|
+
await connection.run(`COPY (SELECT * FROM "${table}") TO '${filePath}' (FORMAT JSON, ARRAY true)`);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const sql = `COPY (SELECT ${columnInfo.map(({ column_name, data_type }) => {
|
|
1116
|
+
const upperType = data_type.toUpperCase();
|
|
1117
|
+
if (upperType === "JSON" || upperType.startsWith("STRUCT")) return `"${column_name}"`;
|
|
1118
|
+
if ([
|
|
1119
|
+
"DOUBLE",
|
|
1120
|
+
"REAL",
|
|
1121
|
+
"FLOAT",
|
|
1122
|
+
"INTEGER",
|
|
1123
|
+
"BIGINT",
|
|
1124
|
+
"SMALLINT",
|
|
1125
|
+
"TINYINT",
|
|
1126
|
+
"DECIMAL",
|
|
1127
|
+
"NUMERIC",
|
|
1128
|
+
"HUGEINT",
|
|
1129
|
+
"UBIGINT",
|
|
1130
|
+
"UINTEGER",
|
|
1131
|
+
"USMALLINT",
|
|
1132
|
+
"UTINYINT"
|
|
1133
|
+
].some((t) => upperType.includes(t))) return `"${column_name}"`;
|
|
1134
|
+
if (upperType === "BOOLEAN" || upperType === "BOOL") return `"${column_name}"`;
|
|
1135
|
+
return `CAST("${column_name}" AS TEXT) AS "${column_name}"`;
|
|
1136
|
+
}).join(", ")} FROM "${table}") TO '${filePath}' (FORMAT JSON, ARRAY true)`;
|
|
1137
|
+
await connection.run(sql);
|
|
1138
|
+
};
|
|
1139
|
+
/**
|
|
1140
|
+
* Manual export method for 'manual' write strategy
|
|
1141
|
+
*
|
|
1142
|
+
* Allows explicit control over when tables are written back to JSON files.
|
|
1143
|
+
*
|
|
1144
|
+
* @param table - Table name to export
|
|
1145
|
+
* @returns Promise that resolves when export completes
|
|
1146
|
+
*/
|
|
1147
|
+
const exportTable = async (table) => {
|
|
1148
|
+
if (writeStrategy === "none") throw new DatabaseError("Cannot export table: write strategy is set to none (read-only mode)", {
|
|
1149
|
+
table,
|
|
1150
|
+
writeStrategy
|
|
1151
|
+
});
|
|
1152
|
+
await exportTableToJSON(connection, table, url);
|
|
1153
|
+
};
|
|
1154
|
+
/**
|
|
1155
|
+
* Creates a table by inferring schema from a JSON file
|
|
1156
|
+
*
|
|
1157
|
+
* This utility is for users who want automatic schema inference from JSON.
|
|
1158
|
+
* Note: Inferred schemas do NOT include UNIQUE constraints, so UPSERT
|
|
1159
|
+
* operations will fail. Use syncSchema() with explicit DDL for UPSERT support.
|
|
1160
|
+
*
|
|
1161
|
+
* @param tableName - Name of the table to create
|
|
1162
|
+
* @param jsonPath - Optional path to JSON file (defaults to {dataDir}/{tableName}.json)
|
|
1163
|
+
* @returns Promise that resolves when table is created
|
|
1164
|
+
* @throws Error if JSON file doesn't exist or table creation fails
|
|
1165
|
+
*/
|
|
1166
|
+
const inferSchemaFromJSON = async (tableName, jsonPath) => {
|
|
1167
|
+
validateTableName(tableName);
|
|
1168
|
+
const filePath = jsonPath || join(url, `${tableName}.json`);
|
|
1169
|
+
try {
|
|
1170
|
+
const { access } = await import("node:fs/promises");
|
|
1171
|
+
const { constants } = await import("node:fs");
|
|
1172
|
+
await access(filePath, constants.F_OK);
|
|
1173
|
+
console.log(`[json-adapter] Inferring schema for ${tableName} from JSON file`);
|
|
1174
|
+
await connection.run(`CREATE TABLE "${tableName}" AS SELECT * FROM read_json_auto('${filePath}')`);
|
|
1175
|
+
pendingJSONFiles.delete(tableName);
|
|
1176
|
+
console.log(`[json-adapter] Created ${tableName} with inferred schema (no UNIQUE constraints)`);
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
throw new DatabaseError(`Failed to infer schema from JSON for table ${tableName}`, {
|
|
1179
|
+
tableName,
|
|
1180
|
+
jsonPath: filePath,
|
|
1181
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
/**
|
|
1186
|
+
* Creates a table-specific interface for simplified table operations
|
|
1187
|
+
*
|
|
1188
|
+
* @param tableName - Table name
|
|
1189
|
+
* @returns TableInterface for the specified table
|
|
1190
|
+
*/
|
|
1191
|
+
const table = (tableName) => ({
|
|
1192
|
+
insert: (data) => insert(tableName, data),
|
|
1193
|
+
get: (where) => get(tableName, where),
|
|
1194
|
+
list: (where) => list(tableName, where)
|
|
1195
|
+
});
|
|
1196
|
+
/**
|
|
1197
|
+
* Parses a tagged template literal into a SQL query and values
|
|
1198
|
+
*
|
|
1199
|
+
* @param strings - Template strings
|
|
1200
|
+
* @param vars - Variables to interpolate into the query
|
|
1201
|
+
* @returns Object with SQL query and values array
|
|
1202
|
+
*/
|
|
1203
|
+
const parseTemplate = (strings, ...vars) => {
|
|
1204
|
+
let sql = strings[0];
|
|
1205
|
+
const values = [];
|
|
1206
|
+
for (let i = 0; i < vars.length; i++) {
|
|
1207
|
+
values.push(vars[i]);
|
|
1208
|
+
sql += `$${i + 1}${strings[i + 1]}`;
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
sql,
|
|
1212
|
+
values
|
|
1213
|
+
};
|
|
1214
|
+
};
|
|
1215
|
+
/**
|
|
1216
|
+
* Executes a SQL query using template literals and returns multiple rows
|
|
1217
|
+
*
|
|
1218
|
+
* @param strings - Template strings
|
|
1219
|
+
* @param vars - Variables to interpolate into the query
|
|
1220
|
+
* @returns Promise resolving to array of result records
|
|
1221
|
+
* @throws Error if the query fails
|
|
1222
|
+
*/
|
|
1223
|
+
const many = async (strings, ...vars) => {
|
|
1224
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
1225
|
+
try {
|
|
1226
|
+
return convertBigInts((await connection.runAndReadAll(sql, values)).getRowObjects());
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
throw new DatabaseError("Failed to execute many query", {
|
|
1229
|
+
sql,
|
|
1230
|
+
values,
|
|
1231
|
+
originalError: formatDbError(e)
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
/**
|
|
1236
|
+
* Executes a SQL query using template literals and returns a single row
|
|
1237
|
+
*
|
|
1238
|
+
* @param strings - Template strings
|
|
1239
|
+
* @param vars - Variables to interpolate into the query
|
|
1240
|
+
* @returns Promise resolving to a single result record or null
|
|
1241
|
+
* @throws Error if the query fails
|
|
1242
|
+
*/
|
|
1243
|
+
const single = async (strings, ...vars) => {
|
|
1244
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
1245
|
+
try {
|
|
1246
|
+
const rows = (await connection.runAndReadAll(sql, values)).getRowObjects();
|
|
1247
|
+
return rows[0] ? convertBigInts(rows[0]) : null;
|
|
1248
|
+
} catch (e) {
|
|
1249
|
+
throw new DatabaseError("Failed to execute single query", {
|
|
1250
|
+
sql,
|
|
1251
|
+
values,
|
|
1252
|
+
originalError: formatDbError(e)
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
/**
|
|
1257
|
+
* Executes a SQL query using template literals and returns a single value
|
|
1258
|
+
*
|
|
1259
|
+
* @param strings - Template strings
|
|
1260
|
+
* @param vars - Variables to interpolate into the query
|
|
1261
|
+
* @returns Promise resolving to a single value (first column of first row)
|
|
1262
|
+
* @throws Error if the query fails
|
|
1263
|
+
*/
|
|
1264
|
+
const pluck = async (strings, ...vars) => {
|
|
1265
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
1266
|
+
try {
|
|
1267
|
+
const rows = (await connection.runAndReadAll(sql, values)).getRowObjects();
|
|
1268
|
+
if (rows.length === 0) return null;
|
|
1269
|
+
const firstRow = rows[0];
|
|
1270
|
+
const firstKey = Object.keys(firstRow)[0];
|
|
1271
|
+
return convertBigInts(firstRow[firstKey]);
|
|
1272
|
+
} catch (e) {
|
|
1273
|
+
throw new DatabaseError("Failed to execute pluck query", {
|
|
1274
|
+
sql,
|
|
1275
|
+
values,
|
|
1276
|
+
originalError: formatDbError(e)
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
/**
|
|
1281
|
+
* Executes a SQL query using template literals without returning results
|
|
1282
|
+
*
|
|
1283
|
+
* @param strings - Template strings
|
|
1284
|
+
* @param vars - Variables to interpolate into the query
|
|
1285
|
+
* @returns Promise that resolves when the query completes
|
|
1286
|
+
* @throws Error if the query fails
|
|
1287
|
+
*/
|
|
1288
|
+
const execute = async (strings, ...vars) => {
|
|
1289
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
1290
|
+
try {
|
|
1291
|
+
await connection.run(sql, values);
|
|
1292
|
+
const createTableMatch = sql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i);
|
|
1293
|
+
if (createTableMatch) {
|
|
1294
|
+
const tableName = createTableMatch[1];
|
|
1295
|
+
const pendingFile = pendingJSONFiles.get(tableName);
|
|
1296
|
+
if (pendingFile) {
|
|
1297
|
+
await loadJSONData(connection, tableName, pendingFile);
|
|
1298
|
+
pendingJSONFiles.delete(tableName);
|
|
1299
|
+
} else if (writeStrategy !== "none") try {
|
|
1300
|
+
const { access } = await import("node:fs/promises");
|
|
1301
|
+
const jsonFilePath = join(url, `${tableName}.json`);
|
|
1302
|
+
try {
|
|
1303
|
+
await access(jsonFilePath);
|
|
1304
|
+
} catch {
|
|
1305
|
+
await exportTableToJSON(connection, tableName, url);
|
|
1306
|
+
}
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
console.warn(`[json-adapter] Could not export newly created table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
throw new DatabaseError("Failed to execute query", {
|
|
1313
|
+
sql,
|
|
1314
|
+
values,
|
|
1315
|
+
originalError: formatDbError(e)
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
/**
|
|
1320
|
+
* Executes a raw SQL query with parameterized values
|
|
1321
|
+
*
|
|
1322
|
+
* @param str - SQL query string
|
|
1323
|
+
* @param values - Variables to use as parameters
|
|
1324
|
+
* @returns Promise resolving to query result with rows and metadata
|
|
1325
|
+
* @throws Error if the query fails
|
|
1326
|
+
*/
|
|
1327
|
+
const query = async (str, ...values) => {
|
|
1328
|
+
const sql = str;
|
|
1329
|
+
const args = Array.isArray(values[0]) ? values[0] : values;
|
|
1330
|
+
try {
|
|
1331
|
+
const rows = convertBigInts((await connection.runAndReadAll(sql, args)).getRowObjects());
|
|
1332
|
+
return {
|
|
1333
|
+
command: sql.split(" ")[0].toUpperCase(),
|
|
1334
|
+
rowCount: rows.length,
|
|
1335
|
+
oid: null,
|
|
1336
|
+
fields: rows.length > 0 ? Object.keys(rows[0]).map((name) => ({
|
|
1337
|
+
name,
|
|
1338
|
+
tableID: 0,
|
|
1339
|
+
columnID: 0,
|
|
1340
|
+
dataTypeID: 0,
|
|
1341
|
+
dataTypeSize: -1,
|
|
1342
|
+
dataTypeModifier: -1,
|
|
1343
|
+
format: "text"
|
|
1344
|
+
})) : [],
|
|
1345
|
+
rows
|
|
1346
|
+
};
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
throw new DatabaseError("Failed to execute raw query", {
|
|
1349
|
+
sql,
|
|
1350
|
+
args,
|
|
1351
|
+
originalError: formatDbError(e)
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
const oo = many;
|
|
1356
|
+
const oO = single;
|
|
1357
|
+
const ox = pluck;
|
|
1358
|
+
const xx = execute;
|
|
1359
|
+
/**
|
|
1360
|
+
* Synchronizes database schema with provided SQL DDL
|
|
1361
|
+
*
|
|
1362
|
+
* Filters out CREATE TRIGGER statements since triggers are not supported
|
|
1363
|
+
* in the JSON adapter (timestamps managed at application level).
|
|
1364
|
+
*
|
|
1365
|
+
* @param schema - SQL schema definition with CREATE TABLE statements
|
|
1366
|
+
* @returns Promise that resolves when schema is synchronized
|
|
1367
|
+
*/
|
|
1368
|
+
const syncSchema = async (schema) => {
|
|
1369
|
+
const statements = schema.trim().split(";").filter((stmt) => stmt.trim() !== "");
|
|
1370
|
+
const createTableStatements = [];
|
|
1371
|
+
const indexStatements = [];
|
|
1372
|
+
const otherStatements = [];
|
|
1373
|
+
for (const stmt of statements) {
|
|
1374
|
+
const trimmed = stmt.trim();
|
|
1375
|
+
if (!trimmed) continue;
|
|
1376
|
+
const upperTrimmed = trimmed.toUpperCase();
|
|
1377
|
+
if (upperTrimmed.startsWith("CREATE TRIGGER")) {
|
|
1378
|
+
console.warn("[json-adapter] Skipping trigger creation - timestamps managed at application level");
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (upperTrimmed.startsWith("CREATE INDEX") || upperTrimmed.startsWith("CREATE UNIQUE INDEX")) indexStatements.push(trimmed);
|
|
1382
|
+
else if (upperTrimmed.startsWith("CREATE TABLE")) createTableStatements.push(trimmed);
|
|
1383
|
+
else otherStatements.push(trimmed);
|
|
1384
|
+
}
|
|
1385
|
+
for (const ddl of createTableStatements) {
|
|
1386
|
+
const { ddl: transformedDDL, indexes: remainingIndexes } = convertUniqueIndexesToInlineConstraints(ddl, indexStatements);
|
|
1387
|
+
const createTableMatch = transformedDDL.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i);
|
|
1388
|
+
const tableName = createTableMatch ? createTableMatch[1] : null;
|
|
1389
|
+
if (tableName && inferredSchemaTables.has(tableName)) try {
|
|
1390
|
+
await connection.run(`DROP TABLE IF EXISTS "${tableName}"`);
|
|
1391
|
+
inferredSchemaTables.delete(tableName);
|
|
1392
|
+
console.log(`[json-adapter] Dropped inferred-schema table ${tableName} for schema upgrade`);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
console.warn(`[json-adapter] Could not drop inferred-schema table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1395
|
+
}
|
|
1396
|
+
try {
|
|
1397
|
+
await connection.run(transformedDDL);
|
|
1398
|
+
if (tableName) {
|
|
1399
|
+
const pendingFile = pendingJSONFiles.get(tableName);
|
|
1400
|
+
if (pendingFile) {
|
|
1401
|
+
await loadJSONData(connection, tableName, pendingFile);
|
|
1402
|
+
pendingJSONFiles.delete(tableName);
|
|
1403
|
+
} else if (writeStrategy !== "none") try {
|
|
1404
|
+
const { access } = await import("node:fs/promises");
|
|
1405
|
+
const jsonFilePath = join(url, `${tableName}.json`);
|
|
1406
|
+
try {
|
|
1407
|
+
await access(jsonFilePath);
|
|
1408
|
+
} catch {
|
|
1409
|
+
await exportTableToJSON(connection, tableName, url);
|
|
1410
|
+
}
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
console.warn(`[json-adapter] Could not export newly created table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
} catch (e) {
|
|
1416
|
+
console.error("Schema sync error (CREATE TABLE):", e);
|
|
1417
|
+
}
|
|
1418
|
+
for (const indexSQL of remainingIndexes) try {
|
|
1419
|
+
await connection.run(indexSQL);
|
|
1420
|
+
} catch (e) {
|
|
1421
|
+
console.error("Schema sync error (CREATE INDEX):", e);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
for (const stmt of otherStatements) try {
|
|
1425
|
+
await connection.run(stmt);
|
|
1426
|
+
} catch (e) {
|
|
1427
|
+
console.error("Schema sync error:", e);
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
/**
|
|
1431
|
+
* Initialize database schemas from JSON manifest
|
|
1432
|
+
* Supports dependency resolution and schema overrides
|
|
1433
|
+
*
|
|
1434
|
+
* @param options - Schema initialization options
|
|
1435
|
+
* @returns Promise that resolves when schemas are initialized
|
|
1436
|
+
*/
|
|
1437
|
+
const initializeSchemas = async (options) => {
|
|
1438
|
+
const schemaManager = new DatabaseSchemaManager();
|
|
1439
|
+
const currentDb = {
|
|
1440
|
+
url,
|
|
1441
|
+
client: connection,
|
|
1442
|
+
query,
|
|
1443
|
+
insert,
|
|
1444
|
+
update,
|
|
1445
|
+
upsert,
|
|
1446
|
+
get,
|
|
1447
|
+
list,
|
|
1448
|
+
getOrInsert,
|
|
1449
|
+
delete: deleteRecords,
|
|
1450
|
+
count,
|
|
1451
|
+
table,
|
|
1452
|
+
tableExists,
|
|
1453
|
+
many,
|
|
1454
|
+
single,
|
|
1455
|
+
pluck,
|
|
1456
|
+
execute,
|
|
1457
|
+
oo,
|
|
1458
|
+
oO,
|
|
1459
|
+
ox,
|
|
1460
|
+
xx,
|
|
1461
|
+
syncSchema
|
|
1462
|
+
};
|
|
1463
|
+
await schemaManager.initializeSchemas(currentDb, options);
|
|
1464
|
+
};
|
|
1465
|
+
/**
|
|
1466
|
+
* Executes a callback within a database transaction
|
|
1467
|
+
* Automatically commits on success or rolls back on error
|
|
1468
|
+
*
|
|
1469
|
+
* @param callback - Function to execute within transaction
|
|
1470
|
+
* @returns Promise resolving to callback result
|
|
1471
|
+
*/
|
|
1472
|
+
const transaction = async (callback) => {
|
|
1473
|
+
try {
|
|
1474
|
+
await connection.run("BEGIN TRANSACTION");
|
|
1475
|
+
const result = await callback({
|
|
1476
|
+
url,
|
|
1477
|
+
client: connection,
|
|
1478
|
+
insert,
|
|
1479
|
+
get,
|
|
1480
|
+
list,
|
|
1481
|
+
update,
|
|
1482
|
+
upsert,
|
|
1483
|
+
getOrInsert,
|
|
1484
|
+
delete: deleteRecords,
|
|
1485
|
+
count,
|
|
1486
|
+
table,
|
|
1487
|
+
many,
|
|
1488
|
+
single,
|
|
1489
|
+
pluck,
|
|
1490
|
+
execute,
|
|
1491
|
+
query,
|
|
1492
|
+
oo,
|
|
1493
|
+
oO,
|
|
1494
|
+
ox,
|
|
1495
|
+
xx,
|
|
1496
|
+
tableExists,
|
|
1497
|
+
syncSchema,
|
|
1498
|
+
transaction
|
|
1499
|
+
});
|
|
1500
|
+
await connection.run("COMMIT");
|
|
1501
|
+
return result;
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
await connection.run("ROLLBACK");
|
|
1504
|
+
throw error;
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
/**
|
|
1508
|
+
* Begins a new transaction and returns a handle for manual control
|
|
1509
|
+
*
|
|
1510
|
+
* Unlike transaction(), this gives you explicit control over commit/rollback.
|
|
1511
|
+
* Ideal for test isolation where you want to rollback after each test.
|
|
1512
|
+
*
|
|
1513
|
+
* @returns Promise resolving to a TransactionHandle
|
|
1514
|
+
*/
|
|
1515
|
+
const beginTransaction = async () => {
|
|
1516
|
+
await connection.run("BEGIN TRANSACTION");
|
|
1517
|
+
let active = true;
|
|
1518
|
+
const commit = async () => {
|
|
1519
|
+
if (!active) throw new DatabaseError("Transaction already ended", {});
|
|
1520
|
+
await connection.run("COMMIT");
|
|
1521
|
+
active = false;
|
|
1522
|
+
};
|
|
1523
|
+
const rollback = async () => {
|
|
1524
|
+
if (!active) throw new DatabaseError("Transaction already ended", {});
|
|
1525
|
+
await connection.run("ROLLBACK");
|
|
1526
|
+
active = false;
|
|
1527
|
+
};
|
|
1528
|
+
const isActive = () => active;
|
|
1529
|
+
return {
|
|
1530
|
+
url,
|
|
1531
|
+
client: connection,
|
|
1532
|
+
insert,
|
|
1533
|
+
get,
|
|
1534
|
+
list,
|
|
1535
|
+
update,
|
|
1536
|
+
upsert,
|
|
1537
|
+
getOrInsert,
|
|
1538
|
+
delete: deleteRecords,
|
|
1539
|
+
count,
|
|
1540
|
+
table,
|
|
1541
|
+
many,
|
|
1542
|
+
single,
|
|
1543
|
+
pluck,
|
|
1544
|
+
execute,
|
|
1545
|
+
query,
|
|
1546
|
+
oo,
|
|
1547
|
+
oO,
|
|
1548
|
+
ox,
|
|
1549
|
+
xx,
|
|
1550
|
+
tableExists,
|
|
1551
|
+
syncSchema,
|
|
1552
|
+
transaction,
|
|
1553
|
+
commit,
|
|
1554
|
+
rollback,
|
|
1555
|
+
isActive
|
|
1556
|
+
};
|
|
1557
|
+
};
|
|
1558
|
+
return {
|
|
1559
|
+
url,
|
|
1560
|
+
requiresSchemaCheck: true,
|
|
1561
|
+
client: connection,
|
|
1562
|
+
query,
|
|
1563
|
+
insert,
|
|
1564
|
+
update,
|
|
1565
|
+
upsert,
|
|
1566
|
+
get,
|
|
1567
|
+
list,
|
|
1568
|
+
getOrInsert,
|
|
1569
|
+
delete: deleteRecords,
|
|
1570
|
+
count,
|
|
1571
|
+
table,
|
|
1572
|
+
tableExists,
|
|
1573
|
+
many,
|
|
1574
|
+
single,
|
|
1575
|
+
pluck,
|
|
1576
|
+
execute,
|
|
1577
|
+
oo,
|
|
1578
|
+
oO,
|
|
1579
|
+
ox,
|
|
1580
|
+
xx,
|
|
1581
|
+
syncSchema,
|
|
1582
|
+
initializeSchemas,
|
|
1583
|
+
transaction,
|
|
1584
|
+
beginTransaction,
|
|
1585
|
+
exportTable,
|
|
1586
|
+
inferSchemaFromJSON
|
|
1587
|
+
};
|
|
1588
|
+
})();
|
|
1589
|
+
pendingConnections$1.set(cacheKey, connectionPromise);
|
|
1590
|
+
try {
|
|
1591
|
+
const db = await connectionPromise;
|
|
1592
|
+
memoryConnectionCache.set(cacheKey, db);
|
|
1593
|
+
return db;
|
|
1594
|
+
} finally {
|
|
1595
|
+
pendingConnections$1.delete(cacheKey);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
//#endregion
|
|
1599
|
+
//#region src/postgres.ts
|
|
1600
|
+
var postgres_exports = /* @__PURE__ */ __exportAll({
|
|
1601
|
+
clearPostgresConnectionCache: () => clearPostgresConnectionCache,
|
|
1602
|
+
getDatabase: () => getDatabase$1
|
|
1603
|
+
});
|
|
1604
|
+
/**
|
|
1605
|
+
* Module-level cache for PostgreSQL connections.
|
|
1606
|
+
* Keyed by dbid (or derived from connection URL).
|
|
1607
|
+
* Prevents creating multiple pg.Pool instances for the same database.
|
|
1608
|
+
*/
|
|
1609
|
+
var connectionCache = /* @__PURE__ */ new Map();
|
|
1610
|
+
var pendingConnections = /* @__PURE__ */ new Map();
|
|
1611
|
+
/**
|
|
1612
|
+
* Clears the PostgreSQL connection cache.
|
|
1613
|
+
* Useful for test isolation and reconnection scenarios.
|
|
1614
|
+
*/
|
|
1615
|
+
function clearPostgresConnectionCache() {
|
|
1616
|
+
connectionCache.clear();
|
|
1617
|
+
pendingConnections.clear();
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Operator for the given distance metric in pgvector
|
|
1621
|
+
*/
|
|
1622
|
+
function vectorOperator(metric) {
|
|
1623
|
+
switch (metric) {
|
|
1624
|
+
case "cosine": return "<=>";
|
|
1625
|
+
case "l2": return "<->";
|
|
1626
|
+
case "ip": return "<#>";
|
|
1627
|
+
default: return "<=>";
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
function getMaxPostgresParameterIndex(sql) {
|
|
1631
|
+
let maxIndex = 0;
|
|
1632
|
+
for (const match of sql.matchAll(/\$(\d+)/g)) {
|
|
1633
|
+
const index = Number(match[1]);
|
|
1634
|
+
if (Number.isSafeInteger(index) && index > maxIndex) maxIndex = index;
|
|
1635
|
+
}
|
|
1636
|
+
return maxIndex;
|
|
1637
|
+
}
|
|
1638
|
+
function usesSingleArrayParameter(sql) {
|
|
1639
|
+
const identifier = String.raw`(?:"[^"]+"|[a-z_][\w$]*)`;
|
|
1640
|
+
const qualifiedIdentifier = String.raw`${identifier}(?:\s*\.\s*${identifier})?`;
|
|
1641
|
+
const arrayType = String.raw`${qualifiedIdentifier}(?:\s*\([^)]*\))?\s*\[\]`;
|
|
1642
|
+
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);
|
|
1643
|
+
}
|
|
1644
|
+
function normalizeRawQueryValues(sql, values) {
|
|
1645
|
+
if (values.length !== 1 || !Array.isArray(values[0])) return values;
|
|
1646
|
+
const valuesArray = values[0];
|
|
1647
|
+
const maxParameterIndex = getMaxPostgresParameterIndex(sql);
|
|
1648
|
+
if (maxParameterIndex === 1 && usesSingleArrayParameter(sql)) return values;
|
|
1649
|
+
if (maxParameterIndex === 0 || maxParameterIndex === valuesArray.length) return valuesArray;
|
|
1650
|
+
return values;
|
|
1651
|
+
}
|
|
1652
|
+
function rewriteQuestionMarkPlaceholders(sql) {
|
|
1653
|
+
let output = "";
|
|
1654
|
+
let count = 0;
|
|
1655
|
+
let index = 0;
|
|
1656
|
+
const copyQuoted = (quote, escaped = false) => {
|
|
1657
|
+
output += quote;
|
|
1658
|
+
index += 1;
|
|
1659
|
+
while (index < sql.length) {
|
|
1660
|
+
const char = sql[index];
|
|
1661
|
+
output += char;
|
|
1662
|
+
index += 1;
|
|
1663
|
+
if (escaped && char === "\\" && index < sql.length) {
|
|
1664
|
+
output += sql[index];
|
|
1665
|
+
index += 1;
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
if (char === quote) {
|
|
1669
|
+
if (sql[index] === quote) {
|
|
1670
|
+
output += sql[index];
|
|
1671
|
+
index += 1;
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
break;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
const copyUntil = (end) => {
|
|
1679
|
+
const endIndex = sql.indexOf(end, index + end.length);
|
|
1680
|
+
if (endIndex === -1) {
|
|
1681
|
+
output += sql.slice(index);
|
|
1682
|
+
index = sql.length;
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
output += sql.slice(index, endIndex + end.length);
|
|
1686
|
+
index = endIndex + end.length;
|
|
1687
|
+
};
|
|
1688
|
+
while (index < sql.length) {
|
|
1689
|
+
const char = sql[index];
|
|
1690
|
+
const next = sql[index + 1];
|
|
1691
|
+
if ((char === "E" || char === "e") && next === "'") {
|
|
1692
|
+
output += char;
|
|
1693
|
+
index += 1;
|
|
1694
|
+
copyQuoted("'", true);
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (char === "'") {
|
|
1698
|
+
copyQuoted("'");
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (char === "\"") {
|
|
1702
|
+
copyQuoted("\"");
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
if (char === "-" && next === "-") {
|
|
1706
|
+
const endIndex = sql.indexOf("\n", index + 2);
|
|
1707
|
+
if (endIndex === -1) {
|
|
1708
|
+
output += sql.slice(index);
|
|
1709
|
+
index = sql.length;
|
|
1710
|
+
} else {
|
|
1711
|
+
output += sql.slice(index, endIndex + 1);
|
|
1712
|
+
index = endIndex + 1;
|
|
1713
|
+
}
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
if (char === "/" && next === "*") {
|
|
1717
|
+
copyUntil("*/");
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
if (char === "$") {
|
|
1721
|
+
const tag = sql.slice(index).match(/^\$[A-Za-z_][\w$]*\$|^\$\$/);
|
|
1722
|
+
if (tag) {
|
|
1723
|
+
copyUntil(tag[0]);
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (char === "?" && next !== "|" && next !== "&") {
|
|
1728
|
+
count += 1;
|
|
1729
|
+
output += `$${count}`;
|
|
1730
|
+
index += 1;
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
output += char;
|
|
1734
|
+
index += 1;
|
|
1735
|
+
}
|
|
1736
|
+
return {
|
|
1737
|
+
sql: output,
|
|
1738
|
+
count
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
function usesSingleQuestionArrayParameter(sql) {
|
|
1742
|
+
const identifier = String.raw`(?:"[^"]+"|[a-z_][\w$]*)`;
|
|
1743
|
+
const qualifiedIdentifier = String.raw`${identifier}(?:\s*\.\s*${identifier})?`;
|
|
1744
|
+
const arrayType = String.raw`${qualifiedIdentifier}(?:\s*\([^)]*\))?\s*\[\]`;
|
|
1745
|
+
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);
|
|
1746
|
+
}
|
|
1747
|
+
function normalizeQuestionMarkQueryValues(sql, values, placeholderCount) {
|
|
1748
|
+
if (values.length !== 1 || !Array.isArray(values[0])) return values;
|
|
1749
|
+
if (placeholderCount === 1 && usesSingleQuestionArrayParameter(sql)) return values;
|
|
1750
|
+
if (placeholderCount === values[0].length) return values[0];
|
|
1751
|
+
return values;
|
|
1752
|
+
}
|
|
1753
|
+
function normalizePostgresRawQuery(sql, values) {
|
|
1754
|
+
if (getMaxPostgresParameterIndex(sql) > 0) return {
|
|
1755
|
+
sql,
|
|
1756
|
+
values: normalizeRawQueryValues(sql, values)
|
|
1757
|
+
};
|
|
1758
|
+
const rewritten = rewriteQuestionMarkPlaceholders(sql);
|
|
1759
|
+
const placeholderCount = rewritten.count;
|
|
1760
|
+
const queryValues = normalizeQuestionMarkQueryValues(sql, values, placeholderCount);
|
|
1761
|
+
if (placeholderCount > 0 && placeholderCount === queryValues.length) return {
|
|
1762
|
+
sql: rewritten.sql,
|
|
1763
|
+
values: queryValues
|
|
1764
|
+
};
|
|
1765
|
+
return {
|
|
1766
|
+
sql,
|
|
1767
|
+
values: normalizeRawQueryValues(sql, values)
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* pgvector index operator class for CREATE INDEX
|
|
1772
|
+
*/
|
|
1773
|
+
function vectorOpsClass(metric) {
|
|
1774
|
+
switch (metric) {
|
|
1775
|
+
case "cosine": return "vector_cosine_ops";
|
|
1776
|
+
case "l2": return "vector_l2_ops";
|
|
1777
|
+
case "ip": return "vector_ip_ops";
|
|
1778
|
+
default: return "vector_cosine_ops";
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Convert a number[] to a pgvector literal string: '[0.1,0.2,0.3]'
|
|
1783
|
+
*/
|
|
1784
|
+
function toVectorLiteral(embedding) {
|
|
1785
|
+
if (embedding.length === 0) throw new DatabaseError("Vector embedding must not be empty", {});
|
|
1786
|
+
for (let i = 0; i < embedding.length; i++) if (!Number.isFinite(embedding[i])) throw new DatabaseError("Vector embedding contains invalid value (NaN or Infinity)", {
|
|
1787
|
+
index: i,
|
|
1788
|
+
value: embedding[i]
|
|
1789
|
+
});
|
|
1790
|
+
return `[${embedding.join(",")}]`;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Creates vector capabilities backed by pgvector for a PostgreSQL pool.
|
|
1794
|
+
*
|
|
1795
|
+
* @param pool - PostgreSQL connection pool
|
|
1796
|
+
* @returns VectorCapabilities implementation
|
|
1797
|
+
*/
|
|
1798
|
+
function createVectorCapabilities(pool) {
|
|
1799
|
+
return {
|
|
1800
|
+
async ensureColumn(table, column, dimensions) {
|
|
1801
|
+
if (!Number.isInteger(dimensions) || dimensions <= 0) throw new DatabaseError("Vector dimensions must be a positive integer", { dimensions });
|
|
1802
|
+
try {
|
|
1803
|
+
await pool.query("CREATE EXTENSION IF NOT EXISTS vector");
|
|
1804
|
+
await pool.query(`ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "${column}" vector(${dimensions})`);
|
|
1805
|
+
} catch (e) {
|
|
1806
|
+
throw new DatabaseError("Failed to ensure vector column", {
|
|
1807
|
+
table,
|
|
1808
|
+
column,
|
|
1809
|
+
dimensions,
|
|
1810
|
+
originalError: formatDbError(e)
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
},
|
|
1814
|
+
async ensureIndex(table, column, options) {
|
|
1815
|
+
const metric = options?.metric || "cosine";
|
|
1816
|
+
const indexType = options?.type || "hnsw";
|
|
1817
|
+
const opsClass = vectorOpsClass(metric);
|
|
1818
|
+
const indexName = `idx_${table}_${column}_${metric}_${indexType}`;
|
|
1819
|
+
try {
|
|
1820
|
+
await pool.query(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${table}" USING ${indexType} ("${column}" ${opsClass})`);
|
|
1821
|
+
} catch (e) {
|
|
1822
|
+
throw new DatabaseError("Failed to ensure vector index", {
|
|
1823
|
+
table,
|
|
1824
|
+
column,
|
|
1825
|
+
indexName,
|
|
1826
|
+
originalError: formatDbError(e)
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
},
|
|
1830
|
+
async upsertVector(table, where, column, embedding) {
|
|
1831
|
+
const keys = Object.keys(where);
|
|
1832
|
+
const values = Object.values(where);
|
|
1833
|
+
const conditions = keys.map((key, i) => `"${key}" IS NOT DISTINCT FROM $${i + 1}`).join(" AND ");
|
|
1834
|
+
const vectorParam = `$${values.length + 1}`;
|
|
1835
|
+
try {
|
|
1836
|
+
await pool.query(`UPDATE "${table}" SET "${column}" = ${vectorParam}::vector WHERE ${conditions}`, [...values, toVectorLiteral(embedding)]);
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
throw new DatabaseError("Failed to upsert vector", {
|
|
1839
|
+
table,
|
|
1840
|
+
column,
|
|
1841
|
+
where,
|
|
1842
|
+
originalError: formatDbError(e)
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
},
|
|
1846
|
+
async search(table, column, embedding, options) {
|
|
1847
|
+
const metric = options?.metric || "cosine";
|
|
1848
|
+
const limit = options?.limit || 10;
|
|
1849
|
+
const op = vectorOperator(metric);
|
|
1850
|
+
const queryParams = [toVectorLiteral(embedding)];
|
|
1851
|
+
let whereClause = `"${column}" IS NOT NULL`;
|
|
1852
|
+
if (options?.where) {
|
|
1853
|
+
whereClause += ` AND (${options.where})`;
|
|
1854
|
+
if (options.params) queryParams.push(...options.params);
|
|
1855
|
+
}
|
|
1856
|
+
const limitParam = `$${queryParams.length + 1}`;
|
|
1857
|
+
queryParams.push(limit);
|
|
1858
|
+
const sql = `SELECT *, ("${column}" ${op} $1::vector) AS distance FROM "${table}" WHERE ${whereClause} ORDER BY "${column}" ${op} $1::vector LIMIT ${limitParam}`;
|
|
1859
|
+
try {
|
|
1860
|
+
return (await pool.query(sql, queryParams)).rows.map((row) => ({
|
|
1861
|
+
...row,
|
|
1862
|
+
id: row.id,
|
|
1863
|
+
distance: Number.parseFloat(row.distance)
|
|
1864
|
+
}));
|
|
1865
|
+
} catch (e) {
|
|
1866
|
+
throw new DatabaseError("Failed to execute vector search", {
|
|
1867
|
+
table,
|
|
1868
|
+
column,
|
|
1869
|
+
metric,
|
|
1870
|
+
limit,
|
|
1871
|
+
originalError: formatDbError(e)
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Creates a PostgreSQL database adapter
|
|
1879
|
+
*
|
|
1880
|
+
* Loads configuration from environment variables with backward compatibility:
|
|
1881
|
+
* - First checks HAVE_SQL_* environment variables (new standard)
|
|
1882
|
+
* - Falls back to SQLOO_* environment variables (legacy)
|
|
1883
|
+
* - User-provided options always take precedence
|
|
1884
|
+
*
|
|
1885
|
+
* Environment variables:
|
|
1886
|
+
* - HAVE_SQL_URL / SQLOO_URL → Connection string (takes precedence)
|
|
1887
|
+
* - HAVE_SQL_DATABASE / SQLOO_DATABASE → Database name
|
|
1888
|
+
* - HAVE_SQL_HOST / SQLOO_HOST → Host (default: 'localhost')
|
|
1889
|
+
* - HAVE_SQL_USER / SQLOO_USER → Username
|
|
1890
|
+
* - HAVE_SQL_PASSWORD / SQLOO_PASSWORD → Password
|
|
1891
|
+
* - HAVE_SQL_PORT / SQLOO_PORT → Port (default: 5432)
|
|
1892
|
+
*
|
|
1893
|
+
* @param options - PostgreSQL connection options
|
|
1894
|
+
* @returns Database interface for PostgreSQL
|
|
1895
|
+
*/
|
|
1896
|
+
async function getDatabase$1(options = {}) {
|
|
1897
|
+
const envUrl = process.env.HAVE_SQL_URL || process.env.SQLOO_URL || null;
|
|
1898
|
+
const envDatabase = process.env.HAVE_SQL_DATABASE || process.env.SQLOO_DATABASE || null;
|
|
1899
|
+
const envHost = process.env.HAVE_SQL_HOST || process.env.SQLOO_HOST || "localhost";
|
|
1900
|
+
const envUser = process.env.HAVE_SQL_USER || process.env.SQLOO_USER || "";
|
|
1901
|
+
const envPortRaw = process.env.HAVE_SQL_PORT || process.env.SQLOO_PORT;
|
|
1902
|
+
const envPort = envPortRaw ? Number(envPortRaw) || 5432 : 5432;
|
|
1903
|
+
const derivedHost = options.host || envHost;
|
|
1904
|
+
const derivedPort = options.port || envPort;
|
|
1905
|
+
const derivedDatabase = options.database || envDatabase;
|
|
1906
|
+
const derivedUser = options.user || envUser;
|
|
1907
|
+
const cacheKey = options.dbid || options.url || (derivedDatabase ? `pg:${derivedUser ? `${derivedUser}@` : ""}${derivedHost}:${derivedPort}/${derivedDatabase}` : envUrl || null);
|
|
1908
|
+
if (cacheKey) {
|
|
1909
|
+
const cached = connectionCache.get(cacheKey);
|
|
1910
|
+
if (cached) return cached;
|
|
1911
|
+
const pending = pendingConnections.get(cacheKey);
|
|
1912
|
+
if (pending) return pending;
|
|
1913
|
+
}
|
|
1914
|
+
const connectionPromise = createDatabase(options, cacheKey);
|
|
1915
|
+
if (cacheKey) {
|
|
1916
|
+
pendingConnections.set(cacheKey, connectionPromise);
|
|
1917
|
+
connectionPromise.finally(() => {
|
|
1918
|
+
if (cacheKey) pendingConnections.delete(cacheKey);
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
return connectionPromise;
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Internal: creates the actual database connection and caches it.
|
|
1925
|
+
*/
|
|
1926
|
+
async function createDatabase(options, cacheKey) {
|
|
1927
|
+
const config = loadEnvConfig(options, {
|
|
1928
|
+
packageName: "sql",
|
|
1929
|
+
schema: {
|
|
1930
|
+
url: "string",
|
|
1931
|
+
database: "string",
|
|
1932
|
+
host: "string",
|
|
1933
|
+
port: "number",
|
|
1934
|
+
user: "string",
|
|
1935
|
+
password: "string"
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
if (!config.url && !config.host && !config.database && !config.user) {
|
|
1939
|
+
const legacyConfig = loadEnvConfig({}, {
|
|
1940
|
+
prefix: "SQLOO",
|
|
1941
|
+
schema: {
|
|
1942
|
+
url: "string",
|
|
1943
|
+
database: "string",
|
|
1944
|
+
host: "string",
|
|
1945
|
+
port: "number",
|
|
1946
|
+
user: "string",
|
|
1947
|
+
password: "string"
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
for (const [key, value] of Object.entries(legacyConfig)) if (config[key] === void 0 && value !== void 0) config[key] = value;
|
|
1951
|
+
}
|
|
1952
|
+
const { database, host = "localhost", user, password, port = 5432 } = config;
|
|
1953
|
+
const url = config.url || `postgresql://${user}${password ? `:${password}` : ""}@${host}:${port}/${database || "postgres"}`;
|
|
1954
|
+
const poolMax = options.max ?? 20;
|
|
1955
|
+
const pool = new Pool(config.url ? {
|
|
1956
|
+
connectionString: config.url,
|
|
1957
|
+
max: poolMax
|
|
1958
|
+
} : {
|
|
1959
|
+
host,
|
|
1960
|
+
user,
|
|
1961
|
+
password,
|
|
1962
|
+
port,
|
|
1963
|
+
database,
|
|
1964
|
+
max: poolMax
|
|
1965
|
+
});
|
|
1966
|
+
const originalEnd = pool.end.bind(pool);
|
|
1967
|
+
const evictFromCache = () => {
|
|
1968
|
+
for (const [key, cached] of connectionCache.entries()) if (cached.client === pool) connectionCache.delete(key);
|
|
1969
|
+
};
|
|
1970
|
+
pool.end = (callback) => {
|
|
1971
|
+
evictFromCache();
|
|
1972
|
+
if (callback) return originalEnd(callback);
|
|
1973
|
+
return originalEnd();
|
|
1974
|
+
};
|
|
1975
|
+
const client = pool;
|
|
1976
|
+
/**
|
|
1977
|
+
* Serializes a value for database storage
|
|
1978
|
+
* Converts objects and arrays to JSON strings
|
|
1979
|
+
* Converts Dates to ISO strings
|
|
1980
|
+
* Passes through primitives unchanged
|
|
1981
|
+
*/
|
|
1982
|
+
const serializeValue = (value) => {
|
|
1983
|
+
if (value === null || value === void 0) return value;
|
|
1984
|
+
if (value instanceof Date) return value.toISOString();
|
|
1985
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1986
|
+
return value;
|
|
1987
|
+
};
|
|
1988
|
+
/**
|
|
1989
|
+
* Serializes all values in an object for database storage
|
|
1990
|
+
*/
|
|
1991
|
+
const serializeRecord = (record) => {
|
|
1992
|
+
const serialized = {};
|
|
1993
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1994
|
+
if (value === void 0) continue;
|
|
1995
|
+
serialized[key] = serializeValue(value);
|
|
1996
|
+
}
|
|
1997
|
+
return serialized;
|
|
1998
|
+
};
|
|
1999
|
+
let serverVersionNumPromise;
|
|
2000
|
+
const nullsNotDistinctIndexCache = /* @__PURE__ */ new Map();
|
|
2001
|
+
const hasNullConflictValue = (conflictColumns, data) => conflictColumns.some((col) => data[col] === null);
|
|
2002
|
+
const validateUpsertConflictColumns = (table, conflictColumns, serializedData) => {
|
|
2003
|
+
const missingColumns = conflictColumns.filter((col) => !(col in serializedData));
|
|
2004
|
+
if (missingColumns.length > 0) throw new DatabaseError("Conflict columns missing from data", {
|
|
2005
|
+
table,
|
|
2006
|
+
conflictColumns,
|
|
2007
|
+
missingColumns,
|
|
2008
|
+
availableColumns: Object.keys(serializedData),
|
|
2009
|
+
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."
|
|
2010
|
+
});
|
|
2011
|
+
};
|
|
2012
|
+
const getServerVersionNum = async () => {
|
|
2013
|
+
serverVersionNumPromise ??= client.query("SHOW server_version_num").then((result) => Number(result.rows[0]?.server_version_num) || 0);
|
|
2014
|
+
return serverVersionNumPromise;
|
|
2015
|
+
};
|
|
2016
|
+
const hasNativeNullsNotDistinctIndex = async (table, conflictColumns) => {
|
|
2017
|
+
if (await getServerVersionNum() < 15e4) return false;
|
|
2018
|
+
const cacheKey = `${table}:${conflictColumns.join(",")}`;
|
|
2019
|
+
let cached = nullsNotDistinctIndexCache.get(cacheKey);
|
|
2020
|
+
if (!cached) {
|
|
2021
|
+
cached = client.query(`
|
|
2022
|
+
SELECT 1
|
|
2023
|
+
FROM pg_index i
|
|
2024
|
+
WHERE i.indrelid = to_regclass($1)
|
|
2025
|
+
AND i.indisunique
|
|
2026
|
+
AND i.indnullsnotdistinct
|
|
2027
|
+
AND ARRAY(
|
|
2028
|
+
SELECT a.attname::text
|
|
2029
|
+
FROM unnest(i.indkey) WITH ORDINALITY AS cols(attnum, ord)
|
|
2030
|
+
JOIN pg_attribute a
|
|
2031
|
+
ON a.attrelid = i.indrelid
|
|
2032
|
+
AND a.attnum = cols.attnum
|
|
2033
|
+
ORDER BY cols.ord
|
|
2034
|
+
) = $2::text[]
|
|
2035
|
+
LIMIT 1
|
|
2036
|
+
`, [table, conflictColumns]).then((result) => result.rows.length > 0).catch(() => false);
|
|
2037
|
+
nullsNotDistinctIndexCache.set(cacheKey, cached);
|
|
2038
|
+
}
|
|
2039
|
+
return cached;
|
|
2040
|
+
};
|
|
2041
|
+
const buildPostgresUpsertParts = (conflictColumns, serializedData) => {
|
|
2042
|
+
const keys = Object.keys(serializedData);
|
|
2043
|
+
return {
|
|
2044
|
+
keys,
|
|
2045
|
+
values: Object.values(serializedData),
|
|
2046
|
+
placeholders: keys.map((_, i) => `$${i + 1}`).join(", "),
|
|
2047
|
+
updateSet: keys.map((key, i) => `${key} = $${i + 1}`).join(", "),
|
|
2048
|
+
conflict: conflictColumns.join(", ")
|
|
2049
|
+
};
|
|
2050
|
+
};
|
|
2051
|
+
const executeStandardPostgresUpsert = async (executor, table, conflictColumns, serializedData) => {
|
|
2052
|
+
const { keys, values, placeholders, updateSet, conflict } = buildPostgresUpsertParts(conflictColumns, serializedData);
|
|
2053
|
+
const sql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders}) ON CONFLICT(${conflict}) DO UPDATE SET ${updateSet}`;
|
|
2054
|
+
return {
|
|
2055
|
+
operation: "upsert",
|
|
2056
|
+
affected: (await executor.query(sql, values)).rowCount ?? 0
|
|
2057
|
+
};
|
|
2058
|
+
};
|
|
2059
|
+
const executeNullAwarePostgresUpsert = async (executor, table, conflictColumns, serializedData) => {
|
|
2060
|
+
const { keys, values, placeholders } = buildPostgresUpsertParts(conflictColumns, serializedData);
|
|
2061
|
+
const lockKey = conflictColumns.map((col) => `${col}:${JSON.stringify(serializedData[col])}`).join("|");
|
|
2062
|
+
await executor.query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))", [`@happyvertical/sql.upsert:${table}`, lockKey]);
|
|
2063
|
+
const updateSet = keys.map((key, i) => `${key} = $${i + 1}`).join(", ");
|
|
2064
|
+
const whereStart = values.length + 1;
|
|
2065
|
+
const whereClause = conflictColumns.map((col, i) => `${col} IS NOT DISTINCT FROM $${whereStart + i}`).join(" AND ");
|
|
2066
|
+
const whereValues = conflictColumns.map((col) => serializedData[col]);
|
|
2067
|
+
const updateSql = `UPDATE ${table} SET ${updateSet} WHERE ${whereClause}`;
|
|
2068
|
+
const updateResult = await executor.query(updateSql, [...values, ...whereValues]);
|
|
2069
|
+
if ((updateResult.rowCount ?? 0) > 0) return {
|
|
2070
|
+
operation: "upsert",
|
|
2071
|
+
affected: updateResult.rowCount ?? 0
|
|
2072
|
+
};
|
|
2073
|
+
const insertSql = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
2074
|
+
return {
|
|
2075
|
+
operation: "upsert",
|
|
2076
|
+
affected: (await executor.query(insertSql, values)).rowCount ?? 0
|
|
2077
|
+
};
|
|
2078
|
+
};
|
|
2079
|
+
const executeNullAwarePostgresUpsertInTransaction = async (table, conflictColumns, serializedData) => {
|
|
2080
|
+
const txClient = await client.connect();
|
|
2081
|
+
try {
|
|
2082
|
+
await txClient.query("BEGIN");
|
|
2083
|
+
const result = await executeNullAwarePostgresUpsert(txClient, table, conflictColumns, serializedData);
|
|
2084
|
+
await txClient.query("COMMIT");
|
|
2085
|
+
return result;
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
await txClient.query("ROLLBACK");
|
|
2088
|
+
throw error;
|
|
2089
|
+
} finally {
|
|
2090
|
+
txClient.release();
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
const executePostgresUpsert = async (executor, table, conflictColumns, data, options, acquireTransaction) => {
|
|
2094
|
+
const serializedData = serializeRecord(data);
|
|
2095
|
+
validateUpsertConflictColumns(table, conflictColumns, serializedData);
|
|
2096
|
+
if (options?.nullsDistinct || !hasNullConflictValue(conflictColumns, serializedData)) return executeStandardPostgresUpsert(executor, table, conflictColumns, serializedData);
|
|
2097
|
+
if (await hasNativeNullsNotDistinctIndex(table, conflictColumns)) return executeStandardPostgresUpsert(executor, table, conflictColumns, serializedData);
|
|
2098
|
+
if (!acquireTransaction) return executeNullAwarePostgresUpsert(executor, table, conflictColumns, serializedData);
|
|
2099
|
+
return executeNullAwarePostgresUpsertInTransaction(table, conflictColumns, serializedData);
|
|
2100
|
+
};
|
|
2101
|
+
/**
|
|
2102
|
+
* Inserts one or more records into a table
|
|
2103
|
+
*
|
|
2104
|
+
* @param table - Table name
|
|
2105
|
+
* @param data - Single record or array of records to insert
|
|
2106
|
+
* @returns Promise resolving to operation result
|
|
2107
|
+
* @throws Error if the insert operation fails
|
|
2108
|
+
*
|
|
2109
|
+
* @example Single record insert:
|
|
2110
|
+
* ```typescript
|
|
2111
|
+
* await db.insert('users', {
|
|
2112
|
+
* name: 'John Doe',
|
|
2113
|
+
* email: 'john@example.com'
|
|
2114
|
+
* });
|
|
2115
|
+
* ```
|
|
2116
|
+
*
|
|
2117
|
+
* @example Multiple record insert:
|
|
2118
|
+
* ```typescript
|
|
2119
|
+
* await db.insert('users', [
|
|
2120
|
+
* { name: 'John', email: 'john@example.com' },
|
|
2121
|
+
* { name: 'Jane', email: 'jane@example.com' }
|
|
2122
|
+
* ]);
|
|
2123
|
+
* ```
|
|
2124
|
+
*/
|
|
2125
|
+
const insert = async (table, data) => {
|
|
2126
|
+
if (Array.isArray(data)) {
|
|
2127
|
+
const serializedRecords = data.map((record) => serializeRecord(record));
|
|
2128
|
+
const keys = Object.keys(serializedRecords[0]);
|
|
2129
|
+
const placeholders = serializedRecords.map((_, i) => `(${keys.map((_, j) => `$${i * keys.length + j + 1}`).join(", ")})`).join(", ");
|
|
2130
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES ${placeholders}`;
|
|
2131
|
+
const values = serializedRecords.reduce((acc, row) => acc.concat(Object.values(row)), []);
|
|
2132
|
+
return {
|
|
2133
|
+
operation: "insert",
|
|
2134
|
+
affected: (await client.query(query, values)).rowCount ?? 0
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
const serializedData = serializeRecord(data);
|
|
2138
|
+
const keys = Object.keys(serializedData);
|
|
2139
|
+
const values = Object.values(serializedData);
|
|
2140
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
|
|
2141
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
2142
|
+
return {
|
|
2143
|
+
operation: "insert",
|
|
2144
|
+
affected: (await client.query(query, values)).rowCount ?? 0
|
|
2145
|
+
};
|
|
2146
|
+
};
|
|
2147
|
+
/**
|
|
2148
|
+
* Retrieves a single record matching the where criteria
|
|
2149
|
+
*
|
|
2150
|
+
* @param table - Table name
|
|
2151
|
+
* @param where - Criteria to match records
|
|
2152
|
+
* @returns Promise resolving to query result
|
|
2153
|
+
*/
|
|
2154
|
+
const get = async (table, where) => {
|
|
2155
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2156
|
+
if (!whereClause) throw new DatabaseError("GET requires at least one WHERE condition to prevent returning an arbitrary record", { table });
|
|
2157
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2158
|
+
try {
|
|
2159
|
+
return (await client.query(query, values)).rows[0] || null;
|
|
2160
|
+
} catch (e) {
|
|
2161
|
+
throw new DatabaseError("Failed to retrieve record from table", {
|
|
2162
|
+
table,
|
|
2163
|
+
sql: query,
|
|
2164
|
+
values,
|
|
2165
|
+
originalError: formatDbError(e)
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
/**
|
|
2170
|
+
* Retrieves multiple records matching the where criteria
|
|
2171
|
+
*
|
|
2172
|
+
* @param table - Table name
|
|
2173
|
+
* @param where - Criteria to match records
|
|
2174
|
+
* @returns Promise resolving to array of records
|
|
2175
|
+
*/
|
|
2176
|
+
const list = async (table, where) => {
|
|
2177
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2178
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2179
|
+
try {
|
|
2180
|
+
return (await client.query(query, values)).rows;
|
|
2181
|
+
} catch (e) {
|
|
2182
|
+
throw new DatabaseError("Failed to list records from table", {
|
|
2183
|
+
table,
|
|
2184
|
+
sql: query,
|
|
2185
|
+
values,
|
|
2186
|
+
originalError: formatDbError(e)
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
/**
|
|
2191
|
+
* Updates records matching the where criteria
|
|
2192
|
+
*
|
|
2193
|
+
* @param table - Table name
|
|
2194
|
+
* @param where - Criteria to match records to update
|
|
2195
|
+
* @param data - New data to set
|
|
2196
|
+
* @returns Promise resolving to operation result
|
|
2197
|
+
*/
|
|
2198
|
+
const update = async (table, where, data) => {
|
|
2199
|
+
const serializedData = serializeRecord(data);
|
|
2200
|
+
const keys = Object.keys(serializedData);
|
|
2201
|
+
const values = Object.values(serializedData);
|
|
2202
|
+
const setClause = keys.map((key, i) => `${key} = $${i + 1}`).join(", ");
|
|
2203
|
+
const { sql: whereClause, values: whereValues } = buildWhere(where, values.length + 1, "postgres");
|
|
2204
|
+
if (!whereClause) throw new DatabaseError("UPDATE requires at least one WHERE condition to prevent accidental update of all records", { table });
|
|
2205
|
+
const sql = `UPDATE ${table} SET ${setClause} ${whereClause}`;
|
|
2206
|
+
try {
|
|
2207
|
+
return {
|
|
2208
|
+
operation: "update",
|
|
2209
|
+
affected: (await client.query(sql, [...values, ...whereValues])).rowCount ?? 0
|
|
2210
|
+
};
|
|
2211
|
+
} catch (e) {
|
|
2212
|
+
throw new DatabaseError("Failed to update records in table", {
|
|
2213
|
+
table,
|
|
2214
|
+
sql,
|
|
2215
|
+
values: [...values, ...whereValues],
|
|
2216
|
+
originalError: formatDbError(e)
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
/**
|
|
2221
|
+
* Inserts a record or updates it if it already exists (UPSERT)
|
|
2222
|
+
*
|
|
2223
|
+
* @param table - Table name
|
|
2224
|
+
* @param conflictColumns - Columns that define the uniqueness constraint
|
|
2225
|
+
* @param data - Data to insert or update
|
|
2226
|
+
* @returns Promise resolving to operation result
|
|
2227
|
+
* @throws Error if the upsert operation fails
|
|
2228
|
+
*/
|
|
2229
|
+
const upsert = async (table, conflictColumns, data, options) => {
|
|
2230
|
+
try {
|
|
2231
|
+
return await executePostgresUpsert(client, table, conflictColumns, data, options, true);
|
|
2232
|
+
} catch (e) {
|
|
2233
|
+
if (e instanceof DatabaseError && e.message === "Conflict columns missing from data") throw e;
|
|
2234
|
+
throw new DatabaseError("Failed to upsert record into table", {
|
|
2235
|
+
table,
|
|
2236
|
+
values: data,
|
|
2237
|
+
conflictColumns,
|
|
2238
|
+
originalError: formatDbError(e)
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
/**
|
|
2243
|
+
* Gets a record matching the where criteria or inserts it if not found
|
|
2244
|
+
*
|
|
2245
|
+
* @param table - Table name
|
|
2246
|
+
* @param where - Criteria to match existing record
|
|
2247
|
+
* @returns Promise resolving to the query result or insert result
|
|
2248
|
+
*/
|
|
2249
|
+
const getOrInsert = async (table, where, data) => {
|
|
2250
|
+
const result = await get(table, where);
|
|
2251
|
+
if (result) return result;
|
|
2252
|
+
await insert(table, data);
|
|
2253
|
+
const inserted = await get(table, where);
|
|
2254
|
+
if (!inserted) throw new DatabaseError("Failed to insert and retrieve record", {
|
|
2255
|
+
table,
|
|
2256
|
+
where,
|
|
2257
|
+
data
|
|
2258
|
+
});
|
|
2259
|
+
return inserted;
|
|
2260
|
+
};
|
|
2261
|
+
/**
|
|
2262
|
+
* Deletes records from a table matching the where criteria
|
|
2263
|
+
*
|
|
2264
|
+
* @param table - Table name
|
|
2265
|
+
* @param where - Criteria to match records for deletion
|
|
2266
|
+
* @returns Promise resolving to operation result with count of deleted rows
|
|
2267
|
+
* @throws Error if the delete operation fails
|
|
2268
|
+
*/
|
|
2269
|
+
const deleteRecords = async (table, where) => {
|
|
2270
|
+
validateTableName$1(table);
|
|
2271
|
+
if (Object.keys(where).length === 0) throw new DatabaseError("DELETE requires at least one WHERE condition to prevent accidental deletion of all records", { table });
|
|
2272
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2273
|
+
try {
|
|
2274
|
+
return {
|
|
2275
|
+
operation: "delete",
|
|
2276
|
+
affected: (await client.query(`DELETE FROM ${table} ${whereClause}`, values)).rowCount ?? 0
|
|
2277
|
+
};
|
|
2278
|
+
} catch (e) {
|
|
2279
|
+
throw new DatabaseError("Failed to delete records from table", {
|
|
2280
|
+
table,
|
|
2281
|
+
where,
|
|
2282
|
+
originalError: formatDbError(e)
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
};
|
|
2286
|
+
/**
|
|
2287
|
+
* Counts records in a table matching the where criteria
|
|
2288
|
+
*
|
|
2289
|
+
* @param table - Table name
|
|
2290
|
+
* @param where - Criteria to match records (optional, counts all if omitted)
|
|
2291
|
+
* @returns Promise resolving to count of matching records
|
|
2292
|
+
* @throws Error if the count operation fails
|
|
2293
|
+
*/
|
|
2294
|
+
const count = async (table, where) => {
|
|
2295
|
+
validateTableName$1(table);
|
|
2296
|
+
try {
|
|
2297
|
+
if (!where || Object.keys(where).length === 0) {
|
|
2298
|
+
const result = await client.query(`SELECT COUNT(*) as count FROM ${table}`);
|
|
2299
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2300
|
+
}
|
|
2301
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2302
|
+
const result = await client.query(`SELECT COUNT(*) as count FROM ${table} ${whereClause}`, values);
|
|
2303
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2304
|
+
} catch (e) {
|
|
2305
|
+
throw new DatabaseError("Failed to count records in table", {
|
|
2306
|
+
table,
|
|
2307
|
+
where,
|
|
2308
|
+
originalError: formatDbError(e)
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
/**
|
|
2313
|
+
* Creates a table-specific interface for simplified table operations
|
|
2314
|
+
*
|
|
2315
|
+
* @param tableName - Table name
|
|
2316
|
+
* @returns TableMethods interface for the specified table
|
|
2317
|
+
*/
|
|
2318
|
+
const table = (tableName) => {
|
|
2319
|
+
return {
|
|
2320
|
+
insert: (data) => insert(tableName, data),
|
|
2321
|
+
get: (data) => get(tableName, data),
|
|
2322
|
+
list: (data) => list(tableName, data)
|
|
2323
|
+
};
|
|
2324
|
+
};
|
|
2325
|
+
/**
|
|
2326
|
+
* Parses a tagged template literal into a SQL query and values
|
|
2327
|
+
*
|
|
2328
|
+
* @param strings - Template strings
|
|
2329
|
+
* @param vars - Variables to interpolate into the query
|
|
2330
|
+
* @returns Object with SQL query and values array
|
|
2331
|
+
*/
|
|
2332
|
+
const parseTemplate = (strings, ...vars) => {
|
|
2333
|
+
let sql = strings[0];
|
|
2334
|
+
const values = [];
|
|
2335
|
+
for (let i = 0; i < vars.length; i++) {
|
|
2336
|
+
values.push(vars[i]);
|
|
2337
|
+
sql += `$${i + 1}${strings[i + 1]}`;
|
|
2338
|
+
}
|
|
2339
|
+
return {
|
|
2340
|
+
sql,
|
|
2341
|
+
values
|
|
2342
|
+
};
|
|
2343
|
+
};
|
|
2344
|
+
/**
|
|
2345
|
+
* Executes a SQL query using template literals and returns a single value
|
|
2346
|
+
*
|
|
2347
|
+
* @param strings - Template strings
|
|
2348
|
+
* @param vars - Variables to interpolate into the query
|
|
2349
|
+
* @returns Promise resolving to a single value (first column of first row)
|
|
2350
|
+
*/
|
|
2351
|
+
const pluck = async (strings, ...vars) => {
|
|
2352
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2353
|
+
try {
|
|
2354
|
+
const firstRow = (await client.query(sql, values)).rows[0];
|
|
2355
|
+
if (!firstRow) return null;
|
|
2356
|
+
return Object.values(firstRow)[0];
|
|
2357
|
+
} catch (e) {
|
|
2358
|
+
throw new DatabaseError("Failed to execute pluck query", {
|
|
2359
|
+
sql,
|
|
2360
|
+
values,
|
|
2361
|
+
originalError: formatDbError(e)
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
};
|
|
2365
|
+
/**
|
|
2366
|
+
* Executes a SQL query using template literals and returns a single row
|
|
2367
|
+
*
|
|
2368
|
+
* @param strings - Template strings
|
|
2369
|
+
* @param vars - Variables to interpolate into the query
|
|
2370
|
+
* @returns Promise resolving to a single result record or null
|
|
2371
|
+
*/
|
|
2372
|
+
const single = async (strings, ...vars) => {
|
|
2373
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2374
|
+
try {
|
|
2375
|
+
return (await client.query(sql, values)).rows[0] || null;
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
throw new DatabaseError("Failed to execute single query", {
|
|
2378
|
+
sql,
|
|
2379
|
+
values,
|
|
2380
|
+
originalError: formatDbError(e)
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
/**
|
|
2385
|
+
* Executes a SQL query using template literals and returns multiple rows
|
|
2386
|
+
*
|
|
2387
|
+
* @param strings - Template strings
|
|
2388
|
+
* @param vars - Variables to interpolate into the query
|
|
2389
|
+
* @returns Promise resolving to array of result records
|
|
2390
|
+
*/
|
|
2391
|
+
const many = async (strings, ...vars) => {
|
|
2392
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2393
|
+
try {
|
|
2394
|
+
return (await client.query(sql, values)).rows;
|
|
2395
|
+
} catch (e) {
|
|
2396
|
+
throw new DatabaseError("Failed to execute many query", {
|
|
2397
|
+
sql,
|
|
2398
|
+
values,
|
|
2399
|
+
originalError: formatDbError(e)
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
/**
|
|
2404
|
+
* Executes a SQL query using template literals without returning results
|
|
2405
|
+
*
|
|
2406
|
+
* @param strings - Template strings
|
|
2407
|
+
* @param vars - Variables to interpolate into the query
|
|
2408
|
+
* @returns Promise that resolves when the query completes
|
|
2409
|
+
*/
|
|
2410
|
+
const execute = async (strings, ...vars) => {
|
|
2411
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2412
|
+
try {
|
|
2413
|
+
await client.query(sql, values);
|
|
2414
|
+
} catch (e) {
|
|
2415
|
+
throw new DatabaseError("Failed to execute query", {
|
|
2416
|
+
sql,
|
|
2417
|
+
values,
|
|
2418
|
+
originalError: formatDbError(e)
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
/**
|
|
2423
|
+
* Executes a raw SQL query with parameterized values
|
|
2424
|
+
*
|
|
2425
|
+
* Uses PostgreSQL-native placeholders ($1, $2, ...). Legacy ? placeholders
|
|
2426
|
+
* are converted only when the placeholder count matches the supplied values,
|
|
2427
|
+
* so Postgres operators such as JSONB ? remain intact.
|
|
2428
|
+
*
|
|
2429
|
+
* @param sql - SQL query string
|
|
2430
|
+
* @param values - Variables to use as parameters
|
|
2431
|
+
* @returns Promise resolving to query result with rows and count
|
|
2432
|
+
*/
|
|
2433
|
+
const query = async (sql, ...values) => {
|
|
2434
|
+
const query = normalizePostgresRawQuery(sql, values);
|
|
2435
|
+
try {
|
|
2436
|
+
const result = await client.query(query.sql, query.values);
|
|
2437
|
+
return {
|
|
2438
|
+
rows: result.rows,
|
|
2439
|
+
rowCount: result.rowCount ?? 0
|
|
2440
|
+
};
|
|
2441
|
+
} catch (e) {
|
|
2442
|
+
throw new DatabaseError("Failed to execute raw query", {
|
|
2443
|
+
sql: query.sql,
|
|
2444
|
+
values: query.values,
|
|
2445
|
+
originalError: formatDbError(e)
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
/**
|
|
2450
|
+
* Acquire a pinned connection from the pool for session-scoped state such as
|
|
2451
|
+
* `pg_advisory_lock`. Every query on the returned handle runs on the same
|
|
2452
|
+
* client until {@link SessionHandle.release} drops the connection (which
|
|
2453
|
+
* frees any session locks it held). The connection is held for the handle's
|
|
2454
|
+
* lifetime, so hold one session per process, not per operation.
|
|
2455
|
+
*/
|
|
2456
|
+
const acquireSession = async () => {
|
|
2457
|
+
const sessionClient = await pool.connect();
|
|
2458
|
+
let released = false;
|
|
2459
|
+
let lost = false;
|
|
2460
|
+
sessionClient.on("error", () => {
|
|
2461
|
+
lost = true;
|
|
2462
|
+
});
|
|
2463
|
+
return {
|
|
2464
|
+
query: async (sql, ...values) => {
|
|
2465
|
+
if (released) throw new DatabaseError("Session has been released", {});
|
|
2466
|
+
if (lost) throw new DatabaseError("Session connection was lost", {});
|
|
2467
|
+
const normalized = normalizePostgresRawQuery(sql, values);
|
|
2468
|
+
try {
|
|
2469
|
+
const result = await sessionClient.query(normalized.sql, normalized.values);
|
|
2470
|
+
return {
|
|
2471
|
+
rows: result.rows,
|
|
2472
|
+
rowCount: result.rowCount ?? 0
|
|
2473
|
+
};
|
|
2474
|
+
} catch (e) {
|
|
2475
|
+
throw new DatabaseError("Failed to execute session query", {
|
|
2476
|
+
sql: normalized.sql,
|
|
2477
|
+
values: normalized.values,
|
|
2478
|
+
originalError: formatDbError(e)
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
},
|
|
2482
|
+
isActive: () => !released && !lost,
|
|
2483
|
+
release: async () => {
|
|
2484
|
+
if (released) return;
|
|
2485
|
+
released = true;
|
|
2486
|
+
if (!lost) {
|
|
2487
|
+
try {
|
|
2488
|
+
await sessionClient.query("ROLLBACK");
|
|
2489
|
+
} catch {}
|
|
2490
|
+
try {
|
|
2491
|
+
await sessionClient.query("SELECT pg_advisory_unlock_all()");
|
|
2492
|
+
} catch {}
|
|
2493
|
+
}
|
|
2494
|
+
sessionClient.release(true);
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
};
|
|
2498
|
+
/**
|
|
2499
|
+
* Checks if a table exists in the database
|
|
2500
|
+
*
|
|
2501
|
+
* @param tableName - Name of the table to check
|
|
2502
|
+
* @returns Promise resolving to boolean indicating if the table exists
|
|
2503
|
+
*/
|
|
2504
|
+
const tableExists = async (tableName) => {
|
|
2505
|
+
return (await client.query(`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1 AND table_schema = 'public')`, [tableName])).rows[0].exists;
|
|
2506
|
+
};
|
|
2507
|
+
/**
|
|
2508
|
+
* Synchronizes database schema with provided SQL DDL
|
|
2509
|
+
* Creates tables if they don't exist and adds missing columns
|
|
2510
|
+
* Also executes CREATE INDEX statements
|
|
2511
|
+
*
|
|
2512
|
+
* @param schema - SQL schema definition with CREATE TABLE and CREATE INDEX statements
|
|
2513
|
+
* @returns Promise that resolves when schema is synchronized
|
|
2514
|
+
*/
|
|
2515
|
+
const syncSchema = async (schema) => {
|
|
2516
|
+
const commands = schema.trim().split(";").filter((command) => command.trim() !== "");
|
|
2517
|
+
const createIndexRegex = /CREATE (UNIQUE )?INDEX (IF NOT EXISTS )?"?(\w+)"? ON "?(\w+)"?\s*\(([^)]+)\)/i;
|
|
2518
|
+
const indexNames = [];
|
|
2519
|
+
for (const command of commands) {
|
|
2520
|
+
const indexMatch = command.trim().match(createIndexRegex);
|
|
2521
|
+
if (indexMatch) indexNames.push(indexMatch[3]);
|
|
2522
|
+
}
|
|
2523
|
+
const existingIndexes = /* @__PURE__ */ new Set();
|
|
2524
|
+
if (indexNames.length > 0) {
|
|
2525
|
+
const result = await client.query(`SELECT indexname FROM pg_indexes
|
|
2526
|
+
WHERE schemaname = 'public' AND indexname = ANY($1::text[])`, [indexNames]);
|
|
2527
|
+
for (const row of result.rows) existingIndexes.add(row.indexname);
|
|
2528
|
+
}
|
|
2529
|
+
for (const command of commands) {
|
|
2530
|
+
const trimmedCommand = command.trim();
|
|
2531
|
+
const tableMatch = trimmedCommand.match(/CREATE TABLE (IF NOT EXISTS )?"?(\w+)"? \(([\s\S]+)\)/i);
|
|
2532
|
+
if (tableMatch) {
|
|
2533
|
+
const tableName = tableMatch[2];
|
|
2534
|
+
const columns = tableMatch[3].trim().split(",\n");
|
|
2535
|
+
if (!await tableExists(tableName)) await client.query(trimmedCommand);
|
|
2536
|
+
else for (const column of columns) {
|
|
2537
|
+
const columnDef = column.trim();
|
|
2538
|
+
const columnMatch = columnDef.match(/"?(\w+)"?\s+(\w+[^,]*)/);
|
|
2539
|
+
if (columnMatch) {
|
|
2540
|
+
const columnName = columnMatch[1];
|
|
2541
|
+
if (columnName.toUpperCase() === "PRIMARY" || columnName.toUpperCase() === "FOREIGN" || columnName.toUpperCase() === "UNIQUE" || columnName.toUpperCase() === "CHECK" || columnName.toUpperCase() === "CONSTRAINT") continue;
|
|
2542
|
+
try {
|
|
2543
|
+
if (!(await client.query(`SELECT EXISTS (
|
|
2544
|
+
SELECT 1 FROM information_schema.columns
|
|
2545
|
+
WHERE table_name = $1
|
|
2546
|
+
AND column_name = $2
|
|
2547
|
+
AND table_schema = 'public'
|
|
2548
|
+
)`, [tableName, columnName])).rows[0].exists) {
|
|
2549
|
+
const alterCommand = `ALTER TABLE "${tableName}" ADD COLUMN ${columnDef}`;
|
|
2550
|
+
await client.query(alterCommand);
|
|
2551
|
+
}
|
|
2552
|
+
} catch (error) {
|
|
2553
|
+
console.error(`Error adding column ${columnName} to ${tableName}:`, error);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
const indexMatch = trimmedCommand.match(createIndexRegex);
|
|
2560
|
+
if (indexMatch) {
|
|
2561
|
+
const indexName = indexMatch[3];
|
|
2562
|
+
const indexTableName = indexMatch[4];
|
|
2563
|
+
if (!existingIndexes.has(indexName)) try {
|
|
2564
|
+
await client.query(trimmedCommand);
|
|
2565
|
+
existingIndexes.add(indexName);
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
console.warn(`Warning: Failed to create index ${indexName} on ${indexTableName}:`, error instanceof Error ? error.message : String(error));
|
|
2568
|
+
}
|
|
2569
|
+
continue;
|
|
2570
|
+
}
|
|
2571
|
+
if (trimmedCommand.toUpperCase().startsWith("CREATE ") && !trimmedCommand.toUpperCase().includes("CREATE TABLE")) try {
|
|
2572
|
+
await client.query(trimmedCommand);
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
console.warn(`Warning: DDL statement may have failed:`, error instanceof Error ? error.message : String(error));
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
};
|
|
2578
|
+
/**
|
|
2579
|
+
* Executes a callback within a database transaction
|
|
2580
|
+
* Automatically commits on success or rolls back on error
|
|
2581
|
+
*
|
|
2582
|
+
* @param callback - Function to execute within transaction
|
|
2583
|
+
* @returns Promise resolving to callback result
|
|
2584
|
+
*/
|
|
2585
|
+
const transaction = async (callback) => {
|
|
2586
|
+
const txClient = await client.connect();
|
|
2587
|
+
try {
|
|
2588
|
+
await txClient.query("BEGIN");
|
|
2589
|
+
const txDb = {
|
|
2590
|
+
url,
|
|
2591
|
+
client: txClient,
|
|
2592
|
+
insert: async (table, data) => {
|
|
2593
|
+
if (Array.isArray(data)) {
|
|
2594
|
+
const keys = Object.keys(data[0]);
|
|
2595
|
+
const placeholders = data.map((_, i) => `(${keys.map((_, j) => `$${i * keys.length + j + 1}`).join(", ")})`).join(", ");
|
|
2596
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES ${placeholders}`;
|
|
2597
|
+
const values = data.reduce((acc, row) => acc.concat(Object.values(row)), []);
|
|
2598
|
+
return {
|
|
2599
|
+
operation: "insert",
|
|
2600
|
+
affected: (await txClient.query(query, values)).rowCount ?? 0
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
const keys = Object.keys(data);
|
|
2604
|
+
const values = Object.values(data);
|
|
2605
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
|
|
2606
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
2607
|
+
return {
|
|
2608
|
+
operation: "insert",
|
|
2609
|
+
affected: (await txClient.query(query, values)).rowCount ?? 0
|
|
2610
|
+
};
|
|
2611
|
+
},
|
|
2612
|
+
get: async (table, where) => {
|
|
2613
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2614
|
+
if (!whereClause) throw new DatabaseError("GET requires at least one WHERE condition to prevent returning an arbitrary record", { table });
|
|
2615
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2616
|
+
return (await txClient.query(query, values)).rows[0] || null;
|
|
2617
|
+
},
|
|
2618
|
+
list: async (table, where) => {
|
|
2619
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2620
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2621
|
+
return (await txClient.query(query, values)).rows;
|
|
2622
|
+
},
|
|
2623
|
+
update: async (table, where, data) => {
|
|
2624
|
+
const keys = Object.keys(data);
|
|
2625
|
+
const values = Object.values(data);
|
|
2626
|
+
const setClause = keys.map((key, i) => `${key} = $${i + 1}`).join(", ");
|
|
2627
|
+
const { sql: whereClause, values: whereValues } = buildWhere(where, values.length + 1, "postgres");
|
|
2628
|
+
if (!whereClause) throw new DatabaseError("UPDATE requires at least one WHERE condition to prevent accidental update of all records", { table });
|
|
2629
|
+
const sql = `UPDATE ${table} SET ${setClause} ${whereClause}`;
|
|
2630
|
+
return {
|
|
2631
|
+
operation: "update",
|
|
2632
|
+
affected: (await txClient.query(sql, [...values, ...whereValues])).rowCount ?? 0
|
|
2633
|
+
};
|
|
2634
|
+
},
|
|
2635
|
+
upsert: async (table, conflictColumns, data, options) => executePostgresUpsert(txClient, table, conflictColumns, data, options, false),
|
|
2636
|
+
getOrInsert: async (table, where, data) => {
|
|
2637
|
+
const result = await txDb.get(table, where);
|
|
2638
|
+
if (result) return result;
|
|
2639
|
+
await txDb.insert(table, data);
|
|
2640
|
+
const inserted = await txDb.get(table, where);
|
|
2641
|
+
if (!inserted) throw new DatabaseError("Failed to insert and retrieve record", {
|
|
2642
|
+
table,
|
|
2643
|
+
where,
|
|
2644
|
+
data
|
|
2645
|
+
});
|
|
2646
|
+
return inserted;
|
|
2647
|
+
},
|
|
2648
|
+
delete: async (table, where) => {
|
|
2649
|
+
validateTableName$1(table);
|
|
2650
|
+
if (Object.keys(where).length === 0) throw new DatabaseError("DELETE requires at least one WHERE condition to prevent accidental deletion of all records", { table });
|
|
2651
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2652
|
+
return {
|
|
2653
|
+
operation: "delete",
|
|
2654
|
+
affected: (await txClient.query(`DELETE FROM ${table} ${whereClause}`, values)).rowCount ?? 0
|
|
2655
|
+
};
|
|
2656
|
+
},
|
|
2657
|
+
count: async (table, where) => {
|
|
2658
|
+
validateTableName$1(table);
|
|
2659
|
+
if (!where || Object.keys(where).length === 0) {
|
|
2660
|
+
const result = await txClient.query(`SELECT COUNT(*) as count FROM ${table}`);
|
|
2661
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2662
|
+
}
|
|
2663
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2664
|
+
const result = await txClient.query(`SELECT COUNT(*) as count FROM ${table} ${whereClause}`, values);
|
|
2665
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2666
|
+
},
|
|
2667
|
+
table: (tableName) => ({
|
|
2668
|
+
insert: (data) => txDb.insert(tableName, data),
|
|
2669
|
+
get: (data) => txDb.get(tableName, data),
|
|
2670
|
+
list: (data) => txDb.list(tableName, data)
|
|
2671
|
+
}),
|
|
2672
|
+
many: async (strings, ...vars) => {
|
|
2673
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2674
|
+
return (await txClient.query(sql, values)).rows;
|
|
2675
|
+
},
|
|
2676
|
+
single: async (strings, ...vars) => {
|
|
2677
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2678
|
+
return (await txClient.query(sql, values)).rows[0] || null;
|
|
2679
|
+
},
|
|
2680
|
+
pluck: async (strings, ...vars) => {
|
|
2681
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2682
|
+
const firstRow = (await txClient.query(sql, values)).rows[0];
|
|
2683
|
+
if (!firstRow) return null;
|
|
2684
|
+
return Object.values(firstRow)[0];
|
|
2685
|
+
},
|
|
2686
|
+
execute: async (strings, ...vars) => {
|
|
2687
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2688
|
+
await txClient.query(sql, values);
|
|
2689
|
+
},
|
|
2690
|
+
query: async (sql, ...values) => {
|
|
2691
|
+
const query = normalizePostgresRawQuery(sql, values);
|
|
2692
|
+
const result = await txClient.query(query.sql, query.values);
|
|
2693
|
+
return {
|
|
2694
|
+
rows: result.rows,
|
|
2695
|
+
rowCount: result.rowCount ?? 0
|
|
2696
|
+
};
|
|
2697
|
+
},
|
|
2698
|
+
oo: async (strings, ...vars) => {
|
|
2699
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2700
|
+
return (await txClient.query(sql, values)).rows;
|
|
2701
|
+
},
|
|
2702
|
+
oO: async (strings, ...vars) => {
|
|
2703
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2704
|
+
return (await txClient.query(sql, values)).rows[0] || null;
|
|
2705
|
+
},
|
|
2706
|
+
ox: async (strings, ...vars) => {
|
|
2707
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2708
|
+
const firstRow = (await txClient.query(sql, values)).rows[0];
|
|
2709
|
+
if (!firstRow) return null;
|
|
2710
|
+
return Object.values(firstRow)[0];
|
|
2711
|
+
},
|
|
2712
|
+
xx: async (strings, ...vars) => {
|
|
2713
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2714
|
+
await txClient.query(sql, values);
|
|
2715
|
+
},
|
|
2716
|
+
tableExists,
|
|
2717
|
+
syncSchema,
|
|
2718
|
+
transaction
|
|
2719
|
+
};
|
|
2720
|
+
const result = await callback(txDb);
|
|
2721
|
+
await txClient.query("COMMIT");
|
|
2722
|
+
return result;
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
await txClient.query("ROLLBACK");
|
|
2725
|
+
throw error;
|
|
2726
|
+
} finally {
|
|
2727
|
+
txClient.release();
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
/**
|
|
2731
|
+
* Begins a new transaction and returns a handle for manual control
|
|
2732
|
+
*
|
|
2733
|
+
* Unlike transaction(), this gives you explicit control over commit/rollback.
|
|
2734
|
+
* Ideal for test isolation where you want to rollback after each test.
|
|
2735
|
+
*
|
|
2736
|
+
* @returns Promise resolving to a TransactionHandle
|
|
2737
|
+
*/
|
|
2738
|
+
const beginTransaction = async () => {
|
|
2739
|
+
const txClient = await client.connect();
|
|
2740
|
+
await txClient.query("BEGIN");
|
|
2741
|
+
let active = true;
|
|
2742
|
+
const commit = async () => {
|
|
2743
|
+
if (!active) throw new DatabaseError("Transaction already ended", {});
|
|
2744
|
+
await txClient.query("COMMIT");
|
|
2745
|
+
active = false;
|
|
2746
|
+
txClient.release();
|
|
2747
|
+
};
|
|
2748
|
+
const rollback = async () => {
|
|
2749
|
+
if (!active) throw new DatabaseError("Transaction already ended", {});
|
|
2750
|
+
await txClient.query("ROLLBACK");
|
|
2751
|
+
active = false;
|
|
2752
|
+
txClient.release();
|
|
2753
|
+
};
|
|
2754
|
+
const isActive = () => active;
|
|
2755
|
+
const txHandle = {
|
|
2756
|
+
url,
|
|
2757
|
+
client: txClient,
|
|
2758
|
+
insert: async (table, data) => {
|
|
2759
|
+
if (Array.isArray(data)) {
|
|
2760
|
+
const keys = Object.keys(data[0]);
|
|
2761
|
+
const placeholders = data.map((_, i) => `(${keys.map((_, j) => `$${i * keys.length + j + 1}`).join(", ")})`).join(", ");
|
|
2762
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES ${placeholders}`;
|
|
2763
|
+
const values = data.reduce((acc, row) => acc.concat(Object.values(row)), []);
|
|
2764
|
+
return {
|
|
2765
|
+
operation: "insert",
|
|
2766
|
+
affected: (await txClient.query(query, values)).rowCount ?? 0
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
const keys = Object.keys(data);
|
|
2770
|
+
const values = Object.values(data);
|
|
2771
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
|
|
2772
|
+
const query = `INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`;
|
|
2773
|
+
return {
|
|
2774
|
+
operation: "insert",
|
|
2775
|
+
affected: (await txClient.query(query, values)).rowCount ?? 0
|
|
2776
|
+
};
|
|
2777
|
+
},
|
|
2778
|
+
get: async (table, where) => {
|
|
2779
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2780
|
+
if (!whereClause) throw new DatabaseError("GET requires at least one WHERE condition to prevent returning an arbitrary record", { table });
|
|
2781
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2782
|
+
return (await txClient.query(query, values)).rows[0] || null;
|
|
2783
|
+
},
|
|
2784
|
+
list: async (table, where) => {
|
|
2785
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2786
|
+
const query = `SELECT * FROM ${table} ${whereClause}`;
|
|
2787
|
+
return (await txClient.query(query, values)).rows;
|
|
2788
|
+
},
|
|
2789
|
+
update: async (table, where, data) => {
|
|
2790
|
+
const keys = Object.keys(data);
|
|
2791
|
+
const values = Object.values(data);
|
|
2792
|
+
const setClause = keys.map((key, i) => `${key} = $${i + 1}`).join(", ");
|
|
2793
|
+
const { sql: whereClause, values: whereValues } = buildWhere(where, values.length + 1, "postgres");
|
|
2794
|
+
if (!whereClause) throw new DatabaseError("UPDATE requires at least one WHERE condition to prevent accidental update of all records", { table });
|
|
2795
|
+
const sql = `UPDATE ${table} SET ${setClause} ${whereClause}`;
|
|
2796
|
+
return {
|
|
2797
|
+
operation: "update",
|
|
2798
|
+
affected: (await txClient.query(sql, [...values, ...whereValues])).rowCount ?? 0
|
|
2799
|
+
};
|
|
2800
|
+
},
|
|
2801
|
+
upsert: async (table, conflictColumns, data, options) => executePostgresUpsert(txClient, table, conflictColumns, data, options, false),
|
|
2802
|
+
getOrInsert: async (table, where, data) => {
|
|
2803
|
+
const result = await txHandle.get(table, where);
|
|
2804
|
+
if (result) return result;
|
|
2805
|
+
await txHandle.insert(table, data);
|
|
2806
|
+
const inserted = await txHandle.get(table, where);
|
|
2807
|
+
if (!inserted) throw new DatabaseError("Failed to insert and retrieve record", {
|
|
2808
|
+
table,
|
|
2809
|
+
where,
|
|
2810
|
+
data
|
|
2811
|
+
});
|
|
2812
|
+
return inserted;
|
|
2813
|
+
},
|
|
2814
|
+
delete: async (table, where) => {
|
|
2815
|
+
validateTableName$1(table);
|
|
2816
|
+
if (Object.keys(where).length === 0) throw new DatabaseError("DELETE requires at least one WHERE condition to prevent accidental deletion of all records", { table });
|
|
2817
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2818
|
+
return {
|
|
2819
|
+
operation: "delete",
|
|
2820
|
+
affected: (await txClient.query(`DELETE FROM ${table} ${whereClause}`, values)).rowCount ?? 0
|
|
2821
|
+
};
|
|
2822
|
+
},
|
|
2823
|
+
count: async (table, where) => {
|
|
2824
|
+
validateTableName$1(table);
|
|
2825
|
+
if (!where || Object.keys(where).length === 0) {
|
|
2826
|
+
const result = await txClient.query(`SELECT COUNT(*) as count FROM ${table}`);
|
|
2827
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2828
|
+
}
|
|
2829
|
+
const { sql: whereClause, values } = buildWhere(where, 1, "postgres");
|
|
2830
|
+
const result = await txClient.query(`SELECT COUNT(*) as count FROM ${table} ${whereClause}`, values);
|
|
2831
|
+
return Number(result.rows[0]?.count) || 0;
|
|
2832
|
+
},
|
|
2833
|
+
table: (tableName) => ({
|
|
2834
|
+
insert: (data) => txHandle.insert(tableName, data),
|
|
2835
|
+
get: (data) => txHandle.get(tableName, data),
|
|
2836
|
+
list: (data) => txHandle.list(tableName, data)
|
|
2837
|
+
}),
|
|
2838
|
+
many: async (strings, ...vars) => {
|
|
2839
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2840
|
+
return (await txClient.query(sql, values)).rows;
|
|
2841
|
+
},
|
|
2842
|
+
single: async (strings, ...vars) => {
|
|
2843
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2844
|
+
return (await txClient.query(sql, values)).rows[0] || null;
|
|
2845
|
+
},
|
|
2846
|
+
pluck: async (strings, ...vars) => {
|
|
2847
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2848
|
+
const firstRow = (await txClient.query(sql, values)).rows[0];
|
|
2849
|
+
if (!firstRow) return null;
|
|
2850
|
+
return Object.values(firstRow)[0];
|
|
2851
|
+
},
|
|
2852
|
+
execute: async (strings, ...vars) => {
|
|
2853
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2854
|
+
await txClient.query(sql, values);
|
|
2855
|
+
},
|
|
2856
|
+
query: async (sql, ...values) => {
|
|
2857
|
+
const query = normalizePostgresRawQuery(sql, values);
|
|
2858
|
+
const result = await txClient.query(query.sql, query.values);
|
|
2859
|
+
return {
|
|
2860
|
+
rows: result.rows,
|
|
2861
|
+
rowCount: result.rowCount ?? 0
|
|
2862
|
+
};
|
|
2863
|
+
},
|
|
2864
|
+
oo: async (strings, ...vars) => {
|
|
2865
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2866
|
+
return (await txClient.query(sql, values)).rows;
|
|
2867
|
+
},
|
|
2868
|
+
oO: async (strings, ...vars) => {
|
|
2869
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2870
|
+
return (await txClient.query(sql, values)).rows[0] || null;
|
|
2871
|
+
},
|
|
2872
|
+
ox: async (strings, ...vars) => {
|
|
2873
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2874
|
+
const firstRow = (await txClient.query(sql, values)).rows[0];
|
|
2875
|
+
if (!firstRow) return null;
|
|
2876
|
+
return Object.values(firstRow)[0];
|
|
2877
|
+
},
|
|
2878
|
+
xx: async (strings, ...vars) => {
|
|
2879
|
+
const { sql, values } = parseTemplate(strings, ...vars);
|
|
2880
|
+
await txClient.query(sql, values);
|
|
2881
|
+
},
|
|
2882
|
+
tableExists,
|
|
2883
|
+
syncSchema,
|
|
2884
|
+
transaction,
|
|
2885
|
+
commit,
|
|
2886
|
+
rollback,
|
|
2887
|
+
isActive
|
|
2888
|
+
};
|
|
2889
|
+
return txHandle;
|
|
2890
|
+
};
|
|
2891
|
+
const oo = many;
|
|
2892
|
+
const oO = single;
|
|
2893
|
+
const ox = pluck;
|
|
2894
|
+
const xx = execute;
|
|
2895
|
+
/**
|
|
2896
|
+
* Initialize database schemas from JSON manifest
|
|
2897
|
+
* Supports dependency resolution and schema overrides
|
|
2898
|
+
*
|
|
2899
|
+
* @param options - Schema initialization options
|
|
2900
|
+
* @returns Promise that resolves when schemas are initialized
|
|
2901
|
+
*/
|
|
2902
|
+
const initializeSchemas = async (options) => {
|
|
2903
|
+
const schemaManager = new DatabaseSchemaManager();
|
|
2904
|
+
const currentDb = {
|
|
2905
|
+
url,
|
|
2906
|
+
client,
|
|
2907
|
+
insert,
|
|
2908
|
+
update,
|
|
2909
|
+
upsert,
|
|
2910
|
+
get,
|
|
2911
|
+
getOrInsert,
|
|
2912
|
+
delete: deleteRecords,
|
|
2913
|
+
count,
|
|
2914
|
+
list,
|
|
2915
|
+
table,
|
|
2916
|
+
many,
|
|
2917
|
+
single,
|
|
2918
|
+
pluck,
|
|
2919
|
+
execute,
|
|
2920
|
+
query,
|
|
2921
|
+
oo,
|
|
2922
|
+
oO,
|
|
2923
|
+
ox,
|
|
2924
|
+
xx,
|
|
2925
|
+
tableExists,
|
|
2926
|
+
syncSchema,
|
|
2927
|
+
transaction
|
|
2928
|
+
};
|
|
2929
|
+
await schemaManager.initializeSchemas(currentDb, options);
|
|
2930
|
+
};
|
|
2931
|
+
/**
|
|
2932
|
+
* Retrieves the schema information for a table
|
|
2933
|
+
*
|
|
2934
|
+
* @param table - Table name
|
|
2935
|
+
* @returns Promise resolving to table schema info or null if table doesn't exist
|
|
2936
|
+
* @throws Error if the query fails
|
|
2937
|
+
*/
|
|
2938
|
+
const getTableSchema = async (table) => {
|
|
2939
|
+
validateTableName$1(table);
|
|
2940
|
+
try {
|
|
2941
|
+
if (!await tableExists(table)) return null;
|
|
2942
|
+
const columnRows = await many`
|
|
2943
|
+
SELECT
|
|
2944
|
+
column_name,
|
|
2945
|
+
data_type,
|
|
2946
|
+
is_nullable,
|
|
2947
|
+
column_default
|
|
2948
|
+
FROM information_schema.columns
|
|
2949
|
+
WHERE table_name = ${table}
|
|
2950
|
+
AND table_schema = 'public'
|
|
2951
|
+
ORDER BY ordinal_position
|
|
2952
|
+
`;
|
|
2953
|
+
const pkRows = await many`
|
|
2954
|
+
SELECT kcu.column_name
|
|
2955
|
+
FROM information_schema.table_constraints tc
|
|
2956
|
+
JOIN information_schema.key_column_usage kcu
|
|
2957
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
2958
|
+
AND tc.table_schema = kcu.table_schema
|
|
2959
|
+
WHERE tc.table_name = ${table}
|
|
2960
|
+
AND tc.table_schema = 'public'
|
|
2961
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
2962
|
+
`;
|
|
2963
|
+
const pkColumns = new Set(pkRows.map((row) => row.column_name));
|
|
2964
|
+
const columns = {};
|
|
2965
|
+
for (const row of columnRows) {
|
|
2966
|
+
const colName = row.column_name;
|
|
2967
|
+
columns[colName] = {
|
|
2968
|
+
type: row.data_type,
|
|
2969
|
+
primaryKey: pkColumns.has(colName),
|
|
2970
|
+
notNull: row.is_nullable === "NO",
|
|
2971
|
+
defaultValue: row.column_default
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
const indexRows = await many`
|
|
2975
|
+
SELECT
|
|
2976
|
+
indexname,
|
|
2977
|
+
indexdef
|
|
2978
|
+
FROM pg_indexes
|
|
2979
|
+
WHERE tablename = ${table}
|
|
2980
|
+
AND schemaname = 'public'
|
|
2981
|
+
AND indexname NOT LIKE '%_pkey'
|
|
2982
|
+
`;
|
|
2983
|
+
const indexes = [];
|
|
2984
|
+
for (const row of indexRows) {
|
|
2985
|
+
const indexName = row.indexname;
|
|
2986
|
+
const indexDef = row.indexdef;
|
|
2987
|
+
const colMatch = indexDef.match(/\(([^)]+)\)/);
|
|
2988
|
+
const indexColumns = colMatch ? colMatch[1].split(",").map((col) => col.trim()) : [];
|
|
2989
|
+
const isUnique = indexDef.toUpperCase().includes("UNIQUE");
|
|
2990
|
+
indexes.push({
|
|
2991
|
+
name: indexName,
|
|
2992
|
+
columns: indexColumns,
|
|
2993
|
+
unique: isUnique
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
const fkRows = await many`
|
|
2997
|
+
SELECT
|
|
2998
|
+
kcu.column_name,
|
|
2999
|
+
ccu.table_name AS foreign_table_name,
|
|
3000
|
+
ccu.column_name AS foreign_column_name,
|
|
3001
|
+
rc.delete_rule,
|
|
3002
|
+
rc.update_rule
|
|
3003
|
+
FROM information_schema.table_constraints AS tc
|
|
3004
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
3005
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
3006
|
+
AND tc.table_schema = kcu.table_schema
|
|
3007
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
3008
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
3009
|
+
AND ccu.table_schema = tc.table_schema
|
|
3010
|
+
JOIN information_schema.referential_constraints AS rc
|
|
3011
|
+
ON rc.constraint_name = tc.constraint_name
|
|
3012
|
+
AND rc.constraint_schema = tc.table_schema
|
|
3013
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
3014
|
+
AND tc.table_name = ${table}
|
|
3015
|
+
AND tc.table_schema = 'public'
|
|
3016
|
+
`;
|
|
3017
|
+
const foreignKeys = [];
|
|
3018
|
+
for (const fkRow of fkRows) foreignKeys.push({
|
|
3019
|
+
column: fkRow.column_name,
|
|
3020
|
+
referencesTable: fkRow.foreign_table_name,
|
|
3021
|
+
referencesColumn: fkRow.foreign_column_name,
|
|
3022
|
+
onDelete: fkRow.delete_rule,
|
|
3023
|
+
onUpdate: fkRow.update_rule
|
|
3024
|
+
});
|
|
3025
|
+
return {
|
|
3026
|
+
tableName: table,
|
|
3027
|
+
columns,
|
|
3028
|
+
indexes,
|
|
3029
|
+
foreignKeys
|
|
3030
|
+
};
|
|
3031
|
+
} catch (e) {
|
|
3032
|
+
throw new DatabaseError("Failed to retrieve table schema", {
|
|
3033
|
+
table,
|
|
3034
|
+
originalError: formatDbError(e)
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
const db = {
|
|
3039
|
+
url,
|
|
3040
|
+
client,
|
|
3041
|
+
insert,
|
|
3042
|
+
update,
|
|
3043
|
+
upsert,
|
|
3044
|
+
get,
|
|
3045
|
+
getOrInsert,
|
|
3046
|
+
delete: deleteRecords,
|
|
3047
|
+
count,
|
|
3048
|
+
list,
|
|
3049
|
+
table,
|
|
3050
|
+
many,
|
|
3051
|
+
single,
|
|
3052
|
+
pluck,
|
|
3053
|
+
execute,
|
|
3054
|
+
query,
|
|
3055
|
+
oo,
|
|
3056
|
+
oO,
|
|
3057
|
+
ox,
|
|
3058
|
+
xx,
|
|
3059
|
+
tableExists,
|
|
3060
|
+
syncSchema,
|
|
3061
|
+
initializeSchemas,
|
|
3062
|
+
transaction,
|
|
3063
|
+
beginTransaction,
|
|
3064
|
+
acquireSession,
|
|
3065
|
+
getTableSchema,
|
|
3066
|
+
alterTable: {
|
|
3067
|
+
/**
|
|
3068
|
+
* Adds a new column to an existing table
|
|
3069
|
+
*
|
|
3070
|
+
* @param table - Table name
|
|
3071
|
+
* @param column - Column definition with name
|
|
3072
|
+
* @returns Promise that resolves when column is added
|
|
3073
|
+
* @throws Error if the alter operation fails
|
|
3074
|
+
*/
|
|
3075
|
+
addColumn: async (table, column) => {
|
|
3076
|
+
validateTableName$1(table);
|
|
3077
|
+
validateColumnName$1(column.name);
|
|
3078
|
+
try {
|
|
3079
|
+
const sql = generateAddColumnStatement(table, column, "postgres");
|
|
3080
|
+
await client.query(sql);
|
|
3081
|
+
} catch (e) {
|
|
3082
|
+
throw new DatabaseError("Failed to add column to table", {
|
|
3083
|
+
table,
|
|
3084
|
+
column: column.name,
|
|
3085
|
+
originalError: formatDbError(e)
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
},
|
|
3089
|
+
/**
|
|
3090
|
+
* Adds a new index to an existing table
|
|
3091
|
+
*
|
|
3092
|
+
* @param table - Table name
|
|
3093
|
+
* @param index - Index definition
|
|
3094
|
+
* @returns Promise that resolves when index is created
|
|
3095
|
+
* @throws Error if the create index operation fails
|
|
3096
|
+
*/
|
|
3097
|
+
addIndex: async (table, index) => {
|
|
3098
|
+
validateTableName$1(table);
|
|
3099
|
+
validateIndexName(index.name);
|
|
3100
|
+
for (const col of index.columns) validateColumnName$1(col);
|
|
3101
|
+
try {
|
|
3102
|
+
const sql = generateCreateIndexStatement(table, index);
|
|
3103
|
+
await client.query(sql);
|
|
3104
|
+
} catch (e) {
|
|
3105
|
+
throw new DatabaseError("Failed to create index on table", {
|
|
3106
|
+
table,
|
|
3107
|
+
index: index.name,
|
|
3108
|
+
originalError: formatDbError(e)
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
},
|
|
3113
|
+
vector: createVectorCapabilities(pool)
|
|
3114
|
+
};
|
|
3115
|
+
const finalCacheKey = cacheKey || url;
|
|
3116
|
+
connectionCache.set(finalCacheKey, db);
|
|
3117
|
+
return db;
|
|
3118
|
+
}
|
|
3119
|
+
//#endregion
|
|
3120
|
+
//#region src/index.ts
|
|
3121
|
+
/**
|
|
3122
|
+
* Checks if the provided value is a database instance rather than configuration options
|
|
3123
|
+
*
|
|
3124
|
+
* @param value - Value to check
|
|
3125
|
+
* @returns True if the value appears to be a DatabaseInterface instance
|
|
3126
|
+
*/
|
|
3127
|
+
function isDatabaseInstance(value) {
|
|
3128
|
+
return value && typeof value === "object" && typeof value.client !== "undefined" && typeof value.insert === "function" && typeof value.get === "function" && typeof value.query === "function";
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Creates a database connection based on the provided options, or returns an existing database instance
|
|
3132
|
+
*
|
|
3133
|
+
* Loads configuration from environment variables using the HAVE_SQL_* pattern:
|
|
3134
|
+
* - HAVE_SQL_TYPE → type ('sqlite' | 'postgres' | 'duckdb' | 'json')
|
|
3135
|
+
* - HAVE_SQL_URL → url (connection string)
|
|
3136
|
+
* - HAVE_SQL_HOST → host (database server hostname)
|
|
3137
|
+
* - HAVE_SQL_PORT → port (database server port number)
|
|
3138
|
+
* - HAVE_SQL_DATABASE → database (database name)
|
|
3139
|
+
* - HAVE_SQL_USER → user (authentication username)
|
|
3140
|
+
* - HAVE_SQL_PASSWORD → password (authentication password)
|
|
3141
|
+
*
|
|
3142
|
+
* User-provided options always take precedence over environment variables.
|
|
3143
|
+
*
|
|
3144
|
+
* ## Lazy schemas
|
|
3145
|
+
*
|
|
3146
|
+
* The `schemas` option accepts either an eagerly-built record or a lazy
|
|
3147
|
+
* function (`() => Record<string, SchemaProvider>`). Adapters that manage
|
|
3148
|
+
* tables via migrations (Postgres, SQLite) never resolve the function, so
|
|
3149
|
+
* there is zero cost for callers that pass schemas uniformly regardless of
|
|
3150
|
+
* adapter type. Only JSON and DuckDB adapters call the function.
|
|
3151
|
+
*
|
|
3152
|
+
* ```typescript
|
|
3153
|
+
* // Caller doesn't need to know the adapter type:
|
|
3154
|
+
* await getDatabase({
|
|
3155
|
+
* ...config,
|
|
3156
|
+
* schemas: () => ObjectRegistry.getAllSchemas(),
|
|
3157
|
+
* });
|
|
3158
|
+
* ```
|
|
3159
|
+
*
|
|
3160
|
+
* @param options - Configuration options for the database connection or an existing database instance
|
|
3161
|
+
* @returns Promise resolving to a DatabaseInterface implementation
|
|
3162
|
+
* @throws Error if the database type is invalid
|
|
3163
|
+
*/
|
|
3164
|
+
async function getDatabase(options = {}) {
|
|
3165
|
+
if (isDatabaseInstance(options)) return options;
|
|
3166
|
+
Object.assign(options, loadEnvConfig(options, {
|
|
3167
|
+
packageName: "sql",
|
|
3168
|
+
schema: {
|
|
3169
|
+
type: "string",
|
|
3170
|
+
url: "string",
|
|
3171
|
+
host: "string",
|
|
3172
|
+
port: "number",
|
|
3173
|
+
database: "string",
|
|
3174
|
+
user: "string",
|
|
3175
|
+
password: "string"
|
|
3176
|
+
}
|
|
3177
|
+
}));
|
|
3178
|
+
if (!options.type && (options.url?.startsWith("file:") || options.url === ":memory:")) options.type = "sqlite";
|
|
3179
|
+
if (options.type === "postgres") return (await Promise.resolve().then(() => postgres_exports)).getDatabase(options);
|
|
3180
|
+
if (options.type === "sqlite") return (await import("./chunks/sqlite-YgU9LCVy.js")).getDatabase(options);
|
|
3181
|
+
if (options.type === "duckdb") return (await import("./chunks/duckdb-DykBBzd6.js")).getDatabase(options);
|
|
3182
|
+
if (options.type === "json") return (await Promise.resolve().then(() => json_exports)).getDatabase(options);
|
|
3183
|
+
throw new Error("Invalid database type");
|
|
3184
|
+
}
|
|
3185
|
+
/**
|
|
3186
|
+
* Synchronizes a SQL schema definition with a database
|
|
3187
|
+
* Creates tables if they don't exist and adds missing columns to existing tables
|
|
3188
|
+
*
|
|
3189
|
+
* @param options - Object containing database and schema
|
|
3190
|
+
* @param options.db - Database interface to use
|
|
3191
|
+
* @param options.schema - SQL schema definition
|
|
3192
|
+
* @throws Error if db or schema are missing or if the database doesn't support syncSchema
|
|
3193
|
+
*/
|
|
3194
|
+
async function syncSchema(options) {
|
|
3195
|
+
const { db, schema } = options;
|
|
3196
|
+
if (!db || !schema) throw new Error("db and schema are required");
|
|
3197
|
+
if (db.syncSchema) await db.syncSchema(schema);
|
|
3198
|
+
else throw new Error("Database adapter does not support schema synchronization");
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* Checks if a table exists in the database
|
|
3202
|
+
*
|
|
3203
|
+
* @param db - Database interface to use
|
|
3204
|
+
* @param tableName - Name of the table to check
|
|
3205
|
+
* @returns Promise resolving to boolean indicating if the table exists
|
|
3206
|
+
*/
|
|
3207
|
+
async function tableExists(db, tableName) {
|
|
3208
|
+
return db.tableExists(tableName);
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* Escapes and formats a value for use in SQL queries
|
|
3212
|
+
*
|
|
3213
|
+
* @param value - Value to escape
|
|
3214
|
+
* @returns String representation of the value safe for SQL use
|
|
3215
|
+
*/
|
|
3216
|
+
function escapeSqlValue(value) {
|
|
3217
|
+
if (value === null) return "NULL";
|
|
3218
|
+
if (value instanceof Date) return `'${value.toISOString()}'`;
|
|
3219
|
+
if (typeof value === "number") return value.toString();
|
|
3220
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
3221
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* Validates a column name for use in SQL queries
|
|
3225
|
+
*
|
|
3226
|
+
* @param column - Column name to validate
|
|
3227
|
+
* @returns The validated column name
|
|
3228
|
+
* @throws Error if the column name contains invalid characters
|
|
3229
|
+
*/
|
|
3230
|
+
function validateColumnName(column) {
|
|
3231
|
+
if (!/^[a-zA-Z0-9_.]+$/.test(column)) throw new Error(`Invalid column name: ${column}`);
|
|
3232
|
+
return column;
|
|
3233
|
+
}
|
|
3234
|
+
var src_default = {
|
|
3235
|
+
getDatabase,
|
|
3236
|
+
syncSchema,
|
|
3237
|
+
tableExists,
|
|
3238
|
+
buildWhere
|
|
3239
|
+
};
|
|
3240
|
+
/** @internal */
|
|
3241
|
+
var PACKAGE_VERSION_INITIALIZED = true;
|
|
3242
|
+
//#endregion
|
|
3243
|
+
export { DatabaseSchemaManager, PACKAGE_VERSION_INITIALIZED, assertCanExportDatabase, assertCanImportDatabase, bucketExpr, buildAggregate, buildWhere, checkExpectedTables, checkRelationship, checkUniqueColumn, clearConnectionCache, clearPostgresConnectionCache, convertUniqueIndexesToInlineConstraints, createPostgresDatabase, databaseNameFromUrl, src_default as default, dropPostgresDatabase, dumpPostgresDatabase, escapeSqlValue, formatDbError, getDatabase, isLocalDatabaseUrl, postgresEnvFromUrl, redactDatabaseUrl, resolveSchemas, restorePostgresDatabase, runDoctor, syncSchema, tableExists, validateColumnName };
|
|
3244
|
+
|
|
3245
|
+
//# sourceMappingURL=index.js.map
|