From 2593925e0035a5eeca73d9c8c0265f35dd9dda37 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Wed, 5 Aug 2026 11:45:15 -0700 Subject: [PATCH 1/8] Add optional indexNames scoping to InMemoryProvider put() When a caller fetches data through a single index and caches the results via put(), InMemoryStore previously seeded every index on the store with those items, even indexes that were never queried. Combined with memory-cursor range bookkeeping in consumers, this can cause a later query on a different index to incorrectly believe it has full coverage and skip the underlying DB, producing gaps ("islands") in the returned data. put() now accepts an optional indexNames?: string[] parameter. When provided, brand-new items (not already tracked in the store) are only written into the primary key plus the specified index(es). Items that are already tracked continue to be kept in sync across every index they were previously part of, so already-cached data never goes stale. Omitting indexNames preserves the exact original behavior (write to all indexes), so all existing callers are unaffected. DbStore/DbProvider interfaces and the public put() shortcut are updated to thread the new parameter through. IndexedDbProvider is intentionally left unchanged since it doesn't need index scoping (native/managed indexing). Added a test covering: new items scoped to the specified index only, already-tracked items staying in sync across all indexes on subsequent scoped puts, and backward compatibility when indexNames is omitted. --- src/InMemoryProvider.ts | 24 ++++++- src/ObjectStoreProvider.ts | 15 ++++- src/tests/ObjectStoreProvider.spec.ts | 95 +++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index de48214..0c8b9e1 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -13,6 +13,7 @@ import { includes, compact, map, + filter, find, values, flatten, @@ -328,7 +329,16 @@ class InMemoryStore implements DbStore { ); } - put(itemOrItems: ItemType | ItemType[]): Promise { + // indexNames is an optional scoping hint: when provided, brand-new items (not already present in the + // store) are only written into the primary key plus the listed index(es), instead of every index on the + // store. This lets callers who fetched data through a single index (e.g. a ranged read served from that + // index) cache the results without seeding "islands" of items into unrelated indexes that were never + // actually queried/loaded for those items. Items that already exist in the store keep being kept in sync + // across every index they were previously tracked by, so already-cached data never goes stale. + put( + itemOrItems: ItemType | ItemType[], + indexNames?: string[] + ): Promise { if (!this._trans.internal_isOpen()) { return Promise.reject("InMemoryTransaction already closed"); } @@ -349,8 +359,16 @@ class InMemoryStore implements DbStore { } this._mergedData.set(pk, item); (this.openPrimaryKey() as InMemoryIndex).put(item); - if (this._storeSchema.indexes) { - for (const index of this._storeSchema.indexes) { + + const indexesToPopulate = + indexNames && !existingItem + ? filter(this._storeSchema.indexes, (index) => + includes(indexNames, index.name) + ) + : this._storeSchema.indexes; + + if (indexesToPopulate) { + for (const index of indexesToPopulate) { (this.openIndex(index.name) as InMemoryIndex).put(item); } } diff --git a/src/ObjectStoreProvider.ts b/src/ObjectStoreProvider.ts index 6b680a8..9e8c5f1 100644 --- a/src/ObjectStoreProvider.ts +++ b/src/ObjectStoreProvider.ts @@ -177,7 +177,12 @@ export interface DbIndex { export interface DbStore { get(key: KeyType): Promise; getMultiple(keyOrKeys: KeyType | KeyType[]): Promise; - put(itemOrItems: ItemType | ItemType[]): Promise; + // indexNames is an optional scoping hint honored by providers (currently InMemoryProvider) that would + // otherwise write brand-new items into every index on the store: when provided, only the primary key and + // the listed index(es) are populated for items that aren't already present in the store. Providers that + // don't support scoping (e.g. IndexedDbProvider, which relies on the browser's native index maintenance) + // may ignore this parameter. + put(itemOrItems: ItemType | ItemType[], indexNames?: string[]): Promise; remove(keyOrKeys: KeyType | KeyType[]): Promise; removeRange( indexName: string, @@ -298,9 +303,13 @@ export abstract class DbProvider { ); } - put(storeName: string, itemOrItems: ItemType | ItemType[]): Promise { + put( + storeName: string, + itemOrItems: ItemType | ItemType[], + indexNames?: string[] + ): Promise { return this._getStoreTransaction(storeName, true).then((store) => { - return store.put(itemOrItems); + return store.put(itemOrItems, indexNames); }); } diff --git a/src/tests/ObjectStoreProvider.spec.ts b/src/tests/ObjectStoreProvider.spec.ts index 15d4d5a..146e61d 100644 --- a/src/tests/ObjectStoreProvider.spec.ts +++ b/src/tests/ObjectStoreProvider.spec.ts @@ -2100,6 +2100,101 @@ describe("ObjectStoreProvider", function () { ); }); + it("put with indexNames only populates the specified index(es) for new items", (done) => { + // indexNames scoping is an InMemoryProvider-only optimization -- other providers either maintain + // indexes natively (indexeddb) or ignore the extra parameter entirely. + if (provName.indexOf("memory") === -1) { + done(); + return; + } + + openProvider( + provName, + { + version: 1, + stores: [ + { + name: "test", + primaryKeyPath: "id", + indexes: [ + { name: "indexA", keyPath: "a" }, + { name: "indexB", keyPath: "b" }, + ], + }, + ], + }, + true + ) + .then((prov) => { + return prov + .put("test", { id: "item1", a: "valA1", b: "valB1" }, [ + "indexA", + ]) + .then(() => { + return Promise.all([ + prov.get("test", "item1"), + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + ]).then(([byPk, byIndexA, byIndexB]) => { + // The primary key always reflects the latest put, regardless of indexNames scoping. + assert(!!byPk); + assert.equal((byPk as TestObj).id, "item1"); + + // The item shows up in the index it was scoped to... + assert.equal(byIndexA.length, 1); + assert.equal((byIndexA[0] as TestObj).id, "item1"); + + // ...but not in an index it was never fetched/loaded through, avoiding the + // "memory island" problem of seeding unrelated indexes with unloaded data. + assert.equal(byIndexB.length, 0); + }); + }) + .then(() => { + // Once the item is already tracked in memory, subsequent scoped puts (e.g. an update + // fetched again via indexA) must keep it in sync across every index it's already part + // of, rather than leaving stale/missing entries in indexes that were skipped this time. + return prov + .put( + "test", + { id: "item1", a: "valA1-updated", b: "valB1-updated" }, + ["indexA"] + ) + .then(() => { + return Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + ]).then(([byIndexA, byIndexB]) => { + assert.equal(byIndexA.length, 1); + assert.equal((byIndexA[0] as any).a, "valA1-updated"); + + assert.equal(byIndexB.length, 1); + assert.equal((byIndexB[0] as any).b, "valB1-updated"); + }); + }); + }) + .then(() => { + // Backward compatibility: omitting indexNames still populates every index for new items. + return prov + .put("test", { id: "item2", a: "valA2", b: "valB2" }) + .then(() => { + return Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + ]).then(([byIndexA, byIndexB]) => { + assert.equal(byIndexA.length, 2); + assert.equal(byIndexB.length, 2); + }); + }); + }) + .then(() => prov.close()) + .catch((e) => prov.close().then(() => Promise.reject(e))); + }) + .then( + () => done(), + (err) => done(err) + ); + }); + it("Invalid Key Type", (done) => { openProvider( provName, From 311d18f09d7067a0f61e7422b8101d7807d066d3 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Wed, 5 Aug 2026 11:56:36 -0700 Subject: [PATCH 2/8] Make indexNames scoping impossible for DB-backed providers Move indexNames off the shared DbStore/DbProvider surface so it is a compile-time error to pass it to any provider other than InMemoryProvider: - Revert DbStore.put() and the base DbProvider.put() shortcut to their original (no indexNames) signatures. - Change DbProvider._getStoreTransaction from private to protected so subclasses can reuse it. - Add a put(storeName, itemOrItems, indexNames?) override directly on InMemoryProvider that casts to InMemoryStore to reach the wider, store-level put() (unchanged). This guarantees indexNames scoping can only ever affect the in-memory cache, never the real database (e.g. IndexedDbProvider), since callers now need a reference typed as InMemoryProvider -- not the generic DbProvider -- to use the parameter at all. --- src/InMemoryProvider.ts | 19 +++++++++++++++++++ src/ObjectStoreProvider.ts | 20 +++++++------------- src/tests/ObjectStoreProvider.spec.ts | 8 ++++++-- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index 0c8b9e1..1218fef 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -149,6 +149,23 @@ export class InMemoryProvider extends DbProvider { internal_getStore(name: string): StoreData { return this._stores.get(name)!!!; } + + // Overrides the base DbProvider.put() shortcut to expose InMemoryStore's indexNames scoping (see + // InMemoryStore.put() for the full rationale). This is intentionally NOT part of the shared DbStore/ + // DbProvider interfaces: scoping which index(es) get populated only makes sense for an in-memory cache + // that's re-derived from a real database, never for the database itself, which must always keep every + // index consistent with the data it stores. Keeping it off the shared interfaces makes it a compile error + // to pass indexNames to any other provider (e.g. IndexedDbProvider) -- callers must have a reference typed + // as InMemoryProvider (not the generic DbProvider) to use this parameter at all. + put( + storeName: string, + itemOrItems: ItemType | ItemType[], + indexNames?: string[] + ): Promise { + return this._getStoreTransaction(storeName, true).then((store) => { + return (store as InMemoryStore).put(itemOrItems, indexNames); + }); + } } // Notes: Doesn't limit the stores it can fetch to those in the stores it was "created" with, nor does it handle read-only transactions @@ -335,6 +352,8 @@ class InMemoryStore implements DbStore { // index) cache the results without seeding "islands" of items into unrelated indexes that were never // actually queried/loaded for those items. Items that already exist in the store keep being kept in sync // across every index they were previously tracked by, so already-cached data never goes stale. + // NOTE: this parameter is intentionally NOT part of the shared DbStore interface -- it's only reachable + // via InMemoryProvider.put() (see there), so it's a compile error to use it against any other provider. put( itemOrItems: ItemType | ItemType[], indexNames?: string[] diff --git a/src/ObjectStoreProvider.ts b/src/ObjectStoreProvider.ts index 9e8c5f1..602dc24 100644 --- a/src/ObjectStoreProvider.ts +++ b/src/ObjectStoreProvider.ts @@ -177,12 +177,7 @@ export interface DbIndex { export interface DbStore { get(key: KeyType): Promise; getMultiple(keyOrKeys: KeyType | KeyType[]): Promise; - // indexNames is an optional scoping hint honored by providers (currently InMemoryProvider) that would - // otherwise write brand-new items into every index on the store: when provided, only the primary key and - // the listed index(es) are populated for items that aren't already present in the store. Providers that - // don't support scoping (e.g. IndexedDbProvider, which relies on the browser's native index maintenance) - // may ignore this parameter. - put(itemOrItems: ItemType | ItemType[], indexNames?: string[]): Promise; + put(itemOrItems: ItemType | ItemType[]): Promise; remove(keyOrKeys: KeyType | KeyType[]): Promise; removeRange( indexName: string, @@ -269,7 +264,10 @@ export abstract class DbProvider { protected abstract _deleteDatabaseInternal(): Promise; - private _getStoreTransaction( + // Protected (rather than private) so that subclasses which need extra, provider-specific put() semantics + // (e.g. InMemoryProvider's indexNames scoping -- see its put() override) can reuse this instead of + // re-implementing store-transaction resolution. + protected _getStoreTransaction( storeName: string, readWrite: boolean ): Promise { @@ -303,13 +301,9 @@ export abstract class DbProvider { ); } - put( - storeName: string, - itemOrItems: ItemType | ItemType[], - indexNames?: string[] - ): Promise { + put(storeName: string, itemOrItems: ItemType | ItemType[]): Promise { return this._getStoreTransaction(storeName, true).then((store) => { - return store.put(itemOrItems, indexNames); + return store.put(itemOrItems); }); } diff --git a/src/tests/ObjectStoreProvider.spec.ts b/src/tests/ObjectStoreProvider.spec.ts index 146e61d..048431b 100644 --- a/src/tests/ObjectStoreProvider.spec.ts +++ b/src/tests/ObjectStoreProvider.spec.ts @@ -2126,7 +2126,11 @@ describe("ObjectStoreProvider", function () { true ) .then((prov) => { - return prov + // indexNames scoping is only reachable via a reference typed as InMemoryProvider -- it's + // intentionally not part of the shared DbStore/DbProvider interfaces so it's a compile error + // to use it against any other provider (e.g. the real IndexedDbProvider/db path). + const memoryProv = prov as InMemoryProvider; + return memoryProv .put("test", { id: "item1", a: "valA1", b: "valB1" }, [ "indexA", ]) @@ -2153,7 +2157,7 @@ describe("ObjectStoreProvider", function () { // Once the item is already tracked in memory, subsequent scoped puts (e.g. an update // fetched again via indexA) must keep it in sync across every index it's already part // of, rather than leaving stale/missing entries in indexes that were skipped this time. - return prov + return memoryProv .put( "test", { id: "item1", a: "valA1-updated", b: "valB1-updated" }, From 0273d3234859c9c50c1fa99685bd37cf86ed2388 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Wed, 5 Aug 2026 12:43:17 -0700 Subject: [PATCH 3/8] Bump version to 0.9.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d57787..91a0972 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/objectstoreprovider", - "version": "0.9.1", + "version": "0.9.2", "description": "A cross-browser object store library", "author": "DataStack Team eleretzk@microsoft.com", "scripts": { From 78dc7b11a07ec0dd9abd9a3091c557d4889853e4 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Fri, 7 Aug 2026 14:54:04 -0700 Subject: [PATCH 4/8] Convert put() and _getStoreTransaction() comments to JSDoc format Addresses PR review feedback from amitshankar-msft: convert the plain comment blocks documenting InMemoryStore.put()'s indexNames parameter and DbProvider._getStoreTransaction()'s protected visibility rationale into JSDoc format for IDE tooltip support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 009bd0a8-f98f-4048-a261-e0a627813a00 --- src/InMemoryProvider.ts | 19 +++++++++++-------- src/ObjectStoreProvider.ts | 8 +++++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index 1218fef..0790216 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -346,14 +346,17 @@ class InMemoryStore implements DbStore { ); } - // indexNames is an optional scoping hint: when provided, brand-new items (not already present in the - // store) are only written into the primary key plus the listed index(es), instead of every index on the - // store. This lets callers who fetched data through a single index (e.g. a ranged read served from that - // index) cache the results without seeding "islands" of items into unrelated indexes that were never - // actually queried/loaded for those items. Items that already exist in the store keep being kept in sync - // across every index they were previously tracked by, so already-cached data never goes stale. - // NOTE: this parameter is intentionally NOT part of the shared DbStore interface -- it's only reachable - // via InMemoryProvider.put() (see there), so it's a compile error to use it against any other provider. + /** + * @param indexNames Optional scoping hint: when provided, brand-new items (not already present in the + * store) are only written into the primary key plus the listed index(es), instead of every index on the + * store. This lets callers who fetched data through a single index (e.g. a ranged read served from that + * index) cache the results without seeding "islands" of items into unrelated indexes that were never + * actually queried/loaded for those items. Items that already exist in the store keep being kept in sync + * across every index they were previously tracked by, so already-cached data never goes stale. + * + * NOTE: this parameter is intentionally NOT part of the shared DbStore interface -- it's only reachable + * via InMemoryProvider.put() (see there), so it's a compile error to use it against any other provider. + */ put( itemOrItems: ItemType | ItemType[], indexNames?: string[] diff --git a/src/ObjectStoreProvider.ts b/src/ObjectStoreProvider.ts index 602dc24..eab05ec 100644 --- a/src/ObjectStoreProvider.ts +++ b/src/ObjectStoreProvider.ts @@ -264,9 +264,11 @@ export abstract class DbProvider { protected abstract _deleteDatabaseInternal(): Promise; - // Protected (rather than private) so that subclasses which need extra, provider-specific put() semantics - // (e.g. InMemoryProvider's indexNames scoping -- see its put() override) can reuse this instead of - // re-implementing store-transaction resolution. + /** + * Protected (rather than private) so that subclasses which need extra, provider-specific put() semantics + * (e.g. InMemoryProvider's indexNames scoping -- see its put() override) can reuse this instead of + * re-implementing store-transaction resolution. + */ protected _getStoreTransaction( storeName: string, readWrite: boolean From ceccccfca5c5d91bb61fe786807b6e8de380c637 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Fri, 7 Aug 2026 14:58:49 -0700 Subject: [PATCH 5/8] Convert remaining put() comment to JSDoc, bump version to 0.9.3 Also converts the InMemoryProvider.put() shortcut override comment (documenting indexNames scoping) to JSDoc format for consistency with InMemoryStore.put() and _getStoreTransaction(). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 009bd0a8-f98f-4048-a261-e0a627813a00 --- package.json | 2 +- src/InMemoryProvider.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 91a0972..09e51b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/objectstoreprovider", - "version": "0.9.2", + "version": "0.9.3", "description": "A cross-browser object store library", "author": "DataStack Team eleretzk@microsoft.com", "scripts": { diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index 0790216..e213a2f 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -150,13 +150,15 @@ export class InMemoryProvider extends DbProvider { return this._stores.get(name)!!!; } - // Overrides the base DbProvider.put() shortcut to expose InMemoryStore's indexNames scoping (see - // InMemoryStore.put() for the full rationale). This is intentionally NOT part of the shared DbStore/ - // DbProvider interfaces: scoping which index(es) get populated only makes sense for an in-memory cache - // that's re-derived from a real database, never for the database itself, which must always keep every - // index consistent with the data it stores. Keeping it off the shared interfaces makes it a compile error - // to pass indexNames to any other provider (e.g. IndexedDbProvider) -- callers must have a reference typed - // as InMemoryProvider (not the generic DbProvider) to use this parameter at all. + /** + * Overrides the base DbProvider.put() shortcut to expose InMemoryStore's indexNames scoping (see + * InMemoryStore.put() for the full rationale). This is intentionally NOT part of the shared DbStore/ + * DbProvider interfaces: scoping which index(es) get populated only makes sense for an in-memory cache + * that's re-derived from a real database, never for the database itself, which must always keep every + * index consistent with the data it stores. Keeping it off the shared interfaces makes it a compile error + * to pass indexNames to any other provider (e.g. IndexedDbProvider) -- callers must have a reference typed + * as InMemoryProvider (not the generic DbProvider) to use this parameter at all. + */ put( storeName: string, itemOrItems: ItemType | ItemType[], From e275e0c83e2b97d8df82b99715f10a597b0a725f Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Fri, 7 Aug 2026 16:12:41 -0700 Subject: [PATCH 6/8] Hide scoped-index put behind putInIndexAfterGet_DoNotUse + IScopedIndexPutProvider Address Jeremie's PR review comment about the scoped-index put() overload being reachable via a direct cast on the public InMemoryProvider class, which made it too easy for consumers to accidentally break the all-indexes-stay-in-sync invariant. - Renamed InMemoryProvider's scoped put overload to putInIndexAfterGet_DoNotUse(storeName, itemOrItems, indexNames), with indexNames now required (not optional) since scoping is the entire point of this method. - Added an exported IScopedIndexPutProvider interface and an asScopedIndexPutProvider(provider) type-guard/cast helper as the only sanctioned way to discover/reach this capability -- InMemoryProvider does not declare 'implements IScopedIndexPutProvider', so it doesn't show up on the class surface itself. - Split InMemoryStore's internal put implementation into a plain put() (interface-compliant with DbStore, always populates every index), putInIndexAfterGet_DoNotUse() (scoped), and a shared private _putInternal() so both call sites reuse the same logic. - Updated the _getStoreTransaction() comment and the scoped-put test to use the new name/helper instead of an InMemoryProvider cast. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 009bd0a8-f98f-4048-a261-e0a627813a00 --- src/InMemoryProvider.ts | 97 +++++++++++++++++++++------ src/ObjectStoreProvider.ts | 4 +- src/tests/ObjectStoreProvider.spec.ts | 28 +++++--- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index e213a2f..64a0543 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -151,25 +151,69 @@ export class InMemoryProvider extends DbProvider { } /** - * Overrides the base DbProvider.put() shortcut to expose InMemoryStore's indexNames scoping (see - * InMemoryStore.put() for the full rationale). This is intentionally NOT part of the shared DbStore/ - * DbProvider interfaces: scoping which index(es) get populated only makes sense for an in-memory cache - * that's re-derived from a real database, never for the database itself, which must always keep every - * index consistent with the data it stores. Keeping it off the shared interfaces makes it a compile error - * to pass indexNames to any other provider (e.g. IndexedDbProvider) -- callers must have a reference typed - * as InMemoryProvider (not the generic DbProvider) to use this parameter at all. + * Deliberately NOT named/overloaded as `put()`, and deliberately not reachable except through + * `asScopedIndexPutProvider()` below -- see there for the full rationale. Exposes InMemoryStore's + * indexNames scoping (see InMemoryStore.putInIndexAfterGet_DoNotUse() for the full rationale). + * This is intentionally NOT part of the shared DbStore/DbProvider interfaces: scoping which + * index(es) get populated only makes sense for an in-memory cache that's re-derived from a real + * database, never for the database itself, which must always keep every index consistent with + * the data it stores. + * + * DO NOT USE unless you're re-populating an in-memory cache from a ranged read that only ever + * touched the listed index(es) -- e.g. right after a `getRange()`/`getMultiple()` served by a + * single index. Using this for any other kind of write will leave the *other* indexes on this + * store permanently missing the item(s), silently diverging from the normal guarantee (shared by + * every other DbProvider, including this same InMemoryProvider's own `put()`) that every index on + * a store always reflects every item in that store. */ - put( + putInIndexAfterGet_DoNotUse( storeName: string, itemOrItems: ItemType | ItemType[], - indexNames?: string[] + indexNames: string[] ): Promise { return this._getStoreTransaction(storeName, true).then((store) => { - return (store as InMemoryStore).put(itemOrItems, indexNames); + return (store as InMemoryStore).putInIndexAfterGet_DoNotUse( + itemOrItems, + indexNames + ); }); } } +/** + * Specialized capability for providers that can scope in-memory index writes to only the + * index(es) that were actually queried, instead of populating every index on the store (see + * `InMemoryProvider.putInIndexAfterGet_DoNotUse()` for the full rationale and warnings). + * + * This is intentionally kept off the public `InMemoryProvider` class surface -- and off the + * shared `DbStore`/`DbProvider` interfaces entirely -- so that reaching for it always requires + * going through `asScopedIndexPutProvider()` below rather than casting/typing a `DbProvider` + * reference as `InMemoryProvider` and calling it directly. + */ +export interface IScopedIndexPutProvider { + putInIndexAfterGet_DoNotUse( + storeName: string, + itemOrItems: ItemType | ItemType[], + indexNames: string[] + ): Promise; +} + +/** + * The only supported way to reach the `IScopedIndexPutProvider` capability described above. + * + * Returns `provider` narrowed to `IScopedIndexPutProvider` when it actually supports scoped index + * puts (currently: any `InMemoryProvider` instance), or `undefined` otherwise. Routing through this + * helper -- instead of casting a `DbProvider` to `InMemoryProvider` -- makes every call site that + * opts into breaking the normal "every index stays in sync" guarantee explicit and easy to find/audit. + */ +export function asScopedIndexPutProvider( + provider: DbProvider +): IScopedIndexPutProvider | undefined { + return provider instanceof InMemoryProvider + ? (provider as unknown as IScopedIndexPutProvider) + : undefined; +} + // Notes: Doesn't limit the stores it can fetch to those in the stores it was "created" with, nor does it handle read-only transactions class InMemoryTransaction implements DbTransaction { private _stores: Map = new Map(); @@ -348,18 +392,33 @@ class InMemoryStore implements DbStore { ); } + put(itemOrItems: ItemType | ItemType[]): Promise { + return this._putInternal(itemOrItems); + } + /** - * @param indexNames Optional scoping hint: when provided, brand-new items (not already present in the - * store) are only written into the primary key plus the listed index(es), instead of every index on the - * store. This lets callers who fetched data through a single index (e.g. a ranged read served from that - * index) cache the results without seeding "islands" of items into unrelated indexes that were never - * actually queried/loaded for those items. Items that already exist in the store keep being kept in sync - * across every index they were previously tracked by, so already-cached data never goes stale. + * DO NOT USE unless you're re-populating an in-memory cache from a ranged read that only ever + * touched the listed index(es) -- see `InMemoryProvider.putInIndexAfterGet_DoNotUse()` for the + * full rationale and warnings. Deliberately named/kept separate from `put()` above (rather than + * an optional 3rd parameter on it) so that the normal, always-safe `put()` required by the shared + * `DbStore` interface can never accidentally be called with scoping semantics. * - * NOTE: this parameter is intentionally NOT part of the shared DbStore interface -- it's only reachable - * via InMemoryProvider.put() (see there), so it's a compile error to use it against any other provider. + * @param indexNames Scoping hint: brand-new items (not already present in the store) are only + * written into the primary key plus the listed index(es), instead of every index on the store. + * This lets callers who fetched data through a single index (e.g. a ranged read served from that + * index) cache the results without seeding "islands" of items into unrelated indexes that were + * never actually queried/loaded for those items. Items that already exist in the store keep being + * kept in sync across every index they were previously tracked by, so already-cached data never + * goes stale. */ - put( + putInIndexAfterGet_DoNotUse( + itemOrItems: ItemType | ItemType[], + indexNames: string[] + ): Promise { + return this._putInternal(itemOrItems, indexNames); + } + + private _putInternal( itemOrItems: ItemType | ItemType[], indexNames?: string[] ): Promise { diff --git a/src/ObjectStoreProvider.ts b/src/ObjectStoreProvider.ts index eab05ec..4b9940c 100644 --- a/src/ObjectStoreProvider.ts +++ b/src/ObjectStoreProvider.ts @@ -266,8 +266,8 @@ export abstract class DbProvider { /** * Protected (rather than private) so that subclasses which need extra, provider-specific put() semantics - * (e.g. InMemoryProvider's indexNames scoping -- see its put() override) can reuse this instead of - * re-implementing store-transaction resolution. + * (e.g. InMemoryProvider's `putInIndexAfterGet_DoNotUse()` indexNames scoping -- see there) can reuse + * this instead of re-implementing store-transaction resolution. */ protected _getStoreTransaction( storeName: string, diff --git a/src/tests/ObjectStoreProvider.spec.ts b/src/tests/ObjectStoreProvider.spec.ts index 048431b..688c9aa 100644 --- a/src/tests/ObjectStoreProvider.spec.ts +++ b/src/tests/ObjectStoreProvider.spec.ts @@ -15,7 +15,7 @@ import { UpgradeCallback, } from "../ObjectStoreProvider"; -import { InMemoryProvider } from "../InMemoryProvider"; +import { InMemoryProvider, asScopedIndexPutProvider } from "../InMemoryProvider"; import { IndexedDbProvider, IndexedDbTransaction } from "../IndexedDbProvider"; import * as IndexedDbProviderModule from "../IndexedDbProvider"; import { @@ -2126,14 +2126,20 @@ describe("ObjectStoreProvider", function () { true ) .then((prov) => { - // indexNames scoping is only reachable via a reference typed as InMemoryProvider -- it's - // intentionally not part of the shared DbStore/DbProvider interfaces so it's a compile error - // to use it against any other provider (e.g. the real IndexedDbProvider/db path). - const memoryProv = prov as InMemoryProvider; - return memoryProv - .put("test", { id: "item1", a: "valA1", b: "valB1" }, [ - "indexA", - ]) + // indexNames scoping is only reachable via asScopedIndexPutProvider() -- it's + // intentionally not part of the shared DbStore/DbProvider interfaces (nor of + // InMemoryProvider's public put()), so this returns undefined for any provider that + // doesn't support it, and it'd be a compile error to call + // putInIndexAfterGet_DoNotUse() directly against a plain DbProvider reference. + const maybeScopedPutProv = asScopedIndexPutProvider(prov); + assert(!!maybeScopedPutProv); + const scopedPutProv = maybeScopedPutProv!!!; + return scopedPutProv + .putInIndexAfterGet_DoNotUse( + "test", + { id: "item1", a: "valA1", b: "valB1" }, + ["indexA"] + ) .then(() => { return Promise.all([ prov.get("test", "item1"), @@ -2157,8 +2163,8 @@ describe("ObjectStoreProvider", function () { // Once the item is already tracked in memory, subsequent scoped puts (e.g. an update // fetched again via indexA) must keep it in sync across every index it's already part // of, rather than leaving stale/missing entries in indexes that were skipped this time. - return memoryProv - .put( + return scopedPutProv + .putInIndexAfterGet_DoNotUse( "test", { id: "item1", a: "valA1-updated", b: "valB1-updated" }, ["indexA"] From b3b485acfcfe703639e268f9c48bec8c6691e6e0 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Mon, 10 Aug 2026 19:58:29 -0700 Subject: [PATCH 7/8] Scope index removal to targeted index(es) in putInIndexAfterGet_DoNotUse Previously, a scoped put via putInIndexAfterGet_DoNotUse only skipped populating unrelated indexes for brand-new items. If the item already existed in the store, the old removal step swept every index on the schema before re-adding, effectively broadcasting the scoped put into every index anyway. Now both the removal and the re-add use the same indexesToTouch list (derived from the scoping hint when provided, else all schema indexes), so an index that wasn't part of this call is never disturbed either way -- it's neither seeded with new data nor evicted of data it already had. This closes the last gap where scoped puts for existing items could still create/heal islands in indexes they weren't meant to touch. Real (unscoped) put() calls are unaffected: _removeFromIndices' new indexesToRemoveFrom parameter defaults to every schema index, so full writes keep resyncing every index as before. Updated putInIndexAfterGet_DoNotUse's JSDoc and the existing unit test to reflect the new both-directions scoping behavior, and added coverage for the "island healed by a real unscoped put" invariant. --- src/InMemoryProvider.ts | 41 ++++++++------ src/tests/ObjectStoreProvider.spec.ts | 82 +++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index 64a0543..87da32d 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -403,13 +403,14 @@ class InMemoryStore implements DbStore { * an optional 3rd parameter on it) so that the normal, always-safe `put()` required by the shared * `DbStore` interface can never accidentally be called with scoping semantics. * - * @param indexNames Scoping hint: brand-new items (not already present in the store) are only - * written into the primary key plus the listed index(es), instead of every index on the store. - * This lets callers who fetched data through a single index (e.g. a ranged read served from that + * @param indexNames Scoping hint: items are only removed from / re-written into the primary key + * plus the listed index(es) -- every other index on the store is left completely untouched. This + * lets callers who fetched data through a single index (e.g. a ranged read served from that * index) cache the results without seeding "islands" of items into unrelated indexes that were - * never actually queried/loaded for those items. Items that already exist in the store keep being - * kept in sync across every index they were previously tracked by, so already-cached data never - * goes stale. + * never actually queried/loaded for those items, and without evicting an already-cached item from + * indexes this call didn't touch. Note that an index left untouched here can only go stale in + * content if the item's data changes without ever going through the real (unscoped) `put()` path -- + * real writes always resync every index. */ putInIndexAfterGet_DoNotUse( itemOrItems: ItemType | ItemType[], @@ -432,26 +433,29 @@ class InMemoryStore implements DbStore { this._storeSchema.primaryKeyPath )!!!; const existingItem = this._mergedData.get(pk); + + // Scope both the removal (of the stale copy) and the re-add (of the new copy) to the + // same index list, so an index this call didn't ask for is never touched either way. + const indexesToTouch = indexNames + ? filter(this._storeSchema.indexes, (index) => + includes(indexNames, index.name) + ) + : this._storeSchema.indexes; + if (existingItem) { // We're going to overwrite the PK anyways - don't remove PK this._removeFromIndices( pk, existingItem, - /** RemovePrimaryKey */ false + /** RemovePrimaryKey */ false, + indexesToTouch ); } this._mergedData.set(pk, item); (this.openPrimaryKey() as InMemoryIndex).put(item); - const indexesToPopulate = - indexNames && !existingItem - ? filter(this._storeSchema.indexes, (index) => - includes(indexNames, index.name) - ) - : this._storeSchema.indexes; - - if (indexesToPopulate) { - for (const index of indexesToPopulate) { + if (indexesToTouch) { + for (const index of indexesToTouch) { (this.openIndex(index.name) as InMemoryIndex).put(item); } } @@ -599,7 +603,8 @@ class InMemoryStore implements DbStore { private _removeFromIndices( key: string, item: ItemType, - removePrimaryKey: boolean + removePrimaryKey: boolean, + indexesToRemoveFrom: IndexSchema[] = this._storeSchema.indexes ?? [] ) { // Don't need to remove from primary key on Puts because set is enough // 1. If it's an existing key then it will get overwritten @@ -608,7 +613,7 @@ class InMemoryStore implements DbStore { (this.openPrimaryKey() as InMemoryIndex).remove(key); } - each(this._storeSchema.indexes, (index: IndexSchema) => { + each(indexesToRemoveFrom, (index: IndexSchema) => { const ind = this.openIndex(index.name) as InMemoryIndex; const indexKeys = ind.internal_getKeysFromItem(item); diff --git a/src/tests/ObjectStoreProvider.spec.ts b/src/tests/ObjectStoreProvider.spec.ts index 688c9aa..f69ae6d 100644 --- a/src/tests/ObjectStoreProvider.spec.ts +++ b/src/tests/ObjectStoreProvider.spec.ts @@ -15,7 +15,10 @@ import { UpgradeCallback, } from "../ObjectStoreProvider"; -import { InMemoryProvider, asScopedIndexPutProvider } from "../InMemoryProvider"; +import { + InMemoryProvider, + asScopedIndexPutProvider, +} from "../InMemoryProvider"; import { IndexedDbProvider, IndexedDbTransaction } from "../IndexedDbProvider"; import * as IndexedDbProviderModule from "../IndexedDbProvider"; import { @@ -2161,8 +2164,10 @@ describe("ObjectStoreProvider", function () { }) .then(() => { // Once the item is already tracked in memory, subsequent scoped puts (e.g. an update - // fetched again via indexA) must keep it in sync across every index it's already part - // of, rather than leaving stale/missing entries in indexes that were skipped this time. + // fetched again via indexA) are removed from and re-added to that SAME scoped index + // list only -- an index that was never populated for this item (indexB) stays + // untouched, rather than being seeded with data that was never actually loaded + // through it (the "memory island" bug this scoping exists to avoid). return scopedPutProv .putInIndexAfterGet_DoNotUse( "test", @@ -2177,8 +2182,39 @@ describe("ObjectStoreProvider", function () { assert.equal(byIndexA.length, 1); assert.equal((byIndexA[0] as any).a, "valA1-updated"); + // Still untouched: indexB was never scoped in for item1. + assert.equal(byIndexB.length, 0); + }); + }); + }) + .then(() => { + // A later scoped put through the OTHER index (indexB) populates indexB for item1 + // without disturbing indexA, since removal/re-add is scoped per-call. + return scopedPutProv + .putInIndexAfterGet_DoNotUse( + "test", + { + id: "item1", + a: "valA1-updated", + b: "valB1-updated-again", + }, + ["indexB"] + ) + .then(() => { + return Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + ]).then(([byIndexA, byIndexB]) => { + // indexA keeps its previous entry untouched by this indexB-scoped call. + assert.equal(byIndexA.length, 1); + assert.equal((byIndexA[0] as any).a, "valA1-updated"); + + // indexB is now populated for item1 for the first time. assert.equal(byIndexB.length, 1); - assert.equal((byIndexB[0] as any).b, "valB1-updated"); + assert.equal( + (byIndexB[0] as any).b, + "valB1-updated-again" + ); }); }); }) @@ -2196,6 +2232,44 @@ describe("ObjectStoreProvider", function () { }); }); }) + .then(() => { + // A real (unscoped) put on an item that only exists in a subset of indexes -- e.g. + // one previously cached as an "island" via a single scoped get -- resyncs it into + // every index, since real writes never pass indexNames. + return scopedPutProv + .putInIndexAfterGet_DoNotUse( + "test", + { id: "item3", a: "valA3", b: "valB3" }, + ["indexA"] + ) + .then(() => + Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + ]) + ) + .then(([byIndexA, byIndexB]) => { + assert.equal(byIndexA.length, 3); + assert.equal(byIndexB.length, 2); // item3 not yet in indexB + }) + .then(() => + prov.put("test", { + id: "item3", + a: "valA3-real-write", + b: "valB3-real-write", + }) + ) + .then(() => + Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + ]) + ) + .then(([byIndexA, byIndexB]) => { + assert.equal(byIndexA.length, 3); + assert.equal(byIndexB.length, 3); // healed by the unscoped write + }); + }) .then(() => prov.close()) .catch((e) => prov.close().then(() => Promise.reject(e))); }) From e0feb7720db8603046c31533665f0150295c24f0 Mon Sep 17 00:00:00 2001 From: Eliran Eretz-Kedosha Date: Tue, 11 Aug 2026 11:35:05 -0700 Subject: [PATCH 8/8] Fix stale index copies on scoped put after get Jeremie flagged that putInIndexAfterGet_DoNotUse could leave a stale copy of an item in any index that wasn't part of the caller's scoped indexNames list, since each InMemoryIndex holds its own independent copy of an item's data. When an existing item is being overwritten, always remove the stale copy from every index it's actually cached by (not just the requested ones), then repopulate the union of "indexes that had it" and the caller's requested indexes. New items (never cached before) are unaffected and still only populate the requested/scoped index(es), preserving the original anti-"memory island" guarantee. - InMemoryIndex.remove() now returns whether it actually removed an entry, so callers can tell which indexes held the item. - _removeFromIndices() returns the list of indexes an item was actually removed from. - _putInternal (backing both put() and putInIndexAfterGet_DoNotUse()) uses that list to compute which indexes to repopulate. Added a regression test exercising Jeremie's exact scenario: an item cached across 3 indexes via a normal put(), then a scoped put via 2 of them with changed data -- asserts the 3rd (untouched by indexNames) index reflects the new data instead of the old, stale copy. --- src/InMemoryProvider.ts | 90 ++++++++++++++------ src/tests/ObjectStoreProvider.spec.ts | 114 ++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 24 deletions(-) diff --git a/src/InMemoryProvider.ts b/src/InMemoryProvider.ts index 87da32d..4e96710 100644 --- a/src/InMemoryProvider.ts +++ b/src/InMemoryProvider.ts @@ -21,6 +21,7 @@ import { takeRight, drop, take, + unionBy, } from "lodash"; import { DbIndexFTSFromRangeQueries, @@ -403,14 +404,17 @@ class InMemoryStore implements DbStore { * an optional 3rd parameter on it) so that the normal, always-safe `put()` required by the shared * `DbStore` interface can never accidentally be called with scoping semantics. * - * @param indexNames Scoping hint: items are only removed from / re-written into the primary key - * plus the listed index(es) -- every other index on the store is left completely untouched. This - * lets callers who fetched data through a single index (e.g. a ranged read served from that - * index) cache the results without seeding "islands" of items into unrelated indexes that were - * never actually queried/loaded for those items, and without evicting an already-cached item from - * indexes this call didn't touch. Note that an index left untouched here can only go stale in - * content if the item's data changes without ever going through the real (unscoped) `put()` path -- - * real writes always resync every index. + * @param indexNames Scoping hint for *newly seen* items only: an item this store has never + * cached before is only written into the primary key plus the listed index(es) -- every other + * index on the store is left untouched, so callers who fetched data through a single index (e.g. + * a ranged read served from that index) can cache the results without seeding "islands" of items + * into unrelated indexes that were never actually queried/loaded for those items. + * + * If the item was already cached, the stale copy is removed from -- and the fresh copy is + * re-written into -- every index it was actually present in (not just the listed one(s)), so an + * index this call didn't ask for can never end up holding stale content. The listed index(es) are + * additionally guaranteed to receive the fresh copy even if the item wasn't previously tracked by + * them, so the ranged read that triggered this call still gets a correctly populated cache there. */ putInIndexAfterGet_DoNotUse( itemOrItems: ItemType | ItemType[], @@ -434,28 +438,43 @@ class InMemoryStore implements DbStore { )!!!; const existingItem = this._mergedData.get(pk); - // Scope both the removal (of the stale copy) and the re-add (of the new copy) to the - // same index list, so an index this call didn't ask for is never touched either way. - const indexesToTouch = indexNames + // Scoping hint from the caller: for a brand-new item (never cached before) this is where + // the fresh copy is written. Left undefined (i.e. every index) for the normal, unscoped + // put() path. + const requestedIndexes = indexNames ? filter(this._storeSchema.indexes, (index) => includes(indexNames, index.name) ) : this._storeSchema.indexes; + let indexesToPopulate = requestedIndexes; + if (existingItem) { - // We're going to overwrite the PK anyways - don't remove PK - this._removeFromIndices( + // Always remove the stale copy from every index it's actually present in -- each index + // holds its own copy of the item, so scoping the removal to just the requested index(es) + // would leave an un-refreshed, stale copy behind in any other index the item was already + // tracked by (see PR #87 discussion). + const indexesThatHadItem = this._removeFromIndices( pk, existingItem, /** RemovePrimaryKey */ false, - indexesToTouch + this._storeSchema.indexes ?? [] ); + + // Re-populate every index the item was actually already cached by (so it's refreshed, + // never left stale) plus whichever index(es) this call is scoped to (so a ranged read + // still gets a correctly populated cache there, even for an index the item wasn't + // previously tracked by). + indexesToPopulate = indexNames + ? unionBy(indexesThatHadItem, requestedIndexes ?? [], "name") + : this._storeSchema.indexes; } + this._mergedData.set(pk, item); (this.openPrimaryKey() as InMemoryIndex).put(item); - if (indexesToTouch) { - for (const index of indexesToTouch) { + if (indexesToPopulate) { + for (const index of indexesToPopulate) { (this.openIndex(index.name) as InMemoryIndex).put(item); } } @@ -605,7 +624,7 @@ class InMemoryStore implements DbStore { item: ItemType, removePrimaryKey: boolean, indexesToRemoveFrom: IndexSchema[] = this._storeSchema.indexes ?? [] - ) { + ): IndexSchema[] { // Don't need to remove from primary key on Puts because set is enough // 1. If it's an existing key then it will get overwritten // 2. If it's a new key then we need to add it @@ -613,6 +632,8 @@ class InMemoryStore implements DbStore { (this.openPrimaryKey() as InMemoryIndex).remove(key); } + const indexesThatHadItem: IndexSchema[] = []; + each(indexesToRemoveFrom, (index: IndexSchema) => { const ind = this.openIndex(index.name) as InMemoryIndex; const indexKeys = ind.internal_getKeysFromItem(item); @@ -620,14 +641,27 @@ class InMemoryStore implements DbStore { // when it's a unique index, value is the item. // in case of a non-unique index, value is an array of items, // and we want to only remove items that have the same primary key + let removedFromThisIndex = false; if (ind.isUniqueIndex()) { - each(indexKeys, (indexKey: string) => ind.remove(indexKey)); + each(indexKeys, (indexKey: string) => { + if (ind.remove(indexKey)) { + removedFromThisIndex = true; + } + }); } else { - each(indexKeys, (idxKey: string) => - ind.remove({ idxKey, primaryKey: key }) - ); + each(indexKeys, (idxKey: string) => { + if (ind.remove({ idxKey, primaryKey: key })) { + removedFromThisIndex = true; + } + }); + } + + if (removedFromThisIndex) { + indexesThatHadItem.push(index); } }); + + return indexesThatHadItem; } } @@ -739,22 +773,24 @@ class InMemoryIndex extends DbIndexFTSFromRangeQueries { * Removes item from index. For non-unique indices, a pair of index value and a primary key is required. * @param key a string, if it's a unique index, a pair of key value and a primary key, if it's a non-unique index * @param skipTransactionOnCreation - * @returns + * @returns Whether an entry was actually found and removed. */ public remove( key: string | { primaryKey: string; idxKey: string }, skipTransactionOnCreation?: boolean - ) { + ): boolean { if (!skipTransactionOnCreation && !this._trans!.internal_isOpen()) { throw new Error("InMemoryTransaction already closed"); } if (typeof key === "string") { + const hadKey = this._indexTree.has(key); this._indexTree.delete(key); + return hadKey; } else { const idxItems = this._indexTree.get(key.idxKey); if (!idxItems) { - return; + return false; } const idxItemsWithoutItem = idxItems.filter((idxItem) => { @@ -765,6 +801,11 @@ class InMemoryIndex extends DbIndexFTSFromRangeQueries { return idxItemPrimaryKeyVal !== key.primaryKey; }); + if (idxItemsWithoutItem.length === idxItems.length) { + // Nothing matched key.primaryKey -- no-op. + return false; + } + // if we removed all items, remove the index tree node. // otherwise, update the index value with the new array // sans the primary key item @@ -773,6 +814,7 @@ class InMemoryIndex extends DbIndexFTSFromRangeQueries { } else { this._indexTree.set(key.idxKey, idxItemsWithoutItem); } + return true; } } diff --git a/src/tests/ObjectStoreProvider.spec.ts b/src/tests/ObjectStoreProvider.spec.ts index f69ae6d..9a4f3d0 100644 --- a/src/tests/ObjectStoreProvider.spec.ts +++ b/src/tests/ObjectStoreProvider.spec.ts @@ -2279,6 +2279,120 @@ describe("ObjectStoreProvider", function () { ); }); + it("put with indexNames refreshes -- rather than skips -- any OTHER index the item was already tracked by, so it never goes stale", (done) => { + // indexNames scoping is an InMemoryProvider-only optimization -- other providers either maintain + // indexes natively (indexeddb) or ignore the extra parameter entirely. + if (provName.indexOf("memory") === -1) { + done(); + return; + } + + openProvider( + provName, + { + version: 1, + stores: [ + { + name: "test", + primaryKeyPath: "id", + indexes: [ + { name: "indexA", keyPath: "a" }, + { name: "indexB", keyPath: "b" }, + { name: "indexC", keyPath: "c" }, + ], + }, + ], + }, + true + ) + .then((prov) => { + const maybeScopedPutProv = asScopedIndexPutProvider(prov); + assert(!!maybeScopedPutProv); + const scopedPutProv = maybeScopedPutProv!!!; + + // A real (unscoped) write populates the item into all three indexes. + return prov + .put("test", { id: "item1", a: "a-1", b: "b-1", c: "c-1" }) + .then(() => + Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + prov.getAll("test", "indexC"), + ]) + ) + .then(([byIndexA, byIndexB, byIndexC]) => { + assert.equal(byIndexA.length, 1); + assert.equal(byIndexB.length, 1); + assert.equal(byIndexC.length, 1); + }) + .then(() => { + // A later scoped "write after get" only lists indexA and indexB -- indexC is not + // in the scoped list, but the item's data has changed. indexC must still be + // refreshed with the new data (not left holding its own stale, un-refreshed copy) + // since it already had this item tracked before this call. + return scopedPutProv.putInIndexAfterGet_DoNotUse( + "test", + { + id: "item1", + a: "a-1-updated", + b: "b-1-updated", + c: "c-1-updated", + }, + ["indexA", "indexB"] + ); + }) + .then(() => + Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + prov.getAll("test", "indexC"), + ]) + ) + .then(([byIndexA, byIndexB, byIndexC]) => { + assert.equal(byIndexA.length, 1); + assert.equal((byIndexA[0] as any).a, "a-1-updated"); + + assert.equal(byIndexB.length, 1); + assert.equal((byIndexB[0] as any).b, "b-1-updated"); + + // The key regression check: indexC was not in the scoped indexNames list for this + // call, but it already tracked item1, so it must be refreshed with the new data + // rather than left holding the stale "c-1" copy. + assert.equal(byIndexC.length, 1); + assert.equal((byIndexC[0] as any).c, "c-1-updated"); + }) + .then(() => { + // A brand-new item (never cached before) scoped to a subset of indexes must still + // only populate the requested index(es) -- the anti-"memory island" guarantee this + // scoping exists for is unaffected by the staleness fix above. + return scopedPutProv + .putInIndexAfterGet_DoNotUse( + "test", + { id: "item2", a: "a-2", b: "b-2", c: "c-2" }, + ["indexA"] + ) + .then(() => + Promise.all([ + prov.getAll("test", "indexA"), + prov.getAll("test", "indexB"), + prov.getAll("test", "indexC"), + ]) + ) + .then(([byIndexA, byIndexB, byIndexC]) => { + assert.equal(byIndexA.length, 2); + assert.equal(byIndexB.length, 1); // item2 not seeded into indexB + assert.equal(byIndexC.length, 1); // item2 not seeded into indexC + }); + }) + .then(() => prov.close()) + .catch((e) => prov.close().then(() => Promise.reject(e))); + }) + .then( + () => done(), + (err) => done(err) + ); + }); + it("Invalid Key Type", (done) => { openProvider( provName,