@firtoz/drizzle-sqlite-wasm 0.2.0 → 0.2.2

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,28 @@
1
1
  # @firtoz/drizzle-sqlite-wasm
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`8abab0a`](https://github.com/firtoz/fullstack-toolkit/commit/8abab0ae7a99320a4254cb128c0fd823726e58e0) Thanks [@firtoz](https://github.com/firtoz)! - Fix critical bug where debug mode prevented database operations from executing. Debug handlers now properly wrap and call the actual backend handlers instead of replacing them.
8
+
9
+ Add cursor-based and offset-based pagination support to `loadSubset` operations, enabling efficient navigation through large datasets.
10
+
11
+ Add `SQLInterceptor` support to log all SQL queries, including direct Drizzle queries, with the new `createInstrumentedDrizzle` function. This provides comprehensive query visibility for debugging and monitoring.
12
+
13
+ Add explicit return type `SqliteCollectionConfig<TTable>` to `sqliteCollectionOptions` function, improving type safety and eliminating the `any` cast at the return statement.
14
+
15
+ - Updated dependencies [[`8abab0a`](https://github.com/firtoz/fullstack-toolkit/commit/8abab0ae7a99320a4254cb128c0fd823726e58e0), [`8abab0a`](https://github.com/firtoz/fullstack-toolkit/commit/8abab0ae7a99320a4254cb128c0fd823726e58e0)]:
16
+ - @firtoz/maybe-error@1.5.2
17
+ - @firtoz/drizzle-utils@0.3.1
18
+
19
+ ## 0.2.1
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [[`46059a2`](https://github.com/firtoz/fullstack-toolkit/commit/46059a28bd0135414b9ed022ffe162a2292adae3)]:
24
+ - @firtoz/drizzle-utils@0.3.0
25
+
3
26
  ## 0.2.0
4
27
 
5
28
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-sqlite-wasm",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Drizzle SQLite WASM bindings",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -43,9 +43,7 @@
43
43
  "typecheck": "tsc --noEmit -p ./tsconfig.json",
44
44
  "lint": "biome check --write src",
45
45
  "lint:ci": "biome ci src",
46
- "format": "biome format src --write",
47
- "test": "bun test --pass-with-no-tests",
48
- "test:watch": "bun test --watch"
46
+ "format": "biome format src --write"
49
47
  },
50
48
  "keywords": [
51
49
  "typescript",
@@ -71,19 +69,19 @@
71
69
  "access": "public"
72
70
  },
73
71
  "dependencies": {
74
- "@firtoz/drizzle-utils": "^0.2.0",
75
- "@firtoz/maybe-error": "^1.5.1",
72
+ "@firtoz/drizzle-utils": "^0.3.1",
73
+ "@firtoz/maybe-error": "^1.5.2",
76
74
  "@firtoz/worker-helper": "^1.0.0",
77
- "@sqlite.org/sqlite-wasm": "^3.51.1-build1",
78
- "@tanstack/db": "^0.5.0",
79
- "drizzle-orm": "^0.44.7",
75
+ "@sqlite.org/sqlite-wasm": "^3.51.1-build2",
76
+ "@tanstack/db": "^0.5.15",
77
+ "drizzle-orm": "^0.45.1",
80
78
  "drizzle-valibot": "^0.4.2",
81
- "react": "^19.2.0",
82
- "valibot": "^1.1.0",
83
- "zod": "^4.1.12"
79
+ "react": "^19.2.3",
80
+ "valibot": "^1.2.0",
81
+ "zod": "^4.2.1"
84
82
  },
85
83
  "devDependencies": {
86
- "@standard-schema/spec": "^1.0.0",
87
- "@types/react": "^19.2.5"
84
+ "@standard-schema/spec": "^1.1.0",
85
+ "@types/react": "^19.2.7"
88
86
  }
89
87
  }
@@ -1,4 +1,8 @@
1
- import type { InferSchemaOutput, SyncMode } from "@tanstack/db";
1
+ import type {
2
+ InferSchemaOutput,
3
+ SyncMode,
4
+ CollectionConfig,
5
+ } from "@tanstack/db";
2
6
  import type { IR } from "@tanstack/db";
3
7
  import {
4
8
  eq,
@@ -31,6 +35,7 @@ import type {
31
35
  TableWithRequiredFields,
32
36
  BaseSyncConfig,
33
37
  SyncBackend,
38
+ CollectionUtils,
34
39
  } from "@firtoz/drizzle-utils";
35
40
  import {
36
41
  createSyncFunction,
@@ -38,6 +43,7 @@ import {
38
43
  createGetKeyFunction,
39
44
  createCollectionConfig,
40
45
  } from "@firtoz/drizzle-utils";
46
+ import type * as v from "valibot";
41
47
 
42
48
  export type AnyDrizzleDatabase = BaseSQLiteDatabase<
43
49
  "async",
@@ -102,6 +108,16 @@ export type SQLOperation =
102
108
  tableName: string;
103
109
  sql?: string;
104
110
  timestamp: number;
111
+ }
112
+ | {
113
+ /** Raw SQL query executed directly via Drizzle (not through collection) */
114
+ type: "raw-query";
115
+ sql: string;
116
+ params?: unknown[];
117
+ method: string;
118
+ rowCount: number;
119
+ context: string;
120
+ timestamp: number;
105
121
  };
106
122
 
107
123
  /**
@@ -144,6 +160,25 @@ export type ValidTableNames<TSchema extends Record<string, unknown>> = {
144
160
  [K in keyof TSchema]: TSchema[K] extends TableWithRequiredFields ? K : never;
145
161
  }[keyof TSchema];
146
162
 
163
+ /**
164
+ * Return type for sqliteCollectionOptions - configuration object for creating SQLite collections
165
+ *
166
+ * Note: The third type parameter of CollectionConfig uses `any` to maintain compatibility with
167
+ * TanStack DB's type system, which expects different schema types in different contexts.
168
+ */
169
+ export type SqliteCollectionConfig<TTable extends Table> = Omit<
170
+ CollectionConfig<
171
+ InferSchemaOutput<SelectSchema<TTable>>,
172
+ string,
173
+ // biome-ignore lint/suspicious/noExplicitAny: Required for TanStack DB type compatibility
174
+ any
175
+ >,
176
+ "utils"
177
+ > & {
178
+ schema: v.GenericSchema<unknown>;
179
+ utils: CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>;
180
+ };
181
+
147
182
  /**
148
183
  * Converts TanStack DB IR BasicExpression to Drizzle SQL expression
149
184
  *
@@ -262,7 +297,9 @@ export function sqliteCollectionOptions<
262
297
  const TDrizzle extends AnyDrizzleDatabase,
263
298
  const TTableName extends string & ValidTableNames<DrizzleSchema<TDrizzle>>,
264
299
  TTable extends DrizzleSchema<TDrizzle>[TTableName] & TableWithRequiredFields,
265
- >(config: DrizzleCollectionConfig<TDrizzle, TTableName>) {
300
+ >(
301
+ config: DrizzleCollectionConfig<TDrizzle, TTableName>,
302
+ ): SqliteCollectionConfig<TTable> {
266
303
  const tableName = config.tableName as string &
267
304
  ValidTableNames<DrizzleSchema<TDrizzle>>;
268
305
 
@@ -321,19 +358,40 @@ export function sqliteCollectionOptions<
321
358
  },
322
359
 
323
360
  loadSubset: async (options, write) => {
324
- // Build the query with optional where, orderBy, and limit
361
+ // Build the query with optional where, orderBy, limit, and offset
325
362
  // Use $dynamic() to enable dynamic query building
326
363
  let query = config.drizzle.select().from(table).$dynamic();
327
364
 
328
- // Convert TanStack DB IR expressions to Drizzle expressions
365
+ // Combine where with cursor expressions if present
366
+ // The cursor.whereFrom gives us rows after the cursor position
329
367
  let hasWhere = false;
330
- if (options.where) {
331
- const drizzleWhere = convertBasicExpressionToDrizzle(
332
- options.where,
333
- table,
334
- );
335
- query = query.where(drizzleWhere);
336
- hasWhere = true;
368
+ if (options.where || options.cursor?.whereFrom) {
369
+ let drizzleWhere: SQL | undefined;
370
+
371
+ if (options.where && options.cursor?.whereFrom) {
372
+ // Combine main where with cursor expression using AND
373
+ const mainWhere = convertBasicExpressionToDrizzle(
374
+ options.where,
375
+ table,
376
+ );
377
+ const cursorWhere = convertBasicExpressionToDrizzle(
378
+ options.cursor.whereFrom,
379
+ table,
380
+ );
381
+ drizzleWhere = and(mainWhere, cursorWhere);
382
+ } else if (options.where) {
383
+ drizzleWhere = convertBasicExpressionToDrizzle(options.where, table);
384
+ } else if (options.cursor?.whereFrom) {
385
+ drizzleWhere = convertBasicExpressionToDrizzle(
386
+ options.cursor.whereFrom,
387
+ table,
388
+ );
389
+ }
390
+
391
+ if (drizzleWhere) {
392
+ query = query.where(drizzleWhere);
393
+ hasWhere = true;
394
+ }
337
395
  }
338
396
 
339
397
  if (options.orderBy) {
@@ -345,6 +403,11 @@ export function sqliteCollectionOptions<
345
403
  query = query.limit(options.limit);
346
404
  }
347
405
 
406
+ // Apply offset for offset-based pagination
407
+ if (options.offset !== undefined && options.offset > 0) {
408
+ query = query.offset(options.offset);
409
+ }
410
+
348
411
  const items = (await query) as unknown as InferSchemaOutput<
349
412
  SelectSchema<TTable>
350
413
  >[];
@@ -358,6 +421,12 @@ export function sqliteCollectionOptions<
358
421
  if (options.limit !== undefined) {
359
422
  contextParts.push(`limit ${options.limit}`);
360
423
  }
424
+ if (options.offset !== undefined && options.offset > 0) {
425
+ contextParts.push(`offset ${options.offset}`);
426
+ }
427
+ if (options.cursor) {
428
+ contextParts.push("with cursor pagination");
429
+ }
361
430
 
362
431
  if (hasWhere) {
363
432
  config.interceptor.onOperation({
@@ -393,6 +462,9 @@ export function sqliteCollectionOptions<
393
462
  if (options.limit !== undefined) {
394
463
  contextParts.push(`limit ${options.limit}`);
395
464
  }
465
+ if (options.offset !== undefined && options.offset > 0) {
466
+ contextParts.push(`offset ${options.offset}`);
467
+ }
396
468
 
397
469
  config.interceptor.onOperation({
398
470
  type: "write",
@@ -544,21 +616,29 @@ export function sqliteCollectionOptions<
544
616
  onInsert: config.debug
545
617
  ? async (params) => {
546
618
  console.log("onInsert", params);
619
+ // Call the actual handler from syncResult (always defined in createSyncFunction)
620
+ // biome-ignore lint/style/noNonNullAssertion: onInsert is always defined in SyncFunctionResult
621
+ await syncResult.onInsert!(params);
547
622
  }
548
623
  : undefined,
549
624
  onUpdate: config.debug
550
625
  ? async (params) => {
551
626
  console.log("onUpdate", params);
627
+ // Call the actual handler from syncResult (always defined in createSyncFunction)
628
+ // biome-ignore lint/style/noNonNullAssertion: onUpdate is always defined in SyncFunctionResult
629
+ await syncResult.onUpdate!(params);
552
630
  }
553
631
  : undefined,
554
632
  onDelete: config.debug
555
633
  ? async (params) => {
556
634
  console.log("onDelete", params);
635
+ // Call the actual handler from syncResult (always defined in createSyncFunction)
636
+ // biome-ignore lint/style/noNonNullAssertion: onDelete is always defined in SyncFunctionResult
637
+ await syncResult.onDelete!(params);
557
638
  }
558
639
  : undefined,
559
640
  syncMode: config.syncMode,
560
641
  });
561
642
 
562
- // biome-ignore lint/suspicious/noExplicitAny: Collection schema type needs to be flexible
563
- return collectionConfig as any;
643
+ return collectionConfig;
564
644
  }
@@ -1,6 +1,10 @@
1
1
  import type { DrizzleConfig } from "drizzle-orm";
2
2
  import { drizzle as drizzleSqliteProxy } from "drizzle-orm/sqlite-proxy";
3
3
  import type { ISqliteWorkerClient } from "../worker/client";
4
+ import type {
5
+ SQLInterceptor,
6
+ SQLOperation,
7
+ } from "../collections/sqlite-collection";
4
8
 
5
9
  export const drizzleSqliteWasmWorker = <
6
10
  TSchema extends Record<string, unknown> = Record<string, never>,
@@ -22,3 +26,139 @@ export const drizzleSqliteWasmWorker = <
22
26
  });
23
27
  }, config);
24
28
  };
29
+
30
+ /**
31
+ * Creates an instrumented Drizzle instance that logs all SQL queries.
32
+ * This wraps the standard drizzleSqliteWasmWorker to intercept every query.
33
+ */
34
+ export const createInstrumentedDrizzle = <
35
+ TSchema extends Record<string, unknown> = Record<string, never>,
36
+ >(
37
+ client: ISqliteWorkerClient,
38
+ config: DrizzleConfig<TSchema> = {},
39
+ interceptor?: SQLInterceptor,
40
+ ) => {
41
+ return drizzleSqliteProxy<TSchema>(async (sql, params, method) => {
42
+ const startTime = Date.now();
43
+
44
+ const result = await new Promise<{ rows: unknown[] }>((resolve, reject) => {
45
+ client.performRemoteCallback(
46
+ {
47
+ sql,
48
+ params,
49
+ method,
50
+ },
51
+ resolve,
52
+ reject,
53
+ );
54
+ });
55
+
56
+ // Log the operation if interceptor is provided
57
+ if (interceptor?.onOperation) {
58
+ // Parse SQL to determine context
59
+ const sqlLower = sql.toLowerCase().trim();
60
+ let context = "Direct Drizzle query";
61
+
62
+ if (sqlLower.startsWith("select")) {
63
+ // Extract table name from SELECT query
64
+ const fromMatch = sql.match(/from\s+["']?(\w+)["']?/i);
65
+ const tableName = fromMatch?.[1] || "unknown";
66
+
67
+ // Check for LIMIT/OFFSET - handle both literal values and ? placeholders
68
+ // Drizzle uses parameterized queries, so we need to check for `limit ?` style
69
+ const hasLimit = /limit\s+(\d+|\?|\$\d+)/i.test(sql);
70
+ const hasOffset = /offset\s+(\d+|\?|\$\d+)/i.test(sql);
71
+ const hasOrderBy = /order\s+by/i.test(sql);
72
+
73
+ if (hasLimit || hasOffset) {
74
+ // Extract actual values from params if using placeholders
75
+ // Drizzle typically puts LIMIT as second-to-last param, OFFSET as last
76
+ let limitVal = "?";
77
+ let offsetVal = "0";
78
+
79
+ // Try to extract literal values first
80
+ const literalLimit = sql.match(/limit\s+(\d+)/i);
81
+ const literalOffset = sql.match(/offset\s+(\d+)/i);
82
+
83
+ if (literalLimit) {
84
+ limitVal = literalLimit[1];
85
+ } else if (params && params.length > 0) {
86
+ // For parameterized queries, try to infer from params
87
+ // LIMIT/OFFSET are usually at the end
88
+ if (hasLimit && hasOffset && params.length >= 2) {
89
+ limitVal = String(params[params.length - 2]);
90
+ offsetVal = String(params[params.length - 1]);
91
+ } else if (hasLimit && params.length >= 1) {
92
+ limitVal = String(params[params.length - 1]);
93
+ }
94
+ }
95
+
96
+ if (literalOffset) {
97
+ offsetVal = literalOffset[1];
98
+ }
99
+
100
+ context = `SELECT with LIMIT ${limitVal} OFFSET ${offsetVal}`;
101
+ } else if (hasOrderBy) {
102
+ context = `SELECT all from ${tableName} (ordered)`;
103
+ } else {
104
+ context = `SELECT all from ${tableName}`;
105
+ }
106
+
107
+ const operation: SQLOperation = {
108
+ type: "raw-query",
109
+ sql,
110
+ params: params as unknown[],
111
+ method,
112
+ rowCount: result.rows?.length ?? 0,
113
+ context,
114
+ timestamp: startTime,
115
+ };
116
+ interceptor.onOperation(operation);
117
+ } else if (sqlLower.startsWith("insert")) {
118
+ const intoMatch = sql.match(/into\s+["']?(\w+)["']?/i);
119
+ context = `INSERT into ${intoMatch?.[1] || "unknown"}`;
120
+
121
+ const operation: SQLOperation = {
122
+ type: "raw-query",
123
+ sql,
124
+ params: params as unknown[],
125
+ method,
126
+ rowCount: 0,
127
+ context,
128
+ timestamp: startTime,
129
+ };
130
+ interceptor.onOperation(operation);
131
+ } else if (sqlLower.startsWith("update")) {
132
+ const tableMatch = sql.match(/update\s+["']?(\w+)["']?/i);
133
+ context = `UPDATE ${tableMatch?.[1] || "unknown"}`;
134
+
135
+ const operation: SQLOperation = {
136
+ type: "raw-query",
137
+ sql,
138
+ params: params as unknown[],
139
+ method,
140
+ rowCount: 0,
141
+ context,
142
+ timestamp: startTime,
143
+ };
144
+ interceptor.onOperation(operation);
145
+ } else if (sqlLower.startsWith("delete")) {
146
+ const fromMatch = sql.match(/from\s+["']?(\w+)["']?/i);
147
+ context = `DELETE from ${fromMatch?.[1] || "unknown"}`;
148
+
149
+ const operation: SQLOperation = {
150
+ type: "raw-query",
151
+ sql,
152
+ params: params as unknown[],
153
+ method,
154
+ rowCount: 0,
155
+ context,
156
+ timestamp: startTime,
157
+ };
158
+ interceptor.onOperation(operation);
159
+ }
160
+ }
161
+
162
+ return result;
163
+ }, config);
164
+ };
@@ -3,12 +3,16 @@ import {
3
3
  customSqliteMigrate,
4
4
  type DurableSqliteMigrationConfig,
5
5
  } from "@firtoz/drizzle-sqlite-wasm/sqlite-wasm-migrator";
6
- import { drizzleSqliteWasmWorker } from "@firtoz/drizzle-sqlite-wasm/drizzle-sqlite-wasm-worker";
6
+ import {
7
+ drizzleSqliteWasmWorker,
8
+ createInstrumentedDrizzle,
9
+ } from "@firtoz/drizzle-sqlite-wasm/drizzle-sqlite-wasm-worker";
7
10
  import type { ISqliteWorkerClient } from "../worker/manager";
8
11
  import {
9
12
  initializeSqliteWorker,
10
13
  isSqliteWorkerInitialized,
11
14
  } from "../worker/global-manager";
15
+ import type { SQLInterceptor } from "../collections/sqlite-collection";
12
16
 
13
17
  export const useDrizzleSqliteDb = <TSchema extends Record<string, unknown>>(
14
18
  WorkerConstructor: new () => Worker,
@@ -16,6 +20,8 @@ export const useDrizzleSqliteDb = <TSchema extends Record<string, unknown>>(
16
20
  schema: TSchema,
17
21
  migrations: DurableSqliteMigrationConfig,
18
22
  debug?: boolean,
23
+ /** Optional interceptor to log ALL SQL queries (including direct Drizzle queries) */
24
+ interceptor?: SQLInterceptor,
19
25
  ) => {
20
26
  const resolveRef = useRef<null | (() => void)>(null);
21
27
  const rejectRef = useRef<null | ((error: unknown) => void)>(null);
@@ -72,45 +78,66 @@ export const useDrizzleSqliteDb = <TSchema extends Record<string, unknown>>(
72
78
  };
73
79
  }, [dbName, WorkerConstructor]);
74
80
 
81
+ // Store interceptor in a ref to avoid recreating drizzle on interceptor changes
82
+ const interceptorRef = useRef(interceptor);
83
+ interceptorRef.current = interceptor;
84
+
75
85
  // Create drizzle instance with a callback-based approach that waits for the client
86
+ // Use instrumented version if interceptor is provided to log ALL queries
76
87
  const drizzle = useMemo(() => {
77
88
  if (debug) {
78
89
  console.log(`[DEBUG] ${dbName} - creating drizzle proxy wrapper`);
79
90
  }
80
- return drizzleSqliteWasmWorker<TSchema>(
81
- {
82
- performRemoteCallback: (data, resolve, reject) => {
83
- const client = sqliteClientRef.current;
84
- if (!client) {
85
- console.error(
86
- `[DEBUG] ${dbName} - performRemoteCallback called but no sqliteClient yet`,
87
- );
88
- reject(
89
- new Error(
90
- `Database ${dbName} not ready yet - still initializing`,
91
- ),
92
- );
93
- return;
94
- }
95
- client.performRemoteCallback(data, resolve, reject);
96
- },
97
- onStarted: (callback) => {
98
- const client = sqliteClientRef.current;
99
- if (!client) {
100
- console.warn(
101
- `[DEBUG] ${dbName} - onStarted called but no sqliteClient yet`,
102
- );
103
- return;
104
- }
105
- client.onStarted(callback);
106
- },
107
- terminate: () => {
108
- sqliteClientRef.current?.terminate();
109
- },
91
+
92
+ const client: ISqliteWorkerClient = {
93
+ performRemoteCallback: (data, resolve, reject) => {
94
+ const actualClient = sqliteClientRef.current;
95
+ if (!actualClient) {
96
+ console.error(
97
+ `[DEBUG] ${dbName} - performRemoteCallback called but no sqliteClient yet`,
98
+ );
99
+ reject(
100
+ new Error(`Database ${dbName} not ready yet - still initializing`),
101
+ );
102
+ return;
103
+ }
104
+ actualClient.performRemoteCallback(data, resolve, reject);
105
+ },
106
+ onStarted: (callback) => {
107
+ const actualClient = sqliteClientRef.current;
108
+ if (!actualClient) {
109
+ console.warn(
110
+ `[DEBUG] ${dbName} - onStarted called but no sqliteClient yet`,
111
+ );
112
+ return;
113
+ }
114
+ actualClient.onStarted(callback);
110
115
  },
111
- { schema },
112
- );
113
- }, [schema, dbName]); // Using ref for sqliteClient to avoid recreating drizzle
116
+ terminate: () => {
117
+ sqliteClientRef.current?.terminate();
118
+ },
119
+ checkpoint: () => {
120
+ return sqliteClientRef.current?.checkpoint() ?? Promise.resolve();
121
+ },
122
+ };
123
+
124
+ // Use instrumented version if interceptor is provided
125
+ // Use a wrapper that accesses the ref so interceptor changes don't recreate drizzle
126
+ const interceptorWrapper: SQLInterceptor = {
127
+ onOperation: (op) => interceptorRef.current?.onOperation?.(op),
128
+ };
129
+
130
+ // Always use instrumented if initial interceptor was provided
131
+ if (interceptor) {
132
+ return createInstrumentedDrizzle<TSchema>(
133
+ client,
134
+ { schema },
135
+ interceptorWrapper,
136
+ );
137
+ }
138
+
139
+ return drizzleSqliteWasmWorker<TSchema>(client, { schema });
140
+ }, [schema, dbName, !!interceptor]); // Only recreate if interceptor presence changes, not on every render
114
141
 
115
142
  useEffect(() => {
116
143
  if (!sqliteClient) {