@firtoz/drizzle-sqlite-wasm 0.1.0

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.
@@ -0,0 +1,532 @@
1
+ // const selectSchema = createSelectSchema(todoTable);
2
+
3
+ import type {
4
+ CollectionConfig,
5
+ InferSchemaOutput,
6
+ LoadSubsetOptions,
7
+ SyncConfig,
8
+ SyncConfigRes,
9
+ SyncMode,
10
+ } from "@tanstack/db";
11
+ import type { IR } from "@tanstack/db";
12
+ import { DeduplicatedLoadSubset } from "@tanstack/db";
13
+ import {
14
+ eq,
15
+ sql,
16
+ type Table,
17
+ gt,
18
+ gte,
19
+ lt,
20
+ lte,
21
+ ne,
22
+ and,
23
+ or,
24
+ not,
25
+ isNull,
26
+ isNotNull,
27
+ like,
28
+ inArray,
29
+ asc,
30
+ desc,
31
+ type SQL,
32
+ getTableColumns,
33
+ } from "drizzle-orm";
34
+ import type {
35
+ SQLiteUpdateSetSource,
36
+ BaseSQLiteDatabase,
37
+ SQLiteInsertValue,
38
+ } from "drizzle-orm/sqlite-core";
39
+ import { createInsertSchema } from "drizzle-valibot";
40
+ import * as v from "valibot";
41
+ import type {
42
+ SelectSchema,
43
+ TableWithRequiredFields,
44
+ } from "@firtoz/drizzle-utils";
45
+
46
+ // WORKAROUND: DeduplicatedLoadSubset has a bug where toggling queries (e.g., isNull/isNotNull)
47
+ // creates invalid expressions like not(or(isNull(...), not(isNull(...))))
48
+ // See: https://github.com/TanStack/db/issues/828
49
+ // TODO: Re-enable once the bug is fixed
50
+ const useDedupe = false as boolean;
51
+
52
+ export type AnyDrizzleDatabase = BaseSQLiteDatabase<
53
+ "async",
54
+ // biome-ignore lint/suspicious/noExplicitAny: We really want to use any here.
55
+ any,
56
+ Record<string, unknown>
57
+ >;
58
+
59
+ export type DrizzleSchema<TDrizzle extends AnyDrizzleDatabase> =
60
+ TDrizzle["_"]["fullSchema"];
61
+
62
+ export interface DrizzleCollectionConfig<
63
+ TDrizzle extends AnyDrizzleDatabase,
64
+ TTableName extends ValidTableNames<DrizzleSchema<TDrizzle>>,
65
+ > {
66
+ drizzle: TDrizzle;
67
+ tableName: ValidTableNames<DrizzleSchema<TDrizzle>> extends never
68
+ ? {
69
+ $error: "The schema needs to include at least one table that uses the syncableTable function.";
70
+ }
71
+ : TTableName;
72
+ readyPromise: Promise<void>;
73
+ syncMode?: SyncMode;
74
+ /**
75
+ * Enable debug logging for query execution and mutations
76
+ */
77
+ debug?: boolean;
78
+ /**
79
+ * Optional callback to checkpoint the database after mutations
80
+ * This ensures WAL is flushed to the main database file for OPFS persistence
81
+ */
82
+ checkpoint?: () => Promise<void>;
83
+ }
84
+
85
+ export type ValidTableNames<TSchema extends Record<string, unknown>> = {
86
+ [K in keyof TSchema]: TSchema[K] extends TableWithRequiredFields ? K : never;
87
+ }[keyof TSchema];
88
+
89
+ /**
90
+ * Converts TanStack DB IR BasicExpression to Drizzle SQL expression
91
+ */
92
+ function convertBasicExpressionToDrizzle<TTable extends Table>(
93
+ expression: IR.BasicExpression,
94
+ table: TTable,
95
+ ): SQL {
96
+ if (expression.type === "ref") {
97
+ // PropRef - reference to a column
98
+ const propRef = expression as IR.PropRef;
99
+ const columnName = propRef.path[propRef.path.length - 1];
100
+ const column = table[columnName as keyof typeof table];
101
+
102
+ if (!column || typeof column !== "object" || !("_" in column)) {
103
+ throw new Error(`Column ${String(columnName)} not found in table`);
104
+ }
105
+
106
+ // Drizzle columns can be used directly in expressions
107
+ return column as unknown as SQL;
108
+ }
109
+
110
+ if (expression.type === "val") {
111
+ // Value - literal value
112
+ const value = expression as IR.Value;
113
+ return sql`${value.value}`;
114
+ }
115
+
116
+ if (expression.type === "func") {
117
+ // Func - function call like eq, gt, lt, etc.
118
+ const func = expression as IR.Func;
119
+ const args = func.args.map((arg) =>
120
+ convertBasicExpressionToDrizzle(arg, table),
121
+ );
122
+
123
+ switch (func.name) {
124
+ case "eq":
125
+ return eq(args[0], args[1]);
126
+ case "ne":
127
+ return ne(args[0], args[1]);
128
+ case "gt":
129
+ return gt(args[0], args[1]);
130
+ case "gte":
131
+ return gte(args[0], args[1]);
132
+ case "lt":
133
+ return lt(args[0], args[1]);
134
+ case "lte":
135
+ return lte(args[0], args[1]);
136
+ case "and": {
137
+ const result = and(...args);
138
+ if (!result) {
139
+ throw new Error("Invalid 'and' expression - no arguments provided");
140
+ }
141
+ return result;
142
+ }
143
+ case "or": {
144
+ const result = or(...args);
145
+ if (!result) {
146
+ throw new Error("Invalid 'or' expression - no arguments provided");
147
+ }
148
+ return result;
149
+ }
150
+ case "not":
151
+ return not(args[0]);
152
+ case "isNull":
153
+ return isNull(args[0]);
154
+ case "isNotNull":
155
+ return isNotNull(args[0]);
156
+ case "like":
157
+ return like(args[0], args[1]);
158
+ case "in":
159
+ return inArray(args[0], args[1]);
160
+ case "isUndefined":
161
+ // isUndefined is same as isNull in SQLite
162
+ return isNull(args[0]);
163
+ default:
164
+ throw new Error(`Unsupported function: ${func.name}`);
165
+ }
166
+ }
167
+
168
+ throw new Error(
169
+ `Unsupported expression type: ${(expression as { type: string }).type}`,
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Converts TanStack DB OrderBy to Drizzle orderBy
175
+ */
176
+ function convertOrderByToDrizzle<TTable extends Table>(
177
+ orderBy: IR.OrderBy,
178
+ table: TTable,
179
+ ): SQL[] {
180
+ return orderBy.map((clause) => {
181
+ const expression = convertBasicExpressionToDrizzle(
182
+ clause.expression,
183
+ table,
184
+ );
185
+ const direction = clause.compareOptions.direction || "asc";
186
+
187
+ return direction === "asc" ? asc(expression) : desc(expression);
188
+ });
189
+ }
190
+
191
+ export function sqliteCollectionOptions<
192
+ const TDrizzle extends AnyDrizzleDatabase,
193
+ const TTableName extends string & ValidTableNames<DrizzleSchema<TDrizzle>>,
194
+ TTable extends DrizzleSchema<TDrizzle>[TTableName] & TableWithRequiredFields,
195
+ >(config: DrizzleCollectionConfig<TDrizzle, TTableName>) {
196
+ type CollectionType = CollectionConfig<
197
+ InferSchemaOutput<SelectSchema<TTable>>,
198
+ string,
199
+ // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
200
+ any
201
+ >;
202
+
203
+ const tableName = config.tableName as string &
204
+ ValidTableNames<DrizzleSchema<TDrizzle>>;
205
+
206
+ const table = config.drizzle?._.fullSchema[tableName] as TTable;
207
+
208
+ let insertListener: CollectionType["onInsert"] | null = null;
209
+ let updateListener: CollectionType["onUpdate"] | null = null;
210
+ let deleteListener: CollectionType["onDelete"] | null = null;
211
+
212
+ // Transaction queue to serialize SQLite transactions (SQLite only supports one transaction at a time)
213
+ // The queue ensures transactions run sequentially and continues even if one fails
214
+ let transactionQueue = Promise.resolve();
215
+ const queueTransaction = <T>(fn: () => Promise<T>): Promise<T> => {
216
+ // Chain this transaction after the previous one (whether it succeeded or failed)
217
+ const result = transactionQueue.then(fn, fn);
218
+ // Update the queue to continue after this transaction completes (success or failure)
219
+ // This ensures the queue doesn't get stuck if a transaction fails
220
+ transactionQueue = result.then(
221
+ () => {}, // Success handler - return undefined to reset queue
222
+ () => {}, // Error handler - return undefined to reset queue (queue continues)
223
+ );
224
+ // Return the actual result so errors propagate to the caller
225
+ return result;
226
+ };
227
+
228
+ const sync: SyncConfig<
229
+ InferSchemaOutput<SelectSchema<TTable>>,
230
+ string
231
+ >["sync"] = (params) => {
232
+ const { begin, write, commit, markReady } = params;
233
+
234
+ const initialSync = async () => {
235
+ await config.readyPromise;
236
+
237
+ try {
238
+ begin();
239
+
240
+ const items = (await config.drizzle
241
+ .select()
242
+ .from(table)) as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
243
+
244
+ for (const item of items) {
245
+ write({
246
+ type: "insert",
247
+ value: item,
248
+ });
249
+ }
250
+
251
+ commit();
252
+ } finally {
253
+ markReady();
254
+ }
255
+ };
256
+
257
+ if (config.syncMode === "eager" || !config.syncMode) {
258
+ initialSync();
259
+ } else {
260
+ markReady();
261
+ }
262
+
263
+ insertListener = async (params) => {
264
+ // Store results to write after transaction succeeds
265
+ const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
266
+
267
+ // Queue the transaction to serialize SQLite operations
268
+ await queueTransaction(async () => {
269
+ await config.drizzle.transaction(async (tx) => {
270
+ for (const item of params.transaction.mutations) {
271
+ // TanStack DB applies schema transform (including ID default) before calling this listener
272
+ // So item.modified already has the ID from insertSchemaWithIdDefault
273
+ const itemToInsert = item.modified;
274
+
275
+ if (config.debug) {
276
+ console.log(
277
+ `[${new Date().toISOString()}] insertListener inserting`,
278
+ itemToInsert,
279
+ );
280
+ }
281
+
282
+ const result: Array<InferSchemaOutput<SelectSchema<TTable>>> =
283
+ (await tx
284
+ .insert(table)
285
+ .values(
286
+ itemToInsert as unknown as SQLiteInsertValue<typeof table>,
287
+ )
288
+ .returning()) as Array<InferSchemaOutput<SelectSchema<TTable>>>;
289
+
290
+ if (config.debug) {
291
+ console.log(
292
+ `[${new Date().toISOString()}] insertListener result`,
293
+ result,
294
+ );
295
+ }
296
+
297
+ if (result.length > 0) {
298
+ results.push(result[0]);
299
+ }
300
+ }
301
+ });
302
+
303
+ // Checkpoint to ensure WAL is flushed to main DB file
304
+ if (config.checkpoint) {
305
+ await config.checkpoint();
306
+ }
307
+ });
308
+
309
+ // Only update reactive store after transaction succeeds
310
+ begin();
311
+ for (const result of results) {
312
+ write({
313
+ type: "insert",
314
+ value: result as unknown as InferSchemaOutput<SelectSchema<TTable>>,
315
+ });
316
+ }
317
+ commit();
318
+ };
319
+
320
+ updateListener = async (params) => {
321
+ // Queue the transaction to serialize SQLite operations
322
+ const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
323
+ await queueTransaction(async () => {
324
+ await config.drizzle.transaction(async (tx) => {
325
+ for (const item of params.transaction.mutations) {
326
+ if (config.debug) {
327
+ console.log(
328
+ `[${new Date().toISOString()}] updateListener updating`,
329
+ item,
330
+ );
331
+ }
332
+
333
+ const updateTime = new Date();
334
+ const result: Array<InferSchemaOutput<SelectSchema<TTable>>> =
335
+ (await tx
336
+ .update(table)
337
+ .set({
338
+ ...item.changes,
339
+ updatedAt: updateTime,
340
+ } as SQLiteUpdateSetSource<typeof table>)
341
+ .where(eq(table.id, item.key))
342
+ .returning()) as Array<InferSchemaOutput<SelectSchema<TTable>>>;
343
+
344
+ if (config.debug) {
345
+ console.log(
346
+ `[${new Date().toISOString()}] updateListener result`,
347
+ result,
348
+ );
349
+ }
350
+
351
+ results.push(...result);
352
+ }
353
+ });
354
+
355
+ // Checkpoint to ensure WAL is flushed to main DB file BEFORE UI updates
356
+ // This ensures persistence before the updateListener completes
357
+ if (config.checkpoint) {
358
+ await config.checkpoint();
359
+ }
360
+ });
361
+
362
+ // Update the reactive store with actual database results
363
+ // This happens after checkpoint completes
364
+ begin();
365
+ for (const result of results) {
366
+ write({
367
+ type: "update",
368
+ value: result,
369
+ });
370
+ }
371
+ commit();
372
+ };
373
+
374
+ deleteListener = async (params) => {
375
+ // Queue the transaction to serialize SQLite operations
376
+ await queueTransaction(async () => {
377
+ await config.drizzle.transaction(async (tx) => {
378
+ for (const item of params.transaction.mutations) {
379
+ await tx.delete(table).where(eq(table.id, item.key));
380
+ }
381
+ });
382
+
383
+ // Checkpoint to ensure WAL is flushed to main DB file
384
+ if (config.checkpoint) {
385
+ await config.checkpoint();
386
+ }
387
+
388
+ begin();
389
+ for (const item of params.transaction.mutations) {
390
+ if (config.debug) {
391
+ console.log(
392
+ `[${new Date().toISOString()}] deleteListener write`,
393
+ item,
394
+ );
395
+ }
396
+ write({
397
+ type: "delete",
398
+ value: item.modified,
399
+ });
400
+ }
401
+ commit();
402
+ });
403
+ };
404
+
405
+ const loadSubset = async (options: LoadSubsetOptions) => {
406
+ await config.readyPromise;
407
+
408
+ begin();
409
+
410
+ try {
411
+ // Build the query with optional where, orderBy, and limit
412
+ // Use $dynamic() to enable dynamic query building
413
+ let query = config.drizzle.select().from(table).$dynamic();
414
+
415
+ // Convert TanStack DB IR expressions to Drizzle expressions
416
+ if (options.where) {
417
+ const drizzleWhere = convertBasicExpressionToDrizzle(
418
+ options.where,
419
+ table,
420
+ );
421
+ query = query.where(drizzleWhere);
422
+ }
423
+
424
+ if (options.orderBy) {
425
+ const drizzleOrderBy = convertOrderByToDrizzle(
426
+ options.orderBy,
427
+ table,
428
+ );
429
+ query = query.orderBy(...drizzleOrderBy);
430
+ }
431
+
432
+ if (options.limit !== undefined) {
433
+ query = query.limit(options.limit);
434
+ }
435
+
436
+ const items = (await query) as unknown as InferSchemaOutput<
437
+ SelectSchema<TTable>
438
+ >[];
439
+
440
+ for (const item of items) {
441
+ write({
442
+ type: "insert",
443
+ value: item,
444
+ });
445
+ }
446
+
447
+ commit();
448
+ } catch (error) {
449
+ // If there's an error, we should still commit to maintain consistency
450
+ commit();
451
+ throw error;
452
+ }
453
+ };
454
+
455
+ // Create deduplicated loadSubset wrapper to avoid redundant queries
456
+ let loadSubsetDedupe: DeduplicatedLoadSubset | null = null;
457
+ if (useDedupe) {
458
+ loadSubsetDedupe = new DeduplicatedLoadSubset({
459
+ loadSubset,
460
+ });
461
+ }
462
+
463
+ return {
464
+ cleanup: () => {
465
+ insertListener = null;
466
+ updateListener = null;
467
+ deleteListener = null;
468
+ loadSubsetDedupe?.reset();
469
+ },
470
+ loadSubset: loadSubsetDedupe?.loadSubset ?? loadSubset,
471
+ } satisfies SyncConfigRes;
472
+ };
473
+
474
+ // Create insert schema and augment it to apply ID default
475
+ // (Other defaults like createdAt/updatedAt are handled by SQLite)
476
+ const insertSchema = createInsertSchema(table);
477
+ const columns = getTableColumns(table);
478
+ const idColumn = columns.id;
479
+
480
+ const insertSchemaWithIdDefault = v.pipe(
481
+ insertSchema,
482
+ v.transform((input) => {
483
+ const result = { ...input } as Record<string, unknown>;
484
+
485
+ // Apply ID default if missing
486
+ if (result.id === undefined && idColumn?.defaultFn) {
487
+ result.id = idColumn.defaultFn();
488
+ }
489
+
490
+ return result as typeof input;
491
+ }),
492
+ );
493
+
494
+ return {
495
+ schema: insertSchemaWithIdDefault,
496
+ getKey: (item: InferSchemaOutput<SelectSchema<TTable>>) => {
497
+ const id = (item as { id: string }).id;
498
+ return id;
499
+ },
500
+ sync: {
501
+ sync,
502
+ },
503
+ onInsert: async (
504
+ params: Parameters<NonNullable<CollectionType["onInsert"]>>[0],
505
+ ) => {
506
+ if (config.debug) {
507
+ console.log("onInsert", params);
508
+ }
509
+
510
+ await insertListener?.(params);
511
+ },
512
+ onUpdate: async (
513
+ params: Parameters<NonNullable<CollectionType["onUpdate"]>>[0],
514
+ ) => {
515
+ if (config.debug) {
516
+ console.log("onUpdate", params);
517
+ }
518
+
519
+ await updateListener?.(params);
520
+ },
521
+ onDelete: async (
522
+ params: Parameters<NonNullable<CollectionType["onDelete"]>>[0],
523
+ ) => {
524
+ if (config.debug) {
525
+ console.log("onDelete", params);
526
+ }
527
+
528
+ await deleteListener?.(params);
529
+ },
530
+ syncMode: config.syncMode,
531
+ } satisfies CollectionType;
532
+ }