idb-ts 3.15.0 → 3.16.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/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
  <a href="https://github.com/maifeeulasad/idb-ts/watchers">
20
20
  <img src="https://img.shields.io/github/watchers/maifeeulasad/idb-ts" alt="GitHub watchers">
21
21
  </a>
22
- <a href="https://img.shields.io/github/commits-since/maifeeulasad/idb-ts/latest/main?include_prereleases">
22
+ <a href="https://github.com/maifeeulasad/idb-ts/commits/main">
23
23
  <img src="https://img.shields.io/github/commits-since/maifeeulasad/idb-ts/latest/main?include_prereleases" alt="Commits since release">
24
24
  </a>
25
25
  </p>
@@ -150,11 +150,13 @@ Designates the decorated property as the primary key of the object store. Exactl
150
150
 
151
151
  #### `@CompositeKeyPath(fields, options?)`
152
152
 
153
- Class-level decorator for composite primary keys. Cannot be combined with `@KeyPath`.
153
+ Class-level decorator for composite primary keys. Cannot be combined with `@KeyPath`. Write it *below* `@DataClass` (decorators are applied bottom-up, and the key path must be registered before `@DataClass` validates it).
154
+
155
+ Key generation is not supported for composite keys: passing `generator` or `autoIncrement` throws at decoration time. Provide every key field explicitly before `create()`.
154
156
 
155
157
  ```typescript
156
- @CompositeKeyPath(['userId', 'projectId'])
157
158
  @DataClass()
159
+ @CompositeKeyPath(['userId', 'projectId'])
158
160
  class UserProject {
159
161
  userId!: string;
160
162
  projectId!: string;
@@ -174,6 +176,10 @@ Creates an IDB index on the decorated field, enabling efficient lookups via `fin
174
176
 
175
177
  Attaches a validation rule to the decorated property. Rules are enforced on every `create` and `update` call. If any rule fails, the operation throws with a message listing all failing fields.
176
178
 
179
+ #### `@Calculated(compute)`
180
+
181
+ Derives the decorated property from the rest of the entity on every `create` and `update`. See [Calculated Fields](#calculated-fields).
182
+
177
183
  #### `@RetentionPolicy(options)`
178
184
 
179
185
  Class-level decorator that configures automatic expiry and deletion of records. See [Data Retention](#data-retention) for full details.
@@ -189,9 +195,9 @@ const db = await Database.build<{
189
195
  }>('shop', [User, Order]);
190
196
  ```
191
197
 
192
- `Database.build` opens (or upgrades) the IDB database, creates object stores and indexes for any entity whose version exceeds the stored database version, starts background retention jobs if applicable, and attaches typed repository properties to the returned object.
198
+ `Database.build` opens (or upgrades) the IDB database, reconciles the declared schema against the stored one (creating missing stores and indexes and removing indexes that are no longer declared), starts background retention jobs if applicable, and attaches typed repository properties to the returned object.
193
199
 
194
- The effective database version is the highest `version` value declared across all registered entities.
200
+ The declared database version is the highest `version` value across all registered entities; see [Migration behaviour](#migration-behaviour) for how drift and downgrades are handled.
195
201
 
196
202
  ### Inspecting database metadata
197
203
 
@@ -236,6 +242,7 @@ await db.User.deleteWhere((q) => q.where('age').lt(18));
236
242
  // Utilities
237
243
  const count = await db.User.count();
238
244
  const exists = await db.User.exists('u1');
245
+ const keys = await db.User.getKeys(); // primary keys only, no record values
239
246
  await db.User.clear();
240
247
  ```
241
248
 
@@ -335,6 +342,23 @@ await db.User.query()
335
342
  .execute();
336
343
  ```
337
344
 
345
+ ### Reusing builders
346
+
347
+ A builder accumulates state: every `where`/`orderBy`/`limit` call mutates the same instance, so chaining more conditions onto an already-executed builder narrows it further. To derive variations from a shared base use `clone()`; to start over with the same instance use `reset()`:
348
+
349
+ ```typescript
350
+ const adults = db.User.query().where('age').gte(18);
351
+
352
+ // Independent variations - neither affects the other or the base
353
+ const admins = await adults.clone().where('role').equals('admin').execute();
354
+ const guests = await adults.clone().where('role').equals('guest').execute();
355
+
356
+ // Reuse one instance from scratch
357
+ const query = db.User.query();
358
+ await query.where('role').equals('admin').execute();
359
+ await query.reset().where('age').lt(18).execute(); // fresh state
360
+ ```
361
+
338
362
  ### Index and range acceleration
339
363
 
340
364
  When a field is indexed, you can constrain the initial IDB candidate set at the storage layer before in-memory filtering begins:
@@ -343,6 +367,13 @@ When a field is indexed, you can constrain the initial IDB candidate set at the
343
367
  await db.Product.query().useIndex('price').range(10, 100).execute();
344
368
  ```
345
369
 
370
+ The two mechanisms are deliberately distinct:
371
+
372
+ - `useIndex(...).range(start, end)` narrows candidates **natively at the IndexedDB layer** via an `IDBKeyRange` — fast, but limited to one indexed field.
373
+ - `.where(...)` conditions are evaluated **in memory** after the candidates are fetched. They can target any field (including one different from the index), at the cost of scanning the fetched candidates.
374
+
375
+ Mixing them is valid and useful — the index range prunes the bulk, `where()` refines the rest. Calling `range()` **without** `useIndex()` throws at execution time instead of silently ignoring the bounds; express such bounds as `where(field).between(start, end)` instead.
376
+
346
377
  ### Aggregations
347
378
 
348
379
  ```typescript
@@ -426,9 +457,11 @@ KeyGenerators.random(); // "xyz789abc"
426
457
 
427
458
  ### Composite keys
428
459
 
460
+ Key generation (`generator` / `autoIncrement`) is not supported for composite keys and throws at decoration time.
461
+
429
462
  ```typescript
430
- @CompositeKeyPath(['userId', 'projectId'])
431
463
  @DataClass()
464
+ @CompositeKeyPath(['userId', 'projectId'])
432
465
  class UserProject {
433
466
  userId!: string;
434
467
  projectId!: string;
@@ -481,6 +514,38 @@ Validation failed for User: email: must be a valid email address; age: must be a
481
514
 
482
515
  ---
483
516
 
517
+ ## Calculated Fields
518
+
519
+ `@Calculated` derives a property from the rest of the entity on every write. The compute function runs on `create` and `update`, **before** validation and timestamps:
520
+
521
+ ```typescript
522
+ import { Calculated } from 'idb-ts';
523
+
524
+ @DataClass()
525
+ class OrderLine {
526
+ @KeyPath({ generator: 'uuid' })
527
+ id!: string;
528
+
529
+ quantity!: number;
530
+ unitPrice!: number;
531
+
532
+ @Calculated<OrderLine>((line) => line.quantity * line.unitPrice)
533
+ total!: number;
534
+ }
535
+
536
+ await db.OrderLine.create({ id: '', quantity: 3, unitPrice: 9.5 } as OrderLine);
537
+ (await db.OrderLine.read(id))!.total; // 28.5 - computed and persisted
538
+ ```
539
+
540
+ Semantics:
541
+
542
+ - Any value the caller assigns to a calculated field is **overwritten** by the compute function on write.
543
+ - `@Validate` rules on the same field see the computed value.
544
+ - The value is persisted, so it can be indexed with `@Index` and queried like any ordinary field.
545
+ - It reflects entity state as of the **last write** - update the inputs and the field recomputes on the next `update`.
546
+
547
+ ---
548
+
484
549
  ## Transactions
485
550
 
486
551
  ### Callback form (recommended)
@@ -542,7 +607,7 @@ When multiple entities define retention policies, the cleanup interval is set to
542
607
 
543
608
  ## Schema Versioning
544
609
 
545
- Increment an entity's `version` to trigger `onupgradeneeded` and update its object store on the user's next visit. The effective database version is the maximum across all registered entities, so adding a new high-version entity is sufficient to initiate a migration.
610
+ Increment an entity's `version` to trigger `onupgradeneeded` and update its object store on the user's next visit. The declared database version is the maximum across all registered entities, so adding a new high-version entity is sufficient to initiate a migration.
546
611
 
547
612
  ```typescript
548
613
  @DataClass({ version: 1 })
@@ -558,14 +623,28 @@ class Comment {
558
623
  /* ... */
559
624
  }
560
625
 
561
- // Database opens at version 3.
562
- // If a user was on version 1, only Post (v2) and Comment (v3) stores are
563
- // created or updated during onupgradeneeded.
626
+ // Database opens at version 3 and reconciles the full declared schema
627
+ // (stores + indexes) inside the upgrade transaction.
564
628
  const db = await Database.build('blog', [User, Post, Comment]);
565
629
 
566
630
  console.log(db.getDatabaseVersion()); // 3
567
631
  ```
568
632
 
633
+ ### Migration behaviour
634
+
635
+ Migration is **declarative**: on every upgrade the actual IndexedDB schema is reconciled against the schema declared by your decorators.
636
+
637
+ | Change | Handling |
638
+ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
639
+ | New entity / store | Created automatically. |
640
+ | Index added (even without a version bump) | Detected as schema drift after opening; the database is reopened one version higher and the index is created. Existing records are re-indexed by IndexedDB. |
641
+ | Index removed (even without a version bump) | Detected as drift; the stale index is deleted. Record data is not affected. |
642
+ | Entity `version` lowered | IndexedDB cannot downgrade. The database opens at the existing on-disk version instead of throwing `VersionError`; `getDatabaseVersion()` reports the on-disk version. |
643
+ | Key path / `autoIncrement` changed | **Not applied** — IndexedDB cannot change a store's key path in place. A warning is logged; migrate the data to a new entity or delete the database. |
644
+ | Entity no longer registered | Its store and data are **preserved** and a warning is logged. Re-register the entity to access the data again, or delete the store manually. |
645
+
646
+ > Because drift detection may reopen the database one version higher than declared, `getDatabaseVersion()` returns the _actual_ IndexedDB version, which can exceed the highest entity `version`.
647
+
569
648
  ---
570
649
 
571
650
  ## Bulk Operations
@@ -580,104 +659,212 @@ await db.User.deleteMany(['u1', 'u2', 'u3']);
580
659
 
581
660
  ---
582
661
 
662
+ ## Sync Adapters (experimental)
663
+
664
+ Bridge the local IndexedDB with any backend by implementing the two-method `SyncAdapter` interface. The library stays transport-agnostic - REST, WebSocket, or in-memory adapters all work the same way.
665
+
666
+ ```typescript
667
+ import type { SyncAdapter } from 'idb-ts';
668
+
669
+ class RestAdapter implements SyncAdapter {
670
+ async push(entityName: string, records: unknown[]): Promise<void> {
671
+ await fetch(`/api/sync/${entityName}`, {
672
+ method: 'PUT',
673
+ body: JSON.stringify(records),
674
+ });
675
+ }
676
+
677
+ async pull(entityName: string): Promise<unknown[] | undefined> {
678
+ const response = await fetch(`/api/sync/${entityName}`);
679
+ return response.ok ? response.json() : undefined;
680
+ }
681
+ }
682
+
683
+ const adapter = new RestAdapter();
684
+ await db.pushTo(adapter); // sends every entity's records to the adapter
685
+ await db.pullFrom(adapter); // upserts records returned by the adapter
686
+ ```
687
+
688
+ - `pushTo` calls `adapter.push(entityName, records)` once per registered entity.
689
+ - `pullFrom` calls `adapter.pull(entityName)` per entity and upserts the returned records by primary key; returning `undefined` leaves that store untouched.
690
+ - Conflict resolution is intentionally left to the adapter/backend - locally, pulled records win by primary key.
691
+
692
+ ---
693
+
694
+ ## Export / Import
695
+
696
+ Snapshot every registered store to a plain serialisable object, and load such a snapshot back - useful for backups, test fixtures, and moving data between environments.
697
+
698
+ ```typescript
699
+ // Export: { EntityName: records[] } for every registered entity
700
+ const dump = await db.exportDatabase();
701
+ localStorage.setItem('backup', JSON.stringify(dump));
702
+
703
+ // Import: writes records verbatim (timestamps preserved), upserting by key
704
+ await db.importDatabase(JSON.parse(localStorage.getItem('backup')!));
705
+
706
+ // Replace instead of merge
707
+ await db.importDatabase(dump, { clear: true });
708
+ ```
709
+
710
+ - Records are written with `put`, so importing over existing keys overwrites those records; other records are kept unless `clear: true` is passed.
711
+ - Dump entries whose entity is not registered in this database are skipped.
712
+ - Internal `__idb_createdAt` / `__idb_updatedAt` fields survive the round-trip verbatim; validation and key generation are bypassed so the restored data matches the exported data exactly.
713
+
714
+ ---
715
+
583
716
  ## Performance
584
717
 
585
718
  <!-- performance start -->
586
719
 
587
720
  Already up to date
588
- Done in 443ms using pnpm v11.5.1
721
+ Done in 383ms using pnpm v11.9.0
589
722
 
590
723
  ### Suite 1: CRUD Operations
591
724
 
592
- | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
593
- |-----------|-----:|---------:|------:|-------:|----:|----:|----:|----:|----:|
594
- | create (single) | 200 | 15.46 | 12,936.753 | 0.077 | 0.058 | 0.137 | 0.162 | 0.052 | 1.01 |
595
- | read (by PK) | 200 | 10.987 | 18,203.475 | 0.054 | 0.047 | 0.089 | 0.163 | 0.04 | 0.175 |
596
- | update (single) | 200 | 121.183 | 1,650.4 | 0.605 | 0.535 | 0.991 | 1.739 | 0.459 | 2.227 |
597
- | findByIndex (email) | 200 | 11.218 | 17,828.706 | 0.056 | 0.052 | 0.083 | 0.111 | 0.044 | 0.143 |
598
- | findOneByIndex (email) | 200 | 13.331 | 15,002.256 | 0.066 | 0.057 | 0.081 | 0.103 | 0.048 | 1.11 |
599
- | count | 200 | 10.209 | 19,589.884 | 0.051 | 0.049 | 0.064 | 0.088 | 0.044 | 0.101 |
600
- | exists | 200 | 16.586 | 12,058.587 | 0.083 | 0.064 | 0.097 | 0.18 | 0.057 | 2.444 |
601
- | list (all) | 50 | 97.836 | 511.058 | 1.956 | 1.724 | 4.683 | 7.585 | 1.481 | 7.585 |
602
- | listPaginated (1, 20) | 200 | 374.357 | 534.25 | 1.871 | 1.692 | 2.005 | 9.138 | 1.447 | 12.441 |
603
- | query().where().gte().execute() | 100 | 182.997 | 546.458 | 1.829 | 1.542 | 1.975 | 2.457 | 1.485 | 14.388 |
604
- | delete (single) | 200 | 138.608 | 1,442.92 | 0.693 | 0.658 | 0.764 | 0.857 | 0.556 | 5.689 |
605
-
725
+ | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
726
+ | ------------------------------- | --: | -------: | ---------: | -----: | ----: | ----: | ----: | ----: | -----: |
727
+ | create (single) | 200 | 11.668 | 17,141.483 | 0.058 | 0.044 | 0.103 | 0.13 | 0.039 | 0.883 |
728
+ | read (by PK) | 200 | 7.972 | 25,087.351 | 0.039 | 0.036 | 0.062 | 0.091 | 0.03 | 0.113 |
729
+ | update (single) | 200 | 133.17 | 1,501.843 | 0.665 | 0.615 | 1.01 | 1.184 | 0.545 | 2.036 |
730
+ | findByIndex (email) | 200 | 8.942 | 22,365.168 | 0.044 | 0.041 | 0.073 | 0.086 | 0.034 | 0.111 |
731
+ | findOneByIndex (email) | 200 | 10.246 | 19,519.677 | 0.051 | 0.044 | 0.057 | 0.072 | 0.039 | 0.998 |
732
+ | count | 200 | 8.28 | 24,155.266 | 0.041 | 0.04 | 0.049 | 0.055 | 0.037 | 0.079 |
733
+ | exists | 200 | 13.325 | 15,009.758 | 0.066 | 0.052 | 0.09 | 0.118 | 0.046 | 2.085 |
734
+ | list (all) | 50 | 96.56 | 517.814 | 1.931 | 1.771 | 4.279 | 6.013 | 1.515 | 6.013 |
735
+ | listPaginated (1, 20) | 200 | 398.616 | 501.736 | 1.993 | 1.765 | 3.022 | 8.919 | 1.51 | 10.708 |
736
+ | query().where().gte().execute() | 100 | 181.336 | 551.463 | 1.813 | 1.599 | 1.896 | 2.042 | 1.524 | 14.147 |
737
+ | delete (single) | 200 | 161.278 | 1,240.095 | 0.806 | 0.773 | 0.896 | 1.019 | 0.64 | 6.043 |
606
738
 
607
739
  ### Suite 2: Batched CRUD (by batch size)
608
740
 
609
- | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
610
- |-----------|-----:|---------:|------:|-------:|----:|----:|----:|----:|----:|
611
- | createMany (10) | 3 | 1.482 | 2,023.901 | 0.493 | 0.531 | 0.534 | 0.534 | 0.415 | 0.534 |
612
- | read batch (10 keys) | 3 | 0.522 | 5,741.792 | 0.174 | 0.169 | 0.191 | 0.191 | 0.161 | 0.191 |
613
- | updateMany (10) | 3 | 5.066 | 592.172 | 1.688 | 1.488 | 2.093 | 2.093 | 1.482 | 2.093 |
614
- | deleteMany (10) | 3 | 3.06 | 980.235 | 1.02 | 1.031 | 1.099 | 1.099 | 0.929 | 1.099 |
615
- | deleteWhere (10+ match) | 3 | 0.984 | 3,048.328 | 0.327 | 0.302 | 0.401 | 0.401 | 0.279 | 0.401 |
616
- | createMany (50) | 3 | 5.753 | 521.463 | 1.917 | 1.933 | 2.106 | 2.106 | 1.712 | 2.106 |
617
- | read batch (50 keys) | 3 | 4.12 | 728.179 | 1.372 | 1.386 | 1.468 | 1.468 | 1.263 | 1.468 |
618
- | updateMany (50) | 3 | 78.35 | 38.29 | 26.116 | 25.63 | 28.291 | 28.291 | 24.426 | 28.291 |
619
- | deleteMany (50) | 3 | 76.547 | 39.191 | 25.514 | 24.517 | 27.751 | 27.751 | 24.276 | 27.751 |
620
- | deleteWhere (50+ match) | 3 | 3.43 | 874.636 | 1.143 | 1.069 | 1.294 | 1.294 | 1.065 | 1.294 |
621
- | createMany (100) | 3 | 11.795 | 254.341 | 3.931 | 4.058 | 4.244 | 4.244 | 3.491 | 4.244 |
622
- | read batch (100 keys) | 3 | 29.324 | 102.305 | 9.773 | 8.39 | 14.867 | 14.867 | 6.062 | 14.867 |
623
- | updateMany (100) | 3 | 272.998 | 10.989 | 90.997 | 89.999 | 95.133 | 95.133 | 87.86 | 95.133 |
624
- | deleteMany (100) | 3 | 270.164 | 11.104 | 90.048 | 88.568 | 98.799 | 98.799 | 82.777 | 98.799 |
625
- | deleteWhere (100+ match) | 3 | 6.852 | 437.839 | 2.283 | 2.136 | 2.591 | 2.591 | 2.123 | 2.591 |
626
- | createMany (500) | 3 | 89.252 | 33.613 | 29.749 | 31.124 | 34.061 | 34.061 | 24.062 | 34.061 |
627
- | read batch (500 keys) | 3 | 163.408 | 18.359 | 54.467 | 59.069 | 60.496 | 60.496 | 43.837 | 60.496 |
628
- | updateMany (500) | 3 | 7,195.857 | 0.417 | 2,398.617 | 2,397.101 | 2,406.21 | 2,406.21 | 2,392.539 | 2,406.21 |
629
- | deleteMany (500) | 3 | 7,951.45 | 0.377 | 2,650.481 | 2,603.507 | 2,912.156 | 2,912.156 | 2,435.78 | 2,912.156 |
630
- | deleteWhere (500+ match) | 3 | 36.772 | 81.584 | 12.256 | 11.967 | 12.995 | 12.995 | 11.806 | 12.995 |
631
-
741
+ | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
742
+ | ------------------------ | --: | --------: | --------: | --------: | --------: | --------: | --------: | --------: | --------: |
743
+ | createMany (10) | 3 | 1.421 | 2,111.564 | 0.473 | 0.513 | 0.528 | 0.528 | 0.377 | 0.528 |
744
+ | read batch (10 keys) | 3 | 0.5 | 5,998.488 | 0.166 | 0.16 | 0.189 | 0.189 | 0.151 | 0.189 |
745
+ | updateMany (10) | 3 | 4.472 | 670.902 | 1.49 | 1.452 | 1.646 | 1.646 | 1.37 | 1.646 |
746
+ | deleteMany (10) | 3 | 3.163 | 948.378 | 1.054 | 1.054 | 1.139 | 1.139 | 0.969 | 1.139 |
747
+ | deleteWhere (10+ match) | 3 | 0.841 | 3,566.321 | 0.28 | 0.278 | 0.312 | 0.312 | 0.249 | 0.312 |
748
+ | createMany (50) | 3 | 4.301 | 697.531 | 1.433 | 1.433 | 1.477 | 1.477 | 1.39 | 1.477 |
749
+ | read batch (50 keys) | 3 | 3.931 | 763.202 | 1.31 | 1.296 | 1.421 | 1.421 | 1.212 | 1.421 |
750
+ | updateMany (50) | 3 | 87.877 | 34.138 | 29.291 | 29.002 | 31.244 | 31.244 | 27.628 | 31.244 |
751
+ | deleteMany (50) | 3 | 93.633 | 32.04 | 31.21 | 31.53 | 33.2 | 33.2 | 28.899 | 33.2 |
752
+ | deleteWhere (50+ match) | 3 | 3.446 | 870.695 | 1.148 | 1.127 | 1.241 | 1.241 | 1.075 | 1.241 |
753
+ | createMany (100) | 3 | 9.571 | 313.443 | 3.19 | 3.074 | 3.8 | 3.8 | 2.695 | 3.8 |
754
+ | read batch (100 keys) | 3 | 12.769 | 234.949 | 4.255 | 4.077 | 5.665 | 5.665 | 3.023 | 5.665 |
755
+ | updateMany (100) | 3 | 314.564 | 9.537 | 104.853 | 104.907 | 104.963 | 104.963 | 104.69 | 104.963 |
756
+ | deleteMany (100) | 3 | 331.429 | 9.052 | 110.474 | 108.426 | 121.766 | 121.766 | 101.228 | 121.766 |
757
+ | deleteWhere (100+ match) | 3 | 6.954 | 431.435 | 2.317 | 2.254 | 2.481 | 2.481 | 2.216 | 2.481 |
758
+ | createMany (500) | 3 | 88.569 | 33.872 | 29.522 | 27.663 | 42.007 | 42.007 | 18.894 | 42.007 |
759
+ | read batch (500 keys) | 3 | 118.5 | 25.316 | 39.498 | 36.064 | 51.839 | 51.839 | 30.592 | 51.839 |
760
+ | updateMany (500) | 3 | 8,577.818 | 0.35 | 2,859.271 | 2,855.88 | 2,877.442 | 2,877.442 | 2,844.49 | 2,877.442 |
761
+ | deleteMany (500) | 3 | 9,452.42 | 0.317 | 3,150.804 | 3,097.544 | 3,450.784 | 3,450.784 | 2,904.084 | 3,450.784 |
762
+ | deleteWhere (500+ match) | 3 | 35.488 | 84.535 | 11.828 | 11.745 | 12.529 | 12.529 | 11.21 | 12.529 |
632
763
 
633
764
  ### Suite 3: Mixed CRUD Operations
634
765
 
635
- | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
636
- |-----------|-----:|---------:|------:|-------:|----:|----:|----:|----:|----:|
637
- | Read-heavy mix (70R/15U/10C/5D) | 200 | 29.799 | 6,711.698 | 0.149 | 0.029 | 0.606 | 0.619 | 0.016 | 0.652 |
638
- | Write-heavy mix (20R/15U/50C/15D) | 200 | 41.659 | 4,800.892 | 0.208 | 0.042 | 0.582 | 0.608 | 0.022 | 3.973 |
639
- | Mixed CRUD + queries | 200 | 118.964 | 1,681.186 | 0.594 | 0.051 | 2.065 | 7.636 | 0.013 | 10.791 |
640
- | Cross-entity mix (User.read + Order.create) | 200 | 7.316 | 27,336.962 | 0.036 | 0.03 | 0.069 | 0.081 | 0.021 | 0.091 |
641
-
766
+ | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
767
+ | ------------------------------------------- | --: | -------: | ---------: | -----: | ----: | ----: | ----: | ----: | ----: |
768
+ | Read-heavy mix (70R/15U/10C/5D) | 200 | 38.005 | 5,262.407 | 0.19 | 0.031 | 0.716 | 0.737 | 0.001 | 0.745 |
769
+ | Write-heavy mix (20R/15U/50C/15D) | 200 | 44.985 | 4,445.915 | 0.225 | 0.035 | 0.707 | 0.739 | 0.001 | 3.708 |
770
+ | Mixed CRUD + queries | 200 | 83.386 | 2,398.49 | 0.417 | 0.033 | 2.061 | 2.141 | 0.011 | 6.234 |
771
+ | Cross-entity mix (User.read + Order.create) | 200 | 6.295 | 31,770.944 | 0.031 | 0.024 | 0.058 | 0.076 | 0.018 | 0.095 |
642
772
 
643
773
  ### Suite 4: Mixed Batched CRUD
644
774
 
645
- | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
646
- |-----------|-----:|---------:|------:|-------:|----:|----:|----:|----:|----:|
647
- | Cycle: createMany -> readAll -> updateMany -> deleteMany (50) | 5 | 66.384 | 75.319 | 13.276 | 13.426 | 17.594 | 17.594 | 10.02 | 17.594 |
648
- | createMany -> query filter -> deleteMany (50) | 5 | 36.888 | 135.546 | 7.377 | 6.693 | 9.052 | 9.052 | 6.469 | 9.052 |
649
- | 5 waves × createMany(50) + deleteMany(50) | 3 | 249.923 | 12.004 | 83.307 | 85.413 | 85.568 | 85.568 | 78.939 | 85.568 |
650
- | Cross-entity batch: createMany(User) + createMany(Order) + deleteMany (×50) | 3 | 140.09 | 21.415 | 46.696 | 47.77 | 51.181 | 51.181 | 41.137 | 51.181 |
651
-
775
+ | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
776
+ | --------------------------------------------------------------------------- | --: | -------: | ------: | -----: | -----: | ------: | ------: | -----: | ------: |
777
+ | Cycle: createMany -> readAll -> updateMany -> deleteMany (50) | 5 | 104.266 | 47.954 | 20.852 | 15.473 | 36.333 | 36.333 | 9.223 | 36.333 |
778
+ | createMany -> query filter -> deleteMany (50) | 5 | 33.079 | 151.155 | 6.615 | 6.368 | 9.824 | 9.824 | 4.751 | 9.824 |
779
+ | 5 waves × createMany(50) + deleteMany(50) | 3 | 292.584 | 10.253 | 97.527 | 94.694 | 108.554 | 108.554 | 89.333 | 108.554 |
780
+ | Cross-entity batch: createMany(User) + createMany(Order) + deleteMany (×50) | 3 | 136.071 | 22.047 | 45.356 | 43.887 | 49.364 | 49.364 | 42.816 | 49.364 |
652
781
 
653
782
  ### Suite 5: Transaction Operations
654
783
 
655
- | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
656
- |-----------|-----:|---------:|------:|-------:|----:|----:|----:|----:|----:|
657
- | tx: single create | 100 | 8.886 | 11,253.788 | 0.089 | 0.073 | 0.178 | 0.228 | 0.07 | 0.253 |
658
- | tx: create 10 users | 100 | 25.153 | 3,975.637 | 0.251 | 0.238 | 0.288 | 0.489 | 0.223 | 0.517 |
659
- | tx: read + update | 100 | 159.349 | 627.553 | 1.593 | 1.525 | 1.63 | 2.939 | 1.499 | 5.745 |
660
- | tx: multi-entity create (User+Order+Session) | 100 | 9.628 | 10,386.133 | 0.096 | 0.087 | 0.128 | 0.189 | 0.081 | 0.23 |
661
- | tx: 10 reads | 100 | 20.01 | 4,997.52 | 0.2 | 0.169 | 0.343 | 0.399 | 0.153 | 0.441 |
662
- | tx: batch create 50 users | 20 | 24.816 | 805.936 | 1.24 | 1.019 | 1.284 | 4.769 | 0.976 | 4.769 |
663
- | tx: query().where().gte() | 100 | 933.212 | 107.157 | 9.331 | 8.393 | 19.189 | 19.988 | 7.73 | 19.998 |
664
- | tx (explicit): begin -> create -> commit | 100 | 6.974 | 14,337.972 | 0.07 | 0.065 | 0.091 | 0.108 | 0.061 | 0.162 |
665
-
784
+ | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
785
+ | -------------------------------------------- | --: | -------: | ---------: | -----: | ----: | -----: | -----: | ----: | -----: |
786
+ | tx: single create | 100 | 7.5 | 13,332.676 | 0.075 | 0.063 | 0.121 | 0.209 | 0.06 | 0.37 |
787
+ | tx: create 10 users | 100 | 23.044 | 4,339.529 | 0.23 | 0.219 | 0.27 | 0.413 | 0.207 | 0.455 |
788
+ | tx: read + update | 100 | 194.689 | 513.64 | 1.947 | 1.906 | 1.986 | 3.09 | 1.836 | 5.522 |
789
+ | tx: multi-entity create (User+Order+Session) | 100 | 7.336 | 13,632.137 | 0.073 | 0.07 | 0.098 | 0.114 | 0.066 | 0.159 |
790
+ | tx: 10 reads | 100 | 15.108 | 6,618.828 | 0.151 | 0.141 | 0.176 | 0.272 | 0.135 | 0.364 |
791
+ | tx: batch create 50 users | 20 | 23.258 | 859.911 | 1.163 | 0.953 | 1.68 | 4.091 | 0.926 | 4.091 |
792
+ | tx: query().where().gte() | 100 | 946.761 | 105.623 | 9.467 | 8.473 | 18.988 | 19.246 | 8.265 | 19.705 |
793
+ | tx (explicit): begin -> create -> commit | 100 | 5.381 | 18,582.249 | 0.054 | 0.052 | 0.067 | 0.078 | 0.049 | 0.081 |
666
794
 
667
795
  ### Suite 6: Mixed Transactions
668
796
 
669
- | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
670
- |-----------|-----:|---------:|------:|-------:|----:|----:|----:|----:|----:|
671
- | tx mixed: read User -> create Order -> update User | 100 | 51.695 | 1,934.439 | 0.517 | 0.41 | 0.485 | 0.724 | 0.38 | 10.259 |
672
- | tx mixed: query User + read Order + create Session | 100 | 113.605 | 880.244 | 1.136 | 1.037 | 1.134 | 1.274 | 1.018 | 9.734 |
673
- | tx multi-entity: create User+Order+Session | 100 | 11.079 | 9,025.916 | 0.111 | 0.106 | 0.138 | 0.143 | 0.1 | 0.166 |
674
- | tx batched: create 20 Users + 40 Orders + 20 Sessions | 10 | 22.237 | 449.703 | 2.223 | 1.238 | 11.051 | 11.051 | 1.164 | 11.051 |
675
- | tx mixed: delete old orders -> create new orders | 100 | 104.138 | 960.266 | 1.041 | 0.971 | 1.09 | 1.189 | 0.829 | 8.342 |
676
- | tx mixed: count Orders -> conditional create | 100 | 9.371 | 10,671.27 | 0.093 | 0.088 | 0.121 | 0.141 | 0.081 | 0.166 |
677
- | tx complex: read User+Orders -> aggregate -> create Session | 100 | 18.87 | 5,299.428 | 0.188 | 0.15 | 0.223 | 0.288 | 0.135 | 3.002 |
797
+ | Operation | Ops | Total ms | Ops/s | Avg ms | P50 | P95 | P99 | Min | Max |
798
+ | ----------------------------------------------------------- | --: | -------: | ---------: | -----: | ----: | ----: | ----: | ----: | -----: |
799
+ | tx mixed: read User -> create Order -> update User | 100 | 53.926 | 1,854.389 | 0.539 | 0.437 | 0.462 | 0.59 | 0.41 | 10.318 |
800
+ | tx mixed: query User + read Order + create Session | 100 | 113.792 | 878.798 | 1.138 | 1.055 | 1.088 | 1.139 | 1.03 | 8.959 |
801
+ | tx multi-entity: create User+Order+Session | 100 | 8.889 | 11,250.085 | 0.089 | 0.087 | 0.102 | 0.106 | 0.082 | 0.132 |
802
+ | tx batched: create 20 Users + 40 Orders + 20 Sessions | 10 | 20.246 | 493.934 | 2.024 | 1.136 | 9.953 | 9.953 | 1.096 | 9.953 |
803
+ | tx mixed: delete old orders -> create new orders | 100 | 122.343 | 817.377 | 1.223 | 1.145 | 1.309 | 1.386 | 0.981 | 8.579 |
804
+ | tx mixed: count Orders -> conditional create | 100 | 7.079 | 14,125.357 | 0.071 | 0.069 | 0.083 | 0.099 | 0.065 | 0.103 |
805
+ | tx complex: read User+Orders -> aggregate -> create Session | 100 | 15.71 | 6,365.173 | 0.157 | 0.117 | 0.216 | 0.33 | 0.107 | 2.822 |
678
806
 
679
807
  <!-- performance end -->
680
808
 
809
+ ## LLM / AI Assistant Instructions
810
+
811
+ Building with an AI coding assistant? Paste the block below into your assistant's context (system prompt, rules file, `CLAUDE.md`, `.cursorrules`, etc.) so it generates correct idb-ts code on the first try.
812
+
813
+ ```text
814
+ idb-ts cheat sheet (TypeScript ORM for IndexedDB, zero runtime deps):
815
+
816
+ Setup
817
+ - import 'reflect-metadata' once at the app entry point.
818
+ - tsconfig: "experimentalDecorators": true, "emitDecoratorMetadata": true.
819
+
820
+ Entities
821
+ - Decorate classes with @DataClass({ version?: number }).
822
+ - Exactly one primary key: @KeyPath({ autoIncrement?, generator? }) on a
823
+ property, or @CompositeKeyPath(['fieldA','fieldB']) on the class
824
+ (written BELOW @DataClass - decorators apply bottom-up).
825
+ - generator: 'uuid' | 'timestamp' | 'random' | (item) => string | number.
826
+ - Secondary indexes: @Index({ unique?: boolean }) on properties.
827
+ - Validation: @Validate((value, item) => boolean, 'message') on properties.
828
+ - Auto-expiry: @RetentionPolicy({ seconds, field?, enabled? }) on the class.
829
+
830
+ Database
831
+ - const db = await Database.build<{ User: EntityRepository<User> }>('name', [User]);
832
+ - Repositories are attached by class name: db.User, db.Order, ...
833
+ - db.close() when done. db.getDatabaseVersion(), db.getAvailableEntities().
834
+
835
+ Repository API (all Promise-based)
836
+ - create(item), createMany(items), read(key), update(item), updateMany(items)
837
+ - delete(key), deleteMany(keys), deleteWhere(q => q.where(...))
838
+ - list(), listPaginated(page, pageSize), count(), exists(key), clear()
839
+ - findByIndex(indexName, value), findOneByIndex(indexName, value)
840
+ - query() -> QueryBuilder
841
+
842
+ QueryBuilder (chainable)
843
+ - .where('field').equals/gt/gte/lt/lte/startsWith/endsWith/contains/
844
+ matches/between/notBetween/in/notIn/containsAny/containsAll(...)
845
+ - .and('field')... / .or().where('field')...
846
+ - Nested groups: .where(qb => qb.where('a').equals(1).or().where('b').equals(2))
847
+ - .orderBy(field, 'asc'|'desc'), .limit(n), .offset(n)
848
+ - .useIndex(indexName).range(start, end) for IDB-level narrowing
849
+ - Terminals: .execute(), .count(), .sum(f), .avg(f), .min(f), .max(f),
850
+ .groupBy(f).count()
851
+
852
+ Transactions
853
+ - await db.transaction(async tx => { await tx.User.create(u); ... }) // auto commit/rollback
854
+ - const tx = await db.beginTransaction(['User','Order']); await tx.commit() / tx.rollback()
855
+
856
+ Gotchas
857
+ - Store names are lower-cased class names; renaming a class = new store.
858
+ - Every write injects __idb_createdAt / __idb_updatedAt (ms timestamps).
859
+ - where() filters run in memory after candidates are fetched; use
860
+ useIndex()+range() to narrow at the IndexedDB layer first.
861
+ - Composite keys are passed as arrays: db.UserProject.read(['u1','p1']).
862
+ ```
863
+
864
+ ### For contributors (and their assistants)
865
+
866
+ Working on idb-ts itself: the entire library lives in `index.ts`; tests are in `__tests__/` (Jest + fake-indexeddb). Key workflows: `pnpm test` (type check + Jest), `pnpm lint`, `pnpm build` (tsc + rollup into `lib/`, which is the only published artifact). Keep changes minimal, always add tests, and document user-facing behaviour in this README rather than separate files.
867
+
681
868
  ## Useful Links
682
869
 
683
870
  - **GitHub**: [maifeeulasad/idb-ts](https://github.com/maifeeulasad/idb-ts)