@nextlyhq/adapter-sqlite 0.0.2-alpha.65 → 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/dist/index.mjs CHANGED
@@ -1,7 +1,9 @@
1
+ import { AsyncLocalStorage, AsyncResource } from 'async_hooks';
1
2
  import { DrizzleAdapter } from '@nextlyhq/adapter-drizzle';
2
3
  import { isApplicationError, createDatabaseError, isDatabaseError } from '@nextlyhq/adapter-drizzle/types';
3
4
  import { checkDialectVersion } from '@nextlyhq/adapter-drizzle/version-check';
4
5
  import { drizzle } from 'drizzle-orm/better-sqlite3';
6
+ import { getTableConfig } from 'drizzle-orm/sqlite-core';
5
7
 
6
8
  // src/index.ts
7
9
  var VERSION = "0.1.0";
@@ -33,6 +35,7 @@ var DEFAULT_CONFIG = {
33
35
  function sanitizeSqliteValue(v) {
34
36
  if (v === void 0) return null;
35
37
  if (typeof v === "boolean") return v ? 1 : 0;
38
+ if (Buffer.isBuffer(v) || ArrayBuffer.isView(v)) return v;
36
39
  if (v instanceof Date) return Math.floor(v.getTime() / 1e3);
37
40
  if (v !== null && typeof v === "object") return JSON.stringify(v);
38
41
  return v;
@@ -85,6 +88,18 @@ var SqliteAdapter = class extends DrizzleAdapter {
85
88
  * surface area without changing the achievable concurrency.
86
89
  */
87
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();
88
103
  /**
89
104
  * Creates a new SQLite adapter instance.
90
105
  *
@@ -247,19 +262,190 @@ var SqliteAdapter = class extends DrizzleAdapter {
247
262
  * @returns Result of the work function
248
263
  *
249
264
  * @remarks
250
- * Uses better-sqlite3's native transaction handling which:
251
- * - Automatically commits on success
252
- * - Automatically rolls back on error
253
- * - Converts nested transactions to savepoints
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.
254
276
  *
255
277
  * Note: SQLite uses DEFERRED, IMMEDIATE, or EXCLUSIVE transaction modes
256
- * rather than isolation levels. This adapter uses IMMEDIATE by default.
278
+ * rather than isolation levels. This adapter uses IMMEDIATE.
257
279
  */
258
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
+ }
259
287
  const run = async () => this.runTransaction(work);
260
288
  const next = this.transactionQueue.then(run, run);
261
289
  this.transactionQueue = next.catch(() => void 0);
262
- return next;
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
+ }
263
449
  }
264
450
  /**
265
451
  * Inner transaction body. Kept private so callers always go through
@@ -272,17 +458,29 @@ var SqliteAdapter = class extends DrizzleAdapter {
272
458
  try {
273
459
  const ctx = this.createTransactionContext(db);
274
460
  db.exec("BEGIN IMMEDIATE");
461
+ const scope = openScope();
275
462
  try {
276
- const result = await work(ctx);
463
+ const result = await this.transactionScope.run(scope, () => work(ctx));
464
+ await settleNested(scope);
277
465
  db.exec("COMMIT");
466
+ scope.finished = true;
278
467
  if (this.config.logger?.debug) {
279
468
  const durationMs = Date.now() - startTime;
280
469
  this.config.logger.debug("Transaction committed", {
281
470
  durationMs
282
471
  });
283
472
  }
284
- return result;
473
+ return {
474
+ result,
475
+ effects: scope.held.filter(
476
+ (held) => !undone(held.scope, held.writesBefore)
477
+ )
478
+ };
285
479
  } catch (error) {
480
+ await settleNested(scope);
481
+ scope.rolledBack = true;
482
+ scope.finished = true;
483
+ scope.held.length = 0;
286
484
  try {
287
485
  db.exec("ROLLBACK");
288
486
  } catch {
@@ -428,11 +626,19 @@ var SqliteAdapter = class extends DrizzleAdapter {
428
626
  /**
429
627
  * Creates a TransactionContext for the given database connection.
430
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
+ }
431
635
  createTransactionContext(db) {
432
- const buildTxExecutor = () => drizzle({ client: db });
433
- let txExecutor;
434
- const txDb = () => txExecutor ??= buildTxExecutor();
636
+ const txHandles = this.transactionDrizzleHandles(
637
+ (relations) => relations ? drizzle({ client: db, relations }) : drizzle({ client: db })
638
+ );
639
+ const txDb = txHandles.bare;
435
640
  return {
641
+ drizzleWithRelations: txHandles.withRelations,
436
642
  // SQLite has no row-level locking and needs none here: `withTransaction`
437
643
  // opens SQLite transactions with BEGIN IMMEDIATE, which takes the write
438
644
  // lock up front and serializes writers for the whole transaction.
@@ -534,6 +740,16 @@ var SqliteAdapter = class extends DrizzleAdapter {
534
740
  (r) => this.mapRowFromRawSql(tableObj, r)
535
741
  );
536
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
+ ),
537
753
  ...this.createTransactionForwarders(txDb),
538
754
  // eslint-disable-next-line @typescript-eslint/require-await
539
755
  savepoint: async (name) => {
@@ -559,7 +775,10 @@ var SqliteAdapter = class extends DrizzleAdapter {
559
775
  // fallow-ignore-next-line complexity
560
776
  classifyError(error, sql) {
561
777
  if (isDatabaseError(error)) return error;
562
- const sqliteError = findDriverError(error);
778
+ const sqliteError = this.driverErrorIn(
779
+ error,
780
+ (candidate) => typeof candidate.code === "string" && candidate.code !== ""
781
+ );
563
782
  let kind = "unknown";
564
783
  if (sqliteError.code) {
565
784
  kind = SQLITE_ERROR_CODES[sqliteError.code] || "unknown";
@@ -591,21 +810,50 @@ var SqliteAdapter = class extends DrizzleAdapter {
591
810
  });
592
811
  }
593
812
  };
594
- function findDriverError(error) {
595
- let cursor = error;
596
- for (let depth = 0; depth < 10 && cursor != null; depth++) {
597
- if (typeof cursor.code === "string" && cursor.code.length > 0)
598
- return cursor;
599
- cursor = cursor.cause;
600
- }
601
- return error ?? {};
602
- }
603
813
  function createSqliteAdapter(config) {
604
814
  return new SqliteAdapter(config);
605
815
  }
606
816
  function isSqliteAdapter(value) {
607
817
  return value instanceof SqliteAdapter;
608
818
  }
819
+ 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;
833
+ }
834
+ function outermost(scope) {
835
+ let at = scope;
836
+ while (at.parent) at = at.parent;
837
+ return at;
838
+ }
839
+ 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;
845
+ }
846
+ function enqueueIn(scope, run) {
847
+ const next = scope.queue.then(run, run);
848
+ scope.queue = next.catch(() => void 0);
849
+ return next;
850
+ }
851
+ 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;
856
+ }
609
857
 
610
858
  export { SqliteAdapter, VERSION, createSqliteAdapter, isSqliteAdapter };
611
859
  //# sourceMappingURL=index.mjs.map