Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/controller-set-array.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 });
```
37 changes: 35 additions & 2 deletions docs/core/api/Controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions docs/rest/api/Invalidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ PostResource.deleteMany(['5', '13', '7']);

</EndpointPlayground>

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.
Expand Down
12 changes: 12 additions & 0 deletions docs/rest/api/Values.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ render(<ItemPage />);

</HooksPlayground>

### 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).
Expand Down
19 changes: 7 additions & 12 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -234,19 +235,13 @@ export default class Controller<
): Promise<void>;

/**
* 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<void>;
set<S extends SetManySchema>(
schema: S,
value: SetManyValue<S>,
): Promise<void>;

set<S extends Queryable>(
schema: S,
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/controller/setManyTypes.ts
Original file line number Diff line number Diff line change
@@ -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, U = T> =
T extends unknown ?
[U] extends [T] ?
false
: true
: never;

type FunctionKeys<U> = {
[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> =
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<U> =
// EntityMixin and other untyped entities
0 extends 1 & U ? { readonly [k: string]: any }
: U extends unknown ?
{ readonly [K in Exclude<keyof U, FunctionKeys<U>>]?: SetField<U[K]> }
: never;

/** Polymorphic rows may carry a discriminator that is not an Entity field */
type SetRowOf<Sch, U> =
Sch extends EntityLike ? SetRow<U>
: SetRow<U> & { readonly [k: string]: unknown };

export type SetManyValue<S> =
S extends readonly (infer E)[] ?
true extends IsUnion<E> ?
readonly { 'Use a Union schema for several Entity types': never }[]
: readonly SetRowOf<E, Denormalize<E>>[]
: S extends { readonly schema: infer Sch } ?
Denormalize<S> extends readonly (infer U)[] ? readonly SetRowOf<Sch, U>[]
: Denormalize<S> extends { readonly [k: string]: infer U } ?
{ readonly [k: string]: SetRowOf<Sch, U> }
: never
: never;
137 changes: 136 additions & 1 deletion packages/react/src/hooks/__tests__/useController/set.tsx
Original file line number Diff line number Diff line change
@@ -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 '../..';
Expand Down Expand Up @@ -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 } });
};
});

Expand Down
11 changes: 11 additions & 0 deletions website/blog/2026-10-03-v0.19-batch-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading