@nextlyhq/adapter-sqlite 0.0.2-alpha.68 → 0.0.2-alpha.69

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/index.mjs CHANGED
@@ -1,860 +1,892 @@
1
- import { AsyncLocalStorage, AsyncResource } from 'async_hooks';
2
- import { DrizzleAdapter } from '@nextlyhq/adapter-drizzle';
3
- import { isApplicationError, createDatabaseError, isDatabaseError } from '@nextlyhq/adapter-drizzle/types';
4
- import { checkDialectVersion } from '@nextlyhq/adapter-drizzle/version-check';
5
- import { drizzle } from 'drizzle-orm/better-sqlite3';
6
- import { getTableConfig } from 'drizzle-orm/sqlite-core';
7
-
1
+ import { AsyncLocalStorage, AsyncResource } from "node:async_hooks";
2
+ import { DrizzleAdapter } from "@nextlyhq/adapter-drizzle";
3
+ import { createDatabaseError, isApplicationError, isDatabaseError, poolWaitTimeoutError } from "@nextlyhq/adapter-drizzle/types";
4
+ import { checkDialectVersion } from "@nextlyhq/adapter-drizzle/version-check";
5
+ import { drizzle } from "drizzle-orm/better-sqlite3";
6
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
8
7
  // src/index.ts
9
- var VERSION = "0.1.0";
10
- var SQLITE_ERROR_CODES = {
11
- // Constraint violations
12
- SQLITE_CONSTRAINT: "constraint",
13
- SQLITE_CONSTRAINT_UNIQUE: "unique_violation",
14
- SQLITE_CONSTRAINT_PRIMARYKEY: "unique_violation",
15
- SQLITE_CONSTRAINT_FOREIGNKEY: "foreign_key_violation",
16
- SQLITE_CONSTRAINT_NOTNULL: "not_null_violation",
17
- SQLITE_CONSTRAINT_CHECK: "check_violation",
18
- // Busy/locked errors
19
- SQLITE_BUSY: "timeout",
20
- SQLITE_LOCKED: "timeout",
21
- // Connection errors
22
- SQLITE_CANTOPEN: "connection",
23
- SQLITE_NOTADB: "connection",
24
- SQLITE_CORRUPT: "connection",
25
- // Query errors
26
- SQLITE_ERROR: "query",
27
- SQLITE_MISUSE: "query",
28
- SQLITE_RANGE: "query"
8
+ /**
9
+ * @nextlyhq/adapter-sqlite
10
+ *
11
+ * SQLite database adapter for Nextly.
12
+ * Extends DrizzleAdapter from @nextlyhq/adapter-drizzle to provide SQLite-specific functionality.
13
+ *
14
+ * @remarks
15
+ * This adapter uses the better-sqlite3 package for database connectivity and provides:
16
+ * - Synchronous API wrapped for async interface compatibility
17
+ * - Transactions the adapter runs itself (BEGIN IMMEDIATE / COMMIT / ROLLBACK), with nested calls run as savepoints
18
+ * - CRUD operations with RETURNING clause support (SQLite 3.35+)
19
+ * - WAL mode for better concurrent read performance
20
+ * - In-memory and file-based database support
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { createSqliteAdapter } from '@nextlyhq/adapter-sqlite';
25
+ *
26
+ * const adapter = createSqliteAdapter({
27
+ * url: 'file:./data/app.db',
28
+ * });
29
+ *
30
+ * await adapter.connect();
31
+ *
32
+ * // Query data
33
+ * const users = await adapter.select('users', {
34
+ * where: { and: [{ column: 'status', op: '=', value: 'active' }] },
35
+ * limit: 10,
36
+ * });
37
+ *
38
+ * await adapter.disconnect();
39
+ * ```
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+ /**
44
+ * Package version
45
+ */
46
+ const VERSION = "0.1.0";
47
+ /**
48
+ * SQLite error codes mapping to DatabaseErrorKind.
49
+ *
50
+ * @see https://sqlite.org/rescode.html
51
+ */
52
+ const SQLITE_ERROR_CODES = {
53
+ SQLITE_CONSTRAINT: "constraint",
54
+ SQLITE_CONSTRAINT_UNIQUE: "unique_violation",
55
+ SQLITE_CONSTRAINT_PRIMARYKEY: "unique_violation",
56
+ SQLITE_CONSTRAINT_FOREIGNKEY: "foreign_key_violation",
57
+ SQLITE_CONSTRAINT_NOTNULL: "not_null_violation",
58
+ SQLITE_CONSTRAINT_CHECK: "check_violation",
59
+ SQLITE_BUSY: "timeout",
60
+ SQLITE_LOCKED: "timeout",
61
+ SQLITE_CANTOPEN: "connection",
62
+ SQLITE_NOTADB: "connection",
63
+ SQLITE_CORRUPT: "connection",
64
+ SQLITE_ERROR: "query",
65
+ SQLITE_MISUSE: "query",
66
+ SQLITE_RANGE: "query"
29
67
  };
30
- var DEFAULT_CONFIG = {
31
- busyTimeout: 5e3,
32
- wal: true,
33
- foreignKeys: true
68
+ /**
69
+ * Default configuration values.
70
+ */
71
+ const DEFAULT_CONFIG = {
72
+ busyTimeout: 5e3,
73
+ wal: true,
74
+ foreignKeys: true,
75
+ connectionTimeoutMs: 15e3
34
76
  };
77
+ /**
78
+ * Converts a JavaScript value to a type that better-sqlite3 can bind.
79
+ * better-sqlite3 only accepts: number, string, bigint, Buffer, null.
80
+ *
81
+ * These conversions have to match what the schema declares, because the rows
82
+ * they write are read back through it. Every column here that takes a `Date`
83
+ * is `integer({ mode: "timestamp" })` — the system columns and user date
84
+ * fields in `runtime-schema-generator.ts`, and the core tables, whose date
85
+ * columns are all INTEGER. So a date is bound as unix seconds, which is what
86
+ * that mode reads.
87
+ */
35
88
  function sanitizeSqliteValue(v) {
36
- if (v === void 0) return null;
37
- if (typeof v === "boolean") return v ? 1 : 0;
38
- if (Buffer.isBuffer(v) || ArrayBuffer.isView(v)) return v;
39
- if (v instanceof Date) return Math.floor(v.getTime() / 1e3);
40
- if (v !== null && typeof v === "object") return JSON.stringify(v);
41
- return v;
89
+ if (v === void 0) return null;
90
+ if (typeof v === "boolean") return v ? 1 : 0;
91
+ if (Buffer.isBuffer(v) || ArrayBuffer.isView(v)) return v;
92
+ if (v instanceof Date) return Math.floor(v.getTime() / 1e3);
93
+ if (v !== null && typeof v === "object") return JSON.stringify(v);
94
+ return v;
42
95
  }
96
+ /**
97
+ * SQLite database adapter for Nextly.
98
+ *
99
+ * Extends the base DrizzleAdapter to provide SQLite-specific functionality
100
+ * using the better-sqlite3 package.
101
+ *
102
+ * @remarks
103
+ * SQLite has some differences from PostgreSQL:
104
+ * - Synchronous API (wrapped for async compatibility)
105
+ * - No connection pooling (single file/connection)
106
+ * - RETURNING clause supported (SQLite 3.35+)
107
+ * - Savepoints supported via nested transactions
108
+ * - No native ILIKE (uses LOWER() LIKE workaround)
109
+ * - JSON support (not JSONB)
110
+ * - No array types
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const adapter = new SqliteAdapter({
115
+ * url: 'file:./data.db',
116
+ * });
117
+ *
118
+ * await adapter.connect();
119
+ * ```
120
+ */
43
121
  var SqliteAdapter = class extends DrizzleAdapter {
44
- // getDrizzle memoization: drizzle v1's constructor builds a relational
45
- // query builder per table in the relations config (~40 tables), and the
46
- // service layer resolves an instance on every db access — construct once
47
- // per relations object (identity-stable: the schema registry caches it
48
- // and hands out a NEW object on invalidation, which naturally misses
49
- // this cache and produces a fresh instance).
50
- drizzleByRelations = /* @__PURE__ */ new WeakMap();
51
- drizzleBare;
52
- /**
53
- * The database dialect - always 'sqlite' for this adapter.
54
- */
55
- dialect = "sqlite";
56
- /**
57
- * Adapter configuration.
58
- */
59
- config;
60
- /**
61
- * better-sqlite3 Database instance.
62
- */
63
- db = null;
64
- /**
65
- * Connection state flag.
66
- */
67
- connected = false;
68
- /**
69
- * Serialization queue for concurrent `transaction()` calls.
70
- *
71
- * better-sqlite3 is synchronous, single-connection, and rejects nested
72
- * `BEGIN` with "cannot start a transaction within a transaction".
73
- * When two `await adapter.transaction(...)` calls overlap (e.g. a bulk
74
- * mutation calling `Promise.allSettled` with N per-row updates), the
75
- * second one tries to BEGIN while the first is still open and the
76
- * driver throws.
77
- *
78
- * Postgres/MySQL avoid this by allocating a fresh client/connection
79
- * per transaction from a pool — there is no equivalent for
80
- * better-sqlite3, so we serialize at the JS layer instead. Every
81
- * `transaction()` invocation chains onto this promise; only one
82
- * BEGIN→COMMIT/ROLLBACK section runs at any moment, preserving
83
- * write-isolation semantics that callers expect from a transactional
84
- * adapter.
85
- *
86
- * Performance impact is the natural floor: SQLite already serializes
87
- * writers at the file-lock level, so the queue removes failed-call
88
- * surface area without changing the achievable concurrency.
89
- */
90
- transactionQueue = Promise.resolve();
91
- /**
92
- * The transaction the current async chain is running inside, if any.
93
- *
94
- * A `transaction()` call made from INSIDE a transaction's work — a store
95
- * writing on behalf of the caller, a plugin's write inside its own
96
- * `ctx.db.transaction` — used to queue behind the very transaction that
97
- * was waiting for it, and neither ever settled: that hung every later
98
- * transaction on the instance too. Such a call now runs as a SAVEPOINT of
99
- * the active transaction instead, so it commits or rolls back with it and
100
- * its own failure undoes only its own part.
101
- */
102
- transactionScope = new AsyncLocalStorage();
103
- /**
104
- * Creates a new SQLite adapter instance.
105
- *
106
- * @param config - Adapter configuration
107
- */
108
- constructor(config) {
109
- super();
110
- this.config = config;
111
- }
112
- /**
113
- * Connect to the SQLite database.
114
- *
115
- * @remarks
116
- * This method initializes the database connection. For SQLite, this
117
- * opens the database file or creates an in-memory database.
118
- * Also configures WAL mode and foreign keys based on config.
119
- *
120
- * @throws {DatabaseError} If connection fails
121
- */
122
- async connect() {
123
- if (this.connected && this.db) {
124
- return;
125
- }
126
- try {
127
- const BetterSqlite3 = await import('better-sqlite3');
128
- const Database = BetterSqlite3.default;
129
- let dbPath;
130
- if (this.config.memory) {
131
- dbPath = ":memory:";
132
- } else if (this.config.url) {
133
- dbPath = this.config.url.replace(/^file:/, "");
134
- } else {
135
- dbPath = ":memory:";
136
- }
137
- if (dbPath !== ":memory:" && !this.config.readonly) {
138
- const fs = await import('fs');
139
- const path = await import('path');
140
- const dir = path.dirname(path.resolve(dbPath));
141
- await fs.promises.mkdir(dir, { recursive: true });
142
- }
143
- this.db = new Database(dbPath, {
144
- readonly: this.config.readonly ?? false,
145
- timeout: this.config.busyTimeout ?? DEFAULT_CONFIG.busyTimeout
146
- });
147
- if ((this.config.wal ?? DEFAULT_CONFIG.wal) && dbPath !== ":memory:" && !this.config.readonly) {
148
- this.db.pragma("journal_mode = WAL");
149
- }
150
- if (this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys) {
151
- this.db.pragma("foreign_keys = ON");
152
- }
153
- await checkDialectVersion(this.db, "sqlite");
154
- this.connected = true;
155
- if (this.config.logger?.info) {
156
- this.config.logger.info("SQLite connection established", {
157
- url: dbPath === ":memory:" ? "in-memory" : dbPath,
158
- wal: this.config.wal ?? DEFAULT_CONFIG.wal,
159
- foreignKeys: this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys
160
- });
161
- }
162
- } catch (error) {
163
- if (this.db) {
164
- try {
165
- this.db.close();
166
- } catch {
167
- }
168
- this.db = null;
169
- }
170
- throw this.classifyError(error);
171
- }
172
- }
173
- /**
174
- * Disconnect from the SQLite database.
175
- *
176
- * @remarks
177
- * This method closes the database connection and releases resources.
178
- */
179
- // better-sqlite3 is synchronous; method is async to satisfy the DatabaseAdapter contract.
180
- // eslint-disable-next-line @typescript-eslint/require-await
181
- async disconnect() {
182
- this.drizzleBare = void 0;
183
- this.drizzleByRelations = /* @__PURE__ */ new WeakMap();
184
- if (!this.db) {
185
- return;
186
- }
187
- try {
188
- this.db.close();
189
- if (this.config.logger?.info) {
190
- this.config.logger.info("SQLite connection closed");
191
- }
192
- } finally {
193
- this.db = null;
194
- this.connected = false;
195
- }
196
- }
197
- /**
198
- * Check if connected to the database.
199
- */
200
- isConnected() {
201
- return this.connected && this.db !== null;
202
- }
203
- /**
204
- * Get connection pool statistics.
205
- *
206
- * @remarks
207
- * SQLite doesn't use connection pooling, so this returns null.
208
- * The database is single-file with a single connection.
209
- */
210
- getPoolStats() {
211
- return null;
212
- }
213
- /**
214
- * Execute a raw SQL query.
215
- *
216
- * @param sql - SQL query string with $1, $2 placeholders (converted to ? for SQLite)
217
- * @param params - Query parameters
218
- * @returns Query results
219
- *
220
- * @throws {DatabaseError} If query execution fails
221
- */
222
- // better-sqlite3 is synchronous; method is async to satisfy the DatabaseAdapter contract.
223
- // eslint-disable-next-line @typescript-eslint/require-await
224
- async executeQuery(sql, params = []) {
225
- const db = this.ensureDb();
226
- const startTime = Date.now();
227
- try {
228
- const convertedSql = this.convertPlaceholders(sql);
229
- const trimmedSql = convertedSql.trim().toUpperCase();
230
- const isPragmaQuery = trimmedSql.startsWith("PRAGMA") && !trimmedSql.includes("=");
231
- const isSelect = trimmedSql.startsWith("SELECT") || isPragmaQuery || trimmedSql.startsWith("WITH");
232
- const hasReturning = trimmedSql.includes("RETURNING");
233
- let result;
234
- const sanitizedParams = params.map(sanitizeSqliteValue);
235
- if (isSelect || hasReturning) {
236
- const stmt = db.prepare(convertedSql);
237
- result = stmt.all(...sanitizedParams);
238
- } else {
239
- const stmt = db.prepare(convertedSql);
240
- const runResult = stmt.run(...sanitizedParams);
241
- result = [
242
- {
243
- changes: runResult.changes,
244
- lastInsertRowid: runResult.lastInsertRowid
245
- }
246
- ];
247
- }
248
- if (this.config.logger?.query) {
249
- const durationMs = Date.now() - startTime;
250
- this.config.logger.query(convertedSql, params, durationMs);
251
- }
252
- return result;
253
- } catch (error) {
254
- throw this.classifyError(error, sql);
255
- }
256
- }
257
- /**
258
- * Execute work within a transaction.
259
- *
260
- * @param work - Function containing transactional operations
261
- * @param options - Transaction options (isolation level not fully supported in SQLite)
262
- * @returns Result of the work function
263
- *
264
- * @remarks
265
- * The adapter manages the transaction itself, because better-sqlite3's
266
- * `db.transaction()` does not take async work: it runs `BEGIN IMMEDIATE`,
267
- * then `COMMIT` when `work` resolves or `ROLLBACK` when it throws.
268
- * - Top-level calls run one at a time.
269
- * - A call made inside an open transaction's work joins it as a savepoint
270
- * (`SAVEPOINT` / `RELEASE` / `ROLLBACK TO`).
271
- * - Nested calls of one scope run one at a time.
272
- * - A call made later from a savepoint that has already released joins the
273
- * innermost scope still open.
274
- * - Nested work started and not awaited finishes before the enclosing scope
275
- * commits or rolls back.
276
- *
277
- * Note: SQLite uses DEFERRED, IMMEDIATE, or EXCLUSIVE transaction modes
278
- * rather than isolation levels. This adapter uses IMMEDIATE.
279
- */
280
- async transaction(work, _options) {
281
- const own = this.transactionScope.getStore();
282
- const parent = this.nestingScope();
283
- if (own && parent !== own) own.writesAfterClosing += 1;
284
- if (parent) {
285
- return enqueueIn(parent, () => this.runSavepoint(work, parent));
286
- }
287
- const run = async () => this.runTransaction(work);
288
- const next = this.transactionQueue.then(run, run);
289
- this.transactionQueue = next.catch(() => void 0);
290
- const { result, effects } = await next;
291
- await this.runHeldEffects(effects);
292
- return result;
293
- }
294
- /**
295
- * Hold `effect` until the transaction enclosing this async context commits,
296
- * or run it now when none does.
297
- *
298
- * The effect belongs to the scope this async context runs in, even one
299
- * that has already released or rolled back: a service whose own
300
- * transaction nested as a savepoint registers from its caller's scope once
301
- * that savepoint has released, and the change is undoable for as long as
302
- * any scope around it is. The outermost transaction runs the effect once
303
- * its COMMIT succeeds, unless a scope between the effect's scope and the
304
- * outermost transaction rolled back, and drops every effect on ROLLBACK or
305
- * a failed COMMIT. A context whose outermost transaction has already
306
- * finished is outside any transaction, so its effect runs now, unless what
307
- * it announces was undone and it has run no transaction since.
308
- */
309
- async afterCommit(effect, onDeferredFailure) {
310
- const scope = this.transactionScope.getStore();
311
- if (!scope) {
312
- await effect();
313
- return;
314
- }
315
- const root = outermost(scope);
316
- if (!root.finished) {
317
- root.held.push({
318
- effect: this.inRegisteringContext(effect),
319
- onDeferredFailure,
320
- scope,
321
- writesBefore: scope.writesAfterClosing
322
- });
323
- return;
324
- }
325
- if (!undone(scope, scope.writesAfterClosing)) await effect();
326
- }
327
- /**
328
- * `effect`, bound to the async context it is registered from.
329
- *
330
- * A held effect runs after the outermost COMMIT, from the code that ran the
331
- * outermost transaction, so without this it sees that code's context rather
332
- * than its own: whatever its caller pinned around the write is gone. That
333
- * includes who is calling. A scope pinned for a scoped API key lost there
334
- * leaves a hook's nested call judged by the key's owner instead of the key,
335
- * and a person's frame lost there leaves the hook told of nobody.
336
- *
337
- * The adapter's own transaction scope is the one store it leaves: the
338
- * effect runs once that transaction has finished, outside it, as an effect
339
- * held to the outermost commit always has.
340
- */
341
- inRegisteringContext(effect) {
342
- return AsyncResource.bind(() => this.transactionScope.exit(effect));
343
- }
344
- /**
345
- * Run the effects a committed transaction held, in order. A failure is
346
- * reported, never thrown: the change is durable, and reporting the
347
- * transaction as failed would invite a retry of something already done.
348
- */
349
- async runHeldEffects(effects) {
350
- for (const { effect, onDeferredFailure } of effects) {
351
- try {
352
- await effect();
353
- } catch (error) {
354
- this.reportHeldEffectFailure(error, onDeferredFailure);
355
- }
356
- }
357
- }
358
- /** Hand a held effect's failure to its reporter, or to the adapter's log. */
359
- reportHeldEffectFailure(error, onDeferredFailure) {
360
- try {
361
- if (onDeferredFailure) {
362
- onDeferredFailure(error);
363
- return;
364
- }
365
- } catch {
366
- }
367
- const failure = error instanceof Error ? error : new Error(String(error));
368
- if (this.config.logger?.error) {
369
- this.config.logger.error(failure, { phase: "after-commit" });
370
- } else {
371
- console.error("An effect held until commit failed:", failure);
372
- }
373
- }
374
- /**
375
- * Whether a `transaction()` call made here would join an open transaction
376
- * as a savepoint. On SQLite it would: the connection is the only one, so a
377
- * transaction open on it encloses whatever the async chain that opened it
378
- * starts.
379
- */
380
- inTransaction() {
381
- return this.nestingScope() !== void 0;
382
- }
383
- /**
384
- * Whether the calling async flow is inside a transaction that has not
385
- * finished, judged from the transaction this flow was started inside,
386
- * never from the connection: another request's open transaction answers
387
- * false here, where its own `transaction()` queues behind that one.
388
- *
389
- * A flow started inside a transaction, even inside a savepoint that has
390
- * since released, stays inside it until its outermost transaction has
391
- * committed or rolled back: until then whatever it writes on the one
392
- * connection lands in that transaction.
393
- */
394
- transactionOpenInFlow() {
395
- const own = this.transactionScope.getStore();
396
- return own !== void 0 && !outermost(own).finished;
397
- }
398
- /**
399
- * The scope a `transaction()` call made here nests in, or none when it
400
- * opens a transaction of its own.
401
- *
402
- * A call made later from inside a savepoint that has since released runs
403
- * inside the innermost scope open on the connection. Queued on an enclosing
404
- * scope, or on the instance, it would wait behind the scope running there,
405
- * which may be the one awaiting it. Savepoints on one connection form a
406
- * stack, so whatever opens now is inside that innermost scope anyway; when
407
- * none is open the call queues on the instance.
408
- *
409
- * The innermost open scope need not be related to the call. When it is a
410
- * sibling savepoint that later rolls back, the call's write rolls back with
411
- * it, although the call itself already resolved. Waiting for an unrelated
412
- * scope instead could wait on the very scope that awaits the call.
413
- */
414
- nestingScope() {
415
- const own = this.transactionScope.getStore();
416
- return own?.active ? own : own?.open.at(-1);
417
- }
418
- /**
419
- * Run `work` as a savepoint of the active transaction: released when it
420
- * resolves, rolled back to when it throws.
421
- */
422
- async runSavepoint(work, parent) {
423
- const db = this.ensureDb();
424
- const depth = parent.depth + 1;
425
- const name = `nextly_sp_${depth}`;
426
- try {
427
- const ctx = this.createTransactionContext(db);
428
- db.exec(`SAVEPOINT ${name}`);
429
- const scope = openScope(parent);
430
- try {
431
- const result = await this.transactionScope.run(scope, () => work(ctx));
432
- await settleNested(scope);
433
- db.exec(`RELEASE ${name}`);
434
- return result;
435
- } catch (error) {
436
- await settleNested(scope);
437
- scope.rolledBack = true;
438
- try {
439
- db.exec(`ROLLBACK TO ${name}`);
440
- db.exec(`RELEASE ${name}`);
441
- } catch {
442
- }
443
- throw error;
444
- }
445
- } catch (error) {
446
- if (isApplicationError(error)) throw error;
447
- throw this.classifyError(error);
448
- }
449
- }
450
- /**
451
- * Inner transaction body. Kept private so callers always go through
452
- * the serialized `transaction()` queue above and the BEGIN/COMMIT
453
- * pair is never invoked outside of it.
454
- */
455
- async runTransaction(work) {
456
- const db = this.ensureDb();
457
- const startTime = Date.now();
458
- try {
459
- const ctx = this.createTransactionContext(db);
460
- db.exec("BEGIN IMMEDIATE");
461
- const scope = openScope();
462
- try {
463
- const result = await this.transactionScope.run(scope, () => work(ctx));
464
- await settleNested(scope);
465
- db.exec("COMMIT");
466
- scope.finished = true;
467
- if (this.config.logger?.debug) {
468
- const durationMs = Date.now() - startTime;
469
- this.config.logger.debug("Transaction committed", {
470
- durationMs
471
- });
472
- }
473
- return {
474
- result,
475
- effects: scope.held.filter(
476
- (held) => !undone(held.scope, held.writesBefore)
477
- )
478
- };
479
- } catch (error) {
480
- await settleNested(scope);
481
- scope.rolledBack = true;
482
- scope.finished = true;
483
- scope.held.length = 0;
484
- try {
485
- db.exec("ROLLBACK");
486
- } catch {
487
- }
488
- throw error;
489
- }
490
- } catch (error) {
491
- if (isApplicationError(error)) throw error;
492
- throw this.classifyError(error);
493
- }
494
- }
495
- /**
496
- * Get SQLite database capabilities.
497
- *
498
- * @remarks
499
- * SQLite capabilities:
500
- * - JSON support (not JSONB)
501
- * - No arrays
502
- * - No native ILIKE
503
- * - RETURNING clause (3.35+)
504
- * - Savepoints supported
505
- * - ON CONFLICT supported
506
- */
507
- getCapabilities() {
508
- return {
509
- dialect: "sqlite",
510
- supportsJsonb: false,
511
- // SQLite uses JSON, not JSONB
512
- supportsJson: true,
513
- supportsArrays: false,
514
- // SQLite doesn't support array types
515
- supportsGeneratedColumns: true,
516
- // SQLite 3.31+
517
- supportsFts: true,
518
- // SQLite FTS5
519
- supportsIlike: false,
520
- // No native ILIKE, use LOWER() LIKE
521
- supportsReturning: true,
522
- // SQLite 3.35+
523
- supportsSavepoints: true,
524
- // SQLite supports savepoints
525
- supportsOnConflict: true,
526
- // ON CONFLICT clause
527
- maxParamsPerQuery: 999,
528
- // SQLite SQLITE_MAX_VARIABLE_NUMBER default
529
- maxIdentifierLength: 128
530
- // SQLite doesn't have a strict limit
531
- };
532
- }
533
- /**
534
- * Override insertMany for bulk insert optimization.
535
- *
536
- * @remarks
537
- * Uses a single multi-row INSERT statement for better performance.
538
- */
539
- async insertMany(table, data, options) {
540
- if (data.length === 0) {
541
- return [];
542
- }
543
- const db = this.ensureDb();
544
- if (data.length === 1) {
545
- const result = await this.insert(table, data[0], options);
546
- return [result];
547
- }
548
- const columns = Object.keys(data[0]);
549
- const params = [];
550
- const valuesClauses = [];
551
- for (const record of data) {
552
- const placeholders = [];
553
- for (const col of columns) {
554
- params.push(sanitizeSqliteValue(record[col]));
555
- placeholders.push("?");
556
- }
557
- valuesClauses.push(`(${placeholders.join(", ")})`);
558
- }
559
- const columnList = columns.map((col) => this.escapeIdentifier(col)).join(", ");
560
- let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columnList}) VALUES ${valuesClauses.join(", ")}`;
561
- if (options?.returning) {
562
- const returning = options.returning === "*" ? "*" : options.returning.map((col) => this.escapeIdentifier(col)).join(", ");
563
- sql += ` RETURNING ${returning}`;
564
- } else {
565
- sql += " RETURNING *";
566
- }
567
- try {
568
- const stmt = db.prepare(sql);
569
- const rows = stmt.all(...params);
570
- const tableObj = this.getTableObject(table);
571
- return rows.map((r) => this.mapRowFromRawSql(tableObj, r));
572
- } catch (error) {
573
- throw this.handleQueryError(error, "insertMany", table);
574
- }
575
- }
576
- // ============================================================
577
- // Protected Helper Methods
578
- // ============================================================
579
- /**
580
- * Ensures database is connected and returns it.
581
- *
582
- * @throws {DatabaseError} If not connected
583
- */
584
- ensureDb() {
585
- if (!this.db) {
586
- throw createDatabaseError({
587
- kind: "connection",
588
- message: "SqliteAdapter is not connected. Call connect() first."
589
- });
590
- }
591
- return this.db;
592
- }
593
- /**
594
- * Return the typed Drizzle instance for SQLite.
595
- * Guarded for server-only usage and requires an active connection.
596
- *
597
- * @param schema - Optional schema for relational queries (db.query.*)
598
- * @returns Drizzle ORM instance wrapping the better-sqlite3 connection
599
- * @throws {Error} If called in browser or not connected
600
- */
601
- getDrizzle(relations) {
602
- if (typeof window !== "undefined") {
603
- throw new Error("getDrizzle() is server-only");
604
- }
605
- const db = this.ensureDb();
606
- if (!relations) {
607
- this.drizzleBare ??= drizzle({ client: db });
608
- return this.drizzleBare;
609
- }
610
- let cached = this.drizzleByRelations.get(relations);
611
- if (!cached) {
612
- cached = drizzle({ client: db, relations });
613
- this.drizzleByRelations.set(relations, cached);
614
- }
615
- return cached;
616
- }
617
- /**
618
- * Convert $1, $2 placeholders to ? for better-sqlite3.
619
- *
620
- * @param sql - SQL with PostgreSQL-style placeholders
621
- * @returns SQL with ? placeholders
622
- */
623
- convertPlaceholders(sql) {
624
- return sql.replace(/\$\d+/g, "?");
625
- }
626
- /**
627
- * Creates a TransactionContext for the given database connection.
628
- */
629
- /** A table-level `primaryKey({ columns })`, read through the SQLite table config. */
630
- compositePrimaryKey(tableObj) {
631
- return getTableConfig(
632
- tableObj
633
- ).primaryKeys.flatMap((key) => key.columns);
634
- }
635
- createTransactionContext(db) {
636
- const txHandles = this.transactionDrizzleHandles(
637
- (relations) => relations ? drizzle({ client: db, relations }) : drizzle({ client: db })
638
- );
639
- const txDb = txHandles.bare;
640
- return {
641
- drizzleWithRelations: txHandles.withRelations,
642
- // SQLite has no row-level locking and needs none here: `withTransaction`
643
- // opens SQLite transactions with BEGIN IMMEDIATE, which takes the write
644
- // lock up front and serializes writers for the whole transaction.
645
- lockRow: () => Promise.resolve(),
646
- // `run`, not `all`: better-sqlite3 throws on a statement that returns no
647
- // rows, which is every statement this method exists to carry. The Drizzle
648
- // instance is the transaction-bound one, so the statement runs inside this
649
- // transaction.
650
- //
651
- // better-sqlite3 is synchronous, so this resolves an already-settled
652
- // promise rather than being declared `async` over a body that never
653
- // awaits.
654
- runStatement: (statement) => {
655
- txDb().run(statement);
656
- return Promise.resolve();
657
- },
658
- // `all`, not `run`: this is the reading half, and better-sqlite3 returns
659
- // rows only from `all`. Synchronous, so the promise is already settled.
660
- queryStatement: (statement) => Promise.resolve(txDb().all(statement)),
661
- // eslint-disable-next-line @typescript-eslint/require-await
662
- execute: async (sql, params = []) => {
663
- const convertedSql = this.convertPlaceholders(sql);
664
- const trimmedSql = convertedSql.trim().toUpperCase();
665
- const isSelect = trimmedSql.startsWith("SELECT") || trimmedSql.includes("RETURNING");
666
- const sanitizedParams = params.map(sanitizeSqliteValue);
667
- if (isSelect) {
668
- const stmt = db.prepare(convertedSql);
669
- return stmt.all(...sanitizedParams);
670
- } else {
671
- const stmt = db.prepare(convertedSql);
672
- const result = stmt.run(...sanitizedParams);
673
- return [
674
- {
675
- changes: result.changes,
676
- lastInsertRowid: result.lastInsertRowid
677
- }
678
- ];
679
- }
680
- },
681
- // Keys only, unlike the PostgreSQL and MySQL raw paths, which also encode
682
- // their date values. `sanitizeSqliteValue` below binds a `Date` as unix
683
- // seconds, and an instant carries no zone -- so what SQLite stores does
684
- // not depend on the server's timezone and there is nothing to correct.
685
- // eslint-disable-next-line @typescript-eslint/require-await
686
- insert: async (table, data, options) => {
687
- const mapped = this.mapKeysToSqlColumns(
688
- this.getTableObject(table),
689
- data
690
- );
691
- const columns = Object.keys(mapped);
692
- const values = Object.values(mapped).map(sanitizeSqliteValue);
693
- const placeholders = values.map(() => "?").join(", ");
694
- let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES (${placeholders})`;
695
- const ret = options?.returning;
696
- const returningEmpty = Array.isArray(ret) && ret.length === 0;
697
- if (!returningEmpty) {
698
- const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(this.getTableObject(table), ret).map((col) => this.escapeIdentifier(col)).join(", ");
699
- sql += ` RETURNING ${returning}`;
700
- }
701
- const stmt = db.prepare(sql);
702
- if (returningEmpty) {
703
- stmt.run(...values);
704
- return void 0;
705
- }
706
- const rows = stmt.all(...values);
707
- return this.mapRowFromRawSql(this.getTableObject(table), rows[0]);
708
- },
709
- // eslint-disable-next-line @typescript-eslint/require-await
710
- insertMany: async (table, data, options) => {
711
- if (data.length === 0) return [];
712
- const tableObj = this.getTableObject(table);
713
- const mappedRecords = data.map(
714
- (r) => this.mapKeysToSqlColumns(tableObj, r)
715
- );
716
- const columns = Object.keys(mappedRecords[0]);
717
- const allValues = [];
718
- const valuesClauses = [];
719
- for (const record of mappedRecords) {
720
- const placeholders = [];
721
- for (const col of columns) {
722
- allValues.push(sanitizeSqliteValue(record[col]));
723
- placeholders.push("?");
724
- }
725
- valuesClauses.push(`(${placeholders.join(", ")})`);
726
- }
727
- let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES ${valuesClauses.join(", ")}`;
728
- const ret = options?.returning;
729
- const returningEmpty = Array.isArray(ret) && ret.length === 0;
730
- if (!returningEmpty) {
731
- const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(this.getTableObject(table), ret).map((col) => this.escapeIdentifier(col)).join(", ");
732
- sql += ` RETURNING ${returning}`;
733
- }
734
- const stmt = db.prepare(sql);
735
- if (returningEmpty) {
736
- stmt.run(...allValues);
737
- return [];
738
- }
739
- return stmt.all(...allValues).map(
740
- (r) => this.mapRowFromRawSql(tableObj, r)
741
- );
742
- },
743
- // Adapter-built, as `insert` above is; `transactionUpdate` says why.
744
- // A column the model does not declare binds as every other value on
745
- // this path does, through `sanitizeSqliteValue`. `all` only when the
746
- // statement carries RETURNING: better-sqlite3 throws on `all` for a
747
- // statement that returns no rows, and on `run` for one that does.
748
- update: this.transactionUpdate(
749
- txDb,
750
- (statement, returnsRows) => returnsRows ? txDb().all(statement) : (txDb().run(statement), void 0),
751
- sanitizeSqliteValue
752
- ),
753
- ...this.createTransactionForwarders(txDb),
754
- // eslint-disable-next-line @typescript-eslint/require-await
755
- savepoint: async (name) => {
756
- db.exec(`SAVEPOINT ${this.escapeIdentifier(name)}`);
757
- },
758
- // eslint-disable-next-line @typescript-eslint/require-await
759
- rollbackToSavepoint: async (name) => {
760
- db.exec(`ROLLBACK TO SAVEPOINT ${this.escapeIdentifier(name)}`);
761
- },
762
- // eslint-disable-next-line @typescript-eslint/require-await
763
- releaseSavepoint: async (name) => {
764
- db.exec(`RELEASE SAVEPOINT ${this.escapeIdentifier(name)}`);
765
- }
766
- };
767
- }
768
- /**
769
- * Classifies a SQLite error into a DatabaseError.
770
- *
771
- * @param error - Original error from better-sqlite3
772
- * @param sql - SQL statement that caused the error (optional)
773
- * @returns DatabaseError with proper classification
774
- */
775
- // fallow-ignore-next-line complexity
776
- classifyError(error, sql) {
777
- if (isDatabaseError(error)) return error;
778
- const sqliteError = this.driverErrorIn(
779
- error,
780
- (candidate) => typeof candidate.code === "string" && candidate.code !== ""
781
- );
782
- let kind = "unknown";
783
- if (sqliteError.code) {
784
- kind = SQLITE_ERROR_CODES[sqliteError.code] || "unknown";
785
- } else if (sqliteError.message) {
786
- const msg = sqliteError.message.toUpperCase();
787
- if (msg.includes("UNIQUE CONSTRAINT")) {
788
- kind = "unique_violation";
789
- } else if (msg.includes("FOREIGN KEY CONSTRAINT")) {
790
- kind = "foreign_key_violation";
791
- } else if (msg.includes("NOT NULL CONSTRAINT")) {
792
- kind = "not_null_violation";
793
- } else if (msg.includes("CHECK CONSTRAINT")) {
794
- kind = "check_violation";
795
- } else if (msg.includes("BUSY") || msg.includes("LOCKED")) {
796
- kind = "timeout";
797
- } else if (msg.includes("SQLITE_CANTOPEN") || msg.includes("UNABLE TO OPEN")) {
798
- kind = "connection";
799
- }
800
- }
801
- let message = sqliteError.message ?? String(error);
802
- if (sql && kind === "query") {
803
- message = `Query failed: ${message}`;
804
- }
805
- return createDatabaseError({
806
- kind,
807
- message,
808
- code: sqliteError.code,
809
- cause: error instanceof Error ? error : void 0
810
- });
811
- }
122
+ drizzleByRelations = /* @__PURE__ */ new WeakMap();
123
+ drizzleBare;
124
+ /**
125
+ * The database dialect - always 'sqlite' for this adapter.
126
+ */
127
+ dialect = "sqlite";
128
+ /**
129
+ * Adapter configuration.
130
+ */
131
+ config;
132
+ /**
133
+ * better-sqlite3 Database instance.
134
+ */
135
+ db = null;
136
+ /**
137
+ * Connection state flag.
138
+ */
139
+ connected = false;
140
+ /**
141
+ * Serialization queue for concurrent `transaction()` calls.
142
+ *
143
+ * better-sqlite3 is synchronous, single-connection, and rejects nested
144
+ * `BEGIN` with "cannot start a transaction within a transaction".
145
+ * When two `await adapter.transaction(...)` calls overlap (e.g. a bulk
146
+ * mutation calling `Promise.allSettled` with N per-row updates), the
147
+ * second one tries to BEGIN while the first is still open and the
148
+ * driver throws.
149
+ *
150
+ * Postgres/MySQL avoid this by allocating a fresh client/connection
151
+ * per transaction from a pool — there is no equivalent for
152
+ * better-sqlite3, so we serialize at the JS layer instead. Every
153
+ * `transaction()` invocation chains onto this promise; only one
154
+ * BEGIN→COMMIT/ROLLBACK section runs at any moment, preserving
155
+ * write-isolation semantics that callers expect from a transactional
156
+ * adapter.
157
+ *
158
+ * Performance impact is the natural floor: SQLite already serializes
159
+ * writers at the file-lock level, so the queue removes failed-call
160
+ * surface area without changing the achievable concurrency.
161
+ */
162
+ transactionQueue = Promise.resolve();
163
+ /**
164
+ * The transaction the current async chain is running inside, if any.
165
+ *
166
+ * A `transaction()` call made from INSIDE a transaction's work — a store
167
+ * writing on behalf of the caller, a plugin's write inside its own
168
+ * `ctx.db.transaction` — used to queue behind the very transaction that
169
+ * was waiting for it, and neither ever settled: that hung every later
170
+ * transaction on the instance too. Such a call now runs as a SAVEPOINT of
171
+ * the active transaction instead, so it commits or rolls back with it and
172
+ * its own failure undoes only its own part.
173
+ */
174
+ transactionScope = new AsyncLocalStorage();
175
+ /**
176
+ * Creates a new SQLite adapter instance.
177
+ *
178
+ * @param config - Adapter configuration
179
+ */
180
+ constructor(config) {
181
+ super();
182
+ this.config = config;
183
+ }
184
+ /**
185
+ * Connect to the SQLite database.
186
+ *
187
+ * @remarks
188
+ * This method initializes the database connection. For SQLite, this
189
+ * opens the database file or creates an in-memory database.
190
+ * Also configures WAL mode and foreign keys based on config.
191
+ *
192
+ * @throws {DatabaseError} If connection fails
193
+ */
194
+ async connect() {
195
+ if (this.connected && this.db) return;
196
+ try {
197
+ const Database = (await import("better-sqlite3")).default;
198
+ let dbPath;
199
+ if (this.config.memory) dbPath = ":memory:";
200
+ else if (this.config.url) dbPath = this.config.url.replace(/^file:/, "");
201
+ else dbPath = ":memory:";
202
+ if (dbPath !== ":memory:" && !this.config.readonly) {
203
+ const fs = await import("node:fs");
204
+ const path = await import("node:path");
205
+ const dir = path.dirname(path.resolve(dbPath));
206
+ await fs.promises.mkdir(dir, { recursive: true });
207
+ }
208
+ this.db = new Database(dbPath, {
209
+ readonly: this.config.readonly ?? false,
210
+ timeout: this.config.busyTimeout ?? DEFAULT_CONFIG.busyTimeout
211
+ });
212
+ if ((this.config.wal ?? DEFAULT_CONFIG.wal) && dbPath !== ":memory:" && !this.config.readonly) this.db.pragma("journal_mode = WAL");
213
+ if (this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys) this.db.pragma("foreign_keys = ON");
214
+ await checkDialectVersion(this.db, "sqlite");
215
+ this.connected = true;
216
+ if (this.config.logger?.info) this.config.logger.info("SQLite connection established", {
217
+ url: dbPath === ":memory:" ? "in-memory" : dbPath,
218
+ wal: this.config.wal ?? DEFAULT_CONFIG.wal,
219
+ foreignKeys: this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys
220
+ });
221
+ } catch (error) {
222
+ if (this.db) {
223
+ try {
224
+ this.db.close();
225
+ } catch {}
226
+ this.db = null;
227
+ }
228
+ throw this.classifyError(error);
229
+ }
230
+ }
231
+ /**
232
+ * Disconnect from the SQLite database.
233
+ *
234
+ * @remarks
235
+ * This method closes the database connection and releases resources.
236
+ */
237
+ async disconnect() {
238
+ this.drizzleBare = void 0;
239
+ this.drizzleByRelations = /* @__PURE__ */ new WeakMap();
240
+ if (!this.db) return;
241
+ try {
242
+ this.db.close();
243
+ if (this.config.logger?.info) this.config.logger.info("SQLite connection closed");
244
+ } finally {
245
+ this.db = null;
246
+ this.connected = false;
247
+ }
248
+ }
249
+ /**
250
+ * Check if connected to the database.
251
+ */
252
+ isConnected() {
253
+ return this.connected && this.db !== null;
254
+ }
255
+ /**
256
+ * Get connection pool statistics.
257
+ *
258
+ * @remarks
259
+ * SQLite doesn't use connection pooling, so this returns null.
260
+ * The database is single-file with a single connection.
261
+ */
262
+ getPoolStats() {
263
+ return null;
264
+ }
265
+ /**
266
+ * Execute a raw SQL query.
267
+ *
268
+ * @param sql - SQL query string with $1, $2 placeholders (converted to ? for SQLite)
269
+ * @param params - Query parameters
270
+ * @returns Query results
271
+ *
272
+ * @throws {DatabaseError} If query execution fails
273
+ */
274
+ async executeQuery(sql, params = []) {
275
+ const db = this.ensureDb();
276
+ const startTime = Date.now();
277
+ try {
278
+ const convertedSql = this.convertPlaceholders(sql);
279
+ const trimmedSql = convertedSql.trim().toUpperCase();
280
+ const isPragmaQuery = trimmedSql.startsWith("PRAGMA") && !trimmedSql.includes("=");
281
+ const isSelect = trimmedSql.startsWith("SELECT") || isPragmaQuery || trimmedSql.startsWith("WITH");
282
+ const hasReturning = trimmedSql.includes("RETURNING");
283
+ let result;
284
+ const sanitizedParams = params.map(sanitizeSqliteValue);
285
+ if (isSelect || hasReturning) result = db.prepare(convertedSql).all(...sanitizedParams);
286
+ else {
287
+ const runResult = db.prepare(convertedSql).run(...sanitizedParams);
288
+ result = [{
289
+ changes: runResult.changes,
290
+ lastInsertRowid: runResult.lastInsertRowid
291
+ }];
292
+ }
293
+ if (this.config.logger?.query) {
294
+ const durationMs = Date.now() - startTime;
295
+ this.config.logger.query(convertedSql, params, durationMs);
296
+ }
297
+ return result;
298
+ } catch (error) {
299
+ throw this.classifyError(error, sql);
300
+ }
301
+ }
302
+ /**
303
+ * Execute work within a transaction.
304
+ *
305
+ * @param work - Function containing transactional operations
306
+ * @param options - Transaction options (isolation level not fully supported in SQLite)
307
+ * @returns Result of the work function
308
+ *
309
+ * @remarks
310
+ * The adapter manages the transaction itself, because better-sqlite3's
311
+ * `db.transaction()` does not take async work: it runs `BEGIN IMMEDIATE`,
312
+ * then `COMMIT` when `work` resolves or `ROLLBACK` when it throws.
313
+ * - Top-level calls run one at a time.
314
+ * - A call made inside an open transaction's work joins it as a savepoint
315
+ * (`SAVEPOINT` / `RELEASE` / `ROLLBACK TO`).
316
+ * - Nested calls of one scope run one at a time.
317
+ * - A call made later from a savepoint that has already released joins the
318
+ * innermost scope still open.
319
+ * - Nested work started and not awaited finishes before the enclosing scope
320
+ * commits or rolls back.
321
+ *
322
+ * Note: SQLite uses DEFERRED, IMMEDIATE, or EXCLUSIVE transaction modes
323
+ * rather than isolation levels. This adapter uses IMMEDIATE.
324
+ */
325
+ /** How long a transaction waits for the connection: `pool.connectionTimeoutMs`. */
326
+ transactionWaitMs() {
327
+ return this.config.pool?.connectionTimeoutMs ?? DEFAULT_CONFIG.connectionTimeoutMs;
328
+ }
329
+ async transaction(work, _options) {
330
+ const own = this.transactionScope.getStore();
331
+ const parent = this.nestingScope();
332
+ if (own && parent !== own) own.writesAfterClosing += 1;
333
+ if (parent) return enqueueIn(parent, () => this.runSavepoint(work, parent));
334
+ const run = async () => this.runTransaction(work);
335
+ const previous = this.transactionQueue;
336
+ const next = waitForTurn(previous, this.transactionWaitMs()).then(run);
337
+ this.transactionQueue = previous.then(() => next).catch(() => void 0);
338
+ const { result, effects } = await next;
339
+ await this.runHeldEffects(effects);
340
+ return result;
341
+ }
342
+ /**
343
+ * Hold `effect` until the transaction enclosing this async context commits,
344
+ * or run it now when none does.
345
+ *
346
+ * The effect belongs to the scope this async context runs in, even one
347
+ * that has already released or rolled back: a service whose own
348
+ * transaction nested as a savepoint registers from its caller's scope once
349
+ * that savepoint has released, and the change is undoable for as long as
350
+ * any scope around it is. The outermost transaction runs the effect once
351
+ * its COMMIT succeeds, unless a scope between the effect's scope and the
352
+ * outermost transaction rolled back, and drops every effect on ROLLBACK or
353
+ * a failed COMMIT. A context whose outermost transaction has already
354
+ * finished is outside any transaction, so its effect runs now, unless what
355
+ * it announces was undone and it has run no transaction since.
356
+ */
357
+ async afterCommit(effect, onDeferredFailure) {
358
+ const scope = this.transactionScope.getStore();
359
+ if (!scope) {
360
+ await effect();
361
+ return;
362
+ }
363
+ const root = outermost(scope);
364
+ if (!root.finished) {
365
+ root.held.push({
366
+ effect: this.inRegisteringContext(effect),
367
+ onDeferredFailure,
368
+ scope,
369
+ writesBefore: scope.writesAfterClosing
370
+ });
371
+ return;
372
+ }
373
+ if (!undone(scope, scope.writesAfterClosing)) await effect();
374
+ }
375
+ /**
376
+ * `effect`, bound to the async context it is registered from.
377
+ *
378
+ * A held effect runs after the outermost COMMIT, from the code that ran the
379
+ * outermost transaction, so without this it sees that code's context rather
380
+ * than its own: whatever its caller pinned around the write is gone. That
381
+ * includes who is calling. A scope pinned for a scoped API key lost there
382
+ * leaves a hook's nested call judged by the key's owner instead of the key,
383
+ * and a person's frame lost there leaves the hook told of nobody.
384
+ *
385
+ * The adapter's own transaction scope is the one store it leaves: the
386
+ * effect runs once that transaction has finished, outside it, as an effect
387
+ * held to the outermost commit always has.
388
+ */
389
+ inRegisteringContext(effect) {
390
+ return AsyncResource.bind(() => this.transactionScope.exit(effect));
391
+ }
392
+ /**
393
+ * Run the effects a committed transaction held, in order. A failure is
394
+ * reported, never thrown: the change is durable, and reporting the
395
+ * transaction as failed would invite a retry of something already done.
396
+ */
397
+ async runHeldEffects(effects) {
398
+ for (const { effect, onDeferredFailure } of effects) try {
399
+ await effect();
400
+ } catch (error) {
401
+ this.reportHeldEffectFailure(error, onDeferredFailure);
402
+ }
403
+ }
404
+ /** Hand a held effect's failure to its reporter, or to the adapter's log. */
405
+ reportHeldEffectFailure(error, onDeferredFailure) {
406
+ try {
407
+ if (onDeferredFailure) {
408
+ onDeferredFailure(error);
409
+ return;
410
+ }
411
+ } catch {}
412
+ const failure = error instanceof Error ? error : new Error(String(error));
413
+ if (this.config.logger?.error) this.config.logger.error(failure, { phase: "after-commit" });
414
+ else console.error("An effect held until commit failed:", failure);
415
+ }
416
+ /**
417
+ * Whether a `transaction()` call made here would join an open transaction
418
+ * as a savepoint. On SQLite it would: the connection is the only one, so a
419
+ * transaction open on it encloses whatever the async chain that opened it
420
+ * starts.
421
+ */
422
+ inTransaction() {
423
+ return this.nestingScope() !== void 0;
424
+ }
425
+ /**
426
+ * Whether the calling async flow is inside a transaction that has not
427
+ * finished, judged from the transaction this flow was started inside,
428
+ * never from the connection: another request's open transaction answers
429
+ * false here, where its own `transaction()` queues behind that one.
430
+ *
431
+ * A flow started inside a transaction, even inside a savepoint that has
432
+ * since released, stays inside it until its outermost transaction has
433
+ * committed or rolled back: until then whatever it writes on the one
434
+ * connection lands in that transaction.
435
+ */
436
+ transactionOpenInFlow() {
437
+ const own = this.transactionScope.getStore();
438
+ return own !== void 0 && !outermost(own).finished;
439
+ }
440
+ /**
441
+ * The scope a `transaction()` call made here nests in, or none when it
442
+ * opens a transaction of its own.
443
+ *
444
+ * A call made later from inside a savepoint that has since released runs
445
+ * inside the innermost scope open on the connection. Queued on an enclosing
446
+ * scope, or on the instance, it would wait behind the scope running there,
447
+ * which may be the one awaiting it. Savepoints on one connection form a
448
+ * stack, so whatever opens now is inside that innermost scope anyway; when
449
+ * none is open the call queues on the instance.
450
+ *
451
+ * The innermost open scope need not be related to the call. When it is a
452
+ * sibling savepoint that later rolls back, the call's write rolls back with
453
+ * it, although the call itself already resolved. Waiting for an unrelated
454
+ * scope instead could wait on the very scope that awaits the call.
455
+ */
456
+ nestingScope() {
457
+ const own = this.transactionScope.getStore();
458
+ return own?.active ? own : own?.open.at(-1);
459
+ }
460
+ /**
461
+ * Run `work` as a savepoint of the active transaction: released when it
462
+ * resolves, rolled back to when it throws.
463
+ */
464
+ async runSavepoint(work, parent) {
465
+ const db = this.ensureDb();
466
+ const name = `nextly_sp_${parent.depth + 1}`;
467
+ try {
468
+ const ctx = this.createTransactionContext(db);
469
+ db.exec(`SAVEPOINT ${name}`);
470
+ const scope = openScope(parent);
471
+ try {
472
+ const result = await this.transactionScope.run(scope, () => work(ctx));
473
+ await settleNested(scope);
474
+ db.exec(`RELEASE ${name}`);
475
+ return result;
476
+ } catch (error) {
477
+ await settleNested(scope);
478
+ scope.rolledBack = true;
479
+ try {
480
+ db.exec(`ROLLBACK TO ${name}`);
481
+ db.exec(`RELEASE ${name}`);
482
+ } catch {}
483
+ throw error;
484
+ }
485
+ } catch (error) {
486
+ if (isApplicationError(error)) throw error;
487
+ throw this.classifyError(error);
488
+ }
489
+ }
490
+ /**
491
+ * Inner transaction body. Kept private so callers always go through
492
+ * the serialized `transaction()` queue above and the BEGIN/COMMIT
493
+ * pair is never invoked outside of it.
494
+ */
495
+ async runTransaction(work) {
496
+ const db = this.ensureDb();
497
+ const startTime = Date.now();
498
+ try {
499
+ const ctx = this.createTransactionContext(db);
500
+ db.exec("BEGIN IMMEDIATE");
501
+ const scope = openScope();
502
+ try {
503
+ const result = await this.transactionScope.run(scope, () => work(ctx));
504
+ await settleNested(scope);
505
+ db.exec("COMMIT");
506
+ scope.finished = true;
507
+ if (this.config.logger?.debug) {
508
+ const durationMs = Date.now() - startTime;
509
+ this.config.logger.debug("Transaction committed", { durationMs });
510
+ }
511
+ return {
512
+ result,
513
+ effects: scope.held.filter((held) => !undone(held.scope, held.writesBefore))
514
+ };
515
+ } catch (error) {
516
+ await settleNested(scope);
517
+ scope.rolledBack = true;
518
+ scope.finished = true;
519
+ scope.held.length = 0;
520
+ try {
521
+ db.exec("ROLLBACK");
522
+ } catch {}
523
+ throw error;
524
+ }
525
+ } catch (error) {
526
+ if (isApplicationError(error)) throw error;
527
+ throw this.classifyError(error);
528
+ }
529
+ }
530
+ /**
531
+ * Get SQLite database capabilities.
532
+ *
533
+ * @remarks
534
+ * SQLite capabilities:
535
+ * - JSON support (not JSONB)
536
+ * - No arrays
537
+ * - No native ILIKE
538
+ * - RETURNING clause (3.35+)
539
+ * - Savepoints supported
540
+ * - ON CONFLICT supported
541
+ */
542
+ getCapabilities() {
543
+ return {
544
+ dialect: "sqlite",
545
+ supportsJsonb: false,
546
+ supportsJson: true,
547
+ supportsArrays: false,
548
+ supportsGeneratedColumns: true,
549
+ supportsFts: true,
550
+ supportsIlike: false,
551
+ supportsReturning: true,
552
+ supportsSavepoints: true,
553
+ supportsOnConflict: true,
554
+ maxParamsPerQuery: 999,
555
+ maxIdentifierLength: 128
556
+ };
557
+ }
558
+ /**
559
+ * Override insertMany for bulk insert optimization.
560
+ *
561
+ * @remarks
562
+ * Uses a single multi-row INSERT statement for better performance.
563
+ */
564
+ async insertMany(table, data, options) {
565
+ if (data.length === 0) return [];
566
+ const db = this.ensureDb();
567
+ if (data.length === 1) return [await this.insert(table, data[0], options)];
568
+ const columns = Object.keys(data[0]);
569
+ const params = [];
570
+ const valuesClauses = [];
571
+ for (const record of data) {
572
+ const placeholders = [];
573
+ for (const col of columns) {
574
+ params.push(sanitizeSqliteValue(record[col]));
575
+ placeholders.push("?");
576
+ }
577
+ valuesClauses.push(`(${placeholders.join(", ")})`);
578
+ }
579
+ const columnList = columns.map((col) => this.escapeIdentifier(col)).join(", ");
580
+ let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columnList}) VALUES ${valuesClauses.join(", ")}`;
581
+ if (options?.returning) {
582
+ const returning = options.returning === "*" ? "*" : options.returning.map((col) => this.escapeIdentifier(col)).join(", ");
583
+ sql += ` RETURNING ${returning}`;
584
+ } else sql += " RETURNING *";
585
+ try {
586
+ const rows = db.prepare(sql).all(...params);
587
+ const tableObj = this.getTableObject(table);
588
+ return rows.map((r) => this.mapRowFromRawSql(tableObj, r));
589
+ } catch (error) {
590
+ throw this.handleQueryError(error, "insertMany", table);
591
+ }
592
+ }
593
+ /**
594
+ * Ensures database is connected and returns it.
595
+ *
596
+ * @throws {DatabaseError} If not connected
597
+ */
598
+ ensureDb() {
599
+ if (!this.db) throw createDatabaseError({
600
+ kind: "connection",
601
+ message: "SqliteAdapter is not connected. Call connect() first."
602
+ });
603
+ return this.db;
604
+ }
605
+ /**
606
+ * Return the typed Drizzle instance for SQLite.
607
+ * Guarded for server-only usage and requires an active connection.
608
+ *
609
+ * @param schema - Optional schema for relational queries (db.query.*)
610
+ * @returns Drizzle ORM instance wrapping the better-sqlite3 connection
611
+ * @throws {Error} If called in browser or not connected
612
+ */
613
+ getDrizzle(relations) {
614
+ if (typeof window !== "undefined") throw new Error("getDrizzle() is server-only");
615
+ const db = this.ensureDb();
616
+ if (!relations) {
617
+ this.drizzleBare ??= drizzle({ client: db });
618
+ return this.drizzleBare;
619
+ }
620
+ let cached = this.drizzleByRelations.get(relations);
621
+ if (!cached) {
622
+ cached = drizzle({
623
+ client: db,
624
+ relations
625
+ });
626
+ this.drizzleByRelations.set(relations, cached);
627
+ }
628
+ return cached;
629
+ }
630
+ /**
631
+ * Convert $1, $2 placeholders to ? for better-sqlite3.
632
+ *
633
+ * @param sql - SQL with PostgreSQL-style placeholders
634
+ * @returns SQL with ? placeholders
635
+ */
636
+ convertPlaceholders(sql) {
637
+ return sql.replace(/\$\d+/g, "?");
638
+ }
639
+ /**
640
+ * Creates a TransactionContext for the given database connection.
641
+ */
642
+ /** A table-level `primaryKey({ columns })`, read through the SQLite table config. */
643
+ compositePrimaryKey(tableObj) {
644
+ return getTableConfig(tableObj).primaryKeys.flatMap((key) => key.columns);
645
+ }
646
+ createTransactionContext(db) {
647
+ const txHandles = this.transactionDrizzleHandles((relations) => relations ? drizzle({
648
+ client: db,
649
+ relations
650
+ }) : drizzle({ client: db }));
651
+ const txDb = txHandles.bare;
652
+ return {
653
+ drizzleWithRelations: txHandles.withRelations,
654
+ lockRow: () => Promise.resolve(),
655
+ runStatement: (statement) => {
656
+ txDb().run(statement);
657
+ return Promise.resolve();
658
+ },
659
+ queryStatement: (statement) => Promise.resolve(txDb().all(statement)),
660
+ execute: async (sql, params = []) => {
661
+ const convertedSql = this.convertPlaceholders(sql);
662
+ const trimmedSql = convertedSql.trim().toUpperCase();
663
+ const isSelect = trimmedSql.startsWith("SELECT") || trimmedSql.includes("RETURNING");
664
+ const sanitizedParams = params.map(sanitizeSqliteValue);
665
+ if (isSelect) return db.prepare(convertedSql).all(...sanitizedParams);
666
+ else {
667
+ const result = db.prepare(convertedSql).run(...sanitizedParams);
668
+ return [{
669
+ changes: result.changes,
670
+ lastInsertRowid: result.lastInsertRowid
671
+ }];
672
+ }
673
+ },
674
+ insert: async (table, data, options) => {
675
+ const mapped = this.mapKeysToSqlColumns(this.getTableObject(table), data);
676
+ const columns = Object.keys(mapped);
677
+ const values = Object.values(mapped).map(sanitizeSqliteValue);
678
+ const placeholders = values.map(() => "?").join(", ");
679
+ let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES (${placeholders})`;
680
+ const ret = options?.returning;
681
+ const returningEmpty = Array.isArray(ret) && ret.length === 0;
682
+ if (!returningEmpty) {
683
+ const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(this.getTableObject(table), ret).map((col) => this.escapeIdentifier(col)).join(", ");
684
+ sql += ` RETURNING ${returning}`;
685
+ }
686
+ const stmt = db.prepare(sql);
687
+ if (returningEmpty) {
688
+ stmt.run(...values);
689
+ return;
690
+ }
691
+ const rows = stmt.all(...values);
692
+ return this.mapRowFromRawSql(this.getTableObject(table), rows[0]);
693
+ },
694
+ insertMany: async (table, data, options) => {
695
+ if (data.length === 0) return [];
696
+ const tableObj = this.getTableObject(table);
697
+ const mappedRecords = data.map((r) => this.mapKeysToSqlColumns(tableObj, r));
698
+ const columns = Object.keys(mappedRecords[0]);
699
+ const allValues = [];
700
+ const valuesClauses = [];
701
+ for (const record of mappedRecords) {
702
+ const placeholders = [];
703
+ for (const col of columns) {
704
+ allValues.push(sanitizeSqliteValue(record[col]));
705
+ placeholders.push("?");
706
+ }
707
+ valuesClauses.push(`(${placeholders.join(", ")})`);
708
+ }
709
+ let sql = `INSERT INTO ${this.escapeIdentifier(table)} (${columns.map((c) => this.escapeIdentifier(c)).join(", ")}) VALUES ${valuesClauses.join(", ")}`;
710
+ const ret = options?.returning;
711
+ const returningEmpty = Array.isArray(ret) && ret.length === 0;
712
+ if (!returningEmpty) {
713
+ const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(this.getTableObject(table), ret).map((col) => this.escapeIdentifier(col)).join(", ");
714
+ sql += ` RETURNING ${returning}`;
715
+ }
716
+ const stmt = db.prepare(sql);
717
+ if (returningEmpty) {
718
+ stmt.run(...allValues);
719
+ return [];
720
+ }
721
+ return stmt.all(...allValues).map((r) => this.mapRowFromRawSql(tableObj, r));
722
+ },
723
+ update: this.transactionUpdate(txDb, (statement, returnsRows) => returnsRows ? txDb().all(statement) : (txDb().run(statement), void 0), sanitizeSqliteValue),
724
+ ...this.createTransactionForwarders(txDb),
725
+ savepoint: async (name) => {
726
+ db.exec(`SAVEPOINT ${this.escapeIdentifier(name)}`);
727
+ },
728
+ rollbackToSavepoint: async (name) => {
729
+ db.exec(`ROLLBACK TO SAVEPOINT ${this.escapeIdentifier(name)}`);
730
+ },
731
+ releaseSavepoint: async (name) => {
732
+ db.exec(`RELEASE SAVEPOINT ${this.escapeIdentifier(name)}`);
733
+ }
734
+ };
735
+ }
736
+ /**
737
+ * Classifies a SQLite error into a DatabaseError.
738
+ *
739
+ * @param error - Original error from better-sqlite3
740
+ * @param sql - SQL statement that caused the error (optional)
741
+ * @returns DatabaseError with proper classification
742
+ */
743
+ classifyError(error, sql) {
744
+ if (isDatabaseError(error)) return error;
745
+ const sqliteError = this.driverErrorIn(error, (candidate) => typeof candidate.code === "string" && candidate.code !== "");
746
+ let kind = "unknown";
747
+ if (sqliteError.code) kind = SQLITE_ERROR_CODES[sqliteError.code] || "unknown";
748
+ else if (sqliteError.message) {
749
+ const msg = sqliteError.message.toUpperCase();
750
+ if (msg.includes("UNIQUE CONSTRAINT")) kind = "unique_violation";
751
+ else if (msg.includes("FOREIGN KEY CONSTRAINT")) kind = "foreign_key_violation";
752
+ else if (msg.includes("NOT NULL CONSTRAINT")) kind = "not_null_violation";
753
+ else if (msg.includes("CHECK CONSTRAINT")) kind = "check_violation";
754
+ else if (msg.includes("BUSY") || msg.includes("LOCKED")) kind = "timeout";
755
+ else if (msg.includes("SQLITE_CANTOPEN") || msg.includes("UNABLE TO OPEN")) kind = "connection";
756
+ }
757
+ let message = sqliteError.message ?? String(error);
758
+ if (sql && kind === "query") message = `Query failed: ${message}`;
759
+ return createDatabaseError({
760
+ kind,
761
+ message,
762
+ code: sqliteError.code,
763
+ cause: error instanceof Error ? error : void 0
764
+ });
765
+ }
812
766
  };
767
+ /**
768
+ * Create a SQLite database adapter.
769
+ *
770
+ * @param config - SQLite adapter configuration
771
+ * @returns A new SqliteAdapter instance
772
+ *
773
+ * @example
774
+ * ```typescript
775
+ * // Simple usage with file path
776
+ * const adapter = createSqliteAdapter({
777
+ * url: 'file:./data.db',
778
+ * });
779
+ *
780
+ * // In-memory database
781
+ * const memAdapter = createSqliteAdapter({
782
+ * memory: true,
783
+ * });
784
+ *
785
+ * // Full configuration
786
+ * const adapter = createSqliteAdapter({
787
+ * url: 'file:./data.db',
788
+ * wal: true,
789
+ * foreignKeys: true,
790
+ * busyTimeout: 5000,
791
+ * logger: {
792
+ * query: (sql, params, duration) => console.log(`Query: ${sql}`),
793
+ * },
794
+ * });
795
+ *
796
+ * await adapter.connect();
797
+ * ```
798
+ */
813
799
  function createSqliteAdapter(config) {
814
- return new SqliteAdapter(config);
800
+ return new SqliteAdapter(config);
815
801
  }
802
+ /**
803
+ * Type guard to check if a value is a SqliteAdapter.
804
+ *
805
+ * @param value - Value to check
806
+ * @returns True if value is a SqliteAdapter instance
807
+ *
808
+ * @example
809
+ * ```typescript
810
+ * if (isSqliteAdapter(adapter)) {
811
+ * // TypeScript knows adapter is SqliteAdapter
812
+ * console.log('Using SQLite');
813
+ * }
814
+ * ```
815
+ */
816
816
  function isSqliteAdapter(value) {
817
- return value instanceof SqliteAdapter;
817
+ return value instanceof SqliteAdapter;
818
818
  }
819
+ /**
820
+ * Open the scope of a savepoint inside `parent`, or of an outermost
821
+ * transaction when there is none, and record it among the connection's open
822
+ * scopes.
823
+ */
819
824
  function openScope(parent) {
820
- const scope = {
821
- active: true,
822
- depth: parent ? parent.depth + 1 : 0,
823
- parent,
824
- rolledBack: false,
825
- writesAfterClosing: 0,
826
- finished: false,
827
- held: [],
828
- queue: Promise.resolve(),
829
- open: parent ? parent.open : []
830
- };
831
- scope.open.push(scope);
832
- return scope;
825
+ const scope = {
826
+ active: true,
827
+ depth: parent ? parent.depth + 1 : 0,
828
+ parent,
829
+ rolledBack: false,
830
+ writesAfterClosing: 0,
831
+ finished: false,
832
+ held: [],
833
+ queue: Promise.resolve(),
834
+ open: parent ? parent.open : []
835
+ };
836
+ scope.open.push(scope);
837
+ return scope;
833
838
  }
839
+ /** The outermost transaction `scope` runs inside; itself for that one. */
834
840
  function outermost(scope) {
835
- let at = scope;
836
- while (at.parent) at = at.parent;
837
- return at;
841
+ let at = scope;
842
+ while (at.parent) at = at.parent;
843
+ return at;
838
844
  }
845
+ /**
846
+ * Whether an effect registered from `scope` describes a write that was
847
+ * undone: `scope`, or a scope it runs inside, rolled back, and no
848
+ * transaction had run from its context since it closed when the effect was
849
+ * registered (`writesBefore`). An effect registered before such a write
850
+ * still describes the undone one.
851
+ */
839
852
  function undone(scope, writesBefore) {
840
- if (writesBefore > 0) return false;
841
- for (let at = scope; at; at = at.parent) {
842
- if (at.rolledBack) return true;
843
- }
844
- return false;
853
+ if (writesBefore > 0) return false;
854
+ for (let at = scope; at; at = at.parent) if (at.rolledBack) return true;
855
+ return false;
856
+ }
857
+ /**
858
+ * Settles once `turn` does, or rejects with the pool-wait timeout error once
859
+ * `boundMs` passes first. A bound of 0 waits without limit, as pg-pool reads a
860
+ * `connectionTimeoutMillis` of 0.
861
+ */
862
+ function waitForTurn(turn, boundMs) {
863
+ if (boundMs <= 0) return turn.then(() => void 0);
864
+ return new Promise((resolve, reject) => {
865
+ const timer = setTimeout(() => reject(poolWaitTimeoutError(boundMs)), boundMs);
866
+ timer.unref();
867
+ turn.then(() => {
868
+ clearTimeout(timer);
869
+ resolve();
870
+ });
871
+ });
845
872
  }
873
+ /** Chain `run` onto a scope's queue, keeping the queue itself unrejected. */
846
874
  function enqueueIn(scope, run) {
847
- const next = scope.queue.then(run, run);
848
- scope.queue = next.catch(() => void 0);
849
- return next;
875
+ const next = scope.queue.then(run, run);
876
+ scope.queue = next.catch(() => void 0);
877
+ return next;
850
878
  }
879
+ /**
880
+ * Close a scope to new nested calls and wait for those already queued. A call
881
+ * arriving after this joins the innermost scope still open on the connection,
882
+ * or, when none is, goes to the instance queue behind the transaction.
883
+ */
851
884
  async function settleNested(scope) {
852
- scope.active = false;
853
- const index = scope.open.indexOf(scope);
854
- if (index !== -1) scope.open.splice(index, 1);
855
- await scope.queue;
885
+ scope.active = false;
886
+ const index = scope.open.indexOf(scope);
887
+ if (index !== -1) scope.open.splice(index, 1);
888
+ await scope.queue;
856
889
  }
857
-
858
890
  export { SqliteAdapter, VERSION, createSqliteAdapter, isSqliteAdapter };
859
- //# sourceMappingURL=index.mjs.map
891
+
860
892
  //# sourceMappingURL=index.mjs.map