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