@firtoz/drizzle-utils 1.0.2 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @firtoz/drizzle-utils
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#64](https://github.com/firtoz/fullstack-toolkit/pull/64) [`556555a`](https://github.com/firtoz/fullstack-toolkit/commit/556555a2e09030a8658be8c07b5881e72be64b2f) Thanks [@firtoz](https://github.com/firtoz)! - **`@firtoz/drizzle-utils`:** Export `DrizzleSqliteTableCollection`; extend `BaseSyncConfig` / `createCollectionConfig` for typed `getSyncPersistKey` and `getKey` as `IdOf<TTable>`; avoid executing `syncableTable` default functions during table definition for Worker/DO globals; export collection helper types for Drizzle-backed TanStack collections.
8
+
9
+ **`@firtoz/drizzle-indexeddb` (major):** `deferLocalPersistence`, `handleBatchPut`, and related collection options; `receiveSync` persistence aligned with generic sync and partial-sync traffic; remove debug ingest usage. **Breaking** alongside the `@firtoz/drizzle-utils` sync/collection typing changes above (including `DrizzleSqliteTableCollection` and `BaseSyncConfig` expectations).
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [[`556555a`](https://github.com/firtoz/fullstack-toolkit/commit/556555a2e09030a8658be8c07b5881e72be64b2f)]:
14
+ - @firtoz/db-helpers@2.1.0
15
+
16
+ ## 1.1.0
17
+
18
+ ### Minor Changes
19
+
20
+ - [`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`.
21
+
3
22
  ## 1.0.2
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-utils",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Shared utilities and types for Drizzle-based packages",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -25,7 +25,7 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "scripts": {
28
- "typecheck": "tsc --noEmit -p ./tsconfig.json",
28
+ "typecheck": "tsgo --noEmit -p ./tsconfig.json",
29
29
  "lint": "biome check --write src",
30
30
  "lint:ci": "biome ci src",
31
31
  "format": "biome format src --write"
@@ -53,19 +53,19 @@
53
53
  "access": "public"
54
54
  },
55
55
  "peerDependencies": {
56
- "@tanstack/db": ">=0.5.33",
57
- "drizzle-orm": ">=0.45.1",
56
+ "@tanstack/db": ">=0.6.1",
57
+ "drizzle-orm": ">=0.45.2",
58
58
  "drizzle-valibot": ">=0.4.0",
59
59
  "valibot": ">=1.3.1"
60
60
  },
61
61
  "devDependencies": {
62
- "@tanstack/db": "^0.5.33",
63
- "drizzle-orm": "^0.45.1",
62
+ "@tanstack/db": "^0.6.1",
63
+ "drizzle-orm": "^0.45.2",
64
64
  "drizzle-valibot": "^0.4.2",
65
65
  "valibot": "^1.3.1"
66
66
  },
67
67
  "dependencies": {
68
- "@firtoz/db-helpers": "^2.0.0",
68
+ "@firtoz/db-helpers": "^2.1.0",
69
69
  "@firtoz/maybe-error": "^1.5.2"
70
70
  }
71
71
  }
@@ -66,6 +66,15 @@ export type InsertSchema<TTable extends Table> = BuildSchema<
66
66
  undefined
67
67
  >;
68
68
 
69
+ /**
70
+ * Schema type with insert input (optionals for defaults) and select output (all fields present).
71
+ * Represents the standard input/output pair for collection schemas.
72
+ */
73
+ export type InsertToSelectSchema<TTable extends Table> = v.GenericSchema<
74
+ v.InferInput<InsertSchema<TTable>>,
75
+ v.InferOutput<SelectSchema<TTable>>
76
+ >;
77
+
69
78
  /**
70
79
  * Helper type to get the table from schema by name
71
80
  */
@@ -82,7 +91,7 @@ export type InferCollectionFromTable<TTable extends Table> = Collection<
82
91
  TTable["$inferSelect"],
83
92
  IdOf<TTable>,
84
93
  UtilsRecord,
85
- SelectSchema<TTable>,
94
+ InsertToSelectSchema<TTable>,
86
95
  Omit<
87
96
  TTable["$inferInsert"],
88
97
  "id"
@@ -99,7 +108,7 @@ export const USE_DEDUPE = _USE_DEDUPE;
99
108
  * Extends the generic (Drizzle-free) config with a Drizzle table reference.
100
109
  */
101
110
  export interface BaseSyncConfig<TTable extends Table>
102
- extends GenericBaseSyncConfig {
111
+ extends GenericBaseSyncConfig<InferSchemaOutput<SelectSchema<TTable>>> {
103
112
  table: TTable;
104
113
  }
105
114
 
@@ -135,7 +144,7 @@ export function createSyncFunction<TTable extends Table>(
135
144
  */
136
145
  export function createInsertSchemaWithDefaults<TTable extends Table>(
137
146
  table: TTable,
138
- ): v.GenericSchema<unknown> {
147
+ ): InsertToSelectSchema<TTable> {
139
148
  const insertSchema = createInsertSchema(table);
140
149
  const columns = getTableColumns(table);
141
150
 
@@ -194,7 +203,7 @@ export function createInsertSchemaWithDefaults<TTable extends Table>(
194
203
 
195
204
  return result;
196
205
  }),
197
- ) as v.GenericSchema<unknown>;
206
+ ) as InsertToSelectSchema<TTable>;
198
207
  }
199
208
 
200
209
  /**
@@ -203,7 +212,7 @@ export function createInsertSchemaWithDefaults<TTable extends Table>(
203
212
  */
204
213
  export function createInsertSchemaWithIdDefault<TTable extends Table>(
205
214
  table: TTable,
206
- ): v.GenericSchema<unknown> {
215
+ ): InsertToSelectSchema<TTable> {
207
216
  const insertSchema = createInsertSchema(table);
208
217
  const columns = getTableColumns(table);
209
218
  const idColumn = columns.id;
@@ -220,17 +229,16 @@ export function createInsertSchemaWithIdDefault<TTable extends Table>(
220
229
 
221
230
  return result;
222
231
  }),
223
- ) as v.GenericSchema<unknown>;
232
+ ) as InsertToSelectSchema<TTable>;
224
233
  }
225
234
 
226
235
  /**
227
236
  * Standard getKey function for collections
228
237
  */
229
238
  export function createGetKeyFunction<TTable extends Table>() {
230
- return (item: InferSchemaOutput<SelectSchema<TTable>>) => {
231
- const id = (item as { id: string }).id;
232
- return id;
233
- };
239
+ type TItem = InferSchemaOutput<SelectSchema<TTable>>;
240
+ type TKey = IdOf<TTable>;
241
+ return (item: TItem): TKey => (item as { id: TKey }).id;
234
242
  }
235
243
 
236
244
  /**
@@ -242,33 +250,30 @@ export function createCollectionConfig<
242
250
  TSchema extends v.GenericSchema<unknown>,
243
251
  >(config: {
244
252
  schema: TSchema;
245
- getKey: (item: InferSchemaOutput<SelectSchema<TTable>>) => string;
253
+ getKey: (item: InferSchemaOutput<SelectSchema<TTable>>) => IdOf<TTable>;
246
254
  syncResult: SyncFunctionResult<TTable>;
247
255
  onInsert?: CollectionConfig<
248
256
  InferSchemaOutput<SelectSchema<TTable>>,
249
257
  string,
250
- // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
251
- any
258
+ TSchema
252
259
  >["onInsert"];
253
260
  onUpdate?: CollectionConfig<
254
261
  InferSchemaOutput<SelectSchema<TTable>>,
255
262
  string,
256
- // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
257
- any
263
+ TSchema
258
264
  >["onUpdate"];
259
265
  onDelete?: CollectionConfig<
260
266
  InferSchemaOutput<SelectSchema<TTable>>,
261
267
  string,
262
- // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
263
- any
268
+ TSchema
264
269
  >["onDelete"];
265
270
  syncMode?: SyncMode;
266
271
  }): Omit<
267
272
  CollectionConfig<
268
273
  InferSchemaOutput<SelectSchema<TTable>>,
269
- string,
270
- // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
271
- any
274
+ IdOf<TTable>,
275
+ TSchema,
276
+ CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>
272
277
  >,
273
278
  "utils"
274
279
  > & {
@@ -277,17 +282,17 @@ export function createCollectionConfig<
277
282
  } {
278
283
  type TItem = InferSchemaOutput<SelectSchema<TTable>>;
279
284
  type ReturnType = Omit<
280
- CollectionConfig<
281
- TItem,
282
- string,
283
- // biome-ignore lint/suspicious/noExplicitAny: Schema type parameter needs to be flexible
284
- any
285
- >,
285
+ CollectionConfig<TItem, IdOf<TTable>, TSchema, CollectionUtils<TItem>>,
286
286
  "utils"
287
287
  > & {
288
288
  schema: TSchema;
289
289
  utils: CollectionUtils<TItem>;
290
290
  };
291
291
 
292
- return createGenericCollectionConfig<TItem, TSchema>(config) as ReturnType;
292
+ const { getKey: getId, ...rest } = config;
293
+ return createGenericCollectionConfig<TItem, TSchema>({
294
+ ...rest,
295
+ // Generic sync is typed with string keys; runtime id may be number — same value as Drizzle row id.
296
+ getKey: (item: TItem) => getId(item) as string,
297
+ }) as ReturnType;
293
298
  }
@@ -0,0 +1,29 @@
1
+ import type { CollectionUtils } from "@firtoz/db-helpers";
2
+ import type {
3
+ Collection,
4
+ InferSchemaInput,
5
+ InferSchemaOutput,
6
+ } from "@tanstack/db";
7
+ import type {
8
+ IdOf,
9
+ InsertToSelectSchema,
10
+ SelectSchema,
11
+ } from "./collection-utils";
12
+ import type { TableWithRequiredFields } from "./syncableTable";
13
+
14
+ /**
15
+ * TanStack {@link Collection} for a syncable Drizzle SQLite table (`syncableTable` columns).
16
+ *
17
+ * Shared by `@firtoz/drizzle-sqlite-wasm` (async driver) and `@firtoz/drizzle-durable-sqlite`
18
+ * (Durable Object sync driver). Import this type from `@firtoz/drizzle-utils` rather than
19
+ * duplicating it per package.
20
+ */
21
+ export type DrizzleSqliteTableCollection<
22
+ TTable extends TableWithRequiredFields,
23
+ > = Collection<
24
+ InferSchemaOutput<SelectSchema<TTable>>,
25
+ IdOf<TTable>,
26
+ CollectionUtils<InferSchemaOutput<SelectSchema<TTable>>>,
27
+ InsertToSelectSchema<TTable>,
28
+ InferSchemaInput<InsertToSelectSchema<TTable>>
29
+ >;
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export type {
22
22
  IdOf,
23
23
  SelectSchema,
24
24
  InsertSchema,
25
+ InsertToSelectSchema,
25
26
  GetTableFromSchema,
26
27
  InferCollectionFromTable,
27
28
  BaseSyncConfig,
@@ -47,3 +48,17 @@ export {
47
48
  } from "./syncableTable";
48
49
 
49
50
  export type { TableWithRequiredFields } from "./syncableTable";
51
+
52
+ export type { DrizzleSqliteTableCollection } from "./drizzle-sqlite-table-collection";
53
+
54
+ export type {
55
+ SQLOperation,
56
+ SQLInterceptor,
57
+ SqliteDriverMode,
58
+ SqliteTableSyncBackendConfig,
59
+ } from "./sqlite-table-sync";
60
+ export {
61
+ convertBasicExpressionToDrizzle,
62
+ convertOrderByToDrizzle,
63
+ createSqliteTableSyncBackend,
64
+ } from "./sqlite-table-sync";
@@ -0,0 +1,121 @@
1
+ import type { IR } from "@tanstack/db";
2
+ import {
3
+ eq,
4
+ sql,
5
+ type Table,
6
+ gt,
7
+ gte,
8
+ lt,
9
+ lte,
10
+ ne,
11
+ and,
12
+ or,
13
+ not,
14
+ isNull,
15
+ isNotNull,
16
+ like,
17
+ inArray,
18
+ asc,
19
+ desc,
20
+ type SQL,
21
+ } from "drizzle-orm";
22
+ import { SQLiteColumn } from "drizzle-orm/sqlite-core";
23
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
24
+
25
+ /**
26
+ * Converts TanStack DB IR BasicExpression to Drizzle SQL expression.
27
+ */
28
+ export function convertBasicExpressionToDrizzle<TTable extends Table>(
29
+ expression: IR.BasicExpression,
30
+ table: TTable,
31
+ ): SQL {
32
+ switch (expression.type) {
33
+ case "ref": {
34
+ const propRef = expression;
35
+ const columnName = propRef.path[propRef.path.length - 1];
36
+ const column = table[columnName as keyof typeof table];
37
+
38
+ if (!column || !(column instanceof SQLiteColumn)) {
39
+ console.error("[SQLite sync backend] Column lookup failed:", {
40
+ columnName,
41
+ column,
42
+ tableKeys: Object.keys(table),
43
+ hasColumn: columnName in table,
44
+ });
45
+ throw new Error(`Column ${String(columnName)} not found in table`);
46
+ }
47
+
48
+ return column as unknown as SQL;
49
+ }
50
+ case "val": {
51
+ const value = expression;
52
+ return sql`${value.value}`;
53
+ }
54
+ case "func": {
55
+ const func = expression;
56
+ const args = func.args.map((arg) =>
57
+ convertBasicExpressionToDrizzle(arg, table),
58
+ );
59
+
60
+ switch (func.name) {
61
+ case "eq":
62
+ return eq(args[0], args[1]);
63
+ case "ne":
64
+ return ne(args[0], args[1]);
65
+ case "gt":
66
+ return gt(args[0], args[1]);
67
+ case "gte":
68
+ return gte(args[0], args[1]);
69
+ case "lt":
70
+ return lt(args[0], args[1]);
71
+ case "lte":
72
+ return lte(args[0], args[1]);
73
+ case "and": {
74
+ const result = and(...args);
75
+ if (!result) {
76
+ throw new Error("Invalid 'and' expression - no arguments provided");
77
+ }
78
+ return result;
79
+ }
80
+ case "or": {
81
+ const result = or(...args);
82
+ if (!result) {
83
+ throw new Error("Invalid 'or' expression - no arguments provided");
84
+ }
85
+ return result;
86
+ }
87
+ case "not":
88
+ return not(args[0]);
89
+ case "isNull":
90
+ return isNull(args[0]);
91
+ case "isNotNull":
92
+ return isNotNull(args[0]);
93
+ case "like":
94
+ return like(args[0], args[1]);
95
+ case "in":
96
+ return inArray(args[0], args[1]);
97
+ case "isUndefined":
98
+ return isNull(args[0]);
99
+ default:
100
+ throw new Error(`Unsupported function: ${func.name}`);
101
+ }
102
+ }
103
+ default:
104
+ exhaustiveGuard(expression);
105
+ }
106
+ }
107
+
108
+ export function convertOrderByToDrizzle<TTable extends Table>(
109
+ orderBy: IR.OrderBy,
110
+ table: TTable,
111
+ ): SQL[] {
112
+ return orderBy.map((clause) => {
113
+ const expression = convertBasicExpressionToDrizzle(
114
+ clause.expression,
115
+ table,
116
+ );
117
+ const direction = clause.compareOptions.direction || "asc";
118
+
119
+ return direction === "asc" ? asc(expression) : desc(expression);
120
+ });
121
+ }
@@ -0,0 +1,10 @@
1
+ export type { SQLOperation, SQLInterceptor } from "./types";
2
+ export {
3
+ convertBasicExpressionToDrizzle,
4
+ convertOrderByToDrizzle,
5
+ } from "./convert-ir";
6
+ export {
7
+ createSqliteTableSyncBackend,
8
+ type SqliteDriverMode,
9
+ type SqliteTableSyncBackendConfig,
10
+ } from "./sqlite-table-sync-backend";
@@ -0,0 +1,535 @@
1
+ import type { ReceiveSyncDurableOp } from "@firtoz/db-helpers";
2
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
3
+ import type { InferSchemaOutput } from "@tanstack/db";
4
+ import { and, eq, getTableColumns, sql, type SQL } from "drizzle-orm";
5
+ import type {
6
+ SQLiteInsertValue,
7
+ SQLiteUpdateSetSource,
8
+ } from "drizzle-orm/sqlite-core";
9
+ import type { SelectSchema, SyncBackend } from "../collection-utils";
10
+ import type { TableWithRequiredFields } from "../syncableTable";
11
+ import {
12
+ convertBasicExpressionToDrizzle,
13
+ convertOrderByToDrizzle,
14
+ } from "./convert-ir";
15
+ import type { SQLInterceptor } from "./types";
16
+
17
+ export type SqliteDriverMode = "async" | "sync";
18
+
19
+ /**
20
+ * `ON CONFLICT DO UPDATE` set map using SQLite `excluded.*` so inserts are idempotent
21
+ * (matches IndexedDB `put` / replayed partial-sync rows already present from `initialLoad`).
22
+ */
23
+ function sqliteExcludedUpsertSet<TTable extends TableWithRequiredFields>(
24
+ table: TTable,
25
+ ): SQLiteUpdateSetSource<TTable> {
26
+ const cols = getTableColumns(table);
27
+ const set: Record<string, SQL> = {};
28
+ for (const [jsName, col] of Object.entries(cols)) {
29
+ if (jsName === "id") continue;
30
+ set[jsName] = sql.raw(`excluded."${col.name}"`);
31
+ }
32
+ return set as SQLiteUpdateSetSource<TTable>;
33
+ }
34
+
35
+ export interface SqliteTableSyncBackendConfig<
36
+ TTable extends TableWithRequiredFields,
37
+ > {
38
+ /** drizzle-orm SQLite database (async WASM/libsql or sync Durable Object) */
39
+ // biome-ignore lint/suspicious/noExplicitAny: generic over sync/async Drizzle DB shapes
40
+ drizzle: any;
41
+ table: TTable;
42
+ tableName: string;
43
+ debug?: boolean;
44
+ checkpoint?: () => Promise<void>;
45
+ interceptor?: SQLInterceptor;
46
+ /**
47
+ * `async`: libsql/WASM — use `await db.transaction(async (tx) => …)`.
48
+ * `sync`: Cloudflare DO SQLite — `transactionSync` requires a **synchronous** callback; use `.all()` / `.run()` on builders inside `tx`.
49
+ */
50
+ driverMode: SqliteDriverMode;
51
+ }
52
+
53
+ export function createSqliteTableSyncBackend<
54
+ TTable extends TableWithRequiredFields,
55
+ >(config: SqliteTableSyncBackendConfig<TTable>): SyncBackend<TTable> {
56
+ type TItem = InferSchemaOutput<SelectSchema<TTable>>;
57
+ const table = config.table;
58
+ const driverMode = config.driverMode;
59
+
60
+ let transactionQueue = Promise.resolve();
61
+ const queueTransaction = <T>(
62
+ _label: string,
63
+ fn: () => Promise<T>,
64
+ ): Promise<T> => {
65
+ const run = (): Promise<T> => fn();
66
+ const result = transactionQueue.then(run, run);
67
+ transactionQueue = result.then(
68
+ () => {},
69
+ () => {},
70
+ );
71
+ return result;
72
+ };
73
+
74
+ const backend: SyncBackend<TTable> = {
75
+ initialLoad: async () => {
76
+ const items = (await config.drizzle
77
+ .select()
78
+ .from(table)) as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
79
+
80
+ if (config.interceptor?.onOperation) {
81
+ config.interceptor.onOperation({
82
+ type: "select-all",
83
+ tableName: config.tableName,
84
+ itemsReturned: items,
85
+ itemCount: items.length,
86
+ context: "Initial load (eager mode)",
87
+ timestamp: Date.now(),
88
+ });
89
+ }
90
+ if (config.interceptor?.onOperation) {
91
+ config.interceptor.onOperation({
92
+ type: "write",
93
+ tableName: config.tableName,
94
+ itemsWritten: items,
95
+ writeCount: items.length,
96
+ context: "Initial load (eager mode)",
97
+ timestamp: Date.now(),
98
+ });
99
+ }
100
+
101
+ return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
102
+ },
103
+
104
+ loadSubset: async (options) => {
105
+ let query = config.drizzle.select().from(table).$dynamic();
106
+
107
+ let hasWhere = false;
108
+ if (options.where || options.cursor?.whereFrom) {
109
+ let drizzleWhere: SQL | undefined;
110
+
111
+ if (options.where && options.cursor?.whereFrom) {
112
+ const mainWhere = convertBasicExpressionToDrizzle(
113
+ options.where,
114
+ table,
115
+ );
116
+ const cursorWhere = convertBasicExpressionToDrizzle(
117
+ options.cursor.whereFrom,
118
+ table,
119
+ );
120
+ drizzleWhere = and(mainWhere, cursorWhere);
121
+ } else if (options.where) {
122
+ drizzleWhere = convertBasicExpressionToDrizzle(options.where, table);
123
+ } else if (options.cursor?.whereFrom) {
124
+ drizzleWhere = convertBasicExpressionToDrizzle(
125
+ options.cursor.whereFrom,
126
+ table,
127
+ );
128
+ }
129
+
130
+ if (drizzleWhere) {
131
+ query = query.where(drizzleWhere);
132
+ hasWhere = true;
133
+ }
134
+ }
135
+
136
+ if (options.orderBy) {
137
+ const drizzleOrderBy = convertOrderByToDrizzle(options.orderBy, table);
138
+ query = query.orderBy(...drizzleOrderBy);
139
+ }
140
+
141
+ if (options.limit !== undefined) {
142
+ query = query.limit(options.limit);
143
+ }
144
+
145
+ if (options.offset !== undefined && options.offset > 0) {
146
+ query = query.offset(options.offset);
147
+ }
148
+
149
+ const items = (await query) as unknown as InferSchemaOutput<
150
+ SelectSchema<TTable>
151
+ >[];
152
+
153
+ if (config.interceptor?.onOperation) {
154
+ const contextParts: string[] = ["On-demand load"];
155
+ if (options.orderBy) contextParts.push("with sorting");
156
+ if (options.limit !== undefined)
157
+ contextParts.push(`limit ${options.limit}`);
158
+ if (options.offset !== undefined && options.offset > 0)
159
+ contextParts.push(`offset ${options.offset}`);
160
+ if (options.cursor) contextParts.push("with cursor pagination");
161
+
162
+ if (hasWhere) {
163
+ config.interceptor.onOperation({
164
+ type: "select-where",
165
+ tableName: config.tableName,
166
+ whereClause: "WHERE clause applied",
167
+ itemsReturned: items,
168
+ itemCount: items.length,
169
+ context: contextParts.join(", "),
170
+ timestamp: Date.now(),
171
+ });
172
+ } else {
173
+ config.interceptor.onOperation({
174
+ type: "select-all",
175
+ tableName: config.tableName,
176
+ itemsReturned: items,
177
+ itemCount: items.length,
178
+ context: contextParts.join(", "),
179
+ timestamp: Date.now(),
180
+ });
181
+ }
182
+ }
183
+
184
+ if (config.interceptor?.onOperation) {
185
+ const contextParts: string[] = ["On-demand load"];
186
+ if (hasWhere) contextParts.push("with WHERE clause");
187
+ if (options.orderBy) contextParts.push("with sorting");
188
+ if (options.limit !== undefined)
189
+ contextParts.push(`limit ${options.limit}`);
190
+ if (options.offset !== undefined && options.offset > 0)
191
+ contextParts.push(`offset ${options.offset}`);
192
+
193
+ config.interceptor.onOperation({
194
+ type: "write",
195
+ tableName: config.tableName,
196
+ itemsWritten: items,
197
+ writeCount: items.length,
198
+ context: contextParts.join(", "),
199
+ timestamp: Date.now(),
200
+ });
201
+ }
202
+
203
+ return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
204
+ },
205
+
206
+ handleInsert: async (items) => {
207
+ const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
208
+
209
+ await queueTransaction("handleInsert", async () => {
210
+ if (driverMode === "sync") {
211
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
212
+ for (const itemToInsert of items) {
213
+ if (config.debug) {
214
+ console.log(
215
+ `[${new Date().toISOString()}] insertListener inserting`,
216
+ itemToInsert,
217
+ );
218
+ }
219
+ const result = tx
220
+ .insert(table)
221
+ .values(
222
+ itemToInsert as unknown as SQLiteInsertValue<typeof table>,
223
+ )
224
+ .onConflictDoUpdate({
225
+ target: table.id,
226
+ set: sqliteExcludedUpsertSet(table),
227
+ })
228
+ .returning()
229
+ .all() as Array<InferSchemaOutput<SelectSchema<TTable>>>;
230
+ if (config.debug) {
231
+ console.log(
232
+ `[${new Date().toISOString()}] insertListener result`,
233
+ result,
234
+ );
235
+ }
236
+ if (result.length > 0) {
237
+ results.push(result[0]);
238
+ }
239
+ }
240
+ });
241
+ } else {
242
+ await config.drizzle.transaction(
243
+ async (tx: typeof config.drizzle) => {
244
+ for (const itemToInsert of items) {
245
+ if (config.debug) {
246
+ console.log(
247
+ `[${new Date().toISOString()}] insertListener inserting`,
248
+ itemToInsert,
249
+ );
250
+ }
251
+ const result = (await tx
252
+ .insert(table)
253
+ .values(
254
+ itemToInsert as unknown as SQLiteInsertValue<typeof table>,
255
+ )
256
+ .onConflictDoUpdate({
257
+ target: table.id,
258
+ set: sqliteExcludedUpsertSet(table),
259
+ })
260
+ .returning()) as Array<
261
+ InferSchemaOutput<SelectSchema<TTable>>
262
+ >;
263
+ if (config.debug) {
264
+ console.log(
265
+ `[${new Date().toISOString()}] insertListener result`,
266
+ result,
267
+ );
268
+ }
269
+ if (result.length > 0) {
270
+ results.push(result[0]);
271
+ }
272
+ }
273
+ },
274
+ );
275
+ }
276
+
277
+ if (config.checkpoint) {
278
+ await config.checkpoint();
279
+ }
280
+ });
281
+
282
+ return results;
283
+ },
284
+
285
+ handleUpdate: async (mutations) => {
286
+ const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
287
+
288
+ await queueTransaction("handleUpdate", async () => {
289
+ if (driverMode === "sync") {
290
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
291
+ for (const mutation of mutations) {
292
+ if (config.debug) {
293
+ console.log(
294
+ `[${new Date().toISOString()}] updateListener updating`,
295
+ mutation,
296
+ );
297
+ }
298
+ const updateTime = new Date();
299
+ const result = tx
300
+ .update(table)
301
+ .set({
302
+ ...mutation.changes,
303
+ updatedAt: updateTime,
304
+ } as SQLiteUpdateSetSource<typeof table>)
305
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
306
+ .where(eq(table.id, mutation.key as any))
307
+ .returning()
308
+ .all() as Array<InferSchemaOutput<SelectSchema<TTable>>>;
309
+ if (config.debug) {
310
+ console.log(
311
+ `[${new Date().toISOString()}] updateListener result`,
312
+ result,
313
+ );
314
+ }
315
+ results.push(...result);
316
+ }
317
+ });
318
+ } else {
319
+ await config.drizzle.transaction(
320
+ async (tx: typeof config.drizzle) => {
321
+ for (const mutation of mutations) {
322
+ if (config.debug) {
323
+ console.log(
324
+ `[${new Date().toISOString()}] updateListener updating`,
325
+ mutation,
326
+ );
327
+ }
328
+ const updateTime = new Date();
329
+ const result = (await tx
330
+ .update(table)
331
+ .set({
332
+ ...mutation.changes,
333
+ updatedAt: updateTime,
334
+ } as SQLiteUpdateSetSource<typeof table>)
335
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
336
+ .where(eq(table.id, mutation.key as any))
337
+ .returning()) as Array<
338
+ InferSchemaOutput<SelectSchema<TTable>>
339
+ >;
340
+ if (config.debug) {
341
+ console.log(
342
+ `[${new Date().toISOString()}] updateListener result`,
343
+ result,
344
+ );
345
+ }
346
+ results.push(...result);
347
+ }
348
+ },
349
+ );
350
+ }
351
+
352
+ if (config.checkpoint) {
353
+ await config.checkpoint();
354
+ }
355
+ });
356
+
357
+ return results;
358
+ },
359
+
360
+ handleDelete: async (mutations) => {
361
+ await queueTransaction("handleDelete", async () => {
362
+ if (driverMode === "sync") {
363
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
364
+ for (const mutation of mutations) {
365
+ tx.delete(table)
366
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
367
+ .where(eq(table.id, mutation.key as any))
368
+ .run();
369
+ }
370
+ });
371
+ } else {
372
+ await config.drizzle.transaction(
373
+ async (tx: typeof config.drizzle) => {
374
+ for (const mutation of mutations) {
375
+ await tx
376
+ .delete(table)
377
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
378
+ .where(eq(table.id, mutation.key as any));
379
+ }
380
+ },
381
+ );
382
+ }
383
+
384
+ if (config.checkpoint) {
385
+ await config.checkpoint();
386
+ }
387
+ });
388
+ },
389
+ handleTruncate: async () => {
390
+ await queueTransaction("handleTruncate", async () => {
391
+ if (driverMode === "sync") {
392
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
393
+ tx.delete(table).run();
394
+ });
395
+ } else {
396
+ await config.drizzle.transaction(
397
+ async (tx: typeof config.drizzle) => {
398
+ await tx.delete(table);
399
+ },
400
+ );
401
+ }
402
+
403
+ if (config.checkpoint) {
404
+ await config.checkpoint();
405
+ }
406
+ });
407
+ },
408
+
409
+ applyReceiveSyncDurableWrites: async (
410
+ ops: ReceiveSyncDurableOp<TItem>[],
411
+ ) => {
412
+ if (ops.length === 0) return;
413
+
414
+ await queueTransaction("applyReceiveSyncDurableWrites", async () => {
415
+ if (driverMode === "sync") {
416
+ config.drizzle.transaction((tx: typeof config.drizzle) => {
417
+ for (const op of ops) {
418
+ switch (op.type) {
419
+ case "insert": {
420
+ if (config.debug) {
421
+ console.log(
422
+ `[${new Date().toISOString()}] receiveSync batch insert`,
423
+ op.value,
424
+ );
425
+ }
426
+ tx.insert(table)
427
+ .values(
428
+ op.value as unknown as SQLiteInsertValue<typeof table>,
429
+ )
430
+ .onConflictDoUpdate({
431
+ target: table.id,
432
+ set: sqliteExcludedUpsertSet(table),
433
+ })
434
+ .run();
435
+ break;
436
+ }
437
+ case "update": {
438
+ if (config.debug) {
439
+ console.log(
440
+ `[${new Date().toISOString()}] receiveSync batch update`,
441
+ op,
442
+ );
443
+ }
444
+ const updateTime = new Date();
445
+ tx.update(table)
446
+ .set({
447
+ ...op.changes,
448
+ updatedAt: updateTime,
449
+ } as SQLiteUpdateSetSource<typeof table>)
450
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
451
+ .where(eq(table.id, op.key as any))
452
+ .run();
453
+ break;
454
+ }
455
+ case "delete":
456
+ tx.delete(table)
457
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
458
+ .where(eq(table.id, op.key as any))
459
+ .run();
460
+ break;
461
+ case "truncate":
462
+ tx.delete(table).run();
463
+ break;
464
+ default:
465
+ exhaustiveGuard(op);
466
+ }
467
+ }
468
+ });
469
+ } else {
470
+ await config.drizzle.transaction(
471
+ async (tx: typeof config.drizzle) => {
472
+ for (const op of ops) {
473
+ switch (op.type) {
474
+ case "insert": {
475
+ if (config.debug) {
476
+ console.log(
477
+ `[${new Date().toISOString()}] receiveSync batch insert`,
478
+ op.value,
479
+ );
480
+ }
481
+ await tx
482
+ .insert(table)
483
+ .values(
484
+ op.value as unknown as SQLiteInsertValue<typeof table>,
485
+ )
486
+ .onConflictDoUpdate({
487
+ target: table.id,
488
+ set: sqliteExcludedUpsertSet(table),
489
+ });
490
+ break;
491
+ }
492
+ case "update": {
493
+ if (config.debug) {
494
+ console.log(
495
+ `[${new Date().toISOString()}] receiveSync batch update`,
496
+ op,
497
+ );
498
+ }
499
+ const updateTime = new Date();
500
+ await tx
501
+ .update(table)
502
+ .set({
503
+ ...op.changes,
504
+ updatedAt: updateTime,
505
+ } as SQLiteUpdateSetSource<typeof table>)
506
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
507
+ .where(eq(table.id, op.key as any));
508
+ break;
509
+ }
510
+ case "delete":
511
+ await tx
512
+ .delete(table)
513
+ // biome-ignore lint/suspicious/noExplicitAny: branded id key
514
+ .where(eq(table.id, op.key as any));
515
+ break;
516
+ case "truncate":
517
+ await tx.delete(table);
518
+ break;
519
+ default:
520
+ exhaustiveGuard(op);
521
+ }
522
+ }
523
+ },
524
+ );
525
+ }
526
+
527
+ if (config.checkpoint) {
528
+ await config.checkpoint();
529
+ }
530
+ });
531
+ },
532
+ };
533
+
534
+ return backend;
535
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Operation tracking for SQLite-backed TanStack sync backends.
3
+ * Uses discriminated unions so TypeScript can narrow on `type`.
4
+ */
5
+ export type SQLOperation =
6
+ | {
7
+ type: "select-all";
8
+ tableName: string;
9
+ itemsReturned: unknown[];
10
+ itemCount: number;
11
+ context: string;
12
+ sql?: string;
13
+ timestamp: number;
14
+ }
15
+ | {
16
+ type: "select-where";
17
+ tableName: string;
18
+ whereClause: string;
19
+ itemsReturned: unknown[];
20
+ itemCount: number;
21
+ context: string;
22
+ sql?: string;
23
+ timestamp: number;
24
+ }
25
+ | {
26
+ type: "write";
27
+ tableName: string;
28
+ itemsWritten: unknown[];
29
+ writeCount: number;
30
+ context: string;
31
+ timestamp: number;
32
+ }
33
+ | {
34
+ type: "insert";
35
+ tableName: string;
36
+ item: unknown;
37
+ sql?: string;
38
+ timestamp: number;
39
+ }
40
+ | {
41
+ type: "update";
42
+ tableName: string;
43
+ updates: unknown;
44
+ sql?: string;
45
+ timestamp: number;
46
+ }
47
+ | {
48
+ type: "delete";
49
+ tableName: string;
50
+ sql?: string;
51
+ timestamp: number;
52
+ }
53
+ | {
54
+ type: "raw-query";
55
+ sql: string;
56
+ params?: unknown[];
57
+ method: string;
58
+ rowCount: number;
59
+ context: string;
60
+ timestamp: number;
61
+ };
62
+
63
+ export interface SQLInterceptor {
64
+ onOperation?: (operation: SQLOperation) => void;
65
+ }
@@ -75,14 +75,12 @@ export const syncableTable = <
75
75
  for (const columnName in tableColumns) {
76
76
  const column = tableColumns[columnName];
77
77
 
78
- let defaultValue: unknown | undefined;
78
+ // Avoid executing defaultFn at module initialization time.
79
+ // In Cloudflare Workers this can trigger disallowed global-scope APIs.
79
80
  if (column.defaultFn) {
80
- defaultValue = column.defaultFn();
81
- } else if (column.default !== undefined) {
82
- defaultValue = column.default;
81
+ continue;
83
82
  }
84
-
85
- if (defaultValue instanceof SQL) {
83
+ if (column.default instanceof SQL) {
86
84
  throw new Error(
87
85
  `Default value for column ${tableName}.${columnName} is a SQL expression, which is not supported for IndexedDB.\n\nYou can use a default value or a default function instead.`,
88
86
  );