@firtoz/drizzle-sqlite-wasm 0.2.15 → 0.2.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @firtoz/drizzle-sqlite-wasm
2
2
 
3
+ ## 0.2.16
4
+
5
+ ### Patch Changes
6
+
7
+ - [`b714ebb`](https://github.com/firtoz/fullstack-toolkit/commit/b714ebbb62ec0e3c3aa56c4105e7499fac11d1e5) Thanks [@firtoz](https://github.com/firtoz)! - Extract shared SQLite TanStack sync backend (`createSqliteTableSyncBackend`, IR→Drizzle helpers, `SQLOperation` types) into `@firtoz/drizzle-utils`. Add `@firtoz/drizzle-durable-sqlite` for Durable Object SQLite collections (`durableSqliteCollectionOptions`). Refactor `@firtoz/drizzle-sqlite-wasm` to use the shared backend with `driverMode: "async"`. `durableSqliteCollectionOptions` accepts optional `readyPromise` (defaults to immediate readiness). README documents the class-field Hono pattern, `app.fetch(request, env)` for bindings, optional `on-demand` + `preload` vs eager + `onFirstReady`, and `honoDoFetcherWithName` without a separate exported app type. Restore JSDoc on `DrizzleSqliteCollectionConfig` (`debug`, `checkpoint`, `interceptor`) for editor tooltips. Align `createStandaloneCollection` generics with `InsertToSelectSchema` from `@firtoz/drizzle-utils`.
8
+
9
+ - Updated dependencies [[`b714ebb`](https://github.com/firtoz/fullstack-toolkit/commit/b714ebbb62ec0e3c3aa56c4105e7499fac11d1e5)]:
10
+ - @firtoz/drizzle-utils@1.1.0
11
+
3
12
  ## 0.2.15
4
13
 
5
14
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-sqlite-wasm",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Drizzle SQLite WASM bindings",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@firtoz/db-helpers": "^2.0.0",
73
- "@firtoz/drizzle-utils": "^1.0.2",
73
+ "@firtoz/drizzle-utils": "^1.1.0",
74
74
  "@firtoz/maybe-error": "^1.5.2",
75
75
  "@firtoz/worker-helper": "^1.5.1",
76
76
  "@sqlite.org/sqlite-wasm": "^3.51.2-build8",
@@ -3,48 +3,25 @@ import type {
3
3
  SyncMode,
4
4
  CollectionConfig,
5
5
  } from "@tanstack/db";
6
- import type { IR } from "@tanstack/db";
7
- import {
8
- eq,
9
- sql,
10
- type Table,
11
- gt,
12
- gte,
13
- lt,
14
- lte,
15
- ne,
16
- and,
17
- or,
18
- not,
19
- isNull,
20
- isNotNull,
21
- like,
22
- inArray,
23
- asc,
24
- desc,
25
- type SQL,
26
- } from "drizzle-orm";
27
- import {
28
- type SQLiteUpdateSetSource,
29
- type BaseSQLiteDatabase,
30
- type SQLiteInsertValue,
31
- SQLiteColumn,
32
- } from "drizzle-orm/sqlite-core";
6
+ import type { Table } from "drizzle-orm";
7
+ import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
33
8
  import type { CollectionUtils } from "@firtoz/db-helpers";
34
9
  import type {
35
10
  SelectSchema,
11
+ InsertToSelectSchema,
36
12
  TableWithRequiredFields,
37
13
  BaseSyncConfig,
38
- SyncBackend,
39
14
  } from "@firtoz/drizzle-utils";
40
15
  import {
41
16
  createSyncFunction,
42
17
  createInsertSchemaWithIdDefault,
43
18
  createGetKeyFunction,
44
19
  createCollectionConfig,
20
+ createSqliteTableSyncBackend,
21
+ type SQLOperation,
22
+ type SQLInterceptor,
45
23
  } from "@firtoz/drizzle-utils";
46
- import { exhaustiveGuard } from "@firtoz/maybe-error";
47
- import type * as v from "valibot";
24
+ export type { SQLOperation, SQLInterceptor };
48
25
 
49
26
  export type AnyDrizzleDatabase = BaseSQLiteDatabase<
50
27
  "async",
@@ -56,81 +33,7 @@ export type AnyDrizzleDatabase = BaseSQLiteDatabase<
56
33
  export type DrizzleSchema<TDrizzle extends AnyDrizzleDatabase> =
57
34
  TDrizzle["_"]["fullSchema"];
58
35
 
59
- /**
60
- * Operation tracking for SQLite queries
61
- * Useful for testing and debugging to verify what operations are actually performed
62
- *
63
- * Uses discriminated unions for type safety - TypeScript can narrow the type based on the 'type' field
64
- */
65
- export type SQLOperation =
66
- | {
67
- type: "select-all";
68
- tableName: string;
69
- itemsReturned: unknown[];
70
- itemCount: number;
71
- context: string;
72
- sql?: string;
73
- timestamp: number;
74
- }
75
- | {
76
- type: "select-where";
77
- tableName: string;
78
- whereClause: string;
79
- itemsReturned: unknown[];
80
- itemCount: number;
81
- context: string;
82
- sql?: string;
83
- timestamp: number;
84
- }
85
- | {
86
- type: "write";
87
- tableName: string;
88
- itemsWritten: unknown[];
89
- writeCount: number;
90
- context: string;
91
- timestamp: number;
92
- }
93
- | {
94
- type: "insert";
95
- tableName: string;
96
- item: unknown;
97
- sql?: string;
98
- timestamp: number;
99
- }
100
- | {
101
- type: "update";
102
- tableName: string;
103
- updates: unknown;
104
- sql?: string;
105
- timestamp: number;
106
- }
107
- | {
108
- type: "delete";
109
- tableName: string;
110
- sql?: string;
111
- timestamp: number;
112
- }
113
- | {
114
- /** Raw SQL query executed directly via Drizzle (not through collection) */
115
- type: "raw-query";
116
- sql: string;
117
- params?: unknown[];
118
- method: string;
119
- rowCount: number;
120
- context: string;
121
- timestamp: number;
122
- };
123
-
124
- /**
125
- * Interceptor interface for tracking SQLite operations
126
- * Allows tests and debugging tools to observe what operations are performed
127
- */
128
- export interface SQLInterceptor {
129
- /** Called when any SQLite operation is performed */
130
- onOperation?: (operation: SQLOperation) => void;
131
- }
132
-
133
- export interface DrizzleCollectionConfig<
36
+ export interface DrizzleSqliteCollectionConfig<
134
37
  TDrizzle extends AnyDrizzleDatabase,
135
38
  TTableName extends ValidTableNames<DrizzleSchema<TDrizzle>>,
136
39
  > {
@@ -161,427 +64,40 @@ export type ValidTableNames<TSchema extends Record<string, unknown>> = {
161
64
  [K in keyof TSchema]: TSchema[K] extends TableWithRequiredFields ? K : never;
162
65
  }[keyof TSchema];
163
66
 
164
- /**
165
- * Return type for sqliteCollectionOptions - configuration object for creating SQLite collections
166
- *
167
- * Note: The third type parameter of CollectionConfig uses `any` to maintain compatibility with
168
- * TanStack DB's type system, which expects different schema types in different contexts.
169
- */
170
67
  export type SqliteCollectionConfig<TTable extends Table> = Omit<
171
68
  CollectionConfig<
172
69
  InferSchemaOutput<SelectSchema<TTable>>,
173
70
  string,
174
- // biome-ignore lint/suspicious/noExplicitAny: Required for TanStack DB type compatibility
175
- any
71
+ InsertToSelectSchema<TTable>
176
72
  >,
177
73
  "utils"
178
74
  > & {
179
- schema: v.GenericSchema<unknown>;
75
+ schema: InsertToSelectSchema<TTable>;
180
76
  utils: CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>;
181
77
  };
182
78
 
183
- /**
184
- * Converts TanStack DB IR BasicExpression to Drizzle SQL expression
185
- *
186
- * Supported operators that TanStack DB pushes down to backend (SUPPORTED_COLLECTION_FUNCS):
187
- * - eq, gt, lt, gte, lte, and, or, in, isNull, isUndefined, not
188
- *
189
- * Additional operators handled for completeness (won't be pushed down in on-demand mode):
190
- * - ne, isNotNull, like
191
- */
192
- function convertBasicExpressionToDrizzle<TTable extends Table>(
193
- expression: IR.BasicExpression,
194
- table: TTable,
195
- ): SQL {
196
- switch (expression.type) {
197
- case "ref": {
198
- const propRef = expression;
199
- const columnName = propRef.path[propRef.path.length - 1];
200
- const column = table[columnName as keyof typeof table];
201
-
202
- if (!column || !(column instanceof SQLiteColumn)) {
203
- console.error("[SQLite Collection] Column lookup failed:", {
204
- columnName,
205
- column,
206
- tableKeys: Object.keys(table),
207
- hasColumn: columnName in table,
208
- });
209
- throw new Error(`Column ${String(columnName)} not found in table`);
210
- }
211
-
212
- return column as unknown as SQL;
213
- }
214
- case "val": {
215
- const value = expression;
216
- return sql`${value.value}`;
217
- }
218
- case "func": {
219
- const func = expression;
220
- const args = func.args.map((arg) =>
221
- convertBasicExpressionToDrizzle(arg, table),
222
- );
223
-
224
- switch (func.name) {
225
- case "eq":
226
- return eq(args[0], args[1]);
227
- case "ne":
228
- return ne(args[0], args[1]);
229
- case "gt":
230
- return gt(args[0], args[1]);
231
- case "gte":
232
- return gte(args[0], args[1]);
233
- case "lt":
234
- return lt(args[0], args[1]);
235
- case "lte":
236
- return lte(args[0], args[1]);
237
- case "and": {
238
- const result = and(...args);
239
- if (!result) {
240
- throw new Error("Invalid 'and' expression - no arguments provided");
241
- }
242
- return result;
243
- }
244
- case "or": {
245
- const result = or(...args);
246
- if (!result) {
247
- throw new Error("Invalid 'or' expression - no arguments provided");
248
- }
249
- return result;
250
- }
251
- case "not":
252
- return not(args[0]);
253
- case "isNull":
254
- return isNull(args[0]);
255
- case "isNotNull":
256
- return isNotNull(args[0]);
257
- case "like":
258
- return like(args[0], args[1]);
259
- case "in":
260
- return inArray(args[0], args[1]);
261
- case "isUndefined":
262
- // isUndefined is same as isNull in SQLite
263
- return isNull(args[0]);
264
- default:
265
- throw new Error(`Unsupported function: ${func.name}`);
266
- }
267
- }
268
- default:
269
- exhaustiveGuard(expression);
270
- }
271
- }
272
-
273
- /**
274
- * Converts TanStack DB OrderBy to Drizzle orderBy
275
- */
276
- function convertOrderByToDrizzle<TTable extends Table>(
277
- orderBy: IR.OrderBy,
278
- table: TTable,
279
- ): SQL[] {
280
- return orderBy.map((clause) => {
281
- const expression = convertBasicExpressionToDrizzle(
282
- clause.expression,
283
- table,
284
- );
285
- const direction = clause.compareOptions.direction || "asc";
286
-
287
- return direction === "asc" ? asc(expression) : desc(expression);
288
- });
289
- }
290
-
291
79
  export function sqliteCollectionOptions<
292
80
  const TDrizzle extends AnyDrizzleDatabase,
293
81
  const TTableName extends string & ValidTableNames<DrizzleSchema<TDrizzle>>,
294
82
  TTable extends DrizzleSchema<TDrizzle>[TTableName] & TableWithRequiredFields,
295
83
  >(
296
- config: DrizzleCollectionConfig<TDrizzle, TTableName>,
84
+ config: DrizzleSqliteCollectionConfig<TDrizzle, TTableName>,
297
85
  ): SqliteCollectionConfig<TTable> {
298
86
  const tableName = config.tableName as string &
299
87
  ValidTableNames<DrizzleSchema<TDrizzle>>;
300
88
 
301
89
  const table = config.drizzle?._.fullSchema[tableName] as TTable;
302
90
 
303
- // Transaction queue to serialize SQLite transactions (SQLite only supports one transaction at a time)
304
- // The queue ensures transactions run sequentially and continues even if one fails
305
- let transactionQueue = Promise.resolve();
306
- const queueTransaction = <T>(fn: () => Promise<T>): Promise<T> => {
307
- // Chain this transaction after the previous one (whether it succeeded or failed)
308
- const result = transactionQueue.then(fn, fn);
309
- // Update the queue to continue after this transaction completes (success or failure)
310
- // This ensures the queue doesn't get stuck if a transaction fails
311
- transactionQueue = result.then(
312
- () => {}, // Success handler - return undefined to reset queue
313
- () => {}, // Error handler - return undefined to reset queue (queue continues)
314
- );
315
- // Return the actual result so errors propagate to the caller
316
- return result;
317
- };
318
-
319
- // Create backend-specific implementation
320
- const backend: SyncBackend<TTable> = {
321
- initialLoad: async () => {
322
- const items = (await config.drizzle
323
- .select()
324
- .from(table)) as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
325
-
326
- // Log SQL operation
327
- if (config.interceptor?.onOperation) {
328
- config.interceptor.onOperation({
329
- type: "select-all",
330
- tableName: config.tableName as string,
331
- itemsReturned: items,
332
- itemCount: items.length,
333
- context: "Initial load (eager mode)",
334
- timestamp: Date.now(),
335
- });
336
- }
337
-
338
- // Log write operation
339
- if (config.interceptor?.onOperation) {
340
- config.interceptor.onOperation({
341
- type: "write",
342
- tableName: config.tableName as string,
343
- itemsWritten: items,
344
- writeCount: items.length,
345
- context: "Initial load (eager mode)",
346
- timestamp: Date.now(),
347
- });
348
- }
349
-
350
- return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
351
- },
352
-
353
- loadSubset: async (options) => {
354
- // Build the query with optional where, orderBy, limit, and offset
355
- // Use $dynamic() to enable dynamic query building
356
- let query = config.drizzle.select().from(table).$dynamic();
357
-
358
- // Combine where with cursor expressions if present
359
- // The cursor.whereFrom gives us rows after the cursor position
360
- let hasWhere = false;
361
- if (options.where || options.cursor?.whereFrom) {
362
- let drizzleWhere: SQL | undefined;
363
-
364
- if (options.where && options.cursor?.whereFrom) {
365
- // Combine main where with cursor expression using AND
366
- const mainWhere = convertBasicExpressionToDrizzle(
367
- options.where,
368
- table,
369
- );
370
- const cursorWhere = convertBasicExpressionToDrizzle(
371
- options.cursor.whereFrom,
372
- table,
373
- );
374
- drizzleWhere = and(mainWhere, cursorWhere);
375
- } else if (options.where) {
376
- drizzleWhere = convertBasicExpressionToDrizzle(options.where, table);
377
- } else if (options.cursor?.whereFrom) {
378
- drizzleWhere = convertBasicExpressionToDrizzle(
379
- options.cursor.whereFrom,
380
- table,
381
- );
382
- }
383
-
384
- if (drizzleWhere) {
385
- query = query.where(drizzleWhere);
386
- hasWhere = true;
387
- }
388
- }
389
-
390
- if (options.orderBy) {
391
- const drizzleOrderBy = convertOrderByToDrizzle(options.orderBy, table);
392
- query = query.orderBy(...drizzleOrderBy);
393
- }
394
-
395
- if (options.limit !== undefined) {
396
- query = query.limit(options.limit);
397
- }
398
-
399
- // Apply offset for offset-based pagination
400
- if (options.offset !== undefined && options.offset > 0) {
401
- query = query.offset(options.offset);
402
- }
403
-
404
- const items = (await query) as unknown as InferSchemaOutput<
405
- SelectSchema<TTable>
406
- >[];
407
-
408
- // Log SQL operation
409
- if (config.interceptor?.onOperation) {
410
- const contextParts: string[] = ["On-demand load"];
411
- if (options.orderBy) {
412
- contextParts.push("with sorting");
413
- }
414
- if (options.limit !== undefined) {
415
- contextParts.push(`limit ${options.limit}`);
416
- }
417
- if (options.offset !== undefined && options.offset > 0) {
418
- contextParts.push(`offset ${options.offset}`);
419
- }
420
- if (options.cursor) {
421
- contextParts.push("with cursor pagination");
422
- }
423
-
424
- if (hasWhere) {
425
- config.interceptor.onOperation({
426
- type: "select-where",
427
- tableName: config.tableName as string,
428
- whereClause: "WHERE clause applied",
429
- itemsReturned: items,
430
- itemCount: items.length,
431
- context: contextParts.join(", "),
432
- timestamp: Date.now(),
433
- });
434
- } else {
435
- config.interceptor.onOperation({
436
- type: "select-all",
437
- tableName: config.tableName as string,
438
- itemsReturned: items,
439
- itemCount: items.length,
440
- context: contextParts.join(", "),
441
- timestamp: Date.now(),
442
- });
443
- }
444
- }
445
-
446
- // Log write operation
447
- if (config.interceptor?.onOperation) {
448
- const contextParts: string[] = ["On-demand load"];
449
- if (hasWhere) {
450
- contextParts.push("with WHERE clause");
451
- }
452
- if (options.orderBy) {
453
- contextParts.push("with sorting");
454
- }
455
- if (options.limit !== undefined) {
456
- contextParts.push(`limit ${options.limit}`);
457
- }
458
- if (options.offset !== undefined && options.offset > 0) {
459
- contextParts.push(`offset ${options.offset}`);
460
- }
461
-
462
- config.interceptor.onOperation({
463
- type: "write",
464
- tableName: config.tableName as string,
465
- itemsWritten: items,
466
- writeCount: items.length,
467
- context: contextParts.join(", "),
468
- timestamp: Date.now(),
469
- });
470
- }
471
-
472
- return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
473
- },
474
-
475
- handleInsert: async (items) => {
476
- const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
477
-
478
- // Queue the transaction to serialize SQLite operations
479
- await queueTransaction(async () => {
480
- await config.drizzle.transaction(async (tx) => {
481
- for (const itemToInsert of items) {
482
- if (config.debug) {
483
- console.log(
484
- `[${new Date().toISOString()}] insertListener inserting`,
485
- itemToInsert,
486
- );
487
- }
488
-
489
- const result: Array<InferSchemaOutput<SelectSchema<TTable>>> =
490
- (await tx
491
- .insert(table)
492
- .values(
493
- itemToInsert as unknown as SQLiteInsertValue<typeof table>,
494
- )
495
- .returning()) as Array<InferSchemaOutput<SelectSchema<TTable>>>;
496
-
497
- if (config.debug) {
498
- console.log(
499
- `[${new Date().toISOString()}] insertListener result`,
500
- result,
501
- );
502
- }
503
-
504
- if (result.length > 0) {
505
- results.push(result[0]);
506
- }
507
- }
508
- });
509
-
510
- // Checkpoint to ensure WAL is flushed to main DB file
511
- if (config.checkpoint) {
512
- await config.checkpoint();
513
- }
514
- });
515
-
516
- return results;
517
- },
518
-
519
- handleUpdate: async (mutations) => {
520
- const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
521
-
522
- // Queue the transaction to serialize SQLite operations
523
- await queueTransaction(async () => {
524
- await config.drizzle.transaction(async (tx) => {
525
- for (const mutation of mutations) {
526
- if (config.debug) {
527
- console.log(
528
- `[${new Date().toISOString()}] updateListener updating`,
529
- mutation,
530
- );
531
- }
532
-
533
- const updateTime = new Date();
534
- const result: Array<InferSchemaOutput<SelectSchema<TTable>>> =
535
- (await tx
536
- .update(table)
537
- .set({
538
- ...mutation.changes,
539
- updatedAt: updateTime,
540
- } as SQLiteUpdateSetSource<typeof table>)
541
- // biome-ignore lint/suspicious/noExplicitAny: Key is string but table.id is branded type
542
- .where(eq(table.id, mutation.key as any))
543
- .returning()) as Array<InferSchemaOutput<SelectSchema<TTable>>>;
544
-
545
- if (config.debug) {
546
- console.log(
547
- `[${new Date().toISOString()}] updateListener result`,
548
- result,
549
- );
550
- }
551
-
552
- results.push(...result);
553
- }
554
- });
555
-
556
- // Checkpoint to ensure WAL is flushed to main DB file BEFORE UI updates
557
- // This ensures persistence before the updateListener completes
558
- if (config.checkpoint) {
559
- await config.checkpoint();
560
- }
561
- });
562
-
563
- return results;
564
- },
565
-
566
- handleDelete: async (mutations) => {
567
- // Queue the transaction to serialize SQLite operations
568
- await queueTransaction(async () => {
569
- await config.drizzle.transaction(async (tx) => {
570
- for (const mutation of mutations) {
571
- // biome-ignore lint/suspicious/noExplicitAny: Key is string but table.id is branded type
572
- await tx.delete(table).where(eq(table.id, mutation.key as any));
573
- }
574
- });
575
-
576
- // Checkpoint to ensure WAL is flushed to main DB file
577
- if (config.checkpoint) {
578
- await config.checkpoint();
579
- }
580
- });
581
- },
582
- };
91
+ const backend = createSqliteTableSyncBackend({
92
+ drizzle: config.drizzle,
93
+ table,
94
+ tableName: config.tableName as string,
95
+ debug: config.debug,
96
+ checkpoint: config.checkpoint,
97
+ interceptor: config.interceptor,
98
+ driverMode: "async",
99
+ });
583
100
 
584
- // Create sync function using shared utilities
585
101
  const baseSyncConfig: BaseSyncConfig<TTable> = {
586
102
  table,
587
103
  readyPromise: config.readyPromise,
@@ -591,11 +107,8 @@ export function sqliteCollectionOptions<
591
107
 
592
108
  const syncResult = createSyncFunction(baseSyncConfig, backend);
593
109
 
594
- // Create insert schema with ID default
595
- // (Other defaults like createdAt/updatedAt are handled by SQLite)
596
110
  const schema = createInsertSchemaWithIdDefault(table);
597
111
 
598
- // Create collection config using shared utilities
599
112
  const collectionConfig = createCollectionConfig({
600
113
  schema,
601
114
  getKey: createGetKeyFunction<TTable>(),
@@ -603,24 +116,21 @@ export function sqliteCollectionOptions<
603
116
  onInsert: config.debug
604
117
  ? async (params) => {
605
118
  console.log("onInsert", params);
606
- // Call the actual handler from syncResult (always defined in createSyncFunction)
607
- // biome-ignore lint/style/noNonNullAssertion: onInsert is always defined in SyncFunctionResult
119
+ // biome-ignore lint/style/noNonNullAssertion: onInsert is always defined in createSyncFunction
608
120
  await syncResult.onInsert!(params);
609
121
  }
610
122
  : undefined,
611
123
  onUpdate: config.debug
612
124
  ? async (params) => {
613
125
  console.log("onUpdate", params);
614
- // Call the actual handler from syncResult (always defined in createSyncFunction)
615
- // biome-ignore lint/style/noNonNullAssertion: onUpdate is always defined in SyncFunctionResult
126
+ // biome-ignore lint/style/noNonNullAssertion: onUpdate is always defined in createSyncFunction
616
127
  await syncResult.onUpdate!(params);
617
128
  }
618
129
  : undefined,
619
130
  onDelete: config.debug
620
131
  ? async (params) => {
621
132
  console.log("onDelete", params);
622
- // Call the actual handler from syncResult (always defined in createSyncFunction)
623
- // biome-ignore lint/style/noNonNullAssertion: onDelete is always defined in SyncFunctionResult
133
+ // biome-ignore lint/style/noNonNullAssertion: onDelete is always defined in createSyncFunction
624
134
  await syncResult.onDelete!(params);
625
135
  }
626
136
  : undefined,