@firtoz/drizzle-indexeddb 0.2.0 → 0.4.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.
@@ -14,6 +14,8 @@ import {
14
14
  createCollectionConfig,
15
15
  } from "@firtoz/drizzle-utils";
16
16
 
17
+ import type { IDBDatabaseLike, KeyRangeSpec } from "../idb-types";
18
+
17
19
  // biome-ignore lint/suspicious/noExplicitAny: intentional
18
20
  type AnyId = IdOf<any>;
19
21
 
@@ -28,78 +30,11 @@ export type IndexedDBSyncItem = {
28
30
  [key: string]: unknown;
29
31
  };
30
32
 
31
- /**
32
- * Operation tracking for IndexedDB queries
33
- * Useful for testing and debugging to verify what operations are actually performed
34
- *
35
- * Uses discriminated unions for type safety - TypeScript can narrow the type based on the 'type' field
36
- */
37
- export type IDBOperation =
38
- | {
39
- type: "getAll";
40
- storeName: string;
41
- itemsReturned: unknown[];
42
- itemCount: number;
43
- context: string;
44
- timestamp: number;
45
- }
46
- | {
47
- type: "index-getAll";
48
- storeName: string;
49
- indexName: string;
50
- keyRange?: IDBKeyRange;
51
- itemsReturned: unknown[];
52
- itemCount: number;
53
- context: string;
54
- timestamp: number;
55
- }
56
- | {
57
- type: "write";
58
- storeName: string;
59
- itemsWritten: unknown[];
60
- writeCount: number;
61
- context: string;
62
- timestamp: number;
63
- }
64
- | {
65
- type: "get";
66
- storeName: string;
67
- key: IDBValidKey;
68
- itemReturned?: unknown;
69
- timestamp: number;
70
- }
71
- | {
72
- type: "put";
73
- storeName: string;
74
- item: unknown;
75
- timestamp: number;
76
- }
77
- | {
78
- type: "delete";
79
- storeName: string;
80
- key: IDBValidKey;
81
- timestamp: number;
82
- }
83
- | {
84
- type: "clear";
85
- storeName: string;
86
- timestamp: number;
87
- };
88
-
89
- /**
90
- * Interceptor interface for tracking IndexedDB operations
91
- * Allows tests and debugging tools to observe what operations are performed
92
- */
93
- export interface IDBInterceptor {
94
- /** Called when any IndexedDB operation is performed */
95
- onOperation?: (operation: IDBOperation) => void;
96
- }
97
-
98
33
  export interface IndexedDBCollectionConfig<TTable extends Table> {
99
34
  /**
100
35
  * Ref to the IndexedDB database instance
101
36
  */
102
- indexedDBRef: React.RefObject<IDBDatabase | null>;
37
+ indexedDBRef: React.RefObject<IDBDatabaseLike | null>;
103
38
  /**
104
39
  * The database name (for perf markers)
105
40
  */
@@ -124,10 +59,6 @@ export interface IndexedDBCollectionConfig<TTable extends Table> {
124
59
  * Enable debug logging for index discovery and query optimization
125
60
  */
126
61
  debug?: boolean;
127
- /**
128
- * Optional interceptor for tracking IndexedDB operations (for testing/debugging)
129
- */
130
- interceptor?: IDBInterceptor;
131
62
  }
132
63
 
133
64
  /**
@@ -257,99 +188,6 @@ export function getExpressionValue(
257
188
  throw new Error(`Cannot get value from expression type: ${expression.type}`);
258
189
  }
259
190
 
260
- /**
261
- * Reads all items from an IndexedDB object store
262
- */
263
- function getAllFromStore(
264
- db: IDBDatabase,
265
- storeName: string,
266
- interceptor?: IDBInterceptor,
267
- ): Promise<IndexedDBSyncItem[]> {
268
- return new Promise((resolve, reject) => {
269
- if (!db.objectStoreNames.contains(storeName)) {
270
- resolve([]);
271
- return;
272
- }
273
-
274
- const transaction = db.transaction(storeName, "readonly");
275
-
276
- const store = transaction.objectStore(storeName);
277
-
278
- const request = store.getAll();
279
-
280
- request.onsuccess = () => {
281
- const items = request.result as IndexedDBSyncItem[];
282
-
283
- // Log operation after executing with results
284
- if (interceptor?.onOperation) {
285
- interceptor.onOperation({
286
- type: "getAll",
287
- storeName,
288
- itemsReturned: items,
289
- itemCount: items.length,
290
- context: "Full table scan",
291
- timestamp: Date.now(),
292
- });
293
- }
294
-
295
- resolve(items);
296
- };
297
-
298
- request.onerror = () => {
299
- reject(request.error);
300
- };
301
- });
302
- }
303
-
304
- /**
305
- * Reads items from an IndexedDB index with an optional key range
306
- * Note: Index existence is validated at collection creation time
307
- */
308
- function getAllFromIndex(
309
- db: IDBDatabase,
310
- storeName: string,
311
- indexName: string,
312
- keyRange?: IDBKeyRange,
313
- interceptor?: IDBInterceptor,
314
- ): Promise<IndexedDBSyncItem[]> {
315
- return new Promise((resolve, reject) => {
316
- const transaction = db.transaction(storeName, "readonly");
317
-
318
- const store = transaction.objectStore(storeName);
319
-
320
- const index = store.index(indexName);
321
-
322
- const request = keyRange ? index.getAll(keyRange) : index.getAll();
323
-
324
- request.onsuccess = () => {
325
- const items = request.result as IndexedDBSyncItem[];
326
-
327
- // Log operation after executing with results
328
- if (interceptor?.onOperation) {
329
- const rangeDesc = keyRange
330
- ? `[${keyRange.lower ?? ""}..${keyRange.upper ?? ""}]`
331
- : "all";
332
- interceptor.onOperation({
333
- type: "index-getAll",
334
- storeName,
335
- indexName,
336
- keyRange,
337
- itemsReturned: items,
338
- itemCount: items.length,
339
- context: `Index query on ${indexName}, range: ${rangeDesc}`,
340
- timestamp: Date.now(),
341
- });
342
- }
343
-
344
- resolve(items);
345
- };
346
-
347
- request.onerror = () => {
348
- reject(request.error);
349
- };
350
- });
351
- }
352
-
353
191
  /**
354
192
  * Attempts to extract a simple indexed query from an IR expression
355
193
  * Returns the field name and key range if the query can be optimized
@@ -369,7 +207,7 @@ export function tryExtractIndexedQuery(
369
207
  expression: IR.BasicExpression,
370
208
  indexes?: Record<string, string>,
371
209
  debug?: boolean,
372
- ): { fieldName: string; indexName: string; keyRange: IDBKeyRange } | null {
210
+ ): { fieldName: string; indexName: string; keyRange: KeyRangeSpec } | null {
373
211
  if (!indexes) {
374
212
  return null;
375
213
  }
@@ -391,25 +229,40 @@ export function tryExtractIndexedQuery(
391
229
  return null;
392
230
  }
393
231
 
394
- // Convert operator to IndexedDB key range
395
-
396
- let keyRange: IDBKeyRange | null = null;
232
+ // Convert operator to key range spec
233
+ let keyRange: KeyRangeSpec | null = null;
397
234
 
398
235
  switch (comparison.operator) {
399
236
  case "eq":
400
- keyRange = IDBKeyRange.only(comparison.value);
237
+ keyRange = { type: "only", value: comparison.value };
401
238
  break;
402
239
  case "gt":
403
- keyRange = IDBKeyRange.lowerBound(comparison.value, true);
240
+ keyRange = {
241
+ type: "lowerBound",
242
+ lower: comparison.value,
243
+ lowerOpen: true,
244
+ };
404
245
  break;
405
246
  case "gte":
406
- keyRange = IDBKeyRange.lowerBound(comparison.value, false);
247
+ keyRange = {
248
+ type: "lowerBound",
249
+ lower: comparison.value,
250
+ lowerOpen: false,
251
+ };
407
252
  break;
408
253
  case "lt":
409
- keyRange = IDBKeyRange.upperBound(comparison.value, true);
254
+ keyRange = {
255
+ type: "upperBound",
256
+ upper: comparison.value,
257
+ upperOpen: true,
258
+ };
410
259
  break;
411
260
  case "lte":
412
- keyRange = IDBKeyRange.upperBound(comparison.value, false);
261
+ keyRange = {
262
+ type: "upperBound",
263
+ upper: comparison.value,
264
+ upperOpen: false,
265
+ };
413
266
  break;
414
267
  default:
415
268
  if (debug) {
@@ -433,104 +286,7 @@ export function tryExtractIndexedQuery(
433
286
  }
434
287
  }
435
288
 
436
- /**
437
- * Adds an item to an IndexedDB object store using an existing transaction
438
- */
439
- function addToStoreInTransaction(
440
- store: IDBObjectStore,
441
- item: IndexedDBSyncItem,
442
- ): Promise<void> {
443
- return new Promise((resolve, reject) => {
444
- const request = store.add(item);
445
-
446
- request.onsuccess = () => {
447
- resolve();
448
- };
449
-
450
- request.onerror = () => {
451
- reject(request.error);
452
- };
453
- });
454
- }
455
-
456
- /**
457
- * Updates an item in an IndexedDB object store using an existing transaction
458
- */
459
- function updateInStoreInTransaction(
460
- store: IDBObjectStore,
461
- item: IndexedDBSyncItem,
462
- ): Promise<void> {
463
- return new Promise((resolve, reject) => {
464
- const request = store.put(item);
465
-
466
- request.onsuccess = () => {
467
- resolve();
468
- };
469
-
470
- request.onerror = () => {
471
- reject(request.error);
472
- };
473
- });
474
- }
475
-
476
- /**
477
- * Deletes an item from an IndexedDB object store using an existing transaction
478
- */
479
- function deleteFromStoreInTransaction(
480
- store: IDBObjectStore,
481
- id: string,
482
- ): Promise<void> {
483
- return new Promise((resolve, reject) => {
484
- const request = store.delete(id);
485
-
486
- request.onsuccess = () => {
487
- resolve();
488
- };
489
-
490
- request.onerror = () => {
491
- reject(request.error);
492
- };
493
- });
494
- }
495
-
496
- /**
497
- * Gets a single item from an IndexedDB object store by ID using an existing transaction
498
- */
499
- function getFromStoreInTransaction(
500
- store: IDBObjectStore,
501
- id: AnyId,
502
- ): Promise<IndexedDBSyncItem | undefined> {
503
- return new Promise((resolve, reject) => {
504
- const request = store.get(id);
505
-
506
- request.onsuccess = () => {
507
- resolve(request.result as IndexedDBSyncItem | undefined);
508
- };
509
-
510
- request.onerror = () => {
511
- reject(request.error);
512
- };
513
- });
514
- }
515
-
516
- /**
517
- * Executes a transaction and returns a promise that resolves when the transaction completes
518
- */
519
- function commitTransaction(transaction: IDBTransaction): Promise<void> {
520
- return new Promise((resolve, reject) => {
521
- transaction.oncomplete = () => {
522
- resolve();
523
- };
524
-
525
- transaction.onerror = () => {
526
- reject(transaction.error);
527
- };
528
-
529
- transaction.onabort = () => {
530
- reject(new Error("Transaction aborted"));
531
- };
532
- });
533
- }
289
+ // Note: Low-level transaction helpers have been replaced by high-level IDBDatabaseLike methods
534
290
 
535
291
  /**
536
292
  * Auto-discovers indexes from the IndexedDB store
@@ -553,30 +309,17 @@ function commitTransaction(transaction: IDBTransaction): Promise<void> {
553
309
  * // This collection will auto-detect and use them for optimized queries
554
310
  */
555
311
  function discoverIndexes(
556
- db: IDBDatabase,
312
+ db: IDBDatabaseLike,
557
313
  storeName: string,
558
314
  ): Record<string, string> {
559
- if (!db.objectStoreNames.contains(storeName)) {
560
- return {};
561
- }
562
-
563
- const transaction = db.transaction(storeName, "readonly");
564
-
565
- const store = transaction.objectStore(storeName);
566
-
315
+ const indexes = db.getStoreIndexes(storeName);
567
316
  const indexMap: Record<string, string> = {};
568
317
 
569
- // Iterate through all indexes in the store
570
- const indexNames = Array.from(store.indexNames);
571
-
572
- for (const indexName of indexNames) {
573
- const index = store.index(indexName);
574
- const keyPath = index.keyPath;
575
-
318
+ for (const index of indexes) {
576
319
  // Only map single-column indexes (string keyPath)
577
320
  // Compound indexes (array keyPath) are more complex and not currently optimized
578
- if (typeof keyPath === "string") {
579
- indexMap[keyPath] = indexName;
321
+ if (typeof index.keyPath === "string") {
322
+ indexMap[index.keyPath] = index.name;
580
323
  }
581
324
  }
582
325
 
@@ -599,12 +342,13 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
599
342
  const discoverIndexesOnce = async () => {
600
343
  await config.readyPromise;
601
344
 
345
+ const db = config.indexedDBRef.current;
346
+ if (!db) {
347
+ throw new Error("Database not ready");
348
+ }
349
+
602
350
  if (!indexesDiscovered) {
603
- discoveredIndexes = discoverIndexes(
604
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
605
- config.indexedDBRef.current!,
606
- config.storeName,
607
- );
351
+ discoveredIndexes = discoverIndexes(db, config.storeName);
608
352
 
609
353
  indexesDiscovered = true;
610
354
  }
@@ -613,26 +357,14 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
613
357
  // Create backend-specific implementation
614
358
  const backend: SyncBackend<TTable> = {
615
359
  initialLoad: async (write) => {
360
+ const db = config.indexedDBRef.current;
361
+ if (!db) {
362
+ throw new Error("Database not ready");
363
+ }
364
+
616
365
  await discoverIndexesOnce();
617
366
 
618
- const items = await getAllFromStore(
619
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
620
- config.indexedDBRef.current!,
621
- config.storeName,
622
- config.interceptor,
623
- );
624
-
625
- // Log what's being written to the collection
626
- if (config.interceptor?.onOperation) {
627
- config.interceptor.onOperation({
628
- type: "write",
629
- storeName: config.storeName,
630
- itemsWritten: items,
631
- writeCount: items.length,
632
- context: "Initial load (eager mode)",
633
- timestamp: Date.now(),
634
- });
635
- }
367
+ const items = await db.getAll<IndexedDBSyncItem>(config.storeName);
636
368
 
637
369
  for (const item of items) {
638
370
  write(item as unknown as InferSchemaOutput<SelectSchema<TTable>>);
@@ -640,13 +372,14 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
640
372
  },
641
373
 
642
374
  loadSubset: async (options, write) => {
375
+ const db = config.indexedDBRef.current;
376
+ if (!db) {
377
+ throw new Error("Database not ready");
378
+ }
379
+
643
380
  // Ensure indexes are discovered before we try to use them
644
381
  if (!indexesDiscovered) {
645
- discoveredIndexes = discoverIndexes(
646
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
647
- config.indexedDBRef.current!,
648
- config.storeName,
649
- );
382
+ discoveredIndexes = discoverIndexes(db, config.storeName);
650
383
  indexesDiscovered = true;
651
384
  }
652
385
 
@@ -659,22 +392,15 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
659
392
 
660
393
  if (indexedQuery) {
661
394
  // Use indexed query for better performance
662
- items = await getAllFromIndex(
663
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
664
- config.indexedDBRef.current!,
395
+
396
+ items = await db.getAllByIndex<IndexedDBSyncItem>(
665
397
  config.storeName,
666
398
  indexedQuery.indexName,
667
399
  indexedQuery.keyRange,
668
- config.interceptor,
669
400
  );
670
401
  } else {
671
402
  // Fall back to getting all items
672
- items = await getAllFromStore(
673
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
674
- config.indexedDBRef.current!,
675
- config.storeName,
676
- config.interceptor,
677
- );
403
+ items = await db.getAll<IndexedDBSyncItem>(config.storeName);
678
404
 
679
405
  // Apply where filter in memory
680
406
  if (options.where) {
@@ -719,65 +445,30 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
719
445
  items = items.slice(0, options.limit);
720
446
  }
721
447
 
722
- // Log what's being written to the collection
723
- if (config.interceptor?.onOperation) {
724
- const contextParts: string[] = ["On-demand load"];
725
- if (indexedQuery) {
726
- contextParts.push(`via index ${indexedQuery.indexName}`);
727
- } else if (options.where) {
728
- contextParts.push("via full scan + in-memory filter");
729
- }
730
- if (options.orderBy) {
731
- contextParts.push("with sorting");
732
- }
733
- if (options.limit !== undefined) {
734
- contextParts.push(`limit ${options.limit}`);
735
- }
736
-
737
- config.interceptor.onOperation({
738
- type: "write",
739
- storeName: config.storeName,
740
- itemsWritten: items,
741
- writeCount: items.length,
742
- context: contextParts.join(", "),
743
- timestamp: Date.now(),
744
- });
745
- }
746
-
747
448
  for (const item of items) {
748
449
  write(item as unknown as InferSchemaOutput<SelectSchema<TTable>>);
749
450
  }
750
451
  },
751
452
 
752
453
  handleInsert: async (mutations) => {
454
+ const db = config.indexedDBRef.current;
455
+ if (!db) {
456
+ throw new Error("Database not ready");
457
+ }
458
+
753
459
  const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
754
460
 
755
461
  try {
756
- // Use a single transaction for all inserts
757
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
758
- const transaction = config.indexedDBRef.current!.transaction(
759
- config.storeName,
760
- "readwrite",
761
- );
762
- const store = transaction.objectStore(config.storeName);
763
-
764
- const addPromises: Promise<void>[] = [];
462
+ const itemsToInsert: IndexedDBSyncItem[] = [];
765
463
 
766
464
  for (const mutation of mutations) {
767
465
  const itemToInsert = mutation.modified;
768
-
769
- // Add result for reactive store
770
466
  results.push(itemToInsert);
771
-
772
- // Add to IndexedDB in parallel (don't await yet)
773
- addPromises.push(
774
- addToStoreInTransaction(store, itemToInsert as IndexedDBSyncItem),
775
- );
467
+ itemsToInsert.push(itemToInsert as IndexedDBSyncItem);
776
468
  }
777
469
 
778
- // Wait for all IndexedDB writes to complete
779
- await Promise.all(addPromises);
780
- await commitTransaction(transaction);
470
+ // Add all items in a single batch operation
471
+ await db.add(config.storeName, itemsToInsert);
781
472
  } catch (error) {
782
473
  // Clear results on error so nothing gets written to reactive store
783
474
  results.length = 0;
@@ -788,20 +479,20 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
788
479
  },
789
480
 
790
481
  handleUpdate: async (mutations) => {
482
+ const db = config.indexedDBRef.current;
483
+
484
+ if (!db) {
485
+ throw new Error("Database not ready");
486
+ }
487
+
791
488
  const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
792
- const originalValues: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
793
- // Use a single transaction for all updates
794
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
795
- const transaction = config.indexedDBRef.current!.transaction(
796
- config.storeName,
797
- "readwrite",
798
- );
799
- const store = transaction.objectStore(config.storeName);
489
+ const itemsToUpdate: IndexedDBSyncItem[] = [];
800
490
 
801
491
  for (const mutation of mutations) {
802
- originalValues.push(mutation.original);
803
-
804
- const existing = await getFromStoreInTransaction(store, mutation.key);
492
+ const existing = await db.get<IndexedDBSyncItem>(
493
+ config.storeName,
494
+ mutation.key,
495
+ );
805
496
 
806
497
  if (existing) {
807
498
  const updateTime = new Date();
@@ -811,7 +502,7 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
811
502
  updatedAt: updateTime,
812
503
  } as IndexedDBSyncItem;
813
504
 
814
- await updateInStoreInTransaction(store, updatedItem);
505
+ itemsToUpdate.push(updatedItem);
815
506
  results.push(
816
507
  updatedItem as unknown as InferSchemaOutput<SelectSchema<TTable>>,
817
508
  );
@@ -821,27 +512,36 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
821
512
  }
822
513
  }
823
514
 
824
- // Wait for transaction to complete
825
- await commitTransaction(transaction);
515
+ // Update all items in a single batch operation
516
+ if (itemsToUpdate.length > 0) {
517
+ await db.put(config.storeName, itemsToUpdate);
518
+ }
826
519
 
827
520
  return results;
828
521
  },
829
522
 
830
523
  handleDelete: async (mutations) => {
831
- // Use a single transaction for all deletes
832
- // biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
833
- const transaction = config.indexedDBRef.current!.transaction(
834
- config.storeName,
835
- "readwrite",
836
- );
837
- const store = transaction.objectStore(config.storeName);
524
+ const db = config.indexedDBRef.current;
838
525
 
839
- for (const mutation of mutations) {
840
- await deleteFromStoreInTransaction(store, mutation.key);
526
+ if (!db) {
527
+ throw new Error("Database not ready");
528
+ }
529
+
530
+ const keysToDelete: IDBValidKey[] = mutations.map((m) => m.key);
531
+
532
+ // Delete all items in a single batch operation
533
+ await db.delete(config.storeName, keysToDelete);
534
+ },
535
+
536
+ handleTruncate: async () => {
537
+ const db = config.indexedDBRef.current;
538
+
539
+ if (!db) {
540
+ throw new Error("Database not ready");
841
541
  }
842
542
 
843
- // Wait for transaction to complete
844
- await commitTransaction(transaction);
543
+ // Clear all items from the store
544
+ await db.clear(config.storeName);
845
545
  },
846
546
  };
847
547
 
@@ -4,6 +4,7 @@ import {
4
4
  useIndexedDBCollection,
5
5
  type DrizzleIndexedDBContextValue,
6
6
  } from "./DrizzleIndexedDBProvider";
7
+ import type { IDBDatabaseLike } from "../idb-types";
7
8
 
8
9
  export type UseDrizzleIndexedDBContextReturn<
9
10
  TSchema extends Record<string, unknown>,
@@ -11,7 +12,7 @@ export type UseDrizzleIndexedDBContextReturn<
11
12
  useCollection: <TTableName extends keyof TSchema & string>(
12
13
  tableName: TTableName,
13
14
  ) => ReturnType<typeof useIndexedDBCollection<TSchema, TTableName>>;
14
- indexedDB: IDBDatabase | null;
15
+ indexedDB: IDBDatabaseLike | null;
15
16
  };
16
17
 
17
18
  export function useDrizzleIndexedDB<