From c6b4b6fba52554099046ea59a1885446d60fe65f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:28:57 +0000 Subject: [PATCH 1/6] fix(core): Type batch controller.set() rows and reject multi-Entity arrays The Array overload of controller.set() accepted any Schema[] with untyped rows, so [User, Admin] (throws in dev, uses User in prod), [[User]], [() => 1] and [{ bogus: 1 }] typechecked, as did ctrl.set([User], [1]). Now the schema must hold a single Entity or Union, in an array, a schema.Array, or a schema.Values (which takes rows keyed by id). Rows are typed by the Entity's fields, with number/string coercion; object values stay open since rows are raw input. Polymorphic rows may carry a discriminator that is not an Entity field, and untyped Entities like EntityMixin accept any row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MbthieGWrKshPjDkH7mGz2 --- .changeset/controller-set-array.md | 7 ++ docs/core/api/Controller.md | 16 ++- docs/rest/api/Values.md | 12 ++ packages/core/src/controller/Controller.ts | 93 +++++++++++++-- .../src/hooks/__tests__/useController/set.tsx | 110 +++++++++++++++++- website/blog/2026-10-03-v0.19-batch-set.md | 11 ++ 6 files changed, 234 insertions(+), 15 deletions(-) diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index b602974a0099..f12c867c7fc1 100644 --- a/.changeset/controller-set-array.md +++ b/.changeset/controller-set-array.md @@ -8,6 +8,8 @@ Fix `controller.set()` types for Array schemas `controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one store update: each row merges with its stored entity, and entities not in `rows` stay. +Rows are typed by the Entity's fields. The schema must be one Entity or [Union](https://dataclient.io/rest/api/Union) in an Array or [Values](https://dataclient.io/rest/api/Values) (which takes an object keyed by id). + ```ts // Before: TypeScript error on [Ticker], so batches became one set() per row for (const row of rows) { @@ -16,4 +18,9 @@ for (const row of rows) { // After: one store update ctrl.set([Ticker], rows); +ctrl.set(new schema.Values(Ticker), { 'BTC-USD': row }); + +// TypeScript errors +ctrl.set([Ticker, Product], rows); // use a Union for mixed types +ctrl.set([Ticker], [{ price: true }]); // price is a number ``` diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 1c25d6f95a29..d40ad1fff43c 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -366,7 +366,7 @@ function UserName() { ### set(queryable, ...args, value) {#set} -Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#schema-overview), or many entities at once with an [Array](/rest/api/Array) schema. +Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#schema-overview), or many entities at once with an [Array](/rest/api/Array) or [Values](/rest/api/Values) schema. ```ts ctrl.set( @@ -400,7 +400,19 @@ ctrl.set( ); ``` -Array schemas take no `args` (so [Entity.pk()](/rest/api/Entity#pk) and [Entity.process()](/rest/api/Entity#process) +Rows are typed by the Entity's fields; numbers and strings may be either, and object, array and Date values are not +checked since rows are raw input. The schema must hold one Entity or [Union](/rest/api/Union): `[User, Admin]` is a +TypeScript error, so use `[new schema.Union({ user: User, admin: Admin }, 'type')]` for mixed lists. +[Values](/rest/api/Values) schemas take an object of rows instead: + +```ts +ctrl.set(new schema.Values(Todo), { + '5': { id: '5', completed: true }, + '6': { id: '6', completed: false }, +}); +``` + +Array and Values schemas take no `args` (so [Entity.pk()](/rest/api/Entity#pk) and [Entity.process()](/rest/api/Entity#process) receive `[]`) and no updater function. Rows that share a pk merge in list order, without [Entity.shouldReorder()](/rest/api/Entity#shouldreorder). Use this instead of calling `set()` once per row, such as when [batching high-frequency stream updates](../concepts/managers.md#batching). diff --git a/docs/rest/api/Values.md b/docs/rest/api/Values.md index 80549eb311f8..001fe6297713 100644 --- a/docs/rest/api/Values.md +++ b/docs/rest/api/Values.md @@ -69,6 +69,18 @@ render(); +### Updating many entities + +Use Values with [Controller.set()](/docs/api/Controller#set-array) to write many entities in one store update, +without an endpoint. + +```ts +ctrl.set(getItems.schema, { + firstThing: { id: 1 }, + secondThing: { id: 2 }, +}); +``` + ### Polymorphic types If your input data is an object that has values of more than one type of entity, but their schema is not easily defined by the key, you can use a mapping of schema, much like [Union](./Union.md) and [schema.Array](./Array.md). diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index a8da8f7ab97d..69e099e883d7 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -234,19 +234,13 @@ export default class Controller< ): Promise; /** - * Sets every item of an Array schema like `[Entity]` in one normalize. - * @see https://dataclient.io/docs/api/Controller#set + * Sets every row of an Array or Values of one Entity (or Union) in one normalize. + * @see https://dataclient.io/docs/api/Controller#set-array */ - set< - S extends - | Schema[] - | { - normalize(...args: any): any[]; - queryKey(...args: any): undefined; - // excludes Entity, whose `any` returns match the members above - pk?: never; - }, - >(schema: S, value: readonly {}[]): Promise; + set( + schema: S, + value: SetManyValue, + ): Promise; set( schema: S, @@ -838,3 +832,78 @@ function extractStateAndArgs(rest: readonly unknown[]): [State, any[]] { // this is typescript generics breaking return [rest[l - 1] as State, args]; } + +/** Matches Entity classes (same members Denormalize<> checks) */ +interface EntityLike { + createIfValid(...args: any): any; + pk(...args: any): any; + readonly key: string; + prototype: any; +} + +/** Entity-like schemas whose rows each normalize to one stored entity */ +type SetEntitySchema = + | EntityLike + // Union + | { + readonly schema: { readonly [k: string]: EntityLike }; + queryKey(...args: any): { schema: string }; + pk?: never; + }; + +/** `[Entity]`, `schema.Array(Entity)` or `schema.Values(Entity)` (or of a Union) */ +type SetManySchema = + | readonly SetEntitySchema[] + | { + readonly schema: SetEntitySchema | { readonly [k: string]: EntityLike }; + schemaKey(): string; + denormalize(...args: any): readonly any[] | { readonly [k: string]: any }; + queryKey(...args: any): undefined; + // excludes Entity, whose `any` returns match the members above + pk?: never; + }; + +type IsUnion = + T extends unknown ? + [U] extends [T] ? + false + : true + : never; + +type FunctionKeys = { + [K in keyof U]: U[K] extends (...args: any) => any ? K : never; +}[keyof U]; + +/** Raw input for one field: numbers and strings coerce; objects are pre-normalize */ +type SetField = + T extends number ? T | string + : T extends string ? T | number + : T extends boolean | null | undefined ? T + : unknown; + +/** Fields of one row */ +type SetRow = + // EntityMixin and other untyped entities + 0 extends 1 & U ? { readonly [k: string]: any } + : U extends unknown ? + { readonly [K in Exclude>]?: SetField } + : never; + +/** Polymorphic rows may carry a discriminator that is not an Entity field */ +type SetRowOf = + Sch extends EntityLike ? SetRow + : SetRow & { readonly [k: string]: unknown }; + +type SetManyValue = + S extends readonly (infer E)[] ? + true extends IsUnion ? + readonly { 'Use a Union schema for several Entity types': never }[] + : readonly SetRowOf>[] + : S extends ( + { readonly schema: infer Sch; denormalize(...args: any): infer R } + ) ? + R extends readonly (infer U)[] ? readonly SetRowOf[] + : R extends { readonly [k: string]: infer U } ? + { readonly [k: string]: SetRowOf } + : never + : never; diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index 32e6cc70615b..8eb28eb0dc35 100644 --- a/packages/react/src/hooks/__tests__/useController/set.tsx +++ b/packages/react/src/hooks/__tests__/useController/set.tsx @@ -1,6 +1,14 @@ import { DataProvider } from '@data-client/react'; import { schema } from '@data-client/rest'; -import { CoolerArticle } from '__tests__/new'; +import { + ArticleFromMixin, + CoolerArticle, + FirstUnion, + SecondUnion, + UnionResource, + UnionSchema, + User, +} from '__tests__/new'; import nock from 'nock'; import { useQuery } from '../..'; @@ -121,6 +129,106 @@ describe('set', () => { controller.set([CoolerArticle], payload); // @ts-expect-error entities need args, even with an array value controller.set(CoolerArticle, [payload]); + + // rows are typed by the Entity + controller.set([CoolerArticle], [{ id: '5', title: 'coerced' }]); + controller.set([CoolerArticle], [{ author: { id: 1, username: 'x' } }]); + // @ts-expect-error rows must be objects + controller.set([CoolerArticle], [1, 'str']); + // @ts-expect-error title is a string + controller.set([CoolerArticle], [{ id: 5, title: false }]); + // @ts-expect-error unknown field + controller.set([CoolerArticle], [{ id: 5, bogus: 1 }]); + const articles = new schema.Array(CoolerArticle); + // @ts-expect-error title is a string + controller.set(articles, [{ id: 5, title: false }]); + + // @ts-expect-error only one Entity per array; use a Union for several + controller.set([CoolerArticle, User], [{ id: 5 }]); + const mixed = [CoolerArticle, User]; + // @ts-expect-error only one Entity per array + controller.set(mixed, [{ id: 5 }]); + // @ts-expect-error nested arrays are not rows of entities + controller.set([[CoolerArticle]], [[{ id: 5 }]]); + // @ts-expect-error functions are not schemas of entities + controller.set([() => 1], [{ id: 5 }]); + // @ts-expect-error plain objects are not entities + controller.set([{ bogus: 1 }], [{ bogus: 5 }]); + // @ts-expect-error schema.Object is not a list + controller.set(new schema.Object({ a: CoolerArticle }), [{ id: 5 }]); + // @ts-expect-error Values take a keyed object, not an array + controller.set(new schema.Values(CoolerArticle), [{ id: 5 }]); + // @ts-expect-error Arrays take an array, not a keyed object + controller.set([CoolerArticle], { 5: { id: 5 } }); + + // non-literal array schemas, like an Endpoint's + const list: (typeof CoolerArticle)[] = [CoolerArticle]; + controller.set(list, [{ id: 5 }]); + controller.set(UnionResource.getList.schema, [ + { id: '1', type: 'first', firstOnlyField: 1 }, + ]); + }; + }); + + it('should batch set polymorphic and Values schemas', async () => { + const { controller } = renderDataClient(() => null); + let promise: any; + act(() => { + promise = controller.set( + [UnionSchema], + [ + { id: '1', body: 'one', type: 'first' }, + { id: '2', body: 'two', type: 'second' }, + ], + ); + }); + await act(() => promise); + act(() => { + promise = controller.set( + new schema.Array({ first: FirstUnion, second: SecondUnion }, 'type'), + [{ id: '3', body: 'three', type: 'first' }], + ); + }); + await act(() => promise); + act(() => { + promise = controller.set(new schema.Values(CoolerArticle), { + a: { id: 7, title: 'seven' }, + b: { id: 8, title: 'eight' }, + }); + }); + await act(() => promise); + const state = controller.getState(); + expect(controller.get(FirstUnion, { id: '1' }, state)?.body).toBe('one'); + expect(controller.get(SecondUnion, { id: '2' }, state)?.body).toBe('two'); + expect(controller.get(FirstUnion, { id: '3' }, state)?.body).toBe('three'); + expect(controller.get(CoolerArticle, { id: 7 }, state)?.title).toBe( + 'seven', + ); + expect(controller.get(CoolerArticle, { id: 8 }, state)?.title).toBe( + 'eight', + ); + + // type tests + () => { + // @ts-expect-error body is a string + controller.set([UnionSchema], [{ id: '1', body: false }]); + // discriminators read by a schemaAttribute function need not be fields + const byKind = new schema.Union( + { first: FirstUnion, second: SecondUnion }, + (input: any) => input.kind, + ); + controller.set([byKind], [{ id: '1', kind: 'first' }]); + controller.set( + new schema.Array( + { first: FirstUnion, second: SecondUnion }, + (input: any) => input.kind, + ), + [{ id: '1', kind: 'first' }], + ); + // EntityMixin rows + controller.set([ArticleFromMixin], [{ id: 5, title: 'mixin' }]); + // @ts-expect-error title is a string + controller.set(new schema.Values(CoolerArticle), { a: { title: false } }); }; }); 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 2c0f61f114e9..30b9d91f0242 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -38,6 +38,17 @@ This works at runtime before v0.19, but only typechecked for single entities, wh per row or a push-only endpoint with `setResponse()`. Array schemas take no `args` and no updater function. [#4103](https://github.com/reactive/data-client/pull/4103) +Rows are typed by the Entity's fields, and the schema must hold a single Entity or [Union](/rest/api/Union). +[Values](/rest/api/Values) schemas work too, taking rows keyed by id. + +```ts +ctrl.set(new schema.Values(Ticker), { 'BTC-USD': row }); + +// TypeScript errors +ctrl.set([Ticker, Product], rows); // use a Union for mixed types +ctrl.set([Ticker], [{ price: true }]); // price is a number +``` + ### Performance {#batch-set-performance} Each `set()` is a separate store update, and every store update copies that entity type's table. Writing rows one From 40277b64623b3b5404f92ce84bd5035ef46d3c64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:20:20 +0000 Subject: [PATCH 2/6] refactor(core): Simplify batch set() types Name the Entity map shape once, drop constraint members the value type already enforces, reuse Denormalize<> for Array/Values rows, and collapse SetField's tail branches. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MbthieGWrKshPjDkH7mGz2 --- packages/core/src/controller/Controller.ts | 25 +++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index 69e099e883d7..dbbbd71d8837 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -833,7 +833,8 @@ function extractStateAndArgs(rest: readonly unknown[]): [State, any[]] { return [rest[l - 1] as State, args]; } -/** Matches Entity classes (same members Denormalize<> checks) */ +/** Matches Entity classes (same members Denormalize<> checks). + * Not EntityInterface: Entity's declared pk() takes mutable `args`, which fails its readonly `args`. */ interface EntityLike { createIfValid(...args: any): any; pk(...args: any): any; @@ -841,12 +842,14 @@ interface EntityLike { prototype: any; } +type EntityMapLike = { readonly [k: string]: EntityLike }; + /** Entity-like schemas whose rows each normalize to one stored entity */ type SetEntitySchema = | EntityLike // Union | { - readonly schema: { readonly [k: string]: EntityLike }; + readonly schema: EntityMapLike; queryKey(...args: any): { schema: string }; pk?: never; }; @@ -855,9 +858,7 @@ type SetEntitySchema = type SetManySchema = | readonly SetEntitySchema[] | { - readonly schema: SetEntitySchema | { readonly [k: string]: EntityLike }; - schemaKey(): string; - denormalize(...args: any): readonly any[] | { readonly [k: string]: any }; + readonly schema: SetEntitySchema | EntityMapLike; queryKey(...args: any): undefined; // excludes Entity, whose `any` returns match the members above pk?: never; @@ -878,10 +879,10 @@ type FunctionKeys = { type SetField = T extends number ? T | string : T extends string ? T | number - : T extends boolean | null | undefined ? T - : unknown; + : T extends object ? unknown + : T; -/** Fields of one row */ +/** Fields of one row; like EntityFields, but distributive and without key remapping (TS 4.0) */ type SetRow = // EntityMixin and other untyped entities 0 extends 1 & U ? { readonly [k: string]: any } @@ -899,11 +900,9 @@ type SetManyValue = true extends IsUnion ? readonly { 'Use a Union schema for several Entity types': never }[] : readonly SetRowOf>[] - : S extends ( - { readonly schema: infer Sch; denormalize(...args: any): infer R } - ) ? - R extends readonly (infer U)[] ? readonly SetRowOf[] - : R extends { readonly [k: string]: infer U } ? + : S extends { readonly schema: infer Sch } ? + Denormalize extends readonly (infer U)[] ? readonly SetRowOf[] + : Denormalize extends { readonly [k: string]: infer U } ? { readonly [k: string]: SetRowOf } : never : never; From b86e50cac21440d25c57feb5c4595e66a4d00a6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:26:41 +0000 Subject: [PATCH 3/6] fix(core): Keep schema.Object off the batch set() overload Restore the schemaKey() member; schema.Object's queryKey() returns any, so queryKey(): undefined alone no longer excluded it. Adds type tests for schema.Object with keyed rows and schema.Lazy. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MbthieGWrKshPjDkH7mGz2 --- packages/core/src/controller/Controller.ts | 2 ++ packages/react/src/hooks/__tests__/useController/set.tsx | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index dbbbd71d8837..ae3f0fe92d62 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -859,6 +859,8 @@ type SetManySchema = | readonly SetEntitySchema[] | { readonly schema: SetEntitySchema | EntityMapLike; + // Array and Values; excludes schema.Object, whose queryKey() returns any + schemaKey(): string; queryKey(...args: any): undefined; // excludes Entity, whose `any` returns match the members above pk?: never; diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index 8eb28eb0dc35..85c765403851 100644 --- a/packages/react/src/hooks/__tests__/useController/set.tsx +++ b/packages/react/src/hooks/__tests__/useController/set.tsx @@ -156,6 +156,12 @@ describe('set', () => { controller.set([{ bogus: 1 }], [{ bogus: 5 }]); // @ts-expect-error schema.Object is not a list controller.set(new schema.Object({ a: CoolerArticle }), [{ id: 5 }]); + // @ts-expect-error schema.Object is not keyed rows either + controller.set(new schema.Object({ a: CoolerArticle }), { a: { id: 5 } }); + // @ts-expect-error Lazy is not a list + controller.set(new schema.Lazy(() => [CoolerArticle]), [{ id: 5 }]); + // @ts-expect-error Lazy is not keyed rows + controller.set(new schema.Lazy(() => CoolerArticle), { a: { id: 5 } }); // @ts-expect-error Values take a keyed object, not an array controller.set(new schema.Values(CoolerArticle), [{ id: 5 }]); // @ts-expect-error Arrays take an array, not a keyed object From 1e1987c1271e914aed64d6716f3b5f74b0cd0259 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:31:24 +0000 Subject: [PATCH 4/6] test(react): Construct schema.Lazy correctly in batch set() type tests Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MbthieGWrKshPjDkH7mGz2 --- packages/react/src/hooks/__tests__/useController/set.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index 85c765403851..a41ff9ed3e92 100644 --- a/packages/react/src/hooks/__tests__/useController/set.tsx +++ b/packages/react/src/hooks/__tests__/useController/set.tsx @@ -159,9 +159,9 @@ describe('set', () => { // @ts-expect-error schema.Object is not keyed rows either controller.set(new schema.Object({ a: CoolerArticle }), { a: { id: 5 } }); // @ts-expect-error Lazy is not a list - controller.set(new schema.Lazy(() => [CoolerArticle]), [{ id: 5 }]); + controller.set(new schema.Lazy([CoolerArticle]), [{ id: 5 }]); // @ts-expect-error Lazy is not keyed rows - controller.set(new schema.Lazy(() => CoolerArticle), { a: { id: 5 } }); + controller.set(new schema.Lazy(CoolerArticle), { a: { id: 5 } }); // @ts-expect-error Values take a keyed object, not an array controller.set(new schema.Values(CoolerArticle), [{ id: 5 }]); // @ts-expect-error Arrays take an array, not a keyed object From 1f45827d422f9f2a934b2affa8be0e6800f98bad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:58:38 +0000 Subject: [PATCH 5/6] feat(core): Allow batch delete with set([Invalidate(Entity)], rows) Type batch set() elements by what makes them writable: one row normalizes to one stored entity (_normalizeNullable() returns an entity ref). That admits Entity, Union and Invalidate, and keeps out Query/All (lists) and Collection (keyed by args). Docs lead with Union and Invalidate examples. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MbthieGWrKshPjDkH7mGz2 --- .changeset/controller-set-array.md | 11 ++++---- docs/core/api/Controller.md | 25 +++++++++++++++++-- docs/rest/api/Invalidate.md | 7 ++++++ packages/core/src/controller/Controller.ts | 13 ++++++---- .../src/hooks/__tests__/useController/set.tsx | 23 ++++++++++++++++- website/blog/2026-10-03-v0.19-batch-set.md | 12 ++++----- 6 files changed, 72 insertions(+), 19 deletions(-) diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index f12c867c7fc1..220efce32ab9 100644 --- a/.changeset/controller-set-array.md +++ b/.changeset/controller-set-array.md @@ -8,7 +8,7 @@ Fix `controller.set()` types for Array schemas `controller.set([Entity], rows)` and `controller.set(new schema.Array(Entity), rows)` now typecheck. This writes every row in one store update: each row merges with its stored entity, and entities not in `rows` stay. -Rows are typed by the Entity's fields. The schema must be one Entity or [Union](https://dataclient.io/rest/api/Union) in an Array or [Values](https://dataclient.io/rest/api/Values) (which takes an object keyed by id). +Rows are typed by the Entity's fields. The schema holds one Entity, [Union](https://dataclient.io/rest/api/Union) (for mixed types) or [Invalidate](https://dataclient.io/rest/api/Invalidate) (to delete), in an Array or [Values](https://dataclient.io/rest/api/Values) (which takes an object keyed by id). ```ts // Before: TypeScript error on [Ticker], so batches became one set() per row @@ -18,9 +18,10 @@ for (const row of rows) { // After: one store update ctrl.set([Ticker], rows); -ctrl.set(new schema.Values(Ticker), { 'BTC-USD': row }); -// TypeScript errors -ctrl.set([Ticker, Product], rows); // use a Union for mixed types -ctrl.set([Ticker], [{ price: true }]); // price is a number +// Mixed Entity types, batch deletes, and rows keyed by id +const Message = new schema.Union({ ticker: Ticker, trade: Trade }, 'type'); +ctrl.set([Message], messages); +ctrl.set([new schema.Invalidate(Ticker)], [{ product_id: 'BTC-USD' }]); +ctrl.set(new schema.Values(Ticker), { 'BTC-USD': row }); ``` diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index d40ad1fff43c..21eb37a645aa 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -401,8 +401,29 @@ ctrl.set( ``` Rows are typed by the Entity's fields; numbers and strings may be either, and object, array and Date values are not -checked since rows are raw input. The schema must hold one Entity or [Union](/rest/api/Union): `[User, Admin]` is a -TypeScript error, so use `[new schema.Union({ user: User, admin: Admin }, 'type')]` for mixed lists. +checked since rows are raw input. + +For lists that mix Entity types, use a [Union](/rest/api/Union); each row is stored by its `type`: + +```ts +const Feed = new schema.Union({ post: Post, comment: Comment }, 'type'); + +ctrl.set( + [Feed], + [ + { id: '1', type: 'post', title: 'Hello' }, + { id: '7', type: 'comment', body: 'Nice!' }, + ], +); +``` + +To delete many entities at once, use [Invalidate](/rest/api/Invalidate#batch-invalidation); rows only need their pk +fields: + +```ts +ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]); +``` + [Values](/rest/api/Values) schemas take an object of rows instead: ```ts diff --git a/docs/rest/api/Invalidate.md b/docs/rest/api/Invalidate.md index 206b7b91a058..b1016bc9f151 100644 --- a/docs/rest/api/Invalidate.md +++ b/docs/rest/api/Invalidate.md @@ -199,6 +199,13 @@ PostResource.deleteMany(['5', '13', '7']); +To delete many entities without an endpoint, such as from a websocket message, pass the same schema to +[Controller.set()](/docs/api/Controller#set-array): + +```ts +ctrl.set([new Invalidate(Post)], [{ id: '5' }, { id: '13' }, { id: '7' }]); +``` + ### Polymorphic types If your endpoint can delete more than one type of entity, you can use polymorphic invalidation. diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index ae3f0fe92d62..c02c411b80b3 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -844,17 +844,20 @@ interface EntityLike { type EntityMapLike = { readonly [k: string]: EntityLike }; -/** Entity-like schemas whose rows each normalize to one stored entity */ +/** What one row normalizes to: a reference to one stored entity */ +type EntityRef = string | { readonly id: string; readonly schema: string }; + +/** Schemas that write each row to one stored entity: Entity, Union, or Invalidate (batch delete). + * Query, All and Collection don't: they normalize to lists, or Collection keys by args batch set() lacks. */ type SetEntitySchema = | EntityLike - // Union | { - readonly schema: EntityMapLike; - queryKey(...args: any): { schema: string }; + _normalizeNullable(): EntityRef | undefined; + // excludes Collection pk?: never; }; -/** `[Entity]`, `schema.Array(Entity)` or `schema.Values(Entity)` (or of a Union) */ +/** `[Entity]`, `schema.Array(Entity)` or `schema.Values(Entity)` (or of a Union or Invalidate) */ type SetManySchema = | readonly SetEntitySchema[] | { diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index a41ff9ed3e92..c0d0702e49b1 100644 --- a/packages/react/src/hooks/__tests__/useController/set.tsx +++ b/packages/react/src/hooks/__tests__/useController/set.tsx @@ -162,6 +162,11 @@ describe('set', () => { controller.set(new schema.Lazy([CoolerArticle]), [{ id: 5 }]); // @ts-expect-error Lazy is not keyed rows controller.set(new schema.Lazy(CoolerArticle), { a: { id: 5 } }); + const query = new schema.Query(new schema.All(CoolerArticle), x => x); + // @ts-expect-error Query is derived from the store, so not writable + controller.set([query], [{ id: 5 }]); + // @ts-expect-error Collections are keyed by args + controller.set([new schema.Collection([CoolerArticle])], [{ id: 5 }]); // @ts-expect-error Values take a keyed object, not an array controller.set(new schema.Values(CoolerArticle), [{ id: 5 }]); // @ts-expect-error Arrays take an array, not a keyed object @@ -176,7 +181,7 @@ describe('set', () => { }; }); - it('should batch set polymorphic and Values schemas', async () => { + it('should batch set polymorphic, Values and Invalidate schemas', async () => { const { controller } = renderDataClient(() => null); let promise: any; act(() => { @@ -214,6 +219,17 @@ describe('set', () => { 'eight', ); + act(() => { + promise = controller.set( + [new schema.Invalidate(CoolerArticle)], + [{ id: 7 }, { id: 8 }], + ); + }); + await act(() => promise); + const after = controller.getState(); + expect(controller.get(CoolerArticle, { id: 7 }, after)).toBeUndefined(); + expect(controller.get(CoolerArticle, { id: 8 }, after)).toBeUndefined(); + // type tests () => { // @ts-expect-error body is a string @@ -231,6 +247,11 @@ describe('set', () => { ), [{ id: '1', kind: 'first' }], ); + controller.set(new schema.Array(new schema.Invalidate(UnionSchema)), [ + { id: '1', type: 'first' }, + ]); + // @ts-expect-error id is a number + controller.set([new schema.Invalidate(CoolerArticle)], [{ id: false }]); // EntityMixin rows controller.set([ArticleFromMixin], [{ id: 5, title: 'mixin' }]); // @ts-expect-error title is a string 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 30b9d91f0242..dfa672ae692e 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -38,15 +38,15 @@ This works at runtime before v0.19, but only typechecked for single entities, wh per row or a push-only endpoint with `setResponse()`. Array schemas take no `args` and no updater function. [#4103](https://github.com/reactive/data-client/pull/4103) -Rows are typed by the Entity's fields, and the schema must hold a single Entity or [Union](/rest/api/Union). -[Values](/rest/api/Values) schemas work too, taking rows keyed by id. +Rows are typed by the Entity's fields. Use a [Union](/rest/api/Union) for lists that mix Entity types, +[Invalidate](/rest/api/Invalidate#batch-invalidation) to delete many entities at once, and [Values](/rest/api/Values) +for rows keyed by id. ```ts +const Message = new schema.Union({ ticker: Ticker, trade: Trade }, 'type'); +ctrl.set([Message], messages); +ctrl.set([new schema.Invalidate(Ticker)], [{ product_id: 'BTC-USD' }]); ctrl.set(new schema.Values(Ticker), { 'BTC-USD': row }); - -// TypeScript errors -ctrl.set([Ticker, Product], rows); // use a Union for mixed types -ctrl.set([Ticker], [{ price: true }]); // price is a number ``` ### Performance {#batch-set-performance} From 6ba389c75fd4aaf3e1e29652c7aa547c77ea4e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:09:38 +0000 Subject: [PATCH 6/6] refactor(core): Move batch set() types to setManyTypes.ts Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MbthieGWrKshPjDkH7mGz2 --- packages/core/src/controller/Controller.ts | 80 +------------------ packages/core/src/controller/setManyTypes.ts | 81 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 79 deletions(-) create mode 100644 packages/core/src/controller/setManyTypes.ts diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index c02c411b80b3..230ec9181297 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -33,6 +33,7 @@ import { createSetResponse, } from './actions/index.js'; import ensurePojo from './ensurePojo.js'; +import type { SetManySchema, SetManyValue } from './setManyTypes.js'; import type { EndpointUpdateFunction } from './types.js'; import { ReduxMiddlewareAPI } from '../manager/applyManager.js'; import type { GCInterface } from '../state/GCPolicy.js'; @@ -832,82 +833,3 @@ function extractStateAndArgs(rest: readonly unknown[]): [State, any[]] { // this is typescript generics breaking return [rest[l - 1] as State, args]; } - -/** Matches Entity classes (same members Denormalize<> checks). - * Not EntityInterface: Entity's declared pk() takes mutable `args`, which fails its readonly `args`. */ -interface EntityLike { - createIfValid(...args: any): any; - pk(...args: any): any; - readonly key: string; - prototype: any; -} - -type EntityMapLike = { readonly [k: string]: EntityLike }; - -/** What one row normalizes to: a reference to one stored entity */ -type EntityRef = string | { readonly id: string; readonly schema: string }; - -/** Schemas that write each row to one stored entity: Entity, Union, or Invalidate (batch delete). - * Query, All and Collection don't: they normalize to lists, or Collection keys by args batch set() lacks. */ -type SetEntitySchema = - | EntityLike - | { - _normalizeNullable(): EntityRef | undefined; - // excludes Collection - pk?: never; - }; - -/** `[Entity]`, `schema.Array(Entity)` or `schema.Values(Entity)` (or of a Union or Invalidate) */ -type SetManySchema = - | readonly SetEntitySchema[] - | { - readonly schema: SetEntitySchema | EntityMapLike; - // Array and Values; excludes schema.Object, whose queryKey() returns any - schemaKey(): string; - queryKey(...args: any): undefined; - // excludes Entity, whose `any` returns match the members above - pk?: never; - }; - -type IsUnion = - T extends unknown ? - [U] extends [T] ? - false - : true - : never; - -type FunctionKeys = { - [K in keyof U]: U[K] extends (...args: any) => any ? K : never; -}[keyof U]; - -/** Raw input for one field: numbers and strings coerce; objects are pre-normalize */ -type SetField = - T extends number ? T | string - : T extends string ? T | number - : T extends object ? unknown - : T; - -/** Fields of one row; like EntityFields, but distributive and without key remapping (TS 4.0) */ -type SetRow = - // EntityMixin and other untyped entities - 0 extends 1 & U ? { readonly [k: string]: any } - : U extends unknown ? - { readonly [K in Exclude>]?: SetField } - : never; - -/** Polymorphic rows may carry a discriminator that is not an Entity field */ -type SetRowOf = - Sch extends EntityLike ? SetRow - : SetRow & { readonly [k: string]: unknown }; - -type SetManyValue = - S extends readonly (infer E)[] ? - true extends IsUnion ? - readonly { 'Use a Union schema for several Entity types': never }[] - : readonly SetRowOf>[] - : S extends { readonly schema: infer Sch } ? - Denormalize extends readonly (infer U)[] ? readonly SetRowOf[] - : Denormalize extends { readonly [k: string]: infer U } ? - { readonly [k: string]: SetRowOf } - : never - : never; diff --git a/packages/core/src/controller/setManyTypes.ts b/packages/core/src/controller/setManyTypes.ts new file mode 100644 index 000000000000..3f7350654c10 --- /dev/null +++ b/packages/core/src/controller/setManyTypes.ts @@ -0,0 +1,81 @@ +/** Types for batch `Controller.set([Entity], rows)` */ +import type { Denormalize } from '@data-client/normalizr'; + +/** Matches Entity classes (same members Denormalize<> checks). + * Not EntityInterface: Entity's declared pk() takes mutable `args`, which fails its readonly `args`. */ +interface EntityLike { + createIfValid(...args: any): any; + pk(...args: any): any; + readonly key: string; + prototype: any; +} + +type EntityMapLike = { readonly [k: string]: EntityLike }; + +/** What one row normalizes to: a reference to one stored entity */ +type EntityRef = string | { readonly id: string; readonly schema: string }; + +/** Schemas that write each row to one stored entity: Entity, Union, or Invalidate (batch delete). + * Query, All and Collection don't: they normalize to lists, or Collection keys by args batch set() lacks. */ +type SetEntitySchema = + | EntityLike + | { + _normalizeNullable(): EntityRef | undefined; + // excludes Collection + pk?: never; + }; + +/** `[Entity]`, `schema.Array(Entity)` or `schema.Values(Entity)` (or of a Union or Invalidate) */ +export type SetManySchema = + | readonly SetEntitySchema[] + | { + readonly schema: SetEntitySchema | EntityMapLike; + // Array and Values; excludes schema.Object, whose queryKey() returns any + schemaKey(): string; + queryKey(...args: any): undefined; + // excludes Entity, whose `any` returns match the members above + pk?: never; + }; + +type IsUnion = + T extends unknown ? + [U] extends [T] ? + false + : true + : never; + +type FunctionKeys = { + [K in keyof U]: U[K] extends (...args: any) => any ? K : never; +}[keyof U]; + +/** Raw input for one field: numbers and strings coerce; objects are pre-normalize */ +type SetField = + T extends number ? T | string + : T extends string ? T | number + : T extends object ? unknown + : T; + +/** Fields of one row; like EntityFields, but distributive and without key remapping (TS 4.0) */ +type SetRow = + // EntityMixin and other untyped entities + 0 extends 1 & U ? { readonly [k: string]: any } + : U extends unknown ? + { readonly [K in Exclude>]?: SetField } + : never; + +/** Polymorphic rows may carry a discriminator that is not an Entity field */ +type SetRowOf = + Sch extends EntityLike ? SetRow + : SetRow & { readonly [k: string]: unknown }; + +export type SetManyValue = + S extends readonly (infer E)[] ? + true extends IsUnion ? + readonly { 'Use a Union schema for several Entity types': never }[] + : readonly SetRowOf>[] + : S extends { readonly schema: infer Sch } ? + Denormalize extends readonly (infer U)[] ? readonly SetRowOf[] + : Denormalize extends { readonly [k: string]: infer U } ? + { readonly [k: string]: SetRowOf } + : never + : never;