@firtoz/drizzle-indexeddb 0.2.0 → 0.3.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 +32 -26
- package/README.md +112 -51
- package/package.json +13 -5
- package/src/bin/generate-migrations.ts +288 -0
- package/src/collections/indexeddb-collection.ts +95 -243
- package/src/context/useDrizzleIndexedDB.ts +2 -1
- package/src/function-migrator.ts +190 -170
- package/src/index.ts +16 -7
- package/src/utils.ts +517 -7
- package/src/snapshot-migrator.ts +0 -420
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
createCollectionConfig,
|
|
15
15
|
} from "@firtoz/drizzle-utils";
|
|
16
16
|
|
|
17
|
+
import type { IDBDatabaseLike, KeyRangeSpec } from "../utils";
|
|
18
|
+
|
|
17
19
|
// biome-ignore lint/suspicious/noExplicitAny: intentional
|
|
18
20
|
type AnyId = IdOf<any>;
|
|
19
21
|
|
|
@@ -99,7 +101,7 @@ export interface IndexedDBCollectionConfig<TTable extends Table> {
|
|
|
99
101
|
/**
|
|
100
102
|
* Ref to the IndexedDB database instance
|
|
101
103
|
*/
|
|
102
|
-
indexedDBRef: React.RefObject<
|
|
104
|
+
indexedDBRef: React.RefObject<IDBDatabaseLike | null>;
|
|
103
105
|
/**
|
|
104
106
|
* The database name (for perf markers)
|
|
105
107
|
*/
|
|
@@ -260,94 +262,63 @@ export function getExpressionValue(
|
|
|
260
262
|
/**
|
|
261
263
|
* Reads all items from an IndexedDB object store
|
|
262
264
|
*/
|
|
263
|
-
function getAllFromStore(
|
|
264
|
-
db:
|
|
265
|
+
async function getAllFromStore(
|
|
266
|
+
db: IDBDatabaseLike,
|
|
265
267
|
storeName: string,
|
|
266
268
|
interceptor?: IDBInterceptor,
|
|
267
269
|
): Promise<IndexedDBSyncItem[]> {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
};
|
|
270
|
+
const items = await db.getAll<IndexedDBSyncItem>(storeName);
|
|
271
|
+
|
|
272
|
+
// Log operation after executing with results
|
|
273
|
+
if (interceptor?.onOperation) {
|
|
274
|
+
interceptor.onOperation({
|
|
275
|
+
type: "getAll",
|
|
276
|
+
storeName,
|
|
277
|
+
itemsReturned: items,
|
|
278
|
+
itemCount: items.length,
|
|
279
|
+
context: "Full table scan",
|
|
280
|
+
timestamp: Date.now(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
297
283
|
|
|
298
|
-
|
|
299
|
-
reject(request.error);
|
|
300
|
-
};
|
|
301
|
-
});
|
|
284
|
+
return items;
|
|
302
285
|
}
|
|
303
286
|
|
|
304
287
|
/**
|
|
305
288
|
* Reads items from an IndexedDB index with an optional key range
|
|
306
289
|
* Note: Index existence is validated at collection creation time
|
|
307
290
|
*/
|
|
308
|
-
function getAllFromIndex(
|
|
309
|
-
db:
|
|
291
|
+
async function getAllFromIndex(
|
|
292
|
+
db: IDBDatabaseLike,
|
|
310
293
|
storeName: string,
|
|
311
294
|
indexName: string,
|
|
312
|
-
keyRange?:
|
|
295
|
+
keyRange?: KeyRangeSpec,
|
|
313
296
|
interceptor?: IDBInterceptor,
|
|
314
297
|
): Promise<IndexedDBSyncItem[]> {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
}
|
|
298
|
+
const items = await db.getAllByIndex<IndexedDBSyncItem>(
|
|
299
|
+
storeName,
|
|
300
|
+
indexName,
|
|
301
|
+
keyRange,
|
|
302
|
+
);
|
|
343
303
|
|
|
344
|
-
|
|
345
|
-
|
|
304
|
+
// Log operation after executing with results
|
|
305
|
+
if (interceptor?.onOperation) {
|
|
306
|
+
const rangeDesc = keyRange
|
|
307
|
+
? `[${keyRange.lower ?? keyRange.value ?? ""}..${keyRange.upper ?? keyRange.value ?? ""}]`
|
|
308
|
+
: "all";
|
|
309
|
+
interceptor.onOperation({
|
|
310
|
+
type: "index-getAll",
|
|
311
|
+
storeName,
|
|
312
|
+
indexName,
|
|
313
|
+
keyRange: keyRange as unknown as IDBKeyRange | undefined,
|
|
314
|
+
itemsReturned: items,
|
|
315
|
+
itemCount: items.length,
|
|
316
|
+
context: `Index query on ${indexName}, range: ${rangeDesc}`,
|
|
317
|
+
timestamp: Date.now(),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
346
320
|
|
|
347
|
-
|
|
348
|
-
reject(request.error);
|
|
349
|
-
};
|
|
350
|
-
});
|
|
321
|
+
return items;
|
|
351
322
|
}
|
|
352
323
|
|
|
353
324
|
/**
|
|
@@ -369,7 +340,7 @@ export function tryExtractIndexedQuery(
|
|
|
369
340
|
expression: IR.BasicExpression,
|
|
370
341
|
indexes?: Record<string, string>,
|
|
371
342
|
debug?: boolean,
|
|
372
|
-
): { fieldName: string; indexName: string; keyRange:
|
|
343
|
+
): { fieldName: string; indexName: string; keyRange: KeyRangeSpec } | null {
|
|
373
344
|
if (!indexes) {
|
|
374
345
|
return null;
|
|
375
346
|
}
|
|
@@ -391,25 +362,40 @@ export function tryExtractIndexedQuery(
|
|
|
391
362
|
return null;
|
|
392
363
|
}
|
|
393
364
|
|
|
394
|
-
// Convert operator to
|
|
395
|
-
|
|
396
|
-
let keyRange: IDBKeyRange | null = null;
|
|
365
|
+
// Convert operator to key range spec
|
|
366
|
+
let keyRange: KeyRangeSpec | null = null;
|
|
397
367
|
|
|
398
368
|
switch (comparison.operator) {
|
|
399
369
|
case "eq":
|
|
400
|
-
keyRange =
|
|
370
|
+
keyRange = { type: "only", value: comparison.value };
|
|
401
371
|
break;
|
|
402
372
|
case "gt":
|
|
403
|
-
keyRange =
|
|
373
|
+
keyRange = {
|
|
374
|
+
type: "lowerBound",
|
|
375
|
+
lower: comparison.value,
|
|
376
|
+
lowerOpen: true,
|
|
377
|
+
};
|
|
404
378
|
break;
|
|
405
379
|
case "gte":
|
|
406
|
-
keyRange =
|
|
380
|
+
keyRange = {
|
|
381
|
+
type: "lowerBound",
|
|
382
|
+
lower: comparison.value,
|
|
383
|
+
lowerOpen: false,
|
|
384
|
+
};
|
|
407
385
|
break;
|
|
408
386
|
case "lt":
|
|
409
|
-
keyRange =
|
|
387
|
+
keyRange = {
|
|
388
|
+
type: "upperBound",
|
|
389
|
+
upper: comparison.value,
|
|
390
|
+
upperOpen: true,
|
|
391
|
+
};
|
|
410
392
|
break;
|
|
411
393
|
case "lte":
|
|
412
|
-
keyRange =
|
|
394
|
+
keyRange = {
|
|
395
|
+
type: "upperBound",
|
|
396
|
+
upper: comparison.value,
|
|
397
|
+
upperOpen: false,
|
|
398
|
+
};
|
|
413
399
|
break;
|
|
414
400
|
default:
|
|
415
401
|
if (debug) {
|
|
@@ -433,104 +419,7 @@ export function tryExtractIndexedQuery(
|
|
|
433
419
|
}
|
|
434
420
|
}
|
|
435
421
|
|
|
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
|
-
}
|
|
422
|
+
// Note: Low-level transaction helpers have been replaced by high-level IDBDatabaseLike methods
|
|
534
423
|
|
|
535
424
|
/**
|
|
536
425
|
* Auto-discovers indexes from the IndexedDB store
|
|
@@ -553,30 +442,17 @@ function commitTransaction(transaction: IDBTransaction): Promise<void> {
|
|
|
553
442
|
* // This collection will auto-detect and use them for optimized queries
|
|
554
443
|
*/
|
|
555
444
|
function discoverIndexes(
|
|
556
|
-
db:
|
|
445
|
+
db: IDBDatabaseLike,
|
|
557
446
|
storeName: string,
|
|
558
447
|
): Record<string, string> {
|
|
559
|
-
|
|
560
|
-
return {};
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const transaction = db.transaction(storeName, "readonly");
|
|
564
|
-
|
|
565
|
-
const store = transaction.objectStore(storeName);
|
|
566
|
-
|
|
448
|
+
const indexes = db.getStoreIndexes(storeName);
|
|
567
449
|
const indexMap: Record<string, string> = {};
|
|
568
450
|
|
|
569
|
-
|
|
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
|
-
|
|
451
|
+
for (const index of indexes) {
|
|
576
452
|
// Only map single-column indexes (string keyPath)
|
|
577
453
|
// Compound indexes (array keyPath) are more complex and not currently optimized
|
|
578
|
-
if (typeof keyPath === "string") {
|
|
579
|
-
indexMap[keyPath] =
|
|
454
|
+
if (typeof index.keyPath === "string") {
|
|
455
|
+
indexMap[index.keyPath] = index.name;
|
|
580
456
|
}
|
|
581
457
|
}
|
|
582
458
|
|
|
@@ -753,31 +629,17 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
|
|
|
753
629
|
const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
|
|
754
630
|
|
|
755
631
|
try {
|
|
756
|
-
|
|
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>[] = [];
|
|
632
|
+
const itemsToInsert: IndexedDBSyncItem[] = [];
|
|
765
633
|
|
|
766
634
|
for (const mutation of mutations) {
|
|
767
635
|
const itemToInsert = mutation.modified;
|
|
768
|
-
|
|
769
|
-
// Add result for reactive store
|
|
770
636
|
results.push(itemToInsert);
|
|
771
|
-
|
|
772
|
-
// Add to IndexedDB in parallel (don't await yet)
|
|
773
|
-
addPromises.push(
|
|
774
|
-
addToStoreInTransaction(store, itemToInsert as IndexedDBSyncItem),
|
|
775
|
-
);
|
|
637
|
+
itemsToInsert.push(itemToInsert as IndexedDBSyncItem);
|
|
776
638
|
}
|
|
777
639
|
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
await
|
|
640
|
+
// Add all items in a single batch operation
|
|
641
|
+
// biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
|
|
642
|
+
await config.indexedDBRef.current!.add(config.storeName, itemsToInsert);
|
|
781
643
|
} catch (error) {
|
|
782
644
|
// Clear results on error so nothing gets written to reactive store
|
|
783
645
|
results.length = 0;
|
|
@@ -789,19 +651,16 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
|
|
|
789
651
|
|
|
790
652
|
handleUpdate: async (mutations) => {
|
|
791
653
|
const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
|
|
792
|
-
const
|
|
793
|
-
|
|
654
|
+
const itemsToUpdate: IndexedDBSyncItem[] = [];
|
|
655
|
+
|
|
794
656
|
// biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
|
|
795
|
-
const
|
|
796
|
-
config.storeName,
|
|
797
|
-
"readwrite",
|
|
798
|
-
);
|
|
799
|
-
const store = transaction.objectStore(config.storeName);
|
|
657
|
+
const db = config.indexedDBRef.current!;
|
|
800
658
|
|
|
801
659
|
for (const mutation of mutations) {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
660
|
+
const existing = await db.get<IndexedDBSyncItem>(
|
|
661
|
+
config.storeName,
|
|
662
|
+
mutation.key,
|
|
663
|
+
);
|
|
805
664
|
|
|
806
665
|
if (existing) {
|
|
807
666
|
const updateTime = new Date();
|
|
@@ -811,7 +670,7 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
|
|
|
811
670
|
updatedAt: updateTime,
|
|
812
671
|
} as IndexedDBSyncItem;
|
|
813
672
|
|
|
814
|
-
|
|
673
|
+
itemsToUpdate.push(updatedItem);
|
|
815
674
|
results.push(
|
|
816
675
|
updatedItem as unknown as InferSchemaOutput<SelectSchema<TTable>>,
|
|
817
676
|
);
|
|
@@ -821,27 +680,20 @@ export function indexedDBCollectionOptions<const TTable extends Table>(
|
|
|
821
680
|
}
|
|
822
681
|
}
|
|
823
682
|
|
|
824
|
-
//
|
|
825
|
-
|
|
683
|
+
// Update all items in a single batch operation
|
|
684
|
+
if (itemsToUpdate.length > 0) {
|
|
685
|
+
await db.put(config.storeName, itemsToUpdate);
|
|
686
|
+
}
|
|
826
687
|
|
|
827
688
|
return results;
|
|
828
689
|
},
|
|
829
690
|
|
|
830
691
|
handleDelete: async (mutations) => {
|
|
831
|
-
|
|
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);
|
|
692
|
+
const keysToDelete: IDBValidKey[] = mutations.map((m) => m.key);
|
|
838
693
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
// Wait for transaction to complete
|
|
844
|
-
await commitTransaction(transaction);
|
|
694
|
+
// Delete all items in a single batch operation
|
|
695
|
+
// biome-ignore lint/style/noNonNullAssertion: DB is guaranteed to be ready after readyPromise resolves
|
|
696
|
+
await config.indexedDBRef.current!.delete(config.storeName, keysToDelete);
|
|
845
697
|
},
|
|
846
698
|
};
|
|
847
699
|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
useIndexedDBCollection,
|
|
5
5
|
type DrizzleIndexedDBContextValue,
|
|
6
6
|
} from "./DrizzleIndexedDBProvider";
|
|
7
|
+
import type { IDBDatabaseLike } from "../utils";
|
|
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:
|
|
15
|
+
indexedDB: IDBDatabaseLike | null;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export function useDrizzleIndexedDB<
|