diff --git a/.agents/skills/changeset/SKILL.md b/.agents/skills/changeset/SKILL.md index 7bcd8f3c87b2..f99a4b3f7aab 100644 --- a/.agents/skills/changeset/SKILL.md +++ b/.agents/skills/changeset/SKILL.md @@ -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") diff --git a/.changeset/controller-set-array.md b/.changeset/controller-set-array.md index 220efce32ab9..9cdc9a779990 100644 --- a/.changeset/controller-set-array.md +++ b/.changeset/controller-set-array.md @@ -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) { diff --git a/.changeset/entity-pk-readonly-args.md b/.changeset/entity-pk-readonly-args.md new file mode 100644 index 000000000000..6cd0e9e187fc --- /dev/null +++ b/.changeset/entity-pk-readonly-args.md @@ -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}`; + } +} +``` diff --git a/.cursor/rules/breaking-changes.mdc b/.cursor/rules/breaking-changes.mdc new file mode 100644 index 000000000000..cb205e2c8a04 --- /dev/null +++ b/.cursor/rules/breaking-changes.mdc @@ -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. diff --git a/packages/core/src/controller/setManyTypes.ts b/packages/core/src/controller/setManyTypes.ts index 3f7350654c10..58d8a3021658 100644 --- a/packages/core/src/controller/setManyTypes.ts +++ b/packages/core/src/controller/setManyTypes.ts @@ -1,16 +1,5 @@ /** 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 }; @@ -18,7 +7,7 @@ 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 @@ -29,7 +18,7 @@ type SetEntitySchema = export type SetManySchema = | readonly SetEntitySchema[] | { - readonly schema: SetEntitySchema | EntityMapLike; + readonly schema: SetEntitySchema | Record; // Array and Values; excludes schema.Object, whose queryKey() returns any schemaKey(): string; queryKey(...args: any): undefined; @@ -65,7 +54,7 @@ type SetRow = /** Polymorphic rows may carry a discriminator that is not an Entity field */ type SetRowOf = - Sch extends EntityLike ? SetRow + Sch extends EntityInterface ? SetRow : SetRow & { readonly [k: string]: unknown }; export type SetManyValue = diff --git a/packages/endpoint/src/schemas/Entity.ts b/packages/endpoint/src/schemas/Entity.ts index 3337db9d9711..e5bec07b09f4 100644 --- a/packages/endpoint/src/schemas/Entity.ts +++ b/packages/endpoint/src/schemas/Entity.ts @@ -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: ( - this: T, - value: Partial>, - 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( + this: T, + value: Partial>, + parent?: any, + key?: string, + args?: readonly any[], + ): string | number | undefined; + }['pk']; /** Do any transformations when first receiving input * diff --git a/packages/endpoint/src/schemas/EntityTypes.ts b/packages/endpoint/src/schemas/EntityTypes.ts index 89187468b3a3..7878680ccd93 100644 --- a/packages/endpoint/src/schemas/EntityTypes.ts +++ b/packages/endpoint/src/schemas/EntityTypes.ts @@ -61,7 +61,7 @@ export interface IEntityClass { value: Partial>, parent?: any, key?: string, - args?: any[], + args?: readonly any[], ): string | number | undefined; /** Return true to merge incoming data; false keeps existing entity * diff --git a/packages/endpoint/src/schemas/__tests__/Entity.test.ts b/packages/endpoint/src/schemas/__tests__/Entity.test.ts index 3e49e0a55ce2..a804380262e9 100644 --- a/packages/endpoint/src/schemas/__tests__/Entity.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Entity.test.ts @@ -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'; @@ -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', () => { diff --git a/plans/next-breaking-release.md b/plans/next-breaking-release.md new file mode 100644 index 000000000000..7a7b714a8e1d --- /dev/null +++ b/plans/next-breaking-release.md @@ -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`. diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index d08e8a6e8caf..286f95fcf288 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -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:** @@ -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}`; + } +} +``` + +:::