turbine-orm 0.19.2 → 0.21.0
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/README.md +82 -18
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +6 -3
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio.js +6 -7
- package/dist/cjs/client.js +33 -20
- package/dist/cjs/dialect.js +116 -0
- package/dist/cjs/errors.js +19 -1
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +20 -0
- package/dist/cjs/mssql.js +1338 -0
- package/dist/cjs/mysql.js +1052 -0
- package/dist/cjs/query/builder.js +293 -73
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +6 -3
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +12 -2
- package/dist/cli/observe.js +7 -8
- package/dist/cli/studio.js +7 -8
- package/dist/client.d.ts +23 -1
- package/dist/client.js +34 -21
- package/dist/dialect.d.ts +258 -1
- package/dist/dialect.js +83 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +17 -0
- package/dist/generate.js +4 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +18 -0
- package/dist/introspect.js +19 -0
- package/dist/mssql.d.ts +233 -0
- package/dist/mssql.js +1298 -0
- package/dist/mysql.d.ts +174 -0
- package/dist/mysql.js +1012 -0
- package/dist/query/builder.d.ts +97 -0
- package/dist/query/builder.js +294 -74
- package/dist/query/index.d.ts +1 -1
- package/dist/sqlite.d.ts +144 -0
- package/dist/sqlite.js +842 -0
- package/dist/typed-sql.d.ts +7 -5
- package/dist/typed-sql.js +9 -7
- package/package.json +30 -1
package/dist/sqlite.js
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm/sqlite — zero-dependency SQLite engine
|
|
3
|
+
*
|
|
4
|
+
* Binds Turbine to SQLite via Node's built-in `node:sqlite` driver
|
|
5
|
+
* (`DatabaseSync`), so SQLite is a **zero new dependency** engine: the root
|
|
6
|
+
* package's runtime dependency stays exactly `pg`. This is the in-process
|
|
7
|
+
* test / edge / "try it in 10 seconds" engine — `:memory:` databases run
|
|
8
|
+
* entirely in-process with no service container.
|
|
9
|
+
*
|
|
10
|
+
* ## Driver
|
|
11
|
+
*
|
|
12
|
+
* - **Primary:** `node:sqlite` `DatabaseSync` (Node ≥ 22.5, experimental). Emits
|
|
13
|
+
* an `ExperimentalWarning` — harmless. No native build, no extra dependency.
|
|
14
|
+
* - **Fallback:** `better-sqlite3` for Node < 22.5. Not bundled and not required;
|
|
15
|
+
* wrap a `better-sqlite3` handle in the same `PgCompatPool` shape if needed.
|
|
16
|
+
*
|
|
17
|
+
* ## Capabilities & limits (vs PostgreSQL)
|
|
18
|
+
*
|
|
19
|
+
* - `RETURNING` + `ON CONFLICT … DO UPDATE` (SQLite ≥ 3.35) → create / upsert /
|
|
20
|
+
* update / delete return real rows in a single statement (`resultStrategy =
|
|
21
|
+
* 'returning'`, same as Postgres).
|
|
22
|
+
* - **Single-writer:** one connection / one write transaction at a time;
|
|
23
|
+
* concurrent writers get `SQLITE_BUSY` (treated as retryable). `journal_mode =
|
|
24
|
+
* WAL` is enabled for file databases to allow concurrent readers.
|
|
25
|
+
* - **Unsupported (throw `UnsupportedFeatureError`):** pgvector distance ops,
|
|
26
|
+
* LISTEN/NOTIFY (`$listen` / `$notify`), RLS `sessionContext`. Advisory-lock
|
|
27
|
+
* migration locking is unavailable — SQLite is single-writer, so migrations
|
|
28
|
+
* serialize naturally.
|
|
29
|
+
* - **Type affinity caveats:** SQLite has no native `BOOLEAN` (0/1 integers) or
|
|
30
|
+
* `DATE` (TEXT/INTEGER). Booleans bind as 1/0; `Date` values bind as ISO-8601
|
|
31
|
+
* text; columns declared `TIMESTAMP`/`DATETIME`/`DATE` are coerced back to
|
|
32
|
+
* `Date`. Integers wider than `Number.MAX_SAFE_INTEGER` come back as strings
|
|
33
|
+
* (the same safe-int policy Turbine uses for Postgres `int8`).
|
|
34
|
+
* - **Case-insensitive matching** uses `COLLATE NOCASE`, which is **ASCII-only**
|
|
35
|
+
* (no Unicode case folding).
|
|
36
|
+
*
|
|
37
|
+
* ## Example — `:memory:` database
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* import { turbineSqlite } from 'turbine-orm/sqlite';
|
|
41
|
+
* import { SCHEMA } from './generated/turbine/metadata.js';
|
|
42
|
+
*
|
|
43
|
+
* const db = turbineSqlite(':memory:', SCHEMA);
|
|
44
|
+
* const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
|
|
45
|
+
* await db.disconnect();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
import { createRequire } from 'node:module';
|
|
49
|
+
import { TurbineClient } from './client.js';
|
|
50
|
+
import { postgresDialect, } from './dialect.js';
|
|
51
|
+
import { ConnectionError } from './errors.js';
|
|
52
|
+
import { isDateType, singularize, snakeToCamel, } from './schema.js';
|
|
53
|
+
let cachedDatabaseSync;
|
|
54
|
+
/**
|
|
55
|
+
* Lazily load `node:sqlite`'s `DatabaseSync` constructor.
|
|
56
|
+
*
|
|
57
|
+
* `node:sqlite` is a built-in only on Node >= 22.5, so importing it at module
|
|
58
|
+
* top-level would make `import 'turbine-orm/sqlite'` — and any module that
|
|
59
|
+
* merely re-exports `sqliteDialect` (e.g. the dialect test suite) — throw
|
|
60
|
+
* `ERR_UNKNOWN_BUILTIN_MODULE` on Node 20. Deferring the require to the moment a
|
|
61
|
+
* connection is actually opened keeps the dialect (pure SQL generation) usable
|
|
62
|
+
* everywhere and scopes the Node-version requirement to `turbineSqlite()`.
|
|
63
|
+
*
|
|
64
|
+
* `createRequire` is anchored on `process.cwd()` so this works identically in
|
|
65
|
+
* the ESM and CJS builds (no `import.meta` / `__filename`, which differ between
|
|
66
|
+
* them); built-in module resolution ignores the anchor entirely.
|
|
67
|
+
*/
|
|
68
|
+
function loadDatabaseSync() {
|
|
69
|
+
if (cachedDatabaseSync)
|
|
70
|
+
return cachedDatabaseSync;
|
|
71
|
+
let ctor;
|
|
72
|
+
try {
|
|
73
|
+
const req = createRequire(process.cwd());
|
|
74
|
+
ctor = req('node:sqlite').DatabaseSync;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new ConnectionError("[turbine] turbine-orm/sqlite requires Node's built-in 'node:sqlite' module (Node >= 22.5). " +
|
|
78
|
+
`Upgrade Node to >= 22.5, or pass an already-open better-sqlite3-compatible handle. (${err.message})`);
|
|
79
|
+
}
|
|
80
|
+
if (typeof ctor !== 'function') {
|
|
81
|
+
throw new ConnectionError("[turbine] 'node:sqlite' loaded but did not export a DatabaseSync constructor — this Node build may lack SQLite support.");
|
|
82
|
+
}
|
|
83
|
+
cachedDatabaseSync = ctor;
|
|
84
|
+
return cachedDatabaseSync;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Coerce an arbitrary JS value into something `node:sqlite` can bind.
|
|
88
|
+
*
|
|
89
|
+
* `node:sqlite` throws on `boolean`, `undefined`, `Date`, and plain objects, so
|
|
90
|
+
* we normalize: booleans → 1/0, `undefined`/`null` → NULL, `Date` → ISO text,
|
|
91
|
+
* `Uint8Array`/`Buffer` → BLOB, and any remaining object/array → JSON text
|
|
92
|
+
* (matches how Turbine already pre-serializes JSON filter params).
|
|
93
|
+
*/
|
|
94
|
+
function toSqliteParam(value) {
|
|
95
|
+
if (value === undefined || value === null)
|
|
96
|
+
return null;
|
|
97
|
+
switch (typeof value) {
|
|
98
|
+
case 'boolean':
|
|
99
|
+
return value ? 1 : 0;
|
|
100
|
+
case 'number':
|
|
101
|
+
case 'bigint':
|
|
102
|
+
case 'string':
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
if (value instanceof Date)
|
|
106
|
+
return value.toISOString();
|
|
107
|
+
if (value instanceof Uint8Array)
|
|
108
|
+
return value; // also covers Buffer
|
|
109
|
+
return JSON.stringify(value);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a single column value read from SQLite. With `setReadBigInts(true)`
|
|
113
|
+
* every integer column comes back as a `bigint`; apply the same safe-integer
|
|
114
|
+
* policy Turbine uses for Postgres `int8` — number when it fits in a JS safe
|
|
115
|
+
* integer, otherwise the decimal string to avoid precision loss. Never mutates
|
|
116
|
+
* any global parser state (the policy lives entirely in this shim).
|
|
117
|
+
*/
|
|
118
|
+
function normalizeValue(value) {
|
|
119
|
+
if (typeof value === 'bigint') {
|
|
120
|
+
const asNumber = Number(value);
|
|
121
|
+
return Number.isSafeInteger(asNumber) ? asNumber : value.toString();
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
/** Convert a `node:sqlite` null-prototype row into a normalized plain object. */
|
|
126
|
+
function normalizeRow(row) {
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const key of Object.keys(row)) {
|
|
129
|
+
out[key] = normalizeValue(row[key]);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Statement classification + error translation
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
/** Statements whose first keyword yields a result set. */
|
|
137
|
+
const ROW_RETURNING_HEAD = /^\s*(?:select|with|pragma|explain|values)\b/i;
|
|
138
|
+
/**
|
|
139
|
+
* Decide whether a statement produces rows (use `.all()`) or only a change
|
|
140
|
+
* count (use `.run()` to capture `changes` for updateMany/deleteMany).
|
|
141
|
+
* A `RETURNING` clause turns any write into a row-producing statement.
|
|
142
|
+
*/
|
|
143
|
+
function statementReturnsRows(sql) {
|
|
144
|
+
return ROW_RETURNING_HEAD.test(sql) || /\breturning\b/i.test(sql);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Augment a `node:sqlite` error with the Postgres-shaped fields that
|
|
148
|
+
* `wrapPgError` understands, so SQLite constraint failures surface as the same
|
|
149
|
+
* typed Turbine errors as Postgres (E008/E009/E010/E011) and `SQLITE_BUSY`
|
|
150
|
+
* becomes a retryable error. The original error (with its real SQLite message)
|
|
151
|
+
* is preserved as the `cause`. Returns the value unchanged when it is not a
|
|
152
|
+
* recognizable SQLite error.
|
|
153
|
+
*
|
|
154
|
+
* `wrapPgError` is invoked downstream (in the query executor and the
|
|
155
|
+
* transaction proxy), so we only annotate here — we never throw a `new`
|
|
156
|
+
* Turbine error from the driver itself.
|
|
157
|
+
*/
|
|
158
|
+
function augmentSqliteError(err) {
|
|
159
|
+
if (!err || typeof err !== 'object')
|
|
160
|
+
return err;
|
|
161
|
+
const e = err;
|
|
162
|
+
if (typeof e.errcode !== 'number')
|
|
163
|
+
return err;
|
|
164
|
+
const target = e;
|
|
165
|
+
const message = e.message ?? '';
|
|
166
|
+
// Primary SQLite result code = errcode & 0xff; the rest are extended codes.
|
|
167
|
+
const primary = e.errcode & 0xff;
|
|
168
|
+
switch (e.errcode) {
|
|
169
|
+
// SQLITE_CONSTRAINT_UNIQUE (2067) / SQLITE_CONSTRAINT_PRIMARYKEY (1555)
|
|
170
|
+
case 2067:
|
|
171
|
+
case 1555: {
|
|
172
|
+
// "UNIQUE constraint failed: tbl.col[, tbl.col2]"
|
|
173
|
+
const m = /constraint failed:\s*(.+)$/i.exec(message);
|
|
174
|
+
const pairs = m ? m[1].split(',').map((s) => s.trim()) : [];
|
|
175
|
+
const cols = pairs.map((p) => p.split('.').pop() ?? p);
|
|
176
|
+
target.code = '23505';
|
|
177
|
+
target.table = pairs[0]?.split('.')[0];
|
|
178
|
+
target.detail = `Key (${cols.join(', ')})=() already exists.`;
|
|
179
|
+
return err;
|
|
180
|
+
}
|
|
181
|
+
// SQLITE_CONSTRAINT_FOREIGNKEY (787)
|
|
182
|
+
case 787:
|
|
183
|
+
target.code = '23503';
|
|
184
|
+
return err;
|
|
185
|
+
// SQLITE_CONSTRAINT_NOTNULL (1299)
|
|
186
|
+
case 1299: {
|
|
187
|
+
const m = /constraint failed:\s*([^.\s]+)\.(\S+)/i.exec(message);
|
|
188
|
+
target.code = '23502';
|
|
189
|
+
if (m) {
|
|
190
|
+
target.table = m[1];
|
|
191
|
+
target.column = m[2];
|
|
192
|
+
}
|
|
193
|
+
return err;
|
|
194
|
+
}
|
|
195
|
+
// SQLITE_CONSTRAINT_CHECK (275)
|
|
196
|
+
case 275:
|
|
197
|
+
target.code = '23514';
|
|
198
|
+
return err;
|
|
199
|
+
default:
|
|
200
|
+
// SQLITE_BUSY (5) / SQLITE_LOCKED (6) / SQLITE_BUSY_SNAPSHOT (261) etc.
|
|
201
|
+
if (primary === 5 || primary === 6) {
|
|
202
|
+
// Map to serialization_failure so withRetry()/$retry() retry it.
|
|
203
|
+
target.code = '40001';
|
|
204
|
+
}
|
|
205
|
+
return err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function normalizeQueryArgs(arg, values) {
|
|
209
|
+
if (typeof arg === 'string')
|
|
210
|
+
return { text: arg, params: values ?? [] };
|
|
211
|
+
return { text: arg.text, params: arg.values ?? values ?? [] };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Execute one statement against a `DatabaseSync` handle and shape the result
|
|
215
|
+
* like a `pg.QueryResult` (`{ rows, rowCount }`). Row-returning statements use
|
|
216
|
+
* `.all()`; pure writes use `.run()` so `changes` populates `rowCount` (which
|
|
217
|
+
* `updateMany` / `deleteMany` read). `lastID` mirrors mysql2's insert-id for
|
|
218
|
+
* parity with the `reselect` strategy (unused by SQLite's `returning` path).
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* Bind the positional `params[]` (in 1-indexed generation order) to the named
|
|
222
|
+
* `:p1`, `:p2`, … placeholders the dialect emits. Mapping by NAME makes binding
|
|
223
|
+
* independent of where each placeholder lands in the SQL text — the same
|
|
224
|
+
* guarantee Postgres' numbered `$N` gives. Returns `undefined` when there are no
|
|
225
|
+
* params so parameter-less statements (BEGIN/COMMIT/DDL) bind nothing.
|
|
226
|
+
*/
|
|
227
|
+
function toNamedBinding(values) {
|
|
228
|
+
if (values.length === 0)
|
|
229
|
+
return undefined;
|
|
230
|
+
const named = {};
|
|
231
|
+
for (let i = 0; i < values.length; i++) {
|
|
232
|
+
named[`p${i + 1}`] = toSqliteParam(values[i]);
|
|
233
|
+
}
|
|
234
|
+
return named;
|
|
235
|
+
}
|
|
236
|
+
function runStatement(db, sql, values) {
|
|
237
|
+
const binding = toNamedBinding(values);
|
|
238
|
+
let stmt;
|
|
239
|
+
try {
|
|
240
|
+
stmt = db.prepare(sql);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
throw augmentSqliteError(err);
|
|
244
|
+
}
|
|
245
|
+
stmt.setReadBigInts(true);
|
|
246
|
+
try {
|
|
247
|
+
if (statementReturnsRows(sql)) {
|
|
248
|
+
const rows = (binding ? stmt.all(binding) : stmt.all()).map(normalizeRow);
|
|
249
|
+
return { rows, rowCount: rows.length };
|
|
250
|
+
}
|
|
251
|
+
const info = binding ? stmt.run(binding) : stmt.run();
|
|
252
|
+
const changes = typeof info.changes === 'bigint' ? Number(info.changes) : info.changes;
|
|
253
|
+
const lastID = typeof info.lastInsertRowid === 'bigint' ? Number(info.lastInsertRowid) : info.lastInsertRowid;
|
|
254
|
+
return { rows: [], rowCount: changes, lastID, insertId: lastID };
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
throw augmentSqliteError(err);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* A `PgCompatPool` backed by a single `node:sqlite` `DatabaseSync` connection.
|
|
262
|
+
* SQLite is single-connection by nature (a `:memory:` database is per-handle),
|
|
263
|
+
* so `connect()` hands back a client over the **same** handle — transactions
|
|
264
|
+
* (`BEGIN`/`COMMIT`/`ROLLBACK`, `SAVEPOINT` nesting) just run on it. Queries are
|
|
265
|
+
* serialized; this is the documented single-writer model.
|
|
266
|
+
*/
|
|
267
|
+
export class SqlitePool {
|
|
268
|
+
/** The underlying `node:sqlite` handle — exposed as an escape hatch (seed/DDL). */
|
|
269
|
+
db;
|
|
270
|
+
closed = false;
|
|
271
|
+
constructor(db) {
|
|
272
|
+
this.db = db;
|
|
273
|
+
}
|
|
274
|
+
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape; runStatement returns plain objects.
|
|
275
|
+
async query(text, values) {
|
|
276
|
+
const { text: sql, params } = normalizeQueryArgs(text, values);
|
|
277
|
+
return runStatement(this.db, sql, params);
|
|
278
|
+
}
|
|
279
|
+
async connect() {
|
|
280
|
+
const db = this.db;
|
|
281
|
+
return {
|
|
282
|
+
// biome-ignore lint/suspicious/noExplicitAny: see query() above.
|
|
283
|
+
query: async (text, values) => {
|
|
284
|
+
const { text: sql, params } = normalizeQueryArgs(text, values);
|
|
285
|
+
return runStatement(db, sql, params);
|
|
286
|
+
},
|
|
287
|
+
release: () => {
|
|
288
|
+
// Single shared connection — nothing to return to a pool.
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
async end() {
|
|
293
|
+
if (this.closed)
|
|
294
|
+
return;
|
|
295
|
+
this.closed = true;
|
|
296
|
+
this.db.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Type mapping (SQLite declared type → TypeScript)
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
/**
|
|
303
|
+
* Map a SQLite declared column type (type affinity) to a TypeScript type.
|
|
304
|
+
* SQLite is dynamically typed; we read the declared `PRAGMA table_info` type.
|
|
305
|
+
*/
|
|
306
|
+
export function sqliteTypeToTs(declaredType, nullable) {
|
|
307
|
+
const t = declaredType.toLowerCase();
|
|
308
|
+
let base;
|
|
309
|
+
if (/int/.test(t))
|
|
310
|
+
base = 'number';
|
|
311
|
+
else if (/(real|floa|doub)/.test(t))
|
|
312
|
+
base = 'number';
|
|
313
|
+
else if (/bool/.test(t))
|
|
314
|
+
base = 'boolean';
|
|
315
|
+
else if (/(timestamp|datetime|date)/.test(t))
|
|
316
|
+
base = 'Date';
|
|
317
|
+
else if (/(char|clob|text)/.test(t))
|
|
318
|
+
base = 'string';
|
|
319
|
+
else if (/blob/.test(t))
|
|
320
|
+
base = 'Uint8Array';
|
|
321
|
+
else if (/(numeric|decimal)/.test(t))
|
|
322
|
+
base = 'string';
|
|
323
|
+
else if (t === '')
|
|
324
|
+
base = 'unknown'; // no declared affinity (BLOB affinity)
|
|
325
|
+
else
|
|
326
|
+
base = 'unknown';
|
|
327
|
+
return nullable ? `${base} | null` : base;
|
|
328
|
+
}
|
|
329
|
+
/** Is a SQLite declared type a date/time type (so values coerce back to `Date`)? */
|
|
330
|
+
function isSqliteDateType(declaredType) {
|
|
331
|
+
const t = declaredType.toLowerCase();
|
|
332
|
+
return isDateType(t) || /(timestamp|datetime|date)/.test(t);
|
|
333
|
+
}
|
|
334
|
+
/** Map a schema-builder (Postgres-flavored) column type to a SQLite affinity. */
|
|
335
|
+
function sqliteColumnAffinity(type) {
|
|
336
|
+
const t = type.toUpperCase();
|
|
337
|
+
if (/SERIAL|INT/.test(t))
|
|
338
|
+
return 'INTEGER';
|
|
339
|
+
if (/REAL|FLOAT|DOUBLE/.test(t))
|
|
340
|
+
return 'REAL';
|
|
341
|
+
if (/NUMERIC|DECIMAL|MONEY/.test(t))
|
|
342
|
+
return 'NUMERIC';
|
|
343
|
+
if (/BLOB|BYTEA/.test(t))
|
|
344
|
+
return 'BLOB';
|
|
345
|
+
if (/BOOL/.test(t))
|
|
346
|
+
return 'INTEGER';
|
|
347
|
+
// TEXT, VARCHAR, CHAR, UUID, JSON(B), TIMESTAMP(TZ), DATE, TIME, ENUM, …
|
|
348
|
+
return 'TEXT';
|
|
349
|
+
}
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// sqliteDialect — the full Dialect contract for SQLite
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
/**
|
|
354
|
+
* SQLite implementation of the {@link Dialect} contract. Standardizes on `"…"`
|
|
355
|
+
* identifier quoting and positional `?` placeholders, uses `json_object` /
|
|
356
|
+
* `json_group_array` for the single-query nested-relation engine (with the
|
|
357
|
+
* critical `json(...)` subresult wrap so nested objects are real JSON, not
|
|
358
|
+
* SQLite's strings-of-strings double-encoding), keeps `RETURNING` + `ON CONFLICT`
|
|
359
|
+
* (SQLite ≥ 3.35), and disables the Postgres-only capabilities (vector,
|
|
360
|
+
* LISTEN/NOTIFY, RLS, advisory locks).
|
|
361
|
+
*/
|
|
362
|
+
export const sqliteDialect = {
|
|
363
|
+
...postgresDialect,
|
|
364
|
+
name: 'sqlite',
|
|
365
|
+
resultStrategy: 'returning', // SQLite ≥ 3.35 has RETURNING
|
|
366
|
+
supportsReturning: true,
|
|
367
|
+
supportsILike: false,
|
|
368
|
+
supportsVector: false,
|
|
369
|
+
supportsListenNotify: false,
|
|
370
|
+
supportsRLS: false,
|
|
371
|
+
supportsAdvisoryLock: false,
|
|
372
|
+
// json_group_array / json_object have no inline ORDER BY argument, so every
|
|
373
|
+
// ordered to-many relation is forced through the inner-subquery rewrite.
|
|
374
|
+
aggSupportsInlineOrderBy: false,
|
|
375
|
+
jsonPathSupport: 'function',
|
|
376
|
+
emptyJsonArrayLiteral: "json('[]')",
|
|
377
|
+
nullJsonLiteral: 'NULL',
|
|
378
|
+
// Named placeholders (`:p1`, `:p2`, …) — NOT positional `?`. Turbine pushes
|
|
379
|
+
// params in 1-indexed generation order but may EMIT them in a different SQL
|
|
380
|
+
// text position (e.g. a `with`-relation LIMIT lands in the SELECT list, ahead
|
|
381
|
+
// of the outer WHERE). Postgres reconciles this via numbered `$N`; positional
|
|
382
|
+
// `?` cannot. SQLite named parameters bind by name, so `:pN` ↔ `params[N-1]`
|
|
383
|
+
// exactly mirrors Postgres' `$N` semantics regardless of text order. The
|
|
384
|
+
// driver binds via a `{ p1, p2, … }` object built from the positional array.
|
|
385
|
+
paramPlaceholder(index) {
|
|
386
|
+
return `:p${index}`;
|
|
387
|
+
},
|
|
388
|
+
quoteIdentifier(name) {
|
|
389
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
390
|
+
},
|
|
391
|
+
buildJsonObject(pairs) {
|
|
392
|
+
const args = pairs.map(([key, expr]) => `'${this.escapeStringLiteral(key)}', ${expr}`);
|
|
393
|
+
return `json_object(${args.join(', ')})`;
|
|
394
|
+
},
|
|
395
|
+
buildJsonArrayAgg(jsonObjectExpr, orderBy) {
|
|
396
|
+
// json() wraps each aggregated object so SQLite stores real JSON, not an
|
|
397
|
+
// escaped string; COALESCE handles the empty-group NULL.
|
|
398
|
+
const suffix = orderBy ? ` ${orderBy}` : '';
|
|
399
|
+
return `COALESCE(json_group_array(json(${jsonObjectExpr}))${suffix}, json('[]'))`;
|
|
400
|
+
},
|
|
401
|
+
/**
|
|
402
|
+
* SQLite's `json_group_array` double-encodes nested objects unless each nested
|
|
403
|
+
* subresult is `json(...)`-wrapped. This is the whole point of the hook: it
|
|
404
|
+
* keeps `users[0].posts[0].author.name` a real parsed tree instead of a string
|
|
405
|
+
* containing a string containing JSON.
|
|
406
|
+
*/
|
|
407
|
+
wrapJsonSubresult(subquery, fallback) {
|
|
408
|
+
return `COALESCE(json((${subquery})), ${fallback})`;
|
|
409
|
+
},
|
|
410
|
+
castAggregate(expr, target) {
|
|
411
|
+
return `CAST(${expr} AS ${target === 'int' ? 'INTEGER' : 'REAL'})`;
|
|
412
|
+
},
|
|
413
|
+
// No `= ANY(array)` in SQLite. `json_each` expands a single JSON-array param
|
|
414
|
+
// into a row set, keeping ONE placeholder (so the SQL cache stays valid
|
|
415
|
+
// regardless of list length) and handling the empty-list case correctly.
|
|
416
|
+
buildInClause(expr, paramRef, negated) {
|
|
417
|
+
return `${expr} ${negated ? 'NOT IN' : 'IN'} (SELECT value FROM json_each(${paramRef}))`;
|
|
418
|
+
},
|
|
419
|
+
inClauseParam(values) {
|
|
420
|
+
return JSON.stringify(values ?? []);
|
|
421
|
+
},
|
|
422
|
+
buildReturningClause(selection = '*') {
|
|
423
|
+
return ` RETURNING ${selection}`;
|
|
424
|
+
},
|
|
425
|
+
buildInsertStatement(input) {
|
|
426
|
+
return (`INSERT INTO ${input.table} (${input.columns.join(', ')}) ` +
|
|
427
|
+
`VALUES (${input.valuePlaceholders.join(', ')})${this.buildReturningClause(input.returning)}`);
|
|
428
|
+
},
|
|
429
|
+
buildBulkInsertStatement(input) {
|
|
430
|
+
// No UNNEST in SQLite — emit multi-row VALUES with flattened, named
|
|
431
|
+
// placeholders (`:p1`, `:p2`, …) matching the flat param order.
|
|
432
|
+
let n = 0;
|
|
433
|
+
const placeholders = input.rowValues
|
|
434
|
+
.map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
|
|
435
|
+
.join(', ');
|
|
436
|
+
const conflict = input.skipDuplicates ? ' ON CONFLICT DO NOTHING' : '';
|
|
437
|
+
return {
|
|
438
|
+
sql: `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES ${placeholders}` +
|
|
439
|
+
`${conflict}${this.buildReturningClause(input.returning)}`,
|
|
440
|
+
params: input.rowValues.flat(),
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
buildUpsertStatement(input) {
|
|
444
|
+
return (`INSERT INTO ${input.table} (${input.insertColumns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})` +
|
|
445
|
+
` ON CONFLICT (${input.conflictColumns.join(', ')}) DO UPDATE SET ${input.updateSetClauses.join(', ')}` +
|
|
446
|
+
this.buildReturningClause(input.returning));
|
|
447
|
+
},
|
|
448
|
+
buildInsensitiveLike(column, paramRef) {
|
|
449
|
+
// COLLATE NOCASE is ASCII-only (no Unicode case folding) — documented limit.
|
|
450
|
+
return `${column} LIKE ${paramRef} COLLATE NOCASE`;
|
|
451
|
+
},
|
|
452
|
+
buildJsonContains(column, paramRef) {
|
|
453
|
+
// Emulated containment: true when any top-level JSON value equals the param.
|
|
454
|
+
// Limited vs Postgres `@>` (no deep/object containment) — jsonPathSupport='function'.
|
|
455
|
+
return `EXISTS (SELECT 1 FROM json_each(${column}) WHERE json_each.value = ${paramRef})`;
|
|
456
|
+
},
|
|
457
|
+
buildJsonPathExtract(column, pathParamRef) {
|
|
458
|
+
return `json_extract(${column}, ${pathParamRef})`;
|
|
459
|
+
},
|
|
460
|
+
// ---- Type mapping -------------------------------------------------------
|
|
461
|
+
typeToTypeScript(dialectType, nullable) {
|
|
462
|
+
return sqliteTypeToTs(dialectType, nullable);
|
|
463
|
+
},
|
|
464
|
+
// SQLite has no true array columns; bulk insert uses multi-row VALUES, so no
|
|
465
|
+
// array cast is ever needed. Returning undefined disables the UNNEST path.
|
|
466
|
+
arrayType: undefined,
|
|
467
|
+
// ---- DDL ----------------------------------------------------------------
|
|
468
|
+
buildColumnType(input) {
|
|
469
|
+
return sqliteColumnAffinity(input.type);
|
|
470
|
+
},
|
|
471
|
+
buildColumnDefinition(input) {
|
|
472
|
+
const isSerial = /serial/i.test(input.type);
|
|
473
|
+
const parts = [input.name];
|
|
474
|
+
if (input.primaryKey && isSerial) {
|
|
475
|
+
// The canonical SQLite auto-increment PK (implicitly NOT NULL).
|
|
476
|
+
parts.push('INTEGER PRIMARY KEY AUTOINCREMENT');
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
parts.push(this.buildColumnType(input));
|
|
480
|
+
if (input.primaryKey)
|
|
481
|
+
parts.push('PRIMARY KEY');
|
|
482
|
+
if (input.unique && !input.primaryKey)
|
|
483
|
+
parts.push('UNIQUE');
|
|
484
|
+
if (input.notNull)
|
|
485
|
+
parts.push('NOT NULL');
|
|
486
|
+
}
|
|
487
|
+
if (input.defaultValue != null)
|
|
488
|
+
parts.push(`DEFAULT ${input.defaultValue}`);
|
|
489
|
+
if (input.references)
|
|
490
|
+
parts.push(`REFERENCES ${input.references.table}(${input.references.column})`);
|
|
491
|
+
return parts.join(' ');
|
|
492
|
+
},
|
|
493
|
+
buildCreateIndexStatement(input) {
|
|
494
|
+
return `CREATE INDEX IF NOT EXISTS ${input.name} ON ${input.table}(${input.columns.join(', ')});`;
|
|
495
|
+
},
|
|
496
|
+
buildCreateTableStatement(input) {
|
|
497
|
+
const body = input.definitions.map((d) => ` ${d}`).join(',\n');
|
|
498
|
+
return `CREATE TABLE ${input.table} (\n${body}\n);`;
|
|
499
|
+
},
|
|
500
|
+
// ---- Migration tracking -------------------------------------------------
|
|
501
|
+
// buildMigrationSelectApplied / UpdateChecksum / InsertApplied / DeleteApplied
|
|
502
|
+
// are inherited from postgresDialect: they call `this.paramPlaceholder(n)`
|
|
503
|
+
// (→ `?`) and `ON CONFLICT (name) DO NOTHING`, both valid SQLite. Only the
|
|
504
|
+
// tracking-table DDL (SERIAL / TIMESTAMPTZ) needs a SQLite-specific override.
|
|
505
|
+
buildMigrationTrackingTable(table) {
|
|
506
|
+
return `
|
|
507
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
508
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
509
|
+
name TEXT NOT NULL UNIQUE,
|
|
510
|
+
checksum TEXT NOT NULL,
|
|
511
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
512
|
+
);
|
|
513
|
+
`;
|
|
514
|
+
},
|
|
515
|
+
// ---- Streaming ----------------------------------------------------------
|
|
516
|
+
/**
|
|
517
|
+
* SQLite is in-process: the whole result set already lives in memory, so the
|
|
518
|
+
* "stream" simply fetches once via the pooled connection and yields the rows
|
|
519
|
+
* in `batchSize` chunks. No server-side cursor (and none needed).
|
|
520
|
+
*/
|
|
521
|
+
async *openStream(connection, sql, params, batchSize) {
|
|
522
|
+
const result = await connection.query(sql, params);
|
|
523
|
+
for (let i = 0; i < result.rows.length; i += batchSize) {
|
|
524
|
+
yield result.rows.slice(i, i + batchSize);
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
// ---- Introspection ------------------------------------------------------
|
|
528
|
+
introspector: {
|
|
529
|
+
async introspect(options) {
|
|
530
|
+
return introspectSqlite(options);
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
function pragma(db, sql) {
|
|
535
|
+
// PRAGMA / SELECT against sqlite_master — read-only, identifiers are SQLite
|
|
536
|
+
// catalog names (never user input here), values normalized for safe ints.
|
|
537
|
+
return db.prepare(sql).all().map(normalizeRow);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Read a live SQLite database (an open `DatabaseSync` handle) into the same
|
|
541
|
+
* {@link SchemaMetadata} shape the Postgres catalog introspector produces.
|
|
542
|
+
* Exposed directly so callers (and tests) can introspect an in-process
|
|
543
|
+
* `:memory:` database without round-tripping through a file path.
|
|
544
|
+
*/
|
|
545
|
+
export function introspectSqliteDatabase(db, options = {}) {
|
|
546
|
+
// ----- Tables (skip SQLite internal + the migration tracking table) -----
|
|
547
|
+
let tableNames = pragma(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map((r) => r.name);
|
|
548
|
+
if (options.include?.length) {
|
|
549
|
+
const inc = new Set(options.include);
|
|
550
|
+
tableNames = tableNames.filter((t) => inc.has(t));
|
|
551
|
+
}
|
|
552
|
+
if (options.exclude?.length) {
|
|
553
|
+
const exc = new Set(options.exclude);
|
|
554
|
+
tableNames = tableNames.filter((t) => !exc.has(t));
|
|
555
|
+
}
|
|
556
|
+
const tableSet = new Set(tableNames);
|
|
557
|
+
const columnsByTable = new Map();
|
|
558
|
+
const pkByTable = new Map();
|
|
559
|
+
const uniqueByTable = new Map();
|
|
560
|
+
const indexesByTable = new Map();
|
|
561
|
+
const foreignKeys = [];
|
|
562
|
+
for (const tableName of tableNames) {
|
|
563
|
+
const q = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
564
|
+
// ----- Columns + primary key -----
|
|
565
|
+
const cols = pragma(db, `PRAGMA table_info(${q(tableName)})`);
|
|
566
|
+
const colMeta = [];
|
|
567
|
+
const pkCols = [];
|
|
568
|
+
for (const c of cols) {
|
|
569
|
+
const declared = c.type || '';
|
|
570
|
+
colMeta.push({
|
|
571
|
+
name: c.name,
|
|
572
|
+
field: snakeToCamel(c.name),
|
|
573
|
+
dialectType: declared,
|
|
574
|
+
pgType: declared,
|
|
575
|
+
tsType: sqliteTypeToTs(declared, c.notnull === 0 && c.pk === 0),
|
|
576
|
+
nullable: c.notnull === 0 && c.pk === 0,
|
|
577
|
+
hasDefault: c.dflt_value !== null || c.pk === 1,
|
|
578
|
+
isArray: false,
|
|
579
|
+
arrayType: undefined,
|
|
580
|
+
pgArrayType: 'text[]',
|
|
581
|
+
});
|
|
582
|
+
if (c.pk > 0)
|
|
583
|
+
pkCols.push({ name: c.name, order: c.pk });
|
|
584
|
+
}
|
|
585
|
+
columnsByTable.set(tableName, colMeta);
|
|
586
|
+
pkByTable.set(tableName, pkCols.sort((a, b) => a.order - b.order).map((p) => p.name));
|
|
587
|
+
// ----- Foreign keys -----
|
|
588
|
+
const fks = pragma(db, `PRAGMA foreign_key_list(${q(tableName)})`);
|
|
589
|
+
// Group by FK id (composite FKs share an id), ordered by seq.
|
|
590
|
+
const fkById = new Map();
|
|
591
|
+
for (const fk of fks.sort((a, b) => a.id - b.id || a.seq - b.seq)) {
|
|
592
|
+
let entry = fkById.get(fk.id);
|
|
593
|
+
if (!entry) {
|
|
594
|
+
entry = {
|
|
595
|
+
sourceTable: tableName,
|
|
596
|
+
sourceColumns: [],
|
|
597
|
+
targetTable: fk.table,
|
|
598
|
+
targetColumns: [],
|
|
599
|
+
constraintName: `fk_${tableName}_${fk.id}`,
|
|
600
|
+
};
|
|
601
|
+
fkById.set(fk.id, entry);
|
|
602
|
+
}
|
|
603
|
+
entry.sourceColumns.push(fk.from);
|
|
604
|
+
// `to` is null when the FK references the target's PK implicitly.
|
|
605
|
+
if (fk.to)
|
|
606
|
+
entry.targetColumns.push(fk.to);
|
|
607
|
+
}
|
|
608
|
+
for (const entry of fkById.values()) {
|
|
609
|
+
if (entry.targetColumns.length === 0) {
|
|
610
|
+
// Implicit reference → the target table's primary key.
|
|
611
|
+
const targetPk = pragma(db, `PRAGMA table_info(${q(entry.targetTable)})`)
|
|
612
|
+
.filter((c) => c.pk > 0)
|
|
613
|
+
.sort((a, b) => a.pk - b.pk)
|
|
614
|
+
.map((c) => c.name);
|
|
615
|
+
entry.targetColumns = targetPk.length > 0 ? targetPk : ['id'];
|
|
616
|
+
}
|
|
617
|
+
if (tableSet.has(entry.targetTable))
|
|
618
|
+
foreignKeys.push(entry);
|
|
619
|
+
}
|
|
620
|
+
// ----- Indexes + unique constraints -----
|
|
621
|
+
const idxList = pragma(db, `PRAGMA index_list(${q(tableName)})`);
|
|
622
|
+
const idxMeta = [];
|
|
623
|
+
const uniques = [];
|
|
624
|
+
for (const idx of idxList) {
|
|
625
|
+
const idxCols = pragma(db, `PRAGMA index_info(${q(idx.name)})`)
|
|
626
|
+
.sort((a, b) => a.seqno - b.seqno)
|
|
627
|
+
.map((c) => c.name);
|
|
628
|
+
idxMeta.push({
|
|
629
|
+
name: idx.name,
|
|
630
|
+
columns: idxCols,
|
|
631
|
+
unique: idx.unique === 1,
|
|
632
|
+
definition: `${idx.unique === 1 ? 'UNIQUE ' : ''}INDEX ${idx.name} ON ${tableName}(${idxCols.join(', ')})`,
|
|
633
|
+
});
|
|
634
|
+
if (idx.unique === 1 && idxCols.length > 0)
|
|
635
|
+
uniques.push(idxCols);
|
|
636
|
+
}
|
|
637
|
+
indexesByTable.set(tableName, idxMeta);
|
|
638
|
+
uniqueByTable.set(tableName, uniques);
|
|
639
|
+
}
|
|
640
|
+
// ----- Build relations from foreign keys (belongsTo + hasMany) -----
|
|
641
|
+
const fkCounts = new Map();
|
|
642
|
+
for (const fk of foreignKeys) {
|
|
643
|
+
const key = `${fk.sourceTable}->${fk.targetTable}`;
|
|
644
|
+
fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
|
|
645
|
+
}
|
|
646
|
+
const relationsByTable = new Map();
|
|
647
|
+
for (const fk of foreignKeys) {
|
|
648
|
+
const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
|
|
649
|
+
const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
|
|
650
|
+
const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
|
|
651
|
+
const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
|
|
652
|
+
? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
653
|
+
: singularize(snakeToCamel(fk.targetTable));
|
|
654
|
+
if (!relationsByTable.has(fk.sourceTable))
|
|
655
|
+
relationsByTable.set(fk.sourceTable, {});
|
|
656
|
+
relationsByTable.get(fk.sourceTable)[belongsToName] = {
|
|
657
|
+
type: 'belongsTo',
|
|
658
|
+
name: belongsToName,
|
|
659
|
+
from: fk.sourceTable,
|
|
660
|
+
to: fk.targetTable,
|
|
661
|
+
foreignKey,
|
|
662
|
+
referenceKey,
|
|
663
|
+
};
|
|
664
|
+
const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
|
|
665
|
+
? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
666
|
+
: snakeToCamel(fk.sourceTable);
|
|
667
|
+
if (!relationsByTable.has(fk.targetTable))
|
|
668
|
+
relationsByTable.set(fk.targetTable, {});
|
|
669
|
+
relationsByTable.get(fk.targetTable)[hasManyName] = {
|
|
670
|
+
type: 'hasMany',
|
|
671
|
+
name: hasManyName,
|
|
672
|
+
from: fk.targetTable,
|
|
673
|
+
to: fk.sourceTable,
|
|
674
|
+
foreignKey,
|
|
675
|
+
referenceKey,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
// ----- Conservative many-to-many auto-detection (additive) -----
|
|
679
|
+
// A table J is a pure junction iff: PK is exactly two columns, exactly two
|
|
680
|
+
// single-column FKs whose source columns ARE the PK, two distinct target
|
|
681
|
+
// tables, and no payload columns. Mirrors the Postgres introspector.
|
|
682
|
+
for (const tableName of tableNames) {
|
|
683
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
684
|
+
if (pk.length !== 2)
|
|
685
|
+
continue;
|
|
686
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
687
|
+
if (tableFks.length !== 2)
|
|
688
|
+
continue;
|
|
689
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
690
|
+
continue;
|
|
691
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
692
|
+
const pkSet = new Set(pk);
|
|
693
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
694
|
+
continue;
|
|
695
|
+
if (new Set(fkCols).size !== 2)
|
|
696
|
+
continue;
|
|
697
|
+
const [fkA, fkB] = tableFks;
|
|
698
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
699
|
+
continue;
|
|
700
|
+
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
701
|
+
if (jCols.length !== 2)
|
|
702
|
+
continue;
|
|
703
|
+
const addM2M = (self, other) => {
|
|
704
|
+
const sourceTbl = self.targetTable;
|
|
705
|
+
const targetTbl = other.targetTable;
|
|
706
|
+
const relName = snakeToCamel(targetTbl);
|
|
707
|
+
if (!relationsByTable.has(sourceTbl))
|
|
708
|
+
relationsByTable.set(sourceTbl, {});
|
|
709
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
710
|
+
if (existing[relName])
|
|
711
|
+
return;
|
|
712
|
+
existing[relName] = {
|
|
713
|
+
type: 'manyToMany',
|
|
714
|
+
name: relName,
|
|
715
|
+
from: sourceTbl,
|
|
716
|
+
to: targetTbl,
|
|
717
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
718
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
719
|
+
through: {
|
|
720
|
+
table: tableName,
|
|
721
|
+
sourceKey: self.sourceColumns[0],
|
|
722
|
+
targetKey: other.sourceColumns[0],
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
};
|
|
726
|
+
addM2M(fkA, fkB);
|
|
727
|
+
addM2M(fkB, fkA);
|
|
728
|
+
}
|
|
729
|
+
// ----- Assemble TableMetadata -----
|
|
730
|
+
const tables = {};
|
|
731
|
+
for (const tableName of tableNames) {
|
|
732
|
+
const columns = columnsByTable.get(tableName) ?? [];
|
|
733
|
+
const columnMap = {};
|
|
734
|
+
const reverseColumnMap = {};
|
|
735
|
+
const dateColumns = new Set();
|
|
736
|
+
const dialectTypes = {};
|
|
737
|
+
const pgTypes = {};
|
|
738
|
+
const allColumns = [];
|
|
739
|
+
for (const col of columns) {
|
|
740
|
+
columnMap[col.field] = col.name;
|
|
741
|
+
reverseColumnMap[col.name] = col.field;
|
|
742
|
+
allColumns.push(col.name);
|
|
743
|
+
dialectTypes[col.name] = col.dialectType ?? col.pgType;
|
|
744
|
+
pgTypes[col.name] = col.pgType;
|
|
745
|
+
if (isSqliteDateType(col.dialectType ?? col.pgType))
|
|
746
|
+
dateColumns.add(col.name);
|
|
747
|
+
}
|
|
748
|
+
tables[tableName] = {
|
|
749
|
+
name: tableName,
|
|
750
|
+
columns,
|
|
751
|
+
columnMap,
|
|
752
|
+
reverseColumnMap,
|
|
753
|
+
dateColumns,
|
|
754
|
+
dialectTypes,
|
|
755
|
+
pgTypes,
|
|
756
|
+
allColumns,
|
|
757
|
+
primaryKey: pkByTable.get(tableName) ?? [],
|
|
758
|
+
uniqueColumns: uniqueByTable.get(tableName) ?? [],
|
|
759
|
+
relations: relationsByTable.get(tableName) ?? {},
|
|
760
|
+
indexes: indexesByTable.get(tableName) ?? [],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return { tables, enums: {} };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Open the SQLite database named by `options.connectionString` (a file path or
|
|
767
|
+
* `':memory:'`), introspect it, and close it. Wraps
|
|
768
|
+
* {@link introspectSqliteDatabase} for the {@link DialectIntrospector} seam used
|
|
769
|
+
* by `introspect()` / `npx turbine generate`.
|
|
770
|
+
*
|
|
771
|
+
* Note: introspecting `':memory:'` opens a *fresh, empty* database (memory DBs
|
|
772
|
+
* are per-handle), so codegen should target a real file.
|
|
773
|
+
*/
|
|
774
|
+
export async function introspectSqlite(options) {
|
|
775
|
+
const DatabaseSync = loadDatabaseSync();
|
|
776
|
+
let db;
|
|
777
|
+
try {
|
|
778
|
+
db = new DatabaseSync(options.connectionString);
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
throw new ConnectionError(`[turbine] Failed to open SQLite database "${options.connectionString}": ${err.message}`);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
return introspectSqliteDatabase(db, { include: options.include, exclude: options.exclude });
|
|
785
|
+
}
|
|
786
|
+
finally {
|
|
787
|
+
db.close();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function openSqliteDatabase(target, options) {
|
|
791
|
+
const DatabaseSync = loadDatabaseSync();
|
|
792
|
+
let db;
|
|
793
|
+
try {
|
|
794
|
+
db = new DatabaseSync(target);
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
throw new ConnectionError(`[turbine] Failed to open SQLite database "${target}": ${err.message}`);
|
|
798
|
+
}
|
|
799
|
+
db.exec(`PRAGMA busy_timeout = ${Number(options.busyTimeoutMs ?? 5000)}`);
|
|
800
|
+
if (options.foreignKeys !== false)
|
|
801
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
802
|
+
if (target !== ':memory:' && options.wal !== false) {
|
|
803
|
+
try {
|
|
804
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// Some filesystems (network mounts) reject WAL — fall back silently.
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return db;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Create a {@link TurbineClient} bound to SQLite via `node:sqlite`.
|
|
814
|
+
*
|
|
815
|
+
* Pass a file path, `':memory:'`, or an already-open `DatabaseSync` handle (so
|
|
816
|
+
* you can seed / introspect it first and reuse the same connection). The
|
|
817
|
+
* returned client uses {@link sqliteDialect} and disables prepared-statement
|
|
818
|
+
* names (SQLite caches plans internally).
|
|
819
|
+
*
|
|
820
|
+
* @param target A SQLite file path, `':memory:'`, or an open `DatabaseSync`.
|
|
821
|
+
* @param schema Introspected or hand-written {@link SchemaMetadata}.
|
|
822
|
+
* @param options Optional pragmas + logging / defaultLimit / warnOnUnlimited.
|
|
823
|
+
*
|
|
824
|
+
* @example
|
|
825
|
+
* ```ts
|
|
826
|
+
* import { turbineSqlite } from 'turbine-orm/sqlite';
|
|
827
|
+
* const db = turbineSqlite(':memory:', SCHEMA);
|
|
828
|
+
* ```
|
|
829
|
+
*/
|
|
830
|
+
export function turbineSqlite(target, schema, options = {}) {
|
|
831
|
+
const db = typeof target === 'string' ? openSqliteDatabase(target, options) : target;
|
|
832
|
+
const pool = new SqlitePool(db);
|
|
833
|
+
return new TurbineClient({
|
|
834
|
+
pool,
|
|
835
|
+
dialect: sqliteDialect,
|
|
836
|
+
preparedStatements: false,
|
|
837
|
+
logging: options.logging,
|
|
838
|
+
defaultLimit: options.defaultLimit,
|
|
839
|
+
warnOnUnlimited: options.warnOnUnlimited,
|
|
840
|
+
}, schema);
|
|
841
|
+
}
|
|
842
|
+
//# sourceMappingURL=sqlite.js.map
|