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
2 changes: 1 addition & 1 deletion .agents/skills/changeset/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ All user-facing text (changesets, blog entries, docs) should be written from the
2. **User vocabulary** — Name public APIs (`RestEndpoint`, `resource()`, hook names). Do not explain how the fix was implemented.
3. **When to add code** — Prefer a minimal example when the change is TypeScript-only or subtle: show the pattern that was broken and now works (subclass, `extend`, option object). Skip examples for trivial renames or obvious one-line fixes.
4. **Examples** — Realistic imports and types; omit unrelated options. For fixes, you can show one “now types correctly” snippet instead of a long before/after if the before state was “TypeScript error on …”.
5. **Breaking changes** — Still say what the user must do; use Before/After sections with code when the migration is non-obvious.
5. **Breaking changes** — Still say what the user must do; use Before/After sections with code when the migration is non-obvious. Before marking anything breaking, follow `.cursor/rules/breaking-changes.mdc` (ship a compatible version, recommend the future-proof form, track the cleanup).

## Changeset format
- **First line**: Action verb ("Add", "Fix", "Update", "Remove")
Expand Down
2 changes: 2 additions & 0 deletions .changeset/controller-set-array.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Fix `controller.set()` types for Array schemas

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

Batch `set()` needs `@data-client/rest` (or `endpoint`/`graphql`) from this release, since older Entity classes don't type as `EntityInterface`.

```ts
// Before: TypeScript error on [Ticker], so batches became one set() per row
for (const row of rows) {
Expand Down
27 changes: 27 additions & 0 deletions .changeset/entity-pk-readonly-args.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@data-client/endpoint': patch
'@data-client/rest': patch
'@data-client/graphql': patch
---

Fix Entity classes not assignable to `EntityInterface`

[Entity.pk()](https://dataclient.io/rest/api/Entity#pk)'s static `args` parameter is now `readonly any[]`, matching `EntityInterface`. Entity classes can be passed where an `EntityInterface` is expected.

```ts
import type { EntityInterface } from '@data-client/react';

// Before: TypeScript error (args is readonly in EntityInterface)
// After: typechecks
const schema: EntityInterface = User;
```

Overrides of `static pk()` that type `args` as a mutable array still compile, but a future breaking release will require `readonly any[]`, so update them now:

```ts
class User extends Entity {
static pk(value: any, parent?: any, key?: string, args?: readonly any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
```
25 changes: 25 additions & 0 deletions .cursor/rules/breaking-changes.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
description: Strategy for type/API changes that would break users or mixed package versions
globs: packages/**, .changeset/**, plans/next-breaking-release.md
alwaysApply: false
---

# Breaking Change Strategy

Get users the fix now with a clean upgrade path; batch the actual break into a later release.

## Before shipping a change that could break

Check both:
- **User code**: subclasses, overrides, explicit annotations, and classes implementing exported interfaces.
- **Mixed versions**: `@data-client/rest`/`endpoint`/`graphql` aren't dependencies of `react`/`vue`/`core`, so users can pair a newer client with an older endpoint (and vice versa). Type checks in core against endpoint types must still accept older endpoint versions.

Requiring a matching version for a feature that is new in the same release is not breaking.

## When it would break

1. **Ship a compatible version**: a shim that keeps old code compiling and working (e.g. method-syntax declarations for parameter bivariance, a loose structural type instead of the strict interface, a deprecated alias).
2. **Prepare users**: in the changeset and the release's draft blog post, recommend the future-proof form now with a code example, so the later break is a no-op for them.
3. **Track the cleanup**: add an entry to [plans/next-breaking-release.md](../../plans/next-breaking-release.md) naming the shim to remove, what it breaks, and the migration.

Mark a change `BREAKING` (minor bump while under 1.0) only when no compatible version is reasonable. When a release is already breaking, work through `plans/next-breaking-release.md`, move each finished item into that release's blog migration guide, and delete it from the plan.
19 changes: 4 additions & 15 deletions packages/core/src/controller/setManyTypes.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
/** 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 };
import type { Denormalize, EntityInterface } from '@data-client/normalizr';

/** 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
| EntityInterface
| {
_normalizeNullable(): EntityRef | undefined;
// excludes Collection
Expand All @@ -29,7 +18,7 @@ type SetEntitySchema =
export type SetManySchema =
| readonly SetEntitySchema[]
| {
readonly schema: SetEntitySchema | EntityMapLike;
readonly schema: SetEntitySchema | Record<string, EntityInterface>;
// Array and Values; excludes schema.Object, whose queryKey() returns any
schemaKey(): string;
queryKey(...args: any): undefined;
Expand Down Expand Up @@ -65,7 +54,7 @@ type SetRow<U> =

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

export type SetManyValue<S> =
Expand Down
17 changes: 10 additions & 7 deletions packages/endpoint/src/schemas/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,16 @@ export default abstract class Entity extends EntityMixin(EmptyBase) {
* @param [key] When normalizing, the key where this entity was found
* @param [args] ...args sent to Endpoint
*/
declare static pk: <T extends typeof Entity>(
this: T,
value: Partial<AbstractInstanceType<T>>,
parent?: any,
key?: string,
args?: any[],
) => string | number | undefined;
declare static pk: {
// method syntax: overrides that type `args` as a mutable array still compile
pk<T extends typeof Entity>(
this: T,
value: Partial<AbstractInstanceType<T>>,
parent?: any,
key?: string,
args?: readonly any[],
): string | number | undefined;
}['pk'];

/** Do any transformations when first receiving input
*
Expand Down
2 changes: 1 addition & 1 deletion packages/endpoint/src/schemas/EntityTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface IEntityClass<TBase extends Constructor = any> {
value: Partial<AbstractInstanceType<T>>,
parent?: any,
key?: string,
args?: any[],
args?: readonly any[],
): string | number | undefined;
/** Return true to merge incoming data; false keeps existing entity
*
Expand Down
19 changes: 19 additions & 0 deletions packages/endpoint/src/schemas/__tests__/Entity.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EntityInterface } from '@data-client/normalizr';
import { normalize, INVALID } from '@data-client/normalizr';
import { denormalize as plainDenormalize } from '@data-client/normalizr';
import { denormalize as immDenormalize } from '@data-client/normalizr/imm';
Expand Down Expand Up @@ -455,6 +456,24 @@ describe(`${Entity.name} normalization`, () => {
}),
).toMatchSnapshot();
});

test('Entity classes satisfy EntityInterface', () => {
class User extends Entity {
readonly id: string = '';
}
const entity: EntityInterface = User;
expect(entity.pk({ id: '5' }, undefined, undefined, [])).toBe('5');
});

test('static pk() overrides may type args as a mutable array', () => {
class User extends Entity {
readonly id: string = '';
static pk(value: any, parent?: any, key?: string, args?: any[]) {
return `${value.id}-${args?.[0]}`;
}
}
expect(User.pk({ id: '5' }, undefined, undefined, ['a'])).toBe('5-a');
});
});

describe('mergeStrategy', () => {
Expand Down
12 changes: 12 additions & 0 deletions plans/next-breaking-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Next Breaking Release TODO

Type and API cleanups deferred because they would break users or mixed package versions in a minor release. Do them in the next release that already breaks compatibility and requires matching `@data-client/*` versions, and list each in that release's blog migration guide.

## Entity `pk()` args

[#4149](https://github.com/reactive/data-client/pull/4149) made Entity classes assignable to `EntityInterface` without breaking anyone, using two compatibility shims.

- **Plain `Entity.pk` declaration**: `packages/endpoint/src/schemas/Entity.ts` declares `static pk` with method syntax (`{ pk(...): ... }['pk']`) so subclass overrides that type `args?: any[]` still compile. Replace it with a plain function type taking `args?: readonly any[]`.
- Breaks: `static pk()` overrides that annotate `args` as a mutable array. Migration: change the annotation to `readonly any[]` (the v0.19 blog already recommends this).
- **Readonly `args` in endpoint's `EntityInterface`**: `packages/endpoint/src/interface.ts` still declares `pk(..., args: any[])`, while normalizr's `EntityInterface` takes `readonly any[]`. Make them match, or have endpoint re-export normalizr's.
- Breaks: Entity subclasses with a mutable-`args` `static pk()` override assigned to endpoint's `EntityInterface`.
30 changes: 30 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 @@ -23,6 +23,7 @@ import StackBlitz from '@site/src/components/StackBlitz';
- Vue `DataClientPlugin` installs on Vue versions before 3.5 instead of throwing `app.onUnmount is not a function` ([#4146](https://github.com/reactive/data-client/pull/4146))
- Fix `Cannot find name 'NoInfer'` and `export type` errors on TypeScript 4.x with `skipLibCheck` off ([#4138](https://github.com/reactive/data-client/pull/4138))
- Fix `Entity`, `Endpoint`, `Union` and `RestEndpoint` type errors on TypeScript 4.0–4.5 with `skipLibCheck` off ([#4140](https://github.com/reactive/data-client/pull/4140))
- [Entity](/rest/api/Entity) classes can be used where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)); [prepare `pk()` overrides](/blog/2026/10/03/v0.19-batch-set#entity-pk-args) for a future release

**Breaking Changes:**

Expand Down Expand Up @@ -102,3 +103,32 @@ If you filter [DevToolsManager](/docs/api/DevToolsManager) actions by schema, a
is the Array schema, so match `action.schema[0]` for `[Ticker]` rather than the Entity itself.

:::

## Other improvements

### Entity pk() args {#entity-pk-args}

[Entity.pk()](/rest/api/Entity#pk) now types its `args` parameter as `readonly any[]`, so Entity classes can be passed
where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)).

```ts
import type { EntityInterface } from '@data-client/react';

const schema: EntityInterface = User;
```

:::tip

Overrides of `static pk()` that annotate `args` as a mutable array still compile, but a future breaking release
will require `readonly`. Update them now:

```ts
class User extends Entity {
// highlight-next-line
static pk(value: any, parent?: any, key?: string, args?: readonly any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
```

:::
Loading