@nextlyhq/adapter-sqlite 0.0.2-alpha.66 → 0.0.2-alpha.67

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
@@ -81,11 +81,11 @@ Most users do not need this.
81
81
 
82
82
  ## Compatibility
83
83
 
84
- | Tool | Version |
85
- | ---------------- | ------- |
86
- | Node.js | 20+ |
87
- | `better-sqlite3` | 11+ |
88
- | `nextly` | 0.0.x |
84
+ | Tool | Version |
85
+ | ---------------- | --------------------- |
86
+ | Node.js | 20.19+, 22.12+ or 24+ |
87
+ | `better-sqlite3` | 11+ |
88
+ | `nextly` | 0.0.x |
89
89
 
90
90
  ## Documentation
91
91
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var async_hooks = require('async_hooks');
3
4
  var adapterDrizzle = require('@nextlyhq/adapter-drizzle');
4
5
  var types = require('@nextlyhq/adapter-drizzle/types');
5
6
  var versionCheck = require('@nextlyhq/adapter-drizzle/version-check');
@@ -89,6 +90,18 @@ var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
89
90
  * surface area without changing the achievable concurrency.
90
91
  */
91
92
  transactionQueue = Promise.resolve();
93
+ /**
94
+ * The transaction the current async chain is running inside, if any.
95
+ *
96
+ * A `transaction()` call made from INSIDE a transaction's work — a store
97
+ * writing on behalf of the caller, a plugin's write inside its own
98
+ * `ctx.db.transaction` — used to queue behind the very transaction that
99
+ * was waiting for it, and neither ever settled: that hung every later
100
+ * transaction on the instance too. Such a call now runs as a SAVEPOINT of
101
+ * the active transaction instead, so it commits or rolls back with it and
102
+ * its own failure undoes only its own part.
103
+ */
104
+ transactionScope = new async_hooks.AsyncLocalStorage();
92
105
  /**
93
106
  * Creates a new SQLite adapter instance.
94
107
  *
@@ -251,19 +264,190 @@ var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
251
264
  * @returns Result of the work function
252
265
  *
253
266
  * @remarks
254
- * Uses better-sqlite3's native transaction handling which:
255
- * - Automatically commits on success
256
- * - Automatically rolls back on error
257
- * - Converts nested transactions to savepoints
267
+ * The adapter manages the transaction itself, because better-sqlite3's
268
+ * `db.transaction()` does not take async work: it runs `BEGIN IMMEDIATE`,
269
+ * then `COMMIT` when `work` resolves or `ROLLBACK` when it throws.
270
+ * - Top-level calls run one at a time.
271
+ * - A call made inside an open transaction's work joins it as a savepoint
272
+ * (`SAVEPOINT` / `RELEASE` / `ROLLBACK TO`).
273
+ * - Nested calls of one scope run one at a time.
274
+ * - A call made later from a savepoint that has already released joins the
275
+ * innermost scope still open.
276
+ * - Nested work started and not awaited finishes before the enclosing scope
277
+ * commits or rolls back.
258
278
  *
259
279
  * Note: SQLite uses DEFERRED, IMMEDIATE, or EXCLUSIVE transaction modes
260
- * rather than isolation levels. This adapter uses IMMEDIATE by default.
280
+ * rather than isolation levels. This adapter uses IMMEDIATE.
261
281
  */
262
282
  async transaction(work, _options) {
283
+ const own = this.transactionScope.getStore();
284
+ const parent = this.nestingScope();
285
+ if (own && parent !== own) own.writesAfterClosing += 1;
286
+ if (parent) {
287
+ return enqueueIn(parent, () => this.runSavepoint(work, parent));
288
+ }
263
289
  const run = async () => this.runTransaction(work);
264
290
  const next = this.transactionQueue.then(run, run);
265
291
  this.transactionQueue = next.catch(() => void 0);
266
- return next;
292
+ const { result, effects } = await next;
293
+ await this.runHeldEffects(effects);
294
+ return result;
295
+ }
296
+ /**
297
+ * Hold `effect` until the transaction enclosing this async context commits,
298
+ * or run it now when none does.
299
+ *
300
+ * The effect belongs to the scope this async context runs in, even one
301
+ * that has already released or rolled back: a service whose own
302
+ * transaction nested as a savepoint registers from its caller's scope once
303
+ * that savepoint has released, and the change is undoable for as long as
304
+ * any scope around it is. The outermost transaction runs the effect once
305
+ * its COMMIT succeeds, unless a scope between the effect's scope and the
306
+ * outermost transaction rolled back, and drops every effect on ROLLBACK or
307
+ * a failed COMMIT. A context whose outermost transaction has already
308
+ * finished is outside any transaction, so its effect runs now, unless what
309
+ * it announces was undone and it has run no transaction since.
310
+ */
311
+ async afterCommit(effect, onDeferredFailure) {
312
+ const scope = this.transactionScope.getStore();
313
+ if (!scope) {
314
+ await effect();
315
+ return;
316
+ }
317
+ const root = outermost(scope);
318
+ if (!root.finished) {
319
+ root.held.push({
320
+ effect: this.inRegisteringContext(effect),
321
+ onDeferredFailure,
322
+ scope,
323
+ writesBefore: scope.writesAfterClosing
324
+ });
325
+ return;
326
+ }
327
+ if (!undone(scope, scope.writesAfterClosing)) await effect();
328
+ }
329
+ /**
330
+ * `effect`, bound to the async context it is registered from.
331
+ *
332
+ * A held effect runs after the outermost COMMIT, from the code that ran the
333
+ * outermost transaction, so without this it sees that code's context rather
334
+ * than its own: whatever its caller pinned around the write is gone. That
335
+ * includes who is calling. A scope pinned for a scoped API key lost there
336
+ * leaves a hook's nested call judged by the key's owner instead of the key,
337
+ * and a person's frame lost there leaves the hook told of nobody.
338
+ *
339
+ * The adapter's own transaction scope is the one store it leaves: the
340
+ * effect runs once that transaction has finished, outside it, as an effect
341
+ * held to the outermost commit always has.
342
+ */
343
+ inRegisteringContext(effect) {
344
+ return async_hooks.AsyncResource.bind(() => this.transactionScope.exit(effect));
345
+ }
346
+ /**
347
+ * Run the effects a committed transaction held, in order. A failure is
348
+ * reported, never thrown: the change is durable, and reporting the
349
+ * transaction as failed would invite a retry of something already done.
350
+ */
351
+ async runHeldEffects(effects) {
352
+ for (const { effect, onDeferredFailure } of effects) {
353
+ try {
354
+ await effect();
355
+ } catch (error) {
356
+ this.reportHeldEffectFailure(error, onDeferredFailure);
357
+ }
358
+ }
359
+ }
360
+ /** Hand a held effect's failure to its reporter, or to the adapter's log. */
361
+ reportHeldEffectFailure(error, onDeferredFailure) {
362
+ try {
363
+ if (onDeferredFailure) {
364
+ onDeferredFailure(error);
365
+ return;
366
+ }
367
+ } catch {
368
+ }
369
+ const failure = error instanceof Error ? error : new Error(String(error));
370
+ if (this.config.logger?.error) {
371
+ this.config.logger.error(failure, { phase: "after-commit" });
372
+ } else {
373
+ console.error("An effect held until commit failed:", failure);
374
+ }
375
+ }
376
+ /**
377
+ * Whether a `transaction()` call made here would join an open transaction
378
+ * as a savepoint. On SQLite it would: the connection is the only one, so a
379
+ * transaction open on it encloses whatever the async chain that opened it
380
+ * starts.
381
+ */
382
+ inTransaction() {
383
+ return this.nestingScope() !== void 0;
384
+ }
385
+ /**
386
+ * Whether the calling async flow is inside a transaction that has not
387
+ * finished, judged from the transaction this flow was started inside,
388
+ * never from the connection: another request's open transaction answers
389
+ * false here, where its own `transaction()` queues behind that one.
390
+ *
391
+ * A flow started inside a transaction, even inside a savepoint that has
392
+ * since released, stays inside it until its outermost transaction has
393
+ * committed or rolled back: until then whatever it writes on the one
394
+ * connection lands in that transaction.
395
+ */
396
+ transactionOpenInFlow() {
397
+ const own = this.transactionScope.getStore();
398
+ return own !== void 0 && !outermost(own).finished;
399
+ }
400
+ /**
401
+ * The scope a `transaction()` call made here nests in, or none when it
402
+ * opens a transaction of its own.
403
+ *
404
+ * A call made later from inside a savepoint that has since released runs
405
+ * inside the innermost scope open on the connection. Queued on an enclosing
406
+ * scope, or on the instance, it would wait behind the scope running there,
407
+ * which may be the one awaiting it. Savepoints on one connection form a
408
+ * stack, so whatever opens now is inside that innermost scope anyway; when
409
+ * none is open the call queues on the instance.
410
+ *
411
+ * The innermost open scope need not be related to the call. When it is a
412
+ * sibling savepoint that later rolls back, the call's write rolls back with
413
+ * it, although the call itself already resolved. Waiting for an unrelated
414
+ * scope instead could wait on the very scope that awaits the call.
415
+ */
416
+ nestingScope() {
417
+ const own = this.transactionScope.getStore();
418
+ return own?.active ? own : own?.open.at(-1);
419
+ }
420
+ /**
421
+ * Run `work` as a savepoint of the active transaction: released when it
422
+ * resolves, rolled back to when it throws.
423
+ */
424
+ async runSavepoint(work, parent) {
425
+ const db = this.ensureDb();
426
+ const depth = parent.depth + 1;
427
+ const name = `nextly_sp_${depth}`;
428
+ try {
429
+ const ctx = this.createTransactionContext(db);
430
+ db.exec(`SAVEPOINT ${name}`);
431
+ const scope = openScope(parent);
432
+ try {
433
+ const result = await this.transactionScope.run(scope, () => work(ctx));
434
+ await settleNested(scope);
435
+ db.exec(`RELEASE ${name}`);
436
+ return result;
437
+ } catch (error) {
438
+ await settleNested(scope);
439
+ scope.rolledBack = true;
440
+ try {
441
+ db.exec(`ROLLBACK TO ${name}`);
442
+ db.exec(`RELEASE ${name}`);
443
+ } catch {
444
+ }
445
+ throw error;
446
+ }
447
+ } catch (error) {
448
+ if (types.isApplicationError(error)) throw error;
449
+ throw this.classifyError(error);
450
+ }
267
451
  }
268
452
  /**
269
453
  * Inner transaction body. Kept private so callers always go through
@@ -276,17 +460,29 @@ var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
276
460
  try {
277
461
  const ctx = this.createTransactionContext(db);
278
462
  db.exec("BEGIN IMMEDIATE");
463
+ const scope = openScope();
279
464
  try {
280
- const result = await work(ctx);
465
+ const result = await this.transactionScope.run(scope, () => work(ctx));
466
+ await settleNested(scope);
281
467
  db.exec("COMMIT");
468
+ scope.finished = true;
282
469
  if (this.config.logger?.debug) {
283
470
  const durationMs = Date.now() - startTime;
284
471
  this.config.logger.debug("Transaction committed", {
285
472
  durationMs
286
473
  });
287
474
  }
288
- return result;
475
+ return {
476
+ result,
477
+ effects: scope.held.filter(
478
+ (held) => !undone(held.scope, held.writesBefore)
479
+ )
480
+ };
289
481
  } catch (error) {
482
+ await settleNested(scope);
483
+ scope.rolledBack = true;
484
+ scope.finished = true;
485
+ scope.held.length = 0;
290
486
  try {
291
487
  db.exec("ROLLBACK");
292
488
  } catch {
@@ -439,10 +635,12 @@ var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
439
635
  ).primaryKeys.flatMap((key) => key.columns);
440
636
  }
441
637
  createTransactionContext(db) {
442
- const buildTxExecutor = () => betterSqlite3.drizzle({ client: db });
443
- let txExecutor;
444
- const txDb = () => txExecutor ??= buildTxExecutor();
638
+ const txHandles = this.transactionDrizzleHandles(
639
+ (relations) => relations ? betterSqlite3.drizzle({ client: db, relations }) : betterSqlite3.drizzle({ client: db })
640
+ );
641
+ const txDb = txHandles.bare;
445
642
  return {
643
+ drizzleWithRelations: txHandles.withRelations,
446
644
  // SQLite has no row-level locking and needs none here: `withTransaction`
447
645
  // opens SQLite transactions with BEGIN IMMEDIATE, which takes the write
448
646
  // lock up front and serializes writers for the whole transaction.
@@ -579,7 +777,10 @@ var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
579
777
  // fallow-ignore-next-line complexity
580
778
  classifyError(error, sql) {
581
779
  if (types.isDatabaseError(error)) return error;
582
- const sqliteError = findDriverError(error);
780
+ const sqliteError = this.driverErrorIn(
781
+ error,
782
+ (candidate) => typeof candidate.code === "string" && candidate.code !== ""
783
+ );
583
784
  let kind = "unknown";
584
785
  if (sqliteError.code) {
585
786
  kind = SQLITE_ERROR_CODES[sqliteError.code] || "unknown";
@@ -611,21 +812,50 @@ var SqliteAdapter = class extends adapterDrizzle.DrizzleAdapter {
611
812
  });
612
813
  }
613
814
  };
614
- function findDriverError(error) {
615
- let cursor = error;
616
- for (let depth = 0; depth < 10 && cursor != null; depth++) {
617
- if (typeof cursor.code === "string" && cursor.code.length > 0)
618
- return cursor;
619
- cursor = cursor.cause;
620
- }
621
- return error ?? {};
622
- }
623
815
  function createSqliteAdapter(config) {
624
816
  return new SqliteAdapter(config);
625
817
  }
626
818
  function isSqliteAdapter(value) {
627
819
  return value instanceof SqliteAdapter;
628
820
  }
821
+ function openScope(parent) {
822
+ const scope = {
823
+ active: true,
824
+ depth: parent ? parent.depth + 1 : 0,
825
+ parent,
826
+ rolledBack: false,
827
+ writesAfterClosing: 0,
828
+ finished: false,
829
+ held: [],
830
+ queue: Promise.resolve(),
831
+ open: parent ? parent.open : []
832
+ };
833
+ scope.open.push(scope);
834
+ return scope;
835
+ }
836
+ function outermost(scope) {
837
+ let at = scope;
838
+ while (at.parent) at = at.parent;
839
+ return at;
840
+ }
841
+ function undone(scope, writesBefore) {
842
+ if (writesBefore > 0) return false;
843
+ for (let at = scope; at; at = at.parent) {
844
+ if (at.rolledBack) return true;
845
+ }
846
+ return false;
847
+ }
848
+ function enqueueIn(scope, run) {
849
+ const next = scope.queue.then(run, run);
850
+ scope.queue = next.catch(() => void 0);
851
+ return next;
852
+ }
853
+ async function settleNested(scope) {
854
+ scope.active = false;
855
+ const index = scope.open.indexOf(scope);
856
+ if (index !== -1) scope.open.splice(index, 1);
857
+ await scope.queue;
858
+ }
629
859
 
630
860
  exports.SqliteAdapter = SqliteAdapter;
631
861
  exports.VERSION = VERSION;