diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index b602974a0099..220efce32ab9 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 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 for (const row of rows) { @@ -16,4 +18,10 @@ for (const row of rows) { // After: one store update ctrl.set([Ticker], rows); + +// 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 1c25d6f95a29..21eb37a645aa 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,40 @@ 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. + +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 +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/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/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..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'; @@ -234,19 +235,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, 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; diff --git a/packages/react/src/hooks/__tests__/useController/set.tsx b/packages/react/src/hooks/__tests__/useController/set.tsx index 32e6cc70615b..c0d0702e49b1 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,133 @@ 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 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 } }); + 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 + 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, Values and Invalidate 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', + ); + + 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 + 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' }], + ); + 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 + 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..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,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. 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 }); +``` + ### Performance {#batch-set-performance} Each `set()` is a separate store update, and every store update copies that entity type's table. Writing rows one