@nextlyhq/adapter-sqlite 0.0.2-alpha.73 → 0.0.2-alpha.75

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 CHANGED
@@ -68,7 +68,7 @@ Most users do not need this.
68
68
 
69
69
  - **Single writer.** SQLite serializes writes; concurrent transactions queue. Fine for one user, painful for production traffic.
70
70
  - **No SSL or TLS.** Not applicable to a local file.
71
- - **Limited types.** No native arrays, no JSONB; JSON is stored as TEXT. ILIKE is emulated as `LOWER(...) LIKE LOWER(...)`.
71
+ - **Limited types.** No native arrays, no JSONB; JSON is stored as TEXT. ILIKE and case-insensitive `contains` fold both sides with `nextly_fold_case()`, a Unicode case fold the adapter registers on every connection it opens (SQLite's own `LOWER()` folds ASCII letters only), then compare with `LIKE`.
72
72
  - **Savepoints.** Supported.
73
73
  - **`RETURNING` clause.** Supported (SQLite 3.35+).
74
74
 
package/dist/index.cjs CHANGED
@@ -1,10 +1,26 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let node_async_hooks = require("node:async_hooks");
3
2
  let _nextlyhq_adapter_drizzle = require("@nextlyhq/adapter-drizzle");
4
3
  let _nextlyhq_adapter_drizzle_types = require("@nextlyhq/adapter-drizzle/types");
5
4
  let _nextlyhq_adapter_drizzle_version_check = require("@nextlyhq/adapter-drizzle/version-check");
6
5
  let drizzle_orm_better_sqlite3 = require("drizzle-orm/better-sqlite3");
7
6
  let drizzle_orm_sqlite_core = require("drizzle-orm/sqlite-core");
7
+ let _nextlyhq_adapter_drizzle_text_match = require("@nextlyhq/adapter-drizzle/text-match");
8
+ // src/sql-functions.ts
9
+ /**
10
+ * The SQL functions a SQLite connection needs before Nextly's statements run on it.
11
+ *
12
+ * @module sql-functions
13
+ */
14
+ /**
15
+ * Register the SQL functions Nextly's statements call on SQLite, on `db`: the Unicode case fold
16
+ * (`nextly_fold_case`), since SQLite's own `lower()` folds ASCII letters only.
17
+ *
18
+ * Every connection the adapter opens is handed here as it opens, before anything runs on it, so
19
+ * no statement can reach a connection without the function.
20
+ */
21
+ function registerSqliteFunctions(db) {
22
+ (0, _nextlyhq_adapter_drizzle_text_match.registerSqliteFoldCase)(db);
23
+ }
8
24
  // src/index.ts
9
25
  /**
10
26
  * @nextlyhq/adapter-sqlite
@@ -106,7 +122,8 @@ function sanitizeSqliteValue(v) {
106
122
  * - No connection pooling (single file/connection)
107
123
  * - RETURNING clause supported (SQLite 3.35+)
108
124
  * - Savepoints supported via nested transactions
109
- * - No native ILIKE (uses LOWER() LIKE workaround)
125
+ * - No native ILIKE (both sides case-folded, then LIKE; the fold is Nextly's own
126
+ * `nextly_fold_case`, registered on connect, since SQLite's `lower()` folds ASCII only)
110
127
  * - JSON support (not JSONB)
111
128
  * - No array types
112
129
  *
@@ -162,18 +179,6 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
162
179
  */
163
180
  transactionQueue = Promise.resolve();
164
181
  /**
165
- * The transaction the current async chain is running inside, if any.
166
- *
167
- * A `transaction()` call made from INSIDE a transaction's work — a store
168
- * writing on behalf of the caller, a plugin's write inside its own
169
- * `ctx.db.transaction` — used to queue behind the very transaction that
170
- * was waiting for it, and neither ever settled: that hung every later
171
- * transaction on the instance too. Such a call now runs as a SAVEPOINT of
172
- * the active transaction instead, so it commits or rolls back with it and
173
- * its own failure undoes only its own part.
174
- */
175
- transactionScope = new node_async_hooks.AsyncLocalStorage();
176
- /**
177
182
  * Creates a new SQLite adapter instance.
178
183
  *
179
184
  * @param config - Adapter configuration
@@ -210,6 +215,7 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
210
215
  readonly: this.config.readonly ?? false,
211
216
  timeout: this.config.busyTimeout ?? DEFAULT_CONFIG.busyTimeout
212
217
  });
218
+ registerSqliteFunctions(this.db);
213
219
  if ((this.config.wal ?? DEFAULT_CONFIG.wal) && dbPath !== ":memory:" && !this.config.readonly) this.db.pragma("journal_mode = WAL");
214
220
  if (this.config.foreignKeys ?? DEFAULT_CONFIG.foreignKeys) this.db.pragma("foreign_keys = ON");
215
221
  await (0, _nextlyhq_adapter_drizzle_version_check.checkDialectVersion)(this.db, "sqlite");
@@ -264,6 +270,30 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
264
270
  return null;
265
271
  }
266
272
  /**
273
+ * The connection as a health check reads it: a file on this machine, with
274
+ * no network connection to encrypt, and the library's version, timed for
275
+ * the round trip. A read that fails leaves the version and time out.
276
+ */
277
+ inspectConnection() {
278
+ const inspection = {
279
+ encryption: "not-applicable",
280
+ local: true
281
+ };
282
+ if (!this.db) return Promise.resolve(inspection);
283
+ try {
284
+ const started = performance.now();
285
+ const row = this.db.prepare("SELECT sqlite_version() AS version").get();
286
+ const roundTripMs = Math.max(0, Math.round(performance.now() - started));
287
+ return Promise.resolve({
288
+ ...inspection,
289
+ roundTripMs,
290
+ ...typeof row?.version === "string" && row.version !== "" ? { serverVersion: row.version } : {}
291
+ });
292
+ } catch {
293
+ return Promise.resolve(inspection);
294
+ }
295
+ }
296
+ /**
267
297
  * Execute a raw SQL query.
268
298
  *
269
299
  * @param sql - SQL query string with $1, $2 placeholders (converted to ? for SQLite)
@@ -300,6 +330,10 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
300
330
  throw this.classifyError(error, sql);
301
331
  }
302
332
  }
333
+ /** How long a transaction waits for the connection: `pool.connectionTimeoutMs`. */
334
+ transactionWaitMs() {
335
+ return this.config.pool?.connectionTimeoutMs ?? DEFAULT_CONFIG.connectionTimeoutMs;
336
+ }
303
337
  /**
304
338
  * Execute work within a transaction.
305
339
  *
@@ -323,18 +357,16 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
323
357
  * Note: SQLite uses DEFERRED, IMMEDIATE, or EXCLUSIVE transaction modes
324
358
  * rather than isolation levels. This adapter uses IMMEDIATE.
325
359
  */
326
- /** How long a transaction waits for the connection: `pool.connectionTimeoutMs`. */
327
- transactionWaitMs() {
328
- return this.config.pool?.connectionTimeoutMs ?? DEFAULT_CONFIG.connectionTimeoutMs;
329
- }
330
- async transaction(work, _options) {
331
- const own = this.transactionScope.getStore();
332
- const parent = this.nestingScope();
333
- if (own && parent !== own) own.writesAfterClosing += 1;
334
- if (parent) return enqueueIn(parent, () => this.runSavepoint(work, parent));
335
- const { result, effects } = await this.takeTurn(() => this.runTransaction(work));
336
- await this.runHeldEffects(effects);
337
- return result;
360
+ transaction(work, options) {
361
+ return this.openOrJoin(work, options, {
362
+ run: () => this.takeTurn(() => this.runTransaction(work, options)),
363
+ retryable: () => false,
364
+ describe: () => ({
365
+ label: "a retryable error",
366
+ detail: {}
367
+ }),
368
+ logger: this.config.logger
369
+ });
338
370
  }
339
371
  /**
340
372
  * Runs `work` outside a transaction, each statement committing as it runs,
@@ -361,189 +393,58 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
361
393
  this.transactionQueue = previous.then(() => next).catch(() => void 0);
362
394
  return next;
363
395
  }
364
- /**
365
- * Hold `effect` until the transaction enclosing this async context commits,
366
- * or run it now when none does.
367
- *
368
- * The effect belongs to the scope this async context runs in, even one
369
- * that has already released or rolled back: a service whose own
370
- * transaction nested as a savepoint registers from its caller's scope once
371
- * that savepoint has released, and the change is undoable for as long as
372
- * any scope around it is. The outermost transaction runs the effect once
373
- * its COMMIT succeeds, unless a scope between the effect's scope and the
374
- * outermost transaction rolled back, and drops every effect on ROLLBACK or
375
- * a failed COMMIT. A context whose outermost transaction has already
376
- * finished is outside any transaction, so its effect runs now, unless what
377
- * it announces was undone and it has run no transaction since.
378
- */
379
- async afterCommit(effect, onDeferredFailure) {
380
- const scope = this.transactionScope.getStore();
381
- if (!scope) {
382
- await effect();
383
- return;
384
- }
385
- const root = outermost(scope);
386
- if (!root.finished) {
387
- root.held.push({
388
- effect: this.inRegisteringContext(effect),
389
- onDeferredFailure,
390
- scope,
391
- writesBefore: scope.writesAfterClosing
392
- });
393
- return;
394
- }
395
- if (!undone(scope, scope.writesAfterClosing)) await effect();
396
- }
397
- /**
398
- * `effect`, bound to the async context it is registered from.
399
- *
400
- * A held effect runs after the outermost COMMIT, from the code that ran the
401
- * outermost transaction, so without this it sees that code's context rather
402
- * than its own: whatever its caller pinned around the write is gone. That
403
- * includes who is calling. A scope pinned for a scoped API key lost there
404
- * leaves a hook's nested call judged by the key's owner instead of the key,
405
- * and a person's frame lost there leaves the hook told of nobody.
406
- *
407
- * The adapter's own transaction scope is the one store it leaves: the
408
- * effect runs once that transaction has finished, outside it, as an effect
409
- * held to the outermost commit always has.
410
- */
411
- inRegisteringContext(effect) {
412
- return node_async_hooks.AsyncResource.bind(() => this.transactionScope.exit(effect));
413
- }
414
- /**
415
- * Run the effects a committed transaction held, in order. A failure is
416
- * reported, never thrown: the change is durable, and reporting the
417
- * transaction as failed would invite a retry of something already done.
418
- */
419
- async runHeldEffects(effects) {
420
- for (const { effect, onDeferredFailure } of effects) try {
421
- await effect();
422
- } catch (error) {
423
- this.reportHeldEffectFailure(error, onDeferredFailure);
424
- }
425
- }
426
- /** Hand a held effect's failure to its reporter, or to the adapter's log. */
427
- reportHeldEffectFailure(error, onDeferredFailure) {
428
- try {
429
- if (onDeferredFailure) {
430
- onDeferredFailure(error);
431
- return;
432
- }
433
- } catch {}
434
- const failure = error instanceof Error ? error : new Error(String(error));
396
+ /** A held effect's failure that no reporter took goes to the adapter's logger. */
397
+ logHeldEffectFailure(failure) {
435
398
  if (this.config.logger?.error) this.config.logger.error(failure, { phase: "after-commit" });
436
- else console.error("An effect held until commit failed:", failure);
437
- }
438
- /**
439
- * Whether a `transaction()` call made here would join an open transaction
440
- * as a savepoint. On SQLite it would: the connection is the only one, so a
441
- * transaction open on it encloses whatever the async chain that opened it
442
- * starts.
443
- */
444
- inTransaction() {
445
- return this.nestingScope() !== void 0;
399
+ else super.logHeldEffectFailure(failure);
446
400
  }
447
401
  /**
448
- * Whether the calling async flow is inside a transaction that has not
449
- * finished, judged from the transaction this flow was started inside,
450
- * never from the connection: another request's open transaction answers
451
- * false here, where its own `transaction()` queues behind that one.
452
- *
453
- * A flow started inside a transaction, even inside a savepoint that has
454
- * since released, stays inside it until its outermost transaction has
455
- * committed or rolled back: until then whatever it writes on the one
456
- * connection lands in that transaction.
457
- */
458
- transactionOpenInFlow() {
459
- const own = this.transactionScope.getStore();
460
- return own !== void 0 && !outermost(own).finished;
461
- }
462
- /**
463
- * The scope a `transaction()` call made here nests in, or none when it
464
- * opens a transaction of its own.
465
- *
466
- * A call made later from inside a savepoint that has since released runs
467
- * inside the innermost scope open on the connection. Queued on an enclosing
468
- * scope, or on the instance, it would wait behind the scope running there,
469
- * which may be the one awaiting it. Savepoints on one connection form a
470
- * stack, so whatever opens now is inside that innermost scope anyway; when
471
- * none is open the call queues on the instance.
472
- *
473
- * The innermost open scope need not be related to the call. When it is a
474
- * sibling savepoint that later rolls back, the call's write rolls back with
475
- * it, although the call itself already resolved. Waiting for an unrelated
476
- * scope instead could wait on the very scope that awaits the call.
402
+ * The statements of a savepoint `depth` deep on the one connection:
403
+ * released when its work resolves, rolled back to when it throws.
477
404
  */
478
- nestingScope() {
479
- const own = this.transactionScope.getStore();
480
- return own?.active ? own : own?.open.at(-1);
481
- }
482
- /**
483
- * Run `work` as a savepoint of the active transaction: released when it
484
- * resolves, rolled back to when it throws.
485
- */
486
- async runSavepoint(work, parent) {
405
+ savepointSteps(depth) {
487
406
  const db = this.ensureDb();
488
- const name = `nextly_sp_${parent.depth + 1}`;
489
- try {
490
- const ctx = this.createTransactionContext(db);
491
- db.exec(`SAVEPOINT ${name}`);
492
- const scope = openScope(parent);
493
- try {
494
- const result = await this.transactionScope.run(scope, () => work(ctx));
495
- await settleNested(scope);
407
+ const name = `nextly_sp_${depth}`;
408
+ return {
409
+ context: this.createTransactionContext(db),
410
+ begin: () => {
411
+ db.exec(`SAVEPOINT ${name}`);
412
+ },
413
+ commit: () => {
414
+ db.exec(`RELEASE ${name}`);
415
+ },
416
+ rollback: () => {
417
+ db.exec(`ROLLBACK TO ${name}`);
496
418
  db.exec(`RELEASE ${name}`);
497
- return result;
498
- } catch (error) {
499
- await settleNested(scope);
500
- scope.rolledBack = true;
501
- try {
502
- db.exec(`ROLLBACK TO ${name}`);
503
- db.exec(`RELEASE ${name}`);
504
- } catch {}
505
- throw error;
506
419
  }
507
- } catch (error) {
508
- if ((0, _nextlyhq_adapter_drizzle_types.isApplicationError)(error)) throw error;
509
- throw this.classifyError(error);
510
- }
420
+ };
511
421
  }
512
422
  /**
513
- * Inner transaction body. Kept private so callers always go through
514
- * the serialized `transaction()` queue above and the BEGIN/COMMIT
515
- * pair is never invoked outside of it.
423
+ * One attempt's transaction body, settled rather than rejected for the
424
+ * transaction's own failure (`runOutermost`). Kept private so callers always
425
+ * go through the serialized `transaction()` queue above and the
426
+ * BEGIN/COMMIT pair is never invoked outside of it.
516
427
  */
517
- async runTransaction(work) {
428
+ async runTransaction(work, options) {
518
429
  const db = this.ensureDb();
519
- const startTime = Date.now();
520
430
  try {
521
- const ctx = this.createTransactionContext(db);
522
- db.exec("BEGIN IMMEDIATE");
523
- const scope = openScope();
524
- try {
525
- const result = await this.transactionScope.run(scope, () => work(ctx));
526
- await settleNested(scope);
527
- db.exec("COMMIT");
528
- scope.finished = true;
529
- if (this.config.logger?.debug) {
530
- const durationMs = Date.now() - startTime;
531
- this.config.logger.debug("Transaction committed", { durationMs });
431
+ return await this.settleOutermost({
432
+ context: this.createTransactionContext(db),
433
+ join: { adapter: (depth) => this.savepointSteps(depth) },
434
+ begin: () => {
435
+ db.exec("BEGIN IMMEDIATE");
436
+ },
437
+ commit: () => {
438
+ db.exec("COMMIT");
439
+ },
440
+ rollback: () => {
441
+ try {
442
+ db.exec("ROLLBACK");
443
+ } catch (error) {
444
+ if (db.inTransaction) throw error;
445
+ }
532
446
  }
533
- return {
534
- result,
535
- effects: scope.held.filter((held) => !undone(held.scope, held.writesBefore))
536
- };
537
- } catch (error) {
538
- await settleNested(scope);
539
- scope.rolledBack = true;
540
- scope.finished = true;
541
- scope.held.length = 0;
542
- try {
543
- db.exec("ROLLBACK");
544
- } catch {}
545
- throw error;
546
- }
447
+ }, work, options);
547
448
  } catch (error) {
548
449
  if ((0, _nextlyhq_adapter_drizzle_types.isApplicationError)(error)) throw error;
549
450
  throw this.classifyError(error);
@@ -587,6 +488,7 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
587
488
  if (data.length === 0) return [];
588
489
  const db = this.ensureDb();
589
490
  if (data.length === 1) return [await this.insert(table, data[0], options)];
491
+ const tableObj = this.resolvedTableObject(table);
590
492
  const columns = Object.keys(data[0]);
591
493
  const params = [];
592
494
  const valuesClauses = [];
@@ -605,9 +507,7 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
605
507
  sql += ` RETURNING ${returning}`;
606
508
  } else sql += " RETURNING *";
607
509
  try {
608
- const rows = db.prepare(sql).all(...params);
609
- const tableObj = this.getTableObject(table);
610
- return rows.map((r) => this.mapRowFromRawSql(tableObj, r));
510
+ return db.prepare(sql).all(...params).map((r) => this.mapRowFromRawSql(tableObj, r));
611
511
  } catch (error) {
612
512
  throw this.handleQueryError(error, "insertMany", table);
613
513
  }
@@ -694,7 +594,8 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
694
594
  }
695
595
  },
696
596
  insert: async (table, data, options) => {
697
- const mapped = this.mapKeysToSqlColumns(this.getTableObject(table), data);
597
+ const tableObj = this.resolvedTableObject(table);
598
+ const mapped = this.mapKeysToSqlColumns(tableObj, data);
698
599
  const columns = Object.keys(mapped);
699
600
  const values = Object.values(mapped).map(sanitizeSqliteValue);
700
601
  const placeholders = values.map(() => "?").join(", ");
@@ -702,7 +603,7 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
702
603
  const ret = options?.returning;
703
604
  const returningEmpty = Array.isArray(ret) && ret.length === 0;
704
605
  if (!returningEmpty) {
705
- const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(this.getTableObject(table), ret).map((col) => this.escapeIdentifier(col)).join(", ");
606
+ const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(tableObj, ret).map((col) => this.escapeIdentifier(col)).join(", ");
706
607
  sql += ` RETURNING ${returning}`;
707
608
  }
708
609
  const stmt = db.prepare(sql);
@@ -711,11 +612,11 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
711
612
  return;
712
613
  }
713
614
  const rows = stmt.all(...values);
714
- return this.mapRowFromRawSql(this.getTableObject(table), rows[0]);
615
+ return this.mapRowFromRawSql(tableObj, rows[0]);
715
616
  },
716
617
  insertMany: async (table, data, options) => {
717
618
  if (data.length === 0) return [];
718
- const tableObj = this.getTableObject(table);
619
+ const tableObj = this.resolvedTableObject(table);
719
620
  const mappedRecords = data.map((r) => this.mapKeysToSqlColumns(tableObj, r));
720
621
  const columns = Object.keys(mappedRecords[0]);
721
622
  const allValues = [];
@@ -732,7 +633,7 @@ var SqliteAdapter = class extends _nextlyhq_adapter_drizzle.DrizzleAdapter {
732
633
  const ret = options?.returning;
733
634
  const returningEmpty = Array.isArray(ret) && ret.length === 0;
734
635
  if (!returningEmpty) {
735
- const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(this.getTableObject(table), ret).map((col) => this.escapeIdentifier(col)).join(", ");
636
+ const returning = !ret || ret === "*" ? "*" : this.mapColumnNamesToSql(tableObj, ret).map((col) => this.escapeIdentifier(col)).join(", ");
736
637
  sql += ` RETURNING ${returning}`;
737
638
  }
738
639
  const stmt = db.prepare(sql);
@@ -839,44 +740,6 @@ function isSqliteAdapter(value) {
839
740
  return value instanceof SqliteAdapter;
840
741
  }
841
742
  /**
842
- * Open the scope of a savepoint inside `parent`, or of an outermost
843
- * transaction when there is none, and record it among the connection's open
844
- * scopes.
845
- */
846
- function openScope(parent) {
847
- const scope = {
848
- active: true,
849
- depth: parent ? parent.depth + 1 : 0,
850
- parent,
851
- rolledBack: false,
852
- writesAfterClosing: 0,
853
- finished: false,
854
- held: [],
855
- queue: Promise.resolve(),
856
- open: parent ? parent.open : []
857
- };
858
- scope.open.push(scope);
859
- return scope;
860
- }
861
- /** The outermost transaction `scope` runs inside; itself for that one. */
862
- function outermost(scope) {
863
- let at = scope;
864
- while (at.parent) at = at.parent;
865
- return at;
866
- }
867
- /**
868
- * Whether an effect registered from `scope` describes a write that was
869
- * undone: `scope`, or a scope it runs inside, rolled back, and no
870
- * transaction had run from its context since it closed when the effect was
871
- * registered (`writesBefore`). An effect registered before such a write
872
- * still describes the undone one.
873
- */
874
- function undone(scope, writesBefore) {
875
- if (writesBefore > 0) return false;
876
- for (let at = scope; at; at = at.parent) if (at.rolledBack) return true;
877
- return false;
878
- }
879
- /**
880
743
  * Settles once `turn` does, or rejects with the pool-wait timeout error once
881
744
  * `boundMs` passes first. A bound of 0 waits without limit, as pg-pool reads a
882
745
  * `connectionTimeoutMillis` of 0.
@@ -892,23 +755,6 @@ function waitForTurn(turn, boundMs) {
892
755
  });
893
756
  });
894
757
  }
895
- /** Chain `run` onto a scope's queue, keeping the queue itself unrejected. */
896
- function enqueueIn(scope, run) {
897
- const next = scope.queue.then(run, run);
898
- scope.queue = next.catch(() => void 0);
899
- return next;
900
- }
901
- /**
902
- * Close a scope to new nested calls and wait for those already queued. A call
903
- * arriving after this joins the innermost scope still open on the connection,
904
- * or, when none is, goes to the instance queue behind the transaction.
905
- */
906
- async function settleNested(scope) {
907
- scope.active = false;
908
- const index = scope.open.indexOf(scope);
909
- if (index !== -1) scope.open.splice(index, 1);
910
- await scope.queue;
911
- }
912
758
  exports.SqliteAdapter = SqliteAdapter;
913
759
  exports.VERSION = VERSION;
914
760
  exports.createSqliteAdapter = createSqliteAdapter;