diff --git a/.cursor/skills/data-client-v0.18-migration/SKILL.md b/.cursor/skills/data-client-v0.18-migration/SKILL.md index 7967d4b0c19b..a318c4eeb032 100644 --- a/.cursor/skills/data-client-v0.18-migration/SKILL.md +++ b/.cursor/skills/data-client-v0.18-migration/SKILL.md @@ -5,7 +5,7 @@ description: Migrate custom @data-client schemas to v0.18 delegate signatures: d # @data-client v0.18 Migration -Applies to anyone implementing a custom [`Schema`](https://dataclient.io/rest/api/CustomSchema) — `SchemaSimple`, `SchemaClass`, polymorphic wrappers, or types that subclass `EntityMixin` directly. Built-in schemas (`Entity`, `resource()`, `Collection`, `Union`, `Values`, `Array`, `Object`, `Query`, `Invalidate`, `Lazy`) are migrated by the library. +Applies to anyone implementing a custom [`Schema`](https://dataclient.io/rest/api/SchemaSimple) — `SchemaSimple`, `SchemaClass`, polymorphic wrappers, or types that subclass `EntityMixin` directly. Built-in schemas (`Entity`, `resource()`, `Collection`, `Union`, `Values`, `Array`, `Object`, `Query`, `Invalidate`, `Lazy`) are migrated by the library. The automated codemod handles the common cases: @@ -54,7 +54,7 @@ normalize(input, parent, key, delegate) { } ``` -Full delegate surface ([`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema)): +Full delegate surface ([`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple)): ```ts interface IDenormalizeDelegate { @@ -167,7 +167,7 @@ On types: **`interface { denormalize(...) }`** uses the literal key `denormalize ### args-dependent output (manual) -The codemod will rewrite `args` to `delegate.args`, but if your schema's *return value* depends on those args, you must also register an [`argsKey`](https://dataclient.io/rest/api/CustomSchema) so memoization invalidates correctly. The codemod cannot do this for you. +The codemod will rewrite `args` to `delegate.args`, but if your schema's *return value* depends on those args, you must also register an [`argsKey`](https://dataclient.io/rest/api/SchemaSimple) so memoization invalidates correctly. The codemod cannot do this for you. `argsKey` returns `fn(args)` for convenience **and** the function reference doubles as the cache path key on `WeakDependencyMap` — so `fn` must be **referentially stable**. Bind it in the constructor or at module scope; an inline arrow creates a new reference per call and misses the cache every time. @@ -236,4 +236,4 @@ Search for these patterns in your codebase: - Changesets: `.changeset/denormalize-delegate.md`, `.changeset/normalize-delegate.md` - Built-in schema diffs: `packages/endpoint/src/schemas/{Array,Object,Values,Union,Query,Invalidate,Lazy,Collection}.ts` -- New interfaces: [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema), [`INormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) +- New interfaces: [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple), [`INormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) diff --git a/docs/rest/api/CustomSchema.md b/docs/rest/api/CustomSchema.md deleted file mode 100644 index dd8d9e7a450e..000000000000 --- a/docs/rest/api/CustomSchema.md +++ /dev/null @@ -1,255 +0,0 @@ ---- -title: Custom Schema - Define data processing protocols -sidebar_label: Custom Schema -description: Interfaces needed to define custom schemas for normalization, denormalization, and query keys. ---- - -# Custom Schema - -Custom schemas participate in normalization, denormalization, and query key -building by implementing the methods normalizr calls while walking a schema tree. - -Most applications should prefer built-in schemas like [Entity](./Entity.md), -[Collection](./Collection.md), [Union](./Union.md), and [Values](./Values.md). -Use this page when building your own schema type. - -The interfaces below list the properties normalizr itself reads. Schema-specific -APIs, such as reordering hooks on [Collection](./Collection.md), are not included. - -## Usage - -This custom schema wraps another schema under a `data` field while preserving the -rest of the object. - -```ts -import type { - IDenormalizeDelegate, - INormalizeDelegate, -} from '@data-client/endpoint'; - -class DataWrapper { - constructor(private schema: any) {} - - normalize( - input: any, - parent: any, - key: string | undefined, - delegate: INormalizeDelegate, - ) { - return { - ...input, - data: delegate.visit(this.schema, input.data, input, 'data'), - }; - } - - denormalize(input: any, delegate: IDenormalizeDelegate) { - return { - ...input, - data: delegate.unvisit(this.schema, input.data), - }; - } -} -``` - -Use `delegate.visit()` to recursively normalize nested schemas and -`delegate.unvisit()` to recursively denormalize them. `delegate.args` exposes the -endpoint args for the current operation. - -## Interfaces - -Any object with `normalize()`, `denormalize()`, or `queryKey()` can be used as a -schema node. Plain arrays and objects are also schemas. - -```ts -type Schema = - | null - | string - | { [K: string]: any } - | Schema[] - | SchemaSimple - | Serializable; - -type Serializable = ( - value: any, -) => T; -``` - -### SchemaSimple {#schemasimple} - -```ts -interface SchemaSimple { - normalize( - input: any, - parent: any, - key: any, - delegate: INormalizeDelegate, - parentEntity?: any, - ): any; - denormalize(input: {}, delegate: IDenormalizeDelegate): T; - queryKey( - args: Args, - queryKey: (...args: any) => any, - delegate: IQueryDelegate, - ): any; -} -``` - -`normalize()` receives the value at the current schema node and returns the -normalized representation stored in the surrounding result. `parentEntity` is the -nearest enclosing entity-like schema, when present. Most custom schemas can -ignore it. - -`denormalize()` receives normalized input and returns the denormalized value. - -`queryKey()` computes the normalized key used to read from the store without -fetching. It is only needed for schemas that can be read directly with -[useQuery()](/docs/api/useQuery), [Controller.get](/docs/api/Controller#get), or -[schema.Query](./Query.md). - -## Normalization delegate - -```ts -interface INormalizeDelegate { - visit: Visit; - readonly args: readonly any[]; - readonly meta: MetaEntry; - getEntities(key: string): EntitiesInterface | undefined; - getEntity: GetEntity; - mergeEntity( - schema: Mergeable & { indexes?: any }, - pk: string, - incomingEntity: any, - ): void; - setEntity( - schema: { key: string; indexes?: any }, - pk: string, - entity: any, - meta?: MetaEntry, - ): void; - invalidate(schema: { key: string }, pk: string): void; - checkLoop(key: string, pk: string, input: object): boolean; -} - -interface Visit { - (schema: any, value: any, parent: any, key: any): any; - creating?: boolean; -} - -interface MetaEntry { - fetchedAt: number; - date: number; - expiresAt: number; -} -``` - -Use `mergeEntity()` when updating an entity-like schema with merge lifecycles. Use -`setEntity()` when the incoming value should overwrite previous normalized data. -Use `invalidate()` when the schema represents an invalid entity result. - -## Denormalization delegate - -```ts -interface IDenormalizeDelegate { - unvisit(schema: any, input: any): any; - readonly args: readonly any[]; - argsKey(fn: (args: readonly any[]) => string | undefined): string | undefined; -} -``` - -Reading `delegate.args` does not contribute to cache invalidation. If -denormalized output changes based on endpoint args, register that dependency with -`delegate.argsKey(fn)`. The function reference must be stable; define it at module -scope or bind it on the schema instance. - -## Query delegate - -```ts -interface IQueryDelegate { - getEntities(key: string): EntitiesInterface | undefined; - getEntity: GetEntity; - getIndex: GetIndex; - INVALID: symbol; -} - -interface Queryable { - queryKey( - args: Args, - queryKey: (...args: any) => any, - delegate: IQueryDelegate, - ): {}; -} -``` - -Return `undefined` from `queryKey()` when the schema cannot produce a valid store -key. Return `delegate.INVALID` when a query result should be treated as invalid. - -## Store access helpers - -```ts -interface EntitiesInterface { - keys(): IterableIterator; - entries(): IterableIterator<[string, any]>; -} - -interface GetEntity { - (key: string, pk: string): any; -} - -type IndexPath = [key: string, index: string, value: string]; - -interface GetIndex { - (...path: IndexPath): string | undefined; -} -``` - -## Entity-like schemas - -Normalizr treats a schema as entity-like when it has a `pk` property. -Entity-like schemas are stored by `key` and primary key, denormalized through -entity caches, and tracked for cycle detection. - -```ts -interface EntityInterface extends SchemaSimple { - readonly key: string; - pk( - params: any, - parent: any, - key: string | undefined, - args: readonly any[], - ): string | number | undefined; - createIfValid(props: any): any; - schema: Record; - prototype: T; - indexes?: string[]; - cacheWith?: object; - maxEntityDepth?: number; -} - -interface Mergeable { - key: string; - merge(existing: any, incoming: any): any; - mergeWithStore( - existingMeta: MetaEntry, - incomingMeta: MetaEntry, - existing: any, - incoming: any, - ): any; - mergeMetaWithStore( - existingMeta: MetaEntry, - incomingMeta: MetaEntry, - existing: any, - incoming: any, - ): MetaEntry; -} -``` - -`cacheWith` lets multiple schema instances share the same entity cache identity. -`maxEntityDepth` limits recursive denormalization depth for very deep entity -graphs. - -## Related - -- [Thinking in Schemas](./schema.md) -- [Entity](./Entity.md) -- [Collection](./Collection.md) -- [Scalar](./Scalar.md) diff --git a/docs/rest/api/SchemaSimple.md b/docs/rest/api/SchemaSimple.md new file mode 100644 index 000000000000..4cac8e8e2082 --- /dev/null +++ b/docs/rest/api/SchemaSimple.md @@ -0,0 +1,781 @@ +--- +title: SchemaSimple - Define data processing protocols +sidebar_label: SchemaSimple +description: Build custom schemas for normalization, denormalization, and query keys. +--- + +# SchemaSimple + +`SchemaSimple` lets you teach `@data-client/rest` how to normalize, +denormalize, and query a response shape that is not covered by the built-in +schemas. + +Most applications should start with [Entity](./Entity.md), +[Collection](./Collection.md), [Union](./Union.md), [Values](./Values.md), or +[Scalar](./Scalar.md). Reach for a custom schema when the schema needs runtime +logic the built-ins don't provide — for example, denormalized output that +depends on endpoint args, or bounded traversal of cyclic / deep entity graphs. + +## Prefer built-ins for shape transformations + +Plain objects and arrays are valid schemas; the [Object](./Object.md) shorthand +spreads input and only visits the keys you declare, so unmentioned fields pass +through unchanged. Most response wrappers do not need a custom schema: + +```typescript +// Response: { data: { id: '5', name: 'Ada' }, requestId: 'abc123' } +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: { data: User }, +}); + +// Response: { nextCursor: '...', results: [{...}, {...}] } +const getUsers = new RestEndpoint({ + path: '/users', + schema: { results: [User] }, +}); +``` + +Use [Collection](./Collection.md) when a list needs identity across requests, +[Values](./Values.md) for keyed maps, [Union](./Union.md) for polymorphic +items, [Entity.process()](./Entity.md#process) for response-shape fixups, and +[Entity.indexes](./Entity.md#indexes) for non-pk lookups. + +## Usage + +The smallest custom schema implements `normalize()` and `denormalize()`. This +example illustrates the interface using a wrapper that preserves response +metadata; in practice you would use `schema: { data: User }` instead — see +[Prefer built-ins](#prefer-built-ins-for-shape-transformations) above. + +```typescript +import { Entity, RestEndpoint } from '@data-client/rest'; +import type { + IDenormalizeDelegate, + INormalizeDelegate, +} from '@data-client/rest'; + +class User extends Entity { + id = ''; + name = ''; + + static key = 'User'; +} + +class DataEnvelope { + constructor(private readonly schema: any) {} + + normalize( + input: any, + _parent: any, + _key: string | undefined, + delegate: INormalizeDelegate, + ) { + return { + ...input, + data: delegate.visit(this.schema, input.data, input, 'data'), + }; + } + + denormalize(input: any, delegate: IDenormalizeDelegate) { + return { + ...input, + data: delegate.unvisit(this.schema, input.data), + }; + } +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: new DataEnvelope(User), +}); +``` + +`normalize()` replaces the nested `data` with the entity pk and stores the +`User` in the entity table. `denormalize()` runs the reverse, reconstructing the +envelope with `data` as a live `User` instance. See [Lifecycle](#lifecycle) for +the full traversal model. + +## Members + +Custom schemas typically implement `normalize()` and `denormalize()`. +Implement `queryKey()` when the schema should be readable from the store without +fetching, such as with [useQuery()](/docs/api/useQuery), +[Controller.get](/docs/api/Controller#get), or [Query](./Query.md). + +### normalize(input, parent, key, delegate, parentEntity?) {#normalize} + +Returns the normalized value to store at this position in the surrounding result. + +```typescript +normalize(input, parent, key, delegate, parentEntity?) +``` + +Use `delegate.visit()` to recurse into nested schemas. + +```typescript +class DataEnvelope { + normalize( + input: any, + _parent: any, + _key: string | undefined, + delegate: INormalizeDelegate, + ) { + return { + ...input, + data: delegate.visit(this.schema, input.data, input, 'data'), + }; + } +} +``` + +`parentEntity` is the nearest enclosing entity-like schema, when present. Most +custom schemas can ignore it. + +### denormalize(input, delegate) {#denormalize} + +Receives the normalized value and returns the denormalized value exposed to +hooks, [Controller](/docs/api/Controller), and endpoint resolution. + +```typescript +denormalize(input, delegate) +``` + +Use `delegate.unvisit()` to recurse into nested schemas. + +```typescript +class DataEnvelope { + denormalize(input: any, delegate: IDenormalizeDelegate) { + return { + ...input, + data: delegate.unvisit(this.schema, input.data), + }; + } +} +``` + +### queryKey(args, unvisit, delegate) {#queryKey} + +Computes the normalized key used to read from the store without fetching. + +```typescript +queryKey(args, unvisit, delegate) +``` + +For wrapper schemas, `queryKey()` usually mirrors the normalized shape returned +by `normalize()`. The recursive `unvisit` argument asks the child schema to build +its own query key from the same endpoint args. + +```typescript +class DataEnvelope { + queryKey( + args: readonly any[], + unvisit: (schema: any, args: readonly any[]) => any, + ) { + const data = unvisit(this.schema, args); + return data === undefined ? undefined : { data }; + } +} +``` + +Return `undefined` when the schema cannot build a valid store key. Return +`delegate.INVALID` when the schema can prove the result should be treated as +invalid. + +## Lifecycle + +### Normalize + +During fetch resolution, the schema tree is walked from the endpoint's +`schema`. Each custom schema receives the raw value at its position and returns +the normalized value to place in the endpoint result. + +```typescript +// Response +{ data: { id: '5', name: 'Ada' }, requestId: 'abc123' } + +// Normalized endpoint result +{ data: '5', requestId: 'abc123' } +``` + +Nested [Entity](./Entity.md), [Collection](./Collection.md), [Union](./Union.md), +and other schemas should be reached through `delegate.visit()`, not by calling +their methods directly. + +### Denormalize + +When cached data is read, custom schemas receive the normalized input they +previously returned from `normalize()`. + +```typescript +denormalize(input: any, delegate: IDenormalizeDelegate) { + return { + ...input, + data: delegate.unvisit(this.schema, input.data), + }; +} +``` + +If denormalized output changes based on endpoint args, use +[`delegate.argsKey()`](#argsKey) so memoization tracks that dependency. + +### Query Key + +`queryKey()` runs when the schema is queried without a network response. This is +how `useQuery()`, `Controller.get()`, and `Query` discover the normalized input +that should be denormalized. + +```typescript +queryKey(args, unvisit) { + const pk = unvisit(User, args); + return pk === undefined ? undefined : { data: pk }; +} +``` + +## Delegate Interfaces + +### INormalizeDelegate {#inormalizedelegate} + +Passed to [`normalize()`](#normalize). Use this to recurse into nested schemas, +read endpoint args, and write entity-like results. + +#### visit(schema, value, parent, key) {#visit} + +Recursively normalizes `value` with another schema. + +```typescript +delegate.visit(schema, value, parent, key) +``` + +`Array` uses `visit()` to normalize each item against the inner schema: + +```typescript +return values.map(value => delegate.visit(schema, value, parent, key)); +``` + +`EntityMixin` uses it to normalize each declared field on an entity: + +```typescript +for (const key of Object.keys(this.schema)) { + processedEntity[key] = delegate.visit( + this.schema[key], + processedEntity[key], + processedEntity, + key, + ); +} +``` + +#### args {#normalize-args} + +Endpoint args for the current normalize operation. + +```typescript +delegate.args +``` + +`EntityMixin.normalize()` reads `args` to compute the entity's primary key from +the incoming response and the original endpoint call: + +```typescript +const args = delegate.args; +const processedEntity = this.process(input, parent, key, args); +const id = this.pk(processedEntity, parent, key, args); +``` + +#### meta {#meta} + +Fetch metadata for the current normalize operation +(`fetchedAt`, `date`, `expiresAt`). + +```typescript +delegate.meta +``` + +`Entity` merge lifecycles use this to decide whether an incoming row is newer +than what is in the store. Custom schemas usually do not need to read it. + +#### mergeEntity(schema, pk, incomingEntity) {#mergeEntity} + +Writes an entity through its merge lifecycle. + +```typescript +delegate.mergeEntity(schema, pk, incomingEntity) +``` + +This is the final step of `EntityMixin.normalize()` after recursing into each +field: + +```typescript +delegate.mergeEntity(this, id, processedEntity); +return id; +``` + +Most custom schemas should delegate entity work to `Entity`, `Collection`, or +another nested schema. Use `mergeEntity()` only when implementing entity-like +behavior yourself. + +#### setEntity(schema, pk, entity, meta?) {#setEntity} + +Writes an entity row by replacing the previous normalized value, skipping the +merge lifecycle that `mergeEntity()` runs. + +```typescript +delegate.setEntity(schema, pk, entity, meta) +``` + +Use this when the incoming row should overwrite prior normalized data rather +than merge with it. `delegate.invalidate()` is implemented as a `setEntity()` +write of an `INVALID` symbol. + +#### invalidate(schema, pk) {#invalidate} + +Marks an entity result invalid, triggering Suspense for endpoints that need it. + +```typescript +delegate.invalidate(schema, pk) +``` + +This is what the `Invalidate` schema does after computing the target entity's +pk: + +```typescript +const processedEntity = entitySchema.process(input, parent, key, args); +const pk = `${entitySchema.pk(processedEntity, parent, key, args)}`; + +delegate.invalidate(entitySchema, pk); +``` + +#### checkLoop(key, pk, input) {#checkLoop} + +Returns `true` when `(entityKey, pk, input)` is being normalized again inside +its own subtree, so the schema should stop recursing. + +```typescript +delegate.checkLoop(entityKey, pk, input) +``` + +`EntityMixin.normalize()` short-circuits before walking nested fields when a +cycle is detected: + +```typescript +if (delegate.checkLoop(this.key, id, input)) return id; +``` + +### IDenormalizeDelegate {#idenormalizedelegate} + +Passed to [`denormalize()`](#denormalize). Use this to recurse into nested +schemas and register args-dependent memoization. + +#### unvisit(schema, input) {#unvisit} + +Recursively denormalizes `input` with another schema. + +```typescript +delegate.unvisit(schema, input) +``` + +`Array.denormalize()` uses `unvisit()` to denormalize each item with the inner +schema: + +```typescript +return input.map(entityOrId => delegate.unvisit(schema, entityOrId)); +``` + +`EntityMixin.denormalize()` uses it once per declared field, propagating +`INVALID` symbols when a required nested entity is missing: + +```typescript +for (const key of Object.keys(this.schema)) { + const value = delegate.unvisit(this.schema[key], input[key]); + // ... +} +``` + +#### args {#denormalize-args} + +Endpoint args for the current denormalize operation. + +```typescript +delegate.args +``` + +`Query.denormalize()` reads `args` to forward them into a user-supplied +processor: + +```typescript +const value = delegate.unvisit(this.schema, input); +return this.process(value, ...delegate.args); +``` + +Reading `args` directly does not contribute to cache invalidation. If +denormalized output changes based on endpoint args, use +[`argsKey()`](#argsKey) instead. + +#### argsKey(fn) {#argsKey} + +Registers an args-derived memoization key while denormalizing. + +```typescript +delegate.argsKey(fn) +``` + +The function reference must be stable. Define it at module scope or bind it on +the schema instance. + +`Scalar.denormalize()` uses a constructor-bound `lensSelector` to register the +current lens (e.g. `portfolio`, `currency`, `locale`) as a memoization +dimension, then looks up the matching cell: + +```typescript +const lensValue = delegate.argsKey(this.lensSelector); +if (lensValue === undefined) return undefined; +const cellData = delegate.unvisit( + this, + `${input[2]}|${input[0]}|${lensValue}`, +); +``` + +### IQueryDelegate {#iquerydelegate} + +Passed to [`queryKey()`](#queryKey). Use this to check whether store data exists +before returning a normalized key. + +#### getEntity(key, pk) {#getEntity} + +Reads one normalized entity row from the store. + +```typescript +delegate.getEntity(entityKey, pk) +``` + +`EntityMixin.queryKey()` uses it to avoid returning a key for an entity that +the store does not currently have: + +```typescript +if (!args[0]) return; +const pk = queryKeyCandidate(this, args, delegate); +if (pk && delegate.getEntity(this.key, pk)) return pk; +``` + +`Collection.queryKey()` does the same after computing the collection's pk from +endpoint args: + +```typescript +const pk = this.pk(undefined, undefined, '', args); +if (delegate.getEntity(this.key, pk)) return pk; +``` + +#### getEntities(key) {#getEntities} + +Reads all normalized rows for an entity key as an iterable view. + +```typescript +delegate.getEntities(entityKey) +``` + +The single-schema branch of `All.queryKey()` returns every cached pk for its +entity: + +```typescript +const entities = delegate.getEntities(this.schema.key); +if (!entities) return delegate.INVALID; +return [...entities.keys()]; +``` + +#### getIndex(key, index, value) {#getIndex} + +Looks up a primary key by an [Entity index](./Entity.md#indexes). + +```typescript +delegate.getIndex(entityKey, indexName, value) +``` + +`EntityMixin.queryKey()` falls back to an index lookup when the first arg +matches an indexed field rather than a pk: + +```typescript +const field = indexFromParams(args[0], schema.indexes); +if (!field) return; +const value = args[0][field]; +return delegate.getIndex(schema.key, field, value); +``` + +#### INVALID {#invalid} + +Sentinel returned when a query result should be treated as invalid. + +```typescript +return delegate.INVALID; +``` + +`All.queryKey()` returns `INVALID` when no rows for the requested entity have +ever been cached, so the consumer knows to fetch rather than treat the empty +list as a hit: + +```typescript +if (!entities) return delegate.INVALID; +``` + +Return `undefined` when the schema simply cannot build a store key yet. Return +`delegate.INVALID` when it can build the key but knows the cached result is +invalid. + +## Entity-Like Schemas + +Normalizr treats a schema as entity-like when it has a `pk` property. +Entity-like schemas are stored by `key` and primary key, denormalized through +entity caches, and tracked for cycle detection. + +The minimal shape is: + +```typescript +{ + key: string; + pk(input, parent, key, args); +} +``` + +Additional members like `createIfValid`, `schema`, `indexes`, `cacheWith`, and +`maxEntityDepth` are used by [Entity](./Entity.md) and related schemas. +Prefer extending `Entity` unless you need a different entity protocol entirely. + +```typescript +class UsernameEntity { + static key = 'User'; + static indexes = ['username']; + + static pk(input: { id?: string }) { + return input.id; + } +} +``` + +`cacheWith` lets multiple schema instances share the same entity cache identity. +`maxEntityDepth` limits recursive denormalization depth for very deep entity +graphs. + +## Examples + +### Argument-Dependent Fields + +If denormalized output changes based on endpoint args, register that dependency +with `delegate.argsKey()`. Reading `delegate.args` directly works for the current +call, but it does not give the memoized denormalization cache enough information +to invalidate when the relevant arg changes. + +Define the selector at module scope or bind it once on the schema instance. The +function reference is part of the cache path. + +```typescript +const localeKey = (args: readonly any[]) => args[0]?.locale; + +class LocalizedText { + normalize(input: Record) { + return input; + } + + denormalize( + input: Record, + delegate: IDenormalizeDelegate, + ) { + const locale = delegate.argsKey(localeKey) ?? 'en'; + return input[locale] ?? input.en; + } + + queryKey() { + return undefined; + } +} + +class Product extends Entity { + id = ''; + name = ''; + + static key = 'Product'; + static schema = { + name: new LocalizedText(), + }; +} + +const getProduct = new RestEndpoint({ + path: '/products/:id', + searchParams: {} as { locale?: string }, + schema: Product, +}); +``` + +With this schema, the normalized `Product.name` can store the full locale map, +while components receive the string for the current `locale` arg. + +### Depth-limited Relationships + +Large bidirectional graphs (parent/children, or `Department ↔ Building ↔ Room`) +can blow up eager denormalization. [Lazy](./Lazy.md) plus +[useQuery](/docs/api/useQuery) is the recommended fix because it keeps +re-render boundaries tight, but a custom schema can also bound traversal +in-place — useful as an interim while migrating, or for self-referential +hierarchies where you do want N levels resolved transparently. + +The pattern uses the fact that one `delegate` object is reused for an entire +denormalize tree, so a `WeakMap` gives per-call state that is +GC'd automatically. + +`DepthLimited` caps how many levels of a specific relationship resolve. Once +the limit is hit, it returns the raw normalized value (the pks) instead of +recursing further: + +```typescript +import type { + IDenormalizeDelegate, + INormalizeDelegate, + Schema, +} from '@data-client/rest'; + +class DepthLimited { + readonly schema: S; + readonly maxDepth: number; + private readonly _state = new WeakMap< + IDenormalizeDelegate, + { depth: number } + >(); + + constructor(schema: S, maxDepth: number) { + this.schema = schema; + this.maxDepth = maxDepth; + } + + normalize(input: any, parent: any, key: any, delegate: INormalizeDelegate) { + return delegate.visit(this.schema, input, parent, key); + } + + denormalize(input: {}, delegate: IDenormalizeDelegate) { + if (input == null || typeof input === 'symbol') return input; + let cell = this._state.get(delegate); + if (!cell) { + cell = { depth: 0 }; + this._state.set(delegate, cell); + } + cell.depth++; + try { + if (cell.depth > this.maxDepth) return input; + return delegate.unvisit(this.schema, input); + } finally { + cell.depth--; + } + } + + queryKey(): undefined { + return undefined; + } +} +``` + +```typescript +class Department extends Entity { + static schema = { + children: new DepthLimited([Department], 3), + parent: new DepthLimited(Department, 1), + }; +} +``` + +`CycleDetect` stops as soon as the same entity type appears twice on the +ancestor path, so it adapts to any schema shape without tuning a depth number. +It pairs with an `Entity` subclass that records the ancestor stack: + +```typescript +const _ancestors = new WeakMap>(); + +function getAncestors(delegate: IDenormalizeDelegate) { + let m = _ancestors.get(delegate); + if (!m) { + m = new Map(); + _ancestors.set(delegate, m); + } + return m; +} + +function extractEntityKeys(schema: any, out = new Set()) { + if (schema == null) return out; + if (schema.pk !== undefined && schema.key) { + out.add(schema.key); + return out; + } + if (Array.isArray(schema) && schema.length === 1) { + return extractEntityKeys(schema[0], out); + } + if (schema.schema !== undefined) extractEntityKeys(schema.schema, out); + return out; +} + +class CycleDetect { + constructor(readonly schema: S) {} + + normalize(input: any, parent: any, key: any, delegate: INormalizeDelegate) { + return delegate.visit(this.schema, input, parent, key); + } + + denormalize(input: {}, delegate: IDenormalizeDelegate) { + if (input == null || typeof input === 'symbol') return input; + const path = getAncestors(delegate); + for (const key of extractEntityKeys(this.schema)) { + if (path.has(key)) return input; + } + return delegate.unvisit(this.schema, input); + } + + queryKey(): undefined { + return undefined; + } +} + +class CycleTrackingEntity extends Entity { + static denormalize( + this: T, + input: any, + delegate: IDenormalizeDelegate, + ): any { + if (typeof input === 'symbol') return input; + const path = getAncestors(delegate); + const k = this.key; + const prev = path.get(k) ?? 0; + path.set(k, prev + 1); + try { + return super.denormalize(input, delegate); + } finally { + if (prev === 0) path.delete(k); + else path.set(k, prev); + } + } +} +``` + +```typescript +class Department extends CycleTrackingEntity { + static schema = { + buildings: new CycleDetect([Building]), + children: new DepthLimited([Department], 3), + parent: new DepthLimited(Department, 1), + }; +} + +class Building extends CycleTrackingEntity { + static schema = { + departments: new CycleDetect([Department]), + }; +} +``` + +Neither wrapper has a `pk`, so they sit outside the entity hot path; consumers +who do not use them pay nothing. At the limit, they return raw normalized +values (the same shape `Lazy` produces). See discussion +[#3828](https://github.com/reactive/data-client/discussions/3828#discussioncomment-16456893) +for the original write-up and tradeoffs versus `Lazy` + `useQuery`. + +## Related + +- [Thinking in Schemas](./schema.md) +- [Entity](./Entity.md) +- [Collection](./Collection.md) +- [Scalar](./Scalar.md) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 9b15d95bec00..9971a823873c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/packages/endpoint/CHANGELOG.md b/packages/endpoint/CHANGELOG.md index 3cf166114204..d6d9ac031109 100644 --- a/packages/endpoint/CHANGELOG.md +++ b/packages/endpoint/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/packages/graphql/CHANGELOG.md b/packages/graphql/CHANGELOG.md index dde8c06b50cd..5fa37aaf13bd 100644 --- a/packages/graphql/CHANGELOG.md +++ b/packages/graphql/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/packages/normalizr/CHANGELOG.md b/packages/normalizr/CHANGELOG.md index 95d7efbf644f..f11575f731d5 100644 --- a/packages/normalizr/CHANGELOG.md +++ b/packages/normalizr/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index de14fc6fe186..6a18ae19cf7e 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index eec0f7fdaad9..15137ea6b6a6 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md index 2fd8f456c822..3cf8ab8caf71 100644 --- a/packages/vue/CHANGELOG.md +++ b/packages/vue/CHANGELOG.md @@ -34,7 +34,7 @@ } ``` - The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/CustomSchema) + The new [`IDenormalizeDelegate`](https://dataclient.io/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper that registers a memoization dimension when output varies with endpoint args. Reading `delegate.args` directly does _not_ contribute to cache invalidation — diff --git a/website/blog/2026-05-01-v0.18-scalar-typed-downloads.md b/website/blog/2026-05-01-v0.18-scalar-typed-downloads.md index 2437f777177d..5f040804c077 100644 --- a/website/blog/2026-05-01-v0.18-scalar-typed-downloads.md +++ b/website/blog/2026-05-01-v0.18-scalar-typed-downloads.md @@ -27,8 +27,8 @@ The example shows the same `Company` entities rendered through different portfol **[Breaking Changes:](/blog/2026/05/01/v0.18-scalar-typed-downloads#migration-guide)** -- [Schema.denormalize() takes a delegate](/blog/2026/05/01/v0.18-scalar-typed-downloads#denormalize-delegate) - `denormalize(input, args, unvisit)` → `denormalize(input, delegate)`. Affects custom [Schema](/rest/api/CustomSchema) implementations only. -- [Schema.normalize() takes a delegate](/blog/2026/05/01/v0.18-scalar-typed-downloads#normalize-delegate) - `normalize(input, parent, key, args, visit, delegate)` → `normalize(input, parent, key, delegate)`. Affects custom [Schema](/rest/api/CustomSchema) implementations only. +- [Schema.denormalize() takes a delegate](/blog/2026/05/01/v0.18-scalar-typed-downloads#denormalize-delegate) - `denormalize(input, args, unvisit)` → `denormalize(input, delegate)`. Affects custom [Schema](/rest/api/SchemaSimple) implementations only. +- [Schema.normalize() takes a delegate](/blog/2026/05/01/v0.18-scalar-typed-downloads#normalize-delegate) - `normalize(input, parent, key, args, visit, delegate)` → `normalize(input, parent, key, delegate)`. Affects custom [Schema](/rest/api/SchemaSimple) implementations only. Upgrade with the automated [codemod](/codemods/v0.18.js): @@ -199,7 +199,7 @@ This upgrade requires updating all package versions simultaneously. -The breaking changes in this release affect only **custom [Schema](/rest/api/CustomSchema) implementations**. +The breaking changes in this release affect only **custom [Schema](/rest/api/SchemaSimple) implementations**. If you only use [`resource()`](/rest/api/resource) and built-in schemas ([`Entity`](/rest/api/Entity), [`Collection`](/rest/api/Collection), [`Union`](/rest/api/Union), [`Values`](/rest/api/Values), [`Array`](/rest/api/Array), [`Object`](/rest/api/Object), [`Query`](/rest/api/Query), @@ -212,11 +212,11 @@ npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,j ### Schema.denormalize() takes a delegate {#denormalize-delegate} -Skip this section if you don't implement custom [Schema](/rest/api/CustomSchema) classes. +Skip this section if you don't implement custom [Schema](/rest/api/SchemaSimple) classes. -[`Schema.denormalize()`](/rest/api/CustomSchema) is now `(input, delegate)` instead of the previous 3-parameter +[`Schema.denormalize()`](/rest/api/SchemaSimple) is now `(input, delegate)` instead of the previous 3-parameter `(input, args, unvisit)` signature. The new -[`IDenormalizeDelegate`](/rest/api/CustomSchema) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper. +[`IDenormalizeDelegate`](/rest/api/SchemaSimple) exposes `unvisit`, `args`, and a new `argsKey(fn)` helper. [#3887](https://github.com/reactive/data-client/pull/3887) @@ -251,13 +251,13 @@ class Wrapper { The codemod handles class methods, object methods, function declarations, TypeScript interface signatures, and pass-through `someSchema.denormalize(input, args, unvisit)` calls. It also adds -the [`IDenormalizeDelegate`](/rest/api/CustomSchema) import as an inline `type` specifier on your existing +the [`IDenormalizeDelegate`](/rest/api/SchemaSimple) import as an inline `type` specifier on your existing `@data-client/*` import (or creates a new `import type` line if none is found). #### args-dependent output -Reading [`delegate.args`](/rest/api/CustomSchema) directly does **not** contribute to memoization. If your schema's *output* -depends on those args (e.g. a lens), declare the dependency through [`delegate.argsKey(fn)`](/rest/api/CustomSchema) so the +Reading [`delegate.args`](/rest/api/SchemaSimple) directly does **not** contribute to memoization. If your schema's *output* +depends on those args (e.g. a lens), declare the dependency through [`delegate.argsKey(fn)`](/rest/api/SchemaSimple) so the cache buckets correctly. The codemod cannot infer this — update by hand. `argsKey` returns `fn(args)` for convenience, and the function reference doubles as the cache path @@ -298,10 +298,10 @@ AI-assisted migration is also available: ### Schema.normalize() takes a delegate {#normalize-delegate} -Skip this section if you don't implement custom [Schema](/rest/api/CustomSchema) classes. +Skip this section if you don't implement custom [Schema](/rest/api/SchemaSimple) classes. -[`Schema.normalize()`](/rest/api/CustomSchema) now reads endpoint args and the recursive visitor from -[`INormalizeDelegate`](/rest/api/CustomSchema), matching the new [denormalize delegate](/rest/api/CustomSchema) shape. +[`Schema.normalize()`](/rest/api/SchemaSimple) now reads endpoint args and the recursive visitor from +[`INormalizeDelegate`](/rest/api/SchemaSimple), matching the new [denormalize delegate](/rest/api/SchemaSimple) shape. The previous signature was `(input, parent, key, args, visit, delegate, parentEntity?)`; the new signature is `(input, parent, key, delegate, parentEntity?)`. [#3934](https://github.com/reactive/data-client/pull/3934) @@ -355,13 +355,13 @@ normalize(input, parent, key, delegate, parentEntity) { } ``` -[`delegate.visit()`](/rest/api/CustomSchema) already carries the same args for recursive normalization, so nested +[`delegate.visit()`](/rest/api/SchemaSimple) already carries the same args for recursive normalization, so nested [schemas](/rest/api/schema) do not need args passed explicitly. #### Additive: `parentEntity` on normalize -The [normalize delegate](/rest/api/CustomSchema)'s `visit()` callback internally tracks the nearest enclosing -entity-like schema and forwards it to [`Schema.normalize()`](/rest/api/CustomSchema) as the optional trailing +The [normalize delegate](/rest/api/SchemaSimple)'s `visit()` callback internally tracks the nearest enclosing +entity-like schema and forwards it to [`Schema.normalize()`](/rest/api/SchemaSimple) as the optional trailing `parentEntity` argument. New [schemas](/rest/api/schema) can opt in to discover their containing entity at normalize time (used internally by [Scalar](/rest/api/Scalar)). diff --git a/website/sidebars-endpoint.json b/website/sidebars-endpoint.json index f03c214c61ca..63fcde3608c8 100644 --- a/website/sidebars-endpoint.json +++ b/website/sidebars-endpoint.json @@ -61,6 +61,6 @@ }, { "type": "doc", - "id": "api/CustomSchema" + "id": "api/SchemaSimple" } ]