From 723ed069072968083b628ab631322e3ac848235a Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Thu, 10 Sep 2026 14:44:26 -0400 Subject: [PATCH 001/109] perf(fetchium): write an entity once per streamed event, not two or three times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `applyMutationEvent` calls `applyEntityRefs(ctx, parsed, /* persist */ true)`, which walks to the event's entity and writes it through `setChildRefs`. It then called `entityMap.save(entity)` on the same entity again, unconditionally, on the next line. `LiveArrayInstance.add` added a third write, calling `child.save()` on a child the same apply pass had already written. Measured against main: an update or create event wrote the entity twice, and an event inserting into a live array wrote the child three times. Tests now assert the counts. Neither removal loses a write. The apply already persisted the entity, which is also what makes the remaining write enough on its own — there is a test that a fresh client over the same store still reads an entity written by an event. The duplication is invisible on `main`, where the apply writes unconditionally, and actively harmful alongside the change-detection work: that skips the write for an entity nothing changed, and this save put it back. On a subscription delivering ~85 entity events per second where 99% carry data identical to what is already stored, that came to ~83 redundant writes per second. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/remove-redundant-entity-saves.md | 7 + packages/fetchium/src/LiveCollection.ts | 5 +- packages/fetchium/src/QueryClient.ts | 4 +- .../src/__tests__/entity-write-counts.test.ts | 154 ++++++++++++++++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 .changeset/remove-redundant-entity-saves.md create mode 100644 packages/fetchium/src/__tests__/entity-write-counts.test.ts diff --git a/.changeset/remove-redundant-entity-saves.md b/.changeset/remove-redundant-entity-saves.md new file mode 100644 index 0000000..b2453e2 --- /dev/null +++ b/.changeset/remove-redundant-entity-saves.md @@ -0,0 +1,7 @@ +--- +'fetchium': patch +--- + +Stop writing an entity to the store more than once per streamed event. `applyMutationEvent` ran `applyEntityRefs` with `persist: true`, which walks to the entity and writes it, and then called `entityMap.save` on the same entity again unconditionally. `LiveArrayInstance.add` added a third write, re-saving a child the apply pass had already written. An update or create event wrote the entity twice, and an event that inserted into a live array wrote the child three times. + +Nothing else changes: the remaining write is the one the apply already performed, so an entity written by an event is still readable after a restart. diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 75b9bb4..977736a 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -300,8 +300,11 @@ export class LiveArrayInstance { const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { + // Only the parent's ref set changes here; the child's own write belongs + // to the apply pass that produced the insert. An append merge run with + // `persist: false` therefore no longer writes a child the caller asked + // to keep out of the store. this._parent.addChildRef(child); - child.save(); } this._notifier.notify(); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index c541d97..6223f28 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -391,7 +391,7 @@ export class QueryClient { const parseCtx = this.mutationParseContext; parseCtx.reset(this, undefined, warn, /* isPartialEvent */ true); const parsedData = parseEntity(data, mergedDef as unknown as EntityDef, parseCtx); - applyEntityRefs(parseCtx, parsedData, true); + applyEntityRefs(parseCtx, parsedData, /* persist */ true); } catch (e) { // Unknown required-union variant: surface as an error (not a silent warn) // so the dropped update is visible. Optional unions degrade during parse. @@ -410,8 +410,6 @@ export class QueryClient { const entity = this.entityMap.getEntity(key); if (entity === undefined) return; - this.entityMap.save(entity); - const wasNew = existing === undefined; let matched = false; diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts new file mode 100644 index 0000000..adedb03 --- /dev/null +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient } from '../QueryClient.js'; +import { testWithClient, setupTestClient, sleep } from './utils.js'; + +/** + * Entity Write Counts + * + * A streamed entity event used to write the entity twice: once through + * `applyEntityRefs(persist: true)` and again through an unconditional save in + * `applyMutationEvent`. These pin the write count so the duplicate can't come + * back, and pin that a single write is still enough to survive a restart. + */ + +class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + listId = t.string; + name = t.string; +} + +class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.entity(Item)) }; +} + +function itemSaves(spy: ReturnType, typename = 'Item') { + return spy.mock.calls.filter(([, value]) => (value as Record)?.__typename === typename); +} + +describe('Entity Write Counts', () => { + const getClient = setupTestClient(); + + it('writes an entity once per streamed event that changes it', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetItems); + await query; + }); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'B' } }); + + expect(itemSaves(saveEntity)).toHaveLength(1); + expect(client.entityMap.getEntity(hashValue(['Item', 'i-1']))!.data.name).toBe('B'); + }); + + it('writes an entity once per streamed event that creates it', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetItems); + await query; + }); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: 'i-2', listId: 'l-1', name: 'C' }, + }); + + expect(itemSaves(saveEntity)).toHaveLength(1); + }); + + it('does not re-write a child when a live array gains it', async () => { + const { client, mockFetch, store } = getClient(); + + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + items = t.liveArray(Item, { constraints: { listId: (this as unknown as { id: string }).id } }); + } + class GetList extends RESTQuery { + path = '/list'; + result = { list: t.entity(List) }; + } + + mockFetch.get('/list', { + list: { + __typename: 'List', + id: 'l-1', + items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }], + }, + }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetList); + await query; + }); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: 'i-2', listId: 'l-1', name: 'B' }, + }); + await sleep(5); + + // The apply writes the new child once. The parent is written because its + // ref set changed. Neither should write the child a second time. + expect(itemSaves(saveEntity)).toHaveLength(1); + // The parent's ref set genuinely changed, so it is written — once. + expect(itemSaves(saveEntity, 'List')).toHaveLength(1); + }); + + it('keeps an entity written by an event readable by a later client', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetItems); + await query; + }); + + client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'Persisted' } }); + await sleep(5); + client.destroy(); + + // One write has to be enough for the read path to find it, not just enough + // to put bytes on disk. Serve something different so a value of + // 'Persisted' can only have come from the store. + mockFetch.reset(); + mockFetch.get( + '/items', + { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'Refetched' }] }, + { delay: 50 }, + ); + + const client2 = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + }); + + await testWithClient(client2, async () => { + const query = fetchQuery(GetItems); + // Force a pull so the store hydration starts, without awaiting the + // refetch — the cached value has to win. + void query.value; + await sleep(); + expect((query.value as unknown as { items: { name: string }[] }).items[0].name).toBe('Persisted'); + }); + + client2.destroy(); + }); +}); From 180cd36f55a027290bf8e50da7f4c32469c9b94a Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 11:43:48 -0400 Subject: [PATCH 002/109] cleanup comment Signed-off-by: Adam Azbel-Bedell --- packages/fetchium/src/LiveCollection.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 977736a..3d564a0 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -300,10 +300,7 @@ export class LiveArrayInstance { const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { - // Only the parent's ref set changes here; the child's own write belongs - // to the apply pass that produced the insert. An append merge run with - // `persist: false` therefore no longer writes a child the caller asked - // to keep out of the store. + // The child was already saved by the apply pass that produced it. Only the parent's ref set needs persisting here. this._parent.addChildRef(child); } From cfc2582564aec3524ca0ac734fbdae80485a639f Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 14:41:06 -0400 Subject: [PATCH 003/109] test(fetchium): pin zero writes for a streamed event that changes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This case could not be tested from either PR alone: with the change-detection merge but not this removal, ten identical events still produced ten writes, and with this removal but not the change detection, also ten. Both are now on main, so the combination is finally assertable — ten identical events write nothing, and the next event that actually changes the entity writes once. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/entity-write-counts.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index adedb03..897e004 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -52,6 +52,29 @@ describe('Entity Write Counts', () => { expect(client.entityMap.getEntity(hashValue(['Item', 'i-1']))!.data.name).toBe('B'); }); + it('writes nothing for streamed events that repeat the current value', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetItems); + await query; + }); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + + // A subscription re-broadcasting what the store already holds: the apply + // declines the write, and nothing writes on its behalf afterwards. + for (let i = 0; i < 10; i++) { + client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'A' } }); + } + expect(itemSaves(saveEntity)).toHaveLength(0); + + client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'B' } }); + expect(itemSaves(saveEntity)).toHaveLength(1); + expect(client.entityMap.getEntity(hashValue(['Item', 'i-1']))!.data.name).toBe('B'); + }); + it('writes an entity once per streamed event that creates it', async () => { const { client, mockFetch, store } = getClient(); mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); From a404c2822488d7b75df794f543b23bbb18ccd1da Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 16:41:02 -0400 Subject: [PATCH 004/109] fix(fetchium): correct the comment on the skipped child save The comment claimed the child "was already saved by the apply pass that produced it." That is false whenever the child's data is unchanged: the apply declines the write, so no save happens on this pass. The record is still current, just not necessarily written by this pass. Co-Authored-By: Claude Opus 5 --- packages/fetchium/src/LiveCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 5232bef..9262243 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -311,7 +311,7 @@ export class LiveArrayInstance { const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { - // The child was already saved by the apply pass that produced it. Only the parent's ref set needs persisting here. + // Only the parent's ref set needs persisting; the child's record is current. this._parent.addChildRef(child); } From 545c5b1071ee326008c4ad808a86526db66bc3a3 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 16:41:13 -0400 Subject: [PATCH 005/109] test(fetchium): pin zero child writes when a live array gains an unchanged entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entity can enter the graph through one query and later be inserted into a live array by an event carrying identical data. The apply declines the child write, so the insert must not add one: child 0 writes, parent 1. Fails on main, where add() re-saves the child. Also hoist the List fixtures now that two tests use them, and raise the refetch delay in the later-client test so the store read cannot race it — it passed at every delay from 0 up, so the value was never load-bearing. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/entity-write-counts.test.ts | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index 897e004..be6b345 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -14,7 +14,7 @@ import { testWithClient, setupTestClient, sleep } from './utils.js'; * A streamed entity event used to write the entity twice: once through * `applyEntityRefs(persist: true)` and again through an unconditional save in * `applyMutationEvent`. These pin the write count so the duplicate can't come - * back, and pin that a single write is still enough to survive a restart. + * back, and pin that a single write is still enough for a later client to read. */ class Item extends Entity { @@ -29,6 +29,17 @@ class GetItems extends RESTQuery { result = { items: t.array(t.entity(Item)) }; } +class List extends Entity { + __typename = t.typename('List'); + id = t.id; + items = t.liveArray(Item, { constraints: { listId: (this as unknown as { id: string }).id } }); +} + +class GetList extends RESTQuery { + path = '/list'; + result = { list: t.entity(List) }; +} + function itemSaves(spy: ReturnType, typename = 'Item') { return spy.mock.calls.filter(([, value]) => (value as Record)?.__typename === typename); } @@ -97,16 +108,6 @@ describe('Entity Write Counts', () => { it('does not re-write a child when a live array gains it', async () => { const { client, mockFetch, store } = getClient(); - class List extends Entity { - __typename = t.typename('List'); - id = t.id; - items = t.liveArray(Item, { constraints: { listId: (this as unknown as { id: string }).id } }); - } - class GetList extends RESTQuery { - path = '/list'; - result = { list: t.entity(List) }; - } - mockFetch.get('/list', { list: { __typename: 'List', @@ -135,6 +136,36 @@ describe('Entity Write Counts', () => { expect(itemSaves(saveEntity, 'List')).toHaveLength(1); }); + it('writes only the parent when a live array gains a child that did not change', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); + mockFetch.get('/list', { list: { __typename: 'List', id: 'l-1', items: [] } }); + + let listQuery!: { value: { list: { items: { id: string }[] } } }; + await testWithClient(client, async () => { + const items = fetchQuery(GetItems); + await items; + const query = fetchQuery(GetList); + await query; + listQuery = query as unknown as typeof listQuery; + }); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + // The item is already current in the store from the first query, so the + // apply declines its write. The insert must not put one back. + client.applyMutationEvent({ + type: 'update', + typename: 'Item', + data: { id: 'i-1', listId: 'l-1', name: 'A' }, + }); + await sleep(5); + + expect(itemSaves(saveEntity)).toHaveLength(0); + // Only the parent's ref set changed, and that write proves the insert ran. + expect(itemSaves(saveEntity, 'List')).toHaveLength(1); + expect(listQuery.value.list.items.map(i => i.id)).toEqual(['i-1']); + }); + it('keeps an entity written by an event readable by a later client', async () => { const { client, mockFetch, store } = getClient(); mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); @@ -155,7 +186,7 @@ describe('Entity Write Counts', () => { mockFetch.get( '/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'Refetched' }] }, - { delay: 50 }, + { delay: 10_000 }, ); const client2 = new QueryClient({ @@ -165,8 +196,8 @@ describe('Entity Write Counts', () => { await testWithClient(client2, async () => { const query = fetchQuery(GetItems); - // Force a pull so the store hydration starts, without awaiting the - // refetch — the cached value has to win. + // Force a pull so the store hydration starts. The refetch is too slow + // to land, so only the store read can satisfy this. void query.value; await sleep(); expect((query.value as unknown as { items: { name: string }[] }).items[0].name).toBe('Persisted'); From d2218feffe138ac244d23a39024094c9dc53b2ed Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 16:41:14 -0400 Subject: [PATCH 006/109] docs(fetchium): note the zero-write outcome in the changeset The change detection that skips an unchanged write is now in place, so removing these saves takes a streamed event that changes nothing from one write to none. That is the primary effect and the changeset omitted it. Co-Authored-By: Claude Opus 5 --- .changeset/remove-redundant-entity-saves.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/remove-redundant-entity-saves.md b/.changeset/remove-redundant-entity-saves.md index b2453e2..d6790c8 100644 --- a/.changeset/remove-redundant-entity-saves.md +++ b/.changeset/remove-redundant-entity-saves.md @@ -2,6 +2,6 @@ 'fetchium': patch --- -Stop writing an entity to the store more than once per streamed event. `applyMutationEvent` ran `applyEntityRefs` with `persist: true`, which walks to the entity and writes it, and then called `entityMap.save` on the same entity again unconditionally. `LiveArrayInstance.add` added a third write, re-saving a child the apply pass had already written. An update or create event wrote the entity twice, and an event that inserted into a live array wrote the child three times. +Stop writing an entity to the store more than once per streamed event. `applyMutationEvent` ran `applyEntityRefs` with `persist: true`, which walks to the entity and writes it, then called `entityMap.save` on the same entity again unconditionally. `LiveArrayInstance.add` added a third write, re-saving a child whose record was already current. An update or create event wrote the entity twice, and an event that inserted into a live array wrote the child three times. -Nothing else changes: the remaining write is the one the apply already performed, so an entity written by an event is still readable after a restart. +The apply already declines the write when the applied data is unchanged, so removing these saves takes a streamed event that changes nothing from one write to none. The remaining write is the one the apply performed, so an entity written by an event is still readable by a later client. From b88c34970b6ae82dcf1d4be456eb91e5b72e9c5e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 17:11:54 -0400 Subject: [PATCH 007/109] test(fetchium): size the refetch delay to observed CI jitter, not to 10s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The later-client test waits a single macrotask tick, so the refetch only has to be slower than that — it passed at every delay from 0 upward, so there was never a race. 10s was arbitrary margin and left a long pending timer behind after the test finished. 1s keeps the network value unable to land while staying proportionate: roughly 20x the worst sleep overshoot seen on a loaded runner here, where a sleep(10) has overtaken a 50ms mock delay. Co-Authored-By: Claude Opus 5 --- packages/fetchium/src/__tests__/entity-write-counts.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index be6b345..f749440 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -186,7 +186,7 @@ describe('Entity Write Counts', () => { mockFetch.get( '/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'Refetched' }] }, - { delay: 10_000 }, + { delay: 1000 }, ); const client2 = new QueryClient({ @@ -196,8 +196,8 @@ describe('Entity Write Counts', () => { await testWithClient(client2, async () => { const query = fetchQuery(GetItems); - // Force a pull so the store hydration starts. The refetch is too slow - // to land, so only the store read can satisfy this. + // Force a pull so the store hydration starts. The refetch is far slower + // than this tick, so only the store read can satisfy the assertion. void query.value; await sleep(); expect((query.value as unknown as { items: { name: string }[] }).items[0].name).toBe('Persisted'); From 0b9128200de256594aced50eea07dc4348cdc1a7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 14 Sep 2026 14:36:12 -0400 Subject: [PATCH 008/109] fix(fetchium): keep write-skipping consistent with the store (pre-0.6.0) PR #60 skips the entity write when applied data is unchanged, keyed on an in-memory `_persisted` flag. The store deletes records on its own (LRU eviction past `cache.maxCount`, refcount cascades, stale purges) without telling the entity, so the next unchanged apply skipped the write while the query record was re-saved pointing at a missing value; on the next cold start the query's cache failed to hydrate and was dropped. Any mounted query with more than 50 live keys of one definition, polling unchanged data, hit this continuously. - `QueryStore.onDelete?(listener)` (optional, additive). Both built-in stores report every id they drop; the client clears `_persisted` on the matching entity. Stores without `onDelete` get every persisted apply written, as before 0.6.0. - Snapshot fast path: union members stored under the array/record symbol keys are now classified, so `t.union(t.array(t.entity(A)), t.object(...))` is dynamic and re-read (was static -> stale in prod, throws in dev). - Entity-array narrowing (shared typenames) is refreshed per apply instead of only when the array's identity changes. - `AsyncQueryStore`'s writer now honors a query def's own `cache.maxCount` instead of always applying the default of 50. Tests: store-consistency.test.ts (R1 x2, R3), store-on-delete.test.ts (x4), entity-snapshot.test.ts (R2). All fail on main, pass here. Co-Authored-By: Claude Sonnet 5 --- .changeset/async-store-honors-max-count.md | 5 + .changeset/snapshot-union-array-members.md | 5 + .changeset/store-reports-deletions.md | 9 + packages/fetchium/src/EntityInstance.ts | 27 ++- packages/fetchium/src/QueryClient.ts | 8 + .../src/__tests__/entity-snapshot.test.ts | 43 ++++- .../src/__tests__/store-consistency.test.ts | 174 ++++++++++++++++++ .../src/__tests__/store-on-delete.test.ts | 165 +++++++++++++++++ packages/fetchium/src/applyEntities.ts | 2 +- packages/fetchium/src/query-types.ts | 3 + packages/fetchium/src/stores/async.ts | 34 +++- packages/fetchium/src/stores/sync.ts | 6 + 12 files changed, 467 insertions(+), 14 deletions(-) create mode 100644 .changeset/async-store-honors-max-count.md create mode 100644 .changeset/snapshot-union-array-members.md create mode 100644 .changeset/store-reports-deletions.md create mode 100644 packages/fetchium/src/__tests__/store-consistency.test.ts create mode 100644 packages/fetchium/src/__tests__/store-on-delete.test.ts diff --git a/.changeset/async-store-honors-max-count.md b/.changeset/async-store-honors-max-count.md new file mode 100644 index 0000000..0e2afef --- /dev/null +++ b/.changeset/async-store-honors-max-count.md @@ -0,0 +1,5 @@ +--- +'fetchium': patch +--- + +`AsyncQueryStore`'s writer ignored a query definition's `cache.maxCount` and always used the default (50) when deciding when to evict the oldest key of a query definition. It now honors the def's own `maxCount`, matching `SyncQueryStore`. diff --git a/.changeset/snapshot-union-array-members.md b/.changeset/snapshot-union-array-members.md new file mode 100644 index 0000000..7ef6d5b --- /dev/null +++ b/.changeset/snapshot-union-array-members.md @@ -0,0 +1,5 @@ +--- +'fetchium': patch +--- + +Snapshot fast path: a union whose only entity-bearing member is an array or record (`t.union(t.array(t.entity(A)), t.object({ ... }))`) was classified as a static field, so a snapshot taken at an unchanged version never re-read it and changes to those entities did not reach consumers (dev builds threw the stale-snapshot guard instead). Union members stored under the array/record symbol keys are now inspected like the others. diff --git a/.changeset/store-reports-deletions.md b/.changeset/store-reports-deletions.md new file mode 100644 index 0000000..570f0c9 --- /dev/null +++ b/.changeset/store-reports-deletions.md @@ -0,0 +1,9 @@ +--- +'fetchium': patch +--- + +Keep the "skip the write when nothing changed" optimisation consistent with the store. The store deletes records on its own — an LRU eviction of the oldest key of a query definition, the refcount cascade that follows it, a stale purge — and the in-memory entity did not hear about it, so its next unchanged apply skipped the write while the query record was re-saved pointing at a value that no longer existed. On the next cold start that query's cache failed to hydrate ("the query cache may be corrupted or invalid") and was dropped. Any mounted query whose definition has more than `cache.maxCount` (default 50) live keys, refetching unchanged data, was affected. + +`QueryStore` gains an optional `onDelete(listener)`; both built-in stores call it with every id they drop, and the client clears the entity's persisted flag so the next apply writes it again. A custom store that does not implement `onDelete` gets every persisted apply written, as before 0.6.0. + +Also: an entity-array field narrowed by a shared-typename def (`t.array(t.entity(X))` with two entity classes for `X`) is re-narrowed after each apply, so a member that gains the fields the def requires shows up without the array itself having to change. diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 99e5bca..4857df3 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -498,6 +498,12 @@ export class EntityInstance { } } +function sameMembers(a: unknown[], b: unknown[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + function filterEntityArray(array: unknown[], innerDef: ValidatorDef, queryClient: QueryClient): unknown[] { const result: unknown[] = []; for (const item of array) { @@ -564,7 +570,13 @@ function computeIsStaticFieldDef(def: ValidatorDef): boolean { if (shape instanceof ValidatorDef) return isStaticFieldDef(shape); if (typeof shape !== 'object') return isStaticFieldDef(shape); if (shape instanceof Set) return true; - return Object.values(shape as Record).every(isStaticFieldDef); + // Reflect.ownKeys: a union's array/record members sit under symbol keys. + const fields = shape as Record; + const keys = Reflect.ownKeys(fields); + for (let i = 0; i < keys.length; i++) { + if (!isStaticFieldDef(fields[keys[i]])) return false; + } + return true; } // ====================================================== @@ -668,7 +680,7 @@ function narrowEntityArray( prop: string, value: unknown[], shapeFields: Record, - filterCache: Map, + filterCache: Map, queryClient: QueryClient, ): unknown[] { const fieldDef = shapeFields[prop]; @@ -679,12 +691,15 @@ function narrowEntityArray( if (typename !== undefined) { const defs = queryClient.getEntityDefsForTypename(typename); if (defs !== undefined && defs.length > 1) { + // Keyed on the apply epoch too: a member can gain the def's fields without the array changing. + const parseId = queryClient.currentParseId; const cached = filterCache.get(prop); - if (cached !== undefined && cached.source === value) { + if (cached !== undefined && cached.source === value && cached.parseId === parseId) { return cached.filtered; } - const filtered = filterEntityArray(value, innerDef, queryClient); - filterCache.set(prop, { source: value, filtered }); + let filtered = filterEntityArray(value, innerDef, queryClient); + if (cached !== undefined && sameMembers(cached.filtered, filtered)) filtered = cached.filtered; + filterCache.set(prop, { source: value, filtered, parseId }); return filtered; } } @@ -713,7 +728,7 @@ function createProxy( const typenameField = shape.typenameField; const wrappedMethods = new Map unknown>(); - const filterCache = new Map(); + const filterCache = new Map(); const toJSON = () => ({ __entityRef: key }); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index c541d97..6226daa 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -74,6 +74,8 @@ export class QueryClient { store: QueryStore; currentParseId: number = 0; + /** Without `store.onDelete`, `_persisted` cannot be trusted. */ + storeReportsDeletes: boolean = false; private context!: QueryContext; private typenameRegistry = new Map[]>(); @@ -101,6 +103,12 @@ export class QueryClient { (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); this.networkManager = config.networkManager ?? new NetworkManager(); this.entityMap = new EntityStore((key, data, refs) => this.store.saveEntity(key, data, refs)); + // A record the store drops must be written again by the next apply. + this.storeReportsDeletes = typeof this.store.onDelete === 'function'; + this.store.onDelete?.(key => { + const entity = this.entityMap.getEntity(key); + if (entity !== undefined) entity._persisted = false; + }); // Register user-supplied adapters for (const adapter of config.adapters ?? []) { diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index 6a781b3..9b52c99 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -11,7 +11,7 @@ import { Entity } from '../proxy.js'; import { RESTQuery } from '../rest/index.js'; import { fetchQuery } from '../query.js'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; -import { setupTestClient } from './utils.js'; +import { setupTestClient, sleep } from './utils.js'; /** * Entity Snapshot Tests @@ -640,4 +640,45 @@ describe('Entity Snapshots', () => { instance.notify(); read(); }); + + it('re-snapshots a union field whose only entity-bearing member is an array', async () => { + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; + } + + class Container extends Entity { + __typename = t.typename('Container'); + id = t.id; + // The array member sits under the ARRAY_KEY symbol; this used to classify as static. + content = t.union(t.array(t.entity(Item)), t.object({ __typename: t.typename('Empty'), reason: t.string })); + } + + class GetContainer extends RESTQuery { + path = '/container'; + result = { container: t.entity(Container) }; + } + + const { client, mockFetch } = getClient(); + mockFetch.get('/container', { + container: { + __typename: 'Container', + id: 'c-1', + content: [{ __typename: 'Item', id: 'i-1', name: 'A' }], + }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetContainer)); + await query; + + const first = read(); + expect((first.container as any).content[0].name).toBe('A'); + + client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'B' } }); + await sleep(5); + + const second = read(); + expect((second.container as any).content[0].name).toBe('B'); + }); }); diff --git a/packages/fetchium/src/__tests__/store-consistency.test.ts b/packages/fetchium/src/__tests__/store-consistency.test.ts new file mode 100644 index 0000000..03e992e --- /dev/null +++ b/packages/fetchium/src/__tests__/store-consistency.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { valueKeyFor, refCountKeyFor } from '../stores/shared.js'; +import { setupTestClient, testWithClient, sleep } from './utils.js'; + +/** A record the store drops on its own must be written again, or queries referencing it fail to hydrate. */ + +function getDocument(kv: any, key: number): unknown | undefined { + const value = kv.getString(valueKeyFor(key)); + return value ? JSON.parse(value) : undefined; +} + +/** Holds a query active without making the test body a consumer. */ +function holdQuery(client: QueryClient, start: () => T) { + return withContexts([[QueryClientContext, client]], () => { + const query = start(); + const w = watcher(() => (query as unknown as { value: unknown }).value); + w.addListener(() => {}); + return query; + }); +} + +describe('Store consistency: _persisted vs store cascade deletion', () => { + const getClient = setupTestClient(); + + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + + class GetProfile extends RESTQuery { + static cache = { maxCount: 1 }; + params = { id: t.id }; + path = `/user/profile/${this.params.id}`; + result = { user: t.entity(User) }; + } + + it('re-persists an entity whose store record was cascade-deleted by LRU eviction', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/user/profile/1', { user: { __typename: 'User', id: 1, name: 'Alice' } }); + mockFetch.get('/user/profile/2', { user: { __typename: 'User', id: 2, name: 'Bob' } }); + const userKey = hashValue(['User', 1]); + + const relay1 = holdQuery(client, () => fetchQuery(GetProfile, { id: '1' })); + await relay1; + expect(getDocument(kv, userKey)).toBeDefined(); + + // maxCount: 1 — the second key evicts Q1 and cascades to User 1. + const relay2 = holdQuery(client, () => fetchQuery(GetProfile, { id: '2' })); + await relay2; + expect(getDocument(kv, userKey)).toBeUndefined(); + expect(kv.getNumber(refCountKeyFor(userKey))).toBeUndefined(); + + // Q1, still mounted, refetches identical data. + const saveEntity = vi.spyOn(store, 'saveEntity'); + await (relay1.value as unknown as { __refetch(): Promise }).__refetch(); + await sleep(5); + + const instances = (client as any).queryInstances as Map; + const q1 = [...instances.values()][0]; + expect(getDocument(kv, q1.storageKey)).toBeDefined(); + expect(kv.getNumber(refCountKeyFor(userKey))).toBeGreaterThan(0); + // The re-saved query refs User 1, so its record must exist. + expect(saveEntity).toHaveBeenCalled(); + expect(getDocument(kv, userKey)).toBeDefined(); + }); + + it('cold start after the scenario above hydrates from cache instead of dropping it', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/user/profile/1', { user: { __typename: 'User', id: 1, name: 'Alice' } }); + mockFetch.get('/user/profile/2', { user: { __typename: 'User', id: 2, name: 'Bob' } }); + + const relay1 = holdQuery(client, () => fetchQuery(GetProfile, { id: '1' })); + await relay1; + const relay2 = holdQuery(client, () => fetchQuery(GetProfile, { id: '2' })); + await relay2; + await (relay1.value as unknown as { __refetch(): Promise }).__refetch(); + await sleep(5); + client.destroy(); + + const warn = vi.fn(); + mockFetch.reset(); + mockFetch.get('/user/profile/1', { user: { __typename: 'User', id: 1, name: 'Refetched' } }, { delay: 500 }); + const client2 = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn }, + } as any); + + await testWithClient(client2, async () => { + const query = fetchQuery(GetProfile, { id: '1' }); + void query.value; + await sleep(10); + // The fetch is delayed, so only the cache can satisfy this. + expect(warn).not.toHaveBeenCalled(); + expect((query.value as unknown as { user: { name: string } })?.user?.name).toBe('Alice'); + }); + client2.destroy(); + }); +}); + +describe('Store consistency: entity-array narrowing follows entity data', () => { + const getClient = setupTestClient(); + + class UserPreview extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + + class UserFull extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + email = t.string; + } + + class Team extends Entity { + __typename = t.typename('Team'); + id = t.id; + // Declared first: within one payload u-1 is parsed with the preview def. + owner = t.entity(UserPreview); + members = t.array(t.entity(UserFull)); + } + + class GetTeam extends RESTQuery { + path = '/team'; + result = { team: t.entity(Team) }; + } + + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/user/${this.params.id}`; + result = { user: t.entity(UserFull) }; + } + + it('members narrowed by satisfiesDef recover once the entity gains the missing field', async () => { + const { client, mockFetch } = getClient(); + const payload = { + team: { + __typename: 'Team', + id: 't-1', + owner: { __typename: 'User', id: 'u-1', name: 'Alice' }, + members: [ + { __typename: 'User', id: 'u-1', name: 'Alice', email: 'a@x' }, + { __typename: 'User', id: 'u-2', name: 'Bob', email: 'b@x' }, + ], + }, + }; + mockFetch.get('/team', payload); + mockFetch.get('/user/u-1', { user: { __typename: 'User', id: 'u-1', name: 'Alice', email: 'a@x' } }); + + const team = holdQuery(client, () => fetchQuery(GetTeam)); + await team; + const members = () => (team.value as any).team.members.map((m: any) => m.id); + // u-1 lacks email under the preview def, so it is narrowed out. + expect(members()).toEqual(['u-2']); + + const user = holdQuery(client, () => fetchQuery(GetUser, { id: 'u-1' })); + await user; + // u-1 now satisfies UserFull; the refetch returns an identical array. + await (team.value as any).__refetch(); + await sleep(5); + expect(members()).toEqual(['u-1', 'u-2']); + }); +}); diff --git a/packages/fetchium/src/__tests__/store-on-delete.test.ts b/packages/fetchium/src/__tests__/store-on-delete.test.ts new file mode 100644 index 0000000..b7d6a09 --- /dev/null +++ b/packages/fetchium/src/__tests__/store-on-delete.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { hashValue } from 'signalium/utils'; +import { AsyncQueryStore, type AsyncPersistentStore, type StoreMessage } from '../stores/async.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { valueKeyFor, refCountKeyFor, DEFAULT_MAX_COUNT } from '../stores/shared.js'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { createMockFetch, sleep } from './utils.js'; + +class MockAsyncPersistentStore implements AsyncPersistentStore { + private readonly kv: Record = Object.create(null); + async has(key: string) { + return key in this.kv; + } + async getString(key: string) { + return this.kv[key] as string | undefined; + } + async setString(key: string, value: string) { + this.kv[key] = value; + } + async getNumber(key: string) { + return this.kv[key] as number | undefined; + } + async setNumber(key: string, value: number) { + this.kv[key] = value; + } + async getBuffer(key: string) { + return this.kv[key] as Uint32Array | undefined; + } + async setBuffer(key: string, value: Uint32Array) { + this.kv[key] = value; + } + async delete(key: string) { + delete this.kv[key]; + } + async getAllKeys() { + return Object.keys(this.kv); + } +} + +const queryDef = (id: string, maxCount: number) => ({ statics: { id, cache: { maxCount } } }) as any; + +describe('QueryStore.onDelete', () => { + it('SyncQueryStore reports every record it drops, including cascaded entities', () => { + const store = new SyncQueryStore(new MemoryPersistentStore()); + const deleted: number[] = []; + store.onDelete(key => deleted.push(key)); + + const q1 = hashValue(['GET:/x', { id: '1' }]); + const q2 = hashValue(['GET:/x', { id: '2' }]); + const e1 = hashValue(['User', 1]); + store.saveEntity(e1, { id: 1 }); + store.saveQuery(queryDef('GET:/x', 1), q1, { __entityRef: e1 }, Date.now(), new Set([e1])); + expect(deleted).toEqual([]); + + // maxCount: 1 — q2 evicts q1, cascading to e1. + store.saveQuery(queryDef('GET:/x', 1), q2, { __entityRef: 0 }, Date.now(), new Set()); + expect(deleted).toEqual([q1, e1]); + }); + + it('AsyncQueryStore writer reports drops once the writer processes them', async () => { + const delegate = new MockAsyncPersistentStore(); + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), + }); + const deleted: number[] = []; + writer.onDelete(key => deleted.push(key)); + + const q1 = hashValue(['GET:/y', { id: '1' }]); + const e1 = hashValue(['User', 1]); + writer.saveEntity(e1, { id: 1 }); + writer.saveQuery(queryDef('GET:/y', DEFAULT_MAX_COUNT), q1, { __entityRef: e1 }, Date.now(), new Set([e1])); + await sleep(20); + expect(deleted).toEqual([]); + expect(await delegate.has(valueKeyFor(e1))).toBe(true); + + for (let i = 2; i <= DEFAULT_MAX_COUNT + 1; i++) { + const q = hashValue(['GET:/y', { id: String(i) }]); + writer.saveQuery(queryDef('GET:/y', DEFAULT_MAX_COUNT), q, { __entityRef: 0 }, Date.now(), new Set()); + } + await sleep(100); + expect(deleted).toEqual([q1, e1]); + expect(await delegate.has(valueKeyFor(e1))).toBe(false); + expect(await delegate.getNumber(refCountKeyFor(e1))).toBeUndefined(); + }); + + it("AsyncQueryStore writer honors a query def's own cache.maxCount", async () => { + const delegate = new MockAsyncPersistentStore(); + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), + }); + const deleted: number[] = []; + writer.onDelete(key => deleted.push(key)); + + const q1 = hashValue(['GET:/z', { id: '1' }]); + const q2 = hashValue(['GET:/z', { id: '2' }]); + writer.saveQuery(queryDef('GET:/z', 1), q1, { __entityRef: 0 }, Date.now(), new Set()); + await sleep(20); + expect(deleted).toEqual([]); + + // maxCount: 1 — the second key must evict the first, not wait for DEFAULT_MAX_COUNT. + writer.saveQuery(queryDef('GET:/z', 1), q2, { __entityRef: 0 }, Date.now(), new Set()); + await sleep(20); + expect(deleted).toEqual([q1]); + }); +}); + +describe('stores without onDelete', () => { + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + class GetUser extends RESTQuery { + path = '/user'; + result = { user: t.entity(User) }; + } + + it('fall back to writing every persisted apply, as before 0.6.0', async () => { + // Implements QueryStore without onDelete. + const inner = new SyncQueryStore(new MemoryPersistentStore()); + let entityWrites = 0; + const store = { + loadQuery: inner.loadQuery.bind(inner), + saveQuery: inner.saveQuery.bind(inner), + saveEntity: (...args: Parameters) => { + entityWrites++; + inner.saveEntity(...args); + }, + activateQuery: inner.activateQuery.bind(inner), + deleteQuery: inner.deleteQuery.bind(inner), + }; + + const mockFetch = createMockFetch(); + mockFetch.get('/user', { user: { __typename: 'User', id: 1, name: 'Alice' } }); + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + } as any); + + const query = withContexts([[QueryClientContext, client]], () => { + const q = fetchQuery(GetUser); + watcher(() => (q as any).value).addListener(() => {}); + return q; + }); + await query; + const afterLoad = entityWrites; + expect(afterLoad).toBeGreaterThan(0); + + await (query.value as any).__refetch(); + await sleep(5); + // Identical data, but the store cannot vouch for the record. + expect(entityWrites).toBe(afterLoad * 2); + client.destroy(); + }); +}); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 104269a..06fdaa8 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -160,7 +160,7 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); // An entity hydrated from the store was never written, so there is no write to skip. - const needsPersist = changed || refsChanged || !entityInstance._persisted; + const needsPersist = changed || refsChanged || !entityInstance._persisted || !queryClient.storeReportsDeletes; entityInstance.setChildRefs(newRefs, persist && needsPersist); const proxy = entityInstance.getProxy(entityShape); diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 95380af..46c5554 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -93,6 +93,9 @@ export interface QueryStore { deleteQuery(queryKey: number): void; purgeStaleQueries?(): MaybePromise; + + /** Called with the key of every record the store drops on its own (eviction, cascade, purge). */ + onDelete?(listener: (key: number) => void): void; } export type MaybePromise = T | Promise; diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 4987c85..fd7c349 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -49,10 +49,11 @@ export type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; + maxCount: number; refIds?: number[]; } | { type: StoreMessageType.SaveEntity; entityKey: number; value: unknown; refIds?: number[] } - | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number } + | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount: number } | { type: StoreMessageType.DeleteQuery; queryKey: number }; export interface AsyncQueryStoreConfig { @@ -74,6 +75,7 @@ export class AsyncQueryStore implements QueryStore { private readonly queues: Map = new Map(); private queueProcessorPromise?: Promise; private resolveQueueWait?: () => void; + private deleteListeners: Array<(key: number) => void> = []; constructor(config: AsyncQueryStoreConfig) { this.isWriter = config.isWriter; @@ -92,6 +94,10 @@ export class AsyncQueryStore implements QueryStore { } } + onDelete(listener: (key: number) => void): void { + this.deleteListeners.push(listener); + } + private handleMessage(msg: StoreMessage): void { if (this.isWriter) { // Enqueue the message for serial processing @@ -144,13 +150,21 @@ export class AsyncQueryStore implements QueryStore { private async processMessage(msg: StoreMessage): Promise { switch (msg.type) { case StoreMessageType.SaveQuery: - await this.writerSaveQuery(msg.queryDefId, msg.queryKey, msg.value, msg.updatedAt, msg.cacheTime, msg.refIds); + await this.writerSaveQuery( + msg.queryDefId, + msg.queryKey, + msg.value, + msg.updatedAt, + msg.cacheTime, + msg.maxCount, + msg.refIds, + ); break; case StoreMessageType.SaveEntity: await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds); break; case StoreMessageType.ActivateQuery: - await this.writerActivateQuery(msg.queryDefId, msg.queryKey, msg.cacheTime); + await this.writerActivateQuery(msg.queryDefId, msg.queryKey, msg.cacheTime, msg.maxCount); break; case StoreMessageType.DeleteQuery: await this.writerDeleteValue(msg.queryKey); @@ -232,6 +246,7 @@ export class AsyncQueryStore implements QueryStore { value, updatedAt, cacheTime: queryDef.statics.cache?.cacheTime ?? DEFAULT_CACHE_TIME, + maxCount: queryDef.statics.cache?.maxCount ?? DEFAULT_MAX_COUNT, refIds: refIds ? Array.from(refIds) : undefined, }; @@ -255,6 +270,7 @@ export class AsyncQueryStore implements QueryStore { queryDefId: queryDef.statics.id, queryKey, cacheTime: queryDef.statics.cache?.cacheTime ?? DEFAULT_CACHE_TIME, + maxCount: queryDef.statics.cache?.maxCount ?? DEFAULT_MAX_COUNT, }; this.dispatch(message); @@ -277,18 +293,24 @@ export class AsyncQueryStore implements QueryStore { value: unknown, updatedAt: number, cacheTime: number, + maxCount: number, refIds?: number[], ): Promise { await this.setValue(queryKey, value, refIds ? new Set(refIds) : undefined); await this.delegate!.setNumber(updatedAtKeyFor(queryKey), updatedAt); - await this.writerActivateQuery(queryDefId, queryKey, cacheTime); + await this.writerActivateQuery(queryDefId, queryKey, cacheTime, maxCount); } private async writerSaveEntity(entityKey: number, value: unknown, refIds?: number[]): Promise { await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined); } - private async writerActivateQuery(queryDefId: string, queryKey: number, cacheTime: number): Promise { + private async writerActivateQuery( + queryDefId: string, + queryKey: number, + cacheTime: number, + maxCount: number, + ): Promise { if (!(await this.delegate!.has(valueKeyFor(queryKey)))) { return; } @@ -297,7 +319,6 @@ export class AsyncQueryStore implements QueryStore { let queue = this.queues.get(queryDefId); if (queue === undefined) { - const maxCount = DEFAULT_MAX_COUNT; queue = await this.delegate!.getBuffer(queueKey); if (queue === undefined) { @@ -423,6 +444,7 @@ export class AsyncQueryStore implements QueryStore { await delegate.delete(valueKeyFor(id)); await delegate.delete(refCountKeyFor(id)); + for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](id); const refIds = await delegate.getBuffer(refIdsKey); await delegate.delete(refIdsKey); // Clean up the refIds key diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 7f1033e..ea18399 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -72,9 +72,14 @@ export class MemoryPersistentStore implements SyncPersistentStore { export class SyncQueryStore implements QueryStore { queues: Map = new Map(); + private deleteListeners: Array<(key: number) => void> = []; constructor(private readonly kv: SyncPersistentStore) {} + onDelete(listener: (key: number) => void): void { + this.deleteListeners.push(listener); + } + loadQuery(queryDef: QueryDefinition, queryKey: number): CachedQuery | undefined { const updatedAt = this.kv.getNumber(updatedAtKeyFor(queryKey)); @@ -275,6 +280,7 @@ export class SyncQueryStore implements QueryStore { kv.delete(valueKeyFor(id)); kv.delete(refCountKeyFor(id)); + for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](id); const refIds = kv.getBuffer(refIdsKeyFor(id)); kv.delete(refIdsKeyFor(id)); // Clean up the refIds key From 9f24e5daf39deb0620418810e29f295e324176b0 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 14 Sep 2026 14:39:51 -0400 Subject: [PATCH 009/109] fix(fetchium): don't write event-created entities that nothing routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyMutationEvent applied an unknown entity with persist: true and then evicted it from memory if no live collection matched. The record stayed in the store with no refcount, so nothing ever collected it — a slow leak for clients that receive broad streams. A new entity is now written only once something routes it: applyEntityRefs runs with persist: false for a create, and persistUnwritten writes the entity (and any not-yet-written descendants) after routing finds a match. Co-Authored-By: Claude Sonnet 5 --- .changeset/remove-redundant-entity-saves.md | 2 + packages/fetchium/src/QueryClient.ts | 22 ++++++++-- .../src/__tests__/entity-write-counts.test.ts | 6 ++- .../src/__tests__/store-consistency.test.ts | 44 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 packages/fetchium/src/__tests__/store-consistency.test.ts diff --git a/.changeset/remove-redundant-entity-saves.md b/.changeset/remove-redundant-entity-saves.md index d6790c8..242e7d8 100644 --- a/.changeset/remove-redundant-entity-saves.md +++ b/.changeset/remove-redundant-entity-saves.md @@ -5,3 +5,5 @@ Stop writing an entity to the store more than once per streamed event. `applyMutationEvent` ran `applyEntityRefs` with `persist: true`, which walks to the entity and writes it, then called `entityMap.save` on the same entity again unconditionally. `LiveArrayInstance.add` added a third write, re-saving a child whose record was already current. An update or create event wrote the entity twice, and an event that inserted into a live array wrote the child three times. The apply already declines the write when the applied data is unchanged, so removing these saves takes a streamed event that changes nothing from one write to none. The remaining write is the one the apply performed, so an entity written by an event is still readable by a later client. + +A `create` event for an entity that no live collection routes is no longer written to the store before being evicted; such a record had no refcount and was never collected. diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 6223f28..02cd0c5 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -391,7 +391,8 @@ export class QueryClient { const parseCtx = this.mutationParseContext; parseCtx.reset(this, undefined, warn, /* isPartialEvent */ true); const parsedData = parseEntity(data, mergedDef as unknown as EntityDef, parseCtx); - applyEntityRefs(parseCtx, parsedData, /* persist */ true); + // A new entity is written only once something routes it. + applyEntityRefs(parseCtx, parsedData, /* persist */ existing !== undefined); } catch (e) { // Unknown required-union variant: surface as an error (not a silent warn) // so the dropped update is visible. Optional unions degrade during parse. @@ -417,8 +418,13 @@ export class QueryClient { matched = true; }); - if (wasNew && !matched) { - entity.evict(); + if (wasNew) { + if (matched) { + // The parent persisted a ref to it, so its record must exist. + persistUnwritten(entity); + } else { + entity.evict(); + } } } @@ -534,3 +540,13 @@ function paramsMatch(instanceParams: Record | undefined, subset } return true; } + +/** Writes an entity and its not-yet-written descendants. */ +function persistUnwritten(entity: EntityInstance): void { + if (!entity._persisted) entity.save(); + const refs = entity.entityRefs; + if (refs === undefined) return; + for (const child of refs.keys()) { + if (!child._persisted) persistUnwritten(child); + } +} diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index f749440..f9c93c2 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -86,7 +86,7 @@ describe('Entity Write Counts', () => { expect(client.entityMap.getEntity(hashValue(['Item', 'i-1']))!.data.name).toBe('B'); }); - it('writes an entity once per streamed event that creates it', async () => { + it('writes nothing for a created entity that no live collection routes', async () => { const { client, mockFetch, store } = getClient(); mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', listId: 'l-1', name: 'A' }] }); @@ -102,7 +102,9 @@ describe('Entity Write Counts', () => { data: { __typename: 'Item', id: 'i-2', listId: 'l-1', name: 'C' }, }); - expect(itemSaves(saveEntity)).toHaveLength(1); + // Evicted, and an unreferenced record would never be collected. + expect(itemSaves(saveEntity)).toHaveLength(0); + expect(client.entityMap.getEntity(hashValue(['Item', 'i-2']))).toBeUndefined(); }); it('does not re-write a child when a live array gains it', async () => { diff --git a/packages/fetchium/src/__tests__/store-consistency.test.ts b/packages/fetchium/src/__tests__/store-consistency.test.ts new file mode 100644 index 0000000..a4d6638 --- /dev/null +++ b/packages/fetchium/src/__tests__/store-consistency.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { valueKeyFor, refCountKeyFor } from '../stores/shared.js'; +import { setupTestClient, testWithClient, sleep } from './utils.js'; + +function getDocument(kv: any, key: number): unknown | undefined { + const value = kv.getString(valueKeyFor(key)); + return value ? JSON.parse(value) : undefined; +} + +describe('Store consistency: create events matching no live collection', () => { + const getClient = setupTestClient(); + + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; + } + + class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.entity(Item)) }; + } + + it('does not leave an orphaned record on disk for an entity it immediately evicts', async () => { + const { client, mockFetch, kv } = getClient(); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: 'i-1', name: 'A' }] }); + await testWithClient(client, async () => { + await fetchQuery(GetItems); + }); + + const key = hashValue(['Item', 'i-2']); + client.applyMutationEvent({ type: 'create', typename: 'Item', data: { __typename: 'Item', id: 'i-2', name: 'C' } }); + await sleep(5); + + expect(client.entityMap.getEntity(key)).toBeUndefined(); + expect(kv.getNumber(refCountKeyFor(key))).toBeUndefined(); + expect(getDocument(kv, key)).toBeUndefined(); + }); +}); From adeebde855ca7a107a494ab41f6a856239f0b3c0 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 14 Sep 2026 16:40:24 -0400 Subject: [PATCH 010/109] fix(fetchium): AsyncQueryStore readers must not offer onDelete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #64 trusts an entity's _persisted flag whenever typeof store.onDelete === 'function'. AsyncQueryStore defined onDelete as a class method, so a reader (isWriter: false, the documented main-thread/worker topology) qualified — but deletions only ever fire listeners in the writer instance (writerDeleteValue). A reader's client therefore still had the PR #60 hole: after the writer evicts and cascades, the reader skipped the unchanged write, re-saved the query with a dangling ref, and the next cold start dropped that query's cache. onDelete is now an optional property assigned only in the writer's constructor, so a reader offers no hook and its client falls back to writing every persisted apply, as before 0.6.0. Restoring the write-skip for readers is a separate follow-up: the writer would need to post a Deleted message back over the channel. Co-Authored-By: Claude Sonnet 5 --- .changeset/store-reports-deletions.md | 2 + .../src/__tests__/store-on-delete.test.ts | 46 +++++++++++++++++-- packages/fetchium/src/stores/async.ts | 7 ++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.changeset/store-reports-deletions.md b/.changeset/store-reports-deletions.md index 570f0c9..f4a8fd7 100644 --- a/.changeset/store-reports-deletions.md +++ b/.changeset/store-reports-deletions.md @@ -6,4 +6,6 @@ Keep the "skip the write when nothing changed" optimisation consistent with the `QueryStore` gains an optional `onDelete(listener)`; both built-in stores call it with every id they drop, and the client clears the entity's persisted flag so the next apply writes it again. A custom store that does not implement `onDelete` gets every persisted apply written, as before 0.6.0. +An `AsyncQueryStore` in reader role does not offer `onDelete` (deletions happen in the writer), so a reader-backed client writes every persisted apply, as before 0.6.0. + Also: an entity-array field narrowed by a shared-typename def (`t.array(t.entity(X))` with two entity classes for `X`) is re-narrowed after each apply, so a member that gains the fields the def requires shows up without the array itself having to change. diff --git a/packages/fetchium/src/__tests__/store-on-delete.test.ts b/packages/fetchium/src/__tests__/store-on-delete.test.ts index b7d6a09..6334f49 100644 --- a/packages/fetchium/src/__tests__/store-on-delete.test.ts +++ b/packages/fetchium/src/__tests__/store-on-delete.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { watcher, withContexts } from 'signalium'; import { hashValue } from 'signalium/utils'; import { AsyncQueryStore, type AsyncPersistentStore, type StoreMessage } from '../stores/async.js'; @@ -71,7 +71,7 @@ describe('QueryStore.onDelete', () => { connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), }); const deleted: number[] = []; - writer.onDelete(key => deleted.push(key)); + writer.onDelete!(key => deleted.push(key)); const q1 = hashValue(['GET:/y', { id: '1' }]); const e1 = hashValue(['User', 1]); @@ -99,7 +99,7 @@ describe('QueryStore.onDelete', () => { connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), }); const deleted: number[] = []; - writer.onDelete(key => deleted.push(key)); + writer.onDelete!(key => deleted.push(key)); const q1 = hashValue(['GET:/z', { id: '1' }]); const q2 = hashValue(['GET:/z', { id: '2' }]); @@ -162,4 +162,44 @@ describe('stores without onDelete', () => { expect(entityWrites).toBe(afterLoad * 2); client.destroy(); }); + + it('an AsyncQueryStore reader offers no onDelete, so its client writes every apply', async () => { + const delegate = new MockAsyncPersistentStore(); + let writerHandle: ((msg: StoreMessage) => void) | undefined; + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: h => ((writerHandle = h), { sendMessage: () => {} }), + }); + const reader = new AsyncQueryStore({ + isWriter: false, + delegate, + connect: () => ({ sendMessage: msg => writerHandle!(msg) }), + }); + expect(writer.onDelete).toBeTypeOf('function'); + expect(reader.onDelete).toBeUndefined(); + + const mockFetch = createMockFetch(); + mockFetch.get('/user', { user: { __typename: 'User', id: 1, name: 'Alice' } }); + const client = new QueryClient({ + store: reader, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + } as any); + const saveEntity = vi.spyOn(reader, 'saveEntity'); + + const query = withContexts([[QueryClientContext, client]], () => { + const q = fetchQuery(GetUser); + watcher(() => (q as any).value).addListener(() => {}); + return q; + }); + await query; + const afterLoad = saveEntity.mock.calls.length; + expect(afterLoad).toBeGreaterThan(0); + + await (query.value as any).__refetch(); + await sleep(5); + // Deletions happen in the writer; the reader cannot know, so it keeps writing. + expect(saveEntity.mock.calls.length).toBe(afterLoad * 2); + client.destroy(); + }); }); diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index fd7c349..614a536 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -76,10 +76,13 @@ export class AsyncQueryStore implements QueryStore { private queueProcessorPromise?: Promise; private resolveQueueWait?: () => void; private deleteListeners: Array<(key: number) => void> = []; + // Only the writer sees deletions; a reader offers no hook, so the client writes every apply. + onDelete?: (listener: (key: number) => void) => void; constructor(config: AsyncQueryStoreConfig) { this.isWriter = config.isWriter; this.delegate = config.delegate; + if (this.isWriter) this.onDelete = listener => this.deleteListeners.push(listener); // Connect and get sendMessage function const { sendMessage } = config.connect(this.handleMessage.bind(this)); @@ -94,10 +97,6 @@ export class AsyncQueryStore implements QueryStore { } } - onDelete(listener: (key: number) => void): void { - this.deleteListeners.push(listener); - } - private handleMessage(msg: StoreMessage): void { if (this.isWriter) { // Enqueue the message for serial processing From 1818694d243922ac5b28a9331767bad3055d09db Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 14:32:39 -0400 Subject: [PATCH 011/109] perf(fetchium): snapshot entities without walking their proxies (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useQuery` deep-snapshots its result through `useReactive` on every dependency change, and the entity snapshot handler walked the proxy to do it. `Object.keys` triggers a `getOwnPropertyDescriptor` trap per key, that trap calls `handler.get` to produce the value it reports, and nested plain data came back as the read-only wrapper proxies the `get` trap returns, which the snapshot then walked through more traps. Every field was read twice per snapshot and each read allocated a descriptor. Each proxy now registers an `EntitySnapshotSource`: consume the notifier and activate the entity relay once, list the enumerable own keys, read a field's raw value. The handler walks nested plain data itself, unwrapping `WRAPPED_VALUE` holders and handing nested entity proxies back to Signalium so they still consume their own notifiers. The value half of the `get` trap moved into `readField` so the trap and the snapshot share one reader. On top of that, `EntityInstance` carries a `version` bumped in `notify()`. A snapshot records the source id, version and key list it came from, so re-snapshotting an entity still at that version re-reads only its dynamic fields: child entities, live collections, and a query's attached getters. `isStaticFieldDef` decides that per shape, recognising only the def shapes `getEntityDef` validates against and treating everything else as dynamic, so an unfamiliar def costs a read rather than serving a stale value. Array items are paired with the snapshot they produced themselves rather than with whatever sat at the same index. Signalium diffs arrays by position, so a front insert handed every entity its neighbour's snapshot and every memoized row re-rendered. The pairing index is built from the consumer's own previous array, so two consumers keep independent trees, and only when a mispairing is detected, so appends don't pay for it. Guarding the result, since none of this is observable from behavior: - Dev builds re-read the fields the fast path skipped and throw if one moved. That catches both a merge that changes `data` without notifying and a field wrongly judged static — the two ways a snapshot could go permanently stale — at the point it happens rather than in a render. It costs dev builds the read the fast path saved. - `__debug_snapshotFullWalks` / `__debug_snapshotFieldReads` let tests assert the fast path is taken. A regression that quietly routes every snapshot through the full walk leaves every behavioral test green. - `src/__benchmarks__/snapshot.bench.ts` and `npm run bench` measure the paths this touches, built with `IS_DEV: false` so the dev guard doesn't skew them. Two smaller behavior notes: - `getOwnPropertyDescriptor` answers from the enumerable key list rather than `prop in shapeFields`. `shapeFields` is an object literal, so `in` was true for inherited names and the trap reported `toString` as an enumerable own property with value `undefined`. It now returns `undefined`. `Object.keys(proxy)` was never affected — it filters `ownKeys`, which never listed inherited names. - The `Entity`-without-a-snapshot-source fallback is gone. Its own properties are the shape's type defs, so walking one produced a plausible-looking object of `t.string`/`t.number` values rather than data. Falling through returns the instance untouched, which is what Signalium does for any class it has no handler for. Costs, measured: the production `QueryClient` chunk grows 80,581 -> 83,781 raw and 21,597 -> 22,647 gzipped (+1,050, +4.9%) for the per-shape key metadata, the static-field analysis and the origin bookkeeping. Dev-only code is fully eliminated — the invariant check and the debug counters appear in no emitted production JS. The snapshot origin holds the source's id rather than the proxy: a snapshot outlives the entity it came from, and a strong reference would have made the entity graph reachable from a detached snapshot tree. Ids are per proxy, which `PROXY_ID` is not — an entity has one proxy per shape and they share a key. Output is otherwise unchanged: same key set, same structural sharing, same reactive dependencies, no public API change. Co-authored-by: Claude Opus 5 (1M context) --- .changeset/entity-snapshot-fast-path.md | 7 + packages/fetchium/package.json | 1 + packages/fetchium/src/EntityInstance.ts | 595 ++++++++++++---- .../src/__benchmarks__/snapshot.bench.ts | 131 ++++ .../src/__tests__/entity-snapshot.test.ts | 643 ++++++++++++++++++ packages/fetchium/vitest.config.ts | 61 +- 6 files changed, 1288 insertions(+), 150 deletions(-) create mode 100644 .changeset/entity-snapshot-fast-path.md create mode 100644 packages/fetchium/src/__benchmarks__/snapshot.bench.ts create mode 100644 packages/fetchium/src/__tests__/entity-snapshot.test.ts diff --git a/.changeset/entity-snapshot-fast-path.md b/.changeset/entity-snapshot-fast-path.md new file mode 100644 index 0000000..ae61ea0 --- /dev/null +++ b/.changeset/entity-snapshot-fast-path.md @@ -0,0 +1,7 @@ +--- +'fetchium': patch +--- + +Entity snapshots no longer walk the proxy to read fields, and an entity whose data hasn't changed is no longer re-read at all. Previously `Object.keys(proxy)` triggered a descriptor trap per key that computed the value in order to report it, so every field was read twice on every dependency change. Each entity now carries a version, and a snapshot taken at the same version re-reads only the fields whose values live elsewhere: child entities, live collections, and a query's own getters. + +Array items are paired with the snapshot they produced themselves rather than the one at the same index, so inserting into or re-sorting a list no longer gives every existing row a new snapshot identity. Output is otherwise unchanged. Dev builds re-read the skipped fields to verify that, so the speedup is a production-build property. diff --git a/packages/fetchium/package.json b/packages/fetchium/package.json index 8339b16..28c0eee 100644 --- a/packages/fetchium/package.json +++ b/packages/fetchium/package.json @@ -156,6 +156,7 @@ "test:unit": "vitest run --project unit", "test:transform": "vitest run --project transform", "test:react": "vitest run --project react", + "bench": "vitest bench --project bench", "check-types": "tsc --noEmit", "copy-docs": "node scripts/copy-docs.mjs", "build": "npm run copy-docs && npm run build:clean && npm run build:types && npm run build:dev && npm run build:prod && node scripts/generate-legacy-entries.cjs", diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 1b0e71d..7ec20eb 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -98,35 +98,245 @@ const objectWrappingHandler: ProxyHandler> = { // Without this, the snapshot encounters the entity proxy (whose prototype is // the user-defined entity class), doesn't recognize it, and returns it as-is. // React then never re-renders on data changes because the proxy reference is -// stable. Walking the proxy via `Object.keys` + property access establishes -// reactive dependencies on the entity's notifier and produces a plain-object -// snapshot whose unchanged subtrees keep stable references. +// stable. Reading the entity's fields establishes reactive dependencies on the +// entity's notifier and produces a plain-object snapshot whose unchanged +// subtrees keep stable references. Fields come from each proxy's +// `EntitySnapshotSource`, not the proxy, which read every field twice. // ====================================================== +/** Trap-free access to one entity proxy's fields, registered by `createProxy`. */ +interface EntitySnapshotSource { + /** Identifies this proxy without retaining it. */ + readonly id: number; + readonly instance: EntityInstance; + readonly notifier: Notifier; + readonly relay: ReactivePromise> | undefined; + keys(): EntityKeys; + /** `proxy[key]` before read-only wrapping. */ + readField(key: string): unknown; +} + +const snapshotSources = new WeakMap(); + +let nextSnapshotSourceId = 1; + +/** + * What a snapshot was produced from, so the next one can skip work. Holds the + * source's id, not the proxy: a snapshot outlives the entity it came from, and + * a strong reference here would make the entity graph reachable from it. + */ +interface SnapshotOrigin { + sourceId: number; + version: number; + keys: EntityKeys; +} + +const snapshotOrigins = new WeakMap(); + +function rememberSnapshot( + sourceId: number, + result: Record, + version: number, + keys: EntityKeys, +): Record { + snapshotOrigins.set(result, { sourceId, version, keys }); + return result; +} + +function indexSnapshotsBySource(prevArr: unknown[]): Map { + const index = new Map(); + for (let i = 0; i < prevArr.length; i++) { + const prevItem = prevArr[i]; + if (prevItem === null || typeof prevItem !== 'object') continue; + const origin = snapshotOrigins.get(prevItem); + if (origin !== undefined) index.set(origin.sourceId, prevItem); + } + return index; +} + +function snapshotCameFrom(snapshotObj: unknown, sourceId: number): boolean { + if (snapshotObj === null || typeof snapshotObj !== 'object') return false; + return snapshotOrigins.get(snapshotObj)?.sourceId === sourceId; +} + +function asPrevObject(prev: unknown): Record | undefined { + return prev !== null && typeof prev === 'object' && !Array.isArray(prev) + ? (prev as Record) + : undefined; +} + +/** + * Snapshot a raw field value. Nested entity proxies go back to Signalium so + * they consume their own notifier. The array and object walks mirror + * `snapshotArray`/`snapshotPlainObject`, which `signalium/utils` does not + * export; a test asserts they stay equal. + */ +function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unknown { + if (value === null || typeof value !== 'object') return value; + if (WRAPPED_VALUE.has(value)) return snapshotRawValue((value as { getValue(): unknown }).getValue(), prev, snap); + if (PROXY_ID.has(value)) return snap(value, prev); + + if (Array.isArray(value)) { + const prevArr = Array.isArray(prev) ? prev : undefined; + let changed = prevArr === undefined || prevArr.length !== value.length; + let prevBySource: Map | undefined; + const result = new Array(value.length); + for (let i = 0; i < value.length; i++) { + const item = value[i]; + let prevItem = prevArr !== undefined ? prevArr[i] : undefined; + const itemSource = item !== null && typeof item === 'object' ? snapshotSources.get(item) : undefined; + if (prevArr !== undefined && itemSource !== undefined) { + if (prevBySource !== undefined) { + prevItem = prevBySource.get(itemSource.id); + } else if (i < prevArr.length && !snapshotCameFrom(prevItem, itemSource.id)) { + // Positions shifted (an insert or re-sort), so pair by identity + // rather than handing each entity its neighbour's snapshot. Indices + // past the old length are appends and skip the index entirely. + prevBySource = indexSnapshotsBySource(prevArr); + prevItem = prevBySource.get(itemSource.id); + } + } + const next = snapshotRawValue(item, prevItem, snap); + result[i] = next; + if (!changed && next !== prevArr![i]) changed = true; + } + return changed ? result : prevArr!; + } + + if (Object.getPrototypeOf(value) === ObjectProto) { + const obj = value as Record; + const prevObj = asPrevObject(prev); + const keys = Object.keys(obj); + let changed = prevObj === undefined || Object.keys(prevObj).length !== keys.length; + const result: Record = {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const next = snapshotRawValue(obj[key], prevObj?.[key], snap); + result[key] = next; + if (!changed && next !== prevObj![key]) changed = true; + } + return changed ? result : prevObj!; + } + + return snap(value, prev); +} + +/** Dev-only counters: losing the fast path is invisible to behavioral tests. */ +export let __debug_snapshotFieldReads = 0; +export let __debug_snapshotFullWalks = 0; +export function __debug_resetSnapshotCounters(): void { + __debug_snapshotFieldReads = 0; + __debug_snapshotFullWalks = 0; +} + +function walkFields( + source: EntitySnapshotSource, + fields: string[], + prevObj: Record | undefined, + snap: SnapshotFn, + into: Record, +): void { + for (let i = 0; i < fields.length; i++) { + const key = fields[i]; + const value = source.readField(key); + if (IS_DEV) __debug_snapshotFieldReads++; + // Methods are bound to the proxy and cached on it, so identity is stable. + into[key] = typeof value === 'function' ? value : snapshotRawValue(value, prevObj?.[key], snap); + } +} + const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unknown => { - const obj = current as Record; - const keys = Object.keys(obj); - const prevObj = - prev !== null && typeof prev === 'object' && !Array.isArray(prev) ? (prev as Record) : undefined; - let changed = !prevObj || Object.keys(prevObj).length !== keys.length; + const source = snapshotSources.get(current); + // An `Entity` that never went through `createProxy` has no fields to read — + // its own properties are the shape's type defs. Hand it back untouched, the + // way Signalium treats any class it has no handler for. + if (source === undefined) return current; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + source.relay?.value; + source.notifier.consume(); + + const version = source.instance.version; + const keys = source.keys(); + + let prevObj = asPrevObject(prev); + let origin = prevObj !== undefined ? snapshotOrigins.get(prevObj) : undefined; + if (origin !== undefined && origin.sourceId !== source.id) { + // Another entity's snapshot, from an array Signalium paired by position. + prevObj = undefined; + origin = undefined; + } + + if (origin !== undefined && origin.version === version && origin.keys === keys) { + // `data` is untouched, so only fields living elsewhere need re-reading. + const before = prevObj!; + const patch: Record = {}; + walkFields(source, keys.dynamic, before, snap, patch); + + let result: Record | undefined; + for (let i = 0; i < keys.dynamic.length; i++) { + const key = keys.dynamic[i]; + if (patch[key] !== before[key]) { + if (result === undefined) result = { ...before }; + result[key] = patch[key]; + } + } + + if (IS_DEV) assertStaticFieldsUnchanged(source, keys, before, snap); + return rememberSnapshot(source.id, result ?? before, version, keys); + } + + if (IS_DEV) __debug_snapshotFullWalks++; + const enumerable = keys.enumerable; const result: Record = {}; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - const val = obj[key]; - let next: unknown; - if (typeof val === 'function') { - // Methods (e.g. __refetch, entity methods) are bound to the original - // proxy via closure, so preserve the reference directly. - next = val; - } else { - next = snap(val, prevObj?.[key]); + walkFields(source, enumerable, prevObj, snap, result); + + let changed = prevObj === undefined || Object.keys(prevObj).length !== enumerable.length; + if (!changed) { + for (let i = 0; i < enumerable.length; i++) { + if (result[enumerable[i]] !== prevObj![enumerable[i]]) { + changed = true; + break; + } } - result[key] = next; - if (!changed && next !== prevObj?.[key]) changed = true; } - return changed ? result : prevObj; + return rememberSnapshot(source.id, changed ? result : prevObj!, version, keys); }; +/** + * Guards the fast path's assumptions: `data` only changes through a `notify()` + * that bumps `version`, and every field outside `keys.dynamic` is a pure + * function of `data`. Either breaking strands a snapshot permanently, which no + * behavioral test catches. Skipped fields only — re-reading a dynamic field is + * not identity-stable, since snapshotting a child updates state it pairs on. + * + * This costs dev builds the read the fast path saved, so the speedup shows up + * in production builds only. + */ +function assertStaticFieldsUnchanged( + source: EntitySnapshotSource, + keys: EntityKeys, + before: Record, + snap: SnapshotFn, +): void { + const verified: Record = {}; + const readsBefore = __debug_snapshotFieldReads; + walkFields(source, keys.static, before, snap, verified); + // This walk is verification, not work the fast path did. + __debug_snapshotFieldReads = readsBefore; + for (const key of keys.static) { + if (verified[key] !== before[key]) { + throw new Error( + `[fetchium] stale entity snapshot: ${source.instance.typename}:${source.instance.id} field '${key}' ` + + `changed while version stayed at ${source.instance.version}. Either the field was mutated without ` + + `notify(), or the shape's static/dynamic split is wrong for it. This throws from inside a snapshot, ` + + `so the reactive computation holding it stays in an error state and will rethrow on every read — ` + + `reload rather than chasing the later throws, which are all this one.`, + ); + } + } +} + // Register once on the `Entity` base class. Signalium 3.0.1+ resolves custom // snapshot handlers via prototype-chain lookup, so every user-defined entity // subclass automatically inherits this handler — no per-class registration @@ -153,6 +363,8 @@ export class EntityInstance { liveCollections: LiveCollectionBinding[] = []; satisfiedDefs: WeakSet> = new WeakSet(); parseId: number = -1; + /** Bumped on `notify()`; see `assertStaticFieldsUnchanged` for the invariant. */ + version: number = 0; _entityCache: { gcTime?: number } | undefined; _extraMethods: Record unknown> | undefined; _extraGetters: Record unknown> | undefined; @@ -274,6 +486,7 @@ export class EntityInstance { } notify(): void { + this.version++; this._notifier.notify(); } @@ -296,6 +509,187 @@ function filterEntityArray(array: unknown[], innerDef: ValidatorDef, qu return result; } +// ====================================================== +// Static-field analysis — which fields a version check covers +// ====================================================== + +/** + * Masks for a field that can change without its owning entity notifying. + * + * No `UNION` bit, but not because the mask covers it: `defineUnion` ORs its + * members' *top-level* masks, so `t.union(t.entity(A), …)` does carry `ENTITY` + * while `t.union(t.object({ child: t.entity(A) }), …)` does not. What catches + * the nested case is `computeIsStaticFieldDef` recursing into the union's + * member defs. Short-circuiting a union on its mask alone would go stale. + */ +const DYNAMIC_MASKS = Mask.ENTITY | Mask.LIVE; + +const staticFieldDefs = new WeakMap, boolean>(); + +/** + * Is a field's snapshot a pure function of its entity's own `data`? One-sided: + * only the shapes recognised here answer `true`, mirroring the allowlist + * `getEntityDef` validates against, so an unfamiliar def costs a read rather + * than serving a stale value. + */ +function isStaticFieldDef(def: unknown): boolean { + // `t.string` is a bare `Mask`, `t.typename('X')` the literal string, + // `t.enum`/`t.const` a `Set` of allowed values. + if (typeof def === 'number' || typeof def === 'string') return true; + if (def instanceof Set) return true; + if (!(def instanceof ValidatorDef)) return false; + + const cached = staticFieldDefs.get(def); + if (cached !== undefined) return cached; + // Break cycles as dynamic; a def only ever reached inside one keeps that + // answer, which costs a read and never goes stale. + staticFieldDefs.set(def, false); + + const isStatic = computeIsStaticFieldDef(def); + staticFieldDefs.set(def, isStatic); + return isStatic; +} + +function computeIsStaticFieldDef(def: ValidatorDef): boolean { + if (def._liveConfig !== undefined) return false; + if ((def.mask & DYNAMIC_MASKS) !== 0) return false; + + // No shape: primitive, format, or union of literals. One inner def: array, + // record, parse result, optional/nullable clone. A record of defs: object. + const shape = def.shape; + if (shape === undefined || shape === null) return true; + if (shape instanceof ValidatorDef) return isStaticFieldDef(shape); + if (typeof shape !== 'object') return isStaticFieldDef(shape); + if (shape instanceof Set) return true; + return Object.values(shape as Record).every(isStaticFieldDef); +} + +// ====================================================== +// Per-shape key metadata +// ====================================================== + +/** + * The key lists a proxy reports, shared per shape. `own` is `ownKeys`; + * `enumerable` drops the non-enumerable entity methods. + */ +interface EntityKeys { + own: string[]; + enumerable: string[]; + dynamic: string[]; + /** `enumerable` minus `dynamic` — the fields a version check covers. */ + static: string[]; + enumerableSet: Set; +} + +const shapeKeyCache = new WeakMap, EntityKeys>(); + +function shapeKeys( + validatorDef: ValidatorDef, + shapeFields: Record, + methods: Record unknown> | undefined, +): EntityKeys { + let keys = shapeKeyCache.get(validatorDef); + if (keys !== undefined) return keys; + + const own = Object.keys(shapeFields); + if (!own.includes('__typename')) own.push('__typename'); + const enumerable = own.slice(); + if (methods !== undefined) { + for (const methodKey of Object.keys(methods)) { + if (!own.includes(methodKey)) own.push(methodKey); + } + } + const dynamic = enumerable.filter(key => key !== '__typename' && !isStaticFieldDef(shapeFields[key])); + + const dynamicSet = new Set(dynamic); + keys = { + own, + enumerable, + dynamic, + static: enumerable.filter(key => !dynamicSet.has(key)), + enumerableSet: new Set(enumerable), + }; + shapeKeyCache.set(validatorDef, keys); + return keys; +} + +/** Plus a query's late-attached methods and getters, always dynamic. */ +function withExtraKeys( + base: EntityKeys, + extraMethods: Record | undefined, + extraGetters: Record | undefined, +): EntityKeys { + const own = base.own.slice(); + const enumerable = base.enumerable.slice(); + const dynamic = base.dynamic.slice(); + for (const extras of [extraMethods, extraGetters]) { + if (extras === undefined) continue; + for (const key of Object.keys(extras)) { + if (!own.includes(key)) own.push(key); + if (!enumerable.includes(key)) enumerable.push(key); + if (!dynamic.includes(key)) dynamic.push(key); + } + } + const dynamicSet = new Set(dynamic); + return { + own, + enumerable, + dynamic, + static: enumerable.filter(key => !dynamicSet.has(key)), + enumerableSet: new Set(enumerable), + }; +} + +// ====================================================== +// Field readers — module level so each proxy holds a pointer, not a closure +// ====================================================== + +function bindMethod( + prop: string, + source: Record unknown>, + cache: Map unknown>, + proxy: object, + reactive: boolean, +): (...args: unknown[]) => unknown { + let bound = cache.get(prop); + if (bound === undefined) { + bound = source[prop].bind(proxy); + if (reactive) bound = reactiveMethod(proxy, bound); + cache.set(prop, bound); + } + return bound; +} + +/** Narrow a shared-typename array to this field's def, cached on array identity. */ +function narrowEntityArray( + prop: string, + value: unknown[], + shapeFields: Record, + filterCache: Map, + queryClient: QueryClient, +): unknown[] { + const fieldDef = shapeFields[prop]; + if (fieldDef instanceof ValidatorDef && (fieldDef.mask & Mask.ARRAY) !== 0) { + const innerDef = fieldDef.shape as ValidatorDef | undefined; + if (innerDef instanceof ValidatorDef && (innerDef.mask & Mask.ENTITY) !== 0) { + const typename = innerDef.typenameValue; + if (typename !== undefined) { + const defs = queryClient.getEntityDefsForTypename(typename); + if (defs !== undefined && defs.length > 1) { + const cached = filterCache.get(prop); + if (cached !== undefined && cached.source === value) { + return cached.filtered; + } + const filtered = filterEntityArray(value, innerDef, queryClient); + filterCache.set(prop, { source: value, filtered }); + return filtered; + } + } + } + } + return value; +} + // ====================================================== // Proxy Creation (module-level function, not a method) // ====================================================== @@ -342,45 +736,46 @@ function createProxy( throw new Error(`typenameField "${typenameField}" must be declared in the entity shape`); } - const baseOwnKeys = Object.keys(shapeFields); - if (!baseOwnKeys.includes('__typename')) { - baseOwnKeys.push('__typename'); - } - if (methods) { - for (const methodKey of Object.keys(methods)) { - if (!baseOwnKeys.includes(methodKey)) { - baseOwnKeys.push(methodKey); - } + // Shared per shape; only a query's root entity allocates its own lists. + const baseKeys = shapeKeys(validatorDef, shapeFields, methods); + let cachedMethods: Record | undefined; + let cachedGetters: Record | undefined; + let keys = baseKeys; + + function entityKeys(): EntityKeys { + // Both slots: these lists decide what a snapshot walks and re-reads. + const methodsNow = instance._extraMethods; + const gettersNow = instance._extraGetters; + if (methodsNow !== cachedMethods || gettersNow !== cachedGetters) { + cachedMethods = methodsNow; + cachedGetters = gettersNow; + keys = + methodsNow === undefined && gettersNow === undefined + ? baseKeys + : withExtraKeys(baseKeys, methodsNow, gettersNow); } + return keys; } - // Cache the full ownKeys list (including extra methods) — rebuilt when - // _extraMethods changes (set lazily after entity creation). - let cachedExtraMethods: Record | undefined; - let ownKeysList = baseOwnKeys; - - function getOwnKeys(): string[] { - const current = instance._extraMethods; - if (current !== cachedExtraMethods) { - cachedExtraMethods = current; - ownKeysList = baseOwnKeys.slice(); - if (current !== undefined) { - for (const methodKey of Object.keys(current)) { - if (!ownKeysList.includes(methodKey)) { - ownKeysList.push(methodKey); - } - } - } - const getters = instance._extraGetters; - if (getters !== undefined) { - for (const getterKey of Object.keys(getters)) { - if (!ownKeysList.includes(getterKey)) { - ownKeysList.push(getterKey); - } - } - } + // The `get` trap's value half, minus reactive bookkeeping and wrapping. + // Symbols, `toJSON` and `__context` never reach here. + function readField(prop: string): unknown { + if (prop === '__typename') return instance.typename; + + const extraGetters = instance._extraGetters; + if (extraGetters !== undefined && prop in extraGetters) return extraGetters[prop](); + + const extraMethods = instance._extraMethods; + if (extraMethods !== undefined && prop in extraMethods) { + return bindMethod(prop, extraMethods, wrappedMethods, proxy, false); + } + + if (methods !== undefined && prop in methods) { + return bindMethod(prop, methods, wrappedMethods, proxy, true); } - return ownKeysList; + + const value = instance.data[prop]; + return Array.isArray(value) ? narrowEntityArray(prop, value, shapeFields, filterCache, queryClient) : value; } const handler: ProxyHandler = { @@ -408,62 +803,7 @@ function createProxy( entityNotifier.consume(); - if (typeof prop === 'string') { - const extraGetters = instance._extraGetters; - if (extraGetters !== undefined && prop in extraGetters) { - return extraGetters[prop](); - } - - const extraMethods = instance._extraMethods; - if (extraMethods !== undefined && prop in extraMethods) { - let bound = wrappedMethods.get(prop); - if (!bound) { - bound = extraMethods[prop].bind(proxy); - wrappedMethods.set(prop, bound); - } - return bound; - } - - if (methods && prop in methods) { - let wrapped = wrappedMethods.get(prop); - if (!wrapped) { - wrapped = reactiveMethod(proxy, methods[prop].bind(proxy)); - wrappedMethods.set(prop, wrapped); - } - return wrapped; - } - } - - const value = instance.data[prop as string]; - - if (typeof value === 'object' && value !== null && WRAPPED_VALUE.has(value)) { - return wrapValue((value as { getValue(): unknown }).getValue()); - } - - if (Array.isArray(value) && typeof prop === 'string') { - const fieldDef = shapeFields[prop]; - if (fieldDef instanceof ValidatorDef && (fieldDef.mask & Mask.ARRAY) !== 0) { - const innerDef = fieldDef.shape as ValidatorDef | undefined; - if (innerDef instanceof ValidatorDef && (innerDef.mask & Mask.ENTITY) !== 0) { - const typename = innerDef.typenameValue; - if (typename !== undefined) { - const defs = queryClient.getEntityDefsForTypename(typename); - if (defs !== undefined && defs.length > 1) { - const cached = filterCache.get(prop); - if (cached !== undefined && cached.source === value) { - return wrapValue(cached.filtered); - } - const filtered = filterEntityArray(value, innerDef, queryClient); - filterCache.set(prop, { source: value, filtered }); - return wrapValue(filtered); - } - } - } - } - return wrapValue(value); - } - - return wrapValue(value); + return wrapValue(readField(prop)); }, set() { @@ -484,28 +824,17 @@ function createProxy( }, ownKeys() { - return getOwnKeys(); + return entityKeys().own; }, getOwnPropertyDescriptor(target, prop) { - if (prop === '__typename') { - return { enumerable: true, configurable: true, value: instance.typename, writable: false }; - } - if (prop in shapeFields) { + // Same list the snapshot walks, so the two agree by construction. + if (typeof prop !== 'string') return undefined; + if (entityKeys().enumerableSet.has(prop)) { return { enumerable: true, configurable: true, value: handler.get!(target, prop, proxy), writable: false }; } - if (typeof prop === 'string') { - const extraGetters = instance._extraGetters; - if (extraGetters && prop in extraGetters) { - return { enumerable: true, configurable: true, value: handler.get!(target, prop, proxy), writable: false }; - } - const extraMethods = instance._extraMethods; - if (extraMethods && prop in extraMethods) { - return { enumerable: true, configurable: true, value: handler.get!(target, prop, proxy), writable: false }; - } - if (methods && prop in methods) { - return { enumerable: false, configurable: true, value: handler.get!(target, prop, proxy), writable: false }; - } + if (methods !== undefined && prop in methods) { + return { enumerable: false, configurable: true, value: handler.get!(target, prop, proxy), writable: false }; } return undefined; }, @@ -514,6 +843,14 @@ function createProxy( proxy = new Proxy>({} as Record, handler); PROXY_ID.set(proxy, key); + snapshotSources.set(proxy, { + id: nextSnapshotSourceId++, + instance, + notifier: entityNotifier, + relay: entityRelay, + keys: entityKeys, + readField, + }); setScopeOwner(proxy, queryClient); return proxy; diff --git a/packages/fetchium/src/__benchmarks__/snapshot.bench.ts b/packages/fetchium/src/__benchmarks__/snapshot.bench.ts new file mode 100644 index 0000000..5457192 --- /dev/null +++ b/packages/fetchium/src/__benchmarks__/snapshot.bench.ts @@ -0,0 +1,131 @@ +import { bench, describe } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { hashValue, snapshot } from 'signalium/utils'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { RESTQuery } from '../rest/index.js'; +import { Entity } from '../proxy.js'; +import { fetchQuery } from '../query.js'; +import { t } from '../typeDefs.js'; + +/** + * What `useQuery` costs per render: `useReactive` deep-snapshots the query + * result on every dependency change, so this is the price of one re-render of + * a list consumer, without React's own reconciliation. + * + * Run with `npm run bench`. Benchmarks build with `IS_DEV: false` so the + * dev-only snapshot invariant check doesn't skew the numbers. + */ + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + symbol = t.string; + name = t.string; + decimals = t.number; + price = t.number; + balance = t.string; + chain = t.string; + verified = t.boolean; + metadata = t.object({ logo: t.string, website: t.string, tags: t.array(t.string) }); +} + +class GetPortfolio extends RESTQuery { + params = { count: t.number }; + path = `/portfolio/${this.params.count}`; + result = { tokens: t.array(t.entity(Token)), updatedAt: t.number }; + getConfig() { + return { retry: 0, gcTime: Infinity }; + } +} + +function portfolio(count: number) { + return { + tokens: Array.from({ length: count }, (_, i) => ({ + __typename: 'Token', + id: `tok-${i}`, + symbol: `SYM${i}`, + name: `Token Number ${i}`, + decimals: 9, + price: 100 + i, + balance: `${i * 1000}`, + chain: 'solana', + verified: true, + metadata: { logo: `logo-${i}.png`, website: `t${i}.example.com`, tags: ['defi'] }, + })), + updatedAt: 1, + }; +} + +async function setup(count: number) { + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [ + new RESTQueryAdapter({ + fetch: (async () => + new Response(JSON.stringify(portfolio(count)), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch, + baseUrl: 'http://localhost', + }), + ], + }); + + // The listener is what activates the relay, exactly as a mounted consumer + // does; an unwatched signal never fetches and `await query` never settles. + const { query, pull } = withContexts([[QueryClientContext, client]], () => { + const q = fetchQuery(GetPortfolio, { count }); + let prev: unknown; + const snapshots = watcher(() => (prev = snapshot(q, prev))); + snapshots.addListener(() => {}); + return { query: q, pull: () => snapshots.value }; + }); + + await query; + pull(); + return { client, pull }; +} + +/** + * One fixture per size, built once. The measured body is only the work a + * streamed event triggers: apply the event, then pull the snapshot the way a + * mounted consumer would. + */ +const fixtures = await Promise.all([100, 1000].map(async count => ({ count, ...(await setup(count)) }))); + +for (const { count, client, pull } of fixtures) { + describe(`${count} entities`, () => { + let price = 1_000_000; + let i = 0; + + bench('re-snapshot after one field changes', () => { + client.applyMutationEvent({ + type: 'update', + typename: 'Token', + data: { id: `tok-${i++ % count}`, price: price++ }, + }); + pull(); + }); + + bench('re-snapshot after an identical event', () => { + const key = hashValue(['Token', 'tok-0']); + const current = client.entityMap.getEntity(key)!.data.price; + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', price: current } }); + pull(); + }); + }); +} + +// Whole cold path — client construction, fetch, parse, apply, first snapshot. +// The snapshot is a small part of it, and the samples are noisy (±10-20% rme), +// so don't read a snapshot change off this. +describe('cold query, first snapshot included', () => { + for (const count of [100, 1000]) { + bench(`${count} entities`, async () => { + const { client } = await setup(count); + client.destroy(); + }); + } +}); diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts new file mode 100644 index 0000000..6a781b3 --- /dev/null +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -0,0 +1,643 @@ +import { describe, it, expect } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { hashValue, snapshot } from 'signalium/utils'; +import { + __debug_resetSnapshotCounters, + __debug_snapshotFieldReads, + __debug_snapshotFullWalks, +} from '../EntityInstance.js'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { setupTestClient } from './utils.js'; + +/** + * Entity Snapshot Tests + * + * `useQuery` -> `useReactive` deep-snapshots the query result so React sees a + * plain, structurally-shared object tree. These tests pin the contract that + * walk relies on: the snapshot's key set matches the proxy's own enumerable + * keys, nothing proxied leaks into the result, and unchanged subtrees keep + * their identity across re-snapshots. + */ + +/** + * Holds a `useReactive`-style snapshot signal open for the whole test, so + * mutation events can be applied between reads. `testWithClient` can't be used + * here: it runs its body as one reactive consumer, and dirtying a signal that + * consumer already read is an error. + */ +function snapshotHarness(client: QueryClient, start: () => T) { + return withContexts([[QueryClientContext, client]], () => { + const query = start(); + let prev: unknown; + const snapshots = watcher(() => (prev = snapshot(query, prev))); + snapshots.addListener(() => {}); + return { + query, + /** The snapshot of the resolved query result, as a component would see it. */ + read: () => (snapshots.value as { value: Record }).value, + }; + }); +} + +function isProxyFree(value: unknown, path = '$'): void { + if (value === null || typeof value !== 'object') return; + const proto = Object.getPrototypeOf(value); + expect(proto === Object.prototype || proto === Array.prototype || value instanceof Date, `${path} is plain`).toBe( + true, + ); + if (value instanceof Date) return; + for (const key of Object.keys(value)) { + isProxyFree((value as Record)[key], `${path}.${key}`); + } +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + symbol = t.string; + price = t.number; + metadata = t.object({ logo: t.string, tags: t.array(t.string) }); + + doubled() { + return (this as unknown as { price: number }).price * 2; + } +} + +class GetPortfolio extends RESTQuery { + path = '/portfolio'; + result = { tokens: t.array(t.entity(Token)) }; +} + +function portfolio(count: number) { + return { + tokens: Array.from({ length: count }, (_, i) => ({ + __typename: 'Token', + id: `tok-${i}`, + symbol: `SYM${i}`, + price: i, + metadata: { logo: `logo-${i}.png`, tags: ['defi'] }, + })), + }; +} + +describe('Entity Snapshots', () => { + const getClient = setupTestClient(); + + it("snapshots exactly the proxy's own enumerable keys", async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(1)); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + + const result = query.value as unknown as Record; + const snap = read(); + + expect(Object.keys(snap)).toEqual(Object.keys(result)); + + const tokenProxy = (result.tokens as Record[])[0]; + const tokenSnap = (snap.tokens as Record[])[0]; + expect(Object.keys(tokenSnap)).toEqual(Object.keys(tokenProxy)); + + // Entity methods are reported non-enumerable by the proxy, so they stay + // out of the snapshot; the query's own methods are enumerable and stay in. + expect(Object.keys(tokenSnap)).not.toContain('doubled'); + expect(typeof snap.__refetch).toBe('function'); + }); + + it('produces a plain, proxy-free tree', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(3)); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + + const snap = read(); + isProxyFree(snap.tokens, '$.tokens'); + expect((snap.tokens as Record[])[1]).toEqual({ + __typename: 'Token', + id: 'tok-1', + symbol: 'SYM1', + price: 1, + metadata: { logo: 'logo-1.png', tags: ['defi'] }, + }); + }); + + it('keeps unchanged entities and nested subtrees identical across re-snapshots', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(5)); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + + const before = read(); + const beforeTokens = before.tokens as Record[]; + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-2', price: 999 } }); + + const after = read(); + const afterTokens = after.tokens as Record[]; + + expect(after).not.toBe(before); + expect(afterTokens[2]).not.toBe(beforeTokens[2]); + expect(afterTokens[2].price).toBe(999); + // The changed entity's untouched nested object keeps its identity... + expect(afterTokens[2].metadata).toBe(beforeTokens[2].metadata); + // ...and so does every entity that didn't change. + for (const i of [0, 1, 3, 4]) { + expect(afterTokens[i], `token ${i}`).toBe(beforeTokens[i]); + } + }); + + it('re-snapshots an ancestor when only a nested entity changes', async () => { + const { client, mockFetch } = getClient(); + + class Holder extends Entity { + __typename = t.typename('Holder'); + id = t.id; + label = t.string; + token = t.entity(Token); + } + class GetHolder extends RESTQuery { + path = '/holder'; + result = { holder: t.entity(Holder) }; + } + + mockFetch.get('/holder', { + holder: { + __typename: 'Holder', + id: 'h-1', + label: 'first', + token: { __typename: 'Token', id: 'tok-9', symbol: 'SYM9', price: 9, metadata: { logo: 'l', tags: [] } }, + }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetHolder)); + await query; + + const before = read() as unknown as { holder: { label: string; token: { price: number } } }; + const beforeHolder = before.holder; + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-9', price: 42 } }); + + const after = read() as unknown as { holder: { label: string; token: { price: number } } }; + + expect(after.holder).not.toBe(beforeHolder); + expect(after.holder.token.price).toBe(42); + expect(after.holder.label).toBe('first'); + }); + + it('unwraps formatted values and keeps method identity stable', async () => { + const { client, mockFetch } = getClient(); + + class Position extends Entity { + __typename = t.typename('Position'); + id = t.id; + opened = t.format('date-time'); + } + class GetPosition extends RESTQuery { + path = '/position'; + result = { position: t.entity(Position) }; + } + + mockFetch.get('/position', { + position: { __typename: 'Position', id: 'p-1', opened: '2026-01-01T00:00:00.000Z' }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPosition)); + await query; + + const before = read(); + const beforePosition = before.position as Record; + expect(beforePosition.opened).toBeInstanceOf(Date); + expect((beforePosition.opened as Date).toISOString()).toBe('2026-01-01T00:00:00.000Z'); + + client.applyMutationEvent({ + type: 'update', + typename: 'Position', + data: { id: 'p-1', opened: '2026-01-02T00:00:00.000Z' }, + }); + + const after = read(); + const afterPosition = after.position as Record; + expect(afterPosition.opened).toBeInstanceOf(Date); + expect((afterPosition.opened as Date).toISOString()).toBe('2026-01-02T00:00:00.000Z'); + expect(after.__refetch).toBe(before.__refetch); + }); + + it('reflects live-array membership changes in the parent snapshot', async () => { + const { client, mockFetch } = getClient(); + + class Message extends Entity { + __typename = t.typename('Message'); + id = t.id; + body = t.string; + chatId = t.string; + } + class Chat extends Entity { + __typename = t.typename('Chat'); + id = t.id; + messages = t.liveArray(Message, { constraints: { chatId: (this as unknown as { id: string }).id } }); + } + class GetChat extends RESTQuery { + path = '/chat'; + result = { chat: t.entity(Chat) }; + } + + mockFetch.get('/chat', { + chat: { + __typename: 'Chat', + id: 'c-1', + messages: [{ __typename: 'Message', id: 'm-1', body: 'hi', chatId: 'c-1' }], + }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetChat)); + await query; + + const before = read() as unknown as { chat: { messages: { id: string }[] } }; + expect(before.chat.messages).toHaveLength(1); + + client.applyMutationEvent({ + type: 'create', + typename: 'Message', + data: { __typename: 'Message', id: 'm-2', body: 'there', chatId: 'c-1' }, + }); + + const after = read() as unknown as { chat: { messages: { id: string }[] } }; + expect(after.chat.messages.map(m => m.id)).toEqual(['m-1', 'm-2']); + }); + + it('keeps existing rows identical when a sorted live array gains a row that sorts first', async () => { + const { client, mockFetch } = getClient(); + + class Event extends Entity { + __typename = t.typename('Event'); + id = t.id; + feedId = t.string; + at = t.number; + } + class Feed extends Entity { + __typename = t.typename('Feed'); + id = t.id; + events = t.liveArray(Event, { + constraints: { feedId: (this as unknown as { id: string }).id }, + sort: (a: { at: number }, b: { at: number }) => b.at - a.at, + }); + } + class GetFeed extends RESTQuery { + path = '/feed'; + result = { feed: t.entity(Feed) }; + } + + mockFetch.get('/feed', { + feed: { + __typename: 'Feed', + id: 'f-1', + events: [ + { __typename: 'Event', id: 'e-2', feedId: 'f-1', at: 2 }, + { __typename: 'Event', id: 'e-1', feedId: 'f-1', at: 1 }, + ], + }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetFeed)); + await query; + + const before = read() as unknown as { feed: { events: { id: string }[] } }; + expect(before.feed.events.map(e => e.id)).toEqual(['e-2', 'e-1']); + + client.applyMutationEvent({ + type: 'create', + typename: 'Event', + data: { __typename: 'Event', id: 'e-3', feedId: 'f-1', at: 3 }, + }); + + const after = read() as unknown as { feed: { events: { id: string }[] } }; + expect(after.feed.events.map(e => e.id)).toEqual(['e-3', 'e-2', 'e-1']); + + // Signalium pairs array items with the previous array's item at the same + // index, so after the insert every row is diffed against its neighbour's + // snapshot. Existing rows must still keep their identity. + expect(after.feed.events[1]).toBe(before.feed.events[0]); + expect(after.feed.events[2]).toBe(before.feed.events[1]); + }); + + it('gives each consumer its own snapshot tree', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); + + const first = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await first.query; + const second = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await second.query; + + const a = first.read(); + const b = second.read(); + + expect(a).not.toBe(b); + expect((a.tokens as unknown[])[0]).not.toBe((b.tokens as unknown[])[0]); + expect(a).toEqual(b); + }); + + it('propagates an update to a field the entity shares with nothing else', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + + const before = read(); + const beforeTokens = before.tokens as Record[]; + + // Two updates in a row: the second must not be swallowed by a snapshot + // that already decided the entity was unchanged. + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', symbol: 'FIRST' } }); + const middle = read(); + expect((middle.tokens as Record[])[0].symbol).toBe('FIRST'); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', symbol: 'SECOND' } }); + const after = read(); + const afterTokens = after.tokens as Record[]; + + expect(afterTokens[0].symbol).toBe('SECOND'); + expect(afterTokens[1]).toBe(beforeTokens[1]); + }); + + it('re-reads nested entities behind wrappers, plain objects and arrays', async () => { + const { client, mockFetch } = getClient(); + + // Shapes modelled on a real schema: primitive unions and enums collapse to + // bare masks, while an entity can sit behind optional/nullable wrappers, + // inside a plain object, or inside an array. Only the entity-bearing + // fields may be re-read from a version short-circuit, so a misjudgement + // here shows up as a nested entity update that never reaches the parent. + const NumberLike = t.union(t.string, t.number); + const OptionalNullableString = t.optional(t.nullable(t.string)); + + class Row extends Entity { + __typename = t.typename('Row'); + id = t.id; + count = NumberLike; + label = OptionalNullableString; + kind = t.enum('up', 'down', 'pending'); + deep = t.object({ a: t.string, inner: t.object({ items: t.array(t.object({ x: t.number })) }) }); + wrapped = t.optional(t.nullable(t.entity(Token))); + boxed = t.object({ note: t.string, child: t.optional(t.nullable(t.entity(Token))) }); + list = t.array(t.entity(Token)); + } + class GetRow extends RESTQuery { + path = '/row'; + result = { row: t.entity(Row) }; + } + + const tok = (id: string, price: number) => ({ + __typename: 'Token', + id, + symbol: id.toUpperCase(), + price, + metadata: { logo: `${id}.png`, tags: ['defi'] }, + }); + + mockFetch.get('/row', { + row: { + __typename: 'Row', + id: 'r-1', + count: '7', + label: null, + kind: 'up', + deep: { a: 'x', inner: { items: [{ x: 1 }] } }, + wrapped: tok('w', 1), + boxed: { note: 'n', child: tok('b', 2) }, + list: [tok('l', 3)], + }, + }); + + type Snap = { + row: { + count: string | number; + deep: { inner: { items: { x: number }[] } }; + wrapped: { price: number }; + boxed: { note: string; child: { price: number } }; + list: { price: number }[]; + }; + }; + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetRow)); + await query; + + const before = read() as unknown as Snap; + expect(before.row.wrapped.price).toBe(1); + expect(before.row.boxed.child.price).toBe(2); + expect(before.row.list[0].price).toBe(3); + + // Each of these changes only a child entity — Row itself never notifies. + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'w', price: 11 } }); + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'b', price: 22 } }); + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'l', price: 33 } }); + + const after = read() as unknown as Snap; + expect(after.row.wrapped.price).toBe(11); + expect(after.row.boxed.child.price).toBe(22); + expect(after.row.list[0].price).toBe(33); + // The parent's own inert fields are untouched, and the plain-object subtree + // that changed nothing keeps its identity. + expect(after.row.count).toBe('7'); + expect(after.row.deep).toBe(before.row.deep); + expect(after.row.boxed.note).toBe('n'); + }); + + it('re-reads the parent when its own inert fields change', async () => { + const { client, mockFetch } = getClient(); + + const NumberLike = t.union(t.string, t.number); + + class Row extends Entity { + __typename = t.typename('Row'); + id = t.id; + count = NumberLike; + kind = t.enum('up', 'down', 'pending'); + deep = t.object({ inner: t.object({ items: t.array(t.object({ x: t.number })) }) }); + } + class GetRow extends RESTQuery { + path = '/row'; + result = { row: t.entity(Row) }; + } + + mockFetch.get('/row', { + row: { __typename: 'Row', id: 'r-1', count: '7', kind: 'up', deep: { inner: { items: [{ x: 1 }] } } }, + }); + + type Snap = { row: { count: string | number; kind: string; deep: { inner: { items: { x: number }[] } } } }; + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetRow)); + await query; + const before = read() as unknown as Snap; + + client.applyMutationEvent({ + type: 'update', + typename: 'Row', + data: { id: 'r-1', count: 9, kind: 'down', deep: { inner: { items: [{ x: 2 }] } } }, + }); + + const after = read() as unknown as Snap; + expect(after.row.count).toBe(9); + expect(after.row.kind).toBe('down'); + expect(after.row.deep.inner.items[0].x).toBe(2); + expect(after.row.deep).not.toBe(before.row.deep); + }); + + it('re-reads only the entity that changed, not the whole list', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(20)); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + + __debug_resetSnapshotCounters(); + read(); + const firstWalks = __debug_snapshotFullWalks; + const firstReads = __debug_snapshotFieldReads; + // The root plus every token. + expect(firstWalks).toBe(21); + + __debug_resetSnapshotCounters(); + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-7', price: 777 } }); + read(); + + // The root's own data didn't change, so it re-reads only its dynamic + // fields; of the 20 tokens only tok-7 is walked. Nothing here is asserted + // by the behavioral tests — losing the fast path keeps them all green. + expect(__debug_snapshotFullWalks).toBe(1); + expect(__debug_snapshotFieldReads).toBeLessThan(firstReads / 5); + }); + + // TODO: drop this once `signalium/utils` exports its structural walkers and + // `snapshotRawValue` can call them instead of reimplementing them. + it('walks nested plain data the same way Signalium would', async () => { + const { client, mockFetch } = getClient(); + + // `snapshotRawValue` reimplements Signalium's unexported array and object + // walks. If the two ever diverge, structural sharing silently changes and + // only a re-render profile would show it. + class Doc extends Entity { + __typename = t.typename('Doc'); + id = t.id; + body = t.object({ + title: t.string, + tags: t.array(t.string), + sections: t.array(t.object({ heading: t.string, lines: t.array(t.string) })), + }); + } + class GetDoc extends RESTQuery { + path = '/doc'; + result = { doc: t.entity(Doc) }; + } + + const raw = (title: string) => ({ + title, + tags: ['a', 'b'], + sections: [ + { heading: 'one', lines: ['x'] }, + { heading: 'two', lines: ['y', 'z'] }, + ], + }); + + mockFetch.get('/doc', { doc: { __typename: 'Doc', id: 'd-1', body: raw('first') } }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetDoc)); + await query; + + type Body = { title: string; tags: string[]; sections: { heading: string; lines: string[] }[] }; + const ours = (read().doc as { body: Body }).body; + const theirs = snapshot(raw('first'), undefined) as Body; + expect(ours).toEqual(theirs); + + // Same structural sharing on a change: the untouched subtrees keep + // identity in both implementations, the changed one does not. + client.applyMutationEvent({ type: 'update', typename: 'Doc', data: { id: 'd-1', body: raw('second') } }); + const oursNext = (read().doc as { body: Body }).body; + const theirsNext = snapshot(raw('second'), theirs) as Body; + + expect(oursNext).toEqual(theirsNext); + expect(oursNext).not.toBe(ours); + expect(theirsNext).not.toBe(theirs); + expect(oursNext.tags === ours.tags).toBe(theirsNext.tags === theirs.tags); + expect(oursNext.sections === ours.sections).toBe(theirsNext.sections === theirs.sections); + expect(oursNext.sections[0] === ours.sections[0]).toBe(theirsNext.sections[0] === theirs.sections[0]); + }); + + it('pairs entities by identity even when a preceding slot holds no entity', async () => { + const { client, mockFetch } = getClient(); + + class Slot extends Entity { + __typename = t.typename('Slot'); + id = t.id; + label = t.string; + } + class Board extends Entity { + __typename = t.typename('Board'); + id = t.id; + slots = t.array(t.optional(t.nullable(t.entity(Slot)))); + } + class GetBoard extends RESTQuery { + path = '/board'; + result = { board: t.entity(Board) }; + } + + const slot = (id: string) => ({ __typename: 'Slot', id, label: id }); + mockFetch.get('/board', { + board: { __typename: 'Board', id: 'b-1', slots: [null, slot('s-1'), slot('s-2')] }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetBoard)); + await query; + + type Snap = { board: { slots: ({ id: string } | null)[] } }; + const before = read() as unknown as Snap; + expect(before.board.slots.map(s => s?.id ?? null)).toEqual([null, 's-1', 's-2']); + + // Dropping the empty leading slot shifts both entities left, and the slot + // they land on previously held `null` rather than a sibling's snapshot. + mockFetch.get('/board', { + board: { __typename: 'Board', id: 'b-1', slots: [slot('s-1'), slot('s-2')] }, + }); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + const after = read() as unknown as Snap; + expect(after.board.slots.map(s => s?.id ?? null)).toEqual(['s-1', 's-2']); + expect(after.board.slots[0]).toBe(before.board.slots[1]); + expect(after.board.slots[1]).toBe(before.board.slots[2]); + }); + + it('throws in dev when a field changes without the version moving', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + read(); + + // The failure the fast path cannot otherwise detect: `data` changed without + // a notify, or a field wrongly judged static. Touching a sibling is what + // makes the snapshot recompute; tok-0 then takes the fast path while its + // version still claims nothing moved. + const instance = client.entityMap.getEntity(hashValue(['Token', 'tok-0']))!; + const original = instance.data.symbol; + instance.data.symbol = 'MUTATED'; + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 5 } }); + + expect(() => read()).toThrow(/stale entity snapshot/); + + // Put the entity back and let the version move, so the watcher's own + // recompute doesn't rethrow after the test has finished. + instance.data.symbol = original; + instance.notify(); + read(); + }); +}); diff --git a/packages/fetchium/vitest.config.ts b/packages/fetchium/vitest.config.ts index 50511d3..88c6e34 100644 --- a/packages/fetchium/vitest.config.ts +++ b/packages/fetchium/vitest.config.ts @@ -12,6 +12,27 @@ const require = module.createRequire(import.meta.url); const reactPath = path.dirname(require.resolve('react/package.json')); const reactDomPath = path.dirname(require.resolve('react-dom/package.json')); +const signaliumBabel = () => + (babel as any)({ + filter: /\.(j|t)sx?$/, + babelConfig: { + babelrc: false, + configFile: false, + sourceMaps: true, + presets: [ + signaliumPreset({ + transformedImports: [ + ['testWithClient', /.*utils\.js$/], + ['watcher', 'signalium'], + ], + }), + ], + parserOpts: { + plugins: ['typescript'], + }, + }, + }); + export default defineConfig({ define: { IS_DEV: 'true', @@ -37,27 +58,7 @@ export default defineConfig({ projects: [ { extends: true, - plugins: [ - (babel as any)({ - filter: /\.(j|t)sx?$/, - babelConfig: { - babelrc: false, - configFile: false, - sourceMaps: true, - presets: [ - signaliumPreset({ - transformedImports: [ - ['testWithClient', /.*utils\.js$/], - ['watcher', 'signalium'], - ], - }), - ], - parserOpts: { - plugins: ['typescript'], - }, - }, - }), - ], + plugins: [signaliumBabel()], test: { include: ['src/__tests__/**/*.test.ts'], exclude: ['src/react/**'], @@ -65,6 +66,24 @@ export default defineConfig({ environment: 'node', }, }, + { + extends: true, + // Benchmarks measure production behaviour: `IS_DEV` gates the snapshot + // invariant check, which deliberately re-reads what the fast path skips. + define: { + IS_DEV: 'false', + IS_LOCAL_DEV: 'false', + }, + plugins: [signaliumBabel()], + test: { + include: [], + name: 'bench', + environment: 'node', + benchmark: { + include: ['src/__benchmarks__/**/*.bench.ts'], + }, + }, + }, { extends: true, plugins: [ From 12c58d8b542f067dcc600319eb5f919af45cda9c Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 11 Sep 2026 14:33:43 -0400 Subject: [PATCH 012/109] perf: don't notify or persist when applied data is unchanged (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(fetchium): don't notify or persist when applied data is unchanged `applyEntity` called `notify()` and persisted on every update, whether or not the data differed from what the entity already held. A refetch or poll returning identical data produced one notify per entity, a consumer recompute for each, a full re-snapshot and one store write per entity, to arrive back where it started. `mergeFields` now reports whether it changed anything and `applyEntity` acts on that. Comparison is by value: identical scalars, formatted values built from the same raw input, arrays and plain objects element by element, entities by proxy identity. Anything it cannot compare confidently — a Date, a typed array, a class instance — counts as different, so the failure mode is an unnecessary notify, not a dropped update. Live collections report the same, and `LiveInstance.reset`/ `append` return a boolean for it. A field that compares equal keeps its existing value, so nested objects and arrays hold their identity. Two pre-existing bugs this reaches, both fixed here: - Record fields never applied an update. A record def's `shape` is its value type — a bare Mask for `t.record(t.string)` — and the merge recursed into it as though it were a set of field defs, iterated nothing, then restored the old value. An update, addition or removal inside a record was silently dropped. Nested field records are now recognised by shape, and records fall through to the value comparison. - The persist decision no longer assumes an unchanged entity is already in the store. `persist` is caller-supplied and the cache-hydrate path passes `false`, so hydrate-then-fetch-identical could have skipped an entity's only write. `EntityInstance._persisted` makes that explicit. `sameRefs` compares the ref key set rather than counts, matching what the store persists and what `setChildRefs` acts on; comparing counts forced writes that produced byte-identical bytes. `sameValue` uses `Object.hasOwn` rather than `in`, which reached the prototype and could compare a missing key against `Object.prototype`'s answer. Tests: 11 integration cases in `entity-change-detection.test.ts`, 10 of which fail against main, plus 34 table-driven cases covering `sameValue` and `sameRefs` directly — every branch, and the fail-safe direction for each type they cannot compare. The identity assertions use `toBe`; `toEqual` cannot observe the property being claimed and passes on main. Co-Authored-By: Claude Opus 5 (1M context) * refactor(fetchium): address review feedback Comments: cut the `needsPersist` rationale to the one non-obvious line, the `_persisted` field doc to its first sentence, and made `sameMembers` read declaratively. Dropped the throwaway const around the live reset/append call. Fixed a misplaced JSDoc: the block describing structural field equality sat above `sameKeys`, which only compares key sets. Moved it to `sameValue` and gave `sameKeys` its own one-liner. Tests: `sameKeys` is now exported and covered like `sameValue` and `sameRefs` (own-keys-only, added/removed keys, a key holding undefined). Added two change-detection cases — a live-array member's own field changing, which must notify the member and leave the list quiet, and a mutation event carrying a nested entity, which must notify only the entity that actually moved. Both fail without the change detection in this branch. Hoisted the `Item`/`List`/`GetList` declarations to module scope with a `list()` data helper; the same block had been copy-pasted into three tests. Co-Authored-By: Claude Opus 5 * test(fetchium): assert the nested entity's merged value, not just its notify The nested-entity change-detection case pinned which entity notified but never what the data became, so a notify that fired without the merge landing would have passed. Reads the child instance back and asserts its field after both events — including the unchanged one, where skipping the notify must not skip the merge. Co-Authored-By: Claude Opus 5 * test(fetchium): assert merged values in the mutation-event cases The unchanged-fields case pinned which entity notified but never what the data became, the same gap just closed for the nested-entity case. Reads the instance back and asserts its field after both events. Also corrects the comment on both: for an event that carries the current value there is nothing to merge, so the assertion guards against the no-op path damaging the value rather than against a skipped merge. The changed event is what pins the merge landing. Co-Authored-By: Claude Opus 5 * test(fetchium): name the mutation-event case for both directions it asserts The name covered only the unchanged half, but the test also applies a real change and asserts the notify and the merged value. That second event is the positive control: without it, the empty-notify assertion above would pass just as happily if the recorder never fired. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/skip-unchanged-entity-merges.md | 7 + packages/fetchium/src/EntityInstance.ts | 3 + packages/fetchium/src/LiveCollection.ts | 51 +- .../__tests__/entity-change-detection.test.ts | 434 ++++++++++++++++++ .../fetchium/src/__tests__/same-value.test.ts | 152 ++++++ packages/fetchium/src/applyEntities.ts | 162 +++++-- 6 files changed, 768 insertions(+), 41 deletions(-) create mode 100644 .changeset/skip-unchanged-entity-merges.md create mode 100644 packages/fetchium/src/__tests__/entity-change-detection.test.ts create mode 100644 packages/fetchium/src/__tests__/same-value.test.ts diff --git a/.changeset/skip-unchanged-entity-merges.md b/.changeset/skip-unchanged-entity-merges.md new file mode 100644 index 0000000..0a1e2d6 --- /dev/null +++ b/.changeset/skip-unchanged-entity-merges.md @@ -0,0 +1,7 @@ +--- +'fetchium': minor +--- + +Applying data identical to what an entity already holds no longer notifies consumers. Previously an unchanged refetch or poll produced one notify per entity, plus a consumer recompute and a re-snapshot for each. Comparison is by value, and anything that can't be compared confidently — a `Date`, a typed array, a class instance — counts as changed, so the failure mode is a redundant notify rather than a dropped update. An unchanged entity is also no longer written to the store by the apply itself, though a streamed mutation event still writes it afterwards through a separate unconditional save in `applyMutationEvent`. + +Two behavior changes: a refetch returning identical data no longer re-renders consumers, though `isPending`/`isFetching` still transition and the query's freshness record is still written; and record fields (`t.record(...)`) now apply updates at all, where merging previously recursed into the record's value type, iterated nothing, and restored the previous value. diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 7ec20eb..99e5bca 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -359,6 +359,8 @@ export class EntityInstance { idField: string | symbol; data: Record; refCount: number = 0; + /** Whether this instance has been written to the store. */ + _persisted: boolean = false; entityRefs: Map | undefined; liveCollections: LiveCollectionBinding[] = []; satisfiedDefs: WeakSet> = new WeakSet(); @@ -482,6 +484,7 @@ export class EntityInstance { } save(): void { + this._persisted = true; this._queryClient.entityMap.save(this); } diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 75b9bb4..aaf6654 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -35,6 +35,15 @@ function resolveEventDef( return undefined; } +/** Whether both lists hold the same entities in the same order. */ +function sameMembers(oldItems: unknown[], newItems: unknown[]): boolean { + if (oldItems.length !== newItems.length) return false; + for (let i = 0; i < newItems.length; i++) { + if (newItems[i] !== oldItems[i]) return false; + } + return true; +} + function buildKeySet(items: unknown[]): Set { const keys = new Set(); for (const item of items) { @@ -66,8 +75,10 @@ export interface LiveCollectionParent { export interface LiveInstance { getValue(): unknown; getRawValue(): unknown; - reset(raw: unknown): void; - append(raw: unknown): void; + /** Returns whether the value/membership actually changed. */ + reset(raw: unknown): boolean; + /** Returns whether anything was actually added. */ + append(raw: unknown): boolean; onEvent( entityKey: number, entity: unknown, @@ -125,12 +136,12 @@ export class LiveCollectionBinding { return this.instance.getRawValue(); } - reset(parsed: unknown): void { - this.instance.reset(parsed); + reset(parsed: unknown): boolean { + return this.instance.reset(parsed); } - append(parsed: unknown): void { - this.instance.append(parsed); + append(parsed: unknown): boolean { + return this.instance.append(parsed); } /** @@ -327,9 +338,10 @@ export class LiveArrayInstance { return this._keys.has(key); } - reset(rawValue: unknown): void { + reset(rawValue: unknown): boolean { const oldItems = this._items; const newItems = Array.isArray(rawValue) ? rawValue : []; + if (sameMembers(oldItems, newItems)) return false; this._items = newItems; this._keys = buildKeySet(newItems); @@ -358,17 +370,18 @@ export class LiveArrayInstance { } } this._notifier.notify(); + return true; } - append(rawValue: unknown): void { - if (!Array.isArray(rawValue)) return; + append(rawValue: unknown): boolean { + if (!Array.isArray(rawValue)) return false; + let added = false; for (const item of rawValue) { if (typeof item !== 'object' || item === null) continue; const key = getProxyId(item as Record); - if (key !== undefined) { - this.add(key, item); - } + if (key !== undefined && this.add(key, item)) added = true; } + return added; } private _findIndex(key: number): number { @@ -449,16 +462,26 @@ export class LiveValueInstance { return this._value; } - reset(value: unknown): void { + /** + * Shaped differently from `LiveArrayInstance.reset`, which returns before + * touching anything: the dedup sets have to be cleared either way, or a + * repeated `create` would apply its reducer twice. Only the notify is + * conditional. + */ + reset(value: unknown): boolean { + const changed = this._value !== value; this._value = value; this._createdKeys.clear(); this._deletedKeys.clear(); + if (!changed) return false; this._notifier.notify(); + return true; } - append(_value: unknown): void { + append(_value: unknown): boolean { // LiveValue doesn't accumulate — append is a no-op. // New page's reducer events are handled via the normal event system. + return false; } } diff --git a/packages/fetchium/src/__tests__/entity-change-detection.test.ts b/packages/fetchium/src/__tests__/entity-change-detection.test.ts new file mode 100644 index 0000000..9a61774 --- /dev/null +++ b/packages/fetchium/src/__tests__/entity-change-detection.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { EntityInstance } from '../EntityInstance.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { testWithClient, setupTestClient } from './utils.js'; + +/** + * Entity Change Detection Tests + * + * A refetch or a poll that returns data the store already holds should be a + * no-op: no consumer is notified and nothing is written. These tests pin that, + * and the flip side — that a real change still notifies and still persists. + */ + +/** Records `typename:id` for every entity that notifies while active. */ +function recordNotifies() { + const notified: string[] = []; + const original = EntityInstance.prototype.notify; + const spy = vi.spyOn(EntityInstance.prototype, 'notify').mockImplementation(function (this: EntityInstance) { + notified.push(`${this.typename}:${this.id}`); + original.call(this); + }); + return { notified, restore: () => spy.mockRestore() }; +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + symbol = t.string; + price = t.number; + issued = t.format('date-time'); + metadata = t.object({ logo: t.string, tags: t.array(t.string) }); +} + +class GetPortfolio extends RESTQuery { + path = '/portfolio'; + result = { tokens: t.array(t.entity(Token)) }; +} + +class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + listId = t.string; + name = t.string; +} + +class List extends Entity { + __typename = t.typename('List'); + id = t.id; + items = t.liveArray(Item, { constraints: { listId: (this as unknown as { id: string }).id } }); +} + +class GetList extends RESTQuery { + path = '/list'; + result = { list: t.entity(List) }; +} + +/** A list whose members are named in order; `i-1`, `i-2`, ... */ +function list(...names: string[]) { + return { + list: { + __typename: 'List', + id: 'l-1', + items: names.map((name, i) => ({ __typename: 'Item', id: `i-${i + 1}`, listId: 'l-1', name })), + }, + }; +} + +function token(i: number, price = i) { + return { + __typename: 'Token', + id: `tok-${i}`, + symbol: `SYM${i}`, + price, + issued: '2026-01-01T00:00:00.000Z', + metadata: { logo: `logo-${i}.png`, tags: ['defi'] }, + }; +} + +function portfolio(count: number, price?: (i: number) => number) { + return { tokens: Array.from({ length: count }, (_, i) => token(i, price?.(i) ?? i)) }; +} + +describe('Entity Change Detection', () => { + const getClient = setupTestClient(); + let recorder: ReturnType | undefined; + + afterEach(() => { + recorder?.restore(); + recorder = undefined; + }); + + function tokenSaves(spy: ReturnType) { + return spy.mock.calls.filter(([, value]) => (value as Record).__typename === 'Token'); + } + + it('notifies nothing and writes nothing when a refetch returns identical data', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/portfolio', portfolio(3)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const saveQuery = vi.spyOn(store, 'saveQuery'); + recorder = recordNotifies(); + + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual([]); + expect(tokenSaves(saveEntity)).toHaveLength(0); + // The query's own freshness bookkeeping still has to be written, or the + // query would look stale forever and refetch on every read. + expect(saveQuery).toHaveBeenCalled(); + }); + }); + + it('notifies and writes only the entities a refetch actually changed', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/portfolio', portfolio(3)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + + const saveEntity = vi.spyOn(store, 'saveEntity'); + recorder = recordNotifies(); + + mockFetch.get( + '/portfolio', + portfolio(3, i => (i === 1 ? 99 : i)), + ); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual(['Token:tok-1']); + expect(tokenSaves(saveEntity)).toHaveLength(1); + expect((query.value as unknown as { tokens: { price: number }[] }).tokens[1].price).toBe(99); + }); + }); + + it('treats a change inside a nested object as a change', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + + recorder = recordNotifies(); + + const changed = portfolio(2); + changed.tokens[0].metadata.logo = 'new-logo.png'; + mockFetch.get('/portfolio', changed); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual(['Token:tok-0']); + expect((query.value as unknown as { tokens: { metadata: { logo: string } }[] }).tokens[0].metadata.logo).toBe( + 'new-logo.png', + ); + }); + }); + + it('keeps an unchanged nested object and its arrays identical across a refetch', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + + type Result = { tokens: { metadata: { tags: string[] } }[] }; + const before = (query.value as unknown as Result).tokens[0].metadata; + const beforeTags = before.tags; + + mockFetch.get( + '/portfolio', + portfolio(2, i => i + 100), + ); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + const after = (query.value as unknown as Result).tokens[0].metadata; + expect(after).toBe(before); + expect(after.tags).toBe(beforeTags); + }); + }); + + it('does not treat a formatted value rebuilt from the same input as a change', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(1)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + const before = (query.value as unknown as { tokens: { issued: Date }[] }).tokens[0].issued; + + recorder = recordNotifies(); + + mockFetch.get('/portfolio', portfolio(1)); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual([]); + const after = (query.value as unknown as { tokens: { issued: Date }[] }).tokens[0].issued; + expect(after).toBe(before); + }); + }); + + it('notifies when a formatted value is rebuilt from different input', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(1)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + + recorder = recordNotifies(); + + const changed = portfolio(1); + changed.tokens[0].issued = '2026-06-01T00:00:00.000Z'; + mockFetch.get('/portfolio', changed); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual(['Token:tok-0']); + expect((query.value as unknown as { tokens: { issued: Date }[] }).tokens[0].issued.toISOString()).toBe( + '2026-06-01T00:00:00.000Z', + ); + }); + }); + + it('notifies nothing when a refetch returns identical live-array membership', async () => { + const { client, mockFetch } = getClient(); + + const response = list('A', 'B'); + mockFetch.get('/list', response); + + await testWithClient(client, async () => { + const query = fetchQuery(GetList); + await query; + const items = (query.value as unknown as { list: { items: { name: string }[] } }).list.items; + expect(items.map(i => i.name)).toEqual(['A', 'B']); + + recorder = recordNotifies(); + + mockFetch.get('/list', response); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual([]); + expect((query.value as unknown as { list: { items: { name: string }[] } }).list.items.map(i => i.name)).toEqual([ + 'A', + 'B', + ]); + }); + }); + + it('notifies when a live array gains a member on refetch', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/list', list('A')); + + await testWithClient(client, async () => { + const query = fetchQuery(GetList); + await query; + + recorder = recordNotifies(); + + mockFetch.get('/list', list('A', 'B')); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toContain('List:l-1'); + expect((query.value as unknown as { list: { items: { name: string }[] } }).list.items.map(i => i.name)).toEqual([ + 'A', + 'B', + ]); + }); + }); + + it("notifies the changed member, not the list, when a live-array member's own field changes", async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/list', list('A', 'B')); + + await testWithClient(client, async () => { + const query = fetchQuery(GetList); + await query; + + recorder = recordNotifies(); + + // Membership is unchanged, so the list stays quiet. + mockFetch.get('/list', list('A', 'B2')); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(recorder.notified).toEqual(['Item:i-2']); + expect((query.value as unknown as { list: { items: { name: string }[] } }).list.items.map(i => i.name)).toEqual([ + 'A', + 'B2', + ]); + }); + }); + + it('notifies for a mutation event only when it changes a field', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + }); + + const instance = client.entityMap.getEntity(hashValue(['Token', 'tok-0']))!; + recorder = recordNotifies(); + + // The no-op must leave the value intact; the real change must land it. + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', price: 0 } }); + expect(recorder.notified).toEqual([]); + expect(instance.data.price).toBe(0); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', price: 7 } }); + expect(recorder.notified).toEqual(['Token:tok-0']); + expect(instance.data.price).toBe(7); + }); + + it('notifies only the nested entity a mutation event actually changes', async () => { + const { client, mockFetch } = getClient(); + + class Owner extends Entity { + __typename = t.typename('Owner'); + id = t.id; + name = t.string; + } + class Doc extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + owner = t.entity(Owner); + } + class GetDoc extends RESTQuery { + path = '/doc'; + result = { doc: t.entity(Doc) }; + } + + const doc = (owner: string) => ({ + id: 'd-1', + title: 'T', + owner: { __typename: 'Owner', id: 'o-1', name: owner }, + }); + mockFetch.get('/doc', { doc: { __typename: 'Doc', ...doc('Ann') } }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetDoc); + await query; + }); + + const owner = client.entityMap.getEntity(hashValue(['Owner', 'o-1']))!; + recorder = recordNotifies(); + + // The no-op must leave the value intact; the real change must land it. + client.applyMutationEvent({ type: 'update', typename: 'Doc', data: doc('Ann') }); + expect(recorder.notified).toEqual([]); + expect(owner.data.name).toBe('Ann'); + + // The parent's own fields and its ref set are unchanged, so it stays quiet. + client.applyMutationEvent({ type: 'update', typename: 'Doc', data: doc('Bea') }); + expect(recorder.notified).toEqual(['Owner:o-1']); + expect(owner.data.name).toBe('Bea'); + }); + + it('applies a key removed from a shapeless record', async () => { + const { client, mockFetch } = getClient(); + + class Config extends Entity { + __typename = t.typename('Config'); + id = t.id; + attrs = t.record(t.string); + } + class GetConfig extends RESTQuery { + path = '/config'; + result = { config: t.entity(Config) }; + } + + mockFetch.get('/config', { config: { __typename: 'Config', id: 'c-1', attrs: { a: '1', b: '2' } } }); + + await testWithClient(client, async () => { + const query = fetchQuery(GetConfig); + await query; + type Result = { config: { attrs: Record } }; + expect((query.value as unknown as Result).config.attrs).toEqual({ a: '1', b: '2' }); + + recorder = recordNotifies(); + + mockFetch.get('/config', { config: { __typename: 'Config', id: 'c-1', attrs: { a: '1' } } }); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + // Copying key by key never applied a removal, so `b` used to survive. + expect((query.value as unknown as Result).config.attrs).toEqual({ a: '1' }); + expect(recorder.notified).toEqual(['Config:c-1']); + }); + }); + + it('writes an entity that has never been persisted, even when nothing changed', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/portfolio', portfolio(1)); + + await testWithClient(client, async () => { + const query = fetchQuery(GetPortfolio); + await query; + + const key = hashValue(['Token', 'tok-0']); + const instance = client.entityMap.getEntity(key)!; + expect(instance._persisted).toBe(true); + + // An entity applied with persist: false has nothing in the store to + // skip, so the next apply must write it whether or not data changed. + instance._persisted = false; + const saveEntity = vi.spyOn(store, 'saveEntity'); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + + expect(tokenSaves(saveEntity)).toHaveLength(1); + }); + }); +}); diff --git a/packages/fetchium/src/__tests__/same-value.test.ts b/packages/fetchium/src/__tests__/same-value.test.ts new file mode 100644 index 0000000..89f6286 --- /dev/null +++ b/packages/fetchium/src/__tests__/same-value.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { sameValue, sameRefs, sameKeys } from '../applyEntities.js'; +import { FormattedValue } from '../typeDefs.js'; +import { PROXY_ID } from '../proxyId.js'; +import type { EntityInstance } from '../EntityInstance.js'; + +/** + * `sameValue` decides whether an apply notifies consumers and writes to the + * store, so a wrong `true` is a dropped update. Every branch answers `false` + * for anything it cannot compare confidently; these pin that direction. + */ + +const proxyLike = (key: number) => { + const obj = {}; + PROXY_ID.set(obj, key); + return obj; +}; + +describe('sameValue', () => { + const equal: [string, unknown, unknown][] = [ + ['identical scalars', 1, 1], + ['identical strings', 'a', 'a'], + ['null and null', null, null], + ['undefined and undefined', undefined, undefined], + ['empty objects', {}, {}], + ['flat objects, same keys and values', { a: 1, b: 'x' }, { a: 1, b: 'x' }], + ['objects with keys in a different order', { a: 1, b: 2 }, { b: 2, a: 1 }], + ['nested objects', { a: { b: { c: [1, 2] } } }, { a: { b: { c: [1, 2] } } }], + ['empty arrays', [], []], + ['arrays of scalars', [1, 'two', null], [1, 'two', null]], + ['arrays of objects', [{ a: 1 }], [{ a: 1 }]], + ]; + + for (const [name, a, b] of equal) { + it(`equal: ${name}`, () => { + expect(sameValue(a, b)).toBe(true); + }); + } + + it('reports the same entity proxy as unchanged', () => { + const proxy = proxyLike(1); + expect(sameValue(proxy, proxy)).toBe(true); + }); + + it('reports NaN as changed, since it is not equal to itself', () => { + expect(sameValue(Number.NaN, Number.NaN)).toBe(false); + }); + + const different: [string, unknown, unknown][] = [ + ['different scalars', 1, 2], + ['number vs string', 1, '1'], + ['null vs object', null, {}], + ['undefined vs null', undefined, null], + ['array length mismatch', [1, 2], [1]], + ['array element mismatch', [1, 2], [1, 3]], + ['array vs object', [1], { 0: 1 }], + ['key count mismatch', { a: 1 }, { a: 1, b: 2 }], + ['same key count, different keys', { a: 1 }, { b: 1 }], + ['nested difference', { a: { b: 1 } }, { a: { b: 2 } }], + ['distinct entity proxies', proxyLike(10), proxyLike(11)], + ['entity proxy vs plain object', proxyLike(12), {}], + ['Date instances with the same time', new Date(0), new Date(0)], + ['Map instances', new Map([['a', 1]]), new Map([['a', 1]])], + ['Set instances', new Set([1]), new Set([1])], + [ + 'class instances with equal fields', + new (class { + x = 1; + })(), + new (class { + x = 1; + })(), + ], + ]; + + for (const [name, a, b] of different) { + it(`different: ${name}`, () => { + expect(sameValue(a, b)).toBe(false); + }); + } + + it('a key answered only by the prototype is not a match', () => { + // `in` would find `constructor` on Object.prototype and compare it against + // the left side's own `constructor`, reporting two different objects equal. + expect(sameValue({ constructor: Object, x: 1 }, { x: 1, y: 2 })).toBe(false); + expect(sameValue({ toString: 1, a: 2 }, { a: 2, b: 3 })).toBe(false); + }); + + it('compares formatted values by the raw input they were built from', () => { + expect(sameValue(new FormattedValue('5', 0, false), new FormattedValue('5', 0, false))).toBe(true); + expect(sameValue(new FormattedValue('5', 0, false), new FormattedValue('6', 0, false))).toBe(false); + expect(sameValue(new FormattedValue('5', 0, false), '5')).toBe(false); + expect(sameValue('5', new FormattedValue('5', 0, false))).toBe(false); + }); +}); + +describe('sameRefs', () => { + const entity = (key: number) => ({ key }) as unknown as EntityInstance; + const a = entity(1); + const b = entity(2); + + it('treats undefined and an empty map as the same', () => { + expect(sameRefs(undefined, undefined)).toBe(true); + expect(sameRefs(new Map(), undefined)).toBe(true); + expect(sameRefs(undefined, new Map())).toBe(true); + }); + + it('compares the key set', () => { + expect(sameRefs(new Map([[a, 1]]), new Map([[a, 1]]))).toBe(true); + expect(sameRefs(new Map([[a, 1]]), new Map([[b, 1]]))).toBe(false); + expect(sameRefs(new Map([[a, 1]]), new Map())).toBe(false); + expect( + sameRefs( + new Map([ + [a, 1], + [b, 1], + ]), + new Map([[a, 1]]), + ), + ).toBe(false); + }); + + it('ignores ref counts, which the store does not persist', () => { + // `EntityStore.save` writes a key set; `setChildRefs` acts on a key + // appearing or disappearing. A count change writes identical bytes. + expect(sameRefs(new Map([[a, 1]]), new Map([[a, 3]]))).toBe(true); + }); +}); + +describe('sameKeys', () => { + // A wrong `true` here leaves a key the server removed in the store. + const cases: [string, Record, Record, boolean][] = [ + ['both empty', {}, {}, true], + ['same keys, same values', { a: 1 }, { a: 1 }, true], + ['same keys, different values', { a: 1 }, { a: 2 }, true], + ['same keys in a different order', { a: 1, b: 2 }, { b: 9, a: 9 }, true], + ['a key added', { a: 1 }, { a: 1, b: 2 }, false], + ['a key removed', { a: 1, b: 2 }, { a: 1 }, false], + ['same count, different keys', { a: 1 }, { b: 1 }, false], + ['a key holding undefined still counts', { a: undefined }, {}, false], + ]; + + it.each(cases)('%s', (_label, a, b, expected) => { + expect(sameKeys(a, b)).toBe(expected); + }); + + it('answers on own keys only, not inherited ones', () => { + const inherited = Object.create({ a: 1 }) as Record; + expect(sameKeys({ a: 1 }, inherited)).toBe(false); + expect(sameKeys(inherited, { a: 1 })).toBe(false); + }); +}); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 2aee9f6..104269a 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -119,8 +119,11 @@ function applyEntity( ? new Map(entityInstance.entityRefs) : new Map(); + // A refetch or a poll that returns identical data is a no-op: nothing to + // notify consumers about, and nothing new to write to the store. + let changed = true; if (isUpdate) { - mergeFields( + changed = mergeFields( shapeFields, data, existingData, @@ -133,7 +136,7 @@ function applyEntity( childRefs, appendMode, ); - entityInstance.notify(); + if (changed) entityInstance.notify(); } else { initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode); } @@ -154,7 +157,11 @@ function applyEntity( } } - entityInstance.setChildRefs(childRefs.size > 0 ? childRefs : undefined, persist); + const newRefs = childRefs.size > 0 ? childRefs : undefined; + const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); + // An entity hydrated from the store was never written, so there is no write to skip. + const needsPersist = changed || refsChanged || !entityInstance._persisted; + entityInstance.setChildRefs(newRefs, persist && needsPersist); const proxy = entityInstance.getProxy(entityShape); @@ -179,7 +186,8 @@ function mergeFields( persist: boolean, childRefs: Map, appendMode: boolean, -): void { +): boolean { + let changed = false; for (const [fieldKey, propShape] of entries(shape)) { if (rawKeys !== undefined && !rawKeys.has(fieldKey)) continue; @@ -190,10 +198,8 @@ function mergeFields( if (propShape instanceof ValidatorDef && propShape._liveConfig !== undefined) { const existingValue = existingData[fieldKey]; if (existingValue instanceof LiveCollectionBinding) { - if (appendMode) { - existingValue.append(data[fieldKey]); - } else { - existingValue.reset(data[fieldKey]); + if (appendMode ? existingValue.append(data[fieldKey]) : existingValue.reset(data[fieldKey])) { + changed = true; } } else { existingData[fieldKey] = createLiveCollection( @@ -203,45 +209,138 @@ function mergeFields( entityData, queryClient, ); + changed = true; } } else { const newVal = data[fieldKey]; const oldVal = existingData[fieldKey]; + if (newVal === oldVal) continue; // Replace a union wholesale instead of merging by field, otherwise a // changed variant keeps the old variant's fields. Unlike entity unions, // there is no partial-update path to preserve here: a partial variant // payload fails validation, so every update carries the full variant. const isUnion = propShape instanceof ValidatorDef && (propShape.mask & Mask.UNION) !== 0; if (!isUnion && isPlainObject(newVal) && isPlainObject(oldVal)) { + // Only an object/entity def carries a record of field defs. A record + // def's shape is its *value* type — a bare `Mask` for `t.record(t.string)` + // — and recursing into that iterated `Object.entries(8)`, merged + // nothing, and then restored the old value: record fields never + // applied an update, an addition or a removal. const nestedShape = - propShape instanceof ValidatorDef && propShape.shape !== undefined + propShape instanceof ValidatorDef && + propShape.shape !== undefined && + typeof propShape.shape === 'object' && + !(propShape.shape instanceof ValidatorDef) && + !(propShape.shape instanceof Set) ? (propShape.shape as Record) : undefined; if (nestedShape !== undefined) { - mergeFields( - nestedShape, - newVal, - oldVal, - undefined, - entityInstance, - entityData, - seen, - queryClient, - persist, - childRefs, - appendMode, - ); - } else { + if ( + mergeFields( + nestedShape, + newVal, + oldVal, + undefined, + entityInstance, + entityData, + seen, + queryClient, + persist, + childRefs, + appendMode, + ) + ) { + changed = true; + } + existingData[fieldKey] = oldVal; + } else if (sameKeys(oldVal, newVal)) { + // Shapeless object (e.g. a record) with the same keys: copy field by field, keeping the + // existing value — and its identity — wherever it already matches. for (const k of Object.keys(newVal)) { - oldVal[k] = newVal[k]; + if (!sameValue(oldVal[k], newVal[k])) { + oldVal[k] = newVal[k]; + changed = true; + } } + existingData[fieldKey] = oldVal; + } else { + // A key was added or removed. Copying field by field would never + // apply a removal, leaving the old key in `data` indefinitely. + existingData[fieldKey] = newVal; + changed = true; } - existingData[fieldKey] = oldVal; - } else { + } else if (!sameValue(oldVal, newVal)) { + // Otherwise the existing value stays, so its identity survives. existingData[fieldKey] = newVal; + changed = true; } } } + return changed; +} + +/** Whether both records have the same set of own keys. */ +export function sameKeys(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + for (let i = 0; i < aKeys.length; i++) { + if (!Object.hasOwn(b, aKeys[i])) return false; + } + return true; +} + +/** + * Structural equality for parsed field values. Entity proxies compare by + * identity (one proxy per entity and shape), formatted values by the raw input + * they were built from, arrays and plain objects element by element. + */ +export function sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (a instanceof FormattedValue || b instanceof FormattedValue) { + return a instanceof FormattedValue && b instanceof FormattedValue && a._raw === b._raw; + } + // Distinct proxies are distinct entities, and anything else exotic (a Date, a + // live collection binding, a class instance) is not safely comparable. + if (PROXY_ID.has(a) || PROXY_ID.has(b)) return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!sameValue(a[i], b[i])) return false; + } + return true; + } + if (Object.getPrototypeOf(a) !== ObjectProto || Object.getPrototypeOf(b) !== ObjectProto) return false; + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + for (let i = 0; i < aKeys.length; i++) { + const key = aKeys[i]; + // `hasOwn`, not `in`: `in` reaches the prototype, so a key missing from `b` + // can be answered by `Object.prototype` and compared against instead. + if (!Object.hasOwn(b, key)) return false; + if (!sameValue((a as Record)[key], (b as Record)[key])) return false; + } + return true; +} + +/** + * Compares the ref *key set*, which is what a write would change: the store + * persists `refKeys` with no counts, and `setChildRefs` retains and releases on + * a key appearing or disappearing. Comparing counts would force a write that + * produced byte-identical bytes. + */ +export function sameRefs( + a: Map | undefined, + b: Map | undefined, +): boolean { + const aSize = a === undefined ? 0 : a.size; + const bSize = b === undefined ? 0 : b.size; + if (aSize !== bSize) return false; + if (aSize === 0) return true; + for (const entity of a!.keys()) { + if (!b!.has(entity)) return false; + } + return true; } // ====================================================== @@ -277,8 +376,17 @@ function initFields( } else { const val = data[fieldKey]; if (isPlainObject(val)) { + // Only an object/entity def carries a record of field defs. A record + // def's shape is its *value* type — a bare `Mask` for `t.record(t.string)` + // — and recursing into that iterated `Object.entries(8)`, merged + // nothing, and then restored the old value: record fields never + // applied an update, an addition or a removal. const nestedShape = - propShape instanceof ValidatorDef && propShape.shape !== undefined + propShape instanceof ValidatorDef && + propShape.shape !== undefined && + typeof propShape.shape === 'object' && + !(propShape.shape instanceof ValidatorDef) && + !(propShape.shape instanceof Set) ? (propShape.shape as Record) : undefined; if (nestedShape !== undefined) { From d42041d0090851320296665499df691e604aecf5 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:34 -0400 Subject: [PATCH 013/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/entity-snapshot-fast-path.md | 4 ++-- .changeset/skip-unchanged-entity-merges.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/entity-snapshot-fast-path.md b/.changeset/entity-snapshot-fast-path.md index ae61ea0..6e25533 100644 --- a/.changeset/entity-snapshot-fast-path.md +++ b/.changeset/entity-snapshot-fast-path.md @@ -2,6 +2,6 @@ 'fetchium': patch --- -Entity snapshots no longer walk the proxy to read fields, and an entity whose data hasn't changed is no longer re-read at all. Previously `Object.keys(proxy)` triggered a descriptor trap per key that computed the value in order to report it, so every field was read twice on every dependency change. Each entity now carries a version, and a snapshot taken at the same version re-reads only the fields whose values live elsewhere: child entities, live collections, and a query's own getters. +Query results rebuild faster after an update: entities whose data didn't change are not read again. -Array items are paired with the snapshot they produced themselves rather than the one at the same index, so inserting into or re-sorting a list no longer gives every existing row a new snapshot identity. Output is otherwise unchanged. Dev builds re-read the skipped fields to verify that, so the speedup is a production-build property. +List results also keep each row's object identity when the list is inserted into or re-sorted, so memoized rows don't re-render. Development builds still re-read everything to check the result, so the speedup shows in production builds only. diff --git a/.changeset/skip-unchanged-entity-merges.md b/.changeset/skip-unchanged-entity-merges.md index 0a1e2d6..388d89f 100644 --- a/.changeset/skip-unchanged-entity-merges.md +++ b/.changeset/skip-unchanged-entity-merges.md @@ -2,6 +2,7 @@ 'fetchium': minor --- -Applying data identical to what an entity already holds no longer notifies consumers. Previously an unchanged refetch or poll produced one notify per entity, plus a consumer recompute and a re-snapshot for each. Comparison is by value, and anything that can't be compared confidently — a `Date`, a typed array, a class instance — counts as changed, so the failure mode is a redundant notify rather than a dropped update. An unchanged entity is also no longer written to the store by the apply itself, though a streamed mutation event still writes it afterwards through a separate unconditional save in `applyMutationEvent`. +A refetch, poll or streamed update that returns the same data no longer re-renders your components. `isPending` and `isFetching` still change as before. -Two behavior changes: a refetch returning identical data no longer re-renders consumers, though `isPending`/`isFetching` still transition and the query's freshness record is still written; and record fields (`t.record(...)`) now apply updates at all, where merging previously recursed into the record's value type, iterated nothing, and restored the previous value. +- Values that can't be compared reliably (a `Date`, a typed array, a class instance) always count as changed. +- `t.record(...)` fields now apply updates. Previously a record field kept its old value. From 90f0b12dd5f1e383a2819afc215bf472d8b55eee Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:34 -0400 Subject: [PATCH 014/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/async-store-honors-max-count.md | 2 +- .changeset/snapshot-union-array-members.md | 2 +- .changeset/store-reports-deletions.md | 9 +++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.changeset/async-store-honors-max-count.md b/.changeset/async-store-honors-max-count.md index 0e2afef..9319171 100644 --- a/.changeset/async-store-honors-max-count.md +++ b/.changeset/async-store-honors-max-count.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -`AsyncQueryStore`'s writer ignored a query definition's `cache.maxCount` and always used the default (50) when deciding when to evict the oldest key of a query definition. It now honors the def's own `maxCount`, matching `SyncQueryStore`. +`AsyncQueryStore` now honors a query's `cache.maxCount`, as `SyncQueryStore` does. It used to always keep the default of 50 keys. diff --git a/.changeset/snapshot-union-array-members.md b/.changeset/snapshot-union-array-members.md index 7ef6d5b..1e4879a 100644 --- a/.changeset/snapshot-union-array-members.md +++ b/.changeset/snapshot-union-array-members.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Snapshot fast path: a union whose only entity-bearing member is an array or record (`t.union(t.array(t.entity(A)), t.object({ ... }))`) was classified as a static field, so a snapshot taken at an unchanged version never re-read it and changes to those entities did not reach consumers (dev builds threw the stale-snapshot guard instead). Union members stored under the array/record symbol keys are now inspected like the others. +Fixed components not updating when an entity changes inside a union that has an array or record member, such as `t.union(t.array(t.entity(A)), t.object({ ... }))`. diff --git a/.changeset/store-reports-deletions.md b/.changeset/store-reports-deletions.md index f4a8fd7..b8b3b9d 100644 --- a/.changeset/store-reports-deletions.md +++ b/.changeset/store-reports-deletions.md @@ -2,10 +2,7 @@ 'fetchium': patch --- -Keep the "skip the write when nothing changed" optimisation consistent with the store. The store deletes records on its own — an LRU eviction of the oldest key of a query definition, the refcount cascade that follows it, a stale purge — and the in-memory entity did not hear about it, so its next unchanged apply skipped the write while the query record was re-saved pointing at a value that no longer existed. On the next cold start that query's cache failed to hydrate ("the query cache may be corrupted or invalid") and was dropped. Any mounted query whose definition has more than `cache.maxCount` (default 50) live keys, refetching unchanged data, was affected. +Fixed cached queries failing to load after a restart ("the query cache may be corrupted or invalid") when a query had more than `cache.maxCount` cached params and refetched unchanged data. -`QueryStore` gains an optional `onDelete(listener)`; both built-in stores call it with every id they drop, and the client clears the entity's persisted flag so the next apply writes it again. A custom store that does not implement `onDelete` gets every persisted apply written, as before 0.6.0. - -An `AsyncQueryStore` in reader role does not offer `onDelete` (deletions happen in the writer), so a reader-backed client writes every persisted apply, as before 0.6.0. - -Also: an entity-array field narrowed by a shared-typename def (`t.array(t.entity(X))` with two entity classes for `X`) is re-narrowed after each apply, so a member that gains the fields the def requires shows up without the array itself having to change. +- Custom stores: `QueryStore` has a new optional `onDelete(listener)`. Call the listener with every id the store deletes on its own. A store without it keeps writing every update, as before. +- A `t.array(t.entity(X))` field whose typename two entity classes share now shows an item as soon as it gains the fields `X` requires. From fd3a663cbfbf095935d9beeced73f74feae1cac2 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 01:46:14 -0400 Subject: [PATCH 015/109] perf(fetchium): quiet query reactivation and focus-aware polling Adds opt-in client options, all defaulting to current behavior: - reactivationGraceMs (+ per-query override): a reactivating query whose data is younger than the grace is not refetched. Data a running subscription kept current counts as fresh until deactivation. Invalidation, refetch(), network reconnects and failed fetches still refetch. - reactivationStaggerMs: reactivation refetches queued in the same task are spread evenly over the window; adapters that set coalescesRequests keep theirs together. - activity + pollResumeJitterMs: poll() stops timers while the app is inactive and spreads overdue ticks (including ticks that fire over a second late after frozen timers) across the jitter window. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/app/api/fetchium/page.md | 49 +- packages/fetchium/src/QueryAdapter.ts | 8 + packages/fetchium/src/QueryClient.ts | 88 ++++ packages/fetchium/src/QueryResult.ts | 93 +++- .../src/__tests__/poll-activity.test.ts | 277 ++++++++++ .../src/__tests__/reactivation-grace.test.ts | 478 ++++++++++++++++++ .../__tests__/topic-batch-delivery.test.ts | 107 ++++ packages/fetchium/src/index.ts | 2 +- packages/fetchium/src/query-types.ts | 23 + .../__tests__/pause-reactivation.test.tsx | 82 +++ .../fetchium/src/subscriptions/polling.ts | 84 ++- 11 files changed, 1240 insertions(+), 51 deletions(-) create mode 100644 packages/fetchium/src/__tests__/poll-activity.test.ts create mode 100644 packages/fetchium/src/__tests__/reactivation-grace.test.ts create mode 100644 packages/fetchium/src/__tests__/topic-batch-delivery.test.ts create mode 100644 packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index ea6a01c..66d5ed9 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,14 +225,18 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| -------------------- | ---------------------------- | ---------------------- | ------------------------------------------------------------------------------------ | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| Field | Type | Default | Description | +| ----------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. A query whose `subscribe` was running when it deactivated counts as fresh until then. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | #### Methods @@ -261,6 +265,12 @@ Base class for transport adapters. An adapter handles sending queries and mutati | `onNetworkStatusChange` | `(isOnline: boolean): void` | Optional. Called when the network comes online or goes offline. | | `destroy` | `(): void` | Optional. Called when the `QueryClient` is destroyed. Clean up connections or timers. | +#### Properties + +| Property | Type | Description | +| ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `coalescesRequests` | `boolean \| undefined` | Optional. Set when the adapter merges requests sent close together. `reactivationStaggerMs` then starts its queries' reactivation refetches together, unspread. | + #### Protected properties | Property | Type | Description | @@ -603,23 +613,24 @@ The `t` object provides a declarative type definition DSL for describing query p | `QueryPromise` | `ReactivePromise>` | The return type of `fetchQuery`. A reactive promise. | | `QueryResult` | `ExtractType & { __refetch(), __fetchNext(), __hasNext, __isFetchingNext }` | The resolved value of a query. Includes pagination helpers. | | `QueryCacheOptions` | `{ maxCount?: number; cacheTime?: number }` | Persistent storage cache settings. `cacheTime` is in minutes (default: 1440 / 24 hours). `maxCount` is the LRU queue size. | -| `QueryConfigOptions` | `{ gcTime?, staleTime?, debounce?, networkMode?, retry?, refreshStaleOnReconnect?, subscribe? }` | Instance-level query configuration. See property table below. | +| `QueryConfigOptions` | `{ gcTime?, staleTime?, debounce?, networkMode?, retry?, refreshStaleOnReconnect?, reactivationGraceMs?, subscribe? }` | Instance-level query configuration. See property table below. | | `QueryRequestOptions` | `{ baseUrl?, credentials?, mode?, cache?, redirect?, referrer?, referrerPolicy?, integrity?, keepalive?, signal? }` | Extended fetch options for individual queries. | -| `QueryContext` | `{ log?, evictionMultiplier? }` | Context object provided to the `QueryClient`. | +| `QueryContext` | `{ log?, evictionMultiplier?, activity?, pollResumeJitterMs? }` | Context object provided to the `QueryClient`. | | `QueryParams` | `Record \| unknown[] \| Record>` | The shape of query parameters at runtime. | | `FetchNextConfig` | `{ url?: unknown; searchParams?: Record }` | Pagination configuration. Values can be FieldRefs. | #### `QueryConfigOptions` properties -| Property | Type | Default | Description | -| ------------------------- | ---------------------------------- | --------------------------- | ------------------------------------------------------------------------ | -| `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | -| `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | -| `debounce` | `number` | `0` | Milliseconds to debounce param-change refetches. | -| `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | -| `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | -| `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | -| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | +| Property | Type | Default | Description | +| ------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | +| `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | +| `debounce` | `number` | `0` | Milliseconds to debounce param-change refetches. | +| `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | +| `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | +| `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | +| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Skip the reactivation refetch while data is younger than this. Overrides the client's `reactivationGraceMs`. | +| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | ### Mutation types diff --git a/packages/fetchium/src/QueryAdapter.ts b/packages/fetchium/src/QueryAdapter.ts index e9924b7..79506cc 100644 --- a/packages/fetchium/src/QueryAdapter.ts +++ b/packages/fetchium/src/QueryAdapter.ts @@ -35,6 +35,14 @@ export type QueryAdapterClass = abstract export abstract class QueryAdapter { protected queryClient: IQueryClientForAdapter | undefined; + /** + * Set when the adapter merges requests sent close together into one (for + * example a multi-topic snapshot request). `reactivationStaggerMs` then + * starts this adapter's reactivation refetches together, so they can still + * share a request, instead of spreading them apart. + */ + declare readonly coalescesRequests?: boolean; + /** * Called once by QueryClient when this adapter is registered. * Subclasses can override to do setup (e.g. open a WebSocket connection). diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 5a24f38..b94ccef 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -31,6 +31,7 @@ import { type QueryStore, type QueryParams, type PreloadedEntityMap, + type ActivitySource, queryKeyFor, } from './query-types.js'; import { SyncQueryStore, MemoryPersistentStore } from './stores/sync.js'; @@ -47,10 +48,44 @@ export interface QueryClientConfig { debug?: (message: string) => void; }; evictionMultiplier?: number; + /** + * Milliseconds. A query that reactivates (a watcher returns, or a paused + * scope resumes) with data younger than this is not refetched, even when the + * data is past its `staleTime`. A query whose subscription (`subscribe`: a + * stream, `poll()`) was running when it deactivated counts as fresh up to + * that moment, since the subscription kept it current. Queries can override + * the window with `reactivationGraceMs` in their config. Network reconnects, + * `refetch()`, `invalidateQueries()`, `markStale()` and a failed last fetch + * still refetch. Default: 0 (every stale query refetches on reactivation). + */ + reactivationGraceMs?: number; + /** + * Milliseconds. Reactivation refetches that start in the same task (for + * example every query on a screen that just resumed) are spread evenly + * across this window, in activation order, instead of all starting at once. + * Queries of an adapter that `coalescesRequests` are not spread. Default: 0 + * (all start together). + */ + reactivationStaggerMs?: number; + /** + * Foreground/background source. When set, `poll()` stops its timers while + * the app is inactive and resumes them when it becomes active again. + * Default: undefined (polls run regardless of app state). + */ + activity?: ActivitySource; + /** + * Milliseconds. A `poll()` tick that is overdue when the app becomes active + * again (or whose timer fires more than a second late, as happens when the + * JS thread was suspended in the background) is rescheduled at a random + * point within this window rather than firing immediately alongside every + * other overdue poll. Default: 0 (overdue ticks fire immediately). + */ + pollResumeJitterMs?: number; } export { type QueryContext, + type ActivitySource, type QueryCacheOptions, type QueryConfigOptions, type FetchNextConfig, @@ -77,6 +112,15 @@ export class QueryClient { /** Without `store.onDelete`, `_persisted` cannot be trusted. */ storeReportsDeletes: boolean = false; + /** See `QueryClientConfig.reactivationGraceMs`. */ + readonly reactivationGraceMs: number; + /** See `QueryClientConfig.reactivationStaggerMs`. */ + readonly reactivationStaggerMs: number; + + /** Queries whose reactivation refetch waits for the current task's stagger flush. */ + private staggerQueue = new Set>(); + private staggerTimer: ReturnType | undefined = undefined; + private context!: QueryContext; private typenameRegistry = new Map[]>(); private constraintRegistry = new Map(); @@ -90,6 +134,8 @@ export class QueryClient { store = new SyncQueryStore(new MemoryPersistentStore()), log, evictionMultiplier, + reactivationGraceMs, + reactivationStaggerMs, adapters: _c, networkManager: _n, gcManager: _g, @@ -97,6 +143,9 @@ export class QueryClient { } = config as QueryClientConfig & Record; this.isServer = typeof window === 'undefined'; this.store = store; + this.reactivationGraceMs = nonNegative(reactivationGraceMs); + this.reactivationStaggerMs = nonNegative(reactivationStaggerMs); + // `activity` and `pollResumeJitterMs` ride along in `rest`: poll() reads them from the context. this.context = { ...rest, log: log ?? console, evictionMultiplier }; this.gcManager = config.gcManager ?? @@ -436,6 +485,38 @@ export class QueryClient { } } + // ====================================================== + // Reactivation stagger + // ====================================================== + + /** + * Queues a reactivation refetch. Everything queued in the same task is + * started from one flush, spread evenly across `reactivationStaggerMs` in + * the order the queries reactivated, so the first one starts right away. + * Queries whose adapter `coalescesRequests` all start right away instead. + */ + scheduleReactivationRefetch(instance: QueryInstance): void { + this.staggerQueue.add(instance); + this.staggerTimer ??= setTimeout(this.flushStaggerQueue, 0); + } + + private flushStaggerQueue = (): void => { + this.staggerTimer = undefined; + const spread: QueryInstance[] = []; + for (const instance of this.staggerQueue) { + if (this.getAdapter(instance.def.statics.adapterClass).coalescesRequests === true) { + instance.runReactivationRefetch(0); + } else { + spread.push(instance); + } + } + this.staggerQueue.clear(); + const step = spread.length > 1 ? this.reactivationStaggerMs / spread.length : 0; + for (let i = 0; i < spread.length; i++) { + spread[i].runReactivationRefetch(Math.round(i * step)); + } + }; + // ====================================================== // Query Invalidation // ====================================================== @@ -524,6 +605,9 @@ export class QueryClient { } destroy(): void { + clearTimeout(this.staggerTimer); + this.staggerTimer = undefined; + this.staggerQueue.clear(); this.networkUnsubscribe?.(); this.gcManager.destroy(); this.networkManager.destroy(); @@ -542,6 +626,10 @@ export class QueryClient { export const QueryClientContext: Context = context(undefined); +function nonNegative(value: unknown): number { + return typeof value === 'number' && value > 0 ? value : 0; +} + function paramsMatch(instanceParams: Record | undefined, subset: Record): boolean { if (instanceParams === undefined) return false; for (const key in subset) { diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index d403dab..ee06d5d 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -52,6 +52,14 @@ export class QueryInstance { private wasPaused: boolean = false; private currentParams: QueryParams | undefined = undefined; private debounceTimer: ReturnType | undefined = undefined; + /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ + private lastFetchFailed: boolean = false; + /** + * When the query last deactivated with its subscription running. The + * subscription (a stream, poll()) kept the data current until then, so the + * reactivation grace measures from here when it is later than `updatedAt`. + */ + private subscribedUntil: number | undefined = undefined; // Invalidates on any signal consumed by getConfig() (such as // responseNotifier, fired by the adapter after each fetch). Param @@ -140,6 +148,7 @@ export class QueryInstance { this._fetchNextAbort = undefined; this._fetchNextPromise = undefined; + this.subscribedUntil = this.unsubscribe !== undefined ? Date.now() : undefined; this.stopSubscription(); if (isPausing) return; @@ -191,8 +200,15 @@ export class QueryInstance { this.runQueryImmediately(); } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; - if (refreshStaleOnReconnect && this.isStale) { - this.runDebounced(); + // The grace covers a relay resuming, not a network reconnect: data + // may have been missed while offline. + const withinGrace = activating && !wasPaused && this.isWithinReactivationGrace; + if (refreshStaleOnReconnect && this.isStale && !withinGrace) { + if (this.queryClient.reactivationStaggerMs > 0) { + this.queryClient.scheduleReactivationRefetch(this); + } else { + this.runDebounced(); + } } } } else if (paramsDidChange) { @@ -408,25 +424,32 @@ export class QueryInstance { const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; - return withRetry( - async () => { - try { - const freshData = await adapter.send(ctx, signal); - this.updatedAt = Date.now(); - - const result = this.applyData(freshData, true); - this.saveQueryMetadata(); - - return result; - } finally { - // In finally so reactive getConfig() reacts to error responses - // (e.g. 404 → subscribe: undefined) even when applyData throws. - this.reconcileSubscription(); - } - }, - this.retryConfig, - signal, - ); + try { + const result = await withRetry( + async () => { + try { + const freshData = await adapter.send(ctx, signal); + this.updatedAt = Date.now(); + + const result = this.applyData(freshData, true); + this.saveQueryMetadata(); + + return result; + } finally { + // In finally so reactive getConfig() reacts to error responses + // (e.g. 404 → subscribe: undefined) even when applyData throws. + this.reconcileSubscription(); + } + }, + this.retryConfig, + signal, + ); + this.lastFetchFailed = false; + return result; + } catch (error) { + if (!signal.aborted) this.lastFetchFailed = true; + throw error; + } } private runQueryImmediately(): void { @@ -438,7 +461,7 @@ export class QueryInstance { this.relayState.setPromise(this.runQuery()); } - private runDebounced(): void { + private runDebounced(extraDelay: number = 0): void { if (this.relayState.isPending) return; const debounce = this.config?.debounce ?? 0; @@ -448,7 +471,17 @@ export class QueryInstance { this.debounceTimer = setTimeout(() => { this.debounceTimer = undefined; this.runQueryImmediately(); - }, debounce); + }, debounce + extraDelay); + } + + /** + * Starts a reactivation refetch after `delay` (plus the query's debounce). + * Called by the QueryClient's stagger flush, a task after the query was + * queued, so it rechecks that the query is still active. + */ + runReactivationRefetch(delay: number): void { + if (!this._isActive || this.isPaused) return; + this.runDebounced(delay); } // ====================================================== @@ -552,6 +585,20 @@ export class QueryInstance { return Date.now() - this.updatedAt >= staleTime; } + /** + * Data is younger than the reactivation grace, and the last fetch didn't + * fail. Data a running subscription kept current counts as fresh up to the + * deactivation. Invalidation (`updatedAt = 0`) always falls outside. + */ + private get isWithinReactivationGrace(): boolean { + const { updatedAt } = this; + if (updatedAt === undefined || updatedAt === 0 || this.lastFetchFailed) return false; + const grace = this.config?.reactivationGraceMs ?? this.queryClient.reactivationGraceMs; + if (!(grace > 0)) return false; + const freshAt = Math.max(updatedAt, this.subscribedUntil ?? 0); + return Date.now() - freshAt < grace; + } + private get isPaused(): boolean { const networkMode = this.config?.networkMode ?? NetworkMode.Online; diff --git a/packages/fetchium/src/__tests__/poll-activity.test.ts b/packages/fetchium/src/__tests__/poll-activity.test.ts new file mode 100644 index 0000000..acd3dd0 --- /dev/null +++ b/packages/fetchium/src/__tests__/poll-activity.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { withContexts } from 'signalium'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { t } from '../typeDefs.js'; +import { QueryClient, QueryClientContext, type QueryClientConfig } from '../QueryClient.js'; +import type { ActivitySource } from '../query-types.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { poll } from '../subscriptions/polling.js'; +import { createMockFetch, createTestWatcher } from './utils.js'; + +/** + * Focus-aware polling: with an `activity` source, poll() stops its timers + * while the app is inactive, and spreads overdue ticks across + * `pollResumeJitterMs` when it becomes active again. + */ + +const INTERVAL = 1_000; + +class GetPolled extends RESTQuery { + path = '/polled'; + result = { n: t.number }; + config = { staleTime: Infinity, subscribe: poll({ interval: INTERVAL }) }; +} + +class GetPolledB extends RESTQuery { + path = '/polled-b'; + result = { n: t.number }; + config = { staleTime: Infinity, subscribe: poll({ interval: INTERVAL }) }; +} + +class GetPolledOwnJitter extends RESTQuery { + path = '/polled'; + result = { n: t.number }; + config = { staleTime: Infinity, subscribe: poll({ interval: INTERVAL, resumeJitterMs: 1_000 }) }; +} + +interface TestActivity extends ActivitySource { + set(active: boolean): void; + listenerCount(): number; +} + +function createActivity(initial: boolean = true): TestActivity { + let active = initial; + const listeners = new Set<() => void>(); + return { + isActive: () => active, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + set(next) { + active = next; + for (const listener of [...listeners]) listener(); + }, + listenerCount: () => listeners.size, + }; +} + +describe('poll() with an activity source', () => { + let mockFetch: ReturnType; + let starts: Array<{ path: string; at: number }>; + let clients: QueryClient[]; + let unsubs: Array<() => void>; + let t0: number; + + beforeEach(() => { + vi.useFakeTimers(); + mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/polled', () => ({ n: ++n })); + mockFetch.get('/polled-b', () => ({ n: ++n })); + starts = []; + clients = []; + unsubs = []; + }); + + afterEach(async () => { + for (const unsub of unsubs) unsub(); + for (const client of clients) client.destroy(); + // Drain signalium's pending flush on the fake clock before restoring real timers. + await vi.advanceTimersByTimeAsync(1_000); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + function makeClient(config: Partial = {}): QueryClient { + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [ + new RESTQueryAdapter({ + baseUrl: 'http://localhost', + fetch: ((url: string, options?: RequestInit) => { + starts.push({ path: new URL(url).pathname, at: Date.now() - t0 }); + return mockFetch(url, options); + }) as typeof fetch, + }), + ], + ...config, + }); + clients.push(client); + return client; + } + + function watch(client: QueryClient, ...queries: Array RESTQuery>): () => void { + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => queries.map(Q => fetchQuery(Q as any).value)), + ); + unsubs.push(unsub); + return unsub; + } + + /** Mounts the queries and lets the initial fetch settle; poll ticks are then due at INTERVAL. */ + async function mount(client: QueryClient, ...queries: Array RESTQuery>): Promise<() => void> { + t0 = Date.now(); + const unsub = watch(client, ...queries); + await vi.advanceTimersByTimeAsync(5); + starts.length = 0; + return unsub; + } + + function count(path: string = '/polled'): number { + return starts.filter(s => s.path === path).length; + } + + it('polls as before when the client has no activity source', async () => { + const client = makeClient(); + await mount(client, GetPolled); + await vi.advanceTimersByTimeAsync(3 * INTERVAL); + expect(count()).toBe(3); + }); + + it('stops polling while inactive', async () => { + const activity = createActivity(); + const client = makeClient({ activity }); + await mount(client, GetPolled); + + await vi.advanceTimersByTimeAsync(INTERVAL); + expect(count()).toBe(1); + + activity.set(false); + await vi.advanceTimersByTimeAsync(10 * INTERVAL); + expect(count()).toBe(1); + }); + + it('resumes a tick that is not yet due at its original time', async () => { + const activity = createActivity(); + const client = makeClient({ activity, pollResumeJitterMs: 300 }); + await mount(client, GetPolled); + + // Due at ~1000; pause at 200, resume at 500. + await vi.advanceTimersByTimeAsync(200); + activity.set(false); + await vi.advanceTimersByTimeAsync(300); + activity.set(true); + + await vi.advanceTimersByTimeAsync(400); + expect(count()).toBe(0); + await vi.advanceTimersByTimeAsync(110); + expect(count()).toBe(1); + expect(starts[0].at).toBeGreaterThanOrEqual(INTERVAL); + expect(starts[0].at).toBeLessThanOrEqual(INTERVAL + 10); + }); + + it('fires an overdue tick immediately on resume when no jitter is configured', async () => { + const activity = createActivity(); + const client = makeClient({ activity }); + await mount(client, GetPolled); + + activity.set(false); + await vi.advanceTimersByTimeAsync(5 * INTERVAL); + expect(count()).toBe(0); + + const resumedAt = Date.now() - t0; + activity.set(true); + await vi.advanceTimersByTimeAsync(5); + expect(count()).toBe(1); + expect(starts[0].at - resumedAt).toBeLessThanOrEqual(2); + }); + + it('spreads overdue ticks across the jitter window on resume', async () => { + const random = vi.spyOn(Math, 'random'); + random.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9); + + const activity = createActivity(); + const client = makeClient({ activity, pollResumeJitterMs: 300 }); + await mount(client, GetPolled, GetPolledB); + + activity.set(false); + await vi.advanceTimersByTimeAsync(5 * INTERVAL); + expect(starts).toHaveLength(0); + + const resumedAt = Date.now() - t0; + activity.set(true); + await vi.advanceTimersByTimeAsync(400); + + expect(starts.map(s => [s.path, s.at - resumedAt])).toEqual([ + ['/polled', 30], + ['/polled-b', 270], + ]); + + // Then back on the regular interval, from each tick. + await vi.advanceTimersByTimeAsync(INTERVAL); + expect(count('/polled')).toBe(2); + expect(count('/polled-b')).toBe(2); + }); + + it('lets a poll override the client jitter window', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const activity = createActivity(); + const client = makeClient({ activity, pollResumeJitterMs: 300 }); + await mount(client, GetPolledOwnJitter); + + activity.set(false); + await vi.advanceTimersByTimeAsync(5 * INTERVAL); + const resumedAt = Date.now() - t0; + activity.set(true); + await vi.advanceTimersByTimeAsync(1_000); + expect(starts.map(s => s.at - resumedAt)).toEqual([500]); + }); + + it('jitters a tick whose timer fired late, as after JS timers were frozen in the background', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + // No activity source: lateness alone identifies the resume. + const client = makeClient({ pollResumeJitterMs: 300 }); + await mount(client, GetPolled); + + // The clock jumps 10s while no timer runs, then the overdue timer fires. + vi.setSystemTime(Date.now() + 10_000); + const jumpedAt = Date.now() - t0; + // The frozen timer fires at its scheduled point (~INTERVAL - 5 from here) but doesn't fetch. + await vi.advanceTimersByTimeAsync(INTERVAL); + expect(count()).toBe(0); + // It is rescheduled at the jitter point instead: 0.5 * 300. + await vi.advanceTimersByTimeAsync(200); + expect(count()).toBe(1); + expect(Math.abs(starts[0].at - jumpedAt - (INTERVAL - 5 + 150))).toBeLessThanOrEqual(1); + }); + + it('fires a late tick immediately when no jitter is configured', async () => { + const client = makeClient(); + await mount(client, GetPolled); + + vi.setSystemTime(Date.now() + 10_000); + await vi.advanceTimersByTimeAsync(INTERVAL - 5); + expect(count()).toBe(1); + }); + + it('does not start polling while mounted inactive, and starts once active', async () => { + const activity = createActivity(false); + const client = makeClient({ activity }); + await mount(client, GetPolled); + + await vi.advanceTimersByTimeAsync(5 * INTERVAL); + expect(count()).toBe(0); + + activity.set(true); + await vi.advanceTimersByTimeAsync(5); + expect(count()).toBe(1); + }); + + it('removes its activity listener when the subscription ends', async () => { + const activity = createActivity(); + const client = makeClient({ activity }); + const unsub = await mount(client, GetPolled); + expect(activity.listenerCount()).toBe(1); + + unsub(); + await vi.advanceTimersByTimeAsync(10); + expect(activity.listenerCount()).toBe(0); + + activity.set(false); + activity.set(true); + await vi.advanceTimersByTimeAsync(5 * INTERVAL); + expect(count()).toBe(0); + }); +}); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts new file mode 100644 index 0000000..674ae9a --- /dev/null +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -0,0 +1,478 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { withContexts } from 'signalium'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { t } from '../typeDefs.js'; +import { QueryClient, QueryClientContext, type QueryClientConfig } from '../QueryClient.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { NetworkManager } from '../NetworkManager.js'; +import { createMockFetch, createTestWatcher } from './utils.js'; + +/** + * Reactivation grace and stagger: a query whose relay resumes (its watchers + * return, or a paused scope resumes) skips the refetch while its data is + * younger than `reactivationGraceMs`, and reactivation refetches that start + * together are spread across `reactivationStaggerMs`. + */ + +class GetA extends RESTQuery { + path = '/a'; + result = { n: t.number }; +} + +class GetB extends RESTQuery { + path = '/b'; + result = { n: t.number }; +} + +class GetC extends RESTQuery { + path = '/c'; + result = { n: t.number }; +} + +describe('reactivation grace and stagger', () => { + let mockFetch: ReturnType; + let starts: Array<{ path: string; at: number }>; + let clients: QueryClient[]; + let unsubs: Array<() => void>; + let counter: number; + + beforeEach(() => { + vi.useFakeTimers(); + mockFetch = createMockFetch(); + starts = []; + clients = []; + unsubs = []; + counter = 0; + for (const path of ['/a', '/b', '/c']) { + mockFetch.get(path, () => ({ n: ++counter })); + } + }); + + afterEach(async () => { + for (const unsub of unsubs) unsub(); + for (const client of clients) client.destroy(); + // Drain signalium's pending flush on the fake clock; a flush left scheduled + // there would block every later flush once real timers are restored. + await vi.advanceTimersByTimeAsync(1_000); + vi.useRealTimers(); + }); + + function makeClient(config: Partial = {}): QueryClient { + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [ + new RESTQueryAdapter({ + baseUrl: 'http://localhost', + fetch: ((url: string, options?: RequestInit) => { + starts.push({ path: new URL(url).pathname, at: Date.now() }); + return mockFetch(url, options); + }) as typeof fetch, + }), + ], + ...config, + }); + clients.push(client); + return client; + } + + /** Activates a watcher over the given queries' values; returns its unsubscribe. */ + function watch(client: QueryClient, ...queries: Array RESTQuery>): () => void { + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => queries.map(Q => fetchQuery(Q as any).value)), + ); + unsubs.push(unsub); + return unsub; + } + + /** Activates, loads, then deactivates (as a pause would). */ + async function loadThenDeactivate(client: QueryClient, ...queries: Array RESTQuery>): Promise { + const unsub = watch(client, ...queries); + await vi.advanceTimersByTimeAsync(50); + unsub(); + await vi.advanceTimersByTimeAsync(10); + } + + /** Fetch start times relative to the first one. */ + function offsets(): number[] { + return starts.map(s => s.at - starts[0].at); + } + + /** Timer 0 counts as 1 ms on the fake clock, so allow a millisecond either way. */ + function expectOffsets(expected: number[]): void { + const actual = offsets(); + expect(actual).toHaveLength(expected.length); + actual.forEach((offset, i) => expect(Math.abs(offset - expected[i])).toBeLessThanOrEqual(1)); + } + + function fetchCount(path: string): number { + return starts.filter(s => s.path === path).length; + } + + describe('reactivationGraceMs', () => { + it('refetches every stale query on reactivation by default', async () => { + const client = makeClient(); + await loadThenDeactivate(client, GetA); + expect(fetchCount('/a')).toBe(1); + + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('skips the refetch while the data is younger than the grace', async () => { + const client = makeClient({ reactivationGraceMs: 15_000 }); + await loadThenDeactivate(client, GetA); + expect(fetchCount('/a')).toBe(1); + + await vi.advanceTimersByTimeAsync(5_000); + const unsub = watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + expect(client.queryInstances.size).toBe(1); + unsub(); + await vi.advanceTimersByTimeAsync(10); + + // Past the grace (and the default staleTime of 0): refetches. + await vi.advanceTimersByTimeAsync(10_000); + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('keeps serving the cached value during the grace', async () => { + const client = makeClient({ reactivationGraceMs: 15_000 }); + await loadThenDeactivate(client, GetA); + + const values: unknown[] = []; + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => { + const relay = fetchQuery(GetA); + values.push({ pending: relay.isPending, n: relay.value?.n }); + }), + ); + unsubs.push(unsub); + await vi.advanceTimersByTimeAsync(50); + expect(values.at(-1)).toEqual({ pending: false, n: 1 }); + }); + + it('lets a query override the client grace', async () => { + class GetNoGrace extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { reactivationGraceMs: 0 }; + } + class GetLongGrace extends RESTQuery { + path = '/b'; + result = { n: t.number }; + config = { reactivationGraceMs: 60_000 }; + } + + const client = makeClient({ reactivationGraceMs: 15_000 }); + await loadThenDeactivate(client, GetNoGrace, GetLongGrace); + await vi.advanceTimersByTimeAsync(20_000); + + watch(client, GetNoGrace, GetLongGrace); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + expect(fetchCount('/b')).toBe(1); + }); + + it('does not apply while the query is within its staleTime anyway', async () => { + class GetFresh extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { staleTime: 60_000 }; + } + const client = makeClient({ reactivationGraceMs: 1_000 }); + await loadThenDeactivate(client, GetFresh); + await vi.advanceTimersByTimeAsync(5_000); + + watch(client, GetFresh); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + }); + + it('still refetches a query invalidated while inactive', async () => { + const client = makeClient({ reactivationGraceMs: 15_000 }); + await loadThenDeactivate(client, GetA); + + client.invalidateQueries([GetA]); + await vi.advanceTimersByTimeAsync(10); + expect(fetchCount('/a')).toBe(1); + + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('still refetches immediately on invalidation and refetch() while active', async () => { + const client = makeClient({ reactivationGraceMs: 15_000 }); + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + + client.invalidateQueries([GetA]); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + + await withContexts([[QueryClientContext, client]], async () => { + const relay = fetchQuery(GetA); + await (relay.value as any).__refetch(); + }); + expect(fetchCount('/a')).toBe(3); + }); + + it('refetches when the last fetch failed, even inside the grace', async () => { + mockFetch.reset(); + mockFetch.get('/a', { n: 1 }); + mockFetch.get('/a', { n: 2 }, { error: new Error('network down') }); + mockFetch.get('/a', { n: 3 }); + + const client = makeClient({ reactivationGraceMs: 15_000 }); + const unsub = watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + + // A refetch fails; updatedAt still dates from the first, successful fetch. + await withContexts([[QueryClientContext, client]], async () => { + const relay = fetchQuery(GetA); + await (relay.value as any).__refetch().catch(() => {}); + }); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + unsub(); + await vi.advanceTimersByTimeAsync(10); + + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(3); + }); + + it('measures from deactivation when a subscription kept the data current', async () => { + // A subscription that never fires: stands in for a stream that delivered + // entity updates while the query was active. + class GetStreamed extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { subscribe: () => () => {} }; + } + + const client = makeClient({ reactivationGraceMs: 15_000 }); + const unsub = watch(client, GetStreamed); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + + // Data is a minute old, but the subscription ran until a moment ago. + await vi.advanceTimersByTimeAsync(60_000); + unsub(); + await vi.advanceTimersByTimeAsync(5_000); + + const unsub2 = watch(client, GetStreamed); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + unsub2(); + await vi.advanceTimersByTimeAsync(10); + + // Deactivated longer than the grace: refetches. + await vi.advanceTimersByTimeAsync(20_000); + watch(client, GetStreamed); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('measures from the fetch for a query without a subscription', async () => { + const client = makeClient({ reactivationGraceMs: 15_000 }); + const unsub = watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + + await vi.advanceTimersByTimeAsync(60_000); + unsub(); + await vi.advanceTimersByTimeAsync(5_000); + + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('still refetches a subscribed query invalidated while inactive', async () => { + class GetStreamed extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { subscribe: () => () => {} }; + } + + const client = makeClient({ reactivationGraceMs: 15_000 }); + await loadThenDeactivate(client, GetStreamed); + client.invalidateQueries([GetStreamed]); + + watch(client, GetStreamed); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('does not apply to a network reconnect', async () => { + const networkManager = new NetworkManager(true); + const client = makeClient({ reactivationGraceMs: 15_000, networkManager }); + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + + networkManager.setNetworkStatus(false); + await vi.advanceTimersByTimeAsync(50); + networkManager.setNetworkStatus(true); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + }); + + describe('reactivationStaggerMs', () => { + it('starts reactivation refetches together by default', async () => { + const client = makeClient(); + await loadThenDeactivate(client, GetA, GetB, GetC); + starts.length = 0; + + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(400); + expect(offsets()).toEqual([0, 0, 0]); + }); + + it('spreads refetches that reactivate in the same task across the window, in order', async () => { + const client = makeClient({ reactivationStaggerMs: 300 }); + await loadThenDeactivate(client, GetA, GetB, GetC); + starts.length = 0; + + const t0 = Date.now(); + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(400); + expect(starts.map(s => s.path)).toEqual(['/a', '/b', '/c']); + expectOffsets([0, 100, 200]); + // The first one waits only for the flush task. + expect(starts[0].at - t0).toBeLessThanOrEqual(2); + }); + + it('starts a lone reactivation refetch without delay', async () => { + const client = makeClient({ reactivationStaggerMs: 300 }); + await loadThenDeactivate(client, GetA); + starts.length = 0; + + const t0 = Date.now(); + watch(client, GetA); + await vi.advanceTimersByTimeAsync(400); + expect(starts).toHaveLength(1); + expect(starts[0].at - t0).toBeLessThanOrEqual(2); + }); + + it('drops a queued refetch whose query deactivated before the flush', async () => { + const client = makeClient({ reactivationStaggerMs: 300 }); + await loadThenDeactivate(client, GetA, GetB); + starts.length = 0; + + watch(client, GetA); + const unsubB = watch(client, GetB); + unsubB(); + await vi.advanceTimersByTimeAsync(400); + expect(starts.map(s => s.path)).toEqual(['/a']); + }); + + it('cancels the delayed refetch when the query deactivates', async () => { + const client = makeClient({ reactivationStaggerMs: 300 }); + await loadThenDeactivate(client, GetA, GetB); + starts.length = 0; + + const unsub = watch(client, GetA, GetB); + await vi.advanceTimersByTimeAsync(20); + expect(starts.map(s => s.path)).toEqual(['/a']); + unsub(); + await vi.advanceTimersByTimeAsync(400); + expect(starts.map(s => s.path)).toEqual(['/a']); + }); + + it('combines with the grace: only queries past it are staggered', async () => { + const client = makeClient({ reactivationStaggerMs: 300, reactivationGraceMs: 15_000 }); + await loadThenDeactivate(client, GetA, GetB); + await vi.advanceTimersByTimeAsync(20_000); + await loadThenDeactivate(client, GetC); + starts.length = 0; + + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(400); + expect(starts.map(s => s.path)).toEqual(['/a', '/b']); + expectOffsets([0, 150]); + }); + + it('starts queries of an adapter that coalesces requests together, and spreads the rest', async () => { + class CoalescingAdapter extends RESTQueryAdapter { + override readonly coalescesRequests = true; + } + class GetCoalescedA extends RESTQuery { + static override adapter = CoalescingAdapter; + path = '/a'; + result = { n: t.number }; + } + class GetCoalescedB extends RESTQuery { + static override adapter = CoalescingAdapter; + path = '/b'; + result = { n: t.number }; + } + + const fetchFn = ((url: string, options?: RequestInit) => { + starts.push({ path: new URL(url).pathname, at: Date.now() }); + return mockFetch(url, options); + }) as typeof fetch; + const client = makeClient({ + adapters: [ + new RESTQueryAdapter({ baseUrl: 'http://localhost', fetch: fetchFn }), + new CoalescingAdapter({ baseUrl: 'http://localhost', fetch: fetchFn }), + ], + reactivationStaggerMs: 300, + }); + await loadThenDeactivate(client, GetCoalescedA, GetC, GetCoalescedB); + starts.length = 0; + + watch(client, GetCoalescedA, GetC, GetCoalescedB); + await vi.advanceTimersByTimeAsync(400); + // One spread query: it starts with the flush too. + expect(starts.map(s => s.path).sort()).toEqual(['/a', '/b', '/c']); + expectOffsets([0, 0, 0]); + }); + + it('spreads only the queries of adapters that do not coalesce', async () => { + class CoalescingAdapter extends RESTQueryAdapter { + override readonly coalescesRequests = true; + } + class GetCoalesced extends RESTQuery { + static override adapter = CoalescingAdapter; + path = '/a'; + result = { n: t.number }; + } + + const fetchFn = ((url: string, options?: RequestInit) => { + starts.push({ path: new URL(url).pathname, at: Date.now() }); + return mockFetch(url, options); + }) as typeof fetch; + const client = makeClient({ + adapters: [ + new RESTQueryAdapter({ baseUrl: 'http://localhost', fetch: fetchFn }), + new CoalescingAdapter({ baseUrl: 'http://localhost', fetch: fetchFn }), + ], + reactivationStaggerMs: 300, + }); + await loadThenDeactivate(client, GetB, GetCoalesced, GetC); + starts.length = 0; + + watch(client, GetB, GetCoalesced, GetC); + await vi.advanceTimersByTimeAsync(400); + // The coalesced query starts with the flush; the other two take the two slots. + expect(starts.map(s => s.path)).toEqual(['/a', '/b', '/c']); + expectOffsets([0, 0, 150]); + }); + + it('does not delay an initial fetch', async () => { + const client = makeClient({ reactivationStaggerMs: 300 }); + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(50); + expect(offsets()).toEqual([0, 0, 0]); + }); + }); +}); diff --git a/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts b/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts new file mode 100644 index 0000000..5944c31 --- /dev/null +++ b/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { TopicQuery } from '../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; +import type { QueryAdapterClass } from '../QueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { t } from '../typeDefs.js'; +import { sleep } from './utils.js'; + +/** + * Several topic snapshots delivered in one task (for example one multi-topic + * snapshot response) settle their queries in a single reactive flush, so + * consumers re-render once rather than once per topic. Snapshots delivered in + * separate tasks flush separately; batching them is the adapter's job. + */ + +class SnapshotAdapter extends TopicQueryAdapter { + subscribe(_topic: string): void {} + + unsubscribe(topic: string): void { + this.clearTopic(topic); + } + + deliver(topic: string, data: unknown): void { + this.fulfillTopic(topic, data); + } +} + +abstract class SnapshotTopicQuery extends TopicQuery { + static override adapter: QueryAdapterClass = SnapshotAdapter; +} + +const TOPICS = ['t:1', 't:2', 't:3', 't:4', 't:5']; + +const QUERIES = TOPICS.map( + topic => + class extends SnapshotTopicQuery { + topic = topic; + result = { n: t.number }; + }, +); + +describe('batched topic snapshot delivery', () => { + let client: QueryClient; + let unsub: (() => void) | undefined; + + afterEach(() => { + unsub?.(); + client?.destroy(); + }); + + async function setup(): Promise<{ adapter: SnapshotAdapter; runs: () => number; values: () => unknown[] }> { + const adapter = new SnapshotAdapter(); + client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [adapter], + }); + + // One watcher per query; a flush runs every dirty watcher's listener, so + // count distinct flushes by the task they ran in. + let flushes = 0; + let flushScheduled = false; + const latest: unknown[] = QUERIES.map(() => undefined); + const unsubs = QUERIES.map((Q, i) => { + const w = withContexts([[QueryClientContext, client]], () => watcher(() => fetchQuery(Q).value?.n)); + return w.addListener(() => { + latest[i] = w.value; + if (!flushScheduled) { + flushScheduled = true; + flushes++; + setTimeout(() => (flushScheduled = false), 0); + } + }); + }); + unsub = () => unsubs.forEach(u => u()); + await sleep(20); + return { adapter, runs: () => flushes, values: () => latest }; + } + + it('settles every query from one delivery task in one flush', async () => { + const { adapter, runs, values } = await setup(); + expect(values()).toEqual([undefined, undefined, undefined, undefined, undefined]); + const before = runs(); + + TOPICS.forEach((topic, i) => adapter.deliver(topic, { n: i + 1 })); + await sleep(20); + + expect(values()).toEqual([1, 2, 3, 4, 5]); + expect(runs() - before).toBe(1); + }); + + it('flushes once per task when deliveries are spread across tasks', async () => { + const { adapter, runs, values } = await setup(); + const before = runs(); + + for (const [i, topic] of TOPICS.entries()) { + adapter.deliver(topic, { n: i + 1 }); + await sleep(5); + } + await sleep(20); + + expect(values()).toEqual([1, 2, 3, 4, 5]); + expect(runs() - before).toBe(TOPICS.length); + }); +}); diff --git a/packages/fetchium/src/index.ts b/packages/fetchium/src/index.ts index 7f0871d..350503c 100644 --- a/packages/fetchium/src/index.ts +++ b/packages/fetchium/src/index.ts @@ -1,7 +1,7 @@ export * from './types.js'; export { QueryClient, QueryClientContext } from './QueryClient.js'; -export type { QueryContext, QueryClientConfig } from './QueryClient.js'; +export type { QueryContext, QueryClientConfig, ActivitySource } from './QueryClient.js'; export { QueryAdapter } from './QueryAdapter.js'; export type { IQueryClientForAdapter } from './QueryAdapter.js'; export { t, registerFormat } from './typeDefs.js'; diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 46c5554..7cc9f41 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -7,6 +7,17 @@ import { QueryDefinition } from './query.js'; // Query Types // ----------------------------------------------------------------------------- +/** + * Reports whether the app is in the foreground. Supplied by the host (for + * React Native, wrap `AppState`), since Fetchium cannot observe it itself. + */ +export interface ActivitySource { + /** `true` while the app is foregrounded and background work should run. */ + isActive(): boolean; + /** Calls `listener` whenever `isActive()` may have changed. Returns an unsubscribe function. */ + subscribe(listener: () => void): () => void; +} + export interface QueryContext { log?: { error?: (message: string, error?: unknown) => void; @@ -15,6 +26,10 @@ export interface QueryContext { debug?: (message: string) => void; }; evictionMultiplier?: number; + /** See `QueryClientConfig.activity`. */ + activity?: ActivitySource; + /** See `QueryClientConfig.pollResumeJitterMs`. */ + pollResumeJitterMs?: number; } /** @@ -47,6 +62,14 @@ export interface QueryConfigOptions { networkMode?: NetworkMode; // default: NetworkMode.Online retry?: RetryConfig | number | boolean; // default: 3 on client, 0 on server refreshStaleOnReconnect?: boolean; // default: true + /** + * Milliseconds. When the query reactivates (a watcher returns, or a paused + * scope resumes) and its data is younger than this, it is not refetched even + * if stale. With a running subscription, the data counts as fresh until the + * query deactivated. Overrides `QueryClientConfig.reactivationGraceMs`. Does + * not affect network reconnects, `refetch()`, or invalidation. + */ + reactivationGraceMs?: number; subscribe?: (this: any, onEvent: (event: import('./types.js').MutationEvent) => void) => () => void; } diff --git a/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx b/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx new file mode 100644 index 0000000..932ccd7 --- /dev/null +++ b/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider, PauseSignalsProvider, component } from 'signalium/react'; +import React from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext, type QueryClientConfig } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { RESTQuery } from '../../rest/index.js'; +import { fetchQuery } from '../../query.js'; +import { createMockFetch, sleep } from '../../__tests__/utils.js'; +import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; + +/** + * A screen hidden behind `PauseSignalsProvider` pauses its queries; resuming + * it reactivates them. With `reactivationGraceMs`, queries whose data is + * younger than the grace resume without refetching. + */ + +class GetItem extends RESTQuery { + path = '/item'; + result = { n: t.number }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +function makeClient(mockFetch: ReturnType, config: Partial = {}) { + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + ...config, + }); + clients.push(client); + return client; +} + +const Item = component(() => { + const item = fetchQuery(GetItem); + return
{item.isReady ? `n=${item.value.n}` : 'Loading...'}
; +}); + +async function pauseThenResume(client: QueryClient): Promise { + const tree = (paused: boolean) => ( + + + + + + ); + + const { getByText, rerender } = render(tree(false)); + await expect.element(getByText('n=1')).toBeInTheDocument(); + + rerender(tree(true)); + await sleep(50); + rerender(tree(false)); + await sleep(100); +} + +describe('resuming a paused screen', () => { + it('refetches its stale queries by default', async () => { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ n: ++n })); + + await pauseThenResume(makeClient(mockFetch)); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('skips the refetch for data younger than reactivationGraceMs', async () => { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ n: ++n })); + + await pauseThenResume(makeClient(mockFetch, { reactivationGraceMs: 15_000 })); + expect(mockFetch.calls).toHaveLength(1); + }); +}); diff --git a/packages/fetchium/src/subscriptions/polling.ts b/packages/fetchium/src/subscriptions/polling.ts index d8b631a..3cee9b6 100644 --- a/packages/fetchium/src/subscriptions/polling.ts +++ b/packages/fetchium/src/subscriptions/polling.ts @@ -1,9 +1,23 @@ import type { MutationEvent } from '../types.js'; +import type { QueryContext } from '../query-types.js'; const MIN_INTERVAL = 100; +/** + * A tick firing this much later than scheduled means its timer was frozen + * (React Native suspends JS timers in the background), so every other poll is + * overdue too. With a resume jitter configured, such a tick is spread instead + * of fired. A busy JS thread delays timers by far less. + */ +const LATE_TICK_THRESHOLD = 1000; + export interface PollConfig { interval: number; + /** + * Overrides `QueryClientConfig.pollResumeJitterMs` for this poll: the window + * an overdue tick is spread across when the app becomes active again. + */ + resumeJitterMs?: number; } function clampInterval(interval: number): number { @@ -22,29 +36,83 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE return function subscribe(this: any, _onEvent: (event: MutationEvent) => void): () => void { let active = true; let timer: ReturnType | undefined; + let inFlight = false; + /** When the next tick is due. Kept while the timer is stopped, so resuming knows what is overdue. */ + let dueAt = Date.now() + interval; const refetch = this.refetch as () => Promise; + const queryContext = this.context as QueryContext | undefined; + const activity = queryContext?.activity; + const jitterWindow = config.resumeJitterMs ?? queryContext?.pollResumeJitterMs ?? 0; + + const isAppActive = (): boolean => activity === undefined || activity.isActive(); - const tick = async () => { + const schedule = (delay: number): void => { + clearTimeout(timer); + dueAt = Date.now() + delay; + timer = setTimeout(tick, delay); + }; + + /** Reschedules an overdue tick at a random point within the jitter window. */ + const scheduleOverdue = (): void => { + schedule(jitterWindow > 0 ? Math.floor(Math.random() * jitterWindow) : 0); + }; + + const stopTimer = (): void => { + clearTimeout(timer); + timer = undefined; + }; + + const tick = async (): Promise => { + timer = undefined; if (!active) return; + // Inactive: leave the tick overdue; the activity listener reschedules it. + if (!isAppActive()) return; + if (jitterWindow > 0 && Date.now() - dueAt > LATE_TICK_THRESHOLD) { + scheduleOverdue(); + return; + } + + inFlight = true; try { await refetch(); } catch { // Keep polling after errors } - if (active) { - timer = setTimeout(tick, interval); + inFlight = false; + + if (!active) return; + if (isAppActive()) { + schedule(interval); + } else { + dueAt = Date.now() + interval; } }; - timer = setTimeout(tick, interval); + const unsubscribeActivity = activity?.subscribe(() => { + if (!active) return; + if (!activity.isActive()) { + stopTimer(); + return; + } + // A refetch in flight schedules the next tick itself when it settles. + if (timer !== undefined || inFlight) return; + const remaining = dueAt - Date.now(); + if (remaining > 0) { + schedule(remaining); + } else { + scheduleOverdue(); + } + }); + + if (isAppActive()) { + schedule(interval); + } return () => { active = false; - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } + stopTimer(); + unsubscribeActivity?.(); }; }; } From 7c59b32f0b7886aa7f7171a004d8c5208ea86cd0 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:09:13 -0400 Subject: [PATCH 016/109] fix(fetchium): credit the reactivation grace only for data a subscription delivered The grace counted any running subscription as keeping the data current until the query deactivated. A poll() that never ticked qualified: it restarts its full interval on every activation, so visits shorter than the interval separated by gaps shorter than the grace chained the grace forever and never refetched (poll 60 s, staleTime 0, grace 10 s: twenty 50 s visits served 18-minute-old data from one fetch). The same credit covered time a focus-aware poll spent stopped in the background. Now only a delivery counts: a stream event through the subscription's onEvent, or a topic event the adapter sends with its topic (sendMutationEvent(event, topic)), and only up to the last one. A poll() keeps data current through the fetches it makes, which already move updatedAt. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/app/api/fetchium/page.md | 24 +-- docs/src/app/core/streaming/page.md | 16 +- packages/fetchium/src/QueryClient.ts | 9 +- packages/fetchium/src/QueryResult.ts | 28 ++- .../src/__tests__/reactivation-grace.test.ts | 165 +++++++++++++++++- packages/fetchium/src/query-types.ts | 4 +- packages/fetchium/src/topic/TopicQuery.ts | 7 +- .../fetchium/src/topic/TopicQueryAdapter.ts | 32 +++- 8 files changed, 241 insertions(+), 44 deletions(-) diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 66d5ed9..4ef56cb 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,18 +225,18 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| ----------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | -| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. A query whose `subscribe` was running when it deactivated counts as fresh until then. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | -| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | -| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | -| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | +| Field | Type | Default | Description | +| ----------------------- | ----------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | #### Methods diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index f19bca1..41ca8da 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -189,7 +189,7 @@ class MyStreamAdapter extends TopicQueryAdapter { this.rejectTopic(msg.topic, new Error(msg.error)); } else if (msg.type === 'event') { // Ongoing mutation event - this.sendMutationEvent(msg.event); + this.sendMutationEvent(msg.event, msg.topic); } }; } @@ -207,13 +207,13 @@ class MyStreamAdapter extends TopicQueryAdapter { The adapter has several protected helper methods: -| Method | Description | -| --------------------------- | ------------------------------------------------------------------------------------------------------ | -| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | -| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | -| `sendMutationEvent(event)` | Push a `MutationEvent` through Fetchium's entity event system. | -| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | -| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | +| Method | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | +| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | +| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass the `topic` it arrived on so that topic's query counts as current (see `reactivationGraceMs`). | +| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | +| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | ### Registering the adapter diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index b94ccef..064d7dd 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -51,10 +51,11 @@ export interface QueryClientConfig { /** * Milliseconds. A query that reactivates (a watcher returns, or a paused * scope resumes) with data younger than this is not refetched, even when the - * data is past its `staleTime`. A query whose subscription (`subscribe`: a - * stream, `poll()`) was running when it deactivated counts as fresh up to - * that moment, since the subscription kept it current. Queries can override - * the window with `reactivationGraceMs` in their config. Network reconnects, + * data is past its `staleTime`. Data a subscription pushed to (a `subscribe` + * stream event, or a topic event sent with its topic) counts as fresh from + * the last push; a `poll()` keeps data current only through the fetches it + * makes. Queries can override the window with `reactivationGraceMs` in + * their config. Network reconnects, * `refetch()`, `invalidateQueries()`, `markStale()` and a failed last fetch * still refetch. Default: 0 (every stale query refetches on reactivation). */ diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index ee06d5d..07b4c3c 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -55,11 +55,15 @@ export class QueryInstance { /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; /** - * When the query last deactivated with its subscription running. The - * subscription (a stream, poll()) kept the data current until then, so the - * reactivation grace measures from here when it is later than `updatedAt`. + * When the running subscription last delivered data: a stream pushed an + * event, or a topic adapter delivered one for this query's topic. The data + * was known current at that moment, so the reactivation grace measures from + * here when it is later than `updatedAt`. A subscription that delivers by + * refetching (poll()) moves `updatedAt` instead. Merely having a + * subscription running proves nothing: a poll that hasn't ticked yet, or one + * stopped while the app was in the background, kept nothing current. */ - private subscribedUntil: number | undefined = undefined; + private lastPushAt: number | undefined = undefined; // Invalidates on any signal consumed by getConfig() (such as // responseNotifier, fired by the adapter after each fetch). Param @@ -148,7 +152,6 @@ export class QueryInstance { this._fetchNextAbort = undefined; this._fetchNextPromise = undefined; - this.subscribedUntil = this.unsubscribe !== undefined ? Date.now() : undefined; this.stopSubscription(); if (isPausing) return; @@ -384,6 +387,7 @@ export class QueryInstance { const ctx = this._executionCtx; this.unsubscribe = subscribeFn.call(ctx, (event: import('./types.js').MutationEvent) => { + this.notePush(); // Collections register under their parent entity's key, so the query key // matches nothing. Undefined until the first apply: no collection yet. event.__eventSource = this.rootEntity?.key; @@ -399,6 +403,9 @@ export class QueryInstance { this.queryClient.getContext(), ); this._executionCtx.refetch = () => this.refetch(); + // `TopicQuery.getConfig.subscribe` hands this to its adapter, which calls + // it when it delivers an event for the query's topic. + (this._executionCtx as unknown as Record)._notePush = this.notePush; this._executionCtx.rawFetchNext = this.def.statics.rawFetchNext; // `TopicQuery.getConfig.subscribe` reads `_topicAdapter` from the ctx; // set it eagerly so subscribe/unsubscribe work on the cache-fresh and @@ -484,6 +491,11 @@ export class QueryInstance { this.runDebounced(delay); } + /** Records that the subscription delivered data. See `lastPushAt`. */ + private notePush = (): void => { + if (this._isActive) this.lastPushAt = Date.now(); + }; + // ====================================================== // Public methods // ====================================================== @@ -587,15 +599,15 @@ export class QueryInstance { /** * Data is younger than the reactivation grace, and the last fetch didn't - * fail. Data a running subscription kept current counts as fresh up to the - * deactivation. Invalidation (`updatedAt = 0`) always falls outside. + * fail. Data a subscription pushed to counts as fresh from its last push. + * Invalidation (`updatedAt = 0`) always falls outside. */ private get isWithinReactivationGrace(): boolean { const { updatedAt } = this; if (updatedAt === undefined || updatedAt === 0 || this.lastFetchFailed) return false; const grace = this.config?.reactivationGraceMs ?? this.queryClient.reactivationGraceMs; if (!(grace > 0)) return false; - const freshAt = Math.max(updatedAt, this.subscribedUntil ?? 0); + const freshAt = Math.max(updatedAt, this.lastPushAt ?? 0); return Date.now() - freshAt < grace; } diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 674ae9a..46d37dd 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -6,6 +6,12 @@ import { t } from '../typeDefs.js'; import { QueryClient, QueryClientContext, type QueryClientConfig } from '../QueryClient.js'; import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; import { NetworkManager } from '../NetworkManager.js'; +import { poll } from '../subscriptions/polling.js'; +import type { ActivitySource } from '../query-types.js'; +import type { MutationEvent } from '../types.js'; +import type { Query } from '../query.js'; +import { TopicQuery } from '../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import { createMockFetch, createTestWatcher } from './utils.js'; /** @@ -249,13 +255,19 @@ describe('reactivation grace and stagger', () => { expect(fetchCount('/a')).toBe(3); }); - it('measures from deactivation when a subscription kept the data current', async () => { - // A subscription that never fires: stands in for a stream that delivered - // entity updates while the query was active. + it('measures from the last push when a subscription delivered data', async () => { + let push: (() => void) | undefined; class GetStreamed extends RESTQuery { path = '/a'; result = { n: t.number }; - config = { subscribe: () => () => {} }; + config = { + subscribe: (onEvent: (event: MutationEvent) => void) => { + push = () => onEvent({ type: 'update', typename: 'Unrelated', data: { id: '1' } }); + return () => { + push = undefined; + }; + }, + }; } const client = makeClient({ reactivationGraceMs: 15_000 }); @@ -263,24 +275,161 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - // Data is a minute old, but the subscription ran until a moment ago. + // Data is a minute old, but the stream delivered an event a moment ago. await vi.advanceTimersByTimeAsync(60_000); + push!(); + await vi.advanceTimersByTimeAsync(1_000); unsub(); await vi.advanceTimersByTimeAsync(5_000); const unsub2 = watch(client, GetStreamed); await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - unsub2(); - await vi.advanceTimersByTimeAsync(10); - // Deactivated longer than the grace: refetches. + // Still subscribed, but silent: the credit stops at the last push, not + // at the deactivation. await vi.advanceTimersByTimeAsync(20_000); + unsub2(); + await vi.advanceTimersByTimeAsync(10); watch(client, GetStreamed); await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(2); }); + it('does not count a subscription that never delivered as keeping the data current', async () => { + class GetQuiet extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { subscribe: () => () => {} }; + } + + const client = makeClient({ reactivationGraceMs: 15_000 }); + const unsub = watch(client, GetQuiet); + await vi.advanceTimersByTimeAsync(50); + await vi.advanceTimersByTimeAsync(60_000); + unsub(); + await vi.advanceTimersByTimeAsync(5_000); + + watch(client, GetQuiet); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); + + it('does not let a poll() that has not ticked chain the grace across short visits', async () => { + class GetPolled extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { subscribe: poll({ interval: 60_000 }) }; + } + + const client = makeClient({ reactivationGraceMs: 10_000 }); + let unsub = watch(client, GetPolled); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(1); + + // Visits shorter than the poll interval, gaps shorter than the grace. The + // poll restarts its interval each visit, so it never ticks. + for (let i = 0; i < 20; i++) { + await vi.advanceTimersByTimeAsync(50_000); + unsub(); + await vi.advanceTimersByTimeAsync(5_000); + unsub = watch(client, GetPolled); + await vi.advanceTimersByTimeAsync(10); + } + + // With a 60 s poll and staleTime 0, the data is never older than a visit. + const lastFetch = starts.filter(s => s.path === '/a').at(-1)!.at; + expect(Date.now() - lastFetch).toBeLessThan(60_000); + expect(fetchCount('/a')).toBe(21); + }); + + it('credits a poll() only through its fetches, not while it was stopped in the background', async () => { + let active = true; + const listeners = new Set<() => void>(); + const activity: ActivitySource = { + isActive: () => active, + subscribe: listener => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const setActive = (value: boolean): void => { + active = value; + for (const listener of listeners) listener(); + }; + class GetPolled extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { subscribe: poll({ interval: 10_000 }) }; + } + + const client = makeClient({ reactivationGraceMs: 15_000, activity }); + const unsub = watch(client, GetPolled); + await vi.advanceTimersByTimeAsync(35_000); + // The initial fetch and ticks at 10, 20 and 30 s. + expect(fetchCount('/a')).toBe(4); + + // Backgrounded: the poll stops. The screen goes away a minute later. + setActive(false); + await vi.advanceTimersByTimeAsync(60_000); + unsub(); + await vi.advanceTimersByTimeAsync(10); + setActive(true); + + // Within the grace of the deactivation, but the last tick was 65 s ago. + watch(client, GetPolled); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(5); + }); + + it('measures from the last event a topic adapter sent with the topic', async () => { + class PushingTopicAdapter extends TopicQueryAdapter { + sends = 0; + subscribe(topic: string): void { + setTimeout(() => this.fulfillTopic(topic, { n: 1 }), 10); + } + unsubscribe(topic: string): void { + this.clearTopic(topic); + } + override send(ctx: Query, signal: AbortSignal): Promise { + this.sends++; + return super.send(ctx, signal); + } + push(topic: string | undefined): void { + this.sendMutationEvent({ type: 'update', typename: 'Unrelated', data: { id: '1' } }, topic); + } + } + class GetTopic extends TopicQuery { + static override adapter = PushingTopicAdapter; + topic = 'prices'; + result = { n: t.number }; + } + + const adapter = new PushingTopicAdapter(); + const client = makeClient({ reactivationGraceMs: 15_000, adapters: [adapter] }); + const visit = async (pushTopic: string | undefined): Promise => { + const unsub = watch(client, GetTopic as any); + await vi.advanceTimersByTimeAsync(60_000); + adapter.push(pushTopic); + await vi.advanceTimersByTimeAsync(1_000); + unsub(); + await vi.advanceTimersByTimeAsync(5_000); + }; + + await visit('prices'); + expect(adapter.sends).toBe(1); + + // Pushed on its topic 6 s ago: inside the grace. + await visit(undefined); + expect(adapter.sends).toBe(1); + + // The last event didn't name the topic, so the query's last delivery is + // the push 67 s ago. + watch(client, GetTopic as any); + await vi.advanceTimersByTimeAsync(50); + expect(adapter.sends).toBe(2); + }); + it('measures from the fetch for a query without a subscription', async () => { const client = makeClient({ reactivationGraceMs: 15_000 }); const unsub = watch(client, GetA); diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 7cc9f41..a4246ea 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -65,8 +65,8 @@ export interface QueryConfigOptions { /** * Milliseconds. When the query reactivates (a watcher returns, or a paused * scope resumes) and its data is younger than this, it is not refetched even - * if stale. With a running subscription, the data counts as fresh until the - * query deactivated. Overrides `QueryClientConfig.reactivationGraceMs`. Does + * if stale. Data a subscription pushed to (a stream event, or a topic event + * sent with its topic) counts as fresh from the last push. Overrides `QueryClientConfig.reactivationGraceMs`. Does * not affect network reconnects, `refetch()`, or invalidation. */ reactivationGraceMs?: number; diff --git a/packages/fetchium/src/topic/TopicQuery.ts b/packages/fetchium/src/topic/TopicQuery.ts index c6569e8..ce5c126 100644 --- a/packages/fetchium/src/topic/TopicQuery.ts +++ b/packages/fetchium/src/topic/TopicQuery.ts @@ -24,12 +24,17 @@ export abstract class TopicQuery extends Query { return { staleTime: 0, subscribe: () => { - const adapter = (this as Record)._topicAdapter as TopicQueryAdapter | undefined; + const ctx = this as Record; + const adapter = ctx._topicAdapter as TopicQueryAdapter | undefined; const topic = this.getTopic ? this.getTopic() : this.topic; + const notePush = ctx._notePush as (() => void) | undefined; + let removeListener: (() => void) | undefined; if (adapter && topic !== undefined) { + if (notePush !== undefined) removeListener = adapter._addPushListener(topic, notePush); adapter.subscribe(topic); } return () => { + removeListener?.(); if (adapter && topic !== undefined) { adapter.unsubscribe(topic); } diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index 584d79b..097b55e 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -23,6 +23,8 @@ interface TopicState { export abstract class TopicQueryAdapter extends QueryAdapter { private _topics = new Map(); + /** Per topic, the subscribed queries' callbacks for a delivered event. */ + private _pushListeners = new Map void>>(); /** * Called when a query activates for a given topic. @@ -128,8 +130,36 @@ export abstract class TopicQueryAdapter extends QueryAdapter { /** * Convenience wrapper — pushes a mutation event through the QueryClient * so that entities and live collections are updated reactively. + * + * Pass the `topic` the event was delivered on to tell that topic's queries + * their subscription is live and current. With the client's + * `reactivationGraceMs`, a query that reactivates within the grace of the + * last such event is not refetched. */ - protected sendMutationEvent(event: MutationEvent): void { + protected sendMutationEvent(event: MutationEvent, topic?: string): void { + if (topic !== undefined) { + const listeners = this._pushListeners.get(topic); + if (listeners !== undefined) for (const listener of listeners) listener(); + } this.queryClient!.applyMutationEvent(event); } + + /** + * Registers a subscribed query's callback for events delivered on `topic`. + * Returns its removal. + * + * @internal + */ + _addPushListener(topic: string, listener: () => void): () => void { + let listeners = this._pushListeners.get(topic); + if (listeners === undefined) { + listeners = new Set(); + this._pushListeners.set(topic, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && this._pushListeners.get(topic) === listeners) this._pushListeners.delete(topic); + }; + } } From 9984ad95ffafa2be820f0bc7f7fa0abc287d3578 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Fri, 2 Oct 2026 23:17:57 -0400 Subject: [PATCH 017/109] perf(fetchium): hydrate synchronously from a sync store and start the first fetch on a microtask A query activated against SyncQueryStore (MMKV on mobile) resolved with its cached value a microtask after activation, so the first React render showed a loading state and a second, sync-lane render showed the data. The first fetch then waited on setTimeout(0). Apply the cached value in the activating read when the store returns synchronously, and run the subscription and first fetch on a microtask, still outside the relay's computation. AsyncQueryStore keeps its async path. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/sync-hydrate-first-fetch.md | 7 + packages/fetchium/src/QueryResult.ts | 89 +++++-- .../src/__tests__/caching-persistence.test.ts | 50 ++-- .../src/__tests__/sync-hydration.test.ts | 231 ++++++++++++++++++ .../react/__tests__/sync-hydration.test.tsx | 120 +++++++++ 5 files changed, 453 insertions(+), 44 deletions(-) create mode 100644 .changeset/sync-hydrate-first-fetch.md create mode 100644 packages/fetchium/src/__tests__/sync-hydration.test.ts create mode 100644 packages/fetchium/src/react/__tests__/sync-hydration.test.tsx diff --git a/.changeset/sync-hydrate-first-fetch.md b/.changeset/sync-hydrate-first-fetch.md new file mode 100644 index 0000000..b39211f --- /dev/null +++ b/.changeset/sync-hydrate-first-fetch.md @@ -0,0 +1,7 @@ +--- +'fetchium': patch +--- + +A query whose cached value lives in a synchronous store (`SyncQueryStore`) now resolves with that value in the same read that activates it, instead of a microtask later. A component that mounts such a query renders the cached data on its first render, not a loading state followed by a re-render. The first fetch, and the subscription before it, now start on a microtask instead of after a `setTimeout(0)`, which on device waits a frame or more. Neither runs inside the activating read. `AsyncQueryStore` hydrates on the tick its load resolves, as before. + +A cached entry that fails to apply is now treated as a cache miss, so the query fetches. Previously it kept the entry's timestamp, and a fresh-looking timestamp could leave the query pending without fetching. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index c12f342..d403dab 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -15,12 +15,17 @@ import { queryKeyFor, CachedQuery, } from './QueryClient.js'; +import type { MaybePromise } from './query-types.js'; import { DEFAULT_GC_TIME } from './stores/shared.js'; import { GcKeyType } from './GcManager.js'; import { Query, QueryDefinition, type ResolvedRetryConfig, resolveRetryConfig } from './query.js'; import { EntityInstance } from './EntityInstance.js'; import { hashValue } from 'signalium/utils'; -import { sleep, withRetry } from './retry.js'; +import { withRetry } from './retry.js'; + +function isThenable(value: MaybePromise): value is Promise { + return typeof (value as { then?: unknown } | null | undefined)?.then === 'function'; +} /** * Thin fetch/relay orchestrator. Data management (proxy, notifier, child refs, @@ -252,27 +257,75 @@ export class QueryInstance { ); } - private async initialize(): Promise { - const qc = this.queryClient; - const state = this.relayState; - + /** + * Runs once, from the relay's first activation. That activation happens + * inside the read that watched the relay (for React, during render). + * + * When the store answers synchronously (SyncQueryStore), the cached value is + * applied right here, so the activating read, and the first render, already + * see it. Subscribing and fetching wait for the next microtask: both call + * into adapter and app code that may read or write signals, which must not + * run while the relay's computation is the current consumer. A microtask + * rather than a timer keeps the first fetch off the macrotask queue. + * + * With an asynchronous store (AsyncQueryStore), the cache resolves on a later + * tick, outside the activating read, and everything runs from there. + */ + private initialize(): void { this.initialized = true; - let cached: CachedQuery | undefined; + let loaded: MaybePromise; try { - cached = await qc.loadCachedQuery(this.def, this.storageKey); + loaded = this.queryClient.loadCachedQuery(this.def, this.storageKey); + } catch (error) { + this.discardCorruptCache(error); + loaded = undefined; + } - if (cached !== undefined) { - this.updatedAt = cached.updatedAt; - state.value = this.applyData(cached.value, false, false, cached.preloadedEntities); - } + if (isThenable(loaded)) { + loaded.then( + cached => { + this.hydrate(cached); + this.startSubscriptionAndFetch(); + }, + error => { + this.discardCorruptCache(error); + this.startSubscriptionAndFetch(); + }, + ); + } else { + this.hydrate(loaded); + queueMicrotask(() => this.startSubscriptionAndFetch()); + } + } + + private discardCorruptCache(error: unknown): void { + const qc = this.queryClient; + qc.store.deleteQuery(this.storageKey); + qc.getContext().log?.warn?.('Failed to initialize query, the query cache may be corrupted or invalid', error); + } + + /** Resolves the relay with a cached value. */ + private hydrate(cached: CachedQuery | undefined): void { + if (cached === undefined) return; + + try { + this.updatedAt = cached.updatedAt; + this.relayState.value = this.applyData(cached.value, false, false, cached.preloadedEntities); } catch (error) { - qc.store.deleteQuery(this.storageKey); - qc.getContext().log?.warn?.('Failed to initialize query, the query cache may be corrupted or invalid', error); + // Unusable entry: treat it as a miss so the query still fetches, instead + // of trusting a timestamp whose data was never applied. + this.updatedAt = undefined; + this.discardCorruptCache(error); } + } - if (this.isPaused) { + /** Starts the subscription, then the first fetch if there is no fresh cached value. */ + private startSubscriptionAndFetch(): void { + // Deactivated in the meantime: update() fetches on reactivation, since the + // relay is still pending (or stale) with no fetch in flight. + if (!this._isActive || this.isPaused) { return; } @@ -282,13 +335,13 @@ export class QueryInstance { // `send()` awaits. this.reconcileSubscription(); - if (cached === undefined || this.isStale) { - await sleep(0); - if (this.isPaused) return; + // Skip if something already started a fetch since activation (refetch()). + const fetchInFlight = this.relayState.isPending && this._abortController !== undefined; + if (this.isStale && !fetchInFlight) { this.runQueryImmediately(); } } catch (error) { - state.setError(error as Error); + this.relayState.setError(error as Error); } } diff --git a/packages/fetchium/src/__tests__/caching-persistence.test.ts b/packages/fetchium/src/__tests__/caching-persistence.test.ts index eaacfac..ca9ab3b 100644 --- a/packages/fetchium/src/__tests__/caching-persistence.test.ts +++ b/packages/fetchium/src/__tests__/caching-persistence.test.ts @@ -230,19 +230,18 @@ describe('Caching and Persistence', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetItem, { id: '1' }); - // Force a pull + // A synchronous store resolves the relay with the cached value in the + // read that activates it, so awaiting it yields the cached value. relay.value; - await sleep(); - + expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ id: 1, name: 'Cached Data' }); + expect(await relay).toMatchObject({ id: 1, name: 'Cached Data' }); - const result = await relay; - - // Immediate value should be the same as the cached value because we're - // background refetching but have a value that is still valid. - expect(result).toMatchObject({ id: 1, name: 'Cached Data' }); + // The background refetch starts a microtask later; the cached value + // stays visible while it is in flight. + await sleep(); + expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ id: 1, name: 'Cached Data' }); - expect(relay.isPending).toBe(false); await sleep(20); @@ -277,19 +276,18 @@ describe('Caching and Persistence', () => { await testWithClient(client2, async () => { const relay = fetchQuery(GetItem); - // Force a pull + // A synchronous store resolves the relay with the cached value in the + // read that activates it, so awaiting it yields the cached value. relay.value; - await sleep(); - + expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ id: 1, value: 'Persistent' }); + expect(await relay).toMatchObject({ id: 1, value: 'Persistent' }); - const result = await relay; - - // Immediate value should be the same as the cached value because we're - // background refetching but have a value that is still valid. - expect(result).toMatchObject({ id: 1, value: 'Persistent' }); + // The background refetch starts a microtask later; the cached value + // stays visible while it is in flight. + await sleep(); + expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ id: 1, value: 'Persistent' }); - expect(relay.isPending).toBe(false); await sleep(30); @@ -380,19 +378,19 @@ describe('Caching and Persistence', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetDocument); - // Force a pull + // A synchronous store resolves the relay with the cached value in the + // read that activates it, so awaiting it yields the cached value. relay.value; - await sleep(); - + expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); - const result = await relay; - - // Immediate value should be the same as the cached value because we're - // background refetching but have a value that is still valid. expect(result).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); + + // The background refetch starts a microtask later; the cached value + // stays visible while it is in flight. + await sleep(); + expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); - expect(relay.isPending).toBe(false); await sleep(20); diff --git a/packages/fetchium/src/__tests__/sync-hydration.test.ts b/packages/fetchium/src/__tests__/sync-hydration.test.ts new file mode 100644 index 0000000..2dcad11 --- /dev/null +++ b/packages/fetchium/src/__tests__/sync-hydration.test.ts @@ -0,0 +1,231 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { describe, it, expect } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext, type CachedQuery } from '../QueryClient.js'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { t } from '../typeDefs.js'; +import { fetchQuery } from '../query.js'; +import { QueryDefinition } from '../query.js'; +import { createMockFetch, sleep, testWithClient } from './utils.js'; + +/** + * Activation timing with a synchronous persistent store. + * + * When the store's `loadQuery` returns synchronously (MMKV on mobile), a + * freshly activated query must expose its cached value in the same read that + * activated it, so the first React render has data instead of a skeleton. + * The first network fetch must start on a microtask, not after a timer hop, + * and must never run inside the activating read. + */ + +/** Same store, but loadQuery resolves on a later microtask (extension-style). */ +class AsyncLoadingQueryStore extends SyncQueryStore { + override loadQuery(queryDef: QueryDefinition, queryKey: number): CachedQuery | undefined { + return Promise.resolve(super.loadQuery(queryDef, queryKey)) as unknown as CachedQuery | undefined; + } +} + +async function flushMicrotasks(count = 20): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve(); + } +} + +function makeClient(store: SyncQueryStore, mockFetch: ReturnType): QueryClient { + return new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); +} + +/** Persist one response for GetItem into `kv` through a throwaway client. */ +async function seedCache(kv: MemoryPersistentStore, QueryClass: new () => RESTQuery, response: unknown) { + const mockFetch = createMockFetch(); + mockFetch.get('/item', response); + const client = makeClient(new SyncQueryStore(kv), mockFetch); + await testWithClient(client, async () => { + await fetchQuery(QueryClass as any); + }); + client.destroy(); +} + +/** + * Activates `read` inside a watched reactive context (the shape React's + * `useReactive` / `component()` render reads take) and returns what the + * activating read observed, plus a disposer. + */ +function activate(client: QueryClient, read: () => T): { first: T; dispose: () => void } { + let first: T | undefined; + let captured = false; + const w = withContexts([[QueryClientContext, client]], () => + watcher(() => { + const value = read(); + if (!captured) { + first = value; + captured = true; + } + }), + ); + const dispose = w.addListener(() => {}); + w.value; + return { first: first as T, dispose }; +} + +describe('Activation with a synchronous store', () => { + it('exposes fresh cached data in the activating read, without fetching', async () => { + class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: 60_000 }; + } + + const kv = new MemoryPersistentStore(); + await seedCache(kv, GetItem, { value: 'cached' }); + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new SyncQueryStore(kv), mockFetch); + + const { first, dispose } = activate(client, () => { + const relay = fetchQuery(GetItem); + return { isReady: relay.isReady, isPending: relay.isPending, value: relay.value?.value }; + }); + + expect(first).toEqual({ isReady: true, isPending: false, value: 'cached' }); + + await sleep(20); + expect(mockFetch.calls).toHaveLength(0); + + dispose(); + client.destroy(); + }); + + it('exposes stale cached data in the activating read, then refetches on a microtask', async () => { + class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: 0 }; + } + + const kv = new MemoryPersistentStore(); + await seedCache(kv, GetItem, { value: 'cached' }); + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }, { delay: 20 }); + const client = makeClient(new SyncQueryStore(kv), mockFetch); + + let relayRef: ReturnType> | undefined; + const { first, dispose } = activate(client, () => { + const relay = (relayRef = fetchQuery(GetItem)); + return { isReady: relay.isReady, isPending: relay.isPending, value: relay.value?.value }; + }); + + // The cached value is resolved, not pending, in the activating read, so a + // consumer that awaits the relay gets it immediately. + expect(first).toEqual({ isReady: true, isPending: false, value: 'cached' }); + + // The fetch never runs inside the activating read... + expect(mockFetch.calls).toHaveLength(0); + + // ...and starts without waiting for a timer. + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + expect(relayRef!.isPending).toBe(true); + expect(relayRef!.value!.value).toBe('cached'); + + await relayRef; + await sleep(0); + expect(relayRef!.value!.value).toBe('fresh'); + + dispose(); + client.destroy(); + }); + + it('starts the first fetch on a microtask when nothing is cached', async () => { + class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + } + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new SyncQueryStore(new MemoryPersistentStore()), mockFetch); + + const { first, dispose } = activate(client, () => { + const relay = fetchQuery(GetItem); + return { isReady: relay.isReady, isPending: relay.isPending }; + }); + + expect(first).toEqual({ isReady: false, isPending: true }); + expect(mockFetch.calls).toHaveLength(0); + + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + + dispose(); + client.destroy(); + }); +}); + +describe('Activation with an asynchronous store', () => { + it('keeps the activating read pending, then hydrates from cache', async () => { + class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: 60_000 }; + } + + const kv = new MemoryPersistentStore(); + await seedCache(kv, GetItem, { value: 'cached' }); + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new AsyncLoadingQueryStore(kv), mockFetch); + + let relayRef: ReturnType> | undefined; + const { first, dispose } = activate(client, () => { + relayRef = fetchQuery(GetItem); + return { isReady: relayRef.isReady, isPending: relayRef.isPending }; + }); + + expect(first).toEqual({ isReady: false, isPending: true }); + + await flushMicrotasks(); + expect(relayRef!.isReady).toBe(true); + expect(relayRef!.value!.value).toBe('cached'); + + await sleep(20); + expect(mockFetch.calls).toHaveLength(0); + + dispose(); + client.destroy(); + }); + + it('fetches after the cache miss resolves, without a timer hop', async () => { + class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + } + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new AsyncLoadingQueryStore(new MemoryPersistentStore()), mockFetch); + + let relayRef: ReturnType> | undefined; + const { dispose } = activate(client, () => { + relayRef = fetchQuery(GetItem); + return relayRef.isPending; + }); + + expect(mockFetch.calls).toHaveLength(0); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + + await relayRef; + expect(relayRef!.value!.value).toBe('fresh'); + + dispose(); + client.destroy(); + }); +}); diff --git a/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx b/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx new file mode 100644 index 0000000..b11fbda --- /dev/null +++ b/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { describe, it, expect, afterEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider, component } from 'signalium/react'; +import React from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { RESTQuery } from '../../rest/index.js'; +import { fetchQuery } from '../../query.js'; +import { createMockFetch, sleep } from '../../__tests__/utils.js'; +import { watcher, withContexts } from 'signalium'; +import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; +import { useQuery } from '../use-query.js'; + +/** + * With a synchronous persistent store, a component mounting a query whose data + * is already cached renders that data on its first render, not a loading + * frame followed by a re-render. + */ + +class GetFreshItem extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { staleTime: 60_000 }; +} + +class GetStaleItem extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { staleTime: 0 }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +function makeClient(kv: MemoryPersistentStore, mockFetch: ReturnType): QueryClient { + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return client; +} + +async function seededStore(QueryClass: new () => RESTQuery, name: string): Promise { + const kv = new MemoryPersistentStore(); + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name }); + const client = makeClient(kv, mockFetch); + const w = withContexts([[QueryClientContext, client]], () => watcher(() => fetchQuery(QueryClass as any).value)); + const unsub = w.addListener(() => {}); + w.value; + for (let i = 0; i < 50 && kv.getAllKeys().length === 0; i++) { + await sleep(10); + } + unsub(); + expect(kv.getAllKeys().length).toBeGreaterThan(0); + return kv; +} + +describe('first render with a synchronous store', () => { + it('useQuery renders fresh cached data on the first render, once', async () => { + const kv = await seededStore(GetFreshItem, 'cached'); + const mockFetch = createMockFetch(); + const client = makeClient(kv, mockFetch); + + const renders: Array = []; + function Item(): React.ReactNode { + const item = useQuery(GetFreshItem); + const label = item.isReady ? item.value.name : undefined; + renders.push(label); + return
{label ?? 'Loading...'}
; + } + + const { getByText } = render( + + + , + ); + + await expect.element(getByText('cached')).toBeInTheDocument(); + await sleep(50); + + expect(renders[0]).toBe('cached'); + expect(renders).toEqual(['cached']); + expect(mockFetch.calls).toHaveLength(0); + }); + + it('component() renders stale cached data on the first render, then the refetched data', async () => { + const kv = await seededStore(GetStaleItem, 'cached'); + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'fresh' }, { delay: 20 }); + const client = makeClient(kv, mockFetch); + + const renders: Array = []; + const Item = component(() => { + const item = fetchQuery(GetStaleItem); + const label = item.isReady ? item.value.name : undefined; + renders.push(label); + return
{label ?? 'Loading...'}
; + }); + + const { getByText } = render( + + + , + ); + + await expect.element(getByText('fresh')).toBeInTheDocument(); + + expect(renders[0]).toBe('cached'); + expect(renders).not.toContain(undefined); + expect(mockFetch.calls).toHaveLength(1); + }); +}); From 32325c3360878af2157c275495dd58e20aa6e45e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:09:16 -0400 Subject: [PATCH 018/109] fix(fetchium): skip a staggered reactivation refetch made redundant during the window A staggered refetch waits up to reactivationStaggerMs and then started unconditionally. A refetch() or poll tick in that window was aborted by it and repeated, and a fetch that had already landed was repeated too. It now starts only if no fetch has started since the query was queued. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 26 +++++++-- .../src/__tests__/reactivation-grace.test.ts | 53 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 07b4c3c..c5441a0 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -64,6 +64,10 @@ export class QueryInstance { * stopped while the app was in the background, kept nothing current. */ private lastPushAt: number | undefined = undefined; + /** Counts fetches started by runQueryImmediately(). */ + private fetchStarts: number = 0; + /** `fetchStarts` when the pending reactivation refetch was queued. */ + private reactivationQueuedAt: number = -1; // Invalidates on any signal consumed by getConfig() (such as // responseNotifier, fired by the adapter after each fetch). Param @@ -208,6 +212,7 @@ export class QueryInstance { const withinGrace = activating && !wasPaused && this.isWithinReactivationGrace; if (refreshStaleOnReconnect && this.isStale && !withinGrace) { if (this.queryClient.reactivationStaggerMs > 0) { + this.reactivationQueuedAt = this.fetchStarts; this.queryClient.scheduleReactivationRefetch(this); } else { this.runDebounced(); @@ -460,6 +465,7 @@ export class QueryInstance { } private runQueryImmediately(): void { + this.fetchStarts++; this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); @@ -484,11 +490,25 @@ export class QueryInstance { /** * Starts a reactivation refetch after `delay` (plus the query's debounce). * Called by the QueryClient's stagger flush, a task after the query was - * queued, so it rechecks that the query is still active. + * queued, so it rechecks that the query is still active. A fetch started + * since the query was queued (a `refetch()`, a poll tick, an invalidation), + * whether still in flight or already done, makes it redundant: it is + * skipped rather than aborting and repeating that fetch. */ runReactivationRefetch(delay: number): void { - if (!this._isActive || this.isPaused) return; - this.runDebounced(delay); + if (!this._isActive || this.isPaused || this.relayState.isPending) return; + const queuedAt = this.reactivationQueuedAt; + if (this.fetchStarts !== queuedAt) return; + + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout( + () => { + this.debounceTimer = undefined; + if (this.fetchStarts !== queuedAt) return; + this.runQueryImmediately(); + }, + (this.config?.debounce ?? 0) + delay, + ); } /** Records that the subscription delivered data. See `lastPushAt`. */ diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 46d37dd..c54600b 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -617,6 +617,59 @@ describe('reactivation grace and stagger', () => { expectOffsets([0, 0, 150]); }); + it('skips a staggered refetch when refetch() started one during the window', async () => { + mockFetch.reset(); + for (const path of ['/a', '/b', '/c']) { + mockFetch.get(path, () => ({ n: ++counter }), { delay: 200 }); + } + const client = makeClient({ reactivationStaggerMs: 3_000 }); + await loadThenDeactivate(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(300); + starts.length = 0; + + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(100); + // /c's slot is 2 s out. A pull to refresh starts it now. + await withContexts([[QueryClientContext, client]], async () => { + void (fetchQuery(GetC).value as any).__refetch(); + }); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/c')).toBe(1); + + // The slot passes while that fetch is in flight, and after it landed. + await vi.advanceTimersByTimeAsync(2_500); + expect(fetchCount('/c')).toBe(1); + expect(fetchCount('/a')).toBe(1); + expect(fetchCount('/b')).toBe(1); + }); + + it('does not abort a fetch still in flight when its stagger slot comes up', async () => { + mockFetch.reset(); + mockFetch.get('/a', () => ({ n: ++counter })); + mockFetch.get('/b', () => ({ n: ++counter })); + mockFetch.get('/c', () => ({ n: ++counter })); + mockFetch.get('/c', () => ({ n: ++counter }), { delay: 5_000 }); + const client = makeClient({ reactivationStaggerMs: 3_000 }); + await loadThenDeactivate(client, GetA, GetB, GetC); + starts.length = 0; + + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(100); + let settled: 'resolved' | 'rejected' | undefined; + await withContexts([[QueryClientContext, client]], async () => { + (fetchQuery(GetC).value as any).__refetch().then( + () => (settled = 'resolved'), + () => (settled = 'rejected'), + ); + }); + + await vi.advanceTimersByTimeAsync(2_500); + expect(fetchCount('/c')).toBe(1); + await vi.advanceTimersByTimeAsync(5_000); + expect(settled).toBe('resolved'); + expect(fetchCount('/c')).toBe(1); + }); + it('does not delay an initial fetch', async () => { const client = makeClient({ reactivationStaggerMs: 300 }); watch(client, GetA, GetB, GetC); From e28c7707ea5cfe1044614e5f5e3a3537add5f66b Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:47 -0400 Subject: [PATCH 019/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/sync-hydrate-first-fetch.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/sync-hydrate-first-fetch.md b/.changeset/sync-hydrate-first-fetch.md index b39211f..001b9b8 100644 --- a/.changeset/sync-hydrate-first-fetch.md +++ b/.changeset/sync-hydrate-first-fetch.md @@ -2,6 +2,7 @@ 'fetchium': patch --- -A query whose cached value lives in a synchronous store (`SyncQueryStore`) now resolves with that value in the same read that activates it, instead of a microtask later. A component that mounts such a query renders the cached data on its first render, not a loading state followed by a re-render. The first fetch, and the subscription before it, now start on a microtask instead of after a `setTimeout(0)`, which on device waits a frame or more. Neither runs inside the activating read. `AsyncQueryStore` hydrates on the tick its load resolves, as before. +With `SyncQueryStore`, a cached query renders its data on the first render instead of showing a loading frame first. -A cached entry that fails to apply is now treated as a cache miss, so the query fetches. Previously it kept the entry's timestamp, and a fresh-looking timestamp could leave the query pending without fetching. +- The first fetch starts on a microtask instead of after `setTimeout(0)`. Tests that wait for a loading state or count renders may need updating. +- A cached entry that can't be loaded is treated as a cache miss and fetched. Previously a fresh-looking entry could leave the query pending without fetching. From 6bc090b99d781dcad702f265c4990e3081824b4e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:47 -0400 Subject: [PATCH 020/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/remove-redundant-entity-saves.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.changeset/remove-redundant-entity-saves.md b/.changeset/remove-redundant-entity-saves.md index 242e7d8..5f142b2 100644 --- a/.changeset/remove-redundant-entity-saves.md +++ b/.changeset/remove-redundant-entity-saves.md @@ -2,8 +2,6 @@ 'fetchium': patch --- -Stop writing an entity to the store more than once per streamed event. `applyMutationEvent` ran `applyEntityRefs` with `persist: true`, which walks to the entity and writes it, then called `entityMap.save` on the same entity again unconditionally. `LiveArrayInstance.add` added a third write, re-saving a child whose record was already current. An update or create event wrote the entity twice, and an event that inserted into a live array wrote the child three times. +Streamed updates write each changed entity to the store once instead of two or three times, and an update that changes nothing writes nothing. -The apply already declines the write when the applied data is unchanged, so removing these saves takes a streamed event that changes nothing from one write to none. The remaining write is the one the apply performed, so an entity written by an event is still readable by a later client. - -A `create` event for an entity that no live collection routes is no longer written to the store before being evicted; such a record had no refcount and was never collected. +A `create` event for an entity that no live collection shows is no longer written to the store, where it was never cleaned up. From 84c80aacfffe349f8641befc8c800d3e3fcaf2c4 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 02:47:50 -0400 Subject: [PATCH 021/109] perf(fetchium): microtask refetches, prefetch/retain leases, useSuspenseQuery Read-path latency: - A refetch with no debounce (stale reactivation, Signal param change) now starts on a microtask instead of setTimeout(0). On React Native a zero timer goes through the native timing module and can wait a frame. Stale reactivation goes from 1 macrotask turn to 0, a param change from 2 to 1 (the remaining one is Signalium's flush), a staggered reactivation from 2 to 1. Zero-delay refetches requested in one task still coalesce. - Invalidation keeps the timer, so a query whose last reader left in the same task is deactivated by Signalium's flush instead of refetched and aborted. New API: - QueryClient.prefetch(QueryClass, params, { ttl }) starts a query and keeps it active for ttl (default 10 s). A reader that mounts inside the window joins it: no second request, data on the first render if it has arrived. - QueryClient.retain(fn, { ttl }) keeps the queries fn reads or returns active until release() or ttl. Built on public Signalium 3.0.3 APIs (watcher + addListener), mirroring the unreleased signalium retain() but bound to the client's QueryClientContext. destroy() releases leases. - useSuspenseQuery (fetchium/react) suspends only on a cold miss, never on a refetch; it decides before subscribing and has the client hold the query while suspended, so a discarded render leaks no watch. A failed cold fetch is thrown to the error boundary; a boundary reset retries. Docs: useQuery's .value never suspended and a rejected .value never threw; the agent doc's `{ suspended }` option never existed. Fixed, and documented the new API. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/fast-delivery-prefetch-retain.md | 11 + docs/src/app/api/fetchium-react/page.md | 55 ++- docs/src/app/api/fetchium/page.md | 34 +- docs/src/app/guides/error-handling/page.md | 12 +- packages/fetchium/plugin/agents/fetchium.md | 4 +- packages/fetchium/src/QueryClient.ts | 255 +++++++++++++- packages/fetchium/src/QueryResult.ts | 78 ++++- .../src/__tests__/read-path-latency.test.ts | 208 +++++++++++ .../src/__tests__/retain-prefetch.test.ts | 323 +++++++++++++++++ packages/fetchium/src/index.ts | 4 +- .../__tests__/prefetch-suspense.test.tsx | 328 ++++++++++++++++++ packages/fetchium/src/react/index.ts | 1 + .../fetchium/src/react/use-suspense-query.ts | 62 ++++ 13 files changed, 1336 insertions(+), 39 deletions(-) create mode 100644 .changeset/fast-delivery-prefetch-retain.md create mode 100644 packages/fetchium/src/__tests__/read-path-latency.test.ts create mode 100644 packages/fetchium/src/__tests__/retain-prefetch.test.ts create mode 100644 packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx create mode 100644 packages/fetchium/src/react/use-suspense-query.ts diff --git a/.changeset/fast-delivery-prefetch-retain.md b/.changeset/fast-delivery-prefetch-retain.md new file mode 100644 index 0000000..a70c38d --- /dev/null +++ b/.changeset/fast-delivery-prefetch-retain.md @@ -0,0 +1,11 @@ +--- +'fetchium': minor +--- + +Adds `queryClient.prefetch(QueryClass, params, { ttl })` and `queryClient.retain(fn, { ttl })`. Both keep queries active without a reader and return a `release` function. A reader that mounts while the lease is held joins the running query: no second request, and the data on its first render if it has arrived. `prefetch` defaults to a 10 s lease (`DEFAULT_PREFETCH_TTL`) and lets a fetch in flight at expiry finish; `retain` lasts until released. + +Adds `useSuspenseQuery` to `fetchium/react`. It suspends only when the query has never produced a value (in memory or in a synchronous store), never for a refetch, and throws a failed cold fetch to the error boundary. + +A refetch with no `debounce` (a stale query reactivating, a Signal param change) now starts on a microtask instead of after `setTimeout(0)`, which on React Native can wait a frame. Invalidation still waits a task, so a query whose last reader left in the same task is deactivated rather than refetched. + +Docs no longer claim that reading `.value` on a pending or rejected query suspends or throws; it does neither. diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index b02089a..98f2c51 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -8,7 +8,7 @@ description: API reference for the fetchium React integration. React hooks for using Fetchium queries in React components. Built on top of Signalium's `useReactive` hook. ```ts -import { useQuery } from 'fetchium/react'; +import { useQuery, useSuspenseQuery } from 'fetchium/react'; ``` --- @@ -39,14 +39,14 @@ React hook for fetching a query. Subscribes the component to the query's reactiv The returned promise object has the following properties: -| Property | Type | Description | -| ------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `value` | `QueryResult` | The resolved query result. Reading this while pending triggers React Suspense. Returns a deep clone to avoid accidental mutation of cached entities. | -| `isReady` | `boolean` | `true` once the query has loaded a value at least once. Use for type narrowing on `value`. | -| `isPending` | `boolean` | `true` while the query is loading. Also true during refetches, even if a value already exists. | -| `isResolved` | `boolean` | `true` when the most recent execution resolved successfully. | -| `isRejected` | `boolean` | `true` when the most recent execution failed. | -| `error` | `unknown` | The error if `isRejected` is `true`. | +| Property | Type | Description | +| ------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `QueryResult` | The resolved query result, or `undefined` until the first value arrives. Returns a deep clone to avoid accidental mutation of cached entities. | +| `isReady` | `boolean` | `true` once the query has loaded a value at least once. Use for type narrowing on `value`. | +| `isPending` | `boolean` | `true` while the query is loading. Also true during refetches, even if a value already exists. | +| `isResolved` | `boolean` | `true` when the most recent execution resolved successfully. | +| `isRejected` | `boolean` | `true` when the most recent execution failed. | +| `error` | `unknown` | The error if `isRejected` is `true`. | The resolved `QueryResult` includes pagination helpers: @@ -133,4 +133,39 @@ const SearchResults = component(() => { - `useQuery` is a thin wrapper around Signalium v3's `useReactive`, which is deep-by-default. It returns a **structurally-shared snapshot** of the query result, so memoized children that receive subtrees as props keep stable references when the underlying data is unchanged. - Fetchium registers a custom snapshot for entity proxies so the snapshot walks into entities (instead of returning them by reference), giving you correct re-rendering on entity field changes. - The snapshot is a plain-object copy of the query result to prevent accidental mutation of the entity cache. Use `draft()` from `fetchium` if you need a mutable copy for mutations. -- When used with React Suspense, reading `.value` on a pending query will suspend the component. +- `useQuery` never suspends. Reading `.value` while pending returns `undefined`. Use [`useSuspenseQuery`](#usesuspensequery) to suspend on a cold miss. + +--- + +### `useSuspenseQuery` + +```ts +function useSuspenseQuery( + QueryClass: new () => T, + params?: ExtractQueryParams, +): ReadyReactivePromise>; +``` + +`useQuery` for components under a React `` boundary. It suspends only on a **cold miss**: the query has never produced a value, neither in memory nor, with a synchronous store, in the persisted cache. Anything else renders at once, including a stale value whose refetch is in flight. Refetches never suspend. Because the hook only returns once a value exists, `value` is always defined. + +A cold fetch that fails is thrown to the nearest error boundary. Resetting the boundary (remounting the component) tries the fetch again. + +Changing params to ones with no value yet is a cold miss, so the component suspends. Wrap the change in `startTransition` to keep showing the previous result. + +Call it from a plain function component. Like `useQuery`, it cannot run inside a Signalium `component()`, which is a reactive context. + +```tsx +import { Suspense } from 'react'; +import { useSuspenseQuery } from 'fetchium/react'; + +function UserName({ id }: { id: number }) { + const user = useSuspenseQuery(GetUser, { id }); + return {user.value.name}; +} + +}> + +; +``` + +While suspended, the client keeps the query active, since React discards a suspended render without subscribing to anything. The component's commit takes over. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 4ef56cb..85f40bd 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -245,7 +245,37 @@ new QueryClient(config: QueryClientConfig) | `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | | `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | | `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | -| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches. | +| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query now and keeps it active for `ttl` ms (default `DEFAULT_PREFETCH_TTL`, 10 s). See below. | +| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until the returned `release` is called, or for `ttl` ms. See below. | +| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches, and releases outstanding leases. | + +#### `prefetch` and `retain` + +Both take a lease: the queries stay active (fetched, subscribed, exempt from GC) without a component reading them. A reader that mounts while the lease is held joins the active query, so it starts no request of its own and renders the data on its first render if it has arrived. Both return an idempotent `release` function. Releasing never interrupts other readers; once the last one goes, the query deactivates and its `gcTime` starts as usual. Calling `release` while a fetch is in flight, with no reader, aborts that fetch. A `prefetch` whose `ttl` runs out mid-fetch instead waits for the fetch to settle before letting go. + +`prefetch` is for the moment a user commits to a navigation, such as a tap. Call it in the handler, before navigating: + +```ts +function onPressToken(id: string) { + queryClient.prefetch(GetTokenDetail, { id }, { ttl: 5_000 }); + router.push(`/token/${id}`); +} +``` + +With a synchronous store, a cached result is applied inside the call. A fresh one makes no request, and a stale one is refetched, as on any activation. + +`retain` keeps a set of queries warm, for example the queries of a screen that is hidden but likely to come back. `fn` runs immediately, with the client as `QueryClientContext`, and again when what it reads changes. It holds a query when it reads one of the query's fields, or when it returns the query's promise or an array of them: + +```ts +const release = queryClient.retain(() => [ + fetchQuery(GetPortfolio), + fetchQuery(GetPrices, { ids }), +]); +// when the surface is gone for good: +release(); +``` + +Without a `ttl`, a `retain` lease lasts until `release` is called. --- @@ -625,7 +655,7 @@ The `t` object provides a declarative type definition DSL for describing query p | ------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | | `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | -| `debounce` | `number` | `0` | Milliseconds to debounce param-change refetches. | +| `debounce` | `number` | `0` | Milliseconds to debounce refetches. At `0`, a refetch starts on a microtask (invalidation: on the next task). | | `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | | `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | | `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index 03b3605..edbdbb4 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -295,7 +295,7 @@ if (result.isRejected) { ### Opting into throw behavior -If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- you can read `.value` directly. Reading `.value` on a rejected `ReactivePromise` throws the error, which will propagate up to the nearest error boundary. +If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- throw the error yourself. Reading `.value` does not throw: on a rejected query it holds the last successful value, or `undefined` if there never was one. ```tsx import { ErrorBoundary } from 'react-error-boundary'; @@ -303,13 +303,13 @@ import { ErrorBoundary } from 'react-error-boundary'; function UserProfile({ userId }: { userId: number }) { const result = useQuery(GetUser, { id: userId }); - // This throws if the query is rejected, - // which the ErrorBoundary above will catch - const user = result.value; + // Hand a failure with nothing to show to the ErrorBoundary above + if (result.isRejected && !result.isReady) throw result.error; + if (!result.isReady) return ; return (
-

{user.name}

+

{result.value.name}

); } @@ -323,6 +323,8 @@ function App() { } ``` +`useSuspenseQuery` from `fetchium/react` does this for you: it suspends while a query with no value loads, and throws a failed cold fetch to the error boundary. + This is a conscious opt-in. The default explicit-checking pattern (`isRejected` + `error`) is recommended for most use cases because it gives you the most flexibility. Error boundaries are best reserved for catching truly unexpected failures that shouldn't be handled inline. --- diff --git a/packages/fetchium/plugin/agents/fetchium.md b/packages/fetchium/plugin/agents/fetchium.md index b873c30..2d8189a 100644 --- a/packages/fetchium/plugin/agents/fetchium.md +++ b/packages/fetchium/plugin/agents/fetchium.md @@ -137,7 +137,9 @@ function UserProfile() { } ``` -`useQuery` returns `ReactivePromise` with: `value`, `isReady`, `isPending`, `isResolved`, `isRejected`, `error`. Pass `{ suspended: true/false }` to control whether the query is active. +`useQuery` returns `ReactivePromise` with: `value`, `isReady`, `isPending`, `isResolved`, `isRejected`, `error`. It never suspends; `value` is `undefined` until the first value arrives. `useSuspenseQuery` (same arguments) suspends on a cold miss only, never for a refetch, and throws a failed cold fetch to the error boundary. + +To start a destination's query when the user taps, call `queryClient.prefetch(QueryClass, params, { ttl })` before navigating; the screen's `useQuery` then joins the running query. `queryClient.retain(() => [fetchQuery(A), fetchQuery(B)], { ttl })` keeps a set of queries active until its returned `release()` is called. ## React + Signalium Usage diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 064d7dd..f0b102a 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1,4 +1,4 @@ -import { context, watcher, ReactiveTask, type Context } from 'signalium'; +import { context, watcher, withContexts, ReactivePromise, ReactiveTask, type Context } from 'signalium'; import { hashValue } from 'signalium/utils'; import { EntityDef, @@ -35,6 +35,8 @@ import { queryKeyFor, } from './query-types.js'; import { SyncQueryStore, MemoryPersistentStore } from './stores/sync.js'; +import type { ExtractType } from './types.js'; +import type { Optionalize, Signalize } from './type-utils.js'; export interface QueryClientConfig { store?: QueryStore; @@ -84,6 +86,42 @@ export interface QueryClientConfig { pollResumeJitterMs?: number; } +export interface RetainOptions { + /** + * Milliseconds after which the lease releases itself. Omit to hold it until + * the returned `release` is called. + */ + ttl?: number; +} + +export interface PrefetchOptions { + /** + * Milliseconds to keep the query active if nothing else reads it. Default: + * `DEFAULT_PREFETCH_TTL` (10 s). + */ + ttl?: number; +} + +/** How long `prefetch()` keeps a query active when no `ttl` is given. */ +export const DEFAULT_PREFETCH_TTL = 10_000; + +/** + * How long a suspense hold outlives its fetch settling when no reader commits + * to claim it (the suspended tree was abandoned). + */ +const SUSPENSE_HOLD_TTL = 10_000; + +interface SuspenseHold { + release: () => void; + /** Resolves (never rejects) once the cold fetch settles. */ + settled: Promise | undefined; + /** The fetch this hold suspended on has settled. */ + done: boolean; + /** That fetch failed and its error has been handed to a render. */ + failed: boolean; + timer: ReturnType | undefined; +} + export { type QueryContext, type ActivitySource, @@ -122,6 +160,11 @@ export class QueryClient { private staggerQueue = new Set>(); private staggerTimer: ReturnType | undefined = undefined; + /** Release functions of outstanding `retain()` / `prefetch()` leases. */ + private leases = new Set<() => void>(); + /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ + private suspenseHolds = new Map(); + private context!: QueryContext; private typenameRegistry = new Map[]>(); private constraintRegistry = new Map(); @@ -328,6 +371,195 @@ export class QueryClient { return queryInstance.relay; } + // ====================================================== + // Leases (retain / prefetch) + // ====================================================== + + /** + * Keeps the queries `fn` reads active (fetched, subscribed, and exempt from + * GC) until the returned `release` is called, or for `ttl` milliseconds, + * whichever comes first. Use it to keep a hidden surface's queries warm, or + * to start the queries a likely next screen needs. + * + * `fn` runs immediately with this client as the `QueryClientContext`, and + * again whenever what it reads changes (for example a Signal param). A query + * is held when `fn` reads one of its fields (`isReady`, `value`, ...), or + * when `fn` returns its promise or an array of them: + * + * ```ts + * const release = client.retain(() => [fetchQuery(GetTokens), fetchQuery(GetPrices, { ids })]); + * // later, when the surface is gone for good: + * release(); + * ``` + * + * A reader that mounts while the lease is held joins the already active + * query: no new request, and the data (if it has arrived) on its first + * render. Releasing never interrupts other readers; when the last one goes, + * the query deactivates and its `gcTime` starts as usual. `release` is + * idempotent. + */ + retain(fn: () => unknown, options?: RetainOptions): () => void { + const w = withContexts([[QueryClientContext, this]], () => watcher(() => holdReturned(fn()))); + const unsubscribe = w.addListener(noop); + + let timer: ReturnType | undefined; + let released = false; + + const release = (): void => { + if (released) return; + released = true; + clearTimeout(timer); + this.leases.delete(release); + unsubscribe(); + }; + + this.leases.add(release); + + try { + // Run now, so the queries activate (and a synchronous store hydrates + // them) before this returns, not on Signalium's next flush. + void w.value; + } catch (error) { + release(); + throw error; + } + + const ttl = options?.ttl; + if (ttl !== undefined && Number.isFinite(ttl)) { + timer = setTimeout(release, Math.max(0, ttl)); + } + + return release; + } + + /** + * Starts `QueryClass` with `params` now and keeps it active for `ttl` + * milliseconds (default {@link DEFAULT_PREFETCH_TTL}), or until the returned + * `release` is called. A fetch still in flight when `ttl` runs out is let + * finish before the lease goes; `release()` lets go at once. Meant for the moment a user commits to a navigation + * (a tap): the destination's reader, mounting within the window, reuses the + * in-flight or finished fetch instead of starting its own, and renders the + * data on its first render if it has arrived. + * + * Cached data counts: with a synchronous store a cached, fresh result is + * applied without a request. A stale one is shown and refetched, as on any + * activation. + */ + prefetch( + QueryClass: new () => T, + params?: Optionalize>>, + options?: PrefetchOptions, + ): () => void { + const def = QueryDefinition.for(QueryClass); + let relay: QueryPromise | undefined; + const release = this.retain(() => (relay = this.getQuery(def, params as QueryParams | undefined))); + + const ttl = options?.ttl ?? DEFAULT_PREFETCH_TTL; + if (Number.isFinite(ttl)) { + const timer = setTimeout( + () => { + // Expiring mid-fetch would abort a request that is about to land: + // hold until it settles. An explicit release() still lets go at once. + if (relay?.isPending) relay.then(release, release); + else release(); + }, + Math.max(0, ttl), + ); + return () => { + clearTimeout(timer); + release(); + }; + } + + return release; + } + + /** + * For `useSuspenseQuery`. Returns a promise to suspend on when the query has + * never produced a value (a cold miss), or `undefined` when it has one to + * render (in memory, or hydrated now from a synchronous store). + * + * A cold miss takes a hold that keeps the query active while the render is + * suspended, since React discards a suspended render without subscribing. + * The reader's commit releases it (`releaseSuspenseHold`); otherwise it + * releases itself `SUSPENSE_HOLD_TTL` after the fetch settles. A query whose + * last fetch failed is refetched once per hold: when that attempt fails too, + * `error` is set and the hold is dropped, so the caller can throw it. + * + * @internal + */ + suspendOnColdMiss( + def: QueryDefinition, + params: QueryParams | undefined, + ): { promise: Promise | undefined; failed?: true; error?: unknown; key: number } { + const key = queryKeyFor(def, params); + const relay = this.getQuery(def, params); + if (relay.isReady) return { promise: undefined, key }; + + let hold = this.suspenseHolds.get(key); + if (hold === undefined) { + const release = this.retain(() => this.getQuery(def, params)); + hold = { release, settled: undefined, done: false, failed: false, timer: undefined }; + this.suspenseHolds.set(key, hold); + } + + if (relay.isReady) { + // Hydrated by the activation: render it, and keep the hold until the reader commits. + this.expireSuspenseHold(key, hold); + return { promise: undefined, key }; + } + + if (!relay.isPending) { + if (hold.done) { + // The fetch this hold waited for failed. Keep the hold until the next + // task: React re-renders a throwing component once more before giving + // up, and that render must throw the same error, not refetch. A later + // mount (an error boundary reset) gets a fresh hold and a new attempt. + const error = relay.error; + if (!hold.failed) { + hold.failed = true; + clearTimeout(hold.timer); + hold.timer = setTimeout(() => { + if (this.suspenseHolds.get(key) === hold) this.releaseSuspenseHold(key); + }, 0); + } + return { promise: undefined, failed: true, error, key }; + } + // A failed or not-yet-started fetch: start it now rather than after the activation's refetch hop. + this.queryInstances.get(key)?.refetch(); + } + + if (hold.settled === undefined) { + const current = hold; + current.settled = new Promise(resolve => { + const settle = (): void => { + current.done = true; + this.expireSuspenseHold(key, current); + resolve(); + }; + relay.then(settle, settle); + }); + } + + return { promise: hold.settled, key }; + } + + /** Releases a `useSuspenseQuery` hold once its reader has committed. @internal */ + releaseSuspenseHold(key: number): void { + const hold = this.suspenseHolds.get(key); + if (hold === undefined) return; + this.suspenseHolds.delete(key); + clearTimeout(hold.timer); + hold.release(); + } + + private expireSuspenseHold(key: number, hold: SuspenseHold): void { + if (hold.timer !== undefined || this.suspenseHolds.get(key) !== hold) return; + hold.timer = setTimeout(() => { + if (this.suspenseHolds.get(key) === hold) this.releaseSuspenseHold(key); + }, SUSPENSE_HOLD_TTL); + } + /** * Gets or creates a MutationResult for the given mutation definition. * Mutations are cached by their definition ID. @@ -606,6 +838,8 @@ export class QueryClient { } destroy(): void { + for (const key of [...this.suspenseHolds.keys()]) this.releaseSuspenseHold(key); + for (const release of [...this.leases]) release(); clearTimeout(this.staggerTimer); this.staggerTimer = undefined; this.staggerQueue.clear(); @@ -627,6 +861,25 @@ export class QueryClient { export const QueryClientContext: Context = context(undefined); +const noop = (): void => {}; + +/** + * Reads the query promises `retain`'s callback returned, so the lease's + * watcher depends on them and keeps them active. `isReady` changes once per + * query, so the watcher reruns at most once for each. + */ +function holdReturned(returned: unknown): void { + if (Array.isArray(returned)) { + for (const item of returned) holdOne(item); + } else { + holdOne(returned); + } +} + +function holdOne(value: unknown): void { + if (value instanceof ReactivePromise) void value.isReady; +} + function nonNegative(value: unknown): number { return typeof value === 'number' && value > 0 ? value : 0; } diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index c5441a0..0f0cc49 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -52,6 +52,11 @@ export class QueryInstance { private wasPaused: boolean = false; private currentParams: QueryParams | undefined = undefined; private debounceTimer: ReturnType | undefined = undefined; + /** + * Bumped to schedule or cancel a zero-delay refetch, which runs on a + * microtask and so can't be cleared like a timer. + */ + private debounceGeneration: number = 0; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; /** @@ -146,8 +151,7 @@ export class QueryInstance { const deactivate = ({ isPausing = false }: DeactivateOptions = {}) => { this._isActive = false; - clearTimeout(this.debounceTimer); - this.debounceTimer = undefined; + this.cancelDebounced(); this._abortController?.abort(); this._abortController = undefined; @@ -474,17 +478,51 @@ export class QueryInstance { this.relayState.setPromise(this.runQuery()); } - private runDebounced(extraDelay: number = 0): void { + /** + * Starts a refetch after the query's `debounce` (plus `extraDelay`). Calls + * made before it starts are coalesced into one fetch. + * + * With no delay the fetch starts on a microtask rather than a timer. That + * still runs outside the reactive computation that asked for it (a relay + * update) and still coalesces every call made in the same task, but doesn't + * wait for the next macrotask. On React Native a zero timer goes through the + * native timing module and can wait up to a frame. + * + * `nextTask` keeps the zero-delay fetch on a timer. Invalidation uses it: a + * query invalidated in the same task its last watcher left is still active + * until Signalium's deactivation flush, and the timer lets that flush cancel + * the fetch rather than start and abort it. + */ + private runDebounced(extraDelay: number = 0, nextTask: boolean = false): void { if (this.relayState.isPending) return; - const debounce = this.config?.debounce ?? 0; + const delay = (this.config?.debounce ?? 0) + extraDelay; - clearTimeout(this.debounceTimer); + this.cancelDebounced(); - this.debounceTimer = setTimeout(() => { - this.debounceTimer = undefined; + if (delay > 0 || nextTask) { + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + this.runQueryImmediately(); + }, delay); + return; + } + + const generation = this.debounceGeneration; + queueMicrotask(() => { + if (generation !== this.debounceGeneration) return; + this.debounceGeneration++; + // Another path started a fetch in the meantime (refetch(), activation). + if (this.relayState.isPending && this._abortController !== undefined) return; this.runQueryImmediately(); - }, debounce + extraDelay); + }); + } + + /** Cancels a refetch scheduled by runDebounced(). */ + private cancelDebounced(): void { + this.debounceGeneration++; + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; } /** @@ -500,15 +538,19 @@ export class QueryInstance { const queuedAt = this.reactivationQueuedAt; if (this.fetchStarts !== queuedAt) return; - clearTimeout(this.debounceTimer); - this.debounceTimer = setTimeout( - () => { - this.debounceTimer = undefined; - if (this.fetchStarts !== queuedAt) return; - this.runQueryImmediately(); - }, - (this.config?.debounce ?? 0) + delay, - ); + const totalDelay = (this.config?.debounce ?? 0) + delay; + if (totalDelay === 0) { + // Within the flush task, on runDebounced()'s microtask. + this.runDebounced(); + return; + } + + this.cancelDebounced(); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + if (this.fetchStarts !== queuedAt) return; + this.runQueryImmediately(); + }, totalDelay); } /** Records that the subscription delivered data. See `lastPushAt`. */ @@ -529,7 +571,7 @@ export class QueryInstance { markStale(): void { this.updatedAt = 0; if (this._isActive && !this.isPaused) { - this.runDebounced(); + this.runDebounced(0, true); } } diff --git a/packages/fetchium/src/__tests__/read-path-latency.test.ts b/packages/fetchium/src/__tests__/read-path-latency.test.ts new file mode 100644 index 0000000..20b6878 --- /dev/null +++ b/packages/fetchium/src/__tests__/read-path-latency.test.ts @@ -0,0 +1,208 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { describe, it, expect, afterEach } from 'vitest'; +import { signal, watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { t } from '../typeDefs.js'; +import { fetchQuery } from '../query.js'; +import { createMockFetch, sleep } from './utils.js'; + +/** + * Read-path latency, counted in macrotask turns. Each turn drains the + * microtask queue, checks the condition, then yields one `setTimeout(0)`. + * Turn 0 means the condition held before any timer ran. + * + * On React Native a zero timer goes through the native timing module and can + * wait up to a frame, so a turn saved here is up to ~16 ms on device. The + * logged `ms` values are Node timers (about 1 ms per turn). + */ + +async function flushMicrotasks(count = 30): Promise { + for (let i = 0; i < count; i++) await Promise.resolve(); +} + +async function turnsUntil(cond: () => boolean, max = 10): Promise { + for (let turn = 0; turn <= max; turn++) { + await flushMicrotasks(); + if (cond()) return turn; + await new Promise(resolve => setTimeout(resolve, 0)); + } + return Infinity; +} + +class GetItem extends RESTQuery { + path = '/item'; + result = { n: t.number }; + config = { staleTime: 0 }; +} + +class GetFreshItem extends RESTQuery { + path = '/fresh'; + result = { n: t.number }; + config = { staleTime: 60_000 }; +} + +class GetUser extends RESTQuery { + params = { id: t.number }; + path = `/users/${this.params.id}`; + result = { n: t.number }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +function setup() { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ n: ++n })); + mockFetch.get('/fresh', () => ({ n: ++n })); + mockFetch.get('/users/[id]', () => ({ n: ++n })); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return { client, mockFetch }; +} + +/** Watches `read` and runs it synchronously, so activation happens now. */ +function watchNow(client: QueryClient, read: () => T): { w: { value: T }; unsub: () => void } { + const w = withContexts([[QueryClientContext, client]], () => watcher(read)); + const unsub = w.addListener(() => {}); + w.value; + return { w: w as { value: T }, unsub }; +} + +async function loadThenDeactivate(client: QueryClient, Q: new () => RESTQuery): Promise { + const { unsub } = watchNow(client, () => fetchQuery(Q as any).value); + await sleep(10); + unsub(); + // Let Signalium's deactivation flush run. + await sleep(10); +} + +describe('read-path latency (macrotask turns)', () => { + it('cold miss: the first fetch starts without a macrotask', async () => { + const { client, mockFetch } = setup(); + const started = performance.now(); + const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); + const turns = await turnsUntil(() => mockFetch.calls.length === 1); + console.log(`[latency] cold miss -> fetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`); + expect(turns).toBe(0); + unsub(); + }); + + it('in-memory cache hit: the value is readable in the activating read', async () => { + const { client, mockFetch } = setup(); + await loadThenDeactivate(client, GetFreshItem); + expect(mockFetch.calls).toHaveLength(1); + + const { w, unsub } = watchNow(client, () => fetchQuery(GetFreshItem).value); + // Same synchronous read that activated the query: no microtask, no timer. + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('stale reactivation: the refetch starts without a macrotask', async () => { + const { client, mockFetch } = setup(); + await loadThenDeactivate(client, GetItem); + expect(mockFetch.calls).toHaveLength(1); + + const started = performance.now(); + const { w, unsub } = watchNow(client, () => fetchQuery(GetItem).value); + // The cached value shows immediately while the refetch runs. + expect(w.value).toMatchObject({ n: 1 }); + const turns = await turnsUntil(() => mockFetch.calls.length === 2); + console.log( + `[latency] stale reactivation -> refetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, + ); + expect(turns).toBe(0); + unsub(); + }); + + it('staggered reactivation: the first refetch starts in the stagger flush', async () => { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ n: ++n })); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + reactivationStaggerMs: 300, + }); + clients.push(client); + await loadThenDeactivate(client, GetItem); + + const started = performance.now(); + const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); + const turns = await turnsUntil(() => mockFetch.calls.length === 2); + console.log( + `[latency] staggered reactivation -> first refetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, + ); + // One timer for the stagger flush, which groups the task's reactivations. + expect(turns).toBe(1); + unsub(); + }); + + it('Signal param change: the new fetch starts in the flush that applies it', async () => { + const { client, mockFetch } = setup(); + const id = signal(1); + const { w, unsub } = watchNow(client, () => fetchQuery(GetUser, { id }).value); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + const started = performance.now(); + id.value = 2; + w.value; + const turns = await turnsUntil(() => mockFetch.calls.length === 2); + console.log( + `[latency] param change -> fetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, + ); + // A watched relay reruns its update in Signalium's flush (one timer); the + // fetch then starts on a microtask of that flush rather than another timer. + expect(turns).toBe(1); + expect(mockFetch.calls[1].url).toContain('/users/2'); + unsub(); + }); + + it('coalesces zero-delay refetches requested in the same task', async () => { + const { client, mockFetch } = setup(); + const id = signal(1); + const { w, unsub } = watchNow(client, () => fetchQuery(GetUser, { id }).value); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + id.value = 2; + w.value; + id.value = 3; + w.value; + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + expect(mockFetch.calls[1].url).toContain('/users/3'); + unsub(); + }); + + it('a deactivation in the same task aborts the zero-delay refetch', async () => { + const { client, mockFetch } = setup(); + await loadThenDeactivate(client, GetItem); + mockFetch.reset(); + mockFetch.get('/item', { n: 2 }, { delay: 30 }); + const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); + unsub(); + // Before the response (30 ms). + await sleep(10); + // Signalium deactivates on its next flush (a timer), after the microtask + // that starts the refetch, so the refetch starts and deactivation cancels + // it. A mount and unmount within one task is the cost of not waiting a + // timer on every reactivation. + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(30); + }); +}); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts new file mode 100644 index 0000000..0f3f8c2 --- /dev/null +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -0,0 +1,323 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { describe, it, expect, afterEach } from 'vitest'; +import { signal, watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext, DEFAULT_PREFETCH_TTL } from '../QueryClient.js'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { t } from '../typeDefs.js'; +import { fetchQuery, QueryDefinition } from '../query.js'; +import type { MutationEvent } from '../types.js'; +import { createMockFetch, sleep } from './utils.js'; + +/** + * client.retain() and client.prefetch(): app-level leases that keep queries + * active without a reader, so a reader that mounts inside the lease joins the + * running query instead of starting its own. + */ + +async function flushMicrotasks(count = 30): Promise { + for (let i = 0; i < count; i++) await Promise.resolve(); +} + +class GetItem extends RESTQuery { + path = '/item'; + result = { n: t.number }; + config = { staleTime: 0 }; +} + +class GetOther extends RESTQuery { + path = '/other'; + result = { n: t.number }; + config = { staleTime: 0 }; +} + +class GetUser extends RESTQuery { + params = { id: t.number }; + path = `/users/${this.params.id}`; + result = { n: t.number }; +} + +class GetFreshUser extends RESTQuery { + params = { id: t.number }; + path = `/users/${this.params.id}`; + result = { n: t.number }; + config = { staleTime: 60_000 }; +} + +let subscribed = 0; +let unsubscribed = 0; + +class GetStreamed extends RESTQuery { + path = '/streamed'; + result = { n: t.number }; + config = { + subscribe: (_onEvent: (event: MutationEvent) => void) => { + subscribed++; + return () => { + unsubscribed++; + }; + }, + }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; + subscribed = 0; + unsubscribed = 0; +}); + +function setup(kv: MemoryPersistentStore = new MemoryPersistentStore()) { + const mockFetch = createMockFetch(); + let n = 0; + for (const path of ['/item', '/other', '/users/[id]', '/streamed']) { + mockFetch.get(path, () => ({ n: ++n })); + } + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return { client, mockFetch, kv }; +} + +/** Mounts a reader: watches `read` and runs it now, like a React render. */ +function mountReader(client: QueryClient, read: () => T): { w: { value: T }; unsub: () => void } { + const w = withContexts([[QueryClientContext, client]], () => watcher(read)); + const unsub = w.addListener(() => {}); + w.value; + return { w: w as { value: T }, unsub }; +} + +describe('client.prefetch()', () => { + it('starts the fetch before returning control to a timer', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(0); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('a reader mounting after the data arrives renders it on its first read, with no second request', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetItem, undefined, { ttl: 1_000 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + // staleTime 0, yet the query is still active, so mounting is not a reactivation. + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('a reader mounting while the fetch is in flight joins it', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 7 }, { delay: 30 }); + client.prefetch(GetItem, undefined, { ttl: 1_000 }); + await sleep(5); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toBeUndefined(); + await sleep(50); + expect(w.value).toMatchObject({ n: 7 }); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('passes params, and keys on them', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetUser, { id: 4 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].url).toContain('/users/4'); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetUser, { id: 4 }).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('serves fresh cached data from a synchronous store without a request', async () => { + const kv = new MemoryPersistentStore(); + const seed = setup(kv); + seed.client.prefetch(GetFreshUser, { id: 1 }); + await sleep(10); + expect(seed.mockFetch.calls).toHaveLength(1); + seed.client.destroy(); + + const { client, mockFetch } = setup(kv); + client.prefetch(GetFreshUser, { id: 1 }); + // Hydrated by the activation inside prefetch(), before it returned. + expect(client.getQuery(QueryDefinition.for(GetFreshUser), { id: 1 }).value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(0); + }); + + it('expires after ttl: the query deactivates, and a later mount is a reactivation', async () => { + const { client, mockFetch } = setup(); + client.prefetch(GetItem, undefined, { ttl: 20 }); + await sleep(60); + expect(mockFetch.calls).toHaveLength(1); + + // staleTime 0 and no grace: reactivating refetches. + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + unsub(); + }); + + it('a ttl that runs out mid-fetch waits for the fetch instead of aborting it', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/streamed', { n: 9 }, { delay: 40 }); + client.prefetch(GetStreamed, undefined, { ttl: 10 }); + await sleep(25); + // Past the ttl, before the response (40 ms): still held, request not cancelled. + expect(unsubscribed).toBe(0); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + await sleep(60); + expect(mockFetch.calls).toHaveLength(1); + // The fetch landed (an aborted one applies nothing), then the lease went. + const relay = client.getQuery(QueryDefinition.for(GetStreamed), undefined); + expect(relay.value).toMatchObject({ n: 9 }); + expect(unsubscribed).toBe(1); + }); + + it('defaults ttl to DEFAULT_PREFETCH_TTL', () => { + expect(DEFAULT_PREFETCH_TTL).toBe(10_000); + }); + + it('release() while a reader is mounted leaves the reader running', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 3 }, { delay: 30 }); + const release = client.prefetch(GetItem); + await sleep(5); + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + release(); + release(); + await sleep(50); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + expect(w.value).toMatchObject({ n: 3 }); + unsub(); + }); + + it('release() with no reader aborts an in-flight fetch', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 3 }, { delay: 30 }); + const release = client.prefetch(GetItem); + await sleep(5); + release(); + // Still before the response (30 ms): the request has been cancelled. + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(40); + }); +}); + +describe('client.retain()', () => { + it('holds the query promises the callback returns', async () => { + const { client, mockFetch } = setup(); + const release = client.retain(() => [fetchQuery(GetItem), fetchQuery(GetOther)]); + await sleep(10); + expect(mockFetch.calls.map(c => new URL(c.url).pathname).sort()).toEqual(['/item', '/other']); + + const { w, unsub } = mountReader(client, () => [fetchQuery(GetItem).value, fetchQuery(GetOther).value]); + expect(w.value).toEqual([expect.objectContaining({ n: expect.any(Number) }), expect.any(Object)]); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + unsub(); + release(); + }); + + it('holds queries whose fields the callback reads', async () => { + const { client, mockFetch } = setup(); + const release = client.retain(() => { + fetchQuery(GetItem).isReady; + }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toMatchObject({ n: 1 }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + release(); + }); + + it('keeps a subscription running until released', async () => { + const { client } = setup(); + const release = client.retain(() => fetchQuery(GetStreamed)); + await sleep(10); + expect(subscribed).toBe(1); + + // A reader coming and going doesn't restart it. + const reader = mountReader(client, () => fetchQuery(GetStreamed).value); + reader.unsub(); + await sleep(10); + expect(subscribed).toBe(1); + expect(unsubscribed).toBe(0); + + release(); + await sleep(10); + expect(unsubscribed).toBe(1); + }); + + it('follows Signal params', async () => { + const { client, mockFetch } = setup(); + const id = signal(1); + const release = client.retain(() => fetchQuery(GetUser, { id })); + await sleep(10); + id.value = 2; + await sleep(20); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/users/1', '/users/2']); + release(); + }); + + it('without ttl, holds until released', async () => { + const { client } = setup(); + const release = client.retain(() => fetchQuery(GetStreamed)); + await sleep(50); + expect(unsubscribed).toBe(0); + release(); + await sleep(10); + expect(unsubscribed).toBe(1); + }); + + it('with ttl, releases itself', async () => { + const { client } = setup(); + client.retain(() => fetchQuery(GetStreamed), { ttl: 20 }); + await sleep(10); + expect(subscribed).toBe(1); + await sleep(40); + expect(unsubscribed).toBe(1); + }); + + it('destroy() releases outstanding leases', async () => { + const { client } = setup(); + client.retain(() => fetchQuery(GetStreamed)); + await sleep(10); + client.destroy(); + await sleep(10); + expect(unsubscribed).toBe(1); + }); + + it('releases and rethrows when the callback throws', () => { + const { client } = setup(); + expect(() => + client.retain(() => { + throw new Error('boom'); + }), + ).toThrow('boom'); + }); +}); diff --git a/packages/fetchium/src/index.ts b/packages/fetchium/src/index.ts index 350503c..92e665b 100644 --- a/packages/fetchium/src/index.ts +++ b/packages/fetchium/src/index.ts @@ -1,7 +1,7 @@ export * from './types.js'; -export { QueryClient, QueryClientContext } from './QueryClient.js'; -export type { QueryContext, QueryClientConfig, ActivitySource } from './QueryClient.js'; +export { QueryClient, QueryClientContext, DEFAULT_PREFETCH_TTL } from './QueryClient.js'; +export type { QueryContext, QueryClientConfig, ActivitySource, RetainOptions, PrefetchOptions } from './QueryClient.js'; export { QueryAdapter } from './QueryAdapter.js'; export type { IQueryClientForAdapter } from './QueryAdapter.js'; export { t, registerFormat } from './typeDefs.js'; diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx new file mode 100644 index 0000000..cee950a --- /dev/null +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -0,0 +1,328 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider } from 'signalium/react'; +import React, { Suspense } from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { RESTQuery } from '../../rest/index.js'; +import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; +import { createMockFetch, sleep } from '../../__tests__/utils.js'; +import type { MutationEvent } from '../../types.js'; +import { useQuery } from '../use-query.js'; +import { useSuspenseQuery } from '../use-suspense-query.js'; + +class GetItem extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { staleTime: 0 }; +} + +class GetFreshItem extends RESTQuery { + path = '/fresh'; + result = { name: t.string }; + config = { staleTime: 60_000 }; +} + +let subscribed = 0; +let unsubscribed = 0; + +class GetStreamed extends RESTQuery { + path = '/streamed'; + result = { name: t.string }; + config = { + subscribe: (_onEvent: (event: MutationEvent) => void) => { + subscribed++; + return () => { + unsubscribed++; + }; + }, + }; +} + +let clients: QueryClient[] = []; + +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; + subscribed = 0; + unsubscribed = 0; +}); + +function makeClient(mockFetch: ReturnType, kv = new MemoryPersistentStore()): QueryClient { + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + return client; +} + +class Boundary extends React.Component< + { children: React.ReactNode; onError: (e: unknown) => void }, + { error: unknown } +> { + state = { error: undefined as unknown }; + static getDerivedStateFromError(error: unknown) { + return { error }; + } + componentDidCatch(error: unknown) { + this.props.onError(error); + } + render() { + return this.state.error !== undefined ?
Boundary caught
: this.props.children; + } +} + +describe('client.prefetch() with React', () => { + it('a screen mounting after a tap-time prefetch renders the data on its first render, with one request', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'prefetched' }, { delay: 20 }); + const client = makeClient(mockFetch); + + // The tap. + client.prefetch(GetItem, undefined, { ttl: 5_000 }); + // The navigation animation. + await sleep(60); + + const renders: Array = []; + function Item(): React.ReactNode { + const item = useQuery(GetItem); + const label = item.isReady ? item.value.name : undefined; + renders.push(label); + return
{label ?? 'Loading...'}
; + } + + const { getByText } = render( + + + , + ); + + await expect.element(getByText('prefetched')).toBeInTheDocument(); + await sleep(50); + expect(renders[0]).toBe('prefetched'); + expect(renders).not.toContain(undefined); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('a screen mounting before the prefetch lands joins the in-flight request', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'joined' }, { delay: 60 }); + const client = makeClient(mockFetch); + + client.prefetch(GetItem); + await sleep(10); + + function Item(): React.ReactNode { + const item = useQuery(GetItem); + return
{item.isReady ? item.value.name : 'Loading...'}
; + } + + const { getByText } = render( + + + , + ); + + await expect.element(getByText('joined')).toBeInTheDocument(); + expect(mockFetch.calls).toHaveLength(1); + }); +}); + +describe('useSuspenseQuery', () => { + it('suspends on a cold miss, then renders the data, with one request', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { name: 'loaded' }, { delay: 20 }); + const client = makeClient(mockFetch); + + const renders: string[] = []; + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetItem); + renders.push(item.value.name); + return
{item.value.name}
; + } + + const { getByText } = render( + + Suspended}> + + + , + ); + + await expect.element(getByText('Suspended')).toBeInTheDocument(); + await expect.element(getByText('loaded')).toBeInTheDocument(); + await sleep(30); + expect(renders.every(r => r === 'loaded')).toBe(true); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('does not suspend when the value is in memory, and does not suspend for the refetch', async () => { + const mockFetch = createMockFetch(); + let n = 0; + mockFetch.get('/item', () => ({ name: `v${++n}` }), { delay: 20 }); + const client = makeClient(mockFetch); + + // Warm it, then let it go inactive. + const release = client.prefetch(GetItem); + await sleep(40); + release(); + await sleep(20); + expect(mockFetch.calls).toHaveLength(1); + + let fallbacks = 0; + function Fallback(): React.ReactNode { + fallbacks++; + return
Suspended
; + } + const renders: string[] = []; + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetItem); + renders.push(item.value.name); + return
{item.value.name}
; + } + + const { getByText } = render( + + }> + + + , + ); + + // staleTime 0: the reactivation refetches, and the refetch must not suspend. + await expect.element(getByText('v2')).toBeInTheDocument(); + expect(renders[0]).toBe('v1'); + expect(fallbacks).toBe(0); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('does not suspend when a synchronous store has the value', async () => { + const kv = new MemoryPersistentStore(); + const seedFetch = createMockFetch(); + seedFetch.get('/fresh', { name: 'persisted' }); + const seed = makeClient(seedFetch, kv); + seed.prefetch(GetFreshItem); + await sleep(20); + seed.destroy(); + + const mockFetch = createMockFetch(); + const client = makeClient(mockFetch, kv); + + let fallbacks = 0; + function Fallback(): React.ReactNode { + fallbacks++; + return
Suspended
; + } + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetFreshItem); + return
{item.value.name}
; + } + + const { getByText } = render( + + }> + + + , + ); + + await expect.element(getByText('persisted')).toBeInTheDocument(); + expect(fallbacks).toBe(0); + expect(mockFetch.calls).toHaveLength(0); + }); + + it('throws a failed cold fetch to the error boundary', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', null, { status: 500 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + const errors: unknown[] = []; + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const { getByText } = render( + + errors.push(e)}> + Suspended}> + + + + , + ); + + await expect.element(getByText('Boundary caught')).toBeInTheDocument(); + expect(errors.length).toBeGreaterThan(0); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('retries a failed cold fetch when the error boundary resets', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const tree = (attempt: number) => ( + + {}}> + Suspended}> + + + + + ); + + const screen = render(tree(0)); + await expect.element(screen.getByText('Boundary caught')).toBeInTheDocument(); + await sleep(10); + + mockFetch.get('/item', { name: 'recovered' }); + screen.rerender(tree(1)); + await expect.element(screen.getByText('recovered')).toBeInTheDocument(); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('hands the query to the reader on commit, so unmounting deactivates it', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/streamed', { name: 'live' }, { delay: 10 }); + const client = makeClient(mockFetch); + + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetStreamed); + return
{item.value.name}
; + } + + const screen = render( + + Suspended}> + + + , + ); + + await expect.element(screen.getByText('live')).toBeInTheDocument(); + expect(subscribed).toBe(1); + screen.unmount(); + await sleep(30); + expect(unsubscribed).toBe(1); + }); +}); diff --git a/packages/fetchium/src/react/index.ts b/packages/fetchium/src/react/index.ts index ee11804..a01c17b 100644 --- a/packages/fetchium/src/react/index.ts +++ b/packages/fetchium/src/react/index.ts @@ -1 +1,2 @@ export { useQuery } from './use-query.js'; +export { useSuspenseQuery } from './use-suspense-query.js'; diff --git a/packages/fetchium/src/react/use-suspense-query.ts b/packages/fetchium/src/react/use-suspense-query.ts new file mode 100644 index 0000000..9ce80d9 --- /dev/null +++ b/packages/fetchium/src/react/use-suspense-query.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import type { ReadyReactivePromise } from 'signalium'; +import { useContext } from 'signalium/react'; +import { ExtractType, QueryResult } from '../types.js'; +import { Query, QueryDefinition } from '../query.js'; +import { QueryClientContext, type QueryParams } from '../QueryClient.js'; +import { HasRequiredKeys, Optionalize, Signalize } from '../type-utils.js'; +import { useQuery } from './use-query.js'; + +/** + * `useQuery` for components rendered under a React `` boundary. + * + * Suspends only on a cold miss: the query has never produced a value, neither + * in memory nor (with a synchronous store) in the persisted cache. Anything + * else renders immediately, including a stale value whose refetch is in + * flight; refetches never suspend. A cold fetch that fails is thrown to the + * nearest error boundary; resetting the boundary retries it. + * + * Changing params to ones with no value yet is a cold miss, so it suspends. + * Wrap the change in `startTransition` to keep showing the previous result. + * + * Call it from a plain function component. Like `useQuery`, it can't run + * inside a Signalium `component()` (that is a reactive context), where + * `fetchQuery` is the way to read queries. + */ +export function useSuspenseQuery( + QueryClass: new () => T, + ...args: HasRequiredKeys> extends true + ? [params: Optionalize>>] + : [params?: Optionalize>> | undefined] +): ReadyReactivePromise> { + const client = useContext(QueryClientContext); + + if (client === undefined) { + throw new Error('QueryClient not found'); + } + + // Decide before useQuery: a render that suspends is discarded without a + // commit, so a subscription taken by it would never be cleaned up. The + // client holds the query active while suspended instead. + const { promise, failed, error, key } = client.suspendOnColdMiss( + QueryDefinition.for(QueryClass), + args[0] as QueryParams | undefined, + ); + + if (failed) { + throw error; + } + + if (promise !== undefined) { + throw promise; + } + + const result = useQuery(QueryClass, ...args); + + // useQuery's subscription now keeps the query active. + useEffect(() => { + client.releaseSuspenseHold(key); + }, [client, key]); + + return result as ReadyReactivePromise>; +} From 1e0b42ba6508377c26ecb8efa9bd9e54709b92d7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 03:02:59 -0400 Subject: [PATCH 022/109] perf(fetchium): skip live value notify when a reducer returns the current value A stream reconnect re-delivers every entity as an update event. Entities, writes and live-array membership were already quiet for identical data, but every update event still ran the live value's onUpdate reducer and notified unconditionally, even when the reducer returned the value it was given. Only notify when the reduced value changed, as LiveValueInstance.reset already does. Adds tests pinning identical re-delivery as a no-op across query results, mutation events, topic events and nested entities (no entity notify, no version bump, no write, no live collection notify, no React re-render), and that a real change still notifies exactly once. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/LiveCollection.ts | 6 +- .../__tests__/identical-redelivery.test.ts | 350 ++++++++++++++++++ .../identical-redelivery-react.test.tsx | 148 ++++++++ 3 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 packages/fetchium/src/__tests__/identical-redelivery.test.ts create mode 100644 packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 9262243..34ae819 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -435,6 +435,7 @@ export class LiveValueInstance { entityData: Record, eventType: 'create' | 'update' | 'delete', ): void { + const prev = this._value; switch (eventType) { case 'create': if (this._createdKeys.has(entityKey)) return; @@ -450,7 +451,10 @@ export class LiveValueInstance { this._value = this._onDelete(this._value, entity ?? entityData); break; } - this._notifier.notify(); + // Update events route here even when they changed nothing (a stream + // re-delivering current data). A reducer that returns the current value is + // a no-op, as in `reset`. + if (this._value !== prev) this._notifier.notify(); } getValue(): unknown { diff --git a/packages/fetchium/src/__tests__/identical-redelivery.test.ts b/packages/fetchium/src/__tests__/identical-redelivery.test.ts new file mode 100644 index 0000000..4b1a32b --- /dev/null +++ b/packages/fetchium/src/__tests__/identical-redelivery.test.ts @@ -0,0 +1,350 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { EntityInstance } from '../EntityInstance.js'; +import { LiveCollectionBinding } from '../LiveCollection.js'; +import { QueryClient } from '../QueryClient.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { TopicQuery } from '../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; +import type { MutationEvent, QueryPromise } from '../types.js'; +import { testWithClient, setupTestClient } from './utils.js'; + +/** + * Identical Re-delivery + * + * A stream reconnect re-snapshots every topic, so every entity on screen is + * re-delivered with the data the store already holds — as a query result, as a + * mutation event, or as a topic event. None of those may notify an entity, bump + * its version, write it, or notify a live collection. A real change still + * notifies exactly once. + */ + +/** Records `typename:id` for every entity notify. */ +function recordNotifies() { + const notified: string[] = []; + const original = EntityInstance.prototype.notify; + const spy = vi.spyOn(EntityInstance.prototype, 'notify').mockImplementation(function (this: EntityInstance) { + notified.push(`${this.typename}:${this.id}`); + original.call(this); + }); + return { notified, restore: () => spy.mockRestore() }; +} + +function savesOf(spy: ReturnType, typename: string) { + return spy.mock.calls.filter(([, value]) => (value as Record)?.__typename === typename); +} + +class Owner extends Entity { + __typename = t.typename('Owner'); + id = t.id; + name = t.string; +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + walletId = t.string; + symbol = t.string; + price = t.number; + issued = t.format('date-time'); + metadata = t.object({ logo: t.string, tags: t.array(t.string) }); + owner = t.entity(Owner); +} + +class Wallet extends Entity { + __typename = t.typename('Wallet'); + id = t.id; + tokens = t.liveArray(Token, { constraints: { walletId: (this as unknown as { id: string }).id } }); + tokenCount = t.liveValue(t.number, Token, { + constraints: { walletId: (this as unknown as { id: string }).id }, + onCreate: (v: number) => v + 1, + onUpdate: (v: number) => v, + onDelete: (v: number) => v - 1, + }); +} + +function token(i: number, overrides: { price?: number; owner?: string } = {}) { + return { + __typename: 'Token', + id: `tok-${i}`, + walletId: 'w-1', + symbol: `SYM${i}`, + price: overrides.price ?? i, + issued: '2026-01-01T00:00:00.000Z', + metadata: { logo: `logo-${i}.png`, tags: ['defi', 'l1'] }, + owner: { __typename: 'Owner', id: 'o-1', name: overrides.owner ?? 'Ann' }, + }; +} + +function wallet(count: number, override?: (i: number) => { price?: number; owner?: string }) { + return { + wallet: { + __typename: 'Wallet', + id: 'w-1', + tokenCount: count, + tokens: Array.from({ length: count }, (_, i) => token(i, override?.(i))), + }, + }; +} + +const tokenKey = (i: number) => hashValue(['Token', `tok-${i}`]); +const ownerKey = hashValue(['Owner', 'o-1']); +const walletKey = hashValue(['Wallet', 'w-1']); + +function versions(client: QueryClient, keys: number[]) { + return keys.map(k => client.entityMap.getEntity(k)!.version); +} + +/** Spies on the notifiers of a wallet's live array and live value. */ +function liveNotifies(client: QueryClient) { + const data = client.entityMap.getEntity(walletKey)!.data; + const tokens = data.tokens as LiveCollectionBinding; + const count = data.tokenCount as LiveCollectionBinding; + return { + tokens: vi.spyOn((tokens.instance as unknown as { _notifier: { notify(): void } })._notifier, 'notify'), + count: vi.spyOn((count.instance as unknown as { _notifier: { notify(): void } })._notifier, 'notify'), + }; +} + +// ====================================================== +// Query results +// ====================================================== + +class GetWallet extends RESTQuery { + path = '/wallet'; + result = { wallet: t.entity(Wallet) }; +} + +describe('Identical re-delivery', () => { + const getClient = setupTestClient(); + let recorder: ReturnType | undefined; + + afterEach(() => { + recorder?.restore(); + recorder = undefined; + vi.restoreAllMocks(); + }); + + async function loadWallet(count: number) { + const tc = getClient(); + tc.mockFetch.get('/wallet', wallet(count)); + let query!: QueryPromise; + await testWithClient(tc.client, async () => { + query = fetchQuery(GetWallet); + await query; + }); + return { ...tc, query }; + } + + describe('(a) query result', () => { + it('is a no-op when a refetch re-delivers identical data', async () => { + const { client, mockFetch, store, query } = await loadWallet(3); + const keys = [walletKey, ownerKey, tokenKey(0), tokenKey(1), tokenKey(2)]; + const before = versions(client, keys); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + await testWithClient(client, async () => { + mockFetch.get('/wallet', wallet(3)); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + expect(live.count).not.toHaveBeenCalled(); + }); + + it('(d) notifies only the changed parent when its nested entity is unchanged', async () => { + const { client, mockFetch, store, query } = await loadWallet(2); + const [ownerBefore, tok0Before, tok1Before] = versions(client, [ownerKey, tokenKey(0), tokenKey(1)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + await testWithClient(client, async () => { + mockFetch.get( + '/wallet', + wallet(2, i => (i === 1 ? { price: 99 } : {})), + ); + (query.value as unknown as { __refetch(): void }).__refetch(); + await query; + }); + + expect(recorder.notified).toEqual(['Token:tok-1']); + expect(versions(client, [ownerKey, tokenKey(0), tokenKey(1)])).toEqual([ownerBefore, tok0Before, tok1Before + 1]); + expect(savesOf(saveEntity, 'Token')).toHaveLength(1); + expect(savesOf(saveEntity, 'Owner')).toHaveLength(0); + expect(savesOf(saveEntity, 'Wallet')).toHaveLength(0); + expect(live.tokens).not.toHaveBeenCalled(); + expect(live.count).not.toHaveBeenCalled(); + }); + }); + + // ====================================================== + // Mutation events + // ====================================================== + + describe('(b) mutation event', () => { + it('is a no-op for a full update carrying identical data, nested values included', async () => { + const { client, store } = await loadWallet(2); + const keys = [walletKey, ownerKey, tokenKey(0), tokenKey(1)]; + const before = versions(client, keys); + const metadata = client.entityMap.getEntity(tokenKey(0))!.data.metadata; + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: token(0) }); + client.applyMutationEvent({ type: 'update', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + expect(live.count).not.toHaveBeenCalled(); + + // A `create` for an entity the store already holds is the same no-op for + // the entity and the live array. (The live value's `onCreate` reducer + // still runs: it is told about a create, not about a change.) + client.applyMutationEvent({ type: 'create', typename: 'Token', data: token(0) }); + client.applyMutationEvent({ type: 'create', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + // The unchanged nested object keeps its identity. + expect(client.entityMap.getEntity(tokenKey(0))!.data.metadata).toBe(metadata); + }); + + it('notifies exactly once for a real field change', async () => { + const { client, store } = await loadWallet(2); + const [ownerBefore, tokBefore] = versions(client, [ownerKey, tokenKey(0)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + recorder = recordNotifies(); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: token(0, { price: 42 }) }); + + expect(recorder.notified).toEqual(['Token:tok-0']); + expect(versions(client, [ownerKey, tokenKey(0)])).toEqual([ownerBefore, tokBefore + 1]); + expect(savesOf(saveEntity, 'Token')).toHaveLength(1); + expect(savesOf(saveEntity, 'Owner')).toHaveLength(0); + expect(client.entityMap.getEntity(tokenKey(0))!.data.price).toBe(42); + }); + + it('(d) notifies only the nested entity that changed inside an unchanged parent', async () => { + const { client, store } = await loadWallet(2); + const [ownerBefore, tokBefore] = versions(client, [ownerKey, tokenKey(0)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + recorder = recordNotifies(); + + client.applyMutationEvent({ type: 'update', typename: 'Token', data: token(0, { owner: 'Bea' }) }); + + expect(recorder.notified).toEqual(['Owner:o-1']); + expect(versions(client, [ownerKey, tokenKey(0)])).toEqual([ownerBefore + 1, tokBefore]); + expect(savesOf(saveEntity, 'Owner')).toHaveLength(1); + expect(savesOf(saveEntity, 'Token')).toHaveLength(0); + }); + }); +}); + +// ====================================================== +// Topic events +// ====================================================== + +class MockTopicAdapter extends TopicQueryAdapter { + snapshots = new Map(); + + subscribe(topic: string): void { + this.fulfillTopic(topic, this.snapshots.get(topic)); + } + + unsubscribe(topic: string): void { + this.clearTopic(topic); + } + + emit(event: MutationEvent): void { + this.sendMutationEvent(event); + } +} + +class WalletTopic extends TopicQuery { + static override adapter = MockTopicAdapter; + topic = 'wallet:w-1'; + result = { wallet: t.entity(Wallet) }; +} + +describe('(c) topic events', () => { + let client: QueryClient; + let adapter: MockTopicAdapter; + let store: SyncQueryStore; + let recorder: ReturnType | undefined; + + afterEach(() => { + recorder?.restore(); + recorder = undefined; + vi.restoreAllMocks(); + client?.destroy(); + }); + + async function loadTopic(count: number) { + store = new SyncQueryStore(new MemoryPersistentStore()); + adapter = new MockTopicAdapter(); + adapter.snapshots.set('wallet:w-1', wallet(count)); + client = new QueryClient({ store, adapters: [adapter] } as never); + await testWithClient(client, async () => { + const query = fetchQuery(WalletTopic); + await query; + expect((query.value as unknown as { wallet: { tokens: unknown[] } }).wallet.tokens).toHaveLength(count); + }); + } + + it('is a no-op when a reconnect re-snapshots every entity as topic events', async () => { + await loadTopic(3); + const keys = [walletKey, ownerKey, tokenKey(0), tokenKey(1), tokenKey(2)]; + const before = versions(client, keys); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + for (let i = 0; i < 3; i++) adapter.emit({ type: 'update', typename: 'Token', data: token(i) }); + adapter.emit({ type: 'update', typename: 'Wallet', data: { id: 'w-1' } }); + + expect(recorder.notified).toEqual([]); + expect(versions(client, keys)).toEqual(before); + expect(saveEntity).not.toHaveBeenCalled(); + expect(live.tokens).not.toHaveBeenCalled(); + expect(live.count).not.toHaveBeenCalled(); + }); + + it('notifies exactly once for a topic event that changes a field', async () => { + await loadTopic(2); + const [walletBefore, tokBefore] = versions(client, [walletKey, tokenKey(1)]); + + const saveEntity = vi.spyOn(store, 'saveEntity'); + const live = liveNotifies(client); + recorder = recordNotifies(); + + adapter.emit({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 7 } }); + + expect(recorder.notified).toEqual(['Token:tok-1']); + expect(versions(client, [walletKey, tokenKey(1)])).toEqual([walletBefore, tokBefore + 1]); + expect(savesOf(saveEntity, 'Token')).toHaveLength(1); + expect(live.tokens).not.toHaveBeenCalled(); + expect(client.entityMap.getEntity(tokenKey(1))!.data.price).toBe(7); + }); +}); diff --git a/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx b/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx new file mode 100644 index 0000000..46a4efe --- /dev/null +++ b/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider, component } from 'signalium/react'; +import React from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { Entity } from '../../proxy.js'; +import { fetchQuery } from '../../query.js'; +import { sleep } from '../../__tests__/utils.js'; +import { TopicQuery } from '../../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../../topic/TopicQueryAdapter.js'; +import type { MutationEvent } from '../../types.js'; +import { createRenderCounter } from './utils.js'; + +/** + * A stream reconnect re-delivers every on-screen entity with the data it + * already holds. Readers of those entities must not re-render for it; a real + * change re-renders them once. + */ + +class MockTopicAdapter extends TopicQueryAdapter { + snapshots = new Map(); + + subscribe(topic: string): void { + this.fulfillTopic(topic, this.snapshots.get(topic)); + } + + unsubscribe(topic: string): void { + this.clearTopic(topic); + } + + emit(event: MutationEvent): void { + this.sendMutationEvent(event); + } +} + +class Owner extends Entity { + __typename = t.typename('Owner'); + id = t.id; + name = t.string; +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + walletId = t.string; + price = t.number; + metadata = t.object({ logo: t.string, tags: t.array(t.string) }); + owner = t.entity(Owner); +} + +class Wallet extends Entity { + __typename = t.typename('Wallet'); + id = t.id; + tokens = t.liveArray(Token, { constraints: { walletId: (this as unknown as { id: string }).id } }); +} + +class WalletTopic extends TopicQuery { + static override adapter = MockTopicAdapter; + topic = 'wallet:w-1'; + result = { wallet: t.entity(Wallet) }; +} + +function token(i: number, price = i) { + return { + __typename: 'Token', + id: `tok-${i}`, + walletId: 'w-1', + price, + metadata: { logo: `logo-${i}.png`, tags: ['defi'] }, + owner: { __typename: 'Owner', id: 'o-1', name: 'Ann' }, + }; +} + +type TokenProxy = { id: string; price: number; metadata: { logo: string }; owner: { name: string } }; + +describe('React readers on identical re-delivery', () => { + let client: QueryClient; + let adapter: MockTopicAdapter; + + beforeEach(() => { + client?.destroy(); + adapter = new MockTopicAdapter(); + adapter.snapshots.set('wallet:w-1', { + wallet: { __typename: 'Wallet', id: 'w-1', tokens: [token(0), token(1)] }, + }); + client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [adapter], + } as never); + }); + + it('does not re-render entity readers when every entity is re-delivered unchanged', async () => { + const Row = createRenderCounter( + ({ token }: { token: TokenProxy }) => ( + + {token.price}:{token.metadata.logo}:{token.owner.name} + + ), + component, + ); + + const ListCounter = createRenderCounter(() => { + const result = fetchQuery(WalletTopic); + if (!result.isReady) return
Loading...
; + const tokens = result.value.wallet.tokens as unknown as TokenProxy[]; + return ( +
+ {tokens.map(tok => ( + + ))} +
+ ); + }, component); + + const { getByTestId } = render( + + + , + ); + + await expect.element(getByTestId('row-tok-1')).toHaveTextContent('1:logo-1.png:Ann'); + await sleep(20); + const rowRendersBefore = Row.renderCount; + const listRendersBefore = ListCounter.renderCount; + + // Reconnect: every entity re-delivered with the data the store holds. + for (let pass = 0; pass < 3; pass++) { + adapter.emit({ type: 'update', typename: 'Wallet', data: { id: 'w-1' } }); + adapter.emit({ type: 'update', typename: 'Token', data: token(0) }); + adapter.emit({ type: 'update', typename: 'Token', data: token(1) }); + adapter.emit({ type: 'update', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); + } + await sleep(50); + + expect(Row.renderCount).toBe(rowRendersBefore); + expect(ListCounter.renderCount).toBe(listRendersBefore); + + // A real change re-renders the row that reads it, once. (The list re-renders + // too: its constrained live array re-filters on any member change.) + adapter.emit({ type: 'update', typename: 'Token', data: token(1, 99) }); + await expect.element(getByTestId('row-tok-1')).toHaveTextContent('99:logo-1.png:Ann'); + await sleep(50); + + expect(Row.renderCount).toBe(rowRendersBefore + 1); + }); +}); From 8ed72a8e789536837f52c65e30f0fc4a08f2fbd0 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 04:04:49 -0400 Subject: [PATCH 023/109] perf(fetchium): start prefetch/retain fetches synchronously A query activated by a read leaves its subscription and first fetch for a microtask, since that work runs adapter and app code that must not run while the relay's computation is the current consumer. Leases paid the same hop: a tap handler that called prefetch() and then navigated had React's sync render microtask (queued by the press) run before the request went out. retain() and prefetch() now collect the queries their first run left a start for (a cold start from initialize(), or a zero-delay runDebounced() refetch on reactivation), and run those starts once the watcher's read has returned, outside any computation. The queued microtask then does nothing. Reader activation is unchanged, as are the stagger queue and debounce timers, the reactivation grace, and sync-store hydration (fresh cached data still issues no request). useSuspenseQuery's internal hold keeps the deferred start, since it leases from inside a render. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 49 ++++++- packages/fetchium/src/QueryResult.ts | 41 +++++- .../src/__tests__/retain-prefetch.test.ts | 135 +++++++++++++++++- 3 files changed, 218 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index f0b102a..bd8b2be 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -162,6 +162,12 @@ export class QueryClient { /** Release functions of outstanding `retain()` / `prefetch()` leases. */ private leases = new Set<() => void>(); + /** + * While a lease's callback first runs: the queries it reached that left + * their first fetch (or a zero-delay refetch) for a microtask. The lease + * starts them before returning. See `retain()`. + */ + private leaseStarts: Set> | undefined = undefined; /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ private suspenseHolds = new Map(); @@ -368,6 +374,10 @@ export class QueryClient { this.queryInstances.set(queryKey, queryInstance as QueryInstance); } + // Already active with its start still queued (a reader activated it earlier + // in this task): the lease starts it too. + this.leaseStarts?.add(queryInstance); + return queryInstance.relay; } @@ -397,8 +407,25 @@ export class QueryClient { * render. Releasing never interrupts other readers; when the last one goes, * the query deactivates and its `gcTime` starts as usual. `release` is * idempotent. + * + * The fetches the first run of `fn` needs start before `retain` returns: a + * request goes out inside the call, ahead of anything already queued on the + * microtask queue (such as a render React scheduled for the same tap). A + * query that depends on another's result (`fn` reads an id, then fetches by + * it) starts once that result arrives, as with any reader. Call `retain` + * from an event handler or effect, not from inside a reactive computation: + * starting a fetch runs adapter code. */ retain(fn: () => unknown, options?: RetainOptions): () => void { + return this.lease(fn, options, true); + } + + /** + * `retain()`, with `startNow` choosing whether the first run's fetches start + * before returning or on their usual microtask. `useSuspenseQuery` leases + * from inside a render, where adapter code must not run, so it passes false. + */ + private lease(fn: () => unknown, options: RetainOptions | undefined, startNow: boolean): () => void { const w = withContexts([[QueryClientContext, this]], () => watcher(() => holdReturned(fn()))); const unsubscribe = w.addListener(noop); @@ -415,6 +442,9 @@ export class QueryClient { this.leases.add(release); + const outerStarts = this.leaseStarts; + const starts = startNow ? new Set>() : undefined; + this.leaseStarts = starts; try { // Run now, so the queries activate (and a synchronous store hydrates // them) before this returns, not on Signalium's next flush. @@ -422,6 +452,13 @@ export class QueryClient { } catch (error) { release(); throw error; + } finally { + this.leaseStarts = outerStarts; + } + + // Outside the watcher's computation now, so adapter code may run. + if (starts !== undefined) { + for (const instance of starts) instance.startPendingNow(); } const ttl = options?.ttl; @@ -498,7 +535,7 @@ export class QueryClient { let hold = this.suspenseHolds.get(key); if (hold === undefined) { - const release = this.retain(() => this.getQuery(def, params)); + const release = this.lease(() => this.getQuery(def, params), undefined, false); hold = { release, settled: undefined, done: false, failed: false, timer: undefined }; this.suspenseHolds.set(key, hold); } @@ -722,6 +759,16 @@ export class QueryClient { // Reactivation stagger // ====================================================== + /** + * Called by a query that queued its start (or a zero-delay refetch) on a + * microtask. During a lease's first run, the lease starts it before returning. + * + * @internal + */ + noteDeferredStart(instance: QueryInstance): void { + this.leaseStarts?.add(instance); + } + /** * Queues a reactivation refetch. Everything queued in the same task is * started from one flush, spread evenly across `reactivationStaggerMs` in diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 0f0cc49..63f59d4 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -57,6 +57,10 @@ export class QueryInstance { * microtask and so can't be cleared like a timer. */ private debounceGeneration: number = 0; + /** The zero-delay refetch runDebounced() queued, until it runs or is cancelled. */ + private pendingDebouncedRun: (() => void) | undefined = undefined; + /** initialize() queued startSubscriptionAndFetch() on a microtask that hasn't run yet. */ + private startPending: boolean = false; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; /** @@ -298,6 +302,10 @@ export class QueryInstance { * * With an asynchronous store (AsyncQueryStore), the cache resolves on a later * tick, outside the activating read, and everything runs from there. + * + * A lease (`retain()` / `prefetch()`) doesn't wait for the microtask: it + * calls startPendingNow() once its activating read has returned, so the + * request goes out inside the call, ahead of any render already queued. */ private initialize(): void { this.initialized = true; @@ -324,10 +332,32 @@ export class QueryInstance { ); } else { this.hydrate(loaded); - queueMicrotask(() => this.startSubscriptionAndFetch()); + this.startPending = true; + this.queryClient.noteDeferredStart(this); + queueMicrotask(this.runPendingStart); } } + private runPendingStart = (): void => { + if (!this.startPending) return; + this.startPending = false; + this.startSubscriptionAndFetch(); + }; + + /** + * Runs, now, the start initialize() or a zero-delay runDebounced() left for + * a microtask, if it hasn't run yet. The microtask then does nothing. Called + * by a lease after its activating read, outside any reactive computation. + * + * @internal + */ + startPendingNow(): void { + this.runPendingStart(); + const run = this.pendingDebouncedRun; + this.pendingDebouncedRun = undefined; + run?.(); + } + private discardCorruptCache(error: unknown): void { const qc = this.queryClient; qc.store.deleteQuery(this.storageKey); @@ -509,18 +539,23 @@ export class QueryInstance { } const generation = this.debounceGeneration; - queueMicrotask(() => { + const run = (): void => { if (generation !== this.debounceGeneration) return; this.debounceGeneration++; + this.pendingDebouncedRun = undefined; // Another path started a fetch in the meantime (refetch(), activation). if (this.relayState.isPending && this._abortController !== undefined) return; this.runQueryImmediately(); - }); + }; + this.pendingDebouncedRun = run; + this.queryClient.noteDeferredStart(this); + queueMicrotask(run); } /** Cancels a refetch scheduled by runDebounced(). */ private cancelDebounced(): void { this.debounceGeneration++; + this.pendingDebouncedRun = undefined; clearTimeout(this.debounceTimer); this.debounceTimer = undefined; } diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 0f3f8c2..165bd43 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -69,13 +69,29 @@ afterEach(() => { unsubscribed = 0; }); -function setup(kv: MemoryPersistentStore = new MemoryPersistentStore()) { +class GetIdentity extends RESTQuery { + path = '/identity'; + result = { id: t.number }; +} + +class GetHoldings extends RESTQuery { + params = { owner: t.number }; + path = `/holdings/${this.params.owner}`; + result = { n: t.number }; +} + +function setup( + kv: MemoryPersistentStore = new MemoryPersistentStore(), + options: { reactivationStaggerMs?: number } = {}, +) { const mockFetch = createMockFetch(); let n = 0; - for (const path of ['/item', '/other', '/users/[id]', '/streamed']) { + for (const path of ['/item', '/other', '/users/[id]', '/streamed', '/holdings/[owner]']) { mockFetch.get(path, () => ({ n: ++n })); } + mockFetch.get('/identity', { id: 42 }); const client = new QueryClient({ + ...options, store: new SyncQueryStore(kv), adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], }); @@ -92,12 +108,88 @@ function mountReader(client: QueryClient, read: () => T): { w: { value: T }; } describe('client.prefetch()', () => { - it('starts the fetch before returning control to a timer', async () => { + it('issues the request before returning', async () => { const { client, mockFetch } = setup(); client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + }); + + it('issues the request ahead of a render already queued on the microtask queue', async () => { + const { client, mockFetch } = setup(); + let callsAtRender = -1; + // React queues its sync render for a press as a microtask before the handler's prefetch. + queueMicrotask(() => { + callsAtRender = mockFetch.calls.length; + }); + client.prefetch(GetItem); + await flushMicrotasks(); + expect(callsAtRender).toBe(1); + }); + + it('a reader activating a query leaves its fetch for the microtask, as before', async () => { + const { client, mockFetch } = setup(); + const { unsub } = mountReader(client, () => fetchQuery(GetItem).value); expect(mockFetch.calls).toHaveLength(0); await flushMicrotasks(); expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('starts a query a reader activated earlier in the same task, once', async () => { + const { client, mockFetch } = setup(); + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(mockFetch.calls).toHaveLength(0); + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + expect(w.value).toMatchObject({ n: 1 }); + unsub(); + }); + + it('a reader mounting in the same task joins the request, with no second one', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 5 }, { delay: 20 }); + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); + expect(w.value).toBeUndefined(); + await sleep(40); + expect(w.value).toMatchObject({ n: 5 }); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + unsub(); + }); + + it('refetches a stale, inactive query before returning', async () => { + const { client, mockFetch } = setup(); + const release = client.prefetch(GetItem); + await sleep(10); + release(); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('leaves a stale reactivation to the stagger when reactivationStaggerMs is set', async () => { + const { client, mockFetch } = setup(undefined, { reactivationStaggerMs: 100 }); + const release = client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + await sleep(10); + release(); + await sleep(10); + + client.prefetch(GetItem); + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); }); it('a reader mounting after the data arrives renders it on its first read, with no second request', async () => { @@ -155,6 +247,7 @@ describe('client.prefetch()', () => { client.prefetch(GetFreshUser, { id: 1 }); // Hydrated by the activation inside prefetch(), before it returned. expect(client.getQuery(QueryDefinition.for(GetFreshUser), { id: 1 }).value).toMatchObject({ n: 1 }); + expect(mockFetch.calls).toHaveLength(0); await sleep(10); expect(mockFetch.calls).toHaveLength(0); }); @@ -226,6 +319,42 @@ describe('client.prefetch()', () => { }); describe('client.retain()', () => { + it('issues the first request of a chained lease before returning, and the next when its input arrives', async () => { + const { client, mockFetch } = setup(); + const release = client.retain(() => { + const identity = fetchQuery(GetIdentity); + if (!identity.isReady) return identity; + return fetchQuery(GetHoldings, { owner: identity.value.id }); + }); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/identity']); + await sleep(10); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/identity', '/holdings/42']); + + const { w, unsub } = mountReader(client, () => fetchQuery(GetHoldings, { owner: 42 }).value); + expect(w.value).toMatchObject({ n: expect.any(Number) }); + await sleep(10); + expect(mockFetch.calls).toHaveLength(2); + unsub(); + release(); + }); + + it('issues both requests of a chained lease before returning when the first is cached and fresh', async () => { + const kv = new MemoryPersistentStore(); + const seed = setup(kv); + seed.client.retain(() => fetchQuery(GetFreshUser, { id: 1 })); + await sleep(10); + seed.client.destroy(); + + const { client, mockFetch } = setup(kv); + const release = client.retain(() => { + const user = fetchQuery(GetFreshUser, { id: 1 }); + if (!user.isReady) return user; + return fetchQuery(GetHoldings, { owner: user.value.n }); + }); + expect(mockFetch.calls.map(c => new URL(c.url).pathname)).toEqual(['/holdings/1']); + release(); + }); + it('holds the query promises the callback returns', async () => { const { client, mockFetch } = setup(); const release = client.retain(() => [fetchQuery(GetItem), fetchQuery(GetOther)]); From b053d83d11407775276ea7f2e480250aa3c6be92 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 06:33:37 -0400 Subject: [PATCH 024/109] fix(fetchium): do not retry 4xx client errors; add shouldRetry A failed attempt whose HTTP status is 4xx (except 408 and 429) now fails at once instead of retrying three times. The status comes from the adapter's error response (ctx.response set during the attempt) or from status, statusCode or response.status on the error. QueryClientConfig.shouldRetry and RetryConfig.shouldRetry override the default. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-client-error-retries.md | 7 + docs/src/app/api/fetchium/page.md | 31 +-- docs/src/app/guides/error-handling/page.md | 11 + packages/fetchium/src/MutationResult.ts | 25 +- packages/fetchium/src/QueryClient.ts | 14 + packages/fetchium/src/QueryResult.ts | 30 ++- .../src/__tests__/retry-should-retry.test.ts | 250 ++++++++++++++++++ packages/fetchium/src/index.ts | 2 + packages/fetchium/src/query.ts | 8 +- packages/fetchium/src/retry.ts | 56 ++++ packages/fetchium/src/types.ts | 12 +- 11 files changed, 416 insertions(+), 30 deletions(-) create mode 100644 .changeset/skip-client-error-retries.md create mode 100644 packages/fetchium/src/__tests__/retry-should-retry.test.ts diff --git a/.changeset/skip-client-error-retries.md b/.changeset/skip-client-error-retries.md new file mode 100644 index 0000000..ee46ed9 --- /dev/null +++ b/.changeset/skip-client-error-retries.md @@ -0,0 +1,7 @@ +--- +'fetchium': minor +--- + +Queries no longer retry client errors. A failed attempt whose HTTP status is 4xx (other than 408 and 429) now fails at once instead of being retried three times; network errors and 5xx responses still retry. The status comes from the response the adapter received (a REST error response whose body fails validation) or from `status`, `statusCode` or `response.status` on the thrown error. + +Adds `shouldRetry(error, attempt, status)` to `QueryClientConfig` and to `RetryConfig` (per query or mutation, overriding the client's) to decide retries yourself, and exports the default as `defaultShouldRetry` along with `getErrorStatus`. `RetryConfig.retries` is now optional and defaults as `retry: true` does. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 85f40bd..3e2b20c 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,18 +225,19 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| ----------------------- | ----------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | -| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | -| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | -| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | -| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | +| Field | Type | Default | Description | +| ----------------------- | ------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | +| `shouldRetry` | `(error, attempt, status) => boolean` | `defaultShouldRetry` | Whether a failed attempt is retried. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). The default retries network errors and 5xx but not 4xx other than 408 and 429. Per-query override: `retry.shouldRetry`. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | #### Methods @@ -690,9 +691,9 @@ The `t` object provides a declarative type definition DSL for describing query p ### Retry types -| Type | Definition | Description | -| ------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `RetryConfig` | `{ retries: number; retryDelay?: (attemptIndex: number) => number }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). | +| Type | Definition | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's `shouldRetry`. | ### Utility types diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index edbdbb4..2da961f 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -147,6 +147,17 @@ class GetUser extends RESTQuery { The default retry delay uses exponential backoff --- each successive attempt waits longer than the last (roughly 1s, 2s, 4s, ...). This avoids hammering a struggling server with rapid retries. +Client errors are not retried: a `4xx` response fails on the first attempt, because repeating the same request gets the same answer. `408 Request Timeout` and `429 Too Many Requests` are the exceptions, and network errors and `5xx` responses are retried. The status comes from the response the adapter received (a REST error response whose body doesn't match `result`), or from `status`, `statusCode` or `response.status` on the thrown error. To decide yourself, pass `shouldRetry`, either per query in `retry` or for every query on the `QueryClient`: + +```ts +const client = new QueryClient({ + adapters: [new RESTQueryAdapter({ baseUrl })], + // attempt starts at 0; status is undefined for network errors + shouldRetry: (error, attempt, status) => + status === undefined || status >= 500, +}); +``` + To disable retries entirely: ```ts diff --git a/packages/fetchium/src/MutationResult.ts b/packages/fetchium/src/MutationResult.ts index 234c706..3410413 100644 --- a/packages/fetchium/src/MutationResult.ts +++ b/packages/fetchium/src/MutationResult.ts @@ -112,16 +112,21 @@ export class MutationResultImpl { ); } - return withRetry(async () => { - const abortController = new AbortController(); - const ctx = createExecutionContext( - this.def.captured, - (request ?? {}) as Record, - this.queryClient.getContext(), - ); - - return (await adapter.sendMutation!(ctx, abortController.signal)) as Result; - }, retryConfig); + return withRetry( + async () => { + const abortController = new AbortController(); + const ctx = createExecutionContext( + this.def.captured, + (request ?? {}) as Record, + this.queryClient.getContext(), + ); + + return (await adapter.sendMutation!(ctx, abortController.signal)) as Result; + }, + retryConfig, + undefined, + { shouldRetry: this.queryClient.shouldRetry }, + ); } } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index bd8b2be..c356848 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -26,6 +26,7 @@ import { ValidatorDef } from './typeDefs.js'; import { ConstraintMatcher, EVENT_SOURCE_FIELD } from './ConstraintMatcher.js'; import { LiveCollectionBinding } from './LiveCollection.js'; import { QueryAdapter, type QueryAdapterClass } from './QueryAdapter.js'; +import { defaultShouldRetry, type ShouldRetry } from './retry.js'; import { type QueryContext, type QueryStore, @@ -62,6 +63,15 @@ export interface QueryClientConfig { * still refetch. Default: 0 (every stale query refetches on reactivation). */ reactivationGraceMs?: number; + /** + * Decides whether a failed query attempt (or a mutation attempt, when the + * mutation enables retries) is retried. Receives the error, the attempt index + * (starting at 0) and the attempt's HTTP status when known. A query's or + * mutation's own `retry.shouldRetry` overrides it. Default: + * `defaultShouldRetry`, which retries network errors and 5xx responses but + * not 4xx responses other than 408 and 429. + */ + shouldRetry?: ShouldRetry; /** * Milliseconds. Reactivation refetches that start in the same task (for * example every query on a screen that just resumed) are spread evenly @@ -155,6 +165,8 @@ export class QueryClient { readonly reactivationGraceMs: number; /** See `QueryClientConfig.reactivationStaggerMs`. */ readonly reactivationStaggerMs: number; + /** See `QueryClientConfig.shouldRetry`. */ + readonly shouldRetry: ShouldRetry; /** Queries whose reactivation refetch waits for the current task's stagger flush. */ private staggerQueue = new Set>(); @@ -186,6 +198,7 @@ export class QueryClient { evictionMultiplier, reactivationGraceMs, reactivationStaggerMs, + shouldRetry, adapters: _c, networkManager: _n, gcManager: _g, @@ -195,6 +208,7 @@ export class QueryClient { this.store = store; this.reactivationGraceMs = nonNegative(reactivationGraceMs); this.reactivationStaggerMs = nonNegative(reactivationStaggerMs); + this.shouldRetry = shouldRetry ?? defaultShouldRetry; // `activity` and `pollResumeJitterMs` ride along in `rest`: poll() reads them from the context. this.context = { ...rest, log: log ?? console, evictionMultiplier }; this.gcManager = diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 63f59d4..249c4f7 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -21,7 +21,7 @@ import { GcKeyType } from './GcManager.js'; import { Query, QueryDefinition, type ResolvedRetryConfig, resolveRetryConfig } from './query.js'; import { EntityInstance } from './EntityInstance.js'; import { hashValue } from 'signalium/utils'; -import { withRetry } from './retry.js'; +import { getFailedResponseStatus, withRetry, type WithRetryOptions } from './retry.js'; function isThenable(value: MaybePromise): value is Promise { return typeof (value as { then?: unknown } | null | undefined)?.then === 'function'; @@ -469,10 +469,12 @@ export class QueryInstance { const ctx = this.getOrCreateExecutionContext(); const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; + const attempt = this.attemptStatusTracker(ctx); try { const result = await withRetry( async () => { + attempt.start(); try { const freshData = await adapter.send(ctx, signal); this.updatedAt = Date.now(); @@ -489,6 +491,7 @@ export class QueryInstance { }, this.retryConfig, signal, + attempt.options, ); this.lastFetchFailed = false; return result; @@ -665,9 +668,11 @@ export class QueryInstance { const ctx = this.getOrCreateExecutionContext(); ctx.resultData = this.rootEntity!.data; const adapter = this.queryClient.getAdapter(def.statics.adapterClass); + const attempt = this.attemptStatusTracker(ctx); return withRetry( async () => { + attempt.start(); const freshData = await adapter.sendNext!(ctx, signal); this.updatedAt = Date.now(); @@ -678,9 +683,32 @@ export class QueryInstance { }, this.retryConfig, signal, + attempt.options, ); } + /** + * Retry options that report the HTTP status of a failed attempt. Adapters + * that expose their response as `ctx.response` (RESTQueryAdapter does) set + * it before the body is parsed and validated, so an error response whose + * body fails validation surfaces as a schema error, not an HTTP error. A + * response assigned during the failed attempt supplies its status. + */ + private attemptStatusTracker(ctx: Query): { start: () => void; options: WithRetryOptions } { + const holder = ctx as unknown as { response?: unknown }; + let responseBefore: unknown; + return { + start: () => { + responseBefore = holder.response; + }, + options: { + shouldRetry: this.queryClient.shouldRetry, + getAttemptStatus: () => + holder.response !== responseBefore ? getFailedResponseStatus(holder.response) : undefined, + }, + }; + } + // ====================================================== // Internal computed properties // ====================================================== diff --git a/packages/fetchium/src/__tests__/retry-should-retry.test.ts b/packages/fetchium/src/__tests__/retry-should-retry.test.ts new file mode 100644 index 0000000..d53abaf --- /dev/null +++ b/packages/fetchium/src/__tests__/retry-should-retry.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { QueryClient } from '../QueryClient.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { RESTQuery, RESTMutation } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { getMutation } from '../mutation.js'; +import { NetworkManager } from '../NetworkManager.js'; +import { defaultShouldRetry, getErrorStatus, type ShouldRetry } from '../retry.js'; +import { createMockFetch, testWithClient } from './utils.js'; +import { t } from '../typeDefs.js'; + +const user = { id: '1', name: 'Alice' }; + +/** An error shaped like a fetch wrapper's HTTP error (e.g. axios / request builders). */ +function httpError(status: number): Error { + return Object.assign(new Error(`HTTP ${status}`), { response: { status } }); +} + +describe('shouldRetry', () => { + let mockFetch: ReturnType; + let client: QueryClient; + + function createClient(shouldRetry?: ShouldRetry): QueryClient { + return new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + networkManager: new NetworkManager(true), + shouldRetry, + }); + } + + class GetUser extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 3, retryDelay: () => 1 } }; + } + + async function runFailing(query: new () => RESTQuery = GetUser): Promise { + let caught: unknown; + await testWithClient(client, async () => { + try { + await fetchQuery(query as typeof GetUser); + } catch (error) { + caught = error; + } + }); + return caught; + } + + beforeEach(() => { + mockFetch = createMockFetch(); + client = createClient(); + }); + + afterEach(() => { + client.destroy(); + }); + + describe('default', () => { + for (const status of [400, 401, 403, 404, 422]) { + it(`does not retry a ${status} response whose body fails validation`, async () => { + mockFetch.get('/users/1', { error: 'nope' }, { status }); + + const error = await runFailing(); + + expect(error).toBeDefined(); + expect(mockFetch.calls).toHaveLength(1); + }); + } + + it('does not retry an error that carries a 4xx status', async () => { + mockFetch.get('/users/1', null, { error: httpError(401) }); + + const error = await runFailing(); + + expect(error).toMatchObject({ response: { status: 401 } }); + expect(mockFetch.calls).toHaveLength(1); + }); + + for (const status of [408, 429, 500, 503]) { + it(`retries a ${status} response`, async () => { + mockFetch.get('/users/1', { error: 'busy' }, { status }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + } + + it('retries a 503 whose body is not JSON, then succeeds', async () => { + mockFetch.get('/users/1', null, { status: 503, jsonError: new SyntaxError('Unexpected token <') }); + mockFetch.get('/users/1', user); + + await testWithClient(client, async () => { + const result = fetchQuery(GetUser); + await result; + expect(result.value).toMatchObject(user); + }); + expect(mockFetch.calls).toHaveLength(2); + }); + + it('retries an error that carries a 429 status', async () => { + mockFetch.get('/users/1', null, { error: httpError(429) }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + + it('retries network errors', async () => { + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + + it('retries a 2xx response whose body fails validation', async () => { + mockFetch.get('/users/1', { unexpected: true }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + + it('does not take the status of an earlier response for a later network error', async () => { + // Fetch 1 succeeds; the refetch fails with a network error, which must + // not inherit a status from the response assigned by fetch 1. + mockFetch.get('/users/1', user); + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetUser); + await result; + await result.value!.__refetch().catch(() => {}); + }); + + expect(mockFetch.calls).toHaveLength(5); + }); + + it('does not retry a 4xx page in fetchNext', async () => { + class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.string), nextPage: t.optional(t.number) }; + fetchNext = { searchParams: { page: this.result.nextPage } }; + config = { retry: { retries: 3, retryDelay: () => 1 } }; + } + mockFetch.get('/items', { items: ['a'], nextPage: 2 }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetItems); + await result; + mockFetch.get('/items', { error: 'bad page' }, { status: 400 }); + await expect(result.value!.__fetchNext()).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(2); + }); + }); + + describe('custom', () => { + it('client-level shouldRetry receives error, attempt and status', async () => { + const seen: Array<[number, number | undefined]> = []; + client.destroy(); + client = createClient((_error, attempt, status) => { + seen.push([attempt, status]); + return attempt < 1; + }); + mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(2); + expect(seen).toEqual([ + [0, 404], + [1, 404], + ]); + }); + + it('query-level shouldRetry overrides the client default', async () => { + class GetUserRetry4xx extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 2, retryDelay: () => 1, shouldRetry: () => true } }; + } + mockFetch.get('/users/1', { error: 'nope' }, { status: 400 }); + + await runFailing(GetUserRetry4xx); + + expect(mockFetch.calls).toHaveLength(3); + }); + + it('query-level shouldRetry can stop retries the default would make', async () => { + class GetUserNoRetry extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 3, retryDelay: () => 1, shouldRetry: () => false } }; + } + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await runFailing(GetUserNoRetry); + + expect(mockFetch.calls).toHaveLength(1); + }); + + it('applies to mutations', async () => { + class CreateUser extends RESTMutation { + readonly path = '/users'; + readonly method = 'POST' as const; + readonly params = { name: t.string }; + readonly body = { name: this.params.name }; + readonly result = { id: t.number }; + config = { retry: { retries: 2, retryDelay: () => 1 } }; + } + mockFetch.post('/users', null, { error: httpError(422) }); + + await testWithClient(client, async () => { + const mut = getMutation(CreateUser); + await expect(mut.run({ name: 'Test' })).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(1); + }); + }); +}); + +describe('defaultShouldRetry', () => { + it('classifies statuses', () => { + const err = new Error('x'); + expect(defaultShouldRetry(err, 0, undefined)).toBe(true); + expect(defaultShouldRetry(err, 0, 400)).toBe(false); + expect(defaultShouldRetry(err, 0, 404)).toBe(false); + expect(defaultShouldRetry(err, 0, 408)).toBe(true); + expect(defaultShouldRetry(err, 0, 429)).toBe(true); + expect(defaultShouldRetry(err, 0, 500)).toBe(true); + }); +}); + +describe('getErrorStatus', () => { + it('reads status, statusCode and response.status', () => { + expect(getErrorStatus({ status: 404 })).toBe(404); + expect(getErrorStatus({ statusCode: 401 })).toBe(401); + expect(getErrorStatus({ response: { status: 503 } })).toBe(503); + expect(getErrorStatus({ response: null })).toBeUndefined(); + expect(getErrorStatus({ status: 'failed' })).toBeUndefined(); + expect(getErrorStatus(new Error('x'))).toBeUndefined(); + expect(getErrorStatus(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/fetchium/src/index.ts b/packages/fetchium/src/index.ts index 92e665b..a11b55b 100644 --- a/packages/fetchium/src/index.ts +++ b/packages/fetchium/src/index.ts @@ -13,3 +13,5 @@ export type { Draft } from './utils.js'; export { NetworkManager, NoOpNetworkManager, defaultNetworkManager, NetworkManagerContext } from './NetworkManager.js'; export { GcManager, NoOpGcManager } from './GcManager.js'; export { Entity } from './proxy.js'; +export { defaultShouldRetry, getErrorStatus } from './retry.js'; +export type { ShouldRetry } from './retry.js'; diff --git a/packages/fetchium/src/query.ts b/packages/fetchium/src/query.ts index d0c397e..c2b041c 100644 --- a/packages/fetchium/src/query.ts +++ b/packages/fetchium/src/query.ts @@ -25,6 +25,8 @@ import type { QueryAdapter, QueryAdapterClass } from './QueryAdapter.js'; export interface ResolvedRetryConfig { retries: number; retryDelay: (attempt: number) => number; + /** Undefined defers to the client's `shouldRetry`. */ + shouldRetry?: (error: unknown, attempt: number, status: number | undefined) => boolean; } export function resolveRetryConfig( @@ -40,7 +42,7 @@ export function resolveRetryConfig( } else if (typeof retryOption === 'number') { retries = retryOption; } else { - retries = retryOption.retries; + retries = retryOption.retries ?? (isServer ? 0 : 3); } const retryDelay = @@ -48,7 +50,9 @@ export function resolveRetryConfig( ? retryOption.retryDelay : (attempt: number) => 1000 * Math.pow(2, attempt); - return { retries, retryDelay }; + const shouldRetry = typeof retryOption === 'object' ? retryOption.shouldRetry : undefined; + + return { retries, retryDelay, shouldRetry }; } // ================================ diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index 221b853..451ab2c 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -1,5 +1,57 @@ import type { ResolvedRetryConfig } from './query.js'; +/** + * Decides whether a failed attempt is retried. Consulted only while retries + * remain. + * + * @param error What the attempt threw. + * @param attempt Index of the failed attempt, starting at 0 (the first request). + * @param status HTTP status of the failed attempt, when known: the status the + * adapter received (`ctx.response.status` for a REST query whose response + * body failed to parse or validate), or else `status`, `statusCode` or + * `response.status` on the error. Undefined for network errors. + */ +export type ShouldRetry = (error: unknown, attempt: number, status: number | undefined) => boolean; + +function asHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' && value >= 100 && value <= 599 ? value : undefined; +} + +/** + * The HTTP status an error carries, read from `status`, `statusCode`, or + * `response.status`. Undefined when none holds an HTTP status code. + */ +export function getErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const e = error as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } | null }; + return asHttpStatus(e.status) ?? asHttpStatus(e.statusCode) ?? asHttpStatus(e.response?.status); +} + +/** The status of an error response (4xx/5xx), or undefined. */ +export function getFailedResponseStatus(response: unknown): number | undefined { + if (typeof response !== 'object' || response === null) return undefined; + const status = asHttpStatus((response as { status?: unknown }).status); + return status !== undefined && status >= 400 ? status : undefined; +} + +/** + * Retries network errors, unknown errors and server errors (5xx). Does not + * retry client errors (4xx), which a repeat of the same request would hit + * again, except 408 Request Timeout and 429 Too Many Requests. + */ +export const defaultShouldRetry: ShouldRetry = (_error, _attempt, status) => + status === undefined || status < 400 || status >= 500 || status === 408 || status === 429; + +export interface WithRetryOptions { + /** Used when the retry config has no `shouldRetry`. Default: `defaultShouldRetry`. */ + shouldRetry?: ShouldRetry; + /** + * Called after an attempt fails. Returns the HTTP status the attempt + * received, for errors that don't carry one themselves. + */ + getAttemptStatus?: () => number | undefined; +} + /** * Safely retrieve the abort reason from a signal. Falls back to an AbortError * for engines (like Hermes) where `signal.reason` is not implemented. @@ -36,11 +88,13 @@ export async function withRetry( fn: () => Promise, config: ResolvedRetryConfig, signal?: AbortSignal, + options?: WithRetryOptions, ): Promise { if (IS_DEV && config.retries < 0) { throw new Error('retries must be non-negative'); } const retries = Math.max(0, config.retries); + const shouldRetry = config.shouldRetry ?? options?.shouldRetry ?? defaultShouldRetry; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { if (signal?.aborted) { @@ -51,6 +105,8 @@ export async function withRetry( } catch (error) { lastError = error; if (attempt >= retries) throw error; + const status = getErrorStatus(error) ?? options?.getAttemptStatus?.(); + if (!shouldRetry(error, attempt, status)) throw error; await sleep(config.retryDelay(attempt), signal); } } diff --git a/packages/fetchium/src/types.ts b/packages/fetchium/src/types.ts index 0004474..e44d3f4 100644 --- a/packages/fetchium/src/types.ts +++ b/packages/fetchium/src/types.ts @@ -58,14 +58,22 @@ export enum NetworkMode { export interface RetryConfig { /** - * Number of retry attempts + * Number of retry attempts. Default: 3 for queries on the client, 0 on the + * server and for mutations. */ - retries: number; + retries?: number; /** * Optional custom delay function (receives attempt index starting at 0) * Default: exponential backoff (1000ms * 2^attempt) */ retryDelay?: (attemptIndex: number) => number; + /** + * Decides whether a failed attempt is retried, from the error, the attempt + * index (starting at 0) and the attempt's HTTP status when known. Overrides + * `QueryClientConfig.shouldRetry`. Default: `defaultShouldRetry`, which does + * not retry 4xx responses other than 408 and 429. + */ + shouldRetry?: (error: unknown, attempt: number, status: number | undefined) => boolean; } export const enum Mask { From 6702797b87d455d421ca8a5f2fc8f15e529f695e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:11:40 -0400 Subject: [PATCH 025/109] fix(fetchium): notify a live value after every reducer run again Skipping the notify when a reducer returned the value it was given broke reducers that mutate in place: one that pushes onto its array and returns it changed the value without notifying, so readers kept the old length. A reference check can't tell that from a no-op, so every routed event that runs a reducer notifies again. Entities, writes and live arrays stay quiet on identical re-delivery. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/LiveCollection.ts | 9 ++-- .../__tests__/identical-redelivery.test.ts | 8 ++- .../fetchium/src/__tests__/live-value.test.ts | 51 +++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 34ae819..673c54a 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -435,7 +435,6 @@ export class LiveValueInstance { entityData: Record, eventType: 'create' | 'update' | 'delete', ): void { - const prev = this._value; switch (eventType) { case 'create': if (this._createdKeys.has(entityKey)) return; @@ -451,10 +450,10 @@ export class LiveValueInstance { this._value = this._onDelete(this._value, entity ?? entityData); break; } - // Update events route here even when they changed nothing (a stream - // re-delivering current data). A reducer that returns the current value is - // a no-op, as in `reset`. - if (this._value !== prev) this._notifier.notify(); + // Notify after every reducer run, even one that returns the value it was + // given: a reducer may mutate the value in place (push onto an array and + // return it), and a reference check can't tell that from a no-op. + this._notifier.notify(); } getValue(): unknown { diff --git a/packages/fetchium/src/__tests__/identical-redelivery.test.ts b/packages/fetchium/src/__tests__/identical-redelivery.test.ts index 4b1a32b..0afa3d9 100644 --- a/packages/fetchium/src/__tests__/identical-redelivery.test.ts +++ b/packages/fetchium/src/__tests__/identical-redelivery.test.ts @@ -212,7 +212,10 @@ describe('Identical re-delivery', () => { expect(versions(client, keys)).toEqual(before); expect(saveEntity).not.toHaveBeenCalled(); expect(live.tokens).not.toHaveBeenCalled(); - expect(live.count).not.toHaveBeenCalled(); + // The live value's onUpdate reducer runs for the Token event and the + // value notifies: a reducer may mutate in place, so a returned identical + // reference doesn't prove nothing changed. + expect(live.count).toHaveBeenCalledTimes(1); // A `create` for an entity the store already holds is the same no-op for // the entity and the live array. (The live value's `onCreate` reducer @@ -328,7 +331,8 @@ describe('(c) topic events', () => { expect(versions(client, keys)).toEqual(before); expect(saveEntity).not.toHaveBeenCalled(); expect(live.tokens).not.toHaveBeenCalled(); - expect(live.count).not.toHaveBeenCalled(); + // Each Token event runs the live value's reducer, which notifies (see (b)). + expect(live.count).toHaveBeenCalledTimes(3); }); it('notifies exactly once for a topic event that changes a field', async () => { diff --git a/packages/fetchium/src/__tests__/live-value.test.ts b/packages/fetchium/src/__tests__/live-value.test.ts index 3bd4455..f6cc650 100644 --- a/packages/fetchium/src/__tests__/live-value.test.ts +++ b/packages/fetchium/src/__tests__/live-value.test.ts @@ -241,6 +241,57 @@ describe('LiveValue', () => { // ============================================================ describe('entity-level liveValue lifecycle', () => { + it('notifies when a reducer mutates the value in place and returns it', async () => { + const { client, mockFetch } = getClient(); + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + listId = t.string; + } + + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + ids = t.liveValue(t.array(t.string), Item, { + constraints: { listId: (this as any).id }, + onCreate: (v: string[], item: any) => { + v.push(item.id); + return v; + }, + onUpdate: (v: string[]) => v, + onDelete: (v: string[]) => v, + }); + } + + class GetList extends RESTQuery { + params = { id: t.id }; + path = `/list/${this.params.id}`; + result = { list: t.entity(List) }; + } + + mockFetch.get('/list/[id]', { list: { __typename: 'List', id: '1', ids: [] } }); + + let length!: () => number; + await testWithClient(client, async () => { + const relay = fetchQuery(GetList, { id: '1' }); + await relay; + const list = relay.value!.list; + length = reactive(() => (list.ids as string[]).length); + expect(length()).toBe(0); + }); + + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: 'x', listId: '1' }, + }); + await sleep(5); + + await testWithClient(client, async () => { + expect(length()).toBe(1); + }); + }); + it('should increment on create and decrement on delete via applyEntityData', async () => { const { client, mockFetch } = getClient(); class Item extends Entity { From b8528e41c415a4436d53d9d9b59b22adbac80bd7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:15:08 -0400 Subject: [PATCH 026/109] fix(fetchium): suspend on the current fetch when a suspense hold's fetch already settled When the fetch a useSuspenseQuery hold waited for settled and another fetch started before the reader rendered (a refetch from elsewhere, a reconnect), the hold kept handing out its already-resolved promise. React re-rendered at once on each throw, spinning tens of thousands of renders until the new fetch landed. A hold whose fetch settled now starts over on the fetch in flight, with a fresh promise. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 11 +++++ .../__tests__/prefetch-suspense.test.tsx | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index c356848..08b7024 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -560,6 +560,17 @@ export class QueryClient { return { promise: undefined, key }; } + if (relay.isPending && hold.done) { + // The fetch this hold waited for settled, and another has started since + // (a refetch from elsewhere). Suspend on that one: the settled promise + // has already resolved, and throwing it again would re-render at once. + clearTimeout(hold.timer); + hold.timer = undefined; + hold.done = false; + hold.failed = false; + hold.settled = undefined; + } + if (!relay.isPending) { if (hold.done) { // The fetch this hold waited for failed. Keep the hold until the next diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx index cee950a..f6fc7a6 100644 --- a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -11,6 +11,8 @@ import { createMockFetch, sleep } from '../../__tests__/utils.js'; import type { MutationEvent } from '../../types.js'; import { useQuery } from '../use-query.js'; import { useSuspenseQuery } from '../use-suspense-query.js'; +import { withContexts } from 'signalium'; +import { fetchQuery } from '../../query.js'; class GetItem extends RESTQuery { path = '/item'; @@ -301,6 +303,51 @@ describe('useSuspenseQuery', () => { expect(mockFetch.calls).toHaveLength(2); }); + it('suspends on a refetch that started after the cold fetch failed, without re-rendering in a loop', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500, delay: 20 }); + mockFetch.get('/item', { name: 'second' }, { delay: 400 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + client.prefetch(GetNoRetry); + const relay = withContexts([[QueryClientContext, client]], () => fetchQuery(GetNoRetry)); + // Something else (another reader's retry, a reconnect) refetches as soon + // as the first attempt fails. + relay.then( + () => {}, + () => { + client.queryInstances.values().next().value!.refetch(); + }, + ); + + let renders = 0; + function Item(): React.ReactNode { + renders++; + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const screen = render( + + {}}> + Suspended}> + + + + , + ); + + await expect.element(screen.getByText('second'), { timeout: 3000 }).toBeInTheDocument(); + expect(renders).toBeLessThan(10); + expect(mockFetch.calls).toHaveLength(2); + }); + it('hands the query to the reader on commit, so unmounting deactivates it', async () => { const mockFetch = createMockFetch(); mockFetch.get('/streamed', { name: 'live' }, { delay: 10 }); From bc0b555cde6141da58c23218bfe05f5ea436f8e9 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:15:15 -0400 Subject: [PATCH 027/109] fix(fetchium): give a remount a new attempt after an abandoned suspense hold failed A hold's failed fetch was kept for SUSPENSE_HOLD_TTL (10 s) whether or not a render ever claimed the error. When the suspended tree was abandoned (the user navigated away) and the fetch then failed, mounting the screen again within that window threw the stale error with no new attempt, though a later mount is promised a fresh one. React retries a suspended render within a task or two of its promise settling, so a failure no render claims within 50 ms now drops the hold. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 34 ++++++++++++---- .../__tests__/prefetch-suspense.test.tsx | 39 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 08b7024..4a00126 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -121,6 +121,14 @@ export const DEFAULT_PREFETCH_TTL = 10_000; */ const SUSPENSE_HOLD_TTL = 10_000; +/** + * How long a failed cold fetch's error waits for a render to claim it. React + * retries a suspended render within a task or two of its promise settling; an + * error nobody claimed by then belongs to an abandoned tree, and a later mount + * makes a fresh attempt instead of inheriting it. + */ +const UNCLAIMED_FAILURE_TTL = 50; + interface SuspenseHold { release: () => void; /** Resolves (never rejects) once the cold fetch settles. */ @@ -535,7 +543,9 @@ export class QueryClient { * The reader's commit releases it (`releaseSuspenseHold`); otherwise it * releases itself `SUSPENSE_HOLD_TTL` after the fetch settles. A query whose * last fetch failed is refetched once per hold: when that attempt fails too, - * `error` is set and the hold is dropped, so the caller can throw it. + * `error` is set and the hold is dropped, so the caller can throw it. A + * failure no render claims within `UNCLAIMED_FAILURE_TTL` (the suspended + * tree was abandoned) drops the hold, so a later mount makes a new attempt. * * @internal */ @@ -594,12 +604,22 @@ export class QueryClient { if (hold.settled === undefined) { const current = hold; current.settled = new Promise(resolve => { - const settle = (): void => { - current.done = true; - this.expireSuspenseHold(key, current); - resolve(); - }; - relay.then(settle, settle); + relay.then( + () => { + current.done = true; + this.expireSuspenseHold(key, current); + resolve(); + }, + () => { + current.done = true; + // Dropped unless the retried render claims the error first. + clearTimeout(current.timer); + current.timer = setTimeout(() => { + if (this.suspenseHolds.get(key) === current && !current.failed) this.releaseSuspenseHold(key); + }, UNCLAIMED_FAILURE_TTL); + resolve(); + }, + ); }); } diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx index f6fc7a6..09160e9 100644 --- a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -348,6 +348,45 @@ describe('useSuspenseQuery', () => { expect(mockFetch.calls).toHaveLength(2); }); + it('makes a new attempt when a tree abandoned while suspended is mounted again after the fetch failed', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500, delay: 20 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const errors: unknown[] = []; + const tree = (show: boolean) => ( + + errors.push(e)}> + Suspended}>{show ? :
Other
}
+
+
+ ); + + const screen = render(tree(true)); + await expect.element(screen.getByText('Suspended')).toBeInTheDocument(); + // Navigated away while suspended; the fetch fails with no reader. + screen.rerender(tree(false)); + await sleep(150); + expect(mockFetch.calls).toHaveLength(1); + + mockFetch.get('/item', { name: 'recovered' }); + screen.rerender(tree(true)); + await expect.element(screen.getByText('recovered')).toBeInTheDocument(); + expect(mockFetch.calls).toHaveLength(2); + expect(errors).toEqual([]); + }); + it('hands the query to the reader on commit, so unmounting deactivates it', async () => { const mockFetch = createMockFetch(); mockFetch.get('/streamed', { name: 'live' }, { delay: 10 }); From 9003c44d2d7ca42b4af5c14cbcad369fa9d93282 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:18:26 -0400 Subject: [PATCH 028/109] fix(fetchium): resubscribe a never-loaded query before refetching it A TopicQuery whose first fetch was aborted (a prefetch or retain released before the snapshot arrived, now routine) never loaded. Reactivation skipped reconcileSubscription() for a query with no data and restarted the fetch from inside the activating read; TopicQueryAdapter.send() then waited on a deferred that only the torn-down subscription could fulfil. A topic whose snapshot was rejected hung the same way on its next activation. runQuery() now puts the subscription back before sending, and the restart of an aborted fetch takes the relay's promise at once (so the doomed one can't settle it) but runs the subscription and the request on a microtask, or when a lease starts them, outside the read. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 52 +++++++++-- .../src/__tests__/topic-query.test.ts | 87 ++++++++++++++++++- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 249c4f7..cf232e4 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -61,6 +61,8 @@ export class QueryInstance { private pendingDebouncedRun: (() => void) | undefined = undefined; /** initialize() queued startSubscriptionAndFetch() on a microtask that hasn't run yet. */ private startPending: boolean = false; + /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ + private pendingRestart: (() => void) | undefined = undefined; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; /** @@ -206,13 +208,12 @@ export class QueryInstance { } // If the relay shows pending but the abort controller is gone, the - // previous fetch was aborted during deactivation. runDebounced() - // would bail out because isPending is still true from the doomed - // promise. Force an immediate refetch so the new setPromise() call - // replaces _promise, causing the stale AbortError rejection to hit - // the `promise !== this._promise` guard and be silently ignored. + // previous fetch was aborted during deactivation (or never started). + // runDebounced() would bail out because isPending is still true from + // the doomed promise, which may also never settle (a topic query's + // send() waits on a subscription that is gone). if (this.relayState.isPending && this._abortController === undefined) { - this.runQueryImmediately(); + this.restartAbortedFetch(); } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; // The grace covers a relay resuming, not a network reconnect: data @@ -356,6 +357,7 @@ export class QueryInstance { const run = this.pendingDebouncedRun; this.pendingDebouncedRun = undefined; run?.(); + this.pendingRestart?.(); } private discardCorruptCache(error: unknown): void { @@ -467,6 +469,9 @@ export class QueryInstance { } const ctx = this.getOrCreateExecutionContext(); + // Put back a subscription a deactivation or pause tore down before + // sending: a topic query's send() waits for data its subscription brings. + this.reconcileSubscription(); const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; const attempt = this.attemptStatusTracker(ctx); @@ -501,6 +506,41 @@ export class QueryInstance { } } + /** + * Replaces a fetch that a deactivation aborted, from inside the activating + * read. The relay takes the new promise now, so the doomed one can no longer + * settle it, but the subscription and the request start on a microtask (or + * when a lease starts them), outside the read: both run adapter code. + */ + private restartAbortedFetch(): void { + this.fetchStarts++; + this._fetchNextAbort?.abort(); + this._fetchNextAbort = undefined; + this._fetchNextPromise = undefined; + const controller = new AbortController(); + this._abortController = controller; + + let resolve!: (value: QueryResult) => void; + let reject!: (error: unknown) => void; + const promise = new Promise>((res, rej) => { + resolve = res; + reject = rej; + }); + const run = (): void => { + if (this.pendingRestart !== run) return; + this.pendingRestart = undefined; + if (controller.signal.aborted) { + reject(controller.signal.reason); + return; + } + this.runQuery().then(resolve, reject); + }; + this.pendingRestart = run; + this.queryClient.noteDeferredStart(this); + queueMicrotask(run); + this.relayState.setPromise(promise); + } + private runQueryImmediately(): void { this.fetchStarts++; this._abortController?.abort(); diff --git a/packages/fetchium/src/__tests__/topic-query.test.ts b/packages/fetchium/src/__tests__/topic-query.test.ts index 49a2adb..51ec769 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { reactive } from 'signalium'; +import { reactive, withContexts } from 'signalium'; import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; -import { QueryClient } from '../QueryClient.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { t } from '../typeDefs.js'; import { Entity } from '../proxy.js'; import { fetchQuery } from '../query.js'; import { getMutation } from '../mutation.js'; import { RESTMutation } from '../rest/index.js'; import { reifyValue } from '../fieldRef.js'; -import { createMockFetch, testWithClient, sleep, getEntityMapSize } from './utils.js'; +import { createMockFetch, createTestWatcher, testWithClient, sleep, getEntityMapSize } from './utils.js'; import { TopicQuery } from '../topic/TopicQuery.js'; import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; @@ -2273,3 +2273,84 @@ describe('TopicQuery', () => { }); }); }); + +describe('TopicQuery reactivation before it ever loaded', () => { + class SlowTopicAdapter extends TopicQueryAdapter { + subscribes = 0; + reject = false; + private timers = new Map>(); + + subscribe(topic: string): void { + this.subscribes++; + this.timers.set( + topic, + setTimeout(() => { + if (this.reject) this.rejectTopic(topic, new Error('topic unavailable')); + else this.fulfillTopic(topic, { n: 1 }); + }, 30), + ); + } + + unsubscribe(topic: string): void { + clearTimeout(this.timers.get(topic)); + this.clearTopic(topic); + } + } + + class GetCount extends TopicQuery { + static override adapter = SlowTopicAdapter; + topic = 'count'; + result = { n: t.number }; + } + + let client: QueryClient; + let adapter: SlowTopicAdapter; + + beforeEach(() => { + adapter = new SlowTopicAdapter(); + client = new QueryClient({ store: new SyncQueryStore(new MemoryPersistentStore()), adapters: [adapter] }); + }); + + afterEach(() => { + client.destroy(); + }); + + function mount(): () => void { + return withContexts([[QueryClientContext, client]], () => createTestWatcher(() => fetchQuery(GetCount).value)) + .unsub; + } + + function relay() { + return withContexts([[QueryClientContext, client]], () => fetchQuery(GetCount)); + } + + it('loads when a reader mounts after a prefetch was released before the snapshot arrived', async () => { + const release = client.prefetch(GetCount); + await sleep(5); + release(); + await sleep(10); + + const unmount = mount(); + await sleep(100); + expect(adapter.subscribes).toBe(2); + expect(relay().isReady).toBe(true); + expect(relay().value!.n).toBe(1); + unmount(); + }); + + it('subscribes again before refetching a topic whose snapshot was rejected', async () => { + adapter.reject = true; + const unmount = mount(); + await sleep(60); + expect(relay().isRejected).toBe(true); + unmount(); + await sleep(10); + + adapter.reject = false; + const remount = mount(); + await sleep(100); + expect(adapter.subscribes).toBe(2); + expect(relay().isReady).toBe(true); + remount(); + }); +}); From 642baa934945a3ebca6b0e0d32ad8335e1ab8868 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Sat, 3 Oct 2026 22:22:07 -0400 Subject: [PATCH 029/109] fix(fetchium): bound lease lifetimes and clean them up on destroy - prefetch()'s ttl is now an upper bound. A lease whose fetch was in flight at expiry waited for it to settle, so a fetch that never settles (offline, a topic nobody fulfils) kept the query active forever. It now releases at the ttl, which aborts the fetch only when no reader has joined it. - destroy() cleared leases but not prefetch's separate ttl timer; prefetch now uses retain()'s ttl, which release() clears. - Releasing leases in destroy() deactivates their queries on Signalium's next flush, after gcManager.destroy(), and that deactivation created a fresh GC bucket interval nothing would clear. GcManager.schedule() is a no-op once destroyed. - retain() called inside a reactive computation took a new lease on every rerun. It now warns once in development, starts its fetches on their usual microtask instead of inside the computation, and releases the leases the computation's previous run took. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/fast-delivery-prefetch-retain.md | 2 +- docs/src/app/api/fetchium/page.md | 2 +- packages/fetchium/src/GcManager.ts | 5 +- packages/fetchium/src/QueryClient.ts | 83 +++++++--- .../src/__tests__/retain-prefetch.test.ts | 148 ++++++++++++++++-- 5 files changed, 201 insertions(+), 39 deletions(-) diff --git a/.changeset/fast-delivery-prefetch-retain.md b/.changeset/fast-delivery-prefetch-retain.md index a70c38d..a31fde9 100644 --- a/.changeset/fast-delivery-prefetch-retain.md +++ b/.changeset/fast-delivery-prefetch-retain.md @@ -2,7 +2,7 @@ 'fetchium': minor --- -Adds `queryClient.prefetch(QueryClass, params, { ttl })` and `queryClient.retain(fn, { ttl })`. Both keep queries active without a reader and return a `release` function. A reader that mounts while the lease is held joins the running query: no second request, and the data on its first render if it has arrived. `prefetch` defaults to a 10 s lease (`DEFAULT_PREFETCH_TTL`) and lets a fetch in flight at expiry finish; `retain` lasts until released. +Adds `queryClient.prefetch(QueryClass, params, { ttl })` and `queryClient.retain(fn, { ttl })`. Both keep queries active without a reader and return a `release` function. A reader that mounts while the lease is held joins the running query: no second request, and the data on its first render if it has arrived. `prefetch` defaults to a 10 s lease (`DEFAULT_PREFETCH_TTL`), an upper bound even with a fetch in flight; `retain` lasts until released. Adds `useSuspenseQuery` to `fetchium/react`. It suspends only when the query has never produced a value (in memory or in a synchronous store), never for a refetch, and throws a failed cold fetch to the error boundary. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 3e2b20c..1321712 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -252,7 +252,7 @@ new QueryClient(config: QueryClientConfig) #### `prefetch` and `retain` -Both take a lease: the queries stay active (fetched, subscribed, exempt from GC) without a component reading them. A reader that mounts while the lease is held joins the active query, so it starts no request of its own and renders the data on its first render if it has arrived. Both return an idempotent `release` function. Releasing never interrupts other readers; once the last one goes, the query deactivates and its `gcTime` starts as usual. Calling `release` while a fetch is in flight, with no reader, aborts that fetch. A `prefetch` whose `ttl` runs out mid-fetch instead waits for the fetch to settle before letting go. +Both take a lease: the queries stay active (fetched, subscribed, exempt from GC) without a component reading them. A reader that mounts while the lease is held joins the active query, so it starts no request of its own and renders the data on its first render if it has arrived. Both return an idempotent `release` function. Releasing never interrupts other readers; once the last one goes, the query deactivates and its `gcTime` starts as usual. Calling `release` while a fetch is in flight, with no reader, aborts that fetch. A `ttl` is an upper bound: the lease goes when it runs out even if the fetch is still in flight (offline, or a topic never fulfilled), which aborts the fetch unless a reader has joined it. Call `retain` and `prefetch` from event handlers or effects, not from inside a reactive computation; called from one, they warn in development and keep only the leases from the computation's latest run. `prefetch` is for the moment a user commits to a navigation, such as a tap. Call it in the handler, before navigating: diff --git a/packages/fetchium/src/GcManager.ts b/packages/fetchium/src/GcManager.ts index c3793b7..ba786b6 100644 --- a/packages/fetchium/src/GcManager.ts +++ b/packages/fetchium/src/GcManager.ts @@ -62,6 +62,8 @@ export class GcManager { private _nextTickScheduled = false; private _onEvict: (key: number, type: GcKeyType) => void; private _multiplier: number; + /** Set by destroy(). Queries that deactivate afterwards schedule nothing. */ + private _destroyed = false; constructor(onEvict: (key: number, type: GcKeyType) => void, multiplier: number = 1) { this._onEvict = onEvict; @@ -69,7 +71,7 @@ export class GcManager { } schedule(key: number, gcTime: number, type: GcKeyType): void { - if (gcTime === Infinity) return; + if (gcTime === Infinity || this._destroyed) return; if (gcTime === 0) { const { _nextTickEntries } = this; @@ -111,6 +113,7 @@ export class GcManager { }; destroy(): void { + this._destroyed = true; const { _buckets, _nextTickEntries } = this; for (const bucket of _buckets.values()) { bucket.destroy(); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 4a00126..159bd97 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1,4 +1,4 @@ -import { context, watcher, withContexts, ReactivePromise, ReactiveTask, type Context } from 'signalium'; +import { context, signal, watcher, withContexts, ReactivePromise, ReactiveTask, type Context } from 'signalium'; import { hashValue } from 'signalium/utils'; import { EntityDef, @@ -188,6 +188,12 @@ export class QueryClient { * starts them before returning. See `retain()`. */ private leaseStarts: Set> | undefined = undefined; + /** + * Leases taken by `retain()` calls inside a reactive computation, by the + * computation, with the run that took them. See `retain()`. + */ + private reactiveLeases = new WeakMap void> }>(); + private warnedReactiveRetain = false; /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ private suspenseHolds = new Map(); @@ -436,10 +442,32 @@ export class QueryClient { * query that depends on another's result (`fn` reads an id, then fetches by * it) starts once that result arrives, as with any reader. Call `retain` * from an event handler or effect, not from inside a reactive computation: - * starting a fetch runs adapter code. + * starting a fetch runs adapter code, and a computation that reruns would + * take a new lease on every run. Called from one anyway, it warns in + * development, starts its fetches on their usual microtask, and releases the + * leases the computation's previous run took. */ retain(fn: () => unknown, options?: RetainOptions): () => void { - return this.lease(fn, options, true); + const owner = currentReactiveOwner(); + if (owner === undefined) return this.lease(fn, options, true); + + if (IS_DEV && !this.warnedReactiveRetain) { + this.warnedReactiveRetain = true; + this.context.log?.warn?.( + 'QueryClient.retain() (or prefetch()) was called inside a reactive computation. Call it from an event handler or effect instead.', + ); + } + + const release = this.lease(fn, options, false); + const previous = this.reactiveLeases.get(owner.ref); + if (previous === undefined || previous.run !== owner.run) { + this.reactiveLeases.set(owner.ref, { run: owner.run, releases: [release] }); + // After taking the new lease, so queries both runs read stay active. + if (previous !== undefined) for (const prior of previous.releases) prior(); + } else { + previous.releases.push(release); + } + return release; } /** @@ -494,8 +522,10 @@ export class QueryClient { /** * Starts `QueryClass` with `params` now and keeps it active for `ttl` * milliseconds (default {@link DEFAULT_PREFETCH_TTL}), or until the returned - * `release` is called. A fetch still in flight when `ttl` runs out is let - * finish before the lease goes; `release()` lets go at once. Meant for the moment a user commits to a navigation + * `release` is called. The `ttl` is an upper bound: the lease goes when it + * runs out even if the fetch is still in flight (offline, or a topic that is + * never fulfilled), which aborts that fetch unless a reader has joined it. + * Meant for the moment a user commits to a navigation * (a tap): the destination's reader, mounting within the window, reuses the * in-flight or finished fetch instead of starting its own, and renders the * data on its first render if it has arrived. @@ -510,27 +540,9 @@ export class QueryClient { options?: PrefetchOptions, ): () => void { const def = QueryDefinition.for(QueryClass); - let relay: QueryPromise | undefined; - const release = this.retain(() => (relay = this.getQuery(def, params as QueryParams | undefined))); - - const ttl = options?.ttl ?? DEFAULT_PREFETCH_TTL; - if (Number.isFinite(ttl)) { - const timer = setTimeout( - () => { - // Expiring mid-fetch would abort a request that is about to land: - // hold until it settles. An explicit release() still lets go at once. - if (relay?.isPending) relay.then(release, release); - else release(); - }, - Math.max(0, ttl), - ); - return () => { - clearTimeout(timer); - release(); - }; - } - - return release; + return this.retain(() => this.getQuery(def, params as QueryParams | undefined), { + ttl: options?.ttl ?? DEFAULT_PREFETCH_TTL, + }); } /** @@ -955,6 +967,25 @@ export const QueryClientContext: Context = context {}; +/** + * The reactive computation (and its run) currently executing, if any. Signalium + * has no public API for this: a signal read records its consumer, so read a + * throwaway signal and look at what it recorded. Undefined when the shape + * isn't the expected one. + */ +function currentReactiveOwner(): { ref: object; run: number } | undefined { + try { + const probe = signal(0); + void probe.value; + const subs = (probe as unknown as { _subs?: Map })._subs; + if (!(subs instanceof Map) || subs.size === 0) return undefined; + const [ref, run] = subs.entries().next().value!; + return typeof ref === 'object' && ref !== null && typeof run === 'number' ? { ref, run } : undefined; + } catch { + return undefined; + } +} + /** * Reads the query promises `retain`'s callback returned, so the lease's * watcher depends on them and keeps them active. `isReady` changes once per diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 165bd43..8d19757 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -1,12 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { describe, it, expect, afterEach } from 'vitest'; -import { signal, watcher, withContexts } from 'signalium'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { reactive, signal, watcher, withContexts } from 'signalium'; import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; import { QueryClient, QueryClientContext, DEFAULT_PREFETCH_TTL } from '../QueryClient.js'; import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; import { t } from '../typeDefs.js'; import { fetchQuery, QueryDefinition } from '../query.js'; import type { MutationEvent } from '../types.js'; +import { GcManager, type GcKeyType } from '../GcManager.js'; +import { NetworkManager } from '../NetworkManager.js'; import { createMockFetch, sleep } from './utils.js'; /** @@ -266,21 +268,69 @@ describe('client.prefetch()', () => { unsub(); }); - it('a ttl that runs out mid-fetch waits for the fetch instead of aborting it', async () => { + it('a ttl that runs out mid-fetch releases the lease, aborting the fetch with no reader', async () => { const { client, mockFetch } = setup(); mockFetch.reset(); mockFetch.get('/streamed', { n: 9 }, { delay: 40 }); client.prefetch(GetStreamed, undefined, { ttl: 10 }); await sleep(25); - // Past the ttl, before the response (40 ms): still held, request not cancelled. - expect(unsubscribed).toBe(0); - expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + // The ttl is an upper bound: past it, before the response, the lease is gone. + expect(unsubscribed).toBe(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(40); + }); + + it('a ttl that runs out mid-fetch leaves a reader that joined the fetch running', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/streamed', { n: 9 }, { delay: 40 }); + client.prefetch(GetStreamed, undefined, { ttl: 10 }); + await sleep(5); + const { w, unsub } = mountReader(client, () => fetchQuery(GetStreamed).value); await sleep(60); expect(mockFetch.calls).toHaveLength(1); - // The fetch landed (an aborted one applies nothing), then the lease went. - const relay = client.getQuery(QueryDefinition.for(GetStreamed), undefined); - expect(relay.value).toMatchObject({ n: 9 }); - expect(unsubscribed).toBe(1); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(false); + expect(w.value).toMatchObject({ n: 9 }); + unsub(); + }); + + it('a ttl caps a lease whose fetch never settles', async () => { + vi.useFakeTimers(); + try { + const networkManager = new NetworkManager(false); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: createMockFetch() as any, baseUrl: 'http://localhost' })], + networkManager, + }); + clients.push(client); + // Offline: the relay stays pending. + client.prefetch(GetStreamed, undefined, { ttl: 1_000 }); + await vi.advanceTimersByTimeAsync(10); + expect(client.getQuery(QueryDefinition.for(GetStreamed), undefined).isPending).toBe(true); + await vi.advanceTimersByTimeAsync(2_000); + expect((client as unknown as { leases: Set }).leases.size).toBe(0); + client.destroy(); + clients = []; + await vi.advanceTimersByTimeAsync(1_000); + } finally { + vi.useRealTimers(); + } + }); + + it('destroy() clears the ttl timer', async () => { + vi.useFakeTimers(); + try { + const { client } = setup(); + client.prefetch(GetItem, undefined, { ttl: 60_000 }); + await vi.advanceTimersByTimeAsync(20); + client.destroy(); + clients = []; + await vi.advanceTimersByTimeAsync(50); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } }); it('defaults ttl to DEFAULT_PREFETCH_TTL', () => { @@ -441,6 +491,84 @@ describe('client.retain()', () => { expect(unsubscribed).toBe(1); }); + it('destroy() leaves no GC timer behind for the queries it releases', async () => { + const created: unknown[] = []; + const cleared = new Set(); + const realSetInterval = globalThis.setInterval; + const realClearInterval = globalThis.clearInterval; + vi.spyOn(globalThis, 'setInterval').mockImplementation(((fn: () => void, ms?: number) => { + const id = realSetInterval(fn, ms); + created.push(id); + return id; + }) as typeof setInterval); + vi.spyOn(globalThis, 'clearInterval').mockImplementation(((id: ReturnType) => { + cleared.add(id); + realClearInterval(id); + }) as typeof clearInterval); + + try { + const { client } = setup(); + // A real GcManager: the tests' window-less environment gets the no-op one. + (client as unknown as { gcManager: GcManager }).gcManager = new GcManager( + (client as unknown as { handleEviction: (key: number, type: GcKeyType) => void }).handleEviction, + 1, + ); + client.retain(() => fetchQuery(GetItem)); + await sleep(20); + client.destroy(); + clients = []; + // The released query deactivates on Signalium's next flush, after destroy(). + await sleep(50); + const leaked = created.filter(id => !cleared.has(id)); + for (const id of leaked) realClearInterval(id as ReturnType); + expect(leaked).toHaveLength(0); + } finally { + vi.restoreAllMocks(); + } + }); + + it('warns, and keeps one lease, when called from a reactive computation that reruns', async () => { + const warn = vi.fn(); + const mockFetch = createMockFetch(); + mockFetch.get('/item', () => ({ n: 1 })); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + log: { warn }, + }); + clients.push(client); + const leases = (client as unknown as { leases: Set }).leases; + + const tick = signal(0); + const computation = reactive(() => { + const value = tick.value; + client.retain(() => fetchQuery(GetItem)); + return value; + }); + const { w, unsub } = mountReader(client, () => computation()); + expect(warn).toHaveBeenCalledTimes(1); + for (let i = 1; i <= 5; i++) { + tick.value = i; + w.value; + await sleep(10); + } + expect(leases.size).toBe(1); + expect(mockFetch.calls).toHaveLength(1); + unsub(); + }); + + it('does not warn when called outside a reactive computation', () => { + const warn = vi.fn(); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: createMockFetch() as any, baseUrl: 'http://localhost' })], + log: { warn }, + }); + clients.push(client); + client.retain(() => fetchQuery(GetItem))(); + expect(warn).not.toHaveBeenCalled(); + }); + it('releases and rethrows when the callback throws', () => { const { client } = setup(); expect(() => From 86577fa0a4a55b7901f9784bb2c6e0f697071d2c Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 11:17:50 -0400 Subject: [PATCH 030/109] fix(fetchium): retry every failed attempt by default; keep shouldRetry opt-in Restores the previous retry default: every failed attempt is retried with the existing backoff, whatever its status, including all 4xx responses. The shouldRetry hook stays (QueryClientConfig.shouldRetry and per-query or per-mutation RetryConfig.shouldRetry, with getErrorStatus for reading a status), so an app can opt out per query where it knows a 4xx is permanent. Not retrying 4xx by default removed several seconds of retries that apps were relying on to recover queries that failed before their first load and had no other retry path. The burst of 400s that motivated the change was fixed at its source, so the default no longer needs to guard against it. defaultShouldRetry is removed (it was new in this branch); without a hook, withRetry no longer computes a status at all. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/should-retry-hook.md | 5 + .changeset/skip-client-error-retries.md | 7 - docs/src/app/api/fetchium/page.md | 2 +- docs/src/app/guides/error-handling/page.md | 19 +- packages/fetchium/src/QueryClient.ts | 12 +- .../src/__tests__/retry-should-retry.test.ts | 234 +++++++++++++----- packages/fetchium/src/index.ts | 2 +- packages/fetchium/src/retry.ts | 20 +- packages/fetchium/src/types.ts | 4 +- 9 files changed, 200 insertions(+), 105 deletions(-) create mode 100644 .changeset/should-retry-hook.md delete mode 100644 .changeset/skip-client-error-retries.md diff --git a/.changeset/should-retry-hook.md b/.changeset/should-retry-hook.md new file mode 100644 index 0000000..1aa0909 --- /dev/null +++ b/.changeset/should-retry-hook.md @@ -0,0 +1,5 @@ +--- +'fetchium': minor +--- + +Adds an optional `shouldRetry(error, attempt, status)` hook to `QueryClientConfig` and to `RetryConfig` (per query or mutation, overriding the client's). Return `false` to stop retrying an error you know is permanent, such as a 4xx from a given endpoint. `status` is the attempt's HTTP status when known: the response the adapter received (a REST error response whose body fails validation) or `status`, `statusCode` or `response.status` on the thrown error. Also exports `getErrorStatus`. Without a hook, retries are unchanged: every failed attempt is retried with the existing backoff. `RetryConfig.retries` is now optional and defaults as `retry: true` does. diff --git a/.changeset/skip-client-error-retries.md b/.changeset/skip-client-error-retries.md deleted file mode 100644 index ee46ed9..0000000 --- a/.changeset/skip-client-error-retries.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'fetchium': minor ---- - -Queries no longer retry client errors. A failed attempt whose HTTP status is 4xx (other than 408 and 429) now fails at once instead of being retried three times; network errors and 5xx responses still retry. The status comes from the response the adapter received (a REST error response whose body fails validation) or from `status`, `statusCode` or `response.status` on the thrown error. - -Adds `shouldRetry(error, attempt, status)` to `QueryClientConfig` and to `RetryConfig` (per query or mutation, overriding the client's) to decide retries yourself, and exports the default as `defaultShouldRetry` along with `getErrorStatus`. `RetryConfig.retries` is now optional and defaults as `retry: true` does. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 1321712..bcf3f80 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -235,7 +235,7 @@ new QueryClient(config: QueryClientConfig) | `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | | `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | | `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | -| `shouldRetry` | `(error, attempt, status) => boolean` | `defaultShouldRetry` | Whether a failed attempt is retried. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). The default retries network errors and 5xx but not 4xx other than 408 and 429. Per-query override: `retry.shouldRetry`. | +| `shouldRetry` | `(error, attempt, status) => boolean` | — | Optional. Whether a failed attempt is retried. Without it, every failed attempt is retried, whatever its status. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). Return `false` to stop retrying errors you know are permanent, such as a 4xx. Per-query override: `retry.shouldRetry`. | | `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | | `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index 2da961f..c44362b 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -147,17 +147,22 @@ class GetUser extends RESTQuery { The default retry delay uses exponential backoff --- each successive attempt waits longer than the last (roughly 1s, 2s, 4s, ...). This avoids hammering a struggling server with rapid retries. -Client errors are not retried: a `4xx` response fails on the first attempt, because repeating the same request gets the same answer. `408 Request Timeout` and `429 Too Many Requests` are the exceptions, and network errors and `5xx` responses are retried. The status comes from the response the adapter received (a REST error response whose body doesn't match `result`), or from `status`, `statusCode` or `response.status` on the thrown error. To decide yourself, pass `shouldRetry`, either per query in `retry` or for every query on the `QueryClient`: +Every failed attempt is retried, whatever its status, including `4xx` responses. When you know an error is permanent, opt out with `shouldRetry`, either per query in `retry` or for every query on the `QueryClient`. It receives the error, the attempt index and the attempt's HTTP status when known: the response the adapter received (a REST error response whose body doesn't match `result`), or `status`, `statusCode` or `response.status` on the thrown error. A query's `shouldRetry` overrides the client's. ```ts -const client = new QueryClient({ - adapters: [new RESTQueryAdapter({ baseUrl })], - // attempt starts at 0; status is undefined for network errors - shouldRetry: (error, attempt, status) => - status === undefined || status >= 500, -}); +class GetUser extends RESTQuery { + // ... + config = { + retry: { + // A 404 won't change on retry; status is undefined for network errors + shouldRetry: (error, attempt, status) => status !== 404, + }, + }; +} ``` +`getErrorStatus(error)` reads the status from an error, if you need it elsewhere. + To disable retries entirely: ```ts diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 159bd97..82dc8b0 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -26,7 +26,7 @@ import { ValidatorDef } from './typeDefs.js'; import { ConstraintMatcher, EVENT_SOURCE_FIELD } from './ConstraintMatcher.js'; import { LiveCollectionBinding } from './LiveCollection.js'; import { QueryAdapter, type QueryAdapterClass } from './QueryAdapter.js'; -import { defaultShouldRetry, type ShouldRetry } from './retry.js'; +import type { ShouldRetry } from './retry.js'; import { type QueryContext, type QueryStore, @@ -67,9 +67,9 @@ export interface QueryClientConfig { * Decides whether a failed query attempt (or a mutation attempt, when the * mutation enables retries) is retried. Receives the error, the attempt index * (starting at 0) and the attempt's HTTP status when known. A query's or - * mutation's own `retry.shouldRetry` overrides it. Default: - * `defaultShouldRetry`, which retries network errors and 5xx responses but - * not 4xx responses other than 408 and 429. + * mutation's own `retry.shouldRetry` overrides it. Optional: without it, + * every failed attempt is retried, whatever its status. Use it to stop + * retrying errors you know are permanent, such as a 4xx from an endpoint. */ shouldRetry?: ShouldRetry; /** @@ -174,7 +174,7 @@ export class QueryClient { /** See `QueryClientConfig.reactivationStaggerMs`. */ readonly reactivationStaggerMs: number; /** See `QueryClientConfig.shouldRetry`. */ - readonly shouldRetry: ShouldRetry; + readonly shouldRetry: ShouldRetry | undefined; /** Queries whose reactivation refetch waits for the current task's stagger flush. */ private staggerQueue = new Set>(); @@ -222,7 +222,7 @@ export class QueryClient { this.store = store; this.reactivationGraceMs = nonNegative(reactivationGraceMs); this.reactivationStaggerMs = nonNegative(reactivationStaggerMs); - this.shouldRetry = shouldRetry ?? defaultShouldRetry; + this.shouldRetry = shouldRetry; // `activity` and `pollResumeJitterMs` ride along in `rest`: poll() reads them from the context. this.context = { ...rest, log: log ?? console, evictionMultiplier }; this.gcManager = diff --git a/packages/fetchium/src/__tests__/retry-should-retry.test.ts b/packages/fetchium/src/__tests__/retry-should-retry.test.ts index d53abaf..9ded5a9 100644 --- a/packages/fetchium/src/__tests__/retry-should-retry.test.ts +++ b/packages/fetchium/src/__tests__/retry-should-retry.test.ts @@ -6,7 +6,7 @@ import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; import { fetchQuery } from '../query.js'; import { getMutation } from '../mutation.js'; import { NetworkManager } from '../NetworkManager.js'; -import { defaultShouldRetry, getErrorStatus, type ShouldRetry } from '../retry.js'; +import { getErrorStatus, type ShouldRetry } from '../retry.js'; import { createMockFetch, testWithClient } from './utils.js'; import { t } from '../typeDefs.js'; @@ -36,6 +36,15 @@ describe('shouldRetry', () => { config = { retry: { retries: 3, retryDelay: () => 1 } }; } + class CreateUser extends RESTMutation { + readonly path = '/users'; + readonly method = 'POST' as const; + readonly params = { name: t.string }; + readonly body = { name: this.params.name }; + readonly result = { id: t.number }; + config = { retry: { retries: 2, retryDelay: () => 1 } }; + } + async function runFailing(query: new () => RESTQuery = GetUser): Promise { let caught: unknown; await testWithClient(client, async () => { @@ -58,36 +67,26 @@ describe('shouldRetry', () => { }); describe('default', () => { - for (const status of [400, 401, 403, 404, 422]) { - it(`does not retry a ${status} response whose body fails validation`, async () => { + for (const status of [400, 401, 403, 404, 408, 422, 429, 500, 503]) { + it(`retries a ${status} response whose body fails validation`, async () => { mockFetch.get('/users/1', { error: 'nope' }, { status }); const error = await runFailing(); expect(error).toBeDefined(); - expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls).toHaveLength(4); }); } - it('does not retry an error that carries a 4xx status', async () => { + it('retries an error that carries a 4xx status', async () => { mockFetch.get('/users/1', null, { error: httpError(401) }); const error = await runFailing(); expect(error).toMatchObject({ response: { status: 401 } }); - expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls).toHaveLength(4); }); - for (const status of [408, 429, 500, 503]) { - it(`retries a ${status} response`, async () => { - mockFetch.get('/users/1', { error: 'busy' }, { status }); - - await runFailing(); - - expect(mockFetch.calls).toHaveLength(4); - }); - } - it('retries a 503 whose body is not JSON, then succeeds', async () => { mockFetch.get('/users/1', null, { status: 503, jsonError: new SyntaxError('Unexpected token <') }); mockFetch.get('/users/1', user); @@ -100,14 +99,6 @@ describe('shouldRetry', () => { expect(mockFetch.calls).toHaveLength(2); }); - it('retries an error that carries a 429 status', async () => { - mockFetch.get('/users/1', null, { error: httpError(429) }); - - await runFailing(); - - expect(mockFetch.calls).toHaveLength(4); - }); - it('retries network errors', async () => { mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); @@ -124,22 +115,7 @@ describe('shouldRetry', () => { expect(mockFetch.calls).toHaveLength(4); }); - it('does not take the status of an earlier response for a later network error', async () => { - // Fetch 1 succeeds; the refetch fails with a network error, which must - // not inherit a status from the response assigned by fetch 1. - mockFetch.get('/users/1', user); - mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); - - await testWithClient(client, async () => { - const result = fetchQuery(GetUser); - await result; - await result.value!.__refetch().catch(() => {}); - }); - - expect(mockFetch.calls).toHaveLength(5); - }); - - it('does not retry a 4xx page in fetchNext', async () => { + it('retries a 4xx page in fetchNext', async () => { class GetItems extends RESTQuery { path = '/items'; result = { items: t.array(t.string), nextPage: t.optional(t.number) }; @@ -155,30 +131,111 @@ describe('shouldRetry', () => { await expect(result.value!.__fetchNext()).rejects.toBeDefined(); }); - expect(mockFetch.calls).toHaveLength(2); + expect(mockFetch.calls).toHaveLength(5); + }); + + it('retries a mutation that enables retries, whatever the status', async () => { + mockFetch.post('/users', null, { error: httpError(422) }); + + await testWithClient(client, async () => { + const mut = getMutation(CreateUser); + await expect(mut.run({ name: 'Test' })).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(3); }); }); describe('custom', () => { + /** Opts out of retrying client errors other than 408 and 429. */ + const skipClientErrors: ShouldRetry = (_error, _attempt, status) => + status === undefined || status < 400 || status >= 500 || status === 408 || status === 429; + + function useSkipClientErrors(): void { + client.destroy(); + client = createClient(skipClientErrors); + } + it('client-level shouldRetry receives error, attempt and status', async () => { - const seen: Array<[number, number | undefined]> = []; + const seen: Array<[unknown, number, number | undefined]> = []; client.destroy(); - client = createClient((_error, attempt, status) => { - seen.push([attempt, status]); + client = createClient((error, attempt, status) => { + seen.push([error, attempt, status]); return attempt < 1; }); mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); - await runFailing(); + const error = await runFailing(); expect(mockFetch.calls).toHaveLength(2); - expect(seen).toEqual([ + expect(seen.map(([, attempt, status]) => [attempt, status])).toEqual([ [0, 404], [1, 404], ]); + expect(seen[1][0]).toBe(error); }); - it('query-level shouldRetry overrides the client default', async () => { + it('passes the status carried by the error', async () => { + const statuses: Array = []; + client.destroy(); + client = createClient((_error, _attempt, status) => { + statuses.push(status); + return true; + }); + mockFetch.get('/users/1', null, { error: httpError(401) }); + + await runFailing(); + + expect(statuses).toEqual([401, 401, 401]); + }); + + for (const status of [400, 401, 404, 422]) { + it(`client-level shouldRetry returning false for ${status} makes one attempt`, async () => { + useSkipClientErrors(); + mockFetch.get('/users/1', { error: 'nope' }, { status }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(1); + }); + } + + it('client-level shouldRetry returning false for a 4xx error makes one attempt', async () => { + useSkipClientErrors(); + mockFetch.get('/users/1', null, { error: httpError(401) }); + + const error = await runFailing(); + + expect(error).toMatchObject({ response: { status: 401 } }); + expect(mockFetch.calls).toHaveLength(1); + }); + + for (const status of [408, 429, 503]) { + it(`client-level shouldRetry still retries what it allows (${status})`, async () => { + useSkipClientErrors(); + mockFetch.get('/users/1', { error: 'busy' }, { status }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + }); + } + + it('query-level shouldRetry returning false for 4xx makes one attempt', async () => { + class GetUserSkip4xx extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { retry: { retries: 3, retryDelay: () => 1, shouldRetry: skipClientErrors } }; + } + mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); + + await runFailing(GetUserSkip4xx); + + expect(mockFetch.calls).toHaveLength(1); + }); + + it('query-level shouldRetry overrides the client-level one', async () => { + useSkipClientErrors(); class GetUserRetry4xx extends RESTQuery { path = '/users/1'; result = t.object({ id: t.string, name: t.string }); @@ -191,7 +248,7 @@ describe('shouldRetry', () => { expect(mockFetch.calls).toHaveLength(3); }); - it('query-level shouldRetry can stop retries the default would make', async () => { + it('query-level shouldRetry can stop retries of a network error', async () => { class GetUserNoRetry extends RESTQuery { path = '/users/1'; result = t.object({ id: t.string, name: t.string }); @@ -204,15 +261,68 @@ describe('shouldRetry', () => { expect(mockFetch.calls).toHaveLength(1); }); - it('applies to mutations', async () => { - class CreateUser extends RESTMutation { - readonly path = '/users'; - readonly method = 'POST' as const; - readonly params = { name: t.string }; - readonly body = { name: this.params.name }; - readonly result = { id: t.number }; - config = { retry: { retries: 2, retryDelay: () => 1 } }; + it('does not pass an earlier response status for a later network error', async () => { + // Attempt 0 gets a 404 response; the later attempts fail with network + // errors, which must report no status rather than the earlier 404. + const statuses: Array = []; + client.destroy(); + client = createClient((_error, _attempt, status) => { + statuses.push(status); + return true; + }); + mockFetch.get('/users/1', { error: 'nope' }, { status: 404 }); + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await runFailing(); + + expect(mockFetch.calls).toHaveLength(4); + expect(statuses).toEqual([404, undefined, undefined]); + }); + + it('does not pass the status of an earlier fetch for a refetch network error', async () => { + // Fetch 1 succeeds; the refetch fails with a network error, which must + // not inherit a status from the response assigned by fetch 1. + const statuses: Array = []; + client.destroy(); + client = createClient((_error, _attempt, status) => { + statuses.push(status); + return true; + }); + mockFetch.get('/users/1', user); + mockFetch.get('/users/1', null, { error: new TypeError('Network request failed') }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetUser); + await result; + await result.value!.__refetch().catch(() => {}); + }); + + expect(mockFetch.calls).toHaveLength(5); + expect(statuses).toEqual([undefined, undefined, undefined]); + }); + + it('client-level shouldRetry returning false for a 4xx page in fetchNext makes one attempt', async () => { + useSkipClientErrors(); + class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.string), nextPage: t.optional(t.number) }; + fetchNext = { searchParams: { page: this.result.nextPage } }; + config = { retry: { retries: 3, retryDelay: () => 1 } }; } + mockFetch.get('/items', { items: ['a'], nextPage: 2 }); + + await testWithClient(client, async () => { + const result = fetchQuery(GetItems); + await result; + mockFetch.get('/items', { error: 'bad page' }, { status: 400 }); + await expect(result.value!.__fetchNext()).rejects.toBeDefined(); + }); + + expect(mockFetch.calls).toHaveLength(2); + }); + + it('applies to mutations', async () => { + useSkipClientErrors(); mockFetch.post('/users', null, { error: httpError(422) }); await testWithClient(client, async () => { @@ -225,18 +335,6 @@ describe('shouldRetry', () => { }); }); -describe('defaultShouldRetry', () => { - it('classifies statuses', () => { - const err = new Error('x'); - expect(defaultShouldRetry(err, 0, undefined)).toBe(true); - expect(defaultShouldRetry(err, 0, 400)).toBe(false); - expect(defaultShouldRetry(err, 0, 404)).toBe(false); - expect(defaultShouldRetry(err, 0, 408)).toBe(true); - expect(defaultShouldRetry(err, 0, 429)).toBe(true); - expect(defaultShouldRetry(err, 0, 500)).toBe(true); - }); -}); - describe('getErrorStatus', () => { it('reads status, statusCode and response.status', () => { expect(getErrorStatus({ status: 404 })).toBe(404); diff --git a/packages/fetchium/src/index.ts b/packages/fetchium/src/index.ts index a11b55b..b482323 100644 --- a/packages/fetchium/src/index.ts +++ b/packages/fetchium/src/index.ts @@ -13,5 +13,5 @@ export type { Draft } from './utils.js'; export { NetworkManager, NoOpNetworkManager, defaultNetworkManager, NetworkManagerContext } from './NetworkManager.js'; export { GcManager, NoOpGcManager } from './GcManager.js'; export { Entity } from './proxy.js'; -export { defaultShouldRetry, getErrorStatus } from './retry.js'; +export { getErrorStatus } from './retry.js'; export type { ShouldRetry } from './retry.js'; diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index 451ab2c..37877ed 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -2,7 +2,7 @@ import type { ResolvedRetryConfig } from './query.js'; /** * Decides whether a failed attempt is retried. Consulted only while retries - * remain. + * remain. Without one, every failed attempt is retried. * * @param error What the attempt threw. * @param attempt Index of the failed attempt, starting at 0 (the first request). @@ -34,16 +34,8 @@ export function getFailedResponseStatus(response: unknown): number | undefined { return status !== undefined && status >= 400 ? status : undefined; } -/** - * Retries network errors, unknown errors and server errors (5xx). Does not - * retry client errors (4xx), which a repeat of the same request would hit - * again, except 408 Request Timeout and 429 Too Many Requests. - */ -export const defaultShouldRetry: ShouldRetry = (_error, _attempt, status) => - status === undefined || status < 400 || status >= 500 || status === 408 || status === 429; - export interface WithRetryOptions { - /** Used when the retry config has no `shouldRetry`. Default: `defaultShouldRetry`. */ + /** Used when the retry config has no `shouldRetry`. Without either, every failed attempt is retried. */ shouldRetry?: ShouldRetry; /** * Called after an attempt fails. Returns the HTTP status the attempt @@ -94,7 +86,7 @@ export async function withRetry( throw new Error('retries must be non-negative'); } const retries = Math.max(0, config.retries); - const shouldRetry = config.shouldRetry ?? options?.shouldRetry ?? defaultShouldRetry; + const shouldRetry = config.shouldRetry ?? options?.shouldRetry; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { if (signal?.aborted) { @@ -105,8 +97,10 @@ export async function withRetry( } catch (error) { lastError = error; if (attempt >= retries) throw error; - const status = getErrorStatus(error) ?? options?.getAttemptStatus?.(); - if (!shouldRetry(error, attempt, status)) throw error; + if (shouldRetry !== undefined) { + const status = getErrorStatus(error) ?? options?.getAttemptStatus?.(); + if (!shouldRetry(error, attempt, status)) throw error; + } await sleep(config.retryDelay(attempt), signal); } } diff --git a/packages/fetchium/src/types.ts b/packages/fetchium/src/types.ts index e44d3f4..9b873bb 100644 --- a/packages/fetchium/src/types.ts +++ b/packages/fetchium/src/types.ts @@ -70,8 +70,8 @@ export interface RetryConfig { /** * Decides whether a failed attempt is retried, from the error, the attempt * index (starting at 0) and the attempt's HTTP status when known. Overrides - * `QueryClientConfig.shouldRetry`. Default: `defaultShouldRetry`, which does - * not retry 4xx responses other than 408 and 429. + * `QueryClientConfig.shouldRetry`. Without either, every failed attempt is + * retried. */ shouldRetry?: (error: unknown, attempt: number, status: number | undefined) => boolean; } From a7fcbd3c197b9bd8704554681beabe86fafa0e8c Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:48 -0400 Subject: [PATCH 031/109] chore(fetchium): add a changeset for the reactivation and polling options This change had no changeset, so its new QueryClient options would have been missing from the release notes. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/quiet-reactivation.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/quiet-reactivation.md diff --git a/.changeset/quiet-reactivation.md b/.changeset/quiet-reactivation.md new file mode 100644 index 0000000..78c626e --- /dev/null +++ b/.changeset/quiet-reactivation.md @@ -0,0 +1,10 @@ +--- +'fetchium': minor +--- + +New `QueryClient` options that make coming back to a screen, or to the app, quieter. All are off by default. + +- `reactivationGraceMs` (also per query): a query that mounts again with data younger than this doesn't refetch. Data a live subscription kept current counts as fresh. +- `reactivationStaggerMs`: refetches from queries that mount together are spread across this window instead of all starting at once. +- `activity: { isActive(), subscribe(listener) }`: `poll()` stops while the app is inactive and resumes when it's active again. On React Native, wrap `AppState`. `pollResumeJitterMs` spreads out polls that were overdue on resume. +- Pass the topic to `TopicQueryAdapter.sendMutationEvent(event, topic)` so that topic's query counts the push as fresh data. From 887d5f86aedb507ed8278a765a880f0d62a5f8e2 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:48 -0400 Subject: [PATCH 032/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/fast-delivery-prefetch-retain.md | 9 ++++----- .changeset/should-retry-hook.md | 4 +++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/fast-delivery-prefetch-retain.md b/.changeset/fast-delivery-prefetch-retain.md index a31fde9..7a5a507 100644 --- a/.changeset/fast-delivery-prefetch-retain.md +++ b/.changeset/fast-delivery-prefetch-retain.md @@ -2,10 +2,9 @@ 'fetchium': minor --- -Adds `queryClient.prefetch(QueryClass, params, { ttl })` and `queryClient.retain(fn, { ttl })`. Both keep queries active without a reader and return a `release` function. A reader that mounts while the lease is held joins the running query: no second request, and the data on its first render if it has arrived. `prefetch` defaults to a 10 s lease (`DEFAULT_PREFETCH_TTL`), an upper bound even with a fetch in flight; `retain` lasts until released. +New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before any component reads them. A component that mounts while one is held reuses its request and renders the data on its first render if it has arrived. Both return a `release` function; `prefetch` lasts 10 seconds by default and `retain` until released. -Adds `useSuspenseQuery` to `fetchium/react`. It suspends only when the query has never produced a value (in memory or in a synchronous store), never for a refetch, and throws a failed cold fetch to the error boundary. +New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no data yet, and sends a failed first fetch to the error boundary. -A refetch with no `debounce` (a stale query reactivating, a Signal param change) now starts on a microtask instead of after `setTimeout(0)`, which on React Native can wait a frame. Invalidation still waits a task, so a query whose last reader left in the same task is deactivated rather than refetched. - -Docs no longer claim that reading `.value` on a pending or rejected query suspends or throws; it does neither. +- A refetch without `debounce` starts on a microtask instead of after `setTimeout(0)`. +- Reading `.value` on a pending or failed query doesn't suspend or throw. The docs said it did. diff --git a/.changeset/should-retry-hook.md b/.changeset/should-retry-hook.md index 1aa0909..be5b0ab 100644 --- a/.changeset/should-retry-hook.md +++ b/.changeset/should-retry-hook.md @@ -2,4 +2,6 @@ 'fetchium': minor --- -Adds an optional `shouldRetry(error, attempt, status)` hook to `QueryClientConfig` and to `RetryConfig` (per query or mutation, overriding the client's). Return `false` to stop retrying an error you know is permanent, such as a 4xx from a given endpoint. `status` is the attempt's HTTP status when known: the response the adapter received (a REST error response whose body fails validation) or `status`, `statusCode` or `response.status` on the thrown error. Also exports `getErrorStatus`. Without a hook, retries are unchanged: every failed attempt is retried with the existing backoff. `RetryConfig.retries` is now optional and defaults as `retry: true` does. +New optional `shouldRetry(error, attempt, status)` on `QueryClientConfig` and on a query's or mutation's `retry` config. Return `false` to stop retrying an error you know is permanent, such as a 404. + +`status` is the HTTP status when it is known, and `getErrorStatus` is exported. Without the hook, retries work as before. `retry.retries` is now optional. From 7f784a82b396b32bcd26787268c7a606039b5e4c Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:29:56 -0400 Subject: [PATCH 033/109] fix(fetchium): optional store hooks for acknowledged, merged and checked entity writes Extends the QueryStore contract with optional methods the client uses to keep write skipping correct. A store without them keeps the pre-0.6 behavior (every apply writes the whole record). - onDelete and the new onPersisted return an unsubscribe. - isSettled(): whether every write handed to the store has been applied; while false the client does not skip entity writes. - hasEntity(key): whether the store holds a record, so a streamed event can refresh the record of an entity that is no longer in memory. - mergeEntity(key, fields, refIds): merges the fields streamed events carried over the stored record (mergeStoredRecord in stores/shared.ts), deriving the references from the merged value. SyncQueryStore shares delete listeners between instances over the same kv, answers hasEntity from the kv and implements mergeEntity. AsyncQueryStore writers acknowledge processed SaveEntity messages, report a failed one as a delete, answer hasEntity from the keys read at start, run purgeStaleQueries through the serial queue, drop malformed channel messages and keep a persisted LRU size for messages without maxCount. Both stores resize a persisted LRU queue into a new buffer instead of throwing (growing) or orphaning entries (shrinking), never evicting the key being activated, and stop loading forever on records that reference each other in a cycle. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/app/api/stores-async/page.md | 24 ++- docs/src/app/api/stores-sync/page.md | 19 +- docs/src/app/data/caching/page.md | 2 +- packages/fetchium/src/query-types.ts | 43 ++++- packages/fetchium/src/stores/async.ts | 256 ++++++++++++++++++++++--- packages/fetchium/src/stores/shared.ts | 44 ++++- packages/fetchium/src/stores/sync.ts | 55 +++++- 7 files changed, 395 insertions(+), 48 deletions(-) diff --git a/docs/src/app/api/stores-async/page.md b/docs/src/app/api/stores-async/page.md index d6e5f25..bbe2c6b 100644 --- a/docs/src/app/api/stores-async/page.md +++ b/docs/src/app/api/stores-async/page.md @@ -41,14 +41,19 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. | -| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | -| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. Only operates when a `delegate` is available. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Dispatches a save-entity message with `merge: true`; the writer merges `fields` over the stored record, if there is one (see `SyncQueryStore.mergeEntity`). | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. A queue persisted with a different `maxCount` is resized by the writer: growing reallocates it, shrinking evicts the keys that no longer fit. | +| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | +| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. In writer mode it runs through the same serial queue as the messages, after everything queued before it; a reader with its own `delegate` runs it directly. Only operates when a `delegate` is available. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every record the writer drops on its own (an LRU eviction, a reference-count cascade, a purge) or fails to write. Returns an unsubscribe function. | +| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every entity record a queued write has stored. Returns an unsubscribe function. | +| `isSettled` | `(): boolean` | Whether every operation handed to the writer has been applied (nothing queued, nothing mid-flight). Always `true` in reader mode. | +| `hasEntity` | `(key: number): boolean \| undefined` | Writer mode only. Whether the delegate holds a record for the key, answered from a set the writer reads from the delegate when it starts (alongside its queue, not ahead of it) and keeps current with every write it queues and every deletion it performs. `undefined` for a key it has not written itself until that read lands; after a read that keeps failing, nothing is assumed held. | --- @@ -126,6 +131,7 @@ type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; + maxCount?: number; // absent from readers built before 0.6; the writer then keeps the queue's size refIds?: number[]; } | { @@ -133,12 +139,14 @@ type StoreMessage = entityKey: number; value: unknown; refIds?: number[]; + merge?: boolean; // `value` holds only some fields: merge them over the stored record } | { type: 2; // ActivateQuery queryDefId: string; queryKey: number; cacheTime: number; + maxCount?: number; } | { type: 3; // DeleteQuery diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index 6135588..c4f4235 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,14 +32,17 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | +| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | --- diff --git a/docs/src/app/data/caching/page.md b/docs/src/app/data/caching/page.md index feacbd1..c3582a0 100644 --- a/docs/src/app/data/caching/page.md +++ b/docs/src/app/data/caching/page.md @@ -133,7 +133,7 @@ class GetUser extends RESTQuery { } ``` -`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. +`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. Changing `maxCount` between releases resizes the queue already persisted for the class the first time the store touches that class after starting: growing keeps every entry, shrinking evicts the oldest entries that no longer fit. --- diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index a4246ea..4578cc9 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -111,14 +111,53 @@ export interface QueryStore { saveEntity(entityKey: number, value: unknown, refIds?: Set): void; + /** + * Writes the fields streamed events supplied for an entity built from them: + * a store that holds a record for the key merges `fields` over it (and + * derives the record's references from the merged value); one that does not + * writes `fields` as the record. A store without this method gets the + * entity's whole in-memory data through `saveEntity` instead, which drops + * from its record the fields the events did not carry. + */ + mergeEntity?(entityKey: number, fields: unknown, refIds?: Set): void; + activateQuery(queryDef: QueryDefinition, storageKey: number): void; deleteQuery(queryKey: number): void; purgeStaleQueries?(): MaybePromise; - /** Called with the key of every record the store drops on its own (eviction, cascade, purge). */ - onDelete?(listener: (key: number) => void): void; + /** + * Called with the key of every record the store drops on its own (eviction, + * cascade, purge). May return an unsubscribe function; the client calls it + * from `destroy()`. A store without this hook is written on every apply. + */ + onDelete?(listener: (key: number) => void): void | (() => void); + + /** + * For stores that process writes asynchronously: called with the key of + * every entity record once its write has actually been applied. While a + * store offers this, the client treats an entity as persisted only after + * the acknowledgement, so a write the store dropped is retried. + */ + onPersisted?(listener: (key: number) => void): void | (() => void); + + /** + * For stores that process writes asynchronously: whether every operation + * handed to the store has been processed. While it returns `false` the + * client does not skip entity writes, since an operation still queued could + * delete the record the skip relies on. Absent means always settled. + */ + isSettled?(): boolean; + + /** + * Whether the store currently holds a record for this entity key, or + * `undefined` while it cannot tell yet (a store that has not finished + * reading what it holds). A streamed event for an entity that is not in + * memory refreshes the record only when this answers `true`; a store + * without this hook is not written for such events. + */ + hasEntity?(key: number): boolean | undefined; } export type MaybePromise = T | Promise; diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 614a536..3946c5d 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -6,10 +6,12 @@ import { DEFAULT_MAX_COUNT, LAST_USED_PREFIX, lastUsedKeyFor, + mergeStoredRecord, queueKeyFor, refCountKeyFor, refIdsKeyFor, updatedAtKeyFor, + VALUE_PREFIX, valueKeyFor, } from './shared.js'; @@ -49,11 +51,19 @@ export type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; - maxCount: number; + /** Absent on the wire from readers built before 0.6; the writer then keeps the persisted queue's size. */ + maxCount?: number; refIds?: number[]; } - | { type: StoreMessageType.SaveEntity; entityKey: number; value: unknown; refIds?: number[] } - | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount: number } + | { + type: StoreMessageType.SaveEntity; + entityKey: number; + value: unknown; + refIds?: number[]; + /** `value` holds only some fields; the writer merges them over the stored record, if any. */ + merge?: boolean; + } + | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount?: number } | { type: StoreMessageType.DeleteQuery; queryKey: number }; export interface AsyncQueryStoreConfig { @@ -63,6 +73,42 @@ export interface AsyncQueryStoreConfig { }; delegate?: AsyncPersistentStore; // Only provided for writer } + +/** + * Writer-internal work that runs through the same serial queue as the wire + * messages, so it is ordered with respect to the writes and deletions around + * it. Never sent over the wire, and distinguishable from anything that is. + */ +class InternalWork { + constructor( + readonly run: () => Promise, + readonly resolve: () => void, + readonly reject: (error: unknown) => void, + ) {} +} + +type QueuedWork = StoreMessage | InternalWork; + +/** Whether something received on the channel is a message this writer handles. */ +function isStoreMessage(msg: unknown): msg is StoreMessage { + if (typeof msg !== 'object' || msg === null) return false; + const type = (msg as { type?: unknown }).type; + return ( + type === StoreMessageType.SaveQuery || + type === StoreMessageType.SaveEntity || + type === StoreMessageType.ActivateQuery || + type === StoreMessageType.DeleteQuery + ); +} + +function subscribe(listeners: Array<(key: number) => void>, listener: (key: number) => void): () => void { + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx !== -1) listeners.splice(idx, 1); + }; +} + // ----------------------------------------------------------------------------- // Async QueryStore Implementation // ----------------------------------------------------------------------------- @@ -71,18 +117,39 @@ export class AsyncQueryStore implements QueryStore { private readonly isWriter: boolean; private readonly delegate?: AsyncPersistentStore; private readonly sendMessage: (msg: StoreMessage) => void; - private readonly messageQueue: StoreMessage[] = []; + private readonly messageQueue: QueuedWork[] = []; private readonly queues: Map = new Map(); private queueProcessorPromise?: Promise; private resolveQueueWait?: () => void; private deleteListeners: Array<(key: number) => void> = []; - // Only the writer sees deletions; a reader offers no hook, so the client writes every apply. - onDelete?: (listener: (key: number) => void) => void; + private persistedListeners: Array<(key: number) => void> = []; + private processing = false; + // The ids whose value the delegate holds (or is about to: a queued write + // counts), mirrored by the writer so that `hasEntity` can answer + // synchronously. Read from the delegate once at start, without holding up + // the queue; until that read lands, writes and deletions are noted aside + // and folded in. + private heldKeys: Set | undefined; + private heldSinceScan: Set | undefined = new Set(); + private droppedSinceScan: Set | undefined = new Set(); + // Only the writer sees deletions and completed writes; a reader offers no + // hook, so the client writes every apply. + onDelete?: (listener: (key: number) => void) => () => void; + onPersisted?: (listener: (key: number) => void) => () => void; + hasEntity?: (key: number) => boolean | undefined; constructor(config: AsyncQueryStoreConfig) { this.isWriter = config.isWriter; this.delegate = config.delegate; - if (this.isWriter) this.onDelete = listener => this.deleteListeners.push(listener); + if (this.isWriter) { + this.onDelete = listener => subscribe(this.deleteListeners, listener); + this.onPersisted = listener => subscribe(this.persistedListeners, listener); + this.hasEntity = key => { + if (this.heldKeys !== undefined) return this.heldKeys.has(key); + // Not read yet: only what this writer itself wrote is known. + return this.heldSinceScan!.has(key) ? true : undefined; + }; + } // Connect and get sendMessage function const { sendMessage } = config.connect(this.handleMessage.bind(this)); @@ -93,19 +160,29 @@ export class AsyncQueryStore implements QueryStore { if (!this.delegate) { throw new Error('Writer must have a delegate'); } + void this.scanHeldKeys(); this.startQueueProcessor(); } } + /** Queues writer-internal work behind everything already queued. */ + private runInternal(run: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.enqueueMessage(new InternalWork(run, resolve, reject)); + }); + } + private handleMessage(msg: StoreMessage): void { - if (this.isWriter) { - // Enqueue the message for serial processing - this.enqueueMessage(msg); + if (!this.isWriter) return; // Readers don't handle incoming messages + if (!isStoreMessage(msg)) { + console.error('Ignoring a message the store does not understand:', msg); + return; } - // Readers don't handle incoming messages + // Enqueue the message for serial processing + this.enqueueMessage(msg); } - private enqueueMessage(msg: StoreMessage): void { + private enqueueMessage(msg: QueuedWork): void { this.messageQueue.push(msg); // Wake up the queue processor if it's waiting if (this.resolveQueueWait) { @@ -116,12 +193,29 @@ export class AsyncQueryStore implements QueryStore { private dispatch(msg: StoreMessage): void { if (this.isWriter) { + if (msg.type === StoreMessageType.SaveEntity) this.noteHeld(msg.entityKey); this.enqueueMessage(msg); } else { this.sendMessage(msg); } } + private noteHeld(id: number): void { + if (this.heldKeys !== undefined) this.heldKeys.add(id); + else { + this.heldSinceScan!.add(id); + this.droppedSinceScan!.delete(id); + } + } + + private noteDropped(id: number): void { + if (this.heldKeys !== undefined) this.heldKeys.delete(id); + else { + this.heldSinceScan!.delete(id); + this.droppedSinceScan!.add(id); + } + } + private startQueueProcessor(): void { this.queueProcessorPromise = this.processQueue(); } @@ -138,16 +232,76 @@ export class AsyncQueryStore implements QueryStore { // Process one message at a time const msg = this.messageQueue.shift()!; + this.processing = true; try { - await this.processMessage(msg); + if (msg instanceof InternalWork) { + try { + await msg.run(); + msg.resolve(); + } catch (error) { + msg.reject(error); + } + } else { + await this.processMessage(msg); + } } catch (error) { console.error('Error processing message:', error); + // A failed entity write leaves the record missing or stale, which to + // the client is the same as the store having dropped it: it must + // write the entity again on its next apply. + if (!(msg instanceof InternalWork) && msg.type === StoreMessageType.SaveEntity) { + this.noteDropped(msg.entityKey); + for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](msg.entityKey); + } + } finally { + this.processing = false; } } } + /** + * Reads which records the delegate holds, alongside the queue rather than + * ahead of it. Writes and deletions processed meanwhile are folded in. + * A read that fails is retried; if it keeps failing, nothing is assumed + * held (an event for an entity not in memory is then not written). + */ + private async scanHeldKeys(): Promise { + const held = new Set(); + for (let attempt = 0; ; attempt++) { + try { + for (const key of await this.delegate!.getAllKeys()) { + if (!key.startsWith(VALUE_PREFIX)) continue; + const id = Number(key.slice(VALUE_PREFIX.length)); + if (Number.isInteger(id)) held.add(id); + } + break; + } catch (error) { + if (attempt >= 2) { + console.error('Could not read the keys the store holds:', error); + held.clear(); + break; + } + await new Promise(resolve => setTimeout(resolve, 50 << attempt)); + } + } + for (const id of this.heldSinceScan!) held.add(id); + for (const id of this.droppedSinceScan!) held.delete(id); + this.heldKeys = held; + this.heldSinceScan = this.droppedSinceScan = undefined; + } + + /** + * Whether every operation handed to this store has been applied. Readers + * hand their operations to a writer elsewhere and never skip writes anyway. + */ + isSettled(): boolean { + return !this.isWriter || (this.messageQueue.length === 0 && !this.processing); + } + private async processMessage(msg: StoreMessage): Promise { switch (msg.type) { + // `maxCount` is absent from messages sent by readers built before it + // was added; the writer then keeps whatever size the queue has. case StoreMessageType.SaveQuery: await this.writerSaveQuery( msg.queryDefId, @@ -160,7 +314,8 @@ export class AsyncQueryStore implements QueryStore { ); break; case StoreMessageType.SaveEntity: - await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds); + await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true); + for (let i = 0; i < this.persistedListeners.length; i++) this.persistedListeners[i](msg.entityKey); break; case StoreMessageType.ActivateQuery: await this.writerActivateQuery(msg.queryDefId, msg.queryKey, msg.cacheTime, msg.maxCount); @@ -213,6 +368,9 @@ export class AsyncQueryStore implements QueryStore { } for (const entityId of entityIds) { + // Records can reference each other in a cycle (a file whose record + // links back to its folder); one read per record. + if (preloaded.has(entityId)) continue; const entityValue = await this.delegate.getString(valueKeyFor(entityId)); if (entityValue === undefined) { @@ -253,14 +411,22 @@ export class AsyncQueryStore implements QueryStore { } saveEntity(entityKey: number, value: unknown, refIds?: Set): void { - const message: StoreMessage = { + this.dispatch({ type: StoreMessageType.SaveEntity, entityKey, value, refIds: refIds ? Array.from(refIds) : undefined, - }; + }); + } - this.dispatch(message); + mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void { + this.dispatch({ + type: StoreMessageType.SaveEntity, + entityKey, + value: fields, + refIds: refIds ? Array.from(refIds) : undefined, + merge: true, + }); } activateQuery(queryDef: QueryDefinition, queryKey: number): void { @@ -292,7 +458,7 @@ export class AsyncQueryStore implements QueryStore { value: unknown, updatedAt: number, cacheTime: number, - maxCount: number, + maxCount: number | undefined, refIds?: number[], ): Promise { await this.setValue(queryKey, value, refIds ? new Set(refIds) : undefined); @@ -300,7 +466,20 @@ export class AsyncQueryStore implements QueryStore { await this.writerActivateQuery(queryDefId, queryKey, cacheTime, maxCount); } - private async writerSaveEntity(entityKey: number, value: unknown, refIds?: number[]): Promise { + private async writerSaveEntity( + entityKey: number, + value: unknown, + refIds: number[] | undefined, + merge: boolean, + ): Promise { + if (merge) { + const stored = await this.delegate!.getString(valueKeyFor(entityKey)); + const merged = stored !== undefined ? mergeStoredRecord(stored, value) : undefined; + if (merged !== undefined) { + await this.setValue(entityKey, merged.value, merged.refIds); + return; + } + } await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined); } @@ -308,7 +487,7 @@ export class AsyncQueryStore implements QueryStore { queryDefId: string, queryKey: number, cacheTime: number, - maxCount: number, + maxCount: number | undefined, ): Promise { if (!(await this.delegate!.has(valueKeyFor(queryKey)))) { return; @@ -321,10 +500,25 @@ export class AsyncQueryStore implements QueryStore { queue = await this.delegate!.getBuffer(queueKey); if (queue === undefined) { - queue = new Uint32Array(maxCount); + queue = new Uint32Array(maxCount ?? DEFAULT_MAX_COUNT); await this.delegate!.setBuffer(queueKey, queue); - } else if (queue.length !== maxCount) { - queue = new Uint32Array(queue.buffer, 0, maxCount); + } else if (maxCount !== undefined && queue.length !== maxCount) { + // `maxCount` changed since the queue was written (until 0.6 the writer + // always used the default, so every older install has a 50-slot queue). + // A view over the old buffer cannot grow past it, and a shorter view + // would silently strand the keys it drops: copy what fits and evict + // the rest properly. The key being activated is about to move to the + // front, so it is kept whichever slot it sits in. + const resized = new Uint32Array(maxCount); + resized.set(queue.subarray(0, Math.min(queue.length, maxCount))); + for (let i = maxCount; i < queue.length; i++) { + const dropped = queue[i]; + if (dropped !== 0 && dropped !== queryKey) { + await this.writerDeleteValue(dropped); + await this.delegate!.delete(updatedAtKeyFor(dropped)); + } + } + queue = resized; await this.delegate!.setBuffer(queueKey, queue); } @@ -355,7 +549,19 @@ export class AsyncQueryStore implements QueryStore { } } - async purgeStaleQueries(): Promise { + /** + * Drops every query definition's cached queries once the definition has not + * been used for its `cacheTime`. Runs through the writer's serial queue, so + * its deletions are ordered with the writes and acknowledgements around + * them: a record it drops cannot be reported as written afterwards. + */ + purgeStaleQueries(): Promise { + if (!this.delegate) return Promise.resolve(); + if (!this.isWriter) return this.purgeStaleQueriesNow(); + return this.runInternal(() => this.purgeStaleQueriesNow()); + } + + private async purgeStaleQueriesNow(): Promise { if (!this.delegate) return; const allKeys = await this.delegate.getAllKeys(); @@ -393,6 +599,7 @@ export class AsyncQueryStore implements QueryStore { const delegate = this.delegate!; await delegate.setString(valueKeyFor(id), JSON.stringify(value)); + this.noteHeld(id); const refIdsKey = refIdsKeyFor(id); @@ -442,6 +649,7 @@ export class AsyncQueryStore implements QueryStore { const refIdsKey = refIdsKeyFor(id); await delegate.delete(valueKeyFor(id)); + this.noteDropped(id); await delegate.delete(refCountKeyFor(id)); for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](id); diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index 2a94976..e2ed01c 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -1,5 +1,6 @@ // Query Instance keys -export const valueKeyFor = (id: number) => `sq:doc:value:${id}`; +export const VALUE_PREFIX = 'sq:doc:value:'; +export const valueKeyFor = (id: number) => `${VALUE_PREFIX}${id}`; export const refCountKeyFor = (id: number) => `sq:doc:refCount:${id}`; export const refIdsKeyFor = (id: number) => `sq:doc:refIds:${id}`; export const updatedAtKeyFor = (id: number) => `sq:doc:updatedAt:${id}`; @@ -15,3 +16,44 @@ export const LAST_USED_PREFIX = 'sq:doc:lastUsed:'; export const DEFAULT_MAX_COUNT = 50; export const DEFAULT_CACHE_TIME = 60 * 24; // 24 hours in minutes export const DEFAULT_GC_TIME = 5; // 5 minutes - in-memory eviction default + +/** + * Merges the fields of a partial record over a stored one, at the JSON level + * (proxies have already been serialized to `{ __entityRef }` markers), and + * derives the merged record's references from the markers it contains. + */ +export function mergeStoredRecord( + stored: string, + partial: unknown, +): { value: Record; refIds: Set | undefined } | undefined { + let record: unknown; + try { + record = JSON.parse(stored); + } catch { + return undefined; + } + // A record that is not an object is not one to merge into; the caller + // writes the fields as the record, which heals it. + if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined; + const value = { + ...(record as Record), + ...(JSON.parse(JSON.stringify(partial)) as object), + }; + const refIds = new Set(); + collectEntityRefs(value, refIds); + return { value, refIds: refIds.size > 0 ? refIds : undefined }; +} + +function collectEntityRefs(value: unknown, into: Set): void { + if (typeof value !== 'object' || value === null) return; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) collectEntityRefs(value[i], into); + return; + } + const ref = (value as { __entityRef?: unknown }).__entityRef; + if (typeof ref === 'number') { + into.add(ref); + return; + } + for (const key in value) collectEntityRefs((value as Record)[key], into); +} diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index ea18399..1f96303 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -11,6 +11,7 @@ import { refIdsKeyFor, updatedAtKeyFor, valueKeyFor, + mergeStoredRecord, } from './shared.js'; export interface SyncPersistentStore { @@ -70,14 +71,36 @@ export class MemoryPersistentStore implements SyncPersistentStore { } } +/** + * Delete listeners are keyed by the backing kv, not the store instance: two + * stores over one kv delete each other's records, and a client behind either + * must hear about it. + */ +const deleteListenersByKv = new WeakMap void>>(); + export class SyncQueryStore implements QueryStore { queues: Map = new Map(); - private deleteListeners: Array<(key: number) => void> = []; + private readonly deleteListeners: Array<(key: number) => void>; - constructor(private readonly kv: SyncPersistentStore) {} + constructor(private readonly kv: SyncPersistentStore) { + let listeners = deleteListenersByKv.get(kv); + if (listeners === undefined) { + listeners = []; + deleteListenersByKv.set(kv, listeners); + } + this.deleteListeners = listeners; + } - onDelete(listener: (key: number) => void): void { + onDelete(listener: (key: number) => void): () => void { this.deleteListeners.push(listener); + return () => { + const idx = this.deleteListeners.indexOf(listener); + if (idx !== -1) this.deleteListeners.splice(idx, 1); + }; + } + + hasEntity(key: number): boolean { + return this.kv.has(valueKeyFor(key)); } loadQuery(queryDef: QueryDefinition, queryKey: number): CachedQuery | undefined { @@ -114,6 +137,9 @@ export class SyncQueryStore implements QueryStore { private preloadEntities(entityIds: Uint32Array, preloaded: PreloadedEntityMap): void { for (const entityId of entityIds) { + // Records can reference each other in a cycle (a file whose record + // links back to its folder); one read per record. + if (preloaded.has(entityId)) continue; const entityValue = this.kv.getString(valueKeyFor(entityId)); if (entityValue === undefined) { @@ -148,6 +174,13 @@ export class SyncQueryStore implements QueryStore { this.setValue(entityKey, value, refIds); } + mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void { + const stored = this.kv.getString(valueKeyFor(entityKey)); + const merged = stored !== undefined ? mergeStoredRecord(stored, fields) : undefined; + if (merged !== undefined) this.setValue(entityKey, merged.value, merged.refIds); + else this.setValue(entityKey, fields, refIds); + } + activateQuery(queryDef: QueryDefinition, queryKey: number): void { if (!this.kv.has(valueKeyFor(queryKey))) { return; @@ -164,7 +197,21 @@ export class SyncQueryStore implements QueryStore { queue = new Uint32Array(maxCount); this.kv.setBuffer(queueKeyFor(queryDefId), queue); } else if (queue.length !== maxCount) { - queue = new Uint32Array(queue.buffer, 0, maxCount); + // `maxCount` changed since the queue was written. A view over the old + // buffer cannot grow past it, and a shorter view would silently strand + // the keys it drops: copy what fits and evict the rest properly. The + // key being activated is about to move to the front, so it is kept + // whichever slot it sits in. + const resized = new Uint32Array(maxCount); + resized.set(queue.subarray(0, Math.min(queue.length, maxCount))); + for (let i = maxCount; i < queue.length; i++) { + const dropped = queue[i]; + if (dropped !== 0 && dropped !== queryKey) { + this.deleteQuery(dropped); + this.kv.delete(updatedAtKeyFor(dropped)); + } + } + queue = resized; this.kv.setBuffer(queueKeyFor(queryDefId), queue); } From c9b4dfe4e27b46cbde036be9e3a8a90fbf1c3ef7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:30:10 -0400 Subject: [PATCH 034/109] fix(fetchium): close the snapshot, hydration and event-persistence regressions A differential hunt against the tree before the snapshot fast path found 17 client-observable regressions from the snapshot fast path, unchanged- apply skipping and single-write events. This closes them on top of the synchronous hydration and fast delivery work: - Dev snapshots are frozen, so a consumer that mutates one fails at its own call site. A detected snapshot drift is served from the re-read and raised out of band (the client's log.error, then an uncaught error), never from inside a snapshot, where it escaped Signalium's watcher flush and stalled every consumer on the page. The fast path compares with Object.is, pairs rebuilt Sets in linear time, and splits static and dynamic fields per client so two classes sharing a typename stay fresh. - Hydrating a cached query never re-parses an entity already in memory. The in-memory data must structurally satisfy the query's shape; if not, a CachedEntityMismatchError lands in hydrate()'s catch, which drops the cached query, clears updatedAt and fetches. Entities built from streamed events are completed from their record. The parser no longer writes into the objects it is given. - Streamed events write the existing entities they changed and the entities they created once a written record references them (root before routing, children first, deferred writes for back references, unrouted roots evicted with everything they created). Event-built entities merge only the fields their events carried through the store's mergeEntity; records the store holds but that are not in memory are refreshed the same way. - _persisted is set only after a successful write (or the store's acknowledgement), so a write that threw is retried by the next apply, and no write is skipped while an async writer has work queued. - destroy() removes the store listeners it registered; late query extras notify a shared root entity; hot-reloaded proxies are walked; an entity listed twice in one payload is applied once; a shared-typename narrowed array consumes the members it leaves out. 55 tests added (skip-write-and-snapshot-fixes, event-persistence); the entity-snapshot, same-value, entity-write-counts and parsing tests follow the new contract (drift via the handler hook, frozen snapshots, NaN/-0, merges counted as writes). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-write-and-snapshot-fixes.md | 17 + docs/src/app/api/fetchium-react/page.md | 2 +- docs/src/app/api/fetchium/page.md | 26 +- docs/src/app/core/entities/page.md | 2 +- docs/src/app/setup/project-setup/page.md | 2 + packages/fetchium/src/ConstraintMatcher.ts | 10 +- packages/fetchium/src/EntityInstance.ts | 423 ++++- packages/fetchium/src/EntityStore.ts | 26 +- packages/fetchium/src/LiveCollection.ts | 23 +- packages/fetchium/src/QueryClient.ts | 212 ++- packages/fetchium/src/QueryResult.ts | 4 + .../src/__tests__/entity-snapshot.test.ts | 51 +- .../src/__tests__/entity-write-counts.test.ts | 25 +- .../src/__tests__/event-persistence.test.ts | 1544 +++++++++++++++++ .../src/__tests__/parsing/entity.test.ts | 8 +- .../fetchium/src/__tests__/same-value.test.ts | 7 +- .../skip-write-and-snapshot-fixes.test.ts | 1247 +++++++++++++ packages/fetchium/src/applyEntities.ts | 166 +- packages/fetchium/src/errors.ts | 13 + packages/fetchium/src/parseEntities.ts | 295 +++- 20 files changed, 3918 insertions(+), 185 deletions(-) create mode 100644 .changeset/skip-write-and-snapshot-fixes.md create mode 100644 packages/fetchium/src/__tests__/event-persistence.test.ts create mode 100644 packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md new file mode 100644 index 0000000..fac9ebc --- /dev/null +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -0,0 +1,17 @@ +--- +'fetchium': patch +--- + +Fixes for the entity snapshot fast path, unchanged-apply skipping and single-write events introduced in 0.6.0. + +- Development builds hand out frozen snapshots, so a consumer that mutates one (a `sort()` in render, an assignment) fails at its own call site with a TypeError. Production snapshots stay mutable; treat them as read-only. The parser no longer writes into the objects it is given, so a snapshot (or part of one) handed back through a mutation event or a mutation's `effects` is read, not mutated, and two events sharing one nested object no longer make two entities share it. +- The dev-only stale-snapshot check no longer throws from inside a snapshot. A throw there escaped Signalium's watcher flush and stopped every consumer on the page from updating. It now serves the re-read value and raises the error out of band, once per field: through the client's `log.error` and then as an uncaught error (a red box in React Native, an uncaught error in the browser, a failed run under vitest). It compares with `Object.is` (so a `NaN` field no longer trips it, and `-0` over `0` is applied at every level), compares a rebuilt `Set` in linear time, and restores the key set of a snapshot a consumer altered. +- Hydrating a cached query no longer re-parses an entity that is already in memory. The re-parse ran already-parsed values through the parser again in place, wiping nested optional formats and parse results, and since the apply then saw no change it never notified; consumers of the first query kept stale snapshots. The in-memory data is used as-is once it structurally satisfies the query's shape, nested objects, arrays, records, unions and child entities included (members an array narrows out on read, and children that have left memory, are not held against it); otherwise the whole cached query is dropped and fetched, never served with an item missing. An entity built from streamed events is completed from its record instead. +- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. +- An entity that appears more than once in one payload (an author listed under `author` and again under `mentions`) is applied once: it keeps its child references and is written once. +- `_persisted` is only set once the store write succeeded, so a write that threw is retried by the next apply. `AsyncQueryStore` writers acknowledge processed entity writes (`onPersisted`), report a failed one, answer `hasEntity` from the keys they read from the delegate at start (alongside the queue, so writes do not wait for it; a queued write counts as held) and keep current, run `purgeStaleQueries` through the same serial queue as the writes (a reader with its own delegate still purges directly), ignore anything on the channel that is not one of their messages, and expose `isSettled()`; the client counts its own outstanding writes, so an acknowledgement of a write another instance queued does not mark a hydrated instance current, and it does not skip a write while the writer has operations queued, since one of them may delete the record the skip relies on. +- `AsyncQueryStore` resizes a persisted LRU queue by copying it (growing no longer throws `RangeError`, shrinking evicts the dropped keys instead of stranding them, never the key being activated), and keeps a queue's persisted size for a message without `maxCount` from an older reader. `SyncQueryStore` gets the same resize. +- `onDelete` returns an unsubscribe and `QueryClient.destroy()` calls it, so a store no longer retains every client created against it. `SyncQueryStore` shares its delete listeners between every instance over the same backing kv. +- Reading a shared-typename entity array consumes the notifiers of the members it currently leaves out, so a reactive consumer learns when a member becomes eligible without a data change, and a consumer that only reads the array is not recomputed when a member it holds changes. +- Two classes sharing a typename: fields that are entity-free in one class but hold an entity through the other are no longer classified static for the snapshot fast path. +- Query extras (`__refetch`, `__hasNext`, ...) attached to a shared entity notify existing consumers; a query whose cached value was rejected fetches even within `staleTime`; snapshots of proxies created by a previous (hot-reloaded) module instance are walked instead of returned as-is. diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index 98f2c51..3bc22c9 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -132,7 +132,7 @@ const SearchResults = component(() => { - `useQuery` is a thin wrapper around Signalium v3's `useReactive`, which is deep-by-default. It returns a **structurally-shared snapshot** of the query result, so memoized children that receive subtrees as props keep stable references when the underlying data is unchanged. - Fetchium registers a custom snapshot for entity proxies so the snapshot walks into entities (instead of returning them by reference), giving you correct re-rendering on entity field changes. -- The snapshot is a plain-object copy of the query result to prevent accidental mutation of the entity cache. Use `draft()` from `fetchium` if you need a mutable copy for mutations. +- The snapshot is a plain-object copy of the query result to prevent accidental mutation of the entity cache. Treat it as read-only: in development builds the snapshot and every nested object and array in it are frozen, so an in-place `sort()`, a `push()` or an assignment in render throws a `TypeError` at that line. Production snapshots are not frozen, and a mutation there is carried forward until the entity changes. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` if you need a mutable copy for mutations. - `useQuery` never suspends. Reading `.value` while pending returns `undefined`. Use [`useSuspenseQuery`](#usesuspensequery) to suspend on a cold miss. --- diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index bcf3f80..3d6f76e 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -241,14 +241,14 @@ new QueryClient(config: QueryClientConfig) #### Methods -| Method | Signature | Description | -| -------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | -| `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | -| `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | -| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query now and keeps it active for `ttl` ms (default `DEFAULT_PREFETCH_TTL`, 10 s). See below. | -| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until the returned `release` is called, or for `ttl` ms. See below. | -| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches, and releases outstanding leases. | +| Method | Signature | Description | +| -------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | +| `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | +| `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | +| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query now and keeps it active for `ttl` ms (default `DEFAULT_PREFETCH_TTL`, 10 s). See below. | +| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until the returned `release` is called, or for `ttl` ms. See below. | +| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches, releases outstanding leases, and unsubscribes the store listeners it registered. | #### `prefetch` and `retain` @@ -710,8 +710,8 @@ The `t` object provides a declarative type definition DSL for describing query p ### Store types -| Type | Definition | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). | -| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | -| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | +| Type | Definition | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). Besides the required methods, a store may implement `onDelete` (the client then skips writing entities whose records are current, and writes one again once the store reports its record dropped), `onPersisted` (for a store that applies writes later), `isSettled`, `hasEntity` (lets a streamed event refresh the record of an entity that is not in memory) and `mergeEntity` (merges the fields streamed events supplied for an entity built from them over its record; without it, such an entity is written whole through `saveEntity`). A store without `onDelete` is written on every apply, as before 0.6; without `hasEntity`, an event for an entity that is not in memory and that no live array retains is not written. | +| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | +| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | diff --git a/docs/src/app/core/entities/page.md b/docs/src/app/core/entities/page.md index 71187c0..11745ae 100644 --- a/docs/src/app/core/entities/page.md +++ b/docs/src/app/core/entities/page.md @@ -58,7 +58,7 @@ class GetPost extends RESTQuery { ``` {% callout type="warning" %} -Entities are **read-only**. Attempting to set a property on an entity will throw an error in development mode. To update entity data, use mutations or streaming updates. +Entities are **read-only**, and so are the snapshots `useQuery` and `useReactive` take of them. Attempting to set a property on an entity, or on a snapshot in a development build, will throw an error. To update entity data, use mutations or streaming updates. {% /callout %} --- diff --git a/docs/src/app/setup/project-setup/page.md b/docs/src/app/setup/project-setup/page.md index d90077d..b7ca2b8 100644 --- a/docs/src/app/setup/project-setup/page.md +++ b/docs/src/app/setup/project-setup/page.md @@ -289,6 +289,8 @@ Fetchium uses a compile-time constant `IS_DEV` to enable additional runtime chec - **Parse validation warnings** --- detailed logs when response data doesn't match your type definitions - **Entity write protection** --- throws when you accidentally try to mutate an entity property directly (entities are read-only) +- **Frozen snapshots** --- the snapshots `useQuery` and `useReactive` hand out are frozen, so mutating one (an in-place `sort()`, an assignment in render) throws a `TypeError` at the offending line instead of silently corrupting what the next render shows +- **Stale-snapshot detection** --- a snapshot taken at an unchanged entity version re-reads only the fields whose values live elsewhere; development builds re-read the rest too and, if one changed, serve the re-read value, log the discrepancy through the client's `log.error`, and raise it once per typename and field as an uncaught error - **Reference validation** --- catches common mistakes like using conditional logic in class field definitions In production builds, all `IS_DEV` code paths are tree-shaken, so there is zero runtime cost. diff --git a/packages/fetchium/src/ConstraintMatcher.ts b/packages/fetchium/src/ConstraintMatcher.ts index 1bb40eb..e5610aa 100644 --- a/packages/fetchium/src/ConstraintMatcher.ts +++ b/packages/fetchium/src/ConstraintMatcher.ts @@ -201,13 +201,19 @@ export class ConstraintMatcher { } } + /** + * With `dryRun`, reports through `onMatch` what the event would do (whether + * a binding matches, and whether one would retain the entity) without + * touching any collection. + */ routeEvent( typename: string, entityData: Record, entityKey: number, eventType: 'create' | 'update' | 'delete', - onMatch?: () => void, + onMatch?: (willRetain: boolean) => void, deleteData?: Record, + dryRun: boolean = false, ): void { for (const group of this._groups.values()) { const hash = computeConstraintHash(entityData, group.fieldPaths); @@ -216,7 +222,7 @@ export class ConstraintMatcher { const bindings = group.getMatching(hash); if (bindings !== undefined) { for (const binding of bindings) { - binding.onEvent(typename, entityKey, eventType, onMatch, deleteData); + binding.onEvent(typename, entityKey, eventType, onMatch, deleteData, dryRun); } } } diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 4857df3..ee948e2 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -159,6 +159,17 @@ function snapshotCameFrom(snapshotObj: unknown, sourceId: number): boolean { return snapshotOrigins.get(snapshotObj)?.sourceId === sourceId; } +/** + * Dev builds hand out frozen snapshots. A consumer that mutates one (a + * `sort()` in render, an assignment) then fails at its own call site with a + * TypeError and a stack trace, instead of the fast path quietly carrying the + * mutation forward. Production keeps the objects mutable: freezing costs a + * call per new container and the contract is documented instead. + */ +function freezeInDev(obj: T): T { + return IS_DEV ? Object.freeze(obj) : obj; +} + function asPrevObject(prev: unknown): Record | undefined { return prev !== null && typeof prev === 'object' && !Array.isArray(prev) ? (prev as Record) @@ -198,9 +209,11 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn } const next = snapshotRawValue(item, prevItem, snap); result[i] = next; - if (!changed && next !== prevArr![i]) changed = true; + // `Object.is`, not `!==`: a NaN slot must not mark the array changed on + // every walk, or the snapshot never reaches a stable identity. + if (!changed && !Object.is(next, prevArr![i])) changed = true; } - return changed ? result : prevArr!; + return changed ? freezeInDev(result) : prevArr!; } if (Object.getPrototypeOf(value) === ObjectProto) { @@ -213,14 +226,68 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn const key = keys[i]; const next = snapshotRawValue(obj[key], prevObj?.[key], snap); result[key] = next; - if (!changed && next !== prevObj![key]) changed = true; + if (!changed && !Object.is(next, prevObj![key])) changed = true; } - return changed ? result : prevObj!; + return changed ? freezeInDev(result) : prevObj!; } return snap(value, prev); } +/** + * Structural equality of two snapshot values (plain objects, arrays, and + * primitives compared with `Object.is`). Used by the dev guard to tell a real + * drift from a value that merely lost reference identity, e.g. a `Set` a + * format parser returned, which Signalium's `snapshotSet` rebuilds each walk. + */ +function sameSnapshotValue(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (!sameSnapshotValue(a[i], b[i])) return false; + return true; + } + if (a instanceof Set || b instanceof Set) { + if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false; + // A rebuilt Set (Signalium's `snapshotSet` copies one every walk) keeps + // the insertion order, so pair members by position first and only search + // for a member that fails to line up. This keeps the common case linear. + const bItems = [...b]; + // Indices of `b` already paired, tracked from the first misalignment on. + let matched: Set | undefined; + let i = 0; + for (const item of a) { + if (matched === undefined && sameSnapshotValue(item, bItems[i])) { + i++; + continue; + } + if (matched === undefined) { + matched = new Set(); + for (let k = 0; k < i; k++) matched.add(k); + } + const j = bItems.findIndex((other, idx) => !matched!.has(idx) && sameSnapshotValue(item, other)); + if (j === -1) return false; + matched.add(j); + i++; + } + return true; + } + if (a instanceof Map || b instanceof Map) { + if (!(a instanceof Map) || !(b instanceof Map) || a.size !== b.size) return false; + for (const [k, v] of a) if (!b.has(k) || !sameSnapshotValue(v, b.get(k))) return false; + return true; + } + if (Object.getPrototypeOf(a) !== ObjectProto || Object.getPrototypeOf(b) !== ObjectProto) return false; + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + for (const key of aKeys) { + if (!Object.hasOwn(b, key)) return false; + if (!sameSnapshotValue((a as Record)[key], (b as Record)[key])) return false; + } + return true; +} + /** Dev-only counters: losing the fast path is invisible to behavioral tests. */ export let __debug_snapshotFieldReads = 0; export let __debug_snapshotFullWalks = 0; @@ -245,12 +312,44 @@ function walkFields( } } +/** + * The pre-fast-path walk: `Object.keys` on the proxy and a read per key. Kept + * for a proxy this module instance did not create — after a hot reload + * re-evaluates this file, proxies made by the previous instance are still + * live but absent from the new `snapshotSources`. + */ +function snapshotProxyByWalking(current: object, prev: unknown, snap: SnapshotFn): unknown { + const obj = current as Record; + const keys = Object.keys(obj); + const prevObj = asPrevObject(prev); + let changed = prevObj === undefined || Object.keys(prevObj).length !== keys.length; + const result: Record = {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const val = obj[key]; + const next = typeof val === 'function' ? val : snap(val, prevObj?.[key]); + result[key] = next; + if (!changed && !Object.is(next, prevObj![key])) changed = true; + } + return changed ? freezeInDev(result) : prevObj; +} + const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unknown => { const source = snapshotSources.get(current); - // An `Entity` that never went through `createProxy` has no fields to read — - // its own properties are the shape's type defs. Hand it back untouched, the - // way Signalium treats any class it has no handler for. - if (source === undefined) return current; + if (source === undefined) { + // A proxy from another instance of this module (hot reload re-evaluated + // this file and its registries, but the proxies are still live): walk it + // the old way rather than handing the proxy back, which would stop React + // from ever re-rendering on its changes. Every entity proxy answers + // `toJSON`; a bare `Entity` instance does not. + if (PROXY_ID.has(current) || typeof (current as { toJSON?: unknown }).toJSON === 'function') { + return snapshotProxyByWalking(current, prev, snap); + } + // An `Entity` that never went through `createProxy` has no fields to read — + // its own properties are the shape's type defs. Hand it back untouched, the + // way Signalium treats any class it has no handler for. + return current; + } // eslint-disable-next-line @typescript-eslint/no-unused-expressions source.relay?.value; @@ -276,14 +375,33 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno let result: Record | undefined; for (let i = 0; i < keys.dynamic.length; i++) { const key = keys.dynamic[i]; - if (patch[key] !== before[key]) { + if (!Object.is(patch[key], before[key])) { if (result === undefined) result = { ...before }; result[key] = patch[key]; } } - if (IS_DEV) assertStaticFieldsUnchanged(source, keys, before, snap); - return rememberSnapshot(source.id, result ?? before, version, keys); + if (IS_DEV) { + // Dev builds re-read what the fast path skipped. A field that drifted is + // served from the re-read (the pre-fast-path behaviour) and reported, + // rather than thrown: a throw from inside a snapshot lands in Signalium's + // watcher flush, which has no catch, and stalls every consumer on the page. + const drifted = verifyStaticFields(source, keys, before, snap); + if (drifted !== undefined) { + if (result === undefined) result = { ...before }; + for (const key of Object.keys(drifted)) result[key] = drifted[key]; + } + // A consumer that added or deleted keys on the snapshot it was handed: + // rebuild with the shape's keys, in the shape's order, as a walk would. + if (!sameKeyList(Object.keys(before), keys.enumerable)) { + const source_ = result ?? before; + const rebuilt: Record = {}; + for (const key of keys.enumerable) rebuilt[key] = source_[key]; + result = rebuilt; + reportDrift(source, '(keys)', 'its key set was altered'); + } + } + return rememberSnapshot(source.id, result !== undefined ? freezeInDev(result) : before, version, keys); } if (IS_DEV) __debug_snapshotFullWalks++; @@ -294,47 +412,97 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno let changed = prevObj === undefined || Object.keys(prevObj).length !== enumerable.length; if (!changed) { for (let i = 0; i < enumerable.length; i++) { - if (result[enumerable[i]] !== prevObj![enumerable[i]]) { + if (!Object.is(result[enumerable[i]], prevObj![enumerable[i]])) { changed = true; break; } } } - return rememberSnapshot(source.id, changed ? result : prevObj!, version, keys); + return rememberSnapshot(source.id, changed ? freezeInDev(result) : prevObj!, version, keys); }; +function sameKeyList(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** Dev-only: (typename, field) pairs already reported, so a drift is raised once. */ +const reportedDrift = new Set(); + +export type SnapshotDriftHandler = (error: Error, queryClient: QueryClient) => void; + /** - * Guards the fast path's assumptions: `data` only changes through a `notify()` + * Dev-only default: log through the client's logger, then raise the error + * outside the snapshot so it surfaces as an uncaught error (a red box in + * React Native, "Uncaught Error" in a browser, a failed run under vitest) + * without throwing from inside Signalium's watcher flush, which has no catch + * and would stop every consumer on the page from updating. + */ +const defaultDriftHandler: SnapshotDriftHandler = (error, queryClient) => { + queryClient.getContext().log?.error?.(error.message, error); + queueMicrotask(() => { + throw error; + }); +}; + +let snapshotDriftHandler: SnapshotDriftHandler = defaultDriftHandler; + +/** + * Dev-only test hook: replace how a stale snapshot is raised, and forget which + * fields were already raised. `undefined` restores the default handler. + */ +export function __setSnapshotDriftHandler(handler: SnapshotDriftHandler | undefined): void { + snapshotDriftHandler = handler ?? defaultDriftHandler; + reportedDrift.clear(); +} + +function reportDrift(source: EntitySnapshotSource, key: string, what: string): void { + const tag = `${source.instance.typename}.${key}`; + if (reportedDrift.has(tag)) return; + reportedDrift.add(tag); + const error = new Error( + `[fetchium] stale entity snapshot: ${source.instance.typename}:${source.instance.id} ${what} while version ` + + `stayed at ${source.instance.version}. The re-read value is being served, but a production build would keep ` + + `the stale one. Either the field was mutated without notify(), the shape's static/dynamic split is wrong ` + + `for it, or a consumer mutated a snapshot it received (dev snapshots are frozen so that fails at the call ` + + `site; in production they are not). Raised once per field.`, + ); + snapshotDriftHandler(error, source.instance._queryClient); +} + +/** + * Checks the fast path's assumptions: `data` only changes through a `notify()` * that bumps `version`, and every field outside `keys.dynamic` is a pure - * function of `data`. Either breaking strands a snapshot permanently, which no - * behavioral test catches. Skipped fields only — re-reading a dynamic field is - * not identity-stable, since snapshotting a child updates state it pairs on. + * function of `data`. Re-reads the skipped fields and returns the ones whose + * value differs structurally from the cached snapshot (identity alone is not + * enough: a `Set` from a format parser is rebuilt on every walk), or + * `undefined` when nothing drifted. Skipped fields only — re-reading a dynamic + * field is not identity-stable, since snapshotting a child updates state it + * pairs on. * * This costs dev builds the read the fast path saved, so the speedup shows up * in production builds only. */ -function assertStaticFieldsUnchanged( +function verifyStaticFields( source: EntitySnapshotSource, keys: EntityKeys, before: Record, snap: SnapshotFn, -): void { +): Record | undefined { const verified: Record = {}; const readsBefore = __debug_snapshotFieldReads; walkFields(source, keys.static, before, snap, verified); // This walk is verification, not work the fast path did. __debug_snapshotFieldReads = readsBefore; + let drifted: Record | undefined; for (const key of keys.static) { - if (verified[key] !== before[key]) { - throw new Error( - `[fetchium] stale entity snapshot: ${source.instance.typename}:${source.instance.id} field '${key}' ` + - `changed while version stayed at ${source.instance.version}. Either the field was mutated without ` + - `notify(), or the shape's static/dynamic split is wrong for it. This throws from inside a snapshot, ` + - `so the reactive computation holding it stays in an error state and will rethrow on every read — ` + - `reload rather than chasing the later throws, which are all this one.`, - ); - } + if (Object.is(verified[key], before[key]) || sameSnapshotValue(verified[key], before[key])) continue; + if (drifted === undefined) drifted = {}; + drifted[key] = verified[key]; + reportDrift(source, key, `field '${key}' changed`); } + return drifted; } // Register once on the `Entity` base class. Signalium 3.0.1+ resolves custom @@ -361,6 +529,23 @@ export class EntityInstance { refCount: number = 0; /** Whether this instance has been written to the store. */ _persisted: boolean = false; + /** Writes handed to a store that acknowledges them, not yet acknowledged. */ + _pendingWrites: number = 0; + /** Set while an apply is still reifying this instance's fields; its data is not yet a record. */ + _applying: boolean = false; + /** + * The data came from streamed events only (the entity was created by one), + * so it may lack fields a record of it on disk has: a write merges over + * that record. Cleared by the first full payload applied to the entity. + */ + _partial: boolean = false; + /** With `_partial`: the fields the events carried, i.e. the ones a write merges. */ + _partialKeys: Set | undefined = undefined; + /** A write requested while a child was still being reified; performed when that apply is done. */ + _deferredWrite: boolean = false; + /** The store is known to hold a record of this entity (hydrated from it, or written to it). */ + _recorded: boolean = false; + private _saving: boolean = false; entityRefs: Map | undefined; liveCollections: LiveCollectionBinding[] = []; satisfiedDefs: WeakSet> = new WeakSet(); @@ -484,8 +669,86 @@ export class EntityInstance { } save(): void { - this._persisted = true; - this._queryClient.entityMap.save(this); + const client = this._queryClient; + if (this._saving) return; + this._saving = true; + try { + // A record's references must point at records that exist: a child the + // store holds no record of (never written, dropped, or a failed write) + // is written first. While an apply is still reifying a child (a payload + // that links back to an ancestor), neither it nor this entity can be + // written yet: both are written, in that order, once the apply is done. + const refs = this.entityRefs; + if (refs !== undefined) { + let deferred = false; + for (const child of refs.keys()) { + if (child._recorded || child._persisted || child._pendingWrites > 0 || child._saving) continue; + if (client.entityMap.getEntity(child.key) !== child) continue; + if (child._applying) { + client.deferWrite(child); + deferred = true; + } else { + try { + child.save(); + } catch (e) { + // This entity's record (if any) still lacks the reference: the + // next apply must write it again. + this._persisted = false; + throw e; + } + if (child._deferredWrite) deferred = true; + } + } + if (deferred) { + this._deferredWrite = true; + client.deferWrite(this); + return; + } + } + this._deferredWrite = false; + // Mark after the store call: a store that throws leaves the record stale + // or missing, so the flag is cleared and the next apply writes again. A + // store that acknowledges writes later (`onPersisted`) marks it itself + // once the write has been processed. An entity built from events hands + // the store only the fields they carried, to merge over its record. + // Counted before the call: a store may acknowledge synchronously. + if (client.storeAcksWrites) this._pendingWrites++; + try { + client.entityMap.save(this, this._partial ? this._partialKeys : undefined); + } catch (e) { + if (client.storeAcksWrites && this._pendingWrites > 0) this._pendingWrites--; + this._persisted = false; + throw e; + } + if (!client.storeAcksWrites) this._persisted = this._recorded = true; + } finally { + this._saving = false; + } + } + + /** A live field of this entity gained or lost a member outside an apply. */ + liveFieldChanged(fieldKey: string): void { + if (this._partial) this._partialKeys?.add(fieldKey); + } + + /** + * The store processed a write of this entity's record. Only a write this + * instance dispatched counts: an acknowledgement can also belong to a write + * queued by a previous instance of the same entity (one that was collected + * and re-hydrated from the store while the write was in flight), and that + * write carried a value this instance never had. The record is current + * once every dispatched write has landed. + */ + acknowledgeWrite(): void { + if (this._pendingWrites === 0) return; + if (--this._pendingWrites === 0) this._persisted = this._recorded = true; + } + + /** The store dropped (or failed to write) this entity's record. */ + recordDropped(): void { + this._persisted = false; + this._recorded = false; + this._pendingWrites = 0; } notify(): void { @@ -504,18 +767,31 @@ function sameMembers(a: unknown[], b: unknown[]): boolean { return true; } -function filterEntityArray(array: unknown[], innerDef: ValidatorDef, queryClient: QueryClient): unknown[] { - const result: unknown[] = []; +interface NarrowedArray { + source: unknown[]; + filtered: unknown[]; + /** Members that do not (yet) satisfy the def; the only ones whose membership can change. */ + excluded: EntityInstance[]; + parseId: number; +} + +function filterEntityArray( + array: unknown[], + innerDef: ValidatorDef, + queryClient: QueryClient, +): { filtered: unknown[]; excluded: EntityInstance[] } { + const filtered: unknown[] = []; + const excluded: EntityInstance[] = []; for (const item of array) { if (typeof item !== 'object' || item === null) continue; const entityKey = PROXY_ID.get(item); if (entityKey === undefined) continue; const entityInstance = queryClient.entityMap.getEntity(entityKey); - if (entityInstance !== undefined && entityInstance.satisfiesDef(innerDef)) { - result.push(item); - } + if (entityInstance === undefined) continue; + if (entityInstance.satisfiesDef(innerDef)) filtered.push(item); + else excluded.push(entityInstance); } - return result; + return { filtered, excluded }; } // ====================================================== @@ -587,7 +863,7 @@ function computeIsStaticFieldDef(def: ValidatorDef): boolean { * The key lists a proxy reports, shared per shape. `own` is `ownKeys`; * `enumerable` drops the non-enumerable entity methods. */ -interface EntityKeys { +export interface EntityKeys { own: string[]; enumerable: string[]; dynamic: string[]; @@ -596,14 +872,29 @@ interface EntityKeys { enumerableSet: Set; } -const shapeKeyCache = new WeakMap, EntityKeys>(); +/** + * A field whose def carries no shape: a primitive, a format, `t.typename`, or + * a set of literals. Its value can only be a primitive or a format's parsed + * value, so no other class sharing the typename can put an entity under it. + */ +function isShapelessFieldDef(def: unknown): boolean { + if (typeof def === 'number' || typeof def === 'string') return true; + if (def instanceof Set) return true; + return def instanceof ValidatorDef && (def.shape === undefined || def.shape === null); +} +/** + * Cached per client (`queryClient.shapeKeyCache`): the split depends on which + * other classes share the typename, and that set grows as queries register. + */ function shapeKeys( validatorDef: ValidatorDef, shapeFields: Record, methods: Record unknown> | undefined, + queryClient: QueryClient, ): EntityKeys { - let keys = shapeKeyCache.get(validatorDef); + const cache = queryClient.shapeKeyCache; + let keys = cache.get(validatorDef); if (keys !== undefined) return keys; const own = Object.keys(shapeFields); @@ -614,7 +905,16 @@ function shapeKeys( if (!own.includes(methodKey)) own.push(methodKey); } } - const dynamic = enumerable.filter(key => key !== '__typename' && !isStaticFieldDef(shapeFields[key])); + // The data object is shared by every class with this typename, so a nested + // object that is entity-free in this class may hold a child entity written + // through another class's shape. Only shapeless fields stay static then. + const typename = validatorDef.typenameValue; + const shared = typename !== undefined && (queryClient.getEntityDefsForTypename(typename)?.length ?? 0) > 1; + const dynamic = enumerable.filter( + key => + key !== '__typename' && + (!isStaticFieldDef(shapeFields[key]) || (shared && !isShapelessFieldDef(shapeFields[key]))), + ); const dynamicSet = new Set(dynamic); keys = { @@ -624,7 +924,7 @@ function shapeKeys( static: enumerable.filter(key => !dynamicSet.has(key)), enumerableSet: new Set(enumerable), }; - shapeKeyCache.set(validatorDef, keys); + cache.set(validatorDef, keys); return keys; } @@ -675,12 +975,24 @@ function bindMethod( return bound; } +/** + * The members a narrowed array leaves out are dependencies of the read: one + * of them gaining the def's fields notifies itself, and that is what must + * recompute the consumer. Members already in the array are read through the + * array by any consumer that cares about their fields, and a member never + * loses eligibility, so consuming them here would only add recomputes (and + * cost a lookup per member on every read of the field). + */ +function consumeExcluded(excluded: EntityInstance[]): void { + for (let i = 0; i < excluded.length; i++) excluded[i].consume(); +} + /** Narrow a shared-typename array to this field's def, cached on array identity. */ function narrowEntityArray( prop: string, value: unknown[], shapeFields: Record, - filterCache: Map, + filterCache: Map, queryClient: QueryClient, ): unknown[] { const fieldDef = shapeFields[prop]; @@ -695,11 +1007,14 @@ function narrowEntityArray( const parseId = queryClient.currentParseId; const cached = filterCache.get(prop); if (cached !== undefined && cached.source === value && cached.parseId === parseId) { + consumeExcluded(cached.excluded); return cached.filtered; } - let filtered = filterEntityArray(value, innerDef, queryClient); + const narrowed = filterEntityArray(value, innerDef, queryClient); + let filtered = narrowed.filtered; if (cached !== undefined && sameMembers(cached.filtered, filtered)) filtered = cached.filtered; - filterCache.set(prop, { source: value, filtered, parseId }); + filterCache.set(prop, { source: value, filtered, excluded: narrowed.excluded, parseId }); + consumeExcluded(narrowed.excluded); return filtered; } } @@ -728,7 +1043,7 @@ function createProxy( const typenameField = shape.typenameField; const wrappedMethods = new Map unknown>(); - const filterCache = new Map(); + const filterCache = new Map(); const toJSON = () => ({ __entityRef: key }); @@ -755,22 +1070,24 @@ function createProxy( } // Shared per shape; only a query's root entity allocates its own lists. - const baseKeys = shapeKeys(validatorDef, shapeFields, methods); + let cachedBase: EntityKeys | undefined; let cachedMethods: Record | undefined; let cachedGetters: Record | undefined; - let keys = baseKeys; + let keys: EntityKeys | undefined; function entityKeys(): EntityKeys { - // Both slots: these lists decide what a snapshot walks and re-reads. + // The base is re-resolved on every call (a WeakMap lookup): the client + // resets its cache when another class registers for this typename, and + // the new split must reach snapshots already in flight. Both extras + // slots matter too: these lists decide what a snapshot walks and re-reads. + const base = shapeKeys(validatorDef, shapeFields, methods, queryClient); const methodsNow = instance._extraMethods; const gettersNow = instance._extraGetters; - if (methodsNow !== cachedMethods || gettersNow !== cachedGetters) { + if (keys === undefined || base !== cachedBase || methodsNow !== cachedMethods || gettersNow !== cachedGetters) { + cachedBase = base; cachedMethods = methodsNow; cachedGetters = gettersNow; - keys = - methodsNow === undefined && gettersNow === undefined - ? baseKeys - : withExtraKeys(baseKeys, methodsNow, gettersNow); + keys = methodsNow === undefined && gettersNow === undefined ? base : withExtraKeys(base, methodsNow, gettersNow); } return keys; } diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 89e0b31..12b8fb8 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -5,9 +5,13 @@ import { ValidatorDef } from './typeDefs.js'; export class EntityStore { private instances = new Map(); - private persistEntity: (key: number, data: Record, refKeys?: Set) => void; + private persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void; + /** Whether the store can merge fields over a record it holds. */ + mergesEntities: boolean = false; - constructor(persistEntity: (key: number, data: Record, refKeys?: Set) => void) { + constructor( + persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void, + ) { this.persistEntity = persistEntity; } @@ -58,12 +62,24 @@ export class EntityStore { this.instances.clear(); } - save(instance: EntityInstance): void { + /** + * With `mergeKeys`, only those fields are handed to the store, to be merged + * over the record it holds (the rest of the instance's data is what streamed + * events left unset, not what the record says). + */ + save(instance: EntityInstance, mergeKeys?: Set): void { let refKeys: Set | undefined; if (instance.entityRefs) { refKeys = new Set(); - for (const e of instance.entityRefs.keys()) refKeys.add(e.key); + // A child evicted from memory has no record to reference. + for (const e of instance.entityRefs.keys()) if (this.instances.get(e.key) === e) refKeys.add(e.key); } - this.persistEntity(instance.key, instance.data, refKeys); + let value = instance.data; + const merge = mergeKeys !== undefined && this.mergesEntities; + if (merge) { + value = {}; + for (const k of mergeKeys) value[k] = instance.data[k]; + } + this.persistEntity(instance.key, value, refKeys, merge); } } diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 673c54a..b85fbcd 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -66,6 +66,8 @@ export interface LiveCollectionParent { addChildRef(child: EntityInstance, persist?: boolean): void; removeChildRef(child: EntityInstance, persist?: boolean): void; save(): void; + /** A live field's membership changed outside an apply (an event was routed into it). */ + liveFieldChanged?(fieldKey: string): void; } // ====================================================== @@ -153,8 +155,9 @@ export class LiveCollectionBinding { typename: string, entityKey: number, eventType: 'create' | 'update' | 'delete', - onMatch?: () => void, + onMatch?: (willRetain: boolean) => void, deleteData?: Record, + dryRun: boolean = false, ): void { const defs = this._entityDefsByTypename.get(typename); if (defs === undefined) return; @@ -175,8 +178,8 @@ export class LiveCollectionBinding { } const entity = entityInstance !== undefined ? entityInstance.getProxy(def as unknown as EntityDef) : deleteData; if (entity !== undefined) { - this.instance.onEvent(entityKey, entity, deleteData ?? entityInstance?.data ?? {}, 'delete'); - onMatch?.(); + if (!dryRun) this.instance.onEvent(entityKey, entity, deleteData ?? entityInstance?.data ?? {}, 'delete'); + onMatch?.(false); } return; } @@ -189,7 +192,9 @@ export class LiveCollectionBinding { if (def === undefined) return; if (!entityInstance.satisfiesDef(def as unknown as ValidatorDef)) return; - onMatch?.(); + // A live array retains what it adds; a live value only reduces over it. + onMatch?.(this.instance instanceof LiveArrayInstance); + if (dryRun) return; const proxy = entityInstance.getProxy(def as unknown as EntityDef); this.instance.onEvent(entityKey, proxy, entityInstance.data, eventType); } @@ -214,6 +219,8 @@ export class LiveArrayInstance { _queryClient: QueryClient; _parent: LiveCollectionParent; + _fieldKey: string | undefined; + constructor( queryClient: QueryClient, parent: LiveCollectionParent, @@ -221,12 +228,14 @@ export class LiveArrayInstance { constraintFieldPaths?: FieldPath[], constraintHash?: number, sort?: (a: unknown, b: unknown) => number, + fieldKey?: string, ) { this._notifier = notifier(); this._items = items; this._keys = buildKeySet(items); this._queryClient = queryClient; this._parent = parent; + this._fieldKey = fieldKey; const needsFilter = constraintFieldPaths !== undefined && constraintHash !== undefined; const needsSort = sort !== undefined; @@ -309,6 +318,7 @@ export class LiveArrayInstance { this._keys.add(key); this._items.push(proxy); + if (this._fieldKey !== undefined) this._parent.liveFieldChanged?.(this._fieldKey); const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { // Only the parent's ref set needs persisting; the child's record is current. @@ -325,6 +335,7 @@ export class LiveArrayInstance { const idx = this._findIndex(key); if (idx !== -1) this._items.splice(idx, 1); + if (this._fieldKey !== undefined) this._parent.liveFieldChanged?.(this._fieldKey); const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { this._parent.removeChildRef(child); @@ -498,6 +509,7 @@ export function createLiveCollection( parent: LiveCollectionParent, parentData: Record, queryClient: QueryClient, + fieldKey?: string, ): LiveCollectionBinding { let constraintFieldRefs = config.constraintFieldRefs; @@ -540,6 +552,7 @@ export function createLiveCollection( arrayConstraintFieldPaths, arrayConstraintHash, config.sort, + fieldKey, ); } else { inner = new LiveValueInstance( @@ -597,7 +610,7 @@ export function initializeLiveFields( } data[fieldName] = existing; } else { - data[fieldName] = createLiveCollection(config, data[fieldName], parent, data, queryClient); + data[fieldName] = createLiveCollection(config, data[fieldName], parent, data, queryClient, fieldName); } } else if ( (fieldDef.mask & Mask.OBJECT) !== 0 && diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 82dc8b0..f7a7626 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -11,7 +11,7 @@ import { } from './types.js'; import { PROXY_ID } from './proxyId.js'; import { EntityStore } from './EntityStore.js'; -import { EntityInstance } from './EntityInstance.js'; +import { EntityInstance, type EntityKeys } from './EntityInstance.js'; import { NetworkManager, NoOpNetworkManager } from './NetworkManager.js'; import { QueryInstance } from './QueryResult.js'; import { MutationResultImpl } from './MutationResult.js'; @@ -168,6 +168,14 @@ export class QueryClient { currentParseId: number = 0; /** Without `store.onDelete`, `_persisted` cannot be trusted. */ storeReportsDeletes: boolean = false; + /** With `store.onPersisted`, `_persisted` is set by the store's acknowledgement, not by `save()`. */ + storeAcksWrites: boolean = false; + /** + * Per-client static/dynamic key split for each entity shape (see + * `shapeKeys` in EntityInstance.ts). Replaced wholesale when a typename + * gains a second class, since the split depends on the other classes. + */ + shapeKeyCache = new WeakMap, EntityKeys>(); /** See `QueryClientConfig.reactivationGraceMs`. */ readonly reactivationGraceMs: number; @@ -203,6 +211,7 @@ export class QueryClient { private mergedDefCache = new Map>(); private adapters = new Map(); private networkUnsubscribe: (() => void) | undefined; + private storeUnsubscribes: Array<() => void> = []; private mutationParseContext = new ParseContext(); constructor(config: QueryClientConfig = {}) { @@ -229,13 +238,23 @@ export class QueryClient { config.gcManager ?? (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); this.networkManager = config.networkManager ?? new NetworkManager(); - this.entityMap = new EntityStore((key, data, refs) => this.store.saveEntity(key, data, refs)); + this.entityMap = new EntityStore((key, data, refs, merge) => { + if (merge) this.store.mergeEntity!(key, data, refs); + else this.store.saveEntity(key, data, refs); + }); + this.entityMap.mergesEntities = typeof this.store.mergeEntity === 'function'; // A record the store drops must be written again by the next apply. this.storeReportsDeletes = typeof this.store.onDelete === 'function'; - this.store.onDelete?.(key => { - const entity = this.entityMap.getEntity(key); - if (entity !== undefined) entity._persisted = false; + const offDelete = this.store.onDelete?.(key => { + this.entityMap.getEntity(key)?.recordDropped(); + }); + if (typeof offDelete === 'function') this.storeUnsubscribes.push(offDelete); + // A store that processes writes later says when a record is really there. + this.storeAcksWrites = typeof this.store.onPersisted === 'function'; + const offPersisted = this.store.onPersisted?.(key => { + this.entityMap.getEntity(key)?.acknowledgeWrite(); }); + if (typeof offPersisted === 'function') this.storeUnsubscribes.push(offPersisted); // Register user-supplied adapters for (const adapter of config.adapters ?? []) { @@ -322,6 +341,15 @@ export class QueryClient { // Typename Registry (per-client) // ====================================================== + /** + * Whether every write handed to the store has been processed, so a skipped + * write cannot be undone by a deletion the store has queued but not yet run. + * A synchronous store is always settled. + */ + storeIsSettled(): boolean { + return this.store.isSettled?.() ?? true; + } + private registerEntityDef(def: ValidatorDef): void { const typename = def.typenameValue; if (typename === undefined) return; @@ -335,6 +363,9 @@ export class QueryClient { existing.push(def); this.mergedDefCache.delete(typename); this.getMergedDef(typename); + // The snapshot fast path's static/dynamic split for this typename's + // classes now has to account for the new class's fields. + this.shapeKeyCache = new WeakMap(); } else { this.typenameRegistry.set(typename, [def]); } @@ -719,7 +750,8 @@ export class QueryClient { obj !== null && !('__entityRef' in (obj as Record)) ) { - (obj as Record)[QUERY_ID] = queryId; + // On a copy: the payload belongs to the adapter (it may be frozen). + obj = { ...(obj as Record), [QUERY_ID]: queryId }; } const parseResult = this.parseData(obj, rootEntityShape as unknown as InternalTypeDef, preloadedEntities); @@ -770,13 +802,16 @@ export class QueryClient { return; } + // Entities the event creates are not written by the apply; entities it + // merely updates are. A created entity is written once a written record + // references it, or below, once the root is known to be retained. + const created = new Set(); try { const warn = this.context.log?.warn ?? (() => {}); const parseCtx = this.mutationParseContext; parseCtx.reset(this, undefined, warn, /* isPartialEvent */ true); const parsedData = parseEntity(data, mergedDef as unknown as EntityDef, parseCtx); - // A new entity is written only once something routes it. - applyEntityRefs(parseCtx, parsedData, /* persist */ existing !== undefined); + applyEntityRefs(parseCtx, parsedData, existing !== undefined ? true : 'existing', false, created); } catch (e) { // Unknown required-union variant: surface as an error (not a silent warn) // so the dropped update is visible. Optional unions degrade during parse. @@ -786,8 +821,9 @@ export class QueryClient { this.context.log?.warn?.('Failed to apply mutation event', e); } if (existing === undefined) { - const created = this.entityMap.getEntity(key); - if (created !== undefined) created.evict(); + // Half applied: nothing built from it can stay. + const createdRoot = this.entityMap.getEntity(key); + if (createdRoot !== undefined) this.evictCreated(createdRoot, created); } return; } @@ -795,19 +831,138 @@ export class QueryClient { const entity = this.entityMap.getEntity(key); if (entity === undefined) return; - const wasNew = existing === undefined; + if (existing !== undefined) { + this.routeEvent(typename, entity.data, key, type, eventSource); + return; + } + + // The root the event created is written, with the entities it created + // under it, when a live array is about to retain it (the array's owner + // is written with the reference) or when the store already holds a record + // of it: a query that was collected from memory can still hold this + // entity in its cache, and the event is that record's only chance to stay + // fresh. That write merges the event's fields over the record, since an + // event is a partial update and the fields it leaves out are not known + // here. The write comes before the routing: if it fails, nothing is + // routed, the failure is logged, and memory stays as it was, as a failed + // event always did. A store that cannot say what it holds (no + // `hasEntity`, or not yet) is not written, so it never accumulates + // records nothing references. Anything else the event created was only + // ever referenced by an unwritten root and stays unwritten. let matched = false; + let retains = false; + this.routeEvent( + typename, + entity.data, + key, + type, + eventSource, + willRetain => { + matched = true; + if (willRetain) retains = true; + }, + undefined, + /* dryRun */ true, + ); + const held = this.store.hasEntity?.(key) === true; + if ((retains || held) && !entity._persisted && entity._pendingWrites === 0) { + try { + entity.save(); + } catch (e) { + this.context.log?.warn?.('Failed to apply mutation event', e); + this.evictUnlessAdopted(entity, created); + return; + } + } - this.routeEvent(typename, entity.data, key, type, eventSource, () => { - matched = true; - }); + this.routeEvent(typename, entity.data, key, type, eventSource); - if (wasNew) { - if (matched) { - // The parent persisted a ref to it, so its record must exist. - persistUnwritten(entity); - } else { - entity.evict(); + if (!matched) this.evictUnlessAdopted(entity, created); + } + + /** + * Evicts the root an event created unless an entity that existed before + * the event now references it (its owner's data holds the proxy, so it is + * live). References from entities the same event created, the root itself + * included, do not count: they were only ever reachable through the root. + */ + private evictUnlessAdopted(root: EntityInstance, created: Set): void { + let createdHolders = 0; + for (const c of created) if (c.entityRefs?.has(root)) createdHolders++; + if (root.refCount <= createdHolders) this.evictCreated(root, created); + } + + /** + * Evicts the root an event created, and with it every entity the event + * created that nothing references any more: they were never written, so + * lingering until a `gcTime` runs out would let a later event write them + * as records nothing references. + */ + private evictCreated(root: EntityInstance, created: Set): void { + root.evict(); + // Evicting one releases what it held; repeat until nothing more is free. + let evicted = true; + while (evicted) { + evicted = false; + for (const c of created) { + if (c !== root && c.refCount === 0 && this.entityMap.getEntity(c.key) === c) { + c.evict(); + evicted = true; + } + } + } + } + + /** + * Entities whose write was requested while an apply was still reifying + * them; written once that apply is done. + */ + private deferredWrites: Set | undefined; + + /** @internal */ + deferWrite(entity: EntityInstance): void { + (this.deferredWrites ??= new Set()).add(entity); + } + + /** + * Writes the deferred entities in the order they were deferred: children + * before their parents. A write that fails leaves the rest unwritten, and + * marked so that the next apply writes them. + */ + /** @internal */ + flushDeferredWrites(): void { + const deferred = this.deferredWrites; + if (deferred === undefined || deferred.size === 0) return; + this.deferredWrites = undefined; + try { + for (const entity of deferred) { + if (entity._applying || this.entityMap.getEntity(entity.key) !== entity) continue; + if (entity._deferredWrite || (!entity._persisted && entity._pendingWrites === 0)) entity.save(); + } + } finally { + for (const entity of deferred) { + if (entity._deferredWrite) { + entity._deferredWrite = false; + entity._persisted = false; + } + } + } + } + + /** + * An apply that failed leaves nothing to write: what it deferred may be half + * reified. The records that were waiting are stale, so the next apply + * writes them. + */ + /** @internal */ + discardDeferredWrites(): void { + const deferred = this.deferredWrites; + if (deferred === undefined) return; + this.deferredWrites = undefined; + for (const entity of deferred) { + if (entity._deferredWrite) { + entity._deferredWrite = false; + entity._persisted = false; } } } @@ -931,14 +1086,15 @@ export class QueryClient { entityKey: number, eventType: 'create' | 'update' | 'delete', eventSource: number | undefined, - onMatch?: () => void, + onMatch?: (willRetain: boolean) => void, deleteData?: Record, + dryRun: boolean = false, ): void { const matcher = this.constraintRegistry.get(typename); if (matcher === undefined) return; const data = eventSource !== undefined ? { ...entityData, [EVENT_SOURCE_FIELD]: eventSource } : entityData; - matcher.routeEvent(typename, data, entityKey, eventType, onMatch, deleteData); + matcher.routeEvent(typename, data, entityKey, eventType, onMatch, deleteData, dryRun); } destroy(): void { @@ -948,6 +1104,8 @@ export class QueryClient { this.staggerTimer = undefined; this.staggerQueue.clear(); this.networkUnsubscribe?.(); + for (const off of this.storeUnsubscribes) off(); + this.storeUnsubscribes = []; this.gcManager.destroy(); this.networkManager.destroy(); for (const adapter of this.adapters.values()) { @@ -1014,13 +1172,3 @@ function paramsMatch(instanceParams: Record | undefined, subset } return true; } - -/** Writes an entity and its not-yet-written descendants. */ -function persistUnwritten(entity: EntityInstance): void { - if (!entity._persisted) entity.save(); - const refs = entity.entityRefs; - if (refs === undefined) return; - for (const child of refs.keys()) { - if (!child._persisted) persistUnwritten(child); - } -} diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index cf232e4..3c2ea52 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -271,6 +271,10 @@ export class QueryInstance { __hasNext: () => this.hasNext, __isFetchingNext: () => this._fetchNextPromise !== undefined, }; + // The entity's key set just changed. A shared entity (a query whose + // result is an entity) may already have consumers, and an apply that + // found its data unchanged will not have notified them. + this.rootEntity.notify(); } return this.rootEntity.getProxy(def.statics.shape as unknown as EntityDef) as QueryResult; diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index 9b52c99..04ced66 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { watcher, withContexts } from 'signalium'; import { hashValue, snapshot } from 'signalium/utils'; import { + __setSnapshotDriftHandler, __debug_resetSnapshotCounters, __debug_snapshotFieldReads, __debug_snapshotFullWalks, @@ -615,7 +616,7 @@ describe('Entity Snapshots', () => { expect(after.board.slots[1]).toBe(before.board.slots[2]); }); - it('throws in dev when a field changes without the version moving', async () => { + it('raises in dev when a field changes without the version moving, and serves the re-read value', async () => { const { client, mockFetch } = getClient(); mockFetch.get('/portfolio', portfolio(2)); @@ -630,15 +631,49 @@ describe('Entity Snapshots', () => { const instance = client.entityMap.getEntity(hashValue(['Token', 'tok-0']))!; const original = instance.data.symbol; instance.data.symbol = 'MUTATED'; - client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 5 } }); + const raised: Error[] = []; + __setSnapshotDriftHandler(error => raised.push(error)); + try { + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 5 } }); + + // The guard must not throw from inside the snapshot: that escapes + // Signalium's watcher flush and stalls every consumer on the page. It + // raises the error out of band (by default as an uncaught error, through + // the client's logger) and hands out what a full walk would have produced. + const snap = read() as unknown as { tokens: { symbol: string }[] }; + expect(snap.tokens[0].symbol).toBe('MUTATED'); + expect(raised).toHaveLength(1); + expect(raised[0].message).toMatch(/stale entity snapshot: Token:tok-0 field 'symbol'/); + + // Raised once per field: a second recompute stays quiet. + client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 6 } }); + read(); + expect(raised).toHaveLength(1); + } finally { + __setSnapshotDriftHandler(undefined); + instance.data.symbol = original; + instance.notify(); + read(); + } + }); - expect(() => read()).toThrow(/stale entity snapshot/); + it('hands out frozen snapshots in dev, so a consumer mutation fails at its call site', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/portfolio', portfolio(2)); - // Put the entity back and let the version move, so the watcher's own - // recompute doesn't rethrow after the test has finished. - instance.data.symbol = original; - instance.notify(); - read(); + const { query, read } = snapshotHarness(client, () => fetchQuery(GetPortfolio)); + await query; + const snap = read() as unknown as { tokens: { symbol: string }[] }; + + expect(Object.isFrozen(snap)).toBe(true); + expect(Object.isFrozen(snap.tokens)).toBe(true); + expect(Object.isFrozen(snap.tokens[0])).toBe(true); + // The classic "sort in render" bug throws where it happens, not later. + expect(() => snap.tokens.sort()).toThrow(TypeError); + expect(() => { + snap.tokens[0].symbol = 'X'; + }).toThrow(TypeError); + expect(snap.tokens[0].symbol).not.toBe('X'); }); it('re-snapshots a union field whose only entity-bearing member is an array', async () => { diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index f9c93c2..8d67268 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -40,8 +40,19 @@ class GetList extends RESTQuery { result = { list: t.entity(List) }; } -function itemSaves(spy: ReturnType, typename = 'Item') { - return spy.mock.calls.filter(([, value]) => (value as Record)?.__typename === typename); +/** Every entity write, whether it replaced the record or merged into it. */ +function spyEntityWrites(store: { saveEntity: (...args: any[]) => void; mergeEntity: (...args: any[]) => void }) { + const save = vi.spyOn(store, 'saveEntity'); + const merge = vi.spyOn(store, 'mergeEntity'); + return { + get calls() { + return [...save.mock.calls, ...merge.mock.calls]; + }, + }; +} + +function itemSaves(spy: { calls: unknown[][] }, typename = 'Item') { + return spy.calls.filter(([, value]) => (value as Record)?.__typename === typename); } describe('Entity Write Counts', () => { @@ -56,7 +67,7 @@ describe('Entity Write Counts', () => { await query; }); - const saveEntity = vi.spyOn(store, 'saveEntity'); + const saveEntity = spyEntityWrites(store); client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'B' } }); expect(itemSaves(saveEntity)).toHaveLength(1); @@ -72,7 +83,7 @@ describe('Entity Write Counts', () => { await query; }); - const saveEntity = vi.spyOn(store, 'saveEntity'); + const saveEntity = spyEntityWrites(store); // A subscription re-broadcasting what the store already holds: the apply // declines the write, and nothing writes on its behalf afterwards. @@ -95,7 +106,7 @@ describe('Entity Write Counts', () => { await query; }); - const saveEntity = vi.spyOn(store, 'saveEntity'); + const saveEntity = spyEntityWrites(store); client.applyMutationEvent({ type: 'create', typename: 'Item', @@ -123,7 +134,7 @@ describe('Entity Write Counts', () => { await query; }); - const saveEntity = vi.spyOn(store, 'saveEntity'); + const saveEntity = spyEntityWrites(store); client.applyMutationEvent({ type: 'create', typename: 'Item', @@ -152,7 +163,7 @@ describe('Entity Write Counts', () => { listQuery = query as unknown as typeof listQuery; }); - const saveEntity = vi.spyOn(store, 'saveEntity'); + const saveEntity = spyEntityWrites(store); // The item is already current in the store from the first query, so the // apply declines its write. The insert must not put one back. client.applyMutationEvent({ diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts new file mode 100644 index 0000000..bc18d56 --- /dev/null +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -0,0 +1,1544 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { AsyncQueryStore, type AsyncPersistentStore } from '../stores/async.js'; +import { valueKeyFor, refCountKeyFor, refIdsKeyFor, queueKeyFor, updatedAtKeyFor } from '../stores/shared.js'; +import { createMockFetch, setupTestClient, sleep } from './utils.js'; + +/** + * What a streamed event leaves in the store: which records are written, which + * are not, and whether every reference on disk points at a record that exists. + * Complements `skip-write-and-snapshot-fixes.test.ts`. + */ + +function getDoc(kv: MemoryPersistentStore, key: number): Record | undefined { + const value = kv.getString(valueKeyFor(key)); + return value ? (JSON.parse(value) as Record) : undefined; +} +function refIds(kv: MemoryPersistentStore, key: number): number[] { + return Array.from(kv.getBuffer(refIdsKeyFor(key)) ?? []); +} + +/** + * Every record must be referenced (a refcount) unless it is a query, and every + * reference must point at a record that exists. + */ +function audit(kv: MemoryPersistentStore): { orphans: number[]; dangling: string[] } { + const ids = new Set(); + for (const k of kv.getAllKeys()) { + const m = /^sq:doc:(value|refCount|refIds|updatedAt):(\d+)$/.exec(k); + if (m) ids.add(Number(m[2])); + } + const orphans: number[] = []; + const dangling: string[] = []; + for (const id of ids) { + const hasValue = kv.getString(valueKeyFor(id)) !== undefined; + const isQuery = kv.getNumber(updatedAtKeyFor(id)) !== undefined; + if (hasValue && !isQuery && kv.getNumber(refCountKeyFor(id)) === undefined) orphans.push(id); + for (const ref of kv.getBuffer(refIdsKeyFor(id)) ?? []) { + if (kv.getString(valueKeyFor(ref)) === undefined) dangling.push(`${id} -> ${ref}`); + } + } + return { orphans, dangling }; +} + +function holdQuery(client: QueryClient, start: () => T): T { + return withContexts([[QueryClientContext, client]], () => { + const query = start(); + const w = watcher(() => (query as unknown as { value: unknown }).value); + w.addListener(() => {}); + return query; + }); +} + +const K = (typename: string, id: string | number) => hashValue([typename, id]); + +/** The keys of every entity write, whichever store method carried it. */ +function spyWrites(store: SyncQueryStore): () => number[] { + const saves = vi.spyOn(store, 'saveEntity'); + const merges = vi.spyOn(store, 'mergeEntity'); + return () => [...saves.mock.calls.map(c => c[0]), ...merges.mock.calls.map(c => c[0])]; +} + +/** Makes every write of the given key throw, whichever store method carries it. */ +function failWritesOf(store: SyncQueryStore, key: number, shouldFail: () => boolean = () => true): void { + const save = store.saveEntity.bind(store); + const merge = store.mergeEntity.bind(store); + vi.spyOn(store, 'saveEntity').mockImplementation((k, value, refs) => { + if (shouldFail() && k === key) throw new Error('quota exceeded'); + save(k, value, refs); + }); + vi.spyOn(store, 'mergeEntity').mockImplementation((k, fields, refs) => { + if (shouldFail() && k === key) throw new Error('quota exceeded'); + merge(k, fields, refs); + }); +} + +// ====================================================== +// Which records an event writes +// ====================================================== + +describe('records a streamed event writes', () => { + class Badge extends Entity { + __typename = t.typename('Badge'); + id = t.id; + label = t.string; + } + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + karma = t.number; + badge = t.optional(t.entity(Badge)); + } + class Comment extends Entity { + __typename = t.typename('Comment'); + id = t.id; + postId = t.string; + body = t.string; + author = t.entity(User); + mentions = t.optional(t.array(t.entity(User))); + } + class Post extends Entity { + __typename = t.typename('Post'); + id = t.id; + title = t.string; + comments = t.liveArray(Comment, { constraints: { postId: (this as any).id } }); + commentCount = t.liveValue(t.number, Comment, { + constraints: { postId: (this as any).id }, + onCreate: (count: number) => count + 1, + onUpdate: (count: number) => count, + onDelete: (count: number) => count - 1, + }); + } + class Thread extends Entity { + __typename = t.typename('Thread'); + id = t.id; + latest = t.liveValue(t.optional(t.string), Comment, { + constraints: { postId: (this as any).id }, + onCreate: (_prev: string | undefined, comment: Comment) => comment.body, + onUpdate: (prev: string | undefined) => prev, + onDelete: (prev: string | undefined) => prev, + }); + } + class GetPost extends RESTQuery { + params = { id: t.id }; + path = `/post/${this.params.id}`; + result = { post: t.entity(Post) }; + } + class GetThread extends RESTQuery { + params = { id: t.id }; + path = `/thread/${this.params.id}`; + result = { thread: t.entity(Thread) }; + } + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/user/${this.params.id}`; + result = { user: t.entity(User) }; + } + + const getClient = setupTestClient(); + const newAuthor = () => ({ + __typename: 'User', + id: 'u7', + name: 'Newbie', + karma: 1, + badge: { __typename: 'Badge', id: 'b7', label: 'new' }, + }); + + it('the same new entity twice in one payload is referenced, counted and written once', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [], commentCount: 0 } }); + const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + await postQ; + const writes = spyWrites(store); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'p1', body: 'hi', author: newAuthor(), mentions: [newAuthor()] }, + }); + await sleep(5); + + const u7 = client.entityMap.getEntity(K('User', 'u7'))!; + // The comment references u7 twice (author and mentions[0]) and u7 keeps its badge. + expect(client.entityMap.getEntity(K('Comment', 'c9'))!.entityRefs!.get(u7)).toBe(2); + expect([...u7.entityRefs!.keys()].map(e => e.key)).toEqual([K('Badge', 'b7')]); + expect(writes().filter(k => k === K('User', 'u7'))).toHaveLength(1); + expect(refIds(kv, K('User', 'u7'))).toEqual([K('Badge', 'b7')]); + expect(getDoc(kv, K('Badge', 'b7'))).toMatchObject({ label: 'new' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + expect((postQ.value as any).post.comments[0].mentions[0].badge.label).toBe('new'); + }); + + it('the same entity twice in a fetched payload keeps its child references', async () => { + const { client, mockFetch, kv } = getClient(); + class GetComments extends RESTQuery { + path = '/comments'; + result = { comments: t.array(t.entity(Comment)) }; + } + const author = () => ({ + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 3, + badge: { __typename: 'Badge', id: 'b1', label: 'gold' }, + }); + const payload = () => ({ + comments: [ + { __typename: 'Comment', id: 'c1', postId: 'p1', body: 'a', author: author() }, + { __typename: 'Comment', id: 'c2', postId: 'p1', body: 'b', author: author() }, + ], + }); + mockFetch.get('/comments', payload()); + const q = holdQuery(client, () => fetchQuery(GetComments)); + await q; + + const u1 = client.entityMap.getEntity(K('User', 'u1'))!; + expect(u1.refCount).toBe(2); + expect([...u1.entityRefs!.keys()].map(e => e.key)).toEqual([K('Badge', 'b1')]); + expect(client.entityMap.getEntity(K('Badge', 'b1'))!.refCount).toBe(1); + expect(refIds(kv, K('User', 'u1'))).toEqual([K('Badge', 'b1')]); + expect(getDoc(kv, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); + expect((q.value as any).comments.map((c: any) => c.author.badge.label)).toEqual(['gold', 'gold']); + + // A refetch with the same payload changes nothing. + mockFetch.get('/comments', payload()); + await (q.value as any).__refetch(); + expect(u1.refCount).toBe(2); + expect(client.entityMap.getEntity(K('Badge', 'b1'))!.refCount).toBe(1); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('a create matched only by a liveValue reducer writes nothing, so no orphan record is left', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/thread/p1', { thread: { __typename: 'Thread', id: 'p1', latest: null } }); + const threadQ = holdQuery(client, () => fetchQuery(GetThread, { id: 'p1' })); + await threadQ; + const writes = spyWrites(store); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'p1', body: 'newest', author: newAuthor() }, + }); + await sleep(5); + + expect((threadQ.value as any).thread.latest).toBe('newest'); + expect(writes()).toEqual([]); + expect(getDoc(kv, K('User', 'u7'))).toBeUndefined(); + expect(getDoc(kv, K('Badge', 'b7'))).toBeUndefined(); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('a partial update for an entity on disk but not in memory merges into its record', async () => { + const { client, mockFetch, kv } = getClient(); + mockFetch.get('/user/u1', { + user: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 10, + badge: { __typename: 'Badge', id: 'b1', label: 'gold' }, + }, + }); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + client.entityMap.getEntity(K('User', 'u1'))!.evict(); + expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); + + // The payload carries the required fields (an event for an entity that is + // not in memory is parsed as a full record) but not the optional badge. + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, + }); + await sleep(5); + + // The badge reference the event did not carry survives. + expect(getDoc(kv, K('User', 'u1'))).toMatchObject({ name: 'Alicia', karma: 10 }); + expect(refIds(kv, K('User', 'u1'))).toEqual([K('Badge', 'b1')]); + expect(getDoc(kv, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); + expect(kv.getNumber(refCountKeyFor(K('Badge', 'b1')))).toBe(1); + expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); + + // A replaced child is written and the old one released. + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { + __typename: 'User', + id: 'u1', + name: 'Alicia', + karma: 10, + badge: { __typename: 'Badge', id: 'b9', label: 'platinum' }, + }, + }); + await sleep(5); + expect(getDoc(kv, K('User', 'u1'))).toMatchObject({ name: 'Alicia', karma: 10 }); + expect(refIds(kv, K('User', 'u1'))).toEqual([K('Badge', 'b9')]); + expect(getDoc(kv, K('Badge', 'b9'))).toMatchObject({ label: 'platinum' }); + expect(getDoc(kv, K('Badge', 'b1'))).toBeUndefined(); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + + // The next session hydrates the merged record. + client.destroy(); + const warn = vi.fn(); + const second = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn, error: () => {} }, + } as never); + mockFetch.get('/user/u1', { user: { __typename: 'User', id: 'u1', name: 'FRESH', karma: 0 } }, { delay: 5000 }); + const q = holdQuery(second, () => fetchQuery(GetUser, { id: 'u1' })); + await sleep(10); + expect((q.value as any).user.name).toBe('Alicia'); + expect((q.value as any).user.karma).toBe(10); + expect((q.value as any).user.badge.label).toBe('platinum'); + expect(warn).not.toHaveBeenCalled(); + second.destroy(); + }); + + it('a store write that fails after routing is reverted, and the failure is logged rather than thrown', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [], commentCount: 0 } }); + const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + await postQ; + const warn = vi + .spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn') + .mockImplementation(() => {}); + failWritesOf(store, K('Comment', 'c9')); + + expect(() => + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'p1', body: 'hi', author: newAuthor() }, + }), + ).not.toThrow(); + await sleep(5); + + expect(warn).toHaveBeenCalledWith('Failed to apply mutation event', expect.any(Error)); + // Memory and the store agree: the comment is in neither, and no record + // references it. (The author written before the failing write stays + // behind as an unreferenced record, as any write that fails mid-walk has + // always left its predecessors.) + expect((postQ.value as any).post.comments).toEqual([]); + expect((postQ.value as any).post.commentCount).toBe(0); + expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); + expect(refIds(kv, K('Post', 'p1'))).toEqual([]); + expect(getDoc(kv, K('Comment', 'c9'))).toBeUndefined(); + expect(audit(kv).dangling).toEqual([]); + }); + + it('a store write that fails for an unrouted root does not leak the root', async () => { + const { client, mockFetch, store } = getClient(); + mockFetch.get('/user/u1', { user: { __typename: 'User', id: 'u1', name: 'Alice', karma: 10 } }); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + client.entityMap.getEntity(K('User', 'u1'))!.evict(); + const warn = vi + .spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn') + .mockImplementation(() => {}); + failWritesOf(store, K('User', 'u1')); + + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, + }); + await sleep(5); + + expect(warn).toHaveBeenCalledWith('Failed to apply mutation event', expect.any(Error)); + expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); + }); + + it('a failed create delivered again is routed once: the array and the reducer agree', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [], commentCount: 0 } }); + const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + await postQ; + vi.spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn').mockImplementation(() => {}); + let fail = true; + failWritesOf(store, K('Comment', 'c9'), () => fail); + const event = () => ({ + type: 'create' as const, + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'p1', body: 'hi', author: newAuthor() }, + }); + + client.applyMutationEvent(event()); + await sleep(5); + expect((postQ.value as any).post.comments).toEqual([]); + expect((postQ.value as any).post.commentCount).toBe(0); + + // The transport retries once the store works again. + fail = false; + client.applyMutationEvent(event()); + await sleep(5); + expect((postQ.value as any).post.comments.map((c: any) => c.id)).toEqual(['c9']); + expect((postQ.value as any).post.commentCount).toBe(1); + expect(getDoc(kv, K('Comment', 'c9'))).toMatchObject({ body: 'hi' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('an update matched only by a liveValue reducer refreshes the record of an entity on disk but not in memory', async () => { + const { client, mockFetch, kv } = getClient(); + class GetComments extends RESTQuery { + path = '/comments'; + result = { comments: t.array(t.entity(Comment)) }; + } + mockFetch.get('/thread/p1', { thread: { __typename: 'Thread', id: 'p1', latest: null } }); + mockFetch.get('/comments', { + comments: [ + { + __typename: 'Comment', + id: 'c1', + postId: 'p1', + body: 'orig', + author: { __typename: 'User', id: 'u1', name: 'Alice', karma: 1 }, + }, + ], + }); + const threadQ = holdQuery(client, () => fetchQuery(GetThread, { id: 'p1' })); + await threadQ; + await holdQuery(client, () => fetchQuery(GetComments)); + // The comments query is collected from memory; its record stays on disk. + client.entityMap.getEntity(K('Comment', 'c1'))!.evict(); + + client.applyMutationEvent({ + type: 'update', + typename: 'Comment', + data: { + __typename: 'Comment', + id: 'c1', + postId: 'p1', + body: 'edited', + author: { __typename: 'User', id: 'u1', name: 'Alice', karma: 1 }, + }, + }); + await sleep(5); + + expect((threadQ.value as any).thread.latest).toBeUndefined(); + expect(getDoc(kv, K('Comment', 'c1'))).toMatchObject({ body: 'edited' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('a create adopted by an existing entity in its own payload is kept and written, even when nothing routes it', async () => { + const { client, mockFetch, kv } = getClient(); + class Note extends Entity { + __typename = t.typename('Note'); + id = t.id; + body = t.string; + article = t.optional(t.entity(ArticleRef)); + } + class NoteRef extends Entity { + __typename = t.typename('Note'); + id = t.id; + body = t.string; + } + class ArticleRef extends Entity { + __typename = t.typename('Article'); + id = t.id; + latestNote = t.optional(t.entity(NoteRef)); + } + class Article extends Entity { + __typename = t.typename('Article'); + id = t.id; + title = t.string; + latestNote = t.optional(t.entity(NoteRef)); + notes = t.array(t.entity(Note)); + } + class GetArticle extends RESTQuery { + params = { id: t.id }; + path = `/article/${this.params.id}`; + result = { article: t.entity(Article) }; + } + mockFetch.get('/article/a1', { + article: { + __typename: 'Article', + id: 'a1', + title: 'A', + notes: [{ __typename: 'Note', id: 'n1', body: 'first' }], + }, + }); + const artQ = holdQuery(client, () => fetchQuery(GetArticle, { id: 'a1' })); + await artQ; + + // No live collection routes notes; the payload's article points back at the note. + client.applyMutationEvent({ + type: 'create', + typename: 'Note', + data: { + __typename: 'Note', + id: 'n9', + body: 'hi', + article: { __typename: 'Article', id: 'a1', latestNote: { __typename: 'Note', id: 'n9', body: 'hi' } }, + }, + }); + await sleep(5); + + const n9 = client.entityMap.getEntity(K('Note', 'n9'))!; + expect(n9).toBeDefined(); + expect(n9.refCount).toBe(1); + expect((artQ.value as any).article.latestNote.body).toBe('hi'); + expect(refIds(kv, K('Article', 'a1')).sort()).toEqual([K('Note', 'n1'), K('Note', 'n9')].sort()); + expect(getDoc(kv, K('Note', 'n9'))).toMatchObject({ body: 'hi' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + + // A later event for the note reaches the article's consumer. + client.applyMutationEvent({ type: 'update', typename: 'Note', data: { id: 'n9', body: 'edited' } }); + expect((artQ.value as any).article.latestNote.body).toBe('edited'); + expect(getDoc(kv, K('Note', 'n9'))).toMatchObject({ body: 'edited' }); + + client.destroy(); + const second = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as never); + mockFetch.get( + '/article/a1', + { article: { __typename: 'Article', id: 'a1', title: 'FRESH', notes: [] } }, + { delay: 5000 }, + ); + const q = holdQuery(second, () => fetchQuery(GetArticle, { id: 'a1' })); + await sleep(10); + expect((q.value as any).article.latestNote.body).toBe('edited'); + second.destroy(); + }); + + it('created entities that only reference each other are evicted with the unrouted root', async () => { + const { client, mockFetch, kv } = getClient(); + class Author extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + karma = t.number; + bestComment = t.optional(t.entity(CommentRef)); + } + class CommentRef extends Entity { + __typename = t.typename('Comment'); + id = t.id; + body = t.string; + } + class Remark extends Entity { + __typename = t.typename('Comment'); + id = t.id; + postId = t.string; + body = t.string; + author = t.entity(Author); + } + class GetRemark extends RESTQuery { + params = { id: t.id }; + path = `/remark/${this.params.id}`; + result = { remark: t.entity(Remark) }; + } + mockFetch.get('/remark/c0', { + remark: { + __typename: 'Comment', + id: 'c0', + postId: 'p0', + body: 'seed', + author: { __typename: 'User', id: 'u0', name: 'Zero', karma: 0 }, + }, + }); + await holdQuery(client, () => fetchQuery(GetRemark, { id: 'c0' })); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { + __typename: 'Comment', + id: 'c9', + postId: 'nowhere', + body: 'hi', + author: { + __typename: 'User', + id: 'u7', + name: 'Newbie', + karma: 1, + bestComment: { __typename: 'Comment', id: 'c9', body: 'hi' }, + }, + }, + }); + await sleep(5); + + expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); + expect(client.entityMap.getEntity(K('User', 'u7'))).toBeUndefined(); + expect(getDoc(kv, K('Comment', 'c9'))).toBeUndefined(); + expect(getDoc(kv, K('User', 'u7'))).toBeUndefined(); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('a root write that fails while an existing entity adopted the root leaves no dangling reference', async () => { + const { client, mockFetch, kv, store } = getClient(); + class PostRef extends Entity { + __typename = t.typename('Post'); + id = t.id; + latestComment = t.optional(t.entity(CommentRef)); + } + class CommentRef extends Entity { + __typename = t.typename('Comment'); + id = t.id; + body = t.string; + } + class Reply extends Entity { + __typename = t.typename('Comment'); + id = t.id; + postId = t.string; + body = t.string; + author = t.entity(User); + post = t.optional(t.entity(PostRef)); + } + class Topic extends Entity { + __typename = t.typename('Post'); + id = t.id; + title = t.string; + latestComment = t.optional(t.entity(CommentRef)); + replies = t.liveArray(Reply, { constraints: { postId: (this as any).id } }); + } + class GetTopic extends RESTQuery { + params = { id: t.id }; + path = `/topic/${this.params.id}`; + result = { topic: t.entity(Topic) }; + } + mockFetch.get('/topic/t1', { topic: { __typename: 'Post', id: 't1', title: 'T', replies: [] } }); + const topicQ = holdQuery(client, () => fetchQuery(GetTopic, { id: 't1' })); + await topicQ; + vi.spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn').mockImplementation(() => {}); + failWritesOf(store, K('Comment', 'c9')); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { + __typename: 'Comment', + id: 'c9', + postId: 't1', + body: 'hi', + author: newAuthor(), + post: { __typename: 'Post', id: 't1', latestComment: { __typename: 'Comment', id: 'c9', body: 'hi' } }, + }, + }); + await sleep(5); + + // The topic's record was not written with a reference to a record that + // does not exist; memory holds the adoption and the next write heals it. + expect(audit(kv).dangling).toEqual([]); + expect((topicQ.value as any).topic.replies).toEqual([]); + expect((topicQ.value as any).topic.latestComment.body).toBe('hi'); + expect(refIds(kv, K('Post', 't1'))).toEqual([]); + }); + + it('a payload that links back to its parent is applied, persisted and hydrated', async () => { + const { client, mockFetch, kv } = getClient(); + class Folder extends Entity { + __typename = t.typename('Folder'); + id = t.id; + name = t.string; + files = t.array(t.entity(File)); + } + class FolderRef extends Entity { + __typename = t.typename('Folder'); + id = t.id; + name = t.string; + } + class File extends Entity { + __typename = t.typename('File'); + id = t.id; + name = t.string; + folder = t.entity(FolderRef); + } + class GetFolder extends RESTQuery { + params = { id: t.id }; + path = `/folder/${this.params.id}`; + result = { folder: t.entity(Folder) }; + } + mockFetch.get('/folder/f1', { + folder: { + __typename: 'Folder', + id: 'f1', + name: 'root', + files: [ + { __typename: 'File', id: 'x1', name: 'a.txt', folder: { __typename: 'Folder', id: 'f1', name: 'root' } }, + ], + }, + }); + const q = holdQuery(client, () => fetchQuery(GetFolder, { id: 'f1' })); + await q; + expect((q.value as any).folder.files[0].folder.name).toBe('root'); + expect(refIds(kv, K('Folder', 'f1'))).toEqual([K('File', 'x1')]); + expect(refIds(kv, K('File', 'x1'))).toEqual([K('Folder', 'f1')]); + + client.destroy(); + const warn = vi.fn(); + const second = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn, error: () => {} }, + } as never); + mockFetch.get( + '/folder/f1', + { folder: { __typename: 'Folder', id: 'f1', name: 'FRESH', files: [] } }, + { delay: 5000 }, + ); + const q2 = holdQuery(second, () => fetchQuery(GetFolder, { id: 'f1' })); + await sleep(10); + expect(warn).not.toHaveBeenCalled(); + expect((q2.value as any).folder.files.map((f: any) => f.folder.name)).toEqual(['root']); + second.destroy(); + }); + + it('merging keeps the fields an event did not carry, including a parse result, and merges created descendants too', async () => { + const { client, mockFetch, kv } = getClient(); + class Icon extends Entity { + __typename = t.typename('Icon'); + id = t.id; + url = t.string; + } + class Award extends Entity { + __typename = t.typename('Badge'); + id = t.id; + label = t.string; + icon = t.optional(t.entity(Icon)); + } + class Profile extends Entity { + __typename = t.typename('Profile'); + id = t.id; + name = t.string; + bio = t.optional(t.string); + badge = t.optional(t.entity(Award)); + parsed = t.optional(t.result(t.entity(Award))); + } + class GetProfile extends RESTQuery { + params = { id: t.id }; + path = `/profile/${this.params.id}`; + result = { profile: t.entity(Profile) }; + } + mockFetch.get('/profile/pr1', { + profile: { + __typename: 'Profile', + id: 'pr1', + name: 'Alice', + bio: 'hello', + badge: { + __typename: 'Badge', + id: 'b1', + label: 'gold', + icon: { __typename: 'Icon', id: 'i1', url: '/gold.png' }, + }, + parsed: { __typename: 'Badge', id: 'b5', label: 'parsed' }, + }, + }); + await holdQuery(client, () => fetchQuery(GetProfile, { id: 'pr1' })); + for (const key of ['pr1']) client.entityMap.getEntity(K('Profile', key))!.evict(); + client.entityMap.getEntity(K('Badge', 'b1'))?.evict(); + client.entityMap.getEntity(K('Icon', 'i1'))?.evict(); + + // The badge under the event is a created entity with a record on disk: + // its own merge keeps the icon the event did not mention. + client.applyMutationEvent({ + type: 'update', + typename: 'Profile', + data: { + __typename: 'Profile', + id: 'pr1', + name: 'Alicia', + badge: { __typename: 'Badge', id: 'b1', label: 'gold+' }, + }, + }); + await sleep(5); + + const doc = getDoc(kv, K('Profile', 'pr1'))!; + expect(doc).toMatchObject({ name: 'Alicia', bio: 'hello' }); + expect(doc.parsed).toEqual({ success: true, value: { __entityRef: K('Badge', 'b5') } }); + expect(getDoc(kv, K('Badge', 'b1'))).toMatchObject({ label: 'gold+', icon: { __entityRef: K('Icon', 'i1') } }); + expect(refIds(kv, K('Badge', 'b1'))).toEqual([K('Icon', 'i1')]); + expect(getDoc(kv, K('Icon', 'i1'))).toMatchObject({ url: '/gold.png' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('a write after a cold start does not rewrite the hydrated entities under it', async () => { + const { client, mockFetch, kv } = getClient(); + const comments = Array.from({ length: 5 }, (_, i) => ({ + __typename: 'Comment', + id: `c${i}`, + postId: 'p1', + body: `b${i}`, + author: { + __typename: 'User', + id: `u${i}`, + name: `n${i}`, + karma: i, + badge: { __typename: 'Badge', id: `b${i}`, label: `l${i}` }, + }, + })); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments, commentCount: 5 } }); + await holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + client.destroy(); + + const store = new SyncQueryStore(kv); + const second = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as never); + mockFetch.get( + '/post/p1', + { post: { __typename: 'Post', id: 'p1', title: 'FRESH', comments: [], commentCount: 0 } }, + { delay: 5000 }, + ); + const q = holdQuery(second, () => fetchQuery(GetPost, { id: 'p1' })); + await sleep(10); + expect((q.value as any).post.comments).toHaveLength(5); + const writes = spyWrites(store); + + second.applyMutationEvent({ type: 'update', typename: 'Post', data: { id: 'p1', title: 'T2' } }); + await sleep(5); + expect(writes()).toEqual([K('Post', 'p1')]); + expect(getDoc(kv, K('Post', 'p1'))).toMatchObject({ title: 'T2' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + second.destroy(); + }); + + it('a member routed into the live array of an entity built from events reaches its record', async () => { + const { client, mockFetch, kv } = getClient(); + // A second class for Post without the live fields makes them optional in + // the merged definition, so an event for a post can omit them. + class PostPreview extends Entity { + __typename = t.typename('Post'); + id = t.id; + title = t.string; + blogId = t.optional(t.string); + } + class Blog extends Entity { + __typename = t.typename('Blog'); + id = t.id; + posts = t.liveArray(PostPreview, { constraints: { blogId: (this as any).id } }); + } + class GetBlog extends RESTQuery { + params = { id: t.id }; + path = `/blog/${this.params.id}`; + result = { blog: t.entity(Blog) }; + } + mockFetch.get('/post/p9', { + post: { + __typename: 'Post', + id: 'p9', + title: 'T', + comments: [ + { + __typename: 'Comment', + id: 'c1', + postId: 'p9', + body: 'first', + author: { __typename: 'User', id: 'u1', name: 'A', karma: 1 }, + }, + ], + commentCount: 1, + }, + }); + mockFetch.get('/blog/b1', { blog: { __typename: 'Blog', id: 'b1', posts: [] } }); + const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p9' })); + await postQ; + const blogQ = holdQuery(client, () => fetchQuery(GetBlog, { id: 'b1' })); + await blogQ; + expect(refIds(kv, K('Post', 'p9'))).toEqual([K('Comment', 'c1')]); + // The post is collected from memory; its record stays on disk. + client.entityMap.getEntity(K('Post', 'p9'))!.evict(); + + // An event brings the post back into memory, retained by the blog's live + // array and built from the event alone: no comments, no count. + client.applyMutationEvent({ + type: 'update', + typename: 'Post', + data: { __typename: 'Post', id: 'p9', title: 'T2', blogId: 'b1', commentCount: 1 }, + }); + await sleep(5); + expect((blogQ.value as any).blog.posts.map((p: any) => p.title)).toEqual(['T2']); + const p9 = client.entityMap.getEntity(K('Post', 'p9'))!; + expect(p9._partial).toBe(true); + expect(getDoc(kv, K('Post', 'p9'))).toMatchObject({ title: 'T2', comments: [{ __entityRef: K('Comment', 'c1') }] }); + + // A comment routed into its (empty in memory) live array reaches the record. + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'p9', body: 'second', author: newAuthor() }, + }); + await sleep(5); + expect(getDoc(kv, K('Post', 'p9'))).toMatchObject({ title: 'T2', comments: [{ __entityRef: K('Comment', 'c9') }] }); + expect(refIds(kv, K('Post', 'p9'))).toEqual([K('Comment', 'c9')]); + expect(getDoc(kv, K('Comment', 'c9'))).toMatchObject({ body: 'second' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('an unrouted root that references itself is evicted', async () => { + const { client, mockFetch, kv } = getClient(); + class MessageRef extends Entity { + __typename = t.typename('Message'); + id = t.id; + body = t.string; + } + class Message extends Entity { + __typename = t.typename('Message'); + id = t.id; + roomId = t.string; + body = t.string; + threadRoot = t.optional(t.entity(MessageRef)); + } + class Room extends Entity { + __typename = t.typename('Room'); + id = t.id; + messages = t.liveArray(Message, { constraints: { roomId: (this as any).id } }); + } + class GetRoom extends RESTQuery { + params = { id: t.id }; + path = `/room/${this.params.id}`; + result = { room: t.entity(Room) }; + } + mockFetch.get('/room/r1', { room: { __typename: 'Room', id: 'r1', messages: [] } }); + await holdQuery(client, () => fetchQuery(GetRoom, { id: 'r1' })); + + client.applyMutationEvent({ + type: 'create', + typename: 'Message', + data: { + __typename: 'Message', + id: 'm9', + roomId: 'OTHER', + body: 'hi', + threadRoot: { __typename: 'Message', id: 'm9', body: 'hi' }, + }, + }); + await sleep(5); + expect(client.entityMap.getEntity(K('Message', 'm9'))).toBeUndefined(); + expect(getDoc(kv, K('Message', 'm9'))).toBeUndefined(); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('an entity an event creates under a root in memory merges over its record too', async () => { + const { client, mockFetch, kv } = getClient(); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [], commentCount: 0 } }); + mockFetch.get('/user/u7', { user: { ...newAuthor(), name: 'Old' } }); + const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + await postQ; + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u7' })); + // The author's record is on disk with its badge; the author is collected from memory. + client.entityMap.getEntity(K('User', 'u7'))!.evict(); + client.entityMap.getEntity(K('Badge', 'b7'))?.evict(); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { + __typename: 'Comment', + id: 'c9', + postId: 'p1', + body: 'hi', + author: { __typename: 'User', id: 'u7', name: 'Newbie', karma: 2 }, + }, + }); + await sleep(5); + expect((postQ.value as any).post.comments.map((c: any) => c.author.name)).toEqual(['Newbie']); + // The event did not mention the badge: the record keeps it. + expect(getDoc(kv, K('User', 'u7'))).toMatchObject({ + name: 'Newbie', + karma: 2, + badge: { __entityRef: K('Badge', 'b7') }, + }); + expect(getDoc(kv, K('Badge', 'b7'))).toMatchObject({ label: 'new' }); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); +}); + +// ====================================================== +// Entities with an in-memory gcTime linger after release +// ====================================================== + +describe('records a streamed event writes when released entities linger (gcTime)', () => { + class Badge extends Entity { + static cache = { gcTime: 1 }; + __typename = t.typename('Badge'); + id = t.id; + label = t.string; + } + class User extends Entity { + static cache = { gcTime: 1 }; + __typename = t.typename('User'); + id = t.id; + name = t.string; + badge = t.optional(t.entity(Badge)); + } + class Comment extends Entity { + static cache = { gcTime: 1 }; + __typename = t.typename('Comment'); + id = t.id; + postId = t.string; + body = t.string; + author = t.entity(User); + } + class Post extends Entity { + __typename = t.typename('Post'); + id = t.id; + title = t.string; + comments = t.liveArray(Comment, { constraints: { postId: (this as any).id } }); + } + class GetPost extends RESTQuery { + params = { id: t.id }; + path = `/post/${this.params.id}`; + result = { post: t.entity(Post) }; + } + const getClient = setupTestClient({ evictionMultiplier: 0.001 }); + const newAuthor = () => ({ + __typename: 'User', + id: 'u7', + name: 'Newbie', + badge: { __typename: 'Badge', id: 'b7', label: 'new' }, + }); + + it('an unrouted create evicts everything it created at once, gcTime or not, and writes none of it', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [] } }); + const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + await postQ; + const writes = spyWrites(store); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'OTHER', body: 'x', author: newAuthor() }, + }); + await sleep(5); + + // Nothing references what the event created; had the author lingered + // until its gcTime, a later event could have written it as a record + // nothing references. + expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); + expect(client.entityMap.getEntity(K('User', 'u7'))).toBeUndefined(); + expect(client.entityMap.getEntity(K('Badge', 'b7'))).toBeUndefined(); + expect(writes()).toEqual([]); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + + // A second unrouted event for the same author still writes nothing. + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9b', postId: 'OTHER', body: 'x', author: newAuthor() }, + }); + await sleep(5); + expect(writes()).toEqual([]); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + + // A routed create with the same author writes it, with its badge. + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c10', postId: 'p1', body: 'y', author: newAuthor() }, + }); + await sleep(5); + expect((postQ.value as any).post.comments.map((c: any) => c.author.badge.label)).toEqual(['new']); + expect(getDoc(kv, K('Comment', 'c10'))).toMatchObject({ body: 'y' }); + expect(getDoc(kv, K('User', 'u7'))).toMatchObject({ name: 'Newbie' }); + expect(getDoc(kv, K('Badge', 'b7'))).toMatchObject({ label: 'new' }); + expect(refIds(kv, K('User', 'u7'))).toEqual([K('Badge', 'b7')]); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + + // GC time passes; the entities are retained and survive. + await sleep(300); + expect(client.entityMap.getEntity(K('User', 'u7'))).toBeDefined(); + expect(client.entityMap.getEntity(K('Badge', 'b7'))).toBeDefined(); + }); + + it('an unrouted create followed by GC leaves nothing in memory or on disk', async () => { + const { client, mockFetch, kv } = getClient(); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [] } }); + await holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + + client.applyMutationEvent({ + type: 'create', + typename: 'Comment', + data: { __typename: 'Comment', id: 'c9', postId: 'OTHER', body: 'x', author: newAuthor() }, + }); + await sleep(300); + + expect(client.entityMap.getEntity(K('User', 'u7'))).toBeUndefined(); + expect(client.entityMap.getEntity(K('Badge', 'b7'))).toBeUndefined(); + expect(getDoc(kv, K('User', 'u7'))).toBeUndefined(); + expect(getDoc(kv, K('Badge', 'b7'))).toBeUndefined(); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); +}); + +// ====================================================== +// AsyncQueryStore writer +// ====================================================== + +class AsyncDelegate implements AsyncPersistentStore { + kv: Record = {}; + constructor(public delayMs: number = 1) {} + private tick() { + return new Promise(resolve => setTimeout(resolve, this.delayMs)); + } + async has(key: string) { + await this.tick(); + return key in this.kv; + } + async getString(key: string) { + await this.tick(); + return this.kv[key] as string | undefined; + } + async setString(key: string, value: string) { + await this.tick(); + this.kv[key] = value; + } + async getNumber(key: string) { + await this.tick(); + return this.kv[key] as number | undefined; + } + async setNumber(key: string, value: number) { + await this.tick(); + this.kv[key] = value; + } + async getBuffer(key: string) { + await this.tick(); + return this.kv[key] as Uint32Array | undefined; + } + async setBuffer(key: string, value: Uint32Array) { + await this.tick(); + this.kv[key] = value; + } + async delete(key: string) { + await this.tick(); + delete this.kv[key]; + } + async getAllKeys() { + await this.tick(); + return Object.keys(this.kv); + } +} + +function writerStore(delegate: AsyncPersistentStore): AsyncQueryStore { + return new AsyncQueryStore({ + isWriter: true, + delegate, + connect: handleMessage => ({ sendMessage: msg => handleMessage(msg) }), + }); +} + +/** Waits for the writer's read of the keys it holds. */ +async function scanned(store: AsyncQueryStore): Promise { + for (let i = 0; i < 5000; i++) { + if (store.hasEntity!(0) !== undefined) return; + await sleep(2); + } + throw new Error('writer never read its keys'); +} + +async function drain(store: AsyncQueryStore): Promise { + for (let i = 0; i < 5000; i++) { + if (store.isSettled()) return; + await sleep(2); + } + throw new Error('writer queue did not drain'); +} + +function asyncDoc(delegate: AsyncDelegate, key: number): Record | undefined { + const value = delegate.kv[valueKeyFor(key)] as string | undefined; + return value ? (JSON.parse(value) as Record) : undefined; +} + +describe('AsyncQueryStore writer', () => { + const clients: QueryClient[] = []; + afterEach(() => { + for (const client of clients.splice(0)) client.destroy(); + }); + + class Badge extends Entity { + __typename = t.typename('Badge'); + id = t.id; + label = t.string; + } + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + karma = t.number; + badge = t.optional(t.entity(Badge)); + } + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/user/${this.params.id}`; + result = { user: t.entity(User) }; + } + + function makeClient(store: AsyncQueryStore, mockFetch: ReturnType) { + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as never); + clients.push(client); + return client; + } + + it('answers hasEntity from the records it holds, seeded from the delegate', async () => { + const delegate = new AsyncDelegate(); + delegate.kv[valueKeyFor(42)] = JSON.stringify({ name: 'on disk' }); + const store = writerStore(delegate); + await scanned(store); + expect(store.hasEntity!(42)).toBe(true); + expect(store.hasEntity!(43)).toBe(false); + + store.saveEntity(43, { name: 'written' }); + await drain(store); + expect(store.hasEntity!(43)).toBe(true); + + // A query whose record references 43 is deleted: the cascade drops it. + store.saveQuery( + { statics: { id: 'def', cache: {} } } as never, + 7, + { user: { __entityRef: 43 } }, + Date.now(), + new Set([43]), + ); + await drain(store); + store.deleteQuery(7); + await drain(store); + expect(store.hasEntity!(43)).toBe(false); + expect(store.hasEntity!(42)).toBe(true); + }); + + it('refreshes the record of an entity on disk but not in memory by merging the event into it', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { + user: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 10, + badge: { __typename: 'Badge', id: 'b1', label: 'gold' }, + }, + }); + const client = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + await drain(store); + await scanned(store); + client.entityMap.getEntity(K('User', 'u1'))!.evict(); + + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, + }); + await drain(store); + + expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ name: 'Alicia', karma: 10 }); + expect(Array.from(delegate.kv[refIdsKeyFor(K('User', 'u1'))] as Uint32Array)).toEqual([K('Badge', 'b1')]); + expect(asyncDoc(delegate, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); + expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); + + // An entity the store has never seen leaves nothing behind. + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u404', name: 'Nobody', karma: 0 }, + }); + await drain(store); + expect(asyncDoc(delegate, K('User', 'u404'))).toBeUndefined(); + }); + + it('does not write an event for an entity it cannot yet say it holds, and refreshes it once it can', async () => { + const delegate = new AsyncDelegate(1); + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { + user: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 10, + badge: { __typename: 'Badge', id: 'b1', label: 'gold' }, + }, + }); + mockFetch.get('/user/u2', { user: { __typename: 'User', id: 'u2', name: 'Bob', karma: 1 } }); + const first = writerStore(delegate); + const client1 = makeClient(first, mockFetch); + await holdQuery(client1, () => fetchQuery(GetUser, { id: 'u1' })); + await drain(first); + client1.destroy(); + + // A fresh writer has not read its keys yet when the event lands. + let releaseScan = () => {}; + const scanGate = new Promise(resolve => (releaseScan = resolve)); + const originalGetAllKeys = delegate.getAllKeys.bind(delegate); + delegate.getAllKeys = async () => { + await scanGate; + return originalGetAllKeys(); + }; + const store = writerStore(delegate); + const client = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u2' })); + expect(store.hasEntity!(K('User', 'u1'))).toBeUndefined(); + // What this writer wrote itself is known even now. + expect(store.hasEntity!(K('User', 'u2'))).toBe(true); + const event = () => ({ + type: 'update' as const, + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, + }); + client.applyMutationEvent(event()); + expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); + releaseScan(); + await scanned(store); + await drain(store); + // Not written: nothing referenced it and the store could not say it held it. + expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ name: 'Alice' }); + expect(store.hasEntity!(K('User', 'u1'))).toBe(true); + + client.applyMutationEvent(event()); + await drain(store); + expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ + name: 'Alicia', + karma: 10, + badge: { __entityRef: K('Badge', 'b1') }, + }); + expect(asyncDoc(delegate, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); + }); + + it('ignores what it does not understand on the channel and keeps processing', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const handle = (store as unknown as { handleMessage(msg: unknown): void }).handleMessage.bind(store); + handle(null); + handle(undefined); + handle('hello'); + handle({ type: -1 }); + handle({ type: 9, entityKey: 1 }); + handle({ type: 1.5 }); + store.saveEntity(7, { name: 'still written' }); + await drain(store); + expect(asyncDoc(delegate, 7)).toMatchObject({ name: 'still written' }); + expect(store.isSettled()).toBe(true); + expect(consoleError).toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); + + it('writes the fields as the record when the stored record cannot be merged into', async () => { + const delegate = new AsyncDelegate(); + delegate.kv[valueKeyFor(5)] = '{not json'; + delegate.kv[valueKeyFor(6)] = JSON.stringify(['an', 'array']); + const store = writerStore(delegate); + store.mergeEntity(5, { name: 'healed' }); + store.mergeEntity(6, { name: 'healed too' }); + await drain(store); + expect(asyncDoc(delegate, 5)).toEqual({ name: 'healed' }); + expect(asyncDoc(delegate, 6)).toEqual({ name: 'healed too' }); + + const kv = new MemoryPersistentStore(); + kv.setString(valueKeyFor(5), '{not json'); + new SyncQueryStore(kv).mergeEntity(5, { name: 'healed' }); + expect(getDoc(kv, 5)).toEqual({ name: 'healed' }); + }); + + it('counts a queued write as held, so an event for it is refreshed before the write lands', async () => { + const delegate = new AsyncDelegate(5); + const store = writerStore(delegate); + await scanned(store); + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { user: { __typename: 'User', id: 'u1', name: 'Alice', karma: 10 } }); + const client = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + // The write is queued, not processed; the entity is collected meanwhile. + expect(store.isSettled()).toBe(false); + expect(store.hasEntity!(K('User', 'u1'))).toBe(true); + client.entityMap.getEntity(K('User', 'u1'))!.evict(); + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, + }); + await drain(store); + expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ name: 'Alicia' }); + }); + + it('a reader with its own delegate still purges stale queries directly', async () => { + const delegate = new AsyncDelegate(); + delegate.kv['sq:doc:lastUsed:old-def'] = 1; + delegate.kv['sq:doc:cacheTime:old-def'] = 1; + delegate.kv[queueKeyFor('old-def')] = new Uint32Array([9, 0]); + delegate.kv[valueKeyFor(9)] = JSON.stringify({ stale: true }); + delegate.kv[updatedAtKeyFor(9)] = 1; + const reader = new AsyncQueryStore({ + isWriter: false, + delegate, + connect: () => ({ sendMessage: () => {} }), + }); + await reader.purgeStaleQueries(); + expect(delegate.kv[valueKeyFor(9)]).toBeUndefined(); + expect(delegate.kv[queueKeyFor('old-def')]).toBeUndefined(); + }); + + it('a store without mergeEntity gets the whole in-memory data of an event-built entity', async () => { + const kv = new MemoryPersistentStore(); + const inner = new SyncQueryStore(kv); + // A custom store forwarding the pre-0.6 surface only. + const custom = { + loadQuery: inner.loadQuery.bind(inner), + saveQuery: inner.saveQuery.bind(inner), + saveEntity: (key: number, value: unknown, refIds?: Set) => inner.saveEntity(key, value, refIds), + activateQuery: inner.activateQuery.bind(inner), + deleteQuery: inner.deleteQuery.bind(inner), + hasEntity: inner.hasEntity.bind(inner), + }; + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { + user: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 10, + badge: { __typename: 'Badge', id: 'b1', label: 'gold' }, + }, + }); + const client = new QueryClient({ + store: custom, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as never); + clients.push(client); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + client.entityMap.getEntity(K('User', 'u1'))!.evict(); + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, + }); + await sleep(5); + // Written whole (the badge the event did not carry is gone, as before + // 0.6), never as a record holding only the event's fields. + expect(getDoc(kv, K('User', 'u1'))).toMatchObject({ __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }); + expect(getDoc(kv, K('User', 'u1'))!.badge).toBeUndefined(); + }); + + it('runs purgeStaleQueries through its queue, after the writes queued before it', async () => { + const delegate = new AsyncDelegate(); + // A definition last used long ago, with one query whose record references entity 5. + delegate.kv['sq:doc:lastUsed:old-def'] = 1; + delegate.kv['sq:doc:cacheTime:old-def'] = 1; + delegate.kv[queueKeyFor('old-def')] = new Uint32Array([9, 0, 0]); + delegate.kv[valueKeyFor(9)] = JSON.stringify({ user: { __entityRef: 5 } }); + delegate.kv[updatedAtKeyFor(9)] = 1; + delegate.kv[refIdsKeyFor(9)] = new Uint32Array([5]); + delegate.kv[valueKeyFor(5)] = JSON.stringify({ name: 'old' }); + delegate.kv[refCountKeyFor(5)] = 1; + const store = writerStore(delegate); + const log: string[] = []; + store.onPersisted!(key => log.push(`ack ${key}`)); + store.onDelete!(key => log.push(`delete ${key}`)); + + store.saveEntity(5, { name: 'new' }); + const purged = store.purgeStaleQueries(); + expect(store.isSettled()).toBe(false); + await purged; + + // The write was acknowledged before the purge dropped the record, so the + // client that heard both ends up with the record marked missing. + expect(log).toEqual(['ack 5', 'delete 9', 'delete 5']); + expect(delegate.kv[valueKeyFor(5)]).toBeUndefined(); + expect(store.hasEntity!(5)).toBe(false); + }); + + it('shrinking the LRU while activating a key among the dropped slots keeps that key', async () => { + const delegate = new AsyncDelegate(); + const defId = 'shrink-def'; + const def = (maxCount: number) => ({ statics: { id: defId, cache: { maxCount } } }) as never; + delegate.kv[queueKeyFor(defId)] = new Uint32Array([4, 3, 2, 1]); + for (const key of [4, 3, 2, 1]) { + delegate.kv[valueKeyFor(key)] = JSON.stringify({ __entityRef: key }); + delegate.kv[updatedAtKeyFor(key)] = 1; + } + const store = writerStore(delegate); + store.activateQuery(def(3), 1); + await drain(store); + + expect(Array.from(delegate.kv[queueKeyFor(defId)] as Uint32Array)).toEqual([1, 4, 3]); + expect(delegate.kv[valueKeyFor(1)]).toBeDefined(); + expect(delegate.kv[valueKeyFor(2)]).toBeUndefined(); + + // The same for SyncQueryStore. + const kv = new MemoryPersistentStore(); + kv.setBuffer(queueKeyFor(defId), new Uint32Array([4, 3, 2, 1])); + for (const key of [4, 3, 2, 1]) { + kv.setString(valueKeyFor(key), JSON.stringify({ __entityRef: key })); + kv.setNumber(updatedAtKeyFor(key), 1); + } + new SyncQueryStore(kv).activateQuery(def(3), 1); + expect(Array.from(kv.getBuffer(queueKeyFor(defId))!)).toEqual([1, 4, 3]); + expect(kv.getString(valueKeyFor(1))).toBeDefined(); + expect(kv.getString(valueKeyFor(2))).toBeUndefined(); + }); + + it('a message without maxCount keeps the persisted queue at its size', async () => { + const delegate = new AsyncDelegate(); + delegate.kv[queueKeyFor('legacy-def')] = new Uint32Array([4, 3, 2, 1]); + for (const key of [4, 3, 2, 1]) { + delegate.kv[valueKeyFor(key)] = JSON.stringify({ __entityRef: key }); + delegate.kv[updatedAtKeyFor(key)] = 1; + } + delegate.kv[valueKeyFor(7)] = JSON.stringify({ __entityRef: 7 }); + const store = writerStore(delegate); + (store as unknown as { handleMessage(msg: unknown): void }).handleMessage({ + type: 2, + queryDefId: 'legacy-def', + queryKey: 7, + cacheTime: 1440, + }); + await drain(store); + + const queue = delegate.kv[queueKeyFor('legacy-def')] as Uint32Array; + expect(Array.from(queue)).toEqual([7, 4, 3, 2]); + expect(delegate.kv[valueKeyFor(1)]).toBeUndefined(); + expect(delegate.kv[valueKeyFor(2)]).toBeDefined(); + }); + + it('an acknowledgement only counts against a write the instance dispatched itself', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { user: { __typename: 'User', id: 'u1', name: 'Alice', karma: 10 } }); + const client = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + const first = client.entityMap.getEntity(K('User', 'u1'))!; + expect(first._pendingWrites).toBe(1); + expect(first._persisted).toBe(false); + await drain(store); + expect(first._pendingWrites).toBe(0); + expect(first._persisted).toBe(true); + + // An instance that never wrote (hydrated from the store) ignores a stray acknowledgement. + first.recordDropped(); + first.acknowledgeWrite(); + expect(first._persisted).toBe(false); + + // Two writes in flight: the record is current once both have landed. + first.save(); + first.save(); + expect(first._pendingWrites).toBe(2); + await drain(store); + expect(first._pendingWrites).toBe(0); + expect(first._persisted).toBe(true); + }); +}); diff --git a/packages/fetchium/src/__tests__/parsing/entity.test.ts b/packages/fetchium/src/__tests__/parsing/entity.test.ts index 11fb3e8..0a0cea0 100644 --- a/packages/fetchium/src/__tests__/parsing/entity.test.ts +++ b/packages/fetchium/src/__tests__/parsing/entity.test.ts @@ -39,11 +39,11 @@ describe('Entity parsing', () => { }, }; - await parseEntities(result, QueryResult, client, new Map()); + const parsed = (await parseEntities(result, QueryResult, client, new Map())) as { data: unknown }; - expect(result.data).toBeInstanceOf(Entity); - expect(result.data).toBeInstanceOf(User); - expect(Object.getPrototypeOf(result.data)).toBe(User.prototype); + expect(parsed.data).toBeInstanceOf(Entity); + expect(parsed.data).toBeInstanceOf(User); + expect(Object.getPrototypeOf(parsed.data)).toBe(User.prototype); }); describe('nested entities', () => { diff --git a/packages/fetchium/src/__tests__/same-value.test.ts b/packages/fetchium/src/__tests__/same-value.test.ts index 89f6286..3fadb13 100644 --- a/packages/fetchium/src/__tests__/same-value.test.ts +++ b/packages/fetchium/src/__tests__/same-value.test.ts @@ -42,8 +42,11 @@ describe('sameValue', () => { expect(sameValue(proxy, proxy)).toBe(true); }); - it('reports NaN as changed, since it is not equal to itself', () => { - expect(sameValue(Number.NaN, Number.NaN)).toBe(false); + it('treats NaN as unchanged and -0 as a change (Object.is semantics)', () => { + // Re-applying a NaN field must not notify on every refetch, and the sign + // of zero is observable through `Object.is` on a proxy read. + expect(sameValue(Number.NaN, Number.NaN)).toBe(true); + expect(sameValue(0, -0)).toBe(false); }); const different: [string, unknown, unknown][] = [ diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts new file mode 100644 index 0000000..c9664f3 --- /dev/null +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -0,0 +1,1247 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { hashValue, snapshot } from 'signalium/utils'; +import { t, registerFormat } from '../typeDefs.js'; +import { Mask } from '../types.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { AsyncQueryStore, type AsyncPersistentStore } from '../stores/async.js'; +import { valueKeyFor, refIdsKeyFor, queueKeyFor, DEFAULT_MAX_COUNT } from '../stores/shared.js'; +import { createMockFetch, setupTestClient, testWithClient, sleep } from './utils.js'; + +/** + * Regression coverage for the follow-ups to the snapshot fast path (#59), + * unchanged-apply skipping (#60), single-write events (#61) and store + * deletion reporting (#64). Each case failed on the tree with those PRs and + * passed on the tree before them. + */ + +function getDoc(kv: MemoryPersistentStore, key: number): Record | undefined { + const value = kv.getString(valueKeyFor(key)); + return value ? (JSON.parse(value) as Record) : undefined; +} + +/** Holds a query active and exposes `useReactive`-style snapshots of it. */ +function snapshotHarness(client: QueryClient, start: () => T) { + return withContexts([[QueryClientContext, client]], () => { + const query = start(); + let prev: unknown; + let computes = 0; + const snapshots = watcher(() => { + computes++; + return (prev = snapshot(query, prev)); + }); + snapshots.addListener(() => {}); + return { + query, + read: () => (snapshots.value as { value: any }).value, + computes: () => computes, + }; + }); +} + +function holdQuery(client: QueryClient, start: () => T): T { + return withContexts([[QueryClientContext, client]], () => { + const query = start(); + const w = watcher(() => (query as unknown as { value: unknown }).value); + w.addListener(() => {}); + return query; + }); +} + +function makeClient(store: SyncQueryStore | AsyncQueryStore, mockFetch: ReturnType) { + const warn = vi.fn(); + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn, error: () => {} }, + } as never); + return { client, warn }; +} + +// ====================================================== +// Cache hydration must not re-parse an entity that is already in memory +// ====================================================== + +describe('hydration of a cached query whose entity is already in memory', () => { + const getClient = setupTestClient(); + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + profile = t.object({ city: t.string, since: t.optional(t.format('date')) }); + } + class GetA extends RESTQuery { + path = '/a'; + result = { user: t.entity(User) }; + config = { staleTime: 60_000 }; + } + class GetB extends RESTQuery { + path = '/b'; + result = { user: t.entity(User), extra: t.string }; + config = { staleTime: 60_000 }; + } + const payload = { __typename: 'User', id: 1, name: 'Alice', profile: { city: 'Rome', since: '2020-01-01' } }; + + it('keeps nested formatted values, notifies nothing, and leaves every consumer consistent', async () => { + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/a', { user: payload }); + mockFetch.get('/b', { user: payload, extra: 'e' }); + + // Session 1 persists both queries. + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetA)); + await holdQuery(first, () => fetchQuery(GetB)); + first.destroy(); + + // Session 2: the network is unreachable; A hydrates, then B hydrates the same entity. + mockFetch.get('/a', { user: payload }, { delay: 10_000 }); + mockFetch.get('/b', { user: payload, extra: 'e' }, { delay: 10_000 }); + const { client, warn } = makeClient(store, mockFetch); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const hA = snapshotHarness(client, () => fetchQuery(GetA)); + await sleep(20); + const a1 = hA.read(); + expect(a1.user.profile.since).toBeInstanceOf(Date); + const computesBefore = hA.computes(); + + const hB = snapshotHarness(client, () => fetchQuery(GetB)); + await sleep(20); + + // The in-memory data was not re-parsed: the proxy, the store record and + // both consumers agree, and A's consumer had no reason to recompute. + const instance = client.entityMap.getEntity(hashValue(['User', 1]))!; + expect((instance.data.profile as { since: unknown }).since).toBeDefined(); + expect((hA.query.value as any).user.profile.since).toBeInstanceOf(Date); + expect(hB.read().user.profile.since).toBeInstanceOf(Date); + expect(hA.read().user.profile.since).toBe(a1.user.profile.since); + expect(hA.computes()).toBe(computesBefore); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + client.destroy(); + } + }); + + it('rejects the cached query when the in-memory entity lacks a field the query requires', async () => { + class Narrow extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + } + class Wide extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + body = t.string; + } + class GetNarrow extends RESTQuery { + path = '/narrow'; + result = { doc: t.entity(Narrow) }; + } + class GetWide extends RESTQuery { + path = '/wide'; + result = { doc: t.entity(Wide) }; + config = { staleTime: 60_000 }; + } + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B' } }); + mockFetch.get('/narrow', { doc: { __typename: 'Doc', id: 1, title: 'T' } }); + + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetWide)); + first.destroy(); + + // The narrow query fetches fresh, so Doc:1 is in memory without `body`. + const { client, warn } = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetNarrow)); + mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B2' } }); + const wide = holdQuery(client, () => fetchQuery(GetWide)); + await wide; + // The cache was dropped with a warning and the network answered instead. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); + expect((wide.value as any).doc.body).toBe('B2'); + client.destroy(); + }); + + it('rejects the cached query when the in-memory entity lacks a nested entity the query requires', async () => { + class Org extends Entity { + __typename = t.typename('Org'); + id = t.id; + name = t.string; + } + class UserSummary extends Entity { + __typename = t.typename('User'); + id = t.id; + meta = t.object({ x: t.number }); + } + class UserFull extends Entity { + __typename = t.typename('User'); + id = t.id; + meta = t.object({ x: t.number, owner: t.entity(Org) }); + } + class GetSummary extends RESTQuery { + path = '/summary'; + result = { user: t.entity(UserSummary) }; + config = { staleTime: 60_000 }; + } + class GetFull extends RESTQuery { + path = '/full'; + result = { user: t.entity(UserFull) }; + config = { staleTime: 60_000 }; + } + const full = { __typename: 'User', id: 1, meta: { x: 1, owner: { __typename: 'Org', id: 'o1', name: 'Acme' } } }; + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/summary', { user: { __typename: 'User', id: 1, meta: { x: 1 } } }); + mockFetch.get('/full', { user: full }); + + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetSummary)); + await holdQuery(first, () => fetchQuery(GetFull)); + first.destroy(); + + // Session 2: the summary hydrates User:1 without `meta.owner`; the full + // query must not resolve from the cache with the owner missing. + mockFetch.get('/full', { user: { ...full, meta: { ...full.meta, x: 2 } } }, { delay: 50 }); + const { client, warn } = makeClient(store, mockFetch); + const summary = holdQuery(client, () => fetchQuery(GetSummary)); + await sleep(10); + expect((summary.value as any).user.meta.x).toBe(1); + + const wide = holdQuery(client, () => fetchQuery(GetFull)); + await sleep(10); + expect(wide.value).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); + await wide; + expect((wide.value as any).user.meta.owner.name).toBe('Acme'); + expect((wide.value as any).user.meta.x).toBe(2); + client.destroy(); + }); + + it('a cached list holding an in-memory entity the query cannot use is refetched whole, not served with a hole', async () => { + class Narrow extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + } + class Wide extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + body = t.string; + } + class GetNarrow extends RESTQuery { + params = { id: t.id }; + path = `/narrow/${this.params.id}`; + result = { doc: t.entity(Narrow) }; + } + class GetDocs extends RESTQuery { + path = '/docs'; + result = { docs: t.array(t.entity(Wide)) }; + config = { staleTime: 60_000 }; + } + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + const docs = [ + { __typename: 'Doc', id: 1, title: 'T1', body: 'B1' }, + { __typename: 'Doc', id: 2, title: 'T2', body: 'B2' }, + ]; + mockFetch.get('/docs', { docs }); + mockFetch.get('/narrow/1', { doc: { __typename: 'Doc', id: 1, title: 'T1' } }); + + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetDocs)); + first.destroy(); + + // Doc:1 comes into memory through the narrow query, without `body`. + const { client, warn } = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetNarrow, { id: 1 })); + mockFetch.get('/docs', { docs: docs.map(d => ({ ...d, body: `${d.body}!` })) }); + const list = holdQuery(client, () => fetchQuery(GetDocs)); + await list; + expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); + expect((list.value as any).docs.map((d: any) => d.body)).toEqual(['B1!', 'B2!']); + client.destroy(); + }); + + it('a member the query narrows out anyway does not reject the cached query', async () => { + class UserPreview extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + class UserFull extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + email = t.string; + } + class Team extends Entity { + __typename = t.typename('Team'); + id = t.id; + owner = t.entity(UserPreview); + members = t.array(t.entity(UserFull)); + } + class GetTeam extends RESTQuery { + path = '/team'; + result = { team: t.entity(Team) }; + config = { staleTime: 60_000 }; + } + class GetOwner extends RESTQuery { + path = '/owner'; + result = { user: t.entity(UserPreview) }; + } + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/team', { + team: { + __typename: 'Team', + id: 't-1', + owner: { __typename: 'User', id: 'u-1', name: 'Alice' }, + members: [ + { __typename: 'User', id: 'u-1', name: 'Alice' }, + { __typename: 'User', id: 'u-2', name: 'Bob', email: 'b@x' }, + ], + }, + }); + mockFetch.get('/owner', { user: { __typename: 'User', id: 'u-1', name: 'Alice' } }); + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetTeam)); + first.destroy(); + + // u-1 is in memory without `email`; the members array narrows it out on + // read, so the cached team is still usable as it is. + const { client, warn } = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetOwner)); + mockFetch.get( + '/team', + { team: { __typename: 'Team', id: 't-1', owner: { __typename: 'User', id: 'u-1', name: 'FRESH' }, members: [] } }, + { delay: 5000 }, + ); + const team = holdQuery(client, () => fetchQuery(GetTeam)); + await sleep(20); + expect(warn).not.toHaveBeenCalled(); + expect((team.value as any).team.members.map((m: any) => m.id)).toEqual(['u-2']); + expect((team.value as any).team.owner.name).toBe('Alice'); + client.destroy(); + }); + + it('an entity built from events is completed from its record when a cached query hydrates it', async () => { + class Post extends Entity { + __typename = t.typename('Post'); + id = t.id; + feedId = t.string; + title = t.string; + body = t.optional(t.string); + tags = t.optional(t.array(t.string)); + } + class GetPost extends RESTQuery { + params = { id: t.id }; + path = `/post/${this.params.id}`; + result = { post: t.entity(Post) }; + config = { staleTime: 60_000 }; + } + class Feed extends Entity { + __typename = t.typename('Feed'); + id = t.id; + posts = t.liveArray(Post, { constraints: { feedId: (this as any).id } }); + } + class GetFeed extends RESTQuery { + params = { id: t.id }; + path = `/feed/${this.params.id}`; + result = { feed: t.entity(Feed) }; + } + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/post/p1', { + post: { __typename: 'Post', id: 'p1', feedId: 'f1', title: 'T', body: 'hello', tags: ['a', 'b'] }, + }); + mockFetch.get('/feed/f1', { feed: { __typename: 'Feed', id: 'f1', posts: [] } }); + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetPost, { id: 'p1' })); + first.destroy(); + + // The post comes into memory through an event routed into the feed: + // title only. Then the cached post query hydrates the same entity. + const { client, warn } = makeClient(store, mockFetch); + const feed = holdQuery(client, () => fetchQuery(GetFeed, { id: 'f1' })); + await feed; + client.applyMutationEvent({ + type: 'create', + typename: 'Post', + data: { __typename: 'Post', id: 'p1', feedId: 'f1', title: 'T2' }, + }); + await sleep(5); + expect((feed.value as any).feed.posts.map((p: any) => p.title)).toEqual(['T2']); + const instance = client.entityMap.getEntity(hashValue(['Post', 'p1']))!; + expect(instance._partial).toBe(true); + expect(instance.data.body).toBeUndefined(); + + mockFetch.get( + '/post/p1', + { post: { __typename: 'Post', id: 'p1', title: 'FRESH', body: 'fresh' } }, + { delay: 5000 }, + ); + const post = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); + await sleep(20); + expect(warn).not.toHaveBeenCalled(); + // The event's title stays; the fields it did not carry come from the record. + expect((post.value as any).post.title).toBe('T2'); + expect((post.value as any).post.body).toBe('hello'); + expect((post.value as any).post.tags).toEqual(['a', 'b']); + expect((feed.value as any).feed.posts[0].body).toBe('hello'); + expect(instance._partial).toBe(false); + client.destroy(); + }); + + it('a frozen payload with undeclared nested values is applied without being written to', async () => { + const { client, mockFetch } = getClient(); + class Doc extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + meta = t.object({ views: t.number }); + } + class GetDoc extends RESTQuery { + path = '/doc'; + result = { doc: t.entity(Doc) }; + } + mockFetch.get('/doc', { doc: { __typename: 'Doc', id: 'd', title: 'T', meta: { views: 1, deep: { x: [1] } } } }); + const h = snapshotHarness(client, () => fetchQuery(GetDoc)); + await h.query; + const snap = h.read().doc; + const warn = vi.spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn'); + + const frozen = Object.freeze({ + id: 'd', + title: 'T2', + meta: Object.freeze({ views: 2, deep: Object.freeze({ x: Object.freeze([2]) }) }), + }); + client.applyMutationEvent({ type: 'update', typename: 'Doc', data: frozen }); + expect(warn).not.toHaveBeenCalled(); + expect(h.read().doc.title).toBe('T2'); + expect(h.read().doc.meta.views).toBe(2); + expect(snap.meta.views).toBe(1); + }); +}); + +// ====================================================== +// Streamed events: what an event changes in an existing entity is written +// ====================================================== + +describe('mutation events and the store', () => { + const getClient = setupTestClient(); + + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + karma = t.number; + badge = t.optional(t.entity(Badge)); + } + class Badge extends Entity { + __typename = t.typename('Badge'); + id = t.id; + label = t.string; + } + class Comment extends Entity { + __typename = t.typename('Comment'); + id = t.id; + postId = t.string; + body = t.string; + author = t.entity(User); + } + class Post extends Entity { + __typename = t.typename('Post'); + id = t.id; + title = t.string; + comments = t.liveArray(Comment, { constraints: { postId: (this as any).id } }); + } + class GetPost extends RESTQuery { + params = { id: t.id }; + path = `/post/${this.params.id}`; + result = { post: t.entity(Post) }; + } + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/user/${this.params.id}`; + result = { user: t.entity(User) }; + } + const u1Key = hashValue(['User', 'u1']); + const badgeKey = hashValue(['Badge', 'b1']); + + async function seed(client: QueryClient, mockFetch: ReturnType, withPost: boolean) { + mockFetch.get('/user/u1', { user: { __typename: 'User', id: 'u1', name: 'Alice', karma: 10 } }); + mockFetch.get('/post/p1', { post: { __typename: 'Post', id: 'p1', title: 'T', comments: [] } }); + const userQ = holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + await userQ; + const postQ = withPost ? holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })) : undefined; + if (postQ) await postQ; + return { userQ, postQ }; + } + + const createEvent = (withBadge: boolean) => ({ + type: 'create' as const, + typename: 'Comment', + data: { + __typename: 'Comment', + id: 'c9', + postId: 'p1', + body: 'hi', + author: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 11, + ...(withBadge ? { badge: { __typename: 'Badge', id: 'b1', label: 'gold' } } : {}), + }, + }, + }); + + it('a create routed into a live array writes the existing author it changed, and the new badge under it', async () => { + const { client, mockFetch, kv } = getClient(); + await seed(client, mockFetch, true); + expect(getDoc(kv, u1Key)!.karma).toBe(10); + + client.applyMutationEvent(createEvent(true)); + await sleep(5); + + expect(getDoc(kv, u1Key)!.karma).toBe(11); + expect(getDoc(kv, badgeKey)).toMatchObject({ label: 'gold' }); + expect(Array.from(kv.getBuffer(refIdsKeyFor(u1Key)) ?? [])).toEqual([badgeKey]); + expect(getDoc(kv, hashValue(['Comment', 'c9']))).toMatchObject({ body: 'hi' }); + }); + + it('a create nothing routes still writes the existing author it changed, and writes no orphan', async () => { + const { client, mockFetch, kv } = getClient(); + await seed(client, mockFetch, true); + + // The post's live array is constrained to postId p1; this comment is for another post. + const event = createEvent(false); + client.applyMutationEvent({ ...event, data: { ...event.data, postId: 'p2' } }); + await sleep(5); + + expect(getDoc(kv, u1Key)!.karma).toBe(11); + expect(getDoc(kv, hashValue(['Comment', 'c9']))).toBeUndefined(); + expect(client.entityMap.getEntity(hashValue(['Comment', 'c9']))).toBeUndefined(); + }); + + it('a full-payload update for an entity on disk but not in memory refreshes its record', async () => { + const { client, mockFetch, kv, store } = getClient(); + await seed(client, mockFetch, false); + // Evict User u1 from memory while its record stays referenced by the cached query. + client.entityMap.getEntity(u1Key)!.evict(); + expect(client.entityMap.getEntity(u1Key)).toBeUndefined(); + expect(getDoc(kv, u1Key)!.name).toBe('Alice'); + + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 12 }, + }); + await sleep(5); + + expect(client.entityMap.getEntity(u1Key)).toBeUndefined(); + expect(getDoc(kv, u1Key)).toMatchObject({ name: 'Alicia', karma: 12 }); + expect(store.hasEntity(u1Key)).toBe(true); + + // An update for an entity the store has never seen leaves nothing behind. + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: 'u404', name: 'Nobody', karma: 0 }, + }); + await sleep(5); + expect(getDoc(kv, hashValue(['User', 'u404']))).toBeUndefined(); + }); +}); + +// ====================================================== +// `_persisted` is only trusted while the store agrees +// ====================================================== + +describe('write skipping stays consistent with the store', () => { + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + class GetUser extends RESTQuery { + path = '/user'; + result = { user: t.entity(User) }; + } + const userKey = hashValue(['User', 1]); + + it('a store write that throws is retried by the next unchanged apply', async () => { + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/user', { user: { __typename: 'User', id: 1, name: 'Alice' } }); + const { client } = makeClient(store, mockFetch); + + const relay = holdQuery(client, () => fetchQuery(GetUser)); + await relay; + expect(getDoc(kv, userKey)).toMatchObject({ name: 'Alice' }); + + // The next refetch changes the name, and the store rejects that write. + const setString = kv.setString.bind(kv); + let failing = true; + vi.spyOn(kv, 'setString').mockImplementation((key, value) => { + if (failing && key === valueKeyFor(userKey)) throw new Error('quota exceeded'); + setString(key, value); + }); + mockFetch.get('/user', { user: { __typename: 'User', id: 1, name: 'Alicia' } }); + await expect((relay.value as any).__refetch()).rejects.toThrow('quota exceeded'); + expect((relay.value as any)?.user?.name ?? client.entityMap.getEntity(userKey)!.data.name).toBe('Alicia'); + expect(getDoc(kv, userKey)).toMatchObject({ name: 'Alice' }); + + // The store recovers; the refetch returns the same data the entity already + // holds, so nothing changed in memory, yet the record must be rewritten. + failing = false; + await withContexts([[QueryClientContext, client]], () => fetchQuery(GetUser).value ?? undefined); + await (client as any).queryInstances.values().next().value.refetch(); + await sleep(20); + expect(getDoc(kv, userKey)).toMatchObject({ name: 'Alicia' }); + client.destroy(); + }); + + it('QueryClient.destroy() removes its store listeners', () => { + const store = new SyncQueryStore(new MemoryPersistentStore()); + const listeners = () => (store as unknown as { deleteListeners: unknown[] }).deleteListeners.length; + const clients = Array.from({ length: 5 }, () => new QueryClient({ store })); + expect(listeners()).toBe(5); + for (const client of clients) client.destroy(); + expect(listeners()).toBe(0); + }); +}); + +// ====================================================== +// AsyncQueryStore writer +// ====================================================== + +class AsyncDelegate implements AsyncPersistentStore { + readonly kv: Record = Object.create(null); + failNextSetString: string | undefined; + private async tick(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); + } + async has(key: string) { + await this.tick(); + return key in this.kv; + } + async getString(key: string) { + await this.tick(); + return this.kv[key] as string | undefined; + } + async setString(key: string, value: string) { + await this.tick(); + if (this.failNextSetString === key) { + this.failNextSetString = undefined; + throw new Error('injected failure'); + } + this.kv[key] = value; + } + async getNumber(key: string) { + await this.tick(); + return this.kv[key] as number | undefined; + } + async setNumber(key: string, value: number) { + await this.tick(); + this.kv[key] = value; + } + async getBuffer(key: string) { + await this.tick(); + return this.kv[key] as Uint32Array | undefined; + } + async setBuffer(key: string, value: Uint32Array) { + await this.tick(); + this.kv[key] = value; + } + async delete(key: string) { + await this.tick(); + delete this.kv[key]; + } + async getAllKeys() { + await this.tick(); + return Object.keys(this.kv); + } +} + +function writerStore(delegate: AsyncPersistentStore): AsyncQueryStore { + return new AsyncQueryStore({ + isWriter: true, + delegate, + connect: handleMessage => ({ sendMessage: msg => handleMessage(msg) }), + }); +} + +async function drain(store: AsyncQueryStore): Promise { + for (let i = 0; i < 5000; i++) { + if (store.isSettled()) return; + await sleep(2); + } + throw new Error('writer queue did not drain'); +} + +describe('AsyncQueryStore writer used in-process', () => { + const clients: QueryClient[] = []; + afterEach(() => { + for (const client of clients.splice(0)) client.destroy(); + }); + + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + class GetProfile extends RESTQuery { + static cache = { maxCount: 2 }; + params = { id: t.id }; + path = `/user/profile/${this.params.id}`; + result = { user: t.entity(User) }; + } + const userKey = (id: string | number) => hashValue(['User', id]); + + it('an unchanged refetch racing a queued eviction still leaves the query hydratable', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + for (const id of ['1', '2', '3']) { + mockFetch.get(`/user/profile/${id}`, { user: { __typename: 'User', id, name: `User ${id}` } }); + } + const { client, warn } = makeClient(store, mockFetch); + clients.push(client); + + const q1 = holdQuery(client, () => fetchQuery(GetProfile, { id: '1' })); + await q1; + await holdQuery(client, () => fetchQuery(GetProfile, { id: '2' })); + await drain(store); + expect(delegate.kv[valueKeyFor(userKey('1'))]).toBeDefined(); + + // Q3's SaveQuery will evict Q1 and cascade to User 1 once processed. Before + // the writer gets there, Q1 refetches identical data. + await holdQuery(client, () => fetchQuery(GetProfile, { id: '3' })); + expect(store.isSettled()).toBe(false); + await (q1.value as any).__refetch(); + await drain(store); + + expect(delegate.kv[valueKeyFor(userKey('1'))]).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); + + // Cold start on the same store: Q1 hydrates from cache. + client.destroy(); + clients.pop(); + mockFetch.reset(); + mockFetch.get('/user/profile/1', { user: { __typename: 'User', id: '1', name: 'Refetched' } }, { delay: 2000 }); + const cold = makeClient(store, mockFetch); + clients.push(cold.client); + const relay = holdQuery(cold.client, () => fetchQuery(GetProfile, { id: '1' })); + await sleep(150); + expect(cold.warn).not.toHaveBeenCalled(); + expect((relay.value as any)?.user?.name).toBe('User 1'); + }); + + it('a SaveEntity the delegate rejected is written again by the next unchanged apply', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/user/profile/1', { user: { __typename: 'User', id: '1', name: 'Alice' } }); + const { client } = makeClient(store, mockFetch); + clients.push(client); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + delegate.failNextSetString = valueKeyFor(userKey('1')); + const q1 = holdQuery(client, () => fetchQuery(GetProfile, { id: '1' })); + await q1; + await drain(store); + expect(delegate.kv[valueKeyFor(userKey('1'))]).toBeUndefined(); + + await (q1.value as any).__refetch(); + await drain(store); + expect(delegate.kv[valueKeyFor(userKey('1'))]).toBeDefined(); + } finally { + consoleError.mockRestore(); + } + }); + + it('resizes a persisted LRU queue in both directions and defaults a missing maxCount', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const defId = 'upgrade-def'; + const def = (maxCount: number) => ({ statics: { id: defId, cache: { maxCount } } }) as never; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + // A 4-slot queue from an older install, fully populated, with records behind each key. + const old = new Uint32Array([4, 3, 2, 1]); + delegate.kv[queueKeyFor(defId)] = old; + for (const key of old) { + delegate.kv[valueKeyFor(key)] = JSON.stringify({ __entityRef: key }); + delegate.kv[`sq:doc:updatedAt:${key}`] = 1; + } + delegate.kv[valueKeyFor(9)] = JSON.stringify({ __entityRef: 9 }); + + // Grow: the buffer is reallocated, the order kept, nothing evicted. + store.activateQuery(def(6), 9); + await drain(store); + let queue = delegate.kv[queueKeyFor(defId)] as Uint32Array; + expect(Array.from(queue)).toEqual([9, 4, 3, 2, 1, 0]); + expect(consoleError).not.toHaveBeenCalled(); + + // Shrink (after a restart, so the in-memory queue is not cached): the + // keys that no longer fit are evicted, not stranded. + const restarted = writerStore(delegate); + restarted.activateQuery(def(3), 9); + await drain(restarted); + queue = delegate.kv[queueKeyFor(defId)] as Uint32Array; + expect(Array.from(queue)).toEqual([9, 4, 3]); + expect(delegate.kv[valueKeyFor(2)]).toBeUndefined(); + expect(delegate.kv[valueKeyFor(1)]).toBeUndefined(); + expect(delegate.kv[valueKeyFor(4)]).toBeDefined(); + + // A message from a reader that predates `maxCount` uses the default size. + const fresh = new AsyncDelegate(); + const freshStore = writerStore(fresh); + fresh.kv[valueKeyFor(7)] = JSON.stringify({ __entityRef: 7 }); + (freshStore as unknown as { handleMessage(msg: unknown): void }).handleMessage({ + type: 2, + queryDefId: 'legacy-def', + queryKey: 7, + cacheTime: 1440, + }); + await drain(freshStore); + const legacyQueue = fresh.kv[queueKeyFor('legacy-def')] as Uint32Array; + expect(legacyQueue.length).toBe(DEFAULT_MAX_COUNT); + expect(legacyQueue[0]).toBe(7); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); +}); + +// ====================================================== +// Snapshot fast path +// ====================================================== + +describe('snapshot fast path follow-ups', () => { + const getClient = setupTestClient(); + + it('a NaN in a static field neither reports drift nor churns snapshot identity', async () => { + const { client, mockFetch } = getClient(); + // JSON cannot carry NaN; a lenient string-to-number format can. + registerFormat( + 'loose-number', + Mask.STRING, + (value: string) => Number(value), + (value: number) => String(value), + ); + class Stat extends Entity { + __typename = t.typename('Stat'); + id = t.id; + label = t.string; + ratio = t.format('loose-number' as never); + series = t.array(t.format('loose-number' as never)); + } + class GetStats extends RESTQuery { + path = '/stats'; + result = { stats: t.array(t.entity(Stat)) }; + } + mockFetch.get('/stats', { + stats: [ + { __typename: 'Stat', id: 's', label: 'a', ratio: 'N/A', series: ['1', 'N/A'] }, + { __typename: 'Stat', id: 'other', label: 'b', ratio: '1', series: [] }, + ], + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const h = snapshotHarness(client, () => fetchQuery(GetStats)); + await h.query; + const first = h.read().stats[0]; + client.applyMutationEvent({ type: 'update', typename: 'Stat', data: { id: 'other', label: 'c' } }); + const second = h.read().stats[0]; + expect(Number.isNaN(second.ratio)).toBe(true); + expect(second).toBe(first); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + it('two classes sharing a typename: a child entity under a field the other class declares entity-free still reaches the consumer', async () => { + const { client, mockFetch } = getClient(); + class Org extends Entity { + __typename = t.typename('Org'); + id = t.id; + name = t.string; + } + class UserSummary extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + meta = t.object({ x: t.number }); + } + class UserFull extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + meta = t.object({ x: t.number, owner: t.entity(Org) }); + } + class GetSummary extends RESTQuery { + path = '/summary'; + result = { user: t.entity(UserSummary) }; + } + class GetFull extends RESTQuery { + path = '/full'; + result = { user: t.entity(UserFull) }; + } + mockFetch.get('/summary', { user: { __typename: 'User', id: 1, name: 'Alice', meta: { x: 1 } } }); + mockFetch.get('/full', { + user: { + __typename: 'User', + id: 1, + name: 'Alice', + meta: { x: 1, owner: { __typename: 'Org', id: 7, name: 'ACME' } }, + }, + }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const summary = snapshotHarness(client, () => fetchQuery(GetSummary)); + await summary.query; + const full = snapshotHarness(client, () => fetchQuery(GetFull)); + await full.query; + expect(summary.read().user.meta.owner.name).toBe('ACME'); + + client.applyMutationEvent({ type: 'update', typename: 'Org', data: { id: 7, name: 'NEW' } }); + expect(summary.read().user.meta.owner.name).toBe('NEW'); + client.applyMutationEvent({ type: 'update', typename: 'Org', data: { id: 7, name: 'NEWER' } }); + expect(summary.read().user.meta.owner.name).toBe('NEWER'); + // Served by the fast path's own bookkeeping, not by the dev guard's re-read. + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + it('a reactive consumer of a narrowed entity array learns when a member becomes eligible', async () => { + const { client, mockFetch } = getClient(); + class UserPreview extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + class UserFull extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + email = t.string; + } + class Team extends Entity { + __typename = t.typename('Team'); + id = t.id; + owner = t.entity(UserPreview); + members = t.array(t.entity(UserFull)); + } + class GetTeam extends RESTQuery { + path = '/team'; + result = { team: t.entity(Team) }; + } + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/user/${this.params.id}`; + result = { user: t.entity(UserFull) }; + } + mockFetch.get('/team', { + team: { + __typename: 'Team', + id: 't-1', + owner: { __typename: 'User', id: 'u-1', name: 'Alice' }, + members: [ + { __typename: 'User', id: 'u-1', name: 'Alice', email: 'a@x' }, + { __typename: 'User', id: 'u-2', name: 'Bob', email: 'b@x' }, + ], + }, + }); + mockFetch.get('/user/u-1', { user: { __typename: 'User', id: 'u-1', name: 'Alice', email: 'a@x' } }); + + await testWithClient(client, async () => { + const team = fetchQuery(GetTeam); + await team; + const ids = watcher(() => (team.value as any).team.members.map((m: any) => m.id).join(',')); + ids.addListener(() => {}); + // u-1 was parsed with the preview def first, so it is narrowed out. + expect(ids.value).toBe('u-2'); + + await fetchQuery(GetUser, { id: 'u-1' }); + await sleep(5); + expect(ids.value).toBe('u-1,u-2'); + }); + }); + + it('query extras attached to a shared entity reach an existing consumer without another trigger', async () => { + const { client, mockFetch } = getClient(); + class Profile extends Entity { + __typename = t.typename('Profile'); + id = t.id; + name = t.string; + } + class GetWrapped extends RESTQuery { + path = '/wrapped'; + result = { profile: t.entity(Profile) }; + } + class GetRoot extends RESTQuery { + path = '/root'; + result = t.entity(Profile); + } + const profile = { __typename: 'Profile', id: 1, name: 'Alice' }; + mockFetch.get('/wrapped', { profile }); + mockFetch.get('/root', profile); + + const wrapped = snapshotHarness(client, () => fetchQuery(GetWrapped)); + await wrapped.query; + expect('__refetch' in wrapped.read().profile).toBe(false); + + const root = snapshotHarness(client, () => fetchQuery(GetRoot)); + await root.query; + expect(typeof wrapped.read().profile.__refetch).toBe('function'); + }); + + it('a length-only consumer of a narrowed entity array does not recompute when a member it holds changes', async () => { + const { client, mockFetch } = getClient(); + class UserPreview extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + } + class UserFull extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + email = t.string; + } + class Team extends Entity { + __typename = t.typename('Team'); + id = t.id; + owner = t.entity(UserPreview); + members = t.array(t.entity(UserFull)); + } + class GetTeam extends RESTQuery { + path = '/team'; + result = { team: t.entity(Team) }; + } + mockFetch.get('/team', { + team: { + __typename: 'Team', + id: 't-1', + owner: { __typename: 'User', id: 'u-1', name: 'Alice' }, + members: [ + { __typename: 'User', id: 'u-1', name: 'Alice', email: 'a@x' }, + { __typename: 'User', id: 'u-2', name: 'Bob', email: 'b@x' }, + ], + }, + }); + + const team = holdQuery(client, () => fetchQuery(GetTeam)); + await team; + let computes = 0; + const count = withContexts([[QueryClientContext, client]], () => { + const w = watcher(() => { + computes++; + return (team.value as any).team.members.length; + }); + w.addListener(() => {}); + return w; + }); + await sleep(5); + expect(count.value).toBe(1); + const base = computes; + + // A member the array holds changes a field the consumer never read. + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u-2', name: 'Robert' } }); + await sleep(5); + expect((team.value as any).team.members[0].name).toBe('Robert'); + expect(count.value).toBe(1); + expect(computes).toBe(base); + + // A member the array left out becomes eligible: that is a change of the array. + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u-1', email: 'alice@x' } }); + await sleep(5); + expect(count.value).toBe(2); + expect(computes).toBe(base + 1); + }); + + it('applies -0 over 0 and keeps NaN, at every level', async () => { + const { client, mockFetch } = getClient(); + class Quote extends Entity { + __typename = t.typename('Quote'); + id = t.id; + price = t.number; + history = t.array(t.number); + } + class GetQuote extends RESTQuery { + path = '/quote'; + result = { quote: t.entity(Quote) }; + } + mockFetch.get('/quote', { quote: { __typename: 'Quote', id: 'q', price: 0, history: [0, 1] } }); + const h = snapshotHarness(client, () => fetchQuery(GetQuote)); + await h.query; + const before = h.computes(); + + client.applyMutationEvent({ type: 'update', typename: 'Quote', data: { id: 'q', price: -0, history: [-0, 1] } }); + expect(Object.is((h.query.value as any).quote.price, -0)).toBe(true); + expect(Object.is(h.read().quote.price, -0)).toBe(true); + expect(Object.is(h.read().quote.history[0], -0)).toBe(true); + expect(h.computes()).toBe(before + 1); + + // The same data again is not a change. + client.applyMutationEvent({ type: 'update', typename: 'Quote', data: { id: 'q', price: -0, history: [-0, 1] } }); + expect(h.computes()).toBe(before + 1); + }); + + it('a snapshot handed back to the parser is read, not written to', async () => { + const { client, mockFetch } = getClient(); + class Doc extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + meta = t.object({ views: t.number, tags: t.array(t.string) }); + extra = t.record(t.number); + } + class GetDoc extends RESTQuery { + path = '/doc'; + result = { doc: t.entity(Doc) }; + } + mockFetch.get('/doc', { + doc: { __typename: 'Doc', id: 'd', title: 'T', meta: { views: 1, tags: ['a'] }, extra: { k: 1 } }, + }); + const h = snapshotHarness(client, () => fetchQuery(GetDoc)); + await h.query; + const snap = h.read().doc; + expect(Object.isFrozen(snap.meta)).toBe(true); + const warn = vi.spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn'); + + // An app forwards part of a snapshot as an event payload (or a mutation's + // `effects.updates`); the parser must not write parsed values into it. + client.applyMutationEvent({ + type: 'update', + typename: 'Doc', + data: { id: 'd', title: 'T2', meta: { ...snap.meta, views: 2 }, extra: snap.extra }, + }); + const meta = { views: 3, tags: snap.meta.tags }; + client.applyMutationEvent({ type: 'update', typename: 'Doc', data: { id: 'd', meta, extra: snap.extra } }); + + expect(warn).not.toHaveBeenCalled(); + expect(h.read().doc.title).toBe('T2'); + expect(h.read().doc.meta.views).toBe(3); + expect(h.read().doc.extra.k).toBe(1); + expect(snap.meta.views).toBe(1); + expect(Object.isFrozen(h.read().doc.meta)).toBe(true); + }); + + it('two events sharing one nested object do not alias entity data', async () => { + const { client, mockFetch } = getClient(); + class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + walletId = t.string; + metadata = t.object({ image: t.string }); + } + class Wallet extends Entity { + __typename = t.typename('Wallet'); + id = t.id; + tokens = t.liveArray(Token, { constraints: { walletId: (this as any).id } }); + } + class GetWallet extends RESTQuery { + path = '/wallet'; + result = { wallet: t.entity(Wallet) }; + } + mockFetch.get('/wallet', { wallet: { __typename: 'Wallet', id: 'w', tokens: [] } }); + const h = snapshotHarness(client, () => fetchQuery(GetWallet)); + await h.query; + await sleep(5); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const shared = { image: 'shared.png' }; + client.applyMutationEvent({ + type: 'create', + typename: 'Token', + data: { __typename: 'Token', id: 'tok-0', walletId: 'w', metadata: shared }, + }); + client.applyMutationEvent({ + type: 'create', + typename: 'Token', + data: { __typename: 'Token', id: 'tok-1', walletId: 'w', metadata: shared }, + }); + await sleep(5); + const tokens = () => h.read().wallet.tokens; + expect(tokens()).toHaveLength(2); + const tok1 = tokens()[1]; + client.applyMutationEvent({ + type: 'update', + typename: 'Token', + data: { id: 'tok-0', metadata: { image: 'new.png' } }, + }); + await sleep(5); + + expect(tokens()[0].metadata.image).toBe('new.png'); + expect(tokens()[1].metadata.image).toBe('shared.png'); + expect(tokens()[1]).toBe(tok1); + expect((h.query.value as any).wallet.tokens[1].metadata.image).toBe('shared.png'); + expect(shared.image).toBe('shared.png'); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + it('a Set-valued format with many object members is verified in linear time', async () => { + const { client, mockFetch } = getClient(); + registerFormat( + 'id-set', + Mask.STRING, + (value: string) => new Set(value.split(',').map(id => ({ id: Number(id) }))), + (value: Set<{ id: number }>) => [...value].map(item => item.id).join(','), + ); + class Roster extends Entity { + __typename = t.typename('Roster'); + id = t.id; + label = t.string; + members = t.format('id-set' as never); + } + class GetRoster extends RESTQuery { + path = '/roster'; + result = { roster: t.entity(Roster), other: t.entity(Roster) }; + } + const ids = Array.from({ length: 3000 }, (_, i) => i).join(','); + mockFetch.get('/roster', { + roster: { __typename: 'Roster', id: 'r', label: 'a', members: ids }, + other: { __typename: 'Roster', id: 'o', label: 'b', members: '0' }, + }); + const h = snapshotHarness(client, () => fetchQuery(GetRoster)); + await h.query; + expect(h.read().roster.members.size).toBe(3000); + + // Each event recomputes the snapshot at an unchanged version of `roster`, + // so the dev guard compares the rebuilt Set with the previous one. + const started = performance.now(); + for (let i = 0; i < 20; i++) { + client.applyMutationEvent({ type: 'update', typename: 'Roster', data: { id: 'o', label: `b${i}` } }); + expect(h.read().roster.members.size).toBe(3000); + } + expect(performance.now() - started).toBeLessThan(2000); + }); +}); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 06fdaa8..15d0214 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -27,6 +27,17 @@ export interface ApplyResult { entityRefs: Map; } +/** + * Whether an apply writes to the store. `true` writes every entity that needs + * it; `false` writes nothing (a cache hydration); `'existing'` writes entities + * that already existed in memory and not the ones this apply creates: those + * are written when a written record references them (`EntityInstance.save` + * writes a record's missing children first), or by the caller once it knows + * the root is retained (a streamed event whose root may end up routed + * nowhere). + */ +export type PersistMode = boolean | 'existing'; + /** * Single depth-first walk from the root that applies entities to the store, * replaces parsed data objects with entity proxies, and counts child refs. @@ -34,15 +45,27 @@ export interface ApplyResult { export function applyEntityRefs( ctx: ParseContext, rootData: unknown, - persist: boolean, + persist: PersistMode, appendMode: boolean = false, + created?: Set, ): ApplyResult { const queryClient = ctx.queryClient!; queryClient.currentParseId++; const seen = ctx.seen!; const entityRefs = new Map(); - const data = reifyAndApply(rootData, seen, queryClient, persist, entityRefs, appendMode); + let data: unknown; + try { + data = reifyAndApply(rootData, seen, queryClient, persist, entityRefs, appendMode, created); + } catch (e) { + queryClient.discardDeferredWrites(); + throw e; + } + + // Entities a written record referenced while they were still being reified + // (a payload that links back to an ancestor) are complete now, and so are + // the records that reference them. + queryClient.flushDeferredWrites(); return { data, entityRefs }; } @@ -55,22 +78,26 @@ function reifyAndApply( value: unknown, seen: Map, ParsedEntity>, queryClient: QueryClient, - persist: boolean, + persist: PersistMode, entityRefs: Map, appendMode: boolean, + created: Set | undefined, ): unknown { if (typeof value !== 'object' || value === null) return value; const entity = seen.get(value as Record); if (entity !== undefined) { - return applyEntity(entity, seen, queryClient, persist, entityRefs, appendMode); + return applyEntity(entity, seen, queryClient, persist, entityRefs, appendMode, created); } + // Only a slot whose value changes is written: values the parser did not + // declare (and so did not copy) may belong to the caller, and be frozen. if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const item = value[i]; if (typeof item === 'object' && item !== null && !(item instanceof FormattedValue) && !PROXY_ID.has(item)) { - value[i] = reifyAndApply(item, seen, queryClient, persist, entityRefs, appendMode); + const reified = reifyAndApply(item, seen, queryClient, persist, entityRefs, appendMode, created); + if (reified !== item) value[i] = reified; } } return value; @@ -81,7 +108,8 @@ function reifyAndApply( for (const key of Object.keys(obj)) { const v = obj[key]; if (typeof v === 'object' && v !== null && !(v instanceof FormattedValue) && !PROXY_ID.has(v)) { - obj[key] = reifyAndApply(v, seen, queryClient, persist, entityRefs, appendMode); + const reified = reifyAndApply(v, seen, queryClient, persist, entityRefs, appendMode, created); + if (reified !== v) obj[key] = reified; } } } @@ -101,13 +129,25 @@ function applyEntity( entity: ParsedEntity, seen: Map, ParsedEntity>, queryClient: QueryClient, - persist: boolean, + persist: PersistMode, parentEntityRefs: Map, appendMode: boolean, + created: Set | undefined, ): Record { - const { key, data, shape: entityShape, rawKeys } = entity; + const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial } = entity; const shapeFields = entityShape.shape; + // The same entity can appear more than once in one payload (an author under + // `author` and again under `mentions`). The parser hands every slot the one + // parsed object, so the walk arrives here once per slot; applying it again + // would find its children already reified, count none of them and release + // every child ref. Count this slot's reference and hand out the proxy. + const applied = queryClient.entityMap.getEntity(key); + if (applied !== undefined && applied.parseId === queryClient.currentParseId) { + parentEntityRefs.set(applied, (parentEntityRefs.get(applied) ?? 0) + 1); + return applied.getProxy(entityShape); + } + const entityInstance = queryClient.prepareEntity(key, data, entityShape); const existingData = entityInstance.data; const isUpdate = existingData !== data; @@ -122,24 +162,55 @@ function applyEntity( // A refetch or a poll that returns identical data is a no-op: nothing to // notify consumers about, and nothing new to write to the store. let changed = true; - if (isUpdate) { - changed = mergeFields( - shapeFields, - data, - existingData, - rawKeys, - entityInstance, - existingData, - seen, - queryClient, - persist, - childRefs, - appendMode, - ); - if (changed) entityInstance.notify(); - } else { - initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode); + // Until the fields are reified the data is not a record yet: a write that + // reaches this instance through a reference from inside its own subtree is + // deferred to the end of the walk. + entityInstance._applying = true; + try { + if (isUpdate) { + changed = mergeFields( + shapeFields, + data, + existingData, + rawKeys, + entityInstance, + existingData, + seen, + queryClient, + persist, + childRefs, + appendMode, + created, + ); + if ((persist === true && rawKeys === undefined) || fillsPartial) { + // A full payload (a fetch), or the record's fields merged in, makes + // the data a complete record again. + entityInstance._partial = false; + entityInstance._partialKeys = undefined; + } else if (entityInstance._partial && rawKeys !== undefined) { + for (const k of rawKeys) entityInstance._partialKeys!.add(k); + } + } else { + initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode, created); + if (eventKeys !== undefined) { + // Streamed events are partial: what this entity holds is what its + // events carried, not necessarily the whole record. + entityInstance._partial = true; + entityInstance._partialKeys = new Set(eventKeys); + // A record always carries its typename and id, whether or not the + // event did (the parser fills the literal in). + if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); + if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); + } else if (persist === false) { + // Hydrated from the store: its record exists. + entityInstance._recorded = true; + } + if (persist === 'existing') created?.add(entityInstance); + } + } finally { + entityInstance._applying = false; } + if (isUpdate && changed) entityInstance.notify(); if (appendMode && entityInstance.liveCollections.length > 0) { for (const binding of entityInstance.liveCollections) { @@ -159,9 +230,17 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); - // An entity hydrated from the store was never written, so there is no write to skip. - const needsPersist = changed || refsChanged || !entityInstance._persisted || !queryClient.storeReportsDeletes; - entityInstance.setChildRefs(newRefs, persist && needsPersist); + // An entity hydrated from the store was never written, so there is no write + // to skip. A skip is also only safe while the store has nothing queued: a + // deletion it has yet to process could drop the record this apply trusts. + const needsPersist = + changed || + refsChanged || + !entityInstance._persisted || + !queryClient.storeReportsDeletes || + !queryClient.storeIsSettled(); + const writes = persist === true || (persist === 'existing' && isUpdate); + entityInstance.setChildRefs(newRefs, writes && needsPersist); const proxy = entityInstance.getProxy(entityShape); @@ -183,16 +262,17 @@ function mergeFields( entityData: Record, seen: Map, ParsedEntity>, queryClient: QueryClient, - persist: boolean, + persist: PersistMode, childRefs: Map, appendMode: boolean, + created: Set | undefined, ): boolean { let changed = false; for (const [fieldKey, propShape] of entries(shape)) { if (rawKeys !== undefined && !rawKeys.has(fieldKey)) continue; if (shouldReify(data[fieldKey])) { - data[fieldKey] = reifyAndApply(data[fieldKey], seen, queryClient, persist, childRefs, appendMode); + data[fieldKey] = reifyAndApply(data[fieldKey], seen, queryClient, persist, childRefs, appendMode, created); } if (propShape instanceof ValidatorDef && propShape._liveConfig !== undefined) { @@ -208,13 +288,14 @@ function mergeFields( entityInstance, entityData, queryClient, + fieldKey, ); changed = true; } } else { const newVal = data[fieldKey]; const oldVal = existingData[fieldKey]; - if (newVal === oldVal) continue; + if (Object.is(newVal, oldVal)) continue; // Replace a union wholesale instead of merging by field, otherwise a // changed variant keeps the old variant's fields. Unlike entity unions, // there is no partial-update path to preserve here: a partial variant @@ -248,6 +329,7 @@ function mergeFields( persist, childRefs, appendMode, + created, ) ) { changed = true; @@ -295,7 +377,8 @@ export function sameKeys(a: Record, b: Record) * they were built from, arrays and plain objects element by element. */ export function sameValue(a: unknown, b: unknown): boolean { - if (a === b) return true; + // `Object.is`: re-applying NaN is not a change, and `-0` over `0` is one. + if (Object.is(a, b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (a instanceof FormattedValue || b instanceof FormattedValue) { return a instanceof FormattedValue && b instanceof FormattedValue && a._raw === b._raw; @@ -354,15 +437,16 @@ function initFields( entityData: Record, seen: Map, ParsedEntity>, queryClient: QueryClient, - persist: boolean, + persist: PersistMode, childRefs: Map, appendMode: boolean, + created: Set | undefined, ): void { for (const [fieldKey, propShape] of entries(shape)) { if (!(fieldKey in data)) continue; if (shouldReify(data[fieldKey])) { - data[fieldKey] = reifyAndApply(data[fieldKey], seen, queryClient, persist, childRefs, appendMode); + data[fieldKey] = reifyAndApply(data[fieldKey], seen, queryClient, persist, childRefs, appendMode, created); } if (propShape instanceof ValidatorDef && propShape._liveConfig !== undefined) { @@ -372,6 +456,7 @@ function initFields( entityInstance, entityData, queryClient, + fieldKey, ); } else { const val = data[fieldKey]; @@ -390,7 +475,18 @@ function initFields( ? (propShape.shape as Record) : undefined; if (nestedShape !== undefined) { - initFields(nestedShape, val, entityInstance, entityData, seen, queryClient, persist, childRefs, appendMode); + initFields( + nestedShape, + val, + entityInstance, + entityData, + seen, + queryClient, + persist, + childRefs, + appendMode, + created, + ); } } } diff --git a/packages/fetchium/src/errors.ts b/packages/fetchium/src/errors.ts index 270975c..65d6cf0 100644 --- a/packages/fetchium/src/errors.ts +++ b/packages/fetchium/src/errors.ts @@ -169,3 +169,16 @@ export function typeError(path: string, expectedType: InternalObjectFieldTypeDef } return new TypeError(`Validation error at ${path}`); } + +/** + * A cached query cannot be hydrated as-is: an entity it holds is already in + * memory and its data does not satisfy the query's shape. Unlike a bad array + * item, this drops the whole cached query (it is refetched) rather than the + * item, since a list missing an item would resolve without a word. + */ +export class CachedEntityMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'CachedEntityMismatchError'; + } +} diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index 1563da6..4abaded 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -7,6 +7,7 @@ import { hashValue } from 'signalium/utils'; import type { QueryClient, PreloadedEntityMap } from './QueryClient.js'; +import type { EntityInstance } from './EntityInstance.js'; import { CaseInsensitiveSet, FormattedValue, @@ -15,7 +16,7 @@ import { VariantGroup, VariantSet, } from './typeDefs.js'; -import { typeError, typeToString, UnknownUnionVariantError } from './errors.js'; +import { typeError, typeToString, UnknownUnionVariantError, CachedEntityMismatchError } from './errors.js'; import { ARRAY_KEY, ArrayDef, @@ -49,8 +50,15 @@ export interface ParsedEntity { data: Record; /** Set for partial event updates — restricts mergeFields to only these keys. */ rawKeys: Set | undefined; + /** The keys a streamed event carried, whether or not the entity was in memory. */ + eventKeys: Set | undefined; + /** A cached record parsed to fill in the fields an entity built from events lacks. */ + fillsPartial: boolean; } +/** Trust-memory verdicts: `true`/`false` once settled, `'pending'` while a check is in progress (a cycle). */ +export type TrustMemo = Map, boolean | 'pending'>>; + // ====================================================== // Parse context — bundles threading parameters // ====================================================== @@ -64,6 +72,8 @@ export class ParseContext { isPartialEvent: boolean = false; seen: Map, ParsedEntity> | undefined = undefined; seenByKey: Map | undefined = undefined; + /** Verdicts of the trust-memory check, per instance and def, for one parse. */ + trusted: TrustMemo | undefined = undefined; reset( queryClient: QueryClient | undefined, @@ -75,6 +85,7 @@ export class ParseContext { this.preloadedEntities = preloadedEntities; this.warn = warn; this.isPartialEvent = isPartialEvent; + this.trusted = undefined; if (queryClient !== undefined) { if (this.seen === undefined) { this.seen = new Map(); @@ -381,6 +392,7 @@ function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseC try { result.push(parseData(array[i], itemShape as unknown as TypeDef, ctx, `${path}[${i}]`)); } catch (e) { + if (e instanceof CachedEntityMismatchError) throw e; ctx.warn('Failed to parse array item, filtering out', { index: i, value: array[i], @@ -392,17 +404,23 @@ function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseC return result; } +// Both walkers parse into a shallow copy of their input, like `parseArrayData` +// builds a new array. The input belongs to whoever handed it in (an adapter, +// an event payload, a consumer's snapshot — frozen in dev builds) and must not +// be written to; the copy keeps every key the input had, as before. + function parseRecordData( record: Record, valueShape: ComplexTypeDef, ctx: ParseContext, path: string, ): Record { + const result: Record = { ...record }; for (const [key, value] of entries(record)) { - record[key] = parseData(value, valueShape as unknown as TypeDef, ctx, `${path}["${key}"]`); + result[key] = parseData(value, valueShape as unknown as TypeDef, ctx, `${path}["${key}"]`); } - return record; + return result; } function parseObjectData( @@ -416,12 +434,13 @@ function parseObjectData( } const shape = objectShape.shape; + const result: Record = { ...obj }; for (const [key, propShape] of entries(shape)) { - obj[key] = parseData(obj[key], propShape as unknown as TypeDef, ctx, `${path}.${key}`); + result[key] = parseData(obj[key], propShape as unknown as TypeDef, ctx, `${path}.${key}`); } - return obj; + return result; } // ====================================================== @@ -459,33 +478,98 @@ function parseEntityData( return existingEntry.data; } - if (preloadedEntities !== undefined) { - const existing = queryClient.entityMap.getEntity(key); - const preloaded = existing?.data ?? preloadedEntities.get(key); - - if (preloaded === undefined) { - throw new Error(`Cached entity ${key} not found in preloaded map`); - } - - obj = preloaded; - } - const parsedData: Record = {}; // For symbol id fields (QUERY_ID), copy the id onto parsedData so // getOrCreateEntity can read it. entries(shape) skips symbol keys. if (typeof entityShape.idField === 'symbol') { parsedData[entityShape.idField] = id; } + + if (preloadedEntities !== undefined) { + const existing = queryClient.entityMap.getEntity(key); + + // The fields of an entity built from streamed events that the record on + // disk has and the events did not carry. + let fillKeys: Set | undefined; + const preloaded = preloadedEntities.get(key); + + if (existing !== undefined && existing._partial && existing._partialKeys !== undefined && preloaded !== undefined) { + // Built from events, the in-memory data lacks what the record has: + // parse the record for those fields only and merge them in. + fillKeys = existing._partialKeys; + obj = preloaded; + } else if (existing !== undefined) { + // The entity is already live in memory, so the cache has nothing newer + // to offer: the in-memory data is what every consumer already sees. + // Re-parsing that data would run already-parsed values (formatted + // values, parse results, child proxies) through the parser again, in + // place, and corrupt them. An empty partial entry merges nothing and + // hands the query the existing proxy. The data must still satisfy this + // query's shape, or the cached query is not usable as-is. + ctx.trusted ??= new Map(); + if (!dataSatisfiesDef(existing.data, entityShape as unknown as ValidatorDef, queryClient, ctx.trusted)) { + throw new CachedEntityMismatchError( + `Cached entity ${entityShape.typenameValue}:${String(existing.id)} in memory does not satisfy the query's shape`, + ); + } + const entry: ParsedEntity = { + key, + shape: entityShape, + data: parsedData, + rawKeys: new Set(), + eventKeys: undefined, + fillsPartial: false, + }; + ctx.seen!.set(parsedData, entry); + ctx.seenByKey!.set(key, entry); + return parsedData; + } else { + if (preloaded === undefined) { + throw new Error(`Cached entity ${key} not found in preloaded map`); + } + obj = preloaded; + } + + if (fillKeys !== undefined) { + const shapeKeys = Object.keys(entityShape.shape); + const rawKeys = new Set(); + for (const k of shapeKeys) if (!fillKeys.has(k)) rawKeys.add(k); + const entry: ParsedEntity = { + key, + shape: entityShape, + data: parsedData, + rawKeys, + eventKeys: undefined, + fillsPartial: true, + }; + ctx.seen!.set(parsedData, entry); + ctx.seenByKey!.set(key, entry); + const entityDesc = `[[${entityShape.typenameValue}:${id}]]`; + for (const [fieldKey, propShape] of entries(entityShape.shape)) { + if (!rawKeys.has(fieldKey)) continue; + parsedData[fieldKey] = parseData( + obj[fieldKey], + propShape as unknown as TypeDef, + ctx, + `${entityDesc}.${fieldKey}`, + ); + } + return parsedData; + } + } // For mutation events updating existing entities, track which keys are // present so mergeFields only touches those fields (true partial update). const existingInStore = queryClient.entityMap.getEntity(key); const isPartial = ctx.isPartialEvent && existingInStore !== undefined; + const eventKeys = ctx.isPartialEvent ? new Set(Object.keys(obj)) : undefined; const entry: ParsedEntity = { key, shape: entityShape, data: parsedData, - rawKeys: isPartial ? new Set(Object.keys(obj)) : undefined, + rawKeys: isPartial ? eventKeys : undefined, + eventKeys, + fillsPartial: false, }; ctx.seen!.set(parsedData, entry); ctx.seenByKey!.set(key, entry); @@ -508,6 +592,183 @@ function parseEntityData( // entitySatisfiesShape // ====================================================== +/** + * Whether already-parsed entity data can be handed to a query declaring `def` + * without being parsed again. Unlike `entitySatisfiesShape` (a top-level + * presence check used for narrowing) this descends into nested objects, arrays, + * records, unions and child entities, because a cached query that resolves with + * a required nested field missing is worse than one that refetches. Where a + * value cannot be judged it is accepted, matching the parser's leniency. + */ +export function dataSatisfiesDef( + data: Record, + def: ValidatorDef, + queryClient: QueryClient, + visiting: TrustMemo, +): boolean { + const shape = def.shape as Record | undefined; + if (shape === undefined || shape === null) return true; + for (const key of Object.keys(shape)) { + if (key === def.typenameField) continue; + if (!valueSatisfiesDef(data[key], shape[key], queryClient, visiting)) return false; + } + return true; +} + +function allowsMissing(mask: number): boolean { + return (mask & Mask.UNDEFINED) !== 0; +} + +function maskOf(value: unknown): number { + switch (typeof value) { + case 'number': + return Mask.NUMBER; + case 'string': + return Mask.STRING; + case 'boolean': + return Mask.BOOLEAN; + case 'undefined': + return Mask.UNDEFINED; + case 'object': + return value === null ? Mask.NULL : Array.isArray(value) ? Mask.ARRAY : Mask.OBJECT; + default: + return 0; + } +} + +function valueSatisfiesDef(value: unknown, fieldDef: unknown, queryClient: QueryClient, visiting: TrustMemo): boolean { + // Literals: the parser fills a missing literal in, and rejects any other value. + if (fieldDef instanceof VariantSet) return value === fieldDef.value; + if (typeof fieldDef === 'string') return value === fieldDef; + if (fieldDef instanceof Set) return fieldDef.has(value as never); + + if (typeof fieldDef === 'number') { + if (value === undefined) return allowsMissing(fieldDef); + if (value === null) return (fieldDef & Mask.NULL) !== 0; + if ((fieldDef & Mask.HAS_FORMAT) !== 0) return value instanceof FormattedValue; + return (fieldDef & maskOf(value)) !== 0; + } + + if (!(fieldDef instanceof ValidatorDef)) return true; + const mask = fieldDef.mask; + if (value === undefined) return allowsMissing(mask); + if (value === null) return (mask & Mask.NULL) !== 0; + + if (fieldDef._liveConfig !== undefined) return true; + if ((mask & Mask.PARSE_RESULT) !== 0) return typeof value === 'object' && 'success' in (value as object); + if (typeof value !== 'object') { + if ((mask & Mask.HAS_FORMAT) !== 0 && (mask & maskOf(value)) !== 0) return false; + return (mask & maskOf(value)) !== 0 || fieldDef.values?.has(value as never) === true; + } + if (value instanceof FormattedValue) return (mask & Mask.HAS_FORMAT) !== 0; + + if ((mask & Mask.ENTITY) !== 0 && (mask & Mask.UNION) === 0) { + return entitySatisfies(value as object, fieldDef, queryClient, visiting); + } + + if ((mask & Mask.UNION) !== 0) { + // Resolve the member the value belongs to; an unrecognised value is left + // to the union's own leniency. + const members = fieldDef.shape as Record | undefined; + if (members === undefined || members === null) return true; + if (Array.isArray(value)) { + const itemDef = members[ARRAY_KEY]; + if (itemDef === undefined || typeof itemDef === 'number') return true; + return arraySatisfies(value, itemDef, queryClient, visiting); + } + const entityKey = PROXY_ID.get(value as object); + const typename = + entityKey !== undefined + ? queryClient.entityMap.getEntity(entityKey)?.typename + : ((value as Record)[fieldDef.typenameField ?? '__typename'] as string | undefined); + const member = typename !== undefined ? members[typename] : undefined; + if (member instanceof VariantGroup) { + const source = + entityKey !== undefined ? queryClient.entityMap.getEntity(entityKey)?.data : (value as Record); + const variant = source?.[member.variantField] as string | undefined; + const variantDef = variant !== undefined ? member.defs[variant] : undefined; + return variantDef === undefined ? true : valueSatisfiesDef(value, variantDef, queryClient, visiting); + } + if (member === undefined || typeof member === 'number') { + const recordDef = members[RECORD_KEY]; + if (typename !== undefined || recordDef === undefined || entityKey !== undefined) return true; + for (const item of Object.values(value as Record)) { + if (!valueSatisfiesDef(item, recordDef, queryClient, visiting)) return false; + } + return true; + } + return valueSatisfiesDef(value, member, queryClient, visiting); + } + + if ((mask & Mask.ARRAY) !== 0) { + if (!Array.isArray(value)) return false; + return arraySatisfies(value, fieldDef.shape, queryClient, visiting); + } + + if ((mask & Mask.RECORD) !== 0) { + if (typeof value !== 'object' || Array.isArray(value)) return false; + for (const item of Object.values(value as Record)) { + if (!valueSatisfiesDef(item, fieldDef.shape, queryClient, visiting)) return false; + } + return true; + } + + if ((mask & Mask.OBJECT) !== 0) { + if (typeof value !== 'object' || Array.isArray(value)) return false; + if (PROXY_ID.has(value as object)) return true; + return dataSatisfiesDef(value as Record, fieldDef, queryClient, visiting); + } + + return (mask & maskOf(value)) !== 0; +} + +/** + * An entity field holds a proxy. One whose instance has left memory is served + * as it is (the data it still holds), as a re-parse would have handed it out + * too; a live one must satisfy the def, with verdicts shared across the parse + * and a cycle counted as satisfied. + */ +function entitySatisfies( + value: object, + def: ValidatorDef, + queryClient: QueryClient, + visiting: TrustMemo, +): boolean { + const entityKey = PROXY_ID.get(value); + if (entityKey === undefined) return false; + const child = queryClient.entityMap.getEntity(entityKey); + if (child === undefined) return true; + let checked = visiting.get(child); + if (checked === undefined) visiting.set(child, (checked = new Map())); + const known = checked.get(def); + if (known !== undefined) return known === 'pending' || known; + checked.set(def, 'pending'); + const verdict = dataSatisfiesDef(child.data, def, queryClient, visiting); + checked.set(def, verdict); + return verdict; +} + +/** + * Members of an entity array whose typename has more than one registered + * class are narrowed on read to the ones satisfying the field's def, so a + * member that does not satisfy it is not a mismatch: it is left out either + * way. + */ +function arraySatisfies(value: unknown[], itemDef: unknown, queryClient: QueryClient, visiting: TrustMemo): boolean { + let narrowed = false; + if (itemDef instanceof ValidatorDef && (itemDef.mask & Mask.ENTITY) !== 0 && (itemDef.mask & Mask.UNION) === 0) { + const typename = itemDef.typenameValue; + const defs = typename !== undefined ? queryClient.getEntityDefsForTypename(typename) : undefined; + narrowed = defs !== undefined && defs.length > 1; + } + for (const item of value) { + if (valueSatisfiesDef(item, itemDef, queryClient, visiting)) continue; + if (narrowed && typeof item === 'object' && item !== null && PROXY_ID.has(item)) continue; + return false; + } + return true; +} + export function entitySatisfiesShape(data: Record, def: ValidatorDef): boolean { return objectSatisfiesShape(data, def.shape as Record, def.typenameField); } From cb4a21112da6c53a41f9b15c1c12459abcb16cc3 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:30:17 -0400 Subject: [PATCH 035/109] perf(fetchium): skip the per-snapshot key-cache lookup and an event's unused Set Moving the static/dynamic key split to a per-client cache made every entity snapshot re-resolve its key lists through a WeakMap lookup, so a list of N entities paid N lookups per re-render. A cache's entries are never overwritten, and the client replaces the whole cache when a typename gains a class, so comparing the cache's identity is enough to know whether the lists can have changed. applyMutationEvent allocated the set of created entities for every event; only an event whose root is new uses it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 24 ++++++++++++++++-------- packages/fetchium/src/QueryClient.ts | 11 ++++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index ee948e2..63412d9 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -1070,21 +1070,29 @@ function createProxy( } // Shared per shape; only a query's root entity allocates its own lists. - let cachedBase: EntityKeys | undefined; + let cachedBaseCache: QueryClient['shapeKeyCache'] | undefined; let cachedMethods: Record | undefined; let cachedGetters: Record | undefined; let keys: EntityKeys | undefined; function entityKeys(): EntityKeys { - // The base is re-resolved on every call (a WeakMap lookup): the client - // resets its cache when another class registers for this typename, and - // the new split must reach snapshots already in flight. Both extras - // slots matter too: these lists decide what a snapshot walks and re-reads. - const base = shapeKeys(validatorDef, shapeFields, methods, queryClient); + // The client replaces its key cache when another class registers for + // this typename, and the new split must reach snapshots already in + // flight. A cache's entries are never overwritten, so the base can only + // have changed when the cache object did: an identity check, rather than + // a WeakMap lookup on every snapshot of every entity. Both extras slots + // matter too: these lists decide what a snapshot walks and re-reads. + const baseCache = queryClient.shapeKeyCache; const methodsNow = instance._extraMethods; const gettersNow = instance._extraGetters; - if (keys === undefined || base !== cachedBase || methodsNow !== cachedMethods || gettersNow !== cachedGetters) { - cachedBase = base; + if ( + keys === undefined || + baseCache !== cachedBaseCache || + methodsNow !== cachedMethods || + gettersNow !== cachedGetters + ) { + const base = shapeKeys(validatorDef, shapeFields, methods, queryClient); + cachedBaseCache = baseCache; cachedMethods = methodsNow; cachedGetters = gettersNow; keys = methodsNow === undefined && gettersNow === undefined ? base : withExtraKeys(base, methodsNow, gettersNow); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index f7a7626..8162d14 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -804,8 +804,9 @@ export class QueryClient { // Entities the event creates are not written by the apply; entities it // merely updates are. A created entity is written once a written record - // references it, or below, once the root is known to be retained. - const created = new Set(); + // references it, or below, once the root is known to be retained. Only an + // event whose root is new can create entities that need collecting here. + const created = existing === undefined ? new Set() : undefined; try { const warn = this.context.log?.warn ?? (() => {}); const parseCtx = this.mutationParseContext; @@ -823,7 +824,7 @@ export class QueryClient { if (existing === undefined) { // Half applied: nothing built from it can stay. const createdRoot = this.entityMap.getEntity(key); - if (createdRoot !== undefined) this.evictCreated(createdRoot, created); + if (createdRoot !== undefined) this.evictCreated(createdRoot, created!); } return; } @@ -870,14 +871,14 @@ export class QueryClient { entity.save(); } catch (e) { this.context.log?.warn?.('Failed to apply mutation event', e); - this.evictUnlessAdopted(entity, created); + this.evictUnlessAdopted(entity, created!); return; } } this.routeEvent(typename, entity.data, key, type, eventSource); - if (!matched) this.evictUnlessAdopted(entity, created); + if (!matched) this.evictUnlessAdopted(entity, created!); } /** From f67c51607fcc040a4a8d4c91e4d2322e9421963b Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 23:22:43 -0400 Subject: [PATCH 036/109] perf(fetchium): take the extra store work back out of streamed events - An entity built from events stops being partial once its fields are known to be the whole record: the store held no record of it when it is first written (hasEntity), or its events carried every field of its shape. Until now every later write of such an entity was a merge that read the stored record back and parsed it, roughly halving the apply rate of updates to stream-born rows. An entity over a record the store holds keeps merging. - With an AsyncQueryStore writer, one entity's queued write no longer turns write skipping off for every entity. The store reports only queued work that can delete a record (new optional `hasQueuedDeletes()`), which excludes the client's own entity writes; an entity whose own write is still queued counts as persisted; and an entity a queued write stops referencing is written again, rather than trusted, when it is referenced again before that write lands. A record that turns out stale (a failed or discarded write) is no longer marked persisted by an older write's acknowledgement. - A create event that no live collection routes is routed once (the dry run's), not twice, and the duplicate-slot check's entity lookup is reused instead of repeated. - Dev builds skip the snapshot key-list check for frozen snapshots, which every snapshot handed out in dev is. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-write-and-snapshot-fixes.md | 4 +- packages/fetchium/src/EntityInstance.ts | 44 +++- packages/fetchium/src/QueryClient.ts | 27 ++- .../src/__tests__/event-persistence.test.ts | 213 ++++++++++++++++++ packages/fetchium/src/applyEntities.ts | 27 ++- packages/fetchium/src/query-types.ts | 10 + packages/fetchium/src/stores/async.ts | 14 +- 7 files changed, 318 insertions(+), 21 deletions(-) diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index fac9ebc..bb5385e 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -7,9 +7,9 @@ Fixes for the entity snapshot fast path, unchanged-apply skipping and single-wri - Development builds hand out frozen snapshots, so a consumer that mutates one (a `sort()` in render, an assignment) fails at its own call site with a TypeError. Production snapshots stay mutable; treat them as read-only. The parser no longer writes into the objects it is given, so a snapshot (or part of one) handed back through a mutation event or a mutation's `effects` is read, not mutated, and two events sharing one nested object no longer make two entities share it. - The dev-only stale-snapshot check no longer throws from inside a snapshot. A throw there escaped Signalium's watcher flush and stopped every consumer on the page from updating. It now serves the re-read value and raises the error out of band, once per field: through the client's `log.error` and then as an uncaught error (a red box in React Native, an uncaught error in the browser, a failed run under vitest). It compares with `Object.is` (so a `NaN` field no longer trips it, and `-0` over `0` is applied at every level), compares a rebuilt `Set` in linear time, and restores the key set of a snapshot a consumer altered. - Hydrating a cached query no longer re-parses an entity that is already in memory. The re-parse ran already-parsed values through the parser again in place, wiping nested optional formats and parse results, and since the apply then saw no change it never notified; consumers of the first query kept stale snapshots. The in-memory data is used as-is once it structurally satisfies the query's shape, nested objects, arrays, records, unions and child entities included (members an array narrows out on read, and children that have left memory, are not held against it); otherwise the whole cached query is dropped and fetched, never served with an item missing. An entity built from streamed events is completed from its record instead. -- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. +- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). Once its fields are known to be the whole record (the store held no record of it when it was first written, or its events carried every field of its shape), it is written whole like any other entity, without reading the record back. An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. - An entity that appears more than once in one payload (an author listed under `author` and again under `mentions`) is applied once: it keeps its child references and is written once. -- `_persisted` is only set once the store write succeeded, so a write that threw is retried by the next apply. `AsyncQueryStore` writers acknowledge processed entity writes (`onPersisted`), report a failed one, answer `hasEntity` from the keys they read from the delegate at start (alongside the queue, so writes do not wait for it; a queued write counts as held) and keep current, run `purgeStaleQueries` through the same serial queue as the writes (a reader with its own delegate still purges directly), ignore anything on the channel that is not one of their messages, and expose `isSettled()`; the client counts its own outstanding writes, so an acknowledgement of a write another instance queued does not mark a hydrated instance current, and it does not skip a write while the writer has operations queued, since one of them may delete the record the skip relies on. +- `_persisted` is only set once the store write succeeded, so a write that threw is retried by the next apply. `AsyncQueryStore` writers acknowledge processed entity writes (`onPersisted`), report a failed one, answer `hasEntity` from the keys they read from the delegate at start (alongside the queue, so writes do not wait for it; a queued write counts as held) and keep current, run `purgeStaleQueries` through the same serial queue as the writes (a reader with its own delegate still purges directly), ignore anything on the channel that is not one of their messages, and expose `isSettled()` and `hasQueuedDeletes()`; the client counts its own outstanding writes, so an acknowledgement of a write another instance queued does not mark a hydrated instance current. It does not skip a write while the writer has an operation queued that may delete the record the skip relies on (anything but the client's own entity writes); an entity whose own write is still queued counts as written, and an entity that one of the client's queued writes stops referencing is written again if it is referenced again before that write lands. - `AsyncQueryStore` resizes a persisted LRU queue by copying it (growing no longer throws `RangeError`, shrinking evicts the dropped keys instead of stranding them, never the key being activated), and keeps a queue's persisted size for a message without `maxCount` from an older reader. `SyncQueryStore` gets the same resize. - `onDelete` returns an unsubscribe and `QueryClient.destroy()` calls it, so a store no longer retains every client created against it. `SyncQueryStore` shares its delete listeners between every instance over the same backing kv. - Reading a shared-typename entity array consumes the notifiers of the members it currently leaves out, so a reactive consumer learns when a member becomes eligible without a data change, and a consumer that only reads the array is not recomputed when a member it holds changes. diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 63412d9..c7b694f 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -393,7 +393,9 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno } // A consumer that added or deleted keys on the snapshot it was handed: // rebuild with the shape's keys, in the shape's order, as a walk would. - if (!sameKeyList(Object.keys(before), keys.enumerable)) { + // A frozen snapshot (every one this module hands out in dev) cannot + // have been altered, so only an unfrozen one pays for the key list. + if (!Object.isFrozen(before) && !sameKeyList(Object.keys(before), keys.enumerable)) { const source_ = result ?? before; const rebuilt: Record = {}; for (const key of keys.enumerable) rebuilt[key] = source_[key]; @@ -617,13 +619,27 @@ export class EntityInstance { } if (oldRefs !== undefined && oldRefs.size > 0) { for (const child of oldRefs.keys()) { - if (newRefs === undefined || !newRefs.has(child)) child.release(); + if (newRefs === undefined || !newRefs.has(child)) { + child.release(); + if (persist) this.writeDropsRef(child); + } } } this.entityRefs = newRefs; if (persist) this.save(); } + /** + * This entity's next write drops its reference to `child`, which can delete + * the child's record. A store that processes writes later (`onPersisted`) + * does that after anything an apply decides meanwhile, so the child is + * written again rather than trusted. A synchronous store reports the + * deletion (`onDelete`) as it happens. + */ + private writeDropsRef(child: EntityInstance): void { + if (this._queryClient.storeAcksWrites) child.recordDropped(); + } + addChildRef(child: EntityInstance, persist: boolean = true): void { if (this.entityRefs === undefined) this.entityRefs = new Map(); const count = this.entityRefs.get(child) ?? 0; @@ -639,6 +655,7 @@ export class EntityInstance { if (count <= 1) { this.entityRefs.delete(child); child.release(); + if (persist) this.writeDropsRef(child); } else { this.entityRefs.set(child, count - 1); } @@ -693,7 +710,7 @@ export class EntityInstance { } catch (e) { // This entity's record (if any) still lacks the reference: the // next apply must write it again. - this._persisted = false; + this.markUnwritten(); throw e; } if (child._deferredWrite) deferred = true; @@ -712,12 +729,18 @@ export class EntityInstance { // once the write has been processed. An entity built from events hands // the store only the fields they carried, to merge over its record. // Counted before the call: a store may acknowledge synchronously. + // With no record to merge over, the fields this entity holds are the + // whole record, so it stops being partial and later writes skip the + // merge's read of the stored record. + if (this._partial && !this._recorded && client.store.hasEntity?.(this.key) === false) { + this._partial = false; + this._partialKeys = undefined; + } if (client.storeAcksWrites) this._pendingWrites++; try { client.entityMap.save(this, this._partial ? this._partialKeys : undefined); } catch (e) { - if (client.storeAcksWrites && this._pendingWrites > 0) this._pendingWrites--; - this._persisted = false; + this.markUnwritten(); throw e; } if (!client.storeAcksWrites) this._persisted = this._recorded = true; @@ -746,8 +769,17 @@ export class EntityInstance { /** The store dropped (or failed to write) this entity's record. */ recordDropped(): void { - this._persisted = false; this._recorded = false; + this.markUnwritten(); + } + + /** + * Its record is stale: the next apply writes it even if it finds nothing + * changed. Writes still queued hold older data, so their acknowledgements + * no longer mark it persisted. + */ + markUnwritten(): void { + this._persisted = false; this._pendingWrites = 0; } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 8162d14..dad9585 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -347,7 +347,9 @@ export class QueryClient { * A synchronous store is always settled. */ storeIsSettled(): boolean { - return this.store.isSettled?.() ?? true; + const store = this.store; + if (store.hasQueuedDeletes !== undefined) return !store.hasQueuedDeletes(); + return store.isSettled?.() ?? true; } private registerEntityDef(def: ValidatorDef): void { @@ -762,8 +764,18 @@ export class QueryClient { return this.entityMap.getEntity(proxyKey!)!; } - prepareEntity(key: number, obj: Record, shape: EntityDef): EntityInstance { + /** `existing`: the instance the caller already looked up under `key`, if any. */ + prepareEntity( + key: number, + obj: Record, + shape: EntityDef, + existing?: EntityInstance, + ): EntityInstance { this.registerEntityDef(shape as unknown as ValidatorDef); + if (existing !== undefined) { + existing.parseId = this.currentParseId; + return existing; + } return this.entityMap.getOrCreateEntity(key, obj, shape, this); } @@ -876,9 +888,10 @@ export class QueryClient { } } - this.routeEvent(typename, entity.data, key, type, eventSource); - - if (!matched) this.evictUnlessAdopted(entity, created!); + // The dry run found no collection to route into: routing again would + // only recompute the constraint hashes. + if (matched) this.routeEvent(typename, entity.data, key, type, eventSource); + else this.evictUnlessAdopted(entity, created!); } /** @@ -944,7 +957,7 @@ export class QueryClient { for (const entity of deferred) { if (entity._deferredWrite) { entity._deferredWrite = false; - entity._persisted = false; + entity.markUnwritten(); } } } @@ -963,7 +976,7 @@ export class QueryClient { for (const entity of deferred) { if (entity._deferredWrite) { entity._deferredWrite = false; - entity._persisted = false; + entity.markUnwritten(); } } } diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index bc18d56..ef429cc 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -1542,3 +1542,216 @@ describe('AsyncQueryStore writer', () => { expect(first._persisted).toBe(true); }); }); + +// ====================================================== +// Store work per streamed event +// ====================================================== + +describe('store work per streamed event', () => { + class Author extends Entity { + __typename = t.typename('Author'); + id = t.id; + name = t.string; + } + class Note extends Entity { + __typename = t.typename('Note'); + id = t.id; + boardId = t.string; + body = t.string; + pinned = t.optional(t.boolean); + author = t.optional(t.entity(Author)); + } + class Board extends Entity { + __typename = t.typename('Board'); + id = t.id; + notes = t.liveArray(Note, { constraints: { boardId: (this as any).id } }); + } + class GetBoard extends RESTQuery { + params = { id: t.id }; + path = `/board/${this.params.id}`; + result = { board: t.entity(Board) }; + } + + const getClient = setupTestClient(); + + it('an entity an event built, of which the store held no record, is written whole afterwards', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/board/b1', { board: { __typename: 'Board', id: 'b1', notes: [] } }); + const boardQ = holdQuery(client, () => fetchQuery(GetBoard, { id: 'b1' })); + await boardQ; + const merges = vi.spyOn(store, 'mergeEntity'); + const reads = vi.spyOn(kv, 'getString'); + + client.applyMutationEvent({ + type: 'create', + typename: 'Note', + data: { __typename: 'Note', id: 'n1', boardId: 'b1', body: 'first' }, + }); + for (let i = 0; i < 3; i++) { + client.applyMutationEvent({ type: 'update', typename: 'Note', data: { id: 'n1', body: `edit ${i}` } }); + } + await sleep(5); + + expect(merges).not.toHaveBeenCalled(); + expect(reads.mock.calls.filter(([key]) => key === valueKeyFor(K('Note', 'n1')))).toEqual([]); + expect(getDoc(kv, K('Note', 'n1'))).toEqual({ __typename: 'Note', id: 'n1', boardId: 'b1', body: 'edit 2' }); + expect((boardQ.value as any).board.notes.map((n: any) => n.body)).toEqual(['edit 2']); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('an entity an event built over a record the store holds keeps merging into it', async () => { + const { client, mockFetch, kv, store } = getClient(); + mockFetch.get('/board/b1', { + board: { + __typename: 'Board', + id: 'b1', + notes: [{ __typename: 'Note', id: 'n1', boardId: 'b1', body: 'a', pinned: true }], + }, + }); + const boardQ = holdQuery(client, () => fetchQuery(GetBoard, { id: 'b1' })); + await boardQ; + // The note leaves memory but its record stays (the board's cache holds it). + client.applyMutationEvent({ type: 'delete', typename: 'Note', data: 'n1' }); + await sleep(5); + kv.setString( + valueKeyFor(K('Note', 'n1')), + JSON.stringify({ __typename: 'Note', id: 'n1', boardId: 'b1', body: 'a', pinned: true }), + ); + const merges = vi.spyOn(store, 'mergeEntity'); + + client.applyMutationEvent({ + type: 'create', + typename: 'Note', + data: { __typename: 'Note', id: 'n1', boardId: 'b1', body: 'b' }, + }); + await sleep(5); + + expect(merges.mock.calls.map(c => c[0])).toEqual([K('Note', 'n1')]); + // The field the event did not carry survives. + expect(getDoc(kv, K('Note', 'n1'))).toMatchObject({ body: 'b', pinned: true }); + }); +}); + +describe('AsyncQueryStore writer: write skipping while writes are queued', () => { + const clients: QueryClient[] = []; + afterEach(() => { + for (const client of clients.splice(0)) client.destroy(); + }); + + class Badge extends Entity { + // Lingers in memory once released, so a later apply finds the same instance. + static cache = { gcTime: 60 }; + __typename = t.typename('Badge'); + id = t.id; + label = t.string; + } + class User extends Entity { + __typename = t.typename('User'); + id = t.id; + name = t.string; + karma = t.number; + badge = t.optional(t.entity(Badge)); + } + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/user/${this.params.id}`; + result = { user: t.entity(User) }; + } + + async function setup(users: Record[]) { + const delegate = new AsyncDelegate(3); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + for (const user of users) mockFetch.get(`/user/${user.id as string}`, { user }); + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as never); + clients.push(client); + for (const user of users) await holdQuery(client, () => fetchQuery(GetUser, { id: user.id as string })); + await drain(store); + await scanned(store); + const saves = vi.spyOn(store, 'saveEntity'); + const merges = vi.spyOn(store, 'mergeEntity'); + const writes = () => [...saves.mock.calls.map(c => c[0]), ...merges.mock.calls.map(c => c[0])]; + return { delegate, store, client, writes }; + } + + const user = (id: string, karma: number, badge?: Record) => ({ + __typename: 'User', + id, + name: id, + karma, + ...(badge !== undefined ? { badge } : {}), + }); + + it("one entity's queued write leaves skipping on for identical events of other entities", async () => { + const { store, client, writes } = await setup([user('u1', 1), user('u2', 2), user('u3', 3)]); + + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u1', karma: 10 } }); + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u2', karma: 2 } }); + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u3', karma: 3 } }); + // Identical to the write still queued for u1. + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u1', karma: 10 } }); + expect(store.isSettled()).toBe(false); + await drain(store); + + expect(writes()).toEqual([K('User', 'u1')]); + }); + + it('a queued deletion still turns skipping off', async () => { + const { store, client, writes } = await setup([user('u1', 1)]); + + store.deleteQuery(12345); + client.applyMutationEvent({ type: 'update', typename: 'User', data: { id: 'u1', karma: 1 } }); + await drain(store); + + expect(writes()).toEqual([K('User', 'u1')]); + }); + + it('an entity a queued write stops referencing is written again when it is referenced again', async () => { + class Card extends Entity { + // Lingers in memory once released, so the next event finds the same instance. + static cache = { gcTime: 60 }; + __typename = t.typename('Card'); + id = t.id; + deckId = t.string; + title = t.string; + } + class Deck extends Entity { + __typename = t.typename('Deck'); + id = t.id; + cards = t.liveArray(Card, { constraints: { deckId: (this as any).id } }); + } + class GetDeck extends RESTQuery { + path = '/deck'; + result = { deck: t.entity(Deck) }; + } + const card = { __typename: 'Card', id: 'c1', deckId: 'd1', title: 'Ace' }; + const delegate = new AsyncDelegate(3); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/deck', { deck: { __typename: 'Deck', id: 'd1', cards: [card] } }); + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as never); + clients.push(client); + const deckQ = holdQuery(client, () => fetchQuery(GetDeck)); + await deckQ; + await drain(store); + await scanned(store); + + // The deck's write drops its reference to c1, the only one on disk; before + // it is processed, the same card is created again with identical data. + client.applyMutationEvent({ type: 'delete', typename: 'Card', data: 'c1' }); + client.applyMutationEvent({ type: 'create', typename: 'Card', data: card }); + await drain(store); + + expect((deckQ.value as any).deck.cards.map((c: any) => c.title)).toEqual(['Ace']); + expect(Array.from(delegate.kv[refIdsKeyFor(K('Deck', 'd1'))] as Uint32Array)).toEqual([K('Card', 'c1')]); + expect(asyncDoc(delegate, K('Card', 'c1'))).toMatchObject({ title: 'Ace' }); + }); +}); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 15d0214..19af1f7 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -148,7 +148,7 @@ function applyEntity( return applied.getProxy(entityShape); } - const entityInstance = queryClient.prepareEntity(key, data, entityShape); + const entityInstance = queryClient.prepareEntity(key, data, entityShape, applied); const existingData = entityInstance.data; const isUpdate = existingData !== data; @@ -188,7 +188,12 @@ function applyEntity( entityInstance._partial = false; entityInstance._partialKeys = undefined; } else if (entityInstance._partial && rawKeys !== undefined) { - for (const k of rawKeys) entityInstance._partialKeys!.add(k); + const partialKeys = entityInstance._partialKeys!; + for (const k of rawKeys) partialKeys.add(k); + if (coversShape(partialKeys, shapeFields)) { + entityInstance._partial = false; + entityInstance._partialKeys = undefined; + } } } else { initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode, created); @@ -201,6 +206,11 @@ function applyEntity( // event did (the parser fills the literal in). if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); + // Events that carried every field built a whole record. + if (coversShape(entityInstance._partialKeys, shapeFields)) { + entityInstance._partial = false; + entityInstance._partialKeys = undefined; + } } else if (persist === false) { // Hydrated from the store: its record exists. entityInstance._recorded = true; @@ -231,12 +241,13 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); // An entity hydrated from the store was never written, so there is no write - // to skip. A skip is also only safe while the store has nothing queued: a - // deletion it has yet to process could drop the record this apply trusts. + // to skip. One whose own write is still queued has its current data on the + // way. A skip is also only safe while the store has no deletion queued: it + // could drop the record this apply trusts. const needsPersist = changed || refsChanged || - !entityInstance._persisted || + (!entityInstance._persisted && entityInstance._pendingWrites === 0) || !queryClient.storeReportsDeletes || !queryClient.storeIsSettled(); const writes = persist === true || (persist === 'existing' && isUpdate); @@ -249,6 +260,12 @@ function applyEntity( return proxy; } +/** Whether `keys` names every field of `shape`. */ +function coversShape(keys: Set, shape: Record): boolean { + for (const k in shape) if (!keys.has(k)) return false; + return true; +} + // ====================================================== // Field merge (update path) — reify + merge in one loop // ====================================================== diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 4578cc9..5e309d2 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -150,6 +150,16 @@ export interface QueryStore { */ isSettled?(): boolean; + /** + * For stores that process writes asynchronously, a narrower `isSettled`: + * whether an operation that could delete a record is still queued. This + * client's own entity writes do not count; for those that drop a + * reference, the client itself writes the referenced entity again. When + * present, the client uses it instead of `isSettled`, so a burst of entity + * writes does not turn write skipping off for every other entity. + */ + hasQueuedDeletes?(): boolean; + /** * Whether the store currently holds a record for this entity key, or * `undefined` while it cannot tell yet (a store that has not finished diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 3946c5d..9cf6023 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -124,6 +124,11 @@ export class AsyncQueryStore implements QueryStore { private deleteListeners: Array<(key: number) => void> = []; private persistedListeners: Array<(key: number) => void> = []; private processing = false; + // Queued work that can delete a record: everything except the entity + // writes of this writer's own client, which tracks the references those + // drop (see `hasQueuedDeletes`). + private queuedDeletes = 0; + private readonly ownEntityWrites = new WeakSet(); // The ids whose value the delegate holds (or is about to: a queued write // counts), mirrored by the writer so that `hasEntity` can answer // synchronously. Read from the delegate once at start, without holding up @@ -137,6 +142,7 @@ export class AsyncQueryStore implements QueryStore { onDelete?: (listener: (key: number) => void) => () => void; onPersisted?: (listener: (key: number) => void) => () => void; hasEntity?: (key: number) => boolean | undefined; + hasQueuedDeletes?: () => boolean; constructor(config: AsyncQueryStoreConfig) { this.isWriter = config.isWriter; @@ -144,6 +150,7 @@ export class AsyncQueryStore implements QueryStore { if (this.isWriter) { this.onDelete = listener => subscribe(this.deleteListeners, listener); this.onPersisted = listener => subscribe(this.persistedListeners, listener); + this.hasQueuedDeletes = () => this.queuedDeletes > 0; this.hasEntity = key => { if (this.heldKeys !== undefined) return this.heldKeys.has(key); // Not read yet: only what this writer itself wrote is known. @@ -183,6 +190,7 @@ export class AsyncQueryStore implements QueryStore { } private enqueueMessage(msg: QueuedWork): void { + if (!this.ownEntityWrites.has(msg)) this.queuedDeletes++; this.messageQueue.push(msg); // Wake up the queue processor if it's waiting if (this.resolveQueueWait) { @@ -193,7 +201,10 @@ export class AsyncQueryStore implements QueryStore { private dispatch(msg: StoreMessage): void { if (this.isWriter) { - if (msg.type === StoreMessageType.SaveEntity) this.noteHeld(msg.entityKey); + if (msg.type === StoreMessageType.SaveEntity) { + this.noteHeld(msg.entityKey); + this.ownEntityWrites.add(msg); + } this.enqueueMessage(msg); } else { this.sendMessage(msg); @@ -255,6 +266,7 @@ export class AsyncQueryStore implements QueryStore { } } finally { this.processing = false; + if (!this.ownEntityWrites.has(msg)) this.queuedDeletes--; } } } From e68c8758a653cf77dbb88a39bc0c4fcb96a1960b Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 00:27:49 -0400 Subject: [PATCH 037/109] perf(fetchium): stop re-running a shared entity's consumers, copying unchanged fetch data and asking the store twice A perf review of the fix found three costs it had added to the happy path. - A query whose result is an entity notified that entity when it attached its extras (__refetch, __hasNext, __isFetchingNext). Every existing consumer of the entity re-ran, and its list's snapshot got a new identity, whenever a detail query for it first applied, even with identical data. The notify is gone. The query's own consumers get the proxy through the relay and snapshot it afresh, so they still see the extras; other consumers see them on their next recompute, as before the fix. - The parser copied every nested t.object and t.record of every fetch (15-21% on a refetch of 1000 entities with nested values). A fetch result or a cached record is now copied only where a parsed value differs from the input, so unchanged nested data costs no allocation. Arrays work the same way (an array of primitives is no longer rebuilt on every fetch); an empty one is always a new array, since a live array grows the array it holds. Event payloads and mutation effects, which the caller may still hold, are still parsed into copies. The parser still never writes into its input: a reified value is written back only when it changed, and a merge into a nested value the adapter froze goes into a copy. - A streamed create asked the store hasEntity even when a live array was about to retain the root, and asked again in save() for an entity built from a partial event. The store is now asked only for a root nothing retains, and save() reuses that answer: routed creates make no kv.has call (a partial one makes one instead of two). An unrouted create keeps its one call; that is what refreshes the record of an entity on disk but not in memory. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-write-and-snapshot-fixes.md | 6 +- packages/fetchium/src/EntityInstance.ts | 5 +- packages/fetchium/src/QueryClient.ts | 34 +++++-- packages/fetchium/src/QueryResult.ts | 11 ++- .../skip-write-and-snapshot-fixes.test.ts | 97 ++++++++++++++++++- packages/fetchium/src/applyEntities.ts | 33 +++++-- packages/fetchium/src/parseEntities.ts | 51 +++++++--- 7 files changed, 193 insertions(+), 44 deletions(-) diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index bb5385e..8d3fca3 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -4,14 +4,14 @@ Fixes for the entity snapshot fast path, unchanged-apply skipping and single-write events introduced in 0.6.0. -- Development builds hand out frozen snapshots, so a consumer that mutates one (a `sort()` in render, an assignment) fails at its own call site with a TypeError. Production snapshots stay mutable; treat them as read-only. The parser no longer writes into the objects it is given, so a snapshot (or part of one) handed back through a mutation event or a mutation's `effects` is read, not mutated, and two events sharing one nested object no longer make two entities share it. +- Development builds hand out frozen snapshots, so a consumer that mutates one (a `sort()` in render, an assignment) fails at its own call site with a TypeError. Production snapshots stay mutable; treat them as read-only. The parser no longer writes into the objects it is given, so a snapshot (or part of one) handed back through a mutation event or a mutation's `effects` is read, not mutated, and two events sharing one nested object no longer make two entities share it. Event payloads are parsed into copies; a fetch result or a cached record is copied only where a parsed value differs from it, so an unchanged nested object, record or array costs no allocation, and a later merge into one the adapter froze goes into a copy. - The dev-only stale-snapshot check no longer throws from inside a snapshot. A throw there escaped Signalium's watcher flush and stopped every consumer on the page from updating. It now serves the re-read value and raises the error out of band, once per field: through the client's `log.error` and then as an uncaught error (a red box in React Native, an uncaught error in the browser, a failed run under vitest). It compares with `Object.is` (so a `NaN` field no longer trips it, and `-0` over `0` is applied at every level), compares a rebuilt `Set` in linear time, and restores the key set of a snapshot a consumer altered. - Hydrating a cached query no longer re-parses an entity that is already in memory. The re-parse ran already-parsed values through the parser again in place, wiping nested optional formats and parse results, and since the apply then saw no change it never notified; consumers of the first query kept stale snapshots. The in-memory data is used as-is once it structurally satisfies the query's shape, nested objects, arrays, records, unions and child entities included (members an array narrows out on read, and children that have left memory, are not held against it); otherwise the whole cached query is dropped and fetched, never served with an item missing. An entity built from streamed events is completed from its record instead. -- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). Once its fields are known to be the whole record (the store held no record of it when it was first written, or its events carried every field of its shape), it is written whole like any other entity, without reading the record back. An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. +- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). Once its fields are known to be the whole record (the store held no record of it when it was first written, or its events carried every field of its shape), it is written whole like any other entity, without reading the record back. An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. The store is asked only for a created root no live array retains, once per event: each such event costs one `hasEntity` call (a `kv.has` with `SyncQueryStore`). - An entity that appears more than once in one payload (an author listed under `author` and again under `mentions`) is applied once: it keeps its child references and is written once. - `_persisted` is only set once the store write succeeded, so a write that threw is retried by the next apply. `AsyncQueryStore` writers acknowledge processed entity writes (`onPersisted`), report a failed one, answer `hasEntity` from the keys they read from the delegate at start (alongside the queue, so writes do not wait for it; a queued write counts as held) and keep current, run `purgeStaleQueries` through the same serial queue as the writes (a reader with its own delegate still purges directly), ignore anything on the channel that is not one of their messages, and expose `isSettled()` and `hasQueuedDeletes()`; the client counts its own outstanding writes, so an acknowledgement of a write another instance queued does not mark a hydrated instance current. It does not skip a write while the writer has an operation queued that may delete the record the skip relies on (anything but the client's own entity writes); an entity whose own write is still queued counts as written, and an entity that one of the client's queued writes stops referencing is written again if it is referenced again before that write lands. - `AsyncQueryStore` resizes a persisted LRU queue by copying it (growing no longer throws `RangeError`, shrinking evicts the dropped keys instead of stranding them, never the key being activated), and keeps a queue's persisted size for a message without `maxCount` from an older reader. `SyncQueryStore` gets the same resize. - `onDelete` returns an unsubscribe and `QueryClient.destroy()` calls it, so a store no longer retains every client created against it. `SyncQueryStore` shares its delete listeners between every instance over the same backing kv. - Reading a shared-typename entity array consumes the notifiers of the members it currently leaves out, so a reactive consumer learns when a member becomes eligible without a data change, and a consumer that only reads the array is not recomputed when a member it holds changes. - Two classes sharing a typename: fields that are entity-free in one class but hold an entity through the other are no longer classified static for the snapshot fast path. -- Query extras (`__refetch`, `__hasNext`, ...) attached to a shared entity notify existing consumers; a query whose cached value was rejected fetches even within `staleTime`; snapshots of proxies created by a previous (hot-reloaded) module instance are walked instead of returned as-is. +- A query whose cached value was rejected fetches even within `staleTime`; a query whose cached value was rejected fetches even within `staleTime`; snapshots of proxies created by a previous (hot-reloaded) module instance are walked instead of returned as-is. diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index c7b694f..c2c8e93 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -685,7 +685,8 @@ export class EntityInstance { return false; } - save(): void { + /** `storeHolds`: what the store's `hasEntity` just said about this entity, if the caller asked. */ + save(storeHolds?: boolean): void { const client = this._queryClient; if (this._saving) return; this._saving = true; @@ -732,7 +733,7 @@ export class EntityInstance { // With no record to merge over, the fields this entity holds are the // whole record, so it stops being partial and later writes skip the // merge's read of the stored record. - if (this._partial && !this._recorded && client.store.hasEntity?.(this.key) === false) { + if (this._partial && !this._recorded && (storeHolds ?? client.store.hasEntity?.(this.key)) === false) { this._partial = false; this._partialKeys = undefined; } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index dad9585..c699fa9 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -712,11 +712,20 @@ export class QueryClient { /** * Parse data: validates, formats, produces parsed entity data objects. * Does NOT touch the entity store. Call applyRefs() after to commit entities. + * + * `copyInput: false` (a fetch result or a cached record, handed over to the + * client) copies a nested object only where a parsed value differs from it. */ - parseData(obj: unknown, shape: InternalTypeDef, preloadedEntities?: PreloadedEntityMap): ParseResult { + parseData( + obj: unknown, + shape: InternalTypeDef, + preloadedEntities?: PreloadedEntityMap, + copyInput: boolean = true, + ): ParseResult { const warn = this.context.log?.warn ?? (() => {}); const ctx = new ParseContext(); ctx.reset(this, preloadedEntities, warn); + ctx.copyInput = copyInput; const data = parseEntities(obj, shape as unknown as ComplexTypeDef, ctx); return { data, ctx }; } @@ -756,7 +765,7 @@ export class QueryClient { obj = { ...(obj as Record), [QUERY_ID]: queryId }; } - const parseResult = this.parseData(obj, rootEntityShape as unknown as InternalTypeDef, preloadedEntities); + const parseResult = this.parseData(obj, rootEntityShape as unknown as InternalTypeDef, preloadedEntities, false); const result = applyEntityRefs(parseResult.ctx, parseResult.data, persist, appendMode); // Discover the root entity from the returned proxy @@ -877,14 +886,19 @@ export class QueryClient { undefined, /* dryRun */ true, ); - const held = this.store.hasEntity?.(key) === true; - if ((retains || held) && !entity._persisted && entity._pendingWrites === 0) { - try { - entity.save(); - } catch (e) { - this.context.log?.warn?.('Failed to apply mutation event', e); - this.evictUnlessAdopted(entity, created!); - return; + // The store is asked only when no live array retains the root, and only + // when there is something to write; its answer is handed to save(). + let held: boolean | undefined; + if (!entity._persisted && entity._pendingWrites === 0) { + if (!retains) held = this.store.hasEntity?.(key); + if (retains || held === true) { + try { + entity.save(held); + } catch (e) { + this.context.log?.warn?.('Failed to apply mutation event', e); + this.evictUnlessAdopted(entity, created!); + return; + } } } diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 3c2ea52..be7c832 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -271,10 +271,13 @@ export class QueryInstance { __hasNext: () => this.hasNext, __isFetchingNext: () => this._fetchNextPromise !== undefined, }; - // The entity's key set just changed. A shared entity (a query whose - // result is an entity) may already have consumers, and an apply that - // found its data unchanged will not have notified them. - this.rootEntity.notify(); + // The entity's key set just changed, but its consumers are not + // notified: this query's own consumers get the proxy through the relay + // and snapshot it afresh, and an existing consumer of a shared entity + // (a list showing the entity this query returns) has no use for another + // query's extras. Notifying would re-run every one of them, and give + // their snapshots a new identity, for data that did not change. They + // see the extras on their next recompute, as before. } return this.rootEntity.getProxy(def.statics.shape as unknown as EntityDef) as QueryResult; diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index c9664f3..81b098f 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -438,6 +438,85 @@ describe('hydration of a cached query whose entity is already in memory', () => expect(h.read().doc.meta.views).toBe(2); expect(snap.meta.views).toBe(1); }); + + it('a frozen fetch result is applied without being written to, and later merges reach its nested values', async () => { + class DocNote extends Entity { + __typename = t.typename('DocNote'); + id = t.id; + docId = t.string; + } + class Doc extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + meta = t.object({ views: t.number, stats: t.object({ likes: t.number }) }); + counts = t.record(t.number); + tags = t.array(t.string); + notes = t.liveArray(DocNote, { constraints: { docId: (this as any).id } }); + } + class GetDoc extends RESTQuery { + path = '/doc'; + result = { doc: t.entity(Doc), note: t.object({ text: t.string }) }; + } + const deepFreeze = (value: T): T => { + if (typeof value === 'object' && value !== null) { + for (const v of Object.values(value)) deepFreeze(v); + Object.freeze(value); + } + return value; + }; + const body = deepFreeze({ + doc: { + __typename: 'Doc', + id: 'd', + title: 'T', + meta: { views: 1, stats: { likes: 3 } }, + counts: { a: 1 }, + tags: ['x'], + notes: [], + }, + note: { text: 'n' }, + }); + const before = JSON.stringify(body); + // Hands out the same frozen object on every call, as an adapter that + // keeps its last response may. + const fetch = async () => ({ ok: true, status: 200, headers: new Headers(), json: async () => body }); + const { client, warn } = makeClient( + new SyncQueryStore(new MemoryPersistentStore()), + fetch as unknown as ReturnType, + ); + const h = snapshotHarness(client, () => fetchQuery(GetDoc)); + await h.query; + expect(h.read().doc.meta.stats.likes).toBe(3); + expect(h.read().note.text).toBe('n'); + + client.applyMutationEvent({ + type: 'update', + typename: 'Doc', + data: { id: 'd', meta: { views: 2, stats: { likes: 4 } }, counts: { a: 2 } }, + }); + expect(warn).not.toHaveBeenCalled(); + expect(h.read().doc.meta.views).toBe(2); + expect(h.read().doc.meta.stats.likes).toBe(4); + expect(h.read().doc.counts.a).toBe(2); + // The live array grows its own array, not the response's (empty, frozen) one. + client.applyMutationEvent({ + type: 'create', + typename: 'DocNote', + data: { __typename: 'DocNote', id: 'n1', docId: 'd' }, + }); + expect(h.read().doc.notes.map((n: { id: string }) => n.id)).toEqual(['n1']); + expect(h.read().doc.tags).toEqual(['x']); + + // A refetch of the same (frozen) response puts the old values back. + await (h.query as any).value.__refetch(); + await sleep(5); + expect(h.read().doc.meta.stats.likes).toBe(3); + expect(h.read().doc.counts.a).toBe(1); + expect(warn).not.toHaveBeenCalled(); + expect(JSON.stringify(body)).toBe(before); + client.destroy(); + }); }); // ====================================================== @@ -992,7 +1071,7 @@ describe('snapshot fast path follow-ups', () => { }); }); - it('query extras attached to a shared entity reach an existing consumer without another trigger', async () => { + it('query extras attached to a shared entity reach the query itself without re-running its other consumers', async () => { const { client, mockFetch } = getClient(); class Profile extends Entity { __typename = t.typename('Profile'); @@ -1010,13 +1089,27 @@ describe('snapshot fast path follow-ups', () => { const profile = { __typename: 'Profile', id: 1, name: 'Alice' }; mockFetch.get('/wrapped', { profile }); mockFetch.get('/root', profile); + mockFetch.get('/wrapped', { profile: { ...profile, name: 'Alicia' } }); const wrapped = snapshotHarness(client, () => fetchQuery(GetWrapped)); await wrapped.query; - expect('__refetch' in wrapped.read().profile).toBe(false); + const before = wrapped.read(); + const computesBefore = wrapped.computes(); + expect('__refetch' in before.profile).toBe(false); + // The entity-rooted query applies identical data: its own consumer sees + // the extras, the wrapped query's consumer is not re-run for them. const root = snapshotHarness(client, () => fetchQuery(GetRoot)); await root.query; + await sleep(5); + expect(typeof root.read().__refetch).toBe('function'); + expect(wrapped.read()).toBe(before); + expect(wrapped.computes()).toBe(computesBefore); + + // Its next recompute (here, a change to the entity) picks them up. + await (wrapped.query as any).value.__refetch(); + await sleep(5); + expect(wrapped.read().profile.name).toBe('Alicia'); expect(typeof wrapped.read().profile.__refetch).toBe('function'); }); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 19af1f7..695babf 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -288,8 +288,12 @@ function mergeFields( for (const [fieldKey, propShape] of entries(shape)) { if (rawKeys !== undefined && !rawKeys.has(fieldKey)) continue; - if (shouldReify(data[fieldKey])) { - data[fieldKey] = reifyAndApply(data[fieldKey], seen, queryClient, persist, childRefs, appendMode, created); + const raw = data[fieldKey]; + if (shouldReify(raw)) { + // Written only when it changes: an object the parser kept as is (a + // fetch result's) may belong to the adapter, and be frozen. + const reified = reifyAndApply(raw, seen, queryClient, persist, childRefs, appendMode, created); + if (reified !== raw) data[fieldKey] = reified; } if (propShape instanceof ValidatorDef && propShape._liveConfig !== undefined) { @@ -332,12 +336,15 @@ function mergeFields( !(propShape.shape instanceof Set) ? (propShape.shape as Record) : undefined; + // The parser keeps a fetch result's unchanged objects as they are, and + // an adapter may hand out frozen ones: merge into a copy of those. + const target = Object.isFrozen(oldVal) ? { ...oldVal } : oldVal; if (nestedShape !== undefined) { if ( mergeFields( nestedShape, newVal, - oldVal, + target, undefined, entityInstance, entityData, @@ -350,18 +357,22 @@ function mergeFields( ) ) { changed = true; + existingData[fieldKey] = target; + } else { + existingData[fieldKey] = oldVal; } - existingData[fieldKey] = oldVal; } else if (sameKeys(oldVal, newVal)) { // Shapeless object (e.g. a record) with the same keys: copy field by field, keeping the // existing value — and its identity — wherever it already matches. + let merged = false; for (const k of Object.keys(newVal)) { if (!sameValue(oldVal[k], newVal[k])) { - oldVal[k] = newVal[k]; - changed = true; + target[k] = newVal[k]; + merged = true; } } - existingData[fieldKey] = oldVal; + if (merged) changed = true; + existingData[fieldKey] = merged ? target : oldVal; } else { // A key was added or removed. Copying field by field would never // apply a removal, leaving the old key in `data` indefinitely. @@ -462,8 +473,12 @@ function initFields( for (const [fieldKey, propShape] of entries(shape)) { if (!(fieldKey in data)) continue; - if (shouldReify(data[fieldKey])) { - data[fieldKey] = reifyAndApply(data[fieldKey], seen, queryClient, persist, childRefs, appendMode, created); + const raw = data[fieldKey]; + if (shouldReify(raw)) { + // Written only when it changes: an object the parser kept as is (a + // fetch result's) may belong to the adapter, and be frozen. + const reified = reifyAndApply(raw, seen, queryClient, persist, childRefs, appendMode, created); + if (reified !== raw) data[fieldKey] = reified; } if (propShape instanceof ValidatorDef && propShape._liveConfig !== undefined) { diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index 4abaded..f71e08e 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -74,6 +74,14 @@ export class ParseContext { seenByKey: Map | undefined = undefined; /** Verdicts of the trust-memory check, per instance and def, for one parse. */ trusted: TrustMemo | undefined = undefined; + /** + * Whether a nested object or record is always parsed into a copy. On for + * input the client does not own and may see again (an event payload, a + * mutation's effects, a consumer's snapshot fed back). Off for a fetch + * result or a cached record, which is copied only where a parsed value + * differs from what it holds: unchanged input costs no allocation. + */ + copyInput: boolean = true; reset( queryClient: QueryClient | undefined, @@ -86,6 +94,7 @@ export class ParseContext { this.warn = warn; this.isPartialEvent = isPartialEvent; this.trusted = undefined; + this.copyInput = true; if (queryClient !== undefined) { if (this.seen === undefined) { this.seen = new Map(); @@ -386,28 +395,36 @@ function parseUnionData( // ====================================================== function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseContext, path: string): unknown[] { - const result: unknown[] = []; + // Without `ctx.copyInput`, an array whose items all parse to themselves is + // returned as is; the copy starts at the first item that differs or fails. + // An empty one is always a new array: a live array grows the one it holds. + let result: unknown[] | undefined = ctx.copyInput || array.length === 0 ? [] : undefined; for (let i = 0; i < array.length; i++) { + const item = array[i]; try { - result.push(parseData(array[i], itemShape as unknown as TypeDef, ctx, `${path}[${i}]`)); + const parsed = parseData(item, itemShape as unknown as TypeDef, ctx, `${path}[${i}]`); + if (result !== undefined) result.push(parsed); + else if (parsed !== item) (result = array.slice(0, i)).push(parsed); } catch (e) { if (e instanceof CachedEntityMismatchError) throw e; + result ??= array.slice(0, i); ctx.warn('Failed to parse array item, filtering out', { index: i, - value: array[i], + value: item, error: e instanceof Error ? e.message : String(e), }); } } - return result; + return result ?? array; } -// Both walkers parse into a shallow copy of their input, like `parseArrayData` -// builds a new array. The input belongs to whoever handed it in (an adapter, -// an event payload, a consumer's snapshot — frozen in dev builds) and must not -// be written to; the copy keeps every key the input had, as before. +// Neither walker writes into its input. With `ctx.copyInput` they parse into +// a shallow copy, as `parseArrayData` builds a new array; without it the copy +// is made only once a parsed value differs from the input's, and input whose +// values all parse to themselves is returned as is. Either way the result +// keeps every key the input had, as before. function parseRecordData( record: Record, @@ -415,12 +432,14 @@ function parseRecordData( ctx: ParseContext, path: string, ): Record { - const result: Record = { ...record }; + let result: Record | undefined = ctx.copyInput ? { ...record } : undefined; for (const [key, value] of entries(record)) { - result[key] = parseData(value, valueShape as unknown as TypeDef, ctx, `${path}["${key}"]`); + const parsed = parseData(value, valueShape as unknown as TypeDef, ctx, `${path}["${key}"]`); + if (result !== undefined) result[key] = parsed; + else if (parsed !== value) (result = { ...record })[key] = parsed; } - return result; + return result ?? record; } function parseObjectData( @@ -434,13 +453,17 @@ function parseObjectData( } const shape = objectShape.shape; - const result: Record = { ...obj }; + let result: Record | undefined = ctx.copyInput ? { ...obj } : undefined; for (const [key, propShape] of entries(shape)) { - result[key] = parseData(obj[key], propShape as unknown as TypeDef, ctx, `${path}.${key}`); + const value = obj[key]; + const parsed = parseData(value, propShape as unknown as TypeDef, ctx, `${path}.${key}`); + if (result !== undefined) result[key] = parsed; + // A shape key the input lacks is still set (to undefined), as a copy does. + else if (parsed !== value || (value === undefined && !(key in obj))) (result = { ...obj })[key] = parsed; } - return result; + return result ?? obj; } // ====================================================== From e583e75fd2e6d9e8f5a78dbcf2d4e0d5757594ce Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:27:11 -0400 Subject: [PATCH 038/109] chore(fetchium): major changeset listing the behavior changes an upgrade brings Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/upgrade-behavior-changes.md | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/upgrade-behavior-changes.md diff --git a/.changeset/upgrade-behavior-changes.md b/.changeset/upgrade-behavior-changes.md new file mode 100644 index 0000000..0ca94b7 --- /dev/null +++ b/.changeset/upgrade-behavior-changes.md @@ -0,0 +1,32 @@ +--- +'fetchium': major +--- + +Upgrade notes: behavior an app can observe without using any new API. Each change has its own entry. + +Rendering + +- With `SyncQueryStore`, a cached query renders its data on the first render, with no loading frame. Tests that wait for a loading state or count renders may need updating. +- A refetch, poll or streamed update with identical data does not re-render. `isPending` and `isFetching` still change. +- `t.record` and `t.result` fields apply updates; a record field used to keep its old value. +- List snapshots keep each row's identity when the list is inserted into or re-sorted. +- A nested `t.object` or `t.record` value, or a live collection's value, is a new object after an update changes it, and keeps its identity otherwise. +- Development builds freeze query results, so mutating one throws a `TypeError`. Treat production results as read-only too. + +Fetching + +- The first fetch, and a refetch without `debounce`, start on a microtask instead of after `setTimeout(0)`. +- On resume, a subscription is restored before the request is sent, so a socket that replays a cached value no longer overwrites the fresh response. +- Changing a Signal param mid-fetch aborts that fetch and fetches the new params. A late response for the old params is dropped. +- A query unmounted mid-fetch refetches as soon as it mounts again, without showing the `AbortError`. +- `retry: {}` and `retry: { retryDelay }` retry like `retry: true`. A `shouldRetry` hook can stop retrying permanent errors. +- `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. Other keys still reach queries as `this.context`. +- `QueryClient.destroy()` aborts every fetch in flight (whoever awaits one gets an `AbortError`), and nothing is written to the store after it. + +Cache and store + +- A cached entry that can't be applied is treated as a miss and fetched, even within `staleTime`. +- A query whose result is not an entity caches each params key separately. Caches from earlier versions are corrected as each key is fetched again. +- Streamed events persist the entities they change. +- `QueryStore` gains optional `mergeEntity`, `onDelete`, `onPersisted`, `isSettled`, `hasQueuedDeletes` and `hasEntity`. A custom store without them writes as before. A store write that throws is retried. +- `AsyncQueryStore` honors `cache.maxCount`, and both built-in stores resize their LRU queue when it changes. From 48971039af5413373b70ef5fc98e5b20f9bf5f56 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:27:11 -0400 Subject: [PATCH 039/109] fix(fetchium): keep merging an event-built entity whose events carried every field of its class Two entity classes can share a typename: a list class, and a detail class that declares more fields. When only the list class was registered in a session, a streamed update for an entity not in memory was parsed with the list class alone. The event carried every field of that class, so the entity was marked whole and written with saveEntity, which replaced its record and deleted the detail class's fields. At the next launch the detail's cached query could not hydrate and waited for the network. The same happened to an entity an event created under a root in memory. Covering every field of a class does not show that the event covers the record: another class, possibly one not registered this session, may have written more fields to it. An event-built entity now stays partial and is merged into its record. It still becomes whole when the store holds no record of it at its first write. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-write-and-snapshot-fixes.md | 2 +- .../src/__tests__/event-persistence.test.ts | 136 ++++++++++++++++++ packages/fetchium/src/applyEntities.ts | 27 ++-- 3 files changed, 147 insertions(+), 18 deletions(-) diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index 8d3fca3..a45376b 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -7,7 +7,7 @@ Fixes for the entity snapshot fast path, unchanged-apply skipping and single-wri - Development builds hand out frozen snapshots, so a consumer that mutates one (a `sort()` in render, an assignment) fails at its own call site with a TypeError. Production snapshots stay mutable; treat them as read-only. The parser no longer writes into the objects it is given, so a snapshot (or part of one) handed back through a mutation event or a mutation's `effects` is read, not mutated, and two events sharing one nested object no longer make two entities share it. Event payloads are parsed into copies; a fetch result or a cached record is copied only where a parsed value differs from it, so an unchanged nested object, record or array costs no allocation, and a later merge into one the adapter froze goes into a copy. - The dev-only stale-snapshot check no longer throws from inside a snapshot. A throw there escaped Signalium's watcher flush and stopped every consumer on the page from updating. It now serves the re-read value and raises the error out of band, once per field: through the client's `log.error` and then as an uncaught error (a red box in React Native, an uncaught error in the browser, a failed run under vitest). It compares with `Object.is` (so a `NaN` field no longer trips it, and `-0` over `0` is applied at every level), compares a rebuilt `Set` in linear time, and restores the key set of a snapshot a consumer altered. - Hydrating a cached query no longer re-parses an entity that is already in memory. The re-parse ran already-parsed values through the parser again in place, wiping nested optional formats and parse results, and since the apply then saw no change it never notified; consumers of the first query kept stale snapshots. The in-memory data is used as-is once it structurally satisfies the query's shape, nested objects, arrays, records, unions and child entities included (members an array narrows out on read, and children that have left memory, are not held against it); otherwise the whole cached query is dropped and fetched, never served with an item missing. An entity built from streamed events is completed from its record instead. -- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). Once its fields are known to be the whole record (the store held no record of it when it was first written, or its events carried every field of its shape), it is written whole like any other entity, without reading the record back. An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. The store is asked only for a created root no live array retains, once per event: each such event costs one `hasEntity` call (a `kv.has` with `SyncQueryStore`). +- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). Once its fields are known to be the whole record (the store held no record of it when it was first written), it is written whole like any other entity, without reading the record back. An event that carries every field of the entity's class does not make it whole: another class sharing the typename, possibly not registered this session, may have written fields to the record that this class does not declare, and a merge keeps them. An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. The store is asked only for a created root no live array retains, once per event: each such event costs one `hasEntity` call (a `kv.has` with `SyncQueryStore`). - An entity that appears more than once in one payload (an author listed under `author` and again under `mentions`) is applied once: it keeps its child references and is written once. - `_persisted` is only set once the store write succeeded, so a write that threw is retried by the next apply. `AsyncQueryStore` writers acknowledge processed entity writes (`onPersisted`), report a failed one, answer `hasEntity` from the keys they read from the delegate at start (alongside the queue, so writes do not wait for it; a queued write counts as held) and keep current, run `purgeStaleQueries` through the same serial queue as the writes (a reader with its own delegate still purges directly), ignore anything on the channel that is not one of their messages, and expose `isSettled()` and `hasQueuedDeletes()`; the client counts its own outstanding writes, so an acknowledgement of a write another instance queued does not mark a hydrated instance current. It does not skip a write while the writer has an operation queued that may delete the record the skip relies on (anything but the client's own entity writes); an entity whose own write is still queued counts as written, and an entity that one of the client's queued writes stops referencing is written again if it is referenced again before that write lands. - `AsyncQueryStore` resizes a persisted LRU queue by copying it (growing no longer throws `RangeError`, shrinking evicts the dropped keys instead of stranding them, never the key being activated), and keeps a queue's persisted size for a message without `maxCount` from an older reader. `SyncQueryStore` gets the same resize. diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index ef429cc..cc771de 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -1755,3 +1755,139 @@ describe('AsyncQueryStore writer: write skipping while writes are queued', () => expect(asyncDoc(delegate, K('Card', 'c1'))).toMatchObject({ title: 'Ace' }); }); }); + +// ====================================================== +// A typename two classes share, across sessions +// ====================================================== + +describe('an event for a typename whose other class is not registered this session', () => { + class Tag extends Entity { + __typename = t.typename('Tag'); + id = t.id; + label = t.string; + } + // A list's class and a detail class of one typename: a list-shaped event + // carries every field of the list's class, but not the detail's. + class CoinRow extends Entity { + __typename = t.typename('Coin'); + id = t.id; + symbol = t.string; + price = t.number; + } + class CoinDetail extends Entity { + __typename = t.typename('Coin'); + id = t.id; + symbol = t.string; + price = t.number; + about = t.string; + tag = t.entity(Tag); + } + class Market extends Entity { + __typename = t.typename('Market'); + id = t.id; + top = t.entity(CoinRow); + } + class GetCoinDetail extends RESTQuery { + path = '/coin'; + result = { coin: t.entity(CoinDetail) }; + } + class GetCoinRows extends RESTQuery { + path = '/coins'; + result = { coins: t.array(t.entity(CoinRow)) }; + } + class GetMarket extends RESTQuery { + path = '/market'; + result = { market: t.entity(Market) }; + } + + const clients: QueryClient[] = []; + afterEach(() => { + for (const client of clients.splice(0)) client.destroy(); + }); + + const detailCoin = { + __typename: 'Coin', + id: 'c1', + symbol: 'C', + price: 1, + about: 'cached', + tag: { __typename: 'Tag', id: 'g1', label: 'gold' }, + }; + const row = (id: string, price: number) => ({ __typename: 'Coin', id, symbol: 'C', price }); + const detailRecord = (price: number) => ({ + __typename: 'Coin', + id: 'c1', + symbol: 'C', + price, + about: 'cached', + tag: { __entityRef: K('Tag', 'g1') }, + }); + + function session(kv: MemoryPersistentStore, mockFetch: ReturnType): QueryClient { + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as never, baseUrl: 'http://localhost' })], + } as never); + clients.push(client); + return client; + } + + /** A first session caches the detail of c1, then ends. */ + async function cacheDetail(kv: MemoryPersistentStore): Promise> { + const mockFetch = createMockFetch(); + mockFetch.get('/coin', { coin: detailCoin }); + const first = session(kv, mockFetch); + await holdQuery(first, () => fetchQuery(GetCoinDetail)); + first.destroy(); + return mockFetch; + } + + /** The next session's detail query, served from the cache before its slow fetch lands. */ + async function detailAtNextStart( + kv: MemoryPersistentStore, + mockFetch: ReturnType, + ): Promise { + mockFetch.get('/coin', { coin: { ...detailCoin, about: 'network' } }, { delay: 200 }); + const next = session(kv, mockFetch); + const detailQ = holdQuery(next, () => fetchQuery(GetCoinDetail)); + await sleep(20); + return detailQ.isReady ? (detailQ.value as any).coin.about : 'not ready'; + } + + it('an update for an entity not in memory merges into its record instead of replacing it', async () => { + const kv = new MemoryPersistentStore(); + const mockFetch = await cacheDetail(kv); + + // Only the list's class is registered (a list of other coins); c1 is not in memory. + mockFetch.get('/coins', { coins: [row('c2', 2)] }); + const client = session(kv, mockFetch); + await holdQuery(client, () => fetchQuery(GetCoinRows)); + client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row('c1', 3) }); + + expect(getDoc(kv, K('Coin', 'c1'))).toEqual(detailRecord(3)); + expect(refIds(kv, K('Coin', 'c1'))).toEqual([K('Tag', 'g1')]); + client.destroy(); + expect(await detailAtNextStart(kv, mockFetch)).toBe('cached'); + }); + + it('an entity an event builds under a root in memory merges into its record too', async () => { + const kv = new MemoryPersistentStore(); + const mockFetch = await cacheDetail(kv); + + mockFetch.get('/market', { market: { __typename: 'Market', id: 'm1', top: row('c2', 2) } }); + const client = session(kv, mockFetch); + await holdQuery(client, () => fetchQuery(GetMarket)); + client.applyMutationEvent({ + type: 'update', + typename: 'Market', + data: { __typename: 'Market', id: 'm1', top: row('c1', 3) }, + }); + // A later event for the in-memory entity still merges. + client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row('c1', 4) }); + + expect(getDoc(kv, K('Coin', 'c1'))).toEqual(detailRecord(4)); + expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); + client.destroy(); + expect(await detailAtNextStart(kv, mockFetch)).toBe('cached'); + }); +}); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 695babf..f9722a5 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -188,12 +188,11 @@ function applyEntity( entityInstance._partial = false; entityInstance._partialKeys = undefined; } else if (entityInstance._partial && rawKeys !== undefined) { - const partialKeys = entityInstance._partialKeys!; - for (const k of rawKeys) partialKeys.add(k); - if (coversShape(partialKeys, shapeFields)) { - entityInstance._partial = false; - entityInstance._partialKeys = undefined; - } + // Stays partial even once the events have carried every field of + // this class: another class sharing the typename may have written + // fields to the record that this one does not declare, and only a + // merge keeps them. + for (const k of rawKeys) entityInstance._partialKeys!.add(k); } } else { initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode, created); @@ -206,11 +205,11 @@ function applyEntity( // event did (the parser fills the literal in). if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); - // Events that carried every field built a whole record. - if (coversShape(entityInstance._partialKeys, shapeFields)) { - entityInstance._partial = false; - entityInstance._partialKeys = undefined; - } + // An event that carried every field of this class is still partial: + // the record may hold fields another class sharing the typename + // declares (one not registered this session, too), so it is merged + // into, not replaced. With no record to merge into, the first write + // makes it whole (see `EntityInstance.save()`). } else if (persist === false) { // Hydrated from the store: its record exists. entityInstance._recorded = true; @@ -260,12 +259,6 @@ function applyEntity( return proxy; } -/** Whether `keys` names every field of `shape`. */ -function coversShape(keys: Set, shape: Record): boolean { - for (const k in shape) if (!keys.has(k)) return false; - return true; -} - // ====================================================== // Field merge (update path) — reify + merge in one loop // ====================================================== From bbc2772deb0ac2ca972bfcb4c8cc90cc43b398cb Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 22:12:21 -0400 Subject: [PATCH 040/109] fix(fetchium): don't turn a same-task unmount or an offline switch into a failed fetch The first fetch and zero-delay refetches now start on a microtask, ahead of Signalium's flush, and that flush is where a query whose last watcher left is deactivated and where a Signal param change reaches it. Three cases that a timer used to absorb now surface as errors: - A query mounted and unmounted in one task sent its first request, the deactivation aborted it and the relay settled with the AbortError, so the next mount showed an error. A first fetch started while that flush was outstanding is now left to finish, as the timer-started one did: the next mount shows its data. One `settled()` per task that starts a first fetch tracks the window; nothing is added per render or apply. - A Signal param change made in the task the last watcher leaves sent a request for the new params that the deactivation then aborted. The param-change refetch now waits for Signalium's outstanding flush (no extra task when the change arrives through that flush) and is cancelled by the deactivation like the timer was. - A stale reactivation in the task the network went offline rejected with "Query is paused due to network status". The deferred start, the zero-delay and debounced refetches and the deferred restart now return early unless the query is active and not paused; deactivate() also cancels a start that hasn't run. A relay that a deactivation's abort settled as rejected is restarted on the next activation, like a relay still pending without a controller, instead of showing the AbortError until a refetch lands. Its cached value is re-settled first, so the refetch doesn't carry the error. A caller awaiting the aborted fetch still settles with the AbortError. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 10 + packages/fetchium/src/QueryResult.ts | 114 ++++++++- .../src/__tests__/activation-timing.test.ts | 227 ++++++++++++++++++ 3 files changed, 343 insertions(+), 8 deletions(-) create mode 100644 .changeset/activation-timing-fixes.md create mode 100644 packages/fetchium/src/__tests__/activation-timing.test.ts diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md new file mode 100644 index 0000000..58b6459 --- /dev/null +++ b/.changeset/activation-timing-fixes.md @@ -0,0 +1,10 @@ +--- +'fetchium': patch +--- + +Fixes for the microtask first fetch and refetches introduced with synchronous hydration and fast delivery. + +- A query mounted and unmounted in the same task (a guard that redirects in a layout effect, a `replace` on mount, a route re-keyed in one commit) no longer leaves its relay rejected with an `AbortError` that the next mount shows. Its first request, which now starts before Signalium's deactivation flush, is left to finish in the background, as it did when the first fetch started on a timer, and the next mount shows its data. +- A Signal param change made in the same task the query's last watcher leaves no longer sends a request that the deactivation then aborts. The refetch for a param change starts once Signalium's flush has run, which is in the same task when the change arrives through that flush. +- A query that reactivates stale in the same task the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". The deferred refetch, a deferred restart and a debounced refetch recheck that the query is still active and not paused before they send. +- When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index be7c832..1504483 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -5,6 +5,7 @@ import { ReactivePromise, type ReadonlySignal, type DeactivateOptions, + settled, } from 'signalium'; import { NetworkMode, type QueryResult, type EntityDef } from './types.js'; import { @@ -27,6 +28,31 @@ function isThenable(value: MaybePromise): value is Promise { return typeof (value as { then?: unknown } | null | undefined)?.then === 'function'; } +/** + * The start window: open from the moment a query's first fetch starts on its + * microtask until the Signalium flush outstanding at that moment has run. A + * query mounted and unmounted in one task is deactivated by that flush, after + * its first fetch already went out; a fetch started inside the window is left + * to finish (as when the first fetch started on a timer, after the flush) + * rather than being aborted into an error. One window serves every first + * fetch started before it closes. + */ +let startWindow = 0; +let startWindowOpen = false; + +const closeStartWindow = (): void => { + startWindowOpen = false; +}; + +function openStartWindow(): number { + if (!startWindowOpen) { + startWindowOpen = true; + startWindow++; + void settled().then(closeStartWindow, closeStartWindow); + } + return startWindow; +} + /** * Thin fetch/relay orchestrator. Data management (proxy, notifier, child refs, * live data) is fully delegated to a root EntityInstance. @@ -63,6 +89,14 @@ export class QueryInstance { private startPending: boolean = false; /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ private pendingRestart: (() => void) | undefined = undefined; + /** The first fetch's controller and its start window. See `openStartWindow()`. */ + private firstFetchController: AbortController | undefined = undefined; + private firstFetchWindow: number = 0; + /** + * A deactivation aborted the fetch in flight, so the relay settles with an + * AbortError. The next activation refetches rather than showing it. + */ + private abortedByDeactivation: boolean = false; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; /** @@ -158,9 +192,27 @@ export class QueryInstance { this._isActive = false; this.cancelDebounced(); - - this._abortController?.abort(); - this._abortController = undefined; + // initialize()'s start, if it hasn't run: the next activation restarts it. + this.startPending = false; + + const controller = this._abortController; + if (controller !== undefined && !controller.signal.aborted) { + const keepFirstFetch = + !isPausing && + controller === this.firstFetchController && + startWindowOpen && + this.firstFetchWindow === startWindow; + if (keepFirstFetch) { + // Mounted and unmounted in one task: let the first fetch finish. + this.firstFetchController = undefined; + } else { + if (this.relayState.isPending) this.abortedByDeactivation = true; + controller.abort(); + this._abortController = undefined; + } + } else { + this._abortController = undefined; + } this._fetchNextAbort?.abort(); this._fetchNextAbort = undefined; @@ -211,8 +263,10 @@ export class QueryInstance { // previous fetch was aborted during deactivation (or never started). // runDebounced() would bail out because isPending is still true from // the doomed promise, which may also never settle (a topic query's - // send() waits on a subscription that is gone). - if (this.relayState.isPending && this._abortController === undefined) { + // send() waits on a subscription that is gone). A relay the + // deactivation's abort already rejected is restarted the same way, + // rather than showing the AbortError until a refetch lands. + if ((this.relayState.isPending && this._abortController === undefined) || this.abortedByDeactivation) { this.restartAbortedFetch(); } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; @@ -232,7 +286,7 @@ export class QueryInstance { // Force rebuild: the running subscriber captured the old params. this.lastSubscribeFn = undefined; this.reconcileSubscription(); - this.runDebounced(); + this.runDebounced(0, false, true); } }; @@ -349,7 +403,12 @@ export class QueryInstance { private runPendingStart = (): void => { if (!this.startPending) return; this.startPending = false; + const fetchesBefore = this.fetchStarts; this.startSubscriptionAndFetch(); + if (this.fetchStarts !== fetchesBefore) { + this.firstFetchController = this._abortController; + this.firstFetchWindow = openStartWindow(); + } }; /** @@ -506,6 +565,8 @@ export class QueryInstance { attempt.options, ); this.lastFetchFailed = false; + // An adapter that ignored the deactivation's abort delivered anyway. + this.abortedByDeactivation = false; return result; } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; @@ -521,6 +582,16 @@ export class QueryInstance { */ private restartAbortedFetch(): void { this.fetchStarts++; + if (this.abortedByDeactivation) { + this.abortedByDeactivation = false; + // The abort rejected the relay (it is no longer pending). Signalium + // keeps a rejected relay's error while it is pending again, so the + // refetch would show the AbortError next to the cached value. + // Re-settling with that value clears it. (With no value yet there is + // nothing to settle with.) + const value = this.relayState.value; + if (value !== undefined && !this.relayState.isPending) this.relayState.value = value; + } this._fetchNextAbort?.abort(); this._fetchNextAbort = undefined; this._fetchNextPromise = undefined; @@ -540,6 +611,12 @@ export class QueryInstance { reject(controller.signal.reason); return; } + if (!this._isActive || this.isPaused) { + // Went offline before it ran. Leave the relay pending with no + // controller: coming back online restarts it. + if (this._abortController === controller) this._abortController = undefined; + return; + } this.runQuery().then(resolve, reject); }; this.pendingRestart = run; @@ -548,8 +625,25 @@ export class QueryInstance { this.relayState.setPromise(promise); } + /** + * Queues a deferred fetch on a microtask, or with `afterFlush`, once + * Signalium's outstanding flush has run. A params change reaches update() + * from inside that flush, and the same flush may also deactivate the query + * (its last watcher left in the same task): waiting for it, which costs no + * extra task there, lets the deactivation cancel the fetch instead of + * aborting it after it went out. + */ + private deferRun(run: () => void, afterFlush: boolean): void { + if (afterFlush) { + void settled().then(run); + } else { + queueMicrotask(run); + } + } + private runQueryImmediately(): void { this.fetchStarts++; + this.abortedByDeactivation = false; this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); @@ -573,7 +667,7 @@ export class QueryInstance { * until Signalium's deactivation flush, and the timer lets that flush cancel * the fetch rather than start and abort it. */ - private runDebounced(extraDelay: number = 0, nextTask: boolean = false): void { + private runDebounced(extraDelay: number = 0, nextTask: boolean = false, afterFlush: boolean = false): void { if (this.relayState.isPending) return; const delay = (this.config?.debounce ?? 0) + extraDelay; @@ -583,6 +677,7 @@ export class QueryInstance { if (delay > 0 || nextTask) { this.debounceTimer = setTimeout(() => { this.debounceTimer = undefined; + if (!this._isActive || this.isPaused) return; this.runQueryImmediately(); }, delay); return; @@ -593,13 +688,16 @@ export class QueryInstance { if (generation !== this.debounceGeneration) return; this.debounceGeneration++; this.pendingDebouncedRun = undefined; + // Deactivated, or gone offline in the same task: the deactivation (or + // coming back online) decides what happens next. + if (!this._isActive || this.isPaused) return; // Another path started a fetch in the meantime (refetch(), activation). if (this.relayState.isPending && this._abortController !== undefined) return; this.runQueryImmediately(); }; this.pendingDebouncedRun = run; this.queryClient.noteDeferredStart(this); - queueMicrotask(run); + this.deferRun(run, afterFlush); } /** Cancels a refetch scheduled by runDebounced(). */ diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts new file mode 100644 index 0000000..5cb7512 --- /dev/null +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect } from 'vitest'; +import { signal, watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; +import { NetworkManager } from '../NetworkManager.js'; +import { t } from '../typeDefs.js'; +import { fetchQuery, queryKeyForClass } from '../query.js'; +import { updatedAtKeyFor } from '../stores/shared.js'; +import { sleep } from './utils.js'; + +/** + * The first fetch and zero-delay refetches start on a microtask, ahead of + * Signalium's flush. That flush is where a Signal param change reaches the + * query and where a query whose last watcher left is deactivated. These tests + * pin what happens when either lands in the same task as the fetch was queued. + */ + +interface Call { + path: string; + aborted: boolean; +} + +/** + * A fetch that answers `{ value: }` after `delay` ms. By default it + * honours the abort signal, as browser and React Native fetch do. + */ +function createFetch(delay: number, { ignoreAbort = false }: { ignoreAbort?: boolean } = {}) { + const calls: Call[] = []; + const fetch = (url: string, options: RequestInit = {}): Promise => { + const path = new URL(url).pathname; + const call: Call = { path, aborted: false }; + calls.push(call); + return new Promise((resolve, reject) => { + const signal = options.signal as AbortSignal | undefined; + const onAbort = () => { + call.aborted = true; + if (ignoreAbort) return; + clearTimeout(timer); + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + const body = { value: path }; + resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + }, delay); + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + }); + }; + return { fetch, calls, paths: () => calls.map(c => c.path + (c.aborted ? '(aborted)' : '')) }; +} + +function makeClient( + kv: MemoryPersistentStore, + fetch: (url: string, options?: RequestInit) => Promise, + networkManager?: NetworkManager, +): QueryClient { + return new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: fetch as any, baseUrl: 'http://localhost' })], + networkManager, + }); +} + +/** Watches `read` the way a render read does, and returns a disposer. */ +function activate(client: QueryClient, read: () => unknown): () => void { + const w = withContexts([[QueryClientContext, client]], () => watcher(read)); + const dispose = w.addListener(() => {}); + void w.value; + return dispose; +} + +function state(relay: any) { + return { + isPending: relay.isPending as boolean, + isRejected: relay.isRejected as boolean, + value: relay.value?.value as string | undefined, + }; +} + +class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; +} + +class GetItemById extends RESTQuery { + params = { id: t.string }; + path = `/items/${this.params.id}`; + result = { value: t.string }; +} + +function persisted(kv: MemoryPersistentStore, params: { id: string }): boolean { + return kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItemById, params))) !== undefined; +} + +describe('Activation and deactivation in one task', () => { + it('lets the first fetch finish when the query is mounted and unmounted in one task', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(10); + const client = makeClient(kv, f.fetch); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + dispose(); + await sleep(40); + + expect(f.paths()).toEqual(['/item']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); + expect(kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItem, undefined)))).toBeTypeOf('number'); + + // Coming back shows the data the background request brought in. + let first: any; + const again = activate(client, () => (first ??= state(fetchQuery(GetItem)))); + expect(first).toEqual({ isPending: false, isRejected: false, value: '/item' }); + again(); + client.destroy(); + }); + + it('still aborts a first fetch the query leaves in a later task', async () => { + const f = createFetch(30); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + + const dispose = activate(client, () => fetchQuery(GetItem).isPending); + await sleep(5); + dispose(); + await sleep(10); + + expect(f.paths()).toEqual(['/item(aborted)']); + client.destroy(); + }); + + it('sends no request for a Signal param change made in the task the last watcher leaves', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(5); + const client = makeClient(kv, f.fetch); + const id = signal('1'); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetItemById, { id })).isPending); + await sleep(20); + id.value = '2'; + dispose(); + await sleep(20); + + expect(f.paths()).toEqual(['/items/1']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/1' }); + expect(persisted(kv, { id: '2' })).toBe(false); + + // Coming back fetches the current params. + const again = activate(client, () => fetchQuery(GetItemById, { id }).isPending); + await sleep(20); + expect(f.paths()).toEqual(['/items/1', '/items/2']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + again(); + client.destroy(); + }); + + it('a stale reactivation in the task the network goes offline waits for the network, without an error', async () => { + const f = createFetch(5); + const networkManager = new NetworkManager(true); + const client = makeClient(new MemoryPersistentStore(), f.fetch, networkManager); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + await sleep(20); + first(); + await sleep(5); + + const second = activate(client, () => fetchQuery(GetItem).isPending); + networkManager.setNetworkStatus(false); + await sleep(20); + expect(f.calls).toHaveLength(1); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); + + networkManager.setNetworkStatus(true); + await sleep(20); + expect(f.calls).toHaveLength(2); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); + second(); + client.destroy(); + }); +}); + +describe('Reactivating after a deactivation aborted the fetch', () => { + it('refetches without showing the AbortError next to the cached value', async () => { + class GetStaleItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: 0 }; + } + const f = createFetch(20); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetStaleItem)).isPending); + await sleep(40); + const refetched = relay.value.__refetch().then( + () => 'resolved', + (error: Error) => `rejected:${error.name}`, + ); + await sleep(5); + first(); + // A caller awaiting the refetch still settles. + expect(await refetched).toBe('rejected:AbortError'); + expect(state(relay)).toEqual({ isPending: false, isRejected: true, value: '/item' }); + + const reads: ReturnType[] = []; + const second = activate(client, () => reads.push(state(fetchQuery(GetStaleItem)))); + expect(reads[0]).toEqual({ isPending: true, isRejected: false, value: '/item' }); + await sleep(40); + expect(reads.some(r => r.isRejected)).toBe(false); + expect(f.paths()).toEqual(['/item', '/item(aborted)', '/item']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); + second(); + client.destroy(); + }); +}); From 94a754ef33a8c08a099f0699d396cc825dd8b20c Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:27:11 -0400 Subject: [PATCH 041/109] chore(fetchium): shorten this change's changesets for app developers Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-write-and-snapshot-fixes.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index a45376b..7a268ec 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -2,16 +2,13 @@ 'fetchium': patch --- -Fixes for the entity snapshot fast path, unchanged-apply skipping and single-write events introduced in 0.6.0. +Fixes for snapshots, unchanged-data skipping and streamed-event writes. -- Development builds hand out frozen snapshots, so a consumer that mutates one (a `sort()` in render, an assignment) fails at its own call site with a TypeError. Production snapshots stay mutable; treat them as read-only. The parser no longer writes into the objects it is given, so a snapshot (or part of one) handed back through a mutation event or a mutation's `effects` is read, not mutated, and two events sharing one nested object no longer make two entities share it. Event payloads are parsed into copies; a fetch result or a cached record is copied only where a parsed value differs from it, so an unchanged nested object, record or array costs no allocation, and a later merge into one the adapter froze goes into a copy. -- The dev-only stale-snapshot check no longer throws from inside a snapshot. A throw there escaped Signalium's watcher flush and stopped every consumer on the page from updating. It now serves the re-read value and raises the error out of band, once per field: through the client's `log.error` and then as an uncaught error (a red box in React Native, an uncaught error in the browser, a failed run under vitest). It compares with `Object.is` (so a `NaN` field no longer trips it, and `-0` over `0` is applied at every level), compares a rebuilt `Set` in linear time, and restores the key set of a snapshot a consumer altered. -- Hydrating a cached query no longer re-parses an entity that is already in memory. The re-parse ran already-parsed values through the parser again in place, wiping nested optional formats and parse results, and since the apply then saw no change it never notified; consumers of the first query kept stale snapshots. The in-memory data is used as-is once it structurally satisfies the query's shape, nested objects, arrays, records, unions and child entities included (members an array narrows out on read, and children that have left memory, are not held against it); otherwise the whole cached query is dropped and fetched, never served with an item missing. An entity built from streamed events is completed from its record instead. -- A streamed `create` (or an `update` for an entity not in memory) now writes the existing entities its payload changed, and the entities it created only once a written record references them: the root a live array retains is written before the event is routed (a write that fails is logged and nothing is routed, as a failed event always behaved), the rest follow from the records that reference them, and what nothing references is evicted at once, `gcTime` or not. A record's write puts its missing children first; a payload that links back to an ancestor defers both writes to the end of the apply. An entity built from events remembers the fields its events carried: its writes go through the store's new `mergeEntity`, which merges those fields over the record on disk, so the fields the events did not carry survive (a store without `mergeEntity` gets the whole in-memory data through `saveEntity`, as before 0.6). Once its fields are known to be the whole record (the store held no record of it when it was first written), it is written whole like any other entity, without reading the record back. An event that carries every field of the entity's class does not make it whole: another class sharing the typename, possibly not registered this session, may have written fields to the record that this class does not declare, and a merge keeps them. An event for an entity the store holds a record of (`hasEntity`) but that is not in memory refreshes that record the same way; a store that cannot say what it holds is not written, so no record nothing references is left behind. The store is asked only for a created root no live array retains, once per event: each such event costs one `hasEntity` call (a `kv.has` with `SyncQueryStore`). -- An entity that appears more than once in one payload (an author listed under `author` and again under `mentions`) is applied once: it keeps its child references and is written once. -- `_persisted` is only set once the store write succeeded, so a write that threw is retried by the next apply. `AsyncQueryStore` writers acknowledge processed entity writes (`onPersisted`), report a failed one, answer `hasEntity` from the keys they read from the delegate at start (alongside the queue, so writes do not wait for it; a queued write counts as held) and keep current, run `purgeStaleQueries` through the same serial queue as the writes (a reader with its own delegate still purges directly), ignore anything on the channel that is not one of their messages, and expose `isSettled()` and `hasQueuedDeletes()`; the client counts its own outstanding writes, so an acknowledgement of a write another instance queued does not mark a hydrated instance current. It does not skip a write while the writer has an operation queued that may delete the record the skip relies on (anything but the client's own entity writes); an entity whose own write is still queued counts as written, and an entity that one of the client's queued writes stops referencing is written again if it is referenced again before that write lands. -- `AsyncQueryStore` resizes a persisted LRU queue by copying it (growing no longer throws `RangeError`, shrinking evicts the dropped keys instead of stranding them, never the key being activated), and keeps a queue's persisted size for a message without `maxCount` from an older reader. `SyncQueryStore` gets the same resize. -- `onDelete` returns an unsubscribe and `QueryClient.destroy()` calls it, so a store no longer retains every client created against it. `SyncQueryStore` shares its delete listeners between every instance over the same backing kv. -- Reading a shared-typename entity array consumes the notifiers of the members it currently leaves out, so a reactive consumer learns when a member becomes eligible without a data change, and a consumer that only reads the array is not recomputed when a member it holds changes. -- Two classes sharing a typename: fields that are entity-free in one class but hold an entity through the other are no longer classified static for the snapshot fast path. -- A query whose cached value was rejected fetches even within `staleTime`; a query whose cached value was rejected fetches even within `staleTime`; snapshots of proxies created by a previous (hot-reloaded) module instance are walked instead of returned as-is. +- Development builds freeze snapshots, so mutating a query result (a `sort()` in render, an assignment) throws a `TypeError` where it happens. Production snapshots are not frozen; treat them as read-only. Fetchium no longer mutates objects you pass it, such as a snapshot handed back in a mutation's `effects`. +- In development, the stale-snapshot check reports through the client's `log.error` and an uncaught error (a red box on React Native) instead of throwing during a snapshot, which stopped every other consumer from updating. +- Mounting a second query whose entities are already in memory no longer leaves the first query's consumers with stale data. The in-memory data is used when it fits the new query's shape; otherwise the cached query is dropped and fetched instead of rendering with an item missing. +- Streamed events persist every entity they change, including existing entities inside a `create` payload and stored entities that are not in memory. An entity built from events is merged into its stored record (new optional `QueryStore.mergeEntity`), so the fields its events did not carry survive a restart. +- An entity that appears more than once in one response is applied and written once. +- A store write that throws is retried by the next apply. `AsyncQueryStore` reports finished writes back to the client, and both built-in stores resize their LRU queue when `cache.maxCount` changes. +- `QueryClient.destroy()` unsubscribes from the store, so a store no longer keeps every client created against it alive. +- A query whose cached value was rejected fetches even within `staleTime`. Snapshots work for entities created before a hot reload. From 8bf023e301fc086ded95bdaa261a96a1426643ac Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 22:12:48 -0400 Subject: [PATCH 042/109] fix(fetchium): fetch and store the current params when a Signal param changes around a fetch A Signal param set in the same task as the query activates reaches update() only in Signalium's next flush, but the first fetch now starts on the microtask before it. It sent the old params, and when the flush applied the change, runDebounced() bailed because the relay was pending: the old params' response was shown as the new params' value and persisted under the new key, so a cold start served it again. A change while the fetch is in flight did the same in every earlier version. - The deferred first start re-reads Signal params (only for queries that have one) and sends the current ones. - A param change while a fetch is in flight, or queued, aborts it and restarts with the new params: the relay takes the new promise at once, and the request starts after Signalium's outstanding flush, outside update(). Reactivating with changed params while a fetch is in flight restarts the same way. - runQuery() remembers the storage key it started with and drops a response for a key that is no longer current (an adapter that ignored the abort) instead of applying and persisting it. - A param change resets the query's timestamp the way invalidation does, so the old params' fresh timestamp can't skip the new params' fetch. The only extra request is the one replacing a fetch for params that are no longer current. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 2 + packages/fetchium/src/QueryResult.ts | 67 ++++++++++++-- .../src/__tests__/activation-timing.test.ts | 91 +++++++++++++++++++ 3 files changed, 152 insertions(+), 8 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 58b6459..01f0ebc 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -8,3 +8,5 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - A Signal param change made in the same task the query's last watcher leaves no longer sends a request that the deactivation then aborts. The refetch for a param change starts once Signalium's flush has run, which is in the same task when the change arrives through that flush. - A query that reactivates stale in the same task the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". The deferred refetch, a deferred restart and a debounced refetch recheck that the query is still active and not paused before they send. - When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. +- A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. +- A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 1504483..fee7e63 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -6,6 +6,7 @@ import { type ReadonlySignal, type DeactivateOptions, settled, + isSignal, } from 'signalium'; import { NetworkMode, type QueryResult, type EntityDef } from './types.js'; import { @@ -28,6 +29,12 @@ function isThenable(value: MaybePromise): value is Promise { return typeof (value as { then?: unknown } | null | undefined)?.then === 'function'; } +function abortError(): Error { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + return error; +} + /** * The start window: open from the moment a query's first fetch starts on its * microtask until the Signalium flush outstanding at that moment has run. A @@ -89,6 +96,8 @@ export class QueryInstance { private startPending: boolean = false; /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ private pendingRestart: (() => void) | undefined = undefined; + /** Some param is a Signal, so its value can change before the first start runs. */ + private hasSignalParams: boolean = false; /** The first fetch's controller and its start window. See `openStartWindow()`. */ private firstFetchController: AbortController | undefined = undefined; private firstFetchWindow: number = 0; @@ -180,6 +189,14 @@ export class QueryInstance { // Compute the query id used for QUERY_ID injection on non-entity results. const extractedParams = extractParamsForKey(params); this._queryId = extractedParams !== undefined ? hashValue(extractedParams) : 0; + if (params !== undefined) { + for (const key in params) { + if (isSignal(params[key])) { + this.hasSignalParams = true; + break; + } + } + } // Create the relay whose value is the root entity's proxy (stable identity) this.relay = relay>( @@ -243,8 +260,7 @@ export class QueryInstance { const paramsDidChange = newStorageKey !== this.storageKey; if (paramsDidChange) { - this.currentParams = newExtractedParams as QueryParams; - this.storageKey = newStorageKey; + this.adoptParams(newExtractedParams, newStorageKey); } this.getOrCreateExecutionContext(); @@ -265,8 +281,12 @@ export class QueryInstance { // the doomed promise, which may also never settle (a topic query's // send() waits on a subscription that is gone). A relay the // deactivation's abort already rejected is restarted the same way, - // rather than showing the AbortError until a refetch lands. - if ((this.relayState.isPending && this._abortController === undefined) || this.abortedByDeactivation) { + // rather than showing the AbortError until a refetch lands, and so + // is a fetch still in flight for params that changed meanwhile. + if ( + (this.relayState.isPending && (this._abortController === undefined || paramsDidChange)) || + this.abortedByDeactivation + ) { this.restartAbortedFetch(); } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; @@ -286,7 +306,14 @@ export class QueryInstance { // Force rebuild: the running subscriber captured the old params. this.lastSubscribeFn = undefined; this.reconcileSubscription(); - this.runDebounced(0, false, true); + if (this.startPending) { + // The first start hasn't run yet; it sends the new params. + } else if (this.relayState.isPending) { + // A fetch for the old params is in flight (or queued): replace it. + this.restartAbortedFetch(true); + } else { + this.runDebounced(0, false, true); + } } }; @@ -403,6 +430,16 @@ export class QueryInstance { private runPendingStart = (): void => { if (!this.startPending) return; this.startPending = false; + if (this.hasSignalParams) { + // A Signal param set in the same task as the activation reaches update() + // only in Signalium's next flush. Send the params as they are now. + const extractedParams = extractParamsForKey(this.params); + const storageKey = queryKeyFor(this.def, extractedParams); + if (storageKey !== this.storageKey) { + this.adoptParams(extractedParams, storageKey); + this.getOrCreateExecutionContext(); + } + } const fetchesBefore = this.fetchStarts; this.startSubscriptionAndFetch(); if (this.fetchStarts !== fetchesBefore) { @@ -411,6 +448,14 @@ export class QueryInstance { } }; + /** Switches to new params. Data shown until the next fetch lands is the old params'. */ + private adoptParams(extractedParams: Record | undefined, storageKey: number): void { + this.currentParams = extractedParams as QueryParams; + this.storageKey = storageKey; + // The timestamp belongs to the old params' data: refetch, as after invalidation. + if (this.updatedAt !== undefined) this.updatedAt = 0; + } + /** * Runs, now, the start initialize() or a zero-delay runDebounced() left for * a microtask, if it hasn't run yet. The microtask then does nothing. Called @@ -541,6 +586,7 @@ export class QueryInstance { const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; const attempt = this.attemptStatusTracker(ctx); + const storageKey = this.storageKey; try { const result = await withRetry( @@ -548,6 +594,9 @@ export class QueryInstance { attempt.start(); try { const freshData = await adapter.send(ctx, signal); + // The params changed while the request was in flight (an adapter + // that ignores the abort): its data belongs to the old params. + if (this.storageKey !== storageKey) throw abortError(); this.updatedAt = Date.now(); const result = this.applyData(freshData, true); @@ -580,7 +629,7 @@ export class QueryInstance { * settle it, but the subscription and the request start on a microtask (or * when a lease starts them), outside the read: both run adapter code. */ - private restartAbortedFetch(): void { + private restartAbortedFetch(afterFlush: boolean = false): void { this.fetchStarts++; if (this.abortedByDeactivation) { this.abortedByDeactivation = false; @@ -592,6 +641,8 @@ export class QueryInstance { const value = this.relayState.value; if (value !== undefined && !this.relayState.isPending) this.relayState.value = value; } + this.cancelDebounced(); + this._abortController?.abort(); this._fetchNextAbort?.abort(); this._fetchNextAbort = undefined; this._fetchNextPromise = undefined; @@ -608,7 +659,7 @@ export class QueryInstance { if (this.pendingRestart !== run) return; this.pendingRestart = undefined; if (controller.signal.aborted) { - reject(controller.signal.reason); + reject(controller.signal.reason ?? abortError()); return; } if (!this._isActive || this.isPaused) { @@ -621,7 +672,7 @@ export class QueryInstance { }; this.pendingRestart = run; this.queryClient.noteDeferredStart(this); - queueMicrotask(run); + this.deferRun(run, afterFlush); this.relayState.setPromise(promise); } diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index 5cb7512..fab5054 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -191,6 +191,97 @@ describe('Activation and deactivation in one task', () => { }); }); +describe('Signal params changing around a fetch', () => { + it('sends the current params when a Signal param changes in the task the query activates', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(5); + const client = makeClient(kv, f.fetch); + const id = signal('1'); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetItemById, { id })).isPending); + id.value = '2'; + await sleep(30); + + expect(f.paths()).toEqual(['/items/2']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + expect(persisted(kv, { id: '1' })).toBe(false); + expect(persisted(kv, { id: '2' })).toBe(true); + dispose(); + client.destroy(); + }); + + it('replaces a fetch in flight for the old params', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(15); + const client = makeClient(kv, f.fetch); + const id = signal('1'); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetItemById, { id })).isPending); + await sleep(5); + id.value = '2'; + await sleep(50); + + expect(f.paths()).toEqual(['/items/1(aborted)', '/items/2']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + expect(persisted(kv, { id: '1' })).toBe(false); + expect(persisted(kv, { id: '2' })).toBe(true); + dispose(); + client.destroy(); + }); + + it('never applies or persists an old-params response from an adapter that ignores the abort', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(15, { ignoreAbort: true }); + const client = makeClient(kv, f.fetch); + const id = signal('1'); + + let relay: any; + const values: (string | undefined)[] = []; + const dispose = activate(client, () => { + relay = fetchQuery(GetItemById, { id }); + values.push(relay.value?.value); + }); + await sleep(5); + id.value = '2'; + await sleep(50); + + expect(f.calls.map(c => c.path)).toEqual(['/items/1', '/items/2']); + expect(values).not.toContain('/items/1'); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + expect(persisted(kv, { id: '1' })).toBe(false); + dispose(); + client.destroy(); + }); + + it('refetches on reactivation when a Signal param changed while the query was inactive, even if fresh', async () => { + class GetFreshItem extends RESTQuery { + params = { id: t.string }; + path = `/items/${this.params.id}`; + result = { value: t.string }; + config = { staleTime: 60_000 }; + } + const f = createFetch(5); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + const id = signal('1'); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetFreshItem, { id })).isPending); + await sleep(20); + first(); + await sleep(5); + id.value = '2'; + + const second = activate(client, () => fetchQuery(GetFreshItem, { id }).isPending); + await sleep(20); + expect(f.paths()).toEqual(['/items/1', '/items/2']); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + second(); + client.destroy(); + }); +}); + describe('Reactivating after a deactivation aborted the fetch', () => { it('refetches without showing the AbortError next to the cached value', async () => { class GetStaleItem extends RESTQuery { From 8be1a63b9673cc2ea12b1bd29886713dced00e1a Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:49 -0400 Subject: [PATCH 043/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/skip-write-and-snapshot-fixes.md | 13 ++----- .changeset/upgrade-behavior-changes.md | 41 +++++++-------------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index 7a268ec..6eb00ef 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -2,13 +2,8 @@ 'fetchium': patch --- -Fixes for snapshots, unchanged-data skipping and streamed-event writes. +Fixed stale data in components when a second query loads entities that are already in memory, and streamed updates that were lost on restart. -- Development builds freeze snapshots, so mutating a query result (a `sort()` in render, an assignment) throws a `TypeError` where it happens. Production snapshots are not frozen; treat them as read-only. Fetchium no longer mutates objects you pass it, such as a snapshot handed back in a mutation's `effects`. -- In development, the stale-snapshot check reports through the client's `log.error` and an uncaught error (a red box on React Native) instead of throwing during a snapshot, which stopped every other consumer from updating. -- Mounting a second query whose entities are already in memory no longer leaves the first query's consumers with stale data. The in-memory data is used when it fits the new query's shape; otherwise the cached query is dropped and fetched instead of rendering with an item missing. -- Streamed events persist every entity they change, including existing entities inside a `create` payload and stored entities that are not in memory. An entity built from events is merged into its stored record (new optional `QueryStore.mergeEntity`), so the fields its events did not carry survive a restart. -- An entity that appears more than once in one response is applied and written once. -- A store write that throws is retried by the next apply. `AsyncQueryStore` reports finished writes back to the client, and both built-in stores resize their LRU queue when `cache.maxCount` changes. -- `QueryClient.destroy()` unsubscribes from the store, so a store no longer keeps every client created against it alive. -- A query whose cached value was rejected fetches even within `staleTime`. Snapshots work for entities created before a hot reload. +- Development builds freeze query results, so mutating one (such as calling `sort()` on a list in render) throws a `TypeError`. Copy the data first, and treat production results as read-only too. +- In development, the stale-data check logs an error instead of throwing and blocking other components' updates. +- Custom stores can add the new optional `QueryStore.mergeEntity`, so an entity built from streamed updates doesn't overwrite stored fields those updates didn't include. Without it, writes work as before. diff --git a/.changeset/upgrade-behavior-changes.md b/.changeset/upgrade-behavior-changes.md index 0ca94b7..626d5b9 100644 --- a/.changeset/upgrade-behavior-changes.md +++ b/.changeset/upgrade-behavior-changes.md @@ -2,31 +2,18 @@ 'fetchium': major --- -Upgrade notes: behavior an app can observe without using any new API. Each change has its own entry. +Breaking changes. Most apps need no code changes, but tests that depend on loading frames, render counts or timers may. -Rendering - -- With `SyncQueryStore`, a cached query renders its data on the first render, with no loading frame. Tests that wait for a loading state or count renders may need updating. -- A refetch, poll or streamed update with identical data does not re-render. `isPending` and `isFetching` still change. -- `t.record` and `t.result` fields apply updates; a record field used to keep its old value. -- List snapshots keep each row's identity when the list is inserted into or re-sorted. -- A nested `t.object` or `t.record` value, or a live collection's value, is a new object after an update changes it, and keeps its identity otherwise. -- Development builds freeze query results, so mutating one throws a `TypeError`. Treat production results as read-only too. - -Fetching - -- The first fetch, and a refetch without `debounce`, start on a microtask instead of after `setTimeout(0)`. -- On resume, a subscription is restored before the request is sent, so a socket that replays a cached value no longer overwrites the fresh response. -- Changing a Signal param mid-fetch aborts that fetch and fetches the new params. A late response for the old params is dropped. -- A query unmounted mid-fetch refetches as soon as it mounts again, without showing the `AbortError`. -- `retry: {}` and `retry: { retryDelay }` retry like `retry: true`. A `shouldRetry` hook can stop retrying permanent errors. -- `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. Other keys still reach queries as `this.context`. -- `QueryClient.destroy()` aborts every fetch in flight (whoever awaits one gets an `AbortError`), and nothing is written to the store after it. - -Cache and store - -- A cached entry that can't be applied is treated as a miss and fetched, even within `staleTime`. -- A query whose result is not an entity caches each params key separately. Caches from earlier versions are corrected as each key is fetched again. -- Streamed events persist the entities they change. -- `QueryStore` gains optional `mergeEntity`, `onDelete`, `onPersisted`, `isSettled`, `hasQueuedDeletes` and `hasEntity`. A custom store without them writes as before. A store write that throws is retried. -- `AsyncQueryStore` honors `cache.maxCount`, and both built-in stores resize their LRU queue when it changes. +- **Results are frozen in development.** Mutating query data throws a `TypeError`; copy it first (`[...data.items].sort()`). +- **Cached data shows on the first render** with `SyncQueryStore`. Update tests that wait for a loading state or count renders. +- **Fetches start sooner.** The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`; update tests that advance timers to see them. +- **Identical data doesn't re-render.** A refetch, poll or streamed update with the same data skips the render; read `isFetching` instead of counting renders. +- **Nested values change identity only when they change.** `t.object`, `t.record` and live-collection values are new objects after a change, so memoized children re-render; drop workarounds that forced it. +- **`t.record` and `t.result` fields apply updates** instead of keeping their old value. +- **Changing a Signal param mid-fetch aborts that fetch** and fetches the new params; the old response is dropped. +- **`QueryClient.destroy()` aborts fetches in flight.** Code that awaits a query during teardown gets an `AbortError`; catch it. +- **Awaiting a query can wait for a remount.** If the last component using a query unmounts before its first data arrives, `await fetchQuery(...)` waits for the next mount, `gcTime` collection or `destroy()` instead of rejecting with an `AbortError`. +- **New reserved `QueryClientConfig` names:** `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are read as options; rename custom context values that use them. +- **`retry: {}` and `retry: { retryDelay }` now retry** like `retry: true`; use `retry: false` for no retries. +- **`AsyncQueryStore` honors `cache.maxCount`.** Check the value if you set one. +- **Custom `QueryStore`s** get new optional methods (`mergeEntity`, `onDelete`, `onPersisted`, `isSettled`, `hasQueuedDeletes`, `hasEntity`); without them nothing changes. From ca682cff33c854fba6f8e4297475265763c3c203 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 22:16:39 -0400 Subject: [PATCH 044/109] fix(fetchium): keep a lease's release aborting the fetch it started The first fetch a query starts on its microtask is left to finish when the Signalium flush outstanding at that moment deactivates the query (a mount and unmount in one task). A lease starts its queries' first fetch synchronously instead, and `release()` with no reader is documented to abort a fetch in flight; a release in the same task as `prefetch()` or `retain()` fell into that window and let the request run. Only the microtask start opens the window now. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 2 +- packages/fetchium/src/QueryResult.ts | 15 ++++++++++++--- .../src/__tests__/retain-prefetch.test.ts | 12 ++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 01f0ebc..76fd129 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -4,7 +4,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hydration and fast delivery. -- A query mounted and unmounted in the same task (a guard that redirects in a layout effect, a `replace` on mount, a route re-keyed in one commit) no longer leaves its relay rejected with an `AbortError` that the next mount shows. Its first request, which now starts before Signalium's deactivation flush, is left to finish in the background, as it did when the first fetch started on a timer, and the next mount shows its data. +- A query mounted and unmounted in the same task (a guard that redirects in a layout effect, a `replace` on mount, a route re-keyed in one commit) no longer leaves its relay rejected with an `AbortError` that the next mount shows. Its first request, which now starts before Signalium's deactivation flush, is left to finish in the background, as it did when the first fetch started on a timer, and the next mount shows its data. Releasing a `prefetch()` or `retain()` lease still aborts the fetch it started, in any task. - A Signal param change made in the same task the query's last watcher leaves no longer sends a request that the deactivation then aborts. The refetch for a param change starts once Signalium's flush has run, which is in the same task when the change arrives through that flush. - A query that reactivates stale in the same task the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". The deferred refetch, a deferred restart and a debounced refetch recheck that the query is still active and not paused before they send. - When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index fee7e63..1b955c6 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -428,6 +428,15 @@ export class QueryInstance { } private runPendingStart = (): void => { + this.runStart(true); + }; + + /** + * `fromMicrotask`: the start raced Signalium's flush, so a deactivation in + * that flush lets its fetch finish. A lease's start (`startPendingNow()`) + * holds the query itself; releasing the lease aborts the fetch as usual. + */ + private runStart(fromMicrotask: boolean): void { if (!this.startPending) return; this.startPending = false; if (this.hasSignalParams) { @@ -442,11 +451,11 @@ export class QueryInstance { } const fetchesBefore = this.fetchStarts; this.startSubscriptionAndFetch(); - if (this.fetchStarts !== fetchesBefore) { + if (fromMicrotask && this.fetchStarts !== fetchesBefore) { this.firstFetchController = this._abortController; this.firstFetchWindow = openStartWindow(); } - }; + } /** Switches to new params. Data shown until the next fetch lands is the old params'. */ private adoptParams(extractedParams: Record | undefined, storageKey: number): void { @@ -464,7 +473,7 @@ export class QueryInstance { * @internal */ startPendingNow(): void { - this.runPendingStart(); + this.runStart(false); const run = this.pendingDebouncedRun; this.pendingDebouncedRun = undefined; run?.(); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 8d19757..f6e3131 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -366,6 +366,18 @@ describe('client.prefetch()', () => { expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); await sleep(40); }); + + it('release() in the same task as prefetch() aborts the fetch it started', async () => { + const { client, mockFetch } = setup(); + mockFetch.reset(); + mockFetch.get('/item', { n: 3 }, { delay: 30 }); + const release = client.prefetch(GetItem); + expect(mockFetch.calls).toHaveLength(1); + release(); + await sleep(10); + expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); + await sleep(40); + }); }); describe('client.retain()', () => { From c7241147a67b99b474f035f170b53eac74ca2514 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 22:07:40 -0400 Subject: [PATCH 045/109] fix(fetchium): keep nested values reactive when they are read on their own `t.object` and `t.record` fields are merged in place and live collection values change in place, so the wrapper a nested value is handed out through keeps its identity across an update. Reads through that wrapper consumed nothing; only the entity proxy's own read did. A `component()` child given `token.price`, `wallet.balances` or `list.items` as a prop therefore had no dependency of its own, and once its parent stops re-rendering it for identical props (Signalium's identical-props hash skip) it kept the old value: a refetched price, a merged balance, a streamed create. Each wrapper is now its own proxy handler and carries a notifier that its traps consume: - an entity's nested object, record or array gets its own notifier, created on the first read; `mergeFields` notifies it when an in-place merge changed that value (a nested `t.object` merge reports a change, or a same-keys record copies a value); - a live collection's value, and anything inside it, follows the collection's notifier, which a reducer or an event already notifies. Consuming the entity's notifier instead would also have fixed staleness, but every unrelated field change on the entity would then re-render a child that holds only `price`. With a notifier per nested value, readers re-run only when their value changed. Cost: no extra work in a snapshot or on an unchanged apply. A read through a wrapper reads a handler field instead of a module-level handler and, inside a computation, records one dependency (a few ns per read). A wrapper allocates its notifier once, on its first read. A changed nested merge does one WeakMap lookup; the separate proxy cache is gone, the wrapper map replaces it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/nested-value-reactivity.md | 5 + packages/fetchium/src/EntityInstance.ts | 134 +++++++--- packages/fetchium/src/LiveCollection.ts | 9 + .../__tests__/nested-value-reactivity.test.ts | 247 ++++++++++++++++++ packages/fetchium/src/applyEntities.ts | 9 +- packages/fetchium/src/nestedNotifiers.ts | 21 ++ .../__tests__/nested-value-props.test.tsx | 183 +++++++++++++ 7 files changed, 569 insertions(+), 39 deletions(-) create mode 100644 .changeset/nested-value-reactivity.md create mode 100644 packages/fetchium/src/__tests__/nested-value-reactivity.test.ts create mode 100644 packages/fetchium/src/nestedNotifiers.ts create mode 100644 packages/fetchium/src/react/__tests__/nested-value-props.test.tsx diff --git a/.changeset/nested-value-reactivity.md b/.changeset/nested-value-reactivity.md new file mode 100644 index 0000000..4d482b4 --- /dev/null +++ b/.changeset/nested-value-reactivity.md @@ -0,0 +1,5 @@ +--- +'fetchium': patch +--- + +Nested values passed on their own stay reactive. A `t.object` or `t.record` field, and a live collection's value, keep their identity when an update changes them, because they are merged in place. A component (or reactive function) that received only the nested value, such as a `component()` child given `token.price` as a prop, read it without depending on anything, so it kept showing the old value whenever it was not re-rendered by its parent, which happens whenever its props are identical. Reads through a nested value now depend on that value: the in-place merge notifies it, and a live collection's value follows the collection. A change to another field of the entity does not re-run readers that hold only the nested value. diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index c2c8e93..bcf87a7 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -6,6 +6,7 @@ import { type EntityDef, Mask } from './types.js'; import { GcKeyType } from './GcManager.js'; import { Entity } from './proxy.js'; import { PROXY_ID } from './proxyId.js'; +import { NESTED_WRAPPERS, type NestedWrapper } from './nestedNotifiers.js'; import type { QueryClient } from './QueryClient.js'; import { ValidatorDef, WRAPPED_VALUE } from './typeDefs.js'; import type { LiveCollectionBinding } from './LiveCollection.js'; @@ -17,79 +18,136 @@ import { entitySatisfiesShape } from './parseEntities.js'; // ====================================================== const ObjectProto = Object.prototype; -const wrappingCache = new WeakMap(); -function wrapValue(value: unknown): unknown { +/** A `WRAPPED_VALUE` item. A live collection's value changes under its own notifier. */ +interface WrappedValue { + getValue(): unknown; + readonly _valueOwner?: Notifier; +} + +/** + * `owner` is the notifier of a live collection whose value this is (or is + * inside): a reducer can change such a value in place without telling + * anything else. Without one, the value is an entity's nested object, record + * or array, and its wrapper gets a notifier of its own that the in-place + * merge notifies. + */ +function wrapValue(value: unknown, owner: Notifier | undefined): unknown { if (typeof value !== 'object' || value === null) return value; - if (WRAPPED_VALUE.has(value)) return wrapValue((value as { getValue(): unknown }).getValue()); + if (WRAPPED_VALUE.has(value)) { + const wrapped = value as WrappedValue; + return wrapValue(wrapped.getValue(), wrapped._valueOwner ?? owner); + } if (PROXY_ID.has(value as object)) return value; if (Array.isArray(value)) { - let cached = wrappingCache.get(value); - if (cached === undefined) { - cached = new Proxy(value, arrayWrappingHandler); - wrappingCache.set(value, cached); + let wrapper = NESTED_WRAPPERS.get(value); + if (wrapper === undefined) { + wrapper = new ArrayWrappingHandler(value, owner); + NESTED_WRAPPERS.set(value, wrapper); } - return cached; + return wrapper.proxy; } if (Object.getPrototypeOf(value) === ObjectProto) { - let cached = wrappingCache.get(value); - if (cached === undefined) { - cached = new Proxy(value as Record, objectWrappingHandler); - wrappingCache.set(value, cached); + let wrapper = NESTED_WRAPPERS.get(value); + if (wrapper === undefined) { + wrapper = new ObjectWrappingHandler(value as Record, owner); + NESTED_WRAPPERS.set(value, wrapper); } - return cached; + return wrapper.proxy; } return value; } -const arrayWrappingHandler: ProxyHandler = { - get(target, prop, receiver) { +/** + * Each wrapper is its own proxy handler. The traps consume `changes`, which + * fires when the wrapped value's contents change in place: something that + * holds only the wrapper (a child component given `entity.price` as a prop) + * has no other dependency that would re-run it, and the wrapper keeps its + * identity across the change. Values read through the wrapper inherit + * `owner`, a live collection's notifier, or get their own. + */ +class ArrayWrappingHandler implements ProxyHandler, NestedWrapper { + readonly proxy: unknown[]; + changes: Notifier | undefined; + readonly owner: Notifier | undefined; + + constructor(target: unknown[], owner: Notifier | undefined) { + this.changes = owner; + this.owner = owner; + this.proxy = new Proxy(target, this); + } + + get(target: unknown[], prop: string | symbol, receiver: unknown): unknown { + // Every read, `length` and the iteration methods included: an in-place + // push changes what all of them return. + (this.changes ??= notifier()).consume(); if (typeof prop === 'string') { const idx = Number(prop); if (Number.isInteger(idx) && idx >= 0 && idx < target.length) { - return wrapValue(target[idx]); + return wrapValue(target[idx], this.owner); } } return Reflect.get(target, prop, receiver); - }, - set() { + } + + set(): boolean { if (IS_DEV) throw new Error('Cannot mutate a read-only array'); return false; - }, - deleteProperty() { + } + + deleteProperty(): boolean { if (IS_DEV) throw new Error('Cannot mutate a read-only array'); return false; - }, -}; + } +} -const objectWrappingHandler: ProxyHandler> = { - get(target, prop, receiver) { +class ObjectWrappingHandler implements ProxyHandler>, NestedWrapper { + readonly proxy: Record; + changes: Notifier | undefined; + readonly owner: Notifier | undefined; + + constructor(target: Record, owner: Notifier | undefined) { + this.changes = owner; + this.owner = owner; + this.proxy = new Proxy(target, this); + } + + get(target: Record, prop: string | symbol, receiver: unknown): unknown { if (typeof prop === 'string') { - return wrapValue(target[prop]); + (this.changes ??= notifier()).consume(); + return wrapValue(target[prop], this.owner); } return Reflect.get(target, prop, receiver); - }, - set() { + } + + set(): boolean { if (IS_DEV) throw new Error('Cannot mutate a read-only object'); return false; - }, - deleteProperty() { + } + + deleteProperty(): boolean { if (IS_DEV) throw new Error('Cannot mutate a read-only object'); return false; - }, - has(target, prop) { + } + + // A live value's reducer can add or remove keys in place. + has(target: Record, prop: string | symbol): boolean { + (this.changes ??= notifier()).consume(); return prop in target; - }, - ownKeys(target) { + } + + ownKeys(target: Record): ArrayLike { + (this.changes ??= notifier()).consume(); return Reflect.ownKeys(target); - }, - getOwnPropertyDescriptor(target, prop) { + } + + getOwnPropertyDescriptor(target: Record, prop: string | symbol): PropertyDescriptor | undefined { return Object.getOwnPropertyDescriptor(target, prop); - }, -}; + } +} // ====================================================== // Custom snapshot for entity proxies — bridges Signalium v3's `useReactive` @@ -1179,7 +1237,7 @@ function createProxy( entityNotifier.consume(); - return wrapValue(readField(prop)); + return wrapValue(readField(prop), undefined); }, set() { diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index b85fbcd..06c162a 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -75,6 +75,8 @@ export interface LiveCollectionParent { // ====================================================== export interface LiveInstance { + /** Notified whenever the value or membership changes. */ + readonly _notifier: Notifier; getValue(): unknown; getRawValue(): unknown; /** Returns whether the value/membership actually changed. */ @@ -100,6 +102,12 @@ export class LiveCollectionBinding { _entityDefsByTypename: Map[]>; _constraintFieldRefs: Map>; readonly instance: LiveInstance; + /** + * What reads of the value's contents consume. The array or object + * `getValue()` returns can change in place without the parent entity + * changing, so its wrapper must not follow the entity's notifier. + */ + readonly _valueOwner: Notifier; constructor( entityDefs: ValidatorDef[], @@ -114,6 +122,7 @@ export class LiveCollectionBinding { this._constraintHashes = constraintHashes; this._constraintFieldRefs = constraintFieldRefs; this.instance = instance; + this._valueOwner = instance._notifier; this._entityDefsByTypename = new Map(); for (const def of entityDefs) { diff --git a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts new file mode 100644 index 0000000..3c430bb --- /dev/null +++ b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from 'vitest'; +import { reactive } from 'signalium'; +import { hashValue } from 'signalium/utils'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { testWithClient, sleep, setupTestClient } from './utils.js'; + +/** + * Nested values (`t.object` and `t.record` fields, live collection values) are + * merged in place and handed out through wrappers that keep their identity. A + * computation that only holds the wrapper, such as a child component given + * `entity.price` as a prop, never reads the entity, so reads through the + * wrapper must depend on whatever notifies when its contents change. + */ +describe('reads through a held nested value', () => { + const getClient = setupTestClient(); + + class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + price = t.object({ usd: t.number, change: t.object({ h24: t.number }) }); + balances = t.record(t.number); + } + + class GetToken extends RESTQuery { + params = { id: t.id }; + path = `/token/${this.params.id}`; + result = t.entity(Token); + } + + it('re-run when an in-place merge changes a t.object field, a nested t.object field or a t.record value', async () => { + const { client, mockFetch } = getClient(); + mockFetch.get('/token/[id]', { + __typename: 'Token', + id: '1', + price: { usd: 100, change: { h24: 1 } }, + balances: { SOL: 1, USDC: 2 }, + }); + mockFetch.get('/token/[id]', { + __typename: 'Token', + id: '1', + price: { usd: 150, change: { h24: 2 } }, + balances: { SOL: 5, USDC: 2 }, + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(GetToken, { id: '1' }); + await relay; + const token = relay.value!; + const price = token.price; + const change = token.price.change; + const balances = token.balances; + + const usd = reactive(() => price.usd); + const h24 = reactive(() => change.h24); + const sol = reactive(() => balances.SOL); + expect([usd(), h24(), sol()]).toEqual([100, 1, 1]); + + await token.__refetch(); + await sleep(0); + + // Still the same wrappers: the merge happened in place. + expect(token.price).toBe(price); + expect(token.balances).toBe(balances); + expect([usd(), h24(), sol()]).toEqual([150, 2, 5]); + }); + }); + + it('do not re-run when only another field of the entity changes', async () => { + const { client, mockFetch } = getClient(); + + class Quote extends Entity { + __typename = t.typename('Quote'); + id = t.id; + symbol = t.string; + price = t.object({ usd: t.number, change: t.object({ h24: t.number }) }); + } + + class GetQuote extends RESTQuery { + params = { id: t.id }; + path = `/quote/${this.params.id}`; + result = t.entity(Quote); + } + + mockFetch.get('/quote/[id]', { + __typename: 'Quote', + id: '1', + symbol: 'A', + price: { usd: 100, change: { h24: 1 } }, + }); + mockFetch.get('/quote/[id]', { + __typename: 'Quote', + id: '1', + symbol: 'B', + price: { usd: 100, change: { h24: 1 } }, + }); + mockFetch.get('/quote/[id]', { + __typename: 'Quote', + id: '1', + symbol: 'B', + price: { usd: 120, change: { h24: 1 } }, + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(GetQuote, { id: '1' }); + await relay; + const quote = relay.value!; + const price = quote.price; + const change = quote.price.change; + const runs = { usd: 0, h24: 0 }; + const usd = reactive(() => (runs.usd++, price.usd)); + const h24 = reactive(() => (runs.h24++, change.h24)); + expect([usd(), h24()]).toEqual([100, 1]); + + await quote.__refetch(); + await sleep(0); + expect(quote.symbol).toBe('B'); + expect([usd(), h24()]).toEqual([100, 1]); + expect(runs).toEqual({ usd: 1, h24: 1 }); + + // A change to `price.usd` re-runs readers of `price`, not of `price.change`. + await quote.__refetch(); + await sleep(0); + expect([usd(), h24()]).toEqual([120, 1]); + expect(runs).toEqual({ usd: 2, h24: 1 }); + }); + }); + + it('re-run when a streamed create grows a held unconstrained t.liveArray', async () => { + const { client, mockFetch } = getClient(); + + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; + } + + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + items = t.liveArray(Item); + } + + class GetList extends RESTQuery { + params = { id: t.id }; + path = `/list/${this.params.id}`; + result = t.entity(List); + } + + mockFetch.get('/list/[id]', { __typename: 'List', id: '1', items: [{ __typename: 'Item', id: '1', name: 'A' }] }); + + let items!: { length: number; map(fn: (item: { name: string }) => U): U[] }; + let length!: () => number; + let names!: () => string; + await testWithClient(client, async () => { + const relay = fetchQuery(GetList, { id: '1' }); + await relay; + items = relay.value!.items; + length = reactive(() => items.length); + names = reactive(() => items.map(item => item.name).join(',')); + expect(length()).toBe(1); + expect(names()).toBe('A'); + }); + + // Unconstrained live arrays are routed by event source, the parent entity's key. + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: '2', name: 'B' }, + __eventSource: hashValue(['List', '1']), + }); + await sleep(0); + + await testWithClient(client, async () => { + expect(length()).toBe(2); + expect(names()).toBe('A,B'); + }); + }); + + it('re-run when a liveValue reducer mutates a held array or object value in place', async () => { + const { client, mockFetch } = getClient(); + + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + listId = t.string; + } + + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + ids = t.liveValue(t.array(t.string), Item, { + constraints: { listId: (this as any).id }, + onCreate: (v: string[], item: any) => { + v.push(item.id); + return v; + }, + onUpdate: (v: string[]) => v, + onDelete: (v: string[]) => v, + }); + seen = t.liveValue(t.record(t.boolean), Item, { + constraints: { listId: (this as any).id }, + onCreate: (v: Record, item: any) => { + v[item.id] = true; + return v; + }, + onUpdate: (v: Record) => v, + onDelete: (v: Record) => v, + }); + } + + class GetList extends RESTQuery { + params = { id: t.id }; + path = `/list/${this.params.id}`; + result = { list: t.entity(List) }; + } + + mockFetch.get('/list/[id]', { list: { __typename: 'List', id: '1', ids: [], seen: {} } }); + + let length!: () => number; + let keys!: () => string; + let has!: () => boolean; + await testWithClient(client, async () => { + const relay = fetchQuery(GetList, { id: '1' }); + await relay; + const ids = relay.value!.list.ids as string[]; + const seen = relay.value!.list.seen as Record; + length = reactive(() => ids.length); + keys = reactive(() => Object.keys(seen).join(',')); + has = reactive(() => 'x' in seen); + expect([length(), keys(), has()]).toEqual([0, '', false]); + }); + + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: 'x', listId: '1' }, + }); + await sleep(0); + + await testWithClient(client, async () => { + expect([length(), keys(), has()]).toEqual([1, 'x', true]); + }); + }); +}); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index f9722a5..86652ec 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -14,6 +14,7 @@ import { FormattedValue, ValidatorDef } from './typeDefs.js'; import { Mask } from './types.js'; import { createLiveCollection, LiveCollectionBinding } from './LiveCollection.js'; import { PROXY_ID } from './proxyId.js'; +import { notifyNestedValue } from './nestedNotifiers.js'; const entries = Object.entries; const ObjectProto = Object.prototype; @@ -350,6 +351,9 @@ function mergeFields( ) ) { changed = true; + // Merged in place, so the value keeps its identity: tell whoever + // holds just this value (a child given it as a prop). + if (target === oldVal) notifyNestedValue(oldVal); existingData[fieldKey] = target; } else { existingData[fieldKey] = oldVal; @@ -364,7 +368,10 @@ function mergeFields( merged = true; } } - if (merged) changed = true; + if (merged) { + changed = true; + if (target === oldVal) notifyNestedValue(oldVal); + } existingData[fieldKey] = merged ? target : oldVal; } else { // A key was added or removed. Copying field by field would never diff --git a/packages/fetchium/src/nestedNotifiers.ts b/packages/fetchium/src/nestedNotifiers.ts new file mode 100644 index 0000000..35f687a --- /dev/null +++ b/packages/fetchium/src/nestedNotifiers.ts @@ -0,0 +1,21 @@ +import type { Notifier } from 'signalium'; + +/** + * The wrapping proxy handed out for a nested object, record or array inside + * an entity, keyed by the wrapped value. Nested values are merged in place, + * so a wrapper keeps its identity when its contents change; reads through it + * consume `changes`, and the merge notifies it. `EntityInstance` creates the + * wrappers, `applyEntities` notifies them. + */ +export interface NestedWrapper { + readonly proxy: object; + /** Created by the first read; nothing to notify before that. */ + changes: Notifier | undefined; +} + +export const NESTED_WRAPPERS = new WeakMap(); + +/** Tell readers of a nested value that a merge changed it in place. */ +export function notifyNestedValue(value: object): void { + NESTED_WRAPPERS.get(value)?.changes?.notify(); +} diff --git a/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx b/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx new file mode 100644 index 0000000..65e13a6 --- /dev/null +++ b/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider, component } from 'signalium/react'; +import { hashValue } from 'signalium/utils'; +import React from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { Entity } from '../../proxy.js'; +import { RESTQuery } from '../../rest/index.js'; +import { fetchQuery } from '../../query.js'; +import { createMockFetch, sleep } from '../../__tests__/utils.js'; +import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; + +/** + * A nested value passed as a prop keeps its identity when it changes, because + * it is merged in place. A `component()` child that skips re-rendering for + * identical props still has to update, through its own reads of the value. + */ +describe('component() child given a nested entity value as a prop', () => { + let client: QueryClient; + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = createMockFetch(); + client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + }); + + afterEach(() => { + client.destroy(); + }); + + it('shows a changed t.object and t.record field after a refetch', async () => { + class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + price = t.object({ usd: t.number }); + balances = t.record(t.number); + } + + class GetToken extends RESTQuery { + params = { id: t.id }; + path = `/token/${this.params.id}`; + result = t.entity(Token); + } + + mockFetch.get('/token/[id]', { __typename: 'Token', id: '1', price: { usd: 100 }, balances: { SOL: 1 } }); + mockFetch.get('/token/[id]', { __typename: 'Token', id: '1', price: { usd: 150 }, balances: { SOL: 5 } }); + + let refetch!: () => Promise; + const Price = component(({ price }: { price: { usd: number } }) => {price.usd}); + const Balances = component(({ balances }: { balances: Record }) => ( + {balances.SOL} + )); + const Parent = component(() => { + const q = fetchQuery(GetToken, { id: '1' }); + if (!q.isReady) return
Loading
; + refetch = () => q.value.__refetch(); + return ( +
+ + +
+ ); + }); + + const { getByTestId } = render( + + + , + ); + await expect.element(getByTestId('usd')).toHaveTextContent('100'); + await expect.element(getByTestId('sol')).toHaveTextContent('1'); + + await refetch(); + + await expect.element(getByTestId('usd')).toHaveTextContent('150'); + await expect.element(getByTestId('sol')).toHaveTextContent('5'); + }); + + it('does not re-render the child when only another field of the entity changes', async () => { + class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + symbol = t.string; + price = t.object({ usd: t.number }); + } + + class GetToken extends RESTQuery { + params = { id: t.id }; + path = `/token/${this.params.id}`; + result = t.entity(Token); + } + + mockFetch.get('/token/[id]', { __typename: 'Token', id: '1', symbol: 'A', price: { usd: 100 } }); + mockFetch.get('/token/[id]', { __typename: 'Token', id: '1', symbol: 'B', price: { usd: 100 } }); + + let refetch!: () => Promise; + let childRenders = 0; + const Price = component(({ price }: { price: { usd: number } }) => { + childRenders++; + return {price.usd}; + }); + const Parent = component(() => { + const q = fetchQuery(GetToken, { id: '1' }); + if (!q.isReady) return
Loading
; + refetch = () => q.value.__refetch(); + return ( +
+ {q.value.symbol} + +
+ ); + }); + + const { getByTestId } = render( + + + , + ); + await expect.element(getByTestId('usd')).toHaveTextContent('100'); + await sleep(20); + const mounted = childRenders; + + await refetch(); + await expect.element(getByTestId('symbol')).toHaveTextContent('B'); + await sleep(20); + + expect(childRenders).toBe(mounted); + }); + + it('shows a streamed create in an unconstrained t.liveArray', async () => { + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; + } + + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + items = t.liveArray(Item); + } + + class GetList extends RESTQuery { + params = { id: t.id }; + path = `/list/${this.params.id}`; + result = t.entity(List); + } + + mockFetch.get('/list/[id]', { __typename: 'List', id: '1', items: [{ __typename: 'Item', id: '1', name: 'A' }] }); + + const Items = component(({ items }: { items: Array<{ name: string }> }) => ( + {items.map(item => item.name).join(',')} + )); + const Parent = component(() => { + const q = fetchQuery(GetList, { id: '1' }); + if (!q.isReady) return
Loading
; + return ; + }); + + const { getByTestId } = render( + + + , + ); + await expect.element(getByTestId('names')).toHaveTextContent('A'); + await sleep(0); + + // Unconstrained live arrays are routed by event source, the parent entity's key. + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: '2', name: 'B' }, + __eventSource: hashValue(['List', '1']), + }); + + await expect.element(getByTestId('names')).toHaveTextContent('A,B'); + }); +}); From ce9ef9285732ee6ccfee5d727e9ece285832dd07 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 22:07:40 -0400 Subject: [PATCH 046/109] test(fetchium): benchmark reads through nested value wrappers Reads of `t.object`, `t.record` and live array values through their wrapping proxies, held on their own or reached through the entity, inside and outside a reactive computation, plus a cold query that creates a wrapper per row. `npm run bench` runs it with the snapshot benchmarks. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__benchmarks__/nested-read.bench.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/fetchium/src/__benchmarks__/nested-read.bench.ts diff --git a/packages/fetchium/src/__benchmarks__/nested-read.bench.ts b/packages/fetchium/src/__benchmarks__/nested-read.bench.ts new file mode 100644 index 0000000..d64b174 --- /dev/null +++ b/packages/fetchium/src/__benchmarks__/nested-read.bench.ts @@ -0,0 +1,202 @@ +import { bench, describe } from 'vitest'; +import { signal, watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { RESTQuery } from '../rest/index.js'; +import { Entity } from '../proxy.js'; +import { fetchQuery } from '../query.js'; +import { t } from '../typeDefs.js'; + +/** + * What reading a nested, non-entity field of an entity costs: `t.object` and + * `t.record` values and `t.liveArray` values are handed out through wrapping + * proxies, so every read goes through a proxy trap. Measured inside a reactive + * computation (a `component()` render or a `reactive()` function) and outside + * one. Each case reads 1,000 fields. + * + * Run with `npm run bench`. + */ + +class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; +} + +class Token extends Entity { + __typename = t.typename('Token'); + id = t.id; + symbol = t.string; + price = t.object({ usd: t.number, change: t.object({ h24: t.number }) }); + balances = t.record(t.number); + items = t.liveArray(Item); +} + +class GetToken extends RESTQuery { + params = { id: t.id }; + path = `/token/${this.params.id}`; + result = t.entity(Token); + getConfig() { + return { retry: 0, gcTime: Infinity }; + } +} + +const payload = { + __typename: 'Token', + id: '1', + symbol: 'SOL', + price: { usd: 150, change: { h24: 2 } }, + balances: { SOL: 5, USDC: 2 }, + items: [ + { __typename: 'Item', id: 'a', name: 'A' }, + { __typename: 'Item', id: 'b', name: 'B' }, + ], +}; + +const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [ + new RESTQueryAdapter({ + fetch: (async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch, + baseUrl: 'http://localhost', + }), + ], +}); + +const query = withContexts([[QueryClientContext, client]], () => fetchQuery(GetToken, { id: '1' })); +const holder = watcher(() => query.value); +holder.addListener(() => {}); +await query; + +type TokenValue = { + symbol: string; + price: { usd: number; change: { h24: number } }; + balances: Record; + items: { length: number }[] & { length: number }; +}; +const token = query.value as unknown as TokenValue; +const price = token.price; +const balances = token.balances; +const items = token.items; + +const N = 1000; +let sink = 0; + +/** A computation re-run on every bench iteration, the way a re-render re-runs a `component()`. */ +function inComputation(body: () => number) { + const tick = signal(0); + const w = watcher(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + tick.value; + return body(); + }); + w.addListener(() => {}); + return () => { + tick.value++; + sink += w.value; + }; +} + +describe('nested reads, 1000 per iteration', () => { + bench('entity field (baseline)', () => { + for (let i = 0; i < N; i++) sink += token.symbol.length; + }); + + bench('entity -> t.object field', () => { + for (let i = 0; i < N; i++) sink += token.price.usd; + }); + + bench('held t.object -> field', () => { + for (let i = 0; i < N; i++) sink += price.usd; + }); + + bench('held t.object -> t.object -> field', () => { + for (let i = 0; i < N; i++) sink += price.change.h24; + }); + + bench('held t.record -> key', () => { + for (let i = 0; i < N; i++) sink += balances.SOL; + }); + + bench('held t.liveArray -> length', () => { + for (let i = 0; i < N; i++) sink += items.length; + }); + + const parentRender = inComputation(() => { + let s = 0; + for (let i = 0; i < N; i++) s += token.price.usd; + return s; + }); + bench('in a computation: entity -> t.object field', parentRender); + + const childRender = inComputation(() => { + let s = 0; + for (let i = 0; i < N; i++) s += price.usd + price.change.h24; + return s; + }); + bench('in a computation: held t.object -> fields', childRender); + + const recordRender = inComputation(() => { + let s = 0; + for (let i = 0; i < N; i++) s += balances.SOL; + return s; + }); + bench('in a computation: held t.record -> key', recordRender); +}); + +// First reads: a wrapper is created once per nested value and then cached. +class Row extends Entity { + __typename = t.typename('Row'); + id = t.id; + meta = t.object({ logo: t.string }); +} + +class GetRows extends RESTQuery { + params = { count: t.number }; + path = `/rows/${this.params.count}`; + result = { rows: t.array(t.entity(Row)) }; + getConfig() { + return { retry: 0, gcTime: Infinity }; + } +} + +async function coldRows(count: number, readNested: boolean) { + const body = JSON.stringify({ + rows: Array.from({ length: count }, (_, i) => ({ __typename: 'Row', id: `r${i}`, meta: { logo: `l${i}` } })), + }); + const rowsClient = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [ + new RESTQueryAdapter({ + fetch: (async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch, + baseUrl: 'http://localhost', + }), + ], + }); + const q = withContexts([[QueryClientContext, rowsClient]], () => fetchQuery(GetRows, { count })); + const w = watcher(() => q.value); + const stop = w.addListener(() => {}); + await q; + const rows = (q.value as unknown as { rows: { meta: { logo: string } }[] }).rows; + for (let i = 0; i < rows.length; i++) + sink += readNested ? rows[i].meta.logo.length : rows[i].meta === undefined ? 0 : 1; + stop(); + rowsClient.destroy(); +} + +describe('cold query, 1000 rows, first read of each row', () => { + bench('row.meta (wrapper created)', () => coldRows(1000, false)); + bench('row.meta.logo (wrapper created, then read)', () => coldRows(1000, true)); +}); + +// Keep `sink` observable so the reads aren't optimized away. +if (sink === -1) console.log(sink); From 0523f540faa24225cc0278de37b6e82e97a69f79 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:41:04 -0400 Subject: [PATCH 047/109] fix(fetchium): keep the query context's push hook out of Object.keys The fast-delivery work attached `_notePush` to each query's execution context as a plain property, so `Object.keys(this)` (or a spread) inside a query method listed a key it never had before. It is now defined non-enumerable; TopicQuery still reads it by name. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 1b955c6..abe04fe 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -565,8 +565,9 @@ export class QueryInstance { ); this._executionCtx.refetch = () => this.refetch(); // `TopicQuery.getConfig.subscribe` hands this to its adapter, which calls - // it when it delivers an event for the query's topic. - (this._executionCtx as unknown as Record)._notePush = this.notePush; + // it when it delivers an event for the query's topic. Non-enumerable, so + // `Object.keys(this)` inside a query method lists the same keys as before. + Object.defineProperty(this._executionCtx, '_notePush', { value: this.notePush, configurable: true }); this._executionCtx.rawFetchNext = this.def.statics.rawFetchNext; // `TopicQuery.getConfig.subscribe` reads `_topicAdapter` from the ctx; // set it eagerly so subscribe/unsubscribe work on the cache-fresh and From a383a465fae63112c6ef6874089e5e9cd0999f2e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:45:13 -0400 Subject: [PATCH 048/109] fix(fetchium): tolerate app context values under the new config names Custom QueryClientConfig keys have always reached query code as `this.context`. The reactivation and polling options gave five of those names a meaning, which broke apps already using them: - poll() called `activity.subscribe` on whatever the context held under `activity`, so an app value of its own under that name failed every polled query before its first fetch, with nothing logged. poll() now uses the value only when it has `isActive` and `subscribe` functions, and otherwise polls as without one, warning once per client in development. A real activity source, including one with prototype methods, works as before. - `QueryContext` declared `activity` and `pollResumeJitterMs`, so an app that had augmented it with its own `activity` field no longer compiled (TS2717). They are config options, not context fields, and are no longer declared there. - `shouldRetry`, `reactivationGraceMs` and `reactivationStaggerMs` were taken out of the config before it became the context. They reach the context again, as every key did before. A `shouldRetry` that is not a function is ignored rather than called on the first failure. The docs now say these names are reserved. - `reactivationStaggerMs: Infinity` turned into setTimeout(..., Infinity), which Node clamps to 1 ms with a warning and other runtimes may never fire. A non-finite stagger or `pollResumeJitterMs` now counts as 0. `reactivationGraceMs: Infinity` keeps meaning "never refetch on reactivation", since it sets no timer. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/app/api/fetchium/page.md | 30 +++++------ packages/fetchium/src/QueryClient.ts | 37 +++++++++----- .../src/__tests__/poll-activity.test.ts | 50 +++++++++++++++++++ .../src/__tests__/reactivation-grace.test.ts | 20 ++++++++ packages/fetchium/src/query-types.ts | 8 +-- .../fetchium/src/subscriptions/polling.ts | 38 ++++++++++++-- 6 files changed, 149 insertions(+), 34 deletions(-) diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 3d6f76e..cb067a3 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,19 +225,21 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| ----------------------- | ------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | -| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. Per-query override: `reactivationGraceMs` in the query config. | -| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. | -| `shouldRetry` | `(error, attempt, status) => boolean` | — | Optional. Whether a failed attempt is retried. Without it, every failed attempt is retried, whatever its status. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). Return `false` to stop retrying errors you know are permanent, such as a 4xx. Per-query override: `retry.shouldRetry`. | -| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. | -| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. Per-poll override: `poll({ interval, resumeJitterMs })`. | +| Field | Type | Default | Description | +| ----------------------- | ------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. `Infinity` never refetches on reactivation. Per-query override: `reactivationGraceMs` in the query config. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. A value that is not a finite positive number counts as 0. | +| `shouldRetry` | `(error, attempt, status) => boolean` | — | Optional. Whether a failed attempt is retried. Without it, every failed attempt is retried, whatever its status. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). Return `false` to stop retrying errors you know are permanent, such as a 4xx. Per-query override: `retry.shouldRetry`. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. A value without `isActive` and `subscribe` functions is ignored, with a warning in development. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. A value that is not a finite positive number counts as 0. Per-poll override: `poll({ interval, resumeJitterMs })`. | + +Every config key, including keys this table does not list, also reaches query and mutation code as `this.context`, so you can pass your own services through the config. The names in the table are reserved: a value under one of them is read as that option, so do not reuse them for your own context values. #### Methods @@ -646,7 +648,7 @@ The `t` object provides a declarative type definition DSL for describing query p | `QueryCacheOptions` | `{ maxCount?: number; cacheTime?: number }` | Persistent storage cache settings. `cacheTime` is in minutes (default: 1440 / 24 hours). `maxCount` is the LRU queue size. | | `QueryConfigOptions` | `{ gcTime?, staleTime?, debounce?, networkMode?, retry?, refreshStaleOnReconnect?, reactivationGraceMs?, subscribe? }` | Instance-level query configuration. See property table below. | | `QueryRequestOptions` | `{ baseUrl?, credentials?, mode?, cache?, redirect?, referrer?, referrerPolicy?, integrity?, keepalive?, signal? }` | Extended fetch options for individual queries. | -| `QueryContext` | `{ log?, evictionMultiplier?, activity?, pollResumeJitterMs? }` | Context object provided to the `QueryClient`. | +| `QueryContext` | `{ log?, evictionMultiplier? }` | Context object provided to the `QueryClient`. | | `QueryParams` | `Record \| unknown[] \| Record>` | The shape of query parameters at runtime. | | `FetchNextConfig` | `{ url?: unknown; searchParams?: Record }` | Pagination configuration. Values can be FieldRefs. | diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index c699fa9..9deb00f 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -39,6 +39,12 @@ import { SyncQueryStore, MemoryPersistentStore } from './stores/sync.js'; import type { ExtractType } from './types.js'; import type { Optionalize, Signalize } from './type-utils.js'; +/** + * Options for `new QueryClient(config)`. Every key, including ones not listed + * here, also reaches query and mutation code as `this.context`, so an app can + * pass its own services through the config. The names declared below are + * reserved: a custom value under one of them is read as that option. + */ export interface QueryClientConfig { store?: QueryStore; adapters?: QueryAdapter[]; @@ -60,7 +66,8 @@ export interface QueryClientConfig { * makes. Queries can override the window with `reactivationGraceMs` in * their config. Network reconnects, * `refetch()`, `invalidateQueries()`, `markStale()` and a failed last fetch - * still refetch. Default: 0 (every stale query refetches on reactivation). + * still refetch. `Infinity` never refetches on reactivation. Default: 0 + * (every stale query refetches on reactivation). */ reactivationGraceMs?: number; /** @@ -76,14 +83,16 @@ export interface QueryClientConfig { * Milliseconds. Reactivation refetches that start in the same task (for * example every query on a screen that just resumed) are spread evenly * across this window, in activation order, instead of all starting at once. - * Queries of an adapter that `coalescesRequests` are not spread. Default: 0 - * (all start together). + * Queries of an adapter that `coalescesRequests` are not spread. A value + * that is not a finite positive number counts as 0. Default: 0 (all start + * together). */ reactivationStaggerMs?: number; /** * Foreground/background source. When set, `poll()` stops its timers while - * the app is inactive and resumes them when it becomes active again. - * Default: undefined (polls run regardless of app state). + * the app is inactive and resumes them when it becomes active again. A value + * without `isActive` and `subscribe` functions is ignored (with a warning in + * development). Default: undefined (polls run regardless of app state). */ activity?: ActivitySource; /** @@ -91,7 +100,8 @@ export interface QueryClientConfig { * again (or whose timer fires more than a second late, as happens when the * JS thread was suspended in the background) is rescheduled at a random * point within this window rather than firing immediately alongside every - * other overdue poll. Default: 0 (overdue ticks fire immediately). + * other overdue poll. A value that is not a finite positive number counts + * as 0. Default: 0 (overdue ticks fire immediately). */ pollResumeJitterMs?: number; } @@ -219,9 +229,6 @@ export class QueryClient { store = new SyncQueryStore(new MemoryPersistentStore()), log, evictionMultiplier, - reactivationGraceMs, - reactivationStaggerMs, - shouldRetry, adapters: _c, networkManager: _n, gcManager: _g, @@ -229,11 +236,15 @@ export class QueryClient { } = config as QueryClientConfig & Record; this.isServer = typeof window === 'undefined'; this.store = store; + const { reactivationGraceMs, reactivationStaggerMs, shouldRetry } = config; this.reactivationGraceMs = nonNegative(reactivationGraceMs); - this.reactivationStaggerMs = nonNegative(reactivationStaggerMs); - this.shouldRetry = shouldRetry; - // `activity` and `pollResumeJitterMs` ride along in `rest`: poll() reads them from the context. - this.context = { ...rest, log: log ?? console, evictionMultiplier }; + // Finite: the window becomes setTimeout delays. + this.reactivationStaggerMs = Number.isFinite(reactivationStaggerMs) ? nonNegative(reactivationStaggerMs) : 0; + this.shouldRetry = typeof shouldRetry === 'function' ? shouldRetry : undefined; + // Every other key passes through to the context, as custom keys always + // have, including the reserved ones read above and `activity` / + // `pollResumeJitterMs`, which poll() reads from there. + this.context = { ...(rest as Record), log: log ?? console, evictionMultiplier }; this.gcManager = config.gcManager ?? (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); diff --git a/packages/fetchium/src/__tests__/poll-activity.test.ts b/packages/fetchium/src/__tests__/poll-activity.test.ts index acd3dd0..56f6505 100644 --- a/packages/fetchium/src/__tests__/poll-activity.test.ts +++ b/packages/fetchium/src/__tests__/poll-activity.test.ts @@ -274,4 +274,54 @@ describe('poll() with an activity source', () => { await vi.advanceTimersByTimeAsync(5 * INTERVAL); expect(count()).toBe(0); }); + + it('accepts an activity source whose methods live on its prototype', async () => { + class AppActivity implements ActivitySource { + active = true; + listeners = new Set<() => void>(); + isActive(): boolean { + return this.active; + } + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + } + const activity = new AppActivity(); + const client = makeClient({ activity }); + await mount(client, GetPolled); + + activity.active = false; + for (const listener of activity.listeners) listener(); + await vi.advanceTimersByTimeAsync(3 * INTERVAL); + expect(count()).toBe(0); + }); + + it('ignores an `activity` context value that is not an activity source, and warns once', async () => { + // Custom config keys reach the context, so an app may use the name for a value of its own. + const warn = vi.fn(); + const client = makeClient({ activity: { history: [] } as unknown as ActivitySource, log: { warn } }); + await mount(client, GetPolled, GetPolledB); + await vi.advanceTimersByTimeAsync(3 * INTERVAL); + expect(count('/polled')).toBe(3); + expect(count('/polled-b')).toBe(3); + expect((client.getContext() as Record).activity).toEqual({ history: [] }); + expect(warn).toHaveBeenCalledTimes(IS_DEV ? 1 : 0); + }); + + it('treats a non-finite pollResumeJitterMs as no jitter', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const activity = createActivity(); + const client = makeClient({ activity, pollResumeJitterMs: Infinity }); + await mount(client, GetPolled); + + activity.set(false); + await vi.advanceTimersByTimeAsync(2 * INTERVAL); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + activity.set(true); + await vi.advanceTimersByTimeAsync(5); + expect(count()).toBe(1); + // An infinite delay is clamped to 1 ms by Node and the browser; other runtimes may never fire it. + expect(setTimeoutSpy.mock.calls.filter(call => call[1] === Infinity)).toEqual([]); + }); }); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index c54600b..0e6500e 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -115,6 +115,15 @@ describe('reactivation grace and stagger', () => { return starts.filter(s => s.path === path).length; } + it('passes the grace, stagger and shouldRetry options through to the query context too', () => { + // As every config key did before these options existed. + const shouldRetry = () => false; + const client = makeClient({ reactivationGraceMs: 5, reactivationStaggerMs: 7, shouldRetry }); + expect(client.getContext()).toMatchObject({ reactivationGraceMs: 5, reactivationStaggerMs: 7, shouldRetry }); + expect(client.shouldRetry).toBe(shouldRetry); + expect(makeClient({ shouldRetry: 'never' as never }).shouldRetry).toBeUndefined(); + }); + describe('reactivationGraceMs', () => { it('refetches every stale query on reactivation by default', async () => { const client = makeClient(); @@ -512,6 +521,17 @@ describe('reactivation grace and stagger', () => { expect(starts[0].at - t0).toBeLessThanOrEqual(2); }); + it('treats a non-finite window as no stagger', async () => { + const client = makeClient({ reactivationStaggerMs: Infinity }); + expect(client.reactivationStaggerMs).toBe(0); + await loadThenDeactivate(client, GetA, GetB, GetC); + starts.length = 0; + + watch(client, GetA, GetB, GetC); + await vi.advanceTimersByTimeAsync(400); + expect(offsets()).toEqual([0, 0, 0]); + }); + it('drops a queued refetch whose query deactivated before the flush', async () => { const client = makeClient({ reactivationStaggerMs: 300 }); await loadThenDeactivate(client, GetA, GetB); diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 5e309d2..feb01d8 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -26,10 +26,10 @@ export interface QueryContext { debug?: (message: string) => void; }; evictionMultiplier?: number; - /** See `QueryClientConfig.activity`. */ - activity?: ActivitySource; - /** See `QueryClientConfig.pollResumeJitterMs`. */ - pollResumeJitterMs?: number; + // `activity` and `pollResumeJitterMs` (see `QueryClientConfig`) reach the + // context as pass-through keys, as any custom config key does, but are not + // declared here: an app may have augmented this interface with its own + // field of the same name. `poll()` checks their shape before using them. } /** diff --git a/packages/fetchium/src/subscriptions/polling.ts b/packages/fetchium/src/subscriptions/polling.ts index 3cee9b6..e4f724b 100644 --- a/packages/fetchium/src/subscriptions/polling.ts +++ b/packages/fetchium/src/subscriptions/polling.ts @@ -1,5 +1,5 @@ import type { MutationEvent } from '../types.js'; -import type { QueryContext } from '../query-types.js'; +import type { ActivitySource, QueryContext } from '../query-types.js'; const MIN_INTERVAL = 100; @@ -20,6 +20,36 @@ export interface PollConfig { resumeJitterMs?: number; } +/** Contexts whose invalid `activity` value was already reported. */ +const warnedActivity = new WeakSet(); + +/** + * The context's `activity`, when it is an `ActivitySource`. Custom config keys + * pass through to the context, so an app may already use the name for a value + * of its own; anything without `isActive` and `subscribe` functions is + * ignored (polls then run as without one), with a warning in development. + */ +function activitySource(queryContext: QueryContext | undefined): ActivitySource | undefined { + const activity = (queryContext as Record | undefined)?.activity; + if (activity === undefined || activity === null) return undefined; + const candidate = activity as Partial; + if (typeof candidate.isActive === 'function' && typeof candidate.subscribe === 'function') { + return activity as ActivitySource; + } + if (IS_DEV && !warnedActivity.has(queryContext!)) { + warnedActivity.add(queryContext!); + queryContext!.log?.warn?.( + 'poll: the `activity` context value is not an ActivitySource ({ isActive(), subscribe(listener) }); polls ignore it.', + ); + } + return undefined; +} + +/** Milliseconds: a finite positive number, else 0. */ +function finitePositive(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + function clampInterval(interval: number): number { if (!Number.isFinite(interval) || interval < MIN_INTERVAL) { if (IS_DEV && (Number.isNaN(interval) || interval < 0)) { @@ -42,8 +72,10 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE const refetch = this.refetch as () => Promise; const queryContext = this.context as QueryContext | undefined; - const activity = queryContext?.activity; - const jitterWindow = config.resumeJitterMs ?? queryContext?.pollResumeJitterMs ?? 0; + const activity = activitySource(queryContext); + const jitterWindow = finitePositive( + config.resumeJitterMs ?? (queryContext as Record | undefined)?.pollResumeJitterMs, + ); const isAppActive = (): boolean => activity === undefined || activity.isActive(); From 0b4f62bb5a236f6f1a789bd60cde35909011e714 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:49:24 -0400 Subject: [PATCH 049/109] fix(fetchium): stop a slow suspense retry render from refetching in a loop useSuspenseQuery dropped a failed cold fetch's hold when no render claimed the error within 50 ms. React starts the retry render soon after the promise settles, but a time-sliced tree with expensive siblings can take longer than that to reach the reader. That render found no hold, took a new one and refetched, and so did every render after it: a failing endpoint got a request every ~100 ms and the error boundary was never reached. - An unclaimed failure now waits 1 s for a render. - If it still expires and the attempt the next mount makes fails too, that error waits as long as an unsettled hold (10 s), so even a slower reader gets its error after one retried request. - A claimed error is still let go on the next task, so an error-boundary reset retries as before. The trade-off: a tree abandoned while suspended and mounted again within a second of its fetch failing now shows that error instead of making a new attempt. The abandoned-remount test waits past the window to keep checking the new attempt. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 32 +++++-- .../src/__tests__/retain-prefetch.test.ts | 91 +++++++++++++++++++ .../__tests__/prefetch-suspense.test.tsx | 49 +++++++++- 3 files changed, 163 insertions(+), 9 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 9deb00f..29e3e6b 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -133,11 +133,14 @@ const SUSPENSE_HOLD_TTL = 10_000; /** * How long a failed cold fetch's error waits for a render to claim it. React - * retries a suspended render within a task or two of its promise settling; an - * error nobody claimed by then belongs to an abandoned tree, and a later mount - * makes a fresh attempt instead of inheriting it. + * starts the retry of a suspended render soon after its promise settles, but + * a large or time-sliced tree can take far longer than a task to reach the + * reader again, and a reader that found no hold would refetch instead of + * throwing, every time. An error nobody claimed by then belongs to an + * abandoned tree, and a later mount makes a fresh attempt instead of + * inheriting it. */ -const UNCLAIMED_FAILURE_TTL = 50; +const UNCLAIMED_FAILURE_TTL = 1_000; interface SuspenseHold { release: () => void; @@ -214,6 +217,13 @@ export class QueryClient { private warnedReactiveRetain = false; /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ private suspenseHolds = new Map(); + /** + * Keys whose last cold failure expired unclaimed. The next failure of the + * attempt that follows waits for a render as long as an unsettled hold + * would, so a reader slower than `UNCLAIMED_FAILURE_TTL` gets the error + * after one retried request rather than refetching in a loop. + */ + private unclaimedFailures = new Set(); private context!: QueryContext; private typenameRegistry = new Map[]>(); @@ -601,7 +611,9 @@ export class QueryClient { * last fetch failed is refetched once per hold: when that attempt fails too, * `error` is set and the hold is dropped, so the caller can throw it. A * failure no render claims within `UNCLAIMED_FAILURE_TTL` (the suspended - * tree was abandoned) drops the hold, so a later mount makes a new attempt. + * tree was abandoned) drops the hold, so a later mount makes a new attempt; + * if that attempt fails too, its error waits `SUSPENSE_HOLD_TTL` for a + * render, so a reader that slow still reaches its error boundary. * * @internal */ @@ -646,6 +658,7 @@ export class QueryClient { const error = relay.error; if (!hold.failed) { hold.failed = true; + this.unclaimedFailures.delete(key); clearTimeout(hold.timer); hold.timer = setTimeout(() => { if (this.suspenseHolds.get(key) === hold) this.releaseSuspenseHold(key); @@ -663,6 +676,7 @@ export class QueryClient { relay.then( () => { current.done = true; + this.unclaimedFailures.delete(key); this.expireSuspenseHold(key, current); resolve(); }, @@ -670,9 +684,12 @@ export class QueryClient { current.done = true; // Dropped unless the retried render claims the error first. clearTimeout(current.timer); + const ttl = this.unclaimedFailures.has(key) ? SUSPENSE_HOLD_TTL : UNCLAIMED_FAILURE_TTL; current.timer = setTimeout(() => { - if (this.suspenseHolds.get(key) === current && !current.failed) this.releaseSuspenseHold(key); - }, UNCLAIMED_FAILURE_TTL); + if (this.suspenseHolds.get(key) !== current || current.failed) return; + this.unclaimedFailures.add(key); + this.releaseSuspenseHold(key); + }, ttl); resolve(); }, ); @@ -1138,6 +1155,7 @@ export class QueryClient { destroy(): void { for (const key of [...this.suspenseHolds.keys()]) this.releaseSuspenseHold(key); + this.unclaimedFailures.clear(); for (const release of [...this.leases]) release(); clearTimeout(this.staggerTimer); this.staggerTimer = undefined; diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index f6e3131..18b776a 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -590,3 +590,94 @@ describe('client.retain()', () => { ).toThrow('boom'); }); }); + +describe('useSuspenseQuery holds (suspendOnColdMiss)', () => { + class GetFailing extends RESTQuery { + path = '/failing'; + result = { n: t.number }; + config = { retry: false }; + } + + type ColdMiss = { promise: Promise | undefined; failed?: true; error?: unknown }; + + function failingClient() { + const mockFetch = createMockFetch(); + mockFetch.get('/failing', { error: 'down' }, { status: 500 }); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: mockFetch as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + const def = QueryDefinition.for(GetFailing); + // What a useSuspenseQuery render does before it throws. + const render = (): ColdMiss => + (client as unknown as { suspendOnColdMiss(d: typeof def, p: undefined): ColdMiss }).suspendOnColdMiss( + def, + undefined, + ); + return { client, mockFetch, render }; + } + + /** Destroys the clients and drains their timers (and Signalium's flush) before restoring real timers. */ + async function destroyOnFakeClock(): Promise { + for (const c of clients) c.destroy(); + clients = []; + await vi.advanceTimersByTimeAsync(1_000); + vi.useRealTimers(); + } + + it('hands a failed cold fetch to a render that reaches the reader long after it failed', async () => { + vi.useFakeTimers(); + try { + const { mockFetch, render } = failingClient(); + const first = render(); + expect(first.promise).toBeDefined(); + await vi.advanceTimersByTimeAsync(5); + await first.promise; + + // A time-sliced retry render that takes several hundred ms to reach the reader. + await vi.advanceTimersByTimeAsync(400); + const retry = render(); + expect(retry).toMatchObject({ failed: true, promise: undefined }); + expect(mockFetch.calls).toHaveLength(1); + + // An error-boundary reset after the claim makes a new attempt. + await vi.advanceTimersByTimeAsync(5); + expect(render().promise).toBeDefined(); + expect(mockFetch.calls).toHaveLength(2); + } finally { + await destroyOnFakeClock(); + } + }); + + it('lets an unclaimed failure go after a while, then keeps the next one until a render claims it', async () => { + vi.useFakeTimers(); + try { + const { mockFetch, render } = failingClient(); + const first = render(); + await vi.advanceTimersByTimeAsync(5); + await first.promise; + + // Nobody claimed the error (the tree was abandoned): a later mount makes a new attempt. + await vi.advanceTimersByTimeAsync(1_500); + const remount = render(); + expect(remount.promise).toBeDefined(); + await vi.advanceTimersByTimeAsync(5); + await remount.promise; + expect(mockFetch.calls).toHaveLength(2); + + // That attempt failed too; a render slower than the first window still gets its error + // instead of refetching again. + await vi.advanceTimersByTimeAsync(1_500); + expect(render()).toMatchObject({ failed: true, promise: undefined }); + expect(mockFetch.calls).toHaveLength(2); + + // A claimed error is let go on the next task, so a reset retries. + await vi.advanceTimersByTimeAsync(5); + expect(render().promise).toBeDefined(); + expect(mockFetch.calls).toHaveLength(3); + } finally { + await destroyOnFakeClock(); + } + }); +}); diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx index 09160e9..710387e 100644 --- a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -375,9 +375,10 @@ describe('useSuspenseQuery', () => { const screen = render(tree(true)); await expect.element(screen.getByText('Suspended')).toBeInTheDocument(); - // Navigated away while suspended; the fetch fails with no reader. + // Navigated away while suspended; the fetch fails with no reader, and no + // render claims the error within the unclaimed-failure window (1 s). screen.rerender(tree(false)); - await sleep(150); + await sleep(1_150); expect(mockFetch.calls).toHaveLength(1); mockFetch.get('/item', { name: 'recovered' }); @@ -387,6 +388,50 @@ describe('useSuspenseQuery', () => { expect(errors).toEqual([]); }); + it('throws a failed cold fetch to the boundary once when the retry render is slow to reach the reader', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { error: 'down' }, { status: 500, delay: 10 }); + const client = makeClient(mockFetch); + + class GetNoRetry extends RESTQuery { + path = '/item'; + result = { name: t.string }; + config = { retry: false }; + } + + // Siblings that take ~100 ms to render: React's time-sliced retry render + // reaches the reader long after the fetch failed. + function Slow(): React.ReactNode { + const until = performance.now() + 10; + while (performance.now() < until) { + // busy + } + return null; + } + function Item(): React.ReactNode { + const item = useSuspenseQuery(GetNoRetry); + return
{item.value.name}
; + } + + const errors: unknown[] = []; + const screen = render( + + errors.push(e)}> + Suspended}> + {Array.from({ length: 10 }, (_, i) => ( + + ))} + + + + , + ); + await expect.element(screen.getByText('Boundary caught'), { timeout: 3000 }).toBeInTheDocument(); + await sleep(300); + expect(mockFetch.calls).toHaveLength(1); + expect(errors.length).toBeGreaterThan(0); + }); + it('hands the query to the reader on commit, so unmounting deactivates it', async () => { const mockFetch = createMockFetch(); mockFetch.get('/streamed', { name: 'live' }, { delay: 10 }); From 15d0aa15d3225fee52056637917223ccacf0be17 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 21:49:36 -0400 Subject: [PATCH 050/109] chore(fetchium): changeset for the config-context and suspense-hold fixes Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/config-context-and-suspense-holds.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/config-context-and-suspense-holds.md diff --git a/.changeset/config-context-and-suspense-holds.md b/.changeset/config-context-and-suspense-holds.md new file mode 100644 index 0000000..e1c09bd --- /dev/null +++ b/.changeset/config-context-and-suspense-holds.md @@ -0,0 +1,11 @@ +--- +'fetchium': patch +--- + +Fixes for the new client options and `useSuspenseQuery`. + +- Custom `QueryClientConfig` keys still reach query code as `this.context`, including `shouldRetry`, `reactivationGraceMs` and `reactivationStaggerMs`, as every key did before those options existed. These names, `activity` and `pollResumeJitterMs` are reserved: a value under one of them is read as that option. +- `poll()` uses the context's `activity` only when it has `isActive` and `subscribe` functions. Any other value under that name (an app service passed through the config) is ignored, with a warning in development, instead of failing every polled query before its first fetch. `QueryContext` no longer declares `activity` or `pollResumeJitterMs`, so an app that augmented it with its own `activity` field compiles again. +- A non-finite `reactivationStaggerMs` or `pollResumeJitterMs` (such as `Infinity`) counts as 0 instead of scheduling a timer with an infinite delay. `reactivationGraceMs: Infinity` means "never refetch on reactivation". A `shouldRetry` that is not a function is ignored. +- `useSuspenseQuery`: a failed cold fetch whose retry render takes more than 50 ms to reach the reader (a large or time-sliced tree) reaches the error boundary instead of refetching in a loop. An error no render claims now waits 1 s, and after one more unclaimed failure the next waits 10 s. An error-boundary reset still retries. A tree abandoned while suspended and mounted again within a second of its fetch failing shows that error. Mounted later, it makes a new attempt. +- `Object.keys(this)` inside a query method no longer lists the internal `_notePush` hook. From c152d9b4f541308080ac0e5001e0698134f8cff8 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 23:07:56 -0400 Subject: [PATCH 051/109] fix(fetchium): close the timing gaps around kept, deferred and late fetches - A first fetch kept across a same-task unmount is now kept only when no subscription is running. A topic query's send() waits on the subscription the deactivation tears down, so the kept fetch could never settle and the next mount stayed pending, with refetch() a no-op. It is aborted as before and the next activation restarts it. - A refetch deferred until Signalium's outstanding flush has run (a Signal param change) now waits at most one task. settled() also waits for every flush scheduled after it, so a listener that keeps writing signals could hold the fetch back until the chain stopped. - With reactivationStaggerMs, refetches a pause or an offline switch aborted are re-settled with their cached value and spread across the window again on resume, instead of all restarting at once. - QueryClient.destroy() aborts each query's fetch in flight and cancels deferred ones, so a kept first fetch no longer writes to the store after the client is gone. - A fetchNext page for params that changed while it was in flight is dropped, as runQuery() already drops a stale first page. - An asynchronous cache load for params that changed while it was pending is not applied, and the start it schedules is skipped when the param change already fetched. A corrupt-entry discard deletes the key it loaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 1 + packages/fetchium/src/QueryResult.ts | 93 ++++++- .../src/__tests__/activation-timing.test.ts | 258 ++++++++++++++++++ 3 files changed, 342 insertions(+), 10 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 29e3e6b..c4d38ce 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1169,6 +1169,7 @@ export class QueryClient { adapter.destroy?.(); } this.adapters.clear(); + for (const instance of this.queryInstances.values()) instance.abortForDestroy(); this.queryInstances.clear(); this.mutationInstances.clear(); this.entityMap.clear(); diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index abe04fe..37e0bac 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -214,11 +214,16 @@ export class QueryInstance { const controller = this._abortController; if (controller !== undefined && !controller.signal.aborted) { + // A fetch that may be waiting on the subscription this + // deactivation tears down (a topic query's send() resolves from + // it) could never finish, so it is aborted as before and the next + // activation restarts it. const keepFirstFetch = !isPausing && controller === this.firstFetchController && startWindowOpen && - this.firstFetchWindow === startWindow; + this.firstFetchWindow === startWindow && + this.unsubscribe === undefined; if (keepFirstFetch) { // Mounted and unmounted in one task: let the first fetch finish. this.firstFetchController = undefined; @@ -283,6 +288,22 @@ export class QueryInstance { // deactivation's abort already rejected is restarted the same way, // rather than showing the AbortError until a refetch lands, and so // is a fetch still in flight for params that changed meanwhile. + // With reactivationStaggerMs, a relay holding a value that the + // abort rejected is re-settled with that value instead and + // refetches like any stale relay below, so the refetches a pause + // or offline switch aborted are still spread across the window. + if ( + this.abortedByDeactivation && + this.queryClient.reactivationStaggerMs > 0 && + !paramsDidChange && + !this.relayState.isPending + ) { + const value = this.relayState.value; + if (value !== undefined) { + this.abortedByDeactivation = false; + this.relayState.value = value; + } + } if ( (this.relayState.isPending && (this._abortController === undefined || paramsDidChange)) || this.abortedByDeactivation @@ -400,23 +421,33 @@ export class QueryInstance { this.initialized = true; let loaded: MaybePromise; + const storageKey = this.storageKey; + const fetchStarts = this.fetchStarts; try { - loaded = this.queryClient.loadCachedQuery(this.def, this.storageKey); + loaded = this.queryClient.loadCachedQuery(this.def, storageKey); } catch (error) { - this.discardCorruptCache(error); + this.discardCorruptCache(error, storageKey); loaded = undefined; } if (isThenable(loaded)) { loaded.then( cached => { - this.hydrate(cached); - this.startSubscriptionAndFetch(); + if (this.storageKey === storageKey) { + this.hydrate(cached); + this.startSubscriptionAndFetch(); + } else { + this.startAfterParamsChangedDuringLoad(fetchStarts); + } }, error => { - this.discardCorruptCache(error); - this.startSubscriptionAndFetch(); + this.discardCorruptCache(error, storageKey); + if (this.storageKey === storageKey) { + this.startSubscriptionAndFetch(); + } else { + this.startAfterParamsChangedDuringLoad(fetchStarts); + } }, ); } else { @@ -427,6 +458,15 @@ export class QueryInstance { } } + /** + * A Signal param changed while an asynchronous cache load was pending. The + * entry belongs to the old params, so it is not applied, and the change + * already fetched the new params unless nothing has started since. + */ + private startAfterParamsChangedDuringLoad(fetchStartsAtLoad: number): void { + if (this.fetchStarts === fetchStartsAtLoad) this.startSubscriptionAndFetch(); + } + private runPendingStart = (): void => { this.runStart(true); }; @@ -480,9 +520,9 @@ export class QueryInstance { this.pendingRestart?.(); } - private discardCorruptCache(error: unknown): void { + private discardCorruptCache(error: unknown, storageKey: number = this.storageKey): void { const qc = this.queryClient; - qc.store.deleteQuery(this.storageKey); + qc.store.deleteQuery(storageKey); qc.getContext().log?.warn?.('Failed to initialize query, the query cache may be corrupted or invalid', error); } @@ -696,7 +736,19 @@ export class QueryInstance { */ private deferRun(run: () => void, afterFlush: boolean): void { if (afterFlush) { - void settled().then(run); + // settled() also waits for every flush scheduled after this one, which + // a listener that writes a signal keeps doing; the timer caps the wait + // at one task. A flush runs within the task it starts in, so the + // timer still fires after the outstanding one. + let timer: ReturnType | undefined = undefined; + const once = (): void => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + run(); + }; + timer = setTimeout(once, 0); + void settled().then(once); } else { queueMicrotask(run); } @@ -797,6 +849,24 @@ export class QueryInstance { }, totalDelay); } + /** + * Called by `QueryClient.destroy()`: aborts the fetches in flight, including + * a first fetch a same-task unmount left running, and cancels deferred + * ones, so nothing reaches the store after the client is gone. + * + * @internal + */ + abortForDestroy(): void { + this.cancelDebounced(); + this.startPending = false; + this.pendingRestart = undefined; + this.firstFetchController = undefined; + this._abortController?.abort(); + this._abortController = undefined; + this._fetchNextAbort?.abort(); + this._fetchNextAbort = undefined; + } + /** Records that the subscription delivered data. See `lastPushAt`. */ private notePush = (): void => { if (this._isActive) this.lastPushAt = Date.now(); @@ -875,11 +945,14 @@ export class QueryInstance { ctx.resultData = this.rootEntity!.data; const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const attempt = this.attemptStatusTracker(ctx); + const storageKey = this.storageKey; return withRetry( async () => { attempt.start(); const freshData = await adapter.sendNext!(ctx, signal); + // As in runQuery(): a page for params that changed meanwhile. + if (this.storageKey !== storageKey) throw abortError(); this.updatedAt = Date.now(); const result = this.applyData(freshData, true, true); diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index fab5054..b716c2a 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -7,6 +7,8 @@ import { NetworkManager } from '../NetworkManager.js'; import { t } from '../typeDefs.js'; import { fetchQuery, queryKeyForClass } from '../query.js'; import { updatedAtKeyFor } from '../stores/shared.js'; +import { TopicQuery } from '../topic/TopicQuery.js'; +import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import { sleep } from './utils.js'; /** @@ -316,3 +318,259 @@ describe('Reactivating after a deactivation aborted the fetch', () => { client.destroy(); }); }); + +/** + * A socket-like topic adapter: subscribing delivers the topic's data 5 ms + * later; unsubscribing cancels that and drops the topic's state, so a send() + * still waiting on it never resolves. + */ +class SocketTopicAdapter extends TopicQueryAdapter { + private timers = new Map>(); + subscribe(topic: string): void { + this.timers.set( + topic, + setTimeout(() => this.fulfillTopic(topic, { value: `data:${topic}` }), 5), + ); + } + unsubscribe(topic: string): void { + clearTimeout(this.timers.get(topic)); + this.timers.delete(topic); + this.clearTopic(topic); + } +} + +class GetPrices extends TopicQuery { + topic = 'prices'; + result = { value: t.string }; +} + +function makeTopicClient(kv: MemoryPersistentStore): QueryClient { + return new QueryClient({ store: new SyncQueryStore(kv), adapters: [new SocketTopicAdapter()] }); +} + +describe('A topic query mounted and unmounted in one task', () => { + it('settles when it is mounted again', async () => { + const client = makeTopicClient(new MemoryPersistentStore()); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); + first(); + await sleep(50); + + const second = activate(client, () => fetchQuery(GetPrices).isPending); + await sleep(50); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: 'data:prices' }); + second(); + client.destroy(); + }); + + it('settles when it is mounted again with a cached value', async () => { + const kv = new MemoryPersistentStore(); + const seed = makeTopicClient(kv); + const seeded = activate(seed, () => fetchQuery(GetPrices).isPending); + await sleep(30); + seeded(); + seed.destroy(); + + const client = makeTopicClient(kv); + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); + first(); + await sleep(50); + + const second = activate(client, () => fetchQuery(GetPrices).isPending); + await sleep(50); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: 'data:prices' }); + second(); + client.destroy(); + }); +}); + +describe('A Signal param change while other signals keep flushing', () => { + it('refetches within a task or two, not once the flushes stop', async () => { + const f = createFetch(1); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + const id = signal('1'); + const noise = signal(0); + + const dispose = activate(client, () => fetchQuery(GetItemById, { id }).isPending); + // A listener that writes a signal another watcher reads: every flush + // schedules the next one, as a ticking price feed does. + const reader = watcher(() => noise.value); + let flushes = 0; + const stopReader = reader.addListener(() => { + if (flushes++ < 200) noise.value = noise.value + 1; + }); + await sleep(20); + + let tasks = 0; + let ticking = true; + const tick = (): void => { + if (!ticking) return; + tasks++; + setTimeout(tick, 0); + }; + setTimeout(tick, 0); + flushes = 0; + id.value = '2'; + noise.value = noise.value + 1; + + let startedAt = -1; + for (let i = 0; i < 400 && startedAt === -1; i++) { + await sleep(0); + if (f.calls.length === 2) startedAt = tasks; + } + ticking = false; + + expect(f.paths()).toEqual(['/items/1', '/items/2']); + expect(startedAt).toBeLessThanOrEqual(3); + expect(flushes).toBeLessThan(200); + stopReader(); + dispose(); + client.destroy(); + }); +}); + +describe('reactivationStaggerMs and refetches a pause aborted', () => { + it('spreads the restarted refetches across the window on reconnect', async () => { + const starts: number[] = []; + let t0 = 0; + const f = createFetch(10); + const fetch = (url: string, options?: RequestInit) => { + starts.push(performance.now() - t0); + return f.fetch(url, options); + }; + const networkManager = new NetworkManager(true); + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: fetch as any, baseUrl: 'http://localhost' })], + networkManager, + reactivationStaggerMs: 300, + }); + + const relays: any[] = []; + const disposers = ['1', '2', '3', '4'].map((id, i) => + activate(client, () => (relays[i] = fetchQuery(GetItemById, { id })).isPending), + ); + await sleep(30); + for (const relay of relays) relay.value.__refetch(); + await sleep(2); + networkManager.setNetworkStatus(false); + await sleep(20); + + t0 = performance.now(); + starts.length = 0; + networkManager.setNetworkStatus(true); + await sleep(400); + + expect(starts).toHaveLength(4); + expect(Math.max(...starts) - Math.min(...starts)).toBeGreaterThan(100); + for (const relay of relays) expect(relay.isRejected).toBe(false); + for (const dispose of disposers) dispose(); + client.destroy(); + }); +}); + +describe('Responses that arrive after the client or the params moved on', () => { + it('writes nothing to the store after destroy(), including a same-task unmount kept first fetch', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(20); + const client = makeClient(kv, f.fetch); + + const dispose = activate(client, () => fetchQuery(GetItem).isPending); + dispose(); + await sleep(0); + client.destroy(); + await sleep(60); + + expect(f.paths()).toEqual(['/item(aborted)']); + expect(kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItem, undefined)))).toBeUndefined(); + }); + + it('drops a fetchNext page for params that changed while it was in flight', async () => { + class GetList extends RESTQuery { + params = { id: t.string }; + path = `/lists/${this.params.id}`; + searchParams = { page: 1 }; + result = { items: t.array(t.string), next: t.optional(t.number) }; + fetchNext = { searchParams: { page: this.result.next } }; + } + const calls: string[] = []; + const fetch = (url: string): Promise => { + const u = new URL(url); + const page = u.searchParams.get('page') ?? '1'; + calls.push(`${u.pathname}#p${page}`); + const body = { items: [`${u.pathname}#p${page}`], next: page === '1' ? 2 : undefined }; + // Ignores the abort signal. + return new Promise(resolve => + setTimeout( + () => + resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response), + page === '1' ? 1 : 20, + ), + ); + }; + const kv = new MemoryPersistentStore(); + const client = makeClient(kv, fetch); + const id = signal('1'); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetList, { id })).isPending); + await sleep(15); + const next = relay.value.__fetchNext().catch((error: Error) => error.name); + await sleep(2); + id.value = '2'; + await sleep(60); + + expect(await next).toBe('AbortError'); + expect(relay.value.items).toEqual(['/lists/2#p1']); + dispose(); + client.destroy(); + }); + + it('does not apply an asynchronous cache load for params that changed while it was pending', async () => { + class SlowLoadStore extends SyncQueryStore { + override loadQuery(...args: Parameters): any { + const loaded = super.loadQuery(...args); + return new Promise(resolve => setTimeout(() => resolve(loaded), 30)); + } + } + const kv = new MemoryPersistentStore(); + { + const seed = makeClient(kv, createFetch(1).fetch); + const seeded = activate(seed, () => fetchQuery(GetItemById, { id: '1' }).isPending); + await sleep(20); + seeded(); + seed.destroy(); + } + const f = createFetch(5); + const client = new QueryClient({ + store: new SlowLoadStore(kv), + adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })], + }); + const id = signal('1'); + + let relay: any; + const values: (string | undefined)[] = []; + const dispose = activate(client, () => { + relay = fetchQuery(GetItemById, { id }); + values.push(relay.value?.value); + }); + await sleep(2); + id.value = '2'; + await sleep(80); + + expect(f.paths()).toEqual(['/items/2']); + expect(values).not.toContain('/items/1'); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + dispose(); + client.destroy(); + }); +}); From 68e8f9b1b875e495b9416eeec93083336bbc91b4 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 23:34:00 -0400 Subject: [PATCH 052/109] perf(fetchium): hand out a new wrapper for a changed nested value instead of tracking reads Reads through an entity's nested object, record or array consumed a notifier per wrapper, which made every such read, render and reactive() reads included, 14-24% slower inside a computation and allocated a handler and a notifier per wrapper. A merge that changes a nested value in place now drops the wrapper it was handed out through. The next read through the entity, which the entity's notifier already re-runs, hands out a new wrapper, so a child given only the nested value (a component() or React.memo child given token.price) sees a new prop and re-renders. An unchanged nested value keeps its wrapper, so the child is left alone when only another field changes. Entity wrappers go back to the shared handlers and consume nothing. A live collection's value still changes in place under the collection's notifier, so its wrappers keep consuming that notifier (it already exists, nothing is allocated per wrapper). The wrapper map is also read through a module-local binding: under vitest's module transform an imported binding is a property load on every read. With both changes the nested-read bench is back at the numbers it had before nested values were made reactive. The React.memo caveat test in component.test.tsx now expects the child to show the new value: the stale display it documented is fixed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/nested-value-reactivity.md | 2 +- packages/fetchium/src/EntityInstance.ts | 120 +++++++++++------- .../__tests__/nested-value-reactivity.test.ts | 47 ++++--- packages/fetchium/src/applyEntities.ts | 11 +- packages/fetchium/src/nestedNotifiers.ts | 28 ++-- .../src/react/__tests__/component.test.tsx | 13 +- 6 files changed, 128 insertions(+), 93 deletions(-) diff --git a/.changeset/nested-value-reactivity.md b/.changeset/nested-value-reactivity.md index 4d482b4..017358c 100644 --- a/.changeset/nested-value-reactivity.md +++ b/.changeset/nested-value-reactivity.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Nested values passed on their own stay reactive. A `t.object` or `t.record` field, and a live collection's value, keep their identity when an update changes them, because they are merged in place. A component (or reactive function) that received only the nested value, such as a `component()` child given `token.price` as a prop, read it without depending on anything, so it kept showing the old value whenever it was not re-rendered by its parent, which happens whenever its props are identical. Reads through a nested value now depend on that value: the in-place merge notifies it, and a live collection's value follows the collection. A change to another field of the entity does not re-run readers that hold only the nested value. +Nested values passed on their own stay current. A `t.object` or `t.record` field, and a live collection's value, are merged in place when an update changes them. A component that received only the nested value, such as a `component()` child given `token.price` as a prop (or a `React.memo` child), was handed the same wrapper after the change, so once its parent stopped re-rendering it for identical props it kept showing the old value. A merge that changes a nested value in place now drops the wrapper it was handed out through: the next read through the entity, which re-renders the parent, hands out a new one, so the child sees a new prop and re-renders. An unchanged nested value keeps its wrapper, so a change to another field of the entity does not re-render a child that holds only the nested value. Reads through a live collection's value depend on the collection, which an event or a reducer already notifies. Reads through an entity's nested values cost nothing extra; a wrapper someone kept from before the change still reads the merged data but does not re-run anything. diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index bcf87a7..69c909b 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -6,7 +6,7 @@ import { type EntityDef, Mask } from './types.js'; import { GcKeyType } from './GcManager.js'; import { Entity } from './proxy.js'; import { PROXY_ID } from './proxyId.js'; -import { NESTED_WRAPPERS, type NestedWrapper } from './nestedNotifiers.js'; +import { NESTED_WRAPPERS } from './nestedNotifiers.js'; import type { QueryClient } from './QueryClient.js'; import { ValidatorDef, WRAPPED_VALUE } from './typeDefs.js'; import type { LiveCollectionBinding } from './LiveCollection.js'; @@ -18,6 +18,8 @@ import { entitySatisfiesShape } from './parseEntities.js'; // ====================================================== const ObjectProto = Object.prototype; +// A module-local binding: read on every nested read. +const nestedWrappers = NESTED_WRAPPERS; /** A `WRAPPED_VALUE` item. A live collection's value changes under its own notifier. */ interface WrappedValue { @@ -27,10 +29,13 @@ interface WrappedValue { /** * `owner` is the notifier of a live collection whose value this is (or is - * inside): a reducer can change such a value in place without telling - * anything else. Without one, the value is an entity's nested object, record - * or array, and its wrapper gets a notifier of its own that the in-place - * merge notifies. + * inside): a reducer or an event can change such a value in place, so reads + * through its wrapper consume that notifier. Without one, the value is an + * entity's nested object, record or array. Its wrapper consumes nothing: a + * merge that changes it in place drops the wrapper (`dropNestedWrapper`), so + * the next read through the entity, which the entity's notifier re-runs, + * hands out a new one. Whoever was given only the nested value (a child + * component given `entity.price` as a prop) then sees a new value too. */ function wrapValue(value: unknown, owner: Notifier | undefined): unknown { if (typeof value !== 'object' || value === null) return value; @@ -41,49 +46,86 @@ function wrapValue(value: unknown, owner: Notifier | undefined): unknown { if (PROXY_ID.has(value as object)) return value; if (Array.isArray(value)) { - let wrapper = NESTED_WRAPPERS.get(value); + let wrapper = nestedWrappers.get(value); if (wrapper === undefined) { - wrapper = new ArrayWrappingHandler(value, owner); - NESTED_WRAPPERS.set(value, wrapper); + wrapper = new Proxy(value, owner === undefined ? arrayWrappingHandler : new OwnedArrayHandler(owner)); + nestedWrappers.set(value, wrapper); } - return wrapper.proxy; + return wrapper; } if (Object.getPrototypeOf(value) === ObjectProto) { - let wrapper = NESTED_WRAPPERS.get(value); + let wrapper = nestedWrappers.get(value); if (wrapper === undefined) { - wrapper = new ObjectWrappingHandler(value as Record, owner); - NESTED_WRAPPERS.set(value, wrapper); + wrapper = new Proxy( + value as Record, + owner === undefined ? objectWrappingHandler : new OwnedObjectHandler(owner), + ); + nestedWrappers.set(value, wrapper); } - return wrapper.proxy; + return wrapper; } return value; } +const arrayWrappingHandler: ProxyHandler = { + get(target, prop, receiver) { + if (typeof prop === 'string') { + const idx = Number(prop); + if (Number.isInteger(idx) && idx >= 0 && idx < target.length) { + return wrapValue(target[idx], undefined); + } + } + return Reflect.get(target, prop, receiver); + }, + set() { + if (IS_DEV) throw new Error('Cannot mutate a read-only array'); + return false; + }, + deleteProperty() { + if (IS_DEV) throw new Error('Cannot mutate a read-only array'); + return false; + }, +}; + +const objectWrappingHandler: ProxyHandler> = { + get(target, prop, receiver) { + if (typeof prop === 'string') { + return wrapValue(target[prop], undefined); + } + return Reflect.get(target, prop, receiver); + }, + set() { + if (IS_DEV) throw new Error('Cannot mutate a read-only object'); + return false; + }, + deleteProperty() { + if (IS_DEV) throw new Error('Cannot mutate a read-only object'); + return false; + }, + has(target, prop) { + return prop in target; + }, + ownKeys(target) { + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, prop) { + return Object.getOwnPropertyDescriptor(target, prop); + }, +}; + /** - * Each wrapper is its own proxy handler. The traps consume `changes`, which - * fires when the wrapped value's contents change in place: something that - * holds only the wrapper (a child component given `entity.price` as a prop) - * has no other dependency that would re-run it, and the wrapper keeps its - * identity across the change. Values read through the wrapper inherit - * `owner`, a live collection's notifier, or get their own. + * The wrapper of a value inside a live collection: its traps consume the + * collection's notifier, and values read through it inherit that owner. */ -class ArrayWrappingHandler implements ProxyHandler, NestedWrapper { - readonly proxy: unknown[]; - changes: Notifier | undefined; - readonly owner: Notifier | undefined; - - constructor(target: unknown[], owner: Notifier | undefined) { - this.changes = owner; - this.owner = owner; - this.proxy = new Proxy(target, this); - } +class OwnedArrayHandler implements ProxyHandler { + constructor(readonly owner: Notifier) {} get(target: unknown[], prop: string | symbol, receiver: unknown): unknown { // Every read, `length` and the iteration methods included: an in-place // push changes what all of them return. - (this.changes ??= notifier()).consume(); + this.owner.consume(); if (typeof prop === 'string') { const idx = Number(prop); if (Number.isInteger(idx) && idx >= 0 && idx < target.length) { @@ -104,20 +146,12 @@ class ArrayWrappingHandler implements ProxyHandler, NestedWrapper { } } -class ObjectWrappingHandler implements ProxyHandler>, NestedWrapper { - readonly proxy: Record; - changes: Notifier | undefined; - readonly owner: Notifier | undefined; - - constructor(target: Record, owner: Notifier | undefined) { - this.changes = owner; - this.owner = owner; - this.proxy = new Proxy(target, this); - } +class OwnedObjectHandler implements ProxyHandler> { + constructor(readonly owner: Notifier) {} get(target: Record, prop: string | symbol, receiver: unknown): unknown { if (typeof prop === 'string') { - (this.changes ??= notifier()).consume(); + this.owner.consume(); return wrapValue(target[prop], this.owner); } return Reflect.get(target, prop, receiver); @@ -135,12 +169,12 @@ class ObjectWrappingHandler implements ProxyHandler>, Ne // A live value's reducer can add or remove keys in place. has(target: Record, prop: string | symbol): boolean { - (this.changes ??= notifier()).consume(); + this.owner.consume(); return prop in target; } ownKeys(target: Record): ArrayLike { - (this.changes ??= notifier()).consume(); + this.owner.consume(); return Reflect.ownKeys(target); } diff --git a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts index 3c430bb..57c1835 100644 --- a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts +++ b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts @@ -9,10 +9,13 @@ import { testWithClient, sleep, setupTestClient } from './utils.js'; /** * Nested values (`t.object` and `t.record` fields, live collection values) are - * merged in place and handed out through wrappers that keep their identity. A - * computation that only holds the wrapper, such as a child component given - * `entity.price` as a prop, never reads the entity, so reads through the - * wrapper must depend on whatever notifies when its contents change. + * merged in place. A computation that only holds the nested value, such as a + * child component given `entity.price` as a prop, never reads the entity: + * - an entity's nested value is handed out through a new wrapper once a merge + * changed it, so the parent, which the entity re-runs, passes a new value; + * an unchanged one keeps its wrapper, so the child is left alone; + * - a live collection's value changes in place under the collection's + * notifier, which reads through its wrapper consume. */ describe('reads through a held nested value', () => { const getClient = setupTestClient(); @@ -30,7 +33,7 @@ describe('reads through a held nested value', () => { result = t.entity(Token); } - it('re-run when an in-place merge changes a t.object field, a nested t.object field or a t.record value', async () => { + it('hand out a new wrapper once an in-place merge changes a t.object field, a nested t.object field or a t.record value', async () => { const { client, mockFetch } = getClient(); mockFetch.get('/token/[id]', { __typename: 'Token', @@ -52,23 +55,28 @@ describe('reads through a held nested value', () => { const price = token.price; const change = token.price.change; const balances = token.balances; + expect([price.usd, change.h24, balances.SOL]).toEqual([100, 1, 1]); - const usd = reactive(() => price.usd); - const h24 = reactive(() => change.h24); - const sol = reactive(() => balances.SOL); + // Readers through the entity re-run. + const usd = reactive(() => token.price.usd); + const h24 = reactive(() => token.price.change.h24); + const sol = reactive(() => token.balances.SOL); expect([usd(), h24(), sol()]).toEqual([100, 1, 1]); await token.__refetch(); await sleep(0); - // Still the same wrappers: the merge happened in place. - expect(token.price).toBe(price); - expect(token.balances).toBe(balances); expect([usd(), h24(), sol()]).toEqual([150, 2, 5]); + // Changed in place: new wrappers, so a holder of the old one is handed a new value. + expect(token.price).not.toBe(price); + expect(token.price.change).not.toBe(change); + expect(token.balances).not.toBe(balances); + // The old wrappers still read the merged data. + expect([price.usd, change.h24, balances.SOL]).toEqual([150, 2, 5]); }); }); - it('do not re-run when only another field of the entity changes', async () => { + it('keep the wrapper of a nested value no merge changed', async () => { const { client, mockFetch } = getClient(); class Quote extends Entity { @@ -109,22 +117,19 @@ describe('reads through a held nested value', () => { const quote = relay.value!; const price = quote.price; const change = quote.price.change; - const runs = { usd: 0, h24: 0 }; - const usd = reactive(() => (runs.usd++, price.usd)); - const h24 = reactive(() => (runs.h24++, change.h24)); - expect([usd(), h24()]).toEqual([100, 1]); await quote.__refetch(); await sleep(0); expect(quote.symbol).toBe('B'); - expect([usd(), h24()]).toEqual([100, 1]); - expect(runs).toEqual({ usd: 1, h24: 1 }); + expect(quote.price).toBe(price); + expect(quote.price.change).toBe(change); - // A change to `price.usd` re-runs readers of `price`, not of `price.change`. + // A change to `price.usd` replaces the wrapper of `price`, not of `price.change`. await quote.__refetch(); await sleep(0); - expect([usd(), h24()]).toEqual([120, 1]); - expect(runs).toEqual({ usd: 2, h24: 1 }); + expect(quote.price.usd).toBe(120); + expect(quote.price).not.toBe(price); + expect(quote.price.change).toBe(change); }); }); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 86652ec..7dd3e58 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -14,7 +14,7 @@ import { FormattedValue, ValidatorDef } from './typeDefs.js'; import { Mask } from './types.js'; import { createLiveCollection, LiveCollectionBinding } from './LiveCollection.js'; import { PROXY_ID } from './proxyId.js'; -import { notifyNestedValue } from './nestedNotifiers.js'; +import { dropNestedWrapper } from './nestedNotifiers.js'; const entries = Object.entries; const ObjectProto = Object.prototype; @@ -351,9 +351,10 @@ function mergeFields( ) ) { changed = true; - // Merged in place, so the value keeps its identity: tell whoever - // holds just this value (a child given it as a prop). - if (target === oldVal) notifyNestedValue(oldVal); + // Merged in place, so the value keeps its identity: hand out a + // new wrapper, so whoever holds just this value (a child given it + // as a prop) sees a new one. A merged copy is a new value already. + if (target === oldVal) dropNestedWrapper(oldVal); existingData[fieldKey] = target; } else { existingData[fieldKey] = oldVal; @@ -370,7 +371,7 @@ function mergeFields( } if (merged) { changed = true; - if (target === oldVal) notifyNestedValue(oldVal); + if (target === oldVal) dropNestedWrapper(oldVal); } existingData[fieldKey] = merged ? target : oldVal; } else { diff --git a/packages/fetchium/src/nestedNotifiers.ts b/packages/fetchium/src/nestedNotifiers.ts index 35f687a..d79fc6f 100644 --- a/packages/fetchium/src/nestedNotifiers.ts +++ b/packages/fetchium/src/nestedNotifiers.ts @@ -1,21 +1,17 @@ -import type { Notifier } from 'signalium'; - /** * The wrapping proxy handed out for a nested object, record or array inside - * an entity, keyed by the wrapped value. Nested values are merged in place, - * so a wrapper keeps its identity when its contents change; reads through it - * consume `changes`, and the merge notifies it. `EntityInstance` creates the - * wrappers, `applyEntities` notifies them. + * an entity (or a live collection's value), keyed by the wrapped value. + * `EntityInstance` creates the wrappers; `applyEntities` drops an entity's + * nested wrapper when a merge changes the value in place. */ -export interface NestedWrapper { - readonly proxy: object; - /** Created by the first read; nothing to notify before that. */ - changes: Notifier | undefined; -} - -export const NESTED_WRAPPERS = new WeakMap(); +export const NESTED_WRAPPERS = new WeakMap(); -/** Tell readers of a nested value that a merge changed it in place. */ -export function notifyNestedValue(value: object): void { - NESTED_WRAPPERS.get(value)?.changes?.notify(); +/** + * A merge changed a nested value in place. Its wrapper is dropped, so the + * next read through the entity hands out a new one: the value is merged in + * place to keep the raw data's identity, but whoever holds only the wrapper + * (a child component given it as a prop) must see a new value to re-render. + */ +export function dropNestedWrapper(value: object): void { + NESTED_WRAPPERS.delete(value); } diff --git a/packages/fetchium/src/react/__tests__/component.test.tsx b/packages/fetchium/src/react/__tests__/component.test.tsx index b53168b..32f1ae6 100644 --- a/packages/fetchium/src/react/__tests__/component.test.tsx +++ b/packages/fetchium/src/react/__tests__/component.test.tsx @@ -753,7 +753,7 @@ describe('React Query Integration with component()', () => { expect(getByTestId('author-name').element().textContent).toBe('Alice'); }); - it('passing a nested plain object to a React.memo child shows stale data after refetch', async () => { + it('passing a nested plain object to a React.memo child shows the new data after refetch', async () => { class User extends Entity { __typename = t.typename('User'); id = t.id; @@ -805,12 +805,11 @@ describe('React Query Integration with component()', () => { await userQuery!.value!.__refetch(); await sleep(10); - // mergeFields mutates the existing nested plain object in place, and - // wrapValue caches the wrapping proxy by source object — so the wrapper - // ref handed to the memo'd child is identical across refetches. The - // child does not re-render, the displayed bio stays stale. - expect(memoRenderCount).toBe(1); - expect(getByTestId('bio').element().textContent).toBe('old bio'); + // mergeFields mutates the existing nested plain object in place, but a + // merge that changed it drops its cached wrapper, so the parent hands + // the memo'd child a new prop and the child re-renders. + expect(memoRenderCount).toBe(2); + expect(getByTestId('bio').element().textContent).toBe('new bio'); }); it('wrapping the child in component() instead of React.memo restores correct re-renders', async () => { From cf3eb395cb53b5c1194a23199c7dee986bef58af Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Mon, 5 Oct 2026 23:34:00 -0400 Subject: [PATCH 053/109] chore(fetchium): changeset for the round-one timing fixes Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 76fd129..975b847 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -10,3 +10,9 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. +- A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. +- The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). +- With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. +- `QueryClient.destroy()` aborts every query's fetch in flight and cancels deferred ones, so nothing is written to the store after it (including a first fetch a same-task unmount left running, and a response that a replacement client sharing the store would otherwise be overwritten by). +- A `__fetchNext()` page for params that changed while it was in flight is dropped, like a stale first page. +- With an asynchronous store, a Signal param change made while the cache load is pending no longer applies the old params' cached entry, and does not send the new params' request twice. From 05c5b45e3761c651c34093d94e253bdfed19cfd4 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 00:32:04 -0400 Subject: [PATCH 054/109] fix(fetchium): settle every awaiter when destroy(), an offline restart or GC cancels a query's fetch A correctness review of the timing fixes found awaiters of a query's relay left waiting forever, and a collected query whose fetch kept running: - destroy() cancelled a first start or a restart that had not run yet, so nothing ever settled the relay. A queued restart now runs against its aborted controller and rejects; a relay pending with no fetch at all is rejected with an AbortError. - A restart that found the network offline left the relay pending with no fetch. If the query was then unmounted, the deactivation had nothing to abort, and the relay stayed pending until a later mount while online. The restart's rejection is now kept, and a full deactivation rejects it (and restarts on the next activation), as the abort it replaced would have. Coming back online still restarts it. - With a real GC manager (browser, React Native), a gcTime: 0 query mounted and unmounted in one task was collected while its kept first fetch was in flight. The fetch then applied to an instance nothing owned: its entities stayed resident and its store write ignored destroy(). Collection is now scheduled when that fetch settles, and a collected query has its work cancelled and its relay settled. - TopicQueryAdapter.send() ignored the abort signal, so whoever awaited a topic query mounted and unmounted in one task waited for the next mount. It now rejects when the signal aborts. Nothing here runs on a render, an apply or a successful fetch, apart from one signal compare in runQuery()'s finally. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 6 +- packages/fetchium/src/QueryClient.ts | 2 + packages/fetchium/src/QueryResult.ts | 72 +++++++- .../src/__tests__/activation-timing.test.ts | 162 +++++++++++++++++- .../fetchium/src/topic/TopicQueryAdapter.ts | 30 +++- 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 975b847..61df14c 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -10,9 +10,11 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. -- A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. +- A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. `TopicQueryAdapter.send()` now honours the abort signal, so whoever awaits that first fetch gets the `AbortError` instead of waiting for the next mount (a bug in every earlier version). - The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). - With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. -- `QueryClient.destroy()` aborts every query's fetch in flight and cancels deferred ones, so nothing is written to the store after it (including a first fetch a same-task unmount left running, and a response that a replacement client sharing the store would otherwise be overwritten by). +- `QueryClient.destroy()` aborts every query's fetch in flight and cancels deferred ones, so nothing is written to the store after it (including a first fetch a same-task unmount left running, and a response that a replacement client sharing the store would otherwise be overwritten by). Whoever awaits a query it cancelled gets an `AbortError`, including a query whose first start or restart had not run yet. +- A restart that finds the network offline leaves the relay pending until the network returns, as before; if the query is unmounted first, the relay rejects with an `AbortError` and the next mount refetches, instead of leaving an awaiter waiting for that mount. +- A query whose first fetch a same-task unmount left running is collected (`gcTime`) once that fetch settles, not while it is still in flight: with `gcTime: 0` in a browser or React Native, collecting it earlier left the fetch's entities resident for good and its store write ignoring `destroy()`. A query the client collects has its work cancelled and its relay settled. - A `__fetchNext()` page for params that changed while it was in flight is dropped, like a stale first page. - With an asynchronous store, a Signal param change made while the cache load is pending no longer applies the old params' cached entry, and does not send the new params' request twice. diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index c4d38ce..3570949 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1097,6 +1097,8 @@ export class QueryClient { const instance = this.queryInstances.get(key); if (instance === undefined) return; instance.stopSubscription(); + // Nothing may settle its relay or reach the store after this. + instance.abortForDestroy(); instance.rootEntity?.evict(); this.queryInstances.delete(key); return; diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 37e0bac..16d4f28 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -96,6 +96,18 @@ export class QueryInstance { private startPending: boolean = false; /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ private pendingRestart: (() => void) | undefined = undefined; + /** + * The rejection of a restart that found the query offline (or inactive) + * when it ran. The relay stays pending on its promise until coming back + * online restarts it, or a full deactivation rejects it. + */ + private parkedRestart: ((error: unknown) => void) | undefined = undefined; + /** + * The signal of a first fetch a same-task unmount let finish. Its query is + * scheduled for collection once that fetch settles, not before: collecting + * it earlier would leave the fetch applying to an instance nothing owns. + */ + private keptFetchSignal: AbortSignal | undefined = undefined; /** Some param is a Signal, so its value can change before the first start runs. */ private hasSignalParams: boolean = false; /** The first fetch's controller and its start window. See `openStartWindow()`. */ @@ -227,6 +239,7 @@ export class QueryInstance { if (keepFirstFetch) { // Mounted and unmounted in one task: let the first fetch finish. this.firstFetchController = undefined; + if (this.relayState.isPending) this.keptFetchSignal = controller.signal; } else { if (this.relayState.isPending) this.abortedByDeactivation = true; controller.abort(); @@ -244,8 +257,19 @@ export class QueryInstance { if (isPausing) return; - const gcTime = this.config?.gcTime ?? DEFAULT_GC_TIME; - this.queryClient.gcManager.schedule(this.queryKey, gcTime, GcKeyType.Query); + // A restart parked while offline has no fetch for an abort to + // reject: settle it as the abort would have, and restart on the next + // activation. + const parked = this.parkedRestart; + if (parked !== undefined) { + this.parkedRestart = undefined; + this.abortedByDeactivation = true; + parked(abortError()); + } + + // A kept first fetch schedules collection when it settles. + if (this.keptFetchSignal !== undefined) return; + this.scheduleGc(); }; const update = (activating: boolean = false) => { @@ -258,6 +282,7 @@ export class QueryInstance { } this._isActive = true; + this.keptFetchSignal = undefined; const newExtractedParams = extractParamsForKey(this.params); const newStorageKey = queryKeyFor(this.def, newExtractedParams); @@ -670,9 +695,19 @@ export class QueryInstance { } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; throw error; + } finally { + if (signal === this.keptFetchSignal) { + this.keptFetchSignal = undefined; + if (!this._isActive) this.scheduleGc(); + } } } + private scheduleGc(): void { + const gcTime = this.config?.gcTime ?? DEFAULT_GC_TIME; + this.queryClient.gcManager.schedule(this.queryKey, gcTime, GcKeyType.Query); + } + /** * Replaces a fetch that a deactivation aborted, from inside the activating * read. The relay takes the new promise now, so the doomed one can no longer @@ -681,6 +716,7 @@ export class QueryInstance { */ private restartAbortedFetch(afterFlush: boolean = false): void { this.fetchStarts++; + this.parkedRestart = undefined; if (this.abortedByDeactivation) { this.abortedByDeactivation = false; // The abort rejected the relay (it is no longer pending). Signalium @@ -714,8 +750,10 @@ export class QueryInstance { } if (!this._isActive || this.isPaused) { // Went offline before it ran. Leave the relay pending with no - // controller: coming back online restarts it. + // controller: coming back online restarts it, and a full + // deactivation before that rejects it. if (this._abortController === controller) this._abortController = undefined; + this.parkedRestart = reject; return; } this.runQuery().then(resolve, reject); @@ -757,6 +795,7 @@ export class QueryInstance { private runQueryImmediately(): void { this.fetchStarts++; this.abortedByDeactivation = false; + this.parkedRestart = undefined; this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); @@ -850,21 +889,38 @@ export class QueryInstance { } /** - * Called by `QueryClient.destroy()`: aborts the fetches in flight, including - * a first fetch a same-task unmount left running, and cancels deferred - * ones, so nothing reaches the store after the client is gone. + * Called by `QueryClient.destroy()`, and when the client collects the + * query: aborts the fetches in flight, including a first fetch a same-task + * unmount left running, cancels deferred ones and settles the relay, so + * nothing reaches the store after the client is gone and no awaiter waits + * forever. * * @internal */ abortForDestroy(): void { this.cancelDebounced(); this.startPending = false; - this.pendingRestart = undefined; this.firstFetchController = undefined; - this._abortController?.abort(); + this.keptFetchSignal = undefined; + const controller = this._abortController; + const inFlight = controller !== undefined && !controller.signal.aborted; + controller?.abort(); this._abortController = undefined; this._fetchNextAbort?.abort(); this._fetchNextAbort = undefined; + // Whoever awaits the relay must still get an answer. A queued restart + // sees its aborted controller and rejects; a parked one is rejected here; + // a fetch in flight rejects with its abort. A relay left pending with no + // fetch at all (its first start never ran) is rejected directly. + const restart = this.pendingRestart; + restart?.(); + this.pendingRestart = undefined; + const parked = this.parkedRestart; + this.parkedRestart = undefined; + parked?.(abortError()); + if (!inFlight && restart === undefined && parked === undefined && this._relayState?.isPending) { + this._relayState.setError(abortError()); + } } /** Records that the subscription delivered data. See `lastPushAt`. */ diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index b716c2a..74e2a28 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { signal, watcher, withContexts } from 'signalium'; import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; @@ -9,7 +9,8 @@ import { fetchQuery, queryKeyForClass } from '../query.js'; import { updatedAtKeyFor } from '../stores/shared.js'; import { TopicQuery } from '../topic/TopicQuery.js'; import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; -import { sleep } from './utils.js'; +import { Entity } from '../proxy.js'; +import { getEntityMapSize, sleep } from './utils.js'; /** * The first fetch and zero-delay refetches start on a microtask, ahead of @@ -82,6 +83,17 @@ function activate(client: QueryClient, read: () => unknown): () => void { return dispose; } +/** How `relay` settles: 'resolved', 'rejected:', or 'pending' if it has not within `ms`. */ +async function outcome(relay: PromiseLike, ms: number = 200): Promise { + return Promise.race([ + Promise.resolve(relay).then( + () => 'resolved', + (error: any) => `rejected:${error?.name ?? error}`, + ), + sleep(ms).then(() => 'pending'), + ]); +} + function state(relay: any) { return { isPending: relay.isPending as boolean, @@ -384,6 +396,152 @@ describe('A topic query mounted and unmounted in one task', () => { second(); client.destroy(); }); + + it('settles an awaiter of the first mount without another mount', async () => { + const client = makeTopicClient(new MemoryPersistentStore()); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); + first(); + expect(await outcome(relay)).toBe('rejected:AbortError'); + client.destroy(); + }); +}); + +describe('Every awaiter settles', () => { + it('when destroy() runs in the task the query mounts, before its first start', async () => { + const { fetch, calls } = createFetch(20); + const client = makeClient(new MemoryPersistentStore(), fetch); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + client.destroy(); + expect(await outcome(relay)).toBe('rejected:AbortError'); + expect(calls).toEqual([]); + dispose(); + }); + + it('when destroy() runs while a restart after a deactivation abort is queued', async () => { + const { fetch } = createFetch(20); + const client = makeClient(new MemoryPersistentStore(), fetch); + + const first = activate(client, () => fetchQuery(GetItem).isPending); + await sleep(5); + first(); // aborts the fetch in flight + await sleep(5); + let relay: any; + const second = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + client.destroy(); + expect(await outcome(relay)).toBe('rejected:AbortError'); + second(); + }); + + it('when a restart found the network offline and the query is then unmounted', async () => { + const { fetch, paths } = createFetch(30); + const networkManager = new NetworkManager(true); + const client = makeClient(new MemoryPersistentStore(), fetch, networkManager); + + const first = activate(client, () => fetchQuery(GetItem).isPending); + await sleep(5); + first(); // aborts the fetch in flight + await sleep(5); + let relay: any; + const second = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + networkManager.setNetworkStatus(false); // the restart finds the query offline + await sleep(5); + second(); + expect(await outcome(relay)).toBe('rejected:AbortError'); + + // Mounted again online, it fetches. + networkManager.setNetworkStatus(true); + const third = activate(client, () => fetchQuery(GetItem).isPending); + await sleep(50); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); + expect(paths()).toEqual(['/item(aborted)', '/item']); + third(); + client.destroy(); + }); +}); + +describe('A gcTime: 0 query whose kept first fetch outlives the unmount', () => { + // Node clients use a no-op GC manager; a browser or React Native client gets the real one. + const hadWindow = 'window' in globalThis; + afterEach(() => { + if (!hadWindow) delete (globalThis as any).window; + }); + + class Thing extends Entity { + __typename = t.typename('Thing'); + id = t.id; + name = t.string; + } + class GetThing extends RESTQuery { + path = '/thing'; + result = { thing: t.entity(Thing) }; + config = { gcTime: 0 }; + } + + function createThingFetch(delay: number) { + let calls = 0; + const fetch = (_url: string, options: RequestInit = {}): Promise => { + calls++; + const body = { thing: { __typename: 'Thing', id: '1', name: 'a' } }; + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + resolve({ ok: true, status: 200, headers: new Headers(), json: async () => body } as unknown as Response), + delay, + ); + (options.signal as AbortSignal | undefined)?.addEventListener('abort', () => { + clearTimeout(timer); + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); + }; + return { fetch, calls: () => calls }; + } + + it('is collected once the fetch lands, leaving nothing resident; a remount before that reuses the fetch', async () => { + if (!hadWindow) (globalThis as any).window = globalThis; + const { fetch, calls } = createThingFetch(20); + const client = makeClient(new MemoryPersistentStore(), fetch); + expect((client as any).isServer).toBe(false); + + const first = activate(client, () => fetchQuery(GetThing).isPending); + first(); + await sleep(5); + expect((client as any).queryInstances.size).toBe(1); + const second = activate(client, () => fetchQuery(GetThing).isPending); + await sleep(40); + second(); + await sleep(20); + expect(calls()).toBe(1); + expect((client as any).queryInstances.size).toBe(0); + expect(getEntityMapSize(client)).toBe(0); + + const third = activate(client, () => fetchQuery(GetThing).isPending); + third(); + await sleep(60); + expect((client as any).queryInstances.size).toBe(0); + expect(getEntityMapSize(client)).toBe(0); + client.destroy(); + }); + + it('writes nothing after destroy()', async () => { + if (!hadWindow) (globalThis as any).window = globalThis; + const { fetch } = createThingFetch(20); + const kv = new MemoryPersistentStore(); + const client = makeClient(kv, fetch); + + const first = activate(client, () => fetchQuery(GetThing).isPending); + first(); + await sleep(5); + client.destroy(); + await sleep(40); + expect(kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetThing, undefined)))).toBeUndefined(); + }); }); describe('A Signal param change while other signals keep flushing', () => { diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index 097b55e..b456aab 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -21,6 +21,30 @@ interface TopicState { error?: unknown; } +/** + * `promise`, or a rejection once `signal` aborts. A topic's data comes from + * its subscription, which the query's deactivation tears down: a fetch that + * deactivation aborted would otherwise wait for data that never comes, and + * so would whoever awaits the query. + */ +function untilAborted(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + value => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + error => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + export abstract class TopicQueryAdapter extends QueryAdapter { private _topics = new Map(); /** Per topic, the subscribed queries' callbacks for a delivered event. */ @@ -92,7 +116,7 @@ export abstract class TopicQueryAdapter extends QueryAdapter { this._topics.clear(); } - override async send(ctx: Query, _signal: AbortSignal): Promise { + override async send(ctx: Query, signal: AbortSignal): Promise { const topicCtx = ctx as TopicCtx; const topic = topicCtx.getTopic ? topicCtx.getTopic() : topicCtx.topic; @@ -109,7 +133,7 @@ export abstract class TopicQueryAdapter extends QueryAdapter { case 'rejected': throw existing.error; case 'pending': - return existing.promise; + return untilAborted(existing.promise!, signal); } } @@ -124,7 +148,7 @@ export abstract class TopicQueryAdapter extends QueryAdapter { this._topics.set(topic, { status: 'pending', promise, resolve, reject }); - return promise; + return untilAborted(promise, signal); } /** From 47be6a8ea0eb5c1ac83895870c9a00ea1a7717a7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 00:33:42 -0400 Subject: [PATCH 055/109] fix(fetchium): keep each params key's cached data under its own record A query whose result is not an entity keeps its data in a root entity whose key came from the params the query instance was created with. A Signal param change switched the storage key but not that root, so every params key's query record pointed at the same root record, which held the newest params' data. On a cold start within staleTime, the old params were served the new params' data with no request. In memory, a second query showing the old params shared the root and was overwritten too. This is in every earlier version; it is not caused by the perf stack. The root's id now follows the current params. The first apply after a change builds the new params' root, so the relay hands out a new result object for them, and the previous root is evicted unless another query instance still shows it. Entity results are unaffected: their root is the entity itself. Cost: one hash per param change and one compare per apply. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 1 + packages/fetchium/src/QueryClient.ts | 13 +++++ packages/fetchium/src/QueryResult.ts | 14 ++++- .../src/__tests__/activation-timing.test.ts | 58 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 61df14c..1c7a9c2 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -10,6 +10,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. +- A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it. Caches written by earlier versions keep the shared record until each key is fetched again. - A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. `TopicQueryAdapter.send()` now honours the abort signal, so whoever awaits that first fetch gets the `AbortError` instead of waiting for the next mount (a bug in every earlier version). - The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). - With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 3570949..ff3fa12 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1092,6 +1092,19 @@ export class QueryClient { // In-Memory GC // ====================================================== + /** + * Evicts the root entity a query's earlier params applied to (a non-entity + * result's), unless another query instance still shows it. + * + * @internal + */ + releaseQueryRoot(root: EntityInstance, owner: QueryInstance): void { + for (const instance of this.queryInstances.values()) { + if (instance !== owner && instance.rootEntity === root) return; + } + root.evict(); + } + private handleEviction = (key: number, type: GcKeyType): void => { if (type === GcKeyType.Query) { const instance = this.queryInstances.get(key); diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 16d4f28..17f5523 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -168,7 +168,11 @@ export class QueryInstance { /** Extra methods (__refetch, __fetchNext) attached to the root entity proxy. */ private _extraMethods: Record unknown> = {}; - /** Query id injected as QUERY_ID on non-entity payloads. */ + /** + * Query id injected as QUERY_ID on non-entity payloads: the key of the root + * entity that holds the result, per params, so each params key's cached + * record points at its own data. + */ private _queryId: number = 0; get key(): number { @@ -382,6 +386,7 @@ export class QueryInstance { preloadedEntities?: import('./query-types.js').PreloadedEntityMap, ): QueryResult { const def = this.def; + const previousRoot = this.rootEntity; this.rootEntity = this.queryClient.parseAndApplyRootEntity( data, this._queryId, @@ -391,6 +396,12 @@ export class QueryInstance { preloadedEntities, ); + if (previousRoot !== undefined && previousRoot !== this.rootEntity && !def.statics.isEntityResult) { + // The params changed: the previous params' root holds their data, and + // their cached record points at it. This query no longer shows it. + this.queryClient.releaseQueryRoot(previousRoot, this); + } + // Attach extra methods and getters on first discovery if (this.rootEntity._extraMethods === undefined) { this.rootEntity._extraMethods = this._extraMethods; @@ -526,6 +537,7 @@ export class QueryInstance { private adoptParams(extractedParams: Record | undefined, storageKey: number): void { this.currentParams = extractedParams as QueryParams; this.storageKey = storageKey; + this._queryId = extractedParams !== undefined ? hashValue(extractedParams) : 0; // The timestamp belongs to the old params' data: refetch, as after invalidation. if (this.updatedAt !== undefined) this.updatedAt = 0; } diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index 74e2a28..b3e37b6 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -113,6 +113,13 @@ class GetItemById extends RESTQuery { result = { value: t.string }; } +class GetItemByIdFresh extends RESTQuery { + params = { id: t.string }; + path = `/items/${this.params.id}`; + result = { value: t.string }; + config = { staleTime: 60_000 }; +} + function persisted(kv: MemoryPersistentStore, params: { id: string }): boolean { return kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItemById, params))) !== undefined; } @@ -206,6 +213,57 @@ describe('Activation and deactivation in one task', () => { }); describe('Signal params changing around a fetch', () => { + it('keeps each params key cached with its own data', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(5); + const client = makeClient(kv, f.fetch); + const id = signal('1'); + + let relay: any; + const dispose = activate(client, () => (relay = fetchQuery(GetItemByIdFresh, { id })).isPending); + await sleep(30); + const first = relay.value; + id.value = '2'; + await sleep(30); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/2' }); + expect(relay.value).not.toBe(first); + dispose(); + await sleep(5); + client.destroy(); + + // A cold start for either key, within staleTime, shows that key's data. + for (const key of ['1', '2']) { + const cold = createFetch(5); + const next = makeClient(kv, cold.fetch); + let cached: any; + const off = activate(next, () => (cached = fetchQuery(GetItemByIdFresh, { id: key })).isPending); + expect(state(cached)).toEqual({ isPending: false, isRejected: false, value: `/items/${key}` }); + await sleep(20); + expect(cold.paths()).toEqual([]); + off(); + next.destroy(); + } + }); + + it('leaves another query showing the old params alone when a Signal param moves on', async () => { + const f = createFetch(5); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + const id = signal('1'); + + let moving: any; + let fixed: any; + const offMoving = activate(client, () => (moving = fetchQuery(GetItemById, { id })).isPending); + const offFixed = activate(client, () => (fixed = fetchQuery(GetItemById, { id: '1' })).isPending); + await sleep(30); + id.value = '2'; + await sleep(30); + expect(state(moving).value).toBe('/items/2'); + expect(state(fixed).value).toBe('/items/1'); + offMoving(); + offFixed(); + client.destroy(); + }); + it('sends the current params when a Signal param changes in the task the query activates', async () => { const kv = new MemoryPersistentStore(); const f = createFetch(5); From 410e006d71409e8b3fb98d5bf7fff789378fc61e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 03:29:42 -0400 Subject: [PATCH 056/109] fix(fetchium): reject an aborted topic fetch with an AbortError when the signal has no reason TopicQueryAdapter.send() rejects with the abort signal's `reason` when the query's deactivation aborts it. React Native installs an AbortController polyfill whose signal has no `reason`, so there the awaiter was rejected with `undefined` and the relay reported a rejection with no error. It now falls back to an AbortError, through the helper retry() already uses for the same case. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 2 +- .../src/__tests__/activation-timing.test.ts | 34 +++++++++++++++++++ packages/fetchium/src/retry.ts | 4 ++- .../fetchium/src/topic/TopicQueryAdapter.ts | 7 ++-- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 1c7a9c2..2bcc25f 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -11,7 +11,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. - A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it. Caches written by earlier versions keep the shared record until each key is fetched again. -- A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. `TopicQueryAdapter.send()` now honours the abort signal, so whoever awaits that first fetch gets the `AbortError` instead of waiting for the next mount (a bug in every earlier version). +- A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. `TopicQueryAdapter.send()` now honours the abort signal, so whoever awaits that first fetch gets the `AbortError` instead of waiting for the next mount (a bug in every earlier version). It rejects with an `AbortError` under React Native's `AbortController` polyfill too, whose signal carries no `reason`. - The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). - With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. - `QueryClient.destroy()` aborts every query's fetch in flight and cancels deferred ones, so nothing is written to the store after it (including a first fetch a same-task unmount left running, and a response that a replacement client sharing the store would otherwise be overwritten by). Whoever awaits a query it cancelled gets an `AbortError`, including a query whose first start or restart had not run yet. diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index b3e37b6..a5e3c73 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -464,6 +464,40 @@ describe('A topic query mounted and unmounted in one task', () => { expect(await outcome(relay)).toBe('rejected:AbortError'); client.destroy(); }); + + describe('under an AbortController whose signal has no reason (the React Native polyfill)', () => { + const NativeAbortController = globalThis.AbortController; + class ReasonlessAbortController { + private controller = new NativeAbortController(); + readonly signal: AbortSignal = this.controller.signal; + constructor() { + Object.defineProperty(this.signal, 'reason', { get: () => undefined }); + } + abort(): void { + this.controller.abort(); + } + } + afterEach(() => { + globalThis.AbortController = NativeAbortController; + }); + + it('rejects the awaiter with an AbortError, not undefined, and a remount still settles', async () => { + globalThis.AbortController = ReasonlessAbortController as unknown as typeof AbortController; + const client = makeTopicClient(new MemoryPersistentStore()); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); + first(); + expect(await outcome(relay)).toBe('rejected:AbortError'); + expect(relay.error).toBeInstanceOf(Error); + + const second = activate(client, () => fetchQuery(GetPrices).isPending); + await sleep(30); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: 'data:prices' }); + second(); + client.destroy(); + }); + }); }); describe('Every awaiter settles', () => { diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index 37877ed..3f22b96 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -47,8 +47,10 @@ export interface WithRetryOptions { /** * Safely retrieve the abort reason from a signal. Falls back to an AbortError * for engines (like Hermes) where `signal.reason` is not implemented. + * + * @internal */ -function getAbortReason(signal: AbortSignal): unknown { +export function getAbortReason(signal: AbortSignal): unknown { if (signal.reason !== undefined) return signal.reason; if (typeof DOMException !== 'undefined') { return new DOMException('The operation was aborted', 'AbortError'); diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index b456aab..e67cd43 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -1,4 +1,5 @@ import { QueryAdapter } from '../QueryAdapter.js'; +import { getAbortReason } from '../retry.js'; import type { Query } from '../query.js'; import type { MutationEvent } from '../types.js'; @@ -28,9 +29,11 @@ interface TopicState { * so would whoever awaits the query. */ function untilAborted(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(signal.reason); + // React Native's AbortController polyfill sets no `reason`: reject with an + // AbortError there too, so the rejection reads as the abort it is. + if (signal.aborted) return Promise.reject(getAbortReason(signal)); return new Promise((resolve, reject) => { - const onAbort = (): void => reject(signal.reason); + const onAbort = (): void => reject(getAbortReason(signal)); signal.addEventListener('abort', onAbort, { once: true }); promise.then( value => { From e9a3d08ad659037208aff44fdb9a0897b0ba95bb Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 03:31:56 -0400 Subject: [PATCH 057/109] fix(fetchium): evict a query's previous root only while the entity map still holds it Since a non-entity result's root key follows the current params, two query instances whose params agree share one root. Two paths then evicted a root another query was still showing: - The first apply after a param change released the previous root with evict(), which removes whatever the map holds at the root's key. When the previous root had already left the map (its query was collected while a reactive function kept the relay, and a new query for the same params has built a root at that key since), that removed the new query's root: the next apply rebuilt it, handing out a new result object for unchanged data, on every refetch. - Collecting a query evicted its root even when another query showed it. A query whose Signal param had moved onto another query's params took that query's root with it, so streamed updates stopped reaching what it showed. The release now leaves a root that is no longer in the map alone, and collection goes through the same release as a param change: the root is evicted only if no other query instance shows it. Entity results are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 2 +- packages/fetchium/src/QueryClient.ts | 15 +++- .../src/__tests__/activation-timing.test.ts | 88 ++++++++++++++++++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 2bcc25f..bfec15e 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -10,7 +10,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. -- A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it. Caches written by earlier versions keep the shared record until each key is fetched again. +- A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it, including when the query is collected. Caches written by earlier versions keep the shared record until each key is fetched again. - A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. `TopicQueryAdapter.send()` now honours the abort signal, so whoever awaits that first fetch gets the `AbortError` instead of waiting for the next mount (a bug in every earlier version). It rejects with an `AbortError` under React Native's `AbortController` polyfill too, whose signal carries no `reason`. - The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). - With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index ff3fa12..ee279be 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -1093,12 +1093,17 @@ export class QueryClient { // ====================================================== /** - * Evicts the root entity a query's earlier params applied to (a non-entity - * result's), unless another query instance still shows it. + * Evicts the root entity of a non-entity result that `owner` no longer + * shows (its params changed, or it was collected), unless another query + * instance still shows it. Since a root's key follows the current params, + * two instances whose params agree share one. A root already gone from the + * map is left alone: evicting it would remove whatever the map now holds + * at its key, which is the root a query showing those params applies to. * * @internal */ releaseQueryRoot(root: EntityInstance, owner: QueryInstance): void { + if (this.entityMap.getEntity(root.key) !== root) return; for (const instance of this.queryInstances.values()) { if (instance !== owner && instance.rootEntity === root) return; } @@ -1112,7 +1117,11 @@ export class QueryClient { instance.stopSubscription(); // Nothing may settle its relay or reach the store after this. instance.abortForDestroy(); - instance.rootEntity?.evict(); + const root = instance.rootEntity; + if (root !== undefined) { + if (instance.def.statics.isEntityResult) root.evict(); + else this.releaseQueryRoot(root, instance); + } this.queryInstances.delete(key); return; } diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index a5e3c73..80dcc05 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { signal, watcher, withContexts } from 'signalium'; +import { reactive, signal, watcher, withContexts } from 'signalium'; import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; @@ -264,6 +264,92 @@ describe('Signal params changing around a fetch', () => { client.destroy(); }); + describe('with collection (a browser or React Native client)', () => { + const hadWindow = 'window' in globalThis; + afterEach(() => { + if (!hadWindow) delete (globalThis as any).window; + }); + + class Thing extends Entity { + __typename = t.typename('Thing'); + id = t.id; + name = t.string; + } + + function thingFetch() { + let count = 0; + return async (url: string): Promise => { + await sleep(2); + const id = new URL(url).pathname.split('/').pop(); + const body = { thing: { __typename: 'Thing', id: `t${id}`, name: `name${id}` }, n: ++count }; + return { ok: true, status: 200, headers: new Headers(), json: async () => body } as unknown as Response; + }; + } + + it('collecting a query whose Signal param moved onto another query’s params leaves that query’s data live', async () => { + if (!hadWindow) (globalThis as any).window = globalThis; + class GetThingById extends RESTQuery { + params = { id: t.string }; + path = `/things/${this.params.id}`; + result = { thing: t.entity(Thing), n: t.number }; + config = { gcTime: 0, staleTime: 60_000 }; + } + const client = makeClient(new MemoryPersistentStore(), thingFetch()); + const id = signal('1'); + + let fixed: any; + const offMoving = activate(client, () => fetchQuery(GetThingById, { id }).isPending); + const offFixed = activate(client, () => (fixed = fetchQuery(GetThingById, { id: '2' })).isPending); + await sleep(20); + id.value = '2'; + await sleep(20); + offMoving(); + await sleep(50); + expect((client as any).queryInstances.size).toBe(1); + + client.applyMutationEvent({ + type: 'update', + typename: 'Thing', + data: { __typename: 'Thing', id: 't2', name: 'renamed' }, + }); + expect(fixed.value.thing.name).toBe('renamed'); + offFixed(); + client.destroy(); + }); + + it('a collected query’s relay that a reactive function kept leaves the root of a new query for the same params alone', async () => { + if (!hadWindow) (globalThis as any).window = globalThis; + class GetThing extends RESTQuery { + path = '/thing/1'; + result = { thing: t.entity(Thing), n: t.number }; + config = { gcTime: 0, staleTime: 0 }; + } + const client = makeClient(new MemoryPersistentStore(), thingFetch()); + const getThing = reactive(() => fetchQuery(GetThing)); + + let kept: any; + const first = activate(client, () => (kept = getThing()).isPending); + await sleep(20); + first(); + await sleep(50); + expect((client as any).queryInstances.size).toBe(0); + + const keptAgain = activate(client, () => getThing().isPending); + let fresh: any; + const second = activate(client, () => (fresh = fetchQuery(GetThing)).isPending); + await sleep(20); + const shown = fresh.value; + await kept.value.__refetch(); + await fresh.value.__refetch(); + expect(fresh.value).toBe(shown); + const instance = [...(client as any).queryInstances.values()][0]; + expect((client as any).entityMap.getEntity(instance.rootEntity.key)).toBe(instance.rootEntity); + keptAgain(); + second(); + client.destroy(); + }); + }); + it('sends the current params when a Signal param changes in the task the query activates', async () => { const kv = new MemoryPersistentStore(); const f = createFetch(5); From d967fe807b58709f4351f821a915c31cbf132520 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 03:35:08 -0400 Subject: [PATCH 058/109] fix(fetchium): keep a topic query pending, not rejected, when it is unmounted before its first data Since send() honours the abort signal, a topic query unmounted before its first data, in a later task than it mounted, rejected its relay with the deactivation's AbortError. Signalium keeps a rejected relay's error until the relay gets a value, so when the query was mounted again it reported `isRejected` with that AbortError until the refetch landed. A screen that checks `isRejected` before `isPending` (a list row scrolled away before its data and back) showed its error state for that time. Before, the relay stayed pending. For a query with a subscription and no value yet, that abort now leaves the relay pending. The next activation's refetch replaces the fetch, so whoever awaits the relay gets that refetch's result; collection and destroy() settle it with an AbortError, as they settle any relay left pending. A restart that found the network offline is held the same way when such a query is unmounted. A query mounted and unmounted in one task still rejects at once: whoever started it is a one-off read that awaits it. Queries without a subscription, and relays with a value, are unchanged. Nothing runs on a render, an apply or a fetch that is not aborted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 4 +- packages/fetchium/src/QueryResult.ts | 46 +++++++++-- .../src/__tests__/activation-timing.test.ts | 79 +++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index bfec15e..be377e4 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -7,7 +7,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - A query mounted and unmounted in the same task (a guard that redirects in a layout effect, a `replace` on mount, a route re-keyed in one commit) no longer leaves its relay rejected with an `AbortError` that the next mount shows. Its first request, which now starts before Signalium's deactivation flush, is left to finish in the background, as it did when the first fetch started on a timer, and the next mount shows its data. Releasing a `prefetch()` or `retain()` lease still aborts the fetch it started, in any task. - A Signal param change made in the same task the query's last watcher leaves no longer sends a request that the deactivation then aborts. The refetch for a param change starts once Signalium's flush has run, which is in the same task when the change arrives through that flush. - A query that reactivates stale in the same task the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". The deferred refetch, a deferred restart and a debounced refetch recheck that the query is still active and not paused before they send. -- When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. +- When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. A query with a subscription (a topic query) that is unmounted before its first data, in a later task than it mounted, is not rejected at all: it stays pending, so its next mount reports no error while it refetches, and whoever awaits it gets that refetch's result, or an `AbortError` once the query is collected or the client destroyed. - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. - A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it, including when the query is collected. Caches written by earlier versions keep the shared record until each key is fetched again. @@ -15,7 +15,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). - With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. - `QueryClient.destroy()` aborts every query's fetch in flight and cancels deferred ones, so nothing is written to the store after it (including a first fetch a same-task unmount left running, and a response that a replacement client sharing the store would otherwise be overwritten by). Whoever awaits a query it cancelled gets an `AbortError`, including a query whose first start or restart had not run yet. -- A restart that finds the network offline leaves the relay pending until the network returns, as before; if the query is unmounted first, the relay rejects with an `AbortError` and the next mount refetches, instead of leaving an awaiter waiting for that mount. +- A restart that finds the network offline leaves the relay pending until the network returns, as before; if the query is unmounted first, the relay rejects with an `AbortError` and the next mount refetches, instead of leaving an awaiter waiting for that mount (a query with a subscription and no value stays pending instead, as above). - A query whose first fetch a same-task unmount left running is collected (`gcTime`) once that fetch settles, not while it is still in flight: with `gcTime: 0` in a browser or React Native, collecting it earlier left the fetch's entities resident for good and its store write ignoring `destroy()`. A query the client collects has its work cancelled and its relay settled. - A `__fetchNext()` page for params that changed while it was in flight is dropped, like a stale first page. - With an asynchronous store, a Signal param change made while the cache load is pending no longer applies the old params' cached entry, and does not send the new params' request twice. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 17f5523..d115c10 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -108,6 +108,11 @@ export class QueryInstance { * it earlier would leave the fetch applying to an instance nothing owns. */ private keptFetchSignal: AbortSignal | undefined = undefined; + /** + * The signal of a fetch whose abort leaves the relay pending rather than + * rejecting it. See `holdsDeactivationAbort()`. + */ + private heldAbortSignal: AbortSignal | undefined = undefined; /** Some param is a Signal, so its value can change before the first start runs. */ private hasSignalParams: boolean = false; /** The first fetch's controller and its start window. See `openStartWindow()`. */ @@ -234,18 +239,18 @@ export class QueryInstance { // deactivation tears down (a topic query's send() resolves from // it) could never finish, so it is aborted as before and the next // activation restarts it. - const keepFirstFetch = - !isPausing && - controller === this.firstFetchController && - startWindowOpen && - this.firstFetchWindow === startWindow && - this.unsubscribe === undefined; + const sameTask = + controller === this.firstFetchController && startWindowOpen && this.firstFetchWindow === startWindow; + const keepFirstFetch = !isPausing && sameTask && this.unsubscribe === undefined; if (keepFirstFetch) { // Mounted and unmounted in one task: let the first fetch finish. this.firstFetchController = undefined; if (this.relayState.isPending) this.keptFetchSignal = controller.signal; } else { - if (this.relayState.isPending) this.abortedByDeactivation = true; + if (this.relayState.isPending) { + this.abortedByDeactivation = true; + if (!sameTask && this.holdsDeactivationAbort()) this.heldAbortSignal = controller.signal; + } controller.abort(); this._abortController = undefined; } @@ -268,7 +273,7 @@ export class QueryInstance { if (parked !== undefined) { this.parkedRestart = undefined; this.abortedByDeactivation = true; - parked(abortError()); + if (!this.holdsDeactivationAbort()) parked(abortError()); } // A kept first fetch schedules collection when it settles. @@ -706,6 +711,12 @@ export class QueryInstance { return result; } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; + if (signal === this.heldAbortSignal) { + // Pending until the next activation replaces this fetch, or the + // query is collected or destroyed (abortForDestroy() settles it). + this.heldAbortSignal = undefined; + return new Promise(() => {}); + } throw error; } finally { if (signal === this.keptFetchSignal) { @@ -715,6 +726,23 @@ export class QueryInstance { } } + /** + * Whether a deactivation that cancels this query's fetch leaves its relay + * pending instead of rejecting it with the AbortError: when the relay has + * no value yet and the query has a subscription (a topic query's fetch + * waits on the subscription the deactivation tears down). Signalium keeps + * a rejected relay's error until it gets a value, so a rejection would + * have the next mount report `isRejected` with the AbortError until its + * refetch lands. The relay instead stays pending: the next activation's + * refetch settles whoever awaits it, as does collection or `destroy()` + * (`abortForDestroy()`). A query mounted and unmounted in one task is not + * held: whoever started it is a one-off read that awaits it, and gets the + * AbortError at once. + */ + private holdsDeactivationAbort(): boolean { + return this.relayState.value === undefined && this.config?.subscribe !== undefined; + } + private scheduleGc(): void { const gcTime = this.config?.gcTime ?? DEFAULT_GC_TIME; this.queryClient.gcManager.schedule(this.queryKey, gcTime, GcKeyType.Query); @@ -729,6 +757,7 @@ export class QueryInstance { private restartAbortedFetch(afterFlush: boolean = false): void { this.fetchStarts++; this.parkedRestart = undefined; + this.heldAbortSignal = undefined; if (this.abortedByDeactivation) { this.abortedByDeactivation = false; // The abort rejected the relay (it is no longer pending). Signalium @@ -808,6 +837,7 @@ export class QueryInstance { this.fetchStarts++; this.abortedByDeactivation = false; this.parkedRestart = undefined; + this.heldAbortSignal = undefined; this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index 80dcc05..a815480 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -586,6 +586,85 @@ describe('A topic query mounted and unmounted in one task', () => { }); }); +describe('A topic query unmounted before its first data, in a later task', () => { + const hadWindow = 'window' in globalThis; + afterEach(() => { + if (!hadWindow) delete (globalThis as any).window; + }); + + /** Mounts the topic query, and unmounts it 1 ms later: before its data, due 5 ms after subscribing. */ + async function mountAndLeave(client: QueryClient): Promise<{ relay: any }> { + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); + await sleep(1); + first(); + // Wrapped: returning the relay itself would await it. + return { relay }; + } + + it('is pending, not rejected, when mounted again, until its data lands', async () => { + const client = makeTopicClient(new MemoryPersistentStore()); + const { relay } = await mountAndLeave(client); + await sleep(20); + expect(relay.isRejected).toBe(false); + + const reads: ReturnType[] = []; + const second = activate(client, () => reads.push(state(fetchQuery(GetPrices)))); + expect(reads[0]).toEqual({ isPending: true, isRejected: false, value: undefined }); + await sleep(3); + expect(state(relay)).toEqual({ isPending: true, isRejected: false, value: undefined }); + await sleep(30); + expect(reads.some(r => r.isRejected)).toBe(false); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: 'data:prices' }); + second(); + client.destroy(); + }); + + it('settles an awaiter of the first mount with the data a later mount brings', async () => { + const client = makeTopicClient(new MemoryPersistentStore()); + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); + const awaited = outcome(relay, 500); + await sleep(1); + first(); + await sleep(20); + + const second = activate(client, () => fetchQuery(GetPrices).isPending); + expect(await awaited).toBe('resolved'); + second(); + client.destroy(); + }); + + it('settles an awaiter with an AbortError when the client is destroyed', async () => { + const client = makeTopicClient(new MemoryPersistentStore()); + const { relay } = await mountAndLeave(client); + const awaited = outcome(relay, 500); + await sleep(20); + client.destroy(); + expect(await awaited).toBe('rejected:AbortError'); + }); + + it('settles an awaiter with an AbortError when the query is collected', async () => { + if (!hadWindow) (globalThis as any).window = globalThis; + class GetCollectedPrices extends TopicQuery { + topic = 'prices'; + result = { value: t.string }; + getConfig() { + return { ...super.getConfig(), gcTime: 0 }; + } + } + const client = makeTopicClient(new MemoryPersistentStore()); + expect((client as any).isServer).toBe(false); + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetCollectedPrices)).isPending); + await sleep(1); + first(); + expect(await outcome(relay, 500)).toBe('rejected:AbortError'); + expect((client as any).queryInstances.size).toBe(0); + client.destroy(); + }); +}); + describe('Every awaiter settles', () => { it('when destroy() runs in the task the query mounts, before its first start', async () => { const { fetch, calls } = createFetch(20); From 7b4ae1ac39693c59001fc8840b620ff2690ef851 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:28:09 -0400 Subject: [PATCH 059/109] fix(fetchium): serve a cached query over an in-memory entity of another class, and keep that class's fields on write Two entity classes can share a typename (a list class and a detail class with more fields). The detail's cached query was dropped when the list class held the entity in memory, and every write by the list class deleted the detail's fields from the record, so the detail's cache could not hydrate even on a cold start. Hydration over an in-memory entity now goes field by field: a field no applied class declares is taken from the cached record, a satisfying value is used as is, anything else still drops the cached query and fetches. A write keeps the record's fields no applied class declares. The entity keeps them once as a JSON fragment, taken from the record at its first write (handed over at hydration, or read once before the first write of an entity built from a fetch, only for a class that lacks fields another class of its typename declared, or handed over when a cached query fills in an entity built from streamed events), and the store writes the fragment ahead of the entity's JSON: no read, parse, merge or copy per later write. An entity built from streamed events that carried every field of its class is written whole only when no other class of its typename declares more fields, and the store's field names cover every record it holds. SyncQueryStore remembers each typename's declared field names across sessions and forgets a name no class has declared for 30 days. A store that already held cached data when this version first opened it was written by a version that did not remember them, so its records may hold fields of a class not registered since: for 30 days from then (when such a class would have been forgotten anyway), or until clear(), its names are not taken to cover every record. Its new clear() deletes the cached data and tells the clients over it, so they drop the fields they kept from the deleted records. The store hooks are optional. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/keep-cached-first-paint.md | 12 + docs/src/app/api/stores-sync/page.md | 27 +- packages/fetchium/src/EntityInstance.ts | 78 ++ packages/fetchium/src/EntityStore.ts | 32 +- packages/fetchium/src/QueryClient.ts | 91 +- .../__tests__/hydrate-in-memory-shape.test.ts | 936 ++++++++++++++++++ .../skip-write-and-snapshot-fixes.test.ts | 224 +++-- packages/fetchium/src/applyEntities.ts | 67 +- packages/fetchium/src/parseEntities.ts | 151 ++- packages/fetchium/src/query-types.ts | 47 +- .../hydrate-in-memory-shape.test.tsx | 153 +++ packages/fetchium/src/stores/async.ts | 26 +- packages/fetchium/src/stores/shared.ts | 30 + packages/fetchium/src/stores/sync.ts | 176 +++- 14 files changed, 1898 insertions(+), 152 deletions(-) create mode 100644 .changeset/keep-cached-first-paint.md create mode 100644 packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts create mode 100644 packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md new file mode 100644 index 0000000..aa06a50 --- /dev/null +++ b/.changeset/keep-cached-first-paint.md @@ -0,0 +1,12 @@ +--- +'fetchium': patch +--- + +Cached data shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. + +- A detail query's cached data is used even when the list class already holds the entity in memory; the fields the list class doesn't declare come from the cache. Previously the cache was dropped and the screen waited for the network. +- Writing an entity through one class no longer deletes the other class's fields from the store, so the detail screen's cache survives a restart. +- `SyncQueryStore` remembers each typename's field names across launches, and forgets a name no class has declared for 30 days. For 30 days after this version first opens a store that already holds data, records may still hold fields of a class not registered since, so they keep being merged rather than overwritten. +- New `SyncQueryStore.clear()`. Use it to wipe the cache instead of clearing the underlying storage directly (an MMKV `clearAll()`): clients are not told about records deleted underneath them, and a later write can put deleted fields back. +- `QueryStore` gains optional `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`, and `saveEntity` takes an optional fourth argument. A custom store without them behaves as before. `AsyncQueryStore` only knows the classes registered in the current session. +- Cost: a record holds the fields of every class of its typename, so hydrating the list class parses the detail fields too (a cached 100-entity list hydrates about a third slower in our benchmark). Typenames with one class are unaffected. diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index c4f4235..aef92ae 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,17 +32,22 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | -| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | +| Method | Signature | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | +| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | +| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity built from a fetch whose class lacks fields another class of its typename declares, so that write keeps them. | +| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | +| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | +| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every class that wrote a record the store holds. The first open by this version notes when it started remembering (`sq:meta:fieldsSince`, after one scan of the keys): 0 when the store held no cached data, the current time otherwise, as for a store an earlier version wrote. `clear()` sets it to 0. Answers `true` for 0, or 30 days later. Until then the client merges into its record an entity that streamed events built with every field of its class, rather than writing it whole, so a record an earlier version wrote keeps the fields of a class not registered since. | +| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. | --- diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 69c909b..eb9d288 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -11,6 +11,22 @@ import type { QueryClient } from './QueryClient.js'; import { ValidatorDef, WRAPPED_VALUE } from './typeDefs.js'; import type { LiveCollectionBinding } from './LiveCollection.js'; import { entitySatisfiesShape } from './parseEntities.js'; +import { recordRestOutside } from './stores/shared.js'; + +/** + * Fields of an entity's stored record that no class applied to the in-memory + * instance declares: another class sharing the typename wrote them, in this + * session or an earlier one. Kept, as raw record JSON, so the instance's + * writes carry them instead of dropping them from the record. + */ +export interface RecordRest { + /** The fields kept. */ + keys: string[]; + /** Their raw values as a JSON object body (`"a":1,"b":{…}`), written as is. */ + json: string; + /** The `{ __entityRef }` keys inside them, which the record goes on referencing. */ + refIds: number[]; +} // ====================================================== // Nested proxy wrapping — transparently unwraps WRAPPED_VALUE items @@ -639,6 +655,20 @@ export class EntityInstance { _deferredWrite: boolean = false; /** The store is known to hold a record of this entity (hydrated from it, or written to it). */ _recorded: boolean = false; + /** The stored record's fields this instance does not hold; its writes carry them. */ + _recordRest: RecordRest | undefined = undefined; + /** + * The stored record handed over at hydration, while `_recordRest` has not + * been taken from it yet: that happens at the first write, so a cold start + * pays nothing for it. + */ + private _storedRecord: Record | undefined = undefined; + /** + * Built from a fetch, of a class that lacks fields another class sharing + * its typename declares: the stored record may hold such fields, so the + * first write reads it (synchronous stores) and keeps them. + */ + _checkStoredRecord: boolean = false; private _saving: boolean = false; entityRefs: Map | undefined; liveCollections: LiveCollectionBinding[] = []; @@ -866,6 +896,54 @@ export class EntityInstance { this.markUnwritten(); } + /** The store deleted this entity's record, and with it the fields this instance kept from it. */ + recordDeleted(): void { + this._recordRest = undefined; + this._storedRecord = undefined; + this.recordDropped(); + } + + /** Whether this instance keeps fields of its stored record (or has the record to take them from). */ + keepsRecordFields(): boolean { + return this._recordRest !== undefined || this._storedRecord !== undefined; + } + + /** + * Keeps the fields of this entity's stored record (raw, as parsed from the + * store) that the instance's data does not hold, so its writes carry them. + * A field the data holds, even as `undefined` (a class declares it and the + * payload left it out), is the instance's to write. The record is kept as + * handed over and the fields are taken from it at the first write. + */ + noteRecord(record: Record): void { + this._storedRecord = record; + this._recordRest = undefined; + } + + /** + * The kept record fields to write with the data. Normally exactly what was + * kept; once a class declaring some of them has been applied, the data + * holds those itself and they are dropped from what is kept. + */ + recordRestForWrite(): RecordRest | undefined { + const data = this.data; + const record = this._storedRecord; + if (record !== undefined) { + this._storedRecord = undefined; + return (this._recordRest = recordRestOutside(record, data)); + } + const rest = this._recordRest; + if (rest === undefined) return undefined; + const keys = rest.keys; + for (let i = 0; i < keys.length; i++) { + if (Object.hasOwn(data, keys[i])) { + const values = JSON.parse(`{${rest.json}}`) as Record; + return (this._recordRest = recordRestOutside(values, data)); + } + } + return rest; + } + /** * Its record is stale: the next apply writes it even if it finds nothing * changed. Writes still queued hold older data, so their acknowledgements diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 12b8fb8..a08d65c 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -5,13 +5,13 @@ import { ValidatorDef } from './typeDefs.js'; export class EntityStore { private instances = new Map(); - private persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void; + private persistEntity: PersistEntity; /** Whether the store can merge fields over a record it holds. */ mergesEntities: boolean = false; + /** A synchronous store's read of an entity's stored record, if it offers one. */ + readEntity: ((key: number) => Record | undefined) | undefined = undefined; - constructor( - persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void, - ) { + constructor(persistEntity: PersistEntity) { this.persistEntity = persistEntity; } @@ -79,7 +79,29 @@ export class EntityStore { if (merge) { value = {}; for (const k of mergeKeys) value[k] = instance.data[k]; + this.persistEntity(instance.key, value, refKeys, true); + return; } - this.persistEntity(instance.key, value, refKeys, merge); + if (instance._checkStoredRecord) { + // First write of an instance whose class may lack fields the record + // holds: read the record once and keep them from now on. + instance._checkStoredRecord = false; + const stored = this.readEntity?.(instance.key); + if (stored !== undefined) instance.noteRecord(stored); + } + const rest = instance.recordRestForWrite(); + if (rest !== undefined && rest.refIds.length > 0) { + refKeys ??= new Set(); + for (let i = 0; i < rest.refIds.length; i++) refKeys.add(rest.refIds[i]); + } + this.persistEntity(instance.key, value, refKeys, false, rest?.json); } } + +type PersistEntity = ( + key: number, + data: Record, + refKeys: Set | undefined, + merge: boolean, + rest?: string, +) => void; diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index ee279be..0b43b99 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -259,15 +259,22 @@ export class QueryClient { config.gcManager ?? (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); this.networkManager = config.networkManager ?? new NetworkManager(); - this.entityMap = new EntityStore((key, data, refs, merge) => { + this.entityMap = new EntityStore((key, data, refs, merge, rest) => { if (merge) this.store.mergeEntity!(key, data, refs); + else if (rest !== undefined) this.store.saveEntity(key, data, refs, rest); else this.store.saveEntity(key, data, refs); }); this.entityMap.mergesEntities = typeof this.store.mergeEntity === 'function'; + if (typeof this.store.readEntity === 'function') { + const store = this.store; + this.entityMap.readEntity = key => store.readEntity!(key); + } // A record the store drops must be written again by the next apply. this.storeReportsDeletes = typeof this.store.onDelete === 'function'; + this.storeKnowsTypenameFields = + typeof this.store.getEntityFieldNames === 'function' && this.store.entityFieldNamesComplete?.() === true; const offDelete = this.store.onDelete?.(key => { - this.entityMap.getEntity(key)?.recordDropped(); + this.entityMap.getEntity(key)?.recordDeleted(); }); if (typeof offDelete === 'function') this.storeUnsubscribes.push(offDelete); // A store that processes writes later says when a record is really there. @@ -392,6 +399,84 @@ export class QueryClient { } else { this.typenameRegistry.set(typename, [def]); } + this.noteTypenameFields(typename, def); + } + + /** + * Per entity def: whether another class sharing its typename declares a + * field it does not, in this session or (with a store that remembers field + * names) an earlier one. A record of such a class may hold fields it does + * not parse, which its writes must keep. + */ + private foreignFieldDefs = new WeakMap, boolean>(); + /** Per typename: every top-level field its classes have declared (see `foreignFieldDefs`). */ + private typenameFields = new Map>(); + /** Whether any registered def lacks a field of its typename; false for an app whose typenames each have one class. */ + hasForeignFieldDefs: boolean = false; + /** + * Whether the store remembers each typename's field names across sessions + * (`getEntityFieldNames`) and they cover every class that wrote a record it + * holds (`entityFieldNamesComplete`), so a class not registered this + * session is still known to `mayMissForeignFields`. + */ + readonly storeKnowsTypenameFields: boolean; + + /** + * Whether a stored record of this def's typename may hold fields the def + * does not declare. One WeakMap lookup; registers the def first if needed + * (a hydration parse runs before the apply registers it). + * + * @internal + */ + mayMissForeignFields(def: ValidatorDef): boolean { + let verdict = this.foreignFieldDefs.get(def); + if (verdict === undefined) { + this.registerEntityDef(def); + verdict = this.foreignFieldDefs.get(def); + if (verdict === undefined) { + // Not a class: the merged def of a typename's classes (a streamed + // event's root), whose fields are some of the typename's. A new class + // registered later replaces the merged def, so the verdict cannot go + // stale. + const fields = def.typenameValue !== undefined ? this.typenameFields.get(def.typenameValue) : undefined; + const shape = def.shape as Record | undefined; + verdict = fields !== undefined && shape !== undefined && fields.size > Object.keys(shape).length; + this.foreignFieldDefs.set(def, verdict); + } + } + return verdict; + } + + /** + * Folds a newly registered def's fields into its typename's field set (and + * the store's, which outlives the session), then re-derives which of the + * typename's defs lack a field of it. Runs once per def and client. + */ + private noteTypenameFields(typename: string, def: ValidatorDef): void { + let fields = this.typenameFields.get(typename); + if (fields === undefined) { + fields = new Set(this.store.getEntityFieldNames?.(typename)); + this.typenameFields.set(typename, fields); + } + const declared = Object.keys(def.shape as Record); + let added: boolean = false; + for (const k of declared) { + if (!fields.has(k)) { + fields.add(k); + added = true; + } + } + // Every declared name, not just the new ones: the store forgets names no + // class has declared for a while. + this.store.addEntityFieldNames?.(typename, declared); + // Every registered def's fields are in the set, so a def lacks one of + // them exactly when the set is larger than its shape. + for (const d of this.typenameRegistry.get(typename)!) { + if (!added && d !== def) continue; + const misses = fields.size > Object.keys(d.shape as Record).length; + this.foreignFieldDefs.set(d, misses); + if (misses) this.hasForeignFieldDefs = true; + } } getEntityDefsForTypename(typename: string): ValidatorDef[] | undefined { @@ -1199,6 +1284,8 @@ export class QueryClient { this.entityMap.clear(); this.constraintRegistry.clear(); this.typenameRegistry.clear(); + this.typenameFields.clear(); + this.foreignFieldDefs = new WeakMap(); this.mergedDefCache.clear(); } } diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts new file mode 100644 index 0000000..7303da2 --- /dev/null +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -0,0 +1,936 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; +import { AsyncQueryStore, type StoreMessage } from '../stores/async.js'; +import { valueKeyFor, refIdsKeyFor, refCountKeyFor } from '../stores/shared.js'; +import { hashValue } from 'signalium/utils'; +import { sleep } from './utils.js'; + +/** + * A persisted query must be served from the cache when one of its entities is + * already in memory with data that does not, by itself, fit the query's shape, + * as long as the cache can make up the difference without serving data older + * than the in-memory entity: + * A. a required top-level `t.format` field: the in-memory value is already + * a parsed FormattedValue; + * B. two entity classes share a typename and the class in memory lacks a + * field the cached query's class requires; + * B2/B3. the other class wrote the record (in this session or an earlier + * one): its write must keep the fields it does not declare, or even a + * cold start with nothing in memory cannot serve the cache. + * The negative cases (C) must keep falling back to the network: the cache + * must never resurrect a value the fresher in-memory entity contradicts. + */ + +let clients: QueryClient[] = []; +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +type Route = { body: unknown; delay: number }; +function makeFetch() { + const routes = new Map(); + const calls: string[] = []; + const fetch = (url: string) => { + const path = new URL(url).pathname; + calls.push(path); + const route = routes.get(path); + if (route === undefined) return Promise.reject(new Error(`no route for ${path}`)); + const body = JSON.parse(JSON.stringify(route.body)); + return new Promise(resolve => + setTimeout( + () => + resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + }), + route.delay, + ), + ); + }; + return { + fetch, + calls, + set(path: string, body: unknown, delay = 0) { + routes.set(path, { body, delay }); + }, + count(path: string) { + return calls.filter(c => c === path).length; + }, + }; +} + +function makeClient(kv: MemoryPersistentStore, f: ReturnType, logs: string[] = []) { + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })], + log: { + warn: (m: unknown, e?: unknown) => logs.push(`warn: ${String(m)} ${e instanceof Error ? e.message : ''}`), + error: (m: unknown) => logs.push(`error: ${String(m)}`), + }, + } as any); + clients.push(client); + return client; +} + +function start(client: QueryClient, fn: () => T): T { + return withContexts([[QueryClientContext, client]], () => { + const q = fn(); + const w = watcher(() => (q as any).value); + w.addListener(() => {}); + return q; + }); +} + +function read(fn: () => T): T | string { + try { + return fn(); + } catch (e) { + return `read threw: ${(e as Error).message}`; + } +} + +function recordOf(kv: MemoryPersistentStore, typename: string, id: string): Record | undefined { + const raw = kv.getString(valueKeyFor(hashValue([typename, id]))); + return raw === undefined ? undefined : JSON.parse(raw); +} + +/** Session boundary: a fresh client over the same kv (an app restart). */ +async function session( + kv: MemoryPersistentStore, + f: ReturnType, + run: (c: QueryClient) => Promise, +) { + const c = makeClient(kv, f); + await run(c); + c.destroy(); + clients = clients.filter(x => x !== c); +} + +// --------------------------------------------------------------------------- +// A. Required top-level format field +// --------------------------------------------------------------------------- + +class Reading extends Entity { + __typename = t.typename('Reading'); + id = t.id; + value = t.number; + takenAt = t.format('date-time'); +} +class GetLatestReading extends RESTQuery { + path = '/reading/latest'; + result = { reading: t.entity(Reading) }; +} +class GetReadingPage extends RESTQuery { + path = '/reading/page'; + result = { reading: t.entity(Reading), label: t.string }; +} +const readingPayload = { __typename: 'Reading', id: 'r1', value: 1, takenAt: '2026-01-01T00:00:00.000Z' }; + +describe('A. required top-level t.format on an in-memory entity', () => { + for (const inMemory of [false, true]) { + it(`cached query is served (entity in memory: ${inMemory})`, async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/reading/page', { reading: readingPayload, label: 'cached' }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetReadingPage)); + }); + + const logs: string[] = []; + const c2 = makeClient(kv, f, logs); + f.set('/reading/latest', { reading: { ...readingPayload, value: 2 } }); + const latest = inMemory ? start(c2, () => fetchQuery(GetLatestReading)) : undefined; + if (latest !== undefined) await latest; + + f.set('/reading/page', { reading: { ...readingPayload, value: 3 }, label: 'net' }, 200); + const page: any = start(c2, () => fetchQuery(GetReadingPage)); + await sleep(20); + const early = { + isReady: page.isReady, + label: read(() => page.value?.label), + value: read(() => page.value?.reading.value), + takenAt: read(() => page.value?.reading.takenAt?.toISOString?.()), + }; + const latestEarly = + latest !== undefined ? read(() => (latest as any).value.reading.takenAt.toISOString()) : undefined; + await sleep(300); + + expect(logs).toEqual([]); + expect(early.isReady).toBe(true); + expect(early.label).toBe('cached'); + // In memory is fresher than the record: its value wins. + expect(early.value).toBe(inMemory ? 2 : 1); + expect(early.takenAt).toBe('2026-01-01T00:00:00.000Z'); + if (latest !== undefined) expect(latestEarly).toBe('2026-01-01T00:00:00.000Z'); + // The network fetch still happens and wins. + expect(f.count('/reading/page')).toBe(2); + expect(page.value.label).toBe('net'); + expect(page.value.reading.value).toBe(3); + }); + } +}); + +// --------------------------------------------------------------------------- +// B. Two classes share a typename; the summary class lacks `details` +// --------------------------------------------------------------------------- + +class ProductSummary extends Entity { + __typename = t.typename('Product'); + id = t.id; + name = t.string; +} +class ProductDetail extends Entity { + __typename = t.typename('Product'); + id = t.id; + name = t.string; + details = t.object({ rating: t.number, tags: t.array(t.string) }); +} +class GetProductList extends RESTQuery { + path = '/products'; + result = { items: t.array(t.entity(ProductSummary)) }; +} +class GetProductDetail extends RESTQuery { + path = '/product'; + result = { product: t.entity(ProductDetail), label: t.string }; +} + +const detailBody = (rating: number, label: string, name = 'P') => ({ + product: { __typename: 'Product', id: 'p1', name, details: { rating, tags: ['a'] } }, + label, +}); +const listBody = (name = 'P') => ({ items: [{ __typename: 'Product', id: 'p1', name }] }); + +type DetailOutcome = { + isReady: boolean; + label: unknown; + name: unknown; + rating: unknown; + listName?: unknown; + listKeys?: unknown; + logs: string[]; + finalLabel: unknown; + finalRating: unknown; + detailRequests: number; + recordAfterHydrate: Record | undefined; + recordAfterFetch: Record | undefined; +}; + +/** + * Session 1 caches the detail. `before(c, kv)` sets up session 2 up to the + * moment the detail activates; the detail's network response is delayed so the + * first read shows whether the cache was served. + */ +async function runDetail( + kv: MemoryPersistentStore, + f: ReturnType, + before: (c: QueryClient) => Promise<{ list?: any } | undefined>, +): Promise { + const logs: string[] = []; + const c = makeClient(kv, f, logs); + // Wrapped: a QueryPromise is thenable, so returning it bare from an async function would unwrap it. + const list = (await before(c))?.list; + f.set('/product', detailBody(6, 'net', 'P-net'), 200); + const requestsBefore = f.count('/product'); + const d: any = start(c, () => fetchQuery(GetProductDetail)); + await sleep(20); + const out: DetailOutcome = { + isReady: d.isReady, + label: read(() => d.value?.label), + name: read(() => d.value?.product.name), + rating: read(() => d.value?.product.details?.rating), + listName: list !== undefined ? read(() => list.value.items[0].name) : undefined, + listKeys: list !== undefined ? read(() => Object.keys(list.value.items[0]).sort().join(',')) : undefined, + logs, + finalLabel: undefined, + finalRating: undefined, + detailRequests: 0, + recordAfterHydrate: recordOf(kv, 'Product', 'p1'), + recordAfterFetch: undefined, + }; + await sleep(300); + out.finalLabel = read(() => d.value?.label); + out.finalRating = read(() => d.value?.product.details?.rating); + out.detailRequests = f.count('/product') - requestsBefore; + out.recordAfterFetch = recordOf(kv, 'Product', 'p1'); + return out; +} + +async function seedDetail(kv: MemoryPersistentStore, f: ReturnType) { + f.set('/product', detailBody(5, 'cached')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetProductDetail)); + }); +} + +/** Seeds the list's cache without disturbing the product record. */ +async function seedListCache(kv: MemoryPersistentStore, f: ReturnType) { + // Cache the list first, then the detail, so the record on disk is the detail's. + f.set('/products', listBody()); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetProductList)); + }); + await seedDetail(kv, f); +} + +function expectServed(o: DetailOutcome, name = 'P') { + expect(o.logs).toEqual([]); + expect(o.isReady).toBe(true); + expect(o.label).toBe('cached'); + expect(o.name).toBe(name); + expect(o.rating).toBe(5); + // The network fetch still happens and wins. + expect(o.detailRequests).toBe(1); + expect(o.finalLabel).toBe('net'); + expect(o.finalRating).toBe(6); + expect(o.recordAfterFetch).toMatchObject({ name: 'P-net', details: { rating: 6, tags: ['a'] } }); +} + +describe('B. shared typename, the in-memory class lacks a field the cached query needs', () => { + it('B0 control: nothing in memory, the cache is served', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedDetail(kv, f); + const o = await runDetail(kv, f, async () => undefined); + expectServed(o); + }); + + it('B1 the summary was hydrated from its own cache (record intact): the cache is served', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedListCache(kv, f); + f.set('/products', listBody(), 1000); // keep the list's refetch out of the window + const o = await runDetail(kv, f, async c => { + const list: any = start(c, () => fetchQuery(GetProductList)); + await sleep(5); + expect(list.isReady).toBe(true); + return { list }; + }); + expectServed(o); + // The list's value is untouched: same name, and the detail-only field does not leak into its shape. + expect(o.listName).toBe('P'); + expect(o.listKeys).toBe('__typename,id,name'); + // Hydration never writes; the record still has the detail's fields. + expect(o.recordAfterHydrate).toMatchObject({ name: 'P', details: { rating: 5 } }); + }); + + it('B2 the summary was fetched from the network this session: the cache is served', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedDetail(kv, f); + f.set('/products', listBody()); + const o = await runDetail(kv, f, async c => { + const list: any = start(c, () => fetchQuery(GetProductList)); + await list; + return { list }; + }); + expectServed(o); + expect(o.listName).toBe('P'); + // The summary's write kept the detail's fields on disk. + expect(o.recordAfterHydrate).toMatchObject({ name: 'P', details: { rating: 5, tags: ['a'] } }); + }); + + it('B3 the summary wrote the record in an earlier session: a cold start still serves the detail cache', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedDetail(kv, f); + f.set('/products', listBody('P2')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetProductList)); + }); + expect(recordOf(kv, 'Product', 'p1')).toMatchObject({ name: 'P2', details: { rating: 5 } }); + const o = await runDetail(kv, f, async () => undefined); + // The summary's newer name, the detail's own fields. + expectServed(o, 'P2'); + }); + + it('B4 the summary hydrated, then refetched with a change, before the detail opens: served, newer name wins', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedListCache(kv, f); + f.set('/products', listBody('P3')); + const o = await runDetail(kv, f, async c => { + const list: any = start(c, () => fetchQuery(GetProductList)); + for (let i = 0; i < 20 && read(() => list.value?.items[0].name) !== 'P3'; i++) await sleep(5); + expect(list.value.items[0].name).toBe('P3'); + return { list }; + }); + expectServed(o, 'P3'); + expect(o.listName).toBe('P3'); + }); +}); + +// --------------------------------------------------------------------------- +// C. Negative controls: the cache must not be served +// --------------------------------------------------------------------------- + +class ProductSummaryOptional extends Entity { + __typename = t.typename('Product'); + id = t.id; + name = t.string; + details = t.optional(t.object({ rating: t.number, tags: t.array(t.string) })); +} +class GetProductListOptional extends RESTQuery { + path = '/products-opt'; + result = { items: t.array(t.entity(ProductSummaryOptional)) }; +} + +class ProductSummaryConflict extends Entity { + __typename = t.typename('Product'); + id = t.id; + name = t.string; + details = t.string; +} +class GetProductListConflict extends RESTQuery { + path = '/products-conflict'; + result = { items: t.array(t.entity(ProductSummaryConflict)) }; +} + +class ProductSummaryNested extends Entity { + __typename = t.typename('Product'); + id = t.id; + name = t.string; + details = t.object({ rating: t.number }); +} +class GetProductListNested extends RESTQuery { + path = '/products-nested'; + result = { items: t.array(t.entity(ProductSummaryNested)) }; +} + +function expectNotServed(o: DetailOutcome) { + // Never shows the cached detail fields; waits for the network, which then wins. + expect(o.isReady).toBe(false); + expect(o.rating === 5).toBe(false); + expect(o.detailRequests).toBe(1); + expect(o.finalLabel).toBe('net'); + expect(o.finalRating).toBe(6); +} + +describe('C. negative controls: a fresher in-memory value contradicts the cache', () => { + it('C1 the in-memory class declares the field and the fresh response omitted it: not served', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedDetail(kv, f); + f.set('/products-opt', { items: [{ __typename: 'Product', id: 'p1', name: 'P' }] }); + const o = await runDetail(kv, f, async c => { + const list: any = start(c, () => fetchQuery(GetProductListOptional)); + await list; + return { list }; + }); + expectNotServed(o); + }); + + it('C2 the in-memory class holds the field with another type: not served, the other query keeps its value', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedDetail(kv, f); + f.set('/products-conflict', { items: [{ __typename: 'Product', id: 'p1', name: 'P', details: 'plain' }] }); + let listRef: any; + const o = await runDetail(kv, f, async c => { + const list: any = start(c, () => fetchQuery(GetProductListConflict)); + await list; + listRef = list; + return { list }; + }); + expectNotServed(o); + expect(read(() => listRef.value.items[0].name)).toBe('P-net'); + }); + + it('C3 the in-memory nested object lacks a required nested field: not served (nested fill is out of scope)', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + await seedDetail(kv, f); + f.set('/products-nested', { items: [{ __typename: 'Product', id: 'p1', name: 'P', details: { rating: 9 } }] }); + const o = await runDetail(kv, f, async c => { + const list: any = start(c, () => fetchQuery(GetProductListNested)); + await list; + return { list }; + }); + expectNotServed(o); + }); +}); + +// --------------------------------------------------------------------------- +// D. Writes keep the fields another class declared, without a per-write cost +// --------------------------------------------------------------------------- + +/** A SyncQueryStore that counts the record reads made for the kept fields. */ +class CountingStore extends SyncQueryStore { + reads = 0; + readEntity(entityKey: number) { + this.reads++; + return super.readEntity(entityKey); + } +} + +function makeCountingClient(store: SyncQueryStore, f: ReturnType) { + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })], + } as any); + clients.push(client); + return client; +} + +class Vendor extends Entity { + __typename = t.typename('Vendor'); + id = t.id; + name = t.string; +} +class ItemSummary extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; +} +class ItemSummaryOptional extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; + details = t.optional(t.object({ rating: t.number })); +} +class ItemDetail extends Entity { + __typename = t.typename('Item'); + id = t.id; + name = t.string; + details = t.object({ rating: t.number }); + vendor = t.entity(Vendor); +} +class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.array(t.entity(ItemSummary)) }; +} +class GetItemsOptional extends RESTQuery { + path = '/items-opt'; + result = { items: t.array(t.entity(ItemSummaryOptional)) }; +} +class GetItem extends RESTQuery { + path = '/item'; + result = { item: t.entity(ItemDetail) }; +} +const itemDetail = (vendor: string, rating = 5, name = 'I') => ({ + item: { + __typename: 'Item', + id: 'i1', + name, + details: { rating }, + vendor: { __typename: 'Vendor', id: vendor, name: vendor }, + }, +}); +const vendorKey = (id: string) => hashValue(['Vendor', id]); + +describe('D. writes keep the fields another class sharing the typename declared', () => { + it('D1 a typename with one class never reads a record before writing', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/reading/page', { reading: readingPayload, label: 'x' }); + for (let session = 0; session < 2; session++) { + const store = new CountingStore(kv); + const c = makeCountingClient(store, f); + await start(c, () => fetchQuery(GetReadingPage)); + expect(store.reads).toBe(0); + c.destroy(); + } + }); + + it('D2 a kept field that references an entity keeps that record alive, and a class declaring it releases it', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + + // The summary fetches in a later session and writes Item:i1: the record + // keeps `details` and the reference to Vendor:v1, whose record survives. + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItems)); + }); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ + __typename: 'Item', + id: 'i1', + name: 'I2', + details: { rating: 5 }, + vendor: { __entityRef: vendorKey('v1') }, + }); + expect(kv.getString(valueKeyFor(vendorKey('v1')))).toBeDefined(); + + // A cold start serves the detail from the cache, vendor included; its + // fetch then replaces the vendor, and the old vendor's record goes. + f.set('/item', itemDetail('v2', 6), 100); + const c = makeClient(kv, f); + const item: any = start(c, () => fetchQuery(GetItem)); + await sleep(20); + expect(item.isReady).toBe(true); + expect(item.value.item.name).toBe('I2'); + expect(item.value.item.vendor.name).toBe('v1'); + await sleep(200); + expect(item.value.item.vendor.name).toBe('v2'); + expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ + details: { rating: 6 }, + vendor: { __entityRef: vendorKey('v2') }, + }); + expect(kv.getString(valueKeyFor(vendorKey('v1')))).toBeUndefined(); + }); + + it('D3 a class that declares the field optional and receives it absent removes it from the record', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + f.set('/items-opt', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItemsOptional)); + }); + const record = recordOf(kv, 'Item', 'i1')!; + expect(record.details).toBeUndefined(); + // `vendor` is declared by the detail class only: kept. + expect(record.vendor).toEqual({ __entityRef: vendorKey('v1') }); + }); + + it('D4 streamed full updates of an in-memory entity read the record at most once', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + + const store = new CountingStore(kv); + const c = makeCountingClient(store, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + const list: any = start(c, () => fetchQuery(GetItems)); + await list; + expect(store.reads).toBe(1); + + const itemValueKey = valueKeyFor(hashValue(['Item', 'i1'])); + const getString = kv.getString.bind(kv); + let recordReads = 0; + kv.getString = (key: string) => { + if (key === itemValueKey) recordReads++; + return getString(key); + }; + for (let i = 0; i < 50; i++) { + c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: `N${i}` } }); + } + await sleep(0); + expect(store.reads).toBe(1); + expect(recordReads).toBe(0); + expect(list.value.items[0].name).toBe('N49'); + expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'N49', details: { rating: 5 } }); + }); + + it('D6 an optional field only another class declares is served from the record, not as missing', async () => { + class NoteSummary extends Entity { + __typename = t.typename('Note'); + id = t.id; + title = t.string; + } + class NoteDetail extends Entity { + __typename = t.typename('Note'); + id = t.id; + title = t.string; + body = t.optional(t.string); + } + class GetNotes extends RESTQuery { + path = '/notes'; + result = { notes: t.array(t.entity(NoteSummary)) }; + } + class GetNote extends RESTQuery { + path = '/note'; + result = { note: t.entity(NoteDetail) }; + } + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/note', { note: { __typename: 'Note', id: 'n1', title: 'T', body: 'cached body' } }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetNote)); + }); + const c = makeClient(kv, f); + f.set('/notes', { notes: [{ __typename: 'Note', id: 'n1', title: 'T2' }] }); + await start(c, () => fetchQuery(GetNotes)); + f.set('/note', { note: { __typename: 'Note', id: 'n1', title: 'T3', body: 'net body' } }, 100); + const note: any = start(c, () => fetchQuery(GetNote)); + await sleep(20); + expect(note.isReady).toBe(true); + expect(note.value.note.title).toBe('T2'); + expect(note.value.note.body).toBe('cached body'); + await sleep(200); + expect(note.value.note.body).toBe('net body'); + }); + + it('D7 an event-built entity a cached list fills in keeps the other class fields at its next writes', async () => { + class Shelf extends Entity { + __typename = t.typename('Shelf'); + id = t.id; + top = t.entity(ItemSummary); + } + class GetShelf extends RESTQuery { + path = '/shelf'; + result = { shelf: t.entity(Shelf) }; + } + const summary = (id: string, name: string) => ({ __typename: 'Item', id, name }); + const shelfEvent = (name: string) => ({ + type: 'update' as const, + typename: 'Shelf', + data: { __typename: 'Shelf', id: 's1', top: summary('i1', name) }, + }); + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + // The list is cached with Item:i1, then the detail writes the record. + f.set('/items', { items: [summary('i1', 'I')] }); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItems)); + await start(c, () => fetchQuery(GetItem)); + }); + + const c = makeClient(kv, f); + f.set('/shelf', { shelf: { __typename: 'Shelf', id: 's1', top: summary('i2', 'J') } }); + await start(c, () => fetchQuery(GetShelf)); + // Item:i1 enters memory from a streamed event, then the cached list fills it in. + c.applyMutationEvent(shelfEvent('E1')); + f.set('/items', { items: [summary('i1', 'L')] }, 50); + const list: any = start(c, () => fetchQuery(GetItems)); + await sleep(10); + expect(list.isReady).toBe(true); + const kept = { details: { rating: 5 }, vendor: { __entityRef: vendorKey('v1') } }; + c.applyMutationEvent(shelfEvent('E2')); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'E2', ...kept }); + await sleep(100); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'L', ...kept }); + }); + + it('D5 the stores write the kept fields with the record, the async writer included', async () => { + const kv = new MemoryPersistentStore(); + new SyncQueryStore(kv).saveEntity(7, { id: 'a', name: 'n' }, undefined, '"extra":{"x":1}'); + expect(JSON.parse(kv.getString(valueKeyFor(7))!)).toEqual({ id: 'a', name: 'n', extra: { x: 1 } }); + + const delegate = new Map(); + const asyncDelegate = { + has: async (k: string) => delegate.has(k), + getString: async (k: string) => delegate.get(k) as string | undefined, + setString: async (k: string, v: string) => void delegate.set(k, v), + getNumber: async (k: string) => delegate.get(k) as number | undefined, + setNumber: async (k: string, v: number) => void delegate.set(k, v), + getBuffer: async (k: string) => delegate.get(k) as Uint32Array | undefined, + setBuffer: async (k: string, v: Uint32Array) => void delegate.set(k, v), + delete: async (k: string) => void delegate.delete(k), + getAllKeys: async () => [...delegate.keys()], + }; + let toWriter: ((msg: StoreMessage) => void) | undefined; + const writer = new AsyncQueryStore({ + isWriter: true, + delegate: asyncDelegate, + connect: handleMessage => { + toWriter = handleMessage; + return { sendMessage: () => {} }; + }, + }); + const reader = new AsyncQueryStore({ + isWriter: false, + connect: () => ({ sendMessage: msg => toWriter!(msg) }), + }); + reader.saveEntity(8, { id: 'b', name: 'n' }, new Set([9]), '"extra":{"__entityRef":9}'); + for (let i = 0; i < 50 && !writer.isSettled(); i++) await sleep(1); + await sleep(1); + expect(JSON.parse(delegate.get(valueKeyFor(8)) as string)).toEqual({ + id: 'b', + name: 'n', + extra: { __entityRef: 9 }, + }); + }); +}); + +describe('E. what the store remembers about the other class, and for how long', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('E1 a cache wiped with clear() does not get the other class fields back from memory', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + + // The summary keeps the detail's fields of Item:i1 in memory; then the + // app wipes the cache (an account switch) and the summary refetches. + const store = new SyncQueryStore(kv); + const c = makeCountingClient(store, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + const list: any = start(c, () => fetchQuery(GetItems)); + await list; + expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ details: { rating: 5 } }); + + store.clear(); + expect(kv.getAllKeys().filter(k => k.startsWith('sq:doc:'))).toEqual([]); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I3' }] }); + await list.value.__refetch(); + + expect(list.value.items[0].name).toBe('I3'); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'I3' }); + expect(kv.getBuffer(refIdsKeyFor(hashValue(['Item', 'i1'])))).toBeUndefined(); + expect(kv.getNumber(refCountKeyFor(vendorKey('v1')))).toBeUndefined(); + }); + + it('E2 a field name no class has declared for 30 days is forgotten', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + const day = 24 * 60 * 60 * 1000; + const t0 = Date.now(); + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(t0); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + + /** A list session at `at`, in a new app process (a store over a copy of the kv knows nothing yet). */ + const listSession = async (at: number): Promise => { + now.mockReturnValue(at); + const copy = new MemoryPersistentStore(); + for (const k of kv.getAllKeys()) { + const v = kv.getString(k) ?? kv.getNumber(k) ?? kv.getBuffer(k); + if (typeof v === 'string') copy.setString(k, v); + else if (typeof v === 'number') copy.setNumber(k, v); + else if (v !== undefined) copy.setBuffer(k, v); + } + const store = new CountingStore(copy); + const c = makeCountingClient(store, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + await start(c, () => fetchQuery(GetItems)); + c.destroy(); + return store.reads; + }; + + // Within 30 days the detail's names are known: the summary's first write keeps its fields. + expect(await listSession(t0 + 29 * day)).toBe(1); + // Past them, the summary is no longer marked: it writes without reading. + expect(await listSession(t0 + 31 * day)).toBe(0); + }); + + it('E3 a typename with one class: an event carrying every field writes the record whole, without a merge', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/reading/latest', { reading: readingPayload }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetLatestReading)); + }); + const store = new SyncQueryStore(kv); + const c = makeCountingClient(store, f); + f.set('/reading/page', { reading: { ...readingPayload, id: 'r2' }, label: 'x' }); + await start(c, () => fetchQuery(GetReadingPage)); + const merges = vi.spyOn(store, 'mergeEntity'); + const saves = vi.spyOn(store, 'saveEntity'); + c.applyMutationEvent({ type: 'update', typename: 'Reading', data: { ...readingPayload, value: 2 } }); + expect(merges).not.toHaveBeenCalled(); + expect(saves.mock.calls.map(call => call[0])).toEqual([hashValue(['Reading', 'r1'])]); + expect(recordOf(kv, 'Reading', 'r1')).toMatchObject({ value: 2 }); + }); + + it('E5 records an earlier release wrote keep the other class fields under such events until the names cover them', async () => { + const day = 24 * 60 * 60 * 1000; + const t0 = Date.now(); + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(t0); + let kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + /** The kv opened by a new app process; without `sq:meta:` keys, as a release that did not remember field names left it. */ + const reopen = (stripMeta: boolean) => { + const copy = new MemoryPersistentStore(); + for (const k of kv.getAllKeys()) { + if (stripMeta && k.startsWith('sq:meta:')) continue; + const v = kv.getString(k) ?? kv.getNumber(k) ?? kv.getBuffer(k); + if (typeof v === 'string') copy.setString(k, v); + else if (typeof v === 'number') copy.setNumber(k, v); + else if (v !== undefined) copy.setBuffer(k, v); + } + kv = copy; + }; + /** A session that registers the summary only, then streams an update carrying every summary field of Item:i1. */ + const listSession = async (at: number, name: string) => { + now.mockReturnValue(at); + const store = new SyncQueryStore(kv); + const c = makeCountingClient(store, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i2', name: 'J' }] }); + await start(c, () => fetchQuery(GetItems)); + const merges = vi.spyOn(store, 'mergeEntity'); + const saves = vi.spyOn(store, 'saveEntity'); + c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name } }); + c.destroy(); + clients = clients.filter(x => x !== c); + return { + merged: merges.mock.calls.length, + savedWhole: saves.mock.calls.some(call => call[0] === hashValue(['Item', 'i1'])), + }; + }; + const kept = { details: { rating: 5 }, vendor: { __entityRef: vendorKey('v1') } }; + + // The first session after the upgrade, and the next one (whose names are the summary's only), merge. + const hour = 60 * 60 * 1000; + reopen(true); + expect(await listSession(t0 + hour, 'U1')).toEqual({ merged: 1, savedWhole: false }); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'U1', ...kept }); + reopen(false); + expect(await listSession(t0 + 2 * hour, 'U2')).toEqual({ merged: 1, savedWhole: false }); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'U2', ...kept }); + // 30 days after the store started remembering, a class not declared since would have been forgotten anyway: written whole. + reopen(false); + kv.setNumber('sq:meta:fieldsSince', t0 + 3 * hour - 30 * day); + expect(await listSession(t0 + 3 * hour, 'U3')).toEqual({ merged: 0, savedWhole: true }); + + // A store that held no cached data when it was first opened, or that clear() emptied, writes such events whole. + const fresh = new MemoryPersistentStore(); + expect(new SyncQueryStore(fresh).entityFieldNamesComplete()).toBe(true); + expect(fresh.getAllKeys()).toEqual([]); + reopen(true); + const store = new SyncQueryStore(kv); + expect(store.entityFieldNamesComplete()).toBe(false); + store.clear(); + expect(store.entityFieldNamesComplete()).toBe(true); + reopen(false); + expect(new SyncQueryStore(kv).entityFieldNamesComplete()).toBe(true); + }); + + it('E4 a store that does not remember field names merges such an event, keeping fields of a class it has not seen', async () => { + class NoFieldNamesStore extends SyncQueryStore {} + (NoFieldNamesStore.prototype as any).getEntityFieldNames = undefined; + (NoFieldNamesStore.prototype as any).addEntityFieldNames = undefined; + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + const c = makeCountingClient(new NoFieldNamesStore(kv), f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i2', name: 'J' }] }); + await start(c, () => fetchQuery(GetItems)); + c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'I2' } }); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ + __typename: 'Item', + id: 'i1', + name: 'I2', + details: { rating: 5 }, + vendor: { __entityRef: vendorKey('v1') }, + }); + }); +}); diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index 81b098f..4e050a2 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -131,48 +131,78 @@ describe('hydration of a cached query whose entity is already in memory', () => } }); - it('rejects the cached query when the in-memory entity lacks a field the query requires', async () => { - class Narrow extends Entity { - __typename = t.typename('Doc'); - id = t.id; - title = t.string; - } - class Wide extends Entity { - __typename = t.typename('Doc'); - id = t.id; - title = t.string; - body = t.string; - } - class GetNarrow extends RESTQuery { - path = '/narrow'; - result = { doc: t.entity(Narrow) }; - } - class GetWide extends RESTQuery { - path = '/wide'; - result = { doc: t.entity(Wide) }; - config = { staleTime: 60_000 }; - } - const kv = new MemoryPersistentStore(); - const store = new SyncQueryStore(kv); - const mockFetch = createMockFetch(); - mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B' } }); - mockFetch.get('/narrow', { doc: { __typename: 'Doc', id: 1, title: 'T' } }); - - const { client: first } = makeClient(store, mockFetch); - await holdQuery(first, () => fetchQuery(GetWide)); - first.destroy(); - - // The narrow query fetches fresh, so Doc:1 is in memory without `body`. - const { client, warn } = makeClient(store, mockFetch); - await holdQuery(client, () => fetchQuery(GetNarrow)); - mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B2' } }); - const wide = holdQuery(client, () => fetchQuery(GetWide)); - await wide; - // The cache was dropped with a warning and the network answered instead. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); - expect((wide.value as any).doc.body).toBe('B2'); - client.destroy(); - }); + for (const absent of [false, true]) { + it( + absent + ? 'rejects the cached query when the in-memory entity holds a field the query requires as absent' + : 'serves the cached query when the in-memory entity lacks a field only another class declares', + async () => { + class Narrow extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + } + // Declares `body` optional: a response without it says, more recently + // than the cache, that there is none. (An object field, which two + // classes may declare with different optionality.) + class NarrowWithBody extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + body = t.optional(t.object({ text: t.string })); + } + class Wide extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + body = t.object({ text: t.string }); + } + class GetNarrow extends RESTQuery { + path = '/narrow'; + result = { doc: t.entity(absent ? NarrowWithBody : Narrow) }; + } + class GetWide extends RESTQuery { + path = '/wide'; + result = { doc: t.entity(Wide) }; + config = { staleTime: 60_000 }; + } + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: { text: 'B' } } }); + mockFetch.get('/narrow', { doc: { __typename: 'Doc', id: 1, title: 'T2' } }); + + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetWide)); + first.destroy(); + + // The narrow query fetches fresh, so Doc:1 is in memory without `body`. + const { client, warn } = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetNarrow)); + const docKey = hashValue(['Doc', 1]); + mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T3', body: { text: 'B2' } } }); + const wide = holdQuery(client, () => fetchQuery(GetWide)); + await wide; + if (absent) { + // The narrow write removed `body` from the record, and the fresher + // absence in memory contradicts the cache: dropped with a warning, + // and the network answered instead. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); + expect((wide.value as any).doc.body.text).toBe('B2'); + } else { + // The narrow write kept the record's `body`, and the cached query is + // served (within its staleTime, so not refetched): the in-memory + // title, the record's body. + expect(warn).not.toHaveBeenCalled(); + expect(mockFetch.calls.filter(c => c.url.endsWith('/wide'))).toHaveLength(1); + expect((wide.value as any).doc.title).toBe('T2'); + expect((wide.value as any).doc.body.text).toBe('B'); + expect(getDoc(kv, docKey)).toEqual({ __typename: 'Doc', id: 1, title: 'T2', body: { text: 'B' } }); + } + client.destroy(); + }, + ); + } it('rejects the cached query when the in-memory entity lacks a nested entity the query requires', async () => { class Org extends Entity { @@ -230,52 +260,70 @@ describe('hydration of a cached query whose entity is already in memory', () => client.destroy(); }); - it('a cached list holding an in-memory entity the query cannot use is refetched whole, not served with a hole', async () => { - class Narrow extends Entity { - __typename = t.typename('Doc'); - id = t.id; - title = t.string; - } - class Wide extends Entity { - __typename = t.typename('Doc'); - id = t.id; - title = t.string; - body = t.string; - } - class GetNarrow extends RESTQuery { - params = { id: t.id }; - path = `/narrow/${this.params.id}`; - result = { doc: t.entity(Narrow) }; - } - class GetDocs extends RESTQuery { - path = '/docs'; - result = { docs: t.array(t.entity(Wide)) }; - config = { staleTime: 60_000 }; - } - const kv = new MemoryPersistentStore(); - const store = new SyncQueryStore(kv); - const mockFetch = createMockFetch(); - const docs = [ - { __typename: 'Doc', id: 1, title: 'T1', body: 'B1' }, - { __typename: 'Doc', id: 2, title: 'T2', body: 'B2' }, - ]; - mockFetch.get('/docs', { docs }); - mockFetch.get('/narrow/1', { doc: { __typename: 'Doc', id: 1, title: 'T1' } }); - - const { client: first } = makeClient(store, mockFetch); - await holdQuery(first, () => fetchQuery(GetDocs)); - first.destroy(); - - // Doc:1 comes into memory through the narrow query, without `body`. - const { client, warn } = makeClient(store, mockFetch); - await holdQuery(client, () => fetchQuery(GetNarrow, { id: 1 })); - mockFetch.get('/docs', { docs: docs.map(d => ({ ...d, body: `${d.body}!` })) }); - const list = holdQuery(client, () => fetchQuery(GetDocs)); - await list; - expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); - expect((list.value as any).docs.map((d: any) => d.body)).toEqual(['B1!', 'B2!']); - client.destroy(); - }); + for (const absent of [false, true]) { + it( + absent + ? 'a cached list holding an in-memory entity the query cannot use is refetched whole, not served with a hole' + : 'a cached list holding an in-memory entity that lacks a field only another class declares is served whole', + async () => { + class Narrow extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + } + class NarrowWithBody extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + body = t.optional(t.object({ text: t.string })); + } + class Wide extends Entity { + __typename = t.typename('Doc'); + id = t.id; + title = t.string; + body = t.object({ text: t.string }); + } + class GetNarrow extends RESTQuery { + params = { id: t.id }; + path = `/narrow/${this.params.id}`; + result = { doc: t.entity(absent ? NarrowWithBody : Narrow) }; + } + class GetDocs extends RESTQuery { + path = '/docs'; + result = { docs: t.array(t.entity(Wide)) }; + config = { staleTime: 60_000 }; + } + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const mockFetch = createMockFetch(); + const docs = [ + { __typename: 'Doc', id: 1, title: 'T1', body: { text: 'B1' } }, + { __typename: 'Doc', id: 2, title: 'T2', body: { text: 'B2' } }, + ]; + mockFetch.get('/docs', { docs }); + mockFetch.get('/narrow/1', { doc: { __typename: 'Doc', id: 1, title: 'T1' } }); + + const { client: first } = makeClient(store, mockFetch); + await holdQuery(first, () => fetchQuery(GetDocs)); + first.destroy(); + + // Doc:1 comes into memory through the narrow query, without `body`. + const { client, warn } = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetNarrow, { id: 1 })); + mockFetch.get('/docs', { docs: docs.map(d => ({ ...d, body: { text: `${d.body.text}!` } })) }); + const list = holdQuery(client, () => fetchQuery(GetDocs)); + await list; + if (absent) { + expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); + expect((list.value as any).docs.map((d: any) => d.body.text)).toEqual(['B1!', 'B2!']); + } else { + expect(warn).not.toHaveBeenCalled(); + expect((list.value as any).docs.map((d: any) => d.body.text)).toEqual(['B1', 'B2']); + } + client.destroy(); + }, + ); + } it('a member the query narrows out anyway does not reject the cached query', async () => { class UserPreview extends Entity { diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 7dd3e58..419db6a 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -11,7 +11,7 @@ import type { QueryClient } from './QueryClient.js'; import type { EntityInstance } from './EntityInstance.js'; import type { ParseContext, ParsedEntity } from './parseEntities.js'; import { FormattedValue, ValidatorDef } from './typeDefs.js'; -import { Mask } from './types.js'; +import { type EntityDef, Mask } from './types.js'; import { createLiveCollection, LiveCollectionBinding } from './LiveCollection.js'; import { PROXY_ID } from './proxyId.js'; import { dropNestedWrapper } from './nestedNotifiers.js'; @@ -135,7 +135,7 @@ function applyEntity( appendMode: boolean, created: Set | undefined, ): Record { - const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial } = entity; + const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial, record } = entity; const shapeFields = entityShape.shape; // The same entity can appear more than once in one payload (an author under @@ -186,14 +186,16 @@ function applyEntity( if ((persist === true && rawKeys === undefined) || fillsPartial) { // A full payload (a fetch), or the record's fields merged in, makes // the data a complete record again. + if (entityInstance._partial && !fillsPartial) checkStoredRecord(entityInstance, entityShape, queryClient); entityInstance._partial = false; entityInstance._partialKeys = undefined; } else if (entityInstance._partial && rawKeys !== undefined) { - // Stays partial even once the events have carried every field of - // this class: another class sharing the typename may have written - // fields to the record that this one does not declare, and only a - // merge keeps them. - for (const k of rawKeys) entityInstance._partialKeys!.add(k); + const partialKeys = entityInstance._partialKeys!; + for (const k of rawKeys) partialKeys.add(k); + if (eventsBuiltWholeRecord(partialKeys, entityShape, queryClient)) { + entityInstance._partial = false; + entityInstance._partialKeys = undefined; + } } } else { initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode, created); @@ -206,14 +208,17 @@ function applyEntity( // event did (the parser fills the literal in). if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); - // An event that carried every field of this class is still partial: - // the record may hold fields another class sharing the typename - // declares (one not registered this session, too), so it is merged - // into, not replaced. With no record to merge into, the first write - // makes it whole (see `EntityInstance.save()`). + // Events that carried every field built a whole record, unless the + // record may hold fields another class of the typename declares. + if (eventsBuiltWholeRecord(entityInstance._partialKeys, entityShape, queryClient)) { + entityInstance._partial = false; + entityInstance._partialKeys = undefined; + } } else if (persist === false) { // Hydrated from the store: its record exists. entityInstance._recorded = true; + } else if (persist === true && record === undefined) { + checkStoredRecord(entityInstance, entityShape, queryClient); } if (persist === 'existing') created?.add(entityInstance); } @@ -221,6 +226,8 @@ function applyEntity( entityInstance._applying = false; } if (isUpdate && changed) entityInstance.notify(); + // Hydration handed over the record: it holds fields the data does not. + if (record !== undefined) entityInstance.noteRecord(record); if (appendMode && entityInstance.liveCollections.length > 0) { for (const binding of entityInstance.liveCollections) { @@ -260,6 +267,42 @@ function applyEntity( return proxy; } +/** + * A whole record is about to be written from a fetch's data: if the entity's + * class lacks fields another class sharing its typename declares, the stored + * record may hold them, and the first write reads it to keep them. For an app + * whose typenames each have one class this is a field read. + */ +function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClient: QueryClient): void { + if ( + queryClient.hasForeignFieldDefs && + !instance.keepsRecordFields() && + queryClient.mayMissForeignFields(shape as unknown as ValidatorDef) + ) { + instance._checkStoredRecord = true; + } +} + +/** + * Whether the fields streamed events carried (`keys`) are the entity's whole + * record, so it can be written whole rather than merged into its record. They + * must name every field of its class, and no other class sharing the typename + * may declare a field this one lacks: such a field can sit in the record (an + * earlier session's write), and only a merge keeps it. That takes a store + * that remembers each typename's field names across sessions, for every + * record it holds; with any other store, or one with records an earlier + * release wrote, a class not registered this session may be unknown, so the + * entity stays partial and is merged. + */ +function eventsBuiltWholeRecord(keys: Set, entityShape: EntityDef, queryClient: QueryClient): boolean { + for (const k in entityShape.shape) if (!keys.has(k)) return false; + if (!queryClient.storeKnowsTypenameFields) return false; + return ( + !queryClient.hasForeignFieldDefs || + !queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) + ); +} + // ====================================================== // Field merge (update path) — reify + merge in one loop // ====================================================== diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index f71e08e..a5f4987 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -54,6 +54,13 @@ export interface ParsedEntity { eventKeys: Set | undefined; /** A cached record parsed to fill in the fields an entity built from events lacks. */ fillsPartial: boolean; + /** + * Hydration only: the raw cached record, when it holds fields the + * instance's data does not (another class sharing the typename wrote + * them). The apply keeps those fields on the instance so its writes carry + * them instead of dropping them from the record. + */ + record?: Record; } /** Trust-memory verdicts: `true`/`false` once settled, `'pending'` while a check is in progress (a cycle). */ @@ -508,6 +515,8 @@ function parseEntityData( parsedData[entityShape.idField] = id; } + let record: Record | undefined; + if (preloadedEntities !== undefined) { const existing = queryClient.entityMap.getEntity(key); @@ -521,36 +530,39 @@ function parseEntityData( // parse the record for those fields only and merge them in. fillKeys = existing._partialKeys; obj = preloaded; - } else if (existing !== undefined) { - // The entity is already live in memory, so the cache has nothing newer - // to offer: the in-memory data is what every consumer already sees. - // Re-parsing that data would run already-parsed values (formatted - // values, parse results, child proxies) through the parser again, in - // place, and corrupt them. An empty partial entry merges nothing and - // hands the query the existing proxy. The data must still satisfy this - // query's shape, or the cached query is not usable as-is. - ctx.trusted ??= new Map(); - if (!dataSatisfiesDef(existing.data, entityShape as unknown as ValidatorDef, queryClient, ctx.trusted)) { - throw new CachedEntityMismatchError( - `Cached entity ${entityShape.typenameValue}:${String(existing.id)} in memory does not satisfy the query's shape`, - ); + // The fill makes the entity whole, so its next write replaces the + // record: hand over the record when it holds fields this class does not + // declare (another class of the typename wrote them), and that write + // keeps them. Hydrating over an event-built entity is rare, so every + // record is checked, not only for a class known to lack fields: a store + // written before field names were remembered may not know the other + // class. + const shape = entityShape.shape; + for (const k in preloaded) { + if (!(k in shape)) { + record = preloaded; + break; + } } - const entry: ParsedEntity = { - key, - shape: entityShape, - data: parsedData, - rawKeys: new Set(), - eventKeys: undefined, - fillsPartial: false, - }; - ctx.seen!.set(parsedData, entry); - ctx.seenByKey!.set(key, entry); - return parsedData; + } else if (existing !== undefined) { + return hydrateFromMemory(existing, preloaded, key, id, parsedData, entityShape, ctx); } else { if (preloaded === undefined) { throw new Error(`Cached entity ${key} not found in preloaded map`); } obj = preloaded; + // Only a class lacking fields another class of its typename declares + // can be handed a record holding fields it does not parse; for every + // other class this is one WeakMap lookup. + if (queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef)) { + const shape = entityShape.shape; + for (const k in preloaded) { + if (!(k in shape)) { + record = preloaded; + break; + } + } + } } if (fillKeys !== undefined) { @@ -565,6 +577,7 @@ function parseEntityData( eventKeys: undefined, fillsPartial: true, }; + if (record !== undefined) entry.record = record; ctx.seen!.set(parsedData, entry); ctx.seenByKey!.set(key, entry); const entityDesc = `[[${entityShape.typenameValue}:${id}]]`; @@ -594,6 +607,7 @@ function parseEntityData( eventKeys, fillsPartial: false, }; + if (record !== undefined) entry.record = record; ctx.seen!.set(parsedData, entry); ctx.seenByKey!.set(key, entry); @@ -611,6 +625,95 @@ function parseEntityData( return parsedData; } +// ====================================================== +// Hydrating an entity that is already in memory +// ====================================================== + +/** + * A cached query references an entity that is already live in memory. The + * in-memory data is at least as fresh as the cached record (it was hydrated + * from that record or applied from a later payload) and every consumer + * already sees it, so it is never re-parsed or overwritten from the cache: + * re-parsing would run already-parsed values (formatted values, parse + * results, child proxies) through the parser again, in place, and corrupt + * them. + * + * Field by field, against the hydrating query's shape: + * - no class applied to the instance declares the field (it is not an own + * key of the data; every applied class's parse sets each of its fields, + * even to `undefined`) and the record holds it: the record's value is + * parsed and merged in. Nothing in memory says anything about it, and it + * was written by a class that declares it, so it is no older than this + * cached query; + * - the in-memory value satisfies the field: used as is; + * - anything else (a value of another type, or `undefined` from a class + * that declares the field optional, which is fresher than the record): + * the cached query cannot be served, `CachedEntityMismatchError`, and it + * is dropped and fetched. + */ +function hydrateFromMemory( + existing: EntityInstance, + preloaded: Record | undefined, + key: number, + id: string | number, + parsedData: Record, + entityShape: EntityDef, + ctx: ParseContext, +): Record { + const queryClient = ctx.queryClient!; + const shape = entityShape.shape as Record; + const typenameField = entityShape.typenameField; + const data = existing.data; + const trusted = (ctx.trusted ??= new Map()); + let fillKeys: Set | undefined; + + for (const fieldKey of Object.keys(shape)) { + if (fieldKey === typenameField) continue; + const value = data[fieldKey]; + if ( + value === undefined && + preloaded !== undefined && + preloaded[fieldKey] !== undefined && + !Object.hasOwn(data, fieldKey) + ) { + // An optional field too: the record knows it, memory does not. + (fillKeys ??= new Set()).add(fieldKey); + continue; + } + if (valueSatisfiesDef(value, shape[fieldKey], queryClient, trusted)) continue; + throw new CachedEntityMismatchError( + `Cached entity ${entityShape.typenameValue}:${String(existing.id)} in memory does not satisfy the query's shape at ${fieldKey}`, + ); + } + + // With nothing to fill, an empty key set merges nothing and hands the + // query the existing proxy. + const entry: ParsedEntity = { + key, + shape: entityShape, + data: parsedData, + rawKeys: fillKeys ?? new Set(), + eventKeys: undefined, + fillsPartial: false, + }; + if (fillKeys !== undefined) entry.record = preloaded; + ctx.seen!.set(parsedData, entry); + ctx.seenByKey!.set(key, entry); + + if (fillKeys !== undefined) { + const entityDesc = `[[${entityShape.typenameValue}:${id}]]`; + for (const fieldKey of fillKeys) { + parsedData[fieldKey] = parseData( + preloaded![fieldKey], + shape[fieldKey] as unknown as TypeDef, + ctx, + `${entityDesc}.${fieldKey}`, + ); + } + } + return parsedData; +} + // ====================================================== // entitySatisfiesShape // ====================================================== diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index feb01d8..9663929 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -109,7 +109,16 @@ export interface QueryStore { refIds?: Set, ): void; - saveEntity(entityKey: number, value: unknown, refIds?: Set): void; + /** + * Writes an entity's record. `rest`, when given, is a JSON object body + * (`"a":1,"b":{…}`) of fields the record already held that no entity class + * applied to the in-memory instance declares (another class sharing the + * typename wrote them). The store writes them alongside `value`, ahead of + * its fields, so the write does not drop them; `refIds` already includes + * the references inside them. A store that ignores `rest` drops those + * fields from the record, as before. + */ + saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void; /** * Writes the fields streamed events supplied for an entity built from them: @@ -168,6 +177,42 @@ export interface QueryStore { * without this hook is not written for such events. */ hasEntity?(key: number): boolean | undefined; + + /** + * Synchronous stores: the stored record of an entity, parsed, or + * `undefined` when there is none. Read once, before the first write of an + * entity built from a fetch whose class lacks fields another class sharing + * its typename declares (see `getEntityFieldNames`), so that write keeps + * the record's fields of the other class. + */ + readEntity?(entityKey: number): Record | undefined; + + /** + * The top-level field names the entity classes of `typename` have + * declared, as `addEntityFieldNames` recorded them in this session or an + * earlier one. Lets the client tell, without reading any record, which + * classes may be handed a record holding fields they do not declare. A + * store without these methods only knows the classes of the current + * session. + */ + getEntityFieldNames?(typename: string): readonly string[] | undefined; + /** + * Called once per entity class and client with every top-level field the + * class declares. A store may forget names no class has declared for a + * while (`SyncQueryStore`: 30 days), so a field an app update removed stops + * being kept in records. + */ + addEntityFieldNames?(typename: string, fields: readonly string[]): void; + /** + * Whether the names `getEntityFieldNames` returns cover every class that + * wrote a record the store holds. Asked once, when the client is created. + * Only then is an entity built from streamed events that carried every + * field of its class, of a typename whose other classes declare nothing + * more, written whole rather than merged into its record. A store some of + * whose records were written before it remembered field names (by an + * earlier release) answers `false` until they cannot matter. + */ + entityFieldNamesComplete?(): boolean; } export type MaybePromise = T | Promise; diff --git a/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx new file mode 100644 index 0000000..f739958 --- /dev/null +++ b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render } from 'vitest-browser-react'; +import { ContextProvider } from 'signalium/react'; +import { watcher, withContexts } from 'signalium'; +import React from 'react'; +import { MemoryPersistentStore, SyncQueryStore } from '../../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../../QueryClient.js'; +import { t } from '../../typeDefs.js'; +import { Entity } from '../../proxy.js'; +import { RESTQuery, RESTQueryAdapter } from '../../rest/index.js'; +import { fetchQuery } from '../../query.js'; +import { sleep } from '../../__tests__/utils.js'; +import { useQuery } from '../index.js'; + +/** + * React view of hydrate-in-memory-shape: a screen whose cached query shares + * an entity with a query already in memory paints its cached value on the + * first commit (a synchronous store), not a loading frame followed by the + * network result. + */ + +let clients: QueryClient[] = []; +afterEach(() => { + for (const c of clients) c.destroy(); + clients = []; +}); + +function makeFetch() { + const routes = new Map(); + const fetch = (url: string) => { + const path = new URL(url).pathname; + const route = routes.get(path); + if (route === undefined) return Promise.reject(new Error(`no route for ${path}`)); + const body = JSON.parse(JSON.stringify(route.body)); + return new Promise(resolve => + setTimeout( + () => + resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + }), + route.delay, + ), + ); + }; + return { + fetch, + set(path: string, body: unknown, delay = 0) { + routes.set(path, { body, delay }); + }, + }; +} + +function makeClient(kv: MemoryPersistentStore, f: ReturnType, warnings: string[] = []) { + const client = new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })], + log: { warn: (m: string) => warnings.push(m), error: (m: string) => warnings.push(m) }, + } as any); + clients.push(client); + return client; +} + +async function fetchIn(client: QueryClient, Q: new () => RESTQuery) { + await withContexts([[QueryClientContext, client]], () => { + const q = fetchQuery(Q as any); + const w = watcher(() => q.value); + w.addListener(() => {}); + return q; + }); +} + +class ProductSummary extends Entity { + __typename = t.typename('RxProduct'); + id = t.id; + name = t.string; +} +class ProductDetail extends Entity { + __typename = t.typename('RxProduct'); + id = t.id; + name = t.string; + details = t.object({ rating: t.number }); + updatedAt = t.format('date-time'); +} +class GetProductList extends RESTQuery { + path = '/products'; + result = { items: t.array(t.entity(ProductSummary)) }; +} +class GetProductDetail extends RESTQuery { + path = '/product'; + result = { product: t.entity(ProductDetail), label: t.string }; +} +class GetProductStamp extends RESTQuery { + path = '/product-stamp'; + result = { product: t.entity(ProductDetail) }; +} + +const product = (rating: number) => ({ + __typename: 'RxProduct', + id: 'p1', + name: 'P', + details: { rating }, + updatedAt: '2026-01-01T00:00:00.000Z', +}); + +describe('cached first paint over an in-memory entity', () => { + for (const scenario of ['nothing in memory', 'summary in memory', 'format field in memory'] as const) { + it(`first commit shows the cached detail (${scenario})`, async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/product', { product: product(5), label: 'cached' }); + const seedClient = makeClient(kv, f); + await fetchIn(seedClient, GetProductDetail); + seedClient.destroy(); + clients = []; + + const warnings: string[] = []; + const client = makeClient(kv, f, warnings); + if (scenario === 'summary in memory') { + f.set('/products', { items: [{ __typename: 'RxProduct', id: 'p1', name: 'P' }] }); + await fetchIn(client, GetProductList); + } else if (scenario === 'format field in memory') { + f.set('/product-stamp', { product: product(5) }); + await fetchIn(client, GetProductStamp); + } + f.set('/product', { product: product(6), label: 'net' }, 150); + + const renders: string[] = []; + function Detail(): React.ReactNode { + const q = useQuery(GetProductDetail); + const label = q.isReady + ? `${q.value.label}:${q.value.product.details.rating}:${q.value.product.updatedAt.getUTCFullYear()}` + : 'loading'; + renders.push(label); + return
{label}
; + } + const { getByText } = render( + + + , + ); + await expect.element(getByText('net:6:2026')).toBeInTheDocument(); + await sleep(10); + expect(warnings).toEqual([]); + expect(renders[0]).toBe('cached:5:2026'); + expect(renders).not.toContain('loading'); + }); + } +}); diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 9cf6023..1971e88 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -62,6 +62,12 @@ export type StoreMessage = refIds?: number[]; /** `value` holds only some fields; the writer merges them over the stored record, if any. */ merge?: boolean; + /** + * Fields of the stored record to keep alongside `value` (a JSON object + * body, see `QueryStore.saveEntity`). Optional on the wire: a writer + * built before it ignores it and writes `value` alone. + */ + rest?: string; } | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount?: number } | { type: StoreMessageType.DeleteQuery; queryKey: number }; @@ -326,7 +332,7 @@ export class AsyncQueryStore implements QueryStore { ); break; case StoreMessageType.SaveEntity: - await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true); + await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true, msg.rest); for (let i = 0; i < this.persistedListeners.length; i++) this.persistedListeners[i](msg.entityKey); break; case StoreMessageType.ActivateQuery: @@ -422,13 +428,15 @@ export class AsyncQueryStore implements QueryStore { this.dispatch(message); } - saveEntity(entityKey: number, value: unknown, refIds?: Set): void { - this.dispatch({ + saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void { + const message: Extract = { type: StoreMessageType.SaveEntity, entityKey, value, refIds: refIds ? Array.from(refIds) : undefined, - }); + }; + if (rest !== undefined && rest !== '') message.rest = rest; + this.dispatch(message); } mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void { @@ -483,6 +491,7 @@ export class AsyncQueryStore implements QueryStore { value: unknown, refIds: number[] | undefined, merge: boolean, + rest?: string, ): Promise { if (merge) { const stored = await this.delegate!.getString(valueKeyFor(entityKey)); @@ -492,7 +501,7 @@ export class AsyncQueryStore implements QueryStore { return; } } - await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined); + await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined, rest); } private async writerActivateQuery( @@ -607,10 +616,13 @@ export class AsyncQueryStore implements QueryStore { } } - private async setValue(id: number, value: unknown, refIds?: Set): Promise { + /** `rest`: fields of the stored record to keep, written ahead of `value`'s (see `QueryStore.saveEntity`). */ + private async setValue(id: number, value: unknown, refIds?: Set, rest?: string): Promise { const delegate = this.delegate!; - await delegate.setString(valueKeyFor(id), JSON.stringify(value)); + let json = JSON.stringify(value); + if (rest !== undefined && rest !== '') json = json.length === 2 ? `{${rest}}` : `{${rest},${json.slice(1)}`; + await delegate.setString(valueKeyFor(id), json); this.noteHeld(id); const refIdsKey = refIdsKeyFor(id); diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index b318e71..7e8f461 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -1,3 +1,5 @@ +// Every key of cached data starts with this +export const DOC_PREFIX = 'sq:doc:'; // Query Instance keys export const VALUE_PREFIX = 'sq:doc:value:'; export const valueKeyFor = (id: number) => `${VALUE_PREFIX}${id}`; @@ -11,6 +13,10 @@ export const lastUsedKeyFor = (queryDefId: string) => `sq:doc:lastUsed:${queryDe export const cacheTimeKeyFor = (queryDefId: string) => `sq:doc:cacheTime:${queryDefId}`; export const LAST_USED_PREFIX = 'sq:doc:lastUsed:'; +// Per typename: the top-level fields its entity classes have declared +export const fieldNamesKeyFor = (typename: string) => `sq:meta:fields:${typename}`; +// Since when the field names cover every record (0: since the store held no cached data) +export const FIELD_NAMES_SINCE_KEY = 'sq:meta:fieldsSince'; // Default values export const DEFAULT_MAX_COUNT = 50; @@ -78,6 +84,30 @@ export function storedRecordRest( return { json: JSON.stringify(rest).slice(1, -1), refIds: [...refIds] }; } +/** + * The fields of a parsed stored record that `data` does not hold as its own + * keys: their names, the fields as a JSON object body (`"a":1,"b":{…}`), and + * the references inside them. `undefined` when there are none. + */ +export function recordRestOutside( + record: Record, + data: Record, +): { keys: string[]; json: string; refIds: number[] } | undefined { + let values: Record | undefined; + let keys: string[] | undefined; + for (const key in record) { + const value = record[key]; + if (value !== undefined && !Object.hasOwn(data, key)) { + (values ??= {})[key] = value; + (keys ??= []).push(key); + } + } + if (values === undefined) return undefined; + const refIds = new Set(); + collectEntityRefs(values, refIds); + return { keys: keys!, json: JSON.stringify(values).slice(1, -1), refIds: [...refIds] }; +} + /** The `{ __entityRef }` markers in a record's JSON, as `collectEntityRefs` finds them in the parsed record. */ export function entityRefsInJson(json: string, into: Set): void { if (json.indexOf('"__entityRef":') === -1) return; diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index b5f4c5d..f95707a 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -11,6 +11,10 @@ import { refIdsKeyFor, updatedAtKeyFor, valueKeyFor, + VALUE_PREFIX, + DOC_PREFIX, + fieldNamesKeyFor, + FIELD_NAMES_SINCE_KEY, storedRecordRest, entityRefsInJson, } from './shared.js'; @@ -103,10 +107,29 @@ const mergeRestsByKv = new WeakMap>( /** Bounds the entries kept; past it the least recently merged is dropped, and its next merge reads again. */ const MAX_MERGE_RESTS = 1024; +/** Per backing kv, like the merge rests: the field names each typename's classes declared, and when. */ +const fieldNamesByKv = new WeakMap>>(); +/** A field name no class has declared for this long (30 days) is forgotten. */ +const FIELD_NAME_TTL = 30 * 24 * 60 * 60 * 1000; +/** A declared name's time is rewritten at most this often (1 day). */ +const FIELD_NAME_REFRESH = 24 * 60 * 60 * 1000; +/** + * Since when a kv's field names have been remembered for every record it + * holds (0: since it held no cached data), and whether that is stored yet + * (see `entityFieldNamesComplete`). + */ +interface FieldNamesSince { + at: number; + stored: boolean; +} +/** Per backing kv, like the field names. */ +const fieldNamesSinceByKv = new WeakMap(); + export class SyncQueryStore implements QueryStore { queues: Map = new Map(); private readonly deleteListeners: Array<(key: number) => void>; private readonly mergeRests: Map; + private readonly fieldNames: Map>; constructor(private readonly kv: SyncPersistentStore) { let listeners = deleteListenersByKv.get(kv); @@ -121,6 +144,12 @@ export class SyncQueryStore implements QueryStore { mergeRestsByKv.set(kv, rests); } this.mergeRests = rests; + let fieldNames = fieldNamesByKv.get(kv); + if (fieldNames === undefined) { + fieldNames = new Map(); + fieldNamesByKv.set(kv, fieldNames); + } + this.fieldNames = fieldNames; } onDelete(listener: (key: number) => void): () => void { @@ -202,8 +231,151 @@ export class SyncQueryStore implements QueryStore { this.activateQuery(queryDef, queryKey); } - saveEntity(entityKey: number, value: unknown, refIds?: Set): void { - this.setValue(entityKey, value, refIds); + saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void { + if (rest === undefined || rest === '') { + this.setValue(entityKey, value, refIds); + return; + } + // The kept fields go first: should one ever repeat a field of `value`, + // the value's own (later) occurrence is the one a parse keeps. + const json = JSON.stringify(value); + const rests = this.mergeRests; + if (rests.size !== 0) rests.delete(entityKey); + this.writeValue(entityKey, json.length === 2 ? `{${rest}}` : `{${rest},${json.slice(1)}`, refIds); + } + + readEntity(entityKey: number): Record | undefined { + const stored = this.kv.getString(valueKeyFor(entityKey)); + if (stored === undefined) return undefined; + let record: unknown; + try { + record = JSON.parse(stored); + } catch { + return undefined; + } + return typeof record === 'object' && record !== null && !Array.isArray(record) + ? (record as Record) + : undefined; + } + + getEntityFieldNames(typename: string): readonly string[] | undefined { + const names = this.fieldNamesOf(typename); + return names.size === 0 ? undefined : [...names.keys()]; + } + + /** + * Whether the remembered field names cover every class that wrote a + * record this store holds. Not while it holds records a version that did + * not remember field names wrote: they may hold fields of a class not + * registered since. When this version first opens a store it notes since + * when the names cover its records (`sq:meta:fieldsSince`, after one scan + * of the keys, stored with the first field names it writes): from then, or + * from the start if it held no cached data. `clear()` resets it to the + * start. 30 days on (`FIELD_NAME_TTL`), a class not declared since then + * would have been forgotten anyway, and the names count as complete. + */ + entityFieldNamesComplete(): boolean { + const at = this.fieldNamesSince().at; + return at === 0 || Date.now() - at >= FIELD_NAME_TTL; + } + + private fieldNamesSince(): FieldNamesSince { + const kv = this.kv; + let since = fieldNamesSinceByKv.get(kv); + if (since !== undefined) return since; + const at = kv.getNumber(FIELD_NAMES_SINCE_KEY); + if (at !== undefined) { + since = { at, stored: true }; + } else { + since = { at: 0, stored: false }; + for (const key of kv.getAllKeys()) { + if (key.startsWith(DOC_PREFIX)) { + since.at = Date.now(); + break; + } + } + } + fieldNamesSinceByKv.set(kv, since); + return since; + } + + /** + * Records that a class of `typename` declares `fields` now. A name keeps + * the time it was last declared (refreshed at most once a day, so a + * session normally writes nothing here); one no class has declared for + * `FIELD_NAME_TTL` is forgotten, so a field an app update removed stops + * marking the typename's classes, and stops being kept in its records. + */ + addEntityFieldNames(typename: string, fields: readonly string[]): void { + const names = this.fieldNamesOf(typename); + const now = Date.now(); + let changed = false; + for (let i = 0; i < fields.length; i++) { + const declaredAt = names.get(fields[i]); + if (declaredAt === undefined || now - declaredAt > FIELD_NAME_REFRESH) { + names.set(fields[i], now); + changed = true; + } + } + if (!changed) return; + const since = this.fieldNamesSince(); + if (!since.stored) { + this.kv.setNumber(FIELD_NAMES_SINCE_KEY, since.at); + since.stored = true; + } + let stored = ''; + for (const [name, declaredAt] of names) stored += `${stored === '' ? '' : '\n'}${name}\t${declaredAt}`; + this.kv.setString(fieldNamesKeyFor(typename), stored); + } + + /** The field names `typename`'s classes declared within `FIELD_NAME_TTL`, and when; read from the kv once. */ + private fieldNamesOf(typename: string): Map { + let names = this.fieldNames.get(typename); + if (names !== undefined) return names; + names = new Map(); + const stored = this.kv.getString(fieldNamesKeyFor(typename)); + if (stored !== undefined && stored !== '') { + const now = Date.now(); + for (const line of stored.split('\n')) { + const tab = line.lastIndexOf('\t'); + if (tab <= 0) continue; + const declaredAt = Number(line.slice(tab + 1)); + if (now - declaredAt <= FIELD_NAME_TTL) names.set(line.slice(0, tab), declaredAt); + } + } + this.fieldNames.set(typename, names); + return names; + } + + /** + * Deletes every query, entity record and queue this store holds (the + * `sq:doc:` keys of its kv) and reports each record to `onDelete` + * listeners, so a client over it drops what it kept from those records + * and its next write of an entity carries only what the entity holds in + * memory. Wipe the cache with this rather than on the kv directly (an + * MMKV `clearAll()`): a client does not know about records deleted under + * it, and its next write of an entity of a typename two classes share puts + * back the other class's fields it kept from the deleted record. The field + * names the store remembers per typename (`sq:meta:`) describe the app's + * entity classes, not cached data, and are kept; with no record left, + * they cover every record from now on (`entityFieldNamesComplete`). + */ + clear(): void { + const kv = this.kv; + const deleted: number[] = []; + for (const key of kv.getAllKeys()) { + if (!key.startsWith(DOC_PREFIX)) continue; + if (key.startsWith(VALUE_PREFIX)) deleted.push(Number(key.slice(VALUE_PREFIX.length))); + kv.delete(key); + } + kv.setNumber(FIELD_NAMES_SINCE_KEY, 0); + fieldNamesSinceByKv.set(kv, { at: 0, stored: true }); + this.queues.clear(); + this.mergeRests.clear(); + const listeners = this.deleteListeners; + for (let i = 0; i < deleted.length; i++) { + for (let j = 0; j < listeners.length; j++) listeners[j](deleted[i]); + } } mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void { From 96662634466ee5ea9c3a639aeec68f235737ade5 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 03:40:20 -0400 Subject: [PATCH 060/109] perf(fetchium): merge a streamed update into a stored record without reading it back each time A full-payload update event for an entity that is not in memory refreshes the entity's record on disk when the store holds one. On a typename that more than one class declares (a list's class and a detail class), the payload lacks the other classes' fields, so the write is a merge, and SyncQueryStore.mergeEntity read the stored record and parsed it on every event, then serialized the result again. A high-rate stream of such events paid that each time: about 40% fewer events per second than before these records were written at all, against 18% for a single-class typename. The store now keeps, per entity record it merged into, which fields the merge carried and what the record holds besides them, as a JSON fragment with the references inside it. The next merge of the same fields writes the payload's JSON with that fragment appended, and takes its references from the markers in the result, without reading the record. Only the first merge per entity reads. Every other write and every deletion of a record through a store over the same kv drops its entry, so a merge never restores a value another write replaced; a merge of different fields reads again. At most 1024 entries are kept, least recently merged first out. The added cost elsewhere is one size check per entity write while nothing was merged, and one map delete per write once something was. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/sync-store-merge-without-read.md | 5 + .../src/__tests__/event-persistence.test.ts | 118 ++++++++++++++++++ packages/fetchium/src/stores/shared.ts | 42 +++++++ packages/fetchium/src/stores/sync.ts | 76 ++++++++++- 4 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 .changeset/sync-store-merge-without-read.md diff --git a/.changeset/sync-store-merge-without-read.md b/.changeset/sync-store-merge-without-read.md new file mode 100644 index 0000000..1533774 --- /dev/null +++ b/.changeset/sync-store-merge-without-read.md @@ -0,0 +1,5 @@ +--- +'fetchium': patch +--- + +`SyncQueryStore.mergeEntity` no longer reads and parses the stored record on every merge. A streamed full payload for an entity that is not in memory, on a typename more than one class declares, is a partial write (the payload lacks the other classes' fields), so each such event merged over the record on disk: a `getString` and a `JSON.parse` per event. The store now keeps, per entity it merged into, what the record holds besides the merged fields, and the next merge of the same fields appends that to the payload's JSON without reading. Only the first merge per entity reads. Any other write or deletion of the record through a store over the same kv drops what was kept, so a merge never restores a value another write replaced. At most 1024 entities are kept; past that the least recently merged reads again. diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index cc771de..c824d6b 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -1632,6 +1632,124 @@ describe('store work per streamed event', () => { }); }); +describe('streamed full payloads for a typename two classes share, entity not in memory', () => { + class Tag extends Entity { + __typename = t.typename('Tag'); + id = t.id; + label = t.string; + } + // A list's class and a detail class of one typename: a list-shaped payload + // carries every field of its class, but not the detail's. + class CoinRow extends Entity { + __typename = t.typename('Coin'); + id = t.id; + symbol = t.string; + price = t.number; + } + class CoinDetail extends Entity { + __typename = t.typename('Coin'); + id = t.id; + symbol = t.string; + price = t.number; + about = t.string; + tag = t.entity(Tag); + } + class GetCoinDetail extends RESTQuery { + params = { id: t.id }; + path = `/coin/${this.params.id}`; + result = { coin: t.entity(CoinDetail) }; + } + class GetCoinRows extends RESTQuery { + path = '/coins'; + result = { coins: t.array(t.entity(CoinRow)) }; + } + + const getClient = setupTestClient(); + + const detail = (about: string) => ({ + coin: { + __typename: 'Coin', + id: 'c1', + symbol: 'C', + price: 1, + about, + tag: { __typename: 'Tag', id: 'g1', label: 'gold' }, + }, + }); + const row = (price: number) => ({ __typename: 'Coin', id: 'c1', symbol: 'C', price }); + + /** Writes the detail's record, registers the list class, and drops both from memory. */ + async function setup(tc: ReturnType): Promise { + tc.mockFetch.get('/coin/c1', detail('first')); + tc.mockFetch.get('/coins', { coins: [{ __typename: 'Coin', id: 'c2', symbol: 'D', price: 2 }] }); + const detailQ = withContexts([[QueryClientContext, tc.client]], () => fetchQuery(GetCoinDetail, { id: 'c1' })); + const w = watcher(() => (detailQ as any).value); + const off = w.addListener(() => {}); + await detailQ; + await holdQuery(tc.client, () => fetchQuery(GetCoinRows)); + off(); + tc.client.entityMap.getEntity(K('Coin', 'c1'))!.evict(); + tc.client.entityMap.getEntity(K('Tag', 'g1'))?.evict(); + } + + it('reads the stored record for the first event only, and keeps the fields the payloads do not carry', async () => { + const tc = getClient(); + await setup(tc); + const reads = vi.spyOn(tc.kv, 'getString'); + + for (let i = 2; i <= 6; i++) { + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row(i) }); + } + + expect(reads.mock.calls.filter(([key]) => key === valueKeyFor(K('Coin', 'c1')))).toHaveLength(1); + expect(getDoc(tc.kv, K('Coin', 'c1'))).toEqual({ + __typename: 'Coin', + id: 'c1', + symbol: 'C', + price: 6, + about: 'first', + tag: { __entityRef: K('Tag', 'g1') }, + }); + expect(refIds(tc.kv, K('Coin', 'c1'))).toEqual([K('Tag', 'g1')]); + expect(audit(tc.kv)).toEqual({ orphans: [], dangling: [] }); + }); + + it('merges over what another write stored meanwhile, not over what an earlier merge saw', async () => { + const tc = getClient(); + await setup(tc); + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row(2) }); + + // A detail-shaped payload rewrites the whole record, changing a field the list payloads do not carry. + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: detail('second').coin }); + expect(tc.client.entityMap.getEntity(K('Coin', 'c1'))).toBeUndefined(); + + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row(3) }); + expect(getDoc(tc.kv, K('Coin', 'c1'))).toMatchObject({ price: 3, about: 'second' }); + + // So does a write through another store over the same kv. + const other = new SyncQueryStore(tc.kv); + other.saveEntity(K('Coin', 'c1'), { __typename: 'Coin', id: 'c1', symbol: 'C', price: 3, about: 'third' }); + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row(4) }); + expect(getDoc(tc.kv, K('Coin', 'c1'))).toEqual({ + __typename: 'Coin', + id: 'c1', + symbol: 'C', + price: 4, + about: 'third', + }); + }); + + it('writes nothing once the store has deleted the record', async () => { + const tc = getClient(); + await setup(tc); + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row(2) }); + tc.store.deleteQuery(K('Coin', 'c1')); + + tc.client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row(3) }); + expect(getDoc(tc.kv, K('Coin', 'c1'))).toBeUndefined(); + }); +}); + describe('AsyncQueryStore writer: write skipping while writes are queued', () => { const clients: QueryClient[] = []; afterEach(() => { diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index e2ed01c..b318e71 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -44,6 +44,48 @@ export function mergeStoredRecord( return { value, refIds: refIds.size > 0 ? refIds : undefined }; } +/** + * What a stored record holds beyond the fields a partial write carries: those + * fields as a JSON fragment (`"a":1,"b":{…}`, or `''` when there are none), + * and the references inside them. A write of `partial` then appends the + * fragment to the partial's own JSON, which is the merged record, without + * reading or parsing the stored one again. Undefined when the stored record + * is not a JSON object. + */ +export function storedRecordRest( + stored: string, + partial: Record, +): { json: string; refIds: number[] } | undefined { + let record: unknown; + try { + record = JSON.parse(stored); + } catch { + return undefined; + } + if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined; + const rest: Record = {}; + let any = false; + for (const key in record as Record) { + // A field the partial holds as undefined is not written by it (JSON drops + // it), so the stored value stays. + if (partial[key] !== undefined) continue; + rest[key] = (record as Record)[key]; + any = true; + } + if (!any) return { json: '', refIds: [] }; + const refIds = new Set(); + collectEntityRefs(rest, refIds); + return { json: JSON.stringify(rest).slice(1, -1), refIds: [...refIds] }; +} + +/** The `{ __entityRef }` markers in a record's JSON, as `collectEntityRefs` finds them in the parsed record. */ +export function entityRefsInJson(json: string, into: Set): void { + if (json.indexOf('"__entityRef":') === -1) return; + const re = /"__entityRef":(\d+)/g; + let match: RegExpExecArray | null; + while ((match = re.exec(json)) !== null) into.add(Number(match[1])); +} + function collectEntityRefs(value: unknown, into: Set): void { if (typeof value !== 'object' || value === null) return; if (Array.isArray(value)) { diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 1f96303..b5f4c5d 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -11,7 +11,8 @@ import { refIdsKeyFor, updatedAtKeyFor, valueKeyFor, - mergeStoredRecord, + storedRecordRest, + entityRefsInJson, } from './shared.js'; export interface SyncPersistentStore { @@ -78,9 +79,34 @@ export class MemoryPersistentStore implements SyncPersistentStore { */ const deleteListenersByKv = new WeakMap void>>(); +/** + * For an entity record last written by `mergeEntity()`: which fields that + * write carried, and what the record holds besides them (`storedRecordRest`). + * The next merge of the same fields writes the merged record from this, + * without reading the stored one. A high-rate stream of updates for entities + * not in memory merges the same fields into the same records over and over; + * only the first merge of each reads. + */ +interface MergeRest { + fields: string; + json: string; + refIds: number[]; +} + +/** + * Keyed by the backing kv, like the delete listeners: any write or deletion + * of a record through a store over that kv drops its entry, so an entry + * always describes the record on disk. (Writing the kv's records other than + * through a store would leave it stale.) + */ +const mergeRestsByKv = new WeakMap>(); +/** Bounds the entries kept; past it the least recently merged is dropped, and its next merge reads again. */ +const MAX_MERGE_RESTS = 1024; + export class SyncQueryStore implements QueryStore { queues: Map = new Map(); private readonly deleteListeners: Array<(key: number) => void>; + private readonly mergeRests: Map; constructor(private readonly kv: SyncPersistentStore) { let listeners = deleteListenersByKv.get(kv); @@ -89,6 +115,12 @@ export class SyncQueryStore implements QueryStore { deleteListenersByKv.set(kv, listeners); } this.deleteListeners = listeners; + let rests = mergeRestsByKv.get(kv); + if (rests === undefined) { + rests = new Map(); + mergeRestsByKv.set(kv, rests); + } + this.mergeRests = rests; } onDelete(listener: (key: number) => void): () => void { @@ -175,10 +207,34 @@ export class SyncQueryStore implements QueryStore { } mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void { - const stored = this.kv.getString(valueKeyFor(entityKey)); - const merged = stored !== undefined ? mergeStoredRecord(stored, fields) : undefined; - if (merged !== undefined) this.setValue(entityKey, merged.value, merged.refIds); - else this.setValue(entityKey, fields, refIds); + const partial = fields as Record; + // The fields this write carries: JSON drops the undefined ones. + let carried = ''; + for (const key in partial) if (partial[key] !== undefined) carried += key + '\n'; + + const rests = this.mergeRests; + let rest = rests.get(entityKey); + if (rest === undefined || rest.fields !== carried) { + const stored = this.kv.getString(valueKeyFor(entityKey)); + const split = stored !== undefined ? storedRecordRest(stored, partial) : undefined; + if (split === undefined) { + // No record to merge into, or not an object: the fields are the record. + this.setValue(entityKey, fields, refIds); + return; + } + rest = { fields: carried, json: split.json, refIds: split.refIds }; + } + + let json = JSON.stringify(fields); + if (rest.json !== '') json = json.length === 2 ? `{${rest.json}}` : `${json.slice(0, -1)},${rest.json}}`; + // The merged record's references, as the markers in it say. + const merged = new Set(rest.refIds); + entityRefsInJson(json, merged); + this.writeValue(entityKey, json, merged); + + rests.delete(entityKey); + rests.set(entityKey, rest); + if (rests.size > MAX_MERGE_RESTS) rests.delete(rests.keys().next().value!); } activateQuery(queryDef: QueryDefinition, queryKey: number): void { @@ -275,9 +331,15 @@ export class SyncQueryStore implements QueryStore { } private setValue(id: number, value: unknown, refIds?: Set): void { + const rests = this.mergeRests; + if (rests.size !== 0) rests.delete(id); + this.writeValue(id, JSON.stringify(value), refIds); + } + + private writeValue(id: number, json: string, refIds?: Set): void { const kv = this.kv; - kv.setString(valueKeyFor(id), JSON.stringify(value)); + kv.setString(valueKeyFor(id), json); const refIdsKey = refIdsKeyFor(id); @@ -324,6 +386,8 @@ export class SyncQueryStore implements QueryStore { deleteQuery(id: number): void { const kv = this.kv; + const rests = this.mergeRests; + if (rests.size !== 0) rests.delete(id); kv.delete(valueKeyFor(id)); kv.delete(refCountKeyFor(id)); From 4363ebdf0c12d55c5d2839bc31e16b3aa42a3220 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:28:09 -0400 Subject: [PATCH 061/109] test(fetchium): benchmark streamed updates for a typename two classes share Co-Authored-By: Claude Opus 5.5 (1M context) --- .../shared-typename-stream.bench.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts diff --git a/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts new file mode 100644 index 0000000..1cfbcd8 --- /dev/null +++ b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts @@ -0,0 +1,172 @@ +import { bench, describe } from 'vitest'; +import { watcher, withContexts } from 'signalium'; +import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { QueryClient, QueryClientContext } from '../QueryClient.js'; +import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; +import { RESTQuery } from '../rest/index.js'; +import { Entity } from '../proxy.js'; +import { fetchQuery } from '../query.js'; +import { t } from '../typeDefs.js'; + +/** + * Streamed full-payload updates for in-memory entities whose typename is + * declared by two classes: a list class and a detail class with fields the + * list class lacks. The detail class wrote the records in an earlier session, + * so every write of a list entity has to keep the detail's fields in its + * record. A typename with one class is measured alongside as the control. + * + * Run with `npm run bench`. + */ + +const meta = { logo: t.string, website: t.string, tags: t.array(t.string) }; + +class MarketRow extends Entity { + __typename = t.typename('Market'); + id = t.id; + symbol = t.string; + name = t.string; + price = t.number; + change = t.number; + volume = t.number; +} + +class MarketDetail extends Entity { + __typename = t.typename('Market'); + id = t.id; + symbol = t.string; + name = t.string; + price = t.number; + change = t.number; + volume = t.number; + extraMetadata = t.object(meta); + categories = t.array(t.string); +} + +class PlainRow extends Entity { + __typename = t.typename('Plain'); + id = t.id; + symbol = t.string; + name = t.string; + price = t.number; + change = t.number; + volume = t.number; +} + +class GetMarkets extends RESTQuery { + params = { n: t.number }; + path = `/markets/${this.params.n}`; + result = { items: t.array(t.entity(MarketRow)) }; + getConfig() { + return { retry: 0, gcTime: Infinity }; + } +} + +class GetMarket extends RESTQuery { + static cache = { maxCount: 1000 }; + params = { id: t.string }; + path = `/market/${this.params.id}`; + result = { market: t.entity(MarketDetail) }; + getConfig() { + return { retry: 0, gcTime: Infinity }; + } +} + +class GetPlain extends RESTQuery { + params = { n: t.number }; + path = `/plain/${this.params.n}`; + result = { items: t.array(t.entity(PlainRow)) }; + getConfig() { + return { retry: 0, gcTime: Infinity }; + } +} + +const row = (typename: string, i: number, price = 100 + i) => ({ + __typename: typename, + id: `m-${i}`, + symbol: `SYM${i}`, + name: `Market ${i}`, + price, + change: 0.5, + volume: 1000 * i, +}); + +const detail = (i: number) => ({ + ...row('Market', i), + extraMetadata: { logo: `logo-${i}.png`, website: `m${i}.example.com`, tags: ['defi', 'x'] }, + categories: ['a', 'b'], +}); + +function respond(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); +} + +function client(kv: MemoryPersistentStore, n: number) { + return new QueryClient({ + store: new SyncQueryStore(kv), + adapters: [ + new RESTQueryAdapter({ + fetch: (async (url: string) => { + const path = new URL(url).pathname; + if (path.startsWith('/market/')) return respond({ market: detail(Number(path.split('-')[1])) }); + if (path.startsWith('/plain/')) + return respond({ items: Array.from({ length: n }, (_, i) => row('Plain', i)) }); + return respond({ items: Array.from({ length: n }, (_, i) => row('Market', i)) }); + }) as unknown as typeof fetch, + baseUrl: 'http://localhost', + }), + ], + }); +} + +async function hold(c: QueryClient, start: () => unknown): Promise { + let q: any; + const w = withContexts([[QueryClientContext, c]], () => { + q = start(); + return watcher(() => q.value); + }); + w.addListener(() => {}); + await q; +} + +const N = 100; +const kv = new MemoryPersistentStore(); + +// Session 1: the detail class writes every record. +const first = client(kv, N); +for (let i = 0; i < N; i++) await hold(first, () => fetchQuery(GetMarket, { id: `m-${i}` })); +first.destroy(); + +// Session 2: the list class holds the same entities in memory. +const c = client(kv, N); +await hold(c, () => fetchQuery(GetMarkets, { n: N })); +await hold(c, () => fetchQuery(GetPlain, { n: N })); + +let tick = 0; + +describe(`streamed full updates, ${N} in-memory list entities`, () => { + bench('shared typename: one entity, price changes', () => { + tick++; + c.applyMutationEvent({ type: 'update', typename: 'Market', data: row('Market', tick % N, tick) }); + }); + + bench('single-class typename (control): one entity, price changes', () => { + tick++; + c.applyMutationEvent({ type: 'update', typename: 'Plain', data: row('Plain', tick % N, tick) }); + }); + + bench(`shared typename: all ${N} entities, price changes`, () => { + tick++; + for (let i = 0; i < N; i++) + c.applyMutationEvent({ type: 'update', typename: 'Market', data: row('Market', i, tick) }); + }); + + bench(`single-class typename (control): all ${N} entities, price changes`, () => { + tick++; + for (let i = 0; i < N; i++) + c.applyMutationEvent({ type: 'update', typename: 'Plain', data: row('Plain', i, tick) }); + }); + + bench('shared typename: one entity, identical update', () => { + c.applyMutationEvent({ type: 'update', typename: 'Market', data: row('Market', 7, 107) }); + }); +}); From 5660a0216d64a868a5b779fe189e8753859953ff Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:27:36 -0400 Subject: [PATCH 062/109] fix(fetchium): keep any query pending, not rejected, when it is unmounted mid-fetch before its first data A query with no value that was unmounted mid-fetch, in a later task than it mounted, rejected its relay with the deactivation's AbortError. Signalium keeps a rejected relay's error until the relay gets a value, so when the query was mounted again it reported isRejected with that AbortError until the refetch landed. A chart that switches timeframe away mid-fetch and back showed its error state for that time. Topic queries were already held pending in this case; every query with no value now is. The next activation's refetch settles whoever awaits the relay; collection and destroy() settle it with an AbortError. A query mounted and unmounted in one task, a pause because the network went offline, and a restart parked offline then unmounted keep rejecting their awaiters at once, unless the query has a subscription. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 2 +- packages/fetchium/src/QueryResult.ts | 28 ++++++++----- .../src/__tests__/activation-timing.test.ts | 39 +++++++++++++++++++ 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index be377e4..443bc70 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -7,7 +7,7 @@ Fixes for the microtask first fetch and refetches introduced with synchronous hy - A query mounted and unmounted in the same task (a guard that redirects in a layout effect, a `replace` on mount, a route re-keyed in one commit) no longer leaves its relay rejected with an `AbortError` that the next mount shows. Its first request, which now starts before Signalium's deactivation flush, is left to finish in the background, as it did when the first fetch started on a timer, and the next mount shows its data. Releasing a `prefetch()` or `retain()` lease still aborts the fetch it started, in any task. - A Signal param change made in the same task the query's last watcher leaves no longer sends a request that the deactivation then aborts. The refetch for a param change starts once Signalium's flush has run, which is in the same task when the change arrives through that flush. - A query that reactivates stale in the same task the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". The deferred refetch, a deferred restart and a debounced refetch recheck that the query is still active and not paused before they send. -- When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. With no value yet, the relay is pending again but Signalium keeps reporting the previous error until the refetch lands. Callers awaiting the aborted fetch still get the `AbortError`. A query with a subscription (a topic query) that is unmounted before its first data, in a later task than it mounted, is not rejected at all: it stays pending, so its next mount reports no error while it refetches, and whoever awaits it gets that refetch's result, or an `AbortError` once the query is collected or the client destroyed. +- When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. A query unmounted before its first data, in a later task than it mounted, is not rejected at all: it stays pending, so its next mount reports no error while it refetches (Signalium would otherwise keep reporting the `AbortError` until the refetch lands), and whoever awaits it gets that refetch's result, or an `AbortError` once the query is collected or the client destroyed. A query mounted and unmounted in one task, and one paused because the network went offline, still reject their awaiters with the `AbortError` at once, unless the query has a subscription (a topic query), which stays pending. - A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. - A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. - A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it, including when the query is collected. Caches written by earlier versions keep the shared record until each key is fetched again. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index d115c10..c586729 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -249,7 +249,9 @@ export class QueryInstance { } else { if (this.relayState.isPending) { this.abortedByDeactivation = true; - if (!sameTask && this.holdsDeactivationAbort()) this.heldAbortSignal = controller.signal; + if (!sameTask && (isPausing ? this.holdsDeactivationAbort() : this.relayState.value === undefined)) { + this.heldAbortSignal = controller.signal; + } } controller.abort(); this._abortController = undefined; @@ -728,16 +730,22 @@ export class QueryInstance { /** * Whether a deactivation that cancels this query's fetch leaves its relay - * pending instead of rejecting it with the AbortError: when the relay has - * no value yet and the query has a subscription (a topic query's fetch - * waits on the subscription the deactivation tears down). Signalium keeps - * a rejected relay's error until it gets a value, so a rejection would - * have the next mount report `isRejected` with the AbortError until its - * refetch lands. The relay instead stays pending: the next activation's + * pending instead of rejecting it with the AbortError. Signalium keeps a + * rejected relay's error until it gets a value, so a rejection would have + * the next mount report `isRejected` with the AbortError until its refetch + * lands (a chart switched away from mid-fetch and back would flash its + * error state). The relay instead stays pending: the next activation's * refetch settles whoever awaits it, as does collection or `destroy()` - * (`abortForDestroy()`). A query mounted and unmounted in one task is not - * held: whoever started it is a one-off read that awaits it, and gets the - * AbortError at once. + * (`abortForDestroy()`). + * + * An unmount mid-fetch holds any query whose relay has no value yet. A + * query mounted and unmounted in one task is not held: whoever started it + * is a one-off read that awaits it, and gets the AbortError at once. A + * pause (the network went offline) and a restart parked while offline, + * which wait on the network rather than on a remount, hold only a query + * with a subscription (this method: a topic query's fetch waits on the + * subscription the deactivation tears down); any other query's awaiter + * gets the AbortError at once, as before. */ private holdsDeactivationAbort(): boolean { return this.relayState.value === undefined && this.config?.subscribe !== undefined; diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index a815480..addd558 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -665,6 +665,45 @@ describe('A topic query unmounted before its first data, in a later task', () => }); }); +describe('A query without a subscription unmounted mid-fetch, in a later task', () => { + it('is pending, not rejected, when mounted again, and its awaiter gets the refetch', async () => { + const f = createFetch(20); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + const awaited = outcome(relay, 500); + await sleep(5); + first(); // aborts the fetch in flight + await sleep(5); + expect(state(relay)).toEqual({ isPending: true, isRejected: false, value: undefined }); + + const reads: ReturnType[] = []; + const second = activate(client, () => reads.push(state(fetchQuery(GetItem)))); + expect(reads[0]).toEqual({ isPending: true, isRejected: false, value: undefined }); + await sleep(50); + expect(reads.some(r => r.isRejected)).toBe(false); + expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); + expect(await awaited).toBe('resolved'); + expect(f.paths()).toEqual(['/item(aborted)', '/item']); + second(); + client.destroy(); + }); + + it('settles its awaiter with an AbortError when the client is destroyed', async () => { + const f = createFetch(20); + const client = makeClient(new MemoryPersistentStore(), f.fetch); + let relay: any; + const first = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + const awaited = outcome(relay, 500); + await sleep(5); + first(); + await sleep(5); + client.destroy(); + expect(await awaited).toBe('rejected:AbortError'); + }); +}); + describe('Every awaiter settles', () => { it('when destroy() runs in the task the query mounts, before its first start', async () => { const { fetch, calls } = createFetch(20); From 19730ae9a85e8a77718f91c418d7487ca6fa3ad4 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:39:01 -0400 Subject: [PATCH 063/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/keep-cached-first-paint.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md index aa06a50..fc34c35 100644 --- a/.changeset/keep-cached-first-paint.md +++ b/.changeset/keep-cached-first-paint.md @@ -2,11 +2,8 @@ 'fetchium': patch --- -Cached data shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. +Cached data shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Previously the detail query waited for the network if the list had already loaded the entity. -- A detail query's cached data is used even when the list class already holds the entity in memory; the fields the list class doesn't declare come from the cache. Previously the cache was dropped and the screen waited for the network. -- Writing an entity through one class no longer deletes the other class's fields from the store, so the detail screen's cache survives a restart. -- `SyncQueryStore` remembers each typename's field names across launches, and forgets a name no class has declared for 30 days. For 30 days after this version first opens a store that already holds data, records may still hold fields of a class not registered since, so they keep being merged rather than overwritten. -- New `SyncQueryStore.clear()`. Use it to wipe the cache instead of clearing the underlying storage directly (an MMKV `clearAll()`): clients are not told about records deleted underneath them, and a later write can put deleted fields back. -- `QueryStore` gains optional `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`, and `saveEntity` takes an optional fourth argument. A custom store without them behaves as before. `AsyncQueryStore` only knows the classes registered in the current session. -- Cost: a record holds the fields of every class of its typename, so hydrating the list class parses the detail fields too (a cached 100-entity list hydrates about a third slower in our benchmark). Typenames with one class are unaffected. +- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Records hold every class's fields, so loading the list class from the cache parses a little more data. +- To wipe the cache, call the new `SyncQueryStore.clear()` instead of clearing the storage directly (such as MMKV `clearAll()`), which can bring deleted fields back. +- Custom stores can add the new optional `QueryStore` methods `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. Without them nothing changes. From 9ffa42bbf342d2b89f24b50d394518a52c221f5e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 11:27:36 -0400 Subject: [PATCH 064/109] chore(fetchium): shorten this change's changesets for app developers Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 26 +++++++------------ .../config-context-and-suspense-holds.md | 10 +++---- .changeset/nested-value-reactivity.md | 2 +- .changeset/sync-store-merge-without-read.md | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 443bc70..d4bc9de 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -2,20 +2,14 @@ 'fetchium': patch --- -Fixes for the microtask first fetch and refetches introduced with synchronous hydration and fast delivery. +Timing fixes for mounting, unmounting and changing params. -- A query mounted and unmounted in the same task (a guard that redirects in a layout effect, a `replace` on mount, a route re-keyed in one commit) no longer leaves its relay rejected with an `AbortError` that the next mount shows. Its first request, which now starts before Signalium's deactivation flush, is left to finish in the background, as it did when the first fetch started on a timer, and the next mount shows its data. Releasing a `prefetch()` or `retain()` lease still aborts the fetch it started, in any task. -- A Signal param change made in the same task the query's last watcher leaves no longer sends a request that the deactivation then aborts. The refetch for a param change starts once Signalium's flush has run, which is in the same task when the change arrives through that flush. -- A query that reactivates stale in the same task the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". The deferred refetch, a deferred restart and a debounced refetch recheck that the query is still active and not paused before they send. -- When a deactivation aborted the fetch in flight and the relay settled with the `AbortError`, the next activation refetches right away, as it already did for a relay still pending. A cached value is shown without the `AbortError` while it refetches. A query unmounted before its first data, in a later task than it mounted, is not rejected at all: it stays pending, so its next mount reports no error while it refetches (Signalium would otherwise keep reporting the `AbortError` until the refetch lands), and whoever awaits it gets that refetch's result, or an `AbortError` once the query is collected or the client destroyed. A query mounted and unmounted in one task, and one paused because the network went offline, still reject their awaiters with the `AbortError` at once, unless the query has a subscription (a topic query), which stays pending. -- A Signal param set in the same task as the query mounts (an effect, a navigation that sets a param signal) no longer sends the old params and stores the result under the new params' key. The first fetch reads the params as they are when it starts. -- A param change while a fetch is in flight aborts that fetch and fetches the new params, instead of resolving the new params with the old params' response and persisting it under the new key (a bug in every earlier version). A response that arrives for params that are no longer current, from an adapter that ignores the abort, is dropped rather than applied or persisted. A param change also counts as invalidation, so a query reactivated after its params changed refetches even if its old data was fresh. -- A query whose result is not an entity keeps each params key's data under its own record. Its data lived in one record keyed by the params it was created with, so after a Signal param change every key's cached entry pointed at the newest params' data (a cold start for the old params showed the new params' data without a request), and another query showing the old params in memory was overwritten too (a bug in every earlier version). The query now shows a new result object for the new params; the previous params' data leaves memory unless another query still shows it, including when the query is collected. Caches written by earlier versions keep the shared record until each key is fetched again. -- A topic query (or any query with a running subscription) mounted and unmounted in the same task no longer stays pending forever on its next mount. Its first fetch may be waiting on the subscription the deactivation tears down, so it is aborted as before 0.6 rather than left to finish, and the next mount restarts it. `TopicQueryAdapter.send()` now honours the abort signal, so whoever awaits that first fetch gets the `AbortError` instead of waiting for the next mount (a bug in every earlier version). It rejects with an `AbortError` under React Native's `AbortController` polyfill too, whose signal carries no `reason`. -- The refetch for a Signal param change waits at most one task for Signalium's outstanding flush, instead of waiting until flushes stop being scheduled (a listener that keeps writing signals could hold it back indefinitely). -- With `reactivationStaggerMs`, refetches that a pause or an offline switch aborted are spread across the window again on resume, instead of all restarting at once. -- `QueryClient.destroy()` aborts every query's fetch in flight and cancels deferred ones, so nothing is written to the store after it (including a first fetch a same-task unmount left running, and a response that a replacement client sharing the store would otherwise be overwritten by). Whoever awaits a query it cancelled gets an `AbortError`, including a query whose first start or restart had not run yet. -- A restart that finds the network offline leaves the relay pending until the network returns, as before; if the query is unmounted first, the relay rejects with an `AbortError` and the next mount refetches, instead of leaving an awaiter waiting for that mount (a query with a subscription and no value stays pending instead, as above). -- A query whose first fetch a same-task unmount left running is collected (`gcTime`) once that fetch settles, not while it is still in flight: with `gcTime: 0` in a browser or React Native, collecting it earlier left the fetch's entities resident for good and its store write ignoring `destroy()`. A query the client collects has its work cancelled and its relay settled. -- A `__fetchNext()` page for params that changed while it was in flight is dropped, like a stale first page. -- With an asynchronous store, a Signal param change made while the cache load is pending no longer applies the old params' cached entry, and does not send the new params' request twice. +- A query mounted and unmounted in the same task (a redirect in a layout effect, a `replace` on mount) no longer shows an `AbortError` on its next mount: its first request finishes in the background. Releasing a `prefetch()` or `retain()` lease still aborts the fetch it started. +- A query unmounted mid-fetch and mounted again (a chart switched to another timeframe and back) no longer flashes an error. With no data yet it stays pending and refetches; with cached data it shows that data while it refetches. +- Changing a Signal param while a fetch is in flight aborts it and fetches the new params, instead of showing and caching the old params' response under the new key. A param set in the same task the query mounts is fetched right away, and a param change makes a remounted query refetch even if its old data was fresh. +- A query whose result is not an entity caches each params key separately. Previously every key's cache pointed at the latest params' data, so a cold start could show another key's data. This bug was in every earlier version. +- A stale query that mounts as the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". +- A topic query mounted and unmounted in the same task no longer stays pending forever on its next mount. `TopicQueryAdapter.send()` honors its abort signal, including under React Native's `AbortController` polyfill. +- `QueryClient.destroy()` aborts and cancels every fetch; whoever awaits one gets an `AbortError`, and nothing is written to the store afterwards. +- With `gcTime: 0`, a query is collected only once its first fetch settles, so its entities no longer leak. +- Also: `reactivationStaggerMs` spreads the refetches that resume after a pause, a `__fetchNext()` page for stale params is dropped, and with an asynchronous store a param change during the cache load no longer applies the old params' entry. diff --git a/.changeset/config-context-and-suspense-holds.md b/.changeset/config-context-and-suspense-holds.md index e1c09bd..6bc47ed 100644 --- a/.changeset/config-context-and-suspense-holds.md +++ b/.changeset/config-context-and-suspense-holds.md @@ -4,8 +4,8 @@ Fixes for the new client options and `useSuspenseQuery`. -- Custom `QueryClientConfig` keys still reach query code as `this.context`, including `shouldRetry`, `reactivationGraceMs` and `reactivationStaggerMs`, as every key did before those options existed. These names, `activity` and `pollResumeJitterMs` are reserved: a value under one of them is read as that option. -- `poll()` uses the context's `activity` only when it has `isActive` and `subscribe` functions. Any other value under that name (an app service passed through the config) is ignored, with a warning in development, instead of failing every polled query before its first fetch. `QueryContext` no longer declares `activity` or `pollResumeJitterMs`, so an app that augmented it with its own `activity` field compiles again. -- A non-finite `reactivationStaggerMs` or `pollResumeJitterMs` (such as `Infinity`) counts as 0 instead of scheduling a timer with an infinite delay. `reactivationGraceMs: Infinity` means "never refetch on reactivation". A `shouldRetry` that is not a function is ignored. -- `useSuspenseQuery`: a failed cold fetch whose retry render takes more than 50 ms to reach the reader (a large or time-sliced tree) reaches the error boundary instead of refetching in a loop. An error no render claims now waits 1 s, and after one more unclaimed failure the next waits 10 s. An error-boundary reset still retries. A tree abandoned while suspended and mounted again within a second of its fetch failing shows that error. Mounted later, it makes a new attempt. -- `Object.keys(this)` inside a query method no longer lists the internal `_notePush` hook. +- Custom `QueryClientConfig` keys still reach queries as `this.context`. `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved: a value under one of them is read as that option. +- `poll()` ignores an `activity` value without `isActive` and `subscribe` functions (with a warning in development) instead of failing every polled query. `QueryContext` no longer declares `activity` or `pollResumeJitterMs`. +- `Infinity` for `reactivationStaggerMs` or `pollResumeJitterMs` counts as 0, and `reactivationGraceMs: Infinity` never refetches on reactivation. A `shouldRetry` that is not a function is ignored. +- `useSuspenseQuery` no longer refetches in a loop when a slow retry render takes too long to reach the component; the error goes to the error boundary, and resetting the boundary retries. +- `Object.keys(this)` inside a query no longer lists an internal hook. diff --git a/.changeset/nested-value-reactivity.md b/.changeset/nested-value-reactivity.md index 017358c..891afa1 100644 --- a/.changeset/nested-value-reactivity.md +++ b/.changeset/nested-value-reactivity.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Nested values passed on their own stay current. A `t.object` or `t.record` field, and a live collection's value, are merged in place when an update changes them. A component that received only the nested value, such as a `component()` child given `token.price` as a prop (or a `React.memo` child), was handed the same wrapper after the change, so once its parent stopped re-rendering it for identical props it kept showing the old value. A merge that changes a nested value in place now drops the wrapper it was handed out through: the next read through the entity, which re-renders the parent, hands out a new one, so the child sees a new prop and re-renders. An unchanged nested value keeps its wrapper, so a change to another field of the entity does not re-render a child that holds only the nested value. Reads through a live collection's value depend on the collection, which an event or a reducer already notifies. Reads through an entity's nested values cost nothing extra; a wrapper someone kept from before the change still reads the merged data but does not re-run anything. +Fixed child components showing stale nested values. A `component()` or `React.memo` child given a nested value as a prop (`token.price`: a `t.object` or `t.record` field, or a live collection's value) kept showing the old value after an update, because it received the same object. A changed nested value is now a new object, so the child re-renders. An unchanged one keeps its identity, so an update to another field doesn't re-render the child. diff --git a/.changeset/sync-store-merge-without-read.md b/.changeset/sync-store-merge-without-read.md index 1533774..8362f6c 100644 --- a/.changeset/sync-store-merge-without-read.md +++ b/.changeset/sync-store-merge-without-read.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -`SyncQueryStore.mergeEntity` no longer reads and parses the stored record on every merge. A streamed full payload for an entity that is not in memory, on a typename more than one class declares, is a partial write (the payload lacks the other classes' fields), so each such event merged over the record on disk: a `getString` and a `JSON.parse` per event. The store now keeps, per entity it merged into, what the record holds besides the merged fields, and the next merge of the same fields appends that to the payload's JSON without reading. Only the first merge per entity reads. Any other write or deletion of the record through a store over the same kv drops what was kept, so a merge never restores a value another write replaced. At most 1024 entities are kept; past that the least recently merged reads again. +Faster streamed updates with `SyncQueryStore`. Merging an update into a stored entity that is not in memory reads and parses the stored record only for the first merge, not on every event (up to 1024 entities are remembered). This is the common case for full-payload updates on a typename that more than one entity class declares. From 129ac415ae6d26ca307cc121a1e3f3028704e65e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 12:56:11 -0400 Subject: [PATCH 065/109] fix(fetchium): keep another class's child references through a list refetch, and trim the shared-typename costs A full fetch through a class that lacks a field another class of the typename declares (a list class next to a detail class with a nested t.entity field) left that field in the entity's data but dropped the child from its references. The child was released and evicted while the detail still showed it, so its updates no longer reached the detail, and the record kept a reference it no longer counted: the child's record was deleted and the detail's cache failed to load at the next cold start. This was in every earlier version. Such an apply now counts the references held by data fields outside its class. It only runs for a class known to lack fields of its typename, on an update with a full payload; typenames with one class pay nothing. Also, for typenames two classes share: - In a store whose field names do not yet cover every record (the first 30 days after an upgrade), a class hydrated from its cache is handed a record that holds fields outside its shape even before the other class registers, so a streamed update for that in-memory entity keeps them. - With a synchronous store, such a record is read again at the entity's first write instead of being held in memory until then. - The first write of a list-class entity built from a fetch reads the stored string and parses it only when it holds one of the other classes' field names (QueryStore.readEntity's new `fields` argument). - SyncQueryStore no longer scans every key when a client is created to tell an empty store from an upgraded one: the 30-day window starts at the first open either way, or at clear(). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/keep-cached-first-paint.md | 3 +- docs/src/app/api/stores-sync/page.md | 32 +-- packages/fetchium/src/EntityInstance.ts | 39 +++- packages/fetchium/src/EntityStore.ts | 8 +- packages/fetchium/src/QueryClient.ts | 28 ++- .../__tests__/hydrate-in-memory-shape.test.ts | 188 +++++++++++++++++- packages/fetchium/src/applyEntities.ts | 57 +++++- packages/fetchium/src/parseEntities.ts | 10 +- packages/fetchium/src/query-types.ts | 9 +- packages/fetchium/src/stores/sync.ts | 39 ++-- 10 files changed, 358 insertions(+), 55 deletions(-) diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md index fc34c35..0aae0ca 100644 --- a/.changeset/keep-cached-first-paint.md +++ b/.changeset/keep-cached-first-paint.md @@ -4,6 +4,7 @@ Cached data shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Previously the detail query waited for the network if the list had already loaded the entity. -- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Records hold every class's fields, so loading the list class from the cache parses a little more data. +- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Records keep every class's fields, so loading the list from the cache also parses the detail's fields. +- Refetching the list no longer drops entities that only the detail references, such as a detail's nested `t.entity` field. The detail kept showing the old value but stopped getting updates for it, and its cache failed to load after a restart. This bug was in every earlier version. - To wipe the cache, call the new `SyncQueryStore.clear()` instead of clearing the storage directly (such as MMKV `clearAll()`), which can bring deleted fields back. - Custom stores can add the new optional `QueryStore` methods `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. Without them nothing changes. diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index aef92ae..0133d23 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,22 +32,22 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| -------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | -| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | -| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity built from a fetch whose class lacks fields another class of its typename declares, so that write keeps them. | -| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | -| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | -| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every class that wrote a record the store holds. The first open by this version notes when it started remembering (`sq:meta:fieldsSince`, after one scan of the keys): 0 when the store held no cached data, the current time otherwise, as for a store an earlier version wrote. `clear()` sets it to 0. Answers `true` for 0, or 30 days later. Until then the client merges into its record an entity that streamed events built with every field of its class, rather than writing it whole, so a record an earlier version wrote keeps the fields of a class not registered since. | -| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. | +| Method | Signature | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | +| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | +| `readEntity` | `(entityKey: number, fields?: readonly string[]): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity whose record may hold fields of another class of its typename, so that write keeps them. With `fields`, a record whose JSON holds none of those keys is not parsed and `undefined` is returned. | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity built from a fetch whose class lacks fields another class of its typename declares, so that write keeps them. | +| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | +| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | +| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every class that wrote a record the store holds. The first open by this version notes when it started remembering (`sq:meta:fieldsSince`): the current time, whether or not an earlier version wrote the store (telling would take a scan of every key at startup). `clear()` sets it to 0. Answers `true` for 0, or 30 days later. Until then the client merges into its record an entity that streamed events built with every field of its class, rather than writing it whole, so a record an earlier version wrote keeps the fields of a class not registered since. | +| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. | --- diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index eb9d288..96b46f5 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -658,17 +658,23 @@ export class EntityInstance { /** The stored record's fields this instance does not hold; its writes carry them. */ _recordRest: RecordRest | undefined = undefined; /** - * The stored record handed over at hydration, while `_recordRest` has not - * been taken from it yet: that happens at the first write, so a cold start - * pays nothing for it. + * The stored record handed over at hydration by a store that cannot read + * it again synchronously, while `_recordRest` has not been taken from it + * yet: that happens at the first write, so a cold start pays nothing for it. */ private _storedRecord: Record | undefined = undefined; /** - * Built from a fetch, of a class that lacks fields another class sharing - * its typename declares: the stored record may hold such fields, so the - * first write reads it (synchronous stores) and keeps them. + * The stored record may hold fields of another class sharing the typename + * (built from a fetch of a class that lacks some, or hydrated from a record + * that holds some), so the first write reads it (synchronous stores) and + * keeps them. */ _checkStoredRecord: boolean = false; + /** + * With `_checkStoredRecord`: the other classes' field names, one of which + * the record must hold to be worth parsing. `undefined`: parse it. + */ + _recordProbe: readonly string[] | undefined = undefined; private _saving: boolean = false; entityRefs: Map | undefined; liveCollections: LiveCollectionBinding[] = []; @@ -900,12 +906,29 @@ export class EntityInstance { recordDeleted(): void { this._recordRest = undefined; this._storedRecord = undefined; + this._checkStoredRecord = false; this.recordDropped(); } - /** Whether this instance keeps fields of its stored record (or has the record to take them from). */ + /** Whether this instance keeps fields of its stored record (or reads them, or has the record to take them from). */ keepsRecordFields(): boolean { - return this._recordRest !== undefined || this._storedRecord !== undefined; + return this._recordRest !== undefined || this._storedRecord !== undefined || this._checkStoredRecord; + } + + /** + * The stored record holds fields the data does not: keep them. A + * synchronous store's record is read again at the first write rather than + * held in memory until then. + */ + recordHoldsOtherFields(record: Record, rereadable: boolean): void { + if (rereadable) { + this._storedRecord = undefined; + this._recordRest = undefined; + this._checkStoredRecord = true; + this._recordProbe = undefined; + } else { + this.noteRecord(record); + } } /** diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index a08d65c..55cf476 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -8,8 +8,9 @@ export class EntityStore { private persistEntity: PersistEntity; /** Whether the store can merge fields over a record it holds. */ mergesEntities: boolean = false; - /** A synchronous store's read of an entity's stored record, if it offers one. */ - readEntity: ((key: number) => Record | undefined) | undefined = undefined; + /** A synchronous store's read of an entity's stored record, if it offers one (see `QueryStore.readEntity`). */ + readEntity: ((key: number, fields?: readonly string[]) => Record | undefined) | undefined = + undefined; constructor(persistEntity: PersistEntity) { this.persistEntity = persistEntity; @@ -86,7 +87,8 @@ export class EntityStore { // First write of an instance whose class may lack fields the record // holds: read the record once and keep them from now on. instance._checkStoredRecord = false; - const stored = this.readEntity?.(instance.key); + const stored = this.readEntity?.(instance.key, instance._recordProbe); + instance._recordProbe = undefined; if (stored !== undefined) instance.noteRecord(stored); } const rest = instance.recordRestForWrite(); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 0b43b99..e6eafe9 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -267,7 +267,7 @@ export class QueryClient { this.entityMap.mergesEntities = typeof this.store.mergeEntity === 'function'; if (typeof this.store.readEntity === 'function') { const store = this.store; - this.entityMap.readEntity = key => store.readEntity!(key); + this.entityMap.readEntity = (key, fields) => store.readEntity!(key, fields); } // A record the store drops must be written again by the next apply. this.storeReportsDeletes = typeof this.store.onDelete === 'function'; @@ -411,6 +411,8 @@ export class QueryClient { private foreignFieldDefs = new WeakMap, boolean>(); /** Per typename: every top-level field its classes have declared (see `foreignFieldDefs`). */ private typenameFields = new Map>(); + /** Per entity def: the fields of its typename it does not declare (see `foreignFieldNames`). */ + private foreignFieldNamesByDef = new WeakMap, readonly string[]>(); /** Whether any registered def lacks a field of its typename; false for an app whose typenames each have one class. */ hasForeignFieldDefs: boolean = false; /** @@ -447,6 +449,28 @@ export class QueryClient { return verdict; } + /** + * The top-level fields other classes sharing this def's typename declare + * and it does not: a stored record holding none of them has nothing for + * the def's writes to keep. Computed once per def while the typename's + * fields are unchanged. + * + * @internal + */ + foreignFieldNames(def: ValidatorDef): readonly string[] { + let names = this.foreignFieldNamesByDef.get(def); + if (names === undefined) { + const missing: string[] = []; + const fields = def.typenameValue !== undefined ? this.typenameFields.get(def.typenameValue) : undefined; + const shape = def.shape as Record | undefined; + if (fields !== undefined && shape !== undefined) { + for (const f of fields) if (!(f in shape)) missing.push(f); + } + this.foreignFieldNamesByDef.set(def, (names = missing)); + } + return names; + } + /** * Folds a newly registered def's fields into its typename's field set (and * the store's, which outlives the session), then re-derives which of the @@ -472,6 +496,7 @@ export class QueryClient { // Every registered def's fields are in the set, so a def lacks one of // them exactly when the set is larger than its shape. for (const d of this.typenameRegistry.get(typename)!) { + if (added) this.foreignFieldNamesByDef.delete(d); if (!added && d !== def) continue; const misses = fields.size > Object.keys(d.shape as Record).length; this.foreignFieldDefs.set(d, misses); @@ -1286,6 +1311,7 @@ export class QueryClient { this.typenameRegistry.clear(); this.typenameFields.clear(); this.foreignFieldDefs = new WeakMap(); + this.foreignFieldNamesByDef = new WeakMap(); this.mergedDefCache.clear(); } } diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts index 7303da2..4c8d8f3 100644 --- a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -468,9 +468,13 @@ describe('C. negative controls: a fresher in-memory value contradicts the cache' /** A SyncQueryStore that counts the record reads made for the kept fields. */ class CountingStore extends SyncQueryStore { reads = 0; - readEntity(entityKey: number) { + /** Reads that found a record worth parsing. */ + parsed = 0; + readEntity(entityKey: number, fields?: readonly string[]) { this.reads++; - return super.readEntity(entityKey); + const record = super.readEntity(entityKey, fields); + if (record !== undefined) this.parsed++; + return record; } } @@ -755,6 +759,169 @@ describe('D. writes keep the fields another class sharing the typename declared' }); }); +describe('F. references and records the other class holds', () => { + /** The summary in memory from the network, then the detail fetched over the same entity. */ + async function listThenDetail(kv: MemoryPersistentStore, f: ReturnType) { + const c = makeClient(kv, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + const list: any = start(c, () => fetchQuery(GetItems)); + await list; + f.set('/item', itemDetail('v1')); + const item: any = start(c, () => fetchQuery(GetItem)); + await item; + return { c, list, item }; + } + + it('F1 a summary refetch over an entity the detail holds keeps the detail child referenced, on disk and after a restart', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + const { c, list, item } = await listThenDetail(kv, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + await list.value.__refetch(); + + expect(item.value.item.vendor.name).toBe('v1'); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ + __typename: 'Item', + id: 'i1', + name: 'I2', + details: { rating: 5 }, + vendor: { __entityRef: vendorKey('v1') }, + }); + expect(Array.from(kv.getBuffer(refIdsKeyFor(hashValue(['Item', 'i1']))) ?? [])).toEqual([vendorKey('v1')]); + expect(recordOf(kv, 'Vendor', 'v1')).toEqual({ __typename: 'Vendor', id: 'v1', name: 'v1' }); + c.destroy(); + clients = clients.filter(x => x !== c); + + f.set('/item', itemDetail('v1', 6, 'net'), 100); + const cold = makeClient(kv, f); + const served: any = start(cold, () => fetchQuery(GetItem)); + await sleep(20); + expect(served.isReady).toBe(true); + expect(served.value.item.vendor.name).toBe('v1'); + }); + + it('F2 after a summary refetch, an update of the detail child still reaches the detail', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + const { c, list, item } = await listThenDetail(kv, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + await list.value.__refetch(); + + c.applyMutationEvent({ type: 'update', typename: 'Vendor', data: { __typename: 'Vendor', id: 'v1', name: 'w1' } }); + await sleep(0); + expect(item.value.item.vendor.name).toBe('w1'); + expect(recordOf(kv, 'Vendor', 'v1')).toMatchObject({ name: 'w1' }); + }); + + it('F3 a summary fetched over records without detail fields parses none of them before its first writes', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + // The detail class registered and wrote i1; i2 and i3 hold summary fields only. + f.set('/item', itemDetail('v1')); + f.set('/items', { + items: [ + { __typename: 'Item', id: 'i2', name: 'J' }, + { __typename: 'Item', id: 'i3', name: 'K' }, + ], + }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + await start(c, () => fetchQuery(GetItems)); + }); + + const store = new CountingStore(kv); + const c = makeCountingClient(store, f); + await start(c, () => fetchQuery(GetItem)); + f.set('/other-items', { + items: [ + { __typename: 'Item', id: 'i1', name: 'I2' }, + { __typename: 'Item', id: 'i2', name: 'J2' }, + { __typename: 'Item', id: 'i3', name: 'K2' }, + ], + }); + const before = { reads: store.reads, parsed: store.parsed }; + // A query with no cache, so i2 and i3 are new in memory; i1 is the detail's. + class GetOtherItems extends RESTQuery { + path = '/other-items'; + result = { items: t.array(t.entity(ItemSummary)) }; + } + await start(c, () => fetchQuery(GetOtherItems)); + expect({ reads: store.reads - before.reads, parsed: store.parsed - before.parsed }).toEqual({ + reads: 2, + parsed: 0, + }); + expect(recordOf(kv, 'Item', 'i2')).toEqual({ __typename: 'Item', id: 'i2', name: 'J2' }); + expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'I2', details: { rating: 5 } }); + }); + + it('F4 a summary hydrated from its cache over a detail record reads the record again at its first write, not holding it until then', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + await start(c, () => fetchQuery(GetItems)); + }); + + const store = new CountingStore(kv); + const c = makeCountingClient(store, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }, 1000); + const list: any = start(c, () => fetchQuery(GetItems)); + await sleep(5); + expect(list.isReady).toBe(true); + const instance = (c as any).entityMap.getEntity(hashValue(['Item', 'i1'])); + expect(instance._storedRecord).toBeUndefined(); + expect(store.reads).toBe(0); + + c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'U' } }); + c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'V' } }); + expect({ reads: store.reads, parsed: store.parsed }).toEqual({ reads: 1, parsed: 1 }); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ + __typename: 'Item', + id: 'i1', + name: 'V', + details: { rating: 5 }, + vendor: { __entityRef: vendorKey('v1') }, + }); + }); + + it('F5 in a store an earlier release wrote, a summary hydrated from its cache keeps the detail fields under a streamed update', async () => { + let kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + await start(c, () => fetchQuery(GetItems)); + }); + // Reopened without the field names, as a release that did not remember them left it. + const copy = new MemoryPersistentStore(); + for (const k of kv.getAllKeys()) { + if (k.startsWith('sq:meta:')) continue; + const v = kv.getString(k) ?? kv.getNumber(k) ?? kv.getBuffer(k); + if (typeof v === 'string') copy.setString(k, v); + else if (typeof v === 'number') copy.setNumber(k, v); + else if (v !== undefined) copy.setBuffer(k, v); + } + kv = copy; + + const c = makeClient(kv, f); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }, 1000); + const list: any = start(c, () => fetchQuery(GetItems)); + await sleep(5); + expect(list.isReady).toBe(true); + c.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name: 'U' } }); + expect(recordOf(kv, 'Item', 'i1')).toEqual({ + __typename: 'Item', + id: 'i1', + name: 'U', + details: { rating: 5 }, + vendor: { __entityRef: vendorKey('v1') }, + }); + }); +}); + describe('E. what the store remembers about the other class, and for how long', () => { afterEach(() => { vi.restoreAllMocks(); @@ -826,6 +993,9 @@ describe('E. what the store remembers about the other class, and for how long', it('E3 a typename with one class: an event carrying every field writes the record whole, without a merge', async () => { const kv = new MemoryPersistentStore(); + // A store whose field names cover every record it holds: emptied with + // clear() (otherwise, 30 days after it started remembering them; see E5). + new SyncQueryStore(kv).clear(); const f = makeFetch(); f.set('/reading/latest', { reading: readingPayload }); await session(kv, f, async c => { @@ -898,10 +1068,18 @@ describe('E. what the store remembers about the other class, and for how long', kv.setNumber('sq:meta:fieldsSince', t0 + 3 * hour - 30 * day); expect(await listSession(t0 + 3 * hour, 'U3')).toEqual({ merged: 0, savedWhole: true }); - // A store that held no cached data when it was first opened, or that clear() emptied, writes such events whole. + // A store first opened by this version counts from then, empty or not: + // telling would take a scan of every key at startup, which it does not do. const fresh = new MemoryPersistentStore(); - expect(new SyncQueryStore(fresh).entityFieldNamesComplete()).toBe(true); - expect(fresh.getAllKeys()).toEqual([]); + const scans = vi.spyOn(fresh, 'getAllKeys'); + expect(new SyncQueryStore(fresh).entityFieldNamesComplete()).toBe(false); + expect(scans).not.toHaveBeenCalled(); + const upgraded = new MemoryPersistentStore(); + upgraded.setString('sq:doc:value:1', '{}'); + const upgradedScans = vi.spyOn(upgraded, 'getAllKeys'); + expect(new SyncQueryStore(upgraded).entityFieldNamesComplete()).toBe(false); + expect(upgradedScans).not.toHaveBeenCalled(); + // One that clear() emptied writes such events whole. reopen(true); const store = new SyncQueryStore(kv); expect(store.entityFieldNamesComplete()).toBe(false); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 419db6a..0d4a591 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -159,6 +159,19 @@ function applyEntity( isUpdate && rawKeys !== undefined && entityInstance.entityRefs !== undefined ? new Map(entityInstance.entityRefs) : new Map(); + // A full payload of a class that lacks fields another class of the typename + // declares leaves those fields in the data: count the references they hold, + // or they would be released while the other class's consumers still show + // them, and dropped from the record's references. + if ( + isUpdate && + rawKeys === undefined && + entityInstance.entityRefs !== undefined && + queryClient.hasForeignFieldDefs && + queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) + ) { + countRefsOutsideShape(existingData, shapeFields, entityInstance.entityRefs, childRefs, queryClient); + } // A refetch or a poll that returns identical data is a no-op: nothing to // notify consumers about, and nothing new to write to the store. @@ -227,7 +240,9 @@ function applyEntity( } if (isUpdate && changed) entityInstance.notify(); // Hydration handed over the record: it holds fields the data does not. - if (record !== undefined) entityInstance.noteRecord(record); + if (record !== undefined) { + entityInstance.recordHoldsOtherFields(record, queryClient.entityMap.readEntity !== undefined); + } if (appendMode && entityInstance.liveCollections.length > 0) { for (const binding of entityInstance.liveCollections) { @@ -267,6 +282,45 @@ function applyEntity( return proxy; } +/** + * Counts, into `childRefs`, the references to children the entity already + * holds (`held`) that sit in data fields `shape` does not declare. + */ +function countRefsOutsideShape( + data: Record, + shape: Record, + held: Map, + childRefs: Map, + queryClient: QueryClient, +): void { + for (const k of Object.keys(data)) { + if (!(k in shape)) countHeldRefs(data[k], held, childRefs, queryClient); + } +} + +function countHeldRefs( + value: unknown, + held: Map, + childRefs: Map, + queryClient: QueryClient, +): void { + if (typeof value !== 'object' || value === null || value instanceof FormattedValue) return; + const key = PROXY_ID.get(value); + if (key !== undefined) { + const child = queryClient.entityMap.getEntity(key); + if (child !== undefined && held.has(child)) childRefs.set(child, (childRefs.get(child) ?? 0) + 1); + return; + } + if (value instanceof LiveCollectionBinding) { + countHeldRefs(value.instance.getRawValue(), held, childRefs, queryClient); + } else if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) countHeldRefs(value[i], held, childRefs, queryClient); + } else if (Object.getPrototypeOf(value) === ObjectProto) { + const obj = value as Record; + for (const k of Object.keys(obj)) countHeldRefs(obj[k], held, childRefs, queryClient); + } +} + /** * A whole record is about to be written from a fetch's data: if the entity's * class lacks fields another class sharing its typename declares, the stored @@ -280,6 +334,7 @@ function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClie queryClient.mayMissForeignFields(shape as unknown as ValidatorDef) ) { instance._checkStoredRecord = true; + instance._recordProbe = queryClient.foreignFieldNames(shape as unknown as ValidatorDef); } } diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index a5f4987..290d409 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -553,8 +553,14 @@ function parseEntityData( obj = preloaded; // Only a class lacking fields another class of its typename declares // can be handed a record holding fields it does not parse; for every - // other class this is one WeakMap lookup. - if (queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef)) { + // other class this is one WeakMap lookup. Unless the store knows every + // class that wrote its records (an earlier release wrote some, or it + // does not remember field names): then any record is checked, as the + // other class may not be registered yet. + if ( + !queryClient.storeKnowsTypenameFields || + queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) + ) { const shape = entityShape.shape; for (const k in preloaded) { if (!(k in shape)) { diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 9663929..24f3ece 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -181,11 +181,12 @@ export interface QueryStore { /** * Synchronous stores: the stored record of an entity, parsed, or * `undefined` when there is none. Read once, before the first write of an - * entity built from a fetch whose class lacks fields another class sharing - * its typename declares (see `getEntityFieldNames`), so that write keeps - * the record's fields of the other class. + * entity whose record may hold fields of another class sharing its + * typename (see `getEntityFieldNames`), so that write keeps them. With + * `fields`, a record that holds none of these top-level fields is not + * parsed: return `undefined`. */ - readEntity?(entityKey: number): Record | undefined; + readEntity?(entityKey: number, fields?: readonly string[]): Record | undefined; /** * The top-level field names the entity classes of `typename` have diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index f95707a..8148aa5 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -115,7 +115,7 @@ const FIELD_NAME_TTL = 30 * 24 * 60 * 60 * 1000; const FIELD_NAME_REFRESH = 24 * 60 * 60 * 1000; /** * Since when a kv's field names have been remembered for every record it - * holds (0: since it held no cached data), and whether that is stored yet + * holds (0: since `clear()` emptied it), and whether that is stored yet * (see `entityFieldNamesComplete`). */ interface FieldNamesSince { @@ -125,6 +125,18 @@ interface FieldNamesSince { /** Per backing kv, like the field names. */ const fieldNamesSinceByKv = new WeakMap(); +/** + * Whether a record's JSON may hold one of `fields` as a key: a cheap check + * before parsing it. A nested object's key can match too; the parse sorts + * that out. + */ +function holdsAnyField(json: string, fields: readonly string[]): boolean { + for (let i = 0; i < fields.length; i++) { + if (json.indexOf(`${JSON.stringify(fields[i])}:`) !== -1) return true; + } + return false; +} + export class SyncQueryStore implements QueryStore { queues: Map = new Map(); private readonly deleteListeners: Array<(key: number) => void>; @@ -244,9 +256,10 @@ export class SyncQueryStore implements QueryStore { this.writeValue(entityKey, json.length === 2 ? `{${rest}}` : `{${rest},${json.slice(1)}`, refIds); } - readEntity(entityKey: number): Record | undefined { + readEntity(entityKey: number, fields?: readonly string[]): Record | undefined { const stored = this.kv.getString(valueKeyFor(entityKey)); if (stored === undefined) return undefined; + if (fields !== undefined && !holdsAnyField(stored, fields)) return undefined; let record: unknown; try { record = JSON.parse(stored); @@ -268,11 +281,12 @@ export class SyncQueryStore implements QueryStore { * record this store holds. Not while it holds records a version that did * not remember field names wrote: they may hold fields of a class not * registered since. When this version first opens a store it notes since - * when the names cover its records (`sq:meta:fieldsSince`, after one scan - * of the keys, stored with the first field names it writes): from then, or - * from the start if it held no cached data. `clear()` resets it to the - * start. 30 days on (`FIELD_NAME_TTL`), a class not declared since then - * would have been forgotten anyway, and the names count as complete. + * when the names cover its records (`sq:meta:fieldsSince`, stored with the + * first field names it writes): from then, whether or not the store held + * cached data (finding out would take a scan of every key). `clear()` + * resets it to the start. 30 days on (`FIELD_NAME_TTL`), a class not + * declared since then would have been forgotten anyway, and the names + * count as complete. */ entityFieldNamesComplete(): boolean { const at = this.fieldNamesSince().at; @@ -287,13 +301,10 @@ export class SyncQueryStore implements QueryStore { if (at !== undefined) { since = { at, stored: true }; } else { - since = { at: 0, stored: false }; - for (const key of kv.getAllKeys()) { - if (key.startsWith(DOC_PREFIX)) { - since.at = Date.now(); - break; - } - } + // Not known: count from now. Telling an empty store from one an + // earlier release wrote would take a scan of every key, on the thread + // that creates the client at startup. + since = { at: Date.now(), stored: false }; } fieldNamesSinceByKv.set(kv, since); return since; From 8eba14ed1c4d5ae305b6249aeb4aa5c3bf983b00 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 13:00:51 -0400 Subject: [PATCH 066/109] chore(fetchium): rewrite this change's changesets for React developers Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 14 ++++---------- .changeset/config-context-and-suspense-holds.md | 8 ++------ .changeset/nested-value-reactivity.md | 2 +- .changeset/sync-store-merge-without-read.md | 2 +- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index d4bc9de..3d344f1 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -2,14 +2,8 @@ 'fetchium': patch --- -Timing fixes for mounting, unmounting and changing params. +Fixed spurious errors and wrong data when a query mounts, unmounts or changes params quickly. -- A query mounted and unmounted in the same task (a redirect in a layout effect, a `replace` on mount) no longer shows an `AbortError` on its next mount: its first request finishes in the background. Releasing a `prefetch()` or `retain()` lease still aborts the fetch it started. -- A query unmounted mid-fetch and mounted again (a chart switched to another timeframe and back) no longer flashes an error. With no data yet it stays pending and refetches; with cached data it shows that data while it refetches. -- Changing a Signal param while a fetch is in flight aborts it and fetches the new params, instead of showing and caching the old params' response under the new key. A param set in the same task the query mounts is fetched right away, and a param change makes a remounted query refetch even if its old data was fresh. -- A query whose result is not an entity caches each params key separately. Previously every key's cache pointed at the latest params' data, so a cold start could show another key's data. This bug was in every earlier version. -- A stale query that mounts as the network goes offline waits for the network instead of rejecting with "Query is paused due to network status". -- A topic query mounted and unmounted in the same task no longer stays pending forever on its next mount. `TopicQueryAdapter.send()` honors its abort signal, including under React Native's `AbortController` polyfill. -- `QueryClient.destroy()` aborts and cancels every fetch; whoever awaits one gets an `AbortError`, and nothing is written to the store afterwards. -- With `gcTime: 0`, a query is collected only once its first fetch settles, so its entities no longer leak. -- Also: `reactivationStaggerMs` spreads the refetches that resume after a pause, a `__fetchNext()` page for stale params is dropped, and with an asynchronous store a param change during the cache load no longer applies the old params' entry. +- Unmounting and remounting a query, in the same task or mid-fetch (switching a chart timeframe and back), no longer shows an `AbortError`. The query shows its cached data, or stays pending, while it refetches. One exception: a topic query with no data that mounts and unmounts in the same task shows the `AbortError` on its next mount until its data arrives (before, it stayed pending forever). +- Changing a Signal param mid-fetch fetches the new params instead of showing the old params' response. A query whose result is not an entity now caches each params value separately; a cold start could show another params value's data. +- `QueryClient.destroy()` aborts every fetch in flight, and code awaiting one gets an `AbortError`. diff --git a/.changeset/config-context-and-suspense-holds.md b/.changeset/config-context-and-suspense-holds.md index 6bc47ed..947404f 100644 --- a/.changeset/config-context-and-suspense-holds.md +++ b/.changeset/config-context-and-suspense-holds.md @@ -2,10 +2,6 @@ 'fetchium': patch --- -Fixes for the new client options and `useSuspenseQuery`. +`useSuspenseQuery` no longer refetches in a loop when a slow retry render takes a while to reach the component. The error goes to the error boundary, and resetting the boundary retries. -- Custom `QueryClientConfig` keys still reach queries as `this.context`. `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved: a value under one of them is read as that option. -- `poll()` ignores an `activity` value without `isActive` and `subscribe` functions (with a warning in development) instead of failing every polled query. `QueryContext` no longer declares `activity` or `pollResumeJitterMs`. -- `Infinity` for `reactivationStaggerMs` or `pollResumeJitterMs` counts as 0, and `reactivationGraceMs: Infinity` never refetches on reactivation. A `shouldRetry` that is not a function is ignored. -- `useSuspenseQuery` no longer refetches in a loop when a slow retry render takes too long to reach the component; the error goes to the error boundary, and resetting the boundary retries. -- `Object.keys(this)` inside a query no longer lists an internal hook. +Custom `QueryClientConfig` keys still reach queries as `this.context`, except the reserved option names `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs`. An invalid value for one of these options is ignored instead of breaking queries. diff --git a/.changeset/nested-value-reactivity.md b/.changeset/nested-value-reactivity.md index 891afa1..cb09f80 100644 --- a/.changeset/nested-value-reactivity.md +++ b/.changeset/nested-value-reactivity.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Fixed child components showing stale nested values. A `component()` or `React.memo` child given a nested value as a prop (`token.price`: a `t.object` or `t.record` field, or a live collection's value) kept showing the old value after an update, because it received the same object. A changed nested value is now a new object, so the child re-renders. An unchanged one keeps its identity, so an update to another field doesn't re-render the child. +Fixed memoized children (`component()` or `React.memo`) showing a stale value when given a nested value such as `token.price` as a prop. A nested `t.object`, `t.record` or live-collection value is now a new object when it changes, and keeps its identity when it doesn't. diff --git a/.changeset/sync-store-merge-without-read.md b/.changeset/sync-store-merge-without-read.md index 8362f6c..e2d1238 100644 --- a/.changeset/sync-store-merge-without-read.md +++ b/.changeset/sync-store-merge-without-read.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Faster streamed updates with `SyncQueryStore`. Merging an update into a stored entity that is not in memory reads and parses the stored record only for the first merge, not on every event (up to 1024 entities are remembered). This is the common case for full-payload updates on a typename that more than one entity class declares. +Faster streamed updates with `SyncQueryStore` for entities that aren't in memory, which is common when two entity classes share a typename. From 725101caaab70532706da2e5fc6378935c80ebf9 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 14:55:09 -0400 Subject: [PATCH 067/109] perf(fetchium): walk only entity-holding foreign fields, and parse the record without probing A full-payload refetch or poll of a class that lacks fields another class of its typename declares counted the references held in every field of the data outside its shape, before the unchanged check. An unchanged refetch of 100 list entities (detail class in memory) was 43% slower, and a changed one 22% slower, than with no walk. - Walk only the other classes' fields whose defs can hold an entity (a t.entity, a live collection, or an array/object/record containing one), computed once per def and recomputed whenever another class of the typename registers (a restart can register the detail class without adding a field name). - Skip the walk for an unchanged payload when the last full payload counted the entity's references. A partial update or a live collection change clears that, so references a streamed update replaced are still dropped by the next refetch. - Drop the readEntity field probe: one indexOf per foreign field name over the record string cost more than the JSON.parse it skipped (records without detail fields: -30% on a cold fetch of 100 entities). readEntity takes the key only again. Tests: F6 (detail class registered after a restart, no new field name) and F7 (unchanged refetch after a streamed update replaced a reference). F3 now expects each record to be parsed at the first write. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/keep-cached-first-paint.md | 8 +- docs/src/app/api/stores-sync/page.md | 4 +- packages/fetchium/src/EntityInstance.ts | 11 ++- packages/fetchium/src/EntityStore.ts | 6 +- packages/fetchium/src/QueryClient.ts | 44 +++++---- .../__tests__/hydrate-in-memory-shape.test.ts | 99 ++++++++++++++++++- packages/fetchium/src/applyEntities.ts | 57 ++++++----- packages/fetchium/src/query-types.ts | 6 +- packages/fetchium/src/stores/sync.ts | 15 +-- 9 files changed, 167 insertions(+), 83 deletions(-) diff --git a/.changeset/keep-cached-first-paint.md b/.changeset/keep-cached-first-paint.md index 0aae0ca..7069e74 100644 --- a/.changeset/keep-cached-first-paint.md +++ b/.changeset/keep-cached-first-paint.md @@ -2,9 +2,9 @@ 'fetchium': patch --- -Cached data shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Previously the detail query waited for the network if the list had already loaded the entity. +Cached data now shows on the first render when two entity classes share a typename, such as a list class and a detail class with more fields. Before, the detail query waited for the network if the list had already loaded the entity. -- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Records keep every class's fields, so loading the list from the cache also parses the detail's fields. -- Refetching the list no longer drops entities that only the detail references, such as a detail's nested `t.entity` field. The detail kept showing the old value but stopped getting updates for it, and its cache failed to load after a restart. This bug was in every earlier version. -- To wipe the cache, call the new `SyncQueryStore.clear()` instead of clearing the storage directly (such as MMKV `clearAll()`), which can bring deleted fields back. +- Writing an entity through one class keeps the other class's fields in the store, so the detail cache survives a restart. Loading the list from the cache now also parses those fields. +- Refetching the list no longer drops entities that only the detail references, such as its nested `t.entity` field. Before, the detail kept the old value, stopped getting updates for it, and its cache failed to load after a restart. This bug was in every earlier version. +- To wipe the cache, call the new `SyncQueryStore.clear()`, not the storage's own clear (such as MMKV `clearAll()`), which can bring deleted fields back. - Custom stores can add the new optional `QueryStore` methods `readEntity`, `getEntityFieldNames`, `addEntityFieldNames` and `entityFieldNamesComplete`. Without them nothing changes. diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index 0133d23..45136ee 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -33,7 +33,7 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods | Method | Signature | Description | -| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | | `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | | `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | @@ -43,7 +43,7 @@ new SyncQueryStore(kv: SyncPersistentStore) | `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | | `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | | `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | -| `readEntity` | `(entityKey: number, fields?: readonly string[]): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity whose record may hold fields of another class of its typename, so that write keeps them. With `fields`, a record whose JSON holds none of those keys is not parsed and `undefined` is returned. | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity built from a fetch whose class lacks fields another class of its typename declares, so that write keeps them. | +| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity whose record may hold fields of another class of its typename, so that write keeps them. | | `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | | `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | | `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every class that wrote a record the store holds. The first open by this version notes when it started remembering (`sq:meta:fieldsSince`): the current time, whether or not an earlier version wrote the store (telling would take a scan of every key at startup). `clear()` sets it to 0. Answers `true` for 0, or 30 days later. Until then the client merges into its record an entity that streamed events built with every field of its class, rather than writing it whole, so a record an earlier version wrote keeps the fields of a class not registered since. | diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 96b46f5..021bf38 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -671,10 +671,10 @@ export class EntityInstance { */ _checkStoredRecord: boolean = false; /** - * With `_checkStoredRecord`: the other classes' field names, one of which - * the record must hold to be worth parsing. `undefined`: parse it. + * `entityRefs` were counted from the whole data by a full payload's apply, + * not carried over by a partial update or changed by a live collection. */ - _recordProbe: readonly string[] | undefined = undefined; + _refsCounted: boolean = false; private _saving: boolean = false; entityRefs: Map | undefined; liveCollections: LiveCollectionBinding[] = []; @@ -769,6 +769,7 @@ export class EntityInstance { } addChildRef(child: EntityInstance, persist: boolean = true): void { + this._refsCounted = false; if (this.entityRefs === undefined) this.entityRefs = new Map(); const count = this.entityRefs.get(child) ?? 0; this.entityRefs.set(child, count + 1); @@ -777,6 +778,7 @@ export class EntityInstance { } removeChildRef(child: EntityInstance, persist: boolean = true): void { + this._refsCounted = false; if (this.entityRefs === undefined) return; const count = this.entityRefs.get(child); if (count === undefined) return; @@ -925,7 +927,6 @@ export class EntityInstance { this._storedRecord = undefined; this._recordRest = undefined; this._checkStoredRecord = true; - this._recordProbe = undefined; } else { this.noteRecord(record); } @@ -1043,7 +1044,7 @@ const staticFieldDefs = new WeakMap, boolean>(); * `getEntityDef` validates against, so an unfamiliar def costs a read rather * than serving a stale value. */ -function isStaticFieldDef(def: unknown): boolean { +export function isStaticFieldDef(def: unknown): boolean { // `t.string` is a bare `Mask`, `t.typename('X')` the literal string, // `t.enum`/`t.const` a `Set` of allowed values. if (typeof def === 'number' || typeof def === 'string') return true; diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 55cf476..3778ac4 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -9,8 +9,7 @@ export class EntityStore { /** Whether the store can merge fields over a record it holds. */ mergesEntities: boolean = false; /** A synchronous store's read of an entity's stored record, if it offers one (see `QueryStore.readEntity`). */ - readEntity: ((key: number, fields?: readonly string[]) => Record | undefined) | undefined = - undefined; + readEntity: ((key: number) => Record | undefined) | undefined = undefined; constructor(persistEntity: PersistEntity) { this.persistEntity = persistEntity; @@ -87,8 +86,7 @@ export class EntityStore { // First write of an instance whose class may lack fields the record // holds: read the record once and keep them from now on. instance._checkStoredRecord = false; - const stored = this.readEntity?.(instance.key, instance._recordProbe); - instance._recordProbe = undefined; + const stored = this.readEntity?.(instance.key); if (stored !== undefined) instance.noteRecord(stored); } const rest = instance.recordRestForWrite(); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index e6eafe9..0b18501 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -11,7 +11,7 @@ import { } from './types.js'; import { PROXY_ID } from './proxyId.js'; import { EntityStore } from './EntityStore.js'; -import { EntityInstance, type EntityKeys } from './EntityInstance.js'; +import { EntityInstance, isStaticFieldDef, type EntityKeys } from './EntityInstance.js'; import { NetworkManager, NoOpNetworkManager } from './NetworkManager.js'; import { QueryInstance } from './QueryResult.js'; import { MutationResultImpl } from './MutationResult.js'; @@ -267,7 +267,7 @@ export class QueryClient { this.entityMap.mergesEntities = typeof this.store.mergeEntity === 'function'; if (typeof this.store.readEntity === 'function') { const store = this.store; - this.entityMap.readEntity = (key, fields) => store.readEntity!(key, fields); + this.entityMap.readEntity = key => store.readEntity!(key); } // A record the store drops must be written again by the next apply. this.storeReportsDeletes = typeof this.store.onDelete === 'function'; @@ -411,8 +411,8 @@ export class QueryClient { private foreignFieldDefs = new WeakMap, boolean>(); /** Per typename: every top-level field its classes have declared (see `foreignFieldDefs`). */ private typenameFields = new Map>(); - /** Per entity def: the fields of its typename it does not declare (see `foreignFieldNames`). */ - private foreignFieldNamesByDef = new WeakMap, readonly string[]>(); + /** Per entity def: the fields other classes of its typename declare that can hold an entity (see `foreignRefFields`). */ + private foreignRefFieldsByDef = new WeakMap, readonly string[]>(); /** Whether any registered def lacks a field of its typename; false for an app whose typenames each have one class. */ hasForeignFieldDefs: boolean = false; /** @@ -450,23 +450,31 @@ export class QueryClient { } /** - * The top-level fields other classes sharing this def's typename declare - * and it does not: a stored record holding none of them has nothing for - * the def's writes to keep. Computed once per def while the typename's - * fields are unchanged. + * The top-level fields that other classes registered for this def's + * typename declare, that it does not, and whose values can hold an entity + * (a `t.entity`, a live array, or an array or object containing one). Only + * these can hold references to children that a full payload of this def + * leaves in the data. Computed once per def until another class of the + * typename registers. * * @internal */ - foreignFieldNames(def: ValidatorDef): readonly string[] { - let names = this.foreignFieldNamesByDef.get(def); + foreignRefFields(def: ValidatorDef): readonly string[] { + let names = this.foreignRefFieldsByDef.get(def); if (names === undefined) { - const missing: string[] = []; - const fields = def.typenameValue !== undefined ? this.typenameFields.get(def.typenameValue) : undefined; + const found: string[] = []; const shape = def.shape as Record | undefined; - if (fields !== undefined && shape !== undefined) { - for (const f of fields) if (!(f in shape)) missing.push(f); + const defs = def.typenameValue !== undefined ? this.typenameRegistry.get(def.typenameValue) : undefined; + if (shape !== undefined && defs !== undefined) { + for (const d of defs) { + if (d === def) continue; + const other = d.shape as Record; + for (const f of Object.keys(other)) { + if (!(f in shape) && !found.includes(f) && !isStaticFieldDef(other[f])) found.push(f); + } + } } - this.foreignFieldNamesByDef.set(def, (names = missing)); + this.foreignRefFieldsByDef.set(def, (names = found)); } return names; } @@ -496,7 +504,9 @@ export class QueryClient { // Every registered def's fields are in the set, so a def lacks one of // them exactly when the set is larger than its shape. for (const d of this.typenameRegistry.get(typename)!) { - if (added) this.foreignFieldNamesByDef.delete(d); + // A new class can declare a field name already known with a def that + // holds entities, so every def of the typename recomputes. + this.foreignRefFieldsByDef.delete(d); if (!added && d !== def) continue; const misses = fields.size > Object.keys(d.shape as Record).length; this.foreignFieldDefs.set(d, misses); @@ -1311,7 +1321,7 @@ export class QueryClient { this.typenameRegistry.clear(); this.typenameFields.clear(); this.foreignFieldDefs = new WeakMap(); - this.foreignFieldNamesByDef = new WeakMap(); + this.foreignRefFieldsByDef = new WeakMap(); this.mergedDefCache.clear(); } } diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts index 4c8d8f3..204f153 100644 --- a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -470,9 +470,9 @@ class CountingStore extends SyncQueryStore { reads = 0; /** Reads that found a record worth parsing. */ parsed = 0; - readEntity(entityKey: number, fields?: readonly string[]) { + readEntity(entityKey: number) { this.reads++; - const record = super.readEntity(entityKey, fields); + const record = super.readEntity(entityKey); if (record !== undefined) this.parsed++; return record; } @@ -759,6 +759,36 @@ describe('D. writes keep the fields another class sharing the typename declared' }); }); +class Part extends Entity { + __typename = t.typename('Part'); + id = t.id; + name = t.string; +} +class GizmoRow extends Entity { + __typename = t.typename('Gizmo'); + id = t.id; + name = t.string; + maker = t.entity(Part); +} +class GizmoDetail extends Entity { + __typename = t.typename('Gizmo'); + id = t.id; + name = t.string; + maker = t.entity(Part); + parts = t.array(t.entity(Part)); +} +class GetGizmos extends RESTQuery { + path = '/gizmos'; + result = { items: t.array(t.entity(GizmoRow)) }; +} +class GetGizmo extends RESTQuery { + path = '/gizmo'; + result = { item: t.entity(GizmoDetail) }; +} +const part = (id: string) => ({ __typename: 'Part', id, name: id }); +const row = (name: string) => ({ __typename: 'Gizmo', id: 'g1', name, maker: part('m1') }); +const partKey = (id: string) => hashValue(['Part', id]); + describe('F. references and records the other class holds', () => { /** The summary in memory from the network, then the detail fetched over the same entity. */ async function listThenDetail(kv: MemoryPersistentStore, f: ReturnType) { @@ -813,7 +843,7 @@ describe('F. references and records the other class holds', () => { expect(recordOf(kv, 'Vendor', 'v1')).toMatchObject({ name: 'w1' }); }); - it('F3 a summary fetched over records without detail fields parses none of them before its first writes', async () => { + it('F3 a summary fetched over records without detail fields reads each record once and writes the fetched fields', async () => { const kv = new MemoryPersistentStore(); const f = makeFetch(); // The detail class registered and wrote i1; i2 and i3 hold summary fields only. @@ -846,14 +876,75 @@ describe('F. references and records the other class holds', () => { result = { items: t.array(t.entity(ItemSummary)) }; } await start(c, () => fetchQuery(GetOtherItems)); + // One read per entity new in memory (i2, i3), at its first write. expect({ reads: store.reads - before.reads, parsed: store.parsed - before.parsed }).toEqual({ reads: 2, - parsed: 0, + parsed: 2, }); expect(recordOf(kv, 'Item', 'i2')).toEqual({ __typename: 'Item', id: 'i2', name: 'J2' }); expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'I2', details: { rating: 5 } }); }); + it('F6 after a restart, a detail class registered after a summary refetch keeps its children referenced through the next one', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/gizmo', { item: { ...row('G'), parts: [part('p1'), part('p2')] } }); + // Session 1 remembers the detail's field names. + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetGizmo)); + }); + + // Session 2: the summary refetches before the detail class registers, so + // the detail adds no field name the store did not already know. + const c = makeClient(kv, f); + f.set('/gizmos', { items: [row('A')] }); + const list: any = start(c, () => fetchQuery(GetGizmos)); + await list; + f.set('/gizmos', { items: [row('B')] }); + await list.value.__refetch(); + const item: any = start(c, () => fetchQuery(GetGizmo)); + await item; + await sleep(10); + f.set('/gizmos', { items: [row('C')] }); + await list.value.__refetch(); + + expect(item.value.item.parts.map((p: any) => p.name)).toEqual(['p1', 'p2']); + expect(new Set(kv.getBuffer(refIdsKeyFor(hashValue(['Gizmo', 'g1']))) ?? [])).toEqual( + new Set([partKey('m1'), partKey('p1'), partKey('p2')]), + ); + c.applyMutationEvent({ type: 'update', typename: 'Part', data: { ...part('p1'), name: 'q1' } }); + await sleep(0); + expect(item.value.item.parts[0].name).toBe('q1'); + }); + + it('F7 a summary refetch that changes nothing still drops the reference a streamed update replaced', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + const c = makeClient(kv, f); + f.set('/gizmos', { items: [row('A')] }); + const list: any = start(c, () => fetchQuery(GetGizmos)); + await list; + f.set('/gizmo', { item: { ...row('A'), parts: [part('p1')] } }); + const item: any = start(c, () => fetchQuery(GetGizmo)); + await item; + // Two full payloads count every reference, then an update replaces the maker. + await list.value.__refetch(); + c.applyMutationEvent({ + type: 'update', + typename: 'Gizmo', + data: { __typename: 'Gizmo', id: 'g1', maker: part('m2') }, + }); + await sleep(0); + f.set('/gizmos', { items: [{ ...row('A'), maker: part('m2') }] }); + await list.value.__refetch(); + + expect(list.value.items[0].maker.name).toBe('m2'); + expect(new Set(kv.getBuffer(refIdsKeyFor(hashValue(['Gizmo', 'g1']))) ?? [])).toEqual( + new Set([partKey('m2'), partKey('p1')]), + ); + expect(c.entityMap.getEntity(partKey('m1'))).toBeUndefined(); + }); + it('F4 a summary hydrated from its cache over a detail record reads the record again at its first write, not holding it until then', async () => { const kv = new MemoryPersistentStore(); const f = makeFetch(); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 0d4a591..81cd0c4 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -159,20 +159,6 @@ function applyEntity( isUpdate && rawKeys !== undefined && entityInstance.entityRefs !== undefined ? new Map(entityInstance.entityRefs) : new Map(); - // A full payload of a class that lacks fields another class of the typename - // declares leaves those fields in the data: count the references they hold, - // or they would be released while the other class's consumers still show - // them, and dropped from the record's references. - if ( - isUpdate && - rawKeys === undefined && - entityInstance.entityRefs !== undefined && - queryClient.hasForeignFieldDefs && - queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) - ) { - countRefsOutsideShape(existingData, shapeFields, entityInstance.entityRefs, childRefs, queryClient); - } - // A refetch or a poll that returns identical data is a no-op: nothing to // notify consumers about, and nothing new to write to the store. let changed = true; @@ -238,6 +224,32 @@ function applyEntity( } finally { entityInstance._applying = false; } + // A full payload of a class that lacks fields another class of the typename + // declares leaves those fields in the data: count the references they hold, + // or they would be released while the other class's consumers still show + // them, and dropped from the record's references. Only fields whose defs + // can hold an entity are walked. An unchanged payload skips the walk when + // the last full payload counted the references: the entity still holds + // exactly those. + const heldRefs = entityInstance.entityRefs; + let keepsHeldRefs = false; + if ( + isUpdate && + rawKeys === undefined && + heldRefs !== undefined && + queryClient.hasForeignFieldDefs && + queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) + ) { + if (!changed && !appendMode && entityInstance._refsCounted) { + keepsHeldRefs = true; + } else { + const fields = queryClient.foreignRefFields(entityShape as unknown as ValidatorDef); + for (let i = 0; i < fields.length; i++) countHeldRefs(existingData[fields[i]], heldRefs, childRefs, queryClient); + } + } + // Partial updates keep the references they did not replace, so only a full + // payload counts every reference the data holds. + entityInstance._refsCounted = rawKeys === undefined && !appendMode; if (isUpdate && changed) entityInstance.notify(); // Hydration handed over the record: it holds fields the data does not. if (record !== undefined) { @@ -260,7 +272,7 @@ function applyEntity( } } - const newRefs = childRefs.size > 0 ? childRefs : undefined; + const newRefs = keepsHeldRefs ? heldRefs : childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); // An entity hydrated from the store was never written, so there is no write // to skip. One whose own write is still queued has its current data on the @@ -284,20 +296,8 @@ function applyEntity( /** * Counts, into `childRefs`, the references to children the entity already - * holds (`held`) that sit in data fields `shape` does not declare. + * holds (`held`) found in `value`. */ -function countRefsOutsideShape( - data: Record, - shape: Record, - held: Map, - childRefs: Map, - queryClient: QueryClient, -): void { - for (const k of Object.keys(data)) { - if (!(k in shape)) countHeldRefs(data[k], held, childRefs, queryClient); - } -} - function countHeldRefs( value: unknown, held: Map, @@ -334,7 +334,6 @@ function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClie queryClient.mayMissForeignFields(shape as unknown as ValidatorDef) ) { instance._checkStoredRecord = true; - instance._recordProbe = queryClient.foreignFieldNames(shape as unknown as ValidatorDef); } } diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 24f3ece..29d23a0 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -182,11 +182,9 @@ export interface QueryStore { * Synchronous stores: the stored record of an entity, parsed, or * `undefined` when there is none. Read once, before the first write of an * entity whose record may hold fields of another class sharing its - * typename (see `getEntityFieldNames`), so that write keeps them. With - * `fields`, a record that holds none of these top-level fields is not - * parsed: return `undefined`. + * typename (see `getEntityFieldNames`), so that write keeps them. */ - readEntity?(entityKey: number, fields?: readonly string[]): Record | undefined; + readEntity?(entityKey: number): Record | undefined; /** * The top-level field names the entity classes of `typename` have diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 8148aa5..e4337d9 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -125,18 +125,6 @@ interface FieldNamesSince { /** Per backing kv, like the field names. */ const fieldNamesSinceByKv = new WeakMap(); -/** - * Whether a record's JSON may hold one of `fields` as a key: a cheap check - * before parsing it. A nested object's key can match too; the parse sorts - * that out. - */ -function holdsAnyField(json: string, fields: readonly string[]): boolean { - for (let i = 0; i < fields.length; i++) { - if (json.indexOf(`${JSON.stringify(fields[i])}:`) !== -1) return true; - } - return false; -} - export class SyncQueryStore implements QueryStore { queues: Map = new Map(); private readonly deleteListeners: Array<(key: number) => void>; @@ -256,10 +244,9 @@ export class SyncQueryStore implements QueryStore { this.writeValue(entityKey, json.length === 2 ? `{${rest}}` : `{${rest},${json.slice(1)}`, refIds); } - readEntity(entityKey: number, fields?: readonly string[]): Record | undefined { + readEntity(entityKey: number): Record | undefined { const stored = this.kv.getString(valueKeyFor(entityKey)); if (stored === undefined) return undefined; - if (fields !== undefined && !holdsAnyField(stored, fields)) return undefined; let record: unknown; try { record = JSON.parse(stored); From 7a9f94ac497c26a80250a243f6329b2e3f5114b1 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 19:36:23 -0400 Subject: [PATCH 068/109] fix(fetchium): re-read formatted fields in the snapshot fast path A formatted value's snapshot can go through a custom snapshot handler that reads another signal, such as a locale. Classifying t.format fields as static skipped them, so the handler's signal stopped being tracked: dev threw the stale-snapshot guard and prod kept the old value. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 10 +++- .../src/__tests__/entity-snapshot.test.ts | 57 ++++++++++++++++++- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 99e5bca..90de39a 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -524,8 +524,11 @@ function filterEntityArray(array: unknown[], innerDef: ValidatorDef, qu * while `t.union(t.object({ child: t.entity(A) }), …)` does not. What catches * the nested case is `computeIsStaticFieldDef` recursing into the union's * member defs. Short-circuiting a union on its mask alone would go stale. + * + * `HAS_FORMAT` because a formatted value's snapshot can go through a custom + * snapshot handler that reads other signals (a locale, say). */ -const DYNAMIC_MASKS = Mask.ENTITY | Mask.LIVE; +const DYNAMIC_MASKS = Mask.ENTITY | Mask.LIVE | Mask.HAS_FORMAT; const staticFieldDefs = new WeakMap, boolean>(); @@ -538,7 +541,8 @@ const staticFieldDefs = new WeakMap, boolean>(); function isStaticFieldDef(def: unknown): boolean { // `t.string` is a bare `Mask`, `t.typename('X')` the literal string, // `t.enum`/`t.const` a `Set` of allowed values. - if (typeof def === 'number' || typeof def === 'string') return true; + if (typeof def === 'number') return (def & DYNAMIC_MASKS) === 0; + if (typeof def === 'string') return true; if (def instanceof Set) return true; if (!(def instanceof ValidatorDef)) return false; @@ -557,7 +561,7 @@ function computeIsStaticFieldDef(def: ValidatorDef): boolean { if (def._liveConfig !== undefined) return false; if ((def.mask & DYNAMIC_MASKS) !== 0) return false; - // No shape: primitive, format, or union of literals. One inner def: array, + // No shape: primitive or union of literals. One inner def: array, // record, parse result, optional/nullable clone. A record of defs: object. const shape = def.shape; if (shape === undefined || shape === null) return true; diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index 6a781b3..b6b3e9d 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { watcher, withContexts } from 'signalium'; -import { hashValue, snapshot } from 'signalium/utils'; +import { signal, watcher, withContexts } from 'signalium'; +import { hashValue, registerCustomSnapshot, snapshot } from 'signalium/utils'; import { __debug_resetSnapshotCounters, __debug_snapshotFieldReads, __debug_snapshotFullWalks, } from '../EntityInstance.js'; -import { t } from '../typeDefs.js'; +import { t, registerFormat } from '../typeDefs.js'; +import { Mask } from '../types.js'; import { Entity } from '../proxy.js'; import { RESTQuery } from '../rest/index.js'; import { fetchQuery } from '../query.js'; @@ -229,6 +230,56 @@ describe('Entity Snapshots', () => { expect(after.__refetch).toBe(before.__refetch); }); + it('re-snapshots a formatted value whose snapshot handler reads a signal', async () => { + const { client, mockFetch } = getClient(); + + const locale = signal('en'); + class Money { + constructor(public cents: number) {} + } + registerCustomSnapshot(Money, current => ({ label: `${locale.value}:${current.cents}` }) as never); + registerFormat( + 'snapshot-locale-money', + Mask.NUMBER, + v => new Money(v), + v => v.cents, + ); + + class Product extends Entity { + __typename = t.typename('Product'); + id = t.id; + price = t.format('snapshot-locale-money' as never); + nested = t.object({ price: t.format('snapshot-locale-money' as never) }); + list = t.array(t.format('snapshot-locale-money' as never)); + } + class GetProduct extends RESTQuery { + path = '/product'; + result = { product: t.entity(Product) }; + } + + mockFetch.get('/product', { + product: { __typename: 'Product', id: 'p-1', price: 100, nested: { price: 200 }, list: [300] }, + }); + + const { query, read } = snapshotHarness(client, () => fetchQuery(GetProduct)); + await query; + + type Snap = { + product: { price: { label: string }; nested: { price: { label: string } }; list: { label: string }[] }; + }; + const labels = () => { + const { product } = read() as unknown as Snap; + return [product.price.label, product.nested.price.label, product.list[0].label]; + }; + expect(labels()).toEqual(['en:100', 'en:200', 'en:300']); + + locale.value = 'fr'; + expect(labels()).toEqual(['fr:100', 'fr:200', 'fr:300']); + + locale.value = 'de'; + expect(labels()).toEqual(['de:100', 'de:200', 'de:300']); + }); + it('reflects live-array membership changes in the parent snapshot', async () => { const { client, mockFetch } = getClient(); From 15dabd0965ce857d8e43679e06e3cc39d4ce4a97 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 19:38:19 -0400 Subject: [PATCH 069/109] fix(fetchium): fetch an invalidated query whatever its staleTime invalidateQueries() sets updatedAt to 0, but isStale only treated undefined as stale, so with staleTime: Infinity an uncached query invalidated before its first fetch started made no request and stayed pending. An invalidation made before the cache loaded is also kept now, instead of being overwritten by the cached timestamp. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 6 +- .../src/__tests__/sync-hydration.test.ts | 70 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index d403dab..d67c31c 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -311,7 +311,8 @@ export class QueryInstance { if (cached === undefined) return; try { - this.updatedAt = cached.updatedAt; + // Keep an invalidation made before the cache loaded. + if (this.updatedAt !== 0) this.updatedAt = cached.updatedAt; this.relayState.value = this.applyData(cached.value, false, false, cached.preloadedEntities); } catch (error) { // Unusable entry: treat it as a miss so the query still fetches, instead @@ -544,7 +545,8 @@ export class QueryInstance { // ====================================================== private get isStale(): boolean { - if (this.updatedAt === undefined) { + // Never loaded, or invalidated (`markStale()`), whatever the staleTime. + if (this.updatedAt === undefined || this.updatedAt === 0) { return true; } diff --git a/packages/fetchium/src/__tests__/sync-hydration.test.ts b/packages/fetchium/src/__tests__/sync-hydration.test.ts index 2dcad11..0c42f39 100644 --- a/packages/fetchium/src/__tests__/sync-hydration.test.ts +++ b/packages/fetchium/src/__tests__/sync-hydration.test.ts @@ -229,3 +229,73 @@ describe('Activation with an asynchronous store', () => { client.destroy(); }); }); + +describe('Invalidation before the first fetch', () => { + class GetForever extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: Infinity }; + } + + it('fetches an uncached query invalidated before activation', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new SyncQueryStore(new MemoryPersistentStore()), mockFetch); + + const relay = withContexts([[QueryClientContext, client]], () => fetchQuery(GetForever)); + client.invalidateQueries([GetForever]); + const { dispose } = activate(client, () => fetchQuery(GetForever).isPending); + + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + await relay; + expect(relay.value!.value).toBe('fresh'); + + dispose(); + client.destroy(); + }); + + for (const [name, Store] of [ + ['sync', SyncQueryStore], + ['async', AsyncLoadingQueryStore], + ] as const) { + it(`fetches an uncached query invalidated before the first fetch starts (${name} store)`, async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new Store(new MemoryPersistentStore()), mockFetch); + + let relay: ReturnType> | undefined; + const { dispose } = activate(client, () => (relay = fetchQuery(GetForever)).isPending); + client.invalidateQueries([GetForever]); + + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + await relay; + expect(relay!.value!.value).toBe('fresh'); + + dispose(); + client.destroy(); + }); + } + + it('refetches a cached query invalidated before its cache loads', async () => { + const kv = new MemoryPersistentStore(); + await seedCache(kv, GetForever, { value: 'cached' }); + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }); + const client = makeClient(new AsyncLoadingQueryStore(kv), mockFetch); + + let relay: ReturnType> | undefined; + const { dispose } = activate(client, () => (relay = fetchQuery(GetForever)).isPending); + client.invalidateQueries([GetForever]); + + await flushMicrotasks(); + expect(mockFetch.calls).toHaveLength(1); + await sleep(0); + expect(relay!.value!.value).toBe('fresh'); + + dispose(); + client.destroy(); + }); +}); From d5b1a9c9f22bcf228dfae5ef07ee92eb25f032c6 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 19:40:16 -0400 Subject: [PATCH 070/109] fix(fetchium): close three gaps in the reactivation grace and stagger - __fetchNext() counts as a fetch start, so a staggered refetch waiting in the window no longer resets the page it loaded. - An aborted fetch that resolves late no longer clears lastFetchFailed, which may belong to the fetch that replaced it. - A reconnect while the query was inactive now voids the grace, using a new NetworkManager.reconnects counter. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/NetworkManager.ts | 21 +++- packages/fetchium/src/QueryResult.ts | 16 ++- .../src/__tests__/reactivation-grace.test.ts | 98 +++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/NetworkManager.ts b/packages/fetchium/src/NetworkManager.ts index e94aa6e..de1b8b8 100644 --- a/packages/fetchium/src/NetworkManager.ts +++ b/packages/fetchium/src/NetworkManager.ts @@ -19,6 +19,7 @@ export class NetworkManager { // Initialize with manual status if provided, otherwise detect from environment const initialOnlineStatus = initialStatus ?? this.detectOnlineStatus(); this.onlineSignal = signal(initialOnlineStatus); + this.lastOnline = initialOnlineStatus; // Automatically attach event listeners if in browser/React Native environment if (this.canAttachListeners()) { @@ -38,12 +39,23 @@ export class NetworkManager { return this.onlineSignal.value; } + /** Offline-to-online transitions so far. */ + reconnects = 0; + /** Untracked copy of the signal's value, so writes don't consume it. */ + private lastOnline: boolean; + + private setOnline(online: boolean): void { + if (online && !this.lastOnline) this.reconnects++; + this.lastOnline = online; + this.onlineSignal.value = online; + } + /** * Manually set the network status (useful for testing) */ setNetworkStatus(online: boolean): void { this.manualOverride = online; - this.onlineSignal.value = online; + this.setOnline(online); } /** @@ -51,7 +63,7 @@ export class NetworkManager { */ clearManualOverride(): void { this.manualOverride = undefined; - this.onlineSignal.value = this.detectOnlineStatus(); + this.setOnline(this.detectOnlineStatus()); } /** @@ -106,13 +118,13 @@ export class NetworkManager { this.handleOnline = () => { if (this.manualOverride === undefined) { - this.onlineSignal.value = true; + this.setOnline(true); } }; this.handleOffline = () => { if (this.manualOverride === undefined) { - this.onlineSignal.value = false; + this.setOnline(false); } }; @@ -126,6 +138,7 @@ export class NetworkManager { // No-op implementation for SSR environments where network status tracking is not needed export class NoOpNetworkManager { private static readonly onlineSignal: Signal = signal(true); + readonly reconnects = 0; get isOnline(): boolean { return true; diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index bb859b5..eb31cb7 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -50,6 +50,8 @@ export class QueryInstance { private _relayState: RelayState> | undefined = undefined; private _isActive: boolean = false; private wasPaused: boolean = false; + /** `networkManager.reconnects` at the last deactivation. */ + private reconnectsAtDeactivate: number = 0; private currentParams: QueryParams | undefined = undefined; private debounceTimer: ReturnType | undefined = undefined; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ @@ -145,6 +147,7 @@ export class QueryInstance { // but skip GC, so resuming reuses the cached result instead of refetching. const deactivate = ({ isPausing = false }: DeactivateOptions = {}) => { this._isActive = false; + this.reconnectsAtDeactivate = this.queryClient.networkManager.reconnects; clearTimeout(this.debounceTimer); this.debounceTimer = undefined; @@ -208,8 +211,12 @@ export class QueryInstance { } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; // The grace covers a relay resuming, not a network reconnect: data - // may have been missed while offline. - const withinGrace = activating && !wasPaused && this.isWithinReactivationGrace; + // may have been missed while offline, including while inactive. + const withinGrace = + activating && + !wasPaused && + this.queryClient.networkManager.reconnects === this.reconnectsAtDeactivate && + this.isWithinReactivationGrace; if (refreshStaleOnReconnect && this.isStale && !withinGrace) { if (this.queryClient.reactivationStaggerMs > 0) { this.reactivationQueuedAt = this.fetchStarts; @@ -457,7 +464,8 @@ export class QueryInstance { this.retryConfig, signal, ); - this.lastFetchFailed = false; + // An aborted fetch may have been replaced by one that failed. + if (!signal.aborted) this.lastFetchFailed = false; return result; } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; @@ -551,6 +559,8 @@ export class QueryInstance { if (this._fetchNextPromise !== undefined) { return this._fetchNextPromise; } + // Cancels a waiting staggered refetch, which would reset the pages. + this.fetchStarts++; // Schedule notification so __isFetchingNext becomes true reactively. // Must be async to avoid "dirtied after consumed" when called from // within a reactive context (the proxy consumes the notifier on access). diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index c54600b..0a59349 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -12,6 +12,7 @@ import type { MutationEvent } from '../types.js'; import type { Query } from '../query.js'; import { TopicQuery } from '../topic/TopicQuery.js'; import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; +import { Entity } from '../proxy.js'; import { createMockFetch, createTestWatcher } from './utils.js'; /** @@ -255,6 +256,54 @@ describe('reactivation grace and stagger', () => { expect(fetchCount('/a')).toBe(3); }); + it('refetches when the last fetch failed, even if an older replaced fetch succeeded later', async () => { + class GetNoRetry extends RESTQuery { + path = '/a'; + result = { n: t.number }; + config = { retry: false }; + } + mockFetch.reset(); + mockFetch.get('/a', { n: 1 }); + mockFetch.get('/a', { n: 2 }, { delay: 1_000 }); + mockFetch.get('/a', { n: 3 }, { error: new Error('network down') }); + mockFetch.get('/a', { n: 4 }); + + const client = makeClient({ reactivationGraceMs: 15_000 }); + let relay: any; + const watchRelay = () => { + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => (relay = fetchQuery(GetNoRetry)).value), + ); + unsubs.push(unsub); + return unsub; + }; + + let unsub = watchRelay(); + await vi.advanceTimersByTimeAsync(50); + + // A slow refetch, which the mock lets finish even after it is aborted. + void relay.value.__refetch().catch(() => {}); + await vi.advanceTimersByTimeAsync(10); + + // Deactivating aborts it; reactivating replaces it with a fetch that fails. + unsub(); + await vi.advanceTimersByTimeAsync(10); + unsub = watchRelay(); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(3); + expect(relay.isRejected).toBe(true); + + // The replaced fetch lands. + await vi.advanceTimersByTimeAsync(1_500); + + unsub(); + await vi.advanceTimersByTimeAsync(10); + watchRelay(); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(4); + expect(relay.isRejected).toBe(false); + }); + it('measures from the last push when a subscription delivered data', async () => { let push: (() => void) | undefined; class GetStreamed extends RESTQuery { @@ -473,6 +522,22 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(2); }); + + it('does not apply after a network reconnect while the query was inactive', async () => { + const networkManager = new NetworkManager(true); + const client = makeClient({ reactivationGraceMs: 15_000, networkManager }); + await loadThenDeactivate(client, GetA); + expect(fetchCount('/a')).toBe(1); + + networkManager.setNetworkStatus(false); + await vi.advanceTimersByTimeAsync(50); + networkManager.setNetworkStatus(true); + await vi.advanceTimersByTimeAsync(50); + + watch(client, GetA); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/a')).toBe(2); + }); }); describe('reactivationStaggerMs', () => { @@ -643,6 +708,39 @@ describe('reactivation grace and stagger', () => { expect(fetchCount('/b')).toBe(1); }); + it('skips a staggered refetch when __fetchNext() loaded a page during the window', async () => { + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + } + class GetItems extends RESTQuery { + path = '/items'; + result = { items: t.liveArray(Item), nextCursor: t.optional(t.string) }; + fetchNext = { searchParams: { cursor: this.result.nextCursor } }; + } + mockFetch.get('/items', { items: [{ __typename: 'Item', id: '1' }], nextCursor: 'c1' }); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: '2' }] }); + mockFetch.get('/items', { items: [{ __typename: 'Item', id: '1' }], nextCursor: 'c1' }); + + const client = makeClient({ reactivationStaggerMs: 3_000 }); + await loadThenDeactivate(client, GetA, GetB, GetItems); + starts.length = 0; + + let value: any; + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => [fetchQuery(GetA).value, fetchQuery(GetB).value, (value = fetchQuery(GetItems).value)]), + ); + unsubs.push(unsub); + // /items' slot is 2 s out. Load the next page now. + await vi.advanceTimersByTimeAsync(100); + await value.__fetchNext(); + expect(value.items.map((i: any) => i.id)).toEqual(['1', '2']); + + await vi.advanceTimersByTimeAsync(3_000); + expect(fetchCount('/items')).toBe(1); + expect(value.items.map((i: any) => i.id)).toEqual(['1', '2']); + }); + it('does not abort a fetch still in flight when its stagger slot comes up', async () => { mockFetch.reset(); mockFetch.get('/a', () => ({ n: ++counter })); From 281e4281b204ca74577688af840240f491aebe02 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 19:49:35 -0400 Subject: [PATCH 071/109] test(fetchium): cover the async write order of a created child and its parent With an AsyncQueryStore writer, the parent's record must not reference a child created by an event until the child's record has been written. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/event-persistence.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index cc771de..1cd529b 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -1541,6 +1541,65 @@ describe('AsyncQueryStore writer', () => { expect(first._pendingWrites).toBe(0); expect(first._persisted).toBe(true); }); + + it('does not write a parent referencing a created child before the child is written', async () => { + class Item extends Entity { + __typename = t.typename('Item'); + id = t.id; + listId = t.string; + title = t.string; + } + class List extends Entity { + __typename = t.typename('List'); + id = t.id; + items = t.liveArray(Item, { constraints: { listId: (this as any).id } }); + } + class GetList extends RESTQuery { + path = '/list'; + result = { list: t.entity(List) }; + } + + // Holds the write of one record until released. + class GatedDelegate extends AsyncDelegate { + gatedKey: string | undefined; + gate: Promise | undefined; + override async setString(key: string, value: string) { + if (key === this.gatedKey) await this.gate; + return super.setString(key, value); + } + } + + const delegate = new GatedDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/list', { list: { __typename: 'List', id: 'l1', items: [] } }); + const client = makeClient(store, mockFetch); + await holdQuery(client, () => fetchQuery(GetList)); + await drain(store); + + const listKey = K('List', 'l1'); + const itemKey = K('Item', 'i1'); + let release = () => {}; + delegate.gate = new Promise(resolve => (release = resolve)); + delegate.gatedKey = valueKeyFor(itemKey); + + client.applyMutationEvent({ + type: 'create', + typename: 'Item', + data: { __typename: 'Item', id: 'i1', listId: 'l1', title: 'new' }, + }); + await sleep(50); + + // The child's write is held, so a cache read now must not find the parent pointing at it. + expect(asyncDoc(delegate, itemKey)).toBeUndefined(); + expect(asyncDoc(delegate, listKey)).toMatchObject({ items: [] }); + expect(Array.from((delegate.kv[refIdsKeyFor(listKey)] as Uint32Array | undefined) ?? [])).toEqual([]); + + release(); + await drain(store); + expect(asyncDoc(delegate, itemKey)).toMatchObject({ title: 'new' }); + expect(asyncDoc(delegate, listKey)).toMatchObject({ items: [{ __entityRef: itemKey }] }); + }); }); // ====================================================== From e1ddbafda23324127047045c92c5ab975fecdcda Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 21:20:17 -0400 Subject: [PATCH 072/109] docs(fetchium): shorten the entityFieldNamesComplete description Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/app/api/stores-sync/page.md | 32 ++++++++++++++-------------- packages/fetchium/src/stores/sync.ts | 15 +++++-------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index 45136ee..f43cdda 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,22 +32,22 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | -| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | -| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity whose record may hold fields of another class of its typename, so that write keeps them. | -| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | -| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | -| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every class that wrote a record the store holds. The first open by this version notes when it started remembering (`sq:meta:fieldsSince`): the current time, whether or not an earlier version wrote the store (telling would take a scan of every key at startup). `clear()` sets it to 0. Answers `true` for 0, or 30 days later. Until then the client merges into its record an entity that streamed events built with every field of its class, rather than writing it whole, so a record an earlier version wrote keeps the fields of a class not registered since. | -| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. | +| Method | Signature | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set, rest?: string): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. `rest`, when given, is a JSON object body of fields the record already held that no class applied to the in-memory entity declares; it is written with the value so the write does not delete them. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | +| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | +| `readEntity` | `(entityKey: number): Record \| undefined` | The stored record of an entity, parsed, or `undefined`. The client reads it once, before the first write of an entity whose record may hold fields of another class of its typename, so that write keeps them. | +| `getEntityFieldNames` | `(typename: string): readonly string[] \| undefined` | The top-level field names the entity classes of `typename` declared, in this session or an earlier one (stored under `sq:meta:fields:`). A name no class has declared for 30 days is forgotten. | +| `addEntityFieldNames` | `(typename: string, fields: readonly string[]): void` | Records that a class of `typename` declares `fields`. Called once per class and client; writes only when a name is new or its time is over a day old. | +| `entityFieldNamesComplete` | `(): boolean` | Whether the remembered field names cover every record in the store. `false` for 30 days after this version first opens the store, since records an earlier version wrote may hold fields of a class not registered since. `true` after `clear()`. While `false`, an entity built from streamed events is merged into its stored record instead of replacing it. | +| `clear` | `(): void` | Deletes every cached query, entity record and queue (`sq:doc:` keys) and reports each record to `onDelete` listeners, so a client over the store drops the fields it kept from those records. Wipe the cache with this, not on the `kv` directly: a client is not told about records deleted under it, and its next write of an entity of a typename two classes share puts the other class's deleted fields back. Field names (`sq:meta:`) are kept. | --- diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index e4337d9..329c090 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -264,16 +264,11 @@ export class SyncQueryStore implements QueryStore { } /** - * Whether the remembered field names cover every class that wrote a - * record this store holds. Not while it holds records a version that did - * not remember field names wrote: they may hold fields of a class not - * registered since. When this version first opens a store it notes since - * when the names cover its records (`sq:meta:fieldsSince`, stored with the - * first field names it writes): from then, whether or not the store held - * cached data (finding out would take a scan of every key). `clear()` - * resets it to the start. 30 days on (`FIELD_NAME_TTL`), a class not - * declared since then would have been forgotten anyway, and the names - * count as complete. + * Whether the remembered field names cover every record this store holds. + * Records an earlier version wrote may hold fields of a class not registered + * since, so this is `false` for `FIELD_NAME_TTL` after this version first + * opens the store (`sq:meta:fieldsSince`), whether or not it was empty. + * `clear()` makes it `true`. */ entityFieldNamesComplete(): boolean { const at = this.fieldNamesSince().at; From bb1aae2e52e3da47a475605359bb160e5c31c3d8 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:43:17 -0400 Subject: [PATCH 073/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/__tests__/entity-snapshot.test.ts | 2 +- packages/fetchium/src/__tests__/store-consistency.test.ts | 4 ++-- packages/fetchium/src/__tests__/store-on-delete.test.ts | 6 +++--- packages/fetchium/src/stores/async.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index dc32f14..886f196 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -702,7 +702,7 @@ describe('Entity Snapshots', () => { class Container extends Entity { __typename = t.typename('Container'); id = t.id; - // The array member sits under the ARRAY_KEY symbol; this used to classify as static. + // The array member is keyed by the ARRAY_KEY symbol, not a string. content = t.union(t.array(t.entity(Item)), t.object({ __typename: t.typename('Empty'), reason: t.string })); } diff --git a/packages/fetchium/src/__tests__/store-consistency.test.ts b/packages/fetchium/src/__tests__/store-consistency.test.ts index 03e992e..e2ca769 100644 --- a/packages/fetchium/src/__tests__/store-consistency.test.ts +++ b/packages/fetchium/src/__tests__/store-consistency.test.ts @@ -53,7 +53,7 @@ describe('Store consistency: _persisted vs store cascade deletion', () => { await relay1; expect(getDocument(kv, userKey)).toBeDefined(); - // maxCount: 1 — the second key evicts Q1 and cascades to User 1. + // maxCount is 1, so the second key evicts Q1 and cascades to User 1. const relay2 = holdQuery(client, () => fetchQuery(GetProfile, { id: '2' })); await relay2; expect(getDocument(kv, userKey)).toBeUndefined(); @@ -166,7 +166,7 @@ describe('Store consistency: entity-array narrowing follows entity data', () => const user = holdQuery(client, () => fetchQuery(GetUser, { id: 'u-1' })); await user; - // u-1 now satisfies UserFull; the refetch returns an identical array. + // u-1 now satisfies UserFull, but the refetch returns an identical array. await (team.value as any).__refetch(); await sleep(5); expect(members()).toEqual(['u-1', 'u-2']); diff --git a/packages/fetchium/src/__tests__/store-on-delete.test.ts b/packages/fetchium/src/__tests__/store-on-delete.test.ts index 6334f49..b8e44ec 100644 --- a/packages/fetchium/src/__tests__/store-on-delete.test.ts +++ b/packages/fetchium/src/__tests__/store-on-delete.test.ts @@ -58,7 +58,7 @@ describe('QueryStore.onDelete', () => { store.saveQuery(queryDef('GET:/x', 1), q1, { __entityRef: e1 }, Date.now(), new Set([e1])); expect(deleted).toEqual([]); - // maxCount: 1 — q2 evicts q1, cascading to e1. + // maxCount is 1, so q2 evicts q1 and cascades to e1. store.saveQuery(queryDef('GET:/x', 1), q2, { __entityRef: 0 }, Date.now(), new Set()); expect(deleted).toEqual([q1, e1]); }); @@ -107,7 +107,7 @@ describe('QueryStore.onDelete', () => { await sleep(20); expect(deleted).toEqual([]); - // maxCount: 1 — the second key must evict the first, not wait for DEFAULT_MAX_COUNT. + // The def's maxCount of 1 applies, not DEFAULT_MAX_COUNT. writer.saveQuery(queryDef('GET:/z', 1), q2, { __entityRef: 0 }, Date.now(), new Set()); await sleep(20); expect(deleted).toEqual([q1]); @@ -198,7 +198,7 @@ describe('stores without onDelete', () => { await (query.value as any).__refetch(); await sleep(5); - // Deletions happen in the writer; the reader cannot know, so it keeps writing. + // Deletions happen in the writer, so the reader cannot skip writes. expect(saveEntity.mock.calls.length).toBe(afterLoad * 2); client.destroy(); }); diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 614a536..e8cf9e8 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -76,7 +76,7 @@ export class AsyncQueryStore implements QueryStore { private queueProcessorPromise?: Promise; private resolveQueueWait?: () => void; private deleteListeners: Array<(key: number) => void> = []; - // Only the writer sees deletions; a reader offers no hook, so the client writes every apply. + // Only the writer sees deletions. Readers leave this undefined, so their client writes every apply. onDelete?: (listener: (key: number) => void) => void; constructor(config: AsyncQueryStoreConfig) { From ada6ef4a529d0ee2813dd6966b9e8220398e2327 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:44:04 -0400 Subject: [PATCH 074/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/LiveCollection.ts | 2 +- .../src/__tests__/entity-write-counts.test.ts | 23 ++++++------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 9262243..5f13800 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -311,7 +311,7 @@ export class LiveArrayInstance { const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { - // Only the parent's ref set needs persisting; the child's record is current. + // Only the parent's ref set changed. The child's record is already current. this._parent.addChildRef(child); } diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index f9c93c2..70a6dd9 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -11,10 +11,8 @@ import { testWithClient, setupTestClient, sleep } from './utils.js'; /** * Entity Write Counts * - * A streamed entity event used to write the entity twice: once through - * `applyEntityRefs(persist: true)` and again through an unconditional save in - * `applyMutationEvent`. These pin the write count so the duplicate can't come - * back, and pin that a single write is still enough for a later client to read. + * Pins how many store writes a streamed entity event causes, and that a single + * write is enough for a later client to read. */ class Item extends Entity { @@ -74,8 +72,6 @@ describe('Entity Write Counts', () => { const saveEntity = vi.spyOn(store, 'saveEntity'); - // A subscription re-broadcasting what the store already holds: the apply - // declines the write, and nothing writes on its behalf afterwards. for (let i = 0; i < 10; i++) { client.applyMutationEvent({ type: 'update', typename: 'Item', data: { id: 'i-1', name: 'A' } }); } @@ -131,10 +127,9 @@ describe('Entity Write Counts', () => { }); await sleep(5); - // The apply writes the new child once. The parent is written because its - // ref set changed. Neither should write the child a second time. + // The apply writes the new child. The live-array insert must not write it again. expect(itemSaves(saveEntity)).toHaveLength(1); - // The parent's ref set genuinely changed, so it is written — once. + // The parent's ref set changed, so it is written once. expect(itemSaves(saveEntity, 'List')).toHaveLength(1); }); @@ -153,8 +148,7 @@ describe('Entity Write Counts', () => { }); const saveEntity = vi.spyOn(store, 'saveEntity'); - // The item is already current in the store from the first query, so the - // apply declines its write. The insert must not put one back. + // i-1 is already current in the store, so the apply skips its write. The insert must not add one. client.applyMutationEvent({ type: 'update', typename: 'Item', @@ -181,9 +175,7 @@ describe('Entity Write Counts', () => { await sleep(5); client.destroy(); - // One write has to be enough for the read path to find it, not just enough - // to put bytes on disk. Serve something different so a value of - // 'Persisted' can only have come from the store. + // Serve a different value, slowly, so 'Persisted' can only come from the store. mockFetch.reset(); mockFetch.get( '/items', @@ -198,8 +190,7 @@ describe('Entity Write Counts', () => { await testWithClient(client2, async () => { const query = fetchQuery(GetItems); - // Force a pull so the store hydration starts. The refetch is far slower - // than this tick, so only the store read can satisfy the assertion. + // Pull the value to start store hydration. void query.value; await sleep(); expect((query.value as unknown as { items: { name: string }[] }).items[0].name).toBe('Persisted'); From fcfadea61564fa1640641dc1512f6da4bd408840 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:44:36 -0400 Subject: [PATCH 075/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryAdapter.ts | 4 +-- packages/fetchium/src/QueryClient.ts | 32 +++++++++--------- packages/fetchium/src/QueryResult.ts | 33 +++++++++---------- .../src/__tests__/poll-activity.test.ts | 6 ++-- .../src/__tests__/reactivation-grace.test.ts | 10 +++--- .../__tests__/topic-batch-delivery.test.ts | 7 ++-- packages/fetchium/src/query-types.ts | 8 ++--- .../__tests__/pause-reactivation.test.tsx | 5 ++- .../fetchium/src/subscriptions/polling.ts | 9 +++-- .../fetchium/src/topic/TopicQueryAdapter.ts | 8 ++--- 10 files changed, 55 insertions(+), 67 deletions(-) diff --git a/packages/fetchium/src/QueryAdapter.ts b/packages/fetchium/src/QueryAdapter.ts index 79506cc..1d1953d 100644 --- a/packages/fetchium/src/QueryAdapter.ts +++ b/packages/fetchium/src/QueryAdapter.ts @@ -38,8 +38,8 @@ export abstract class QueryAdapter { /** * Set when the adapter merges requests sent close together into one (for * example a multi-topic snapshot request). `reactivationStaggerMs` then - * starts this adapter's reactivation refetches together, so they can still - * share a request, instead of spreading them apart. + * starts this adapter's reactivation refetches together so they can still + * share a request. */ declare readonly coalescesRequests?: boolean; diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 064d7dd..8cdb0b5 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -50,14 +50,13 @@ export interface QueryClientConfig { evictionMultiplier?: number; /** * Milliseconds. A query that reactivates (a watcher returns, or a paused - * scope resumes) with data younger than this is not refetched, even when the - * data is past its `staleTime`. Data a subscription pushed to (a `subscribe` - * stream event, or a topic event sent with its topic) counts as fresh from - * the last push; a `poll()` keeps data current only through the fetches it - * makes. Queries can override the window with `reactivationGraceMs` in - * their config. Network reconnects, - * `refetch()`, `invalidateQueries()`, `markStale()` and a failed last fetch - * still refetch. Default: 0 (every stale query refetches on reactivation). + * scope resumes) with data younger than this is not refetched, even if past + * its `staleTime`. Data a subscription pushed to (a `subscribe` event, or a + * topic event sent with its topic) counts as fresh from the last push. A + * `poll()` counts only through its fetches. Queries can override this with + * `reactivationGraceMs`. Network reconnects, `refetch()`, + * `invalidateQueries()`, `markStale()` and a failed last fetch still + * refetch. Default: 0 (every stale query refetches on reactivation). */ reactivationGraceMs?: number; /** @@ -75,11 +74,10 @@ export interface QueryClientConfig { */ activity?: ActivitySource; /** - * Milliseconds. A `poll()` tick that is overdue when the app becomes active - * again (or whose timer fires more than a second late, as happens when the - * JS thread was suspended in the background) is rescheduled at a random - * point within this window rather than firing immediately alongside every - * other overdue poll. Default: 0 (overdue ticks fire immediately). + * Milliseconds. A `poll()` tick that is overdue when the app becomes active, + * or whose timer fires over a second late (as after the JS thread was + * suspended), fires at a random point within this window instead of + * immediately alongside every other overdue poll. Default: 0. */ pollResumeJitterMs?: number; } @@ -491,10 +489,10 @@ export class QueryClient { // ====================================================== /** - * Queues a reactivation refetch. Everything queued in the same task is - * started from one flush, spread evenly across `reactivationStaggerMs` in - * the order the queries reactivated, so the first one starts right away. - * Queries whose adapter `coalescesRequests` all start right away instead. + * Queues a reactivation refetch. One flush per task spreads the queue evenly + * across `reactivationStaggerMs` in reactivation order, the first starting + * immediately. Queries of an adapter that `coalescesRequests` all start + * immediately. */ scheduleReactivationRefetch(instance: QueryInstance): void { this.staggerQueue.add(instance); diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index eb31cb7..7748c08 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -57,16 +57,14 @@ export class QueryInstance { /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; /** - * When the running subscription last delivered data: a stream pushed an - * event, or a topic adapter delivered one for this query's topic. The data - * was known current at that moment, so the reactivation grace measures from - * here when it is later than `updatedAt`. A subscription that delivers by - * refetching (poll()) moves `updatedAt` instead. Merely having a - * subscription running proves nothing: a poll that hasn't ticked yet, or one - * stopped while the app was in the background, kept nothing current. + * When the subscription last pushed data (a stream event, or a topic event + * for this query's topic). The reactivation grace measures from here when + * it is later than `updatedAt`. poll() delivers by refetching, so it moves + * `updatedAt` instead. A running subscription alone proves nothing: a poll + * that hasn't ticked, or one stopped in the background, kept nothing current. */ private lastPushAt: number | undefined = undefined; - /** Counts fetches started by runQueryImmediately(). */ + /** Bumped by every fetch or fetchNext start. A queued reactivation refetch uses it to detect it was overtaken. */ private fetchStarts: number = 0; /** `fetchStarts` when the pending reactivation refetch was queued. */ private reactivationQueuedAt: number = -1; @@ -211,7 +209,7 @@ export class QueryInstance { } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; // The grace covers a relay resuming, not a network reconnect: data - // may have been missed while offline, including while inactive. + // may have been missed while offline, even while inactive. const withinGrace = activating && !wasPaused && @@ -497,12 +495,11 @@ export class QueryInstance { } /** - * Starts a reactivation refetch after `delay` (plus the query's debounce). - * Called by the QueryClient's stagger flush, a task after the query was - * queued, so it rechecks that the query is still active. A fetch started - * since the query was queued (a `refetch()`, a poll tick, an invalidation), - * whether still in flight or already done, makes it redundant: it is - * skipped rather than aborting and repeating that fetch. + * Starts a reactivation refetch after `delay` plus the query's debounce. + * The stagger flush calls this a task after queueing, so it rechecks that + * the query is still active. Any fetch started since queueing (`refetch()`, + * a poll tick, an invalidation) makes it redundant, so it is skipped rather + * than aborting and repeating that fetch. */ runReactivationRefetch(delay: number): void { if (!this._isActive || this.isPaused || this.relayState.isPending) return; @@ -630,9 +627,9 @@ export class QueryInstance { } /** - * Data is younger than the reactivation grace, and the last fetch didn't - * fail. Data a subscription pushed to counts as fresh from its last push. - * Invalidation (`updatedAt = 0`) always falls outside. + * Data is younger than the reactivation grace and the last fetch didn't + * fail. Pushed data counts from its last push. Invalidation (`updatedAt = 0`) + * always falls outside. */ private get isWithinReactivationGrace(): boolean { const { updatedAt } = this; diff --git a/packages/fetchium/src/__tests__/poll-activity.test.ts b/packages/fetchium/src/__tests__/poll-activity.test.ts index acd3dd0..53c82eb 100644 --- a/packages/fetchium/src/__tests__/poll-activity.test.ts +++ b/packages/fetchium/src/__tests__/poll-activity.test.ts @@ -225,13 +225,13 @@ describe('poll() with an activity source', () => { const client = makeClient({ pollResumeJitterMs: 300 }); await mount(client, GetPolled); - // The clock jumps 10s while no timer runs, then the overdue timer fires. + // Jump the clock 10 s without running timers, as a frozen JS thread would. vi.setSystemTime(Date.now() + 10_000); const jumpedAt = Date.now() - t0; - // The frozen timer fires at its scheduled point (~INTERVAL - 5 from here) but doesn't fetch. + // The timer fires at its scheduled point (~INTERVAL - 5 from here) but doesn't fetch. await vi.advanceTimersByTimeAsync(INTERVAL); expect(count()).toBe(0); - // It is rescheduled at the jitter point instead: 0.5 * 300. + // It fetches at the jitter point instead: 0.5 * 300. await vi.advanceTimersByTimeAsync(200); expect(count()).toBe(1); expect(Math.abs(starts[0].at - jumpedAt - (INTERVAL - 5 + 150))).toBeLessThanOrEqual(1); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 0a59349..00755d4 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -16,10 +16,9 @@ import { Entity } from '../proxy.js'; import { createMockFetch, createTestWatcher } from './utils.js'; /** - * Reactivation grace and stagger: a query whose relay resumes (its watchers - * return, or a paused scope resumes) skips the refetch while its data is - * younger than `reactivationGraceMs`, and reactivation refetches that start - * together are spread across `reactivationStaggerMs`. + * A reactivating query skips its refetch while its data is younger than + * `reactivationGraceMs`. Reactivation refetches that start together are spread + * across `reactivationStaggerMs`. */ class GetA extends RESTQuery { @@ -335,8 +334,7 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - // Still subscribed, but silent: the credit stops at the last push, not - // at the deactivation. + // Silent since: the grace counts from the last push, not the deactivation. await vi.advanceTimersByTimeAsync(20_000); unsub2(); await vi.advanceTimersByTimeAsync(10); diff --git a/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts b/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts index 5944c31..9aa4470 100644 --- a/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts +++ b/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts @@ -10,10 +10,9 @@ import { t } from '../typeDefs.js'; import { sleep } from './utils.js'; /** - * Several topic snapshots delivered in one task (for example one multi-topic - * snapshot response) settle their queries in a single reactive flush, so - * consumers re-render once rather than once per topic. Snapshots delivered in - * separate tasks flush separately; batching them is the adapter's job. + * Topic snapshots delivered in one task (for example one multi-topic snapshot + * response) settle their queries in a single reactive flush. Snapshots from + * separate tasks flush separately. Batching those is the adapter's job. */ class SnapshotAdapter extends TopicQueryAdapter { diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index a4246ea..79b0c29 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -64,10 +64,10 @@ export interface QueryConfigOptions { refreshStaleOnReconnect?: boolean; // default: true /** * Milliseconds. When the query reactivates (a watcher returns, or a paused - * scope resumes) and its data is younger than this, it is not refetched even - * if stale. Data a subscription pushed to (a stream event, or a topic event - * sent with its topic) counts as fresh from the last push. Overrides `QueryClientConfig.reactivationGraceMs`. Does - * not affect network reconnects, `refetch()`, or invalidation. + * scope resumes) with data younger than this, it is not refetched even if + * stale. Data a subscription pushed to counts as fresh from the last push. + * Overrides `QueryClientConfig.reactivationGraceMs`. Does not affect network + * reconnects, `refetch()`, or invalidation. */ reactivationGraceMs?: number; subscribe?: (this: any, onEvent: (event: import('./types.js').MutationEvent) => void) => () => void; diff --git a/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx b/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx index 932ccd7..5ad3bc0 100644 --- a/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx +++ b/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx @@ -11,9 +11,8 @@ import { createMockFetch, sleep } from '../../__tests__/utils.js'; import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; /** - * A screen hidden behind `PauseSignalsProvider` pauses its queries; resuming - * it reactivates them. With `reactivationGraceMs`, queries whose data is - * younger than the grace resume without refetching. + * Resuming a screen paused by `PauseSignalsProvider` reactivates its queries. + * With `reactivationGraceMs`, data younger than the grace is not refetched. */ class GetItem extends RESTQuery { diff --git a/packages/fetchium/src/subscriptions/polling.ts b/packages/fetchium/src/subscriptions/polling.ts index 3cee9b6..d678869 100644 --- a/packages/fetchium/src/subscriptions/polling.ts +++ b/packages/fetchium/src/subscriptions/polling.ts @@ -4,10 +4,9 @@ import type { QueryContext } from '../query-types.js'; const MIN_INTERVAL = 100; /** - * A tick firing this much later than scheduled means its timer was frozen - * (React Native suspends JS timers in the background), so every other poll is - * overdue too. With a resume jitter configured, such a tick is spread instead - * of fired. A busy JS thread delays timers by far less. + * A tick this late means its timer was frozen (React Native suspends JS timers + * in the background), so every other poll is overdue too and the tick gets + * jittered. A busy JS thread delays timers by far less. */ const LATE_TICK_THRESHOLD = 1000; @@ -66,7 +65,7 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE const tick = async (): Promise => { timer = undefined; if (!active) return; - // Inactive: leave the tick overdue; the activity listener reschedules it. + // Leave the tick overdue. The activity listener reschedules it on resume. if (!isAppActive()) return; if (jitterWindow > 0 && Date.now() - dueAt > LATE_TICK_THRESHOLD) { scheduleOverdue(); diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index 097b55e..a4c278e 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -131,10 +131,8 @@ export abstract class TopicQueryAdapter extends QueryAdapter { * Convenience wrapper — pushes a mutation event through the QueryClient * so that entities and live collections are updated reactively. * - * Pass the `topic` the event was delivered on to tell that topic's queries - * their subscription is live and current. With the client's - * `reactivationGraceMs`, a query that reactivates within the grace of the - * last such event is not refetched. + * Pass the `topic` the event arrived on to mark that topic's queries as + * current, so their `reactivationGraceMs` measures from this event. */ protected sendMutationEvent(event: MutationEvent, topic?: string): void { if (topic !== undefined) { @@ -146,7 +144,7 @@ export abstract class TopicQueryAdapter extends QueryAdapter { /** * Registers a subscribed query's callback for events delivered on `topic`. - * Returns its removal. + * Returns a function that removes it. * * @internal */ From 19d9d68a0567c02f81a27b517ed468fc60c31377 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:44:49 -0400 Subject: [PATCH 076/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 56 +++++++++---------- packages/fetchium/src/LiveCollection.ts | 11 ++-- .../src/__benchmarks__/snapshot.bench.ts | 6 +- .../__tests__/entity-change-detection.test.ts | 7 +-- .../src/__tests__/entity-snapshot.test.ts | 10 ++-- packages/fetchium/src/applyEntities.ts | 43 +++++++------- 6 files changed, 63 insertions(+), 70 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 90de39a..c13e8f7 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -100,8 +100,8 @@ const objectWrappingHandler: ProxyHandler> = { // React then never re-renders on data changes because the proxy reference is // stable. Reading the entity's fields establishes reactive dependencies on the // entity's notifier and produces a plain-object snapshot whose unchanged -// subtrees keep stable references. Fields come from each proxy's -// `EntitySnapshotSource`, not the proxy, which read every field twice. +// subtrees keep stable references. Fields are read through each proxy's +// `EntitySnapshotSource`, since walking the proxy reads every field twice. // ====================================================== /** Trap-free access to one entity proxy's fields, registered by `createProxy`. */ @@ -247,9 +247,9 @@ function walkFields( const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unknown => { const source = snapshotSources.get(current); - // An `Entity` that never went through `createProxy` has no fields to read — - // its own properties are the shape's type defs. Hand it back untouched, the - // way Signalium treats any class it has no handler for. + // An `Entity` not built by `createProxy` has no fields to read (its own + // properties are the shape's type defs). Return it as-is, as Signalium does + // for any class it has no handler for. if (source === undefined) return current; // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -268,7 +268,7 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno } if (origin !== undefined && origin.version === version && origin.keys === keys) { - // `data` is untouched, so only fields living elsewhere need re-reading. + // `data` is untouched, so only dynamic fields need re-reading. const before = prevObj!; const patch: Record = {}; walkFields(source, keys.dynamic, before, snap, patch); @@ -304,14 +304,15 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno }; /** - * Guards the fast path's assumptions: `data` only changes through a `notify()` + * Checks the fast path's assumptions: `data` only changes through a `notify()` * that bumps `version`, and every field outside `keys.dynamic` is a pure - * function of `data`. Either breaking strands a snapshot permanently, which no - * behavioral test catches. Skipped fields only — re-reading a dynamic field is - * not identity-stable, since snapshotting a child updates state it pairs on. + * function of `data`. A violation leaves a snapshot permanently stale, which no + * behavioral test catches. Only static fields are checked: re-reading a dynamic + * field isn't identity-stable, because snapshotting a child updates the state + * it pairs on. * - * This costs dev builds the read the fast path saved, so the speedup shows up - * in production builds only. + * The re-read cancels the fast path's savings, so the speedup only shows in + * production builds. */ function assertStaticFieldsUnchanged( source: EntitySnapshotSource, @@ -322,7 +323,7 @@ function assertStaticFieldsUnchanged( const verified: Record = {}; const readsBefore = __debug_snapshotFieldReads; walkFields(source, keys.static, before, snap, verified); - // This walk is verification, not work the fast path did. + // Verification reads don't count against the fast path. __debug_snapshotFieldReads = readsBefore; for (const key of keys.static) { if (verified[key] !== before[key]) { @@ -513,17 +514,16 @@ function filterEntityArray(array: unknown[], innerDef: ValidatorDef, qu } // ====================================================== -// Static-field analysis — which fields a version check covers +// Static-field analysis (which fields a version check covers) // ====================================================== /** * Masks for a field that can change without its owning entity notifying. * - * No `UNION` bit, but not because the mask covers it: `defineUnion` ORs its - * members' *top-level* masks, so `t.union(t.entity(A), …)` does carry `ENTITY` - * while `t.union(t.object({ child: t.entity(A) }), …)` does not. What catches - * the nested case is `computeIsStaticFieldDef` recursing into the union's - * member defs. Short-circuiting a union on its mask alone would go stale. + * `defineUnion` ORs only its members' top-level masks, so + * `t.union(t.object({ child: t.entity(A) }), …)` carries no `ENTITY` bit. + * `computeIsStaticFieldDef` catches that by recursing into member defs. Never + * short-circuit a union on its mask alone. * * `HAS_FORMAT` because a formatted value's snapshot can go through a custom * snapshot handler that reads other signals (a locale, say). @@ -533,10 +533,10 @@ const DYNAMIC_MASKS = Mask.ENTITY | Mask.LIVE | Mask.HAS_FORMAT; const staticFieldDefs = new WeakMap, boolean>(); /** - * Is a field's snapshot a pure function of its entity's own `data`? One-sided: - * only the shapes recognised here answer `true`, mirroring the allowlist - * `getEntityDef` validates against, so an unfamiliar def costs a read rather - * than serving a stale value. + * Whether a field's snapshot is a pure function of its entity's own `data`. + * Only the shapes recognised here (the allowlist `getEntityDef` validates + * against) answer `true`, so an unfamiliar def costs a re-read, never a stale + * value. */ function isStaticFieldDef(def: unknown): boolean { // `t.string` is a bare `Mask`, `t.typename('X')` the literal string, @@ -548,8 +548,8 @@ function isStaticFieldDef(def: unknown): boolean { const cached = staticFieldDefs.get(def); if (cached !== undefined) return cached; - // Break cycles as dynamic; a def only ever reached inside one keeps that - // answer, which costs a read and never goes stale. + // Treat cycles as dynamic. A def reached only through one keeps that answer, + // which costs a read but never goes stale. staticFieldDefs.set(def, false); const isStatic = computeIsStaticFieldDef(def); @@ -620,7 +620,7 @@ function shapeKeys( return keys; } -/** Plus a query's late-attached methods and getters, always dynamic. */ +/** Adds a query's late-attached methods and getters, which are always dynamic. */ function withExtraKeys( base: EntityKeys, extraMethods: Record | undefined, @@ -648,7 +648,7 @@ function withExtraKeys( } // ====================================================== -// Field readers — module level so each proxy holds a pointer, not a closure +// Field readers (module level so proxies share them, not per-proxy closures) // ====================================================== function bindMethod( @@ -750,7 +750,7 @@ function createProxy( let keys = baseKeys; function entityKeys(): EntityKeys { - // Both slots: these lists decide what a snapshot walks and re-reads. + // Track both slots, since these lists decide what a snapshot walks and re-reads. const methodsNow = instance._extraMethods; const gettersNow = instance._extraGetters; if (methodsNow !== cachedMethods || gettersNow !== cachedGetters) { diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index aaf6654..c83ce3c 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -75,9 +75,9 @@ export interface LiveCollectionParent { export interface LiveInstance { getValue(): unknown; getRawValue(): unknown; - /** Returns whether the value/membership actually changed. */ + /** Returns whether the value or membership changed. */ reset(raw: unknown): boolean; - /** Returns whether anything was actually added. */ + /** Returns whether anything was added. */ append(raw: unknown): boolean; onEvent( entityKey: number, @@ -463,10 +463,9 @@ export class LiveValueInstance { } /** - * Shaped differently from `LiveArrayInstance.reset`, which returns before - * touching anything: the dedup sets have to be cleared either way, or a - * repeated `create` would apply its reducer twice. Only the notify is - * conditional. + * Unlike `LiveArrayInstance.reset`, this can't return early: the dedup sets + * must be cleared even when the value is unchanged, or a repeated `create` + * would apply its reducer twice. Only the notify is conditional. */ reset(value: unknown): boolean { const changed = this._value !== value; diff --git a/packages/fetchium/src/__benchmarks__/snapshot.bench.ts b/packages/fetchium/src/__benchmarks__/snapshot.bench.ts index 5457192..bea5240 100644 --- a/packages/fetchium/src/__benchmarks__/snapshot.bench.ts +++ b/packages/fetchium/src/__benchmarks__/snapshot.bench.ts @@ -118,9 +118,9 @@ for (const { count, client, pull } of fixtures) { }); } -// Whole cold path — client construction, fetch, parse, apply, first snapshot. -// The snapshot is a small part of it, and the samples are noisy (±10-20% rme), -// so don't read a snapshot change off this. +// Whole cold path: client construction, fetch, parse, apply, first snapshot. +// The snapshot is a small part of it and samples are noisy (±10-20% rme), so +// don't read a snapshot change off this. describe('cold query, first snapshot included', () => { for (const count of [100, 1000]) { bench(`${count} entities`, async () => { diff --git a/packages/fetchium/src/__tests__/entity-change-detection.test.ts b/packages/fetchium/src/__tests__/entity-change-detection.test.ts index 9a61774..8c39119 100644 --- a/packages/fetchium/src/__tests__/entity-change-detection.test.ts +++ b/packages/fetchium/src/__tests__/entity-change-detection.test.ts @@ -10,9 +10,8 @@ import { testWithClient, setupTestClient } from './utils.js'; /** * Entity Change Detection Tests * - * A refetch or a poll that returns data the store already holds should be a - * no-op: no consumer is notified and nothing is written. These tests pin that, - * and the flip side — that a real change still notifies and still persists. + * A refetch or poll that returns data the store already holds notifies no one + * and writes nothing. A real change still notifies and persists. */ /** Records `typename:id` for every entity that notifies while active. */ @@ -403,7 +402,7 @@ describe('Entity Change Detection', () => { (query.value as unknown as { __refetch(): void }).__refetch(); await query; - // Copying key by key never applied a removal, so `b` used to survive. + // A key-by-key copy can't apply a removal, so `b` would survive one. expect((query.value as unknown as Result).config.attrs).toEqual({ a: '1' }); expect(recorder.notified).toEqual(['Config:c-1']); }); diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index b6b3e9d..fe7d8a3 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -424,9 +424,9 @@ describe('Entity Snapshots', () => { // Shapes modelled on a real schema: primitive unions and enums collapse to // bare masks, while an entity can sit behind optional/nullable wrappers, - // inside a plain object, or inside an array. Only the entity-bearing - // fields may be re-read from a version short-circuit, so a misjudgement - // here shows up as a nested entity update that never reaches the parent. + // inside a plain object, or inside an array. The fast path re-reads only + // entity-bearing fields, so misclassifying one shows up as a nested entity + // update that never reaches the parent. const NumberLike = t.union(t.string, t.number); const OptionalNullableString = t.optional(t.nullable(t.string)); @@ -561,8 +561,8 @@ describe('Entity Snapshots', () => { read(); // The root's own data didn't change, so it re-reads only its dynamic - // fields; of the 20 tokens only tok-7 is walked. Nothing here is asserted - // by the behavioral tests — losing the fast path keeps them all green. + // fields; of the 20 tokens only tok-7 is walked. The behavioral tests stay + // green without the fast path, so only these counts catch its loss. expect(__debug_snapshotFullWalks).toBe(1); expect(__debug_snapshotFieldReads).toBeLessThan(firstReads / 5); }); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 104269a..25d8967 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -119,8 +119,7 @@ function applyEntity( ? new Map(entityInstance.entityRefs) : new Map(); - // A refetch or a poll that returns identical data is a no-op: nothing to - // notify consumers about, and nothing new to write to the store. + // A refetch or poll that returns identical data neither notifies nor writes. let changed = true; if (isUpdate) { changed = mergeFields( @@ -159,7 +158,7 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); - // An entity hydrated from the store was never written, so there is no write to skip. + // An instance this client hasn't saved yet is written even when unchanged. const needsPersist = changed || refsChanged || !entityInstance._persisted; entityInstance.setChildRefs(newRefs, persist && needsPersist); @@ -221,11 +220,9 @@ function mergeFields( // payload fails validation, so every update carries the full variant. const isUnion = propShape instanceof ValidatorDef && (propShape.mask & Mask.UNION) !== 0; if (!isUnion && isPlainObject(newVal) && isPlainObject(oldVal)) { - // Only an object/entity def carries a record of field defs. A record - // def's shape is its *value* type — a bare `Mask` for `t.record(t.string)` - // — and recursing into that iterated `Object.entries(8)`, merged - // nothing, and then restored the old value: record fields never - // applied an update, an addition or a removal. + // Only object/entity defs have a record of field defs as their shape. + // A record def's shape is its value type (a bare `Mask` for + // `t.record(t.string)`), which must not be recursed into as fields. const nestedShape = propShape instanceof ValidatorDef && propShape.shape !== undefined && @@ -254,8 +251,8 @@ function mergeFields( } existingData[fieldKey] = oldVal; } else if (sameKeys(oldVal, newVal)) { - // Shapeless object (e.g. a record) with the same keys: copy field by field, keeping the - // existing value — and its identity — wherever it already matches. + // Shapeless object (e.g. a record) with the same keys: copy only the + // fields that differ, so matching values keep their identity. for (const k of Object.keys(newVal)) { if (!sameValue(oldVal[k], newVal[k])) { oldVal[k] = newVal[k]; @@ -264,13 +261,13 @@ function mergeFields( } existingData[fieldKey] = oldVal; } else { - // A key was added or removed. Copying field by field would never - // apply a removal, leaving the old key in `data` indefinitely. + // A key was added or removed. Replace wholesale, since copying field + // by field can't apply a removal. existingData[fieldKey] = newVal; changed = true; } } else if (!sameValue(oldVal, newVal)) { - // Otherwise the existing value stays, so its identity survives. + // An equal value keeps the existing reference. existingData[fieldKey] = newVal; changed = true; } @@ -300,8 +297,8 @@ export function sameValue(a: unknown, b: unknown): boolean { if (a instanceof FormattedValue || b instanceof FormattedValue) { return a instanceof FormattedValue && b instanceof FormattedValue && a._raw === b._raw; } - // Distinct proxies are distinct entities, and anything else exotic (a Date, a - // live collection binding, a class instance) is not safely comparable. + // Distinct proxies are distinct entities. Other exotic objects (a Date, a + // live collection binding, a class instance) fail the prototype check below. if (PROXY_ID.has(a) || PROXY_ID.has(b)) return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; @@ -315,8 +312,8 @@ export function sameValue(a: unknown, b: unknown): boolean { if (aKeys.length !== Object.keys(b).length) return false; for (let i = 0; i < aKeys.length; i++) { const key = aKeys[i]; - // `hasOwn`, not `in`: `in` reaches the prototype, so a key missing from `b` - // can be answered by `Object.prototype` and compared against instead. + // `hasOwn`, not `in`: `in` would let `Object.prototype` answer for a key + // missing from `b`. if (!Object.hasOwn(b, key)) return false; if (!sameValue((a as Record)[key], (b as Record)[key])) return false; } @@ -326,8 +323,8 @@ export function sameValue(a: unknown, b: unknown): boolean { /** * Compares the ref *key set*, which is what a write would change: the store * persists `refKeys` with no counts, and `setChildRefs` retains and releases on - * a key appearing or disappearing. Comparing counts would force a write that - * produced byte-identical bytes. + * a key appearing or disappearing. Comparing counts would force a write of + * identical bytes. */ export function sameRefs( a: Map | undefined, @@ -376,11 +373,9 @@ function initFields( } else { const val = data[fieldKey]; if (isPlainObject(val)) { - // Only an object/entity def carries a record of field defs. A record - // def's shape is its *value* type — a bare `Mask` for `t.record(t.string)` - // — and recursing into that iterated `Object.entries(8)`, merged - // nothing, and then restored the old value: record fields never - // applied an update, an addition or a removal. + // Only object/entity defs have a record of field defs as their shape. + // A record def's shape is its value type (a bare `Mask` for + // `t.record(t.string)`), which must not be recursed into as fields. const nestedShape = propShape instanceof ValidatorDef && propShape.shape !== undefined && From c224b2027612e940217001d8854f372b55c4e883 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:45:05 -0400 Subject: [PATCH 077/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 26 +++++++------------ .../src/__tests__/caching-persistence.test.ts | 18 +++++-------- .../src/__tests__/sync-hydration.test.ts | 16 ++++-------- 3 files changed, 21 insertions(+), 39 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index d67c31c..d14ab55 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -258,18 +258,14 @@ export class QueryInstance { } /** - * Runs once, from the relay's first activation. That activation happens - * inside the read that watched the relay (for React, during render). + * Runs once, from the relay's first activation, inside the read that watched + * the relay (for React, during render). * - * When the store answers synchronously (SyncQueryStore), the cached value is - * applied right here, so the activating read, and the first render, already - * see it. Subscribing and fetching wait for the next microtask: both call - * into adapter and app code that may read or write signals, which must not - * run while the relay's computation is the current consumer. A microtask - * rather than a timer keeps the first fetch off the macrotask queue. - * - * With an asynchronous store (AsyncQueryStore), the cache resolves on a later - * tick, outside the activating read, and everything runs from there. + * A synchronous store's cached value is applied here, so the activating read + * already sees it. Subscribing and fetching wait a microtask, not a timer: + * they call adapter and app code that must not touch signals while the + * relay's computation is the current consumer. With an async store, the + * cache resolves on a later tick and everything runs from there. */ private initialize(): void { this.initialized = true; @@ -315,8 +311,7 @@ export class QueryInstance { if (this.updatedAt !== 0) this.updatedAt = cached.updatedAt; this.relayState.value = this.applyData(cached.value, false, false, cached.preloadedEntities); } catch (error) { - // Unusable entry: treat it as a miss so the query still fetches, instead - // of trusting a timestamp whose data was never applied. + // The data was never applied, so drop its timestamp and let the query fetch. this.updatedAt = undefined; this.discardCorruptCache(error); } @@ -324,8 +319,7 @@ export class QueryInstance { /** Starts the subscription, then the first fetch if there is no fresh cached value. */ private startSubscriptionAndFetch(): void { - // Deactivated in the meantime: update() fetches on reactivation, since the - // relay is still pending (or stale) with no fetch in flight. + // If deactivated meanwhile, update() fetches on reactivation. if (!this._isActive || this.isPaused) { return; } @@ -336,7 +330,7 @@ export class QueryInstance { // `send()` awaits. this.reconcileSubscription(); - // Skip if something already started a fetch since activation (refetch()). + // refetch() may have started a fetch since activation. const fetchInFlight = this.relayState.isPending && this._abortController !== undefined; if (this.isStale && !fetchInFlight) { this.runQueryImmediately(); diff --git a/packages/fetchium/src/__tests__/caching-persistence.test.ts b/packages/fetchium/src/__tests__/caching-persistence.test.ts index ca9ab3b..4e60cb5 100644 --- a/packages/fetchium/src/__tests__/caching-persistence.test.ts +++ b/packages/fetchium/src/__tests__/caching-persistence.test.ts @@ -230,15 +230,13 @@ describe('Caching and Persistence', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetItem, { id: '1' }); - // A synchronous store resolves the relay with the cached value in the - // read that activates it, so awaiting it yields the cached value. + // A synchronous store resolves the relay in the read that activates it. relay.value; expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ id: 1, name: 'Cached Data' }); expect(await relay).toMatchObject({ id: 1, name: 'Cached Data' }); - // The background refetch starts a microtask later; the cached value - // stays visible while it is in flight. + // The background refetch starts a microtask later, with the cached value still visible. await sleep(); expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ id: 1, name: 'Cached Data' }); @@ -276,15 +274,13 @@ describe('Caching and Persistence', () => { await testWithClient(client2, async () => { const relay = fetchQuery(GetItem); - // A synchronous store resolves the relay with the cached value in the - // read that activates it, so awaiting it yields the cached value. + // A synchronous store resolves the relay in the read that activates it. relay.value; expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ id: 1, value: 'Persistent' }); expect(await relay).toMatchObject({ id: 1, value: 'Persistent' }); - // The background refetch starts a microtask later; the cached value - // stays visible while it is in flight. + // The background refetch starts a microtask later, with the cached value still visible. await sleep(); expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ id: 1, value: 'Persistent' }); @@ -378,16 +374,14 @@ describe('Caching and Persistence', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetDocument); - // A synchronous store resolves the relay with the cached value in the - // read that activates it, so awaiting it yields the cached value. + // A synchronous store resolves the relay in the read that activates it. relay.value; expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); const result = await relay; expect(result).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); - // The background refetch starts a microtask later; the cached value - // stays visible while it is in flight. + // The background refetch starts a microtask later, with the cached value still visible. await sleep(); expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); diff --git a/packages/fetchium/src/__tests__/sync-hydration.test.ts b/packages/fetchium/src/__tests__/sync-hydration.test.ts index 0c42f39..dcf62a1 100644 --- a/packages/fetchium/src/__tests__/sync-hydration.test.ts +++ b/packages/fetchium/src/__tests__/sync-hydration.test.ts @@ -10,13 +10,10 @@ import { QueryDefinition } from '../query.js'; import { createMockFetch, sleep, testWithClient } from './utils.js'; /** - * Activation timing with a synchronous persistent store. + * Activation timing with a synchronous persistent store (e.g. MMKV on mobile). * - * When the store's `loadQuery` returns synchronously (MMKV on mobile), a - * freshly activated query must expose its cached value in the same read that - * activated it, so the first React render has data instead of a skeleton. - * The first network fetch must start on a microtask, not after a timer hop, - * and must never run inside the activating read. + * The activating read must see the cached value, so the first React render has + * data. The first fetch starts on a microtask, never inside the activating read. */ /** Same store, but loadQuery resolves on a later microtask (extension-style). */ @@ -51,9 +48,8 @@ async function seedCache(kv: MemoryPersistentStore, QueryClass: new () => RESTQu } /** - * Activates `read` inside a watched reactive context (the shape React's - * `useReactive` / `component()` render reads take) and returns what the - * activating read observed, plus a disposer. + * Activates `read` inside a watched reactive context, as React render reads do, + * and returns what the activating read observed plus a disposer. */ function activate(client: QueryClient, read: () => T): { first: T; dispose: () => void } { let first: T | undefined; @@ -121,8 +117,6 @@ describe('Activation with a synchronous store', () => { return { isReady: relay.isReady, isPending: relay.isPending, value: relay.value?.value }; }); - // The cached value is resolved, not pending, in the activating read, so a - // consumer that awaits the relay gets it immediately. expect(first).toEqual({ isReady: true, isPending: false, value: 'cached' }); // The fetch never runs inside the activating read... From 2dc709ebcf6181eff244031794ef883e2705b558 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:45:15 -0400 Subject: [PATCH 078/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/LiveCollection.ts | 5 +- packages/fetchium/src/QueryClient.ts | 74 +++++++++---------- packages/fetchium/src/QueryResult.ts | 53 +++++++------ .../__tests__/identical-redelivery.test.ts | 19 ++--- .../src/__tests__/read-path-latency.test.ts | 11 ++- .../src/__tests__/retry-should-retry.test.ts | 5 +- .../fetchium/src/react/use-suspense-query.ts | 10 +-- packages/fetchium/src/retry.ts | 9 ++- 8 files changed, 87 insertions(+), 99 deletions(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 673c54a..5e46539 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -450,9 +450,8 @@ export class LiveValueInstance { this._value = this._onDelete(this._value, entity ?? entityData); break; } - // Notify after every reducer run, even one that returns the value it was - // given: a reducer may mutate the value in place (push onto an array and - // return it), and a reference check can't tell that from a no-op. + // Notify even when the reducer returns the same reference: it may have + // mutated the value in place. this._notifier.notify(); } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 82dc8b0..de92617 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -67,9 +67,8 @@ export interface QueryClientConfig { * Decides whether a failed query attempt (or a mutation attempt, when the * mutation enables retries) is retried. Receives the error, the attempt index * (starting at 0) and the attempt's HTTP status when known. A query's or - * mutation's own `retry.shouldRetry` overrides it. Optional: without it, - * every failed attempt is retried, whatever its status. Use it to stop - * retrying errors you know are permanent, such as a 4xx from an endpoint. + * mutation's own `retry.shouldRetry` overrides it. Without it, every failed + * attempt is retried. Use it to stop retrying permanent errors such as a 4xx. */ shouldRetry?: ShouldRetry; /** @@ -183,13 +182,13 @@ export class QueryClient { /** Release functions of outstanding `retain()` / `prefetch()` leases. */ private leases = new Set<() => void>(); /** - * While a lease's callback first runs: the queries it reached that left - * their first fetch (or a zero-delay refetch) for a microtask. The lease - * starts them before returning. See `retain()`. + * Set during a lease's first run: the queries it reached whose first fetch + * (or zero-delay refetch) is queued on a microtask. The lease starts them + * before returning. See `retain()`. */ private leaseStarts: Set> | undefined = undefined; /** - * Leases taken by `retain()` calls inside a reactive computation, by the + * Leases taken by `retain()` inside a reactive computation, keyed by the * computation, with the run that took them. See `retain()`. */ private reactiveLeases = new WeakMap void> }>(); @@ -402,8 +401,8 @@ export class QueryClient { this.queryInstances.set(queryKey, queryInstance as QueryInstance); } - // Already active with its start still queued (a reader activated it earlier - // in this task): the lease starts it too. + // A reader may have activated it earlier in this task with its start still + // queued. The lease starts it too. this.leaseStarts?.add(queryInstance); return queryInstance.relay; @@ -436,16 +435,16 @@ export class QueryClient { * the query deactivates and its `gcTime` starts as usual. `release` is * idempotent. * - * The fetches the first run of `fn` needs start before `retain` returns: a - * request goes out inside the call, ahead of anything already queued on the - * microtask queue (such as a render React scheduled for the same tap). A - * query that depends on another's result (`fn` reads an id, then fetches by - * it) starts once that result arrives, as with any reader. Call `retain` - * from an event handler or effect, not from inside a reactive computation: - * starting a fetch runs adapter code, and a computation that reruns would - * take a new lease on every run. Called from one anyway, it warns in - * development, starts its fetches on their usual microtask, and releases the - * leases the computation's previous run took. + * The first run's fetches start before `retain` returns, ahead of anything + * already on the microtask queue (such as a render React scheduled for the + * same tap). A query that depends on another's result starts once that + * result arrives, as with any reader. + * + * Call `retain` from an event handler or effect, not a reactive computation: + * starting a fetch runs adapter code, and a rerunning computation would take + * a new lease each run. Called from one anyway, it warns in development, + * starts its fetches on their usual microtask, and releases the leases the + * computation's previous run took. */ retain(fn: () => unknown, options?: RetainOptions): () => void { const owner = currentReactiveOwner(); @@ -522,17 +521,16 @@ export class QueryClient { /** * Starts `QueryClass` with `params` now and keeps it active for `ttl` * milliseconds (default {@link DEFAULT_PREFETCH_TTL}), or until the returned - * `release` is called. The `ttl` is an upper bound: the lease goes when it - * runs out even if the fetch is still in flight (offline, or a topic that is - * never fulfilled), which aborts that fetch unless a reader has joined it. - * Meant for the moment a user commits to a navigation - * (a tap): the destination's reader, mounting within the window, reuses the - * in-flight or finished fetch instead of starting its own, and renders the - * data on its first render if it has arrived. + * `release` is called. Meant for the tap that commits to a navigation: the + * destination's reader, mounting within the window, reuses the in-flight or + * finished fetch and renders the data on its first render if it has arrived. + * + * The `ttl` is an upper bound. The lease ends even if the fetch is still in + * flight (offline, or a topic never fulfilled), which aborts it unless a + * reader has joined. * - * Cached data counts: with a synchronous store a cached, fresh result is - * applied without a request. A stale one is shown and refetched, as on any - * activation. + * With a synchronous store a fresh cached result is applied without a + * request. A stale one is shown and refetched, as on any activation. */ prefetch( QueryClass: new () => T, @@ -550,14 +548,14 @@ export class QueryClient { * never produced a value (a cold miss), or `undefined` when it has one to * render (in memory, or hydrated now from a synchronous store). * - * A cold miss takes a hold that keeps the query active while the render is - * suspended, since React discards a suspended render without subscribing. - * The reader's commit releases it (`releaseSuspenseHold`); otherwise it - * releases itself `SUSPENSE_HOLD_TTL` after the fetch settles. A query whose - * last fetch failed is refetched once per hold: when that attempt fails too, - * `error` is set and the hold is dropped, so the caller can throw it. A - * failure no render claims within `UNCLAIMED_FAILURE_TTL` (the suspended - * tree was abandoned) drops the hold, so a later mount makes a new attempt. + * A cold miss takes a hold that keeps the query active while suspended, + * since React discards a suspended render without subscribing. The reader's + * commit releases it (`releaseSuspenseHold`), or it releases itself + * `SUSPENSE_HOLD_TTL` after the fetch settles. A query whose last fetch + * failed is refetched once per hold. If that attempt fails too, `error` is + * returned for the caller to throw and the hold is dropped. A failure no + * render claims within `UNCLAIMED_FAILURE_TTL` also drops the hold, so a + * later mount makes a new attempt. * * @internal */ @@ -818,7 +816,7 @@ export class QueryClient { /** * Called by a query that queued its start (or a zero-delay refetch) on a - * microtask. During a lease's first run, the lease starts it before returning. + * microtask, so a lease in its first run can start it before returning. * * @internal */ diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 91a77f2..08884c9 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -212,9 +212,9 @@ export class QueryInstance { // If the relay shows pending but the abort controller is gone, the // previous fetch was aborted during deactivation (or never started). - // runDebounced() would bail out because isPending is still true from - // the doomed promise, which may also never settle (a topic query's - // send() waits on a subscription that is gone). + // runDebounced() would bail out on the doomed promise's isPending, + // and that promise may never settle (a topic query's send() waits on + // a subscription that is gone). if (this.relayState.isPending && this._abortController === undefined) { this.restartAbortedFetch(); } else { @@ -311,9 +311,9 @@ export class QueryInstance { * With an asynchronous store (AsyncQueryStore), the cache resolves on a later * tick, outside the activating read, and everything runs from there. * - * A lease (`retain()` / `prefetch()`) doesn't wait for the microtask: it - * calls startPendingNow() once its activating read has returned, so the - * request goes out inside the call, ahead of any render already queued. + * A lease (`retain()` / `prefetch()`) skips the microtask: it calls + * startPendingNow() after its activating read, so the request goes out ahead + * of any render already queued. */ private initialize(): void { this.initialized = true; @@ -353,9 +353,10 @@ export class QueryInstance { }; /** - * Runs, now, the start initialize() or a zero-delay runDebounced() left for - * a microtask, if it hasn't run yet. The microtask then does nothing. Called - * by a lease after its activating read, outside any reactive computation. + * Runs now any start that initialize(), a zero-delay runDebounced() or + * restartAbortedFetch() queued on a microtask, which then does nothing. + * Called by a lease after its activating read, outside any reactive + * computation. * * @internal */ @@ -477,8 +478,8 @@ export class QueryInstance { } const ctx = this.getOrCreateExecutionContext(); - // Put back a subscription a deactivation or pause tore down before - // sending: a topic query's send() waits for data its subscription brings. + // Restore a subscription that a deactivation or pause tore down: a topic + // query's send() waits for data its subscription brings. this.reconcileSubscription(); const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; @@ -517,9 +518,9 @@ export class QueryInstance { /** * Replaces a fetch that a deactivation aborted, from inside the activating - * read. The relay takes the new promise now, so the doomed one can no longer - * settle it, but the subscription and the request start on a microtask (or - * when a lease starts them), outside the read: both run adapter code. + * read. The relay takes the new promise now so the doomed one can't settle + * it. The subscription and request run adapter code, so they start outside + * the read, on a microtask or when a lease starts them. */ private restartAbortedFetch(): void { this.fetchStarts++; @@ -564,16 +565,15 @@ export class QueryInstance { * Starts a refetch after the query's `debounce` (plus `extraDelay`). Calls * made before it starts are coalesced into one fetch. * - * With no delay the fetch starts on a microtask rather than a timer. That - * still runs outside the reactive computation that asked for it (a relay - * update) and still coalesces every call made in the same task, but doesn't - * wait for the next macrotask. On React Native a zero timer goes through the - * native timing module and can wait up to a frame. + * With no delay the fetch starts on a microtask, not a timer. It still runs + * outside the reactive computation that asked for it and still coalesces + * calls from the same task, without waiting a macrotask (on React Native a + * zero timer can wait up to a frame). * * `nextTask` keeps the zero-delay fetch on a timer. Invalidation uses it: a - * query invalidated in the same task its last watcher left is still active - * until Signalium's deactivation flush, and the timer lets that flush cancel - * the fetch rather than start and abort it. + * query invalidated in the task its last watcher left stays active until + * Signalium's deactivation flush, and the timer lets that flush cancel the + * fetch instead of starting and aborting it. */ private runDebounced(extraDelay: number = 0, nextTask: boolean = false): void { if (this.relayState.isPending) return; @@ -739,11 +739,10 @@ export class QueryInstance { } /** - * Retry options that report the HTTP status of a failed attempt. Adapters - * that expose their response as `ctx.response` (RESTQueryAdapter does) set - * it before the body is parsed and validated, so an error response whose - * body fails validation surfaces as a schema error, not an HTTP error. A - * response assigned during the failed attempt supplies its status. + * Retry options that report a failed attempt's HTTP status. RESTQueryAdapter + * sets `ctx.response` before parsing and validating the body, so an error + * response with an invalid body throws a schema error that carries no + * status. Only a response assigned during the failed attempt counts. */ private attemptStatusTracker(ctx: Query): { start: () => void; options: WithRetryOptions } { const holder = ctx as unknown as { response?: unknown }; diff --git a/packages/fetchium/src/__tests__/identical-redelivery.test.ts b/packages/fetchium/src/__tests__/identical-redelivery.test.ts index 0afa3d9..6349ef2 100644 --- a/packages/fetchium/src/__tests__/identical-redelivery.test.ts +++ b/packages/fetchium/src/__tests__/identical-redelivery.test.ts @@ -14,13 +14,10 @@ import type { MutationEvent, QueryPromise } from '../types.js'; import { testWithClient, setupTestClient } from './utils.js'; /** - * Identical Re-delivery - * - * A stream reconnect re-snapshots every topic, so every entity on screen is - * re-delivered with the data the store already holds — as a query result, as a - * mutation event, or as a topic event. None of those may notify an entity, bump - * its version, write it, or notify a live collection. A real change still - * notifies exactly once. + * A stream reconnect re-delivers every on-screen entity with the data the + * store already holds, as a query result, mutation event or topic event. None + * may notify an entity, bump its version, write it, or notify a live + * collection. A real change notifies exactly once. */ /** Records `typename:id` for every entity notify. */ @@ -212,14 +209,12 @@ describe('Identical re-delivery', () => { expect(versions(client, keys)).toEqual(before); expect(saveEntity).not.toHaveBeenCalled(); expect(live.tokens).not.toHaveBeenCalled(); - // The live value's onUpdate reducer runs for the Token event and the - // value notifies: a reducer may mutate in place, so a returned identical - // reference doesn't prove nothing changed. + // The live value's reducer still runs and notifies, since it may mutate + // in place. expect(live.count).toHaveBeenCalledTimes(1); // A `create` for an entity the store already holds is the same no-op for - // the entity and the live array. (The live value's `onCreate` reducer - // still runs: it is told about a create, not about a change.) + // the entity and the live array. The live value's `onCreate` still runs. client.applyMutationEvent({ type: 'create', typename: 'Token', data: token(0) }); client.applyMutationEvent({ type: 'create', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); diff --git a/packages/fetchium/src/__tests__/read-path-latency.test.ts b/packages/fetchium/src/__tests__/read-path-latency.test.ts index 20b6878..e6f92aa 100644 --- a/packages/fetchium/src/__tests__/read-path-latency.test.ts +++ b/packages/fetchium/src/__tests__/read-path-latency.test.ts @@ -164,8 +164,8 @@ describe('read-path latency (macrotask turns)', () => { console.log( `[latency] param change -> fetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, ); - // A watched relay reruns its update in Signalium's flush (one timer); the - // fetch then starts on a microtask of that flush rather than another timer. + // The relay update reruns in Signalium's flush (one timer), and the fetch + // starts on a microtask of that flush. expect(turns).toBe(1); expect(mockFetch.calls[1].url).toContain('/users/2'); unsub(); @@ -197,10 +197,9 @@ describe('read-path latency (macrotask turns)', () => { unsub(); // Before the response (30 ms). await sleep(10); - // Signalium deactivates on its next flush (a timer), after the microtask - // that starts the refetch, so the refetch starts and deactivation cancels - // it. A mount and unmount within one task is the cost of not waiting a - // timer on every reactivation. + // Signalium deactivates on its next flush (a timer), after the refetch's + // microtask has started it. This wasted request is the trade-off for not + // waiting a timer on every reactivation. expect(mockFetch.calls).toHaveLength(1); expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); await sleep(30); diff --git a/packages/fetchium/src/__tests__/retry-should-retry.test.ts b/packages/fetchium/src/__tests__/retry-should-retry.test.ts index 9ded5a9..f0fb2d5 100644 --- a/packages/fetchium/src/__tests__/retry-should-retry.test.ts +++ b/packages/fetchium/src/__tests__/retry-should-retry.test.ts @@ -262,8 +262,7 @@ describe('shouldRetry', () => { }); it('does not pass an earlier response status for a later network error', async () => { - // Attempt 0 gets a 404 response; the later attempts fail with network - // errors, which must report no status rather than the earlier 404. + // Attempt 0 gets a 404, every later attempt a network error. const statuses: Array = []; client.destroy(); client = createClient((_error, _attempt, status) => { @@ -280,8 +279,6 @@ describe('shouldRetry', () => { }); it('does not pass the status of an earlier fetch for a refetch network error', async () => { - // Fetch 1 succeeds; the refetch fails with a network error, which must - // not inherit a status from the response assigned by fetch 1. const statuses: Array = []; client.destroy(); client = createClient((_error, _attempt, status) => { diff --git a/packages/fetchium/src/react/use-suspense-query.ts b/packages/fetchium/src/react/use-suspense-query.ts index 9ce80d9..26c03a3 100644 --- a/packages/fetchium/src/react/use-suspense-query.ts +++ b/packages/fetchium/src/react/use-suspense-query.ts @@ -20,8 +20,8 @@ import { useQuery } from './use-query.js'; * Wrap the change in `startTransition` to keep showing the previous result. * * Call it from a plain function component. Like `useQuery`, it can't run - * inside a Signalium `component()` (that is a reactive context), where - * `fetchQuery` is the way to read queries. + * inside a Signalium `component()` (a reactive context); use `fetchQuery` + * there. */ export function useSuspenseQuery( QueryClass: new () => T, @@ -35,9 +35,9 @@ export function useSuspenseQuery( throw new Error('QueryClient not found'); } - // Decide before useQuery: a render that suspends is discarded without a - // commit, so a subscription taken by it would never be cleaned up. The - // client holds the query active while suspended instead. + // Decide before useQuery: a suspended render is discarded without a commit, + // so a subscription it took would never be cleaned up. The client holds the + // query active while suspended instead. const { promise, failed, error, key } = client.suspendOnColdMiss( QueryDefinition.for(QueryClass), args[0] as QueryParams | undefined, diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index 37877ed..cbd375d 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -6,10 +6,11 @@ import type { ResolvedRetryConfig } from './query.js'; * * @param error What the attempt threw. * @param attempt Index of the failed attempt, starting at 0 (the first request). - * @param status HTTP status of the failed attempt, when known: the status the - * adapter received (`ctx.response.status` for a REST query whose response - * body failed to parse or validate), or else `status`, `statusCode` or - * `response.status` on the error. Undefined for network errors. + * @param status HTTP status of the failed attempt, when known: `status`, + * `statusCode` or `response.status` on the error, or else the status of the + * response the adapter received (`ctx.response.status`, for a REST error + * response whose body failed to parse or validate). Undefined for network + * errors. */ export type ShouldRetry = (error: unknown, attempt: number, status: number | undefined) => boolean; From b063b83885a47d967c64f01e8ea0491787b68df8 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:47:31 -0400 Subject: [PATCH 079/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 48 +++++++--------- packages/fetchium/src/EntityStore.ts | 4 +- packages/fetchium/src/QueryClient.ts | 38 ++++++------- .../shared-typename-stream.bench.ts | 9 ++- .../__tests__/hydrate-in-memory-shape.test.ts | 38 ++++++------- .../skip-write-and-snapshot-fixes.test.ts | 15 ++--- packages/fetchium/src/applyEntities.ts | 32 +++++------ packages/fetchium/src/parseEntities.ts | 56 ++++++++----------- packages/fetchium/src/query-types.ts | 29 ++++------ .../hydrate-in-memory-shape.test.tsx | 7 +-- packages/fetchium/src/stores/async.ts | 6 +- packages/fetchium/src/stores/sync.ts | 37 ++++++------ 12 files changed, 138 insertions(+), 181 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index a3be78e..814e57b 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -14,17 +14,16 @@ import { entitySatisfiesShape } from './parseEntities.js'; import { recordRestOutside } from './stores/shared.js'; /** - * Fields of an entity's stored record that no class applied to the in-memory - * instance declares: another class sharing the typename wrote them, in this - * session or an earlier one. Kept, as raw record JSON, so the instance's - * writes carry them instead of dropping them from the record. + * Fields of an entity's stored record that no class applied to the instance + * declares, written by another class sharing the typename. Kept as raw JSON so + * the instance's writes don't drop them. */ export interface RecordRest { /** The fields kept. */ keys: string[]; /** Their raw values as a JSON object body (`"a":1,"b":{…}`), written as is. */ json: string; - /** The `{ __entityRef }` keys inside them, which the record goes on referencing. */ + /** The `{ __entityRef }` keys inside them, which the record still references. */ refIds: number[]; } @@ -658,21 +657,19 @@ export class EntityInstance { /** The stored record's fields this instance does not hold; its writes carry them. */ _recordRest: RecordRest | undefined = undefined; /** - * The stored record handed over at hydration by a store that cannot read - * it again synchronously, while `_recordRest` has not been taken from it - * yet: that happens at the first write, so a cold start pays nothing for it. + * The stored record, held until `_recordRest` is taken from it at the first + * write so a cold start pays nothing. Kept from hydration only for stores + * that can't re-read it synchronously. */ private _storedRecord: Record | undefined = undefined; /** - * The stored record may hold fields of another class sharing the typename - * (built from a fetch of a class that lacks some, or hydrated from a record - * that holds some), so the first write reads it (synchronous stores) and - * keeps them. + * The stored record may hold another class's fields, so the first write + * reads it (synchronous stores) and keeps them. */ _checkStoredRecord: boolean = false; /** - * `entityRefs` were counted from the whole data by a full payload's apply, - * not carried over by a partial update or changed by a live collection. + * `entityRefs` were counted from the whole data by a full payload, and not + * since changed by a partial update or a live collection. */ _refsCounted: boolean = false; private _saving: boolean = false; @@ -904,7 +901,7 @@ export class EntityInstance { this.markUnwritten(); } - /** The store deleted this entity's record, and with it the fields this instance kept from it. */ + /** The store deleted this entity's record: drop the fields kept from it. */ recordDeleted(): void { this._recordRest = undefined; this._storedRecord = undefined; @@ -912,15 +909,15 @@ export class EntityInstance { this.recordDropped(); } - /** Whether this instance keeps fields of its stored record (or reads them, or has the record to take them from). */ + /** Whether this instance keeps, or will read, fields of its stored record. */ keepsRecordFields(): boolean { return this._recordRest !== undefined || this._storedRecord !== undefined || this._checkStoredRecord; } /** - * The stored record holds fields the data does not: keep them. A - * synchronous store's record is read again at the first write rather than - * held in memory until then. + * The stored record holds fields the data does not: keep them. A rereadable + * (synchronous) store's record is read again at the first write instead of + * held in memory. */ recordHoldsOtherFields(record: Record, rereadable: boolean): void { if (rereadable) { @@ -933,11 +930,9 @@ export class EntityInstance { } /** - * Keeps the fields of this entity's stored record (raw, as parsed from the - * store) that the instance's data does not hold, so its writes carry them. - * A field the data holds, even as `undefined` (a class declares it and the - * payload left it out), is the instance's to write. The record is kept as - * handed over and the fields are taken from it at the first write. + * Keeps the stored record (raw, as parsed) so the instance's writes carry + * the fields its data lacks. They are taken from it at the first write. A + * field the data holds, even as `undefined`, is the instance's to write. */ noteRecord(record: Record): void { this._storedRecord = record; @@ -945,9 +940,8 @@ export class EntityInstance { } /** - * The kept record fields to write with the data. Normally exactly what was - * kept; once a class declaring some of them has been applied, the data - * holds those itself and they are dropped from what is kept. + * The kept record fields to write with the data, minus any an applied class + * now declares (the data holds those itself). */ recordRestForWrite(): RecordRest | undefined { const data = this.data; diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 3778ac4..ca3bc55 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -83,8 +83,8 @@ export class EntityStore { return; } if (instance._checkStoredRecord) { - // First write of an instance whose class may lack fields the record - // holds: read the record once and keep them from now on. + // The class may lack fields the record holds: read it once, on the + // first write. instance._checkStoredRecord = false; const stored = this.readEntity?.(instance.key); if (stored !== undefined) instance.noteRecord(stored); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 0b18501..1d3806f 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -403,10 +403,9 @@ export class QueryClient { } /** - * Per entity def: whether another class sharing its typename declares a - * field it does not, in this session or (with a store that remembers field - * names) an earlier one. A record of such a class may hold fields it does - * not parse, which its writes must keep. + * Per entity def: whether another class of its typename declares a field it + * lacks, this session or (via the store) an earlier one. Its records may + * hold fields it does not parse, which its writes must keep. */ private foreignFieldDefs = new WeakMap, boolean>(); /** Per typename: every top-level field its classes have declared (see `foreignFieldDefs`). */ @@ -425,8 +424,8 @@ export class QueryClient { /** * Whether a stored record of this def's typename may hold fields the def - * does not declare. One WeakMap lookup; registers the def first if needed - * (a hydration parse runs before the apply registers it). + * does not declare. Registers the def first if needed, since a hydration + * parse runs before the apply registers it. * * @internal */ @@ -436,10 +435,9 @@ export class QueryClient { this.registerEntityDef(def); verdict = this.foreignFieldDefs.get(def); if (verdict === undefined) { - // Not a class: the merged def of a typename's classes (a streamed - // event's root), whose fields are some of the typename's. A new class - // registered later replaces the merged def, so the verdict cannot go - // stale. + // Not a class but a typename's merged def (a streamed event's root). + // Registering a new class replaces the merged def, so this verdict + // cannot go stale. const fields = def.typenameValue !== undefined ? this.typenameFields.get(def.typenameValue) : undefined; const shape = def.shape as Record | undefined; verdict = fields !== undefined && shape !== undefined && fields.size > Object.keys(shape).length; @@ -450,12 +448,10 @@ export class QueryClient { } /** - * The top-level fields that other classes registered for this def's - * typename declare, that it does not, and whose values can hold an entity - * (a `t.entity`, a live array, or an array or object containing one). Only - * these can hold references to children that a full payload of this def - * leaves in the data. Computed once per def until another class of the - * typename registers. + * Top-level fields that other classes of this def's typename declare, this + * def does not, and that can hold an entity. Only these can hold child + * references that a full payload of this def leaves in the data. Cached per + * def until another class of the typename registers. * * @internal */ @@ -480,9 +476,9 @@ export class QueryClient { } /** - * Folds a newly registered def's fields into its typename's field set (and - * the store's, which outlives the session), then re-derives which of the - * typename's defs lack a field of it. Runs once per def and client. + * Folds a newly registered def's fields into its typename's field set and + * the store's, then re-derives which of the typename's defs lack one. Runs + * once per def and client. */ private noteTypenameFields(typename: string, def: ValidatorDef): void { let fields = this.typenameFields.get(typename); @@ -504,8 +500,8 @@ export class QueryClient { // Every registered def's fields are in the set, so a def lacks one of // them exactly when the set is larger than its shape. for (const d of this.typenameRegistry.get(typename)!) { - // A new class can declare a field name already known with a def that - // holds entities, so every def of the typename recomputes. + // A new class may declare a known field name with an entity-holding + // def, so every def's ref fields recompute. this.foreignRefFieldsByDef.delete(d); if (!added && d !== def) continue; const misses = fields.size > Object.keys(d.shape as Record).length; diff --git a/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts index 1cfbcd8..5d9adbb 100644 --- a/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts +++ b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts @@ -9,11 +9,10 @@ import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; /** - * Streamed full-payload updates for in-memory entities whose typename is - * declared by two classes: a list class and a detail class with fields the - * list class lacks. The detail class wrote the records in an earlier session, - * so every write of a list entity has to keep the detail's fields in its - * record. A typename with one class is measured alongside as the control. + * Streamed full-payload updates of in-memory entities whose typename two + * classes share: a list class and a detail class with extra fields. The detail + * class wrote the records in an earlier session, so every list-entity write + * must keep the detail's fields. A single-class typename is the control. * * Run with `npm run bench`. */ diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts index 204f153..14a0b5f 100644 --- a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -13,19 +13,15 @@ import { hashValue } from 'signalium/utils'; import { sleep } from './utils.js'; /** - * A persisted query must be served from the cache when one of its entities is - * already in memory with data that does not, by itself, fit the query's shape, - * as long as the cache can make up the difference without serving data older - * than the in-memory entity: - * A. a required top-level `t.format` field: the in-memory value is already - * a parsed FormattedValue; - * B. two entity classes share a typename and the class in memory lacks a - * field the cached query's class requires; - * B2/B3. the other class wrote the record (in this session or an earlier - * one): its write must keep the fields it does not declare, or even a - * cold start with nothing in memory cannot serve the cache. - * The negative cases (C) must keep falling back to the network: the cache - * must never resurrect a value the fresher in-memory entity contradicts. + * A persisted query is served from the cache when one of its entities is in + * memory with data that doesn't fit the query's shape by itself, as long as + * the cache can fill the gap without serving data older than memory: + * A. a required top-level `t.format` field, already parsed in memory; + * B. two classes share a typename and the in-memory class lacks a field the + * cached query's class requires. The in-memory class's writes must keep + * the other class's fields, or even a cold start cannot serve the cache. + * The negative cases (C) fall back to the network: the cache must never + * resurrect a value the fresher in-memory entity contradicts. */ let clients: QueryClient[] = []; @@ -239,7 +235,7 @@ async function runDetail( ): Promise { const logs: string[] = []; const c = makeClient(kv, f, logs); - // Wrapped: a QueryPromise is thenable, so returning it bare from an async function would unwrap it. + // `before` wraps the list: a QueryPromise is thenable, so an async function returning it bare would unwrap it. const list = (await before(c))?.list; f.set('/product', detailBody(6, 'net', 'P-net'), 200); const requestsBefore = f.count('/product'); @@ -1078,14 +1074,14 @@ describe('E. what the store remembers about the other class, and for how long', // Within 30 days the detail's names are known: the summary's first write keeps its fields. expect(await listSession(t0 + 29 * day)).toBe(1); - // Past them, the summary is no longer marked: it writes without reading. + // Past 30 days the names are forgotten: the summary writes without reading. expect(await listSession(t0 + 31 * day)).toBe(0); }); it('E3 a typename with one class: an event carrying every field writes the record whole, without a merge', async () => { const kv = new MemoryPersistentStore(); - // A store whose field names cover every record it holds: emptied with - // clear() (otherwise, 30 days after it started remembering them; see E5). + // clear() makes the field names cover every record (otherwise that takes + // 30 days; see E5). new SyncQueryStore(kv).clear(); const f = makeFetch(); f.set('/reading/latest', { reading: readingPayload }); @@ -1115,7 +1111,7 @@ describe('E. what the store remembers about the other class, and for how long', await session(kv, f, async c => { await start(c, () => fetchQuery(GetItem)); }); - /** The kv opened by a new app process; without `sq:meta:` keys, as a release that did not remember field names left it. */ + /** The kv reopened by a new app process. `stripMeta` drops `sq:meta:` keys, as a release without field names left it. */ const reopen = (stripMeta: boolean) => { const copy = new MemoryPersistentStore(); for (const k of kv.getAllKeys()) { @@ -1154,13 +1150,13 @@ describe('E. what the store remembers about the other class, and for how long', reopen(false); expect(await listSession(t0 + 2 * hour, 'U2')).toEqual({ merged: 1, savedWhole: false }); expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'U2', ...kept }); - // 30 days after the store started remembering, a class not declared since would have been forgotten anyway: written whole. + // 30 days after the store started remembering names: written whole. reopen(false); kv.setNumber('sq:meta:fieldsSince', t0 + 3 * hour - 30 * day); expect(await listSession(t0 + 3 * hour, 'U3')).toEqual({ merged: 0, savedWhole: true }); - // A store first opened by this version counts from then, empty or not: - // telling would take a scan of every key at startup, which it does not do. + // A store without `fieldsSince` counts from now, empty or not, rather than + // scan every key at startup. const fresh = new MemoryPersistentStore(); const scans = vi.spyOn(fresh, 'getAllKeys'); expect(new SyncQueryStore(fresh).entityFieldNamesComplete()).toBe(false); diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index 4e050a2..e48bfd2 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -142,9 +142,8 @@ describe('hydration of a cached query whose entity is already in memory', () => id = t.id; title = t.string; } - // Declares `body` optional: a response without it says, more recently - // than the cache, that there is none. (An object field, which two - // classes may declare with different optionality.) + // Declares `body` optional, so a response without it says, more + // recently than the cache, that there is none. class NarrowWithBody extends Entity { __typename = t.typename('Doc'); id = t.id; @@ -184,15 +183,13 @@ describe('hydration of a cached query whose entity is already in memory', () => const wide = holdQuery(client, () => fetchQuery(GetWide)); await wide; if (absent) { - // The narrow write removed `body` from the record, and the fresher - // absence in memory contradicts the cache: dropped with a warning, - // and the network answered instead. + // The narrow write removed `body` from the record and the fresher + // absence contradicts the cache: dropped with a warning and fetched. expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); expect((wide.value as any).doc.body.text).toBe('B2'); } else { - // The narrow write kept the record's `body`, and the cached query is - // served (within its staleTime, so not refetched): the in-memory - // title, the record's body. + // The narrow write kept the record's `body`. Served within staleTime + // without a refetch: the in-memory title, the record's body. expect(warn).not.toHaveBeenCalled(); expect(mockFetch.calls.filter(c => c.url.endsWith('/wide'))).toHaveLength(1); expect((wide.value as any).doc.title).toBe('T2'); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 81cd0c4..5bdbe47 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -224,13 +224,11 @@ function applyEntity( } finally { entityInstance._applying = false; } - // A full payload of a class that lacks fields another class of the typename - // declares leaves those fields in the data: count the references they hold, + // A full payload of a class lacking fields another class of the typename + // declares leaves those fields in the data. Count the references they hold, // or they would be released while the other class's consumers still show - // them, and dropped from the record's references. Only fields whose defs - // can hold an entity are walked. An unchanged payload skips the walk when - // the last full payload counted the references: the entity still holds - // exactly those. + // them. An unchanged payload skips the walk if the last full payload already + // counted them. const heldRefs = entityInstance.entityRefs; let keepsHeldRefs = false; if ( @@ -322,10 +320,10 @@ function countHeldRefs( } /** - * A whole record is about to be written from a fetch's data: if the entity's - * class lacks fields another class sharing its typename declares, the stored - * record may hold them, and the first write reads it to keep them. For an app - * whose typenames each have one class this is a field read. + * A fetch's data is about to replace the whole record. If the class lacks + * fields another class of its typename declares, the record may hold them, so + * the first write reads it to keep them. With one class per typename this + * costs a field read. */ function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClient: QueryClient): void { if ( @@ -338,14 +336,12 @@ function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClie } /** - * Whether the fields streamed events carried (`keys`) are the entity's whole - * record, so it can be written whole rather than merged into its record. They - * must name every field of its class, and no other class sharing the typename - * may declare a field this one lacks: such a field can sit in the record (an - * earlier session's write), and only a merge keeps it. That takes a store - * that remembers each typename's field names across sessions, for every - * record it holds; with any other store, or one with records an earlier - * release wrote, a class not registered this session may be unknown, so the + * Whether the fields streamed events carried (`keys`) make up the entity's + * whole record, so it can be written whole instead of merged. They must cover + * its class, and no other class of the typename may declare a field this one + * lacks, since the record could hold it. That needs a store whose remembered + * field names cover every record it holds (`storeKnowsTypenameFields`). + * Otherwise a class not registered this session may be unknown, so the * entity stays partial and is merged. */ function eventsBuiltWholeRecord(keys: Set, entityShape: EntityDef, queryClient: QueryClient): boolean { diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index 290d409..6558244 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -56,9 +56,8 @@ export interface ParsedEntity { fillsPartial: boolean; /** * Hydration only: the raw cached record, when it holds fields the - * instance's data does not (another class sharing the typename wrote - * them). The apply keeps those fields on the instance so its writes carry - * them instead of dropping them from the record. + * instance's data does not (written by another class of the typename). The + * apply keeps them so the instance's writes don't drop them. */ record?: Record; } @@ -531,12 +530,10 @@ function parseEntityData( fillKeys = existing._partialKeys; obj = preloaded; // The fill makes the entity whole, so its next write replaces the - // record: hand over the record when it holds fields this class does not - // declare (another class of the typename wrote them), and that write - // keeps them. Hydrating over an event-built entity is rare, so every - // record is checked, not only for a class known to lack fields: a store - // written before field names were remembered may not know the other - // class. + // record. Hand over a record holding fields this class does not declare + // so that write keeps them. This path is rare, so every record is + // checked: a store written before field names were remembered may not + // know the other class. const shape = entityShape.shape; for (const k in preloaded) { if (!(k in shape)) { @@ -552,11 +549,9 @@ function parseEntityData( } obj = preloaded; // Only a class lacking fields another class of its typename declares - // can be handed a record holding fields it does not parse; for every - // other class this is one WeakMap lookup. Unless the store knows every - // class that wrote its records (an earlier release wrote some, or it - // does not remember field names): then any record is checked, as the - // other class may not be registered yet. + // can get a record with fields it does not parse. If the store's field + // names don't cover every record, the other class may not be registered + // yet, so every record is checked. if ( !queryClient.storeKnowsTypenameFields || queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) @@ -636,26 +631,21 @@ function parseEntityData( // ====================================================== /** - * A cached query references an entity that is already live in memory. The - * in-memory data is at least as fresh as the cached record (it was hydrated - * from that record or applied from a later payload) and every consumer - * already sees it, so it is never re-parsed or overwritten from the cache: - * re-parsing would run already-parsed values (formatted values, parse - * results, child proxies) through the parser again, in place, and corrupt - * them. + * A cached query references an entity already live in memory. The in-memory + * data is at least as fresh as the cached record and every consumer already + * sees it, so it is never overwritten from the cache. Re-parsing it would also + * run already-parsed values (formatted values, child proxies) through the + * parser again, in place, and corrupt them. * - * Field by field, against the hydrating query's shape: - * - no class applied to the instance declares the field (it is not an own - * key of the data; every applied class's parse sets each of its fields, - * even to `undefined`) and the record holds it: the record's value is - * parsed and merged in. Nothing in memory says anything about it, and it - * was written by a class that declares it, so it is no older than this - * cached query; - * - the in-memory value satisfies the field: used as is; - * - anything else (a value of another type, or `undefined` from a class - * that declares the field optional, which is fresher than the record): - * the cached query cannot be served, `CachedEntityMismatchError`, and it - * is dropped and fetched. + * Each field of the hydrating query's shape: + * - not an own key of the data (no applied class declares it, since each + * class's parse sets all its fields, even to `undefined`) but held by the + * record: parsed from the record and merged in. A class that declares it + * wrote it, so it is no older than this cached query. + * - the in-memory value satisfies the field: used as is. + * - otherwise (another type, or `undefined` from a class declaring it + * optional, which is fresher than the record): `CachedEntityMismatchError`, + * so the cached query is dropped and fetched. */ function hydrateFromMemory( existing: EntityInstance, diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 29d23a0..1d87fdd 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -111,12 +111,10 @@ export interface QueryStore { /** * Writes an entity's record. `rest`, when given, is a JSON object body - * (`"a":1,"b":{…}`) of fields the record already held that no entity class - * applied to the in-memory instance declares (another class sharing the - * typename wrote them). The store writes them alongside `value`, ahead of - * its fields, so the write does not drop them; `refIds` already includes - * the references inside them. A store that ignores `rest` drops those - * fields from the record, as before. + * (`"a":1,"b":{…}`) of fields the record already held that the instance's + * classes don't declare (another class of the typename wrote them). Write + * them ahead of `value`'s fields so they are kept. `refIds` already includes + * the references inside them. A store that ignores `rest` drops them. */ saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void; @@ -187,12 +185,11 @@ export interface QueryStore { readEntity?(entityKey: number): Record | undefined; /** - * The top-level field names the entity classes of `typename` have - * declared, as `addEntityFieldNames` recorded them in this session or an - * earlier one. Lets the client tell, without reading any record, which - * classes may be handed a record holding fields they do not declare. A - * store without these methods only knows the classes of the current - * session. + * The top-level field names `typename`'s entity classes have declared, as + * `addEntityFieldNames` recorded them in this or an earlier session. Lets + * the client tell, without reading records, which classes may get a record + * holding fields they don't declare. Without these methods the client only + * knows this session's classes. */ getEntityFieldNames?(typename: string): readonly string[] | undefined; /** @@ -205,11 +202,9 @@ export interface QueryStore { /** * Whether the names `getEntityFieldNames` returns cover every class that * wrote a record the store holds. Asked once, when the client is created. - * Only then is an entity built from streamed events that carried every - * field of its class, of a typename whose other classes declare nothing - * more, written whole rather than merged into its record. A store some of - * whose records were written before it remembered field names (by an - * earlier release) answers `false` until they cannot matter. + * Only then can an entity built from streamed events be written whole + * instead of merged into its record. A store holding records written before + * it remembered field names answers `false` until they cannot matter. */ entityFieldNamesComplete?(): boolean; } diff --git a/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx index f739958..f5f62d9 100644 --- a/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx +++ b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx @@ -13,10 +13,9 @@ import { sleep } from '../../__tests__/utils.js'; import { useQuery } from '../index.js'; /** - * React view of hydrate-in-memory-shape: a screen whose cached query shares - * an entity with a query already in memory paints its cached value on the - * first commit (a synchronous store), not a loading frame followed by the - * network result. + * With a synchronous store, a screen whose cached query shares an entity with + * a query already in memory paints the cached value on its first commit, not a + * loading frame. */ let clients: QueryClient[] = []; diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 1971e88..ef92376 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -63,9 +63,9 @@ export type StoreMessage = /** `value` holds only some fields; the writer merges them over the stored record, if any. */ merge?: boolean; /** - * Fields of the stored record to keep alongside `value` (a JSON object - * body, see `QueryStore.saveEntity`). Optional on the wire: a writer - * built before it ignores it and writes `value` alone. + * Fields of the stored record to keep alongside `value`, as a JSON + * object body (see `QueryStore.saveEntity`). An older writer ignores it + * and writes `value` alone. */ rest?: string; } diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 329c090..627780f 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -236,8 +236,8 @@ export class SyncQueryStore implements QueryStore { this.setValue(entityKey, value, refIds); return; } - // The kept fields go first: should one ever repeat a field of `value`, - // the value's own (later) occurrence is the one a parse keeps. + // Kept fields go first, so if one repeats a field of `value` the parse + // keeps `value`'s later occurrence. const json = JSON.stringify(value); const rests = this.mergeRests; if (rests.size !== 0) rests.delete(entityKey); @@ -283,9 +283,8 @@ export class SyncQueryStore implements QueryStore { if (at !== undefined) { since = { at, stored: true }; } else { - // Not known: count from now. Telling an empty store from one an - // earlier release wrote would take a scan of every key, on the thread - // that creates the client at startup. + // Unknown: count from now. Telling an empty store from one an earlier + // release wrote would take a scan of every key at startup. since = { at: Date.now(), stored: false }; } fieldNamesSinceByKv.set(kv, since); @@ -293,11 +292,10 @@ export class SyncQueryStore implements QueryStore { } /** - * Records that a class of `typename` declares `fields` now. A name keeps - * the time it was last declared (refreshed at most once a day, so a - * session normally writes nothing here); one no class has declared for - * `FIELD_NAME_TTL` is forgotten, so a field an app update removed stops - * marking the typename's classes, and stops being kept in its records. + * Records that a class of `typename` declares `fields` now. Each name keeps + * the time it was last declared, refreshed at most daily, so most sessions + * write nothing. A name undeclared for `FIELD_NAME_TTL` is forgotten, so a + * field an app update removed stops being kept in records. */ addEntityFieldNames(typename: string, fields: readonly string[]): void { const names = this.fieldNamesOf(typename); @@ -341,17 +339,14 @@ export class SyncQueryStore implements QueryStore { } /** - * Deletes every query, entity record and queue this store holds (the - * `sq:doc:` keys of its kv) and reports each record to `onDelete` - * listeners, so a client over it drops what it kept from those records - * and its next write of an entity carries only what the entity holds in - * memory. Wipe the cache with this rather than on the kv directly (an - * MMKV `clearAll()`): a client does not know about records deleted under - * it, and its next write of an entity of a typename two classes share puts - * back the other class's fields it kept from the deleted record. The field - * names the store remembers per typename (`sq:meta:`) describe the app's - * entity classes, not cached data, and are kept; with no record left, - * they cover every record from now on (`entityFieldNamesComplete`). + * Deletes every query, entity record and queue (the kv's `sq:doc:` keys) + * and reports each record to `onDelete` listeners, so clients drop what + * they kept from those records. Wipe the cache with this, not on the kv + * directly (e.g. MMKV `clearAll()`): a client unaware of the deletion would + * write back another class's fields it kept from a deleted record. The + * per-typename field names (`sq:meta:`) describe the app's classes, not + * cached data, so they are kept. With no records left, they are complete + * from now on (`entityFieldNamesComplete`). */ clear(): void { const kv = this.kv; From 6e7ef1b5e2b03f9ca02ec1b897417e98157cc9a3 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:47:53 -0400 Subject: [PATCH 080/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 24 ++- packages/fetchium/src/LiveCollection.ts | 6 +- packages/fetchium/src/QueryClient.ts | 51 +++-- packages/fetchium/src/QueryResult.ts | 179 ++++++++---------- .../src/__benchmarks__/nested-read.bench.ts | 9 +- .../__tests__/nested-value-reactivity.test.ts | 11 +- .../src/__tests__/reactivation-grace.test.ts | 2 +- .../src/__tests__/retain-prefetch.test.ts | 3 +- packages/fetchium/src/applyEntities.ts | 6 +- packages/fetchium/src/nestedNotifiers.ts | 14 +- packages/fetchium/src/query-types.ts | 6 +- packages/fetchium/src/stores/shared.ts | 17 +- packages/fetchium/src/stores/sync.ts | 21 +- .../fetchium/src/subscriptions/polling.ts | 8 +- .../fetchium/src/topic/TopicQueryAdapter.ts | 9 +- 15 files changed, 169 insertions(+), 197 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index e04d0d2..29fd7c3 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -18,7 +18,7 @@ import { entitySatisfiesShape } from './parseEntities.js'; // ====================================================== const ObjectProto = Object.prototype; -// A module-local binding: read on every nested read. +// Module-local alias: read on every nested read. const nestedWrappers = NESTED_WRAPPERS; /** A `WRAPPED_VALUE` item. A live collection's value changes under its own notifier. */ @@ -28,14 +28,12 @@ interface WrappedValue { } /** - * `owner` is the notifier of a live collection whose value this is (or is - * inside): a reducer or an event can change such a value in place, so reads - * through its wrapper consume that notifier. Without one, the value is an - * entity's nested object, record or array. Its wrapper consumes nothing: a - * merge that changes it in place drops the wrapper (`dropNestedWrapper`), so - * the next read through the entity, which the entity's notifier re-runs, - * hands out a new one. Whoever was given only the nested value (a child - * component given `entity.price` as a prop) then sees a new value too. + * `owner` is the notifier of the live collection this value belongs to. A + * reducer or event can change such a value in place, so reads through its + * wrapper consume that notifier. Without an owner the value is nested in an + * entity and its wrapper consumes nothing: an in-place merge drops the wrapper + * (`dropNestedWrapper`), so the next read hands out a new one and a child + * given only the nested value (e.g. `entity.price` as a prop) re-renders. */ function wrapValue(value: unknown, owner: Notifier | undefined): unknown { if (typeof value !== 'object' || value === null) return value; @@ -116,15 +114,15 @@ const objectWrappingHandler: ProxyHandler> = { }; /** - * The wrapper of a value inside a live collection: its traps consume the - * collection's notifier, and values read through it inherit that owner. + * Wraps a value inside a live collection. Traps consume the collection's + * notifier, and nested values inherit that owner. */ class OwnedArrayHandler implements ProxyHandler { constructor(readonly owner: Notifier) {} get(target: unknown[], prop: string | symbol, receiver: unknown): unknown { - // Every read, `length` and the iteration methods included: an in-place - // push changes what all of them return. + // Consume on every read, including `length` and iteration methods: an + // in-place push changes all of them. this.owner.consume(); if (typeof prop === 'string') { const idx = Number(prop); diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 06c162a..76e5350 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -103,9 +103,9 @@ export class LiveCollectionBinding { _constraintFieldRefs: Map>; readonly instance: LiveInstance; /** - * What reads of the value's contents consume. The array or object - * `getValue()` returns can change in place without the parent entity - * changing, so its wrapper must not follow the entity's notifier. + * Consumed by reads of the value's contents. The value from `getValue()` + * can change in place without the parent entity changing, so its wrapper + * can't rely on the entity's notifier. */ readonly _valueOwner: Notifier; diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index ee279be..65eb06e 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -40,10 +40,10 @@ import type { ExtractType } from './types.js'; import type { Optionalize, Signalize } from './type-utils.js'; /** - * Options for `new QueryClient(config)`. Every key, including ones not listed - * here, also reaches query and mutation code as `this.context`, so an app can - * pass its own services through the config. The names declared below are - * reserved: a custom value under one of them is read as that option. + * Options for `new QueryClient(config)`. Every key, including unlisted ones, + * also reaches query and mutation code as `this.context`, so an app can pass + * its own services through the config. The names declared below are reserved + * and always read as that option. */ export interface QueryClientConfig { store?: QueryStore; @@ -132,13 +132,11 @@ export const DEFAULT_PREFETCH_TTL = 10_000; const SUSPENSE_HOLD_TTL = 10_000; /** - * How long a failed cold fetch's error waits for a render to claim it. React - * starts the retry of a suspended render soon after its promise settles, but - * a large or time-sliced tree can take far longer than a task to reach the - * reader again, and a reader that found no hold would refetch instead of - * throwing, every time. An error nobody claimed by then belongs to an - * abandoned tree, and a later mount makes a fresh attempt instead of - * inheriting it. + * How long a failed cold fetch's error waits for a render to claim it. A + * large or time-sliced tree can take far longer than a task to retry a + * suspended render, and a reader that finds no hold refetches instead of + * throwing. An error still unclaimed after this belongs to an abandoned tree, + * so a later mount makes a fresh attempt instead of inheriting it. */ const UNCLAIMED_FAILURE_TTL = 1_000; @@ -218,10 +216,10 @@ export class QueryClient { /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ private suspenseHolds = new Map(); /** - * Keys whose last cold failure expired unclaimed. The next failure of the - * attempt that follows waits for a render as long as an unsettled hold - * would, so a reader slower than `UNCLAIMED_FAILURE_TTL` gets the error - * after one retried request rather than refetching in a loop. + * Keys whose last cold failure expired unclaimed. The next attempt's failure + * waits `SUSPENSE_HOLD_TTL` instead, so a reader slower than + * `UNCLAIMED_FAILURE_TTL` gets the error after one retry rather than + * refetching in a loop. */ private unclaimedFailures = new Set(); @@ -248,12 +246,12 @@ export class QueryClient { this.store = store; const { reactivationGraceMs, reactivationStaggerMs, shouldRetry } = config; this.reactivationGraceMs = nonNegative(reactivationGraceMs); - // Finite: the window becomes setTimeout delays. + // Must be finite: the window is split into setTimeout delays. this.reactivationStaggerMs = Number.isFinite(reactivationStaggerMs) ? nonNegative(reactivationStaggerMs) : 0; this.shouldRetry = typeof shouldRetry === 'function' ? shouldRetry : undefined; - // Every other key passes through to the context, as custom keys always - // have, including the reserved ones read above and `activity` / - // `pollResumeJitterMs`, which poll() reads from there. + // All other keys pass through to the context, including the reserved ones + // read above and `activity` / `pollResumeJitterMs`, which poll() reads + // from there. this.context = { ...(rest as Record), log: log ?? console, evictionMultiplier }; this.gcManager = config.gcManager ?? @@ -611,9 +609,9 @@ export class QueryClient { * last fetch failed is refetched once per hold: when that attempt fails too, * `error` is set and the hold is dropped, so the caller can throw it. A * failure no render claims within `UNCLAIMED_FAILURE_TTL` (the suspended - * tree was abandoned) drops the hold, so a later mount makes a new attempt; - * if that attempt fails too, its error waits `SUSPENSE_HOLD_TTL` for a - * render, so a reader that slow still reaches its error boundary. + * tree was abandoned) drops the hold, so a later mount makes a new attempt. + * If that attempt also fails, its error waits `SUSPENSE_HOLD_TTL` for a + * render, so even a slow reader reaches its error boundary. * * @internal */ @@ -1094,11 +1092,10 @@ export class QueryClient { /** * Evicts the root entity of a non-entity result that `owner` no longer - * shows (its params changed, or it was collected), unless another query - * instance still shows it. Since a root's key follows the current params, - * two instances whose params agree share one. A root already gone from the - * map is left alone: evicting it would remove whatever the map now holds - * at its key, which is the root a query showing those params applies to. + * shows (its params changed, or it was collected), unless another instance + * with the same params still shows it. A root already gone from the entity + * map is skipped: its key now belongs to the root of whichever query shows + * those params. * * @internal */ diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index d7f7307..2a3e939 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -36,13 +36,12 @@ function abortError(): Error { } /** - * The start window: open from the moment a query's first fetch starts on its + * The start window: open from when a query's first fetch starts on its * microtask until the Signalium flush outstanding at that moment has run. A - * query mounted and unmounted in one task is deactivated by that flush, after - * its first fetch already went out; a fetch started inside the window is left - * to finish (as when the first fetch started on a timer, after the flush) - * rather than being aborted into an error. One window serves every first - * fetch started before it closes. + * query mounted and unmounted in one task is deactivated by that flush after + * its first fetch went out. A first fetch started inside the window is left + * to finish instead of being aborted into an error. One window serves every + * first fetch started before it closes. */ let startWindow = 0; let startWindowOpen = false; @@ -99,19 +98,19 @@ export class QueryInstance { /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ private pendingRestart: (() => void) | undefined = undefined; /** - * The rejection of a restart that found the query offline (or inactive) - * when it ran. The relay stays pending on its promise until coming back - * online restarts it, or a full deactivation rejects it. + * Rejects a restart that found the query offline or inactive when it ran. + * The relay stays pending until reconnecting restarts it or a full + * deactivation rejects it. */ private parkedRestart: ((error: unknown) => void) | undefined = undefined; /** - * The signal of a first fetch a same-task unmount let finish. Its query is - * scheduled for collection once that fetch settles, not before: collecting - * it earlier would leave the fetch applying to an instance nothing owns. + * Signal of a first fetch that a same-task unmount let finish. GC is + * scheduled once it settles: collecting earlier would leave the fetch + * applying to an instance nothing owns. */ private keptFetchSignal: AbortSignal | undefined = undefined; /** - * The signal of a fetch whose abort leaves the relay pending rather than + * Signal of a fetch whose abort leaves the relay pending instead of * rejecting it. See `holdsDeactivationAbort()`. */ private heldAbortSignal: AbortSignal | undefined = undefined; @@ -121,8 +120,8 @@ export class QueryInstance { private firstFetchController: AbortController | undefined = undefined; private firstFetchWindow: number = 0; /** - * A deactivation aborted the fetch in flight, so the relay settles with an - * AbortError. The next activation refetches rather than showing it. + * A deactivation aborted the in-flight fetch, so the relay settles with an + * AbortError. The next activation refetches instead of showing it. */ private abortedByDeactivation: boolean = false; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ @@ -176,9 +175,9 @@ export class QueryInstance { private _extraMethods: Record unknown> = {}; /** - * Query id injected as QUERY_ID on non-entity payloads: the key of the root - * entity that holds the result, per params, so each params key's cached - * record points at its own data. + * Query id injected as QUERY_ID on non-entity payloads. It keys the root + * entity and follows the params, so each params key's cached record points + * at its own data. */ private _queryId: number = 0; @@ -233,15 +232,14 @@ export class QueryInstance { this.reconnectsAtDeactivate = this.queryClient.networkManager.reconnects; this.cancelDebounced(); - // initialize()'s start, if it hasn't run: the next activation restarts it. + // Cancel initialize()'s start if it hasn't run. The next activation restarts it. this.startPending = false; const controller = this._abortController; if (controller !== undefined && !controller.signal.aborted) { - // A fetch that may be waiting on the subscription this - // deactivation tears down (a topic query's send() resolves from - // it) could never finish, so it is aborted as before and the next - // activation restarts it. + // A fetch may be waiting on the subscription this deactivation + // tears down (a topic query's send() resolves from it), so it is + // aborted and the next activation restarts it. const sameTask = controller === this.firstFetchController && startWindowOpen && this.firstFetchWindow === startWindow; const keepFirstFetch = !isPausing && sameTask && this.unsubscribe === undefined; @@ -271,9 +269,8 @@ export class QueryInstance { if (isPausing) return; - // A restart parked while offline has no fetch for an abort to - // reject: settle it as the abort would have, and restart on the next - // activation. + // A restart parked while offline has no fetch to abort. Settle it as + // the abort would have. The next activation restarts it. const parked = this.parkedRestart; if (parked !== undefined) { this.parkedRestart = undefined; @@ -323,14 +320,13 @@ export class QueryInstance { // previous fetch was aborted during deactivation (or never started). // runDebounced() would bail out because isPending is still true from // the doomed promise, which may also never settle (a topic query's - // send() waits on a subscription that is gone). A relay the - // deactivation's abort already rejected is restarted the same way, - // rather than showing the AbortError until a refetch lands, and so - // is a fetch still in flight for params that changed meanwhile. - // With reactivationStaggerMs, a relay holding a value that the - // abort rejected is re-settled with that value instead and - // refetches like any stale relay below, so the refetches a pause - // or offline switch aborted are still spread across the window. + // send() waits on a subscription that is gone). A relay the abort + // already rejected is restarted too, so it doesn't show the + // AbortError until a refetch lands. So is a fetch still in flight + // for params that changed meanwhile. With reactivationStaggerMs, a + // rejected relay that holds a value is re-settled with it and + // refetches like any stale relay below, so aborted refetches are + // still spread across the window. if ( this.abortedByDeactivation && this.queryClient.reactivationStaggerMs > 0 && @@ -371,7 +367,7 @@ export class QueryInstance { this.lastSubscribeFn = undefined; this.reconcileSubscription(); if (this.startPending) { - // The first start hasn't run yet; it sends the new params. + // The pending first start sends the new params. } else if (this.relayState.isPending) { // A fetch for the old params is in flight (or queued): replace it. this.restartAbortedFetch(true); @@ -411,8 +407,7 @@ export class QueryInstance { ); if (previousRoot !== undefined && previousRoot !== this.rootEntity && !def.statics.isEntityResult) { - // The params changed: the previous params' root holds their data, and - // their cached record points at it. This query no longer shows it. + // The params changed, so this query no longer shows the old params' root. this.queryClient.releaseQueryRoot(previousRoot, this); } @@ -509,9 +504,9 @@ export class QueryInstance { } /** - * A Signal param changed while an asynchronous cache load was pending. The - * entry belongs to the old params, so it is not applied, and the change - * already fetched the new params unless nothing has started since. + * A Signal param changed while an async cache load was pending. The loaded + * entry belongs to the old params, so it is dropped, and a fetch starts + * unless the change already started one. */ private startAfterParamsChangedDuringLoad(fetchStartsAtLoad: number): void { if (this.fetchStarts === fetchStartsAtLoad) this.startSubscriptionAndFetch(); @@ -522,9 +517,9 @@ export class QueryInstance { }; /** - * `fromMicrotask`: the start raced Signalium's flush, so a deactivation in - * that flush lets its fetch finish. A lease's start (`startPendingNow()`) - * holds the query itself; releasing the lease aborts the fetch as usual. + * `fromMicrotask` means the start raced Signalium's flush, so a deactivation + * in that flush lets the fetch finish. A lease's start (`startPendingNow()`) + * doesn't race: the lease holds the query, and releasing it aborts the fetch. */ private runStart(fromMicrotask: boolean): void { if (!this.startPending) return; @@ -552,7 +547,7 @@ export class QueryInstance { this.currentParams = extractedParams as QueryParams; this.storageKey = storageKey; this._queryId = extractedParams !== undefined ? hashValue(extractedParams) : 0; - // The timestamp belongs to the old params' data: refetch, as after invalidation. + // The timestamp belongs to the old params' data, so force a refetch as invalidation does. if (this.updatedAt !== undefined) this.updatedAt = 0; } @@ -657,8 +652,8 @@ export class QueryInstance { ); this._executionCtx.refetch = () => this.refetch(); // `TopicQuery.getConfig.subscribe` hands this to its adapter, which calls - // it when it delivers an event for the query's topic. Non-enumerable, so - // `Object.keys(this)` inside a query method lists the same keys as before. + // it when it delivers an event for the query's topic. Non-enumerable so + // it stays out of `Object.keys(this)` inside query methods. Object.defineProperty(this._executionCtx, '_notePush', { value: this.notePush, configurable: true }); this._executionCtx.rawFetchNext = this.def.statics.rawFetchNext; // `TopicQuery.getConfig.subscribe` reads `_topicAdapter` from the ctx; @@ -696,8 +691,8 @@ export class QueryInstance { attempt.start(); try { const freshData = await adapter.send(ctx, signal); - // The params changed while the request was in flight (an adapter - // that ignores the abort): its data belongs to the old params. + // The params changed in flight and the adapter ignored the abort. + // The data belongs to the old params. if (this.storageKey !== storageKey) throw abortError(); this.updatedAt = Date.now(); @@ -723,8 +718,8 @@ export class QueryInstance { } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; if (signal === this.heldAbortSignal) { - // Pending until the next activation replaces this fetch, or the - // query is collected or destroyed (abortForDestroy() settles it). + // Stay pending until the next activation replaces this fetch, or + // abortForDestroy() settles it on collection or destroy. this.heldAbortSignal = undefined; return new Promise(() => {}); } @@ -738,23 +733,20 @@ export class QueryInstance { } /** - * Whether a deactivation that cancels this query's fetch leaves its relay - * pending instead of rejecting it with the AbortError. Signalium keeps a - * rejected relay's error until it gets a value, so a rejection would have - * the next mount report `isRejected` with the AbortError until its refetch - * lands (a chart switched away from mid-fetch and back would flash its - * error state). The relay instead stays pending: the next activation's - * refetch settles whoever awaits it, as does collection or `destroy()` - * (`abortForDestroy()`). + * Whether a deactivation that aborts this query's fetch leaves the relay + * pending instead of rejecting it. Signalium keeps a rejected relay's error + * until it gets a value, so the next mount would report `isRejected` with + * the AbortError until its refetch lands (a chart switched away from + * mid-fetch and back would flash its error state). A held relay is settled + * by the next activation's refetch, or by `abortForDestroy()`. * - * An unmount mid-fetch holds any query whose relay has no value yet. A - * query mounted and unmounted in one task is not held: whoever started it - * is a one-off read that awaits it, and gets the AbortError at once. A - * pause (the network went offline) and a restart parked while offline, - * which wait on the network rather than on a remount, hold only a query - * with a subscription (this method: a topic query's fetch waits on the - * subscription the deactivation tears down); any other query's awaiter - * gets the AbortError at once, as before. + * An unmount mid-fetch holds any query whose relay has no value yet, except + * one mounted and unmounted in the same task: that is a one-off read, and + * its awaiter gets the AbortError at once. A pause (offline) or a restart + * parked while offline waits on the network, not a remount, so it uses this + * method and holds only a query with a subscription, since a topic query's + * fetch waits on the subscription the deactivation tears down. Other + * awaiters get the AbortError at once. */ private holdsDeactivationAbort(): boolean { return this.relayState.value === undefined && this.config?.subscribe !== undefined; @@ -777,11 +769,10 @@ export class QueryInstance { this.heldAbortSignal = undefined; if (this.abortedByDeactivation) { this.abortedByDeactivation = false; - // The abort rejected the relay (it is no longer pending). Signalium - // keeps a rejected relay's error while it is pending again, so the - // refetch would show the AbortError next to the cached value. - // Re-settling with that value clears it. (With no value yet there is - // nothing to settle with.) + // The abort rejected the relay. Signalium keeps a rejected relay's + // error while it is pending again, so the refetch would show the + // AbortError next to the cached value. Re-settling with the value + // clears it. const value = this.relayState.value; if (value !== undefined && !this.relayState.isPending) this.relayState.value = value; } @@ -807,9 +798,9 @@ export class QueryInstance { return; } if (!this._isActive || this.isPaused) { - // Went offline before it ran. Leave the relay pending with no - // controller: coming back online restarts it, and a full - // deactivation before that rejects it. + // Went offline or inactive before it ran. Park it with the relay + // pending: reconnecting restarts it, and a full deactivation first + // rejects it. if (this._abortController === controller) this._abortController = undefined; this.parkedRestart = reject; return; @@ -823,19 +814,18 @@ export class QueryInstance { } /** - * Queues a deferred fetch on a microtask, or with `afterFlush`, once - * Signalium's outstanding flush has run. A params change reaches update() - * from inside that flush, and the same flush may also deactivate the query - * (its last watcher left in the same task): waiting for it, which costs no - * extra task there, lets the deactivation cancel the fetch instead of - * aborting it after it went out. + * Queues a deferred fetch on a microtask or, with `afterFlush`, after + * Signalium's outstanding flush. A params change reaches update() inside + * that flush, which may also deactivate the query. Waiting for it costs no + * extra task and lets the deactivation cancel the fetch instead of aborting + * it after it went out. */ private deferRun(run: () => void, afterFlush: boolean): void { if (afterFlush) { - // settled() also waits for every flush scheduled after this one, which - // a listener that writes a signal keeps doing; the timer caps the wait - // at one task. A flush runs within the task it starts in, so the - // timer still fires after the outstanding one. + // settled() also waits for later flushes, which a listener that writes + // signals can keep scheduling, so the timer caps the wait at one task. + // A flush finishes within the task it starts in, so the timer still + // fires after the outstanding one. let timer: ReturnType | undefined = undefined; const once = (): void => { if (timer === undefined) return; @@ -899,8 +889,8 @@ export class QueryInstance { if (generation !== this.debounceGeneration) return; this.debounceGeneration++; this.pendingDebouncedRun = undefined; - // Deactivated, or gone offline in the same task: the deactivation (or - // coming back online) decides what happens next. + // Deactivated or went offline in the same task. Deactivation or + // reconnecting decides what happens next. if (!this._isActive || this.isPaused) return; // Another path started a fetch in the meantime (refetch(), activation). if (this.relayState.isPending && this._abortController !== undefined) return; @@ -948,11 +938,10 @@ export class QueryInstance { } /** - * Called by `QueryClient.destroy()`, and when the client collects the - * query: aborts the fetches in flight, including a first fetch a same-task - * unmount left running, cancels deferred ones and settles the relay, so - * nothing reaches the store after the client is gone and no awaiter waits - * forever. + * Called by `QueryClient.destroy()` and when the client collects the query. + * Aborts in-flight fetches (including a first fetch a same-task unmount + * kept), cancels deferred ones and settles the relay, so nothing reaches + * the store afterwards and no awaiter hangs. * * @internal */ @@ -967,10 +956,10 @@ export class QueryInstance { this._abortController = undefined; this._fetchNextAbort?.abort(); this._fetchNextAbort = undefined; - // Whoever awaits the relay must still get an answer. A queued restart - // sees its aborted controller and rejects; a parked one is rejected here; - // a fetch in flight rejects with its abort. A relay left pending with no - // fetch at all (its first start never ran) is rejected directly. + // Every awaiter must get an answer. A queued restart sees its aborted + // controller and rejects, a parked one is rejected here, and an in-flight + // fetch rejects with its abort. A pending relay with no fetch at all (its + // first start never ran) is rejected directly. const restart = this.pendingRestart; restart?.(); this.pendingRestart = undefined; diff --git a/packages/fetchium/src/__benchmarks__/nested-read.bench.ts b/packages/fetchium/src/__benchmarks__/nested-read.bench.ts index d64b174..461b81d 100644 --- a/packages/fetchium/src/__benchmarks__/nested-read.bench.ts +++ b/packages/fetchium/src/__benchmarks__/nested-read.bench.ts @@ -9,11 +9,10 @@ import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; /** - * What reading a nested, non-entity field of an entity costs: `t.object` and - * `t.record` values and `t.liveArray` values are handed out through wrapping - * proxies, so every read goes through a proxy trap. Measured inside a reactive - * computation (a `component()` render or a `reactive()` function) and outside - * one. Each case reads 1,000 fields. + * Cost of reading nested, non-entity fields of an entity. `t.object`, + * `t.record` and `t.liveArray` values are handed out through wrapping proxies, + * so every read goes through a proxy trap. Measured inside and outside a + * reactive computation. Each case reads 1,000 fields. * * Run with `npm run bench`. */ diff --git a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts index 57c1835..9e4a739 100644 --- a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts +++ b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts @@ -9,13 +9,10 @@ import { testWithClient, sleep, setupTestClient } from './utils.js'; /** * Nested values (`t.object` and `t.record` fields, live collection values) are - * merged in place. A computation that only holds the nested value, such as a - * child component given `entity.price` as a prop, never reads the entity: - * - an entity's nested value is handed out through a new wrapper once a merge - * changed it, so the parent, which the entity re-runs, passes a new value; - * an unchanged one keeps its wrapper, so the child is left alone; - * - a live collection's value changes in place under the collection's - * notifier, which reads through its wrapper consume. + * merged in place, and a child given only the nested value never reads the + * entity. A changed entity value gets a new wrapper, so the parent passes a new + * value. A live collection value's wrapper reads consume the collection's + * notifier. */ describe('reads through a held nested value', () => { const getClient = setupTestClient(); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 19d8a21..cd5f7c5 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -117,7 +117,7 @@ describe('reactivation grace and stagger', () => { } it('passes the grace, stagger and shouldRetry options through to the query context too', () => { - // As every config key did before these options existed. + // Like any other config key. const shouldRetry = () => false; const client = makeClient({ reactivationGraceMs: 5, reactivationStaggerMs: 7, shouldRetry }); expect(client.getContext()).toMatchObject({ reactivationGraceMs: 5, reactivationStaggerMs: 7, shouldRetry }); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 18b776a..33d5f0d 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -666,8 +666,7 @@ describe('useSuspenseQuery holds (suspendOnColdMiss)', () => { await remount.promise; expect(mockFetch.calls).toHaveLength(2); - // That attempt failed too; a render slower than the first window still gets its error - // instead of refetching again. + // That attempt failed too. A render slower than the first window still gets its error. await vi.advanceTimersByTimeAsync(1_500); expect(render()).toMatchObject({ failed: true, promise: undefined }); expect(mockFetch.calls).toHaveLength(2); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 7dd3e58..47e5938 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -351,9 +351,9 @@ function mergeFields( ) ) { changed = true; - // Merged in place, so the value keeps its identity: hand out a - // new wrapper, so whoever holds just this value (a child given it - // as a prop) sees a new one. A merged copy is a new value already. + // Merged in place, so the value keeps its identity. Drop its + // wrapper so a child given just this value as a prop sees a new + // one. A merged copy is already a new value. if (target === oldVal) dropNestedWrapper(oldVal); existingData[fieldKey] = target; } else { diff --git a/packages/fetchium/src/nestedNotifiers.ts b/packages/fetchium/src/nestedNotifiers.ts index d79fc6f..891d178 100644 --- a/packages/fetchium/src/nestedNotifiers.ts +++ b/packages/fetchium/src/nestedNotifiers.ts @@ -1,16 +1,14 @@ /** - * The wrapping proxy handed out for a nested object, record or array inside - * an entity (or a live collection's value), keyed by the wrapped value. - * `EntityInstance` creates the wrappers; `applyEntities` drops an entity's - * nested wrapper when a merge changes the value in place. + * Wrapping proxies for nested objects, records and arrays inside entities and + * live collection values, keyed by the wrapped value. `EntityInstance` creates + * them and `applyEntities` drops them on in-place merges. */ export const NESTED_WRAPPERS = new WeakMap(); /** - * A merge changed a nested value in place. Its wrapper is dropped, so the - * next read through the entity hands out a new one: the value is merged in - * place to keep the raw data's identity, but whoever holds only the wrapper - * (a child component given it as a prop) must see a new value to re-render. + * Call when a merge changes a nested value in place. The raw value keeps its + * identity, but a child holding only the wrapper (e.g. as a prop) needs a new + * value to re-render, so the next read hands out a new wrapper. */ export function dropNestedWrapper(value: object): void { NESTED_WRAPPERS.delete(value); diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index feb01d8..1c80d17 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -27,9 +27,9 @@ export interface QueryContext { }; evictionMultiplier?: number; // `activity` and `pollResumeJitterMs` (see `QueryClientConfig`) reach the - // context as pass-through keys, as any custom config key does, but are not - // declared here: an app may have augmented this interface with its own - // field of the same name. `poll()` checks their shape before using them. + // context as pass-through keys but are not declared here, since an app may + // have augmented this interface with its own field of the same name. + // `poll()` validates them before use. } /** diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index b318e71..5c9bf32 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -45,12 +45,11 @@ export function mergeStoredRecord( } /** - * What a stored record holds beyond the fields a partial write carries: those - * fields as a JSON fragment (`"a":1,"b":{…}`, or `''` when there are none), - * and the references inside them. A write of `partial` then appends the - * fragment to the partial's own JSON, which is the merged record, without - * reading or parsing the stored one again. Undefined when the stored record - * is not a JSON object. + * The stored record's fields that `partial` does not write, as a JSON fragment + * (`"a":1,"b":{…}`, or `''` if none) plus the references inside them. + * Appending the fragment to `partial`'s JSON yields the merged record without + * re-reading the stored one. Undefined if the stored record is not a JSON + * object. */ export function storedRecordRest( stored: string, @@ -66,8 +65,8 @@ export function storedRecordRest( const rest: Record = {}; let any = false; for (const key in record as Record) { - // A field the partial holds as undefined is not written by it (JSON drops - // it), so the stored value stays. + // JSON drops a field the partial holds as undefined, so the stored value + // stays. if (partial[key] !== undefined) continue; rest[key] = (record as Record)[key]; any = true; @@ -78,7 +77,7 @@ export function storedRecordRest( return { json: JSON.stringify(rest).slice(1, -1), refIds: [...refIds] }; } -/** The `{ __entityRef }` markers in a record's JSON, as `collectEntityRefs` finds them in the parsed record. */ +/** Adds the `__entityRef` ids in a record's JSON to `into`, as `collectEntityRefs` would for the parsed record. */ export function entityRefsInJson(json: string, into: Set): void { if (json.indexOf('"__entityRef":') === -1) return; const re = /"__entityRef":(\d+)/g; diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index b5f4c5d..8e1b59e 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -80,12 +80,11 @@ export class MemoryPersistentStore implements SyncPersistentStore { const deleteListenersByKv = new WeakMap void>>(); /** - * For an entity record last written by `mergeEntity()`: which fields that - * write carried, and what the record holds besides them (`storedRecordRest`). - * The next merge of the same fields writes the merged record from this, - * without reading the stored one. A high-rate stream of updates for entities - * not in memory merges the same fields into the same records over and over; - * only the first merge of each reads. + * For a record last written by `mergeEntity()`: which fields that write + * carried, and the rest of the record (`storedRecordRest`). A later merge of + * the same fields builds the record from this without reading the stored one, + * so a high-rate update stream for entities not in memory reads each record + * only once. */ interface MergeRest { fields: string; @@ -94,13 +93,12 @@ interface MergeRest { } /** - * Keyed by the backing kv, like the delete listeners: any write or deletion - * of a record through a store over that kv drops its entry, so an entry - * always describes the record on disk. (Writing the kv's records other than - * through a store would leave it stale.) + * Keyed by the backing kv, like the delete listeners. Any write or delete + * through a store over that kv drops the record's entry, so entries match + * what is on disk. Writing to the kv directly would leave them stale. */ const mergeRestsByKv = new WeakMap>(); -/** Bounds the entries kept; past it the least recently merged is dropped, and its next merge reads again. */ +/** LRU bound. An evicted record's next merge reads it again. */ const MAX_MERGE_RESTS = 1024; export class SyncQueryStore implements QueryStore { @@ -227,7 +225,6 @@ export class SyncQueryStore implements QueryStore { let json = JSON.stringify(fields); if (rest.json !== '') json = json.length === 2 ? `{${rest.json}}` : `${json.slice(0, -1)},${rest.json}}`; - // The merged record's references, as the markers in it say. const merged = new Set(rest.refIds); entityRefsInJson(json, merged); this.writeValue(entityKey, json, merged); diff --git a/packages/fetchium/src/subscriptions/polling.ts b/packages/fetchium/src/subscriptions/polling.ts index e4f724b..e73f6fa 100644 --- a/packages/fetchium/src/subscriptions/polling.ts +++ b/packages/fetchium/src/subscriptions/polling.ts @@ -24,10 +24,10 @@ export interface PollConfig { const warnedActivity = new WeakSet(); /** - * The context's `activity`, when it is an `ActivitySource`. Custom config keys - * pass through to the context, so an app may already use the name for a value - * of its own; anything without `isActive` and `subscribe` functions is - * ignored (polls then run as without one), with a warning in development. + * The context's `activity`, if it is an `ActivitySource`. Custom config keys + * pass through to the context, so an app may already use this name for its + * own value. Anything without `isActive` and `subscribe` functions is ignored, + * with a warning in development. */ function activitySource(queryContext: QueryContext | undefined): ActivitySource | undefined { const activity = (queryContext as Record | undefined)?.activity; diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index e67cd43..8531694 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -24,13 +24,12 @@ interface TopicState { /** * `promise`, or a rejection once `signal` aborts. A topic's data comes from - * its subscription, which the query's deactivation tears down: a fetch that - * deactivation aborted would otherwise wait for data that never comes, and - * so would whoever awaits the query. + * its subscription, which deactivation tears down, so an aborted fetch would + * otherwise wait forever, along with whoever awaits the query. */ function untilAborted(promise: Promise, signal: AbortSignal): Promise { - // React Native's AbortController polyfill sets no `reason`: reject with an - // AbortError there too, so the rejection reads as the abort it is. + // React Native's AbortController polyfill sets no `reason`, so + // getAbortReason() falls back to an AbortError. if (signal.aborted) return Promise.reject(getAbortReason(signal)); return new Promise((resolve, reject) => { const onAbort = (): void => reject(getAbortReason(signal)); From 3213594bc0340ad988f742ffb63a48c39921ce00 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:48:55 -0400 Subject: [PATCH 081/109] chore(fetchium): tighten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 135 ++++++++---------- packages/fetchium/src/QueryClient.ts | 51 +++---- packages/fetchium/src/QueryResult.ts | 12 +- .../src/__tests__/entity-snapshot.test.ts | 6 +- .../src/__tests__/event-persistence.test.ts | 16 +-- .../skip-write-and-snapshot-fixes.test.ts | 10 +- packages/fetchium/src/applyEntities.ts | 56 ++++---- packages/fetchium/src/errors.ts | 8 +- packages/fetchium/src/parseEntities.ts | 61 ++++---- packages/fetchium/src/query-types.ts | 41 +++--- packages/fetchium/src/stores/async.ts | 53 +++---- packages/fetchium/src/stores/shared.ts | 4 +- packages/fetchium/src/stores/sync.ts | 7 +- 13 files changed, 200 insertions(+), 260 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 84b012c..4206853 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -160,11 +160,9 @@ function snapshotCameFrom(snapshotObj: unknown, sourceId: number): boolean { } /** - * Dev builds hand out frozen snapshots. A consumer that mutates one (a - * `sort()` in render, an assignment) then fails at its own call site with a - * TypeError and a stack trace, instead of the fast path quietly carrying the - * mutation forward. Production keeps the objects mutable: freezing costs a - * call per new container and the contract is documented instead. + * Dev builds freeze snapshots, so a consumer that mutates one (a `sort()` in + * render) throws at its own call site instead of the fast path silently + * carrying the mutation forward. Production skips the per-container cost. */ function freezeInDev(obj: T): T { return IS_DEV ? Object.freeze(obj) : obj; @@ -235,10 +233,9 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn } /** - * Structural equality of two snapshot values (plain objects, arrays, and - * primitives compared with `Object.is`). Used by the dev guard to tell a real - * drift from a value that merely lost reference identity, e.g. a `Set` a - * format parser returned, which Signalium's `snapshotSet` rebuilds each walk. + * Structural equality of two snapshot values. The dev guard uses it to tell + * real drift from a value that only lost identity, such as a format parser's + * `Set`, which Signalium's `snapshotSet` rebuilds each walk. */ function sameSnapshotValue(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; @@ -250,9 +247,8 @@ function sameSnapshotValue(a: unknown, b: unknown): boolean { } if (a instanceof Set || b instanceof Set) { if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false; - // A rebuilt Set (Signalium's `snapshotSet` copies one every walk) keeps - // the insertion order, so pair members by position first and only search - // for a member that fails to line up. This keeps the common case linear. + // A rebuilt Set keeps insertion order, so pair members by position and + // only search for one that fails to line up. The common case stays linear. const bItems = [...b]; // Indices of `b` already paired, tracked from the first misalignment on. let matched: Set | undefined; @@ -313,10 +309,10 @@ function walkFields( } /** - * The pre-fast-path walk: `Object.keys` on the proxy and a read per key. Kept - * for a proxy this module instance did not create — after a hot reload - * re-evaluates this file, proxies made by the previous instance are still - * live but absent from the new `snapshotSources`. + * Full walk: `Object.keys` on the proxy and a read per key. For proxies this + * module instance did not create: after a hot reload re-evaluates this file, + * the previous instance's proxies are still live but absent from + * `snapshotSources`. */ function snapshotProxyByWalking(current: object, prev: unknown, snap: SnapshotFn): unknown { const obj = current as Record; @@ -337,11 +333,9 @@ function snapshotProxyByWalking(current: object, prev: unknown, snap: SnapshotFn const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unknown => { const source = snapshotSources.get(current); if (source === undefined) { - // A proxy from another instance of this module (hot reload re-evaluated - // this file and its registries, but the proxies are still live): walk it - // the old way rather than handing the proxy back, which would stop React - // from ever re-rendering on its changes. Every entity proxy answers - // `toJSON`; a bare `Entity` instance does not. + // A proxy from a previous instance of this module (hot reload): walk it. + // Handing the proxy back would stop React re-rendering on its changes. + // Every entity proxy answers `toJSON`; a bare `Entity` instance does not. if (PROXY_ID.has(current) || typeof (current as { toJSON?: unknown }).toJSON === 'function') { return snapshotProxyByWalking(current, prev, snap); } @@ -382,19 +376,17 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno } if (IS_DEV) { - // Dev builds re-read what the fast path skipped. A field that drifted is - // served from the re-read (the pre-fast-path behaviour) and reported, - // rather than thrown: a throw from inside a snapshot lands in Signalium's - // watcher flush, which has no catch, and stalls every consumer on the page. + // Re-read what the fast path skipped. A drifted field is served from the + // re-read and reported, not thrown: a throw inside a snapshot lands in + // Signalium's watcher flush, which has no catch, and stalls every consumer. const drifted = verifyStaticFields(source, keys, before, snap); if (drifted !== undefined) { if (result === undefined) result = { ...before }; for (const key of Object.keys(drifted)) result[key] = drifted[key]; } - // A consumer that added or deleted keys on the snapshot it was handed: - // rebuild with the shape's keys, in the shape's order, as a walk would. - // A frozen snapshot (every one this module hands out in dev) cannot - // have been altered, so only an unfrozen one pays for the key list. + // A consumer added or deleted keys on its snapshot: rebuild with the + // shape's keys in shape order. Dev snapshots are frozen and cannot have + // been altered, so only an unfrozen one pays for the key list. if (!Object.isFrozen(before) && !sameKeyList(Object.keys(before), keys.enumerable)) { const source_ = result ?? before; const rebuilt: Record = {}; @@ -435,11 +427,9 @@ const reportedDrift = new Set(); export type SnapshotDriftHandler = (error: Error, queryClient: QueryClient) => void; /** - * Dev-only default: log through the client's logger, then raise the error - * outside the snapshot so it surfaces as an uncaught error (a red box in - * React Native, "Uncaught Error" in a browser, a failed run under vitest) - * without throwing from inside Signalium's watcher flush, which has no catch - * and would stop every consumer on the page from updating. + * Dev-only default: log, then rethrow in a microtask so the error surfaces as + * uncaught (a red box in React Native, a failed vitest run) without throwing + * inside Signalium's watcher flush. */ const defaultDriftHandler: SnapshotDriftHandler = (error, queryClient) => { queryClient.getContext().log?.error?.(error.message, error); @@ -476,10 +466,9 @@ function reportDrift(source: EntitySnapshotSource, key: string, what: string): v /** * Checks the fast path's assumptions: `data` only changes through a `notify()` * that bumps `version`, and every field outside `keys.dynamic` is a pure - * function of `data`. Re-reads the skipped fields and returns the ones whose - * value differs structurally from the cached snapshot (identity alone is not - * enough: a `Set` from a format parser is rebuilt on every walk), or - * `undefined` when nothing drifted. Skipped fields only — re-reading a dynamic + * function of `data`. Re-reads the skipped fields and returns those that + * differ structurally from the cached snapshot, or `undefined` if none do. + * Skipped fields only — re-reading a dynamic * field is not identity-stable, since snapshotting a child updates state it * pairs on. * @@ -536,9 +525,9 @@ export class EntityInstance { /** Set while an apply is still reifying this instance's fields; its data is not yet a record. */ _applying: boolean = false; /** - * The data came from streamed events only (the entity was created by one), - * so it may lack fields a record of it on disk has: a write merges over - * that record. Cleared by the first full payload applied to the entity. + * The data came only from streamed events, so it may lack fields the stored + * record has: a write merges over that record. Cleared by the first full + * payload applied to the entity. */ _partial: boolean = false; /** With `_partial`: the fields the events carried, i.e. the ones a write merges. */ @@ -631,10 +620,9 @@ export class EntityInstance { /** * This entity's next write drops its reference to `child`, which can delete - * the child's record. A store that processes writes later (`onPersisted`) - * does that after anything an apply decides meanwhile, so the child is - * written again rather than trusted. A synchronous store reports the - * deletion (`onDelete`) as it happens. + * the child's record. An acknowledging store does that after anything an + * apply decides meanwhile, so the child is written again rather than + * trusted. A synchronous store reports the deletion through `onDelete`. */ private writeDropsRef(child: EntityInstance): void { if (this._queryClient.storeAcksWrites) child.recordDropped(); @@ -691,11 +679,10 @@ export class EntityInstance { if (this._saving) return; this._saving = true; try { - // A record's references must point at records that exist: a child the - // store holds no record of (never written, dropped, or a failed write) - // is written first. While an apply is still reifying a child (a payload - // that links back to an ancestor), neither it nor this entity can be - // written yet: both are written, in that order, once the apply is done. + // A record's references must point at existing records, so a child the + // store has no record of is written first. A child an apply is still + // reifying (a payload that links back to an ancestor) can't be written + // yet: both are written, child first, once the apply is done. const refs = this.entityRefs; if (refs !== undefined) { let deferred = false; @@ -709,8 +696,7 @@ export class EntityInstance { try { child.save(); } catch (e) { - // This entity's record (if any) still lacks the reference: the - // next apply must write it again. + // This entity's record still lacks the reference. this.markUnwritten(); throw e; } @@ -724,26 +710,24 @@ export class EntityInstance { } } this._deferredWrite = false; - // Mark after the store call: a store that throws leaves the record stale - // or missing, so the flag is cleared and the next apply writes again. A - // store that acknowledges writes later (`onPersisted`) marks it itself - // once the write has been processed. An entity built from events hands - // the store only the fields they carried, to merge over its record. - // Counted before the call: a store may acknowledge synchronously. - // With no record to merge over, the fields this entity holds are the - // whole record, so it stops being partial and later writes skip the - // merge's read of the stored record. + // With no record to merge over, the fields held are the whole record: + // stop being partial so later writes skip the merge's read. if (this._partial && !this._recorded && (storeHolds ?? client.store.hasEntity?.(this.key)) === false) { this._partial = false; this._partialKeys = undefined; } + // Counted before the call: a store may acknowledge synchronously. if (client.storeAcksWrites) this._pendingWrites++; + // A partial entity hands the store only the fields its events carried. try { client.entityMap.save(this, this._partial ? this._partialKeys : undefined); } catch (e) { this.markUnwritten(); throw e; } + // Marked after the call, so a store that throws leaves it unset and the + // next apply writes again. An acknowledging store marks it in + // `acknowledgeWrite`. if (!client.storeAcksWrites) this._persisted = this._recorded = true; } finally { this._saving = false; @@ -756,12 +740,10 @@ export class EntityInstance { } /** - * The store processed a write of this entity's record. Only a write this - * instance dispatched counts: an acknowledgement can also belong to a write - * queued by a previous instance of the same entity (one that was collected - * and re-hydrated from the store while the write was in flight), and that - * write carried a value this instance never had. The record is current - * once every dispatched write has landed. + * The store processed a write of this entity's record. Only writes this + * instance dispatched count: an acknowledgement can belong to a write from a + * previous instance (collected and re-hydrated while it was in flight), + * carrying data this instance never had. */ acknowledgeWrite(): void { if (this._pendingWrites === 0) return; @@ -1013,12 +995,10 @@ function bindMethod( } /** - * The members a narrowed array leaves out are dependencies of the read: one - * of them gaining the def's fields notifies itself, and that is what must - * recompute the consumer. Members already in the array are read through the - * array by any consumer that cares about their fields, and a member never - * loses eligibility, so consuming them here would only add recomputes (and - * cost a lookup per member on every read of the field). + * Members a narrowed array leaves out are dependencies of the read: one that + * gains the def's fields notifies itself, which must recompute the consumer. + * Included members are read through the array and never lose eligibility, so + * consuming them would only add recomputes. */ function consumeExcluded(excluded: EntityInstance[]): void { for (let i = 0; i < excluded.length; i++) excluded[i].consume(); @@ -1114,11 +1094,10 @@ function createProxy( function entityKeys(): EntityKeys { // The client replaces its key cache when another class registers for - // this typename, and the new split must reach snapshots already in - // flight. A cache's entries are never overwritten, so the base can only - // have changed when the cache object did: an identity check, rather than - // a WeakMap lookup on every snapshot of every entity. Both extras slots - // matter too: these lists decide what a snapshot walks and re-reads. + // this typename, and live proxies must pick up the new split. Entries are + // never overwritten, so checking the cache's identity is enough and avoids + // a WeakMap lookup per snapshot. Both extras slots matter too: these lists + // decide what a snapshot walks and re-reads. const baseCache = queryClient.shapeKeyCache; const methodsNow = instance._extraMethods; const gettersNow = instance._extraGetters; diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index c699fa9..a00a98a 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -823,10 +823,10 @@ export class QueryClient { return; } - // Entities the event creates are not written by the apply; entities it - // merely updates are. A created entity is written once a written record - // references it, or below, once the root is known to be retained. Only an - // event whose root is new can create entities that need collecting here. + // The apply writes entities the event updates, not ones it creates. A + // created entity is written once a written record references it, or below + // once the root is known to be retained. Only an event with a new root can + // create entities that need collecting here. const created = existing === undefined ? new Set() : undefined; try { const warn = this.context.log?.warn ?? (() => {}); @@ -858,19 +858,13 @@ export class QueryClient { return; } - // The root the event created is written, with the entities it created - // under it, when a live array is about to retain it (the array's owner - // is written with the reference) or when the store already holds a record - // of it: a query that was collected from memory can still hold this - // entity in its cache, and the event is that record's only chance to stay - // fresh. That write merges the event's fields over the record, since an - // event is a partial update and the fields it leaves out are not known - // here. The write comes before the routing: if it fails, nothing is - // routed, the failure is logged, and memory stays as it was, as a failed - // event always did. A store that cannot say what it holds (no - // `hasEntity`, or not yet) is not written, so it never accumulates - // records nothing references. Anything else the event created was only - // ever referenced by an unwritten root and stays unwritten. + // The created root (and what it created) is written when a live array is + // about to retain it, or when the store already holds a record of it: a + // collected query's cache can still hold this entity, and the event is + // that record's only chance to stay fresh. An event is partial, so the + // write merges over the record. It comes before routing so that a failed + // write routes nothing. A store that can't say what it holds (no + // `hasEntity`) is not written, so it never accumulates unreferenced records. let matched = false; let retains = false; this.routeEvent( @@ -909,10 +903,9 @@ export class QueryClient { } /** - * Evicts the root an event created unless an entity that existed before - * the event now references it (its owner's data holds the proxy, so it is - * live). References from entities the same event created, the root itself - * included, do not count: they were only ever reachable through the root. + * Evicts the root an event created unless an entity that existed before the + * event now references it. References from entities the same event created + * (the root included) don't count: they were only reachable through the root. */ private evictUnlessAdopted(root: EntityInstance, created: Set): void { let createdHolders = 0; @@ -921,10 +914,9 @@ export class QueryClient { } /** - * Evicts the root an event created, and with it every entity the event - * created that nothing references any more: they were never written, so - * lingering until a `gcTime` runs out would let a later event write them - * as records nothing references. + * Evicts the root an event created, along with every entity the event + * created that is no longer referenced. They were never written, and + * lingering until `gcTime` would let a later event write them as orphans. */ private evictCreated(root: EntityInstance, created: Set): void { root.evict(); @@ -954,8 +946,8 @@ export class QueryClient { /** * Writes the deferred entities in the order they were deferred: children - * before their parents. A write that fails leaves the rest unwritten, and - * marked so that the next apply writes them. + * before their parents. If a write fails, the rest are marked so the next + * apply writes them. */ /** @internal */ flushDeferredWrites(): void { @@ -978,9 +970,8 @@ export class QueryClient { } /** - * An apply that failed leaves nothing to write: what it deferred may be half - * reified. The records that were waiting are stale, so the next apply - * writes them. + * After a failed apply, what it deferred may be half reified, so nothing is + * written. Those records are stale and the next apply writes them. */ /** @internal */ discardDeferredWrites(): void { diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index e6879c9..5b184e1 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -278,13 +278,11 @@ export class QueryInstance { __hasNext: () => this.hasNext, __isFetchingNext: () => this._fetchNextPromise !== undefined, }; - // The entity's key set just changed, but its consumers are not - // notified: this query's own consumers get the proxy through the relay - // and snapshot it afresh, and an existing consumer of a shared entity - // (a list showing the entity this query returns) has no use for another - // query's extras. Notifying would re-run every one of them, and give - // their snapshots a new identity, for data that did not change. They - // see the extras on their next recompute, as before. + // The entity's key set changed, but consumers are deliberately not + // notified. This query's consumers get the proxy through the relay and + // snapshot it fresh, and other consumers of a shared entity have no use + // for this query's extras. Notifying would re-run them all and change + // their snapshot identity for unchanged data. } return this.rootEntity.getProxy(def.statics.shape as unknown as EntityDef) as QueryResult; diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index e1cbf4f..fe00aa4 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -687,10 +687,8 @@ describe('Entity Snapshots', () => { try { client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 5 } }); - // The guard must not throw from inside the snapshot: that escapes - // Signalium's watcher flush and stalls every consumer on the page. It - // raises the error out of band (by default as an uncaught error, through - // the client's logger) and hands out what a full walk would have produced. + // The guard reports out of band instead of throwing inside the snapshot, + // and hands out what a full walk would have produced. const snap = read() as unknown as { tokens: { symbol: string }[] }; expect(snap.tokens[0].symbol).toBe('MUTATED'); expect(raised).toHaveLength(1); diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index 1cd529b..7e2cfb0 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -327,9 +327,8 @@ describe('records a streamed event writes', () => { expect(warn).toHaveBeenCalledWith('Failed to apply mutation event', expect.any(Error)); // Memory and the store agree: the comment is in neither, and no record - // references it. (The author written before the failing write stays - // behind as an unreferenced record, as any write that fails mid-walk has - // always left its predecessors.) + // references it. The author written before the failing write stays behind + // unreferenced, as with any write that fails mid-walk. expect((postQ.value as any).post.comments).toEqual([]); expect((postQ.value as any).post.commentCount).toBe(0); expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); @@ -1018,9 +1017,8 @@ describe('records a streamed event writes when released entities linger (gcTime) }); await sleep(5); - // Nothing references what the event created; had the author lingered - // until its gcTime, a later event could have written it as a record - // nothing references. + // Nothing references what the event created. Had the author lingered + // until its gcTime, a later event could have written it as an orphan. expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); expect(client.entityMap.getEntity(K('User', 'u7'))).toBeUndefined(); expect(client.entityMap.getEntity(K('Badge', 'b7'))).toBeUndefined(); @@ -1427,8 +1425,8 @@ describe('AsyncQueryStore writer', () => { data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, }); await sleep(5); - // Written whole (the badge the event did not carry is gone, as before - // 0.6), never as a record holding only the event's fields. + // Written whole, dropping the badge the event did not carry, never as a + // record holding only the event's fields. expect(getDoc(kv, K('User', 'u1'))).toMatchObject({ __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }); expect(getDoc(kv, K('User', 'u1'))!.badge).toBeUndefined(); }); @@ -1803,7 +1801,7 @@ describe('AsyncQueryStore writer: write skipping while writes are queued', () => await drain(store); await scanned(store); - // The deck's write drops its reference to c1, the only one on disk; before + // The deck's write drops its reference to c1, the only one on disk. Before // it is processed, the same card is created again with identical data. client.applyMutationEvent({ type: 'delete', typename: 'Card', data: 'c1' }); client.applyMutationEvent({ type: 'create', typename: 'Card', data: card }); diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index 81b098f..dc2219e 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -14,10 +14,8 @@ import { valueKeyFor, refIdsKeyFor, queueKeyFor, DEFAULT_MAX_COUNT } from '../st import { createMockFetch, setupTestClient, testWithClient, sleep } from './utils.js'; /** - * Regression coverage for the follow-ups to the snapshot fast path (#59), - * unchanged-apply skipping (#60), single-write events (#61) and store - * deletion reporting (#64). Each case failed on the tree with those PRs and - * passed on the tree before them. + * Invariants the snapshot fast path (#59), unchanged-apply skipping (#60), + * single-write events (#61) and store deletion reporting (#64) must keep. */ function getDoc(kv: MemoryPersistentStore, key: number): Record | undefined { @@ -1097,8 +1095,8 @@ describe('snapshot fast path follow-ups', () => { const computesBefore = wrapped.computes(); expect('__refetch' in before.profile).toBe(false); - // The entity-rooted query applies identical data: its own consumer sees - // the extras, the wrapped query's consumer is not re-run for them. + // The entity-rooted query applies identical data. Its own consumer sees + // the extras, but the wrapped query's consumer is not re-run for them. const root = snapshotHarness(client, () => fetchQuery(GetRoot)); await root.query; await sleep(5); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index f9722a5..86c01d8 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -29,12 +29,10 @@ export interface ApplyResult { /** * Whether an apply writes to the store. `true` writes every entity that needs - * it; `false` writes nothing (a cache hydration); `'existing'` writes entities - * that already existed in memory and not the ones this apply creates: those - * are written when a written record references them (`EntityInstance.save` - * writes a record's missing children first), or by the caller once it knows - * the root is retained (a streamed event whose root may end up routed - * nowhere). + * it, and `false` writes nothing (cache hydration). `'existing'` writes only + * entities already in memory. Ones this apply creates are written when a + * written record references them (`EntityInstance.save` writes missing + * children first), or by the caller once it knows the root is retained. */ export type PersistMode = boolean | 'existing'; @@ -90,8 +88,8 @@ function reifyAndApply( return applyEntity(entity, seen, queryClient, persist, entityRefs, appendMode, created); } - // Only a slot whose value changes is written: values the parser did not - // declare (and so did not copy) may belong to the caller, and be frozen. + // Assign only slots whose value changes: values the parser did not declare + // (and so did not copy) may belong to the caller and be frozen. if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const item = value[i]; @@ -137,11 +135,10 @@ function applyEntity( const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial } = entity; const shapeFields = entityShape.shape; - // The same entity can appear more than once in one payload (an author under - // `author` and again under `mentions`). The parser hands every slot the one - // parsed object, so the walk arrives here once per slot; applying it again - // would find its children already reified, count none of them and release - // every child ref. Count this slot's reference and hand out the proxy. + // An entity can appear in several slots of one payload (under `author` and + // `mentions`), all sharing one parsed object. Applying it again would find + // its children already reified, count none and release every child ref, so + // only count this slot's reference. const applied = queryClient.entityMap.getEntity(key); if (applied !== undefined && applied.parseId === queryClient.currentParseId) { parentEntityRefs.set(applied, (parentEntityRefs.get(applied) ?? 0) + 1); @@ -188,10 +185,9 @@ function applyEntity( entityInstance._partial = false; entityInstance._partialKeys = undefined; } else if (entityInstance._partial && rawKeys !== undefined) { - // Stays partial even once the events have carried every field of - // this class: another class sharing the typename may have written - // fields to the record that this one does not declare, and only a - // merge keeps them. + // Stays partial even after events carry every field of this class: + // another class sharing the typename may have written fields this one + // doesn't declare, and only a merge keeps them. for (const k of rawKeys) entityInstance._partialKeys!.add(k); } } else { @@ -205,11 +201,9 @@ function applyEntity( // event did (the parser fills the literal in). if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); - // An event that carried every field of this class is still partial: - // the record may hold fields another class sharing the typename - // declares (one not registered this session, too), so it is merged - // into, not replaced. With no record to merge into, the first write - // makes it whole (see `EntityInstance.save()`). + // Partial even if the event carried every field of this class, for the + // same reason (the other class may not be registered this session). + // With no record to merge into, the first write makes it whole. } else if (persist === false) { // Hydrated from the store: its record exists. entityInstance._recorded = true; @@ -240,9 +234,9 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); // An entity hydrated from the store was never written, so there is no write - // to skip. One whose own write is still queued has its current data on the - // way. A skip is also only safe while the store has no deletion queued: it - // could drop the record this apply trusts. + // to skip. One with a write still queued has its current data on the way. + // Skipping is only safe while the store has no deletion queued, which could + // drop the record this apply trusts. const needsPersist = changed || refsChanged || @@ -283,8 +277,8 @@ function mergeFields( const raw = data[fieldKey]; if (shouldReify(raw)) { - // Written only when it changes: an object the parser kept as is (a - // fetch result's) may belong to the adapter, and be frozen. + // Assign only on change: an object the parser kept as is (from a fetch + // result) may belong to the adapter and be frozen. const reified = reifyAndApply(raw, seen, queryClient, persist, childRefs, appendMode, created); if (reified !== raw) data[fieldKey] = reified; } @@ -329,8 +323,8 @@ function mergeFields( !(propShape.shape instanceof Set) ? (propShape.shape as Record) : undefined; - // The parser keeps a fetch result's unchanged objects as they are, and - // an adapter may hand out frozen ones: merge into a copy of those. + // An unchanged object kept from a fetch result may be frozen by the + // adapter: merge into a copy. const target = Object.isFrozen(oldVal) ? { ...oldVal } : oldVal; if (nestedShape !== undefined) { if ( @@ -468,8 +462,8 @@ function initFields( const raw = data[fieldKey]; if (shouldReify(raw)) { - // Written only when it changes: an object the parser kept as is (a - // fetch result's) may belong to the adapter, and be frozen. + // Assign only on change: an object the parser kept as is (from a fetch + // result) may belong to the adapter and be frozen. const reified = reifyAndApply(raw, seen, queryClient, persist, childRefs, appendMode, created); if (reified !== raw) data[fieldKey] = reified; } diff --git a/packages/fetchium/src/errors.ts b/packages/fetchium/src/errors.ts index 65d6cf0..b13059a 100644 --- a/packages/fetchium/src/errors.ts +++ b/packages/fetchium/src/errors.ts @@ -171,10 +171,10 @@ export function typeError(path: string, expectedType: InternalObjectFieldTypeDef } /** - * A cached query cannot be hydrated as-is: an entity it holds is already in - * memory and its data does not satisfy the query's shape. Unlike a bad array - * item, this drops the whole cached query (it is refetched) rather than the - * item, since a list missing an item would resolve without a word. + * A cached query can't be hydrated as-is: an entity it holds is already in + * memory and doesn't satisfy the query's shape. Unlike a bad array item, this + * drops the whole cached query (which is refetched), since a list silently + * missing an item is worse. */ export class CachedEntityMismatchError extends Error { constructor(message: string) { diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index f71e08e..15a6348 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -75,11 +75,10 @@ export class ParseContext { /** Verdicts of the trust-memory check, per instance and def, for one parse. */ trusted: TrustMemo | undefined = undefined; /** - * Whether a nested object or record is always parsed into a copy. On for - * input the client does not own and may see again (an event payload, a - * mutation's effects, a consumer's snapshot fed back). Off for a fetch - * result or a cached record, which is copied only where a parsed value - * differs from what it holds: unchanged input costs no allocation. + * Whether nested objects and records always parse into a copy. On for input + * the client doesn't own and may see again (an event payload, mutation + * effects, a snapshot fed back). Off for a fetch result or cached record, + * which is copied only where a parsed value differs from it. */ copyInput: boolean = true; @@ -396,8 +395,9 @@ function parseUnionData( function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseContext, path: string): unknown[] { // Without `ctx.copyInput`, an array whose items all parse to themselves is - // returned as is; the copy starts at the first item that differs or fails. - // An empty one is always a new array: a live array grows the one it holds. + // returned as is, and the copy starts at the first item that differs or + // fails. An empty array is always new, since a live array grows the one it + // holds. let result: unknown[] | undefined = ctx.copyInput || array.length === 0 ? [] : undefined; for (let i = 0; i < array.length; i++) { @@ -421,10 +421,9 @@ function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseC } // Neither walker writes into its input. With `ctx.copyInput` they parse into -// a shallow copy, as `parseArrayData` builds a new array; without it the copy -// is made only once a parsed value differs from the input's, and input whose -// values all parse to themselves is returned as is. Either way the result -// keeps every key the input had, as before. +// a shallow copy. Without it, the copy is made only once a parsed value +// differs, and input whose values all parse to themselves is returned as is. +// The result keeps every key the input had. function parseRecordData( record: Record, @@ -511,8 +510,8 @@ function parseEntityData( if (preloadedEntities !== undefined) { const existing = queryClient.entityMap.getEntity(key); - // The fields of an entity built from streamed events that the record on - // disk has and the events did not carry. + // For an entity built from events: the fields they carried. The record + // supplies the rest. let fillKeys: Set | undefined; const preloaded = preloadedEntities.get(key); @@ -522,13 +521,11 @@ function parseEntityData( fillKeys = existing._partialKeys; obj = preloaded; } else if (existing !== undefined) { - // The entity is already live in memory, so the cache has nothing newer - // to offer: the in-memory data is what every consumer already sees. - // Re-parsing that data would run already-parsed values (formatted - // values, parse results, child proxies) through the parser again, in - // place, and corrupt them. An empty partial entry merges nothing and - // hands the query the existing proxy. The data must still satisfy this - // query's shape, or the cached query is not usable as-is. + // A live entity is newer than the cache. Re-parsing its data would run + // already-parsed values (formatted values, parse results, child + // proxies) through the parser again and corrupt them. An empty partial + // entry merges nothing and hands the query the existing proxy. The data + // must still satisfy this query's shape, or the cached query is unusable. ctx.trusted ??= new Map(); if (!dataSatisfiesDef(existing.data, entityShape as unknown as ValidatorDef, queryClient, ctx.trusted)) { throw new CachedEntityMismatchError( @@ -617,11 +614,10 @@ function parseEntityData( /** * Whether already-parsed entity data can be handed to a query declaring `def` - * without being parsed again. Unlike `entitySatisfiesShape` (a top-level - * presence check used for narrowing) this descends into nested objects, arrays, - * records, unions and child entities, because a cached query that resolves with - * a required nested field missing is worse than one that refetches. Where a - * value cannot be judged it is accepted, matching the parser's leniency. + * without re-parsing. Unlike `entitySatisfiesShape` (a top-level presence + * check for narrowing), this descends into nested values and child entities: + * a cached query missing a required nested field is worse than a refetch. + * Values that can't be judged are accepted, matching the parser's leniency. */ export function dataSatisfiesDef( data: Record, @@ -746,10 +742,10 @@ function valueSatisfiesDef(value: unknown, fieldDef: unknown, queryClient: Query } /** - * An entity field holds a proxy. One whose instance has left memory is served - * as it is (the data it still holds), as a re-parse would have handed it out - * too; a live one must satisfy the def, with verdicts shared across the parse - * and a cycle counted as satisfied. + * An entity field holds a proxy. One whose instance has left memory is + * accepted, since a re-parse would hand out its data too. A live one must + * satisfy the def. Verdicts are shared across the parse, and a cycle counts as + * satisfied. */ function entitySatisfies( value: object, @@ -772,10 +768,9 @@ function entitySatisfies( } /** - * Members of an entity array whose typename has more than one registered - * class are narrowed on read to the ones satisfying the field's def, so a - * member that does not satisfy it is not a mismatch: it is left out either - * way. + * When the item typename has several registered classes, the array is + * narrowed on read to members that satisfy the def, so a member that doesn't + * is not a mismatch. */ function arraySatisfies(value: unknown[], itemDef: unknown, queryClient: QueryClient, visiting: TrustMemo): boolean { let narrowed = false; diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 5e309d2..e6518e4 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -112,12 +112,11 @@ export interface QueryStore { saveEntity(entityKey: number, value: unknown, refIds?: Set): void; /** - * Writes the fields streamed events supplied for an entity built from them: - * a store that holds a record for the key merges `fields` over it (and - * derives the record's references from the merged value); one that does not - * writes `fields` as the record. A store without this method gets the - * entity's whole in-memory data through `saveEntity` instead, which drops - * from its record the fields the events did not carry. + * Writes the fields streamed events supplied for an entity built from them. + * If the store holds a record for the key, it merges `fields` over it and + * derives references from the merged value. Otherwise `fields` becomes the + * record. Without this method the client calls `saveEntity` with the whole + * in-memory data, which drops the fields the events did not carry. */ mergeEntity?(entityKey: number, fields: unknown, refIds?: Set): void; @@ -129,16 +128,16 @@ export interface QueryStore { /** * Called with the key of every record the store drops on its own (eviction, - * cascade, purge). May return an unsubscribe function; the client calls it - * from `destroy()`. A store without this hook is written on every apply. + * cascade, purge). May return an unsubscribe function, which the client + * calls from `destroy()`. A store without this hook is written on every + * apply. */ onDelete?(listener: (key: number) => void): void | (() => void); /** - * For stores that process writes asynchronously: called with the key of - * every entity record once its write has actually been applied. While a - * store offers this, the client treats an entity as persisted only after - * the acknowledgement, so a write the store dropped is retried. + * For stores that process writes asynchronously: called with each entity + * key once its write has been applied. The client then treats an entity as + * persisted only after this acknowledgement, so a dropped write is retried. */ onPersisted?(listener: (key: number) => void): void | (() => void); @@ -152,20 +151,18 @@ export interface QueryStore { /** * For stores that process writes asynchronously, a narrower `isSettled`: - * whether an operation that could delete a record is still queued. This - * client's own entity writes do not count; for those that drop a - * reference, the client itself writes the referenced entity again. When - * present, the client uses it instead of `isSettled`, so a burst of entity - * writes does not turn write skipping off for every other entity. + * whether an operation that could delete a record is still queued. The + * client's own entity writes don't count, since it rewrites any entity whose + * reference they drop. When present it replaces `isSettled`, so a burst of + * entity writes doesn't disable write skipping for every other entity. */ hasQueuedDeletes?(): boolean; /** - * Whether the store currently holds a record for this entity key, or - * `undefined` while it cannot tell yet (a store that has not finished - * reading what it holds). A streamed event for an entity that is not in - * memory refreshes the record only when this answers `true`; a store - * without this hook is not written for such events. + * Whether the store holds a record for this entity key, or `undefined` if + * it can't tell yet (it hasn't finished reading what it holds). A streamed + * event for an entity not in memory refreshes the record only when this + * returns `true`. Without this hook, such events are not written. */ hasEntity?(key: number): boolean | undefined; } diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 9cf6023..ab257ea 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -75,9 +75,9 @@ export interface AsyncQueryStoreConfig { } /** - * Writer-internal work that runs through the same serial queue as the wire - * messages, so it is ordered with respect to the writes and deletions around - * it. Never sent over the wire, and distinguishable from anything that is. + * Writer-internal work run through the same serial queue as wire messages, so + * it is ordered with the writes and deletions around it. Never sent over the + * wire. */ class InternalWork { constructor( @@ -124,21 +124,19 @@ export class AsyncQueryStore implements QueryStore { private deleteListeners: Array<(key: number) => void> = []; private persistedListeners: Array<(key: number) => void> = []; private processing = false; - // Queued work that can delete a record: everything except the entity - // writes of this writer's own client, which tracks the references those - // drop (see `hasQueuedDeletes`). + // Queued work that can delete a record: everything except this writer's + // own client's entity writes, whose dropped references the client tracks. private queuedDeletes = 0; private readonly ownEntityWrites = new WeakSet(); - // The ids whose value the delegate holds (or is about to: a queued write - // counts), mirrored by the writer so that `hasEntity` can answer - // synchronously. Read from the delegate once at start, without holding up - // the queue; until that read lands, writes and deletions are noted aside - // and folded in. + // Ids whose value the delegate holds (queued writes included), mirrored so + // `hasEntity` can answer synchronously. Read from the delegate once at + // start without blocking the queue. Writes and deletions before that read + // lands are noted aside and folded in. private heldKeys: Set | undefined; private heldSinceScan: Set | undefined = new Set(); private droppedSinceScan: Set | undefined = new Set(); - // Only the writer sees deletions and completed writes; a reader offers no - // hook, so the client writes every apply. + // Only the writer sees deletions and completed writes. A reader offers no + // hooks, so the client writes every apply. onDelete?: (listener: (key: number) => void) => () => void; onPersisted?: (listener: (key: number) => void) => () => void; hasEntity?: (key: number) => boolean | undefined; @@ -257,9 +255,8 @@ export class AsyncQueryStore implements QueryStore { } } catch (error) { console.error('Error processing message:', error); - // A failed entity write leaves the record missing or stale, which to - // the client is the same as the store having dropped it: it must - // write the entity again on its next apply. + // A failed entity write leaves the record missing or stale. Report it + // as dropped so the client writes the entity again on its next apply. if (!(msg instanceof InternalWork) && msg.type === StoreMessageType.SaveEntity) { this.noteDropped(msg.entityKey); for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](msg.entityKey); @@ -274,8 +271,8 @@ export class AsyncQueryStore implements QueryStore { /** * Reads which records the delegate holds, alongside the queue rather than * ahead of it. Writes and deletions processed meanwhile are folded in. - * A read that fails is retried; if it keeps failing, nothing is assumed - * held (an event for an entity not in memory is then not written). + * A failed read is retried. If it keeps failing nothing is assumed held, so + * an event for an entity not in memory is not written. */ private async scanHeldKeys(): Promise { const held = new Set(); @@ -312,8 +309,6 @@ export class AsyncQueryStore implements QueryStore { private async processMessage(msg: StoreMessage): Promise { switch (msg.type) { - // `maxCount` is absent from messages sent by readers built before it - // was added; the writer then keeps whatever size the queue has. case StoreMessageType.SaveQuery: await this.writerSaveQuery( msg.queryDefId, @@ -515,12 +510,11 @@ export class AsyncQueryStore implements QueryStore { queue = new Uint32Array(maxCount ?? DEFAULT_MAX_COUNT); await this.delegate!.setBuffer(queueKey, queue); } else if (maxCount !== undefined && queue.length !== maxCount) { - // `maxCount` changed since the queue was written (until 0.6 the writer - // always used the default, so every older install has a 50-slot queue). - // A view over the old buffer cannot grow past it, and a shorter view - // would silently strand the keys it drops: copy what fits and evict - // the rest properly. The key being activated is about to move to the - // front, so it is kept whichever slot it sits in. + // `maxCount` changed since the queue was written (writers before 0.6 + // always used the default of 50). A view over the old buffer can't + // grow, and a shorter one would strand the keys it drops: copy what + // fits and evict the rest. The key being activated moves to the + // front, so it is kept wherever it sits. const resized = new Uint32Array(maxCount); resized.set(queue.subarray(0, Math.min(queue.length, maxCount))); for (let i = maxCount; i < queue.length; i++) { @@ -562,10 +556,9 @@ export class AsyncQueryStore implements QueryStore { } /** - * Drops every query definition's cached queries once the definition has not - * been used for its `cacheTime`. Runs through the writer's serial queue, so - * its deletions are ordered with the writes and acknowledgements around - * them: a record it drops cannot be reported as written afterwards. + * Drops every query definition's cached queries once the definition has + * gone unused for its `cacheTime`. Runs through the writer's serial queue, + * so a record it drops cannot be reported as written afterwards. */ purgeStaleQueries(): Promise { if (!this.delegate) return Promise.resolve(); diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index e2ed01c..3d2dcb2 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -32,8 +32,8 @@ export function mergeStoredRecord( } catch { return undefined; } - // A record that is not an object is not one to merge into; the caller - // writes the fields as the record, which heals it. + // A non-object record can't be merged into. The caller writes the fields as + // the record instead, which heals it. if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined; const value = { ...(record as Record), diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 1f96303..7d3cf71 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -198,10 +198,9 @@ export class SyncQueryStore implements QueryStore { this.kv.setBuffer(queueKeyFor(queryDefId), queue); } else if (queue.length !== maxCount) { // `maxCount` changed since the queue was written. A view over the old - // buffer cannot grow past it, and a shorter view would silently strand - // the keys it drops: copy what fits and evict the rest properly. The - // key being activated is about to move to the front, so it is kept - // whichever slot it sits in. + // buffer can't grow, and a shorter one would strand the keys it drops: + // copy what fits and evict the rest. The key being activated moves to + // the front, so it is kept wherever it sits. const resized = new Uint32Array(maxCount); resized.set(queue.subarray(0, Math.min(queue.length, maxCount))); for (let i = maxCount; i < queue.length; i++) { From f412cb9eb5a13f2616bc9761aa4d840e1121c0b5 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:51:24 -0400 Subject: [PATCH 082/109] chore(fetchium): drop PR numbers from a test header Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/skip-write-and-snapshot-fixes.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index dc2219e..e8b2d7d 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -14,8 +14,8 @@ import { valueKeyFor, refIdsKeyFor, queueKeyFor, DEFAULT_MAX_COUNT } from '../st import { createMockFetch, setupTestClient, testWithClient, sleep } from './utils.js'; /** - * Invariants the snapshot fast path (#59), unchanged-apply skipping (#60), - * single-write events (#61) and store deletion reporting (#64) must keep. + * Invariants the snapshot fast path, unchanged-apply skipping, single-write + * events and store deletion reporting must keep. */ function getDoc(kv: MemoryPersistentStore, key: number): Record | undefined { From aa417f33fcf5ca3ed737fab75f4c18a3faff5ad2 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:57:02 -0400 Subject: [PATCH 083/109] fix(fetchium): release a retain() lease when starting one of its queries throws Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 7 ++++++- .../src/__tests__/retain-prefetch.test.ts | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index bb49e9e..9e3c127 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -505,7 +505,12 @@ export class QueryClient { // Outside the watcher's computation now, so adapter code may run. if (starts !== undefined) { - for (const instance of starts) instance.startPendingNow(); + try { + for (const instance of starts) instance.startPendingNow(); + } catch (error) { + release(); + throw error; + } } const ttl = options?.ttl; diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 8d19757..0118709 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -6,6 +6,7 @@ import { QueryClient, QueryClientContext, DEFAULT_PREFETCH_TTL } from '../QueryC import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; import { t } from '../typeDefs.js'; import { fetchQuery, QueryDefinition } from '../query.js'; +import { QueryInstance } from '../QueryResult.js'; import type { MutationEvent } from '../types.js'; import { GcManager, type GcKeyType } from '../GcManager.js'; import { NetworkManager } from '../NetworkManager.js'; @@ -577,4 +578,23 @@ describe('client.retain()', () => { }), ).toThrow('boom'); }); + + it('releases and rethrows when starting a query throws', async () => { + const { client } = setup(); + const start = QueryInstance.prototype.startPendingNow; + let calls = 0; + const spy = vi.spyOn(QueryInstance.prototype, 'startPendingNow').mockImplementation(function ( + this: QueryInstance, + ) { + if (++calls === 2) throw new Error('boom'); + start.call(this); + }); + + expect(() => client.retain(() => [fetchQuery(GetStreamed), fetchQuery(GetItem)])).toThrow('boom'); + spy.mockRestore(); + + await sleep(10); + expect(subscribed).toBe(1); + expect(unsubscribed).toBe(1); + }); }); From 99982a577506a4546abdeab7fd6ae0a7b62138e5 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Tue, 6 Oct 2026 23:59:28 -0400 Subject: [PATCH 084/109] fix(fetchium): keep store bookkeeping right after a failed write or an unsubscribe - A failed entity write asks the delegate whether the previous record is still there, instead of assuming it is gone, so hasEntity() keeps answering true and later events for the entity still reach it. - Store listeners are notified over a copy, so a listener that unsubscribes while being notified no longer makes the next one miss the event. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../skip-write-and-snapshot-fixes.test.ts | 35 +++++++++++++++++++ .../src/__tests__/store-on-delete.test.ts | 29 +++++++++++++++ packages/fetchium/src/stores/async.ts | 21 ++++++++--- packages/fetchium/src/stores/sync.ts | 3 +- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index e8b2d7d..1f17054 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -856,6 +856,41 @@ describe('AsyncQueryStore writer used in-process', () => { } }); + it('a rejected SaveEntity over an existing record still treats the record as held', async () => { + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/user/profile/1', { user: { __typename: 'User', id: '1', name: 'Alice' } }); + const { client } = makeClient(store, mockFetch); + clients.push(client); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const name = () => JSON.parse(delegate.kv[valueKeyFor(userKey('1'))] as string).name; + const update = (newName: string) => + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { __typename: 'User', id: '1', name: newName }, + }); + try { + await holdQuery(client, () => fetchQuery(GetProfile, { id: '1' })); + await drain(store); + + delegate.failNextSetString = valueKeyFor(userKey('1')); + update('Bob'); + await drain(store); + expect(name()).toBe('Alice'); + expect(store.hasEntity!(userKey('1'))).toBe(true); + + // With the entity out of memory, the next update must still reach the record. + client.entityMap.getEntity(userKey('1'))!.evict(); + update('Carol'); + await drain(store); + expect(name()).toBe('Carol'); + } finally { + consoleError.mockRestore(); + } + }); + it('resizes a persisted LRU queue in both directions and defaults a missing maxCount', async () => { const delegate = new AsyncDelegate(); const store = writerStore(delegate); diff --git a/packages/fetchium/src/__tests__/store-on-delete.test.ts b/packages/fetchium/src/__tests__/store-on-delete.test.ts index b8e44ec..fad9cf7 100644 --- a/packages/fetchium/src/__tests__/store-on-delete.test.ts +++ b/packages/fetchium/src/__tests__/store-on-delete.test.ts @@ -112,6 +112,35 @@ describe('QueryStore.onDelete', () => { await sleep(20); expect(deleted).toEqual([q1]); }); + + it('SyncQueryStore still notifies the next listener when one unsubscribes during a deletion', () => { + const store = new SyncQueryStore(new MemoryPersistentStore()); + const deleted: number[] = []; + const unsubscribe = store.onDelete(() => unsubscribe()); + store.onDelete(key => deleted.push(key)); + + const q1 = hashValue(['GET:/u', { id: '1' }]); + store.saveQuery(queryDef('GET:/u', 10), q1, { __entityRef: 0 }, Date.now(), new Set()); + store.deleteQuery(q1); + expect(deleted).toEqual([q1]); + }); + + it('AsyncQueryStore writer still notifies the next listener when one unsubscribes during a deletion', async () => { + const writer = new AsyncQueryStore({ + isWriter: true, + delegate: new MockAsyncPersistentStore(), + connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), + }); + const deleted: number[] = []; + const unsubscribe = writer.onDelete!(() => unsubscribe()); + writer.onDelete!(key => deleted.push(key)); + + const q1 = hashValue(['GET:/v', { id: '1' }]); + writer.saveQuery(queryDef('GET:/v', 10), q1, { __entityRef: 0 }, Date.now(), new Set()); + writer.deleteQuery(q1); + await sleep(20); + expect(deleted).toEqual([q1]); + }); }); describe('stores without onDelete', () => { diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index ab257ea..4db29d7 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -101,6 +101,7 @@ function isStoreMessage(msg: unknown): msg is StoreMessage { ); } +/** Callers notify over a copy, since a listener may unsubscribe while being notified. */ function subscribe(listeners: Array<(key: number) => void>, listener: (key: number) => void): () => void { listeners.push(listener); return () => { @@ -225,6 +226,18 @@ export class AsyncQueryStore implements QueryStore { } } + /** The previous record may have survived a failed write, so ask the delegate. */ + private async recheckHeld(id: number): Promise { + let held = false; + try { + held = await this.delegate!.has(valueKeyFor(id)); + } catch { + // Unknown: treat it as missing. + } + if (held) this.noteHeld(id); + else this.noteDropped(id); + } + private startQueueProcessor(): void { this.queueProcessorPromise = this.processQueue(); } @@ -258,8 +271,8 @@ export class AsyncQueryStore implements QueryStore { // A failed entity write leaves the record missing or stale. Report it // as dropped so the client writes the entity again on its next apply. if (!(msg instanceof InternalWork) && msg.type === StoreMessageType.SaveEntity) { - this.noteDropped(msg.entityKey); - for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](msg.entityKey); + await this.recheckHeld(msg.entityKey); + for (const listener of this.deleteListeners.slice()) listener(msg.entityKey); } } finally { this.processing = false; @@ -322,7 +335,7 @@ export class AsyncQueryStore implements QueryStore { break; case StoreMessageType.SaveEntity: await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true); - for (let i = 0; i < this.persistedListeners.length; i++) this.persistedListeners[i](msg.entityKey); + for (const listener of this.persistedListeners.slice()) listener(msg.entityKey); break; case StoreMessageType.ActivateQuery: await this.writerActivateQuery(msg.queryDefId, msg.queryKey, msg.cacheTime, msg.maxCount); @@ -656,7 +669,7 @@ export class AsyncQueryStore implements QueryStore { await delegate.delete(valueKeyFor(id)); this.noteDropped(id); await delegate.delete(refCountKeyFor(id)); - for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](id); + for (const listener of this.deleteListeners.slice()) listener(id); const refIds = await delegate.getBuffer(refIdsKey); await delegate.delete(refIdsKey); // Clean up the refIds key diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 7d3cf71..b9edf49 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -326,7 +326,8 @@ export class SyncQueryStore implements QueryStore { kv.delete(valueKeyFor(id)); kv.delete(refCountKeyFor(id)); - for (let i = 0; i < this.deleteListeners.length; i++) this.deleteListeners[i](id); + // A copy: a listener may unsubscribe while being notified. + for (const listener of this.deleteListeners.slice()) listener(id); const refIds = kv.getBuffer(refIdsKeyFor(id)); kv.delete(refIdsKeyFor(id)); // Clean up the refIds key From ae6aa5cd22b2fc9751a012d54c18e4d7ee6bd3f9 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:00:42 -0400 Subject: [PATCH 085/109] fix(fetchium): drop a response that arrives after destroy() An adapter that ignores the abort could still resolve after destroy() or GC collection, and its data was applied and saved. A counter bumped by abortForDestroy() now makes runQuery() and runFetchNext() drop it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 15 ++++++++++----- .../src/__tests__/activation-timing.test.ts | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 614f31b..e5cdb81 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -118,6 +118,8 @@ export class QueryInstance { private hasSignalParams: boolean = false; /** The first fetch's controller and its start window. See `openStartWindow()`. */ private firstFetchController: AbortController | undefined = undefined; + /** Bumped by `abortForDestroy()`, so a late response from before it is dropped. */ + private destroyCount: number = 0; private firstFetchWindow: number = 0; /** * A deactivation aborted the in-flight fetch, so the relay settles with an @@ -679,6 +681,7 @@ export class QueryInstance { const signal = this._abortController?.signal ?? new AbortController().signal; const attempt = this.attemptStatusTracker(ctx); const storageKey = this.storageKey; + const destroyCount = this.destroyCount; try { const result = await withRetry( @@ -686,9 +689,9 @@ export class QueryInstance { attempt.start(); try { const freshData = await adapter.send(ctx, signal); - // The params changed in flight and the adapter ignored the abort. - // The data belongs to the old params. - if (this.storageKey !== storageKey) throw abortError(); + // The adapter ignored the abort and the params changed, or the + // client was destroyed, while the request was in flight. + if (this.storageKey !== storageKey || this.destroyCount !== destroyCount) throw abortError(); this.updatedAt = Date.now(); const result = this.applyData(freshData, true); @@ -939,6 +942,7 @@ export class QueryInstance { * @internal */ abortForDestroy(): void { + this.destroyCount++; this.cancelDebounced(); this.startPending = false; this.firstFetchController = undefined; @@ -1045,13 +1049,14 @@ export class QueryInstance { const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const attempt = this.attemptStatusTracker(ctx); const storageKey = this.storageKey; + const destroyCount = this.destroyCount; return withRetry( async () => { attempt.start(); const freshData = await adapter.sendNext!(ctx, signal); - // As in runQuery(): a page for params that changed meanwhile. - if (this.storageKey !== storageKey) throw abortError(); + // As in runQuery(). + if (this.storageKey !== storageKey || this.destroyCount !== destroyCount) throw abortError(); this.updatedAt = Date.now(); const result = this.applyData(freshData, true, true); diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index addd558..1028572 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -941,6 +941,21 @@ describe('Responses that arrive after the client or the params moved on', () => expect(kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItem, undefined)))).toBeUndefined(); }); + it('writes nothing to the store after destroy() when the adapter ignores the abort', async () => { + const kv = new MemoryPersistentStore(); + const f = createFetch(20, { ignoreAbort: true }); + const client = makeClient(kv, f.fetch); + + activate(client, () => fetchQuery(GetItem).isPending); + await sleep(0); + client.destroy(); + await sleep(60); + + expect(f.paths()).toEqual(['/item(aborted)']); + expect(kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItem, undefined)))).toBeUndefined(); + expect(kv.getAllKeys().filter(k => k.startsWith('sq:doc:value:'))).toEqual([]); + }); + it('drops a fetchNext page for params that changed while it was in flight', async () => { class GetList extends RESTQuery { params = { id: t.string }; From 144b840f97427d18322d4ad59316ecfe901c07d7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:03:27 -0400 Subject: [PATCH 086/109] fix(fetchium): keep the other class's fields when a record read or write fails - The stored-record check is cleared after readEntity() returns, so a read that throws is retried at the next write instead of being skipped. - A failed async write is reported through onDelete. When the store still holds the record, the client now only marks the entity unwritten instead of dropping the fields it kept from that record. - SyncQueryStore.clear() notifies listeners over a copy, as the other notify sites do. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityStore.ts | 4 +- packages/fetchium/src/QueryClient.ts | 7 +- .../__tests__/hydrate-in-memory-shape.test.ts | 100 ++++++++++++++++++ packages/fetchium/src/stores/sync.ts | 3 +- 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index ca3bc55..3b4e61c 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -84,9 +84,9 @@ export class EntityStore { } if (instance._checkStoredRecord) { // The class may lack fields the record holds: read it once, on the - // first write. - instance._checkStoredRecord = false; + // first write. Cleared after the read, so a failed read is retried. const stored = this.readEntity?.(instance.key); + instance._checkStoredRecord = false; if (stored !== undefined) instance.noteRecord(stored); } const rest = instance.recordRestForWrite(); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 579e099..beb24f5 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -271,7 +271,12 @@ export class QueryClient { this.storeKnowsTypenameFields = typeof this.store.getEntityFieldNames === 'function' && this.store.entityFieldNamesComplete?.() === true; const offDelete = this.store.onDelete?.(key => { - this.entityMap.getEntity(key)?.recordDeleted(); + const instance = this.entityMap.getEntity(key); + if (instance === undefined) return; + // A failed write is reported too. If the record survived it, keep the + // fields kept from it. + if (this.store.hasEntity?.(key) === true) instance.recordDropped(); + else instance.recordDeleted(); }); if (typeof offDelete === 'function') this.storeUnsubscribes.push(offDelete); // A store that processes writes later says when a record is really there. diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts index 14a0b5f..990c3b0 100644 --- a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -714,6 +714,106 @@ describe('D. writes keep the fields another class sharing the typename declared' expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'L', ...kept }); }); + it('D8 a failed record read is retried at the next write, so the other class fields are kept', async () => { + const kv = new MemoryPersistentStore(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + await session(kv, f, async c => { + await start(c, () => fetchQuery(GetItem)); + }); + + class FlakyReadStore extends SyncQueryStore { + failNext = true; + readEntity(entityKey: number) { + if (this.failNext) { + this.failNext = false; + throw new Error('read failed'); + } + return super.readEntity(entityKey); + } + } + class GetFirst extends RESTQuery { + path = '/first'; + result = { items: t.array(t.entity(ItemSummary)) }; + } + class GetSecond extends RESTQuery { + path = '/second'; + result = { items: t.array(t.entity(ItemSummary)) }; + } + f.set('/first', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + f.set('/second', { items: [{ __typename: 'Item', id: 'i1', name: 'I3' }] }); + const c = makeCountingClient(new FlakyReadStore(kv), f); + + await Promise.resolve(start(c, () => fetchQuery(GetFirst))).catch(() => {}); + await start(c, () => fetchQuery(GetSecond)); + expect(recordOf(kv, 'Item', 'i1')).toMatchObject({ name: 'I3', details: { rating: 5 } }); + }); + + it('D9 a failed async write over a surviving record keeps the other class fields at the next write', async () => { + const data = new Map(); + let failNextWriteOf: string | undefined; + const delegate = { + has: async (k: string) => data.has(k), + getString: async (k: string) => data.get(k) as string | undefined, + setString: async (k: string, v: string) => { + if (k === failNextWriteOf) { + failNextWriteOf = undefined; + throw new Error('write failed'); + } + data.set(k, v); + }, + getNumber: async (k: string) => data.get(k) as number | undefined, + setNumber: async (k: string, v: number) => void data.set(k, v), + getBuffer: async (k: string) => data.get(k) as Uint32Array | undefined, + setBuffer: async (k: string, v: Uint32Array) => void data.set(k, v), + delete: async (k: string) => void data.delete(k), + getAllKeys: async () => [...data.keys()], + }; + const writerStore = () => + new AsyncQueryStore({ + isWriter: true, + delegate, + connect: handleMessage => ({ sendMessage: msg => handleMessage(msg) }), + }); + const drain = async (store: AsyncQueryStore) => { + for (let i = 0; i < 500 && !store.isSettled(); i++) await sleep(2); + }; + const record = () => JSON.parse(data.get(valueKeyFor(hashValue(['Item', 'i1']))) as string); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + + // Both classes write i1 in the first session, so the record holds the detail fields. + const first = writerStore(); + const c1 = makeCountingClient(first as any, f); + await start(c1, () => fetchQuery(GetItem)); + await start(c1, () => fetchQuery(GetItems)); + await drain(first); + c1.destroy(); + + // Next session: only the summary class, hydrated from its cache over that record. + const second = writerStore(); + const c2 = makeCountingClient(second as any, f); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await start(c2, () => fetchQuery(GetItems)); + await drain(second); + const update = (name: string) => + c2.applyMutationEvent({ type: 'update', typename: 'Item', data: { __typename: 'Item', id: 'i1', name } }); + + failNextWriteOf = valueKeyFor(hashValue(['Item', 'i1'])); + update('I2'); + await drain(second); + expect(record()).toMatchObject({ name: 'I', details: { rating: 5 } }); + + update('I3'); + await drain(second); + expect(record()).toMatchObject({ name: 'I3', details: { rating: 5 } }); + } finally { + consoleError.mockRestore(); + } + }); + it('D5 the stores write the kept fields with the record, the async writer included', async () => { const kv = new MemoryPersistentStore(); new SyncQueryStore(kv).saveEntity(7, { id: 'a', name: 'n' }, undefined, '"extra":{"x":1}'); diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index c008e79..e809aab 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -358,9 +358,8 @@ export class SyncQueryStore implements QueryStore { fieldNamesSinceByKv.set(kv, { at: 0, stored: true }); this.queues.clear(); this.mergeRests.clear(); - const listeners = this.deleteListeners; for (let i = 0; i < deleted.length; i++) { - for (let j = 0; j < listeners.length; j++) listeners[j](deleted[i]); + for (const listener of this.deleteListeners.slice()) listener(deleted[i]); } } From c3e458b763f16a38749f4594f9671288280fa81d Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:14:40 -0400 Subject: [PATCH 087/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 105 ++++-------------- packages/fetchium/src/LiveCollection.ts | 7 +- .../src/__benchmarks__/snapshot.bench.ts | 22 +--- .../__tests__/entity-change-detection.test.ts | 18 +-- .../src/__tests__/entity-snapshot.test.ts | 61 ++-------- .../fetchium/src/__tests__/same-value.test.ts | 11 +- packages/fetchium/src/applyEntities.ts | 38 ++----- 7 files changed, 45 insertions(+), 217 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index c13e8f7..6ef8d1d 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -98,13 +98,11 @@ const objectWrappingHandler: ProxyHandler> = { // Without this, the snapshot encounters the entity proxy (whose prototype is // the user-defined entity class), doesn't recognize it, and returns it as-is. // React then never re-renders on data changes because the proxy reference is -// stable. Reading the entity's fields establishes reactive dependencies on the -// entity's notifier and produces a plain-object snapshot whose unchanged -// subtrees keep stable references. Fields are read through each proxy's -// `EntitySnapshotSource`, since walking the proxy reads every field twice. +// stable. Reading the fields consumes the entity's notifier and yields a plain +// snapshot whose unchanged subtrees keep stable references. // ====================================================== -/** Trap-free access to one entity proxy's fields, registered by `createProxy`. */ +/** Trap-free access to one entity proxy's fields. */ interface EntitySnapshotSource { /** Identifies this proxy without retaining it. */ readonly id: number; @@ -120,11 +118,7 @@ const snapshotSources = new WeakMap(); let nextSnapshotSourceId = 1; -/** - * What a snapshot was produced from, so the next one can skip work. Holds the - * source's id, not the proxy: a snapshot outlives the entity it came from, and - * a strong reference here would make the entity graph reachable from it. - */ +/** Holds the source id, not the proxy, so a snapshot never retains the entity graph. */ interface SnapshotOrigin { sourceId: number; version: number; @@ -165,12 +159,7 @@ function asPrevObject(prev: unknown): Record | undefined { : undefined; } -/** - * Snapshot a raw field value. Nested entity proxies go back to Signalium so - * they consume their own notifier. The array and object walks mirror - * `snapshotArray`/`snapshotPlainObject`, which `signalium/utils` does not - * export; a test asserts they stay equal. - */ +/** Mirrors Signalium's unexported array and object walks. A test asserts they match. */ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unknown { if (value === null || typeof value !== 'object') return value; if (WRAPPED_VALUE.has(value)) return snapshotRawValue((value as { getValue(): unknown }).getValue(), prev, snap); @@ -189,9 +178,7 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn if (prevBySource !== undefined) { prevItem = prevBySource.get(itemSource.id); } else if (i < prevArr.length && !snapshotCameFrom(prevItem, itemSource.id)) { - // Positions shifted (an insert or re-sort), so pair by identity - // rather than handing each entity its neighbour's snapshot. Indices - // past the old length are appends and skip the index entirely. + // Positions shifted, so pair by identity rather than by index. prevBySource = indexSnapshotsBySource(prevArr); prevItem = prevBySource.get(itemSource.id); } @@ -221,7 +208,7 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn return snap(value, prev); } -/** Dev-only counters: losing the fast path is invisible to behavioral tests. */ +/** Dev-only: losing the fast path is invisible to behavioral tests. */ export let __debug_snapshotFieldReads = 0; export let __debug_snapshotFullWalks = 0; export function __debug_resetSnapshotCounters(): void { @@ -240,16 +227,14 @@ function walkFields( const key = fields[i]; const value = source.readField(key); if (IS_DEV) __debug_snapshotFieldReads++; - // Methods are bound to the proxy and cached on it, so identity is stable. + // Bound methods are cached, so their identity is stable. into[key] = typeof value === 'function' ? value : snapshotRawValue(value, prevObj?.[key], snap); } } const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unknown => { const source = snapshotSources.get(current); - // An `Entity` not built by `createProxy` has no fields to read (its own - // properties are the shape's type defs). Return it as-is, as Signalium does - // for any class it has no handler for. + // Not built by `createProxy`, so there are no fields to read. if (source === undefined) return current; // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -303,17 +288,7 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno return rememberSnapshot(source.id, changed ? result : prevObj!, version, keys); }; -/** - * Checks the fast path's assumptions: `data` only changes through a `notify()` - * that bumps `version`, and every field outside `keys.dynamic` is a pure - * function of `data`. A violation leaves a snapshot permanently stale, which no - * behavioral test catches. Only static fields are checked: re-reading a dynamic - * field isn't identity-stable, because snapshotting a child updates the state - * it pairs on. - * - * The re-read cancels the fast path's savings, so the speedup only shows in - * production builds. - */ +/** Dev check: a static field must not change while `version` stays put. */ function assertStaticFieldsUnchanged( source: EntitySnapshotSource, keys: EntityKeys, @@ -323,7 +298,6 @@ function assertStaticFieldsUnchanged( const verified: Record = {}; const readsBefore = __debug_snapshotFieldReads; walkFields(source, keys.static, before, snap, verified); - // Verification reads don't count against the fast path. __debug_snapshotFieldReads = readsBefore; for (const key of keys.static) { if (verified[key] !== before[key]) { @@ -360,13 +334,12 @@ export class EntityInstance { idField: string | symbol; data: Record; refCount: number = 0; - /** Whether this instance has been written to the store. */ _persisted: boolean = false; entityRefs: Map | undefined; liveCollections: LiveCollectionBinding[] = []; satisfiedDefs: WeakSet> = new WeakSet(); parseId: number = -1; - /** Bumped on `notify()`; see `assertStaticFieldsUnchanged` for the invariant. */ + /** Snapshots trust this to move whenever `data` changes. */ version: number = 0; _entityCache: { gcTime?: number } | undefined; _extraMethods: Record unknown> | undefined; @@ -513,34 +486,14 @@ function filterEntityArray(array: unknown[], innerDef: ValidatorDef, qu return result; } -// ====================================================== -// Static-field analysis (which fields a version check covers) -// ====================================================== - -/** - * Masks for a field that can change without its owning entity notifying. - * - * `defineUnion` ORs only its members' top-level masks, so - * `t.union(t.object({ child: t.entity(A) }), …)` carries no `ENTITY` bit. - * `computeIsStaticFieldDef` catches that by recursing into member defs. Never - * short-circuit a union on its mask alone. - * - * `HAS_FORMAT` because a formatted value's snapshot can go through a custom - * snapshot handler that reads other signals (a locale, say). - */ +// Fields that can change without the entity notifying (formats may read other signals). +// A union's mask omits its members' nested bits, so never trust a union's mask alone. const DYNAMIC_MASKS = Mask.ENTITY | Mask.LIVE | Mask.HAS_FORMAT; const staticFieldDefs = new WeakMap, boolean>(); -/** - * Whether a field's snapshot is a pure function of its entity's own `data`. - * Only the shapes recognised here (the allowlist `getEntityDef` validates - * against) answer `true`, so an unfamiliar def costs a re-read, never a stale - * value. - */ +/** Unrecognised defs answer false, which costs a re-read but never a stale value. */ function isStaticFieldDef(def: unknown): boolean { - // `t.string` is a bare `Mask`, `t.typename('X')` the literal string, - // `t.enum`/`t.const` a `Set` of allowed values. if (typeof def === 'number') return (def & DYNAMIC_MASKS) === 0; if (typeof def === 'string') return true; if (def instanceof Set) return true; @@ -548,8 +501,7 @@ function isStaticFieldDef(def: unknown): boolean { const cached = staticFieldDefs.get(def); if (cached !== undefined) return cached; - // Treat cycles as dynamic. A def reached only through one keeps that answer, - // which costs a read but never goes stale. + // Cycles resolve as dynamic. staticFieldDefs.set(def, false); const isStatic = computeIsStaticFieldDef(def); @@ -561,8 +513,6 @@ function computeIsStaticFieldDef(def: ValidatorDef): boolean { if (def._liveConfig !== undefined) return false; if ((def.mask & DYNAMIC_MASKS) !== 0) return false; - // No shape: primitive or union of literals. One inner def: array, - // record, parse result, optional/nullable clone. A record of defs: object. const shape = def.shape; if (shape === undefined || shape === null) return true; if (shape instanceof ValidatorDef) return isStaticFieldDef(shape); @@ -571,19 +521,11 @@ function computeIsStaticFieldDef(def: ValidatorDef): boolean { return Object.values(shape as Record).every(isStaticFieldDef); } -// ====================================================== -// Per-shape key metadata -// ====================================================== - -/** - * The key lists a proxy reports, shared per shape. `own` is `ownKeys`; - * `enumerable` drops the non-enumerable entity methods. - */ +/** Key lists a proxy reports, shared per shape. */ interface EntityKeys { own: string[]; enumerable: string[]; dynamic: string[]; - /** `enumerable` minus `dynamic` — the fields a version check covers. */ static: string[]; enumerableSet: Set; } @@ -620,7 +562,7 @@ function shapeKeys( return keys; } -/** Adds a query's late-attached methods and getters, which are always dynamic. */ +/** A query's late-attached methods and getters are always dynamic. */ function withExtraKeys( base: EntityKeys, extraMethods: Record | undefined, @@ -647,10 +589,6 @@ function withExtraKeys( }; } -// ====================================================== -// Field readers (module level so proxies share them, not per-proxy closures) -// ====================================================== - function bindMethod( prop: string, source: Record unknown>, @@ -667,7 +605,7 @@ function bindMethod( return bound; } -/** Narrow a shared-typename array to this field's def, cached on array identity. */ +/** Narrows a shared-typename array to this field's def. */ function narrowEntityArray( prop: string, value: unknown[], @@ -743,14 +681,12 @@ function createProxy( throw new Error(`typenameField "${typenameField}" must be declared in the entity shape`); } - // Shared per shape; only a query's root entity allocates its own lists. const baseKeys = shapeKeys(validatorDef, shapeFields, methods); let cachedMethods: Record | undefined; let cachedGetters: Record | undefined; let keys = baseKeys; function entityKeys(): EntityKeys { - // Track both slots, since these lists decide what a snapshot walks and re-reads. const methodsNow = instance._extraMethods; const gettersNow = instance._extraGetters; if (methodsNow !== cachedMethods || gettersNow !== cachedGetters) { @@ -764,8 +700,7 @@ function createProxy( return keys; } - // The `get` trap's value half, minus reactive bookkeeping and wrapping. - // Symbols, `toJSON` and `__context` never reach here. + // The `get` trap's value path, without tracking or wrapping. function readField(prop: string): unknown { if (prop === '__typename') return instance.typename; @@ -835,7 +770,7 @@ function createProxy( }, getOwnPropertyDescriptor(target, prop) { - // Same list the snapshot walks, so the two agree by construction. + // Same list the snapshot walks. if (typeof prop !== 'string') return undefined; if (entityKeys().enumerableSet.has(prop)) { return { enumerable: true, configurable: true, value: handler.get!(target, prop, proxy), writable: false }; diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index c83ce3c..32abc1e 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -35,7 +35,6 @@ function resolveEventDef( return undefined; } -/** Whether both lists hold the same entities in the same order. */ function sameMembers(oldItems: unknown[], newItems: unknown[]): boolean { if (oldItems.length !== newItems.length) return false; for (let i = 0; i < newItems.length; i++) { @@ -462,11 +461,7 @@ export class LiveValueInstance { return this._value; } - /** - * Unlike `LiveArrayInstance.reset`, this can't return early: the dedup sets - * must be cleared even when the value is unchanged, or a repeated `create` - * would apply its reducer twice. Only the notify is conditional. - */ + /** Clears the dedup sets even when unchanged, or a repeated `create` reduces twice. */ reset(value: unknown): boolean { const changed = this._value !== value; this._value = value; diff --git a/packages/fetchium/src/__benchmarks__/snapshot.bench.ts b/packages/fetchium/src/__benchmarks__/snapshot.bench.ts index bea5240..fa2e59a 100644 --- a/packages/fetchium/src/__benchmarks__/snapshot.bench.ts +++ b/packages/fetchium/src/__benchmarks__/snapshot.bench.ts @@ -9,14 +9,8 @@ import { Entity } from '../proxy.js'; import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; -/** - * What `useQuery` costs per render: `useReactive` deep-snapshots the query - * result on every dependency change, so this is the price of one re-render of - * a list consumer, without React's own reconciliation. - * - * Run with `npm run bench`. Benchmarks build with `IS_DEV: false` so the - * dev-only snapshot invariant check doesn't skew the numbers. - */ +// Cost of the deep snapshot `useQuery` takes per render, excluding React itself. +// Run with `npm run bench`. class Token extends Entity { __typename = t.typename('Token'); @@ -73,8 +67,7 @@ async function setup(count: number) { ], }); - // The listener is what activates the relay, exactly as a mounted consumer - // does; an unwatched signal never fetches and `await query` never settles. + // Without a listener the relay never activates and `await query` never settles. const { query, pull } = withContexts([[QueryClientContext, client]], () => { const q = fetchQuery(GetPortfolio, { count }); let prev: unknown; @@ -88,11 +81,6 @@ async function setup(count: number) { return { client, pull }; } -/** - * One fixture per size, built once. The measured body is only the work a - * streamed event triggers: apply the event, then pull the snapshot the way a - * mounted consumer would. - */ const fixtures = await Promise.all([100, 1000].map(async count => ({ count, ...(await setup(count)) }))); for (const { count, client, pull } of fixtures) { @@ -118,9 +106,7 @@ for (const { count, client, pull } of fixtures) { }); } -// Whole cold path: client construction, fetch, parse, apply, first snapshot. -// The snapshot is a small part of it and samples are noisy (±10-20% rme), so -// don't read a snapshot change off this. +// Noisy, and the snapshot is a small part of it. describe('cold query, first snapshot included', () => { for (const count of [100, 1000]) { bench(`${count} entities`, async () => { diff --git a/packages/fetchium/src/__tests__/entity-change-detection.test.ts b/packages/fetchium/src/__tests__/entity-change-detection.test.ts index 8c39119..a451a0d 100644 --- a/packages/fetchium/src/__tests__/entity-change-detection.test.ts +++ b/packages/fetchium/src/__tests__/entity-change-detection.test.ts @@ -7,13 +7,6 @@ import { RESTQuery } from '../rest/index.js'; import { fetchQuery } from '../query.js'; import { testWithClient, setupTestClient } from './utils.js'; -/** - * Entity Change Detection Tests - * - * A refetch or poll that returns data the store already holds notifies no one - * and writes nothing. A real change still notifies and persists. - */ - /** Records `typename:id` for every entity that notifies while active. */ function recordNotifies() { const notified: string[] = []; @@ -57,7 +50,6 @@ class GetList extends RESTQuery { result = { list: t.entity(List) }; } -/** A list whose members are named in order; `i-1`, `i-2`, ... */ function list(...names: string[]) { return { list: { @@ -113,8 +105,7 @@ describe('Entity Change Detection', () => { expect(recorder.notified).toEqual([]); expect(tokenSaves(saveEntity)).toHaveLength(0); - // The query's own freshness bookkeeping still has to be written, or the - // query would look stale forever and refetch on every read. + // Query freshness is still written, or the query would refetch on every read. expect(saveQuery).toHaveBeenCalled(); }); }); @@ -295,7 +286,6 @@ describe('Entity Change Detection', () => { recorder = recordNotifies(); - // Membership is unchanged, so the list stays quiet. mockFetch.get('/list', list('A', 'B2')); (query.value as unknown as { __refetch(): void }).__refetch(); await query; @@ -320,7 +310,6 @@ describe('Entity Change Detection', () => { const instance = client.entityMap.getEntity(hashValue(['Token', 'tok-0']))!; recorder = recordNotifies(); - // The no-op must leave the value intact; the real change must land it. client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', price: 0 } }); expect(recorder.notified).toEqual([]); expect(instance.data.price).toBe(0); @@ -364,12 +353,10 @@ describe('Entity Change Detection', () => { const owner = client.entityMap.getEntity(hashValue(['Owner', 'o-1']))!; recorder = recordNotifies(); - // The no-op must leave the value intact; the real change must land it. client.applyMutationEvent({ type: 'update', typename: 'Doc', data: doc('Ann') }); expect(recorder.notified).toEqual([]); expect(owner.data.name).toBe('Ann'); - // The parent's own fields and its ref set are unchanged, so it stays quiet. client.applyMutationEvent({ type: 'update', typename: 'Doc', data: doc('Bea') }); expect(recorder.notified).toEqual(['Owner:o-1']); expect(owner.data.name).toBe('Bea'); @@ -402,7 +389,6 @@ describe('Entity Change Detection', () => { (query.value as unknown as { __refetch(): void }).__refetch(); await query; - // A key-by-key copy can't apply a removal, so `b` would survive one. expect((query.value as unknown as Result).config.attrs).toEqual({ a: '1' }); expect(recorder.notified).toEqual(['Config:c-1']); }); @@ -420,8 +406,6 @@ describe('Entity Change Detection', () => { const instance = client.entityMap.getEntity(key)!; expect(instance._persisted).toBe(true); - // An entity applied with persist: false has nothing in the store to - // skip, so the next apply must write it whether or not data changed. instance._persisted = false; const saveEntity = vi.spyOn(store, 'saveEntity'); (query.value as unknown as { __refetch(): void }).__refetch(); diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index fe7d8a3..97af89a 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -14,22 +14,7 @@ import { fetchQuery } from '../query.js'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { setupTestClient } from './utils.js'; -/** - * Entity Snapshot Tests - * - * `useQuery` -> `useReactive` deep-snapshots the query result so React sees a - * plain, structurally-shared object tree. These tests pin the contract that - * walk relies on: the snapshot's key set matches the proxy's own enumerable - * keys, nothing proxied leaks into the result, and unchanged subtrees keep - * their identity across re-snapshots. - */ - -/** - * Holds a `useReactive`-style snapshot signal open for the whole test, so - * mutation events can be applied between reads. `testWithClient` can't be used - * here: it runs its body as one reactive consumer, and dirtying a signal that - * consumer already read is an error. - */ +/** Not `testWithClient`: dirtying a signal its consumer already read is an error. */ function snapshotHarness(client: QueryClient, start: () => T) { return withContexts([[QueryClientContext, client]], () => { const query = start(); @@ -38,7 +23,6 @@ function snapshotHarness(client: QueryClient, start: () => T) { snapshots.addListener(() => {}); return { query, - /** The snapshot of the resolved query result, as a component would see it. */ read: () => (snapshots.value as { value: Record }).value, }; }); @@ -104,8 +88,7 @@ describe('Entity Snapshots', () => { const tokenSnap = (snap.tokens as Record[])[0]; expect(Object.keys(tokenSnap)).toEqual(Object.keys(tokenProxy)); - // Entity methods are reported non-enumerable by the proxy, so they stay - // out of the snapshot; the query's own methods are enumerable and stay in. + // Entity methods are non-enumerable. Query methods are enumerable. expect(Object.keys(tokenSnap)).not.toContain('doubled'); expect(typeof snap.__refetch).toBe('function'); }); @@ -146,9 +129,7 @@ describe('Entity Snapshots', () => { expect(after).not.toBe(before); expect(afterTokens[2]).not.toBe(beforeTokens[2]); expect(afterTokens[2].price).toBe(999); - // The changed entity's untouched nested object keeps its identity... expect(afterTokens[2].metadata).toBe(beforeTokens[2].metadata); - // ...and so does every entity that didn't change. for (const i of [0, 1, 3, 4]) { expect(afterTokens[i], `token ${i}`).toBe(beforeTokens[i]); } @@ -371,9 +352,7 @@ describe('Entity Snapshots', () => { const after = read() as unknown as { feed: { events: { id: string }[] } }; expect(after.feed.events.map(e => e.id)).toEqual(['e-3', 'e-2', 'e-1']); - // Signalium pairs array items with the previous array's item at the same - // index, so after the insert every row is diffed against its neighbour's - // snapshot. Existing rows must still keep their identity. + // Signalium pairs items by index, so each row is offered its neighbour's snapshot. expect(after.feed.events[1]).toBe(before.feed.events[0]); expect(after.feed.events[2]).toBe(before.feed.events[1]); }); @@ -405,8 +384,6 @@ describe('Entity Snapshots', () => { const before = read(); const beforeTokens = before.tokens as Record[]; - // Two updates in a row: the second must not be swallowed by a snapshot - // that already decided the entity was unchanged. client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-0', symbol: 'FIRST' } }); const middle = read(); expect((middle.tokens as Record[])[0].symbol).toBe('FIRST'); @@ -422,11 +399,7 @@ describe('Entity Snapshots', () => { it('re-reads nested entities behind wrappers, plain objects and arrays', async () => { const { client, mockFetch } = getClient(); - // Shapes modelled on a real schema: primitive unions and enums collapse to - // bare masks, while an entity can sit behind optional/nullable wrappers, - // inside a plain object, or inside an array. The fast path re-reads only - // entity-bearing fields, so misclassifying one shows up as a nested entity - // update that never reaches the parent. + // A misclassified entity field shows up as a child update missing from the parent. const NumberLike = t.union(t.string, t.number); const OptionalNullableString = t.optional(t.nullable(t.string)); @@ -486,7 +459,7 @@ describe('Entity Snapshots', () => { expect(before.row.boxed.child.price).toBe(2); expect(before.row.list[0].price).toBe(3); - // Each of these changes only a child entity — Row itself never notifies. + // Only children change. Row itself never notifies. client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'w', price: 11 } }); client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'b', price: 22 } }); client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'l', price: 33 } }); @@ -495,8 +468,6 @@ describe('Entity Snapshots', () => { expect(after.row.wrapped.price).toBe(11); expect(after.row.boxed.child.price).toBe(22); expect(after.row.list[0].price).toBe(33); - // The parent's own inert fields are untouched, and the plain-object subtree - // that changed nothing keeps its identity. expect(after.row.count).toBe('7'); expect(after.row.deep).toBe(before.row.deep); expect(after.row.boxed.note).toBe('n'); @@ -560,21 +531,15 @@ describe('Entity Snapshots', () => { client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-7', price: 777 } }); read(); - // The root's own data didn't change, so it re-reads only its dynamic - // fields; of the 20 tokens only tok-7 is walked. The behavioral tests stay - // green without the fast path, so only these counts catch its loss. + // Only these counts catch a lost fast path. expect(__debug_snapshotFullWalks).toBe(1); expect(__debug_snapshotFieldReads).toBeLessThan(firstReads / 5); }); - // TODO: drop this once `signalium/utils` exports its structural walkers and - // `snapshotRawValue` can call them instead of reimplementing them. + // TODO: drop once `signalium/utils` exports its structural walkers. it('walks nested plain data the same way Signalium would', async () => { const { client, mockFetch } = getClient(); - // `snapshotRawValue` reimplements Signalium's unexported array and object - // walks. If the two ever diverge, structural sharing silently changes and - // only a re-render profile would show it. class Doc extends Entity { __typename = t.typename('Doc'); id = t.id; @@ -608,8 +573,6 @@ describe('Entity Snapshots', () => { const theirs = snapshot(raw('first'), undefined) as Body; expect(ours).toEqual(theirs); - // Same structural sharing on a change: the untouched subtrees keep - // identity in both implementations, the changed one does not. client.applyMutationEvent({ type: 'update', typename: 'Doc', data: { id: 'd-1', body: raw('second') } }); const oursNext = (read().doc as { body: Body }).body; const theirsNext = snapshot(raw('second'), theirs) as Body; @@ -652,8 +615,6 @@ describe('Entity Snapshots', () => { const before = read() as unknown as Snap; expect(before.board.slots.map(s => s?.id ?? null)).toEqual([null, 's-1', 's-2']); - // Dropping the empty leading slot shifts both entities left, and the slot - // they land on previously held `null` rather than a sibling's snapshot. mockFetch.get('/board', { board: { __typename: 'Board', id: 'b-1', slots: [slot('s-1'), slot('s-2')] }, }); @@ -674,10 +635,7 @@ describe('Entity Snapshots', () => { await query; read(); - // The failure the fast path cannot otherwise detect: `data` changed without - // a notify, or a field wrongly judged static. Touching a sibling is what - // makes the snapshot recompute; tok-0 then takes the fast path while its - // version still claims nothing moved. + // Mutate tok-0 without a notify, then touch a sibling to force a recompute. const instance = client.entityMap.getEntity(hashValue(['Token', 'tok-0']))!; const original = instance.data.symbol; instance.data.symbol = 'MUTATED'; @@ -685,8 +643,7 @@ describe('Entity Snapshots', () => { expect(() => read()).toThrow(/stale entity snapshot/); - // Put the entity back and let the version move, so the watcher's own - // recompute doesn't rethrow after the test has finished. + // Restore and notify so the watcher doesn't rethrow after the test. instance.data.symbol = original; instance.notify(); read(); diff --git a/packages/fetchium/src/__tests__/same-value.test.ts b/packages/fetchium/src/__tests__/same-value.test.ts index 89f6286..d7a326f 100644 --- a/packages/fetchium/src/__tests__/same-value.test.ts +++ b/packages/fetchium/src/__tests__/same-value.test.ts @@ -4,11 +4,7 @@ import { FormattedValue } from '../typeDefs.js'; import { PROXY_ID } from '../proxyId.js'; import type { EntityInstance } from '../EntityInstance.js'; -/** - * `sameValue` decides whether an apply notifies consumers and writes to the - * store, so a wrong `true` is a dropped update. Every branch answers `false` - * for anything it cannot compare confidently; these pin that direction. - */ +// A wrong `true` drops an update, so anything uncertain must compare unequal. const proxyLike = (key: number) => { const obj = {}; @@ -80,8 +76,6 @@ describe('sameValue', () => { } it('a key answered only by the prototype is not a match', () => { - // `in` would find `constructor` on Object.prototype and compare it against - // the left side's own `constructor`, reporting two different objects equal. expect(sameValue({ constructor: Object, x: 1 }, { x: 1, y: 2 })).toBe(false); expect(sameValue({ toString: 1, a: 2 }, { a: 2, b: 3 })).toBe(false); }); @@ -121,14 +115,11 @@ describe('sameRefs', () => { }); it('ignores ref counts, which the store does not persist', () => { - // `EntityStore.save` writes a key set; `setChildRefs` acts on a key - // appearing or disappearing. A count change writes identical bytes. expect(sameRefs(new Map([[a, 1]]), new Map([[a, 3]]))).toBe(true); }); }); describe('sameKeys', () => { - // A wrong `true` here leaves a key the server removed in the store. const cases: [string, Record, Record, boolean][] = [ ['both empty', {}, {}, true], ['same keys, same values', { a: 1 }, { a: 1 }, true], diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 25d8967..4fc57ba 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -119,7 +119,6 @@ function applyEntity( ? new Map(entityInstance.entityRefs) : new Map(); - // A refetch or poll that returns identical data neither notifies nor writes. let changed = true; if (isUpdate) { changed = mergeFields( @@ -158,7 +157,7 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); - // An instance this client hasn't saved yet is written even when unchanged. + // The store may not hold this instance yet, so write it regardless. const needsPersist = changed || refsChanged || !entityInstance._persisted; entityInstance.setChildRefs(newRefs, persist && needsPersist); @@ -220,9 +219,7 @@ function mergeFields( // payload fails validation, so every update carries the full variant. const isUnion = propShape instanceof ValidatorDef && (propShape.mask & Mask.UNION) !== 0; if (!isUnion && isPlainObject(newVal) && isPlainObject(oldVal)) { - // Only object/entity defs have a record of field defs as their shape. - // A record def's shape is its value type (a bare `Mask` for - // `t.record(t.string)`), which must not be recursed into as fields. + // A record def's shape is its value type, not field defs, so don't recurse into it. const nestedShape = propShape instanceof ValidatorDef && propShape.shape !== undefined && @@ -251,8 +248,7 @@ function mergeFields( } existingData[fieldKey] = oldVal; } else if (sameKeys(oldVal, newVal)) { - // Shapeless object (e.g. a record) with the same keys: copy only the - // fields that differ, so matching values keep their identity. + // Copy only differing fields so equal values keep their identity. for (const k of Object.keys(newVal)) { if (!sameValue(oldVal[k], newVal[k])) { oldVal[k] = newVal[k]; @@ -261,13 +257,11 @@ function mergeFields( } existingData[fieldKey] = oldVal; } else { - // A key was added or removed. Replace wholesale, since copying field - // by field can't apply a removal. + // Copying field by field can't apply a key removal. existingData[fieldKey] = newVal; changed = true; } } else if (!sameValue(oldVal, newVal)) { - // An equal value keeps the existing reference. existingData[fieldKey] = newVal; changed = true; } @@ -276,7 +270,6 @@ function mergeFields( return changed; } -/** Whether both records have the same set of own keys. */ export function sameKeys(a: Record, b: Record): boolean { const aKeys = Object.keys(a); if (aKeys.length !== Object.keys(b).length) return false; @@ -286,19 +279,14 @@ export function sameKeys(a: Record, b: Record) return true; } -/** - * Structural equality for parsed field values. Entity proxies compare by - * identity (one proxy per entity and shape), formatted values by the raw input - * they were built from, arrays and plain objects element by element. - */ +/** Structural equality. Proxies compare by identity, formatted values by raw input. */ export function sameValue(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (a instanceof FormattedValue || b instanceof FormattedValue) { return a instanceof FormattedValue && b instanceof FormattedValue && a._raw === b._raw; } - // Distinct proxies are distinct entities. Other exotic objects (a Date, a - // live collection binding, a class instance) fail the prototype check below. + // Distinct proxies are distinct entities. if (PROXY_ID.has(a) || PROXY_ID.has(b)) return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; @@ -312,20 +300,14 @@ export function sameValue(a: unknown, b: unknown): boolean { if (aKeys.length !== Object.keys(b).length) return false; for (let i = 0; i < aKeys.length; i++) { const key = aKeys[i]; - // `hasOwn`, not `in`: `in` would let `Object.prototype` answer for a key - // missing from `b`. + // Not `in`, which lets `Object.prototype` answer for a missing key. if (!Object.hasOwn(b, key)) return false; if (!sameValue((a as Record)[key], (b as Record)[key])) return false; } return true; } -/** - * Compares the ref *key set*, which is what a write would change: the store - * persists `refKeys` with no counts, and `setChildRefs` retains and releases on - * a key appearing or disappearing. Comparing counts would force a write of - * identical bytes. - */ +/** Compares key sets only. The store persists ref keys without counts. */ export function sameRefs( a: Map | undefined, b: Map | undefined, @@ -373,9 +355,7 @@ function initFields( } else { const val = data[fieldKey]; if (isPlainObject(val)) { - // Only object/entity defs have a record of field defs as their shape. - // A record def's shape is its value type (a bare `Mask` for - // `t.record(t.string)`), which must not be recursed into as fields. + // A record def's shape is its value type, not field defs, so don't recurse into it. const nestedShape = propShape instanceof ValidatorDef && propShape.shape !== undefined && From c33c1bb634c556c5cef4797773ce57a1e75616ef Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:15:05 -0400 Subject: [PATCH 088/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/NetworkManager.ts | 1 - packages/fetchium/src/QueryAdapter.ts | 7 +-- packages/fetchium/src/QueryClient.ts | 44 ++++-------------- packages/fetchium/src/QueryResult.ts | 34 +++----------- .../src/__tests__/poll-activity.test.ts | 12 +---- .../src/__tests__/reactivation-grace.test.ts | 45 +++++-------------- .../__tests__/topic-batch-delivery.test.ts | 9 +--- packages/fetchium/src/query-types.ts | 16 +------ .../__tests__/pause-reactivation.test.tsx | 5 --- .../fetchium/src/subscriptions/polling.ts | 18 +++----- .../fetchium/src/topic/TopicQueryAdapter.ts | 12 +---- 11 files changed, 39 insertions(+), 164 deletions(-) diff --git a/packages/fetchium/src/NetworkManager.ts b/packages/fetchium/src/NetworkManager.ts index de1b8b8..9712a53 100644 --- a/packages/fetchium/src/NetworkManager.ts +++ b/packages/fetchium/src/NetworkManager.ts @@ -39,7 +39,6 @@ export class NetworkManager { return this.onlineSignal.value; } - /** Offline-to-online transitions so far. */ reconnects = 0; /** Untracked copy of the signal's value, so writes don't consume it. */ private lastOnline: boolean; diff --git a/packages/fetchium/src/QueryAdapter.ts b/packages/fetchium/src/QueryAdapter.ts index 1d1953d..4fd3920 100644 --- a/packages/fetchium/src/QueryAdapter.ts +++ b/packages/fetchium/src/QueryAdapter.ts @@ -35,12 +35,7 @@ export type QueryAdapterClass = abstract export abstract class QueryAdapter { protected queryClient: IQueryClientForAdapter | undefined; - /** - * Set when the adapter merges requests sent close together into one (for - * example a multi-topic snapshot request). `reactivationStaggerMs` then - * starts this adapter's reactivation refetches together so they can still - * share a request. - */ + /** Set if the adapter merges nearby requests into one. Its reactivation refetches are then not staggered. */ declare readonly coalescesRequests?: boolean; /** diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 8cdb0b5..cbc4ab5 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -49,36 +49,16 @@ export interface QueryClientConfig { }; evictionMultiplier?: number; /** - * Milliseconds. A query that reactivates (a watcher returns, or a paused - * scope resumes) with data younger than this is not refetched, even if past - * its `staleTime`. Data a subscription pushed to (a `subscribe` event, or a - * topic event sent with its topic) counts as fresh from the last push. A - * `poll()` counts only through its fetches. Queries can override this with - * `reactivationGraceMs`. Network reconnects, `refetch()`, - * `invalidateQueries()`, `markStale()` and a failed last fetch still - * refetch. Default: 0 (every stale query refetches on reactivation). + * Ms. A reactivating query with data younger than this skips its stale refetch. + * Subscription pushes count as fresh data, `poll()` only through its fetches. + * Reconnects, `refetch()`, invalidation and a failed last fetch still refetch. Default: 0. */ reactivationGraceMs?: number; - /** - * Milliseconds. Reactivation refetches that start in the same task (for - * example every query on a screen that just resumed) are spread evenly - * across this window, in activation order, instead of all starting at once. - * Queries of an adapter that `coalescesRequests` are not spread. Default: 0 - * (all start together). - */ + /** Ms. Reactivation refetches started in the same task are spread evenly across this window. Default: 0. */ reactivationStaggerMs?: number; - /** - * Foreground/background source. When set, `poll()` stops its timers while - * the app is inactive and resumes them when it becomes active again. - * Default: undefined (polls run regardless of app state). - */ + /** Foreground/background source. `poll()` stops its timers while the app is inactive. */ activity?: ActivitySource; - /** - * Milliseconds. A `poll()` tick that is overdue when the app becomes active, - * or whose timer fires over a second late (as after the JS thread was - * suspended), fires at a random point within this window instead of - * immediately alongside every other overdue poll. Default: 0. - */ + /** Ms. Overdue `poll()` ticks on resume fire at a random point in this window, not all at once. Default: 0. */ pollResumeJitterMs?: number; } @@ -111,12 +91,9 @@ export class QueryClient { /** Without `store.onDelete`, `_persisted` cannot be trusted. */ storeReportsDeletes: boolean = false; - /** See `QueryClientConfig.reactivationGraceMs`. */ readonly reactivationGraceMs: number; - /** See `QueryClientConfig.reactivationStaggerMs`. */ readonly reactivationStaggerMs: number; - /** Queries whose reactivation refetch waits for the current task's stagger flush. */ private staggerQueue = new Set>(); private staggerTimer: ReturnType | undefined = undefined; @@ -144,7 +121,7 @@ export class QueryClient { this.store = store; this.reactivationGraceMs = nonNegative(reactivationGraceMs); this.reactivationStaggerMs = nonNegative(reactivationStaggerMs); - // `activity` and `pollResumeJitterMs` ride along in `rest`: poll() reads them from the context. + // poll() reads `activity` and `pollResumeJitterMs` from the context, via `rest`. this.context = { ...rest, log: log ?? console, evictionMultiplier }; this.gcManager = config.gcManager ?? @@ -488,12 +465,7 @@ export class QueryClient { // Reactivation stagger // ====================================================== - /** - * Queues a reactivation refetch. One flush per task spreads the queue evenly - * across `reactivationStaggerMs` in reactivation order, the first starting - * immediately. Queries of an adapter that `coalescesRequests` all start - * immediately. - */ + /** Queues a reactivation refetch. Each task's queue is spread across `reactivationStaggerMs`. */ scheduleReactivationRefetch(instance: QueryInstance): void { this.staggerQueue.add(instance); this.staggerTimer ??= setTimeout(this.flushStaggerQueue, 0); diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index c887e05..43023f6 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -50,23 +50,15 @@ export class QueryInstance { private _relayState: RelayState> | undefined = undefined; private _isActive: boolean = false; private wasPaused: boolean = false; - /** `networkManager.reconnects` at the last deactivation. */ private reconnectsAtDeactivate: number = 0; private currentParams: QueryParams | undefined = undefined; private debounceTimer: ReturnType | undefined = undefined; - /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ + /** Errored, not aborted. Disables the reactivation grace. */ private lastFetchFailed: boolean = false; - /** - * When the subscription last pushed data (a stream event, or a topic event - * for this query's topic). The reactivation grace measures from here when - * it is later than `updatedAt`. poll() delivers by refetching, so it moves - * `updatedAt` instead. A running subscription alone proves nothing: a poll - * that hasn't ticked, or one stopped in the background, kept nothing current. - */ + /** Not set by poll(): it delivers by refetching, which moves `updatedAt`. */ private lastPushAt: number | undefined = undefined; - /** Bumped by every fetch or fetchNext start. A queued reactivation refetch uses it to detect it was overtaken. */ + /** Lets a queued reactivation refetch detect that another fetch overtook it. */ private fetchStarts: number = 0; - /** `fetchStarts` when the pending reactivation refetch was queued. */ private reactivationQueuedAt: number = -1; // Invalidates on any signal consumed by getConfig() (such as @@ -208,8 +200,7 @@ export class QueryInstance { this.runQueryImmediately(); } else { const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; - // The grace covers a relay resuming, not a network reconnect: data - // may have been missed while offline, even while inactive. + // No grace after a reconnect: data may have been missed while offline. const withinGrace = activating && !wasPaused && @@ -408,8 +399,7 @@ export class QueryInstance { this.queryClient.getContext(), ); this._executionCtx.refetch = () => this.refetch(); - // `TopicQuery.getConfig.subscribe` hands this to its adapter, which calls - // it when it delivers an event for the query's topic. + // `TopicQuery.getConfig.subscribe` passes this to its adapter. (this._executionCtx as unknown as Record)._notePush = this.notePush; this._executionCtx.rawFetchNext = this.def.statics.rawFetchNext; // `TopicQuery.getConfig.subscribe` reads `_topicAdapter` from the ctx; @@ -488,13 +478,7 @@ export class QueryInstance { }, debounce + extraDelay); } - /** - * Starts a reactivation refetch after `delay` plus the query's debounce. - * The stagger flush calls this a task after queueing, so it rechecks that - * the query is still active. Any fetch started since queueing (`refetch()`, - * a poll tick, an invalidation) makes it redundant, so it is skipped rather - * than aborting and repeating that fetch. - */ + /** Runs a task after queueing. Skipped if a fetch started since, rather than aborting and repeating it. */ runReactivationRefetch(delay: number): void { if (!this._isActive || this.isPaused || this.relayState.isPending) return; const queuedAt = this.reactivationQueuedAt; @@ -511,7 +495,6 @@ export class QueryInstance { ); } - /** Records that the subscription delivered data. See `lastPushAt`. */ private notePush = (): void => { if (this._isActive) this.lastPushAt = Date.now(); }; @@ -620,11 +603,6 @@ export class QueryInstance { return Date.now() - this.updatedAt >= staleTime; } - /** - * Data is younger than the reactivation grace and the last fetch didn't - * fail. Pushed data counts from its last push. Invalidation (`updatedAt = 0`) - * always falls outside. - */ private get isWithinReactivationGrace(): boolean { const { updatedAt } = this; if (updatedAt === undefined || updatedAt === 0 || this.lastFetchFailed) return false; diff --git a/packages/fetchium/src/__tests__/poll-activity.test.ts b/packages/fetchium/src/__tests__/poll-activity.test.ts index 53c82eb..f48e378 100644 --- a/packages/fetchium/src/__tests__/poll-activity.test.ts +++ b/packages/fetchium/src/__tests__/poll-activity.test.ts @@ -9,12 +9,6 @@ import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; import { poll } from '../subscriptions/polling.js'; import { createMockFetch, createTestWatcher } from './utils.js'; -/** - * Focus-aware polling: with an `activity` source, poll() stops its timers - * while the app is inactive, and spreads overdue ticks across - * `pollResumeJitterMs` when it becomes active again. - */ - const INTERVAL = 1_000; class GetPolled extends RESTQuery { @@ -110,7 +104,7 @@ describe('poll() with an activity source', () => { return unsub; } - /** Mounts the queries and lets the initial fetch settle; poll ticks are then due at INTERVAL. */ + /** Poll ticks are then due at INTERVAL. */ async function mount(client: QueryClient, ...queries: Array RESTQuery>): Promise<() => void> { t0 = Date.now(); const unsub = watch(client, ...queries); @@ -199,7 +193,6 @@ describe('poll() with an activity source', () => { ['/polled-b', 270], ]); - // Then back on the regular interval, from each tick. await vi.advanceTimersByTimeAsync(INTERVAL); expect(count('/polled')).toBe(2); expect(count('/polled-b')).toBe(2); @@ -228,10 +221,9 @@ describe('poll() with an activity source', () => { // Jump the clock 10 s without running timers, as a frozen JS thread would. vi.setSystemTime(Date.now() + 10_000); const jumpedAt = Date.now() - t0; - // The timer fires at its scheduled point (~INTERVAL - 5 from here) but doesn't fetch. await vi.advanceTimersByTimeAsync(INTERVAL); expect(count()).toBe(0); - // It fetches at the jitter point instead: 0.5 * 300. + // It fetches at the jitter point, 0.5 * 300, after its scheduled time. await vi.advanceTimersByTimeAsync(200); expect(count()).toBe(1); expect(Math.abs(starts[0].at - jumpedAt - (INTERVAL - 5 + 150))).toBeLessThanOrEqual(1); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 00755d4..244bb7f 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -15,12 +15,6 @@ import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import { Entity } from '../proxy.js'; import { createMockFetch, createTestWatcher } from './utils.js'; -/** - * A reactivating query skips its refetch while its data is younger than - * `reactivationGraceMs`. Reactivation refetches that start together are spread - * across `reactivationStaggerMs`. - */ - class GetA extends RESTQuery { path = '/a'; result = { n: t.number }; @@ -58,8 +52,7 @@ describe('reactivation grace and stagger', () => { afterEach(async () => { for (const unsub of unsubs) unsub(); for (const client of clients) client.destroy(); - // Drain signalium's pending flush on the fake clock; a flush left scheduled - // there would block every later flush once real timers are restored. + // Drain signalium's flush on the fake clock, or it blocks later flushes under real timers. await vi.advanceTimersByTimeAsync(1_000); vi.useRealTimers(); }); @@ -82,7 +75,6 @@ describe('reactivation grace and stagger', () => { return client; } - /** Activates a watcher over the given queries' values; returns its unsubscribe. */ function watch(client: QueryClient, ...queries: Array RESTQuery>): () => void { const { unsub } = withContexts([[QueryClientContext, client]], () => createTestWatcher(() => queries.map(Q => fetchQuery(Q as any).value)), @@ -91,7 +83,6 @@ describe('reactivation grace and stagger', () => { return unsub; } - /** Activates, loads, then deactivates (as a pause would). */ async function loadThenDeactivate(client: QueryClient, ...queries: Array RESTQuery>): Promise { const unsub = watch(client, ...queries); await vi.advanceTimersByTimeAsync(50); @@ -99,12 +90,11 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(10); } - /** Fetch start times relative to the first one. */ function offsets(): number[] { return starts.map(s => s.at - starts[0].at); } - /** Timer 0 counts as 1 ms on the fake clock, so allow a millisecond either way. */ + // Timer 0 counts as 1 ms on the fake clock. function expectOffsets(expected: number[]): void { const actual = offsets(); expect(actual).toHaveLength(expected.length); @@ -139,7 +129,6 @@ describe('reactivation grace and stagger', () => { unsub(); await vi.advanceTimersByTimeAsync(10); - // Past the grace (and the default staleTime of 0): refetches. await vi.advanceTimersByTimeAsync(10_000); watch(client, GetA); await vi.advanceTimersByTimeAsync(50); @@ -240,7 +229,7 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - // A refetch fails; updatedAt still dates from the first, successful fetch. + // updatedAt still dates from the first, successful fetch. await withContexts([[QueryClientContext, client]], async () => { const relay = fetchQuery(GetA); await (relay.value as any).__refetch().catch(() => {}); @@ -280,11 +269,10 @@ describe('reactivation grace and stagger', () => { let unsub = watchRelay(); await vi.advanceTimersByTimeAsync(50); - // A slow refetch, which the mock lets finish even after it is aborted. + // The mock lets this finish even after it is aborted. void relay.value.__refetch().catch(() => {}); await vi.advanceTimersByTimeAsync(10); - // Deactivating aborts it; reactivating replaces it with a fetch that fails. unsub(); await vi.advanceTimersByTimeAsync(10); unsub = watchRelay(); @@ -292,7 +280,6 @@ describe('reactivation grace and stagger', () => { expect(fetchCount('/a')).toBe(3); expect(relay.isRejected).toBe(true); - // The replaced fetch lands. await vi.advanceTimersByTimeAsync(1_500); unsub(); @@ -323,7 +310,7 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - // Data is a minute old, but the stream delivered an event a moment ago. + // Data is a minute old, but the stream pushed a moment ago. await vi.advanceTimersByTimeAsync(60_000); push!(); await vi.advanceTimersByTimeAsync(1_000); @@ -334,7 +321,6 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - // Silent since: the grace counts from the last push, not the deactivation. await vi.advanceTimersByTimeAsync(20_000); unsub2(); await vi.advanceTimersByTimeAsync(10); @@ -374,8 +360,7 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(1); - // Visits shorter than the poll interval, gaps shorter than the grace. The - // poll restarts its interval each visit, so it never ticks. + // Each visit restarts the poll interval, so it never ticks. for (let i = 0; i < 20; i++) { await vi.advanceTimersByTimeAsync(50_000); unsub(); @@ -384,7 +369,6 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(10); } - // With a 60 s poll and staleTime 0, the data is never older than a visit. const lastFetch = starts.filter(s => s.path === '/a').at(-1)!.at; expect(Date.now() - lastFetch).toBeLessThan(60_000); expect(fetchCount('/a')).toBe(21); @@ -413,17 +397,15 @@ describe('reactivation grace and stagger', () => { const client = makeClient({ reactivationGraceMs: 15_000, activity }); const unsub = watch(client, GetPolled); await vi.advanceTimersByTimeAsync(35_000); - // The initial fetch and ticks at 10, 20 and 30 s. expect(fetchCount('/a')).toBe(4); - // Backgrounded: the poll stops. The screen goes away a minute later. setActive(false); await vi.advanceTimersByTimeAsync(60_000); unsub(); await vi.advanceTimersByTimeAsync(10); setActive(true); - // Within the grace of the deactivation, but the last tick was 65 s ago. + // Deactivated 10 ms ago, but the last tick was 65 s ago. watch(client, GetPolled); await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(5); @@ -466,12 +448,10 @@ describe('reactivation grace and stagger', () => { await visit('prices'); expect(adapter.sends).toBe(1); - // Pushed on its topic 6 s ago: inside the grace. await visit(undefined); expect(adapter.sends).toBe(1); - // The last event didn't name the topic, so the query's last delivery is - // the push 67 s ago. + // The last event didn't name the topic, so the last push was 67 s ago. watch(client, GetTopic as any); await vi.advanceTimersByTimeAsync(50); expect(adapter.sends).toBe(2); @@ -559,7 +539,6 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(400); expect(starts.map(s => s.path)).toEqual(['/a', '/b', '/c']); expectOffsets([0, 100, 200]); - // The first one waits only for the flush task. expect(starts[0].at - t0).toBeLessThanOrEqual(2); }); @@ -644,7 +623,7 @@ describe('reactivation grace and stagger', () => { watch(client, GetCoalescedA, GetC, GetCoalescedB); await vi.advanceTimersByTimeAsync(400); - // One spread query: it starts with the flush too. + // A lone spread query starts with the flush too. expect(starts.map(s => s.path).sort()).toEqual(['/a', '/b', '/c']); expectOffsets([0, 0, 0]); }); @@ -675,7 +654,6 @@ describe('reactivation grace and stagger', () => { watch(client, GetB, GetCoalesced, GetC); await vi.advanceTimersByTimeAsync(400); - // The coalesced query starts with the flush; the other two take the two slots. expect(starts.map(s => s.path)).toEqual(['/a', '/b', '/c']); expectOffsets([0, 0, 150]); }); @@ -692,14 +670,13 @@ describe('reactivation grace and stagger', () => { watch(client, GetA, GetB, GetC); await vi.advanceTimersByTimeAsync(100); - // /c's slot is 2 s out. A pull to refresh starts it now. + // /c's slot is 2 s out. await withContexts([[QueryClientContext, client]], async () => { void (fetchQuery(GetC).value as any).__refetch(); }); await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/c')).toBe(1); - // The slot passes while that fetch is in flight, and after it landed. await vi.advanceTimersByTimeAsync(2_500); expect(fetchCount('/c')).toBe(1); expect(fetchCount('/a')).toBe(1); @@ -729,7 +706,7 @@ describe('reactivation grace and stagger', () => { createTestWatcher(() => [fetchQuery(GetA).value, fetchQuery(GetB).value, (value = fetchQuery(GetItems).value)]), ); unsubs.push(unsub); - // /items' slot is 2 s out. Load the next page now. + // /items' slot is 2 s out. await vi.advanceTimersByTimeAsync(100); await value.__fetchNext(); expect(value.items.map((i: any) => i.id)).toEqual(['1', '2']); diff --git a/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts b/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts index 9aa4470..75ca5bd 100644 --- a/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts +++ b/packages/fetchium/src/__tests__/topic-batch-delivery.test.ts @@ -9,11 +9,7 @@ import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; import { sleep } from './utils.js'; -/** - * Topic snapshots delivered in one task (for example one multi-topic snapshot - * response) settle their queries in a single reactive flush. Snapshots from - * separate tasks flush separately. Batching those is the adapter's job. - */ +// Snapshots from separate tasks flush separately. Batching those is the adapter's job. class SnapshotAdapter extends TopicQueryAdapter { subscribe(_topic: string): void {} @@ -57,8 +53,7 @@ describe('batched topic snapshot delivery', () => { adapters: [adapter], }); - // One watcher per query; a flush runs every dirty watcher's listener, so - // count distinct flushes by the task they ran in. + // A flush runs every dirty watcher's listener, so count flushes by task. let flushes = 0; let flushScheduled = false; const latest: unknown[] = QUERIES.map(() => undefined); diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 79b0c29..07a53a3 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -7,12 +7,8 @@ import { QueryDefinition } from './query.js'; // Query Types // ----------------------------------------------------------------------------- -/** - * Reports whether the app is in the foreground. Supplied by the host (for - * React Native, wrap `AppState`), since Fetchium cannot observe it itself. - */ +/** Host-supplied foreground state, e.g. wrapping React Native's `AppState`. */ export interface ActivitySource { - /** `true` while the app is foregrounded and background work should run. */ isActive(): boolean; /** Calls `listener` whenever `isActive()` may have changed. Returns an unsubscribe function. */ subscribe(listener: () => void): () => void; @@ -26,9 +22,7 @@ export interface QueryContext { debug?: (message: string) => void; }; evictionMultiplier?: number; - /** See `QueryClientConfig.activity`. */ activity?: ActivitySource; - /** See `QueryClientConfig.pollResumeJitterMs`. */ pollResumeJitterMs?: number; } @@ -62,13 +56,7 @@ export interface QueryConfigOptions { networkMode?: NetworkMode; // default: NetworkMode.Online retry?: RetryConfig | number | boolean; // default: 3 on client, 0 on server refreshStaleOnReconnect?: boolean; // default: true - /** - * Milliseconds. When the query reactivates (a watcher returns, or a paused - * scope resumes) with data younger than this, it is not refetched even if - * stale. Data a subscription pushed to counts as fresh from the last push. - * Overrides `QueryClientConfig.reactivationGraceMs`. Does not affect network - * reconnects, `refetch()`, or invalidation. - */ + /** Ms. Overrides `QueryClientConfig.reactivationGraceMs` for this query. */ reactivationGraceMs?: number; subscribe?: (this: any, onEvent: (event: import('./types.js').MutationEvent) => void) => () => void; } diff --git a/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx b/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx index 5ad3bc0..e7b7fd8 100644 --- a/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx +++ b/packages/fetchium/src/react/__tests__/pause-reactivation.test.tsx @@ -10,11 +10,6 @@ import { fetchQuery } from '../../query.js'; import { createMockFetch, sleep } from '../../__tests__/utils.js'; import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; -/** - * Resuming a screen paused by `PauseSignalsProvider` reactivates its queries. - * With `reactivationGraceMs`, data younger than the grace is not refetched. - */ - class GetItem extends RESTQuery { path = '/item'; result = { n: t.number }; diff --git a/packages/fetchium/src/subscriptions/polling.ts b/packages/fetchium/src/subscriptions/polling.ts index d678869..2f47130 100644 --- a/packages/fetchium/src/subscriptions/polling.ts +++ b/packages/fetchium/src/subscriptions/polling.ts @@ -3,19 +3,12 @@ import type { QueryContext } from '../query-types.js'; const MIN_INTERVAL = 100; -/** - * A tick this late means its timer was frozen (React Native suspends JS timers - * in the background), so every other poll is overdue too and the tick gets - * jittered. A busy JS thread delays timers by far less. - */ +// A tick this late means timers were frozen in the background, so every poll is overdue. const LATE_TICK_THRESHOLD = 1000; export interface PollConfig { interval: number; - /** - * Overrides `QueryClientConfig.pollResumeJitterMs` for this poll: the window - * an overdue tick is spread across when the app becomes active again. - */ + /** Overrides `QueryClientConfig.pollResumeJitterMs` for this poll. */ resumeJitterMs?: number; } @@ -36,7 +29,7 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE let active = true; let timer: ReturnType | undefined; let inFlight = false; - /** When the next tick is due. Kept while the timer is stopped, so resuming knows what is overdue. */ + /** Kept while the timer is stopped, so resuming knows what is overdue. */ let dueAt = Date.now() + interval; const refetch = this.refetch as () => Promise; @@ -52,7 +45,6 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE timer = setTimeout(tick, delay); }; - /** Reschedules an overdue tick at a random point within the jitter window. */ const scheduleOverdue = (): void => { schedule(jitterWindow > 0 ? Math.floor(Math.random() * jitterWindow) : 0); }; @@ -65,7 +57,7 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE const tick = async (): Promise => { timer = undefined; if (!active) return; - // Leave the tick overdue. The activity listener reschedules it on resume. + // Stay overdue. The activity listener reschedules on resume. if (!isAppActive()) return; if (jitterWindow > 0 && Date.now() - dueAt > LATE_TICK_THRESHOLD) { scheduleOverdue(); @@ -94,7 +86,7 @@ export function poll(config: PollConfig): (this: any, onEvent: (event: MutationE stopTimer(); return; } - // A refetch in flight schedules the next tick itself when it settles. + // An in-flight refetch schedules the next tick itself. if (timer !== undefined || inFlight) return; const remaining = dueAt - Date.now(); if (remaining > 0) { diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index a4c278e..ed7ac1a 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -23,7 +23,6 @@ interface TopicState { export abstract class TopicQueryAdapter extends QueryAdapter { private _topics = new Map(); - /** Per topic, the subscribed queries' callbacks for a delivered event. */ private _pushListeners = new Map void>>(); /** @@ -130,9 +129,7 @@ export abstract class TopicQueryAdapter extends QueryAdapter { /** * Convenience wrapper — pushes a mutation event through the QueryClient * so that entities and live collections are updated reactively. - * - * Pass the `topic` the event arrived on to mark that topic's queries as - * current, so their `reactivationGraceMs` measures from this event. + * Pass `topic` so its queries' `reactivationGraceMs` counts from this event. */ protected sendMutationEvent(event: MutationEvent, topic?: string): void { if (topic !== undefined) { @@ -142,12 +139,7 @@ export abstract class TopicQueryAdapter extends QueryAdapter { this.queryClient!.applyMutationEvent(event); } - /** - * Registers a subscribed query's callback for events delivered on `topic`. - * Returns a function that removes it. - * - * @internal - */ + /** @internal */ _addPushListener(topic: string, listener: () => void): () => void { let listeners = this._pushListeners.get(topic); if (listeners === undefined) { From fa11a4da7cb918842b33057adf9a81820f8146c2 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:15:12 -0400 Subject: [PATCH 089/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 46 ++--------- packages/fetchium/src/EntityStore.ts | 5 +- packages/fetchium/src/QueryClient.ts | 53 ++---------- .../shared-typename-stream.bench.ts | 12 +-- .../__tests__/hydrate-in-memory-shape.test.ts | 81 +++---------------- .../skip-write-and-snapshot-fixes.test.ts | 7 +- packages/fetchium/src/applyEntities.ts | 36 ++------- packages/fetchium/src/parseEntities.ts | 38 ++------- packages/fetchium/src/query-types.ts | 33 +++----- .../hydrate-in-memory-shape.test.tsx | 7 +- packages/fetchium/src/stores/async.ts | 7 +- packages/fetchium/src/stores/shared.ts | 12 +-- packages/fetchium/src/stores/sync.ts | 42 +++------- 13 files changed, 72 insertions(+), 307 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 8ec7d73..7579007 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -13,17 +13,11 @@ import type { LiveCollectionBinding } from './LiveCollection.js'; import { entitySatisfiesShape } from './parseEntities.js'; import { recordRestOutside } from './stores/shared.js'; -/** - * Fields of an entity's stored record that no class applied to the instance - * declares, written by another class sharing the typename. Kept as raw JSON so - * the instance's writes don't drop them. - */ +/** Stored record fields written by another class of the typename, kept so writes don't drop them. */ export interface RecordRest { - /** The fields kept. */ keys: string[]; - /** Their raw values as a JSON object body (`"a":1,"b":{…}`), written as is. */ + /** Raw JSON object body (`"a":1,"b":{…}`), written as is. */ json: string; - /** The `{ __entityRef }` keys inside them, which the record still references. */ refIds: number[]; } @@ -641,23 +635,12 @@ export class EntityInstance { _deferredWrite: boolean = false; /** The store is known to hold a record of this entity (hydrated from it, or written to it). */ _recorded: boolean = false; - /** The stored record's fields this instance does not hold; its writes carry them. */ _recordRest: RecordRest | undefined = undefined; - /** - * The stored record, held until `_recordRest` is taken from it at the first - * write so a cold start pays nothing. Kept from hydration only for stores - * that can't re-read it synchronously. - */ + /** Raw stored record, turned into `_recordRest` lazily at the first write. */ private _storedRecord: Record | undefined = undefined; - /** - * The stored record may hold another class's fields, so the first write - * reads it (synchronous stores) and keeps them. - */ + /** The first write must re-read the stored record (synchronous stores only). */ _checkStoredRecord: boolean = false; - /** - * `entityRefs` were counted from the whole data by a full payload, and not - * since changed by a partial update or a live collection. - */ + /** `entityRefs` came from a full payload, untouched since by partial updates. */ _refsCounted: boolean = false; private _saving: boolean = false; entityRefs: Map | undefined; @@ -881,7 +864,6 @@ export class EntityInstance { this.markUnwritten(); } - /** The store deleted this entity's record: drop the fields kept from it. */ recordDeleted(): void { this._recordRest = undefined; this._storedRecord = undefined; @@ -889,16 +871,11 @@ export class EntityInstance { this.recordDropped(); } - /** Whether this instance keeps, or will read, fields of its stored record. */ keepsRecordFields(): boolean { return this._recordRest !== undefined || this._storedRecord !== undefined || this._checkStoredRecord; } - /** - * The stored record holds fields the data does not: keep them. A rereadable - * (synchronous) store's record is read again at the first write instead of - * held in memory. - */ + /** A rereadable store's record is read again at the first write instead of held in memory. */ recordHoldsOtherFields(record: Record, rereadable: boolean): void { if (rereadable) { this._storedRecord = undefined; @@ -909,20 +886,13 @@ export class EntityInstance { } } - /** - * Keeps the stored record (raw, as parsed) so the instance's writes carry - * the fields its data lacks. They are taken from it at the first write. A - * field the data holds, even as `undefined`, is the instance's to write. - */ + /** A field the data holds, even as `undefined`, is the instance's to write. */ noteRecord(record: Record): void { this._storedRecord = record; this._recordRest = undefined; } - /** - * The kept record fields to write with the data, minus any an applied class - * now declares (the data holds those itself). - */ + /** Drops kept fields that an applied class now declares. */ recordRestForWrite(): RecordRest | undefined { const data = this.data; const record = this._storedRecord; diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 3b4e61c..4d31af3 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -8,7 +8,7 @@ export class EntityStore { private persistEntity: PersistEntity; /** Whether the store can merge fields over a record it holds. */ mergesEntities: boolean = false; - /** A synchronous store's read of an entity's stored record, if it offers one (see `QueryStore.readEntity`). */ + /** Synchronous stores only. */ readEntity: ((key: number) => Record | undefined) | undefined = undefined; constructor(persistEntity: PersistEntity) { @@ -83,8 +83,7 @@ export class EntityStore { return; } if (instance._checkStoredRecord) { - // The class may lack fields the record holds: read it once, on the - // first write. Cleared after the read, so a failed read is retried. + // Cleared after the read so a read that throws is retried. const stored = this.readEntity?.(instance.key); instance._checkStoredRecord = false; if (stored !== undefined) instance.noteRecord(stored); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index beb24f5..b1b1f7a 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -273,8 +273,7 @@ export class QueryClient { const offDelete = this.store.onDelete?.(key => { const instance = this.entityMap.getEntity(key); if (instance === undefined) return; - // A failed write is reported too. If the record survived it, keep the - // fields kept from it. + // Failed writes are reported too. A surviving record keeps its fields. if (this.store.hasEntity?.(key) === true) instance.recordDropped(); else instance.recordDeleted(); }); @@ -404,42 +403,22 @@ export class QueryClient { this.noteTypenameFields(typename, def); } - /** - * Per entity def: whether another class of its typename declares a field it - * lacks, this session or (via the store) an earlier one. Its records may - * hold fields it does not parse, which its writes must keep. - */ + /** Per def: whether another class of its typename, in any session, declares a field it lacks. */ private foreignFieldDefs = new WeakMap, boolean>(); - /** Per typename: every top-level field its classes have declared (see `foreignFieldDefs`). */ private typenameFields = new Map>(); - /** Per entity def: the fields other classes of its typename declare that can hold an entity (see `foreignRefFields`). */ private foreignRefFieldsByDef = new WeakMap, readonly string[]>(); - /** Whether any registered def lacks a field of its typename; false for an app whose typenames each have one class. */ hasForeignFieldDefs: boolean = false; - /** - * Whether the store remembers each typename's field names across sessions - * (`getEntityFieldNames`) and they cover every class that wrote a record it - * holds (`entityFieldNamesComplete`), so a class not registered this - * session is still known to `mayMissForeignFields`. - */ + /** The store's remembered field names cover every class that wrote a record it holds. */ readonly storeKnowsTypenameFields: boolean; - /** - * Whether a stored record of this def's typename may hold fields the def - * does not declare. Registers the def first if needed, since a hydration - * parse runs before the apply registers it. - * - * @internal - */ + /** Registers the def first, since a hydration parse runs before the apply registers it. @internal */ mayMissForeignFields(def: ValidatorDef): boolean { let verdict = this.foreignFieldDefs.get(def); if (verdict === undefined) { this.registerEntityDef(def); verdict = this.foreignFieldDefs.get(def); if (verdict === undefined) { - // Not a class but a typename's merged def (a streamed event's root). - // Registering a new class replaces the merged def, so this verdict - // cannot go stale. + // A typename's merged def. A new class replaces it, so this can't go stale. const fields = def.typenameValue !== undefined ? this.typenameFields.get(def.typenameValue) : undefined; const shape = def.shape as Record | undefined; verdict = fields !== undefined && shape !== undefined && fields.size > Object.keys(shape).length; @@ -449,14 +428,7 @@ export class QueryClient { return verdict; } - /** - * Top-level fields that other classes of this def's typename declare, this - * def does not, and that can hold an entity. Only these can hold child - * references that a full payload of this def leaves in the data. Cached per - * def until another class of the typename registers. - * - * @internal - */ + /** Entity-holding fields other classes of the typename declare and this def lacks. @internal */ foreignRefFields(def: ValidatorDef): readonly string[] { let names = this.foreignRefFieldsByDef.get(def); if (names === undefined) { @@ -477,11 +449,6 @@ export class QueryClient { return names; } - /** - * Folds a newly registered def's fields into its typename's field set and - * the store's, then re-derives which of the typename's defs lack one. Runs - * once per def and client. - */ private noteTypenameFields(typename: string, def: ValidatorDef): void { let fields = this.typenameFields.get(typename); if (fields === undefined) { @@ -496,14 +463,10 @@ export class QueryClient { added = true; } } - // Every declared name, not just the new ones: the store forgets names no - // class has declared for a while. + // All names, not just new ones: the store forgets names undeclared for a while. this.store.addEntityFieldNames?.(typename, declared); - // Every registered def's fields are in the set, so a def lacks one of - // them exactly when the set is larger than its shape. for (const d of this.typenameRegistry.get(typename)!) { - // A new class may declare a known field name with an entity-holding - // def, so every def's ref fields recompute. + // A known name may now have an entity-holding def. this.foreignRefFieldsByDef.delete(d); if (!added && d !== def) continue; const misses = fields.size > Object.keys(d.shape as Record).length; diff --git a/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts index 5d9adbb..beed360 100644 --- a/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts +++ b/packages/fetchium/src/__benchmarks__/shared-typename-stream.bench.ts @@ -8,14 +8,8 @@ import { Entity } from '../proxy.js'; import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; -/** - * Streamed full-payload updates of in-memory entities whose typename two - * classes share: a list class and a detail class with extra fields. The detail - * class wrote the records in an earlier session, so every list-entity write - * must keep the detail's fields. A single-class typename is the control. - * - * Run with `npm run bench`. - */ +// Streamed full updates where list and detail classes share a typename. +// Each list write must keep the detail's fields. Run with `npm run bench`. const meta = { logo: t.string, website: t.string, tags: t.array(t.string) }; @@ -130,12 +124,10 @@ async function hold(c: QueryClient, start: () => unknown): Promise { const N = 100; const kv = new MemoryPersistentStore(); -// Session 1: the detail class writes every record. const first = client(kv, N); for (let i = 0; i < N; i++) await hold(first, () => fetchQuery(GetMarket, { id: `m-${i}` })); first.destroy(); -// Session 2: the list class holds the same entities in memory. const c = client(kv, N); await hold(c, () => fetchQuery(GetMarkets, { n: N })); await hold(c, () => fetchQuery(GetPlain, { n: N })); diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts index 990c3b0..7778d57 100644 --- a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -12,17 +12,8 @@ import { valueKeyFor, refIdsKeyFor, refCountKeyFor } from '../stores/shared.js'; import { hashValue } from 'signalium/utils'; import { sleep } from './utils.js'; -/** - * A persisted query is served from the cache when one of its entities is in - * memory with data that doesn't fit the query's shape by itself, as long as - * the cache can fill the gap without serving data older than memory: - * A. a required top-level `t.format` field, already parsed in memory; - * B. two classes share a typename and the in-memory class lacks a field the - * cached query's class requires. The in-memory class's writes must keep - * the other class's fields, or even a cold start cannot serve the cache. - * The negative cases (C) fall back to the network: the cache must never - * resurrect a value the fresher in-memory entity contradicts. - */ +// A cached query is served when an in-memory entity lacks fields the cache can +// fill, but never with values the fresher in-memory entity contradicts. let clients: QueryClient[] = []; afterEach(() => { @@ -102,7 +93,7 @@ function recordOf(kv: MemoryPersistentStore, typename: string, id: string): Reco return raw === undefined ? undefined : JSON.parse(raw); } -/** Session boundary: a fresh client over the same kv (an app restart). */ +/** A fresh client over the same kv (an app restart). */ async function session( kv: MemoryPersistentStore, f: ReturnType, @@ -114,9 +105,7 @@ async function session( clients = clients.filter(x => x !== c); } -// --------------------------------------------------------------------------- // A. Required top-level format field -// --------------------------------------------------------------------------- class Reading extends Entity { __typename = t.typename('Reading'); @@ -166,11 +155,10 @@ describe('A. required top-level t.format on an in-memory entity', () => { expect(logs).toEqual([]); expect(early.isReady).toBe(true); expect(early.label).toBe('cached'); - // In memory is fresher than the record: its value wins. + // Memory is fresher than the record. expect(early.value).toBe(inMemory ? 2 : 1); expect(early.takenAt).toBe('2026-01-01T00:00:00.000Z'); if (latest !== undefined) expect(latestEarly).toBe('2026-01-01T00:00:00.000Z'); - // The network fetch still happens and wins. expect(f.count('/reading/page')).toBe(2); expect(page.value.label).toBe('net'); expect(page.value.reading.value).toBe(3); @@ -178,9 +166,7 @@ describe('A. required top-level t.format on an in-memory entity', () => { } }); -// --------------------------------------------------------------------------- // B. Two classes share a typename; the summary class lacks `details` -// --------------------------------------------------------------------------- class ProductSummary extends Entity { __typename = t.typename('Product'); @@ -223,11 +209,7 @@ type DetailOutcome = { recordAfterFetch: Record | undefined; }; -/** - * Session 1 caches the detail. `before(c, kv)` sets up session 2 up to the - * moment the detail activates; the detail's network response is delayed so the - * first read shows whether the cache was served. - */ +/** The detail's response is delayed so the first read shows whether the cache was served. */ async function runDetail( kv: MemoryPersistentStore, f: ReturnType, @@ -235,7 +217,7 @@ async function runDetail( ): Promise { const logs: string[] = []; const c = makeClient(kv, f, logs); - // `before` wraps the list: a QueryPromise is thenable, so an async function returning it bare would unwrap it. + // Wrapped: an async function returning a bare (thenable) QueryPromise would unwrap it. const list = (await before(c))?.list; f.set('/product', detailBody(6, 'net', 'P-net'), 200); const requestsBefore = f.count('/product'); @@ -270,9 +252,8 @@ async function seedDetail(kv: MemoryPersistentStore, f: ReturnType) { - // Cache the list first, then the detail, so the record on disk is the detail's. + // Detail last, so the record on disk is the detail's. f.set('/products', listBody()); await session(kv, f, async c => { await start(c, () => fetchQuery(GetProductList)); @@ -286,7 +267,6 @@ function expectServed(o: DetailOutcome, name = 'P') { expect(o.label).toBe('cached'); expect(o.name).toBe(name); expect(o.rating).toBe(5); - // The network fetch still happens and wins. expect(o.detailRequests).toBe(1); expect(o.finalLabel).toBe('net'); expect(o.finalRating).toBe(6); @@ -314,10 +294,8 @@ describe('B. shared typename, the in-memory class lacks a field the cached query return { list }; }); expectServed(o); - // The list's value is untouched: same name, and the detail-only field does not leak into its shape. expect(o.listName).toBe('P'); expect(o.listKeys).toBe('__typename,id,name'); - // Hydration never writes; the record still has the detail's fields. expect(o.recordAfterHydrate).toMatchObject({ name: 'P', details: { rating: 5 } }); }); @@ -333,7 +311,6 @@ describe('B. shared typename, the in-memory class lacks a field the cached query }); expectServed(o); expect(o.listName).toBe('P'); - // The summary's write kept the detail's fields on disk. expect(o.recordAfterHydrate).toMatchObject({ name: 'P', details: { rating: 5, tags: ['a'] } }); }); @@ -347,7 +324,6 @@ describe('B. shared typename, the in-memory class lacks a field the cached query }); expect(recordOf(kv, 'Product', 'p1')).toMatchObject({ name: 'P2', details: { rating: 5 } }); const o = await runDetail(kv, f, async () => undefined); - // The summary's newer name, the detail's own fields. expectServed(o, 'P2'); }); @@ -367,9 +343,7 @@ describe('B. shared typename, the in-memory class lacks a field the cached query }); }); -// --------------------------------------------------------------------------- // C. Negative controls: the cache must not be served -// --------------------------------------------------------------------------- class ProductSummaryOptional extends Entity { __typename = t.typename('Product'); @@ -405,7 +379,6 @@ class GetProductListNested extends RESTQuery { } function expectNotServed(o: DetailOutcome) { - // Never shows the cached detail fields; waits for the network, which then wins. expect(o.isReady).toBe(false); expect(o.rating === 5).toBe(false); expect(o.detailRequests).toBe(1); @@ -457,14 +430,10 @@ describe('C. negative controls: a fresher in-memory value contradicts the cache' }); }); -// --------------------------------------------------------------------------- // D. Writes keep the fields another class declared, without a per-write cost -// --------------------------------------------------------------------------- -/** A SyncQueryStore that counts the record reads made for the kept fields. */ class CountingStore extends SyncQueryStore { reads = 0; - /** Reads that found a record worth parsing. */ parsed = 0; readEntity(entityKey: number) { this.reads++; @@ -551,8 +520,6 @@ describe('D. writes keep the fields another class sharing the typename declared' await start(c, () => fetchQuery(GetItem)); }); - // The summary fetches in a later session and writes Item:i1: the record - // keeps `details` and the reference to Vendor:v1, whose record survives. f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); await session(kv, f, async c => { await start(c, () => fetchQuery(GetItems)); @@ -566,8 +533,6 @@ describe('D. writes keep the fields another class sharing the typename declared' }); expect(kv.getString(valueKeyFor(vendorKey('v1')))).toBeDefined(); - // A cold start serves the detail from the cache, vendor included; its - // fetch then replaces the vendor, and the old vendor's record goes. f.set('/item', itemDetail('v2', 6), 100); const c = makeClient(kv, f); const item: any = start(c, () => fetchQuery(GetItem)); @@ -597,7 +562,6 @@ describe('D. writes keep the fields another class sharing the typename declared' }); const record = recordOf(kv, 'Item', 'i1')!; expect(record.details).toBeUndefined(); - // `vendor` is declared by the detail class only: kept. expect(record.vendor).toEqual({ __entityRef: vendorKey('v1') }); }); @@ -690,7 +654,6 @@ describe('D. writes keep the fields another class sharing the typename declared' }); const kv = new MemoryPersistentStore(); const f = makeFetch(); - // The list is cached with Item:i1, then the detail writes the record. f.set('/items', { items: [summary('i1', 'I')] }); f.set('/item', itemDetail('v1')); await session(kv, f, async c => { @@ -701,7 +664,6 @@ describe('D. writes keep the fields another class sharing the typename declared' const c = makeClient(kv, f); f.set('/shelf', { shelf: { __typename: 'Shelf', id: 's1', top: summary('i2', 'J') } }); await start(c, () => fetchQuery(GetShelf)); - // Item:i1 enters memory from a streamed event, then the cached list fills it in. c.applyMutationEvent(shelfEvent('E1')); f.set('/items', { items: [summary('i1', 'L')] }, 50); const list: any = start(c, () => fetchQuery(GetItems)); @@ -783,7 +745,6 @@ describe('D. writes keep the fields another class sharing the typename declared' f.set('/item', itemDetail('v1')); f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); - // Both classes write i1 in the first session, so the record holds the detail fields. const first = writerStore(); const c1 = makeCountingClient(first as any, f); await start(c1, () => fetchQuery(GetItem)); @@ -791,7 +752,6 @@ describe('D. writes keep the fields another class sharing the typename declared' await drain(first); c1.destroy(); - // Next session: only the summary class, hydrated from its cache over that record. const second = writerStore(); const c2 = makeCountingClient(second as any, f); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -886,7 +846,6 @@ const row = (name: string) => ({ __typename: 'Gizmo', id: 'g1', name, maker: par const partKey = (id: string) => hashValue(['Part', id]); describe('F. references and records the other class holds', () => { - /** The summary in memory from the network, then the detail fetched over the same entity. */ async function listThenDetail(kv: MemoryPersistentStore, f: ReturnType) { const c = makeClient(kv, f); f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); @@ -942,7 +901,6 @@ describe('F. references and records the other class holds', () => { it('F3 a summary fetched over records without detail fields reads each record once and writes the fetched fields', async () => { const kv = new MemoryPersistentStore(); const f = makeFetch(); - // The detail class registered and wrote i1; i2 and i3 hold summary fields only. f.set('/item', itemDetail('v1')); f.set('/items', { items: [ @@ -966,13 +924,11 @@ describe('F. references and records the other class holds', () => { ], }); const before = { reads: store.reads, parsed: store.parsed }; - // A query with no cache, so i2 and i3 are new in memory; i1 is the detail's. class GetOtherItems extends RESTQuery { path = '/other-items'; result = { items: t.array(t.entity(ItemSummary)) }; } await start(c, () => fetchQuery(GetOtherItems)); - // One read per entity new in memory (i2, i3), at its first write. expect({ reads: store.reads - before.reads, parsed: store.parsed - before.parsed }).toEqual({ reads: 2, parsed: 2, @@ -985,13 +941,11 @@ describe('F. references and records the other class holds', () => { const kv = new MemoryPersistentStore(); const f = makeFetch(); f.set('/gizmo', { item: { ...row('G'), parts: [part('p1'), part('p2')] } }); - // Session 1 remembers the detail's field names. await session(kv, f, async c => { await start(c, () => fetchQuery(GetGizmo)); }); - // Session 2: the summary refetches before the detail class registers, so - // the detail adds no field name the store did not already know. + // The detail registers after the summary refetch and adds no new field name. const c = makeClient(kv, f); f.set('/gizmos', { items: [row('A')] }); const list: any = start(c, () => fetchQuery(GetGizmos)); @@ -1023,7 +977,6 @@ describe('F. references and records the other class holds', () => { f.set('/gizmo', { item: { ...row('A'), parts: [part('p1')] } }); const item: any = start(c, () => fetchQuery(GetGizmo)); await item; - // Two full payloads count every reference, then an update replaces the maker. await list.value.__refetch(); c.applyMutationEvent({ type: 'update', @@ -1082,7 +1035,7 @@ describe('F. references and records the other class holds', () => { await start(c, () => fetchQuery(GetItem)); await start(c, () => fetchQuery(GetItems)); }); - // Reopened without the field names, as a release that did not remember them left it. + // Strip the field names, as an earlier release left the store. const copy = new MemoryPersistentStore(); for (const k of kv.getAllKeys()) { if (k.startsWith('sq:meta:')) continue; @@ -1122,8 +1075,6 @@ describe('E. what the store remembers about the other class, and for how long', await start(c, () => fetchQuery(GetItem)); }); - // The summary keeps the detail's fields of Item:i1 in memory; then the - // app wipes the cache (an account switch) and the summary refetches. const store = new SyncQueryStore(kv); const c = makeCountingClient(store, f); f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); @@ -1154,7 +1105,6 @@ describe('E. what the store remembers about the other class, and for how long', await start(c, () => fetchQuery(GetItem)); }); - /** A list session at `at`, in a new app process (a store over a copy of the kv knows nothing yet). */ const listSession = async (at: number): Promise => { now.mockReturnValue(at); const copy = new MemoryPersistentStore(); @@ -1172,16 +1122,13 @@ describe('E. what the store remembers about the other class, and for how long', return store.reads; }; - // Within 30 days the detail's names are known: the summary's first write keeps its fields. expect(await listSession(t0 + 29 * day)).toBe(1); - // Past 30 days the names are forgotten: the summary writes without reading. expect(await listSession(t0 + 31 * day)).toBe(0); }); it('E3 a typename with one class: an event carrying every field writes the record whole, without a merge', async () => { const kv = new MemoryPersistentStore(); - // clear() makes the field names cover every record (otherwise that takes - // 30 days; see E5). + // Otherwise the field names take 30 days to cover every record. new SyncQueryStore(kv).clear(); const f = makeFetch(); f.set('/reading/latest', { reading: readingPayload }); @@ -1211,7 +1158,7 @@ describe('E. what the store remembers about the other class, and for how long', await session(kv, f, async c => { await start(c, () => fetchQuery(GetItem)); }); - /** The kv reopened by a new app process. `stripMeta` drops `sq:meta:` keys, as a release without field names left it. */ + /** `stripMeta` mimics a store an earlier release left. */ const reopen = (stripMeta: boolean) => { const copy = new MemoryPersistentStore(); for (const k of kv.getAllKeys()) { @@ -1223,7 +1170,6 @@ describe('E. what the store remembers about the other class, and for how long', } kv = copy; }; - /** A session that registers the summary only, then streams an update carrying every summary field of Item:i1. */ const listSession = async (at: number, name: string) => { now.mockReturnValue(at); const store = new SyncQueryStore(kv); @@ -1242,7 +1188,6 @@ describe('E. what the store remembers about the other class, and for how long', }; const kept = { details: { rating: 5 }, vendor: { __entityRef: vendorKey('v1') } }; - // The first session after the upgrade, and the next one (whose names are the summary's only), merge. const hour = 60 * 60 * 1000; reopen(true); expect(await listSession(t0 + hour, 'U1')).toEqual({ merged: 1, savedWhole: false }); @@ -1250,13 +1195,10 @@ describe('E. what the store remembers about the other class, and for how long', reopen(false); expect(await listSession(t0 + 2 * hour, 'U2')).toEqual({ merged: 1, savedWhole: false }); expect(recordOf(kv, 'Item', 'i1')).toEqual({ __typename: 'Item', id: 'i1', name: 'U2', ...kept }); - // 30 days after the store started remembering names: written whole. reopen(false); kv.setNumber('sq:meta:fieldsSince', t0 + 3 * hour - 30 * day); expect(await listSession(t0 + 3 * hour, 'U3')).toEqual({ merged: 0, savedWhole: true }); - // A store without `fieldsSince` counts from now, empty or not, rather than - // scan every key at startup. const fresh = new MemoryPersistentStore(); const scans = vi.spyOn(fresh, 'getAllKeys'); expect(new SyncQueryStore(fresh).entityFieldNamesComplete()).toBe(false); @@ -1266,7 +1208,6 @@ describe('E. what the store remembers about the other class, and for how long', const upgradedScans = vi.spyOn(upgraded, 'getAllKeys'); expect(new SyncQueryStore(upgraded).entityFieldNamesComplete()).toBe(false); expect(upgradedScans).not.toHaveBeenCalled(); - // One that clear() emptied writes such events whole. reopen(true); const store = new SyncQueryStore(kv); expect(store.entityFieldNamesComplete()).toBe(false); diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index 9d9f7ce..3a96aca 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -140,8 +140,7 @@ describe('hydration of a cached query whose entity is already in memory', () => id = t.id; title = t.string; } - // Declares `body` optional, so a response without it says, more - // recently than the cache, that there is none. + // A response without the optional `body` is fresher than the cache. class NarrowWithBody extends Entity { __typename = t.typename('Doc'); id = t.id; @@ -181,13 +180,9 @@ describe('hydration of a cached query whose entity is already in memory', () => const wide = holdQuery(client, () => fetchQuery(GetWide)); await wide; if (absent) { - // The narrow write removed `body` from the record and the fresher - // absence contradicts the cache: dropped with a warning and fetched. expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); expect((wide.value as any).doc.body.text).toBe('B2'); } else { - // The narrow write kept the record's `body`. Served within staleTime - // without a refetch: the in-memory title, the record's body. expect(warn).not.toHaveBeenCalled(); expect(mockFetch.calls.filter(c => c.url.endsWith('/wide'))).toHaveLength(1); expect((wide.value as any).doc.title).toBe('T2'); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 62a2b2a..f00cf82 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -156,8 +156,7 @@ function applyEntity( isUpdate && rawKeys !== undefined && entityInstance.entityRefs !== undefined ? new Map(entityInstance.entityRefs) : new Map(); - // A refetch or a poll that returns identical data is a no-op: nothing to - // notify consumers about, and nothing new to write to the store. + // A refetch or poll that returns identical data neither notifies nor writes. let changed = true; // Until the fields are reified the data is not a record yet: a write that // reaches this instance through a reference from inside its own subtree is @@ -204,8 +203,6 @@ function applyEntity( // event did (the parser fills the literal in). if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); - // Events that carried every field built a whole record, unless the - // record may hold fields another class of the typename declares. if (eventsBuiltWholeRecord(entityInstance._partialKeys, entityShape, queryClient)) { entityInstance._partial = false; entityInstance._partialKeys = undefined; @@ -221,11 +218,8 @@ function applyEntity( } finally { entityInstance._applying = false; } - // A full payload of a class lacking fields another class of the typename - // declares leaves those fields in the data. Count the references they hold, - // or they would be released while the other class's consumers still show - // them. An unchanged payload skips the walk if the last full payload already - // counted them. + // Fields only another class declares stay in the data. Count their refs or + // they get released while that class's consumers still show them. const heldRefs = entityInstance.entityRefs; let keepsHeldRefs = false; if ( @@ -242,11 +236,8 @@ function applyEntity( for (let i = 0; i < fields.length; i++) countHeldRefs(existingData[fields[i]], heldRefs, childRefs, queryClient); } } - // Partial updates keep the references they did not replace, so only a full - // payload counts every reference the data holds. entityInstance._refsCounted = rawKeys === undefined && !appendMode; if (isUpdate && changed) entityInstance.notify(); - // Hydration handed over the record: it holds fields the data does not. if (record !== undefined) { entityInstance.recordHoldsOtherFields(record, queryClient.entityMap.readEntity !== undefined); } @@ -289,10 +280,6 @@ function applyEntity( return proxy; } -/** - * Counts, into `childRefs`, the references to children the entity already - * holds (`held`) found in `value`. - */ function countHeldRefs( value: unknown, held: Map, @@ -316,12 +303,7 @@ function countHeldRefs( } } -/** - * A fetch's data is about to replace the whole record. If the class lacks - * fields another class of its typename declares, the record may hold them, so - * the first write reads it to keep them. With one class per typename this - * costs a field read. - */ +/** The fetch replaces the whole record, so the first write must read it to keep other classes' fields. */ function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClient: QueryClient): void { if ( queryClient.hasForeignFieldDefs && @@ -333,13 +315,9 @@ function checkStoredRecord(instance: EntityInstance, shape: EntityDef, queryClie } /** - * Whether the fields streamed events carried (`keys`) make up the entity's - * whole record, so it can be written whole instead of merged. They must cover - * its class, and no other class of the typename may declare a field this one - * lacks, since the record could hold it. That needs a store whose remembered - * field names cover every record it holds (`storeKnowsTypenameFields`). - * Otherwise a class not registered this session may be unknown, so the - * entity stays partial and is merged. + * Whether streamed fields make a whole record that can be written instead of + * merged. Without `storeKnowsTypenameFields` an unregistered class may own + * fields in the record, so it stays partial. */ function eventsBuiltWholeRecord(keys: Set, entityShape: EntityDef, queryClient: QueryClient): boolean { for (const k in entityShape.shape) if (!keys.has(k)) return false; diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index ed26a31..7fc5da6 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -54,11 +54,7 @@ export interface ParsedEntity { eventKeys: Set | undefined; /** A cached record parsed to fill in the fields an entity built from events lacks. */ fillsPartial: boolean; - /** - * Hydration only: the raw cached record, when it holds fields the - * instance's data does not (written by another class of the typename). The - * apply keeps them so the instance's writes don't drop them. - */ + /** Hydration only: the raw record, when it holds fields another class wrote. */ record?: Record; } @@ -528,11 +524,7 @@ function parseEntityData( // parse the record for those fields only and merge them in. fillKeys = existing._partialKeys; obj = preloaded; - // The fill makes the entity whole, so its next write replaces the - // record. Hand over a record holding fields this class does not declare - // so that write keeps them. This path is rare, so every record is - // checked: a store written before field names were remembered may not - // know the other class. + // Rare path, so check every record: older stores may not know the other class. const shape = entityShape.shape; for (const k in preloaded) { if (!(k in shape)) { @@ -547,10 +539,6 @@ function parseEntityData( throw new Error(`Cached entity ${key} not found in preloaded map`); } obj = preloaded; - // Only a class lacking fields another class of its typename declares - // can get a record with fields it does not parse. If the store's field - // names don't cover every record, the other class may not be registered - // yet, so every record is checked. if ( !queryClient.storeKnowsTypenameFields || queryClient.mayMissForeignFields(entityShape as unknown as ValidatorDef) @@ -630,21 +618,9 @@ function parseEntityData( // ====================================================== /** - * A cached query references an entity already live in memory. The in-memory - * data is at least as fresh as the cached record and every consumer already - * sees it, so it is never overwritten from the cache. Re-parsing it would also - * run already-parsed values (formatted values, child proxies) through the - * parser again, in place, and corrupt them. - * - * Each field of the hydrating query's shape: - * - not an own key of the data (no applied class declares it, since each - * class's parse sets all its fields, even to `undefined`) but held by the - * record: parsed from the record and merged in. A class that declares it - * wrote it, so it is no older than this cached query. - * - the in-memory value satisfies the field: used as is. - * - otherwise (another type, or `undefined` from a class declaring it - * optional, which is fresher than the record): `CachedEntityMismatchError`, - * so the cached query is dropped and fetched. + * Memory is at least as fresh as the cache, so it is never overwritten from it. + * Re-parsing would also corrupt already-parsed values. Only fields no applied + * class declares are filled from the record. */ function hydrateFromMemory( existing: EntityInstance, @@ -671,7 +647,6 @@ function hydrateFromMemory( preloaded[fieldKey] !== undefined && !Object.hasOwn(data, fieldKey) ) { - // An optional field too: the record knows it, memory does not. (fillKeys ??= new Set()).add(fieldKey); continue; } @@ -681,8 +656,7 @@ function hydrateFromMemory( ); } - // With nothing to fill, an empty key set merges nothing and hands the - // query the existing proxy. + // An empty key set merges nothing and hands the query the existing proxy. const entry: ParsedEntity = { key, shape: entityShape, diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index b8da687..bd55f6f 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -110,11 +110,8 @@ export interface QueryStore { ): void; /** - * Writes an entity's record. `rest`, when given, is a JSON object body - * (`"a":1,"b":{…}`) of fields the record already held that the instance's - * classes don't declare (another class of the typename wrote them). Write - * them ahead of `value`'s fields so they are kept. `refIds` already includes - * the references inside them. A store that ignores `rest` drops them. + * `rest`: JSON object body (`"a":1,"b":{…}`) of fields another class of the + * typename wrote. Write it ahead of `value`'s fields. `refIds` covers it. */ saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void; @@ -174,34 +171,24 @@ export interface QueryStore { hasEntity?(key: number): boolean | undefined; /** - * Synchronous stores: the stored record of an entity, parsed, or - * `undefined` when there is none. Read once, before the first write of an - * entity whose record may hold fields of another class sharing its - * typename (see `getEntityFieldNames`), so that write keeps them. + * Synchronous stores only: the parsed stored record, or `undefined`. Read + * before an entity's first write when its record may hold another class's fields. */ readEntity?(entityKey: number): Record | undefined; /** - * The top-level field names `typename`'s entity classes have declared, as - * `addEntityFieldNames` recorded them in this or an earlier session. Lets - * the client tell, without reading records, which classes may get a record - * holding fields they don't declare. Without these methods the client only - * knows this session's classes. + * Top-level field names `typename`'s classes declared, as recorded by + * `addEntityFieldNames` in this or an earlier session. */ getEntityFieldNames?(typename: string): readonly string[] | undefined; /** - * Called once per entity class and client with every top-level field the - * class declares. A store may forget names no class has declared for a - * while (`SyncQueryStore`: 30 days), so a field an app update removed stops - * being kept in records. + * Called once per entity class and client with its top-level fields. A store + * may forget names undeclared for a while, so removed fields stop being kept. */ addEntityFieldNames?(typename: string, fields: readonly string[]): void; /** - * Whether the names `getEntityFieldNames` returns cover every class that - * wrote a record the store holds. Asked once, when the client is created. - * Only then can an entity built from streamed events be written whole - * instead of merged into its record. A store holding records written before - * it remembered field names answers `false` until they cannot matter. + * Whether `getEntityFieldNames` covers every class that wrote a held record. + * Asked once at client creation. Only then are streamed entities written whole. */ entityFieldNamesComplete?(): boolean; } diff --git a/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx index f5f62d9..16e04c7 100644 --- a/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx +++ b/packages/fetchium/src/react/__tests__/hydrate-in-memory-shape.test.tsx @@ -12,11 +12,8 @@ import { fetchQuery } from '../../query.js'; import { sleep } from '../../__tests__/utils.js'; import { useQuery } from '../index.js'; -/** - * With a synchronous store, a screen whose cached query shares an entity with - * a query already in memory paints the cached value on its first commit, not a - * loading frame. - */ +// Sync store: a cached query sharing an entity with an in-memory query paints +// its cached value on the first commit, not a loading frame. let clients: QueryClient[] = []; afterEach(() => { diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 78f27ce..a1915bc 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -62,11 +62,7 @@ export type StoreMessage = refIds?: number[]; /** `value` holds only some fields; the writer merges them over the stored record, if any. */ merge?: boolean; - /** - * Fields of the stored record to keep alongside `value`, as a JSON - * object body (see `QueryStore.saveEntity`). An older writer ignores it - * and writes `value` alone. - */ + /** See `QueryStore.saveEntity`. */ rest?: string; } | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount?: number } @@ -622,7 +618,6 @@ export class AsyncQueryStore implements QueryStore { } } - /** `rest`: fields of the stored record to keep, written ahead of `value`'s (see `QueryStore.saveEntity`). */ private async setValue(id: number, value: unknown, refIds?: Set, rest?: string): Promise { const delegate = this.delegate!; diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index 89c584b..a89a21c 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -1,4 +1,3 @@ -// Every key of cached data starts with this export const DOC_PREFIX = 'sq:doc:'; // Query Instance keys export const VALUE_PREFIX = 'sq:doc:value:'; @@ -13,9 +12,8 @@ export const lastUsedKeyFor = (queryDefId: string) => `sq:doc:lastUsed:${queryDe export const cacheTimeKeyFor = (queryDefId: string) => `sq:doc:cacheTime:${queryDefId}`; export const LAST_USED_PREFIX = 'sq:doc:lastUsed:'; -// Per typename: the top-level fields its entity classes have declared export const fieldNamesKeyFor = (typename: string) => `sq:meta:fields:${typename}`; -// Since when the field names cover every record (0: since the store held no cached data) +// 0: field names have covered every record since the store was emptied export const FIELD_NAMES_SINCE_KEY = 'sq:meta:fieldsSince'; // Default values @@ -83,11 +81,7 @@ export function storedRecordRest( return { json: JSON.stringify(rest).slice(1, -1), refIds: [...refIds] }; } -/** - * The fields of a parsed stored record that `data` does not hold as its own - * keys: their names, the fields as a JSON object body (`"a":1,"b":{…}`), and - * the references inside them. `undefined` when there are none. - */ +/** The fields of a parsed stored record that `data` does not hold as own keys. */ export function recordRestOutside( record: Record, data: Record, @@ -107,7 +101,7 @@ export function recordRestOutside( return { keys: keys!, json: JSON.stringify(values).slice(1, -1), refIds: [...refIds] }; } -/** The `{ __entityRef }` markers in a record's JSON, as `collectEntityRefs` finds them in the parsed record. */ +/** Adds the `__entityRef` ids in a record's JSON to `into`, as `collectEntityRefs` would for the parsed record. */ export function entityRefsInJson(json: string, into: Set): void { if (json.indexOf('"__entityRef":') === -1) return; const re = /"__entityRef":(\d+)/g; diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index e809aab..9d10ff3 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -105,22 +105,14 @@ const mergeRestsByKv = new WeakMap>( /** LRU bound. An evicted record's next merge reads it again. */ const MAX_MERGE_RESTS = 1024; -/** Per backing kv, like the merge rests: the field names each typename's classes declared, and when. */ const fieldNamesByKv = new WeakMap>>(); -/** A field name no class has declared for this long (30 days) is forgotten. */ const FIELD_NAME_TTL = 30 * 24 * 60 * 60 * 1000; -/** A declared name's time is rewritten at most this often (1 day). */ const FIELD_NAME_REFRESH = 24 * 60 * 60 * 1000; -/** - * Since when a kv's field names have been remembered for every record it - * holds (0: since `clear()` emptied it), and whether that is stored yet - * (see `entityFieldNamesComplete`). - */ +/** `at` 0: since `clear()` emptied the kv. */ interface FieldNamesSince { at: number; stored: boolean; } -/** Per backing kv, like the field names. */ const fieldNamesSinceByKv = new WeakMap(); export class SyncQueryStore implements QueryStore { @@ -234,8 +226,7 @@ export class SyncQueryStore implements QueryStore { this.setValue(entityKey, value, refIds); return; } - // Kept fields go first, so if one repeats a field of `value` the parse - // keeps `value`'s later occurrence. + // Kept fields go first so a repeated key parses as `value`'s. const json = JSON.stringify(value); const rests = this.mergeRests; if (rests.size !== 0) rests.delete(entityKey); @@ -262,11 +253,9 @@ export class SyncQueryStore implements QueryStore { } /** - * Whether the remembered field names cover every record this store holds. - * Records an earlier version wrote may hold fields of a class not registered - * since, so this is `false` for `FIELD_NAME_TTL` after this version first - * opens the store (`sq:meta:fieldsSince`), whether or not it was empty. - * `clear()` makes it `true`. + * `false` for `FIELD_NAME_TTL` after this version first opens the store, + * since older records may hold fields of an unregistered class. `clear()` + * makes it `true`. */ entityFieldNamesComplete(): boolean { const at = this.fieldNamesSince().at; @@ -281,8 +270,7 @@ export class SyncQueryStore implements QueryStore { if (at !== undefined) { since = { at, stored: true }; } else { - // Unknown: count from now. Telling an empty store from one an earlier - // release wrote would take a scan of every key at startup. + // Count from now. Telling an empty store apart would need a startup key scan. since = { at: Date.now(), stored: false }; } fieldNamesSinceByKv.set(kv, since); @@ -290,10 +278,8 @@ export class SyncQueryStore implements QueryStore { } /** - * Records that a class of `typename` declares `fields` now. Each name keeps - * the time it was last declared, refreshed at most daily, so most sessions - * write nothing. A name undeclared for `FIELD_NAME_TTL` is forgotten, so a - * field an app update removed stops being kept in records. + * Each name keeps when it was last declared, refreshed at most daily. A name + * undeclared for `FIELD_NAME_TTL` is forgotten. */ addEntityFieldNames(typename: string, fields: readonly string[]): void { const names = this.fieldNamesOf(typename); @@ -317,7 +303,6 @@ export class SyncQueryStore implements QueryStore { this.kv.setString(fieldNamesKeyFor(typename), stored); } - /** The field names `typename`'s classes declared within `FIELD_NAME_TTL`, and when; read from the kv once. */ private fieldNamesOf(typename: string): Map { let names = this.fieldNames.get(typename); if (names !== undefined) return names; @@ -337,14 +322,9 @@ export class SyncQueryStore implements QueryStore { } /** - * Deletes every query, entity record and queue (the kv's `sq:doc:` keys) - * and reports each record to `onDelete` listeners, so clients drop what - * they kept from those records. Wipe the cache with this, not on the kv - * directly (e.g. MMKV `clearAll()`): a client unaware of the deletion would - * write back another class's fields it kept from a deleted record. The - * per-typename field names (`sq:meta:`) describe the app's classes, not - * cached data, so they are kept. With no records left, they are complete - * from now on (`entityFieldNamesComplete`). + * Deletes all cached data and reports each record to `onDelete`. Use this, + * not the kv's own clear: a client unaware of the deletion would write back + * fields it kept from deleted records. Field names are kept. */ clear(): void { const kv = this.kv; From b40ebdc69ac94173ec788019b7325d191533378f Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:15:16 -0400 Subject: [PATCH 090/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/LiveCollection.ts | 2 +- packages/fetchium/src/QueryClient.ts | 1 - .../src/__tests__/entity-write-counts.test.ts | 12 +----------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index d4e75bd..9401e00 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -311,7 +311,7 @@ export class LiveArrayInstance { const child = this._queryClient.entityMap.getEntity(key); if (child !== undefined) { - // Only the parent's ref set changed. The child's record is already current. + // The child's record is already current. this._parent.addChildRef(child); } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 5a24f38..d592f11 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -550,7 +550,6 @@ function paramsMatch(instanceParams: Record | undefined, subset return true; } -/** Writes an entity and its not-yet-written descendants. */ function persistUnwritten(entity: EntityInstance): void { if (!entity._persisted) entity.save(); const refs = entity.entityRefs; diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index 70a6dd9..a602ebd 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -8,13 +8,6 @@ import { fetchQuery } from '../query.js'; import { QueryClient } from '../QueryClient.js'; import { testWithClient, setupTestClient, sleep } from './utils.js'; -/** - * Entity Write Counts - * - * Pins how many store writes a streamed entity event causes, and that a single - * write is enough for a later client to read. - */ - class Item extends Entity { __typename = t.typename('Item'); id = t.id; @@ -127,9 +120,7 @@ describe('Entity Write Counts', () => { }); await sleep(5); - // The apply writes the new child. The live-array insert must not write it again. expect(itemSaves(saveEntity)).toHaveLength(1); - // The parent's ref set changed, so it is written once. expect(itemSaves(saveEntity, 'List')).toHaveLength(1); }); @@ -148,7 +139,6 @@ describe('Entity Write Counts', () => { }); const saveEntity = vi.spyOn(store, 'saveEntity'); - // i-1 is already current in the store, so the apply skips its write. The insert must not add one. client.applyMutationEvent({ type: 'update', typename: 'Item', @@ -157,7 +147,7 @@ describe('Entity Write Counts', () => { await sleep(5); expect(itemSaves(saveEntity)).toHaveLength(0); - // Only the parent's ref set changed, and that write proves the insert ran. + // This write proves the insert ran. expect(itemSaves(saveEntity, 'List')).toHaveLength(1); expect(listQuery.value.list.items.map(i => i.id)).toEqual(['i-1']); }); From a103d63abc0126a611f1d2bc8657fc834434fdcc Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:15:27 -0400 Subject: [PATCH 091/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityInstance.ts | 18 +-- packages/fetchium/src/LiveCollection.ts | 7 +- packages/fetchium/src/QueryClient.ts | 77 +++------- packages/fetchium/src/QueryResult.ts | 144 ++++-------------- .../src/__benchmarks__/nested-read.bench.ts | 12 +- .../src/__tests__/activation-timing.test.ts | 29 +--- .../src/__tests__/event-persistence.test.ts | 4 +- .../__tests__/nested-value-reactivity.test.ts | 12 +- .../src/__tests__/reactivation-grace.test.ts | 1 - .../src/__tests__/retain-prefetch.test.ts | 2 - packages/fetchium/src/applyEntities.ts | 5 +- packages/fetchium/src/nestedNotifiers.ts | 12 +- packages/fetchium/src/query-types.ts | 6 +- .../src/react/__tests__/component.test.tsx | 5 +- .../__tests__/nested-value-props.test.tsx | 7 +- .../__tests__/prefetch-suspense.test.tsx | 4 +- packages/fetchium/src/stores/shared.ts | 12 +- packages/fetchium/src/stores/sync.ts | 14 +- .../fetchium/src/subscriptions/polling.ts | 8 +- .../fetchium/src/topic/TopicQueryAdapter.ts | 9 +- 20 files changed, 88 insertions(+), 300 deletions(-) diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 1d6828a..5f9ded2 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -18,7 +18,6 @@ import { entitySatisfiesShape } from './parseEntities.js'; // ====================================================== const ObjectProto = Object.prototype; -// Module-local alias: read on every nested read. const nestedWrappers = NESTED_WRAPPERS; /** A `WRAPPED_VALUE` item. A live collection's value changes under its own notifier. */ @@ -27,14 +26,7 @@ interface WrappedValue { readonly _valueOwner?: Notifier; } -/** - * `owner` is the notifier of the live collection this value belongs to. A - * reducer or event can change such a value in place, so reads through its - * wrapper consume that notifier. Without an owner the value is nested in an - * entity and its wrapper consumes nothing: an in-place merge drops the wrapper - * (`dropNestedWrapper`), so the next read hands out a new one and a child - * given only the nested value (e.g. `entity.price` as a prop) re-renders. - */ +/** `owner` is the live collection's notifier. Unowned wrappers consume nothing, and merges replace them. */ function wrapValue(value: unknown, owner: Notifier | undefined): unknown { if (typeof value !== 'object' || value === null) return value; if (WRAPPED_VALUE.has(value)) { @@ -113,16 +105,12 @@ const objectWrappingHandler: ProxyHandler> = { }, }; -/** - * Wraps a value inside a live collection. Traps consume the collection's - * notifier, and nested values inherit that owner. - */ +/** Wraps a live collection value. Reads consume the collection's notifier. */ class OwnedArrayHandler implements ProxyHandler { constructor(readonly owner: Notifier) {} get(target: unknown[], prop: string | symbol, receiver: unknown): unknown { - // Consume on every read, including `length` and iteration methods: an - // in-place push changes all of them. + // Consume on every read: an in-place push changes `length` and iteration too. this.owner.consume(); if (typeof prop === 'string') { const idx = Number(prop); diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 89a01a1..f252ee3 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -75,7 +75,6 @@ export interface LiveCollectionParent { // ====================================================== export interface LiveInstance { - /** Notified whenever the value or membership changes. */ readonly _notifier: Notifier; getValue(): unknown; getRawValue(): unknown; @@ -102,11 +101,7 @@ export class LiveCollectionBinding { _entityDefsByTypename: Map[]>; _constraintFieldRefs: Map>; readonly instance: LiveInstance; - /** - * Consumed by reads of the value's contents. The value from `getValue()` - * can change in place without the parent entity changing, so its wrapper - * can't rely on the entity's notifier. - */ + /** Consumed by reads of the value's contents, which can change in place. */ readonly _valueOwner: Notifier; constructor( diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index f15d937..23c1847 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -39,12 +39,7 @@ import { SyncQueryStore, MemoryPersistentStore } from './stores/sync.js'; import type { ExtractType } from './types.js'; import type { Optionalize, Signalize } from './type-utils.js'; -/** - * Options for `new QueryClient(config)`. Every key, including unlisted ones, - * also reaches query and mutation code as `this.context`, so an app can pass - * its own services through the config. The names declared below are reserved - * and always read as that option. - */ +/** Options for `new QueryClient(config)`. Every key also reaches queries and mutations as `this.context`. */ export interface QueryClientConfig { store?: QueryStore; adapters?: QueryAdapter[]; @@ -59,15 +54,10 @@ export interface QueryClientConfig { evictionMultiplier?: number; /** * Milliseconds. A query that reactivates (a watcher returns, or a paused - * scope resumes) with data younger than this is not refetched, even when the - * data is past its `staleTime`. Data a subscription pushed to (a `subscribe` - * stream event, or a topic event sent with its topic) counts as fresh from - * the last push; a `poll()` keeps data current only through the fetches it - * makes. Queries can override the window with `reactivationGraceMs` in - * their config. Network reconnects, - * `refetch()`, `invalidateQueries()`, `markStale()` and a failed last fetch - * still refetch. `Infinity` never refetches on reactivation. Default: 0 - * (every stale query refetches on reactivation). + * scope resumes) with data younger than this is not refetched, even if stale. + * Subscription pushes count as fresh data. Queries can override it with + * `reactivationGraceMs`. Reconnects, `refetch()`, invalidation, `markStale()` + * and a failed last fetch still refetch. Default: 0. */ reactivationGraceMs?: number; /** @@ -82,25 +72,21 @@ export interface QueryClientConfig { * Milliseconds. Reactivation refetches that start in the same task (for * example every query on a screen that just resumed) are spread evenly * across this window, in activation order, instead of all starting at once. - * Queries of an adapter that `coalescesRequests` are not spread. A value - * that is not a finite positive number counts as 0. Default: 0 (all start - * together). + * Queries of an adapter that `coalescesRequests` are not spread. Values that + * are not finite and positive count as 0. Default: 0. */ reactivationStaggerMs?: number; /** * Foreground/background source. When set, `poll()` stops its timers while - * the app is inactive and resumes them when it becomes active again. A value - * without `isActive` and `subscribe` functions is ignored (with a warning in - * development). Default: undefined (polls run regardless of app state). + * the app is inactive and resumes them when it becomes active again. Values + * without `isActive` and `subscribe` are ignored. Default: undefined. */ activity?: ActivitySource; /** - * Milliseconds. A `poll()` tick that is overdue when the app becomes active - * again (or whose timer fires more than a second late, as happens when the - * JS thread was suspended in the background) is rescheduled at a random - * point within this window rather than firing immediately alongside every - * other overdue poll. A value that is not a finite positive number counts - * as 0. Default: 0 (overdue ticks fire immediately). + * Milliseconds. A `poll()` tick that is overdue on resume, or whose timer + * fires over a second late, runs at a random point within this window + * instead of immediately. Values that are not finite and positive count as + * 0. Default: 0. */ pollResumeJitterMs?: number; } @@ -132,10 +118,8 @@ const SUSPENSE_HOLD_TTL = 10_000; /** * How long a failed cold fetch's error waits for a render to claim it. A - * large or time-sliced tree can take far longer than a task to retry a - * suspended render, and a reader that finds no hold refetches instead of - * throwing. An error still unclaimed after this belongs to an abandoned tree, - * so a later mount makes a fresh attempt instead of inheriting it. + * time-sliced retry render can take far longer than a task. An unclaimed + * error belongs to an abandoned tree, so a later mount retries. */ const UNCLAIMED_FAILURE_TTL = 1_000; @@ -214,12 +198,7 @@ export class QueryClient { private warnedReactiveRetain = false; /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ private suspenseHolds = new Map(); - /** - * Keys whose last cold failure expired unclaimed. The next attempt's failure - * waits `SUSPENSE_HOLD_TTL` instead, so a reader slower than - * `UNCLAIMED_FAILURE_TTL` gets the error after one retry rather than - * refetching in a loop. - */ + /** Keys whose last cold failure expired unclaimed. The next one waits `SUSPENSE_HOLD_TTL` for a slow reader. */ private unclaimedFailures = new Set(); private context!: QueryContext; @@ -248,9 +227,7 @@ export class QueryClient { // Must be finite: the window is split into setTimeout delays. this.reactivationStaggerMs = Number.isFinite(reactivationStaggerMs) ? nonNegative(reactivationStaggerMs) : 0; this.shouldRetry = typeof shouldRetry === 'function' ? shouldRetry : undefined; - // All other keys pass through to the context, including the reserved ones - // read above and `activity` / `pollResumeJitterMs`, which poll() reads - // from there. + // The rest, including `activity` and `pollResumeJitterMs` for poll(), go to the context. this.context = { ...(rest as Record), log: log ?? console, evictionMultiplier }; this.gcManager = config.gcManager ?? @@ -605,16 +582,9 @@ export class QueryClient { * never produced a value (a cold miss), or `undefined` when it has one to * render (in memory, or hydrated now from a synchronous store). * - * A cold miss takes a hold that keeps the query active while the render is - * suspended, since React discards a suspended render without subscribing. - * The reader's commit releases it (`releaseSuspenseHold`); otherwise it - * releases itself `SUSPENSE_HOLD_TTL` after the fetch settles. A query whose - * last fetch failed is refetched once per hold: when that attempt fails too, - * `error` is set and the hold is dropped, so the caller can throw it. A - * failure no render claims within `UNCLAIMED_FAILURE_TTL` (the suspended - * tree was abandoned) drops the hold, so a later mount makes a new attempt. - * If that attempt also fails, its error waits `SUSPENSE_HOLD_TTL` for a - * render, so even a slow reader reaches its error boundary. + * A cold miss takes a hold that keeps the query active while suspended, + * since React discards a suspended render without subscribing. A failed + * query refetches once per hold, and a second failure sets `error`. * * @internal */ @@ -1085,12 +1055,7 @@ export class QueryClient { // ====================================================== /** - * Evicts the root entity of a non-entity result that `owner` no longer - * shows (its params changed, or it was collected), unless another instance - * with the same params still shows it. A root already gone from the entity - * map is skipped: its key now belongs to the root of whichever query shows - * those params. - * + * Evicts `owner`'s old non-entity root unless another instance still shows it. * @internal */ releaseQueryRoot(root: EntityInstance, owner: QueryInstance): void { diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index e5cdb81..7d04486 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -35,14 +35,7 @@ function abortError(): Error { return error; } -/** - * The start window: open from when a query's first fetch starts on its - * microtask until the Signalium flush outstanding at that moment has run. A - * query mounted and unmounted in one task is deactivated by that flush after - * its first fetch went out. A first fetch started inside the window is left - * to finish instead of being aborted into an error. One window serves every - * first fetch started before it closes. - */ +/** Open from a microtask first-fetch start until the pending flush runs. A deactivation inside it keeps the fetch. */ let startWindow = 0; let startWindowOpen = false; @@ -97,34 +90,18 @@ export class QueryInstance { private startPending: boolean = false; /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ private pendingRestart: (() => void) | undefined = undefined; - /** - * Rejects a restart that found the query offline or inactive when it ran. - * The relay stays pending until reconnecting restarts it or a full - * deactivation rejects it. - */ + /** Rejects a restart parked while offline or inactive. */ private parkedRestart: ((error: unknown) => void) | undefined = undefined; - /** - * Signal of a first fetch that a same-task unmount let finish. GC is - * scheduled once it settles: collecting earlier would leave the fetch - * applying to an instance nothing owns. - */ + /** First fetch a same-task unmount let finish. GC waits for it to settle. */ private keptFetchSignal: AbortSignal | undefined = undefined; - /** - * Signal of a fetch whose abort leaves the relay pending instead of - * rejecting it. See `holdsDeactivationAbort()`. - */ + /** Aborted fetch that leaves the relay pending instead of rejected. */ private heldAbortSignal: AbortSignal | undefined = undefined; - /** Some param is a Signal, so its value can change before the first start runs. */ private hasSignalParams: boolean = false; - /** The first fetch's controller and its start window. See `openStartWindow()`. */ private firstFetchController: AbortController | undefined = undefined; - /** Bumped by `abortForDestroy()`, so a late response from before it is dropped. */ + /** Bumped by `abortForDestroy()` so a late response is dropped. */ private destroyCount: number = 0; private firstFetchWindow: number = 0; - /** - * A deactivation aborted the in-flight fetch, so the relay settles with an - * AbortError. The next activation refetches instead of showing it. - */ + /** The next activation refetches instead of showing the AbortError. */ private abortedByDeactivation: boolean = false; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; @@ -174,11 +151,7 @@ export class QueryInstance { /** Extra methods (__refetch, __fetchNext) attached to the root entity proxy. */ private _extraMethods: Record unknown> = {}; - /** - * Query id injected as QUERY_ID on non-entity payloads. It keys the root - * entity and follows the params, so each params key's cached record points - * at its own data. - */ + /** Query id injected as QUERY_ID on non-entity payloads. Follows the params. */ private _queryId: number = 0; get key(): number { @@ -232,14 +205,12 @@ export class QueryInstance { this.reconnectsAtDeactivate = this.queryClient.networkManager.reconnects; this.cancelDebounced(); - // Cancel initialize()'s start if it hasn't run. The next activation restarts it. + // Cancel a start that hasn't run. The next activation restarts it. this.startPending = false; const controller = this._abortController; if (controller !== undefined && !controller.signal.aborted) { - // A fetch may be waiting on the subscription this deactivation - // tears down (a topic query's send() resolves from it), so it is - // aborted and the next activation restarts it. + // A topic query's send() may wait on the subscription torn down here. const sameTask = controller === this.firstFetchController && startWindowOpen && this.firstFetchWindow === startWindow; const keepFirstFetch = !isPausing && sameTask && this.unsubscribe === undefined; @@ -269,8 +240,7 @@ export class QueryInstance { if (isPausing) return; - // A restart parked while offline has no fetch to abort. Settle it as - // the abort would have. The next activation restarts it. + // A parked restart has no fetch to abort, so settle it here. const parked = this.parkedRestart; if (parked !== undefined) { this.parkedRestart = undefined; @@ -318,15 +288,8 @@ export class QueryInstance { // If the relay shows pending but the abort controller is gone, the // previous fetch was aborted during deactivation (or never started). - // runDebounced() would bail out because isPending is still true from - // the doomed promise, which may also never settle (a topic query's - // send() waits on a subscription that is gone). A relay the abort - // already rejected is restarted too, so it doesn't show the - // AbortError until a refetch lands. So is a fetch still in flight - // for params that changed meanwhile. With reactivationStaggerMs, a - // rejected relay that holds a value is re-settled with it and - // refetches like any stale relay below, so aborted refetches are - // still spread across the window. + // runDebounced() would bail on the doomed promise. A rejected relay restarts + // too, unless a stagger can re-settle its value and spread the refetch. if ( this.abortedByDeactivation && this.queryClient.reactivationStaggerMs > 0 && @@ -369,7 +332,7 @@ export class QueryInstance { if (this.startPending) { // The pending first start sends the new params. } else if (this.relayState.isPending) { - // A fetch for the old params is in flight (or queued): replace it. + // Replace the in-flight or queued fetch for the old params. this.restartAbortedFetch(true); } else { this.runDebounced(0, false, true); @@ -407,7 +370,6 @@ export class QueryInstance { ); if (previousRoot !== undefined && previousRoot !== this.rootEntity && !def.statics.isEntityResult) { - // The params changed, so this query no longer shows the old params' root. this.queryClient.releaseQueryRoot(previousRoot, this); } @@ -501,11 +463,7 @@ export class QueryInstance { } } - /** - * A Signal param changed while an async cache load was pending. The loaded - * entry belongs to the old params, so it is dropped, and a fetch starts - * unless the change already started one. - */ + /** Drops a cache load for old params. Fetches unless the change already did. */ private startAfterParamsChangedDuringLoad(fetchStartsAtLoad: number): void { if (this.fetchStarts === fetchStartsAtLoad) this.startSubscriptionAndFetch(); } @@ -514,17 +472,12 @@ export class QueryInstance { this.runStart(true); }; - /** - * `fromMicrotask` means the start raced Signalium's flush, so a deactivation - * in that flush lets the fetch finish. A lease's start (`startPendingNow()`) - * doesn't race: the lease holds the query, and releasing it aborts the fetch. - */ + /** A microtask start races Signalium's flush, so a deactivation in it lets the fetch finish. */ private runStart(fromMicrotask: boolean): void { if (!this.startPending) return; this.startPending = false; if (this.hasSignalParams) { - // A Signal param set in the same task as the activation reaches update() - // only in Signalium's next flush. Send the params as they are now. + // A Signal param set this task reaches update() only next flush. Read it now. const extractedParams = extractParamsForKey(this.params); const storageKey = queryKeyFor(this.def, extractedParams); if (storageKey !== this.storageKey) { @@ -540,12 +493,11 @@ export class QueryInstance { } } - /** Switches to new params. Data shown until the next fetch lands is the old params'. */ private adoptParams(extractedParams: Record | undefined, storageKey: number): void { this.currentParams = extractedParams as QueryParams; this.storageKey = storageKey; this._queryId = extractedParams !== undefined ? hashValue(extractedParams) : 0; - // The timestamp belongs to the old params' data, so force a refetch as invalidation does. + // The timestamp is the old params' data. Force a refetch. if (this.updatedAt !== undefined) this.updatedAt = 0; } @@ -649,8 +601,7 @@ export class QueryInstance { ); this._executionCtx.refetch = () => this.refetch(); // `TopicQuery.getConfig.subscribe` hands this to its adapter, which calls - // it when it delivers an event for the query's topic. Non-enumerable so - // it stays out of `Object.keys(this)` inside query methods. + // it on a topic event. Non-enumerable to stay out of `Object.keys(this)`. Object.defineProperty(this._executionCtx, '_notePush', { value: this.notePush, configurable: true }); this._executionCtx.rawFetchNext = this.def.statics.rawFetchNext; // `TopicQuery.getConfig.subscribe` reads `_topicAdapter` from the ctx; @@ -689,8 +640,7 @@ export class QueryInstance { attempt.start(); try { const freshData = await adapter.send(ctx, signal); - // The adapter ignored the abort and the params changed, or the - // client was destroyed, while the request was in flight. + // Params changed or client destroyed mid-request, and the adapter ignored the abort. if (this.storageKey !== storageKey || this.destroyCount !== destroyCount) throw abortError(); this.updatedAt = Date.now(); @@ -716,8 +666,7 @@ export class QueryInstance { } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; if (signal === this.heldAbortSignal) { - // Stay pending until the next activation replaces this fetch, or - // abortForDestroy() settles it on collection or destroy. + // Stay pending until reactivation or abortForDestroy() settles it. this.heldAbortSignal = undefined; return new Promise(() => {}); } @@ -731,20 +680,8 @@ export class QueryInstance { } /** - * Whether a deactivation that aborts this query's fetch leaves the relay - * pending instead of rejecting it. Signalium keeps a rejected relay's error - * until it gets a value, so the next mount would report `isRejected` with - * the AbortError until its refetch lands (a chart switched away from - * mid-fetch and back would flash its error state). A held relay is settled - * by the next activation's refetch, or by `abortForDestroy()`. - * - * An unmount mid-fetch holds any query whose relay has no value yet, except - * one mounted and unmounted in the same task: that is a one-off read, and - * its awaiter gets the AbortError at once. A pause (offline) or a restart - * parked while offline waits on the network, not a remount, so it uses this - * method and holds only a query with a subscription, since a topic query's - * fetch waits on the subscription the deactivation tears down. Other - * awaiters get the AbortError at once. + * Whether a pause's abort leaves the relay pending. Signalium keeps a rejected + * relay's error until it gets a value, so the next activation would show it. */ private holdsDeactivationAbort(): boolean { return this.relayState.value === undefined && this.config?.subscribe !== undefined; @@ -767,10 +704,7 @@ export class QueryInstance { this.heldAbortSignal = undefined; if (this.abortedByDeactivation) { this.abortedByDeactivation = false; - // The abort rejected the relay. Signalium keeps a rejected relay's - // error while it is pending again, so the refetch would show the - // AbortError next to the cached value. Re-settling with the value - // clears it. + // Re-settle with the value. Signalium keeps the AbortError while pending again. const value = this.relayState.value; if (value !== undefined && !this.relayState.isPending) this.relayState.value = value; } @@ -796,9 +730,7 @@ export class QueryInstance { return; } if (!this._isActive || this.isPaused) { - // Went offline or inactive before it ran. Park it with the relay - // pending: reconnecting restarts it, and a full deactivation first - // rejects it. + // Went offline or inactive first. Park it until reconnect or deactivation. if (this._abortController === controller) this._abortController = undefined; this.parkedRestart = reject; return; @@ -811,19 +743,10 @@ export class QueryInstance { this.relayState.setPromise(promise); } - /** - * Queues a deferred fetch on a microtask or, with `afterFlush`, after - * Signalium's outstanding flush. A params change reaches update() inside - * that flush, which may also deactivate the query. Waiting for it costs no - * extra task and lets the deactivation cancel the fetch instead of aborting - * it after it went out. - */ + /** `afterFlush` waits for the pending flush, so a deactivation in it cancels the fetch first. */ private deferRun(run: () => void, afterFlush: boolean): void { if (afterFlush) { - // settled() also waits for later flushes, which a listener that writes - // signals can keep scheduling, so the timer caps the wait at one task. - // A flush finishes within the task it starts in, so the timer still - // fires after the outstanding one. + // settled() may keep waiting on later flushes. The timer caps it at one task. let timer: ReturnType | undefined = undefined; const once = (): void => { if (timer === undefined) return; @@ -886,8 +809,7 @@ export class QueryInstance { if (generation !== this.debounceGeneration) return; this.debounceGeneration++; this.pendingDebouncedRun = undefined; - // Deactivated or went offline in the same task. Deactivation or - // reconnecting decides what happens next. + // Deactivated or went offline meanwhile. Those paths decide what runs next. if (!this._isActive || this.isPaused) return; // Another path started a fetch in the meantime (refetch(), activation). if (this.relayState.isPending && this._abortController !== undefined) return; @@ -934,11 +856,7 @@ export class QueryInstance { } /** - * Called by `QueryClient.destroy()` and when the client collects the query. - * Aborts in-flight fetches (including a first fetch a same-task unmount - * kept), cancels deferred ones and settles the relay, so nothing reaches - * the store afterwards and no awaiter hangs. - * + * On destroy or collection. Settles every awaiter, so nothing hangs or reaches the store. * @internal */ abortForDestroy(): void { @@ -953,10 +871,7 @@ export class QueryInstance { this._abortController = undefined; this._fetchNextAbort?.abort(); this._fetchNextAbort = undefined; - // Every awaiter must get an answer. A queued restart sees its aborted - // controller and rejects, a parked one is rejected here, and an in-flight - // fetch rejects with its abort. A pending relay with no fetch at all (its - // first start never ran) is rejected directly. + // Every awaiter must settle, including a pending relay whose start never ran. const restart = this.pendingRestart; restart?.(); this.pendingRestart = undefined; @@ -1055,7 +970,6 @@ export class QueryInstance { async () => { attempt.start(); const freshData = await adapter.sendNext!(ctx, signal); - // As in runQuery(). if (this.storageKey !== storageKey || this.destroyCount !== destroyCount) throw abortError(); this.updatedAt = Date.now(); diff --git a/packages/fetchium/src/__benchmarks__/nested-read.bench.ts b/packages/fetchium/src/__benchmarks__/nested-read.bench.ts index 461b81d..ba136af 100644 --- a/packages/fetchium/src/__benchmarks__/nested-read.bench.ts +++ b/packages/fetchium/src/__benchmarks__/nested-read.bench.ts @@ -8,14 +8,8 @@ import { Entity } from '../proxy.js'; import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; -/** - * Cost of reading nested, non-entity fields of an entity. `t.object`, - * `t.record` and `t.liveArray` values are handed out through wrapping proxies, - * so every read goes through a proxy trap. Measured inside and outside a - * reactive computation. Each case reads 1,000 fields. - * - * Run with `npm run bench`. - */ +// Reads of nested non-entity fields, which go through wrapping proxies. +// Run with `npm run bench`. class Item extends Entity { __typename = t.typename('Item'); @@ -148,7 +142,7 @@ describe('nested reads, 1000 per iteration', () => { bench('in a computation: held t.record -> key', recordRender); }); -// First reads: a wrapper is created once per nested value and then cached. +// First reads create and cache the wrappers. class Row extends Entity { __typename = t.typename('Row'); id = t.id; diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index 1028572..02bb3bb 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -12,22 +12,15 @@ import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import { Entity } from '../proxy.js'; import { getEntityMapSize, sleep } from './utils.js'; -/** - * The first fetch and zero-delay refetches start on a microtask, ahead of - * Signalium's flush. That flush is where a Signal param change reaches the - * query and where a query whose last watcher left is deactivated. These tests - * pin what happens when either lands in the same task as the fetch was queued. - */ +// First fetches and zero-delay refetches start on a microtask, ahead of the +// Signalium flush that applies Signal param changes and deactivations. interface Call { path: string; aborted: boolean; } -/** - * A fetch that answers `{ value: }` after `delay` ms. By default it - * honours the abort signal, as browser and React Native fetch do. - */ +/** A fetch answering `{ value: }` after `delay` ms. Honours abort by default. */ function createFetch(delay: number, { ignoreAbort = false }: { ignoreAbort?: boolean } = {}) { const calls: Call[] = []; const fetch = (url: string, options: RequestInit = {}): Promise => { @@ -139,7 +132,6 @@ describe('Activation and deactivation in one task', () => { expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/item' }); expect(kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItem, undefined)))).toBeTypeOf('number'); - // Coming back shows the data the background request brought in. let first: any; const again = activate(client, () => (first ??= state(fetchQuery(GetItem)))); expect(first).toEqual({ isPending: false, isRejected: false, value: '/item' }); @@ -177,7 +169,6 @@ describe('Activation and deactivation in one task', () => { expect(state(relay)).toEqual({ isPending: false, isRejected: false, value: '/items/1' }); expect(persisted(kv, { id: '2' })).toBe(false); - // Coming back fetches the current params. const again = activate(client, () => fetchQuery(GetItemById, { id }).isPending); await sleep(20); expect(f.paths()).toEqual(['/items/1', '/items/2']); @@ -231,7 +222,6 @@ describe('Signal params changing around a fetch', () => { await sleep(5); client.destroy(); - // A cold start for either key, within staleTime, shows that key's data. for (const key of ['1', '2']) { const cold = createFetch(5); const next = makeClient(kv, cold.fetch); @@ -459,7 +449,6 @@ describe('Reactivating after a deactivation aborted the fetch', () => { ); await sleep(5); first(); - // A caller awaiting the refetch still settles. expect(await refetched).toBe('rejected:AbortError'); expect(state(relay)).toEqual({ isPending: false, isRejected: true, value: '/item' }); @@ -475,11 +464,7 @@ describe('Reactivating after a deactivation aborted the fetch', () => { }); }); -/** - * A socket-like topic adapter: subscribing delivers the topic's data 5 ms - * later; unsubscribing cancels that and drops the topic's state, so a send() - * still waiting on it never resolves. - */ +/** Socket-like topic adapter. Unsubscribing drops topic state, so a waiting send() never resolves. */ class SocketTopicAdapter extends TopicQueryAdapter { private timers = new Map>(); subscribe(topic: string): void { @@ -592,7 +577,7 @@ describe('A topic query unmounted before its first data, in a later task', () => if (!hadWindow) delete (globalThis as any).window; }); - /** Mounts the topic query, and unmounts it 1 ms later: before its data, due 5 ms after subscribing. */ + /** Mounts the topic query and unmounts it 1 ms later, before its data (due at 5 ms). */ async function mountAndLeave(client: QueryClient): Promise<{ relay: any }> { let relay: any; const first = activate(client, () => (relay = fetchQuery(GetPrices)).isPending); @@ -748,7 +733,6 @@ describe('Every awaiter settles', () => { second(); expect(await outcome(relay)).toBe('rejected:AbortError'); - // Mounted again online, it fetches. networkManager.setNetworkStatus(true); const third = activate(client, () => fetchQuery(GetItem).isPending); await sleep(50); @@ -848,8 +832,7 @@ describe('A Signal param change while other signals keep flushing', () => { const noise = signal(0); const dispose = activate(client, () => fetchQuery(GetItemById, { id }).isPending); - // A listener that writes a signal another watcher reads: every flush - // schedules the next one, as a ticking price feed does. + // A listener that writes a signal makes every flush schedule the next. const reader = watcher(() => noise.value); let flushes = 0; const stopReader = reader.addListener(() => { diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index 1b08e82..217b7b4 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -1695,8 +1695,7 @@ describe('streamed full payloads for a typename two classes share, entity not in id = t.id; label = t.string; } - // A list's class and a detail class of one typename: a list-shaped payload - // carries every field of its class, but not the detail's. + // A list class and a detail class of one typename. List payloads lack detail fields. class CoinRow extends Entity { __typename = t.typename('Coin'); id = t.id; @@ -1735,7 +1734,6 @@ describe('streamed full payloads for a typename two classes share, entity not in }); const row = (price: number) => ({ __typename: 'Coin', id: 'c1', symbol: 'C', price }); - /** Writes the detail's record, registers the list class, and drops both from memory. */ async function setup(tc: ReturnType): Promise { tc.mockFetch.get('/coin/c1', detail('first')); tc.mockFetch.get('/coins', { coins: [{ __typename: 'Coin', id: 'c2', symbol: 'D', price: 2 }] }); diff --git a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts index 9e4a739..4d1e400 100644 --- a/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts +++ b/packages/fetchium/src/__tests__/nested-value-reactivity.test.ts @@ -7,13 +7,8 @@ import { RESTQuery } from '../rest/index.js'; import { fetchQuery } from '../query.js'; import { testWithClient, sleep, setupTestClient } from './utils.js'; -/** - * Nested values (`t.object` and `t.record` fields, live collection values) are - * merged in place, and a child given only the nested value never reads the - * entity. A changed entity value gets a new wrapper, so the parent passes a new - * value. A live collection value's wrapper reads consume the collection's - * notifier. - */ +// Nested values merge in place. A changed entity value gets a new wrapper, and a +// live collection value's wrapper consumes the collection's notifier. describe('reads through a held nested value', () => { const getClient = setupTestClient(); @@ -54,7 +49,6 @@ describe('reads through a held nested value', () => { const balances = token.balances; expect([price.usd, change.h24, balances.SOL]).toEqual([100, 1, 1]); - // Readers through the entity re-run. const usd = reactive(() => token.price.usd); const h24 = reactive(() => token.price.change.h24); const sol = reactive(() => token.balances.SOL); @@ -64,7 +58,7 @@ describe('reads through a held nested value', () => { await sleep(0); expect([usd(), h24(), sol()]).toEqual([150, 2, 5]); - // Changed in place: new wrappers, so a holder of the old one is handed a new value. + // New wrappers, so a holder of the old one gets a new value. expect(token.price).not.toBe(price); expect(token.price.change).not.toBe(change); expect(token.balances).not.toBe(balances); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 31d45b9..c155c5a 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -116,7 +116,6 @@ describe('reactivation grace and stagger', () => { } it('passes the grace, stagger and shouldRetry options through to the query context too', () => { - // Like any other config key. const shouldRetry = () => false; const client = makeClient({ reactivationGraceMs: 5, reactivationStaggerMs: 7, shouldRetry }); expect(client.getContext()).toMatchObject({ reactivationGraceMs: 5, reactivationStaggerMs: 7, shouldRetry }); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index fa7971f..b9d0d2d 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -661,7 +661,6 @@ describe('useSuspenseQuery holds (suspendOnColdMiss)', () => { expect(retry).toMatchObject({ failed: true, promise: undefined }); expect(mockFetch.calls).toHaveLength(1); - // An error-boundary reset after the claim makes a new attempt. await vi.advanceTimersByTimeAsync(5); expect(render().promise).toBeDefined(); expect(mockFetch.calls).toHaveLength(2); @@ -691,7 +690,6 @@ describe('useSuspenseQuery holds (suspendOnColdMiss)', () => { expect(render()).toMatchObject({ failed: true, promise: undefined }); expect(mockFetch.calls).toHaveLength(2); - // A claimed error is let go on the next task, so a reset retries. await vi.advanceTimersByTimeAsync(5); expect(render().promise).toBeDefined(); expect(mockFetch.calls).toHaveLength(3); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 52ae1db..985723d 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -342,9 +342,8 @@ function mergeFields( ) ) { changed = true; - // Merged in place, so the value keeps its identity. Drop its - // wrapper so a child given just this value as a prop sees a new - // one. A merged copy is already a new value. + // Merged in place: drop the wrapper so a child given just this value gets a new + // one. if (target === oldVal) dropNestedWrapper(oldVal); existingData[fieldKey] = target; } else { diff --git a/packages/fetchium/src/nestedNotifiers.ts b/packages/fetchium/src/nestedNotifiers.ts index 891d178..b67d743 100644 --- a/packages/fetchium/src/nestedNotifiers.ts +++ b/packages/fetchium/src/nestedNotifiers.ts @@ -1,15 +1,7 @@ -/** - * Wrapping proxies for nested objects, records and arrays inside entities and - * live collection values, keyed by the wrapped value. `EntityInstance` creates - * them and `applyEntities` drops them on in-place merges. - */ +/** Wrapping proxies for nested values in entities and live collections, keyed by raw value. */ export const NESTED_WRAPPERS = new WeakMap(); -/** - * Call when a merge changes a nested value in place. The raw value keeps its - * identity, but a child holding only the wrapper (e.g. as a prop) needs a new - * value to re-render, so the next read hands out a new wrapper. - */ +/** Call when a merge changes a nested value in place, so the next read hands out a new wrapper. */ export function dropNestedWrapper(value: object): void { NESTED_WRAPPERS.delete(value); } diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 93f344a..fcfdae4 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -26,10 +26,8 @@ export interface QueryContext { debug?: (message: string) => void; }; evictionMultiplier?: number; - // `activity` and `pollResumeJitterMs` (see `QueryClientConfig`) reach the - // context as pass-through keys but are not declared here, since an app may - // have augmented this interface with its own field of the same name. - // `poll()` validates them before use. + // `activity` and `pollResumeJitterMs` reach the context undeclared: an app may + // have augmented this interface with the same names. } /** diff --git a/packages/fetchium/src/react/__tests__/component.test.tsx b/packages/fetchium/src/react/__tests__/component.test.tsx index 32f1ae6..87128fb 100644 --- a/packages/fetchium/src/react/__tests__/component.test.tsx +++ b/packages/fetchium/src/react/__tests__/component.test.tsx @@ -805,9 +805,8 @@ describe('React Query Integration with component()', () => { await userQuery!.value!.__refetch(); await sleep(10); - // mergeFields mutates the existing nested plain object in place, but a - // merge that changed it drops its cached wrapper, so the parent hands - // the memo'd child a new prop and the child re-renders. + // The merge mutates the nested object in place but drops its wrapper, so the + // memo'd child gets a new prop. expect(memoRenderCount).toBe(2); expect(getByTestId('bio').element().textContent).toBe('new bio'); }); diff --git a/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx b/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx index 65e13a6..d20e264 100644 --- a/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx +++ b/packages/fetchium/src/react/__tests__/nested-value-props.test.tsx @@ -12,11 +12,8 @@ import { fetchQuery } from '../../query.js'; import { createMockFetch, sleep } from '../../__tests__/utils.js'; import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; -/** - * A nested value passed as a prop keeps its identity when it changes, because - * it is merged in place. A `component()` child that skips re-rendering for - * identical props still has to update, through its own reads of the value. - */ +// A nested value prop keeps its identity when merged in place. A `component()` +// child that skips re-rendering for identical props updates through its own reads. describe('component() child given a nested entity value as a prop', () => { let client: QueryClient; let mockFetch: ReturnType; diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx index 710387e..d949e56 100644 --- a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -375,8 +375,8 @@ describe('useSuspenseQuery', () => { const screen = render(tree(true)); await expect.element(screen.getByText('Suspended')).toBeInTheDocument(); - // Navigated away while suspended; the fetch fails with no reader, and no - // render claims the error within the unclaimed-failure window (1 s). + // Navigated away while suspended. The fetch fails and no render claims the + // error within the 1 s window. screen.rerender(tree(false)); await sleep(1_150); expect(mockFetch.calls).toHaveLength(1); diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index 08d754a..19a4040 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -45,11 +45,8 @@ export function mergeStoredRecord( } /** - * The stored record's fields that `partial` does not write, as a JSON fragment - * (`"a":1,"b":{…}`, or `''` if none) plus the references inside them. - * Appending the fragment to `partial`'s JSON yields the merged record without - * re-reading the stored one. Undefined if the stored record is not a JSON - * object. + * The stored record's fields not in `partial`, as a JSON fragment plus their refs. + * Appended to `partial`'s JSON it gives the merged record. Undefined for a non-object. */ export function storedRecordRest( stored: string, @@ -65,8 +62,7 @@ export function storedRecordRest( const rest: Record = {}; let any = false; for (const key in record as Record) { - // JSON drops a field the partial holds as undefined, so the stored value - // stays. + // JSON drops a field the partial holds as undefined, so the stored value stays. if (partial[key] !== undefined) continue; rest[key] = (record as Record)[key]; any = true; @@ -77,7 +73,7 @@ export function storedRecordRest( return { json: JSON.stringify(rest).slice(1, -1), refIds: [...refIds] }; } -/** Adds the `__entityRef` ids in a record's JSON to `into`, as `collectEntityRefs` would for the parsed record. */ +/** Adds the `__entityRef` ids in a record's JSON to `into`. */ export function entityRefsInJson(json: string, into: Set): void { if (json.indexOf('"__entityRef":') === -1) return; const re = /"__entityRef":(\d+)/g; diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index e2f93d7..966f0da 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -79,24 +79,14 @@ export class MemoryPersistentStore implements SyncPersistentStore { */ const deleteListenersByKv = new WeakMap void>>(); -/** - * For a record last written by `mergeEntity()`: which fields that write - * carried, and the rest of the record (`storedRecordRest`). A later merge of - * the same fields builds the record from this without reading the stored one, - * so a high-rate update stream for entities not in memory reads each record - * only once. - */ +/** A `mergeEntity()` write's fields and the record's rest, so a same-fields merge skips the read. */ interface MergeRest { fields: string; json: string; refIds: number[]; } -/** - * Keyed by the backing kv, like the delete listeners. Any write or delete - * through a store over that kv drops the record's entry, so entries match - * what is on disk. Writing to the kv directly would leave them stale. - */ +/** Keyed by kv. Writes through any store over it drop entries. Direct kv writes do not. */ const mergeRestsByKv = new WeakMap>(); /** LRU bound. An evicted record's next merge reads it again. */ const MAX_MERGE_RESTS = 1024; diff --git a/packages/fetchium/src/subscriptions/polling.ts b/packages/fetchium/src/subscriptions/polling.ts index 8f73596..ba42b3f 100644 --- a/packages/fetchium/src/subscriptions/polling.ts +++ b/packages/fetchium/src/subscriptions/polling.ts @@ -19,15 +19,9 @@ export interface PollConfig { resumeJitterMs?: number; } -/** Contexts whose invalid `activity` value was already reported. */ const warnedActivity = new WeakSet(); -/** - * The context's `activity`, if it is an `ActivitySource`. Custom config keys - * pass through to the context, so an app may already use this name for its - * own value. Anything without `isActive` and `subscribe` functions is ignored, - * with a warning in development. - */ +/** The context's `activity` if it is an `ActivitySource`. Apps may use the key for their own value. */ function activitySource(queryContext: QueryContext | undefined): ActivitySource | undefined { const activity = (queryContext as Record | undefined)?.activity; if (activity === undefined || activity === null) return undefined; diff --git a/packages/fetchium/src/topic/TopicQueryAdapter.ts b/packages/fetchium/src/topic/TopicQueryAdapter.ts index 5c3f734..dcee4a0 100644 --- a/packages/fetchium/src/topic/TopicQueryAdapter.ts +++ b/packages/fetchium/src/topic/TopicQueryAdapter.ts @@ -22,14 +22,9 @@ interface TopicState { error?: unknown; } -/** - * `promise`, or a rejection once `signal` aborts. A topic's data comes from - * its subscription, which deactivation tears down, so an aborted fetch would - * otherwise wait forever, along with whoever awaits the query. - */ +/** `promise`, or a rejection once `signal` aborts. A topic fetch would otherwise hang after deactivation. */ function untilAborted(promise: Promise, signal: AbortSignal): Promise { - // React Native's AbortController polyfill sets no `reason`, so - // getAbortReason() falls back to an AbortError. + // React Native's AbortController sets no `reason`, so getAbortReason() falls back. if (signal.aborted) return Promise.reject(getAbortReason(signal)); return new Promise((resolve, reject) => { const onAbort = (): void => reject(getAbortReason(signal)); From feeacdcfb21e92cb135a7ebfdcab7bc7811e89e6 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:16:22 -0400 Subject: [PATCH 092/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/ConstraintMatcher.ts | 6 +- packages/fetchium/src/EntityInstance.ts | 144 +++++------------- packages/fetchium/src/EntityStore.ts | 7 +- packages/fetchium/src/LiveCollection.ts | 2 +- packages/fetchium/src/QueryClient.ts | 68 ++------- packages/fetchium/src/QueryResult.ts | 7 +- .../src/__tests__/entity-snapshot.test.ts | 5 +- .../src/__tests__/entity-write-counts.test.ts | 4 +- .../src/__tests__/event-persistence.test.ts | 95 +++--------- .../fetchium/src/__tests__/same-value.test.ts | 2 - .../skip-write-and-snapshot-fixes.test.ts | 70 ++------- packages/fetchium/src/applyEntities.ts | 55 ++----- packages/fetchium/src/errors.ts | 6 +- packages/fetchium/src/parseEntities.ts | 58 ++----- packages/fetchium/src/query-types.ts | 40 ++--- packages/fetchium/src/stores/async.ts | 57 ++----- packages/fetchium/src/stores/shared.ts | 8 +- packages/fetchium/src/stores/sync.ts | 17 +-- 18 files changed, 150 insertions(+), 501 deletions(-) diff --git a/packages/fetchium/src/ConstraintMatcher.ts b/packages/fetchium/src/ConstraintMatcher.ts index e5610aa..8058205 100644 --- a/packages/fetchium/src/ConstraintMatcher.ts +++ b/packages/fetchium/src/ConstraintMatcher.ts @@ -201,11 +201,7 @@ export class ConstraintMatcher { } } - /** - * With `dryRun`, reports through `onMatch` what the event would do (whether - * a binding matches, and whether one would retain the entity) without - * touching any collection. - */ + /** With `dryRun`, only reports matches through `onMatch`, touching no collection. */ routeEvent( typename: string, entityData: Record, diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index 76c598d..2339fde 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -159,11 +159,7 @@ function snapshotCameFrom(snapshotObj: unknown, sourceId: number): boolean { return snapshotOrigins.get(snapshotObj)?.sourceId === sourceId; } -/** - * Dev builds freeze snapshots, so a consumer that mutates one (a `sort()` in - * render) throws at its own call site instead of the fast path silently - * carrying the mutation forward. Production skips the per-container cost. - */ +/** Dev-only: a consumer that mutates a snapshot throws at its call site. */ function freezeInDev(obj: T): T { return IS_DEV ? Object.freeze(obj) : obj; } @@ -207,8 +203,7 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn } const next = snapshotRawValue(item, prevItem, snap); result[i] = next; - // `Object.is`, not `!==`: a NaN slot must not mark the array changed on - // every walk, or the snapshot never reaches a stable identity. + // `Object.is`, so a NaN slot doesn't change identity on every walk. if (!changed && !Object.is(next, prevArr![i])) changed = true; } return changed ? freezeInDev(result) : prevArr!; @@ -232,11 +227,7 @@ function snapshotRawValue(value: unknown, prev: unknown, snap: SnapshotFn): unkn return snap(value, prev); } -/** - * Structural equality of two snapshot values. The dev guard uses it to tell - * real drift from a value that only lost identity, such as a format parser's - * `Set`, which Signalium's `snapshotSet` rebuilds each walk. - */ +/** Dev guard: tells real drift from a value that only lost identity, like a rebuilt `Set`. */ function sameSnapshotValue(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; @@ -247,10 +238,8 @@ function sameSnapshotValue(a: unknown, b: unknown): boolean { } if (a instanceof Set || b instanceof Set) { if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false; - // A rebuilt Set keeps insertion order, so pair members by position and - // only search for one that fails to line up. The common case stays linear. + // Pair by position, searching only after the first misalignment. const bItems = [...b]; - // Indices of `b` already paired, tracked from the first misalignment on. let matched: Set | undefined; let i = 0; for (const item of a) { @@ -308,12 +297,7 @@ function walkFields( } } -/** - * Full walk: `Object.keys` on the proxy and a read per key. For proxies this - * module instance did not create: after a hot reload re-evaluates this file, - * the previous instance's proxies are still live but absent from - * `snapshotSources`. - */ +/** For proxies from a previous instance of this module (hot reload), absent from `snapshotSources`. */ function snapshotProxyByWalking(current: object, prev: unknown, snap: SnapshotFn): unknown { const obj = current as Record; const keys = Object.keys(obj); @@ -333,15 +317,11 @@ function snapshotProxyByWalking(current: object, prev: unknown, snap: SnapshotFn const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unknown => { const source = snapshotSources.get(current); if (source === undefined) { - // A proxy from a previous instance of this module (hot reload): walk it. - // Handing the proxy back would stop React re-rendering on its changes. - // Every entity proxy answers `toJSON`; a bare `Entity` instance does not. + // Hot-reloaded proxy. Returning it as-is would stop React re-rendering on its changes. if (PROXY_ID.has(current) || typeof (current as { toJSON?: unknown }).toJSON === 'function') { return snapshotProxyByWalking(current, prev, snap); } - // An `Entity` that never went through `createProxy` has no fields to read — - // its own properties are the shape's type defs. Hand it back untouched, the - // way Signalium treats any class it has no handler for. + // A bare `Entity` has no fields to read, only the shape's type defs. return current; } @@ -376,17 +356,13 @@ const snapshotEntity = (current: object, prev: unknown, snap: SnapshotFn): unkno } if (IS_DEV) { - // Re-read what the fast path skipped. A drifted field is served from the - // re-read and reported, not thrown: a throw inside a snapshot lands in - // Signalium's watcher flush, which has no catch, and stalls every consumer. + // Report drift, don't throw: Signalium's watcher flush has no catch. const drifted = verifyStaticFields(source, keys, before, snap); if (drifted !== undefined) { if (result === undefined) result = { ...before }; for (const key of Object.keys(drifted)) result[key] = drifted[key]; } - // A consumer added or deleted keys on its snapshot: rebuild with the - // shape's keys in shape order. Dev snapshots are frozen and cannot have - // been altered, so only an unfrozen one pays for the key list. + // A consumer altered the snapshot's keys. Frozen ones can't have been. if (!Object.isFrozen(before) && !sameKeyList(Object.keys(before), keys.enumerable)) { const source_ = result ?? before; const rebuilt: Record = {}; @@ -421,16 +397,12 @@ function sameKeyList(a: string[], b: string[]): boolean { return true; } -/** Dev-only: (typename, field) pairs already reported, so a drift is raised once. */ +/** Dev-only: drifts already raised, keyed `typename.field`. */ const reportedDrift = new Set(); export type SnapshotDriftHandler = (error: Error, queryClient: QueryClient) => void; -/** - * Dev-only default: log, then rethrow in a microtask so the error surfaces as - * uncaught (a red box in React Native, a failed vitest run) without throwing - * inside Signalium's watcher flush. - */ +/** Rethrows in a microtask so the error surfaces as uncaught, outside Signalium's watcher flush. */ const defaultDriftHandler: SnapshotDriftHandler = (error, queryClient) => { queryClient.getContext().log?.error?.(error.message, error); queueMicrotask(() => { @@ -440,10 +412,7 @@ const defaultDriftHandler: SnapshotDriftHandler = (error, queryClient) => { let snapshotDriftHandler: SnapshotDriftHandler = defaultDriftHandler; -/** - * Dev-only test hook: replace how a stale snapshot is raised, and forget which - * fields were already raised. `undefined` restores the default handler. - */ +/** Dev-only test hook. Also clears the raised set. `undefined` restores the default. */ export function __setSnapshotDriftHandler(handler: SnapshotDriftHandler | undefined): void { snapshotDriftHandler = handler ?? defaultDriftHandler; reportedDrift.clear(); @@ -466,11 +435,8 @@ function reportDrift(source: EntitySnapshotSource, key: string, what: string): v /** * Checks the fast path's assumptions: `data` only changes through a `notify()` * that bumps `version`, and every field outside `keys.dynamic` is a pure - * function of `data`. Re-reads the skipped fields and returns those that - * differ structurally from the cached snapshot, or `undefined` if none do. - * Skipped fields only — re-reading a dynamic - * field is not identity-stable, since snapshotting a child updates state it - * pairs on. + * function of `data`. Returns the static fields that drifted, if any. + * Dynamic fields are skipped, since re-reading them is not identity-stable. * * The re-read cancels the fast path's savings, so the speedup only shows in * production builds. @@ -520,21 +486,17 @@ export class EntityInstance { refCount: number = 0; /** Whether this instance has been written to the store. */ _persisted: boolean = false; - /** Writes handed to a store that acknowledges them, not yet acknowledged. */ + /** Writes sent to an acknowledging store, not yet acknowledged. */ _pendingWrites: number = 0; - /** Set while an apply is still reifying this instance's fields; its data is not yet a record. */ + /** An apply is still reifying this instance's fields. */ _applying: boolean = false; - /** - * The data came only from streamed events, so it may lack fields the stored - * record has: a write merges over that record. Cleared by the first full - * payload applied to the entity. - */ + /** Data came only from events, so a write merges over the stored record. */ _partial: boolean = false; - /** With `_partial`: the fields the events carried, i.e. the ones a write merges. */ + /** With `_partial`: the fields a write merges. */ _partialKeys: Set | undefined = undefined; - /** A write requested while a child was still being reified; performed when that apply is done. */ + /** A write waiting for a child's apply to finish. */ _deferredWrite: boolean = false; - /** The store is known to hold a record of this entity (hydrated from it, or written to it). */ + /** The store is known to hold a record of this entity. */ _recorded: boolean = false; private _saving: boolean = false; entityRefs: Map | undefined; @@ -619,10 +581,8 @@ export class EntityInstance { } /** - * This entity's next write drops its reference to `child`, which can delete - * the child's record. An acknowledging store does that after anything an - * apply decides meanwhile, so the child is written again rather than - * trusted. A synchronous store reports the deletion through `onDelete`. + * The write may delete the child's record after the current apply, so it is + * rewritten rather than trusted. Sync stores report deletes via `onDelete`. */ private writeDropsRef(child: EntityInstance): void { if (this._queryClient.storeAcksWrites) child.recordDropped(); @@ -673,16 +633,14 @@ export class EntityInstance { return false; } - /** `storeHolds`: what the store's `hasEntity` just said about this entity, if the caller asked. */ + /** `storeHolds`: the store's `hasEntity` answer, if the caller already has it. */ save(storeHolds?: boolean): void { const client = this._queryClient; if (this._saving) return; this._saving = true; try { - // A record's references must point at existing records, so a child the - // store has no record of is written first. A child an apply is still - // reifying (a payload that links back to an ancestor) can't be written - // yet: both are written, child first, once the apply is done. + // References must point at existing records, so unrecorded children are + // written first. A child still being reified defers both writes. const refs = this.entityRefs; if (refs !== undefined) { let deferred = false; @@ -696,7 +654,6 @@ export class EntityInstance { try { child.save(); } catch (e) { - // This entity's record still lacks the reference. this.markUnwritten(); throw e; } @@ -710,24 +667,20 @@ export class EntityInstance { } } this._deferredWrite = false; - // With no record to merge over, the fields held are the whole record: - // stop being partial so later writes skip the merge's read. + // No record to merge over, so the fields held are the whole record. if (this._partial && !this._recorded && (storeHolds ?? client.store.hasEntity?.(this.key)) === false) { this._partial = false; this._partialKeys = undefined; } // Counted before the call: a store may acknowledge synchronously. if (client.storeAcksWrites) this._pendingWrites++; - // A partial entity hands the store only the fields its events carried. try { client.entityMap.save(this, this._partial ? this._partialKeys : undefined); } catch (e) { this.markUnwritten(); throw e; } - // Marked after the call, so a store that throws leaves it unset and the - // next apply writes again. An acknowledging store marks it in - // `acknowledgeWrite`. + // After the call, so a throwing store leaves it unset. if (!client.storeAcksWrites) this._persisted = this._recorded = true; } finally { this._saving = false; @@ -739,12 +692,7 @@ export class EntityInstance { if (this._partial) this._partialKeys?.add(fieldKey); } - /** - * The store processed a write of this entity's record. Only writes this - * instance dispatched count: an acknowledgement can belong to a write from a - * previous instance (collected and re-hydrated while it was in flight), - * carrying data this instance never had. - */ + /** Ignores acks for writes a previous instance of this entity dispatched. */ acknowledgeWrite(): void { if (this._pendingWrites === 0) return; if (--this._pendingWrites === 0) this._persisted = this._recorded = true; @@ -756,11 +704,7 @@ export class EntityInstance { this.markUnwritten(); } - /** - * Its record is stale: the next apply writes it even if it finds nothing - * changed. Writes still queued hold older data, so their acknowledgements - * no longer mark it persisted. - */ + /** Forces the next apply to write. Queued writes hold older data, so their acks are dropped. */ markUnwritten(): void { this._persisted = false; this._pendingWrites = 0; @@ -785,7 +729,7 @@ function sameMembers(a: unknown[], b: unknown[]): boolean { interface NarrowedArray { source: unknown[]; filtered: unknown[]; - /** Members that do not (yet) satisfy the def; the only ones whose membership can change. */ + /** Members not (yet) satisfying the def, the only ones whose membership can change. */ excluded: EntityInstance[]; parseId: number; } @@ -890,21 +834,14 @@ export interface EntityKeys { enumerableSet: Set; } -/** - * A field whose def carries no shape: a primitive, a format, `t.typename`, or - * a set of literals. Its value can only be a primitive or a format's parsed - * value, so no other class sharing the typename can put an entity under it. - */ +/** No class sharing the typename can put an entity under a shapeless field. */ function isShapelessFieldDef(def: unknown): boolean { if (typeof def === 'number' || typeof def === 'string') return true; if (def instanceof Set) return true; return def instanceof ValidatorDef && (def.shape === undefined || def.shape === null); } -/** - * Cached per client (`queryClient.shapeKeyCache`): the split depends on which - * other classes share the typename, and that set grows as queries register. - */ +/** Cached per client: the split depends on which classes share the typename. */ function shapeKeys( validatorDef: ValidatorDef, shapeFields: Record, @@ -923,9 +860,7 @@ function shapeKeys( if (!own.includes(methodKey)) own.push(methodKey); } } - // The data object is shared by every class with this typename, so a nested - // object that is entity-free in this class may hold a child entity written - // through another class's shape. Only shapeless fields stay static then. + // Another class sharing the typename may write an entity into a nested object. const typename = validatorDef.typenameValue; const shared = typename !== undefined && (queryClient.getEntityDefsForTypename(typename)?.length ?? 0) > 1; const dynamic = enumerable.filter( @@ -993,12 +928,7 @@ function bindMethod( return bound; } -/** - * Members a narrowed array leaves out are dependencies of the read: one that - * gains the def's fields notifies itself, which must recompute the consumer. - * Included members are read through the array and never lose eligibility, so - * consuming them would only add recomputes. - */ +/** An excluded member that gains the def's fields must recompute the consumer. */ function consumeExcluded(excluded: EntityInstance[]): void { for (let i = 0; i < excluded.length; i++) excluded[i].consume(); } @@ -1092,11 +1022,7 @@ function createProxy( let keys: EntityKeys | undefined; function entityKeys(): EntityKeys { - // The client replaces its key cache when another class registers for - // this typename, and live proxies must pick up the new split. Entries are - // never overwritten, so checking the cache's identity is enough and avoids - // a WeakMap lookup per snapshot. Both extras slots matter too: these lists - // decide what a snapshot walks and re-reads. + // The client replaces its key cache when a class registers for this typename. const baseCache = queryClient.shapeKeyCache; const methodsNow = instance._extraMethods; const gettersNow = instance._extraGetters; diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 12b8fb8..45410a7 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -6,7 +6,6 @@ import { ValidatorDef } from './typeDefs.js'; export class EntityStore { private instances = new Map(); private persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void; - /** Whether the store can merge fields over a record it holds. */ mergesEntities: boolean = false; constructor( @@ -62,11 +61,7 @@ export class EntityStore { this.instances.clear(); } - /** - * With `mergeKeys`, only those fields are handed to the store, to be merged - * over the record it holds (the rest of the instance's data is what streamed - * events left unset, not what the record says). - */ + /** With `mergeKeys`, only those fields are sent, merged over the stored record. */ save(instance: EntityInstance, mergeKeys?: Set): void { let refKeys: Set | undefined; if (instance.entityRefs) { diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 88f0c6c..84f5164 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -66,7 +66,7 @@ export interface LiveCollectionParent { addChildRef(child: EntityInstance, persist?: boolean): void; removeChildRef(child: EntityInstance, persist?: boolean): void; save(): void; - /** A live field's membership changed outside an apply (an event was routed into it). */ + /** A live field's membership changed outside an apply. */ liveFieldChanged?(fieldKey: string): void; } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index e3a2b21..1a7f539 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -165,13 +165,9 @@ export class QueryClient { currentParseId: number = 0; /** Without `store.onDelete`, `_persisted` cannot be trusted. */ storeReportsDeletes: boolean = false; - /** With `store.onPersisted`, `_persisted` is set by the store's acknowledgement, not by `save()`. */ + /** With `store.onPersisted`, `_persisted` is set on acknowledgement, not by `save()`. */ storeAcksWrites: boolean = false; - /** - * Per-client static/dynamic key split for each entity shape (see - * `shapeKeys` in EntityInstance.ts). Replaced wholesale when a typename - * gains a second class, since the split depends on the other classes. - */ + /** Replaced when a typename gains a class, since the static/dynamic split depends on it. */ shapeKeyCache = new WeakMap, EntityKeys>(); /** See `QueryClientConfig.reactivationGraceMs`. */ @@ -246,7 +242,6 @@ export class QueryClient { this.entityMap.getEntity(key)?.recordDropped(); }); if (typeof offDelete === 'function') this.storeUnsubscribes.push(offDelete); - // A store that processes writes later says when a record is really there. this.storeAcksWrites = typeof this.store.onPersisted === 'function'; const offPersisted = this.store.onPersisted?.(key => { this.entityMap.getEntity(key)?.acknowledgeWrite(); @@ -338,11 +333,7 @@ export class QueryClient { // Typename Registry (per-client) // ====================================================== - /** - * Whether every write handed to the store has been processed, so a skipped - * write cannot be undone by a deletion the store has queued but not yet run. - * A synchronous store is always settled. - */ + /** No queued store deletion can undo a skipped write. */ storeIsSettled(): boolean { const store = this.store; if (store.hasQueuedDeletes !== undefined) return !store.hasQueuedDeletes(); @@ -362,8 +353,6 @@ export class QueryClient { existing.push(def); this.mergedDefCache.delete(typename); this.getMergedDef(typename); - // The snapshot fast path's static/dynamic split for this typename's - // classes now has to account for the new class's fields. this.shapeKeyCache = new WeakMap(); } else { this.typenameRegistry.set(typename, [def]); @@ -713,9 +702,7 @@ export class QueryClient { /** * Parse data: validates, formats, produces parsed entity data objects. * Does NOT touch the entity store. Call applyRefs() after to commit entities. - * - * `copyInput: false` (a fetch result or a cached record, handed over to the - * client) copies a nested object only where a parsed value differs from it. + * `copyInput: false` copies a nested object only where a parsed value differs. */ parseData( obj: unknown, @@ -774,7 +761,6 @@ export class QueryClient { return this.entityMap.getEntity(proxyKey!)!; } - /** `existing`: the instance the caller already looked up under `key`, if any. */ prepareEntity( key: number, obj: Record, @@ -824,10 +810,7 @@ export class QueryClient { return; } - // The apply writes entities the event updates, not ones it creates. A - // created entity is written once a written record references it, or below - // once the root is known to be retained. Only an event with a new root can - // create entities that need collecting here. + // Entities the event creates are written only once something retains them. const created = existing === undefined ? new Set() : undefined; try { const warn = this.context.log?.warn ?? (() => {}); @@ -844,7 +827,6 @@ export class QueryClient { this.context.log?.warn?.('Failed to apply mutation event', e); } if (existing === undefined) { - // Half applied: nothing built from it can stay. const createdRoot = this.entityMap.getEntity(key); if (createdRoot !== undefined) this.evictCreated(createdRoot, created!); } @@ -859,13 +841,8 @@ export class QueryClient { return; } - // The created root (and what it created) is written when a live array is - // about to retain it, or when the store already holds a record of it: a - // collected query's cache can still hold this entity, and the event is - // that record's only chance to stay fresh. An event is partial, so the - // write merges over the record. It comes before routing so that a failed - // write routes nothing. A store that can't say what it holds (no - // `hasEntity`) is not written, so it never accumulates unreferenced records. + // Write a created root if a live array will retain it or the store already + // holds it. Before routing, so a failed write routes nothing. let matched = false; let retains = false; this.routeEvent( @@ -881,8 +858,6 @@ export class QueryClient { undefined, /* dryRun */ true, ); - // The store is asked only when no live array retains the root, and only - // when there is something to write; its answer is handed to save(). let held: boolean | undefined; if (!entity._persisted && entity._pendingWrites === 0) { if (!retains) held = this.store.hasEntity?.(key); @@ -897,31 +872,20 @@ export class QueryClient { } } - // The dry run found no collection to route into: routing again would - // only recompute the constraint hashes. if (matched) this.routeEvent(typename, entity.data, key, type, eventSource); else this.evictUnlessAdopted(entity, created!); } - /** - * Evicts the root an event created unless an entity that existed before the - * event now references it. References from entities the same event created - * (the root included) don't count: they were only reachable through the root. - */ + /** References from entities the same event created don't count as adoption. */ private evictUnlessAdopted(root: EntityInstance, created: Set): void { let createdHolders = 0; for (const c of created) if (c.entityRefs?.has(root)) createdHolders++; if (root.refCount <= createdHolders) this.evictCreated(root, created); } - /** - * Evicts the root an event created, along with every entity the event - * created that is no longer referenced. They were never written, and - * lingering until `gcTime` would let a later event write them as orphans. - */ + /** Also evicts unreferenced entities the event created, so a later event can't write them as orphans. */ private evictCreated(root: EntityInstance, created: Set): void { root.evict(); - // Evicting one releases what it held; repeat until nothing more is free. let evicted = true; while (evicted) { evicted = false; @@ -934,10 +898,6 @@ export class QueryClient { } } - /** - * Entities whose write was requested while an apply was still reifying - * them; written once that apply is done. - */ private deferredWrites: Set | undefined; /** @internal */ @@ -945,11 +905,7 @@ export class QueryClient { (this.deferredWrites ??= new Set()).add(entity); } - /** - * Writes the deferred entities in the order they were deferred: children - * before their parents. If a write fails, the rest are marked so the next - * apply writes them. - */ + /** Children were deferred before parents, so insertion order is write order. */ /** @internal */ flushDeferredWrites(): void { const deferred = this.deferredWrites; @@ -970,10 +926,6 @@ export class QueryClient { } } - /** - * After a failed apply, what it deferred may be half reified, so nothing is - * written. Those records are stale and the next apply writes them. - */ /** @internal */ discardDeferredWrites(): void { const deferred = this.deferredWrites; diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 50a72c0..63ac80b 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -276,11 +276,8 @@ export class QueryInstance { __hasNext: () => this.hasNext, __isFetchingNext: () => this._fetchNextPromise !== undefined, }; - // The entity's key set changed, but consumers are deliberately not - // notified. This query's consumers get the proxy through the relay and - // snapshot it fresh, and other consumers of a shared entity have no use - // for this query's extras. Notifying would re-run them all and change - // their snapshot identity for unchanged data. + // Deliberately not notified: this query's consumers snapshot fresh via the + // relay, and other consumers of a shared entity don't need these extras. } return this.rootEntity.getProxy(def.statics.shape as unknown as EntityDef) as QueryResult; diff --git a/packages/fetchium/src/__tests__/entity-snapshot.test.ts b/packages/fetchium/src/__tests__/entity-snapshot.test.ts index 0facbe3..50a43a4 100644 --- a/packages/fetchium/src/__tests__/entity-snapshot.test.ts +++ b/packages/fetchium/src/__tests__/entity-snapshot.test.ts @@ -687,14 +687,12 @@ describe('Entity Snapshots', () => { try { client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 5 } }); - // The guard reports out of band instead of throwing inside the snapshot, - // and hands out what a full walk would have produced. const snap = read() as unknown as { tokens: { symbol: string }[] }; expect(snap.tokens[0].symbol).toBe('MUTATED'); expect(raised).toHaveLength(1); expect(raised[0].message).toMatch(/stale entity snapshot: Token:tok-0 field 'symbol'/); - // Raised once per field: a second recompute stays quiet. + // Raised once per field. client.applyMutationEvent({ type: 'update', typename: 'Token', data: { id: 'tok-1', price: 6 } }); read(); expect(raised).toHaveLength(1); @@ -717,7 +715,6 @@ describe('Entity Snapshots', () => { expect(Object.isFrozen(snap)).toBe(true); expect(Object.isFrozen(snap.tokens)).toBe(true); expect(Object.isFrozen(snap.tokens[0])).toBe(true); - // The classic "sort in render" bug throws where it happens, not later. expect(() => snap.tokens.sort()).toThrow(TypeError); expect(() => { snap.tokens[0].symbol = 'X'; diff --git a/packages/fetchium/src/__tests__/entity-write-counts.test.ts b/packages/fetchium/src/__tests__/entity-write-counts.test.ts index ed9df09..df689db 100644 --- a/packages/fetchium/src/__tests__/entity-write-counts.test.ts +++ b/packages/fetchium/src/__tests__/entity-write-counts.test.ts @@ -38,7 +38,6 @@ class GetList extends RESTQuery { result = { list: t.entity(List) }; } -/** Every entity write, whether it replaced the record or merged into it. */ function spyEntityWrites(store: { saveEntity: (...args: any[]) => void; mergeEntity: (...args: any[]) => void }) { const save = vi.spyOn(store, 'saveEntity'); const merge = vi.spyOn(store, 'mergeEntity'); @@ -159,8 +158,7 @@ describe('Entity Write Counts', () => { }); const saveEntity = spyEntityWrites(store); - // The item is already current in the store from the first query, so the - // apply declines its write. The insert must not put one back. + // i-1 is already current in the store, so the apply skips its write. The insert must not add one. client.applyMutationEvent({ type: 'update', typename: 'Item', diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index 7e2cfb0..824775a 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -12,11 +12,7 @@ import { AsyncQueryStore, type AsyncPersistentStore } from '../stores/async.js'; import { valueKeyFor, refCountKeyFor, refIdsKeyFor, queueKeyFor, updatedAtKeyFor } from '../stores/shared.js'; import { createMockFetch, setupTestClient, sleep } from './utils.js'; -/** - * What a streamed event leaves in the store: which records are written, which - * are not, and whether every reference on disk points at a record that exists. - * Complements `skip-write-and-snapshot-fixes.test.ts`. - */ +// What a streamed event leaves in the store, and whether every reference on disk resolves. function getDoc(kv: MemoryPersistentStore, key: number): Record | undefined { const value = kv.getString(valueKeyFor(key)); @@ -26,10 +22,7 @@ function refIds(kv: MemoryPersistentStore, key: number): number[] { return Array.from(kv.getBuffer(refIdsKeyFor(key)) ?? []); } -/** - * Every record must be referenced (a refcount) unless it is a query, and every - * reference must point at a record that exists. - */ +/** Every non-query record must be referenced, and every reference must resolve. */ function audit(kv: MemoryPersistentStore): { orphans: number[]; dangling: string[] } { const ids = new Set(); for (const k of kv.getAllKeys()) { @@ -60,14 +53,12 @@ function holdQuery(client: QueryClient, start: () => T): T { const K = (typename: string, id: string | number) => hashValue([typename, id]); -/** The keys of every entity write, whichever store method carried it. */ function spyWrites(store: SyncQueryStore): () => number[] { const saves = vi.spyOn(store, 'saveEntity'); const merges = vi.spyOn(store, 'mergeEntity'); return () => [...saves.mock.calls.map(c => c[0]), ...merges.mock.calls.map(c => c[0])]; } -/** Makes every write of the given key throw, whichever store method carries it. */ function failWritesOf(store: SyncQueryStore, key: number, shouldFail: () => boolean = () => true): void { const save = store.saveEntity.bind(store); const merge = store.mergeEntity.bind(store); @@ -81,10 +72,6 @@ function failWritesOf(store: SyncQueryStore, key: number, shouldFail: () => bool }); } -// ====================================================== -// Which records an event writes -// ====================================================== - describe('records a streamed event writes', () => { class Badge extends Entity { __typename = t.typename('Badge'); @@ -168,7 +155,7 @@ describe('records a streamed event writes', () => { await sleep(5); const u7 = client.entityMap.getEntity(K('User', 'u7'))!; - // The comment references u7 twice (author and mentions[0]) and u7 keeps its badge. + // u7 is referenced twice (author and mentions[0]). expect(client.entityMap.getEntity(K('Comment', 'c9'))!.entityRefs!.get(u7)).toBe(2); expect([...u7.entityRefs!.keys()].map(e => e.key)).toEqual([K('Badge', 'b7')]); expect(writes().filter(k => k === K('User', 'u7'))).toHaveLength(1); @@ -209,7 +196,6 @@ describe('records a streamed event writes', () => { expect(getDoc(kv, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); expect((q.value as any).comments.map((c: any) => c.author.badge.label)).toEqual(['gold', 'gold']); - // A refetch with the same payload changes nothing. mockFetch.get('/comments', payload()); await (q.value as any).__refetch(); expect(u1.refCount).toBe(2); @@ -253,8 +239,7 @@ describe('records a streamed event writes', () => { client.entityMap.getEntity(K('User', 'u1'))!.evict(); expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); - // The payload carries the required fields (an event for an entity that is - // not in memory is parsed as a full record) but not the optional badge. + // Required fields only: an event for an entity not in memory parses as a full record. client.applyMutationEvent({ type: 'update', typename: 'User', @@ -262,14 +247,12 @@ describe('records a streamed event writes', () => { }); await sleep(5); - // The badge reference the event did not carry survives. expect(getDoc(kv, K('User', 'u1'))).toMatchObject({ name: 'Alicia', karma: 10 }); expect(refIds(kv, K('User', 'u1'))).toEqual([K('Badge', 'b1')]); expect(getDoc(kv, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); expect(kv.getNumber(refCountKeyFor(K('Badge', 'b1')))).toBe(1); expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); - // A replaced child is written and the old one released. client.applyMutationEvent({ type: 'update', typename: 'User', @@ -288,7 +271,6 @@ describe('records a streamed event writes', () => { expect(getDoc(kv, K('Badge', 'b1'))).toBeUndefined(); expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); - // The next session hydrates the merged record. client.destroy(); const warn = vi.fn(); const second = new QueryClient({ @@ -326,9 +308,7 @@ describe('records a streamed event writes', () => { await sleep(5); expect(warn).toHaveBeenCalledWith('Failed to apply mutation event', expect.any(Error)); - // Memory and the store agree: the comment is in neither, and no record - // references it. The author written before the failing write stays behind - // unreferenced, as with any write that fails mid-walk. + // The author written before the failing write stays behind unreferenced. expect((postQ.value as any).post.comments).toEqual([]); expect((postQ.value as any).post.commentCount).toBe(0); expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); @@ -377,7 +357,6 @@ describe('records a streamed event writes', () => { expect((postQ.value as any).post.comments).toEqual([]); expect((postQ.value as any).post.commentCount).toBe(0); - // The transport retries once the store works again. fail = false; client.applyMutationEvent(event()); await sleep(5); @@ -408,7 +387,7 @@ describe('records a streamed event writes', () => { const threadQ = holdQuery(client, () => fetchQuery(GetThread, { id: 'p1' })); await threadQ; await holdQuery(client, () => fetchQuery(GetComments)); - // The comments query is collected from memory; its record stays on disk. + // Collected from memory. Its record stays on disk. client.entityMap.getEntity(K('Comment', 'c1'))!.evict(); client.applyMutationEvent({ @@ -470,7 +449,7 @@ describe('records a streamed event writes', () => { const artQ = holdQuery(client, () => fetchQuery(GetArticle, { id: 'a1' })); await artQ; - // No live collection routes notes; the payload's article points back at the note. + // The payload's article points back at the note. client.applyMutationEvent({ type: 'create', typename: 'Note', @@ -491,7 +470,6 @@ describe('records a streamed event writes', () => { expect(getDoc(kv, K('Note', 'n9'))).toMatchObject({ body: 'hi' }); expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); - // A later event for the note reaches the article's consumer. client.applyMutationEvent({ type: 'update', typename: 'Note', data: { id: 'n9', body: 'edited' } }); expect((artQ.value as any).article.latestNote.body).toBe('edited'); expect(getDoc(kv, K('Note', 'n9'))).toMatchObject({ body: 'edited' }); @@ -628,8 +606,7 @@ describe('records a streamed event writes', () => { }); await sleep(5); - // The topic's record was not written with a reference to a record that - // does not exist; memory holds the adoption and the next write heals it. + // Memory holds the adoption and the next write heals the record. expect(audit(kv).dangling).toEqual([]); expect((topicQ.value as any).topic.replies).toEqual([]); expect((topicQ.value as any).topic.latestComment.body).toBe('hi'); @@ -741,8 +718,7 @@ describe('records a streamed event writes', () => { client.entityMap.getEntity(K('Badge', 'b1'))?.evict(); client.entityMap.getEntity(K('Icon', 'i1'))?.evict(); - // The badge under the event is a created entity with a record on disk: - // its own merge keeps the icon the event did not mention. + // A created badge with a record on disk: its merge keeps the icon. client.applyMutationEvent({ type: 'update', typename: 'Profile', @@ -809,8 +785,7 @@ describe('records a streamed event writes', () => { it('a member routed into the live array of an entity built from events reaches its record', async () => { const { client, mockFetch, kv } = getClient(); - // A second class for Post without the live fields makes them optional in - // the merged definition, so an event for a post can omit them. + // Without the live fields, a second class makes them optional in events. class PostPreview extends Entity { __typename = t.typename('Post'); id = t.id; @@ -850,11 +825,9 @@ describe('records a streamed event writes', () => { const blogQ = holdQuery(client, () => fetchQuery(GetBlog, { id: 'b1' })); await blogQ; expect(refIds(kv, K('Post', 'p9'))).toEqual([K('Comment', 'c1')]); - // The post is collected from memory; its record stays on disk. client.entityMap.getEntity(K('Post', 'p9'))!.evict(); - // An event brings the post back into memory, retained by the blog's live - // array and built from the event alone: no comments, no count. + // Rebuilt from the event alone: no comments, no count. client.applyMutationEvent({ type: 'update', typename: 'Post', @@ -866,7 +839,6 @@ describe('records a streamed event writes', () => { expect(p9._partial).toBe(true); expect(getDoc(kv, K('Post', 'p9'))).toMatchObject({ title: 'T2', comments: [{ __entityRef: K('Comment', 'c1') }] }); - // A comment routed into its (empty in memory) live array reaches the record. client.applyMutationEvent({ type: 'create', typename: 'Comment', @@ -930,7 +902,6 @@ describe('records a streamed event writes', () => { const postQ = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); await postQ; await holdQuery(client, () => fetchQuery(GetUser, { id: 'u7' })); - // The author's record is on disk with its badge; the author is collected from memory. client.entityMap.getEntity(K('User', 'u7'))!.evict(); client.entityMap.getEntity(K('Badge', 'b7'))?.evict(); @@ -947,7 +918,6 @@ describe('records a streamed event writes', () => { }); await sleep(5); expect((postQ.value as any).post.comments.map((c: any) => c.author.name)).toEqual(['Newbie']); - // The event did not mention the badge: the record keeps it. expect(getDoc(kv, K('User', 'u7'))).toMatchObject({ name: 'Newbie', karma: 2, @@ -958,10 +928,6 @@ describe('records a streamed event writes', () => { }); }); -// ====================================================== -// Entities with an in-memory gcTime linger after release -// ====================================================== - describe('records a streamed event writes when released entities linger (gcTime)', () => { class Badge extends Entity { static cache = { gcTime: 1 }; @@ -1017,15 +983,13 @@ describe('records a streamed event writes when released entities linger (gcTime) }); await sleep(5); - // Nothing references what the event created. Had the author lingered - // until its gcTime, a later event could have written it as an orphan. + // Had the author lingered until gcTime, a later event could write it as an orphan. expect(client.entityMap.getEntity(K('Comment', 'c9'))).toBeUndefined(); expect(client.entityMap.getEntity(K('User', 'u7'))).toBeUndefined(); expect(client.entityMap.getEntity(K('Badge', 'b7'))).toBeUndefined(); expect(writes()).toEqual([]); expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); - // A second unrouted event for the same author still writes nothing. client.applyMutationEvent({ type: 'create', typename: 'Comment', @@ -1035,7 +999,6 @@ describe('records a streamed event writes when released entities linger (gcTime) expect(writes()).toEqual([]); expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); - // A routed create with the same author writes it, with its badge. client.applyMutationEvent({ type: 'create', typename: 'Comment', @@ -1049,7 +1012,6 @@ describe('records a streamed event writes when released entities linger (gcTime) expect(refIds(kv, K('User', 'u7'))).toEqual([K('Badge', 'b7')]); expect(audit(kv)).toEqual({ orphans: [], dangling: [] }); - // GC time passes; the entities are retained and survive. await sleep(300); expect(client.entityMap.getEntity(K('User', 'u7'))).toBeDefined(); expect(client.entityMap.getEntity(K('Badge', 'b7'))).toBeDefined(); @@ -1075,9 +1037,7 @@ describe('records a streamed event writes when released entities linger (gcTime) }); }); -// ====================================================== // AsyncQueryStore writer -// ====================================================== class AsyncDelegate implements AsyncPersistentStore { kv: Record = {}; @@ -1245,7 +1205,6 @@ describe('AsyncQueryStore writer', () => { expect(asyncDoc(delegate, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); - // An entity the store has never seen leaves nothing behind. client.applyMutationEvent({ type: 'update', typename: 'User', @@ -1286,7 +1245,6 @@ describe('AsyncQueryStore writer', () => { const client = makeClient(store, mockFetch); await holdQuery(client, () => fetchQuery(GetUser, { id: 'u2' })); expect(store.hasEntity!(K('User', 'u1'))).toBeUndefined(); - // What this writer wrote itself is known even now. expect(store.hasEntity!(K('User', 'u2'))).toBe(true); const event = () => ({ type: 'update' as const, @@ -1392,7 +1350,7 @@ describe('AsyncQueryStore writer', () => { it('a store without mergeEntity gets the whole in-memory data of an event-built entity', async () => { const kv = new MemoryPersistentStore(); const inner = new SyncQueryStore(kv); - // A custom store forwarding the pre-0.6 surface only. + // A custom store with only the pre-0.6 surface. const custom = { loadQuery: inner.loadQuery.bind(inner), saveQuery: inner.saveQuery.bind(inner), @@ -1425,8 +1383,7 @@ describe('AsyncQueryStore writer', () => { data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, }); await sleep(5); - // Written whole, dropping the badge the event did not carry, never as a - // record holding only the event's fields. + // Written whole, never as a record of only the event's fields. expect(getDoc(kv, K('User', 'u1'))).toMatchObject({ __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }); expect(getDoc(kv, K('User', 'u1'))!.badge).toBeUndefined(); }); @@ -1452,8 +1409,7 @@ describe('AsyncQueryStore writer', () => { expect(store.isSettled()).toBe(false); await purged; - // The write was acknowledged before the purge dropped the record, so the - // client that heard both ends up with the record marked missing. + // Acknowledged before the purge dropped it, so the record ends up marked missing. expect(log).toEqual(['ack 5', 'delete 9', 'delete 5']); expect(delegate.kv[valueKeyFor(5)]).toBeUndefined(); expect(store.hasEntity!(5)).toBe(false); @@ -1531,7 +1487,6 @@ describe('AsyncQueryStore writer', () => { first.acknowledgeWrite(); expect(first._persisted).toBe(false); - // Two writes in flight: the record is current once both have landed. first.save(); first.save(); expect(first._pendingWrites).toBe(2); @@ -1557,7 +1512,6 @@ describe('AsyncQueryStore writer', () => { result = { list: t.entity(List) }; } - // Holds the write of one record until released. class GatedDelegate extends AsyncDelegate { gatedKey: string | undefined; gate: Promise | undefined; @@ -1600,10 +1554,6 @@ describe('AsyncQueryStore writer', () => { }); }); -// ====================================================== -// Store work per streamed event -// ====================================================== - describe('store work per streamed event', () => { class Author extends Entity { __typename = t.typename('Author'); @@ -1684,7 +1634,6 @@ describe('store work per streamed event', () => { await sleep(5); expect(merges.mock.calls.map(c => c[0])).toEqual([K('Note', 'n1')]); - // The field the event did not carry survives. expect(getDoc(kv, K('Note', 'n1'))).toMatchObject({ body: 'b', pinned: true }); }); }); @@ -1696,7 +1645,7 @@ describe('AsyncQueryStore writer: write skipping while writes are queued', () => }); class Badge extends Entity { - // Lingers in memory once released, so a later apply finds the same instance. + // Lingers once released, so a later apply finds the same instance. static cache = { gcTime: 60 }; __typename = t.typename('Badge'); id = t.id; @@ -1769,7 +1718,6 @@ describe('AsyncQueryStore writer: write skipping while writes are queued', () => it('an entity a queued write stops referencing is written again when it is referenced again', async () => { class Card extends Entity { - // Lingers in memory once released, so the next event finds the same instance. static cache = { gcTime: 60 }; __typename = t.typename('Card'); id = t.id; @@ -1801,8 +1749,7 @@ describe('AsyncQueryStore writer: write skipping while writes are queued', () => await drain(store); await scanned(store); - // The deck's write drops its reference to c1, the only one on disk. Before - // it is processed, the same card is created again with identical data. + // Recreated with identical data before the deck's write, which drops c1, lands. client.applyMutationEvent({ type: 'delete', typename: 'Card', data: 'c1' }); client.applyMutationEvent({ type: 'create', typename: 'Card', data: card }); await drain(store); @@ -1813,18 +1760,13 @@ describe('AsyncQueryStore writer: write skipping while writes are queued', () => }); }); -// ====================================================== -// A typename two classes share, across sessions -// ====================================================== - describe('an event for a typename whose other class is not registered this session', () => { class Tag extends Entity { __typename = t.typename('Tag'); id = t.id; label = t.string; } - // A list's class and a detail class of one typename: a list-shaped event - // carries every field of the list's class, but not the detail's. + // A list-shaped event carries every CoinRow field, but not the detail's. class CoinRow extends Entity { __typename = t.typename('Coin'); id = t.id; @@ -1939,7 +1881,6 @@ describe('an event for a typename whose other class is not registered this sessi typename: 'Market', data: { __typename: 'Market', id: 'm1', top: row('c1', 3) }, }); - // A later event for the in-memory entity still merges. client.applyMutationEvent({ type: 'update', typename: 'Coin', data: row('c1', 4) }); expect(getDoc(kv, K('Coin', 'c1'))).toEqual(detailRecord(4)); diff --git a/packages/fetchium/src/__tests__/same-value.test.ts b/packages/fetchium/src/__tests__/same-value.test.ts index 3fadb13..67ab7a9 100644 --- a/packages/fetchium/src/__tests__/same-value.test.ts +++ b/packages/fetchium/src/__tests__/same-value.test.ts @@ -43,8 +43,6 @@ describe('sameValue', () => { }); it('treats NaN as unchanged and -0 as a change (Object.is semantics)', () => { - // Re-applying a NaN field must not notify on every refetch, and the sign - // of zero is observable through `Object.is` on a proxy read. expect(sameValue(Number.NaN, Number.NaN)).toBe(true); expect(sameValue(0, -0)).toBe(false); }); diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index 1f17054..bbd14a3 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -13,10 +13,7 @@ import { AsyncQueryStore, type AsyncPersistentStore } from '../stores/async.js'; import { valueKeyFor, refIdsKeyFor, queueKeyFor, DEFAULT_MAX_COUNT } from '../stores/shared.js'; import { createMockFetch, setupTestClient, testWithClient, sleep } from './utils.js'; -/** - * Invariants the snapshot fast path, unchanged-apply skipping, single-write - * events and store deletion reporting must keep. - */ +// Invariants of the snapshot fast path, write skipping, events and store deletion reporting. function getDoc(kv: MemoryPersistentStore, key: number): Record | undefined { const value = kv.getString(valueKeyFor(key)); @@ -61,10 +58,6 @@ function makeClient(store: SyncQueryStore | AsyncQueryStore, mockFetch: ReturnTy return { client, warn }; } -// ====================================================== -// Cache hydration must not re-parse an entity that is already in memory -// ====================================================== - describe('hydration of a cached query whose entity is already in memory', () => { const getClient = setupTestClient(); class User extends Entity { @@ -92,13 +85,12 @@ describe('hydration of a cached query whose entity is already in memory', () => mockFetch.get('/a', { user: payload }); mockFetch.get('/b', { user: payload, extra: 'e' }); - // Session 1 persists both queries. const { client: first } = makeClient(store, mockFetch); await holdQuery(first, () => fetchQuery(GetA)); await holdQuery(first, () => fetchQuery(GetB)); first.destroy(); - // Session 2: the network is unreachable; A hydrates, then B hydrates the same entity. + // Session 2, offline: A hydrates, then B hydrates the same entity. mockFetch.get('/a', { user: payload }, { delay: 10_000 }); mockFetch.get('/b', { user: payload, extra: 'e' }, { delay: 10_000 }); const { client, warn } = makeClient(store, mockFetch); @@ -113,8 +105,7 @@ describe('hydration of a cached query whose entity is already in memory', () => const hB = snapshotHarness(client, () => fetchQuery(GetB)); await sleep(20); - // The in-memory data was not re-parsed: the proxy, the store record and - // both consumers agree, and A's consumer had no reason to recompute. + // Not re-parsed, so A's consumer had no reason to recompute. const instance = client.entityMap.getEntity(hashValue(['User', 1]))!; expect((instance.data.profile as { since: unknown }).since).toBeDefined(); expect((hA.query.value as any).user.profile.since).toBeInstanceOf(Date); @@ -166,7 +157,6 @@ describe('hydration of a cached query whose entity is already in memory', () => mockFetch.get('/wide', { doc: { __typename: 'Doc', id: 1, title: 'T', body: 'B2' } }); const wide = holdQuery(client, () => fetchQuery(GetWide)); await wide; - // The cache was dropped with a warning and the network answered instead. expect(warn).toHaveBeenCalledWith(expect.stringContaining('query cache may be corrupted'), expect.anything()); expect((wide.value as any).doc.body).toBe('B2'); client.destroy(); @@ -210,8 +200,7 @@ describe('hydration of a cached query whose entity is already in memory', () => await holdQuery(first, () => fetchQuery(GetFull)); first.destroy(); - // Session 2: the summary hydrates User:1 without `meta.owner`; the full - // query must not resolve from the cache with the owner missing. + // Session 2: the summary hydrates User:1 without `meta.owner`. mockFetch.get('/full', { user: { ...full, meta: { ...full.meta, x: 2 } } }, { delay: 50 }); const { client, warn } = makeClient(store, mockFetch); const summary = holdQuery(client, () => fetchQuery(GetSummary)); @@ -321,8 +310,7 @@ describe('hydration of a cached query whose entity is already in memory', () => await holdQuery(first, () => fetchQuery(GetTeam)); first.destroy(); - // u-1 is in memory without `email`; the members array narrows it out on - // read, so the cached team is still usable as it is. + // u-1 lacks `email`, but the members array narrows it out on read. const { client, warn } = makeClient(store, mockFetch); await holdQuery(client, () => fetchQuery(GetOwner)); mockFetch.get( @@ -374,8 +362,7 @@ describe('hydration of a cached query whose entity is already in memory', () => await holdQuery(first, () => fetchQuery(GetPost, { id: 'p1' })); first.destroy(); - // The post comes into memory through an event routed into the feed: - // title only. Then the cached post query hydrates the same entity. + // An event brings the post into memory with its title only. const { client, warn } = makeClient(store, mockFetch); const feed = holdQuery(client, () => fetchQuery(GetFeed, { id: 'f1' })); await feed; @@ -398,7 +385,6 @@ describe('hydration of a cached query whose entity is already in memory', () => const post = holdQuery(client, () => fetchQuery(GetPost, { id: 'p1' })); await sleep(20); expect(warn).not.toHaveBeenCalled(); - // The event's title stays; the fields it did not carry come from the record. expect((post.value as any).post.title).toBe('T2'); expect((post.value as any).post.body).toBe('hello'); expect((post.value as any).post.tags).toEqual(['a', 'b']); @@ -476,8 +462,7 @@ describe('hydration of a cached query whose entity is already in memory', () => note: { text: 'n' }, }); const before = JSON.stringify(body); - // Hands out the same frozen object on every call, as an adapter that - // keeps its last response may. + // The same frozen object on every call, as an adapter may hand out. const fetch = async () => ({ ok: true, status: 200, headers: new Headers(), json: async () => body }); const { client, warn } = makeClient( new SyncQueryStore(new MemoryPersistentStore()), @@ -497,7 +482,7 @@ describe('hydration of a cached query whose entity is already in memory', () => expect(h.read().doc.meta.views).toBe(2); expect(h.read().doc.meta.stats.likes).toBe(4); expect(h.read().doc.counts.a).toBe(2); - // The live array grows its own array, not the response's (empty, frozen) one. + // The live array grows its own array, not the frozen response's. client.applyMutationEvent({ type: 'create', typename: 'DocNote', @@ -517,10 +502,6 @@ describe('hydration of a cached query whose entity is already in memory', () => }); }); -// ====================================================== -// Streamed events: what an event changes in an existing entity is written -// ====================================================== - describe('mutation events and the store', () => { const getClient = setupTestClient(); @@ -621,7 +602,7 @@ describe('mutation events and the store', () => { it('a full-payload update for an entity on disk but not in memory refreshes its record', async () => { const { client, mockFetch, kv, store } = getClient(); await seed(client, mockFetch, false); - // Evict User u1 from memory while its record stays referenced by the cached query. + // Its record stays referenced by the cached query. client.entityMap.getEntity(u1Key)!.evict(); expect(client.entityMap.getEntity(u1Key)).toBeUndefined(); expect(getDoc(kv, u1Key)!.name).toBe('Alice'); @@ -637,7 +618,6 @@ describe('mutation events and the store', () => { expect(getDoc(kv, u1Key)).toMatchObject({ name: 'Alicia', karma: 12 }); expect(store.hasEntity(u1Key)).toBe(true); - // An update for an entity the store has never seen leaves nothing behind. client.applyMutationEvent({ type: 'update', typename: 'User', @@ -648,10 +628,6 @@ describe('mutation events and the store', () => { }); }); -// ====================================================== -// `_persisted` is only trusted while the store agrees -// ====================================================== - describe('write skipping stays consistent with the store', () => { class User extends Entity { __typename = t.typename('User'); @@ -687,8 +663,7 @@ describe('write skipping stays consistent with the store', () => { expect((relay.value as any)?.user?.name ?? client.entityMap.getEntity(userKey)!.data.name).toBe('Alicia'); expect(getDoc(kv, userKey)).toMatchObject({ name: 'Alice' }); - // The store recovers; the refetch returns the same data the entity already - // holds, so nothing changed in memory, yet the record must be rewritten. + // The refetch matches memory, yet the record must be rewritten. failing = false; await withContexts([[QueryClientContext, client]], () => fetchQuery(GetUser).value ?? undefined); await (client as any).queryInstances.values().next().value.refetch(); @@ -707,9 +682,7 @@ describe('write skipping stays consistent with the store', () => { }); }); -// ====================================================== // AsyncQueryStore writer -// ====================================================== class AsyncDelegate implements AsyncPersistentStore { readonly kv: Record = Object.create(null); @@ -810,8 +783,7 @@ describe('AsyncQueryStore writer used in-process', () => { await drain(store); expect(delegate.kv[valueKeyFor(userKey('1'))]).toBeDefined(); - // Q3's SaveQuery will evict Q1 and cascade to User 1 once processed. Before - // the writer gets there, Q1 refetches identical data. + // Q3's queued SaveQuery will evict Q1 and cascade to User 1. Q1 refetches first. await holdQuery(client, () => fetchQuery(GetProfile, { id: '3' })); expect(store.isSettled()).toBe(false); await (q1.value as any).__refetch(); @@ -820,7 +792,6 @@ describe('AsyncQueryStore writer used in-process', () => { expect(delegate.kv[valueKeyFor(userKey('1'))]).toBeDefined(); expect(warn).not.toHaveBeenCalled(); - // Cold start on the same store: Q1 hydrates from cache. client.destroy(); clients.pop(); mockFetch.reset(); @@ -898,7 +869,7 @@ describe('AsyncQueryStore writer used in-process', () => { const def = (maxCount: number) => ({ statics: { id: defId, cache: { maxCount } } }) as never; const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); try { - // A 4-slot queue from an older install, fully populated, with records behind each key. + // A full 4-slot queue from an older install. const old = new Uint32Array([4, 3, 2, 1]); delegate.kv[queueKeyFor(defId)] = old; for (const key of old) { @@ -914,8 +885,7 @@ describe('AsyncQueryStore writer used in-process', () => { expect(Array.from(queue)).toEqual([9, 4, 3, 2, 1, 0]); expect(consoleError).not.toHaveBeenCalled(); - // Shrink (after a restart, so the in-memory queue is not cached): the - // keys that no longer fit are evicted, not stranded. + // Shrink after a restart: keys that no longer fit are evicted, not stranded. const restarted = writerStore(delegate); restarted.activateQuery(def(3), 9); await drain(restarted); @@ -946,10 +916,6 @@ describe('AsyncQueryStore writer used in-process', () => { }); }); -// ====================================================== -// Snapshot fast path -// ====================================================== - describe('snapshot fast path follow-ups', () => { const getClient = setupTestClient(); @@ -1130,8 +1096,7 @@ describe('snapshot fast path follow-ups', () => { const computesBefore = wrapped.computes(); expect('__refetch' in before.profile).toBe(false); - // The entity-rooted query applies identical data. Its own consumer sees - // the extras, but the wrapped query's consumer is not re-run for them. + // The wrapped query's consumer is not re-run for the root query's extras. const root = snapshotHarness(client, () => fetchQuery(GetRoot)); await root.query; await sleep(5); @@ -1233,7 +1198,6 @@ describe('snapshot fast path follow-ups', () => { expect(Object.is(h.read().quote.history[0], -0)).toBe(true); expect(h.computes()).toBe(before + 1); - // The same data again is not a change. client.applyMutationEvent({ type: 'update', typename: 'Quote', data: { id: 'q', price: -0, history: [-0, 1] } }); expect(h.computes()).toBe(before + 1); }); @@ -1260,8 +1224,7 @@ describe('snapshot fast path follow-ups', () => { expect(Object.isFrozen(snap.meta)).toBe(true); const warn = vi.spyOn(client.getContext().log as { warn: (...args: unknown[]) => void }, 'warn'); - // An app forwards part of a snapshot as an event payload (or a mutation's - // `effects.updates`); the parser must not write parsed values into it. + // A snapshot forwarded as an event payload must not be written into. client.applyMutationEvent({ type: 'update', typename: 'Doc', @@ -1361,8 +1324,7 @@ describe('snapshot fast path follow-ups', () => { await h.query; expect(h.read().roster.members.size).toBe(3000); - // Each event recomputes the snapshot at an unchanged version of `roster`, - // so the dev guard compares the rebuilt Set with the previous one. + // Each event makes the dev guard compare a rebuilt Set with the previous one. const started = performance.now(); for (let i = 0; i < 20; i++) { client.applyMutationEvent({ type: 'update', typename: 'Roster', data: { id: 'o', label: `b${i}` } }); diff --git a/packages/fetchium/src/applyEntities.ts b/packages/fetchium/src/applyEntities.ts index 8d24bb5..c357ec4 100644 --- a/packages/fetchium/src/applyEntities.ts +++ b/packages/fetchium/src/applyEntities.ts @@ -28,11 +28,9 @@ export interface ApplyResult { } /** - * Whether an apply writes to the store. `true` writes every entity that needs - * it, and `false` writes nothing (cache hydration). `'existing'` writes only - * entities already in memory. Ones this apply creates are written when a - * written record references them (`EntityInstance.save` writes missing - * children first), or by the caller once it knows the root is retained. + * `false` writes nothing (cache hydration). `'existing'` writes only entities + * already in memory. Created ones are written once a written record references + * them, or by the caller once it knows the root is retained. */ export type PersistMode = boolean | 'existing'; @@ -60,9 +58,6 @@ export function applyEntityRefs( throw e; } - // Entities a written record referenced while they were still being reified - // (a payload that links back to an ancestor) are complete now, and so are - // the records that reference them. queryClient.flushDeferredWrites(); return { data, entityRefs }; @@ -88,8 +83,7 @@ function reifyAndApply( return applyEntity(entity, seen, queryClient, persist, entityRefs, appendMode, created); } - // Assign only slots whose value changes: values the parser did not declare - // (and so did not copy) may belong to the caller and be frozen. + // Assign only on change: values the parser didn't copy may be frozen. if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const item = value[i]; @@ -135,10 +129,7 @@ function applyEntity( const { key, data, shape: entityShape, rawKeys, eventKeys, fillsPartial } = entity; const shapeFields = entityShape.shape; - // An entity can appear in several slots of one payload (under `author` and - // `mentions`), all sharing one parsed object. Applying it again would find - // its children already reified, count none and release every child ref, so - // only count this slot's reference. + // Already applied from another slot: re-applying would release every child ref. const applied = queryClient.entityMap.getEntity(key); if (applied !== undefined && applied.parseId === queryClient.currentParseId) { parentEntityRefs.set(applied, (parentEntityRefs.get(applied) ?? 0) + 1); @@ -158,9 +149,7 @@ function applyEntity( // A refetch or poll that returns identical data neither notifies nor writes. let changed = true; - // Until the fields are reified the data is not a record yet: a write that - // reaches this instance through a reference from inside its own subtree is - // deferred to the end of the walk. + // A write reaching this instance from inside its own subtree is deferred. entityInstance._applying = true; try { if (isUpdate) { @@ -179,32 +168,20 @@ function applyEntity( created, ); if ((persist === true && rawKeys === undefined) || fillsPartial) { - // A full payload (a fetch), or the record's fields merged in, makes - // the data a complete record again. entityInstance._partial = false; entityInstance._partialKeys = undefined; } else if (entityInstance._partial && rawKeys !== undefined) { - // Stays partial even after events carry every field of this class: - // another class sharing the typename may have written fields this one - // doesn't declare, and only a merge keeps them. + // Stays partial: another class sharing the typename may hold undeclared fields. for (const k of rawKeys) entityInstance._partialKeys!.add(k); } } else { initFields(shapeFields, data, entityInstance, data, seen, queryClient, persist, childRefs, appendMode, created); if (eventKeys !== undefined) { - // Streamed events are partial: what this entity holds is what its - // events carried, not necessarily the whole record. entityInstance._partial = true; entityInstance._partialKeys = new Set(eventKeys); - // A record always carries its typename and id, whether or not the - // event did (the parser fills the literal in). if (entityShape.typenameField !== undefined) entityInstance._partialKeys.add(entityShape.typenameField); if (typeof entityShape.idField === 'string') entityInstance._partialKeys.add(entityShape.idField); - // Partial even if the event carried every field of this class, for the - // same reason (the other class may not be registered this session). - // With no record to merge into, the first write makes it whole. } else if (persist === false) { - // Hydrated from the store: its record exists. entityInstance._recorded = true; } if (persist === 'existing') created?.add(entityInstance); @@ -232,10 +209,8 @@ function applyEntity( const newRefs = childRefs.size > 0 ? childRefs : undefined; const refsChanged = !sameRefs(entityInstance.entityRefs, newRefs); - // An entity hydrated from the store was never written, so there is no write - // to skip. One with a write still queued has its current data on the way. - // Skipping is only safe while the store has no deletion queued, which could - // drop the record this apply trusts. + // An entity hydrated from the store was never written, so there is no write to skip. + // A queued store deletion could drop the record this apply trusts. const needsPersist = changed || refsChanged || @@ -276,8 +251,6 @@ function mergeFields( const raw = data[fieldKey]; if (shouldReify(raw)) { - // Assign only on change: an object the parser kept as is (from a fetch - // result) may belong to the adapter and be frozen. const reified = reifyAndApply(raw, seen, queryClient, persist, childRefs, appendMode, created); if (reified !== raw) data[fieldKey] = reified; } @@ -320,8 +293,7 @@ function mergeFields( !(propShape.shape instanceof Set) ? (propShape.shape as Record) : undefined; - // An unchanged object kept from a fetch result may be frozen by the - // adapter: merge into a copy. + // May be frozen by the adapter. const target = Object.isFrozen(oldVal) ? { ...oldVal } : oldVal; if (nestedShape !== undefined) { if ( @@ -346,8 +318,8 @@ function mergeFields( existingData[fieldKey] = oldVal; } } else if (sameKeys(oldVal, newVal)) { - // Shapeless object (e.g. a record) with the same keys: copy field by field, keeping the - // existing value — and its identity — wherever it already matches. + // Shapeless object (e.g. a record) with the same keys: copy only the + // fields that differ, so matching values keep their identity. let merged = false; for (const k of Object.keys(newVal)) { if (!sameValue(oldVal[k], newVal[k])) { @@ -389,7 +361,6 @@ export function sameKeys(a: Record, b: Record) * they were built from, arrays and plain objects element by element. */ export function sameValue(a: unknown, b: unknown): boolean { - // `Object.is`: re-applying NaN is not a change, and `-0` over `0` is one. if (Object.is(a, b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (a instanceof FormattedValue || b instanceof FormattedValue) { @@ -459,8 +430,6 @@ function initFields( const raw = data[fieldKey]; if (shouldReify(raw)) { - // Assign only on change: an object the parser kept as is (from a fetch - // result) may belong to the adapter and be frozen. const reified = reifyAndApply(raw, seen, queryClient, persist, childRefs, appendMode, created); if (reified !== raw) data[fieldKey] = reified; } diff --git a/packages/fetchium/src/errors.ts b/packages/fetchium/src/errors.ts index b13059a..044db65 100644 --- a/packages/fetchium/src/errors.ts +++ b/packages/fetchium/src/errors.ts @@ -171,10 +171,8 @@ export function typeError(path: string, expectedType: InternalObjectFieldTypeDef } /** - * A cached query can't be hydrated as-is: an entity it holds is already in - * memory and doesn't satisfy the query's shape. Unlike a bad array item, this - * drops the whole cached query (which is refetched), since a list silently - * missing an item is worse. + * An in-memory entity doesn't satisfy a cached query's shape. Drops the whole + * cached query rather than filtering the item out. */ export class CachedEntityMismatchError extends Error { constructor(message: string) { diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index 15a6348..5dbec38 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -50,13 +50,13 @@ export interface ParsedEntity { data: Record; /** Set for partial event updates — restricts mergeFields to only these keys. */ rawKeys: Set | undefined; - /** The keys a streamed event carried, whether or not the entity was in memory. */ + /** The keys a streamed event carried. */ eventKeys: Set | undefined; - /** A cached record parsed to fill in the fields an entity built from events lacks. */ + /** A cached record filling in fields an entity built from events lacks. */ fillsPartial: boolean; } -/** Trust-memory verdicts: `true`/`false` once settled, `'pending'` while a check is in progress (a cycle). */ +/** `'pending'` while a check is in progress, so a cycle counts as satisfied. */ export type TrustMemo = Map, boolean | 'pending'>>; // ====================================================== @@ -72,13 +72,10 @@ export class ParseContext { isPartialEvent: boolean = false; seen: Map, ParsedEntity> | undefined = undefined; seenByKey: Map | undefined = undefined; - /** Verdicts of the trust-memory check, per instance and def, for one parse. */ trusted: TrustMemo | undefined = undefined; /** - * Whether nested objects and records always parse into a copy. On for input - * the client doesn't own and may see again (an event payload, mutation - * effects, a snapshot fed back). Off for a fetch result or cached record, - * which is copied only where a parsed value differs from it. + * On for input the client doesn't own and may see again (events, effects). + * Off for a fetch result or cached record, copied only where a value differs. */ copyInput: boolean = true; @@ -394,10 +391,7 @@ function parseUnionData( // ====================================================== function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseContext, path: string): unknown[] { - // Without `ctx.copyInput`, an array whose items all parse to themselves is - // returned as is, and the copy starts at the first item that differs or - // fails. An empty array is always new, since a live array grows the one it - // holds. + // An empty array is always new, since a live array grows the one it holds. let result: unknown[] | undefined = ctx.copyInput || array.length === 0 ? [] : undefined; for (let i = 0; i < array.length; i++) { @@ -420,10 +414,7 @@ function parseArrayData(array: unknown[], itemShape: ComplexTypeDef, ctx: ParseC return result ?? array; } -// Neither walker writes into its input. With `ctx.copyInput` they parse into -// a shallow copy. Without it, the copy is made only once a parsed value -// differs, and input whose values all parse to themselves is returned as is. -// The result keeps every key the input had. +// Neither walker writes into its input. function parseRecordData( record: Record, @@ -458,7 +449,6 @@ function parseObjectData( const value = obj[key]; const parsed = parseData(value, propShape as unknown as TypeDef, ctx, `${path}.${key}`); if (result !== undefined) result[key] = parsed; - // A shape key the input lacks is still set (to undefined), as a copy does. else if (parsed !== value || (value === undefined && !(key in obj))) (result = { ...obj })[key] = parsed; } @@ -510,22 +500,16 @@ function parseEntityData( if (preloadedEntities !== undefined) { const existing = queryClient.entityMap.getEntity(key); - // For an entity built from events: the fields they carried. The record - // supplies the rest. let fillKeys: Set | undefined; const preloaded = preloadedEntities.get(key); if (existing !== undefined && existing._partial && existing._partialKeys !== undefined && preloaded !== undefined) { - // Built from events, the in-memory data lacks what the record has: - // parse the record for those fields only and merge them in. + // Built from events: parse only the fields the record adds. fillKeys = existing._partialKeys; obj = preloaded; } else if (existing !== undefined) { - // A live entity is newer than the cache. Re-parsing its data would run - // already-parsed values (formatted values, parse results, child - // proxies) through the parser again and corrupt them. An empty partial - // entry merges nothing and hands the query the existing proxy. The data - // must still satisfy this query's shape, or the cached query is unusable. + // A live entity is newer than the cache, and re-parsing parsed values + // corrupts them. Merge nothing, but check it satisfies this shape. ctx.trusted ??= new Map(); if (!dataSatisfiesDef(existing.data, entityShape as unknown as ValidatorDef, queryClient, ctx.trusted)) { throw new CachedEntityMismatchError( @@ -613,11 +597,8 @@ function parseEntityData( // ====================================================== /** - * Whether already-parsed entity data can be handed to a query declaring `def` - * without re-parsing. Unlike `entitySatisfiesShape` (a top-level presence - * check for narrowing), this descends into nested values and child entities: - * a cached query missing a required nested field is worse than a refetch. - * Values that can't be judged are accepted, matching the parser's leniency. + * Whether parsed entity data satisfies `def` deeply, unlike the top-level + * `entitySatisfiesShape`. Values that can't be judged are accepted. */ export function dataSatisfiesDef( data: Record, @@ -686,8 +667,6 @@ function valueSatisfiesDef(value: unknown, fieldDef: unknown, queryClient: Query } if ((mask & Mask.UNION) !== 0) { - // Resolve the member the value belongs to; an unrecognised value is left - // to the union's own leniency. const members = fieldDef.shape as Record | undefined; if (members === undefined || members === null) return true; if (Array.isArray(value)) { @@ -741,12 +720,7 @@ function valueSatisfiesDef(value: unknown, fieldDef: unknown, queryClient: Query return (mask & maskOf(value)) !== 0; } -/** - * An entity field holds a proxy. One whose instance has left memory is - * accepted, since a re-parse would hand out its data too. A live one must - * satisfy the def. Verdicts are shared across the parse, and a cycle counts as - * satisfied. - */ +/** An instance no longer in memory is accepted, since a re-parse would hand out its data too. */ function entitySatisfies( value: object, def: ValidatorDef, @@ -767,11 +741,7 @@ function entitySatisfies( return verdict; } -/** - * When the item typename has several registered classes, the array is - * narrowed on read to members that satisfy the def, so a member that doesn't - * is not a mismatch. - */ +/** A shared-typename array is narrowed on read, so a non-matching member is not a mismatch. */ function arraySatisfies(value: unknown[], itemDef: unknown, queryClient: QueryClient, visiting: TrustMemo): boolean { let narrowed = false; if (itemDef instanceof ValidatorDef && (itemDef.mask & Mask.ENTITY) !== 0 && (itemDef.mask & Mask.UNION) === 0) { diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index 40e4357..c2365e7 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -112,11 +112,9 @@ export interface QueryStore { saveEntity(entityKey: number, value: unknown, refIds?: Set): void; /** - * Writes the fields streamed events supplied for an entity built from them. - * If the store holds a record for the key, it merges `fields` over it and - * derives references from the merged value. Otherwise `fields` becomes the - * record. Without this method the client calls `saveEntity` with the whole - * in-memory data, which drops the fields the events did not carry. + * Writes the fields streamed events supplied, merged over the stored record + * if one exists (references derived from the merged value). Without it the + * client calls `saveEntity`, dropping fields the events did not carry. */ mergeEntity?(entityKey: number, fields: unknown, refIds?: Set): void; @@ -128,41 +126,35 @@ export interface QueryStore { /** * Called with the key of every record the store drops on its own (eviction, - * cascade, purge). May return an unsubscribe function, which the client - * calls from `destroy()`. A store without this hook is written on every - * apply. + * cascade, purge). May return an unsubscribe, called from `destroy()`. + * Without it, entities are written on every apply. */ onDelete?(listener: (key: number) => void): void | (() => void); /** - * For stores that process writes asynchronously: called with each entity - * key once its write has been applied. The client then treats an entity as - * persisted only after this acknowledgement, so a dropped write is retried. + * Async stores: called with each entity key once its write is applied. The + * client treats an entity as persisted only after this acknowledgement. */ onPersisted?(listener: (key: number) => void): void | (() => void); /** - * For stores that process writes asynchronously: whether every operation - * handed to the store has been processed. While it returns `false` the - * client does not skip entity writes, since an operation still queued could - * delete the record the skip relies on. Absent means always settled. + * Async stores: whether every queued operation has been processed. While + * `false` the client does not skip unchanged entity writes. Absent means + * always settled. */ isSettled?(): boolean; /** - * For stores that process writes asynchronously, a narrower `isSettled`: - * whether an operation that could delete a record is still queued. The - * client's own entity writes don't count, since it rewrites any entity whose - * reference they drop. When present it replaces `isSettled`, so a burst of - * entity writes doesn't disable write skipping for every other entity. + * Async stores: a narrower `isSettled` that ignores the client's own entity + * writes, reporting only queued operations that could delete a record. + * Takes precedence over `isSettled` when present. */ hasQueuedDeletes?(): boolean; /** - * Whether the store holds a record for this entity key, or `undefined` if - * it can't tell yet (it hasn't finished reading what it holds). A streamed - * event for an entity not in memory refreshes the record only when this - * returns `true`. Without this hook, such events are not written. + * Whether the store holds a record for this key, or `undefined` if it can't + * tell yet. An event for an entity not in memory is written only when this + * returns `true`. */ hasEntity?(key: number): boolean | undefined; } diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index 4db29d7..c512f4d 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -51,7 +51,7 @@ export type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; - /** Absent on the wire from readers built before 0.6; the writer then keeps the persisted queue's size. */ + /** Absent from older readers. The writer then keeps the persisted queue's size. */ maxCount?: number; refIds?: number[]; } @@ -60,7 +60,7 @@ export type StoreMessage = entityKey: number; value: unknown; refIds?: number[]; - /** `value` holds only some fields; the writer merges them over the stored record, if any. */ + /** `value` holds only some fields, merged over the stored record. */ merge?: boolean; } | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount?: number } @@ -74,11 +74,7 @@ export interface AsyncQueryStoreConfig { delegate?: AsyncPersistentStore; // Only provided for writer } -/** - * Writer-internal work run through the same serial queue as wire messages, so - * it is ordered with the writes and deletions around it. Never sent over the - * wire. - */ +/** Writer-internal work, serialized with wire messages. Never sent over the wire. */ class InternalWork { constructor( readonly run: () => Promise, @@ -89,7 +85,6 @@ class InternalWork { type QueuedWork = StoreMessage | InternalWork; -/** Whether something received on the channel is a message this writer handles. */ function isStoreMessage(msg: unknown): msg is StoreMessage { if (typeof msg !== 'object' || msg === null) return false; const type = (msg as { type?: unknown }).type; @@ -125,19 +120,15 @@ export class AsyncQueryStore implements QueryStore { private deleteListeners: Array<(key: number) => void> = []; private persistedListeners: Array<(key: number) => void> = []; private processing = false; - // Queued work that can delete a record: everything except this writer's - // own client's entity writes, whose dropped references the client tracks. + // Everything except this client's entity writes, whose dropped refs the client tracks. private queuedDeletes = 0; private readonly ownEntityWrites = new WeakSet(); - // Ids whose value the delegate holds (queued writes included), mirrored so - // `hasEntity` can answer synchronously. Read from the delegate once at - // start without blocking the queue. Writes and deletions before that read - // lands are noted aside and folded in. + // Mirror of the delegate's value keys so `hasEntity` answers synchronously. + // Changes before the initial scan lands are noted aside and folded in. private heldKeys: Set | undefined; private heldSinceScan: Set | undefined = new Set(); private droppedSinceScan: Set | undefined = new Set(); - // Only the writer sees deletions and completed writes. A reader offers no - // hooks, so the client writes every apply. + // Writer only. A reader offers no hooks, so its client writes every apply. onDelete?: (listener: (key: number) => void) => () => void; onPersisted?: (listener: (key: number) => void) => () => void; hasEntity?: (key: number) => boolean | undefined; @@ -152,7 +143,6 @@ export class AsyncQueryStore implements QueryStore { this.hasQueuedDeletes = () => this.queuedDeletes > 0; this.hasEntity = key => { if (this.heldKeys !== undefined) return this.heldKeys.has(key); - // Not read yet: only what this writer itself wrote is known. return this.heldSinceScan!.has(key) ? true : undefined; }; } @@ -171,7 +161,6 @@ export class AsyncQueryStore implements QueryStore { } } - /** Queues writer-internal work behind everything already queued. */ private runInternal(run: () => Promise): Promise { return new Promise((resolve, reject) => { this.enqueueMessage(new InternalWork(run, resolve, reject)); @@ -232,7 +221,7 @@ export class AsyncQueryStore implements QueryStore { try { held = await this.delegate!.has(valueKeyFor(id)); } catch { - // Unknown: treat it as missing. + // Treat as missing. } if (held) this.noteHeld(id); else this.noteDropped(id); @@ -268,8 +257,7 @@ export class AsyncQueryStore implements QueryStore { } } catch (error) { console.error('Error processing message:', error); - // A failed entity write leaves the record missing or stale. Report it - // as dropped so the client writes the entity again on its next apply. + // Report as dropped so the client rewrites it on its next apply. if (!(msg instanceof InternalWork) && msg.type === StoreMessageType.SaveEntity) { await this.recheckHeld(msg.entityKey); for (const listener of this.deleteListeners.slice()) listener(msg.entityKey); @@ -281,12 +269,7 @@ export class AsyncQueryStore implements QueryStore { } } - /** - * Reads which records the delegate holds, alongside the queue rather than - * ahead of it. Writes and deletions processed meanwhile are folded in. - * A failed read is retried. If it keeps failing nothing is assumed held, so - * an event for an entity not in memory is not written. - */ + /** Runs alongside the queue, not ahead of it. On repeated failure nothing is assumed held. */ private async scanHeldKeys(): Promise { const held = new Set(); for (let attempt = 0; ; attempt++) { @@ -312,10 +295,6 @@ export class AsyncQueryStore implements QueryStore { this.heldSinceScan = this.droppedSinceScan = undefined; } - /** - * Whether every operation handed to this store has been applied. Readers - * hand their operations to a writer elsewhere and never skip writes anyway. - */ isSettled(): boolean { return !this.isWriter || (this.messageQueue.length === 0 && !this.processing); } @@ -388,8 +367,7 @@ export class AsyncQueryStore implements QueryStore { } for (const entityId of entityIds) { - // Records can reference each other in a cycle (a file whose record - // links back to its folder); one read per record. + // Records can reference each other in a cycle. if (preloaded.has(entityId)) continue; const entityValue = await this.delegate.getString(valueKeyFor(entityId)); @@ -523,11 +501,8 @@ export class AsyncQueryStore implements QueryStore { queue = new Uint32Array(maxCount ?? DEFAULT_MAX_COUNT); await this.delegate!.setBuffer(queueKey, queue); } else if (maxCount !== undefined && queue.length !== maxCount) { - // `maxCount` changed since the queue was written (writers before 0.6 - // always used the default of 50). A view over the old buffer can't - // grow, and a shorter one would strand the keys it drops: copy what - // fits and evict the rest. The key being activated moves to the - // front, so it is kept wherever it sits. + // A view over the old buffer can't grow, and a shorter one would strand + // the keys it drops. The activated key moves to the front, so keep it. const resized = new Uint32Array(maxCount); resized.set(queue.subarray(0, Math.min(queue.length, maxCount))); for (let i = maxCount; i < queue.length; i++) { @@ -568,11 +543,7 @@ export class AsyncQueryStore implements QueryStore { } } - /** - * Drops every query definition's cached queries once the definition has - * gone unused for its `cacheTime`. Runs through the writer's serial queue, - * so a record it drops cannot be reported as written afterwards. - */ + /** Runs through the writer's queue, so a record it drops can't be reported as written afterwards. */ purgeStaleQueries(): Promise { if (!this.delegate) return Promise.resolve(); if (!this.isWriter) return this.purgeStaleQueriesNow(); diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index 3d2dcb2..0fff5c8 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -17,11 +17,7 @@ export const DEFAULT_MAX_COUNT = 50; export const DEFAULT_CACHE_TIME = 60 * 24; // 24 hours in minutes export const DEFAULT_GC_TIME = 5; // 5 minutes - in-memory eviction default -/** - * Merges the fields of a partial record over a stored one, at the JSON level - * (proxies have already been serialized to `{ __entityRef }` markers), and - * derives the merged record's references from the markers it contains. - */ +/** Merges at the JSON level and derives refs from the `__entityRef` markers. */ export function mergeStoredRecord( stored: string, partial: unknown, @@ -32,8 +28,6 @@ export function mergeStoredRecord( } catch { return undefined; } - // A non-object record can't be merged into. The caller writes the fields as - // the record instead, which heals it. if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined; const value = { ...(record as Record), diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index b9edf49..801286f 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -71,11 +71,7 @@ export class MemoryPersistentStore implements SyncPersistentStore { } } -/** - * Delete listeners are keyed by the backing kv, not the store instance: two - * stores over one kv delete each other's records, and a client behind either - * must hear about it. - */ +/** Keyed by kv, not store: two stores over one kv delete each other's records. */ const deleteListenersByKv = new WeakMap void>>(); export class SyncQueryStore implements QueryStore { @@ -137,8 +133,7 @@ export class SyncQueryStore implements QueryStore { private preloadEntities(entityIds: Uint32Array, preloaded: PreloadedEntityMap): void { for (const entityId of entityIds) { - // Records can reference each other in a cycle (a file whose record - // links back to its folder); one read per record. + // Records can reference each other in a cycle. if (preloaded.has(entityId)) continue; const entityValue = this.kv.getString(valueKeyFor(entityId)); @@ -197,10 +192,8 @@ export class SyncQueryStore implements QueryStore { queue = new Uint32Array(maxCount); this.kv.setBuffer(queueKeyFor(queryDefId), queue); } else if (queue.length !== maxCount) { - // `maxCount` changed since the queue was written. A view over the old - // buffer can't grow, and a shorter one would strand the keys it drops: - // copy what fits and evict the rest. The key being activated moves to - // the front, so it is kept wherever it sits. + // A view over the old buffer can't grow, and a shorter one would strand + // the keys it drops. The activated key moves to the front, so keep it. const resized = new Uint32Array(maxCount); resized.set(queue.subarray(0, Math.min(queue.length, maxCount))); for (let i = maxCount; i < queue.length; i++) { @@ -326,7 +319,7 @@ export class SyncQueryStore implements QueryStore { kv.delete(valueKeyFor(id)); kv.delete(refCountKeyFor(id)); - // A copy: a listener may unsubscribe while being notified. + // A listener may unsubscribe while being notified. for (const listener of this.deleteListeners.slice()) listener(id); const refIds = kv.getBuffer(refIdsKeyFor(id)); From fe48f0d26f9643fc87862de2ab0b51db537bae51 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:16:23 -0400 Subject: [PATCH 093/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 18 ++++------------- .../src/__tests__/caching-persistence.test.ts | 6 +----- .../src/__tests__/sync-hydration.test.ts | 20 +++++-------------- .../react/__tests__/sync-hydration.test.tsx | 6 +----- 4 files changed, 11 insertions(+), 39 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index d14ab55..eacbd85 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -257,16 +257,7 @@ export class QueryInstance { ); } - /** - * Runs once, from the relay's first activation, inside the read that watched - * the relay (for React, during render). - * - * A synchronous store's cached value is applied here, so the activating read - * already sees it. Subscribing and fetching wait a microtask, not a timer: - * they call adapter and app code that must not touch signals while the - * relay's computation is the current consumer. With an async store, the - * cache resolves on a later tick and everything runs from there. - */ + /** Runs once, inside the read that first activates the relay. */ private initialize(): void { this.initialized = true; @@ -292,6 +283,7 @@ export class QueryInstance { ); } else { this.hydrate(loaded); + // Adapter and app code must not touch signals while the relay is the current consumer. queueMicrotask(() => this.startSubscriptionAndFetch()); } } @@ -302,7 +294,6 @@ export class QueryInstance { qc.getContext().log?.warn?.('Failed to initialize query, the query cache may be corrupted or invalid', error); } - /** Resolves the relay with a cached value. */ private hydrate(cached: CachedQuery | undefined): void { if (cached === undefined) return; @@ -311,13 +302,12 @@ export class QueryInstance { if (this.updatedAt !== 0) this.updatedAt = cached.updatedAt; this.relayState.value = this.applyData(cached.value, false, false, cached.preloadedEntities); } catch (error) { - // The data was never applied, so drop its timestamp and let the query fetch. + // Never applied, so drop the timestamp and let the query fetch. this.updatedAt = undefined; this.discardCorruptCache(error); } } - /** Starts the subscription, then the first fetch if there is no fresh cached value. */ private startSubscriptionAndFetch(): void { // If deactivated meanwhile, update() fetches on reactivation. if (!this._isActive || this.isPaused) { @@ -539,7 +529,7 @@ export class QueryInstance { // ====================================================== private get isStale(): boolean { - // Never loaded, or invalidated (`markStale()`), whatever the staleTime. + // 0 means invalidated, whatever the staleTime. if (this.updatedAt === undefined || this.updatedAt === 0) { return true; } diff --git a/packages/fetchium/src/__tests__/caching-persistence.test.ts b/packages/fetchium/src/__tests__/caching-persistence.test.ts index 4e60cb5..3a48cd6 100644 --- a/packages/fetchium/src/__tests__/caching-persistence.test.ts +++ b/packages/fetchium/src/__tests__/caching-persistence.test.ts @@ -236,7 +236,7 @@ describe('Caching and Persistence', () => { expect(relay.value!).toMatchObject({ id: 1, name: 'Cached Data' }); expect(await relay).toMatchObject({ id: 1, name: 'Cached Data' }); - // The background refetch starts a microtask later, with the cached value still visible. + // The refetch starts a microtask later. await sleep(); expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ id: 1, name: 'Cached Data' }); @@ -274,13 +274,11 @@ describe('Caching and Persistence', () => { await testWithClient(client2, async () => { const relay = fetchQuery(GetItem); - // A synchronous store resolves the relay in the read that activates it. relay.value; expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ id: 1, value: 'Persistent' }); expect(await relay).toMatchObject({ id: 1, value: 'Persistent' }); - // The background refetch starts a microtask later, with the cached value still visible. await sleep(); expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ id: 1, value: 'Persistent' }); @@ -374,14 +372,12 @@ describe('Caching and Persistence', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetDocument); - // A synchronous store resolves the relay in the read that activates it. relay.value; expect(relay.isPending).toBe(false); expect(relay.value!).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); const result = await relay; expect(result).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); - // The background refetch starts a microtask later, with the cached value still visible. await sleep(); expect(relay.isPending).toBe(true); expect(relay.value!).toMatchObject({ user: { __typename: 'User', id: 1, name: 'Persisted User' } }); diff --git a/packages/fetchium/src/__tests__/sync-hydration.test.ts b/packages/fetchium/src/__tests__/sync-hydration.test.ts index dcf62a1..449f669 100644 --- a/packages/fetchium/src/__tests__/sync-hydration.test.ts +++ b/packages/fetchium/src/__tests__/sync-hydration.test.ts @@ -9,14 +9,9 @@ import { fetchQuery } from '../query.js'; import { QueryDefinition } from '../query.js'; import { createMockFetch, sleep, testWithClient } from './utils.js'; -/** - * Activation timing with a synchronous persistent store (e.g. MMKV on mobile). - * - * The activating read must see the cached value, so the first React render has - * data. The first fetch starts on a microtask, never inside the activating read. - */ - -/** Same store, but loadQuery resolves on a later microtask (extension-style). */ +// With a sync store, the activating read sees cached data and the fetch starts on a microtask. + +/** loadQuery resolves on a later microtask, like an async store. */ class AsyncLoadingQueryStore extends SyncQueryStore { override loadQuery(queryDef: QueryDefinition, queryKey: number): CachedQuery | undefined { return Promise.resolve(super.loadQuery(queryDef, queryKey)) as unknown as CachedQuery | undefined; @@ -36,7 +31,6 @@ function makeClient(store: SyncQueryStore, mockFetch: ReturnType RESTQuery, response: unknown) { const mockFetch = createMockFetch(); mockFetch.get('/item', response); @@ -47,10 +41,7 @@ async function seedCache(kv: MemoryPersistentStore, QueryClass: new () => RESTQu client.destroy(); } -/** - * Activates `read` inside a watched reactive context, as React render reads do, - * and returns what the activating read observed plus a disposer. - */ +/** Activates `read` in a watched context, as React render does, and returns its first result. */ function activate(client: QueryClient, read: () => T): { first: T; dispose: () => void } { let first: T | undefined; let captured = false; @@ -119,10 +110,9 @@ describe('Activation with a synchronous store', () => { expect(first).toEqual({ isReady: true, isPending: false, value: 'cached' }); - // The fetch never runs inside the activating read... + // Never inside the activating read. expect(mockFetch.calls).toHaveLength(0); - // ...and starts without waiting for a timer. await flushMicrotasks(); expect(mockFetch.calls).toHaveLength(1); expect(relayRef!.isPending).toBe(true); diff --git a/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx b/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx index b11fbda..0de6643 100644 --- a/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx +++ b/packages/fetchium/src/react/__tests__/sync-hydration.test.tsx @@ -13,11 +13,7 @@ import { watcher, withContexts } from 'signalium'; import { RESTQueryAdapter } from '../../rest/RESTQueryAdapter.js'; import { useQuery } from '../use-query.js'; -/** - * With a synchronous persistent store, a component mounting a query whose data - * is already cached renders that data on its first render, not a loading - * frame followed by a re-render. - */ +// A sync store's cached data shows on the first render, with no loading frame. class GetFreshItem extends RESTQuery { path = '/item'; From 10958e9d250be75384024db9b936761c2a6f34a7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:16:59 -0400 Subject: [PATCH 094/109] chore(fetchium): cut and shorten comments Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/GcManager.ts | 1 - packages/fetchium/src/LiveCollection.ts | 3 +- packages/fetchium/src/QueryClient.ts | 163 +++++------------- packages/fetchium/src/QueryResult.ts | 73 ++------ .../__tests__/identical-redelivery.test.ts | 33 +--- .../src/__tests__/read-path-latency.test.ts | 25 +-- .../src/__tests__/retain-prefetch.test.ts | 16 +- .../src/__tests__/retry-should-retry.test.ts | 2 - .../identical-redelivery-react.test.tsx | 10 +- .../__tests__/prefetch-suspense.test.tsx | 10 +- .../fetchium/src/react/use-suspense-query.ts | 22 +-- packages/fetchium/src/retry.ts | 24 +-- packages/fetchium/src/types.ts | 6 +- 13 files changed, 89 insertions(+), 299 deletions(-) diff --git a/packages/fetchium/src/GcManager.ts b/packages/fetchium/src/GcManager.ts index ba786b6..30196d2 100644 --- a/packages/fetchium/src/GcManager.ts +++ b/packages/fetchium/src/GcManager.ts @@ -62,7 +62,6 @@ export class GcManager { private _nextTickScheduled = false; private _onEvict: (key: number, type: GcKeyType) => void; private _multiplier: number; - /** Set by destroy(). Queries that deactivate afterwards schedule nothing. */ private _destroyed = false; constructor(onEvict: (key: number, type: GcKeyType) => void, multiplier: number = 1) { diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 3634ad2..2dba6ea 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -450,8 +450,7 @@ export class LiveValueInstance { this._value = this._onDelete(this._value, entity ?? entityData); break; } - // Notify even when the reducer returns the same reference: it may have - // mutated the value in place. + // Notify even for the same reference: the reducer may mutate in place. this._notifier.notify(); } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 9e3c127..f1dae3c 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -63,11 +63,9 @@ export interface QueryClientConfig { */ reactivationGraceMs?: number; /** - * Decides whether a failed query attempt (or a mutation attempt, when the - * mutation enables retries) is retried. Receives the error, the attempt index - * (starting at 0) and the attempt's HTTP status when known. A query's or - * mutation's own `retry.shouldRetry` overrides it. Without it, every failed - * attempt is retried. Use it to stop retrying permanent errors such as a 4xx. + * Decides whether a failed query or mutation attempt is retried, for example + * to stop on a 4xx. A query's or mutation's own `retry.shouldRetry` overrides + * it. Default: every failed attempt is retried. */ shouldRetry?: ShouldRetry; /** @@ -94,45 +92,29 @@ export interface QueryClientConfig { } export interface RetainOptions { - /** - * Milliseconds after which the lease releases itself. Omit to hold it until - * the returned `release` is called. - */ + /** Milliseconds until the lease releases itself. Omit to hold until `release` is called. */ ttl?: number; } export interface PrefetchOptions { - /** - * Milliseconds to keep the query active if nothing else reads it. Default: - * `DEFAULT_PREFETCH_TTL` (10 s). - */ + /** Milliseconds to keep the query active. Default: `DEFAULT_PREFETCH_TTL` (10 s). */ ttl?: number; } -/** How long `prefetch()` keeps a query active when no `ttl` is given. */ export const DEFAULT_PREFETCH_TTL = 10_000; -/** - * How long a suspense hold outlives its fetch settling when no reader commits - * to claim it (the suspended tree was abandoned). - */ +/** How long a hold outlives its fetch when no reader commits (abandoned tree). */ const SUSPENSE_HOLD_TTL = 10_000; -/** - * How long a failed cold fetch's error waits for a render to claim it. React - * retries a suspended render within a task or two of its promise settling; an - * error nobody claimed by then belongs to an abandoned tree, and a later mount - * makes a fresh attempt instead of inheriting it. - */ +/** React retries a suspended render within a task or two. An error unclaimed by then is dropped. */ const UNCLAIMED_FAILURE_TTL = 50; interface SuspenseHold { release: () => void; - /** Resolves (never rejects) once the cold fetch settles. */ + /** Never rejects. */ settled: Promise | undefined; - /** The fetch this hold suspended on has settled. */ done: boolean; - /** That fetch failed and its error has been handed to a render. */ + /** The error has been handed to a render. */ failed: boolean; timer: ReturnType | undefined; } @@ -177,21 +159,13 @@ export class QueryClient { private staggerQueue = new Set>(); private staggerTimer: ReturnType | undefined = undefined; - /** Release functions of outstanding `retain()` / `prefetch()` leases. */ private leases = new Set<() => void>(); - /** - * Set during a lease's first run: the queries it reached whose first fetch - * (or zero-delay refetch) is queued on a microtask. The lease starts them - * before returning. See `retain()`. - */ + /** During a lease's first run: queries with a start queued on a microtask, for the lease to run now. */ private leaseStarts: Set> | undefined = undefined; - /** - * Leases taken by `retain()` inside a reactive computation, keyed by the - * computation, with the run that took them. See `retain()`. - */ + /** Leases `retain()` took inside a reactive computation, by computation and run. */ private reactiveLeases = new WeakMap void> }>(); private warnedReactiveRetain = false; - /** Leases taken by `useSuspenseQuery` for cold misses, by query instance key. */ + /** `useSuspenseQuery` cold-miss leases, by query key. */ private suspenseHolds = new Map(); private context!: QueryContext; @@ -399,8 +373,7 @@ export class QueryClient { this.queryInstances.set(queryKey, queryInstance as QueryInstance); } - // A reader may have activated it earlier in this task with its start still - // queued. The lease starts it too. + // An earlier reader may have activated it with its start still queued. this.leaseStarts?.add(queryInstance); return queryInstance.relay; @@ -411,38 +384,15 @@ export class QueryClient { // ====================================================== /** - * Keeps the queries `fn` reads active (fetched, subscribed, and exempt from - * GC) until the returned `release` is called, or for `ttl` milliseconds, - * whichever comes first. Use it to keep a hidden surface's queries warm, or - * to start the queries a likely next screen needs. - * - * `fn` runs immediately with this client as the `QueryClientContext`, and - * again whenever what it reads changes (for example a Signal param). A query - * is held when `fn` reads one of its fields (`isReady`, `value`, ...), or - * when `fn` returns its promise or an array of them: + * Keeps the queries `fn` reads or returns active (fetched, subscribed, exempt + * from GC) until `release` is called or `ttl` elapses. `fn` reruns when what + * it reads changes. First-run fetches start before `retain` returns. Call it + * from an event handler or effect, not a reactive computation. * * ```ts * const release = client.retain(() => [fetchQuery(GetTokens), fetchQuery(GetPrices, { ids })]); - * // later, when the surface is gone for good: * release(); * ``` - * - * A reader that mounts while the lease is held joins the already active - * query: no new request, and the data (if it has arrived) on its first - * render. Releasing never interrupts other readers; when the last one goes, - * the query deactivates and its `gcTime` starts as usual. `release` is - * idempotent. - * - * The first run's fetches start before `retain` returns, ahead of anything - * already on the microtask queue (such as a render React scheduled for the - * same tap). A query that depends on another's result starts once that - * result arrives, as with any reader. - * - * Call `retain` from an event handler or effect, not a reactive computation: - * starting a fetch runs adapter code, and a rerunning computation would take - * a new lease each run. Called from one anyway, it warns in development, - * starts its fetches on their usual microtask, and releases the leases the - * computation's previous run took. */ retain(fn: () => unknown, options?: RetainOptions): () => void { const owner = currentReactiveOwner(); @@ -467,11 +417,7 @@ export class QueryClient { return release; } - /** - * `retain()`, with `startNow` choosing whether the first run's fetches start - * before returning or on their usual microtask. `useSuspenseQuery` leases - * from inside a render, where adapter code must not run, so it passes false. - */ + /** `startNow: false` leaves first-run fetches on their microtask, for callers inside a render. */ private lease(fn: () => unknown, options: RetainOptions | undefined, startNow: boolean): () => void { const w = withContexts([[QueryClientContext, this]], () => watcher(() => holdReturned(fn()))); const unsubscribe = w.addListener(noop); @@ -493,8 +439,7 @@ export class QueryClient { const starts = startNow ? new Set>() : undefined; this.leaseStarts = starts; try { - // Run now, so the queries activate (and a synchronous store hydrates - // them) before this returns, not on Signalium's next flush. + // Activate now, not on Signalium's next flush. void w.value; } catch (error) { release(); @@ -522,18 +467,10 @@ export class QueryClient { } /** - * Starts `QueryClass` with `params` now and keeps it active for `ttl` - * milliseconds (default {@link DEFAULT_PREFETCH_TTL}), or until the returned - * `release` is called. Meant for the tap that commits to a navigation: the - * destination's reader, mounting within the window, reuses the in-flight or - * finished fetch and renders the data on its first render if it has arrived. - * - * The `ttl` is an upper bound. The lease ends even if the fetch is still in - * flight (offline, or a topic never fulfilled), which aborts it unless a - * reader has joined. - * - * With a synchronous store a fresh cached result is applied without a - * request. A stale one is shown and refetched, as on any activation. + * Starts `QueryClass` now and keeps it active for `ttl` ms (default + * {@link DEFAULT_PREFETCH_TTL}) or until `release` is called, so a reader + * mounting within that window reuses the fetch. The lease ends at `ttl` even + * if the fetch is in flight, which aborts it unless a reader has joined. */ prefetch( QueryClass: new () => T, @@ -547,19 +484,8 @@ export class QueryClient { } /** - * For `useSuspenseQuery`. Returns a promise to suspend on when the query has - * never produced a value (a cold miss), or `undefined` when it has one to - * render (in memory, or hydrated now from a synchronous store). - * - * A cold miss takes a hold that keeps the query active while suspended, - * since React discards a suspended render without subscribing. The reader's - * commit releases it (`releaseSuspenseHold`), or it releases itself - * `SUSPENSE_HOLD_TTL` after the fetch settles. A query whose last fetch - * failed is refetched once per hold. If that attempt fails too, `error` is - * returned for the caller to throw and the hold is dropped. A failure no - * render claims within `UNCLAIMED_FAILURE_TTL` also drops the hold, so a - * later mount makes a new attempt. - * + * On a cold miss, returns a promise to suspend on and holds the query active + * until the reader commits. A failed cold fetch returns `error` instead. * @internal */ suspendOnColdMiss( @@ -578,15 +504,14 @@ export class QueryClient { } if (relay.isReady) { - // Hydrated by the activation: render it, and keep the hold until the reader commits. + // Hydrated by the activation. Keep the hold until the reader commits. this.expireSuspenseHold(key, hold); return { promise: undefined, key }; } if (relay.isPending && hold.done) { - // The fetch this hold waited for settled, and another has started since - // (a refetch from elsewhere). Suspend on that one: the settled promise - // has already resolved, and throwing it again would re-render at once. + // Another fetch started after ours settled. Rethrowing the resolved + // promise would re-render at once. clearTimeout(hold.timer); hold.timer = undefined; hold.done = false; @@ -596,10 +521,8 @@ export class QueryClient { if (!relay.isPending) { if (hold.done) { - // The fetch this hold waited for failed. Keep the hold until the next - // task: React re-renders a throwing component once more before giving - // up, and that render must throw the same error, not refetch. A later - // mount (an error boundary reset) gets a fresh hold and a new attempt. + // Keep the hold one task: React re-renders a throwing component once + // more, and that render must throw the same error, not refetch. const error = relay.error; if (!hold.failed) { hold.failed = true; @@ -610,7 +533,7 @@ export class QueryClient { } return { promise: undefined, failed: true, error, key }; } - // A failed or not-yet-started fetch: start it now rather than after the activation's refetch hop. + // Start now, not after the activation's refetch hop. this.queryInstances.get(key)?.refetch(); } @@ -818,9 +741,7 @@ export class QueryClient { // ====================================================== /** - * Called by a query that queued its start (or a zero-delay refetch) on a - * microtask, so a lease in its first run can start it before returning. - * + * A query queued a start on a microtask. A lease in its first run starts it now. * @internal */ noteDeferredStart(instance: QueryInstance): void { @@ -828,10 +749,10 @@ export class QueryClient { } /** - * Queues a reactivation refetch. Everything queued in the same task is - * started from one flush, spread evenly across `reactivationStaggerMs` in - * the order the queries reactivated, so the first one starts right away. - * Queries whose adapter `coalescesRequests` all start right away instead. + * Queues a reactivation refetch. One flush per task spreads the queue evenly + * across `reactivationStaggerMs` in reactivation order, the first starting + * immediately. Queries of an adapter that `coalescesRequests` all start + * immediately. */ scheduleReactivationRefetch(instance: QueryInstance): void { this.staggerQueue.add(instance); @@ -969,10 +890,8 @@ export const QueryClientContext: Context = context {}; /** - * The reactive computation (and its run) currently executing, if any. Signalium - * has no public API for this: a signal read records its consumer, so read a - * throwaway signal and look at what it recorded. Undefined when the shape - * isn't the expected one. + * The running reactive computation and its run, if any. Signalium has no public + * API for this, so read a throwaway signal and inspect the consumer it recorded. */ function currentReactiveOwner(): { ref: object; run: number } | undefined { try { @@ -987,11 +906,7 @@ function currentReactiveOwner(): { ref: object; run: number } | undefined { } } -/** - * Reads the query promises `retain`'s callback returned, so the lease's - * watcher depends on them and keeps them active. `isReady` changes once per - * query, so the watcher reruns at most once for each. - */ +/** Reads `isReady` on returned query promises so the lease's watcher keeps them active. */ function holdReturned(returned: unknown): void { if (Array.isArray(returned)) { for (const item of returned) holdOne(item); diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 4cd9471..14c4d5e 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -54,16 +54,10 @@ export class QueryInstance { private reconnectsAtDeactivate: number = 0; private currentParams: QueryParams | undefined = undefined; private debounceTimer: ReturnType | undefined = undefined; - /** - * Bumped to schedule or cancel a zero-delay refetch, which runs on a - * microtask and so can't be cleared like a timer. - */ + /** Bumped to cancel a zero-delay refetch, which runs on a microtask that can't be cleared. */ private debounceGeneration: number = 0; - /** The zero-delay refetch runDebounced() queued, until it runs or is cancelled. */ private pendingDebouncedRun: (() => void) | undefined = undefined; - /** initialize() queued startSubscriptionAndFetch() on a microtask that hasn't run yet. */ private startPending: boolean = false; - /** The fetch restartAbortedFetch() queued on a microtask, until it runs. */ private pendingRestart: (() => void) | undefined = undefined; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; @@ -208,11 +202,8 @@ export class QueryInstance { this.reconcileSubscription(); } - // If the relay shows pending but the abort controller is gone, the - // previous fetch was aborted during deactivation (or never started). - // runDebounced() would bail out on the doomed promise's isPending, - // and that promise may never settle (a topic query's send() waits on - // a subscription that is gone). + // Pending with no abort controller: the fetch was aborted or never started, and its + // promise may never settle. runDebounced() would bail on that isPending. if (this.relayState.isPending && this._abortController === undefined) { this.restartAbortedFetch(); } else { @@ -299,19 +290,11 @@ export class QueryInstance { * Runs once, from the relay's first activation, inside the read that watched * the relay (for React, during render). * - * When the store answers synchronously (SyncQueryStore), the cached value is - * applied right here, so the activating read, and the first render, already - * see it. Subscribing and fetching wait for the next microtask: both call - * into adapter and app code that may read or write signals, which must not - * run while the relay's computation is the current consumer. A microtask - * rather than a timer keeps the first fetch off the macrotask queue. - * - * With an asynchronous store (AsyncQueryStore), the cache resolves on a later - * tick, outside the activating read, and everything runs from there. - * - * A lease (`retain()` / `prefetch()`) skips the microtask: it calls - * startPendingNow() after its activating read, so the request goes out ahead - * of any render already queued. + * A synchronous store's cached value is applied here, so the activating read + * already sees it. Subscribing and fetching wait a microtask, not a timer: + * they call adapter and app code that must not touch signals while the + * relay's computation is the current consumer. With an async store, the + * cache resolves on a later tick and everything runs from there. */ private initialize(): void { this.initialized = true; @@ -351,11 +334,7 @@ export class QueryInstance { }; /** - * Runs now any start that initialize(), a zero-delay runDebounced() or - * restartAbortedFetch() queued on a microtask, which then does nothing. - * Called by a lease after its activating read, outside any reactive - * computation. - * + * Runs any microtask-queued start now. Called by a lease outside its reactive read. * @internal */ startPendingNow(): void { @@ -474,8 +453,7 @@ export class QueryInstance { } const ctx = this.getOrCreateExecutionContext(); - // Restore a subscription that a deactivation or pause tore down: a topic - // query's send() waits for data its subscription brings. + // A topic query's send() waits on its subscription, which deactivation may have torn down. this.reconcileSubscription(); const adapter = this.queryClient.getAdapter(def.statics.adapterClass); const signal = this._abortController?.signal ?? new AbortController().signal; @@ -513,10 +491,8 @@ export class QueryInstance { } /** - * Replaces a fetch that a deactivation aborted, from inside the activating - * read. The relay takes the new promise now so the doomed one can't settle - * it. The subscription and request run adapter code, so they start outside - * the read, on a microtask or when a lease starts them. + * Sets the new promise now so the aborted one can't settle the relay. The + * request runs adapter code, so it starts outside the read, on a microtask. */ private restartAbortedFetch(): void { this.fetchStarts++; @@ -558,18 +534,9 @@ export class QueryInstance { } /** - * Starts a refetch after the query's `debounce` (plus `extraDelay`). Calls - * made before it starts are coalesced into one fetch. - * - * With no delay the fetch starts on a microtask, not a timer. It still runs - * outside the reactive computation that asked for it and still coalesces - * calls from the same task, without waiting a macrotask (on React Native a - * zero timer can wait up to a frame). - * - * `nextTask` keeps the zero-delay fetch on a timer. Invalidation uses it: a - * query invalidated in the task its last watcher left stays active until - * Signalium's deactivation flush, and the timer lets that flush cancel the - * fetch instead of starting and aborting it. + * With no delay the fetch starts on a microtask (a zero timer can wait a frame on + * React Native). `nextTask` forces a timer so a deactivation flush in the same + * task can cancel the fetch instead of starting and aborting it. */ private runDebounced(extraDelay: number = 0, nextTask: boolean = false): void { if (this.relayState.isPending) return; @@ -591,7 +558,7 @@ export class QueryInstance { if (generation !== this.debounceGeneration) return; this.debounceGeneration++; this.pendingDebouncedRun = undefined; - // Another path started a fetch in the meantime (refetch(), activation). + // Another path started a fetch in the meantime. if (this.relayState.isPending && this._abortController !== undefined) return; this.runQueryImmediately(); }; @@ -600,7 +567,6 @@ export class QueryInstance { queueMicrotask(run); } - /** Cancels a refetch scheduled by runDebounced(). */ private cancelDebounced(): void { this.debounceGeneration++; this.pendingDebouncedRun = undefined; @@ -622,7 +588,6 @@ export class QueryInstance { const totalDelay = (this.config?.debounce ?? 0) + delay; if (totalDelay === 0) { - // Within the flush task, on runDebounced()'s microtask. this.runDebounced(); return; } @@ -734,10 +699,8 @@ export class QueryInstance { } /** - * Retry options that report a failed attempt's HTTP status. RESTQueryAdapter - * sets `ctx.response` before parsing and validating the body, so an error - * response with an invalid body throws a schema error that carries no - * status. Only a response assigned during the failed attempt counts. + * Reports the status of `ctx.response` when the failed attempt set it, for a + * REST error response whose body failed validation and so carries no status. */ private attemptStatusTracker(ctx: Query): { start: () => void; options: WithRetryOptions } { const holder = ctx as unknown as { response?: unknown }; diff --git a/packages/fetchium/src/__tests__/identical-redelivery.test.ts b/packages/fetchium/src/__tests__/identical-redelivery.test.ts index 6349ef2..5cb4b7a 100644 --- a/packages/fetchium/src/__tests__/identical-redelivery.test.ts +++ b/packages/fetchium/src/__tests__/identical-redelivery.test.ts @@ -13,14 +13,9 @@ import { TopicQueryAdapter } from '../topic/TopicQueryAdapter.js'; import type { MutationEvent, QueryPromise } from '../types.js'; import { testWithClient, setupTestClient } from './utils.js'; -/** - * A stream reconnect re-delivers every on-screen entity with the data the - * store already holds, as a query result, mutation event or topic event. None - * may notify an entity, bump its version, write it, or notify a live - * collection. A real change notifies exactly once. - */ - -/** Records `typename:id` for every entity notify. */ +// Re-delivering data the store already holds must not notify, bump versions, +// write, or notify live collections. A real change notifies exactly once. + function recordNotifies() { const notified: string[] = []; const original = EntityInstance.prototype.notify; @@ -96,7 +91,6 @@ function versions(client: QueryClient, keys: number[]) { return keys.map(k => client.entityMap.getEntity(k)!.version); } -/** Spies on the notifiers of a wallet's live array and live value. */ function liveNotifies(client: QueryClient) { const data = client.entityMap.getEntity(walletKey)!.data; const tokens = data.tokens as LiveCollectionBinding; @@ -107,10 +101,6 @@ function liveNotifies(client: QueryClient) { }; } -// ====================================================== -// Query results -// ====================================================== - class GetWallet extends RESTQuery { path = '/wallet'; result = { wallet: t.entity(Wallet) }; @@ -187,10 +177,6 @@ describe('Identical re-delivery', () => { }); }); - // ====================================================== - // Mutation events - // ====================================================== - describe('(b) mutation event', () => { it('is a no-op for a full update carrying identical data, nested values included', async () => { const { client, store } = await loadWallet(2); @@ -209,12 +195,10 @@ describe('Identical re-delivery', () => { expect(versions(client, keys)).toEqual(before); expect(saveEntity).not.toHaveBeenCalled(); expect(live.tokens).not.toHaveBeenCalled(); - // The live value's reducer still runs and notifies, since it may mutate - // in place. + // The live value's reducer may mutate in place, so it still notifies. expect(live.count).toHaveBeenCalledTimes(1); - // A `create` for an entity the store already holds is the same no-op for - // the entity and the live array. The live value's `onCreate` still runs. + // A `create` for an entity the store already holds is the same no-op. client.applyMutationEvent({ type: 'create', typename: 'Token', data: token(0) }); client.applyMutationEvent({ type: 'create', typename: 'Owner', data: { id: 'o-1', name: 'Ann' } }); @@ -222,7 +206,6 @@ describe('Identical re-delivery', () => { expect(versions(client, keys)).toEqual(before); expect(saveEntity).not.toHaveBeenCalled(); expect(live.tokens).not.toHaveBeenCalled(); - // The unchanged nested object keeps its identity. expect(client.entityMap.getEntity(tokenKey(0))!.data.metadata).toBe(metadata); }); @@ -259,10 +242,6 @@ describe('Identical re-delivery', () => { }); }); -// ====================================================== -// Topic events -// ====================================================== - class MockTopicAdapter extends TopicQueryAdapter { snapshots = new Map(); @@ -326,7 +305,7 @@ describe('(c) topic events', () => { expect(versions(client, keys)).toEqual(before); expect(saveEntity).not.toHaveBeenCalled(); expect(live.tokens).not.toHaveBeenCalled(); - // Each Token event runs the live value's reducer, which notifies (see (b)). + // The live value's reducer notifies once per Token event. expect(live.count).toHaveBeenCalledTimes(3); }); diff --git a/packages/fetchium/src/__tests__/read-path-latency.test.ts b/packages/fetchium/src/__tests__/read-path-latency.test.ts index e6f92aa..f92713a 100644 --- a/packages/fetchium/src/__tests__/read-path-latency.test.ts +++ b/packages/fetchium/src/__tests__/read-path-latency.test.ts @@ -8,15 +8,8 @@ import { t } from '../typeDefs.js'; import { fetchQuery } from '../query.js'; import { createMockFetch, sleep } from './utils.js'; -/** - * Read-path latency, counted in macrotask turns. Each turn drains the - * microtask queue, checks the condition, then yields one `setTimeout(0)`. - * Turn 0 means the condition held before any timer ran. - * - * On React Native a zero timer goes through the native timing module and can - * wait up to a frame, so a turn saved here is up to ~16 ms on device. The - * logged `ms` values are Node timers (about 1 ms per turn). - */ +// Latency in macrotask turns: each turn drains microtasks, checks, then yields one setTimeout(0). +// Turn 0 means no timer ran. On React Native a zero timer can wait a frame. async function flushMicrotasks(count = 30): Promise { for (let i = 0; i < count; i++) await Promise.resolve(); @@ -70,7 +63,6 @@ function setup() { return { client, mockFetch }; } -/** Watches `read` and runs it synchronously, so activation happens now. */ function watchNow(client: QueryClient, read: () => T): { w: { value: T }; unsub: () => void } { const w = withContexts([[QueryClientContext, client]], () => watcher(read)); const unsub = w.addListener(() => {}); @@ -103,7 +95,6 @@ describe('read-path latency (macrotask turns)', () => { expect(mockFetch.calls).toHaveLength(1); const { w, unsub } = watchNow(client, () => fetchQuery(GetFreshItem).value); - // Same synchronous read that activated the query: no microtask, no timer. expect(w.value).toMatchObject({ n: 1 }); await sleep(10); expect(mockFetch.calls).toHaveLength(1); @@ -117,7 +108,6 @@ describe('read-path latency (macrotask turns)', () => { const started = performance.now(); const { w, unsub } = watchNow(client, () => fetchQuery(GetItem).value); - // The cached value shows immediately while the refetch runs. expect(w.value).toMatchObject({ n: 1 }); const turns = await turnsUntil(() => mockFetch.calls.length === 2); console.log( @@ -145,7 +135,7 @@ describe('read-path latency (macrotask turns)', () => { console.log( `[latency] staggered reactivation -> first refetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, ); - // One timer for the stagger flush, which groups the task's reactivations. + // One timer for the stagger flush. expect(turns).toBe(1); unsub(); }); @@ -164,8 +154,7 @@ describe('read-path latency (macrotask turns)', () => { console.log( `[latency] param change -> fetch start: ${turns} turns, ${(performance.now() - started).toFixed(2)} ms`, ); - // The relay update reruns in Signalium's flush (one timer), and the fetch - // starts on a microtask of that flush. + // One timer for Signalium's flush. The fetch starts on its microtask. expect(turns).toBe(1); expect(mockFetch.calls[1].url).toContain('/users/2'); unsub(); @@ -195,11 +184,9 @@ describe('read-path latency (macrotask turns)', () => { mockFetch.get('/item', { n: 2 }, { delay: 30 }); const { unsub } = watchNow(client, () => fetchQuery(GetItem).value); unsub(); - // Before the response (30 ms). await sleep(10); - // Signalium deactivates on its next flush (a timer), after the refetch's - // microtask has started it. This wasted request is the trade-off for not - // waiting a timer on every reactivation. + // Deactivation lands a timer after the refetch's microtask started it. + // Accepted to avoid a timer on every reactivation. expect(mockFetch.calls).toHaveLength(1); expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); await sleep(30); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 0118709..70572b0 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -12,12 +12,6 @@ import { GcManager, type GcKeyType } from '../GcManager.js'; import { NetworkManager } from '../NetworkManager.js'; import { createMockFetch, sleep } from './utils.js'; -/** - * client.retain() and client.prefetch(): app-level leases that keep queries - * active without a reader, so a reader that mounts inside the lease joins the - * running query instead of starting its own. - */ - async function flushMicrotasks(count = 30): Promise { for (let i = 0; i < count; i++) await Promise.resolve(); } @@ -102,7 +96,6 @@ function setup( return { client, mockFetch, kv }; } -/** Mounts a reader: watches `read` and runs it now, like a React render. */ function mountReader(client: QueryClient, read: () => T): { w: { value: T }; unsub: () => void } { const w = withContexts([[QueryClientContext, client]], () => watcher(read)); const unsub = w.addListener(() => {}); @@ -201,7 +194,7 @@ describe('client.prefetch()', () => { await sleep(10); expect(mockFetch.calls).toHaveLength(1); - // staleTime 0, yet the query is still active, so mounting is not a reactivation. + // Still active, so mounting is not a reactivation. const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); expect(w.value).toMatchObject({ n: 1 }); await sleep(10); @@ -248,7 +241,6 @@ describe('client.prefetch()', () => { const { client, mockFetch } = setup(kv); client.prefetch(GetFreshUser, { id: 1 }); - // Hydrated by the activation inside prefetch(), before it returned. expect(client.getQuery(QueryDefinition.for(GetFreshUser), { id: 1 }).value).toMatchObject({ n: 1 }); expect(mockFetch.calls).toHaveLength(0); await sleep(10); @@ -261,7 +253,6 @@ describe('client.prefetch()', () => { await sleep(60); expect(mockFetch.calls).toHaveLength(1); - // staleTime 0 and no grace: reactivating refetches. const { w, unsub } = mountReader(client, () => fetchQuery(GetItem).value); expect(w.value).toMatchObject({ n: 1 }); await sleep(10); @@ -275,7 +266,6 @@ describe('client.prefetch()', () => { mockFetch.get('/streamed', { n: 9 }, { delay: 40 }); client.prefetch(GetStreamed, undefined, { ttl: 10 }); await sleep(25); - // The ttl is an upper bound: past it, before the response, the lease is gone. expect(unsubscribed).toBe(1); expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); await sleep(40); @@ -361,7 +351,6 @@ describe('client.prefetch()', () => { const release = client.prefetch(GetItem); await sleep(5); release(); - // Still before the response (30 ms): the request has been cancelled. await sleep(10); expect(mockFetch.calls).toHaveLength(1); expect(mockFetch.calls[0].options.signal?.aborted).toBe(true); @@ -441,7 +430,6 @@ describe('client.retain()', () => { await sleep(10); expect(subscribed).toBe(1); - // A reader coming and going doesn't restart it. const reader = mountReader(client, () => fetchQuery(GetStreamed).value); reader.unsub(); await sleep(10); @@ -509,7 +497,7 @@ describe('client.retain()', () => { try { const { client } = setup(); - // A real GcManager: the tests' window-less environment gets the no-op one. + // A window-less environment gets the no-op GcManager. (client as unknown as { gcManager: GcManager }).gcManager = new GcManager( (client as unknown as { handleEviction: (key: number, type: GcKeyType) => void }).handleEviction, 1, diff --git a/packages/fetchium/src/__tests__/retry-should-retry.test.ts b/packages/fetchium/src/__tests__/retry-should-retry.test.ts index f0fb2d5..35bdb3d 100644 --- a/packages/fetchium/src/__tests__/retry-should-retry.test.ts +++ b/packages/fetchium/src/__tests__/retry-should-retry.test.ts @@ -12,7 +12,6 @@ import { t } from '../typeDefs.js'; const user = { id: '1', name: 'Alice' }; -/** An error shaped like a fetch wrapper's HTTP error (e.g. axios / request builders). */ function httpError(status: number): Error { return Object.assign(new Error(`HTTP ${status}`), { response: { status } }); } @@ -147,7 +146,6 @@ describe('shouldRetry', () => { }); describe('custom', () => { - /** Opts out of retrying client errors other than 408 and 429. */ const skipClientErrors: ShouldRetry = (_error, _attempt, status) => status === undefined || status < 400 || status >= 500 || status === 408 || status === 429; diff --git a/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx b/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx index 46a4efe..f0ed25b 100644 --- a/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx +++ b/packages/fetchium/src/react/__tests__/identical-redelivery-react.test.tsx @@ -13,12 +13,6 @@ import { TopicQueryAdapter } from '../../topic/TopicQueryAdapter.js'; import type { MutationEvent } from '../../types.js'; import { createRenderCounter } from './utils.js'; -/** - * A stream reconnect re-delivers every on-screen entity with the data it - * already holds. Readers of those entities must not re-render for it; a real - * change re-renders them once. - */ - class MockTopicAdapter extends TopicQueryAdapter { snapshots = new Map(); @@ -125,7 +119,6 @@ describe('React readers on identical re-delivery', () => { const rowRendersBefore = Row.renderCount; const listRendersBefore = ListCounter.renderCount; - // Reconnect: every entity re-delivered with the data the store holds. for (let pass = 0; pass < 3; pass++) { adapter.emit({ type: 'update', typename: 'Wallet', data: { id: 'w-1' } }); adapter.emit({ type: 'update', typename: 'Token', data: token(0) }); @@ -137,8 +130,7 @@ describe('React readers on identical re-delivery', () => { expect(Row.renderCount).toBe(rowRendersBefore); expect(ListCounter.renderCount).toBe(listRendersBefore); - // A real change re-renders the row that reads it, once. (The list re-renders - // too: its constrained live array re-filters on any member change.) + // The list re-renders too: its constrained live array re-filters on any member change. adapter.emit({ type: 'update', typename: 'Token', data: token(1, 99) }); await expect.element(getByTestId('row-tok-1')).toHaveTextContent('99:logo-1.png:Ann'); await sleep(50); diff --git a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx index 09160e9..5680fc1 100644 --- a/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx +++ b/packages/fetchium/src/react/__tests__/prefetch-suspense.test.tsx @@ -82,9 +82,7 @@ describe('client.prefetch() with React', () => { mockFetch.get('/item', { name: 'prefetched' }, { delay: 20 }); const client = makeClient(mockFetch); - // The tap. client.prefetch(GetItem, undefined, { ttl: 5_000 }); - // The navigation animation. await sleep(60); const renders: Array = []; @@ -166,7 +164,6 @@ describe('useSuspenseQuery', () => { mockFetch.get('/item', () => ({ name: `v${++n}` }), { delay: 20 }); const client = makeClient(mockFetch); - // Warm it, then let it go inactive. const release = client.prefetch(GetItem); await sleep(40); release(); @@ -193,7 +190,7 @@ describe('useSuspenseQuery', () => { , ); - // staleTime 0: the reactivation refetches, and the refetch must not suspend. + // The stale reactivation refetches without suspending. await expect.element(getByText('v2')).toBeInTheDocument(); expect(renders[0]).toBe('v1'); expect(fallbacks).toBe(0); @@ -317,8 +314,7 @@ describe('useSuspenseQuery', () => { client.prefetch(GetNoRetry); const relay = withContexts([[QueryClientContext, client]], () => fetchQuery(GetNoRetry)); - // Something else (another reader's retry, a reconnect) refetches as soon - // as the first attempt fails. + // Another refetch starts as soon as the first attempt fails. relay.then( () => {}, () => { @@ -375,7 +371,7 @@ describe('useSuspenseQuery', () => { const screen = render(tree(true)); await expect.element(screen.getByText('Suspended')).toBeInTheDocument(); - // Navigated away while suspended; the fetch fails with no reader. + // Unmount while suspended, so the fetch fails with no reader. screen.rerender(tree(false)); await sleep(150); expect(mockFetch.calls).toHaveLength(1); diff --git a/packages/fetchium/src/react/use-suspense-query.ts b/packages/fetchium/src/react/use-suspense-query.ts index 26c03a3..09b6e56 100644 --- a/packages/fetchium/src/react/use-suspense-query.ts +++ b/packages/fetchium/src/react/use-suspense-query.ts @@ -8,20 +8,10 @@ import { HasRequiredKeys, Optionalize, Signalize } from '../type-utils.js'; import { useQuery } from './use-query.js'; /** - * `useQuery` for components rendered under a React `` boundary. - * - * Suspends only on a cold miss: the query has never produced a value, neither - * in memory nor (with a synchronous store) in the persisted cache. Anything - * else renders immediately, including a stale value whose refetch is in - * flight; refetches never suspend. A cold fetch that fails is thrown to the - * nearest error boundary; resetting the boundary retries it. - * - * Changing params to ones with no value yet is a cold miss, so it suspends. - * Wrap the change in `startTransition` to keep showing the previous result. - * - * Call it from a plain function component. Like `useQuery`, it can't run - * inside a Signalium `component()` (a reactive context); use `fetchQuery` - * there. + * `useQuery` under a `` boundary. Suspends only on a cold miss (no + * value in memory or a sync store), never on refetch. A failed cold fetch is + * thrown to the error boundary. Wrap param changes in `startTransition` to keep + * the previous result. Not usable inside a Signalium `component()`. */ export function useSuspenseQuery( QueryClass: new () => T, @@ -35,9 +25,7 @@ export function useSuspenseQuery( throw new Error('QueryClient not found'); } - // Decide before useQuery: a suspended render is discarded without a commit, - // so a subscription it took would never be cleaned up. The client holds the - // query active while suspended instead. + // Before useQuery: a suspended render never commits, so its subscription would leak. const { promise, failed, error, key } = client.suspendOnColdMiss( QueryDefinition.for(QueryClass), args[0] as QueryParams | undefined, diff --git a/packages/fetchium/src/retry.ts b/packages/fetchium/src/retry.ts index cbd375d..ed8a828 100644 --- a/packages/fetchium/src/retry.ts +++ b/packages/fetchium/src/retry.ts @@ -2,15 +2,9 @@ import type { ResolvedRetryConfig } from './query.js'; /** * Decides whether a failed attempt is retried. Consulted only while retries - * remain. Without one, every failed attempt is retried. - * - * @param error What the attempt threw. - * @param attempt Index of the failed attempt, starting at 0 (the first request). - * @param status HTTP status of the failed attempt, when known: `status`, - * `statusCode` or `response.status` on the error, or else the status of the - * response the adapter received (`ctx.response.status`, for a REST error - * response whose body failed to parse or validate). Undefined for network - * errors. + * remain. `attempt` starts at 0. `status` comes from the error (`status`, + * `statusCode`, `response.status`) or the adapter's `ctx.response`, and is + * undefined for network errors. */ export type ShouldRetry = (error: unknown, attempt: number, status: number | undefined) => boolean; @@ -18,10 +12,7 @@ function asHttpStatus(value: unknown): number | undefined { return typeof value === 'number' && value >= 100 && value <= 599 ? value : undefined; } -/** - * The HTTP status an error carries, read from `status`, `statusCode`, or - * `response.status`. Undefined when none holds an HTTP status code. - */ +/** The HTTP status in an error's `status`, `statusCode` or `response.status`, if any. */ export function getErrorStatus(error: unknown): number | undefined { if (typeof error !== 'object' || error === null) return undefined; const e = error as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } | null }; @@ -36,12 +27,9 @@ export function getFailedResponseStatus(response: unknown): number | undefined { } export interface WithRetryOptions { - /** Used when the retry config has no `shouldRetry`. Without either, every failed attempt is retried. */ + /** Fallback when the retry config has no `shouldRetry`. */ shouldRetry?: ShouldRetry; - /** - * Called after an attempt fails. Returns the HTTP status the attempt - * received, for errors that don't carry one themselves. - */ + /** Status of the failed attempt, for errors that carry none. */ getAttemptStatus?: () => number | undefined; } diff --git a/packages/fetchium/src/types.ts b/packages/fetchium/src/types.ts index 9b873bb..e19fda3 100644 --- a/packages/fetchium/src/types.ts +++ b/packages/fetchium/src/types.ts @@ -68,10 +68,8 @@ export interface RetryConfig { */ retryDelay?: (attemptIndex: number) => number; /** - * Decides whether a failed attempt is retried, from the error, the attempt - * index (starting at 0) and the attempt's HTTP status when known. Overrides - * `QueryClientConfig.shouldRetry`. Without either, every failed attempt is - * retried. + * Decides whether a failed attempt is retried. Overrides + * `QueryClientConfig.shouldRetry`. Default: every failed attempt is retried. */ shouldRetry?: (error: unknown, attempt: number, status: number | undefined) => boolean; } From 8d0adf61c1bc423facfb77a68cbc846e96f0f07e Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:17:42 -0400 Subject: [PATCH 095/109] chore(fetchium): note Infinity in the reactivationGraceMs doc Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index e272cba..0376340 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -57,7 +57,8 @@ export interface QueryClientConfig { * scope resumes) with data younger than this is not refetched, even if stale. * Subscription pushes count as fresh data. Queries can override it with * `reactivationGraceMs`. Reconnects, `refetch()`, invalidation, `markStale()` - * and a failed last fetch still refetch. Default: 0. + * and a failed last fetch still refetch. Default: 0. `Infinity` never + * refetches on reactivation. */ reactivationGraceMs?: number; /** From fff11755c86a4b7c8b81fb8992d6d1bc7fd41b73 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 00:17:43 -0400 Subject: [PATCH 096/109] chore(fetchium): say what mayMissForeignFields answers Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 5712cdb..692a306 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -354,7 +354,11 @@ export class QueryClient { /** The store's remembered field names cover every class that wrote a record it holds. */ readonly storeKnowsTypenameFields: boolean; - /** Registers the def first, since a hydration parse runs before the apply registers it. @internal */ + /** + * Whether a stored record of this def's typename may hold fields the def + * lacks. Registers the def first: a hydration parse can run before the apply + * does. @internal + */ mayMissForeignFields(def: ValidatorDef): boolean { let verdict = this.foreignFieldDefs.get(def); if (verdict === undefined) { From 8cccf201f0d13a51c3b687254d73e50a22beace7 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 12:49:02 -0400 Subject: [PATCH 097/109] fix(fetchium): keep a throwing getConfig() from stalling Signalium's scheduler Options resolution now catches a getConfig() throw and returns default options plus the error, so it never throws inside a Signalium flush. The relay rejects with that error instead, and the start path skips fetching while it is set. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 27 +++++- .../src/__tests__/retain-prefetch.test.ts | 95 +++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 7b366a7..4fff71e 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -73,7 +73,22 @@ export class QueryInstance { private _resolvedOptions: ReadonlySignal<{ config: QueryConfigOptions | undefined; retryConfig: ResolvedRetryConfig; - }> = reactiveSignal(() => this.def.resolveOptions(this._executionCtx!)); + error?: unknown; + }> = reactiveSignal(() => this.resolveOptionsSafely()); + + // A throw here would escape Signalium's flush and stall its scheduler. + private resolveOptionsSafely() { + try { + return this.def.resolveOptions(this._executionCtx!); + } catch (error) { + return { config: undefined, retryConfig: resolveRetryConfig(undefined), error }; + } + } + + private get configError(): unknown { + if (this._executionCtx === undefined) return undefined; + return this._resolvedOptions.value.error; + } get config(): QueryConfigOptions | undefined { if (this._executionCtx === undefined) return undefined; @@ -184,6 +199,12 @@ export class QueryInstance { this.getOrCreateExecutionContext(); + const configError = this.configError; + if (configError !== undefined) { + this.relayState.setError(configError as Error); + return; + } + if (!this.initialized) { this.queryClient.activateQuery(this); this.initialize(); @@ -349,7 +370,7 @@ export class QueryInstance { private startSubscriptionAndFetch(): void { // If deactivated meanwhile, update() fetches on reactivation. - if (!this._isActive || this.isPaused) { + if (!this._isActive || this.isPaused || this.configError !== undefined) { return; } @@ -418,7 +439,7 @@ export class QueryInstance { this.def.statics.adapterClass, ); - this._resolvedOptions = reactiveSignal(() => this.def.resolveOptions(this._executionCtx!)); + this._resolvedOptions = reactiveSignal(() => this.resolveOptionsSafely()); } return this._executionCtx; diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 70572b0..9a1a89a 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -586,3 +586,98 @@ describe('client.retain()', () => { expect(unsubscribed).toBe(1); }); }); + +describe('a getConfig() that throws after a signal change', () => { + const configBroken = signal(false); + + class GetFragile extends RESTQuery { + path = '/item'; + result = { n: t.number }; + getConfig() { + if (configBroken.value) throw new Error('bad config'); + return { staleTime: 0 }; + } + } + + function trackUnhandled(): { errors: unknown[]; stop: () => void } { + const errors: unknown[] = []; + const onError = (error: unknown) => errors.push(error); + process.on('uncaughtException', onError); + process.on('unhandledRejection', onError); + return { + errors, + stop: () => { + process.off('uncaughtException', onError); + process.off('unhandledRejection', onError); + }, + }; + } + + async function expectSchedulerRunning(): Promise { + const s = signal(0); + const w = watcher(() => s.value); + let fired = 0; + const unsub = w.addListener(() => fired++); + await sleep(10); + const before = fired; + s.value = 1; + await sleep(10); + expect(fired).toBeGreaterThan(before); + unsub(); + } + + afterEach(() => { + configBroken.value = false; + }); + + it('a retained request that breaks a later query getConfig still releases at ttl, and the later query rejects', async () => { + const mockFetch = createMockFetch(); + mockFetch.get('/streamed', { n: 1 }); + mockFetch.get('/item', { n: 2 }); + const fetchBreakingConfig = (url: string, init?: RequestInit) => { + if (url.endsWith('/streamed')) configBroken.value = true; + return mockFetch(url, init); + }; + const client = new QueryClient({ + store: new SyncQueryStore(new MemoryPersistentStore()), + adapters: [new RESTQueryAdapter({ fetch: fetchBreakingConfig as any, baseUrl: 'http://localhost' })], + }); + clients.push(client); + const unhandled = trackUnhandled(); + + let fragile: ReturnType> | undefined; + const release = client.retain(() => [fetchQuery(GetStreamed), (fragile = fetchQuery(GetFragile))], { ttl: 30 }); + expect(release).toBeTypeOf('function'); + expect(subscribed).toBe(1); + + await sleep(10); + expect(fragile!.isRejected).toBe(true); + expect((fragile!.error as Error).message).toBe('bad config'); + + await sleep(40); + expect(unsubscribed).toBe(1); + + await expectSchedulerRunning(); + unhandled.stop(); + expect(unhandled.errors).toEqual([]); + }); + + it('a reader gets the error on its relay, and other watchers keep running', async () => { + const { client } = setup(); + const unhandled = trackUnhandled(); + let fragile: ReturnType> | undefined; + const reader = mountReader(client, () => (fragile = fetchQuery(GetFragile)).value); + await sleep(10); + expect(fragile!.isResolved).toBe(true); + + configBroken.value = true; + await sleep(10); + expect(fragile!.isRejected).toBe(true); + expect((fragile!.error as Error).message).toBe('bad config'); + + await expectSchedulerRunning(); + reader.unsub(); + unhandled.stop(); + expect(unhandled.errors).toEqual([]); + }); +}); From cb300b483ed66dc3c60b1f032e36ebb3b63270aa Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 12:52:35 -0400 Subject: [PATCH 098/109] fix(fetchium): recover a query once its getConfig() stops throwing A config error now stops the query's running subscription before rejecting. When the error clears, the query refetches and resubscribes on its own. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 10 ++++ .../src/__tests__/retain-prefetch.test.ts | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 4fff71e..61e3d49 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -60,6 +60,7 @@ export class QueryInstance { private pendingRestart: (() => void) | undefined = undefined; /** The last fetch ended in an error (not an abort). Disables the reactivation grace. */ private lastFetchFailed: boolean = false; + private rejectedByConfig: boolean = false; /** Not set by poll(): it delivers by refetching, which moves `updatedAt`. */ private lastPushAt: number | undefined = undefined; /** Lets a queued reactivation refetch detect that another fetch overtook it. */ @@ -201,13 +202,22 @@ export class QueryInstance { const configError = this.configError; if (configError !== undefined) { + this.stopSubscription(); + this.rejectedByConfig = true; this.relayState.setError(configError as Error); return; } + const recovering = this.rejectedByConfig; + this.rejectedByConfig = false; + if (!this.initialized) { this.queryClient.activateQuery(this); this.initialize(); + } else if (recovering) { + if (wasPaused || activating) this.queryClient.activateQuery(this); + this.reconcileSubscription(); + this.runDebounced(); } else if (wasPaused || activating) { this.queryClient.activateQuery(this); diff --git a/packages/fetchium/src/__tests__/retain-prefetch.test.ts b/packages/fetchium/src/__tests__/retain-prefetch.test.ts index 9a1a89a..be52a9c 100644 --- a/packages/fetchium/src/__tests__/retain-prefetch.test.ts +++ b/packages/fetchium/src/__tests__/retain-prefetch.test.ts @@ -599,6 +599,23 @@ describe('a getConfig() that throws after a signal change', () => { } } + class GetFragileStreamed extends RESTQuery { + path = '/streamed'; + result = { n: t.number }; + getConfig() { + if (configBroken.value) throw new Error('bad config'); + return { + staleTime: 0, + subscribe: () => { + subscribed++; + return () => { + unsubscribed++; + }; + }, + }; + } + } + function trackUnhandled(): { errors: unknown[]; stop: () => void } { const errors: unknown[] = []; const onError = (error: unknown) => errors.push(error); @@ -680,4 +697,40 @@ describe('a getConfig() that throws after a signal change', () => { unhandled.stop(); expect(unhandled.errors).toEqual([]); }); + + it('stops a running subscription when getConfig starts throwing', async () => { + const { client } = setup(); + let fragile: ReturnType> | undefined; + const reader = mountReader(client, () => (fragile = fetchQuery(GetFragileStreamed)).value); + await sleep(10); + expect(subscribed).toBe(1); + + configBroken.value = true; + await sleep(10); + expect(fragile!.isRejected).toBe(true); + expect(unsubscribed).toBe(1); + reader.unsub(); + // Let the reader tear down before afterEach clears the error. + await sleep(10); + }); + + it('recovers when getConfig stops throwing: refetches and resubscribes', async () => { + const { client, mockFetch } = setup(); + let fragile: ReturnType> | undefined; + const reader = mountReader(client, () => (fragile = fetchQuery(GetFragileStreamed)).value); + await sleep(10); + expect(mockFetch.calls).toHaveLength(1); + + configBroken.value = true; + await sleep(10); + expect(fragile!.isRejected).toBe(true); + + configBroken.value = false; + await sleep(10); + expect(fragile!.isResolved).toBe(true); + expect(fragile!.value).toMatchObject({ n: 2 }); + expect(mockFetch.calls).toHaveLength(2); + expect(subscribed).toBe(2); + reader.unsub(); + }); }); From 1d0f6d0c200be417139e96915306be90008293fc Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 12:55:39 -0400 Subject: [PATCH 099/109] fix(fetchium): stop all store writes and requests after destroy() destroy() now sets a client-level flag first. Store writes (entity persist, saveQueryData, activateQuery, deleteQuery) are dropped once it is set, and runQuery/runFetchNext drop a late response from any instance, including one collected and then reactivated outside queryInstances. abortForDestroy() also stops the subscription, so poll() timers end. Starts, refetches, restarts, network updates and async cache-load callbacks do nothing after destroy, a pending relay rejects with an AbortError, and getAdapter() no longer builds a default adapter. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 12 +- packages/fetchium/src/QueryResult.ts | 35 +++- .../src/__tests__/activation-timing.test.ts | 182 +++++++++++++++++- 3 files changed, 221 insertions(+), 8 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 0376340..0aa370b 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -148,6 +148,8 @@ export class QueryClient { store: QueryStore; currentParseId: number = 0; + /** Set first by `destroy()`. Gates store writes and new requests. */ + destroyed: boolean = false; /** Without `store.onDelete`, `_persisted` cannot be trusted. */ storeReportsDeletes: boolean = false; /** With `store.onPersisted`, `_persisted` is set on acknowledgement, not by `save()`. */ @@ -207,6 +209,7 @@ export class QueryClient { (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); this.networkManager = config.networkManager ?? new NetworkManager(); this.entityMap = new EntityStore((key, data, refs, merge) => { + if (this.destroyed) return; if (merge) this.store.mergeEntity!(key, data, refs); else this.store.saveEntity(key, data, refs); }); @@ -286,6 +289,7 @@ export class QueryClient { return match; } + if (this.destroyed) throw new Error(`QueryClient was destroyed. No adapter for ${adapterClass.name}.`); let adapter: QueryAdapter; try { adapter = new (adapterClass as new () => QueryAdapter)(); @@ -357,6 +361,7 @@ export class QueryClient { updatedAt: number, entityRefs?: Map, ): void { + if (this.destroyed) return; const refKeys = entityRefs !== undefined && entityRefs.size > 0 ? new Set([...entityRefs.keys()].map(e => e.key)) @@ -365,6 +370,7 @@ export class QueryClient { } activateQuery(queryInstance: QueryInstance): void { + if (this.destroyed) return; const { def, queryKey, storageKey, config } = queryInstance; this.store.activateQuery(def as any, storageKey); @@ -372,6 +378,10 @@ export class QueryClient { this.gcManager.cancel(queryKey, gcTime); } + deleteQuery(queryKey: number): void { + if (!this.destroyed) this.store.deleteQuery(queryKey); + } + loadCachedQuery(queryDef: QueryDefinition, queryKey: number) { return this.store.loadQuery(queryDef as any, queryKey); } @@ -957,7 +967,6 @@ export class QueryClient { if (type === GcKeyType.Query) { const instance = this.queryInstances.get(key); if (instance === undefined) return; - instance.stopSubscription(); // Nothing may settle its relay or reach the store after this. instance.abortForDestroy(); const root = instance.rootEntity; @@ -1021,6 +1030,7 @@ export class QueryClient { } destroy(): void { + this.destroyed = true; for (const key of [...this.suspenseHolds.keys()]) this.releaseSuspenseHold(key); this.unclaimedFailures.clear(); for (const release of [...this.leases]) release(); diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 7c883ac..7f5065a 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -240,6 +240,7 @@ export class QueryInstance { }; const update = (activating: boolean = false) => { + if (this.stoppedByDestroy()) return; const { wasPaused, isPaused, initialized } = this; this.wasPaused = isPaused; @@ -404,6 +405,7 @@ export class QueryInstance { if (isThenable(loaded)) { loaded.then( cached => { + if (this.stoppedByDestroy()) return; if (this.storageKey === storageKey) { this.hydrate(cached); this.startSubscriptionAndFetch(); @@ -412,6 +414,7 @@ export class QueryInstance { } }, error => { + if (this.stoppedByDestroy()) return; this.discardCorruptCache(error, storageKey); if (this.storageKey === storageKey) { this.startSubscriptionAndFetch(); @@ -480,7 +483,7 @@ export class QueryInstance { private discardCorruptCache(error: unknown, storageKey: number = this.storageKey): void { const qc = this.queryClient; - qc.store.deleteQuery(storageKey); + qc.deleteQuery(storageKey); qc.getContext().log?.warn?.('Failed to initialize query, the query cache may be corrupted or invalid', error); } @@ -500,7 +503,7 @@ export class QueryInstance { private startSubscriptionAndFetch(): void { // If deactivated meanwhile, update() fetches on reactivation. - if (!this._isActive || this.isPaused) { + if (this.stoppedByDestroy() || !this._isActive || this.isPaused) { return; } @@ -530,7 +533,7 @@ export class QueryInstance { private reconcileSubscription(): void { // A fetch aborted by deactivate() still reaches this from runQuery's // finally. Subscribing then would leave a subscription nothing tears down. - if (!this._isActive) return; + if (!this._isActive || this.queryClient.destroyed) return; const subscribeFn = this.config?.subscribe; if (subscribeFn === this.lastSubscribeFn) return; @@ -597,9 +600,11 @@ export class QueryInstance { async () => { attempt.start(); try { + this.throwIfDestroyed(); const freshData = await adapter.send(ctx, signal); // Params changed or client destroyed mid-request, and the adapter ignored the abort. if (this.storageKey !== storageKey || this.destroyCount !== destroyCount) throw abortError(); + this.throwIfDestroyed(); this.updatedAt = Date.now(); const result = this.applyData(freshData, true); @@ -683,7 +688,7 @@ export class QueryInstance { const run = (): void => { if (this.pendingRestart !== run) return; this.pendingRestart = undefined; - if (controller.signal.aborted) { + if (controller.signal.aborted || this.queryClient.destroyed) { reject(controller.signal.reason ?? abortError()); return; } @@ -720,6 +725,7 @@ export class QueryInstance { } private runQueryImmediately(): void { + if (this.stoppedByDestroy()) return; this.fetchStarts++; this.abortedByDeactivation = false; this.parkedRestart = undefined; @@ -738,7 +744,7 @@ export class QueryInstance { * task can cancel the fetch instead of starting and aborting it. */ private runDebounced(extraDelay: number = 0, nextTask: boolean = false, afterFlush: boolean = false): void { - if (this.relayState.isPending) return; + if (this.stoppedByDestroy() || this.relayState.isPending) return; const delay = (this.config?.debounce ?? 0) + extraDelay; @@ -803,6 +809,7 @@ export class QueryInstance { abortForDestroy(): void { this.destroyCount++; this.cancelDebounced(); + this.stopSubscription(); this.startPending = false; this.firstFetchController = undefined; this.keptFetchSignal = undefined; @@ -824,6 +831,20 @@ export class QueryInstance { } } + /** After `destroy()`: starts nothing, and rejects a pending relay. */ + private stoppedByDestroy(): boolean { + if (!this.queryClient.destroyed) return false; + if (this._relayState?.isPending) this._relayState.setError(abortError()); + return true; + } + + /** For an instance `destroy()` missed (collected, then read again). Aborting ends its retries too. */ + private throwIfDestroyed(): void { + if (!this.queryClient.destroyed) return; + this.abortForDestroy(); + throw abortError(); + } + /** Records that the subscription delivered data. See `lastPushAt`. */ private notePush = (): void => { if (this._isActive) this.lastPushAt = Date.now(); @@ -889,7 +910,7 @@ export class QueryInstance { }; private get hasNext(): boolean { - if (this.rootEntity === undefined || !this._executionCtx) return false; + if (this.rootEntity === undefined || !this._executionCtx || this.queryClient.destroyed) return false; const adapter = this.queryClient.getAdapter(this.def.statics.adapterClass); if (!adapter.hasNext) return false; this._executionCtx.resultData = this.rootEntity.data; @@ -910,8 +931,10 @@ export class QueryInstance { return withRetry( async () => { attempt.start(); + this.throwIfDestroyed(); const freshData = await adapter.sendNext!(ctx, signal); if (this.storageKey !== storageKey || this.destroyCount !== destroyCount) throw abortError(); + this.throwIfDestroyed(); this.updatedAt = Date.now(); const result = this.applyData(freshData, true, true); diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index 02bb3bb..28102c4 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { reactive, signal, watcher, withContexts } from 'signalium'; import { MemoryPersistentStore, SyncQueryStore } from '../stores/sync.js'; +import { AsyncQueryStore, type AsyncPersistentStore } from '../stores/async.js'; +import { poll } from '../subscriptions/polling.js'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; import { NetworkManager } from '../NetworkManager.js'; @@ -117,6 +119,47 @@ function persisted(kv: MemoryPersistentStore, params: { id: string }): boolean { return kv.getNumber(updatedAtKeyFor(queryKeyForClass(GetItemById, params))) !== undefined; } +/** Records the writes made once `armed` is set. */ +class RecordingKv extends MemoryPersistentStore { + armed = false; + writes: string[] = []; + override setString(key: string, value: string): void { + if (this.armed) this.writes.push(key); + super.setString(key, value); + } + override setNumber(key: string, value: number): void { + if (this.armed) this.writes.push(key); + super.setNumber(key, value); + } + override setBuffer(key: string, value: Uint32Array): void { + if (this.armed) this.writes.push(key); + super.setBuffer(key, value); + } + override delete(key: string): void { + if (this.armed) this.writes.push(`delete ${key}`); + super.delete(key); + } +} + +/** An async view of `kv` whose reads take `delay` ms. */ +function slowReads(kv: MemoryPersistentStore, delay: number): AsyncPersistentStore { + const read = async (get: () => T): Promise => { + await sleep(delay); + return get(); + }; + return { + has: key => read(() => kv.has(key)), + getString: key => read(() => kv.getString(key)), + getNumber: key => read(() => kv.getNumber(key)), + getBuffer: key => read(() => kv.getBuffer(key)), + setString: async (key, value) => kv.setString(key, value), + setNumber: async (key, value) => kv.setNumber(key, value), + setBuffer: async (key, value) => kv.setBuffer(key, value), + delete: async key => kv.delete(key), + getAllKeys: async () => kv.getAllKeys(), + }; +} + describe('Activation and deactivation in one task', () => { it('lets the first fetch finish when the query is mounted and unmounted in one task', async () => { const kv = new MemoryPersistentStore(); @@ -1025,4 +1068,141 @@ describe('Responses that arrive after the client or the params moved on', () => dispose(); client.destroy(); }); + + describe('after destroy()', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** destroy() drops the adapters, so catch a request through a default one on the global fetch too. */ + function setup(delay: number, networkManager?: NetworkManager) { + const kv = new RecordingKv(); + const f = createFetch(delay, { ignoreAbort: true }); + vi.stubGlobal('fetch', f.fetch); + vi.stubGlobal('location', { origin: 'http://localhost' }); + return { kv, f, client: makeClient(kv, f.fetch, networkManager) }; + } + + it('drops a late response from a collected query that a kept relay reactivated', async () => { + vi.stubGlobal('window', globalThis); + class GetItemGc0 extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { gcTime: 0 }; + } + const { kv, f, client } = setup(20); + + let kept: any; + const first = activate(client, () => (kept = fetchQuery(GetItemGc0)).isPending); + await sleep(40); + first(); + await sleep(20); + expect(client.queryInstances.size).toBe(0); + + activate(client, () => kept.isPending); + await sleep(5); + expect(f.calls.length).toBe(2); + client.destroy(); + kv.armed = true; + await sleep(40); + + expect(kv.writes).toEqual([]); + expect(await outcome(kept, 0)).toBe('rejected:AbortError'); + }); + + it('stops a poll() subscription', async () => { + class GetPolled extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { subscribe: poll({ interval: 50 }) }; + } + const { kv, f, client } = setup(5); + + activate(client, () => fetchQuery(GetPolled).isPending); + await sleep(20); + client.destroy(); + kv.armed = true; + await sleep(120); + + expect(f.calls.length).toBe(1); + expect(kv.writes).toEqual([]); + }); + + it('ignores __refetch() and builds no default adapter', async () => { + const { kv, f, client } = setup(5); + + let relay: any; + activate(client, () => (relay = fetchQuery(GetItem)).isPending); + await sleep(20); + client.destroy(); + kv.armed = true; + relay.value.__refetch(); + await sleep(30); + + expect(f.calls.length).toBe(1); + expect(kv.writes).toEqual([]); + expect(() => client.getAdapter(RESTQueryAdapter)).toThrow(/destroyed/); + }); + + it('rejects a query a mounted reader creates after destroy() without fetching', async () => { + const { kv, f, client } = setup(5); + const tick = signal(0); + + let relay: any; + activate(client, () => { + void tick.value; + return (relay = fetchQuery(GetItem)).isPending; + }); + await sleep(20); + client.destroy(); + kv.armed = true; + tick.value = 1; + await sleep(40); + + expect(f.calls.length).toBe(1); + expect(kv.writes).toEqual([]); + expect(await outcome(relay, 0)).toBe('rejected:AbortError'); + }); + + it('does not refetch when a shared NetworkManager reconnects', async () => { + const networkManager = new NetworkManager(true); + const { kv, f, client } = setup(5, networkManager); + + activate(client, () => fetchQuery(GetItem).isPending); + await sleep(20); + client.destroy(); + kv.armed = true; + networkManager.setNetworkStatus(false); + await sleep(5); + networkManager.setNetworkStatus(true); + await sleep(40); + + expect(f.calls.length).toBe(1); + expect(kv.writes).toEqual([]); + }); + + it('does not fetch when an asynchronous cache load finishes after destroy()', async () => { + const { kv, f } = setup(5); + const store = new AsyncQueryStore({ + isWriter: true, + delegate: slowReads(kv, 10), + connect: () => ({ sendMessage: () => {} }), + }); + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })], + }); + + let relay: any; + activate(client, () => (relay = fetchQuery(GetItem)).isPending); + await sleep(0); + client.destroy(); + kv.armed = true; + await sleep(60); + + expect(f.calls.length).toBe(0); + expect(kv.writes).toEqual([]); + expect(await outcome(relay, 0)).toBe('rejected:AbortError'); + }); + }); }); From b384ba838cda7ebb3483b80a44f811e5e322b871 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 12:56:57 -0400 Subject: [PATCH 100/109] fix(fetchium): keep other class fields when an async write follows a failed read The async store has no synchronous record read, so a flagged class whose hydration read failed wrote its record whole and dropped the detail fields. Such writes now send the keys the instance owns, and the writer keeps the stored fields outside them, counting their references. A failed writer read fails the write, and the flag stays so the next write retries. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/EntityStore.ts | 8 +- packages/fetchium/src/QueryClient.ts | 3 +- .../__tests__/hydrate-in-memory-shape.test.ts | 189 +++++++++++++++++- packages/fetchium/src/query-types.ts | 5 +- packages/fetchium/src/stores/async.ts | 36 +++- 5 files changed, 234 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index f3e3f72..5ff713d 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -79,8 +79,13 @@ export class EntityStore { return; } if (instance._checkStoredRecord) { + if (this.readEntity === undefined) { + // The writer keeps the record's other fields, so the flag stays for every write. + this.persistEntity(instance.key, value, refKeys, false, undefined, Object.keys(value)); + return; + } // Cleared after the read so a read that throws is retried. - const stored = this.readEntity?.(instance.key); + const stored = this.readEntity(instance.key); instance._checkStoredRecord = false; if (stored !== undefined) instance.noteRecord(stored); } @@ -99,4 +104,5 @@ type PersistEntity = ( refKeys: Set | undefined, merge: boolean, rest?: string, + ownedKeys?: string[], ) => void; diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 692a306..b34e27c 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -206,8 +206,9 @@ export class QueryClient { config.gcManager ?? (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); this.networkManager = config.networkManager ?? new NetworkManager(); - this.entityMap = new EntityStore((key, data, refs, merge, rest) => { + this.entityMap = new EntityStore((key, data, refs, merge, rest, ownedKeys) => { if (merge) this.store.mergeEntity!(key, data, refs); + else if (ownedKeys !== undefined) this.store.saveEntity(key, data, refs, undefined, ownedKeys); else if (rest !== undefined) this.store.saveEntity(key, data, refs, rest); else this.store.saveEntity(key, data, refs); }); diff --git a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts index 7778d57..287a0bf 100644 --- a/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts +++ b/packages/fetchium/src/__tests__/hydrate-in-memory-shape.test.ts @@ -10,7 +10,8 @@ import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; import { AsyncQueryStore, type StoreMessage } from '../stores/async.js'; import { valueKeyFor, refIdsKeyFor, refCountKeyFor } from '../stores/shared.js'; import { hashValue } from 'signalium/utils'; -import { sleep } from './utils.js'; +import { sleep, testWithClient } from './utils.js'; +import { GcManager } from '../GcManager.js'; // A cached query is served when an in-memory entity lacks fields the cache can // fill, but never with values the fresher in-memory entity contradicts. @@ -774,6 +775,192 @@ describe('D. writes keep the fields another class sharing the typename declared' } }); + function asyncWriter() { + const data = new Map(); + const failReads = new Map(); + const reads: string[] = []; + const delegate = { + has: async (k: string) => data.has(k), + getString: async (k: string) => { + reads.push(k); + const n = failReads.get(k) ?? 0; + if (n > 0) { + failReads.set(k, n - 1); + throw new Error('read failed'); + } + return data.get(k) as string | undefined; + }, + setString: async (k: string, v: string) => void data.set(k, v), + getNumber: async (k: string) => data.get(k) as number | undefined, + setNumber: async (k: string, v: number) => void data.set(k, v), + getBuffer: async (k: string) => data.get(k) as Uint32Array | undefined, + setBuffer: async (k: string, v: Uint32Array) => void data.set(k, v), + delete: async (k: string) => void data.delete(k), + getAllKeys: async () => [...data.keys()], + }; + const store = () => + new AsyncQueryStore({ + isWriter: true, + delegate, + connect: handleMessage => ({ sendMessage: msg => handleMessage(msg) }), + }); + const drain = async (s: AsyncQueryStore) => { + for (let i = 0; i < 500 && !s.isSettled(); i++) await sleep(2); + }; + const record = (typename: string, id: string) => { + const raw = data.get(valueKeyFor(hashValue([typename, id]))) as string | undefined; + return raw === undefined ? undefined : JSON.parse(raw); + }; + return { data, failReads, reads, store, drain, record }; + } + + function makeQuietClient(store: AsyncQueryStore, f: ReturnType) { + const client = new QueryClient({ + store, + adapters: [new RESTQueryAdapter({ fetch: f.fetch as any, baseUrl: 'http://localhost' })], + log: { warn: () => {}, error: () => {} }, + } as any); + clients.push(client); + return client; + } + + async function asyncSession( + env: ReturnType, + f: ReturnType, + run: (c: QueryClient) => Promise, + ) { + const s = env.store(); + const c = makeQuietClient(s, f); + await run(c); + await sleep(10); + await env.drain(s); + c.destroy(); + clients = clients.filter(x => x !== c); + } + + const itemValueKey = valueKeyFor(hashValue(['Item', 'i1'])); + const keptOnDisk = (env: ReturnType, name: string) => { + expect(env.record('Item', 'i1')).toEqual({ + __typename: 'Item', + id: 'i1', + name, + details: { rating: 5 }, + vendor: { __entityRef: vendorKey('v1') }, + }); + expect(env.data.get(refIdsKeyFor(hashValue(['Item', 'i1'])))).toEqual(new Uint32Array([vendorKey('v1')])); + expect(env.record('Vendor', 'v1')).toBeDefined(); + }; + const keptAfterRestart = async (env: ReturnType, f: ReturnType) => { + f.set('/item', itemDetail('v2', 6), 1000); + await asyncSession(env, f, async c => { + const item: any = start(c, () => fetchQuery(GetItem)); + await item; + expect(item.value.item.details.rating).toBe(5); + expect(item.value.item.vendor.name).toBe('v1'); + }); + }; + + it('D10 with the detail out of memory, a failed async hydration read then a fetch keeps the detail fields, on disk and after a restart', async () => { + class GetItemBriefly extends RESTQuery { + path = '/item'; + result = { item: t.entity(ItemDetail) }; + config = { gcTime: 0 }; + } + const env = asyncWriter(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + await asyncSession(env, f, async c => { + await start(c, () => fetchQuery(GetItem)); + await start(c, () => fetchQuery(GetItems)); + }); + + await asyncSession(env, f, async c => { + c.gcManager = new GcManager((c as any).handleEviction); + await testWithClient(c, async () => { + await fetchQuery(GetItemBriefly); + }); + await sleep(20); + expect(c.entityMap.hasEntity(hashValue(['Item', 'i1']))).toBe(false); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + env.failReads.set(itemValueKey, 1); + await Promise.resolve(start(c, () => fetchQuery(GetItems))).catch(() => {}); + expect(env.failReads.get(itemValueKey)).toBe(0); + }); + keptOnDisk(env, 'I2'); + await keptAfterRestart(env, f); + }); + + it('D11 as D10 with the detail class registered by another entity, and a failed writer read retried at the next write', async () => { + class GetOtherItem extends RESTQuery { + path = '/other-item'; + result = { item: t.entity(ItemDetail) }; + } + const env = asyncWriter(); + const f = makeFetch(); + f.set('/item', itemDetail('v1')); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + f.set('/other-item', { item: { ...itemDetail('v9').item, id: 'i9' } }); + await asyncSession(env, f, async c => { + await start(c, () => fetchQuery(GetItem)); + await start(c, () => fetchQuery(GetItems)); + }); + + const s = env.store(); + const c = makeQuietClient(s, f); + await start(c, () => fetchQuery(GetOtherItem)); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I2' }] }); + env.failReads.set(itemValueKey, 1); + const list: any = start(c, () => fetchQuery(GetItems)); + await Promise.resolve(list).catch(() => {}); + await sleep(10); + await env.drain(s); + expect(env.failReads.get(itemValueKey)).toBe(0); + keptOnDisk(env, 'I2'); + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I3' }] }); + env.failReads.set(itemValueKey, 1); + await list.value.__refetch(); + await sleep(10); + await env.drain(s); + expect(env.failReads.get(itemValueKey)).toBe(0); + keptOnDisk(env, 'I2'); + } finally { + consoleError.mockRestore(); + } + + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I4' }] }); + await list.value.__refetch(); + await sleep(10); + await env.drain(s); + keptOnDisk(env, 'I4'); + c.destroy(); + await keptAfterRestart(env, f); + }); + + it('D12 an async write reads the record first only for a class that lacks fields another class of its typename declares', async () => { + class GetOtherItem extends RESTQuery { + path = '/other-item'; + result = { item: t.entity(ItemDetail) }; + } + const env = asyncWriter(); + const f = makeFetch(); + f.set('/reading/page', { reading: readingPayload, label: 'x' }); + f.set('/other-item', { item: { ...itemDetail('v9').item, id: 'i9' } }); + f.set('/items', { items: [{ __typename: 'Item', id: 'i1', name: 'I' }] }); + const readingValueKey = valueKeyFor(hashValue(['Reading', 'r1'])); + await asyncSession(env, f, async c => { + await start(c, () => fetchQuery(GetReadingPage)); + await start(c, () => fetchQuery(GetOtherItem)); + await start(c, () => fetchQuery(GetItems)); + }); + expect(env.record('Reading', 'r1')).toBeDefined(); + expect(env.reads.filter(k => k === readingValueKey).length).toBe(0); + expect(env.reads.filter(k => k === itemValueKey).length).toBe(1); + }); + it('D5 the stores write the kept fields with the record, the async writer included', async () => { const kv = new MemoryPersistentStore(); new SyncQueryStore(kv).saveEntity(7, { id: 'a', name: 'n' }, undefined, '"extra":{"x":1}'); diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index d6702d5..cb831f6 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -100,8 +100,11 @@ export interface QueryStore { /** * `rest`: JSON object body (`"a":1,"b":{…}`) of fields another class of the * typename wrote. Write it ahead of `value`'s fields. `refIds` covers it. + * + * `ownedKeys`: sent instead of `rest` by a client without `readEntity`. The + * stored record's fields outside these keys are kept, and their references counted. */ - saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void; + saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string, ownedKeys?: string[]): void; /** * Writes the fields streamed events supplied, merged over the stored record diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index c380cc6..4355dd9 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -8,6 +8,7 @@ import { lastUsedKeyFor, mergeStoredRecord, queueKeyFor, + recordRestOutside, refCountKeyFor, refIdsKeyFor, updatedAtKeyFor, @@ -64,6 +65,8 @@ export type StoreMessage = merge?: boolean; /** See `QueryStore.saveEntity`. */ rest?: string; + /** See `QueryStore.saveEntity`. Ignored by older writers. */ + ownedKeys?: string[]; } | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount?: number } | { type: StoreMessageType.DeleteQuery; queryKey: number }; @@ -98,6 +101,20 @@ function isStoreMessage(msg: unknown): msg is StoreMessage { ); } +/** The stored record's fields outside `ownedKeys`, or undefined if there are none. */ +function storedRecordOutside(stored: string, ownedKeys: string[]) { + let record: unknown; + try { + record = JSON.parse(stored); + } catch { + return undefined; + } + if (typeof record !== 'object' || record === null || Array.isArray(record)) return undefined; + const owned: Record = Object.create(null); + for (const key of ownedKeys) owned[key] = true; + return recordRestOutside(record as Record, owned); +} + /** Callers notify over a copy, since a listener may unsubscribe while being notified. */ function subscribe(listeners: Array<(key: number) => void>, listener: (key: number) => void): () => void { listeners.push(listener); @@ -315,7 +332,7 @@ export class AsyncQueryStore implements QueryStore { ); break; case StoreMessageType.SaveEntity: - await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true, msg.rest); + await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true, msg.rest, msg.ownedKeys); for (const listener of this.persistedListeners.slice()) listener(msg.entityKey); break; case StoreMessageType.ActivateQuery: @@ -410,7 +427,7 @@ export class AsyncQueryStore implements QueryStore { this.dispatch(message); } - saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string): void { + saveEntity(entityKey: number, value: unknown, refIds?: Set, rest?: string, ownedKeys?: string[]): void { const message: Extract = { type: StoreMessageType.SaveEntity, entityKey, @@ -418,6 +435,7 @@ export class AsyncQueryStore implements QueryStore { refIds: refIds ? Array.from(refIds) : undefined, }; if (rest !== undefined && rest !== '') message.rest = rest; + if (ownedKeys !== undefined) message.ownedKeys = ownedKeys; this.dispatch(message); } @@ -474,6 +492,7 @@ export class AsyncQueryStore implements QueryStore { refIds: number[] | undefined, merge: boolean, rest?: string, + ownedKeys?: string[], ): Promise { if (merge) { const stored = await this.delegate!.getString(valueKeyFor(entityKey)); @@ -483,7 +502,18 @@ export class AsyncQueryStore implements QueryStore { return; } } - await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined, rest); + const refs = refIds ? new Set(refIds) : undefined; + if (ownedKeys !== undefined) { + const stored = await this.delegate!.getString(valueKeyFor(entityKey)); + const kept = stored !== undefined ? storedRecordOutside(stored, ownedKeys) : undefined; + if (kept !== undefined) { + const keptRefs = refs ?? new Set(); + for (const id of kept.refIds) keptRefs.add(id); + await this.setValue(entityKey, value, keptRefs, kept.json); + return; + } + } + await this.setValue(entityKey, value, refs, rest); } private async writerActivateQuery( From 96a5200c0a4e4cda1f30763420b5e07dbe36711c Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 13:01:13 -0400 Subject: [PATCH 101/109] fix(fetchium): keep event writes and store listeners safe when the store can't answer A failed write whose has() recheck also fails now retries, then leaves the key held. An event refresh is sent as an ifStored merge, which the writer drops and reports when no record exists, so a wrongly held key never writes partial fields as a record. Events for entities not in memory are refreshed during the writer's key scan and from readers again. A throwing store listener is logged instead of stopping the writer or a deletion cascade. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/app/api/fetchium/page.md | 10 +- docs/src/app/api/stores-async/page.md | 26 ++-- packages/fetchium/src/EntityInstance.ts | 9 +- packages/fetchium/src/EntityStore.ts | 18 ++- packages/fetchium/src/QueryClient.ts | 16 ++- .../src/__tests__/event-persistence.test.ts | 94 +++++++++++++-- .../skip-write-and-snapshot-fixes.test.ts | 111 ++++++++++++++++++ .../src/__tests__/store-on-delete.test.ts | 88 ++++++++++++++ packages/fetchium/src/query-types.ts | 8 +- packages/fetchium/src/stores/async.ts | 51 +++++--- packages/fetchium/src/stores/shared.ts | 11 ++ packages/fetchium/src/stores/sync.ts | 4 +- 12 files changed, 379 insertions(+), 67 deletions(-) diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 3d6f76e..5120ea2 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -710,8 +710,8 @@ The `t` object provides a declarative type definition DSL for describing query p ### Store types -| Type | Definition | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). Besides the required methods, a store may implement `onDelete` (the client then skips writing entities whose records are current, and writes one again once the store reports its record dropped), `onPersisted` (for a store that applies writes later), `isSettled`, `hasEntity` (lets a streamed event refresh the record of an entity that is not in memory) and `mergeEntity` (merges the fields streamed events supplied for an entity built from them over its record; without it, such an entity is written whole through `saveEntity`). A store without `onDelete` is written on every apply, as before 0.6; without `hasEntity`, an event for an entity that is not in memory and that no live array retains is not written. | -| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | -| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | +| Type | Definition | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). Besides the required methods, a store may implement `onDelete` (the client then skips writing entities whose records are current, and writes one again once the store reports its record dropped), `onPersisted` (for a store that applies writes later), `isSettled`, `hasEntity` (lets a streamed event refresh the record of an entity that is not in memory) and `mergeEntity` (merges the fields streamed events supplied for an entity built from them over its record; without it, such an entity is written whole through `saveEntity`). A store without `onDelete` is written on every apply, as before 0.6; when `hasEntity` is missing or answers `undefined`, an event for an entity that is not in memory and that no live array retains is written only as a `mergeEntity` call with `ifStored`, which writes nothing when the store holds no record, and not at all without `mergeEntity`. | +| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | +| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | diff --git a/docs/src/app/api/stores-async/page.md b/docs/src/app/api/stores-async/page.md index bbe2c6b..ab56842 100644 --- a/docs/src/app/api/stores-async/page.md +++ b/docs/src/app/api/stores-async/page.md @@ -41,19 +41,19 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Dispatches a save-entity message with `merge: true`; the writer merges `fields` over the stored record, if there is one (see `SyncQueryStore.mergeEntity`). | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. A queue persisted with a different `maxCount` is resized by the writer: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | -| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. In writer mode it runs through the same serial queue as the messages, after everything queued before it; a reader with its own `delegate` runs it directly. Only operates when a `delegate` is available. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every record the writer drops on its own (an LRU eviction, a reference-count cascade, a purge) or fails to write. Returns an unsubscribe function. | -| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every entity record a queued write has stored. Returns an unsubscribe function. | -| `isSettled` | `(): boolean` | Whether every operation handed to the writer has been applied (nothing queued, nothing mid-flight). Always `true` in reader mode. | -| `hasEntity` | `(key: number): boolean \| undefined` | Writer mode only. Whether the delegate holds a record for the key, answered from a set the writer reads from the delegate when it starts (alongside its queue, not ahead of it) and keeps current with every write it queues and every deletion it performs. `undefined` for a key it has not written itself until that read lands; after a read that keeps failing, nothing is assumed held. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a save-entity message with `merge: true`; the writer merges `fields` over the stored record, if there is one (see `SyncQueryStore.mergeEntity`). With `ifStored` and no stored record, the writer writes nothing and reports the key to `onDelete` listeners. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. A queue persisted with a different `maxCount` is resized by the writer: growing reallocates it, shrinking evicts the keys that no longer fit. | +| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | +| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. In writer mode it runs through the same serial queue as the messages, after everything queued before it; a reader with its own `delegate` runs it directly. Only operates when a `delegate` is available. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every record the writer drops on its own (an LRU eviction, a reference-count cascade, a purge) or fails to write. Returns an unsubscribe function. | +| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every entity record a queued write has stored. Returns an unsubscribe function. | +| `isSettled` | `(): boolean` | Whether every operation handed to the writer has been applied (nothing queued, nothing mid-flight). Always `true` in reader mode. | +| `hasEntity` | `(key: number): boolean \| undefined` | Writer mode only. Whether the delegate holds a record for the key, answered from a set the writer reads from the delegate when it starts (alongside its queue, not ahead of it) and keeps current with every write it queues and every deletion it performs. `undefined` for a key it has not written itself until that read lands; after a read that keeps failing, nothing is assumed held. An event for an entity not in memory is sent as an `ifStored` merge while the answer is `undefined`, and always from a reader. | --- diff --git a/packages/fetchium/src/EntityInstance.ts b/packages/fetchium/src/EntityInstance.ts index ae28ec2..041b733 100644 --- a/packages/fetchium/src/EntityInstance.ts +++ b/packages/fetchium/src/EntityInstance.ts @@ -618,8 +618,11 @@ export class EntityInstance { return false; } - /** `storeHolds`: the store's `hasEntity` answer, if the caller already has it. */ - save(storeHolds?: boolean): void { + /** + * `storeHolds`: the store's `hasEntity` answer, if the caller already has it. + * `ifStored`: a merge only refreshes a stored record, never creates one. + */ + save(storeHolds?: boolean, ifStored?: boolean): void { const client = this._queryClient; if (this._saving) return; this._saving = true; @@ -660,7 +663,7 @@ export class EntityInstance { // Counted before the call: a store may acknowledge synchronously. if (client.storeAcksWrites) this._pendingWrites++; try { - client.entityMap.save(this, this._partial ? this._partialKeys : undefined); + client.entityMap.save(this, this._partial ? this._partialKeys : undefined, ifStored); } catch (e) { this.markUnwritten(); throw e; diff --git a/packages/fetchium/src/EntityStore.ts b/packages/fetchium/src/EntityStore.ts index 45410a7..32f8522 100644 --- a/packages/fetchium/src/EntityStore.ts +++ b/packages/fetchium/src/EntityStore.ts @@ -3,14 +3,20 @@ import type { QueryClient } from './QueryClient.js'; import { EntityInstance } from './EntityInstance.js'; import { ValidatorDef } from './typeDefs.js'; +type PersistEntity = ( + key: number, + data: Record, + refKeys?: Set, + merge?: boolean, + ifStored?: boolean, +) => void; + export class EntityStore { private instances = new Map(); - private persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void; + private persistEntity: PersistEntity; mergesEntities: boolean = false; - constructor( - persistEntity: (key: number, data: Record, refKeys?: Set, merge?: boolean) => void, - ) { + constructor(persistEntity: PersistEntity) { this.persistEntity = persistEntity; } @@ -62,7 +68,7 @@ export class EntityStore { } /** With `mergeKeys`, only those fields are sent, merged over the stored record. */ - save(instance: EntityInstance, mergeKeys?: Set): void { + save(instance: EntityInstance, mergeKeys?: Set, ifStored?: boolean): void { let refKeys: Set | undefined; if (instance.entityRefs) { refKeys = new Set(); @@ -75,6 +81,6 @@ export class EntityStore { value = {}; for (const k of mergeKeys) value[k] = instance.data[k]; } - this.persistEntity(instance.key, value, refKeys, merge); + this.persistEntity(instance.key, value, refKeys, merge, ifStored); } } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 5b7942e..f6cbb9f 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -188,8 +188,8 @@ export class QueryClient { config.gcManager ?? (this.isServer ? new NoOpGcManager() : new GcManager(this.handleEviction, evictionMultiplier)); this.networkManager = config.networkManager ?? new NetworkManager(); - this.entityMap = new EntityStore((key, data, refs, merge) => { - if (merge) this.store.mergeEntity!(key, data, refs); + this.entityMap = new EntityStore((key, data, refs, merge, ifStored) => { + if (merge) this.store.mergeEntity!(key, data, refs, ifStored); else this.store.saveEntity(key, data, refs); }); this.entityMap.mergesEntities = typeof this.store.mergeEntity === 'function'; @@ -747,8 +747,8 @@ export class QueryClient { return; } - // Write a created root if a live array will retain it or the store already - // holds it. Before routing, so a failed write routes nothing. + // Write a created root if a live array will retain it, else only refresh a + // record the store may hold. Before routing, so a failed write routes nothing. let matched = false; let retains = false; this.routeEvent( @@ -767,9 +767,13 @@ export class QueryClient { let held: boolean | undefined; if (!entity._persisted && entity._pendingWrites === 0) { if (!retains) held = this.store.hasEntity?.(key); - if (retains || held === true) { + // Unknown: entities created under the root would be written even if the root isn't. + const refresh = + held === true || + (held === undefined && entity._partial && created!.size === 1 && this.entityMap.mergesEntities); + if (retains || refresh) { try { - entity.save(held); + entity.save(held, !retains); } catch (e) { this.context.log?.warn?.('Failed to apply mutation event', e); this.evictUnlessAdopted(entity, created!); diff --git a/packages/fetchium/src/__tests__/event-persistence.test.ts b/packages/fetchium/src/__tests__/event-persistence.test.ts index 824775a..eafeeaf 100644 --- a/packages/fetchium/src/__tests__/event-persistence.test.ts +++ b/packages/fetchium/src/__tests__/event-persistence.test.ts @@ -8,7 +8,7 @@ import { RESTQueryAdapter } from '../rest/RESTQueryAdapter.js'; import { fetchQuery } from '../query.js'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; -import { AsyncQueryStore, type AsyncPersistentStore } from '../stores/async.js'; +import { AsyncQueryStore, type AsyncPersistentStore, type StoreMessage } from '../stores/async.js'; import { valueKeyFor, refCountKeyFor, refIdsKeyFor, queueKeyFor, updatedAtKeyFor } from '../stores/shared.js'; import { createMockFetch, setupTestClient, sleep } from './utils.js'; @@ -1214,7 +1214,7 @@ describe('AsyncQueryStore writer', () => { expect(asyncDoc(delegate, K('User', 'u404'))).toBeUndefined(); }); - it('does not write an event for an entity it cannot yet say it holds, and refreshes it once it can', async () => { + it('refreshes a record it holds from an event that lands before it has read its keys', async () => { const delegate = new AsyncDelegate(1); const mockFetch = createMockFetch(); mockFetch.get('/user/u1', { @@ -1246,28 +1246,96 @@ describe('AsyncQueryStore writer', () => { await holdQuery(client, () => fetchQuery(GetUser, { id: 'u2' })); expect(store.hasEntity!(K('User', 'u1'))).toBeUndefined(); expect(store.hasEntity!(K('User', 'u2'))).toBe(true); - const event = () => ({ - type: 'update' as const, - typename: 'User', - data: { __typename: 'User', id: 'u1', name: 'Alicia', karma: 10 }, - }); - client.applyMutationEvent(event()); + const update = (data: Record) => + client.applyMutationEvent({ type: 'update', typename: 'User', data: { __typename: 'User', karma: 10, ...data } }); + // Not written: the badge it creates would be, whether or not a record of u1 exists. + update({ id: 'u1', name: 'Al', badge: { __typename: 'Badge', id: 'b2', label: 'new' } }); + update({ id: 'u1', name: 'Alicia' }); + update({ id: 'u404', name: 'Nobody' }); expect(client.entityMap.getEntity(K('User', 'u1'))).toBeUndefined(); releaseScan(); await scanned(store); await drain(store); - // Not written: nothing referenced it and the store could not say it held it. - expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ name: 'Alice' }); - expect(store.hasEntity!(K('User', 'u1'))).toBe(true); - client.applyMutationEvent(event()); - await drain(store); expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ name: 'Alicia', karma: 10, badge: { __entityRef: K('Badge', 'b1') }, }); expect(asyncDoc(delegate, K('Badge', 'b1'))).toMatchObject({ label: 'gold' }); + expect(asyncDoc(delegate, K('User', 'u404'))).toBeUndefined(); + expect(asyncDoc(delegate, K('Badge', 'b2'))).toBeUndefined(); + expect(store.hasEntity!(K('User', 'u404'))).toBe(false); + }); + + it('a reader sends an event for an entity on disk but not in memory to the writer as a merge', async () => { + const delegate = new AsyncDelegate(); + let toWriter: (msg: StoreMessage) => void = () => {}; + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: handle => ((toWriter = handle), { sendMessage: () => {} }), + }); + const reader = new AsyncQueryStore({ isWriter: false, connect: () => ({ sendMessage: msg => toWriter(msg) }) }); + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { + user: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 10, + badge: { __typename: 'Badge', id: 'b1', label: 'gold' }, + }, + }); + const client = makeClient(reader, mockFetch); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + await drain(writer); + client.entityMap.getEntity(K('User', 'u1'))!.evict(); + + const update = (id: string, name: string) => + client.applyMutationEvent({ type: 'update', typename: 'User', data: { __typename: 'User', id, name, karma: 3 } }); + update('u1', 'Alicia'); + update('u404', 'Nobody'); + await drain(writer); + + expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ + name: 'Alicia', + karma: 3, + badge: { __entityRef: K('Badge', 'b1') }, + }); + expect(asyncDoc(delegate, K('User', 'u404'))).toBeUndefined(); + }); + + it("a reader's write of an entity created under one in memory is written whole, so the reference resolves", async () => { + const delegate = new AsyncDelegate(); + let toWriter: (msg: StoreMessage) => void = () => {}; + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: handle => ((toWriter = handle), { sendMessage: () => {} }), + }); + const reader = new AsyncQueryStore({ isWriter: false, connect: () => ({ sendMessage: msg => toWriter(msg) }) }); + const mockFetch = createMockFetch(); + mockFetch.get('/user/u1', { user: { __typename: 'User', id: 'u1', name: 'Alice', karma: 10 } }); + const client = makeClient(reader, mockFetch); + await holdQuery(client, () => fetchQuery(GetUser, { id: 'u1' })); + await drain(writer); + + client.applyMutationEvent({ + type: 'update', + typename: 'User', + data: { + __typename: 'User', + id: 'u1', + name: 'Alice', + karma: 10, + badge: { __typename: 'Badge', id: 'b9', label: 'x' }, + }, + }); + await drain(writer); + + expect(asyncDoc(delegate, K('User', 'u1'))).toMatchObject({ badge: { __entityRef: K('Badge', 'b9') } }); + expect(asyncDoc(delegate, K('Badge', 'b9'))).toEqual({ __typename: 'Badge', id: 'b9', label: 'x' }); }); it('ignores what it does not understand on the channel and keeps processing', async () => { diff --git a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts index bbd14a3..ce9c86f 100644 --- a/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts +++ b/packages/fetchium/src/__tests__/skip-write-and-snapshot-fixes.test.ts @@ -687,11 +687,16 @@ describe('write skipping stays consistent with the store', () => { class AsyncDelegate implements AsyncPersistentStore { readonly kv: Record = Object.create(null); failNextSetString: string | undefined; + failHas = 0; private async tick(): Promise { await new Promise(resolve => setTimeout(resolve, 0)); } async has(key: string) { await this.tick(); + if (this.failHas > 0) { + this.failHas--; + throw new Error('injected failure'); + } return key in this.kv; } async getString(key: string) { @@ -862,6 +867,112 @@ describe('AsyncQueryStore writer used in-process', () => { } }); + it('a rejected SaveEntity whose recheck also fails keeps the record held', async () => { + class Person extends Entity { + __typename = t.typename('Person'); + id = t.id; + name = t.string; + role = t.optional(t.string); + } + class GetPerson extends RESTQuery { + path = '/person'; + result = { person: t.entity(Person) }; + } + const key = hashValue(['Person', '1']); + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const mockFetch = createMockFetch(); + mockFetch.get('/person', { person: { __typename: 'Person', id: '1', name: 'Alice', role: 'admin' } }); + const { client } = makeClient(store, mockFetch); + clients.push(client); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const update = (name: string) => + client.applyMutationEvent({ type: 'update', typename: 'Person', data: { __typename: 'Person', id: '1', name } }); + try { + await holdQuery(client, () => fetchQuery(GetPerson)); + await drain(store); + client.entityMap.getEntity(key)!.evict(); + + delegate.failNextSetString = valueKeyFor(key); + delegate.failHas = 3; + update('Bob'); + await drain(store); + expect(store.hasEntity!(key)).toBe(true); + + update('Carol'); + await drain(store); + expect(JSON.parse(delegate.kv[valueKeyFor(key)] as string)).toMatchObject({ name: 'Carol', role: 'admin' }); + } finally { + consoleError.mockRestore(); + } + }); + + it('an event refresh of a record the writer wrongly holds writes nothing, and the entity is rewritten whole', async () => { + class Task extends Entity { + __typename = t.typename('Task'); + id = t.id; + boardId = t.string; + title = t.string; + done = t.optional(t.boolean); + } + class Board extends Entity { + __typename = t.typename('Board'); + id = t.id; + edits = t.liveValue(t.number, Task, { + constraints: { boardId: (this as any).id }, + onCreate: (n: number) => n, + onUpdate: (n: number) => n + 1, + onDelete: (n: number) => n, + }); + } + class GetBoard extends RESTQuery { + path = '/board'; + result = { board: t.entity(Board) }; + } + class GetTask extends RESTQuery { + path = '/task'; + result = { task: t.entity(Task) }; + } + const key = hashValue(['Task', 't1']); + const delegate = new AsyncDelegate(); + const store = writerStore(delegate); + const deleted: number[] = []; + store.onDelete!(k => deleted.push(k)); + const mockFetch = createMockFetch(); + mockFetch.get('/board', { board: { __typename: 'Board', id: 'b1', edits: 0 } }); + mockFetch.get('/task', { task: { __typename: 'Task', id: 't1', boardId: 'b1', title: 'A', done: false } }); + const { client } = makeClient(store, mockFetch); + clients.push(client); + const update = (title: string) => + client.applyMutationEvent({ + type: 'update', + typename: 'Task', + data: { __typename: 'Task', id: 't1', boardId: 'b1', title }, + }); + + await holdQuery(client, () => fetchQuery(GetBoard)); + await holdQuery(client, () => fetchQuery(GetTask)); + await drain(store); + // The record is lost behind the writer's back, so it still counts it held. + delete delegate.kv[valueKeyFor(key)]; + client.entityMap.getEntity(key)!.evict(); + + update('B'); + await drain(store); + expect(delegate.kv[valueKeyFor(key)]).toBeUndefined(); + expect(deleted).toContain(key); + expect(store.hasEntity!(key)).toBe(false); + + update('C'); + await drain(store); + expect(JSON.parse(delegate.kv[valueKeyFor(key)] as string)).toEqual({ + __typename: 'Task', + id: 't1', + boardId: 'b1', + title: 'C', + }); + }); + it('resizes a persisted LRU queue in both directions and defaults a missing maxCount', async () => { const delegate = new AsyncDelegate(); const store = writerStore(delegate); diff --git a/packages/fetchium/src/__tests__/store-on-delete.test.ts b/packages/fetchium/src/__tests__/store-on-delete.test.ts index fad9cf7..a6c2d6c 100644 --- a/packages/fetchium/src/__tests__/store-on-delete.test.ts +++ b/packages/fetchium/src/__tests__/store-on-delete.test.ts @@ -141,6 +141,94 @@ describe('QueryStore.onDelete', () => { await sleep(20); expect(deleted).toEqual([q1]); }); + + it('SyncQueryStore finishes a cascade and notifies every listener when one throws', () => { + const kv = new MemoryPersistentStore(); + const store = new SyncQueryStore(kv); + const deleted: number[] = []; + store.onDelete(() => { + throw new Error('listener failed'); + }); + store.onDelete(key => deleted.push(key)); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const q1 = hashValue(['GET:/t', { id: '1' }]); + const e1 = hashValue(['User', 1]); + store.saveEntity(e1, { id: 1 }); + store.saveQuery(queryDef('GET:/t', 10), q1, { __entityRef: e1 }, Date.now(), new Set([e1])); + store.deleteQuery(q1); + expect(deleted).toEqual([q1, e1]); + expect(kv.has(valueKeyFor(e1))).toBe(false); + expect(consoleError).toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }); + + it('AsyncQueryStore writer finishes a cascade when a listener throws', async () => { + const delegate = new MockAsyncPersistentStore(); + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), + }); + const deleted: number[] = []; + writer.onDelete!(() => { + throw new Error('listener failed'); + }); + writer.onDelete!(key => deleted.push(key)); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const q1 = hashValue(['GET:/w', { id: '1' }]); + const e1 = hashValue(['User', 1]); + writer.saveEntity(e1, { id: 1 }); + writer.saveQuery(queryDef('GET:/w', 10), q1, { __entityRef: e1 }, Date.now(), new Set([e1])); + writer.deleteQuery(q1); + await sleep(20); + expect(deleted).toEqual([q1, e1]); + expect(await delegate.has(valueKeyFor(e1))).toBe(false); + } finally { + consoleError.mockRestore(); + } + }); + + it('AsyncQueryStore writer keeps processing when a listener throws over a failed write', async () => { + class FlakyStore extends MockAsyncPersistentStore { + failNext: string | undefined; + override async setString(key: string, value: string) { + if (key === this.failNext) { + this.failNext = undefined; + throw new Error('injected failure'); + } + return super.setString(key, value); + } + } + const delegate = new FlakyStore(); + const writer = new AsyncQueryStore({ + isWriter: true, + delegate, + connect: (_handle: (msg: StoreMessage) => void) => ({ sendMessage: () => {} }), + }); + const deleted: number[] = []; + writer.onDelete!(() => { + throw new Error('listener failed'); + }); + writer.onDelete!(key => deleted.push(key)); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const e1 = hashValue(['User', 1]); + const e2 = hashValue(['User', 2]); + delegate.failNext = valueKeyFor(e1); + writer.saveEntity(e1, { id: 1 }); + writer.saveEntity(e2, { id: 2 }); + await sleep(20); + expect(deleted).toEqual([e1]); + expect(await delegate.has(valueKeyFor(e2))).toBe(true); + expect(writer.isSettled()).toBe(true); + } finally { + consoleError.mockRestore(); + } + }); }); describe('stores without onDelete', () => { diff --git a/packages/fetchium/src/query-types.ts b/packages/fetchium/src/query-types.ts index c0756d2..5b70c45 100644 --- a/packages/fetchium/src/query-types.ts +++ b/packages/fetchium/src/query-types.ts @@ -103,8 +103,10 @@ export interface QueryStore { * Writes the fields streamed events supplied, merged over the stored record * if one exists (references derived from the merged value). Without it the * client calls `saveEntity`, dropping fields the events did not carry. + * With `ifStored`, nothing is written when no record is stored, and a store + * with `onDelete` reports the key as dropped. */ - mergeEntity?(entityKey: number, fields: unknown, refIds?: Set): void; + mergeEntity?(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void; activateQuery(queryDef: QueryDefinition, storageKey: number): void; @@ -141,8 +143,8 @@ export interface QueryStore { /** * Whether the store holds a record for this key, or `undefined` if it can't - * tell yet. An event for an entity not in memory is written only when this - * returns `true`. + * tell yet. An event for an entity not in memory is written as an `ifStored` + * merge unless this returns `false`. Without `mergeEntity`, only when `true`. */ hasEntity?(key: number): boolean | undefined; } diff --git a/packages/fetchium/src/stores/async.ts b/packages/fetchium/src/stores/async.ts index c512f4d..2c6518c 100644 --- a/packages/fetchium/src/stores/async.ts +++ b/packages/fetchium/src/stores/async.ts @@ -7,6 +7,7 @@ import { LAST_USED_PREFIX, lastUsedKeyFor, mergeStoredRecord, + notifyListeners, queueKeyFor, refCountKeyFor, refIdsKeyFor, @@ -62,6 +63,8 @@ export type StoreMessage = refIds?: number[]; /** `value` holds only some fields, merged over the stored record. */ merge?: boolean; + /** With `merge`: dropped, and reported deleted, if no record is stored. */ + ifStored?: boolean; } | { type: StoreMessageType.ActivateQuery; queryDefId: string; queryKey: number; cacheTime: number; maxCount?: number } | { type: StoreMessageType.DeleteQuery; queryKey: number }; @@ -96,7 +99,6 @@ function isStoreMessage(msg: unknown): msg is StoreMessage { ); } -/** Callers notify over a copy, since a listener may unsubscribe while being notified. */ function subscribe(listeners: Array<(key: number) => void>, listener: (key: number) => void): () => void { listeners.push(listener); return () => { @@ -215,16 +217,22 @@ export class AsyncQueryStore implements QueryStore { } } - /** The previous record may have survived a failed write, so ask the delegate. */ + /** The previous record may have survived a failed write. Held unless the delegate says otherwise. */ private async recheckHeld(id: number): Promise { - let held = false; - try { - held = await this.delegate!.has(valueKeyFor(id)); - } catch { - // Treat as missing. + for (let attempt = 0; ; attempt++) { + try { + if (await this.delegate!.has(valueKeyFor(id))) this.noteHeld(id); + else this.noteDropped(id); + return; + } catch (error) { + if (attempt >= 2) { + console.error('Could not check whether the store holds a record:', error); + this.noteHeld(id); + return; + } + await new Promise(resolve => setTimeout(resolve, 50 << attempt)); + } } - if (held) this.noteHeld(id); - else this.noteDropped(id); } private startQueueProcessor(): void { @@ -260,7 +268,7 @@ export class AsyncQueryStore implements QueryStore { // Report as dropped so the client rewrites it on its next apply. if (!(msg instanceof InternalWork) && msg.type === StoreMessageType.SaveEntity) { await this.recheckHeld(msg.entityKey); - for (const listener of this.deleteListeners.slice()) listener(msg.entityKey); + notifyListeners(this.deleteListeners, msg.entityKey); } } finally { this.processing = false; @@ -313,8 +321,11 @@ export class AsyncQueryStore implements QueryStore { ); break; case StoreMessageType.SaveEntity: - await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true); - for (const listener of this.persistedListeners.slice()) listener(msg.entityKey); + if ( + await this.writerSaveEntity(msg.entityKey, msg.value, msg.refIds, msg.merge === true, msg.ifStored === true) + ) { + notifyListeners(this.persistedListeners, msg.entityKey); + } break; case StoreMessageType.ActivateQuery: await this.writerActivateQuery(msg.queryDefId, msg.queryKey, msg.cacheTime, msg.maxCount); @@ -417,13 +428,14 @@ export class AsyncQueryStore implements QueryStore { }); } - mergeEntity(entityKey: number, fields: unknown, refIds?: Set): void { + mergeEntity(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void { this.dispatch({ type: StoreMessageType.SaveEntity, entityKey, value: fields, refIds: refIds ? Array.from(refIds) : undefined, merge: true, + ifStored, }); } @@ -469,16 +481,23 @@ export class AsyncQueryStore implements QueryStore { value: unknown, refIds: number[] | undefined, merge: boolean, - ): Promise { + ifStored: boolean, + ): Promise { if (merge) { const stored = await this.delegate!.getString(valueKeyFor(entityKey)); + if (stored === undefined && ifStored) { + this.noteDropped(entityKey); + notifyListeners(this.deleteListeners, entityKey); + return false; + } const merged = stored !== undefined ? mergeStoredRecord(stored, value) : undefined; if (merged !== undefined) { await this.setValue(entityKey, merged.value, merged.refIds); - return; + return true; } } await this.setValue(entityKey, value, refIds ? new Set(refIds) : undefined); + return true; } private async writerActivateQuery( @@ -640,7 +659,7 @@ export class AsyncQueryStore implements QueryStore { await delegate.delete(valueKeyFor(id)); this.noteDropped(id); await delegate.delete(refCountKeyFor(id)); - for (const listener of this.deleteListeners.slice()) listener(id); + notifyListeners(this.deleteListeners, id); const refIds = await delegate.getBuffer(refIdsKey); await delegate.delete(refIdsKey); // Clean up the refIds key diff --git a/packages/fetchium/src/stores/shared.ts b/packages/fetchium/src/stores/shared.ts index 0fff5c8..93f6fcf 100644 --- a/packages/fetchium/src/stores/shared.ts +++ b/packages/fetchium/src/stores/shared.ts @@ -51,3 +51,14 @@ function collectEntityRefs(value: unknown, into: Set): void { } for (const key in value) collectEntityRefs((value as Record)[key], into); } + +/** Notifies over a copy, since a listener may unsubscribe while being notified. */ +export function notifyListeners(listeners: Array<(key: number) => void>, key: number): void { + for (const listener of listeners.slice()) { + try { + listener(key); + } catch (error) { + console.error('A store listener threw:', error); + } + } +} diff --git a/packages/fetchium/src/stores/sync.ts b/packages/fetchium/src/stores/sync.ts index 801286f..f2df858 100644 --- a/packages/fetchium/src/stores/sync.ts +++ b/packages/fetchium/src/stores/sync.ts @@ -12,6 +12,7 @@ import { updatedAtKeyFor, valueKeyFor, mergeStoredRecord, + notifyListeners, } from './shared.js'; export interface SyncPersistentStore { @@ -319,8 +320,7 @@ export class SyncQueryStore implements QueryStore { kv.delete(valueKeyFor(id)); kv.delete(refCountKeyFor(id)); - // A listener may unsubscribe while being notified. - for (const listener of this.deleteListeners.slice()) listener(id); + notifyListeners(this.deleteListeners, id); const refIds = kv.getBuffer(refIdsKeyFor(id)); kv.delete(refIdsKeyFor(id)); // Clean up the refIds key From dd6007946be200a5f44472d7482f15daa3f6dbc9 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 15:11:13 -0400 Subject: [PATCH 102/109] fix(fetchium): keep a newer abort's flag when a replaced fetch lands late Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 5 +- .../src/__tests__/activation-timing.test.ts | 65 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 8eeb50a..fbc212c 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -624,6 +624,7 @@ export class QueryInstance { const signal = this._abortController?.signal ?? new AbortController().signal; const attempt = this.attemptStatusTracker(ctx); const storageKey = this.storageKey; + const fetchStart = this.fetchStarts; const destroyCount = this.destroyCount; try { @@ -654,8 +655,8 @@ export class QueryInstance { ); // An aborted fetch may have been replaced by one that failed. if (!signal.aborted) this.lastFetchFailed = false; - // An adapter that ignored the deactivation's abort delivered anyway. - this.abortedByDeactivation = false; + // Not if a newer fetch's abort set it. + if (this.fetchStarts === fetchStart) this.abortedByDeactivation = false; return result; } catch (error) { if (!signal.aborted) this.lastFetchFailed = true; diff --git a/packages/fetchium/src/__tests__/activation-timing.test.ts b/packages/fetchium/src/__tests__/activation-timing.test.ts index 28102c4..997a10e 100644 --- a/packages/fetchium/src/__tests__/activation-timing.test.ts +++ b/packages/fetchium/src/__tests__/activation-timing.test.ts @@ -952,6 +952,71 @@ describe('reactivationStaggerMs and refetches a pause aborted', () => { }); describe('Responses that arrive after the client or the params moved on', () => { + it('a replaced fetch that lands late does not clear the newer abort, so the next mount retries', async () => { + class GetForever extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: Infinity }; + } + // Request 2 ignores its abort. + const calls: string[] = []; + const fetch = (_url: string, options: RequestInit = {}): Promise => { + const n = calls.push(`#${calls.length + 1}`); + const signal = options.signal as AbortSignal | undefined; + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + const body = { value: `v${n}` }; + resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response); + }, + n === 2 ? 60 : n === 3 ? 30 : 5, + ); + if (n === 2) return; + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }, + { once: true }, + ); + }); + }; + const client = makeClient(new MemoryPersistentStore(), fetch); + let relay: any; + const mount = () => activate(client, () => (relay = fetchQuery(GetForever)).value); + + let dispose = mount(); + await sleep(20); + void relay.value.__refetch().catch(() => {}); + await sleep(5); + dispose(); + await sleep(5); + + dispose = mount(); + await sleep(5); + dispose(); + await sleep(5); + expect(calls).toHaveLength(3); + + await sleep(80); + dispose = mount(); + await sleep(20); + expect(calls).toHaveLength(4); + expect(relay.isRejected).toBe(false); + dispose(); + client.destroy(); + }); + it('writes nothing to the store after destroy(), including a same-task unmount kept first fetch', async () => { const kv = new MemoryPersistentStore(); const f = createFetch(20); From efd435a03d2a3d83f84ca9668962dfce9ff53396 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 18:37:27 -0400 Subject: [PATCH 103/109] fix(fetchium): cancel a queued debounce when starting a fetch immediately Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 2 ++ .../src/__tests__/sync-hydration.test.ts | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index eacbd85..cb7e4ac 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -415,6 +415,8 @@ export class QueryInstance { } private runQueryImmediately(): void { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); diff --git a/packages/fetchium/src/__tests__/sync-hydration.test.ts b/packages/fetchium/src/__tests__/sync-hydration.test.ts index 449f669..93c797c 100644 --- a/packages/fetchium/src/__tests__/sync-hydration.test.ts +++ b/packages/fetchium/src/__tests__/sync-hydration.test.ts @@ -262,6 +262,36 @@ describe('Invalidation before the first fetch', () => { }); } + it('fetches once when a stale cached query is invalidated right after synchronous hydration', async () => { + class GetItem extends RESTQuery { + path = '/item'; + result = { value: t.string }; + config = { staleTime: 0 }; + } + + const kv = new MemoryPersistentStore(); + await seedCache(kv, GetItem, { value: 'cached' }); + + const mockFetch = createMockFetch(); + mockFetch.get('/item', { value: 'fresh' }, { delay: 20 }); + const client = makeClient(new SyncQueryStore(kv), mockFetch); + + let relay: ReturnType> | undefined; + const { dispose } = activate(client, () => (relay = fetchQuery(GetItem)).isPending); + client.invalidateQueries([GetItem]); + + await flushMicrotasks(); + expect(relay!.isPending).toBe(true); + await relay; + await sleep(30); + expect(mockFetch.calls).toHaveLength(1); + expect(mockFetch.calls[0].options.signal!.aborted).toBe(false); + expect(relay!.value!.value).toBe('fresh'); + + dispose(); + client.destroy(); + }); + it('refetches a cached query invalidated before its cache loads', async () => { const kv = new MemoryPersistentStore(); await seedCache(kv, GetForever, { value: 'cached' }); From 27ea7d8e7d965f78e16b7e63800aecc922276d66 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 18:38:45 -0400 Subject: [PATCH 104/109] fix(fetchium): cancel any queued debounced run when starting a fetch immediately Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 21de0f6..113b109 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -535,8 +535,7 @@ export class QueryInstance { private runQueryImmediately(): void { this.fetchStarts++; - clearTimeout(this.debounceTimer); - this.debounceTimer = undefined; + this.cancelDebounced(); this._abortController?.abort(); this._abortController = new AbortController(); this._fetchNextAbort?.abort(); From a0f62081cd3548bcd910c70956acf31dbf6f5403 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 18:42:06 -0400 Subject: [PATCH 105/109] fix(fetchium): pass a mutation response's status to shouldRetry Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/MutationResult.ts | 13 ++++++++++--- .../src/__tests__/retry-should-retry.test.ts | 18 ++++++++++++++++++ packages/fetchium/src/rest/RESTMutation.ts | 2 ++ packages/fetchium/src/rest/RESTQueryAdapter.ts | 2 ++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/fetchium/src/MutationResult.ts b/packages/fetchium/src/MutationResult.ts index 3410413..78041fa 100644 --- a/packages/fetchium/src/MutationResult.ts +++ b/packages/fetchium/src/MutationResult.ts @@ -2,12 +2,12 @@ import { task, type ReactiveTask } from 'signalium'; import { ComplexTypeDef, MutationEffects, EntityClassOrTypename } from './types.js'; import { ValidatorDef, getEntityDef } from './typeDefs.js'; import { type QueryClient } from './QueryClient.js'; -import { MutationDefinition } from './mutation.js'; +import { MutationDefinition, type Mutation } from './mutation.js'; import { resolveRetryConfig } from './query.js'; import { parseEntities, ParseContext } from './parseEntities.js'; import { createExecutionContext, reifyValue } from './fieldRef.js'; import { Entity } from './proxy.js'; -import { withRetry } from './retry.js'; +import { getFailedResponseStatus, withRetry } from './retry.js'; /** * Internal mutation manager. Consumers interact with the public `task` property, @@ -112,20 +112,27 @@ export class MutationResultImpl { ); } + let attemptCtx: Mutation | undefined; return withRetry( async () => { + attemptCtx = undefined; const abortController = new AbortController(); const ctx = createExecutionContext( this.def.captured, (request ?? {}) as Record, this.queryClient.getContext(), ); + attemptCtx = ctx; return (await adapter.sendMutation!(ctx, abortController.signal)) as Result; }, retryConfig, undefined, - { shouldRetry: this.queryClient.shouldRetry }, + { + shouldRetry: this.queryClient.shouldRetry, + // Each attempt has a fresh ctx, so a response here is this attempt's. + getAttemptStatus: () => getFailedResponseStatus((attemptCtx as { response?: unknown } | undefined)?.response), + }, ); } } diff --git a/packages/fetchium/src/__tests__/retry-should-retry.test.ts b/packages/fetchium/src/__tests__/retry-should-retry.test.ts index 35bdb3d..40e1432 100644 --- a/packages/fetchium/src/__tests__/retry-should-retry.test.ts +++ b/packages/fetchium/src/__tests__/retry-should-retry.test.ts @@ -327,6 +327,24 @@ describe('shouldRetry', () => { expect(mockFetch.calls).toHaveLength(1); }); + + it('passes the status of a mutation 4xx whose body is not JSON', async () => { + const statuses: Array = []; + client.destroy(); + client = createClient((error, attempt, status) => { + statuses.push(status); + return skipClientErrors(error, attempt, status); + }); + mockFetch.post('/users', null, { status: 422, jsonError: new SyntaxError('Unexpected token <') }); + + await testWithClient(client, async () => { + const mut = getMutation(CreateUser); + await expect(mut.run({ name: 'Test' })).rejects.toBeInstanceOf(SyntaxError); + }); + + expect(mockFetch.calls).toHaveLength(1); + expect(statuses).toEqual([422]); + }); }); }); diff --git a/packages/fetchium/src/rest/RESTMutation.ts b/packages/fetchium/src/rest/RESTMutation.ts index a1e2e1a..38d0228 100644 --- a/packages/fetchium/src/rest/RESTMutation.ts +++ b/packages/fetchium/src/rest/RESTMutation.ts @@ -12,6 +12,8 @@ export abstract class RESTMutation extends Mutation { headers?: HeadersInit; requestOptions?: QueryRequestOptions; + declare response: Response | undefined; + getIdentityKey(): string { return `${this.method ?? 'POST'}:${this.path ?? ''}`; } diff --git a/packages/fetchium/src/rest/RESTQueryAdapter.ts b/packages/fetchium/src/rest/RESTQueryAdapter.ts index c41901c..bba1acd 100644 --- a/packages/fetchium/src/rest/RESTQueryAdapter.ts +++ b/packages/fetchium/src/rest/RESTQueryAdapter.ts @@ -216,6 +216,8 @@ export class RESTQueryAdapter extends QueryAdapter { ...fetchOptions, }); + restCtx.response = fetchResponse as unknown as Response; + return fetchResponse.json(); } } From 04a46095b17412dc9b1f02a56a840b0c35550855 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 18:43:25 -0400 Subject: [PATCH 106/109] fix(fetchium): refetch changed params and pending invalidations on reactivation Skip the reactivation grace when a Signal param changed while inactive, and let a debounced fetch (e.g. invalidateQueries()) scheduled before the stagger flush drop the query's stagger slot instead of being replaced by it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryResult.ts | 3 + .../src/__tests__/reactivation-grace.test.ts | 57 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 65f40ce..a04066c 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -204,6 +204,7 @@ export class QueryInstance { const withinGrace = activating && !wasPaused && + !paramsDidChange && this.queryClient.networkManager.reconnects === this.reconnectsAtDeactivate && this.isWithinReactivationGrace; if (refreshStaleOnReconnect && this.isStale && !withinGrace) { @@ -459,6 +460,8 @@ export class QueryInstance { if (this.relayState.isPending) return; const debounce = this.config?.debounce ?? 0; + // Drops a queued reactivation refetch, which would replace this timer. + this.reactivationQueuedAt = -1; clearTimeout(this.debounceTimer); diff --git a/packages/fetchium/src/__tests__/reactivation-grace.test.ts b/packages/fetchium/src/__tests__/reactivation-grace.test.ts index 244bb7f..7981e91 100644 --- a/packages/fetchium/src/__tests__/reactivation-grace.test.ts +++ b/packages/fetchium/src/__tests__/reactivation-grace.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { withContexts } from 'signalium'; +import { signal, withContexts } from 'signalium'; import { RESTQuery, RESTQueryAdapter } from '../rest/index.js'; import { fetchQuery } from '../query.js'; import { t } from '../typeDefs.js'; @@ -516,6 +516,39 @@ describe('reactivation grace and stagger', () => { await vi.advanceTimersByTimeAsync(50); expect(fetchCount('/a')).toBe(2); }); + + it('does not apply when a Signal param changed while the query was inactive', async () => { + class GetUser extends RESTQuery { + params = { id: t.id }; + path = `/users/${this.params.id}`; + result = { name: t.string }; + } + mockFetch.get('/users/1', { name: 'User 1' }); + mockFetch.get('/users/2', { name: 'User 2' }); + const client = makeClient({ reactivationGraceMs: 15_000 }); + const id = signal('1'); + + let name: string | undefined; + const visit = () => { + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => (name = fetchQuery(GetUser, { id }).value?.name)), + ); + unsubs.push(unsub); + return unsub; + }; + + const unsub = visit(); + await vi.advanceTimersByTimeAsync(50); + expect(name).toBe('User 1'); + unsub(); + await vi.advanceTimersByTimeAsync(10); + + id.value = '2'; + visit(); + await vi.advanceTimersByTimeAsync(50); + expect(fetchCount('/users/2')).toBe(1); + expect(name).toBe('User 2'); + }); }); describe('reactivationStaggerMs', () => { @@ -743,6 +776,28 @@ describe('reactivation grace and stagger', () => { expect(fetchCount('/c')).toBe(1); }); + it('does not delay an invalidation made before the stagger queue flushes', async () => { + const client = makeClient({ reactivationStaggerMs: 3_000 }); + await loadThenDeactivate(client, GetA, GetB); + starts.length = 0; + + let invalidated = false; + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => { + // A microtask lands after reactivation queues both refetches, before the queue flushes. + if (!invalidated) queueMicrotask(() => client.invalidateQueries([GetB])); + invalidated = true; + return [fetchQuery(GetA).value, fetchQuery(GetB).value]; + }), + ); + unsubs.push(unsub); + await vi.advanceTimersByTimeAsync(50); + expect(starts.map(s => s.path).sort()).toEqual(['/a', '/b']); + + await vi.advanceTimersByTimeAsync(3_000); + expect(fetchCount('/b')).toBe(1); + }); + it('does not delay an initial fetch', async () => { const client = makeClient({ reactivationStaggerMs: 300 }); watch(client, GetA, GetB, GetC); From c1fef155585abcd7a78bceb052f2debc21beab65 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 19:53:42 -0400 Subject: [PATCH 107/109] perf(fetchium): warm V8's enum cache for an entity shape at registration Calling Object.keys(def.shape) once makes later iterations over the shape faster on V8. In-memory event benches run 7-30% faster. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/fetchium/src/QueryClient.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 0dd0ab0..4735402 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -336,6 +336,8 @@ export class QueryClient { } else { this.typenameRegistry.set(typename, [def]); } + // Builds V8's enum cache for the shape, which speeds up later iterations over it. + Object.keys(def.shape as object); } getEntityDefsForTypename(typename: string): ValidatorDef[] | undefined { From 3ecb2ffd9ff7ba29aeac1f9b3ba9deaa311f1f81 Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 19:59:03 -0400 Subject: [PATCH 108/109] docs(fetchium): make the perf stack's docs and changesets concise Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 8 +- .changeset/async-store-honors-max-count.md | 2 +- .../config-context-and-suspense-holds.md | 6 +- .changeset/entity-snapshot-fast-path.md | 4 +- .changeset/fast-delivery-prefetch-retain.md | 9 +- .changeset/nested-value-reactivity.md | 2 +- .changeset/quiet-reactivation.md | 10 +- .changeset/remove-redundant-entity-saves.md | 4 +- .changeset/should-retry-hook.md | 4 +- .changeset/skip-unchanged-entity-merges.md | 5 +- .changeset/skip-write-and-snapshot-fixes.md | 8 +- .changeset/snapshot-union-array-members.md | 2 +- .changeset/store-reports-deletions.md | 6 +- .changeset/sync-hydrate-first-fetch.md | 5 +- .changeset/sync-store-merge-without-read.md | 2 +- .changeset/upgrade-behavior-changes.md | 27 +++-- docs/src/app/api/fetchium-react/page.md | 14 +-- docs/src/app/api/fetchium/page.md | 100 +++++++++--------- docs/src/app/api/stores-async/page.md | 31 +++--- docs/src/app/api/stores-sync/page.md | 22 ++-- docs/src/app/core/entities/page.md | 2 +- docs/src/app/core/streaming/page.md | 14 +-- docs/src/app/data/caching/page.md | 2 +- docs/src/app/guides/error-handling/page.md | 9 +- docs/src/app/setup/project-setup/page.md | 4 +- 25 files changed, 136 insertions(+), 166 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 3d344f1..1ff0079 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -2,8 +2,8 @@ 'fetchium': patch --- -Fixed spurious errors and wrong data when a query mounts, unmounts or changes params quickly. +Fixed errors and wrong data when a query mounts, unmounts or changes params quickly. -- Unmounting and remounting a query, in the same task or mid-fetch (switching a chart timeframe and back), no longer shows an `AbortError`. The query shows its cached data, or stays pending, while it refetches. One exception: a topic query with no data that mounts and unmounts in the same task shows the `AbortError` on its next mount until its data arrives (before, it stayed pending forever). -- Changing a Signal param mid-fetch fetches the new params instead of showing the old params' response. A query whose result is not an entity now caches each params value separately; a cold start could show another params value's data. -- `QueryClient.destroy()` aborts every fetch in flight, and code awaiting one gets an `AbortError`. +- Remounting a query no longer shows an `AbortError`. It shows its cached data, or stays pending, while it refetches. +- Changing a Signal param mid-fetch fetches the new params. Each params value of a non-entity result is cached separately. +- `QueryClient.destroy()` aborts fetches in flight. Queries read afterwards reject with an `AbortError` and send no requests. diff --git a/.changeset/async-store-honors-max-count.md b/.changeset/async-store-honors-max-count.md index 9319171..500c0a3 100644 --- a/.changeset/async-store-honors-max-count.md +++ b/.changeset/async-store-honors-max-count.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -`AsyncQueryStore` now honors a query's `cache.maxCount`, as `SyncQueryStore` does. It used to always keep the default of 50 keys. +`AsyncQueryStore` now honors a query's `cache.maxCount` instead of always keeping 50 entries. diff --git a/.changeset/config-context-and-suspense-holds.md b/.changeset/config-context-and-suspense-holds.md index 947404f..f3f7302 100644 --- a/.changeset/config-context-and-suspense-holds.md +++ b/.changeset/config-context-and-suspense-holds.md @@ -2,6 +2,6 @@ 'fetchium': patch --- -`useSuspenseQuery` no longer refetches in a loop when a slow retry render takes a while to reach the component. The error goes to the error boundary, and resetting the boundary retries. - -Custom `QueryClientConfig` keys still reach queries as `this.context`, except the reserved option names `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs`. An invalid value for one of these options is ignored instead of breaking queries. +- Fixed `useSuspenseQuery` refetching in a loop when a retry render is slow. +- A query whose `getConfig()` throws now rejects with that error instead of breaking other queries. +- `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. Other keys still reach queries as `this.context`. diff --git a/.changeset/entity-snapshot-fast-path.md b/.changeset/entity-snapshot-fast-path.md index 6e25533..2fd63f9 100644 --- a/.changeset/entity-snapshot-fast-path.md +++ b/.changeset/entity-snapshot-fast-path.md @@ -2,6 +2,4 @@ 'fetchium': patch --- -Query results rebuild faster after an update: entities whose data didn't change are not read again. - -List results also keep each row's object identity when the list is inserted into or re-sorted, so memoized rows don't re-render. Development builds still re-read everything to check the result, so the speedup shows in production builds only. +Query results rebuild faster after an update in production builds, and list rows keep their identity when the list is re-sorted or inserted into. diff --git a/.changeset/fast-delivery-prefetch-retain.md b/.changeset/fast-delivery-prefetch-retain.md index 7a5a507..2f3044b 100644 --- a/.changeset/fast-delivery-prefetch-retain.md +++ b/.changeset/fast-delivery-prefetch-retain.md @@ -2,9 +2,6 @@ 'fetchium': minor --- -New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before any component reads them. A component that mounts while one is held reuses its request and renders the data on its first render if it has arrived. Both return a `release` function; `prefetch` lasts 10 seconds by default and `retain` until released. - -New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no data yet, and sends a failed first fetch to the error boundary. - -- A refetch without `debounce` starts on a microtask instead of after `setTimeout(0)`. -- Reading `.value` on a pending or failed query doesn't suspend or throw. The docs said it did. +- New `queryClient.prefetch(Query, params, { ttl })` and `queryClient.retain(fn, { ttl })` start queries before a component reads them. A component that mounts meanwhile reuses the request. +- New `useSuspenseQuery` in `fetchium/react`. It suspends only while the query has no value. +- Refetches without `debounce` start on a microtask instead of after `setTimeout(0)`. diff --git a/.changeset/nested-value-reactivity.md b/.changeset/nested-value-reactivity.md index cb09f80..0d273e5 100644 --- a/.changeset/nested-value-reactivity.md +++ b/.changeset/nested-value-reactivity.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Fixed memoized children (`component()` or `React.memo`) showing a stale value when given a nested value such as `token.price` as a prop. A nested `t.object`, `t.record` or live-collection value is now a new object when it changes, and keeps its identity when it doesn't. +Fixed memoized children not updating when passed a nested value such as `token.price`. Nested `t.object`, `t.record` and live-collection values are new objects when they change. diff --git a/.changeset/quiet-reactivation.md b/.changeset/quiet-reactivation.md index 78c626e..37eef21 100644 --- a/.changeset/quiet-reactivation.md +++ b/.changeset/quiet-reactivation.md @@ -2,9 +2,9 @@ 'fetchium': minor --- -New `QueryClient` options that make coming back to a screen, or to the app, quieter. All are off by default. +New `QueryClient` options, all off by default: -- `reactivationGraceMs` (also per query): a query that mounts again with data younger than this doesn't refetch. Data a live subscription kept current counts as fresh. -- `reactivationStaggerMs`: refetches from queries that mount together are spread across this window instead of all starting at once. -- `activity: { isActive(), subscribe(listener) }`: `poll()` stops while the app is inactive and resumes when it's active again. On React Native, wrap `AppState`. `pollResumeJitterMs` spreads out polls that were overdue on resume. -- Pass the topic to `TopicQueryAdapter.sendMutationEvent(event, topic)` so that topic's query counts the push as fresh data. +- `reactivationGraceMs` (also per query): skip the refetch when a query remounts with data younger than this. +- `reactivationStaggerMs`: spread the refetches of queries that remount together across this window. +- `activity` and `pollResumeJitterMs`: pause `poll()` while the app is inactive and spread overdue polls on resume. +- `TopicQueryAdapter.sendMutationEvent(event, topic)` counts the event as fresh data for that topic's query. diff --git a/.changeset/remove-redundant-entity-saves.md b/.changeset/remove-redundant-entity-saves.md index 5f142b2..44049c7 100644 --- a/.changeset/remove-redundant-entity-saves.md +++ b/.changeset/remove-redundant-entity-saves.md @@ -2,6 +2,4 @@ 'fetchium': patch --- -Streamed updates write each changed entity to the store once instead of two or three times, and an update that changes nothing writes nothing. - -A `create` event for an entity that no live collection shows is no longer written to the store, where it was never cleaned up. +Streamed updates write each changed entity once, and unchanged updates write nothing. diff --git a/.changeset/should-retry-hook.md b/.changeset/should-retry-hook.md index be5b0ab..c27ca03 100644 --- a/.changeset/should-retry-hook.md +++ b/.changeset/should-retry-hook.md @@ -2,6 +2,4 @@ 'fetchium': minor --- -New optional `shouldRetry(error, attempt, status)` on `QueryClientConfig` and on a query's or mutation's `retry` config. Return `false` to stop retrying an error you know is permanent, such as a 404. - -`status` is the HTTP status when it is known, and `getErrorStatus` is exported. Without the hook, retries work as before. `retry.retries` is now optional. +New `shouldRetry(error, attempt, status)` option on `QueryClientConfig` and `retry` configs. Return `false` to stop retrying, for example on a 404. `getErrorStatus` is exported and `retry.retries` is now optional. diff --git a/.changeset/skip-unchanged-entity-merges.md b/.changeset/skip-unchanged-entity-merges.md index 388d89f..6a4b1ee 100644 --- a/.changeset/skip-unchanged-entity-merges.md +++ b/.changeset/skip-unchanged-entity-merges.md @@ -2,7 +2,4 @@ 'fetchium': minor --- -A refetch, poll or streamed update that returns the same data no longer re-renders your components. `isPending` and `isFetching` still change as before. - -- Values that can't be compared reliably (a `Date`, a typed array, a class instance) always count as changed. -- `t.record(...)` fields now apply updates. Previously a record field kept its old value. +A refetch, poll or streamed update with identical data no longer re-renders components. `t.record` fields now apply updates. diff --git a/.changeset/skip-write-and-snapshot-fixes.md b/.changeset/skip-write-and-snapshot-fixes.md index 6eb00ef..e957027 100644 --- a/.changeset/skip-write-and-snapshot-fixes.md +++ b/.changeset/skip-write-and-snapshot-fixes.md @@ -2,8 +2,6 @@ 'fetchium': patch --- -Fixed stale data in components when a second query loads entities that are already in memory, and streamed updates that were lost on restart. - -- Development builds freeze query results, so mutating one (such as calling `sort()` on a list in render) throws a `TypeError`. Copy the data first, and treat production results as read-only too. -- In development, the stale-data check logs an error instead of throwing and blocking other components' updates. -- Custom stores can add the new optional `QueryStore.mergeEntity`, so an entity built from streamed updates doesn't overwrite stored fields those updates didn't include. Without it, writes work as before. +- Fixed stale data when a query loads entities already in memory, and streamed updates lost on restart. +- Development builds freeze query results. Copy data before mutating it. +- Custom stores can implement the new optional `QueryStore.mergeEntity`. diff --git a/.changeset/snapshot-union-array-members.md b/.changeset/snapshot-union-array-members.md index 1e4879a..df5530d 100644 --- a/.changeset/snapshot-union-array-members.md +++ b/.changeset/snapshot-union-array-members.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Fixed components not updating when an entity changes inside a union that has an array or record member, such as `t.union(t.array(t.entity(A)), t.object({ ... }))`. +Fixed components not updating when an entity changes inside a union with an array or record member. diff --git a/.changeset/store-reports-deletions.md b/.changeset/store-reports-deletions.md index b8b3b9d..f8a1f19 100644 --- a/.changeset/store-reports-deletions.md +++ b/.changeset/store-reports-deletions.md @@ -2,7 +2,5 @@ 'fetchium': patch --- -Fixed cached queries failing to load after a restart ("the query cache may be corrupted or invalid") when a query had more than `cache.maxCount` cached params and refetched unchanged data. - -- Custom stores: `QueryStore` has a new optional `onDelete(listener)`. Call the listener with every id the store deletes on its own. A store without it keeps writing every update, as before. -- A `t.array(t.entity(X))` field whose typename two entity classes share now shows an item as soon as it gains the fields `X` requires. +- Fixed cached queries failing to load after a restart when a query had more than `cache.maxCount` cached entries. +- Custom stores can implement the new optional `QueryStore.onDelete(listener)`. diff --git a/.changeset/sync-hydrate-first-fetch.md b/.changeset/sync-hydrate-first-fetch.md index 001b9b8..c93306c 100644 --- a/.changeset/sync-hydrate-first-fetch.md +++ b/.changeset/sync-hydrate-first-fetch.md @@ -2,7 +2,4 @@ 'fetchium': patch --- -With `SyncQueryStore`, a cached query renders its data on the first render instead of showing a loading frame first. - -- The first fetch starts on a microtask instead of after `setTimeout(0)`. Tests that wait for a loading state or count renders may need updating. -- A cached entry that can't be loaded is treated as a cache miss and fetched. Previously a fresh-looking entry could leave the query pending without fetching. +With `SyncQueryStore`, cached data shows on the first render, and the first fetch starts on a microtask. A cache entry that fails to load is refetched. diff --git a/.changeset/sync-store-merge-without-read.md b/.changeset/sync-store-merge-without-read.md index e2d1238..7ab29c0 100644 --- a/.changeset/sync-store-merge-without-read.md +++ b/.changeset/sync-store-merge-without-read.md @@ -2,4 +2,4 @@ 'fetchium': patch --- -Faster streamed updates with `SyncQueryStore` for entities that aren't in memory, which is common when two entity classes share a typename. +Faster streamed updates with `SyncQueryStore` for entities not in memory. diff --git a/.changeset/upgrade-behavior-changes.md b/.changeset/upgrade-behavior-changes.md index 626d5b9..1b18a6f 100644 --- a/.changeset/upgrade-behavior-changes.md +++ b/.changeset/upgrade-behavior-changes.md @@ -2,18 +2,17 @@ 'fetchium': major --- -Breaking changes. Most apps need no code changes, but tests that depend on loading frames, render counts or timers may. +Most apps need no changes. Tests that depend on loading frames, render counts or timers may. -- **Results are frozen in development.** Mutating query data throws a `TypeError`; copy it first (`[...data.items].sort()`). -- **Cached data shows on the first render** with `SyncQueryStore`. Update tests that wait for a loading state or count renders. -- **Fetches start sooner.** The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`; update tests that advance timers to see them. -- **Identical data doesn't re-render.** A refetch, poll or streamed update with the same data skips the render; read `isFetching` instead of counting renders. -- **Nested values change identity only when they change.** `t.object`, `t.record` and live-collection values are new objects after a change, so memoized children re-render; drop workarounds that forced it. -- **`t.record` and `t.result` fields apply updates** instead of keeping their old value. -- **Changing a Signal param mid-fetch aborts that fetch** and fetches the new params; the old response is dropped. -- **`QueryClient.destroy()` aborts fetches in flight.** Code that awaits a query during teardown gets an `AbortError`; catch it. -- **Awaiting a query can wait for a remount.** If the last component using a query unmounts before its first data arrives, `await fetchQuery(...)` waits for the next mount, `gcTime` collection or `destroy()` instead of rejecting with an `AbortError`. -- **New reserved `QueryClientConfig` names:** `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are read as options; rename custom context values that use them. -- **`retry: {}` and `retry: { retryDelay }` now retry** like `retry: true`; use `retry: false` for no retries. -- **`AsyncQueryStore` honors `cache.maxCount`.** Check the value if you set one. -- **Custom `QueryStore`s** get new optional methods (`mergeEntity`, `onDelete`, `onPersisted`, `isSettled`, `hasQueuedDeletes`, `hasEntity`); without them nothing changes. +- Query results are frozen in development. Copy data before mutating it. +- With `SyncQueryStore`, cached data shows on the first render. +- The first fetch and refetches without `debounce` start on a microtask, not after `setTimeout(0)`. +- Identical data no longer re-renders. Check `isFetching` instead of counting renders. +- Nested `t.object`, `t.record` and live-collection values are new objects when they change. +- `t.record` and `t.result` fields apply updates. +- Changing a Signal param mid-fetch aborts that fetch. +- After `QueryClient.destroy()`, queries reject with an `AbortError` and send no requests. +- If the last reader unmounts before a query's first data, `await fetchQuery(...)` waits for the next mount, collection or `destroy()`. +- `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. +- `retry: {}` and `retry: { retryDelay }` now retry like `retry: true`. +- `AsyncQueryStore` honors `cache.maxCount`. diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index 3bc22c9..c63ff0a 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -132,8 +132,8 @@ const SearchResults = component(() => { - `useQuery` is a thin wrapper around Signalium v3's `useReactive`, which is deep-by-default. It returns a **structurally-shared snapshot** of the query result, so memoized children that receive subtrees as props keep stable references when the underlying data is unchanged. - Fetchium registers a custom snapshot for entity proxies so the snapshot walks into entities (instead of returning them by reference), giving you correct re-rendering on entity field changes. -- The snapshot is a plain-object copy of the query result to prevent accidental mutation of the entity cache. Treat it as read-only: in development builds the snapshot and every nested object and array in it are frozen, so an in-place `sort()`, a `push()` or an assignment in render throws a `TypeError` at that line. Production snapshots are not frozen, and a mutation there is carried forward until the entity changes. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` if you need a mutable copy for mutations. -- `useQuery` never suspends. Reading `.value` while pending returns `undefined`. Use [`useSuspenseQuery`](#usesuspensequery) to suspend on a cold miss. +- The snapshot is a read-only copy of the query result. Development builds freeze it, so mutating it throws a `TypeError`. Sort a copy (`[...items].sort()`) or use `draft()` from `fetchium` when you need a mutable copy. +- `useQuery` never suspends: `.value` is `undefined` until the first value arrives. Use [`useSuspenseQuery`](#usesuspensequery) to suspend. --- @@ -146,13 +146,9 @@ function useSuspenseQuery( ): ReadyReactivePromise>; ``` -`useQuery` for components under a React `` boundary. It suspends only on a **cold miss**: the query has never produced a value, neither in memory nor, with a synchronous store, in the persisted cache. Anything else renders at once, including a stale value whose refetch is in flight. Refetches never suspend. Because the hook only returns once a value exists, `value` is always defined. +Like `useQuery`, but suspends while the query has no value yet. Refetches don't suspend, so `value` is always defined. A failed first fetch is thrown to the nearest error boundary, and resetting the boundary retries it. -A cold fetch that fails is thrown to the nearest error boundary. Resetting the boundary (remounting the component) tries the fetch again. - -Changing params to ones with no value yet is a cold miss, so the component suspends. Wrap the change in `startTransition` to keep showing the previous result. - -Call it from a plain function component. Like `useQuery`, it cannot run inside a Signalium `component()`, which is a reactive context. +Changing params to ones with no value yet suspends again. Wrap the change in `startTransition` to keep showing the previous result. ```tsx import { Suspense } from 'react'; @@ -167,5 +163,3 @@ function UserName({ id }: { id: number }) { ; ``` - -While suspended, the client keeps the query active, since React discards a suspended render without subscribing to anything. The component's commit takes over. diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 824ffde..8799a5b 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -225,38 +225,38 @@ Central coordinator for queries, mutations, entity storage, caching, and garbage new QueryClient(config: QueryClientConfig) ``` -| Field | Type | Default | Description | -| ----------------------- | ------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | -| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | -| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | -| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | -| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | -| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | -| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that reactivates (a watcher returns, or a paused scope resumes) with data younger than this is not refetched, even if stale. Data a subscription pushed to (a `subscribe` stream event, or a topic event sent with its topic) counts as fresh from the last push; a running `poll()` counts through the fetches it makes. Network reconnects, `__refetch()` and invalidation still refetch. `Infinity` never refetches on reactivation. Per-query override: `reactivationGraceMs` in the query config. | -| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Reactivation refetches that start in the same task are spread evenly across this window, in activation order, instead of all starting at once. A value that is not a finite positive number counts as 0. | -| `shouldRetry` | `(error, attempt, status) => boolean` | — | Optional. Whether a failed attempt is retried. Without it, every failed attempt is retried, whatever its status. `attempt` starts at 0; `status` is the attempt's HTTP status when known (the adapter's error response, or `status` / `statusCode` / `response.status` on the error). Return `false` to stop retrying errors you know are permanent, such as a 4xx. Per-query override: `retry.shouldRetry`. | -| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }` foreground source. When set, `poll()` stops its timers while inactive and resumes them on activation. A value without `isActive` and `subscribe` functions is ignored, with a warning in development. | -| `pollResumeJitterMs` | `number` | `0` | Milliseconds. A `poll()` tick that is overdue on resume (or whose timer fired over a second late, as after JS timers were frozen in the background) is rescheduled at a random point in this window instead of firing at once. A value that is not a finite positive number counts as 0. Per-poll override: `poll({ interval, resumeJitterMs })`. | - -Every config key, including keys this table does not list, also reaches query and mutation code as `this.context`, so you can pass your own services through the config. The names in the table are reserved: a value under one of them is read as that option, so do not reuse them for your own context values. +| Field | Type | Default | Description | +| ----------------------- | ------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | `QueryStore` | In-memory | Persistent storage backend. Defaults to `SyncQueryStore(MemoryPersistentStore)`. | +| `adapters` | `QueryAdapter[]` | `[]` | Transport adapters (e.g. `new RESTQueryAdapter({ fetch, baseUrl })`). | +| `log` | `LogContext \| undefined` | `console` | Logger with `error`, `warn`, `info`, `debug` methods. | +| `evictionMultiplier` | `number \| undefined` | `1` | Scales all GC times for testing. Set to `0.001` to make timers fire in milliseconds. | +| `networkManager` | `NetworkManager` | `new NetworkManager()` | Tracks network connectivity. | +| `gcManager` | `GcManager \| NoOpGcManager` | Auto-detected | GC manager. Uses `NoOpGcManager` on the server. | +| `reactivationGraceMs` | `number` | `0` | Milliseconds. A query that remounts with data younger than this doesn't refetch. Data a subscription pushed counts as fresh. Reconnects, `__refetch()` and invalidation still refetch. `Infinity` never refetches. Per query: `reactivationGraceMs`. | +| `reactivationStaggerMs` | `number` | `0` | Milliseconds. Spreads the refetches of queries that remount together across this window. | +| `shouldRetry` | `(error, attempt, status) => boolean` | — | Return `false` to stop retrying an error, such as a 404. `status` is the HTTP status when known. Per query: `retry.shouldRetry`. | +| `activity` | `ActivitySource \| undefined` | — | `{ isActive(), subscribe(listener) }`. `poll()` pauses while inactive. | +| `pollResumeJitterMs` | `number` | `0` | Milliseconds. Spreads polls that are overdue on resume across this window. Per poll: `poll({ interval, resumeJitterMs })`. | + +Other config keys reach queries and mutations as `this.context`. Don't reuse the option names above for your own values. #### Methods -| Method | Signature | Description | -| -------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | -| `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | -| `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | -| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query now and keeps it active for `ttl` ms (default `DEFAULT_PREFETCH_TTL`, 10 s). See below. | -| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until the returned `release` is called, or for `ttl` ms. See below. | -| `destroy` | `(): void` | Tears down the GC manager, network manager, and all caches, releases outstanding leases, and unsubscribes the store listeners it registered. | +| Method | Signature | Description | +| -------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `getContext` | `(): QueryContext` | Returns the `QueryContext` passed at construction. | +| `applyMutationEvent` | `(event: MutationEvent): void` | Applies an external mutation event (create/update/delete) to the entity store. | +| `invalidateQueries` | `(targets: ReadonlyArray): void` | Marks matching query instances as stale. Accepts query classes and optional param subsets for filtering. | +| `prefetch` | `(QueryClass, params?, { ttl? }?): () => void` | Starts a query and keeps it active for `ttl` ms (default 10 s). | +| `retain` | `(fn: () => unknown, { ttl? }?): () => void` | Keeps the queries `fn` reads active until `release()` is called, or for `ttl` ms. | +| `destroy` | `(): void` | Aborts fetches in flight and releases everything the client holds. Queries read afterwards reject with an `AbortError`. | #### `prefetch` and `retain` -Both take a lease: the queries stay active (fetched, subscribed, exempt from GC) without a component reading them. A reader that mounts while the lease is held joins the active query, so it starts no request of its own and renders the data on its first render if it has arrived. Both return an idempotent `release` function. Releasing never interrupts other readers; once the last one goes, the query deactivates and its `gcTime` starts as usual. Calling `release` while a fetch is in flight, with no reader, aborts that fetch. A `ttl` is an upper bound: the lease goes when it runs out even if the fetch is still in flight (offline, or a topic never fulfilled), which aborts the fetch unless a reader has joined it. Call `retain` and `prefetch` from event handlers or effects, not from inside a reactive computation; called from one, they warn in development and keep only the leases from the computation's latest run. +Both keep queries active without a component reading them and return a `release` function. A component that mounts meanwhile reuses the running request. Call them from event handlers or effects, not during render. -`prefetch` is for the moment a user commits to a navigation, such as a tap. Call it in the handler, before navigating: +Use `prefetch` when the user commits to a navigation: ```ts function onPressToken(id: string) { @@ -265,21 +265,17 @@ function onPressToken(id: string) { } ``` -With a synchronous store, a cached result is applied inside the call. A fresh one makes no request, and a stale one is refetched, as on any activation. - -`retain` keeps a set of queries warm, for example the queries of a screen that is hidden but likely to come back. `fn` runs immediately, with the client as `QueryClientContext`, and again when what it reads changes. It holds a query when it reads one of the query's fields, or when it returns the query's promise or an array of them: +Use `retain` to keep a screen's queries warm. `fn` reruns when what it reads changes, and the lease lasts until `release()` unless you pass a `ttl`: ```ts const release = queryClient.retain(() => [ fetchQuery(GetPortfolio), fetchQuery(GetPrices, { ids }), ]); -// when the surface is gone for good: +// later release(); ``` -Without a `ttl`, a `retain` lease lasts until `release` is called. - --- ### `QueryAdapter` (abstract) @@ -300,9 +296,9 @@ Base class for transport adapters. An adapter handles sending queries and mutati #### Properties -| Property | Type | Description | -| ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `coalescesRequests` | `boolean \| undefined` | Optional. Set when the adapter merges requests sent close together. `reactivationStaggerMs` then starts its queries' reactivation refetches together, unspread. | +| Property | Type | Description | +| ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `coalescesRequests` | `boolean \| undefined` | Optional. Set if the adapter batches requests sent close together. Its queries' refetches are then not staggered. | #### Protected properties @@ -654,16 +650,16 @@ The `t` object provides a declarative type definition DSL for describing query p #### `QueryConfigOptions` properties -| Property | Type | Default | Description | -| ------------------------- | ---------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | -| `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | -| `debounce` | `number` | `0` | Milliseconds to debounce refetches. At `0`, a refetch starts on a microtask (invalidation: on the next task). | -| `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | -| `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | -| `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | -| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Skip the reactivation refetch while data is younger than this. Overrides the client's `reactivationGraceMs`. | -| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | +| Property | Type | Default | Description | +| ------------------------- | ---------------------------------- | --------------------------- | ------------------------------------------------------------------------ | +| `gcTime` | `number` | `5` | In-memory eviction time in minutes. `0` = next-tick, `Infinity` = never. | +| `staleTime` | `number` | `0` | Milliseconds data is considered fresh. `0` = always stale. | +| `debounce` | `number` | `0` | Milliseconds to debounce refetches. | +| `networkMode` | `NetworkMode` | `NetworkMode.Online` | When to allow fetching. | +| `retry` | `RetryConfig \| number \| boolean` | `3` (client) / `0` (server) | Retry configuration. | +| `refreshStaleOnReconnect` | `boolean` | `true` | Whether to refetch stale queries when network reconnects. | +| `reactivationGraceMs` | `number` | client value (`0`) | Milliseconds. Overrides the client's `reactivationGraceMs`. | +| `subscribe` | `(onEvent) => () => void` | — | Subscribe to external events that should trigger refetches. | ### Mutation types @@ -693,9 +689,9 @@ The `t` object provides a declarative type definition DSL for describing query p ### Retry types -| Type | Definition | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Retry configuration. Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's `shouldRetry`. | +| Type | Definition | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `RetryConfig` | `{ retries?: number; retryDelay?: (attemptIndex: number) => number; shouldRetry?: (error, attempt, status) => boolean }` | Default delay: exponential backoff (`1000ms * 2^attempt`). `shouldRetry` overrides the client's. | ### Utility types @@ -712,8 +708,8 @@ The `t` object provides a declarative type definition DSL for describing query p ### Store types -| Type | Definition | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). Besides the required methods, a store may implement `onDelete` (the client then skips writing entities whose records are current, and writes one again once the store reports its record dropped), `onPersisted` (for a store that applies writes later), `isSettled`, `hasEntity` (lets a streamed event refresh the record of an entity that is not in memory) and `mergeEntity` (merges the fields streamed events supplied for an entity built from them over its record; without it, such an entity is written whole through `saveEntity`). A store without `onDelete` is written on every apply, as before 0.6; when `hasEntity` is missing or answers `undefined`, an event for an entity that is not in memory and that no live array retains is written only as a `mergeEntity` call with `ifStored`, which writes nothing when the store holds no record, and not at all without `mergeEntity`. | -| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | -| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | +| Type | Definition | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `QueryStore` | Interface | Storage backend interface. See [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async). | +| `CachedQuery` | `{ value: unknown; refIds: Set \| undefined; updatedAt: number; preloadedEntities?: PreloadedEntityMap }` | Cached query data returned by `QueryStore.loadQuery()`. | +| `PreloadedEntityMap` | `Map>` | Pre-loaded entity data for hydration. | diff --git a/docs/src/app/api/stores-async/page.md b/docs/src/app/api/stores-async/page.md index ab56842..0dee90b 100644 --- a/docs/src/app/api/stores-async/page.md +++ b/docs/src/app/api/stores-async/page.md @@ -41,19 +41,19 @@ new AsyncQueryStore(config: AsyncQueryStoreConfig) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a save-entity message with `merge: true`; the writer merges `fields` over the stored record, if there is one (see `SyncQueryStore.mergeEntity`). With `ifStored` and no stored record, the writer writes nothing and reports the key to `onDelete` listeners. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. A queue persisted with a different `maxCount` is resized by the writer: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | -| `purgeStaleQueries` | `(): Promise` | Scans all stored query classes and removes expired entries. In writer mode it runs through the same serial queue as the messages, after everything queued before it; a reader with its own `delegate` runs it directly. Only operates when a `delegate` is available. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every record the writer drops on its own (an LRU eviction, a reference-count cascade, a purge) or fails to write. Returns an unsubscribe function. | -| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer mode only. Registers a listener called with the key of every entity record a queued write has stored. Returns an unsubscribe function. | -| `isSettled` | `(): boolean` | Whether every operation handed to the writer has been applied (nothing queued, nothing mid-flight). Always `true` in reader mode. | -| `hasEntity` | `(key: number): boolean \| undefined` | Writer mode only. Whether the delegate holds a record for the key, answered from a set the writer reads from the delegate when it starts (alongside its queue, not ahead of it) and keeps current with every write it queues and every deletion it performs. `undefined` for a key it has not written itself until that read lands; after a read that keeps failing, nothing is assumed held. An event for an entity not in memory is sent as an `ifStored` merge while the answer is `undefined`, and always from a reader. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): Promise` | Loads a cached query. Only works when a `delegate` is available (writer mode). Returns `undefined` if no delegate, if the entry has expired, or if it does not exist. Preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Dispatches a save-query message. In writer mode, enqueues for serial processing. In reader mode, sends via `sendMessage`. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Dispatches a save-entity message. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set, ifStored?: boolean): void` | Dispatches a merge of `fields` over the stored record. With `ifStored`, the writer skips it when no record is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Dispatches an activate-query message to update the LRU queue. | +| `deleteQuery` | `(queryKey: number): void` | Dispatches a delete-query message. | +| `purgeStaleQueries` | `(): Promise` | Removes expired entries. Requires a `delegate`. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Writer only. Calls `listener` with each record the writer deletes or fails to write. Returns an unsubscribe function. | +| `onPersisted` | `(listener: (key: number) => void): () => void` | Writer only. Calls `listener` with each entity record the writer stores. Returns an unsubscribe function. | +| `isSettled` | `(): boolean` | Whether the writer has applied everything queued. Always `true` for a reader. | +| `hasEntity` | `(key: number): boolean \| undefined` | Writer only. Whether a record is stored for `key`, or `undefined` until the writer has read its keys. | --- @@ -131,7 +131,7 @@ type StoreMessage = value: unknown; updatedAt: number; cacheTime: number; - maxCount?: number; // absent from readers built before 0.6; the writer then keeps the queue's size + maxCount?: number; refIds?: number[]; } | { @@ -139,7 +139,8 @@ type StoreMessage = entityKey: number; value: unknown; refIds?: number[]; - merge?: boolean; // `value` holds only some fields: merge them over the stored record + merge?: boolean; // merge `value` over the stored record + ifStored?: boolean; // with `merge`: skip if no record is stored } | { type: 2; // ActivateQuery diff --git a/docs/src/app/api/stores-sync/page.md b/docs/src/app/api/stores-sync/page.md index c4f4235..845cfda 100644 --- a/docs/src/app/api/stores-sync/page.md +++ b/docs/src/app/api/stores-sync/page.md @@ -32,17 +32,17 @@ new SyncQueryStore(kv: SyncPersistentStore) #### Methods -| Method | Signature | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | -| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | -| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity's serialized data and its child entity references. Manages reference counts for nested entities. | -| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Writes the fields streamed events supplied for an entity built from them: merged over the stored record when there is one (the record's references are then derived from the merged value), written as the record otherwise. A record that cannot be merged into is replaced. | -| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of the LRU queue for its query class. If the queue is full, the oldest entry is evicted. A queue persisted with a different `maxCount` is resized: growing reallocates it, shrinking evicts the keys that no longer fit. | -| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | -| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | -| `onDelete` | `(listener: (key: number) => void): () => void` | Registers a listener called with the key of every record the store drops on its own (an LRU eviction, the reference-count cascade that follows it, a purge). Listeners are shared by every store over the same `kv`. Returns an unsubscribe function; `QueryClient.destroy()` calls it. | -| `hasEntity` | `(key: number): boolean` | Whether the store holds a record for the key. The client refreshes the record of an entity a streamed event updates while the entity is not in memory only when this answers `true`. | +| Method | Signature | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadQuery` | `(queryDef: QueryDefinition, queryKey: number): CachedQuery \| undefined` | Loads a cached query by key. Returns `undefined` if the cache entry has expired (beyond `cacheTime`) or does not exist. Also preloads referenced entities. | +| `saveQuery` | `(queryDef: QueryDefinition, queryKey: number, value: unknown, updatedAt: number, refIds?: Set): void` | Persists a query result, its timestamp, and entity reference IDs. Activates the query in the LRU queue. | +| `saveEntity` | `(entityKey: number, value: unknown, refIds?: Set): void` | Persists an entity and its child references. | +| `mergeEntity` | `(entityKey: number, fields: unknown, refIds?: Set): void` | Merges `fields` over the stored record, or writes them as the record if none is stored. | +| `activateQuery` | `(queryDef: QueryDefinition, queryKey: number): void` | Moves a query to the front of its class's LRU queue, evicting the oldest entry when full. | +| `deleteQuery` | `(queryKey: number): void` | Deletes a query's stored value, reference IDs, and decrements reference counts for all referenced entities. | +| `purgeStaleQueries` | `(): void` | Scans all query classes and removes those whose `lastUsedAt` timestamp exceeds their `cacheTime`. Called automatically on `QueryClient` construction. | +| `onDelete` | `(listener: (key: number) => void): () => void` | Calls `listener` with each record the store deletes on its own (eviction, cascade, purge). Returns an unsubscribe function. | +| `hasEntity` | `(key: number): boolean` | Whether a record is stored for `key`. | --- diff --git a/docs/src/app/core/entities/page.md b/docs/src/app/core/entities/page.md index 11745ae..71b430b 100644 --- a/docs/src/app/core/entities/page.md +++ b/docs/src/app/core/entities/page.md @@ -58,7 +58,7 @@ class GetPost extends RESTQuery { ``` {% callout type="warning" %} -Entities are **read-only**, and so are the snapshots `useQuery` and `useReactive` take of them. Attempting to set a property on an entity, or on a snapshot in a development build, will throw an error. To update entity data, use mutations or streaming updates. +Entities are **read-only**, and so are the snapshots `useQuery` takes of them. Setting a property throws (on snapshots, in development). Use mutations or streaming updates to change entity data. {% /callout %} --- diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index 41ca8da..6021133 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -207,13 +207,13 @@ class MyStreamAdapter extends TopicQueryAdapter { The adapter has several protected helper methods: -| Method | Description | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | -| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | -| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass the `topic` it arrived on so that topic's query counts as current (see `reactivationGraceMs`). | -| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | -| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | +| Method | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. | +| `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. | +| `sendMutationEvent(event, topic?)` | Push a `MutationEvent` through Fetchium's entity event system. Pass `topic` so its query counts the event as fresh data. | +| `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. | +| `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. | ### Registering the adapter diff --git a/docs/src/app/data/caching/page.md b/docs/src/app/data/caching/page.md index c3582a0..feacbd1 100644 --- a/docs/src/app/data/caching/page.md +++ b/docs/src/app/data/caching/page.md @@ -133,7 +133,7 @@ class GetUser extends RESTQuery { } ``` -`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. Changing `maxCount` between releases resizes the queue already persisted for the class the first time the store touches that class after starting: growing keeps every entry, shrinking evicts the oldest entries that no longer fit. +`maxCount` limits how many distinct parameter combinations are stored for this query class. When the limit is exceeded, the oldest entries are evicted. The default is `50`. --- diff --git a/docs/src/app/guides/error-handling/page.md b/docs/src/app/guides/error-handling/page.md index c44362b..8eeb44e 100644 --- a/docs/src/app/guides/error-handling/page.md +++ b/docs/src/app/guides/error-handling/page.md @@ -147,7 +147,7 @@ class GetUser extends RESTQuery { The default retry delay uses exponential backoff --- each successive attempt waits longer than the last (roughly 1s, 2s, 4s, ...). This avoids hammering a struggling server with rapid retries. -Every failed attempt is retried, whatever its status, including `4xx` responses. When you know an error is permanent, opt out with `shouldRetry`, either per query in `retry` or for every query on the `QueryClient`. It receives the error, the attempt index and the attempt's HTTP status when known: the response the adapter received (a REST error response whose body doesn't match `result`), or `status`, `statusCode` or `response.status` on the thrown error. A query's `shouldRetry` overrides the client's. +Every failed attempt is retried, including `4xx` responses. Use `shouldRetry` to stop retrying errors you know are permanent. It gets the error, the attempt index and the HTTP status when known. Set it per query in `retry`, or for every query on the `QueryClient`. ```ts class GetUser extends RESTQuery { @@ -161,7 +161,7 @@ class GetUser extends RESTQuery { } ``` -`getErrorStatus(error)` reads the status from an error, if you need it elsewhere. +`getErrorStatus(error)` reads the status from an error. To disable retries entirely: @@ -311,7 +311,7 @@ if (result.isRejected) { ### Opting into throw behavior -If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- throw the error yourself. Reading `.value` does not throw: on a rejected query it holds the last successful value, or `undefined` if there never was one. +To let an error boundary handle query failures, throw the error yourself. Reading `.value` doesn't throw. ```tsx import { ErrorBoundary } from 'react-error-boundary'; @@ -319,7 +319,6 @@ import { ErrorBoundary } from 'react-error-boundary'; function UserProfile({ userId }: { userId: number }) { const result = useQuery(GetUser, { id: userId }); - // Hand a failure with nothing to show to the ErrorBoundary above if (result.isRejected && !result.isReady) throw result.error; if (!result.isReady) return ; @@ -339,7 +338,7 @@ function App() { } ``` -`useSuspenseQuery` from `fetchium/react` does this for you: it suspends while a query with no value loads, and throws a failed cold fetch to the error boundary. +`useSuspenseQuery` does this for you when the query has no value yet. This is a conscious opt-in. The default explicit-checking pattern (`isRejected` + `error`) is recommended for most use cases because it gives you the most flexibility. Error boundaries are best reserved for catching truly unexpected failures that shouldn't be handled inline. diff --git a/docs/src/app/setup/project-setup/page.md b/docs/src/app/setup/project-setup/page.md index b7ca2b8..7e2761b 100644 --- a/docs/src/app/setup/project-setup/page.md +++ b/docs/src/app/setup/project-setup/page.md @@ -289,8 +289,8 @@ Fetchium uses a compile-time constant `IS_DEV` to enable additional runtime chec - **Parse validation warnings** --- detailed logs when response data doesn't match your type definitions - **Entity write protection** --- throws when you accidentally try to mutate an entity property directly (entities are read-only) -- **Frozen snapshots** --- the snapshots `useQuery` and `useReactive` hand out are frozen, so mutating one (an in-place `sort()`, an assignment in render) throws a `TypeError` at the offending line instead of silently corrupting what the next render shows -- **Stale-snapshot detection** --- a snapshot taken at an unchanged entity version re-reads only the fields whose values live elsewhere; development builds re-read the rest too and, if one changed, serve the re-read value, log the discrepancy through the client's `log.error`, and raise it once per typename and field as an uncaught error +- **Frozen snapshots** --- mutating a snapshot from `useQuery` throws a `TypeError` +- **Stale-snapshot detection** --- logs an error when a snapshot misses an entity change - **Reference validation** --- catches common mistakes like using conditional logic in class field definitions In production builds, all `IS_DEV` code paths are tree-shaken, so there is zero runtime cost. From 036e2bbd31c4045207837c1046d0544b233af70f Mon Sep 17 00:00:00 2001 From: Adam Azbel-Bedell Date: Wed, 7 Oct 2026 20:08:15 -0400 Subject: [PATCH 109/109] docs(fetchium): separate hooks from component(), and qualify abort and lease behaviour Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/activation-timing-fixes.md | 2 +- .changeset/upgrade-behavior-changes.md | 2 +- docs/src/app/api/fetchium-react/page.md | 34 ++++++++++++++++++------- docs/src/app/api/fetchium/page.md | 2 +- docs/src/app/core/streaming/page.md | 7 ++--- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.changeset/activation-timing-fixes.md b/.changeset/activation-timing-fixes.md index 1ff0079..e73f8cf 100644 --- a/.changeset/activation-timing-fixes.md +++ b/.changeset/activation-timing-fixes.md @@ -4,6 +4,6 @@ Fixed errors and wrong data when a query mounts, unmounts or changes params quickly. -- Remounting a query no longer shows an `AbortError`. It shows its cached data, or stays pending, while it refetches. +- Remounting a query after an aborted fetch shows its cached data, or stays pending, instead of an `AbortError`. A topic query with no data that mounts and unmounts in the same task is the exception: it shows the `AbortError` until its data arrives, and its first awaiter rejects. - Changing a Signal param mid-fetch fetches the new params. Each params value of a non-entity result is cached separately. - `QueryClient.destroy()` aborts fetches in flight. Queries read afterwards reject with an `AbortError` and send no requests. diff --git a/.changeset/upgrade-behavior-changes.md b/.changeset/upgrade-behavior-changes.md index 1b18a6f..c2a13c9 100644 --- a/.changeset/upgrade-behavior-changes.md +++ b/.changeset/upgrade-behavior-changes.md @@ -12,7 +12,7 @@ Most apps need no changes. Tests that depend on loading frames, render counts or - `t.record` and `t.result` fields apply updates. - Changing a Signal param mid-fetch aborts that fetch. - After `QueryClient.destroy()`, queries reject with an `AbortError` and send no requests. -- If the last reader unmounts before a query's first data, `await fetchQuery(...)` waits for the next mount, collection or `destroy()`. +- If the last reader unmounts before a query's first data, `await fetchQuery(...)` waits for the next mount, collection or `destroy()`. A topic query mounted and unmounted in the same task still rejects. - `shouldRetry`, `reactivationGraceMs`, `reactivationStaggerMs`, `activity` and `pollResumeJitterMs` are reserved `QueryClientConfig` names. - `retry: {}` and `retry: { retryDelay }` now retry like `retry: true`. - `AsyncQueryStore` honors `cache.maxCount`. diff --git a/docs/src/app/api/fetchium-react/page.md b/docs/src/app/api/fetchium-react/page.md index c63ff0a..a636cbc 100644 --- a/docs/src/app/api/fetchium-react/page.md +++ b/docs/src/app/api/fetchium-react/page.md @@ -7,6 +7,19 @@ description: API reference for the fetchium React integration. React hooks for using Fetchium queries in React components. Built on top of Signalium's `useReactive` hook. +Use these hooks in ordinary React function components. Inside a Signalium `component()`, which is already reactive, call `fetchQuery` directly instead: + +```tsx +import { component } from 'signalium/react'; +import { fetchQuery } from 'fetchium'; + +const UserList = component(() => { + const query = fetchQuery(GetUsers); + if (!query.isReady) return
Loading...
; + return
{query.value.total} users
; +}); +``` + ```ts import { useQuery, useSuspenseQuery } from 'fetchium/react'; ``` @@ -60,12 +73,11 @@ The resolved `QueryResult` includes pagination helpers: #### Requirements - A `QueryClient` must be provided via `QueryClientContext` using Signalium's `ContextProvider`. -- The component must be wrapped in a Signalium `component()` or use `useReactive` for the reactive system to function. +- Call it from an ordinary React function component, not inside a Signalium `component()`. #### Example ```tsx -import { component } from 'signalium/react'; import { useQuery } from 'fetchium/react'; class GetUsers extends RESTQuery { @@ -77,7 +89,7 @@ class GetUsers extends RESTQuery { }; } -const UserList = component(() => { +function UserList() { const query = useQuery(GetUsers); if (query.isPending) { @@ -100,17 +112,18 @@ const UserList = component(() => { ); -}); +} ``` #### With parameters ```tsx -const UserProfile = component(({ userId }: { userId: string }) => { +function UserProfile({ userId }: { userId: string }) { const query = useQuery(GetUser, { id: userId }); + if (!query.isReady) return null; return
{query.value.name}
; -}); +} ``` #### With reactive parameters @@ -120,12 +133,13 @@ import { signal } from 'signalium'; const searchTerm = signal(''); -const SearchResults = component(() => { +function SearchResults() { const query = useQuery(SearchUsers, { q: searchTerm }); + if (!query.isReady) return null; - // Component re-renders when searchTerm changes and the query refetches + // Re-renders when searchTerm changes and the query refetches return
{query.value.results.length} results
; -}); +} ``` #### Notes @@ -150,6 +164,8 @@ Like `useQuery`, but suspends while the query has no value yet. Refetches don't Changing params to ones with no value yet suspends again. Wrap the change in `startTransition` to keep showing the previous result. +Like `useQuery`, call it from an ordinary React component, not inside a Signalium `component()`. + ```tsx import { Suspense } from 'react'; import { useSuspenseQuery } from 'fetchium/react'; diff --git a/docs/src/app/api/fetchium/page.md b/docs/src/app/api/fetchium/page.md index 8799a5b..81d473e 100644 --- a/docs/src/app/api/fetchium/page.md +++ b/docs/src/app/api/fetchium/page.md @@ -254,7 +254,7 @@ Other config keys reach queries and mutations as `this.context`. Don't reuse the #### `prefetch` and `retain` -Both keep queries active without a component reading them and return a `release` function. A component that mounts meanwhile reuses the running request. Call them from event handlers or effects, not during render. +Both keep queries active without a component reading them and return a `release` function. A component that mounts meanwhile reuses the running request. Releasing, or reaching the `ttl`, aborts a request still in flight unless a reader has joined it, and never interrupts joined readers. Call them from event handlers or effects, not during render. Use `prefetch` when the user commits to a navigation: diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index 6021133..1dd053f 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -343,14 +343,15 @@ When the subscription fires a `create` event for a `ChatMessage` whose `channelI ```tsx import { component } from 'signalium/react'; -import { useQuery } from 'fetchium/react'; +import { fetchQuery } from 'fetchium'; const ChatRoom = component(({ channelId }: { channelId: string }) => { - const { messages } = useQuery(GetMessages, { channelId }); + const query = fetchQuery(GetMessages, { channelId }); + if (!query.isReady) return null; return (
- {messages.map((msg) => ( + {query.value.messages.map((msg) => (
{msg.author.name}: {msg.text}