diff --git a/.changeset/invalidate-union-support.md b/.changeset/invalidate-union-support.md new file mode 100644 index 000000000000..5e0d93f027e5 --- /dev/null +++ b/.changeset/invalidate-union-support.md @@ -0,0 +1,21 @@ +--- +'@data-client/endpoint': minor +'@data-client/rest': minor +'@data-client/graphql': minor +--- + +Add [Union](https://dataclient.io/rest/api/Union) support to [schema.Invalidate](https://dataclient.io/rest/api/Invalidate) +for polymorphic delete operations: + +```ts +new schema.Invalidate( + { users: User, groups: Group }, + 'type' +) +``` + +or + +```ts +new schema.Invalidate(MyUnionSchema) +``` diff --git a/.cursor/commands/changeset.md b/.cursor/commands/changeset.md index 34291b76365c..e26fe263e794 100644 --- a/.cursor/commands/changeset.md +++ b/.cursor/commands/changeset.md @@ -26,6 +26,7 @@ Generate release notes for user-facing changes in published packages. - **Breaking**: Prefix with `BREAKING CHANGE:` or `BREAKING:` - **Body**: 1–3 lines describing outcome, not implementation - **New exports**: Use "New exports:" with bullet list +- **Documentation links**: Link concepts that have doc pages in @docs (e.g., `[Union](https://dataclient.io/rest/api/Union)`) ## Code Examples in Changesets - Fixes: `// Before: ... ❌` `// After: ... ✓` diff --git a/docs/rest/api/Invalidate.md b/docs/rest/api/Invalidate.md index 2a73fa32adad..520fbfe959e8 100644 --- a/docs/rest/api/Invalidate.md +++ b/docs/rest/api/Invalidate.md @@ -12,9 +12,22 @@ import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground'; Describes entities to be marked as [INVALID](/docs/concepts/expiry-policy#invalid). This removes items from a collection, or [forces suspense](/docs/concepts/expiry-policy#invalidate-entity) for endpoints where the entity is required. -Constructor: +## Constructor -- `entity` which entity to invalidate. The input is used to compute the pk() for lookup. +```typescript +new schema.Invalidate(entity) +new schema.Invalidate(union) +new schema.Invalidate(entityMap, schemaAttribute) +``` + +- `entity`: A singular [Entity](./Entity.md) to invalidate. +- `union`: A [Union](./Union.md) schema for polymorphic invalidation. +- `entityMap`: A mapping of schema keys to [Entities](./Entity.md). +- `schemaAttribute`: _optional_ (required if `entityMap` is used) The attribute on each entity found that defines what schema, per the entityMap, to use when normalizing. + Can be a string or a function. If given a function, accepts the following arguments: + - `value`: The input value of the entity. + - `parent`: The parent object of the input array. + - `key`: The key at which the input array appears on the parent object. ## Usage @@ -177,6 +190,68 @@ PostResource.deleteMany(['5', '13', '7']); +### Polymorphic types + +If your endpoint can delete more than one type of entity, you can use polymorphic invalidation. + +#### With Union schema + +The simplest approach is to pass an existing [Union](./Union.md) schema directly: + +```typescript +class User extends Entity { + id = ''; + name = ''; + readonly type = 'users'; +} +class Group extends Entity { + id = ''; + groupname = ''; + readonly type = 'groups'; +} + +const MemberUnion = new schema.Union( + { users: User, groups: Group }, + 'type' +); + +const deleteMember = new RestEndpoint({ + path: '/members/:id', + method: 'DELETE', + schema: new schema.Invalidate(MemberUnion), +}); +``` + +#### string schemaAttribute + +Alternatively, define the polymorphic mapping inline with a string attribute: + +```typescript +const deleteMember = new RestEndpoint({ + path: '/members/:id', + method: 'DELETE', + schema: new schema.Invalidate( + { users: User, groups: Group }, + 'type' + ), +}); +``` + +#### function schemaAttribute + +The return values should match a key in the entity map. This is useful for more complex discrimination logic: + +```typescript +const deleteMember = new RestEndpoint({ + path: '/members/:id', + method: 'DELETE', + schema: new schema.Invalidate( + { users: User, groups: Group }, + (input, parent, key) => input.memberType === 'user' ? 'users' : 'groups' + ), +}); +``` + ### Impact on useSuspense() When entities are invalidated in a result currently being presented in React, useSuspense() diff --git a/packages/endpoint/src/schemas/Invalidate.ts b/packages/endpoint/src/schemas/Invalidate.ts index aaa145876ff1..d5238e6ba0a8 100644 --- a/packages/endpoint/src/schemas/Invalidate.ts +++ b/packages/endpoint/src/schemas/Invalidate.ts @@ -1,10 +1,9 @@ -import type { - EntityInterface, - INormalizeDelegate, - SchemaSimple, -} from '../interface.js'; +import PolymorphicSchema from './Polymorphic.js'; +import type { EntityInterface, INormalizeDelegate } from '../interface.js'; import type { AbstractInstanceType } from '../normal.js'; +type ProcessableEntity = EntityInterface & { process: any }; + /** * Marks entity as Invalid. * @@ -13,12 +12,8 @@ import type { AbstractInstanceType } from '../normal.js'; * @see https://dataclient.io/rest/api/Invalidate */ export default class Invalidate< - E extends EntityInterface & { - process: any; - }, -> implements SchemaSimple { - declare protected _entity: E; - + E extends ProcessableEntity | Record, +> extends PolymorphicSchema { /** * Marks entity as Invalid. * @@ -26,15 +21,20 @@ export default class Invalidate< * Optional (like variable sized Array and Values) will simply remove the item. * @see https://dataclient.io/rest/api/Invalidate */ - constructor(entity: E) { + constructor( + entity: E, + schemaAttribute?: E extends Record ? + string | ((input: any, parent: any, key: any) => string) + : undefined, + ) { if (process.env.NODE_ENV !== 'production' && !entity) { throw new Error('Invalidate schema requires "entity" option.'); } - this._entity = entity; + super(entity, schemaAttribute); } get key(): string { - return this._entity.key; + return this.schemaKey(); } normalize( @@ -44,53 +44,72 @@ export default class Invalidate< args: any[], visit: (...args: any) => any, delegate: INormalizeDelegate, - ): string { - // TODO: what's store needs to be a differing type from fromJS - const processedEntity = this._entity.process(input, parent, key, args); - let pk = this._entity.pk(processedEntity, parent, key, args); + ): string | { id: string; schema: string } { + const entitySchema = this.inferSchema(input, parent, key); + if (!entitySchema) return input; + + // Handle string/number input (already processed pk) + // Note: This branch is typically not reached through public API as getVisit + // handles primitives before calling schema.normalize() + let pk: string | number | undefined; + /* istanbul ignore if */ + if (typeof input === 'string' || typeof input === 'number') { + pk = input; + } else { + // Must call process() to get correct pk + const processedEntity = entitySchema.process(input, parent, key, args); + pk = entitySchema.pk(processedEntity ?? input, parent, key, args); - if ( - process.env.NODE_ENV !== 'production' && - (pk === undefined || pk === '' || pk === 'undefined') - ) { - const error = new Error( - `Missing usable primary key when normalizing response. + if ( + process.env.NODE_ENV !== 'production' && + (pk === undefined || pk === '' || pk === 'undefined') + ) { + const error = new Error( + `Missing usable primary key when normalizing response. This is likely due to a malformed response. Try inspecting the network response or fetch() return value. Or use debugging tools: https://dataclient.io/docs/getting-started/debugging Learn more about schemas: https://dataclient.io/docs/api/schema - Invalidate(Entity): Invalidate(${this._entity.key}) + Invalidate(Entity): Invalidate(${entitySchema.key}) Value (processed): ${input && JSON.stringify(input, null, 2)} `, - ); - (error as any).status = 400; - throw error; + ); + (error as any).status = 400; + throw error; + } } pk = `${pk}`; // ensure pk is a string // any queued updates are meaningless with delete, so we should just set it // and creates will have a different pk - delegate.invalidate({ key: this._entity.key }, pk); - return pk; + delegate.invalidate(entitySchema, pk); + + return this.isSingleSchema ? pk : ( + { id: pk, schema: this.getSchemaAttribute(input, parent, key) } + ); } - queryKey(args: any, unvisit: unknown, delegate: unknown): undefined { + queryKey(_args: any, _unvisit: unknown, _delegate: unknown): undefined { return undefined; } denormalize( - id: string, + id: string | { id: string; schema: string }, args: readonly any[], unvisit: (schema: any, input: any) => any, - ): AbstractInstanceType { - // TODO: is this really always going to be the full object - validate that calling fetch will give this even when input is a string - return unvisit(this._entity, id) as any; + ): E extends ProcessableEntity ? AbstractInstanceType + : AbstractInstanceType { + // denormalizeValue handles both single entity and polymorphic cases + return this.denormalizeValue(id, unvisit) as any; } /* istanbul ignore next */ - _denormalizeNullable(): AbstractInstanceType | undefined { + _denormalizeNullable(): + | (E extends ProcessableEntity ? AbstractInstanceType + : AbstractInstanceType) + | undefined { return {} as any; } diff --git a/packages/endpoint/src/schemas/Polymorphic.ts b/packages/endpoint/src/schemas/Polymorphic.ts index 1ff35b7a5b9f..da292321939a 100644 --- a/packages/endpoint/src/schemas/Polymorphic.ts +++ b/packages/endpoint/src/schemas/Polymorphic.ts @@ -23,8 +23,9 @@ export default class PolymorphicSchema { } define(definition: any) { - // sending Union into another Polymorphic gets hoisted - if ('_schemaAttribute' in definition && !this._schemaAttribute) { + // Only Union opts into hoisting (_hoistable = true) + // This prevents Array(Array(...)), Values(Array(...)), Array(Invalidate(...)) issues + if (definition._hoistable && !this._schemaAttribute) { this.schema = definition.schema; this._schemaAttribute = definition._schemaAttribute; } else { diff --git a/packages/endpoint/src/schemas/Union.ts b/packages/endpoint/src/schemas/Union.ts index 12679c497d31..ef91dcaa8b34 100644 --- a/packages/endpoint/src/schemas/Union.ts +++ b/packages/endpoint/src/schemas/Union.ts @@ -6,6 +6,9 @@ import { Visit } from '../interface.js'; * @see https://dataclient.io/rest/api/Union */ export default class UnionSchema extends PolymorphicSchema { + // Union is designed to be transparent; allow hoisting into wrappers (Array, Values) + protected readonly _hoistable = true as const; + constructor(definition: any, schemaAttribute: any) { if (!schemaAttribute) { throw new Error( diff --git a/packages/endpoint/src/schemas/__tests__/Array.test.js b/packages/endpoint/src/schemas/__tests__/Array.test.js index 91efc7f42d29..7bc0f1ceb10e 100644 --- a/packages/endpoint/src/schemas/__tests__/Array.test.js +++ b/packages/endpoint/src/schemas/__tests__/Array.test.js @@ -452,3 +452,58 @@ describe.each([ }); }); }); + +describe('nested polymorphic schemas', () => { + class User extends IDEntity { + type = 'users'; + } + class Group extends IDEntity { + type = 'groups'; + } + + test('Array of Array normalizes without hoisting', () => { + const innerArray = new schema.Array(User); + const outerArray = new schema.Array(innerArray); + + const input = [[{ id: '1' }, { id: '2' }], [{ id: '3' }]]; + const output = normalize(outerArray, input); + + expect(output.entities.User).toEqual({ + 1: expect.objectContaining({ id: '1' }), + 2: expect.objectContaining({ id: '2' }), + 3: expect.objectContaining({ id: '3' }), + }); + expect(output.result).toEqual([['1', '2'], ['3']]); + }); + + test('Array of Union normalizes with hoisting', () => { + const union = new schema.Union({ users: User, groups: Group }, 'type'); + const arrayOfUnion = new schema.Array(union); + + const input = [ + { id: '1', type: 'users' }, + { id: '2', type: 'groups' }, + ]; + const output = normalize(arrayOfUnion, input); + + expect(output.entities.User['1']).toBeDefined(); + expect(output.entities.Group['2']).toBeDefined(); + expect(output.result).toEqual([ + { id: '1', schema: 'users' }, + { id: '2', schema: 'groups' }, + ]); + }); + + test('Array of Invalidate normalizes without hoisting (calls invalidate)', () => { + const invalidate = new schema.Invalidate(User); + const arrayOfInvalidate = new schema.Array(invalidate); + + const input = [{ id: '1' }, { id: '2' }]; + const output = normalize(arrayOfInvalidate, input); + + // Invalidate should mark entities as INVALID, not store them as objects + expect(output.entities.User['1']).toEqual(expect.any(Symbol)); + expect(output.entities.User['2']).toEqual(expect.any(Symbol)); + expect(output.result).toEqual(['1', '2']); + }); +}); diff --git a/packages/endpoint/src/schemas/__tests__/Invalidate.test.ts b/packages/endpoint/src/schemas/__tests__/Invalidate.test.ts index de3e2e496a71..1f2d9f9b07de 100644 --- a/packages/endpoint/src/schemas/__tests__/Invalidate.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Invalidate.test.ts @@ -45,6 +45,14 @@ describe(`${schema.Invalidate.name} normalization`, () => { ).toMatchSnapshot(); }); + test('normalizes number input within array (already processed pk)', () => { + class MyEntity extends IDEntity {} + // Numbers work when inside arrays (bypasses entry-point type validation) + expect( + normalize(new schema.Array(new schema.Invalidate(MyEntity)), [1, 2, 3]), + ).toMatchSnapshot(); + }); + test('does not query', () => { class User extends IDEntity {} @@ -83,6 +91,72 @@ describe(`${schema.Invalidate.name} normalization`, () => { } expect(normalizeBad).toThrowErrorMatchingSnapshot(); }); + + describe('with Union schema', () => { + class User extends IDEntity { + readonly type: string = 'users'; + } + class Group extends IDEntity { + readonly type: string = 'groups'; + } + + test('normalizes a union object with string schemaAttribute', () => { + const invalidateUnion = new schema.Invalidate( + { users: User, groups: Group }, + 'type', + ); + + expect( + normalize(invalidateUnion, { id: '1', type: 'users' }), + ).toMatchSnapshot(); + expect( + normalize(invalidateUnion, { id: '2', type: 'groups' }), + ).toMatchSnapshot(); + }); + + test('normalizes a union object with function schemaAttribute', () => { + const invalidateUnion = new schema.Invalidate( + { users: User, groups: Group }, + (input: any) => input.type, + ); + + expect( + normalize(invalidateUnion, { id: '1', type: 'users' }), + ).toMatchSnapshot(); + expect( + normalize(invalidateUnion, { id: '2', type: 'groups' }), + ).toMatchSnapshot(); + }); + + test('normalizes array of Invalidate unions', () => { + const invalidateUnion = new schema.Invalidate( + { users: User, groups: Group }, + 'type', + ); + + expect( + normalize(new schema.Array(invalidateUnion), [ + { id: '1', type: 'users' }, + { id: '2', type: 'groups' }, + ]), + ).toMatchSnapshot(); + }); + + test('returns input when schema attribute does not match', () => { + const invalidateUnion = new schema.Invalidate( + { users: User, groups: Group }, + 'type', + ); + + // 'unknown' type doesn't match any schema - returns input unchanged + const result = normalize(invalidateUnion, { + id: '1', + type: 'unknown', + }); + expect(result.result).toEqual({ id: '1', type: 'unknown' }); + expect(result.entities).toEqual({}); + }); + }); }); describe(`${schema.Invalidate.name} denormalization`, () => { @@ -210,4 +284,71 @@ describe(`${schema.Invalidate.name} denormalization`, () => { }, ); }); + + describe('with Union schema', () => { + class UserDenorm extends IDEntity { + readonly username: string = ''; + readonly type: string = 'users'; + } + class GroupDenorm extends IDEntity { + readonly groupname: string = ''; + readonly type: string = 'groups'; + } + + const unionEntities = { + UserDenorm: { + '1': UserDenorm.fromJS({ id: '1', username: 'Alice', type: 'users' }), + '3': UserDenorm.fromJS({ id: '3', username: 'Charlie', type: 'users' }), + }, + GroupDenorm: { + '2': GroupDenorm.fromJS({ + id: '2', + groupname: 'Admins', + type: 'groups', + }), + }, + }; + + test('denormalizes a union entity', () => { + const invalidateUnion = new schema.Invalidate( + { users: UserDenorm, groups: GroupDenorm }, + 'type', + ); + const user = new SimpleMemoCache(POJOPolicy).denormalize( + invalidateUnion, + { id: '1', schema: 'users' }, + unionEntities, + ); + expect(user).not.toEqual(expect.any(Symbol)); + if (typeof user === 'symbol') return; + expect(user).toBeInstanceOf(UserDenorm); + expect((user as any).username).toBe('Alice'); + + const group = new SimpleMemoCache(POJOPolicy).denormalize( + invalidateUnion, + { id: '2', schema: 'groups' }, + unionEntities, + ); + expect(group).not.toEqual(expect.any(Symbol)); + if (typeof group === 'symbol') return; + expect(group).toBeInstanceOf(GroupDenorm); + expect((group as any).groupname).toBe('Admins'); + }); + + test('denormalizes invalidated union entity as symbol', () => { + const invalidateUnion = new schema.Invalidate( + { users: UserDenorm, groups: GroupDenorm }, + 'type', + ); + const user = new SimpleMemoCache(POJOPolicy).denormalize( + invalidateUnion, + { id: '1', schema: 'users' }, + { + UserDenorm: { '1': INVALID }, + GroupDenorm: {}, + }, + ); + expect(user).toEqual(expect.any(Symbol)); + }); + }); }); diff --git a/packages/endpoint/src/schemas/__tests__/Values.test.js b/packages/endpoint/src/schemas/__tests__/Values.test.js index 1da3d57cae05..00f2c4b100c8 100644 --- a/packages/endpoint/src/schemas/__tests__/Values.test.js +++ b/packages/endpoint/src/schemas/__tests__/Values.test.js @@ -407,3 +407,70 @@ describe.each([ }, ); }); + +describe('nested polymorphic schemas', () => { + class User extends IDEntity { + type = 'users'; + } + class Group extends IDEntity { + type = 'groups'; + } + + test('Values of Array normalizes without hoisting', () => { + const innerArray = new schema.Array(User); + const outerValues = new schema.Values(innerArray); + + const input = { + team1: [{ id: '1' }, { id: '2' }], + team2: [{ id: '3' }], + }; + const output = normalize(outerValues, input); + + expect(output.entities.User).toEqual({ + 1: expect.objectContaining({ id: '1' }), + 2: expect.objectContaining({ id: '2' }), + 3: expect.objectContaining({ id: '3' }), + }); + expect(output.result).toEqual({ + team1: ['1', '2'], + team2: ['3'], + }); + }); + + test('Values of Union normalizes with hoisting', () => { + const union = new schema.Union({ users: User, groups: Group }, 'type'); + const valuesOfUnion = new schema.Values(union); + + const input = { + first: { id: '1', type: 'users' }, + second: { id: '2', type: 'groups' }, + }; + const output = normalize(valuesOfUnion, input); + + expect(output.entities.User['1']).toBeDefined(); + expect(output.entities.Group['2']).toBeDefined(); + expect(output.result).toEqual({ + first: { id: '1', schema: 'users' }, + second: { id: '2', schema: 'groups' }, + }); + }); + + test('Values of Invalidate normalizes without hoisting (calls invalidate)', () => { + const invalidate = new schema.Invalidate(User); + const valuesOfInvalidate = new schema.Values(invalidate); + + const input = { + first: { id: '1' }, + second: { id: '2' }, + }; + const output = normalize(valuesOfInvalidate, input); + + // Invalidate should mark entities as INVALID, not store them as objects + expect(output.entities.User['1']).toEqual(expect.any(Symbol)); + expect(output.entities.User['2']).toEqual(expect.any(Symbol)); + expect(output.result).toEqual({ + first: '1', + second: '2', + }); + }); +}); diff --git a/packages/endpoint/src/schemas/__tests__/__snapshots__/Invalidate.test.ts.snap b/packages/endpoint/src/schemas/__tests__/__snapshots__/Invalidate.test.ts.snap index 3d8f11ce47c2..46b50734b534 100644 --- a/packages/endpoint/src/schemas/__tests__/__snapshots__/Invalidate.test.ts.snap +++ b/packages/endpoint/src/schemas/__tests__/__snapshots__/Invalidate.test.ts.snap @@ -146,6 +146,19 @@ exports[`Invalidate normalization normalizes an object 1`] = ` } `; +exports[`Invalidate normalization normalizes number input within array (already processed pk) 1`] = ` +{ + "entities": {}, + "entitiesMeta": {}, + "indexes": {}, + "result": [ + 1, + 2, + 3, + ], +} +`; + exports[`Invalidate normalization should throw a custom error if data does not include pk (serializes pk) 1`] = ` "Missing usable primary key when normalizing response. @@ -175,3 +188,139 @@ exports[`Invalidate normalization should throw a custom error if data does not i } " `; + +exports[`Invalidate normalization with Union schema normalizes a union object with function schemaAttribute 1`] = ` +{ + "entities": { + "User": { + "1": Symbol(INVALID), + }, + }, + "entitiesMeta": { + "User": { + "1": { + "date": 1557831718135, + "expiresAt": Infinity, + "fetchedAt": 0, + }, + }, + }, + "indexes": {}, + "result": { + "id": "1", + "schema": "users", + }, +} +`; + +exports[`Invalidate normalization with Union schema normalizes a union object with function schemaAttribute 2`] = ` +{ + "entities": { + "Group": { + "2": Symbol(INVALID), + }, + }, + "entitiesMeta": { + "Group": { + "2": { + "date": 1557831718135, + "expiresAt": Infinity, + "fetchedAt": 0, + }, + }, + }, + "indexes": {}, + "result": { + "id": "2", + "schema": "groups", + }, +} +`; + +exports[`Invalidate normalization with Union schema normalizes a union object with string schemaAttribute 1`] = ` +{ + "entities": { + "User": { + "1": Symbol(INVALID), + }, + }, + "entitiesMeta": { + "User": { + "1": { + "date": 1557831718135, + "expiresAt": Infinity, + "fetchedAt": 0, + }, + }, + }, + "indexes": {}, + "result": { + "id": "1", + "schema": "users", + }, +} +`; + +exports[`Invalidate normalization with Union schema normalizes a union object with string schemaAttribute 2`] = ` +{ + "entities": { + "Group": { + "2": Symbol(INVALID), + }, + }, + "entitiesMeta": { + "Group": { + "2": { + "date": 1557831718135, + "expiresAt": Infinity, + "fetchedAt": 0, + }, + }, + }, + "indexes": {}, + "result": { + "id": "2", + "schema": "groups", + }, +} +`; + +exports[`Invalidate normalization with Union schema normalizes array of Invalidate unions 1`] = ` +{ + "entities": { + "Group": { + "2": Symbol(INVALID), + }, + "User": { + "1": Symbol(INVALID), + }, + }, + "entitiesMeta": { + "Group": { + "2": { + "date": 1557831718135, + "expiresAt": Infinity, + "fetchedAt": 0, + }, + }, + "User": { + "1": { + "date": 1557831718135, + "expiresAt": Infinity, + "fetchedAt": 0, + }, + }, + }, + "indexes": {}, + "result": [ + { + "id": "1", + "schema": "users", + }, + { + "id": "2", + "schema": "groups", + }, + ], +} +`; diff --git a/packages/react/src/hooks/__tests__/useController/fetch.tsx b/packages/react/src/hooks/__tests__/useController/fetch.tsx index d709b7539743..1a804c7589c4 100644 --- a/packages/react/src/hooks/__tests__/useController/fetch.tsx +++ b/packages/react/src/hooks/__tests__/useController/fetch.tsx @@ -333,6 +333,53 @@ describe.each([ expect(warnSpy.mock.calls.length).toBe(0); }); + it('should return denormalized value when schema is present (delete)', async () => { + const { controller } = renderDataClient( + () => { + return 'hi'; + }, + { + resolverFixtures: [ + { + endpoint: CoolerArticleResource.delete, + args: [{ id: payload.id }], + response: payload, + }, + ], + }, + ); + const ret = await controller.fetch(CoolerArticleResource.delete, { + id: payload.id, + }); + expect(ret.content).toEqual(payload.content); + expect(ret).toBeInstanceOf(CoolerArticle); + expect(warnSpy.mock.calls.length).toBe(0); + }); + + it('should return undefined when delete response is just an id (no entity in store)', async () => { + const { controller } = renderDataClient( + () => { + return 'hi'; + }, + { + resolverFixtures: [ + { + endpoint: CoolerArticleResource.delete, + args: [{ id: payload.id }], + response: payload.id, + }, + ], + }, + ); + const ret = await controller.fetch(CoolerArticleResource.delete, { + id: payload.id, + }); + // When server returns just an id (not the full entity), denormalization + // returns undefined since the entity was invalidated and removed from store + expect(ret).toBeUndefined(); + expect(warnSpy.mock.calls.length).toBe(0); + }); + it('should return denormalized value when schema is present (unions)', async () => { const response = [ null, diff --git a/website/blog/2026-01-10-v0.15-vue-support-collection-remove.md b/website/blog/2026-01-10-v0.15-vue-support-collection-remove.md index a36ba523f6f6..3a5ce229c430 100644 --- a/website/blog/2026-01-10-v0.15-vue-support-collection-remove.md +++ b/website/blog/2026-01-10-v0.15-vue-support-collection-remove.md @@ -16,6 +16,7 @@ import Link from '@docusaurus/Link'; - [Collection.remove](/blog/2026/01/10/v0.15-vue-support-collection-remove#collectionremove) for removing items from collections - [RestEndpoint.remove](/blog/2026/01/10/v0.15-vue-support-collection-remove#restendpointremove) for combined PATCH + collection removal - [Unions can query() without type discriminator](/blog/2026/01/10/v0.15-vue-support-collection-remove#union-queries) +- [Invalidate supports Unions](/blog/2026/01/10/v0.15-vue-support-collection-remove#invalidate-unions) for polymorphic delete operations - [mockInitialState()](/blog/2026/01/10/v0.15-vue-support-collection-remove#mockinitialstate) for simpler test setup **Performance:** @@ -189,6 +190,26 @@ const newsEvent = useQuery(EventUnion, { id, type: 'news' }); +## Invalidate supports Unions {#invalidate-unions} + +[schema.Invalidate](/rest/api/Invalidate) now accepts [Union](/rest/api/Union) schemas for polymorphic delete operations. [#3559](https://github.com/reactive/data-client/pull/3559) + +```ts +const FeedUnion = new schema.Union( + { posts: Post, comments: Comment }, + 'type', +); + +// Delete endpoint handles any feed item type +const deleteFeedItem = new RestEndpoint({ + path: '/feed/:id', + method: 'DELETE', + schema: new schema.Invalidate(FeedUnion), +}); + +await ctrl.fetch(deleteFeedItem, { id: '123' }); +``` + ## Testing Utilities ### mockInitialState()