Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
10 changes: 10 additions & 0 deletions .changeset/normalize-delegate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@data-client/endpoint": minor
"@data-client/normalizr": minor
---

Move normalize `args` and recursive `visit` into the existing normalize delegate passed to schemas.
Custom `Schema.normalize()` implementations should migrate from
`normalize(input, parent, key, args, visit, delegate, parentEntity?)` to
`normalize(input, parent, key, delegate, parentEntity?)`, then read
`delegate.args` and call `delegate.visit()` for recursive normalization.
84 changes: 74 additions & 10 deletions .cursor/skills/data-client-v0.18-migration/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: data-client-v0.18-migration
description: Migrate custom @data-client schemas from the v0.18 denormalize(input, args, unvisit) signature to the v0.18 denormalize(input, delegate) signature. Use when upgrading to v0.18, when seeing TS errors about unvisit not being callable, or when adapting custom Schema implementations.
description: Migrate custom @data-client schemas to v0.18 delegate signatures: denormalize(input, args, unvisit) -> denormalize(input, delegate) and normalize(input, parent, key, args, visit, delegate) -> normalize(input, parent, key, delegate). Use when upgrading to v0.18, seeing TS errors about unvisit/visit/args signatures, or adapting custom Schema implementations.
---

# @data-client v0.18 Migration
Expand Down Expand Up @@ -31,6 +31,22 @@ denormalize(input, delegate) {
}
```

`Schema.normalize()` also takes a delegate, matching the denormalize shape. The
old signature was `(input, parent, key, args, visit, delegate, parentEntity?)`;
the new signature is `(input, parent, key, delegate, parentEntity?)`.

```ts
// before
normalize(input, parent, key, args, visit, delegate) {
return visit(this.schema, input, parent, key, args);
}

// after
normalize(input, parent, key, delegate) {
return delegate.visit(this.schema, input, parent, key);
}
```

Full delegate surface ([`IDenormalizeDelegate`](https://dataclient.io/docs/api/Schema)):

```ts
Expand Down Expand Up @@ -69,6 +85,38 @@ class Wrapper {
}
```

### Normalize methods

`(input, parent, key, args, visit, delegate, parentEntity?)` →
`(input, parent, key, delegate, parentEntity?)`. Inside the body:

- `visit(schema, value, parent, key, args)` → `delegate.visit(schema, value, parent, key)`
- bare `args` references (including spreads) → `delegate.args`
- pass-through `someSchema.normalize(input, parent, key, args, visit, delegate)` →
`someSchema.normalize(input, parent, key, delegate)`

```ts
// before
class Wrapper {
normalize(input: {}, parent: any, key: string, args: readonly any[], visit: any, delegate: any) {
const value = visit(this.schema, input, parent, key, args);
return this.process(value, ...args);
}
}

// after
class Wrapper {
normalize(input: {}, parent: any, key: string, delegate: INormalizeDelegate) {
const value = delegate.visit(this.schema, input, parent, key);
return this.process(value, ...delegate.args);
}
}
```

If your normalize implementation used a different name for the existing delegate
parameter, such as `snapshot`, the codemod keeps that name and rewrites
`args`/`visit` to `snapshot.args` / `snapshot.visit`.

### TypeScript signatures

Update method signatures and `declare` fields the same way:
Expand All @@ -77,6 +125,7 @@ Update method signatures and `declare` fields the same way:
// before
interface MySchema {
denormalize(input: {}, args: readonly any[], unvisit: (s: any, v: any) => any): any;
normalize(input: {}, parent: any, key: any, args: readonly any[], visit: (s: any, v: any, p: any, k: any, a: readonly any[]) => any, delegate: any): any;
}

class Lazy {
Expand All @@ -87,22 +136,29 @@ class Lazy {
) => any;
}

// after — the codemod adds `IDenormalizeDelegate` to your existing
// after — the codemod adds delegate types to your existing
// `@data-client/{rest,endpoint,normalizr,...}` import as an inline
// `type` specifier. Only when no such import exists does it create a
// new `import type { IDenormalizeDelegate } from '@data-client/endpoint'`.
import { Entity, type IDenormalizeDelegate } from '@data-client/rest';
// `type` specifier. Only when no such import exists does it create
// new `import type { ... } from '@data-client/endpoint'` lines.
import {
Entity,
type IDenormalizeDelegate,
type INormalizeDelegate,
} from '@data-client/rest';

interface MySchema {
denormalize(input: {}, delegate: IDenormalizeDelegate): any;
normalize(input: {}, parent: any, key: any, delegate: INormalizeDelegate): any;
}

class Lazy {
declare _denormalizeNullable: (input: {}, delegate: IDenormalizeDelegate) => any;
}
```

The codemod matches `denormalize`, `_denormalize`, and `_denormalizeNullable` on type declarations.
The codemod matches `denormalize`, `_denormalize`, and `_denormalizeNullable`
on type declarations. It also matches `normalize` method/property signatures
with the old 6- or 7-argument form.

### args-dependent output (manual)

Expand Down Expand Up @@ -138,13 +194,17 @@ See [`Scalar`](https://dataclient.io/rest/api/Scalar) for a real-world example.
These are rare; do them by hand:

- **Computed/string-keyed methods**: only literal `denormalize` keys are matched.
- **Computed/string-keyed normalize methods**: only literal `normalize` keys are matched.
- **Methods reassigned dynamically** (`obj.denormalize = function(input, args, unvisit) { ... }`).
- **Normalize methods reassigned dynamically** (`obj.normalize = function(input, parent, key, args, visit, delegate) { ... }`).
- **Custom helper functions** that wrap `(args, unvisit)` and are passed around — you'll need to update both the helper and its callers.
- **`argsKey` registration** for schemas whose output varies with `args` (see above).

## New (additive, no migration needed)
## New normalize context

`Schema.normalize()` and the `visit()` callback gain an optional trailing `parentEntity` parameter — the nearest enclosing entity-like schema, tracked automatically by the visit walker. Existing schemas don't need changes; new schemas can opt in.
`Schema.normalize()` keeps the optional trailing `parentEntity` parameter — the
nearest enclosing entity-like schema, tracked automatically by the visit walker.
Existing schemas that use it should keep it after the delegate parameter.

### Optional Collection cleanup

Expand Down Expand Up @@ -189,13 +249,17 @@ class User extends Entity {
Search for these patterns in your codebase:

- `denormalize(input` followed by 3 params — both class methods and bare functions
- `normalize(input` followed by `args, visit, delegate` params
- `unvisit(` calls inside a `denormalize` body
- `visit(` calls inside a `normalize` body
- Spread `...args` inside a `denormalize` body
- Spread `...args` inside a `normalize` body
- TS interfaces / `declare` fields with the 3-param signature
- TS interfaces / `declare` fields with the old normalize signature
- Custom `Schema` / `SchemaClass` / `SchemaSimple` implementations

## Reference

- Changeset: `.changeset/denormalize-delegate.md`
- 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 interface: [`IDenormalizeDelegate`](https://dataclient.io/docs/api/Schema)
- New interfaces: [`IDenormalizeDelegate`](https://dataclient.io/docs/api/Schema), [`INormalizeDelegate`](https://dataclient.io/docs/api/Schema)
88 changes: 88 additions & 0 deletions docs/rest/api/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,94 @@ interface Queryable {
}
```

## Custom Schema interface {#custom-schema}

Custom schema implementations can participate in normalization and denormalization
by implementing the same methods as built-in schemas.

### normalize(input, parent, key, delegate, parentEntity?) {#schema-normalize}

`normalize()` receives the value at the current schema node and returns the
normalized representation to store in the surrounding result. Use
`delegate.visit()` to recursively normalize nested schemas and `delegate.args`
to read the endpoint args for the current normalize call.

```ts
import type { INormalizeDelegate } from '@data-client/endpoint';

class Wrapper {
schema = Article;

normalize(
input: any,
parent: any,
key: string | undefined,
delegate: INormalizeDelegate,
) {
const normalized = delegate.visit(this.schema, input.data, input, 'data');
return {
...input,
data: normalized,
requestId: delegate.args[0]?.requestId,
};
}
}
```

#### INormalizeDelegate

```ts
interface INormalizeDelegate {
visit(schema: any, input: any, parent: any, key: any): any;
readonly args: readonly any[];
readonly meta: { fetchedAt: number; date: number; expiresAt: number };
getEntities(key: string): EntitiesInterface | undefined;
getEntity(key: string, pk: string): any;
mergeEntity(schema: Mergeable, pk: string, incomingEntity: any): void;
setEntity(schema: { key: string }, pk: string, entity: any): void;
invalidate(schema: { key: string }, pk: string): void;
checkLoop(key: string, pk: string, input: object): boolean;
}
```

`parentEntity` is the nearest enclosing entity-like schema, when present. Most
custom schemas can ignore it; schemas that need their containing entity context
can use it to derive related storage keys.

:::note

Before v0.18, `normalize()` received `args` and `visit` as separate positional
parameters: `(input, parent, key, args, visit, delegate, parentEntity?)`. Use
`delegate.args` and `delegate.visit()` instead.

:::

### denormalize(input, delegate) {#schema-denormalize}

`denormalize()` receives the normalized input and returns the denormalized value.
Use `delegate.unvisit()` for nested schemas and `delegate.args` for endpoint
args.

```ts
import type { IDenormalizeDelegate } from '@data-client/endpoint';

class Wrapper {
schema = Article;

denormalize(input: any, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input.data);
return {
...input,
data: value,
requestId: delegate.args[0]?.requestId,
};
}
}
```

If denormalized output changes based on args, register that cache dimension with
`delegate.argsKey(fn)`. See [Scalar](./Scalar.md) for an args-dependent schema.

## Schema Overview

<SchemaTable/>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"globals": "^16.0.0",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"jscodeshift": "^17.3.0",
"mkdirp": "^3.0.0",
"nock": "13.3.1",
"npm-run-all": "^4.1.5",
Expand Down
17 changes: 8 additions & 9 deletions packages/endpoint/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ export interface SchemaSimple<T = any, Args extends readonly any[] = any> {
* @param input The value being normalized.
* @param parent The parent object/array/dictionary containing `input`.
* @param key The key under which `input` lives on `parent`.
* @param args The endpoint args for this normalize call.
* @param visit Recursive visitor for nested schemas.
* @param delegate Store accessors for reading/writing entities.
* @param delegate Recursive visitor, endpoint args, and store accessors.
* @param parentEntity Nearest enclosing entity-like schema (one with `pk`),
* tracked automatically by the visit walker. `Scalar`
* uses this to discover its entity binding.
Expand All @@ -41,9 +39,7 @@ export interface SchemaSimple<T = any, Args extends readonly any[] = any> {
input: any,
parent: any,
key: any,
args: any[],
visit: (...args: any) => any,
delegate: { getEntity: any; setEntity: any },
delegate: INormalizeDelegate,
parentEntity?: any,
): any;
denormalize(input: {}, delegate: IDenormalizeDelegate): T;
Expand Down Expand Up @@ -130,14 +126,13 @@ export interface EntityTable {
* Schemas that recurse via `visit` should pass their own
* `input` (or the surrounding container) here.
* @param key The key under which `value` lives on `parent`.
* @param args The endpoint args for this normalize call.
*
* The walker internally tracks the nearest enclosing entity-like schema and
* forwards it to `schema.normalize` as a trailing `parentEntity` argument —
* see `SchemaSimple.normalize`. Consumers of `visit` don't pass it.
* see `SchemaSimple.normalize`.
*/
export interface Visit {
(schema: any, value: any, parent: any, key: any, args: readonly any[]): any;
(schema: any, value: any, parent: any, key: any): any;
creating?: boolean;
}

Expand Down Expand Up @@ -199,6 +194,10 @@ export interface IDenormalizeDelegate {

/** Helpers during schema.normalize() */
export interface INormalizeDelegate {
/** Recursive normalize of nested schemas */
visit: Visit;
/** Raw endpoint args for this normalize call */
readonly args: readonly any[];
/** Action meta-data for this normalize call */
readonly meta: { fetchedAt: number; date: number; expiresAt: number };
/** Get all entities for a given schema key */
Expand Down
2 changes: 1 addition & 1 deletion packages/endpoint/src/normal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,5 @@ export type NormalizedSchema<E, R> = {
};

export interface EntityMap<T = any> {
readonly [k: string]: EntityInterface<T>;
readonly [k: string]: any;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
14 changes: 2 additions & 12 deletions packages/endpoint/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class Array<S extends Schema = Schema> implements SchemaClass {
constructor(
definition: S,
schemaAttribute?: S extends EntityMap<infer T> ?
keyof T | SchemaFunction<keyof S>
string | SchemaFunction<keyof S>
: undefined,
);

Expand All @@ -69,8 +69,6 @@ export class Array<S extends Schema = Schema> implements SchemaClass {
input: any,
parent: any,
key: any,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): (S extends EntityMap ? UnionResult<S> : Normalize<S>)[];

Expand Down Expand Up @@ -110,7 +108,7 @@ export class All<
constructor(
definition: S,
schemaAttribute?: S extends EntityMap<infer T> ?
keyof T | SchemaFunction<keyof S>
string | SchemaFunction<keyof S>
: undefined,
);

Expand All @@ -123,8 +121,6 @@ export class All<
input: any,
parent: any,
key: any,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): (S extends EntityMap ? UnionResult<S> : Normalize<S>)[];

Expand Down Expand Up @@ -167,8 +163,6 @@ export class Object<
input: any,
parent: any,
key: any,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): NormalizeObject<O>;

Expand Down Expand Up @@ -251,8 +245,6 @@ export interface UnionInstance<
input: any,
parent: any,
key: any,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): UnionResult<Choices>;

Expand Down Expand Up @@ -322,8 +314,6 @@ export class Values<Choices extends Schema = any> implements SchemaClass {
input: any,
parent: any,
key: any,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): Record<
string,
Expand Down
Loading
Loading