@firtoz/drizzle-indexeddb 1.0.0 → 2.0.1

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-indexeddb
2
2
 
3
+ ## 2.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`bca3758`](https://github.com/firtoz/fullstack-toolkit/commit/bca3758ab5ad2661b950360dc35edda2680c3b4e) Thanks [@firtoz](https://github.com/firtoz)! - Bump minimum `valibot` peer dependency from `>=1.0.0` to `>=1.3.1`.
8
+
9
+ - Updated dependencies [[`bca3758`](https://github.com/firtoz/fullstack-toolkit/commit/bca3758ab5ad2661b950360dc35edda2680c3b4e), [`bca3758`](https://github.com/firtoz/fullstack-toolkit/commit/bca3758ab5ad2661b950360dc35edda2680c3b4e)]:
10
+ - @firtoz/idb-collections@0.2.1
11
+ - @firtoz/drizzle-utils@1.0.2
12
+
13
+ ## 2.0.0
14
+
15
+ ### Major Changes
16
+
17
+ - [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3) Thanks [@firtoz](https://github.com/firtoz)! - BREAKING: Renamed Drizzle-specific exports with `Drizzle` prefix for clarity. `indexedDBCollectionOptions` → `drizzleIndexedDBCollectionOptions`, `IndexedDBCollectionConfig` → `DrizzleIndexedDBCollectionConfig`, `IndexedDBSyncItem` → `DrizzleIndexedDBSyncItem`. Removed `KeyRangeSpec` re-export (import from `@firtoz/idb-collections` instead). `tryExtractIndexedQuery` moved to `@firtoz/idb-collections`.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [[`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3), [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3), [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3), [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3)]:
22
+ - @firtoz/idb-collections@0.2.0
23
+ - @firtoz/db-helpers@2.0.0
24
+ - @firtoz/drizzle-utils@1.0.1
25
+
3
26
  ## 1.0.0
4
27
 
5
28
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-indexeddb",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "IndexedDB migrations powered by Drizzle ORM",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -68,24 +68,25 @@
68
68
  "access": "public"
69
69
  },
70
70
  "peerDependencies": {
71
- "@firtoz/drizzle-utils": ">=1.0.0",
72
- "@tanstack/db": ">=0.5.26",
71
+ "@firtoz/drizzle-utils": ">=1.0.2",
72
+ "@tanstack/db": ">=0.5.33",
73
73
  "drizzle-orm": ">=0.45.1",
74
74
  "drizzle-valibot": ">=0.4.0",
75
75
  "react": ">=19.2.4",
76
- "valibot": ">=1.0.0"
76
+ "valibot": ">=1.3.1"
77
77
  },
78
78
  "devDependencies": {
79
- "@firtoz/drizzle-utils": "^1.0.0",
80
- "@tanstack/db": "^0.5.26",
79
+ "@firtoz/drizzle-utils": "^1.0.2",
80
+ "@tanstack/db": "^0.5.33",
81
81
  "@types/react": "^19.2.14",
82
82
  "drizzle-orm": "^0.45.1",
83
83
  "drizzle-valibot": "^0.4.2",
84
84
  "react": "^19.2.4",
85
- "valibot": "^1.2.0"
85
+ "valibot": "^1.3.1"
86
86
  },
87
87
  "dependencies": {
88
- "@firtoz/db-helpers": "^1.0.0",
88
+ "@firtoz/db-helpers": "^2.0.0",
89
+ "@firtoz/idb-collections": "^0.2.1",
89
90
  "@firtoz/maybe-error": "^1.5.2",
90
91
  "citty": "^0.2.1"
91
92
  }
@@ -0,0 +1,310 @@
1
+ import type { InferSchemaOutput, SyncMode } from "@tanstack/db";
2
+ import type { IR } from "@tanstack/db";
3
+ import { parseOrderByExpression } from "@tanstack/db";
4
+ import type { Table } from "drizzle-orm";
5
+
6
+ import {
7
+ type IdOf,
8
+ type SelectSchema,
9
+ type BaseSyncConfig,
10
+ type SyncBackend,
11
+ createSyncFunction,
12
+ createInsertSchemaWithDefaults,
13
+ createGetKeyFunction,
14
+ createCollectionConfig,
15
+ } from "@firtoz/drizzle-utils";
16
+ import { evaluateExpression } from "@firtoz/db-helpers";
17
+ import { tryExtractIndexedQuery } from "@firtoz/idb-collections";
18
+
19
+ import type { IDBDatabaseLike } from "../idb-types";
20
+
21
+ // biome-ignore lint/suspicious/noExplicitAny: intentional
22
+ type AnyId = IdOf<any>;
23
+
24
+ /**
25
+ * Type for items stored in IndexedDB (must have required sync fields)
26
+ */
27
+ export type DrizzleIndexedDBSyncItem = {
28
+ id: AnyId;
29
+ createdAt: Date;
30
+ updatedAt: Date;
31
+ deletedAt: Date | null;
32
+ [key: string]: unknown;
33
+ };
34
+
35
+ export interface DrizzleIndexedDBCollectionConfig<TTable extends Table> {
36
+ /**
37
+ * Ref to the IndexedDB database instance
38
+ */
39
+ indexedDBRef: React.RefObject<IDBDatabaseLike | null>;
40
+ /**
41
+ * The Drizzle table definition (used for schema and type inference only)
42
+ */
43
+ table: TTable;
44
+ /**
45
+ * The name of the IndexedDB object store (should match the table name)
46
+ */
47
+ storeName: string;
48
+ /**
49
+ * Promise that resolves when the database is ready
50
+ */
51
+ readyPromise: Promise<void>;
52
+ /**
53
+ * Sync mode: 'eager' (immediate) or 'on-demand'
54
+ */
55
+ syncMode?: SyncMode;
56
+ /**
57
+ * Enable debug logging for index discovery and query optimization
58
+ */
59
+ debug?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Auto-discovers indexes from the IndexedDB store.
64
+ * Returns a map of field names to index names for single-column indexes.
65
+ */
66
+ function discoverIndexes(
67
+ db: IDBDatabaseLike,
68
+ storeName: string,
69
+ ): Record<string, string> {
70
+ const indexes = db.getStoreIndexes(storeName);
71
+ const indexMap: Record<string, string> = {};
72
+
73
+ for (const index of indexes) {
74
+ if (typeof index.keyPath === "string") {
75
+ indexMap[index.keyPath] = index.name;
76
+ }
77
+ }
78
+
79
+ return indexMap;
80
+ }
81
+
82
+ /**
83
+ * Creates a TanStack DB collection config for IndexedDB backed by Drizzle ORM.
84
+ */
85
+ export function drizzleIndexedDBCollectionOptions<const TTable extends Table>(
86
+ config: DrizzleIndexedDBCollectionConfig<TTable>,
87
+ ) {
88
+ let discoveredIndexes: Record<string, string> = {};
89
+ let indexesDiscovered = false;
90
+
91
+ const table = config.table;
92
+
93
+ const discoverIndexesOnce = async () => {
94
+ await config.readyPromise;
95
+
96
+ const db = config.indexedDBRef.current;
97
+ if (!db) {
98
+ throw new Error("Database not ready");
99
+ }
100
+
101
+ if (!indexesDiscovered) {
102
+ discoveredIndexes = discoverIndexes(db, config.storeName);
103
+
104
+ indexesDiscovered = true;
105
+ }
106
+ };
107
+
108
+ const backend: SyncBackend<TTable> = {
109
+ initialLoad: async () => {
110
+ const db = config.indexedDBRef.current;
111
+ if (!db) {
112
+ throw new Error("Database not ready");
113
+ }
114
+
115
+ await discoverIndexesOnce();
116
+
117
+ const items = await db.getAll<DrizzleIndexedDBSyncItem>(config.storeName);
118
+
119
+ return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
120
+ },
121
+ loadSubset: async (options) => {
122
+ const db = config.indexedDBRef.current;
123
+ if (!db) {
124
+ throw new Error("Database not ready");
125
+ }
126
+
127
+ if (!indexesDiscovered) {
128
+ discoveredIndexes = discoverIndexes(db, config.storeName);
129
+ indexesDiscovered = true;
130
+ }
131
+
132
+ let items: DrizzleIndexedDBSyncItem[];
133
+
134
+ let combinedWhere = options.where;
135
+ if (options.cursor?.whereFrom) {
136
+ if (combinedWhere) {
137
+ combinedWhere = {
138
+ type: "func",
139
+ name: "and",
140
+ args: [combinedWhere, options.cursor.whereFrom],
141
+ } as IR.Func;
142
+ } else {
143
+ combinedWhere = options.cursor.whereFrom;
144
+ }
145
+ }
146
+
147
+ const indexedQuery = combinedWhere
148
+ ? tryExtractIndexedQuery(combinedWhere, discoveredIndexes, config.debug)
149
+ : null;
150
+
151
+ if (indexedQuery) {
152
+ items = await db.getAllByIndex<DrizzleIndexedDBSyncItem>(
153
+ config.storeName,
154
+ indexedQuery.indexName,
155
+ indexedQuery.keyRange,
156
+ );
157
+ } else {
158
+ items = await db.getAll<DrizzleIndexedDBSyncItem>(config.storeName);
159
+
160
+ if (combinedWhere) {
161
+ const whereExpression = combinedWhere;
162
+ items = items.filter((item) =>
163
+ evaluateExpression(
164
+ whereExpression,
165
+ item as Record<string, unknown>,
166
+ ),
167
+ );
168
+ }
169
+ }
170
+
171
+ if (options.orderBy) {
172
+ const sorts = parseOrderByExpression(options.orderBy);
173
+ items.sort((a, b) => {
174
+ for (const sort of sorts) {
175
+ // biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
176
+ let aValue: any = a;
177
+ // biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
178
+ let bValue: any = b;
179
+ for (const fieldName of sort.field) {
180
+ aValue = aValue?.[fieldName];
181
+ bValue = bValue?.[fieldName];
182
+ }
183
+
184
+ if (aValue < bValue) {
185
+ return sort.direction === "asc" ? -1 : 1;
186
+ }
187
+ if (aValue > bValue) {
188
+ return sort.direction === "asc" ? 1 : -1;
189
+ }
190
+ }
191
+ return 0;
192
+ });
193
+ }
194
+
195
+ if (options.offset !== undefined && options.offset > 0) {
196
+ items = items.slice(options.offset);
197
+ }
198
+
199
+ if (options.limit !== undefined) {
200
+ items = items.slice(0, options.limit);
201
+ }
202
+
203
+ return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
204
+ },
205
+
206
+ handleInsert: async (itemsToInsert) => {
207
+ const db = config.indexedDBRef.current;
208
+ if (!db) {
209
+ throw new Error("Database not ready");
210
+ }
211
+
212
+ await db.add(config.storeName, itemsToInsert);
213
+
214
+ return itemsToInsert;
215
+ },
216
+
217
+ handleUpdate: async (mutations) => {
218
+ const db = config.indexedDBRef.current;
219
+
220
+ if (!db) {
221
+ throw new Error("Database not ready");
222
+ }
223
+
224
+ const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
225
+ const itemsToUpdate: DrizzleIndexedDBSyncItem[] = [];
226
+
227
+ for (const mutation of mutations) {
228
+ const existing = await db.get<DrizzleIndexedDBSyncItem>(
229
+ config.storeName,
230
+ mutation.key,
231
+ );
232
+
233
+ if (existing) {
234
+ const updateTime = new Date();
235
+ const updatedItem = {
236
+ ...existing,
237
+ ...mutation.changes,
238
+ updatedAt: updateTime,
239
+ } as DrizzleIndexedDBSyncItem;
240
+
241
+ itemsToUpdate.push(updatedItem);
242
+ results.push(
243
+ updatedItem as unknown as InferSchemaOutput<SelectSchema<TTable>>,
244
+ );
245
+ } else {
246
+ results.push(mutation.original);
247
+ }
248
+ }
249
+
250
+ if (itemsToUpdate.length > 0) {
251
+ await db.put(config.storeName, itemsToUpdate);
252
+ }
253
+
254
+ return results;
255
+ },
256
+
257
+ handleDelete: async (mutations) => {
258
+ const db = config.indexedDBRef.current;
259
+
260
+ if (!db) {
261
+ throw new Error("Database not ready");
262
+ }
263
+
264
+ const keysToDelete: IDBValidKey[] = mutations.map((m) => m.key);
265
+
266
+ await db.delete(config.storeName, keysToDelete);
267
+ },
268
+
269
+ handleTruncate: async () => {
270
+ const db = config.indexedDBRef.current;
271
+
272
+ if (!db) {
273
+ throw new Error("Database not ready");
274
+ }
275
+
276
+ await db.clear(config.storeName);
277
+ },
278
+ };
279
+
280
+ const wrappedBackend: SyncBackend<TTable> = {
281
+ ...backend,
282
+ initialLoad: async () => {
283
+ if (config.syncMode === "eager" || !config.syncMode) {
284
+ return await backend.initialLoad();
285
+ }
286
+
287
+ await discoverIndexesOnce();
288
+
289
+ return [];
290
+ },
291
+ };
292
+
293
+ const baseSyncConfig: BaseSyncConfig<TTable> = {
294
+ table,
295
+ readyPromise: config.readyPromise,
296
+ syncMode: config.syncMode,
297
+ debug: config.debug,
298
+ };
299
+
300
+ const syncResult = createSyncFunction(baseSyncConfig, wrappedBackend);
301
+
302
+ const schema = createInsertSchemaWithDefaults(table);
303
+
304
+ return createCollectionConfig({
305
+ schema,
306
+ getKey: createGetKeyFunction<TTable>(),
307
+ syncResult,
308
+ syncMode: config.syncMode,
309
+ });
310
+ }
@@ -16,8 +16,8 @@ import {
16
16
  } from "@tanstack/db";
17
17
  import { getTableName, type Table } from "drizzle-orm";
18
18
  import {
19
- indexedDBCollectionOptions,
20
- type IndexedDBCollectionConfig,
19
+ drizzleIndexedDBCollectionOptions,
20
+ type DrizzleIndexedDBCollectionConfig,
21
21
  } from "@firtoz/drizzle-indexeddb";
22
22
  import type { CollectionUtils } from "@firtoz/db-helpers";
23
23
  import type {
@@ -173,14 +173,14 @@ export function DrizzleIndexedDBProvider<
173
173
  const actualTableName = getTableName(table);
174
174
 
175
175
  // Create collection options
176
- const collectionConfig = indexedDBCollectionOptions({
176
+ const collectionConfig = drizzleIndexedDBCollectionOptions({
177
177
  indexedDBRef,
178
178
  table,
179
179
  storeName: actualTableName,
180
180
  readyPromise: readyPromise.promise,
181
181
  debug,
182
182
  syncMode,
183
- } as IndexedDBCollectionConfig<Table>);
183
+ } as DrizzleIndexedDBCollectionConfig<Table>);
184
184
 
185
185
  // Create new collection and cache it with ref count 0
186
186
  // The collection will wait for readyPromise before accessing the database
package/src/idb-types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { KeyRangeSpec } from "@firtoz/idb-collections";
2
+
1
3
  /**
2
4
  * Index information returned by getStoreIndexes
3
5
  */
@@ -21,18 +23,6 @@ export interface CreateIndexOptions {
21
23
  unique?: boolean;
22
24
  }
23
25
 
24
- /**
25
- * Key range specification for index queries
26
- */
27
- export interface KeyRangeSpec {
28
- type: "only" | "lowerBound" | "upperBound" | "bound";
29
- value?: unknown;
30
- lower?: unknown;
31
- upper?: unknown;
32
- lowerOpen?: boolean;
33
- upperOpen?: boolean;
34
- }
35
-
36
26
  /**
37
27
  * Minimal database interface with high-level async operations.
38
28
  * This is the interface that custom implementations (mocks, Chrome extension proxies, etc.) need to implement.
package/src/index.ts CHANGED
@@ -17,7 +17,6 @@ export type {
17
17
  IndexInfo,
18
18
  CreateStoreOptions,
19
19
  CreateIndexOptions,
20
- KeyRangeSpec,
21
20
  } from "./idb-types";
22
21
 
23
22
  // IDB Interceptor (for testing/debugging)
@@ -34,10 +33,10 @@ export { createInstrumentedDbCreator } from "./instrumented-idb-database";
34
33
 
35
34
  // Collection
36
35
  export {
37
- indexedDBCollectionOptions,
38
- type IndexedDBCollectionConfig,
39
- type IndexedDBSyncItem,
40
- } from "./collections/indexeddb-collection";
36
+ drizzleIndexedDBCollectionOptions,
37
+ type DrizzleIndexedDBCollectionConfig,
38
+ type DrizzleIndexedDBSyncItem,
39
+ } from "./collections/drizzle-indexeddb-collection";
41
40
 
42
41
  // Standalone Collection (for use outside React)
43
42
  export {
@@ -1,3 +1,4 @@
1
+ import type { KeyRangeSpec } from "@firtoz/idb-collections";
1
2
  import type {
2
3
  IDBDatabaseLike,
3
4
  IDBCreator,
@@ -5,7 +6,6 @@ import type {
5
6
  IndexInfo,
6
7
  CreateStoreOptions,
7
8
  CreateIndexOptions,
8
- KeyRangeSpec,
9
9
  } from "./idb-types";
10
10
  import type { IDBInterceptor } from "./idb-interceptor";
11
11
  import { defaultIDBCreator } from "./native-idb-database";
@@ -1,4 +1,5 @@
1
1
  import { exhaustiveGuard } from "@firtoz/maybe-error";
2
+ import type { KeyRangeSpec } from "@firtoz/idb-collections";
2
3
  import type {
3
4
  IDBDatabaseLike,
4
5
  IDBCreator,
@@ -6,7 +7,6 @@ import type {
6
7
  IndexInfo,
7
8
  CreateStoreOptions,
8
9
  CreateIndexOptions,
9
- KeyRangeSpec,
10
10
  } from "./idb-types";
11
11
 
12
12
  /**
@@ -11,9 +11,9 @@ import type { Table } from "drizzle-orm";
11
11
  import type { CollectionUtils } from "@firtoz/db-helpers";
12
12
  import type { IdOf, InsertSchema, SelectSchema } from "@firtoz/drizzle-utils";
13
13
  import {
14
- indexedDBCollectionOptions,
15
- type IndexedDBCollectionConfig,
16
- } from "./collections/indexeddb-collection";
14
+ drizzleIndexedDBCollectionOptions,
15
+ type DrizzleIndexedDBCollectionConfig,
16
+ } from "./collections/drizzle-indexeddb-collection";
17
17
  import {
18
18
  migrateIndexedDBWithFunctions,
19
19
  type Migration,
@@ -262,14 +262,14 @@ export function createStandaloneCollection<TTable extends Table>(
262
262
  initDB();
263
263
 
264
264
  // Create collection config
265
- const collectionConfig = indexedDBCollectionOptions({
265
+ const collectionConfig = drizzleIndexedDBCollectionOptions({
266
266
  indexedDBRef,
267
267
  table,
268
268
  storeName,
269
269
  readyPromise,
270
270
  debug,
271
271
  syncMode,
272
- } as IndexedDBCollectionConfig<TTable>);
272
+ } as DrizzleIndexedDBCollectionConfig<TTable>);
273
273
 
274
274
  // Create the collection
275
275
  const collection = createCollection(
@@ -1,590 +0,0 @@
1
- import type { InferSchemaOutput, SyncMode } from "@tanstack/db";
2
- import type { IR } from "@tanstack/db";
3
- import { extractSimpleComparisons, parseOrderByExpression } from "@tanstack/db";
4
- import type { Table } from "drizzle-orm";
5
-
6
- import {
7
- type IdOf,
8
- type SelectSchema,
9
- type BaseSyncConfig,
10
- type SyncBackend,
11
- createSyncFunction,
12
- createInsertSchemaWithDefaults,
13
- createGetKeyFunction,
14
- createCollectionConfig,
15
- } from "@firtoz/drizzle-utils";
16
-
17
- import type { IDBDatabaseLike, KeyRangeSpec } from "../idb-types";
18
-
19
- // biome-ignore lint/suspicious/noExplicitAny: intentional
20
- type AnyId = IdOf<any>;
21
-
22
- /**
23
- * Type for items stored in IndexedDB (must have required sync fields)
24
- */
25
- export type IndexedDBSyncItem = {
26
- id: AnyId;
27
- createdAt: Date;
28
- updatedAt: Date;
29
- deletedAt: Date | null;
30
- [key: string]: unknown;
31
- };
32
-
33
- export interface IndexedDBCollectionConfig<TTable extends Table> {
34
- /**
35
- * Ref to the IndexedDB database instance
36
- */
37
- indexedDBRef: React.RefObject<IDBDatabaseLike | null>;
38
- /**
39
- * The Drizzle table definition (used for schema and type inference only)
40
- */
41
- table: TTable;
42
- /**
43
- * The name of the IndexedDB object store (should match the table name)
44
- */
45
- storeName: string;
46
- /**
47
- * Promise that resolves when the database is ready
48
- */
49
- readyPromise: Promise<void>;
50
- /**
51
- * Sync mode: 'eager' (immediate) or 'lazy' (on-demand)
52
- */
53
- syncMode?: SyncMode;
54
- /**
55
- * Enable debug logging for index discovery and query optimization
56
- */
57
- debug?: boolean;
58
- }
59
-
60
- /**
61
- * Evaluates a TanStack DB IR expression against an IndexedDB item
62
- * @internal Exported for testing
63
- */
64
- export function evaluateExpression(
65
- expression: IR.BasicExpression,
66
- item: Record<string, unknown>,
67
- ): boolean {
68
- switch (expression.type) {
69
- case "ref": {
70
- const propRef = expression;
71
- const columnName = propRef.path[propRef.path.length - 1];
72
- return item[columnName as string] !== undefined;
73
- }
74
- case "val": {
75
- const value = expression;
76
- return !!value.value;
77
- }
78
- case "func": {
79
- const func = expression;
80
-
81
- switch (func.name) {
82
- case "eq": {
83
- const left = getExpressionValue(func.args[0], item);
84
- const right = getExpressionValue(func.args[1], item);
85
- return left === right;
86
- }
87
- case "ne": {
88
- const left = getExpressionValue(func.args[0], item);
89
- const right = getExpressionValue(func.args[1], item);
90
- return left !== right;
91
- }
92
- case "gt": {
93
- const left = getExpressionValue(func.args[0], item);
94
- const right = getExpressionValue(func.args[1], item);
95
- return left > right;
96
- }
97
- case "gte": {
98
- const left = getExpressionValue(func.args[0], item);
99
- const right = getExpressionValue(func.args[1], item);
100
- return left >= right;
101
- }
102
- case "lt": {
103
- const left = getExpressionValue(func.args[0], item);
104
- const right = getExpressionValue(func.args[1], item);
105
- return left < right;
106
- }
107
- case "lte": {
108
- const left = getExpressionValue(func.args[0], item);
109
- const right = getExpressionValue(func.args[1], item);
110
- return left <= right;
111
- }
112
- case "and": {
113
- return func.args.every((arg) => evaluateExpression(arg, item));
114
- }
115
- case "or": {
116
- return func.args.some((arg) => evaluateExpression(arg, item));
117
- }
118
- case "not": {
119
- return !evaluateExpression(func.args[0], item);
120
- }
121
- case "isNull": {
122
- const value = getExpressionValue(func.args[0], item);
123
- return value === null || value === undefined;
124
- }
125
- case "isNotNull": {
126
- const value = getExpressionValue(func.args[0], item);
127
- return value !== null && value !== undefined;
128
- }
129
- case "like": {
130
- const left = String(getExpressionValue(func.args[0], item));
131
- const right = String(getExpressionValue(func.args[1], item));
132
- // Convert SQL LIKE pattern to regex (case-sensitive)
133
- const pattern = right.replace(/%/g, ".*").replace(/_/g, ".");
134
- return new RegExp(`^${pattern}$`).test(left);
135
- }
136
- case "ilike": {
137
- const left = String(getExpressionValue(func.args[0], item));
138
- const right = String(getExpressionValue(func.args[1], item));
139
- // Convert SQL ILIKE pattern to regex (case-insensitive)
140
- const pattern = right.replace(/%/g, ".*").replace(/_/g, ".");
141
- return new RegExp(`^${pattern}$`, "i").test(left);
142
- }
143
- case "in": {
144
- const left = getExpressionValue(func.args[0], item);
145
- const right = getExpressionValue(func.args[1], item);
146
- // Check if left value is in the right array
147
- return Array.isArray(right) && right.includes(left);
148
- }
149
- case "isUndefined": {
150
- const value = getExpressionValue(func.args[0], item);
151
- return value === null || value === undefined;
152
- }
153
- default:
154
- throw new Error(`Unsupported function: ${func.name}`);
155
- }
156
- }
157
- default: {
158
- const _ex: never = expression;
159
- void _ex;
160
- throw new Error(
161
- `Unsupported expression type: ${(expression as { type: string }).type}`,
162
- );
163
- }
164
- }
165
- }
166
-
167
- /**
168
- * Gets the value from an IR expression
169
- * @internal Exported for testing
170
- */
171
- export function getExpressionValue(
172
- expression: IR.BasicExpression,
173
- item: Record<string, unknown>,
174
- // biome-ignore lint/suspicious/noExplicitAny: We need any here for dynamic values
175
- ): any {
176
- switch (expression.type) {
177
- case "ref": {
178
- const propRef = expression;
179
- const columnName = propRef.path[propRef.path.length - 1];
180
- return item[columnName as string];
181
- }
182
- case "val": {
183
- const value = expression;
184
- return value.value;
185
- }
186
- case "func":
187
- throw new Error("Cannot get value from func expression");
188
- default: {
189
- const _ex: never = expression;
190
- void _ex;
191
- throw new Error(
192
- `Cannot get value from expression type: ${(expression as { type: string }).type}`,
193
- );
194
- }
195
- }
196
- }
197
-
198
- /**
199
- * Attempts to extract a simple indexed query from an IR expression
200
- * Returns the field name and key range if the query can be optimized
201
- *
202
- * NOTE: IndexedDB indexes are much more limited than SQL WHERE clauses:
203
- * - Only supports simple comparisons on a SINGLE indexed field
204
- * - Supported operators: eq, gt, gte, lt, lte
205
- * - Complex queries (AND, OR, NOT, multiple fields) fall back to in-memory filtering
206
- *
207
- * Indexes are auto-discovered from your Drizzle schema:
208
- * - Define indexes using index().on() in your schema
209
- * - Run migrations to create them in IndexedDB
210
- * - This collection automatically detects and uses them
211
- * @internal Exported for testing
212
- */
213
- export function tryExtractIndexedQuery(
214
- expression: IR.BasicExpression,
215
- indexes?: Record<string, string>,
216
- debug?: boolean,
217
- ): { fieldName: string; indexName: string; keyRange: KeyRangeSpec } | null {
218
- if (!indexes) {
219
- return null;
220
- }
221
-
222
- try {
223
- // Use TanStack DB helper to extract simple comparisons
224
- const comparisons = extractSimpleComparisons(expression);
225
-
226
- // We can only use an index for a single field
227
- if (comparisons.length !== 1) {
228
- return null;
229
- }
230
-
231
- const comparison = comparisons[0];
232
- const fieldName = comparison.field.join(".");
233
- const indexName = indexes[fieldName];
234
-
235
- if (!indexName) {
236
- return null;
237
- }
238
-
239
- // Convert operator to key range spec
240
- let keyRange: KeyRangeSpec | null = null;
241
-
242
- switch (comparison.operator) {
243
- case "eq":
244
- keyRange = { type: "only", value: comparison.value };
245
- break;
246
- case "gt":
247
- keyRange = {
248
- type: "lowerBound",
249
- lower: comparison.value,
250
- lowerOpen: true,
251
- };
252
- break;
253
- case "gte":
254
- keyRange = {
255
- type: "lowerBound",
256
- lower: comparison.value,
257
- lowerOpen: false,
258
- };
259
- break;
260
- case "lt":
261
- keyRange = {
262
- type: "upperBound",
263
- upper: comparison.value,
264
- upperOpen: true,
265
- };
266
- break;
267
- case "lte":
268
- keyRange = {
269
- type: "upperBound",
270
- upper: comparison.value,
271
- upperOpen: false,
272
- };
273
- break;
274
- default:
275
- if (debug) {
276
- console.warn(
277
- `Skipping indexed query extraction for unsupported operator: ${comparison.operator}`,
278
- );
279
- }
280
- return null;
281
- }
282
-
283
- if (!keyRange) {
284
- return null;
285
- }
286
-
287
- return { fieldName, indexName, keyRange };
288
- } catch (error) {
289
- console.error("Error extracting indexed query", error, expression);
290
- // If extractSimpleComparisons fails, it's a complex query
291
-
292
- return null;
293
- }
294
- }
295
-
296
- // Note: Low-level transaction helpers have been replaced by high-level IDBDatabaseLike methods
297
-
298
- /**
299
- * Auto-discovers indexes from the IndexedDB store
300
- * Returns a map of field names to index names for single-column indexes
301
- *
302
- * NOTE: Indexes are created automatically by Drizzle migrations based on your schema:
303
- *
304
- * @example
305
- * // In your schema.ts:
306
- * export const todoTable = syncableTable(
307
- * "todo",
308
- * { title: text("title"), userId: text("userId") },
309
- * (t) => [
310
- * index("todo_user_id_index").on(t.userId),
311
- * index("todo_created_at_index").on(t.createdAt),
312
- * ]
313
- * );
314
- *
315
- * // The migrator will automatically create these indexes in IndexedDB
316
- * // This collection will auto-detect and use them for optimized queries
317
- */
318
- function discoverIndexes(
319
- db: IDBDatabaseLike,
320
- storeName: string,
321
- ): Record<string, string> {
322
- const indexes = db.getStoreIndexes(storeName);
323
- const indexMap: Record<string, string> = {};
324
-
325
- for (const index of indexes) {
326
- // Only map single-column indexes (string keyPath)
327
- // Compound indexes (array keyPath) are more complex and not currently optimized
328
- if (typeof index.keyPath === "string") {
329
- indexMap[index.keyPath] = index.name;
330
- }
331
- }
332
-
333
- return indexMap;
334
- }
335
-
336
- /**
337
- * Creates a TanStack DB collection config for IndexedDB
338
- */
339
- export function indexedDBCollectionOptions<const TTable extends Table>(
340
- config: IndexedDBCollectionConfig<TTable>,
341
- ) {
342
- // Defer index discovery until the database is ready
343
- let discoveredIndexes: Record<string, string> = {};
344
- let indexesDiscovered = false;
345
-
346
- const table = config.table;
347
-
348
- // Discover indexes once when the database is ready
349
- const discoverIndexesOnce = async () => {
350
- await config.readyPromise;
351
-
352
- const db = config.indexedDBRef.current;
353
- if (!db) {
354
- throw new Error("Database not ready");
355
- }
356
-
357
- if (!indexesDiscovered) {
358
- discoveredIndexes = discoverIndexes(db, config.storeName);
359
-
360
- indexesDiscovered = true;
361
- }
362
- };
363
-
364
- // Create backend-specific implementation
365
- const backend: SyncBackend<TTable> = {
366
- initialLoad: async () => {
367
- const db = config.indexedDBRef.current;
368
- if (!db) {
369
- throw new Error("Database not ready");
370
- }
371
-
372
- await discoverIndexesOnce();
373
-
374
- const items = await db.getAll<IndexedDBSyncItem>(config.storeName);
375
-
376
- return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
377
- },
378
- loadSubset: async (options) => {
379
- const db = config.indexedDBRef.current;
380
- if (!db) {
381
- throw new Error("Database not ready");
382
- }
383
-
384
- // Ensure indexes are discovered before we try to use them
385
- if (!indexesDiscovered) {
386
- discoveredIndexes = discoverIndexes(db, config.storeName);
387
- indexesDiscovered = true;
388
- }
389
-
390
- let items: IndexedDBSyncItem[];
391
-
392
- // Combine where with cursor expressions if present
393
- // The cursor.whereFrom gives us rows after the cursor position
394
- let combinedWhere = options.where;
395
- if (options.cursor?.whereFrom) {
396
- if (combinedWhere) {
397
- // Combine main where with cursor expression using AND
398
- combinedWhere = {
399
- type: "func",
400
- name: "and",
401
- args: [combinedWhere, options.cursor.whereFrom],
402
- } as IR.Func;
403
- } else {
404
- combinedWhere = options.cursor.whereFrom;
405
- }
406
- }
407
-
408
- // Try to use an index for efficient querying
409
- const indexedQuery = combinedWhere
410
- ? tryExtractIndexedQuery(combinedWhere, discoveredIndexes, config.debug)
411
- : null;
412
-
413
- if (indexedQuery) {
414
- // Use indexed query for better performance
415
- // Index returns exact results for single-field queries, no additional filtering needed
416
- items = await db.getAllByIndex<IndexedDBSyncItem>(
417
- config.storeName,
418
- indexedQuery.indexName,
419
- indexedQuery.keyRange,
420
- );
421
- } else {
422
- // Fall back to getting all items
423
- items = await db.getAll<IndexedDBSyncItem>(config.storeName);
424
-
425
- // Apply combined where filter in memory
426
- if (combinedWhere) {
427
- const whereExpression = combinedWhere;
428
- items = items.filter((item) =>
429
- evaluateExpression(
430
- whereExpression,
431
- item as Record<string, unknown>,
432
- ),
433
- );
434
- }
435
- }
436
-
437
- // Apply orderBy
438
- if (options.orderBy) {
439
- const sorts = parseOrderByExpression(options.orderBy);
440
- items.sort((a, b) => {
441
- for (const sort of sorts) {
442
- // Access nested field (though typically will be single level)
443
- // biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
444
- let aValue: any = a;
445
- // biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
446
- let bValue: any = b;
447
- for (const fieldName of sort.field) {
448
- aValue = aValue?.[fieldName];
449
- bValue = bValue?.[fieldName];
450
- }
451
-
452
- if (aValue < bValue) {
453
- return sort.direction === "asc" ? -1 : 1;
454
- }
455
- if (aValue > bValue) {
456
- return sort.direction === "asc" ? 1 : -1;
457
- }
458
- }
459
- return 0;
460
- });
461
- }
462
-
463
- // Apply offset (skip first N items for pagination)
464
- if (options.offset !== undefined && options.offset > 0) {
465
- items = items.slice(options.offset);
466
- }
467
-
468
- // Apply limit
469
- if (options.limit !== undefined) {
470
- items = items.slice(0, options.limit);
471
- }
472
-
473
- return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
474
- },
475
-
476
- handleInsert: async (itemsToInsert) => {
477
- const db = config.indexedDBRef.current;
478
- if (!db) {
479
- throw new Error("Database not ready");
480
- }
481
-
482
- // Add all items in a single batch operation
483
- await db.add(config.storeName, itemsToInsert);
484
-
485
- return itemsToInsert;
486
- },
487
-
488
- handleUpdate: async (mutations) => {
489
- const db = config.indexedDBRef.current;
490
-
491
- if (!db) {
492
- throw new Error("Database not ready");
493
- }
494
-
495
- const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
496
- const itemsToUpdate: IndexedDBSyncItem[] = [];
497
-
498
- for (const mutation of mutations) {
499
- const existing = await db.get<IndexedDBSyncItem>(
500
- config.storeName,
501
- mutation.key,
502
- );
503
-
504
- if (existing) {
505
- const updateTime = new Date();
506
- const updatedItem = {
507
- ...existing,
508
- ...mutation.changes,
509
- updatedAt: updateTime,
510
- } as IndexedDBSyncItem;
511
-
512
- itemsToUpdate.push(updatedItem);
513
- results.push(
514
- updatedItem as unknown as InferSchemaOutput<SelectSchema<TTable>>,
515
- );
516
- } else {
517
- // If item doesn't exist, push original to maintain order
518
- results.push(mutation.original);
519
- }
520
- }
521
-
522
- // Update all items in a single batch operation
523
- if (itemsToUpdate.length > 0) {
524
- await db.put(config.storeName, itemsToUpdate);
525
- }
526
-
527
- return results;
528
- },
529
-
530
- handleDelete: async (mutations) => {
531
- const db = config.indexedDBRef.current;
532
-
533
- if (!db) {
534
- throw new Error("Database not ready");
535
- }
536
-
537
- const keysToDelete: IDBValidKey[] = mutations.map((m) => m.key);
538
-
539
- // Delete all items in a single batch operation
540
- await db.delete(config.storeName, keysToDelete);
541
- },
542
-
543
- handleTruncate: async () => {
544
- const db = config.indexedDBRef.current;
545
-
546
- if (!db) {
547
- throw new Error("Database not ready");
548
- }
549
-
550
- // Clear all items from the store
551
- await db.clear(config.storeName);
552
- },
553
- };
554
-
555
- // For non-eager sync modes, still discover indexes before marking ready
556
- const wrappedBackend: SyncBackend<TTable> = {
557
- ...backend,
558
- initialLoad: async () => {
559
- if (config.syncMode === "eager" || !config.syncMode) {
560
- return await backend.initialLoad();
561
- }
562
-
563
- // For non-eager sync modes, still discover indexes but don't load data
564
- await discoverIndexesOnce();
565
-
566
- return [];
567
- },
568
- };
569
-
570
- // Create sync function using shared utilities
571
- const baseSyncConfig: BaseSyncConfig<TTable> = {
572
- table,
573
- readyPromise: config.readyPromise,
574
- syncMode: config.syncMode,
575
- debug: config.debug,
576
- };
577
-
578
- const syncResult = createSyncFunction(baseSyncConfig, wrappedBackend);
579
-
580
- // Create insert schema with all defaults (IndexedDB needs them upfront)
581
- const schema = createInsertSchemaWithDefaults(table);
582
-
583
- // Create collection config using shared utilities
584
- return createCollectionConfig({
585
- schema,
586
- getKey: createGetKeyFunction<TTable>(),
587
- syncResult,
588
- syncMode: config.syncMode,
589
- });
590
- }