From 05d723f54fa6e60fea940bd4684a72403c482029 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:45:29 +0000 Subject: [PATCH 01/13] fix(endpoint): Show a re-added item once in a Collection Deleting an item invalidates its entity but leaves its id in each Collection that held it. Pushing (or unshifting) an item with the same id then appended a second copy of the id, so the list showed the item twice while All() counted it once. push and unshift now drop an id already in the list before adding it, so the item moves to the end or start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .changeset/collection-push-readd.md | 15 ++++++ docs/rest/api/Collection.md | 4 +- packages/endpoint/src/schemas/Collection.ts | 29 +++++++----- .../src/schemas/__tests__/Collection.test.ts | 47 ++++++++++++++++++- 4 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 .changeset/collection-push-readd.md diff --git a/.changeset/collection-push-readd.md b/.changeset/collection-push-readd.md new file mode 100644 index 000000000000..e1c2825f6983 --- /dev/null +++ b/.changeset/collection-push-readd.md @@ -0,0 +1,15 @@ +--- +'@data-client/endpoint': patch +'@data-client/graphql': patch +'@data-client/rest': patch +--- + +Fix Collection push and unshift showing an item twice after it was deleted and added back + +After an item was deleted, adding an item with the same id through [push](https://dataclient.io/rest/api/Collection#push) or [unshift](https://dataclient.io/rest/api/Collection#unshift) (for example `getList.push`) listed it twice, while [All](https://dataclient.io/rest/api/All) counted it once. This happens with lists keyed by another entity's id, like cart lines keyed by product or a list of followed users. Now an item already in the collection moves to the end (push) or start (unshift). + +```ts +await ctrl.fetch(CartResource.delete, { id: 'mug' }); +await ctrl.fetch(CartResource.getList.push, { id: 'mug', quantity: 1 }); +// getList shows the mug once, at the end +``` diff --git a/docs/rest/api/Collection.md b/docs/rest/api/Collection.md index bbb316970839..5adab61ee308 100644 --- a/docs/rest/api/Collection.md +++ b/docs/rest/api/Collection.md @@ -683,7 +683,7 @@ updates without network requests. For network-based mutations, see [RestEndpoint ### push -A creation schema that places new item(s) at the _end_ of this collection. +A creation schema that places new item(s) at the _end_ of this collection. An item already in it moves to the end. ```ts // Add a new todo to the end of the list (local only, no network request) @@ -692,7 +692,7 @@ ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' } ### unshift -A creation schema that places new item(s) at the _start_ of this collection. +A creation schema that places new item(s) at the _start_ of this collection. An item already in it moves to the start. ```ts // Add a new todo to the beginning of the list (local only) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 84af90b3aeee..90be35c6426d 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -11,19 +11,6 @@ import { import type { Values as ValuesType, Array as ArrayType } from '../schema.js'; import type { DefaultArgs } from '../schemaTypes.js'; -const pushMerge = (existing: any, incoming: any) => { - return [...existing, ...incoming]; -}; -/** Collection merge that places incoming items at the start. - * - * @see https://dataclient.io/rest/api/Collection#moveWith - */ -export const unshift = (existing: any, incoming: any) => { - return [...incoming, ...existing]; -}; -const valuesMerge = (existing: any, incoming: any) => { - return { ...existing, ...incoming }; -}; type NormalizedRef = { id: string; schema: string } | string; const isShallowEqual = (a: NormalizedRef, b: NormalizedRef): boolean => { @@ -37,6 +24,22 @@ const isShallowEqual = (a: NormalizedRef, b: NormalizedRef): boolean => { const removeMerge = (existing: NormalizedRef[], incoming: NormalizedRef[]) => existing.filter(item => !incoming.some(inc => isShallowEqual(item, inc))); +/** An item added again (like one deleted, then re-created with the same pk) + * moves to its new place instead of showing twice */ +const pushMerge = (existing: any, incoming: any) => { + return [...removeMerge(existing, incoming), ...incoming]; +}; +/** Collection merge that places incoming items at the start. + * + * @see https://dataclient.io/rest/api/Collection#moveWith + */ +export const unshift = (existing: any, incoming: any) => { + return [...incoming, ...removeMerge(existing, incoming)]; +}; +const valuesMerge = (existing: any, incoming: any) => { + return { ...existing, ...incoming }; +}; + const valuesRemoveMerge = ( existing: Record, incoming: Record, diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 98412c243b90..cd117c0f3c22 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -1,6 +1,11 @@ // eslint-env jest import { initialState, State } from '@data-client/core'; -import { normalize, denormalize, MemoCache } from '@data-client/normalizr'; +import { + normalize, + denormalize, + MemoCache, + INVALID, +} from '@data-client/normalizr'; import { ArticleResource, IDEntity } from '__tests__/new'; import { Record } from 'immutable'; @@ -755,6 +760,46 @@ describe(`${schema.Collection.name} normalization`, () => { ]); }); + test.each([ + ['push', ['10', '5'], ['5', '10']], + ['unshift', ['5', '10'], ['10', '5']], + ] as const)( + '%s of an id already in the collection moves it instead of adding a copy', + (method, before, after) => { + const init = { + entities: { + [User.schema.todos.key]: { + '{"userId":"1"}': [...before], + }, + Todo: { + '5': { id: '5', title: 'finish collections' }, + // deleted, so the collection still holds its id + '10': INVALID, + }, + }, + entitiesMeta: { + [User.schema.todos.key]: { + '{"userId":"1"}': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + }, + Todo: { + '5': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + '10': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + }, + }, + indexes: {}, + }; + const state = normalize( + User.schema.todos[method], + [{ id: '10', title: 'recreated' }], + [{ userId: '1' }], + init, + ); + expect(state.entities[User.schema.todos.key]['{"userId":"1"}']).toEqual( + after, + ); + }, + ); + describe('push should add only to collections matching filterArgumentKeys', () => { const initializingSchema = new Collection([Todo]); let state = { From 83f0ac191636c09c9411bb2c88196a0080877876 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:46:00 +0000 Subject: [PATCH 02/13] docs: Note the Collection re-add fix in the v0.19 post Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- website/blog/2026-10-03-v0.19-batch-set.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 333ed9e206fd..33c8d2b02d1d 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -56,6 +56,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Polling keeps the schema of endpoints given [pollFrequency](/rest/api/RestEndpoint#pollfrequency) with `.extend()`, so a polled [useLive()](/docs/api/useLive) list updates right away from mutations and optimistic updates instead of waiting for the next poll ([#4437](https://github.com/reactive/data-client/pull/4437)) - [DataProvider](/docs/api/DataProvider) only warns that it doesn't update during SSR when something fetches while the server renders, instead of on every server render, so pages that load data after mount (like Expo Router's static web output) stay quiet ([#4449](https://github.com/reactive/data-client/pull/4449)) - Server rendering with React 19 no longer logs "Uncaught suspense" when nothing suspended; React renders and discards fallbacks of large boundaries, and the warning now waits for a fallback that actually shows ([#4450](https://github.com/reactive/data-client/pull/4450)) +- [Collection push](/rest/api/Collection#push) and `unshift` (like `getList.push`) show an item once when it was deleted and then added back, instead of twice; it moves to the end or start ([#4487](https://github.com/reactive/data-client/pull/4487)) - Vue `createDataClient()` is deprecated, since it's internal to [DataClientPlugin](/vue/api/DataClientPlugin); install the plugin with `app.use(DataClientPlugin, options)` instead of calling it directly ([#4193](https://github.com/reactive/data-client/pull/4193)) - Vue `renderDataCompose()`'s `waitForNextUpdate()` is deprecated, since it gives up silently after 1 second and lets a test pass while still suspended; `await result` instead, or `await allSettled()` after a change, which now also waits for fetches a prop change starts ([unit testing composables](/vue/guides/unit-testing-composables#renderdatacompose-api), [#4221](https://github.com/reactive/data-client/pull/4221)) From 98458e6ee04348936ad75c8ca28168c885a741c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:51:23 +0000 Subject: [PATCH 03/13] fix(endpoint): Keep Collection push working when it holds null Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/Collection.ts | 3 +- .../src/schemas/__tests__/Collection.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 90be35c6426d..0589f03757b6 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -15,7 +15,8 @@ type NormalizedRef = { id: string; schema: string } | string; const isShallowEqual = (a: NormalizedRef, b: NormalizedRef): boolean => { // TODO: make this extensible in the child's schema - if (typeof a === 'object' && typeof b === 'object') { + // arrays keep null members, and typeof null is 'object' + if (typeof a === 'object' && typeof b === 'object' && a && b) { return a.id === b.id && a.schema === b.schema; } return a === b; diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index cd117c0f3c22..f7fd556b45af 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -338,6 +338,38 @@ describe(`${schema.Collection.name} normalization`, () => { ]); }); + test('push works when the collection holds null', () => { + const collectionPK = collectionUnion.pk({}, {}, '', [ + { completed: false }, + ]); + const meta = { date: 1557831718135, expiresAt: Infinity, fetchedAt: 0 }; + const init: State = { + ...initialState, + entities: { + [collectionUnion.key]: { + [collectionPK]: [null, { id: '2', schema: 'groups' }], + }, + Group: { '2': { id: '2', type: 'groups' } }, + }, + entitiesMeta: { + [collectionUnion.key]: { [collectionPK]: meta }, + Group: { '2': meta }, + }, + indexes: {}, + }; + const state = normalize( + collectionUnion.push, + [{ id: '1', type: 'users' }], + [{ completed: false }], + init, + ); + expect(state.entities[collectionUnion.key]?.[collectionPK]).toEqual([ + null, + { id: '2', schema: 'groups' }, + { id: '1', schema: 'users' }, + ]); + }); + test('remove works updates the correct collections', () => { const init = { entities: { From 6add6a52c654d4badcd580f02e5dd378e0b1785e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:53:56 +0000 Subject: [PATCH 04/13] docs: Regenerate Collection skill references Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .agents/skills/data-client-schema/references/Collection.md | 4 ++-- .../skills/data-client-schema/references/Collection.vue.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/data-client-schema/references/Collection.md b/.agents/skills/data-client-schema/references/Collection.md index 2b887165c680..41322c2b9513 100644 --- a/.agents/skills/data-client-schema/references/Collection.md +++ b/.agents/skills/data-client-schema/references/Collection.md @@ -455,7 +455,7 @@ updates without network requests. For network-based mutations, see [RestEndpoint ### push -A creation schema that places new item(s) at the _end_ of this collection. +A creation schema that places new item(s) at the _end_ of this collection. An item already in it moves to the end. ```ts // Add a new todo to the end of the list (local only, no network request) @@ -464,7 +464,7 @@ ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' } ### unshift -A creation schema that places new item(s) at the _start_ of this collection. +A creation schema that places new item(s) at the _start_ of this collection. An item already in it moves to the start. ```ts // Add a new todo to the beginning of the list (local only) diff --git a/.agents/skills/data-client-schema/references/Collection.vue.md b/.agents/skills/data-client-schema/references/Collection.vue.md index 9abd9c430a0d..ebc61f569f24 100644 --- a/.agents/skills/data-client-schema/references/Collection.vue.md +++ b/.agents/skills/data-client-schema/references/Collection.vue.md @@ -441,7 +441,7 @@ updates without network requests. For network-based mutations, see [RestEndpoint ### push -A creation schema that places new item(s) at the _end_ of this collection. +A creation schema that places new item(s) at the _end_ of this collection. An item already in it moves to the end. ```ts // Add a new todo to the end of the list (local only, no network request) @@ -450,7 +450,7 @@ ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' } ### unshift -A creation schema that places new item(s) at the _start_ of this collection. +A creation schema that places new item(s) at the _start_ of this collection. An item already in it moves to the start. ```ts // Add a new todo to the beginning of the list (local only) From 6c099fe4c1f2298b460bc166c29382bea9652dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:09:02 +0000 Subject: [PATCH 05/13] fix(endpoint): Keep Array push semantics; drop deleted ids before adding push and unshift append like Array.push() again. Before adding, a create drops refs to deleted (INVALID) entities from the matching collections, so an item re-created with a deleted pk shows once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../references/Collection.md | 4 +- .../references/Collection.vue.md | 4 +- .changeset/collection-push-readd.md | 2 +- docs/rest/api/Collection.md | 4 +- packages/endpoint/src/schemas/Collection.ts | 72 +++++++++++++------ .../src/schemas/__tests__/Collection.test.ts | 27 ++++++- website/blog/2026-10-03-v0.19-batch-set.md | 2 +- 7 files changed, 86 insertions(+), 29 deletions(-) diff --git a/.agents/skills/data-client-schema/references/Collection.md b/.agents/skills/data-client-schema/references/Collection.md index 41322c2b9513..2b887165c680 100644 --- a/.agents/skills/data-client-schema/references/Collection.md +++ b/.agents/skills/data-client-schema/references/Collection.md @@ -455,7 +455,7 @@ updates without network requests. For network-based mutations, see [RestEndpoint ### push -A creation schema that places new item(s) at the _end_ of this collection. An item already in it moves to the end. +A creation schema that places new item(s) at the _end_ of this collection. ```ts // Add a new todo to the end of the list (local only, no network request) @@ -464,7 +464,7 @@ ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' } ### unshift -A creation schema that places new item(s) at the _start_ of this collection. An item already in it moves to the start. +A creation schema that places new item(s) at the _start_ of this collection. ```ts // Add a new todo to the beginning of the list (local only) diff --git a/.agents/skills/data-client-schema/references/Collection.vue.md b/.agents/skills/data-client-schema/references/Collection.vue.md index ebc61f569f24..9abd9c430a0d 100644 --- a/.agents/skills/data-client-schema/references/Collection.vue.md +++ b/.agents/skills/data-client-schema/references/Collection.vue.md @@ -441,7 +441,7 @@ updates without network requests. For network-based mutations, see [RestEndpoint ### push -A creation schema that places new item(s) at the _end_ of this collection. An item already in it moves to the end. +A creation schema that places new item(s) at the _end_ of this collection. ```ts // Add a new todo to the end of the list (local only, no network request) @@ -450,7 +450,7 @@ ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' } ### unshift -A creation schema that places new item(s) at the _start_ of this collection. An item already in it moves to the start. +A creation schema that places new item(s) at the _start_ of this collection. ```ts // Add a new todo to the beginning of the list (local only) diff --git a/.changeset/collection-push-readd.md b/.changeset/collection-push-readd.md index e1c2825f6983..559bcf55fbba 100644 --- a/.changeset/collection-push-readd.md +++ b/.changeset/collection-push-readd.md @@ -6,7 +6,7 @@ Fix Collection push and unshift showing an item twice after it was deleted and added back -After an item was deleted, adding an item with the same id through [push](https://dataclient.io/rest/api/Collection#push) or [unshift](https://dataclient.io/rest/api/Collection#unshift) (for example `getList.push`) listed it twice, while [All](https://dataclient.io/rest/api/All) counted it once. This happens with lists keyed by another entity's id, like cart lines keyed by product or a list of followed users. Now an item already in the collection moves to the end (push) or start (unshift). +After an item was deleted, adding an item with the same id through [push](https://dataclient.io/rest/api/Collection#push) or [unshift](https://dataclient.io/rest/api/Collection#unshift) (for example `getList.push`) listed it twice, while [All](https://dataclient.io/rest/api/All) counted it once. This happens with lists keyed by another entity's id, like cart lines keyed by product or a list of followed users. Now adding to a collection first drops the ids of deleted items it still holds, so the re-created item shows once: at the end (push) or start (unshift). Items that aren't deleted are untouched, so pushing an id the list already shows still adds it again, as `Array.push()` would. ```ts await ctrl.fetch(CartResource.delete, { id: 'mug' }); diff --git a/docs/rest/api/Collection.md b/docs/rest/api/Collection.md index 5adab61ee308..bbb316970839 100644 --- a/docs/rest/api/Collection.md +++ b/docs/rest/api/Collection.md @@ -683,7 +683,7 @@ updates without network requests. For network-based mutations, see [RestEndpoint ### push -A creation schema that places new item(s) at the _end_ of this collection. An item already in it moves to the end. +A creation schema that places new item(s) at the _end_ of this collection. ```ts // Add a new todo to the end of the list (local only, no network request) @@ -692,7 +692,7 @@ ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' } ### unshift -A creation schema that places new item(s) at the _start_ of this collection. An item already in it moves to the start. +A creation schema that places new item(s) at the _start_ of this collection. ```ts // Add a new todo to the beginning of the list (local only) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 0589f03757b6..3164fca7de39 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -11,35 +11,31 @@ import { import type { Values as ValuesType, Array as ArrayType } from '../schema.js'; import type { DefaultArgs } from '../schemaTypes.js'; -type NormalizedRef = { id: string; schema: string } | string; - -const isShallowEqual = (a: NormalizedRef, b: NormalizedRef): boolean => { - // TODO: make this extensible in the child's schema - // arrays keep null members, and typeof null is 'object' - if (typeof a === 'object' && typeof b === 'object' && a && b) { - return a.id === b.id && a.schema === b.schema; - } - return a === b; -}; - -const removeMerge = (existing: NormalizedRef[], incoming: NormalizedRef[]) => - existing.filter(item => !incoming.some(inc => isShallowEqual(item, inc))); - -/** An item added again (like one deleted, then re-created with the same pk) - * moves to its new place instead of showing twice */ const pushMerge = (existing: any, incoming: any) => { - return [...removeMerge(existing, incoming), ...incoming]; + return [...existing, ...incoming]; }; /** Collection merge that places incoming items at the start. * * @see https://dataclient.io/rest/api/Collection#moveWith */ export const unshift = (existing: any, incoming: any) => { - return [...incoming, ...removeMerge(existing, incoming)]; + return [...incoming, ...existing]; }; const valuesMerge = (existing: any, incoming: any) => { return { ...existing, ...incoming }; }; +type NormalizedRef = { id: string; schema: string } | string; + +const isShallowEqual = (a: NormalizedRef, b: NormalizedRef): boolean => { + // TODO: make this extensible in the child's schema + if (typeof a === 'object' && typeof b === 'object') { + return a.id === b.id && a.schema === b.schema; + } + return a === b; +}; + +const removeMerge = (existing: NormalizedRef[], incoming: NormalizedRef[]) => + existing.filter(item => !incoming.some(inc => isShallowEqual(item, inc))); const valuesRemoveMerge = ( existing: Record, @@ -363,6 +359,11 @@ function normalizeCreate( // visit instances are created on each normalize call so this will safely be reset visit.creating = true; } + // parent is args when not nested + const filterCollections = (this.createCollectionFilter as any)(...args); + // before normalizing input, which revives an entity re-created with a deleted pk + if (this.schema instanceof ArraySchema) + dropDeleted(this, filterCollections, delegate); const normalizedValue = this.schema.normalize( !(this.schema instanceof ArraySchema) || Array.isArray(input) ? input @@ -371,8 +372,6 @@ function normalizeCreate( key, delegate, ); - // parent is args when not nested - const filterCollections = (this.createCollectionFilter as any)(...args); // add to any collections that match this const entities = delegate.getEntities(this.key); if (entities) @@ -383,6 +382,39 @@ function normalizeCreate( return normalizedValue as any; } +/** A deleted entity's ref stays in its collections, where denormalize skips it. + * Drop those refs, so re-creating that pk adds it once, like Array.push() would. + */ +function dropDeleted( + collection: CollectionSchema, + filterCollections: (collectionKey: Record) => boolean, + delegate: INormalizeDelegate, +) { + const entities = delegate.getEntities(collection.key); + if (!entities) return; + const members = collection.schema; + // polymorphic refs are { id, schema } + const single = members.isSingleSchema ? members.schema : undefined; + const isDeleted = (ref: any) => { + const entity = single ?? (ref && members.schema[ref.schema]); + // Invalidate stores a symbol in place of the entity + return ( + typeof entity?.key === 'string' && + typeof delegate.getEntity(entity.key, single ? ref : ref.id) === 'symbol' + ); + }; + for (const collectionKey of entities.keys()) { + if (!filterCollections(JSON.parse(collectionKey))) continue; + const existing = delegate.getEntity(collection.key, collectionKey); + if (!Array.isArray(existing) || !existing.some(isDeleted)) continue; + delegate.setEntity( + collection, + collectionKey, + existing.filter(ref => !isDeleted(ref)), + ); + } +} + function CreateMover>( collection: C, addMerge: (existing: any, incoming: any) => any, diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index f7fd556b45af..ab852b4b837b 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -796,7 +796,7 @@ describe(`${schema.Collection.name} normalization`, () => { ['push', ['10', '5'], ['5', '10']], ['unshift', ['5', '10'], ['10', '5']], ] as const)( - '%s of an id already in the collection moves it instead of adding a copy', + '%s of a deleted then re-created id adds it once', (method, before, after) => { const init = { entities: { @@ -832,6 +832,31 @@ describe(`${schema.Collection.name} normalization`, () => { }, ); + test('push of an id already in the collection adds it again, like Array.push()', () => { + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const init = { + entities: { + [User.schema.todos.key]: { '{"userId":"1"}': ['5'] }, + Todo: { '5': { id: '5', title: 'finish collections' } }, + }, + entitiesMeta: { + [User.schema.todos.key]: { '{"userId":"1"}': meta }, + Todo: { '5': meta }, + }, + indexes: {}, + }; + const state = normalize( + User.schema.todos.push, + [{ id: '5', title: 'finish collections' }], + [{ userId: '1' }], + init, + ); + expect(state.entities[User.schema.todos.key]['{"userId":"1"}']).toEqual([ + '5', + '5', + ]); + }); + describe('push should add only to collections matching filterArgumentKeys', () => { const initializingSchema = new Collection([Todo]); let state = { diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 33c8d2b02d1d..2d5c5e22fc79 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -56,7 +56,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Polling keeps the schema of endpoints given [pollFrequency](/rest/api/RestEndpoint#pollfrequency) with `.extend()`, so a polled [useLive()](/docs/api/useLive) list updates right away from mutations and optimistic updates instead of waiting for the next poll ([#4437](https://github.com/reactive/data-client/pull/4437)) - [DataProvider](/docs/api/DataProvider) only warns that it doesn't update during SSR when something fetches while the server renders, instead of on every server render, so pages that load data after mount (like Expo Router's static web output) stay quiet ([#4449](https://github.com/reactive/data-client/pull/4449)) - Server rendering with React 19 no longer logs "Uncaught suspense" when nothing suspended; React renders and discards fallbacks of large boundaries, and the warning now waits for a fallback that actually shows ([#4450](https://github.com/reactive/data-client/pull/4450)) -- [Collection push](/rest/api/Collection#push) and `unshift` (like `getList.push`) show an item once when it was deleted and then added back, instead of twice; it moves to the end or start ([#4487](https://github.com/reactive/data-client/pull/4487)) +- [Collection push](/rest/api/Collection#push) and `unshift` (like `getList.push`) show an item once when it was deleted and then added back, instead of twice ([#4487](https://github.com/reactive/data-client/pull/4487)) - Vue `createDataClient()` is deprecated, since it's internal to [DataClientPlugin](/vue/api/DataClientPlugin); install the plugin with `app.use(DataClientPlugin, options)` instead of calling it directly ([#4193](https://github.com/reactive/data-client/pull/4193)) - Vue `renderDataCompose()`'s `waitForNextUpdate()` is deprecated, since it gives up silently after 1 second and lets a test pass while still suspended; `await result` instead, or `await allSettled()` after a change, which now also waits for fetches a prop change starts ([unit testing composables](/vue/guides/unit-testing-composables#renderdatacompose-api), [#4221](https://github.com/reactive/data-client/pull/4221)) From 22c9d55c029843f21fcdf0c72e923d26092b1067 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:20:00 +0000 Subject: [PATCH 06/13] perf(endpoint): Only look for deleted refs of the items being added Checks each incoming item's pk before normalizing it, so a create into a large collection no longer scans every member when nothing was deleted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .changeset/collection-push-readd.md | 2 +- packages/endpoint/src/schemas/Collection.ts | 84 ++++++++++--------- .../src/schemas/__tests__/Collection.test.ts | 69 +++++++++++++++ 3 files changed, 116 insertions(+), 39 deletions(-) diff --git a/.changeset/collection-push-readd.md b/.changeset/collection-push-readd.md index 559bcf55fbba..23d76be18c47 100644 --- a/.changeset/collection-push-readd.md +++ b/.changeset/collection-push-readd.md @@ -6,7 +6,7 @@ Fix Collection push and unshift showing an item twice after it was deleted and added back -After an item was deleted, adding an item with the same id through [push](https://dataclient.io/rest/api/Collection#push) or [unshift](https://dataclient.io/rest/api/Collection#unshift) (for example `getList.push`) listed it twice, while [All](https://dataclient.io/rest/api/All) counted it once. This happens with lists keyed by another entity's id, like cart lines keyed by product or a list of followed users. Now adding to a collection first drops the ids of deleted items it still holds, so the re-created item shows once: at the end (push) or start (unshift). Items that aren't deleted are untouched, so pushing an id the list already shows still adds it again, as `Array.push()` would. +After an item was deleted, adding an item with the same id through [push](https://dataclient.io/rest/api/Collection#push) or [unshift](https://dataclient.io/rest/api/Collection#unshift) (for example `getList.push`) listed it twice, while [All](https://dataclient.io/rest/api/All) counted it once. This happens with lists keyed by another entity's id, like cart lines keyed by product or a list of followed users. Now, when an added item was deleted, its old id is dropped first, so it shows once: at the end (push) or start (unshift). Ids of items that weren't deleted are untouched, so pushing an id the list already shows still adds it again, as `Array.push()` would. ```ts await ctrl.fetch(CartResource.delete, { id: 'mug' }); diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 3164fca7de39..79df4b7bfb16 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -1,5 +1,6 @@ import ArraySchema from './Array.js'; import { consistentSerialize } from './consistentSerialize.js'; +import { getValues } from './utils.js'; import Values from './Values.js'; import { IDenormalizeDelegate, @@ -28,7 +29,8 @@ type NormalizedRef = { id: string; schema: string } | string; const isShallowEqual = (a: NormalizedRef, b: NormalizedRef): boolean => { // TODO: make this extensible in the child's schema - if (typeof a === 'object' && typeof b === 'object') { + // arrays keep null members, and typeof null is 'object' + if (typeof a === 'object' && typeof b === 'object' && a && b) { return a.id === b.id && a.schema === b.schema; } return a === b; @@ -359,60 +361,66 @@ function normalizeCreate( // visit instances are created on each normalize call so this will safely be reset visit.creating = true; } + const isArray = this.schema instanceof ArraySchema; + if (isArray && !Array.isArray(input)) input = [input]; + // before normalizing input, which revives an entity re-created with a deleted pk + const deleted = + isArray ? deletedRefs(this.schema, input, parent, key, delegate) : []; + const normalizedValue = this.schema.normalize(input, parent, key, delegate); // parent is args when not nested const filterCollections = (this.createCollectionFilter as any)(...args); - // before normalizing input, which revives an entity re-created with a deleted pk - if (this.schema instanceof ArraySchema) - dropDeleted(this, filterCollections, delegate); - const normalizedValue = this.schema.normalize( - !(this.schema instanceof ArraySchema) || Array.isArray(input) ? - input - : [input], - parent, - key, - delegate, - ); // add to any collections that match this const entities = delegate.getEntities(this.key); if (entities) for (const collectionKey of entities.keys()) { if (!filterCollections(JSON.parse(collectionKey))) continue; + if (deleted.length) dropRefs(this, collectionKey, deleted, delegate); delegate.mergeEntity(this, collectionKey, normalizedValue); } return normalizedValue as any; } -/** A deleted entity's ref stays in its collections, where denormalize skips it. - * Drop those refs, so re-creating that pk adds it once, like Array.push() would. +/** Refs to the incoming items whose entity was deleted. + * + * A deleted entity's ref stays in its collections, where denormalize skips it. + * Re-creating that pk would show it twice, so those refs are dropped first. */ -function dropDeleted( - collection: CollectionSchema, - filterCollections: (collectionKey: Record) => boolean, +function deletedRefs( + members: any, + input: any, + parent: any, + key: string, delegate: INormalizeDelegate, -) { - const entities = delegate.getEntities(collection.key); - if (!entities) return; - const members = collection.schema; - // polymorphic refs are { id, schema } - const single = members.isSingleSchema ? members.schema : undefined; - const isDeleted = (ref: any) => { - const entity = single ?? (ref && members.schema[ref.schema]); +): NormalizedRef[] { + const refs: NormalizedRef[] = []; + for (const item of getValues(input)) { + const schema = item && members.inferSchema(item, parent, key); + // only entities have a pk; skip nested objects and unknown members + if (typeof schema?.process !== 'function' || typeof schema.key !== 'string') + continue; + const processed = schema.process(item, parent, key, delegate.args); + if (processed === undefined) continue; + const pk = `${schema.pk(processed, parent, key, delegate.args)}`; // Invalidate stores a symbol in place of the entity - return ( - typeof entity?.key === 'string' && - typeof delegate.getEntity(entity.key, single ? ref : ref.id) === 'symbol' - ); - }; - for (const collectionKey of entities.keys()) { - if (!filterCollections(JSON.parse(collectionKey))) continue; - const existing = delegate.getEntity(collection.key, collectionKey); - if (!Array.isArray(existing) || !existing.some(isDeleted)) continue; - delegate.setEntity( - collection, - collectionKey, - existing.filter(ref => !isDeleted(ref)), + if (typeof delegate.getEntity(schema.key, pk) !== 'symbol') continue; + refs.push( + members.isSingleSchema ? pk : ( + { id: pk, schema: members.getSchemaAttribute(item, parent, key) } + ), ); } + return refs; +} + +function dropRefs( + collection: CollectionSchema, + collectionKey: string, + refs: NormalizedRef[], + delegate: INormalizeDelegate, +) { + const existing = delegate.getEntity(collection.key, collectionKey); + if (Array.isArray(existing)) + delegate.setEntity(collection, collectionKey, removeMerge(existing, refs)); } function CreateMover>( diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index ab852b4b837b..007939845b4d 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -370,6 +370,44 @@ describe(`${schema.Collection.name} normalization`, () => { ]); }); + test('push of a deleted then re-created Union member adds it once', () => { + const collectionPK = collectionUnion.pk({}, {}, '', [ + { completed: false }, + ]); + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const init: State = { + ...initialState, + entities: { + [collectionUnion.key]: { + [collectionPK]: [ + null, + { id: '1', schema: 'users' }, + { id: '2', schema: 'groups' }, + ], + }, + User: { '1': INVALID }, + Group: { '2': { id: '2', type: 'groups' } }, + }, + entitiesMeta: { + [collectionUnion.key]: { [collectionPK]: meta }, + User: { '1': meta }, + Group: { '2': meta }, + }, + indexes: {}, + }; + const state = normalize( + collectionUnion.push, + [{ id: '1', type: 'users' }], + [{ completed: false }], + init, + ); + expect(state.entities[collectionUnion.key]?.[collectionPK]).toEqual([ + null, + { id: '2', schema: 'groups' }, + { id: '1', schema: 'users' }, + ]); + }); + test('remove works updates the correct collections', () => { const init = { entities: { @@ -832,6 +870,37 @@ describe(`${schema.Collection.name} normalization`, () => { }, ); + test('re-creating a deleted id leaves collections it is not added to alone', () => { + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const init = { + entities: { + [User.schema.todos.key]: { + '{"userId":"1"}': ['10'], + '{"userId":"2"}': ['10'], + }, + Todo: { '10': INVALID }, + }, + entitiesMeta: { + [User.schema.todos.key]: { + '{"userId":"1"}': meta, + '{"userId":"2"}': meta, + }, + Todo: { '10': meta }, + }, + indexes: {}, + }; + const state = normalize( + User.schema.todos.push, + [{ id: '10', title: 'recreated' }], + [{ userId: '1' }], + init, + ); + expect(state.entities[User.schema.todos.key]).toEqual({ + '{"userId":"1"}': ['10'], + '{"userId":"2"}': ['10'], + }); + }); + test('push of an id already in the collection adds it again, like Array.push()', () => { const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init = { From 161ae10f078f31db5a6306f95ba980f5190db023 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:25:28 +0000 Subject: [PATCH 07/13] fix(endpoint): Keep Collection meta and skip id refs when dropping deleted refs Drop the deleted refs inside the merge, so the stored list keeps its meta and out-of-order responses still reorder. Ids passed as refs aren't processed, since normalize passes them through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/Collection.ts | 38 ++++++++--- .../src/schemas/__tests__/Collection.test.ts | 65 +++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 79df4b7bfb16..0423e7acf5c3 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -370,12 +370,12 @@ function normalizeCreate( // parent is args when not nested const filterCollections = (this.createCollectionFilter as any)(...args); // add to any collections that match this + const target = deleted.length ? withoutRefs(this, deleted) : this; const entities = delegate.getEntities(this.key); if (entities) for (const collectionKey of entities.keys()) { if (!filterCollections(JSON.parse(collectionKey))) continue; - if (deleted.length) dropRefs(this, collectionKey, deleted, delegate); - delegate.mergeEntity(this, collectionKey, normalizedValue); + delegate.mergeEntity(target, collectionKey, normalizedValue); } return normalizedValue as any; } @@ -394,7 +394,9 @@ function deletedRefs( ): NormalizedRef[] { const refs: NormalizedRef[] = []; for (const item of getValues(input)) { - const schema = item && members.inferSchema(item, parent, key); + // refs (ids) pass through normalize, so they can't revive an entity + if (typeof item !== 'object' || item === null) continue; + const schema = members.inferSchema(item, parent, key); // only entities have a pk; skip nested objects and unknown members if (typeof schema?.process !== 'function' || typeof schema.key !== 'string') continue; @@ -412,15 +414,33 @@ function deletedRefs( return refs; } -function dropRefs( +/** Merges like `collection`, after dropping `refs` from the stored list */ +function withoutRefs( collection: CollectionSchema, - collectionKey: string, refs: NormalizedRef[], - delegate: INormalizeDelegate, ) { - const existing = delegate.getEntity(collection.key, collectionKey); - if (Array.isArray(existing)) - delegate.setEntity(collection, collectionKey, removeMerge(existing, refs)); + const drop = (existing: any) => + Array.isArray(existing) ? removeMerge(existing, refs) : existing; + return Object.create(collection, { + merge: { + value: (existing: any, incoming: any) => + collection.merge(drop(existing), incoming), + }, + mergeWithStore: { + value: ( + existingMeta: any, + incomingMeta: any, + existing: any, + incoming: any, + ) => + collection.mergeWithStore( + existingMeta, + incomingMeta, + drop(existing), + incoming, + ), + }, + }); } function CreateMover>( diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 007939845b4d..57fa436153ca 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -901,6 +901,71 @@ describe(`${schema.Collection.name} normalization`, () => { }); }); + test('re-creating a deleted id keeps newer collection meta', () => { + const init = { + entities: { + [User.schema.todos.key]: { '{"userId":"1"}': ['10', '5'] }, + Todo: { + '5': { id: '5', title: 'finish collections' }, + '10': INVALID, + }, + }, + entitiesMeta: { + [User.schema.todos.key]: { + '{"userId":"1"}': { date: 100, expiresAt: Infinity, fetchedAt: 100 }, + }, + Todo: { + '5': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + '10': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + }, + }, + indexes: {}, + }; + // a create response that was fetched before the list + const state = normalize( + User.schema.todos.push, + [{ id: '10', title: 'recreated' }], + [{ userId: '1' }], + init, + { date: 10, expiresAt: Infinity, fetchedAt: 10 }, + ); + expect(state.entities[User.schema.todos.key]['{"userId":"1"}']).toEqual([ + '10', + '5', + ]); + expect( + state.entitiesMeta[User.schema.todos.key]['{"userId":"1"}'].fetchedAt, + ).toBe(100); + }); + + test('push of ids does not process them', () => { + const process = jest.spyOn(Todo, 'process'); + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const init = { + entities: { + [User.schema.todos.key]: { '{"userId":"1"}': ['5'] }, + Todo: { '5': { id: '5', title: 'finish collections' } }, + }, + entitiesMeta: { + [User.schema.todos.key]: { '{"userId":"1"}': meta }, + Todo: { '5': meta }, + }, + indexes: {}, + }; + const state = normalize( + User.schema.todos.push, + ['5'], + [{ userId: '1' }], + init, + ); + expect(process).not.toHaveBeenCalled(); + expect(state.entities[User.schema.todos.key]['{"userId":"1"}']).toEqual([ + '5', + '5', + ]); + process.mockRestore(); + }); + test('push of an id already in the collection adds it again, like Array.push()', () => { const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init = { From a7cfcb2ae17839cf09dd4dd139fd2b6ac3a7fcb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:28:21 +0000 Subject: [PATCH 08/13] test(endpoint): Cover Collection create paths for deleted refs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- .../src/schemas/__tests__/Collection.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 57fa436153ca..e86b43deae42 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -938,6 +938,63 @@ describe(`${schema.Collection.name} normalization`, () => { ).toBe(100); }); + test('two pushes in one response each add their item once', () => { + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const init = { + entities: { + [userTodos.key]: { '{"userId":"1"}': ['11', '5'] }, + Todo: { '5': { id: '5', title: 'finish collections' }, '11': INVALID }, + }, + entitiesMeta: { + [userTodos.key]: { '{"userId":"1"}': meta }, + Todo: { '5': meta, '11': meta }, + }, + indexes: {}, + }; + const state = normalize( + { first: userTodos.push, second: userTodos.push }, + { first: [{ id: '10' }], second: [{ id: '11' }] }, + [{ userId: '1' }], + init, + ); + expect(state.entities[userTodos.key]['{"userId":"1"}']).toEqual([ + '5', + '10', + '11', + ]); + }); + + test('push skips items whose process() invalidates them', () => { + class Draft extends IDEntity { + static key = 'Draft'; + static process(input: any) { + return input.discard ? undefined : { ...input }; + } + } + const drafts = new Collection([Draft]); + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const state = normalize(drafts.push, [{ id: '1', discard: true }], [{}], { + entities: { [drafts.key]: { '{}': ['1'] }, Draft: { '1': INVALID } }, + entitiesMeta: { [drafts.key]: { '{}': meta }, Draft: { '1': meta } }, + indexes: {}, + }); + expect(state.entities[drafts.key]['{}']).toEqual(['1', '1']); + }); + + test('push into a collection of nested objects appends', () => { + const assignments = new Collection([{ todo: Todo }]); + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + const state = normalize(assignments.push, [{ todo: { id: '2' } }], [{}], { + entities: { [assignments.key]: { '{}': [{ todo: '1' }] } }, + entitiesMeta: { [assignments.key]: { '{}': meta } }, + indexes: {}, + }); + expect(state.entities[assignments.key]['{}']).toEqual([ + { todo: '1' }, + { todo: '2' }, + ]); + }); + test('push of ids does not process them', () => { const process = jest.spyOn(Todo, 'process'); const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; From 68cbc3365295ab1f9961aec1001d2b46aa5b70c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:29:34 +0000 Subject: [PATCH 09/13] test(endpoint): Fix possibly undefined meta in Collection test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/__tests__/Collection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index e86b43deae42..10a4c7a36cb0 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -934,7 +934,7 @@ describe(`${schema.Collection.name} normalization`, () => { '5', ]); expect( - state.entitiesMeta[User.schema.todos.key]['{"userId":"1"}'].fetchedAt, + state.entitiesMeta[User.schema.todos.key]['{"userId":"1"}']?.fetchedAt, ).toBe(100); }); From f9050d136fca4d6bfc8a9923f5664239a86a1e69 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:32:02 +0000 Subject: [PATCH 10/13] refactor(endpoint): Take deleted refs from the normalized output Record which incoming items were deleted, then drop the refs normalize produced for them, so the ref format lives in one place. Hoist the test meta fixture and drop a test the Union deletion test already covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/Collection.ts | 35 ++++++------ .../src/schemas/__tests__/Collection.test.ts | 57 ++++--------------- 2 files changed, 28 insertions(+), 64 deletions(-) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 0423e7acf5c3..6340f16de52a 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -1,6 +1,5 @@ import ArraySchema from './Array.js'; import { consistentSerialize } from './consistentSerialize.js'; -import { getValues } from './utils.js'; import Values from './Values.js'; import { IDenormalizeDelegate, @@ -365,12 +364,18 @@ function normalizeCreate( if (isArray && !Array.isArray(input)) input = [input]; // before normalizing input, which revives an entity re-created with a deleted pk const deleted = - isArray ? deletedRefs(this.schema, input, parent, key, delegate) : []; + isArray ? deletedIndexes(this.schema, input, parent, key, delegate) : []; const normalizedValue = this.schema.normalize(input, parent, key, delegate); // parent is args when not nested const filterCollections = (this.createCollectionFilter as any)(...args); // add to any collections that match this - const target = deleted.length ? withoutRefs(this, deleted) : this; + const target = + deleted.length ? + withoutRefs( + this, + deleted.map(i => normalizedValue[i]), + ) + : this; const entities = delegate.getEntities(this.key); if (entities) for (const collectionKey of entities.keys()) { @@ -380,20 +385,21 @@ function normalizeCreate( return normalizedValue as any; } -/** Refs to the incoming items whose entity was deleted. +/** Indexes of the incoming items whose entity was deleted. * * A deleted entity's ref stays in its collections, where denormalize skips it. - * Re-creating that pk would show it twice, so those refs are dropped first. + * Re-creating that pk would show it twice, so its old ref is dropped first. */ -function deletedRefs( +function deletedIndexes( members: any, - input: any, + input: any[], parent: any, key: string, delegate: INormalizeDelegate, -): NormalizedRef[] { - const refs: NormalizedRef[] = []; - for (const item of getValues(input)) { +): number[] { + const indexes: number[] = []; + for (let i = 0; i < input.length; i++) { + const item = input[i]; // refs (ids) pass through normalize, so they can't revive an entity if (typeof item !== 'object' || item === null) continue; const schema = members.inferSchema(item, parent, key); @@ -404,14 +410,9 @@ function deletedRefs( if (processed === undefined) continue; const pk = `${schema.pk(processed, parent, key, delegate.args)}`; // Invalidate stores a symbol in place of the entity - if (typeof delegate.getEntity(schema.key, pk) !== 'symbol') continue; - refs.push( - members.isSingleSchema ? pk : ( - { id: pk, schema: members.getSchemaAttribute(item, parent, key) } - ), - ); + if (typeof delegate.getEntity(schema.key, pk) === 'symbol') indexes.push(i); } - return refs; + return indexes; } /** Merges like `collection`, after dropping `refs` from the stored list */ diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 10a4c7a36cb0..3b8f6725022e 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -109,6 +109,8 @@ test('key works with custom schema', () => { }); describe(`${schema.Collection.name} normalization`, () => { + const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; + let warnSpy: jest.Spied; afterEach(() => { warnSpy.mockRestore(); @@ -338,43 +340,10 @@ describe(`${schema.Collection.name} normalization`, () => { ]); }); - test('push works when the collection holds null', () => { - const collectionPK = collectionUnion.pk({}, {}, '', [ - { completed: false }, - ]); - const meta = { date: 1557831718135, expiresAt: Infinity, fetchedAt: 0 }; - const init: State = { - ...initialState, - entities: { - [collectionUnion.key]: { - [collectionPK]: [null, { id: '2', schema: 'groups' }], - }, - Group: { '2': { id: '2', type: 'groups' } }, - }, - entitiesMeta: { - [collectionUnion.key]: { [collectionPK]: meta }, - Group: { '2': meta }, - }, - indexes: {}, - }; - const state = normalize( - collectionUnion.push, - [{ id: '1', type: 'users' }], - [{ completed: false }], - init, - ); - expect(state.entities[collectionUnion.key]?.[collectionPK]).toEqual([ - null, - { id: '2', schema: 'groups' }, - { id: '1', schema: 'users' }, - ]); - }); - test('push of a deleted then re-created Union member adds it once', () => { const collectionPK = collectionUnion.pk({}, {}, '', [ { completed: false }, ]); - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init: State = { ...initialState, entities: { @@ -830,16 +799,16 @@ describe(`${schema.Collection.name} normalization`, () => { ]); }); - test.each([ + test.each<['push' | 'unshift', string[], string[]]>([ ['push', ['10', '5'], ['5', '10']], ['unshift', ['5', '10'], ['10', '5']], - ] as const)( + ])( '%s of a deleted then re-created id adds it once', (method, before, after) => { const init = { entities: { [User.schema.todos.key]: { - '{"userId":"1"}': [...before], + '{"userId":"1"}': before, }, Todo: { '5': { id: '5', title: 'finish collections' }, @@ -849,11 +818,11 @@ describe(`${schema.Collection.name} normalization`, () => { }, entitiesMeta: { [User.schema.todos.key]: { - '{"userId":"1"}': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + '{"userId":"1"}': meta, }, Todo: { - '5': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, - '10': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + '5': meta, + '10': meta, }, }, indexes: {}, @@ -871,7 +840,6 @@ describe(`${schema.Collection.name} normalization`, () => { ); test('re-creating a deleted id leaves collections it is not added to alone', () => { - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init = { entities: { [User.schema.todos.key]: { @@ -915,8 +883,8 @@ describe(`${schema.Collection.name} normalization`, () => { '{"userId":"1"}': { date: 100, expiresAt: Infinity, fetchedAt: 100 }, }, Todo: { - '5': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, - '10': { date: 0, expiresAt: Infinity, fetchedAt: 0 }, + '5': meta, + '10': meta, }, }, indexes: {}, @@ -939,7 +907,6 @@ describe(`${schema.Collection.name} normalization`, () => { }); test('two pushes in one response each add their item once', () => { - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init = { entities: { [userTodos.key]: { '{"userId":"1"}': ['11', '5'] }, @@ -972,7 +939,6 @@ describe(`${schema.Collection.name} normalization`, () => { } } const drafts = new Collection([Draft]); - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const state = normalize(drafts.push, [{ id: '1', discard: true }], [{}], { entities: { [drafts.key]: { '{}': ['1'] }, Draft: { '1': INVALID } }, entitiesMeta: { [drafts.key]: { '{}': meta }, Draft: { '1': meta } }, @@ -983,7 +949,6 @@ describe(`${schema.Collection.name} normalization`, () => { test('push into a collection of nested objects appends', () => { const assignments = new Collection([{ todo: Todo }]); - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const state = normalize(assignments.push, [{ todo: { id: '2' } }], [{}], { entities: { [assignments.key]: { '{}': [{ todo: '1' }] } }, entitiesMeta: { [assignments.key]: { '{}': meta } }, @@ -997,7 +962,6 @@ describe(`${schema.Collection.name} normalization`, () => { test('push of ids does not process them', () => { const process = jest.spyOn(Todo, 'process'); - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init = { entities: { [User.schema.todos.key]: { '{"userId":"1"}': ['5'] }, @@ -1024,7 +988,6 @@ describe(`${schema.Collection.name} normalization`, () => { }); test('push of an id already in the collection adds it again, like Array.push()', () => { - const meta = { date: 0, expiresAt: Infinity, fetchedAt: 0 }; const init = { entities: { [User.schema.todos.key]: { '{"userId":"1"}': ['5'] }, From 186ac77db2b593727f84ff1021580ebc5c193415 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:37:37 +0000 Subject: [PATCH 11/13] refactor(endpoint): Name deleted indexes and refs separately State that ArraySchema.normalize keeps one ref per input item, and test a push where a deleted item follows a live one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/Collection.ts | 12 +++---- .../src/schemas/__tests__/Collection.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 6340f16de52a..9852a182a81d 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -363,19 +363,15 @@ function normalizeCreate( const isArray = this.schema instanceof ArraySchema; if (isArray && !Array.isArray(input)) input = [input]; // before normalizing input, which revives an entity re-created with a deleted pk - const deleted = + const deletedAt = isArray ? deletedIndexes(this.schema, input, parent, key, delegate) : []; const normalizedValue = this.schema.normalize(input, parent, key, delegate); // parent is args when not nested const filterCollections = (this.createCollectionFilter as any)(...args); + // ArraySchema.normalize maps input 1:1, so normalizedValue[i] is input[i]'s ref + const deletedRefs: NormalizedRef[] = deletedAt.map(i => normalizedValue[i]); // add to any collections that match this - const target = - deleted.length ? - withoutRefs( - this, - deleted.map(i => normalizedValue[i]), - ) - : this; + const target = deletedRefs.length ? withoutRefs(this, deletedRefs) : this; const entities = delegate.getEntities(this.key); if (entities) for (const collectionKey of entities.keys()) { diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 3b8f6725022e..73e2a771ab78 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -839,6 +839,37 @@ describe(`${schema.Collection.name} normalization`, () => { }, ); + test('push drops only the deleted ids among several items', () => { + const init = { + entities: { + [User.schema.todos.key]: { '{"userId":"1"}': ['10', '5'] }, + Todo: { + '5': { id: '5', title: 'finish collections' }, + '10': INVALID, + }, + }, + entitiesMeta: { + [User.schema.todos.key]: { '{"userId":"1"}': meta }, + Todo: { '5': meta, '10': meta }, + }, + indexes: {}, + }; + const state = normalize( + User.schema.todos.push, + [ + { id: '5', title: 'again' }, + { id: '10', title: 'recreated' }, + ], + [{ userId: '1' }], + init, + ); + expect(state.entities[User.schema.todos.key]['{"userId":"1"}']).toEqual([ + '5', + '5', + '10', + ]); + }); + test('re-creating a deleted id leaves collections it is not added to alone', () => { const init = { entities: { From b646a8b092f82d8667cb872c0c527089f3f9d225 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:47:25 +0000 Subject: [PATCH 12/13] test(endpoint): Share Collection push test state Build the repeated todos lists and their meta with one helper, and drop a call-site comment the helper's docblock already covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/Collection.ts | 2 +- .../src/schemas/__tests__/Collection.test.ts | 131 +++++------------- 2 files changed, 38 insertions(+), 95 deletions(-) diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 9852a182a81d..b5eb159fde75 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -362,7 +362,7 @@ function normalizeCreate( } const isArray = this.schema instanceof ArraySchema; if (isArray && !Array.isArray(input)) input = [input]; - // before normalizing input, which revives an entity re-created with a deleted pk + // before normalize, which revives the entity const deletedAt = isArray ? deletedIndexes(this.schema, input, parent, key, delegate) : []; const normalizedValue = this.schema.normalize(input, parent, key, delegate); diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 73e2a771ab78..a24becd9c676 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -799,34 +799,34 @@ describe(`${schema.Collection.name} normalization`, () => { ]); }); + const todo5 = { '5': { id: '5', title: 'finish collections' } }; + /** User.schema.todos lists and Todo entities, each stored with `meta` */ + const todosState = ( + lists: Record, + todos: Record, + listMeta = meta, + ) => ({ + entities: { [User.schema.todos.key]: lists, Todo: todos }, + entitiesMeta: { + [User.schema.todos.key]: Object.fromEntries( + Object.keys(lists).map(pk => [pk, listMeta]), + ), + Todo: Object.fromEntries(Object.keys(todos).map(pk => [pk, meta])), + }, + indexes: {}, + }); + test.each<['push' | 'unshift', string[], string[]]>([ ['push', ['10', '5'], ['5', '10']], ['unshift', ['5', '10'], ['10', '5']], ])( '%s of a deleted then re-created id adds it once', (method, before, after) => { - const init = { - entities: { - [User.schema.todos.key]: { - '{"userId":"1"}': before, - }, - Todo: { - '5': { id: '5', title: 'finish collections' }, - // deleted, so the collection still holds its id - '10': INVALID, - }, - }, - entitiesMeta: { - [User.schema.todos.key]: { - '{"userId":"1"}': meta, - }, - Todo: { - '5': meta, - '10': meta, - }, - }, - indexes: {}, - }; + const init = todosState( + { '{"userId":"1"}': before }, + // deleted, so the collection still holds its id + { ...todo5, '10': INVALID }, + ); const state = normalize( User.schema.todos[method], [{ id: '10', title: 'recreated' }], @@ -840,20 +840,10 @@ describe(`${schema.Collection.name} normalization`, () => { ); test('push drops only the deleted ids among several items', () => { - const init = { - entities: { - [User.schema.todos.key]: { '{"userId":"1"}': ['10', '5'] }, - Todo: { - '5': { id: '5', title: 'finish collections' }, - '10': INVALID, - }, - }, - entitiesMeta: { - [User.schema.todos.key]: { '{"userId":"1"}': meta }, - Todo: { '5': meta, '10': meta }, - }, - indexes: {}, - }; + const init = todosState( + { '{"userId":"1"}': ['10', '5'] }, + { ...todo5, '10': INVALID }, + ); const state = normalize( User.schema.todos.push, [ @@ -871,23 +861,10 @@ describe(`${schema.Collection.name} normalization`, () => { }); test('re-creating a deleted id leaves collections it is not added to alone', () => { - const init = { - entities: { - [User.schema.todos.key]: { - '{"userId":"1"}': ['10'], - '{"userId":"2"}': ['10'], - }, - Todo: { '10': INVALID }, - }, - entitiesMeta: { - [User.schema.todos.key]: { - '{"userId":"1"}': meta, - '{"userId":"2"}': meta, - }, - Todo: { '10': meta }, - }, - indexes: {}, - }; + const init = todosState( + { '{"userId":"1"}': ['10'], '{"userId":"2"}': ['10'] }, + { '10': INVALID }, + ); const state = normalize( User.schema.todos.push, [{ id: '10', title: 'recreated' }], @@ -901,25 +878,11 @@ describe(`${schema.Collection.name} normalization`, () => { }); test('re-creating a deleted id keeps newer collection meta', () => { - const init = { - entities: { - [User.schema.todos.key]: { '{"userId":"1"}': ['10', '5'] }, - Todo: { - '5': { id: '5', title: 'finish collections' }, - '10': INVALID, - }, - }, - entitiesMeta: { - [User.schema.todos.key]: { - '{"userId":"1"}': { date: 100, expiresAt: Infinity, fetchedAt: 100 }, - }, - Todo: { - '5': meta, - '10': meta, - }, - }, - indexes: {}, - }; + const init = todosState( + { '{"userId":"1"}': ['10', '5'] }, + { ...todo5, '10': INVALID }, + { date: 100, expiresAt: Infinity, fetchedAt: 100 }, + ); // a create response that was fetched before the list const state = normalize( User.schema.todos.push, @@ -993,17 +956,7 @@ describe(`${schema.Collection.name} normalization`, () => { test('push of ids does not process them', () => { const process = jest.spyOn(Todo, 'process'); - const init = { - entities: { - [User.schema.todos.key]: { '{"userId":"1"}': ['5'] }, - Todo: { '5': { id: '5', title: 'finish collections' } }, - }, - entitiesMeta: { - [User.schema.todos.key]: { '{"userId":"1"}': meta }, - Todo: { '5': meta }, - }, - indexes: {}, - }; + const init = todosState({ '{"userId":"1"}': ['5'] }, todo5); const state = normalize( User.schema.todos.push, ['5'], @@ -1019,17 +972,7 @@ describe(`${schema.Collection.name} normalization`, () => { }); test('push of an id already in the collection adds it again, like Array.push()', () => { - const init = { - entities: { - [User.schema.todos.key]: { '{"userId":"1"}': ['5'] }, - Todo: { '5': { id: '5', title: 'finish collections' } }, - }, - entitiesMeta: { - [User.schema.todos.key]: { '{"userId":"1"}': meta }, - Todo: { '5': meta }, - }, - indexes: {}, - }; + const init = todosState({ '{"userId":"1"}': ['5'] }, todo5); const state = normalize( User.schema.todos.push, [{ id: '5', title: 'finish collections' }], From d140e03b665dadadedbfb974bd5164fd7902159d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:49:46 +0000 Subject: [PATCH 13/13] test(endpoint): Avoid immutable's Record in Collection test types Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gC38VCqJFH8PMKWL49Le5 --- packages/endpoint/src/schemas/__tests__/Collection.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index a24becd9c676..60afe24cd725 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -802,8 +802,8 @@ describe(`${schema.Collection.name} normalization`, () => { const todo5 = { '5': { id: '5', title: 'finish collections' } }; /** User.schema.todos lists and Todo entities, each stored with `meta` */ const todosState = ( - lists: Record, - todos: Record, + lists: { [pk: string]: string[] }, + todos: { [pk: string]: unknown }, listMeta = meta, ) => ({ entities: { [User.schema.todos.key]: lists, Todo: todos },