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
53 changes: 53 additions & 0 deletions .changeset/denormalize-delegate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@data-client/endpoint': minor
'@data-client/rest': minor
'@data-client/graphql': minor
'@data-client/normalizr': minor
'@data-client/core': minor
'@data-client/react': minor
'@data-client/vue': minor
---

**BREAKING**: `Schema.denormalize()` is now `(input, delegate)` instead
of the previous `(input, args, unvisit)` 3-parameter signature.

```ts
// before
denormalize(input, args, unvisit) {
return unvisit(this.schema, input);
}

// after
denormalize(input, delegate) {
return delegate.unvisit(this.schema, input);
}
```

The new [`IDenormalizeDelegate`](https://dataclient.io/docs/api/Schema)
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 —
schemas that branch on args must call `argsKey`. The `fn` reference
doubles as the cache path key, so it must be **referentially stable**
— define it on the instance or at module scope, not inline per call:

```ts
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // stable reference across calls
}
denormalize(input, delegate) {
const portfolio = delegate.argsKey(this.lensSelector);
return this.lookup(input, portfolio);
}
}
```

All built-in schemas (`Array`, `Object`, `Values`, `Union`, `Query`,
`Invalidate`, `Lazy`, `Collection`) have been updated. Custom schemas
implementing `SchemaSimple` must update their `denormalize` signature.

`Schema.normalize()` and the `visit()` callback also gain an optional
trailing `parentEntity` argument tracking the nearest enclosing
entity-like schema. This is additive — existing schemas don't need
changes unless they want to use it.
70 changes: 70 additions & 0 deletions .changeset/scalar-entity-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
'@data-client/endpoint': minor
'@data-client/rest': minor
'@data-client/graphql': minor
'@data-client/normalizr': minor
'@data-client/core': minor
'@data-client/react': minor
'@data-client/vue': minor
---

Add [Scalar](https://dataclient.io/rest/api/Scalar) schema for lens-dependent entity fields.

`Scalar` models entity fields whose values vary by a runtime "lens" (such as the
selected portfolio, currency, or locale). Multiple components can render the
same entity through different lenses simultaneously — each sees the correct
values without the entity itself ever being mutated. Lens-dependent values are
stored in a separate cell table and joined at denormalize time from endpoint
args.

New exports: `Scalar`, `schema.Scalar`.

A single `Scalar` instance can serve both as an `Entity.schema` field (parent
entity inferred from the visit) and standalone — inside `Values(Scalar)`,
`[Scalar]`, or `Collection([Scalar])` — for cheap column-only refreshes
(entity bound explicitly via `entity`). Cell pks are derived from the map key
or via `Scalar.entityPk()`, which defaults to `Entity.pk()` so custom and
composite primary keys work with no override:

```ts
import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest';

class Company extends Entity {
id = '';
price = 0;
pct_equity = 0;
shares = 0;
}
const PortfolioScalar = new Scalar({
lens: args => args[0]?.portfolio,
key: 'portfolio',
entity: Company,
});
Company.schema = {
pct_equity: PortfolioScalar,
shares: PortfolioScalar,
};

// Full load — Company rows + scalar cells for the current portfolio
export const getCompanies = new RestEndpoint({
path: '/companies',
searchParams: {} as { portfolio: string },
schema: new Collection([Company], { argsKey: () => ({}) }),
});
// Lens-only refresh — writes to the same Scalar(portfolio) cell table
export const getPortfolioColumns = new RestEndpoint({
path: '/companies/columns',
searchParams: {} as { portfolio: string },
schema: new Collection([PortfolioScalar], {
argsKey: ({ portfolio }) => ({ portfolio }),
}),
});
```

`useSuspense(getCompanies, { portfolio: 'A' })` and
`useSuspense(getCompanies, { portfolio: 'B' })` resolve to different
`pct_equity` / `shares` while sharing the same `Company` row.

`Scalar.queryKey` enumerates cells in its table for the current lens, so
endpoints that use `Scalar` directly as their top-level schema reconstruct
from cache without a network round-trip once the cells are present.
9 changes: 9 additions & 0 deletions .cursor/skills/data-client-schema/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ to represent the data expected.
### Object

- [Entity](references/Entity.md) - represents a single unique object (denormalized)
- [EntityMixin](references/EntityMixin.md) - turn any pre-existing class into an Entity
- [new Union(Entity)](references/Union.md) - polymorphic objects (A | B)
- `{[key:string]: Schema}` - immutable objects
- [new Invalidate(Entity|Union)](references/Invalidate.md) - to delete an Entity
- [new Lazy(() => Schema)](references/Lazy.md) - break circular imports / defer deep recursive denormalization

### List

Expand All @@ -27,6 +29,10 @@ to represent the data expected.
- `new Collection(Values(Schema))` - mutable/growable maps
- [new Values(Schema)](references/Values.md) - immutable maps

### Lens-dependent entity fields

- [new Scalar({ lens, key, entity? })](references/Scalar.md) - per-field cells that vary by a runtime "lens" (portfolio, currency, locale) without mutating the underlying entity

### Derived / selector pattern

- [new Query(Queryable)](references/Query.md) - memoized programmatic selectors
Expand Down Expand Up @@ -156,10 +162,13 @@ See [partial-entities](references/partial-entities.md) for patterns and examples
For detailed API documentation, see the [references](references/) directory:

- [Entity](references/Entity.md) - Normalized data class
- [EntityMixin](references/EntityMixin.md) - Turn any class into an Entity
- [Collection](references/Collection.md) - Mutable/growable lists
- [Union](references/Union.md) - Polymorphic schemas
- [Query](references/Query.md) - Programmatic selectors
- [Invalidate](references/Invalidate.md) - Delete entities
- [Lazy](references/Lazy.md) - Deferred / circular schemas
- [Scalar](references/Scalar.md) - Lens-dependent entity fields
- [Values](references/Values.md) - Map schemas
- [All](references/All.md) - List all entities of a kind
- [Array](references/Array.md) - Immutable list schema
Expand Down
1 change: 1 addition & 0 deletions .cursor/skills/data-client-schema/references/Lazy.md
1 change: 1 addition & 0 deletions .cursor/skills/data-client-schema/references/Scalar.md
163 changes: 163 additions & 0 deletions .cursor/skills/data-client-v0.17-migration/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
---
name: data-client-v0.17-migration
description: Migrate custom @data-client schemas from the v0.16 denormalize(input, args, unvisit) signature to the v0.17 denormalize(input, delegate) signature. Use when upgrading to v0.17, when seeing TS errors about unvisit not being callable, or when adapting custom Schema implementations.
---

# @data-client v0.17 Migration

Applies to anyone implementing a custom [`Schema`](https://dataclient.io/docs/api/Schema) — `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:

```bash
npx jscodeshift -t https://dataclient.io/codemods/v0.17.js --extensions=ts,tsx,js,jsx src/
```

This skill describes what it does and how to handle the cases it can't.

## What changed

`Schema.denormalize()` now takes a single `delegate` instead of `(args, unvisit)`. Reading `delegate.args` does **not** contribute to cache invalidation — schemas whose output varies with endpoint args must register that dependency through `delegate.argsKey(fn)`.

```ts
// before
denormalize(input, args, unvisit) {
return unvisit(this.schema, input);
}

// after
denormalize(input, delegate) {
return delegate.unvisit(this.schema, input);
}
```

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

```ts
interface IDenormalizeDelegate {
unvisit(schema: any, input: any): any;
readonly args: readonly any[];
argsKey(fn: (args: readonly any[]) => string | undefined): string | undefined;
}
```

## Migration rules

### Class methods

`(input, args, unvisit)` → `(input, delegate)`. Inside the body:

- `unvisit(schema, value)` → `delegate.unvisit(schema, value)`
- bare `args` references (including spreads) → `delegate.args`
- pass-through `someSchema.denormalize(input, args, unvisit)` → `someSchema.denormalize(input, delegate)`

```ts
// before
class Wrapper {
denormalize(input: {}, args: readonly any[], unvisit: any) {
const value = unvisit(this.schema, input);
return this.process(value, ...args);
}
}

// after
class Wrapper {
denormalize(input: {}, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input);
return this.process(value, ...delegate.args);
}
}
```

### TypeScript signatures

Update method signatures and `declare` fields the same way:

```ts
// before
interface MySchema {
denormalize(input: {}, args: readonly any[], unvisit: (s: any, v: any) => any): any;
}

class Lazy {
declare _denormalizeNullable: (
input: {},
args: readonly any[],
unvisit: (s: any, v: any) => any,
) => any;
}

// after — the codemod adds `IDenormalizeDelegate` 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';

interface MySchema {
denormalize(input: {}, delegate: IDenormalizeDelegate): any;
}

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

The codemod matches `denormalize`, `_denormalize`, and `_denormalizeNullable` on type declarations.

### 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/docs/api/Schema) 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.

```ts
// before — args read directly
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}

// after — stable instance field + declared memo dimension
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens;
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector);
return this.lookup(input, lens);
}
}
```

See [`Scalar`](https://dataclient.io/rest/api/Scalar) for a real-world example.

## What the codemod skips

These are rare; do them by hand:

- **Computed/string-keyed methods**: only literal `denormalize` keys are matched.
- **Methods reassigned dynamically** (`obj.denormalize = function(input, args, unvisit) { ... }`).
- **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)

`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.

## Where to find affected code

Search for these patterns in your codebase:

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

## Reference

- Changeset: `.changeset/denormalize-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)
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Reactive Data Client

Monorepo for `@data-client` npm packages.
Monorepo for `@data-client` high performance npm packages.

## Architecture

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,13 @@ For the small price of 9kb gziped.    [🏁Get started now](https://da
<td align="center">🛑</td>
</tr>
<tr>
<td rowSpan="1"><a href="https://en.wikipedia.org/wiki/Scalar_(mathematics)">Scalar</a></td>
<td align="center">✅</td>
<td><a href="https://dataclient.io/rest/api/Scalar">Scalar</a></td>
<td>lens-dependent entity fields</td>
<td align="center">✅</td>
</tr>
<tr>
<td rowSpan="2">any</td>
<td align="center"></td>
<td><a href="https://dataclient.io/rest/api/Query">Query(Queryable)</a></td>
Expand Down
4 changes: 2 additions & 2 deletions docs/core/api/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import VoteDemo from '../shared/\_VoteDemo.mdx';
Data rendering without the fetch.

Access any [Queryable Schema](/rest/api/schema#queryable)'s store value; like [Entity](/rest/api/Entity), [All](/rest/api/All), [Collection](/rest/api/Collection), [Query](/rest/api/Query),
and [Union](/rest/api/Union). [Lazy](/rest/api/Lazy) fields also work via their [`.query`](/rest/api/Lazy#query) accessor.
[Union](/rest/api/Union), and [Scalar](/rest/api/Scalar). [Lazy](/rest/api/Lazy) fields also work via their [`.query`](/rest/api/Lazy#query) accessor.
If the value does not exist, returns `undefined`.

`useQuery()` is reactive to data [mutations](../getting-started/mutations.md); rerendering only when necessary. Returns `undefined`
Expand Down Expand Up @@ -60,7 +60,7 @@ function useQuery<S extends Queryable>(

[Queryable](/rest/api/schema#queryable) schemas require an `queryKey()` method that returns something. These include
[Entity](/rest/api/Entity), [All](/rest/api/All), [Collection](/rest/api/Collection), [Query](/rest/api/Query),
and [Union](/rest/api/Union). [Lazy](/rest/api/Lazy) fields produce a Queryable via their [`.query`](/rest/api/Lazy#query) accessor.
[Union](/rest/api/Union), and [Scalar](/rest/api/Scalar). [Lazy](/rest/api/Lazy) fields produce a Queryable via their [`.query`](/rest/api/Lazy#query) accessor.

```ts
interface Queryable {
Expand Down
7 changes: 7 additions & 0 deletions docs/core/shared/_schema_table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@
<td align="center">🛑</td>
</tr>
<tr>
<td rowSpan={1}><a href="https://en.wikipedia.org/wiki/Scalar_(mathematics)">Scalar</a></td>
<td align="center">✅</td>
<td>[Scalar](/rest/api/Scalar)</td>
<td>lens-dependent entity fields</td>
<td align="center">✅</td>
</tr>
<tr>
<td rowSpan={2}>any</td>
<td align="center"></td>
<td>[Query(Queryable)](/rest/api/Query)</td>
Expand Down
2 changes: 1 addition & 1 deletion docs/rest/api/Collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ class User extends Entity {
In this case, `user.todos` and getTodos() response (from the argsKey example) will always
be the same (referentially equal) Array.

### nonFilterArgumentKeys?
### nonFilterArgumentKeys? {#nonFilterArgumentKeys}

A convenient alternative to [argsKey](#argsKey)

Expand Down
Loading
Loading