Skip to content

Commit 56d575e

Browse files
authored
feat: Invalidate accepts Unions (#3685)
* feat: Invalidate accepts Unions * fix: Invalidate should denormalize to undefined if entity is not found
1 parent 53de2ee commit 56d575e

File tree

12 files changed

+640
-40
lines changed

12 files changed

+640
-40
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@data-client/endpoint': minor
3+
'@data-client/rest': minor
4+
'@data-client/graphql': minor
5+
---
6+
7+
Add [Union](https://dataclient.io/rest/api/Union) support to [schema.Invalidate](https://dataclient.io/rest/api/Invalidate)
8+
for polymorphic delete operations:
9+
10+
```ts
11+
new schema.Invalidate(
12+
{ users: User, groups: Group },
13+
'type'
14+
)
15+
```
16+
17+
or
18+
19+
```ts
20+
new schema.Invalidate(MyUnionSchema)
21+
```

.cursor/commands/changeset.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Generate release notes for user-facing changes in published packages.
2626
- **Breaking**: Prefix with `BREAKING CHANGE:` or `BREAKING:`
2727
- **Body**: 1–3 lines describing outcome, not implementation
2828
- **New exports**: Use "New exports:" with bullet list
29+
- **Documentation links**: Link concepts that have doc pages in @docs (e.g., `[Union](https://dataclient.io/rest/api/Union)`)
2930

3031
## Code Examples in Changesets
3132
- Fixes: `// Before: ... ❌` `// After: ... ✓`

docs/rest/api/Invalidate.md

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,22 @@ import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground';
1212
Describes entities to be marked as [INVALID](/docs/concepts/expiry-policy#invalid). This removes items from a
1313
collection, or [forces suspense](/docs/concepts/expiry-policy#invalidate-entity) for endpoints where the entity is required.
1414

15-
Constructor:
15+
## Constructor
1616

17-
- `entity` which entity to invalidate. The input is used to compute the pk() for lookup.
17+
```typescript
18+
new schema.Invalidate(entity)
19+
new schema.Invalidate(union)
20+
new schema.Invalidate(entityMap, schemaAttribute)
21+
```
22+
23+
- `entity`: A singular [Entity](./Entity.md) to invalidate.
24+
- `union`: A [Union](./Union.md) schema for polymorphic invalidation.
25+
- `entityMap`: A mapping of schema keys to [Entities](./Entity.md).
26+
- `schemaAttribute`: _optional_ (required if `entityMap` is used) The attribute on each entity found that defines what schema, per the entityMap, to use when normalizing.
27+
Can be a string or a function. If given a function, accepts the following arguments:
28+
- `value`: The input value of the entity.
29+
- `parent`: The parent object of the input array.
30+
- `key`: The key at which the input array appears on the parent object.
1831

1932
## Usage
2033

@@ -177,6 +190,68 @@ PostResource.deleteMany(['5', '13', '7']);
177190

178191
</EndpointPlayground>
179192

193+
### Polymorphic types
194+
195+
If your endpoint can delete more than one type of entity, you can use polymorphic invalidation.
196+
197+
#### With Union schema
198+
199+
The simplest approach is to pass an existing [Union](./Union.md) schema directly:
200+
201+
```typescript
202+
class User extends Entity {
203+
id = '';
204+
name = '';
205+
readonly type = 'users';
206+
}
207+
class Group extends Entity {
208+
id = '';
209+
groupname = '';
210+
readonly type = 'groups';
211+
}
212+
213+
const MemberUnion = new schema.Union(
214+
{ users: User, groups: Group },
215+
'type'
216+
);
217+
218+
const deleteMember = new RestEndpoint({
219+
path: '/members/:id',
220+
method: 'DELETE',
221+
schema: new schema.Invalidate(MemberUnion),
222+
});
223+
```
224+
225+
#### string schemaAttribute
226+
227+
Alternatively, define the polymorphic mapping inline with a string attribute:
228+
229+
```typescript
230+
const deleteMember = new RestEndpoint({
231+
path: '/members/:id',
232+
method: 'DELETE',
233+
schema: new schema.Invalidate(
234+
{ users: User, groups: Group },
235+
'type'
236+
),
237+
});
238+
```
239+
240+
#### function schemaAttribute
241+
242+
The return values should match a key in the entity map. This is useful for more complex discrimination logic:
243+
244+
```typescript
245+
const deleteMember = new RestEndpoint({
246+
path: '/members/:id',
247+
method: 'DELETE',
248+
schema: new schema.Invalidate(
249+
{ users: User, groups: Group },
250+
(input, parent, key) => input.memberType === 'user' ? 'users' : 'groups'
251+
),
252+
});
253+
```
254+
180255
### Impact on useSuspense()
181256

182257
When entities are invalidated in a result currently being presented in React, useSuspense()

packages/endpoint/src/schemas/Invalidate.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import type {
2-
EntityInterface,
3-
INormalizeDelegate,
4-
SchemaSimple,
5-
} from '../interface.js';
1+
import PolymorphicSchema from './Polymorphic.js';
2+
import type { EntityInterface, INormalizeDelegate } from '../interface.js';
63
import type { AbstractInstanceType } from '../normal.js';
74

5+
type ProcessableEntity = EntityInterface & { process: any };
6+
87
/**
98
* Marks entity as Invalid.
109
*
@@ -13,28 +12,29 @@ import type { AbstractInstanceType } from '../normal.js';
1312
* @see https://dataclient.io/rest/api/Invalidate
1413
*/
1514
export default class Invalidate<
16-
E extends EntityInterface & {
17-
process: any;
18-
},
19-
> implements SchemaSimple {
20-
declare protected _entity: E;
21-
15+
E extends ProcessableEntity | Record<string, ProcessableEntity>,
16+
> extends PolymorphicSchema {
2217
/**
2318
* Marks entity as Invalid.
2419
*
2520
* This triggers suspense for all endpoints requiring it.
2621
* Optional (like variable sized Array and Values) will simply remove the item.
2722
* @see https://dataclient.io/rest/api/Invalidate
2823
*/
29-
constructor(entity: E) {
24+
constructor(
25+
entity: E,
26+
schemaAttribute?: E extends Record<string, ProcessableEntity> ?
27+
string | ((input: any, parent: any, key: any) => string)
28+
: undefined,
29+
) {
3030
if (process.env.NODE_ENV !== 'production' && !entity) {
3131
throw new Error('Invalidate schema requires "entity" option.');
3232
}
33-
this._entity = entity;
33+
super(entity, schemaAttribute);
3434
}
3535

3636
get key(): string {
37-
return this._entity.key;
37+
return this.schemaKey();
3838
}
3939

4040
normalize(
@@ -44,53 +44,72 @@ export default class Invalidate<
4444
args: any[],
4545
visit: (...args: any) => any,
4646
delegate: INormalizeDelegate,
47-
): string {
48-
// TODO: what's store needs to be a differing type from fromJS
49-
const processedEntity = this._entity.process(input, parent, key, args);
50-
let pk = this._entity.pk(processedEntity, parent, key, args);
47+
): string | { id: string; schema: string } {
48+
const entitySchema = this.inferSchema(input, parent, key);
49+
if (!entitySchema) return input;
50+
51+
// Handle string/number input (already processed pk)
52+
// Note: This branch is typically not reached through public API as getVisit
53+
// handles primitives before calling schema.normalize()
54+
let pk: string | number | undefined;
55+
/* istanbul ignore if */
56+
if (typeof input === 'string' || typeof input === 'number') {
57+
pk = input;
58+
} else {
59+
// Must call process() to get correct pk
60+
const processedEntity = entitySchema.process(input, parent, key, args);
61+
pk = entitySchema.pk(processedEntity ?? input, parent, key, args);
5162

52-
if (
53-
process.env.NODE_ENV !== 'production' &&
54-
(pk === undefined || pk === '' || pk === 'undefined')
55-
) {
56-
const error = new Error(
57-
`Missing usable primary key when normalizing response.
63+
if (
64+
process.env.NODE_ENV !== 'production' &&
65+
(pk === undefined || pk === '' || pk === 'undefined')
66+
) {
67+
const error = new Error(
68+
`Missing usable primary key when normalizing response.
5869
5970
This is likely due to a malformed response.
6071
Try inspecting the network response or fetch() return value.
6172
Or use debugging tools: https://dataclient.io/docs/getting-started/debugging
6273
Learn more about schemas: https://dataclient.io/docs/api/schema
6374
64-
Invalidate(Entity): Invalidate(${this._entity.key})
75+
Invalidate(Entity): Invalidate(${entitySchema.key})
6576
Value (processed): ${input && JSON.stringify(input, null, 2)}
6677
`,
67-
);
68-
(error as any).status = 400;
69-
throw error;
78+
);
79+
(error as any).status = 400;
80+
throw error;
81+
}
7082
}
7183
pk = `${pk}`; // ensure pk is a string
7284

7385
// any queued updates are meaningless with delete, so we should just set it
7486
// and creates will have a different pk
75-
delegate.invalidate({ key: this._entity.key }, pk);
76-
return pk;
87+
delegate.invalidate(entitySchema, pk);
88+
89+
return this.isSingleSchema ? pk : (
90+
{ id: pk, schema: this.getSchemaAttribute(input, parent, key) }
91+
);
7792
}
7893

79-
queryKey(args: any, unvisit: unknown, delegate: unknown): undefined {
94+
queryKey(_args: any, _unvisit: unknown, _delegate: unknown): undefined {
8095
return undefined;
8196
}
8297

8398
denormalize(
84-
id: string,
99+
id: string | { id: string; schema: string },
85100
args: readonly any[],
86101
unvisit: (schema: any, input: any) => any,
87-
): AbstractInstanceType<E> {
88-
// TODO: is this really always going to be the full object - validate that calling fetch will give this even when input is a string
89-
return unvisit(this._entity, id) as any;
102+
): E extends ProcessableEntity ? AbstractInstanceType<E>
103+
: AbstractInstanceType<E[keyof E]> {
104+
// denormalizeValue handles both single entity and polymorphic cases
105+
return this.denormalizeValue(id, unvisit) as any;
90106
}
91107

92108
/* istanbul ignore next */
93-
_denormalizeNullable(): AbstractInstanceType<E> | undefined {
109+
_denormalizeNullable():
110+
| (E extends ProcessableEntity ? AbstractInstanceType<E>
111+
: AbstractInstanceType<E[keyof E]>)
112+
| undefined {
94113
return {} as any;
95114
}
96115

packages/endpoint/src/schemas/Polymorphic.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ export default class PolymorphicSchema {
2323
}
2424

2525
define(definition: any) {
26-
// sending Union into another Polymorphic gets hoisted
27-
if ('_schemaAttribute' in definition && !this._schemaAttribute) {
26+
// Only Union opts into hoisting (_hoistable = true)
27+
// This prevents Array(Array(...)), Values(Array(...)), Array(Invalidate(...)) issues
28+
if (definition._hoistable && !this._schemaAttribute) {
2829
this.schema = definition.schema;
2930
this._schemaAttribute = definition._schemaAttribute;
3031
} else {

packages/endpoint/src/schemas/Union.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { Visit } from '../interface.js';
66
* @see https://dataclient.io/rest/api/Union
77
*/
88
export default class UnionSchema extends PolymorphicSchema {
9+
// Union is designed to be transparent; allow hoisting into wrappers (Array, Values)
10+
protected readonly _hoistable = true as const;
11+
912
constructor(definition: any, schemaAttribute: any) {
1013
if (!schemaAttribute) {
1114
throw new Error(

packages/endpoint/src/schemas/__tests__/Array.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,58 @@ describe.each([
452452
});
453453
});
454454
});
455+
456+
describe('nested polymorphic schemas', () => {
457+
class User extends IDEntity {
458+
type = 'users';
459+
}
460+
class Group extends IDEntity {
461+
type = 'groups';
462+
}
463+
464+
test('Array of Array normalizes without hoisting', () => {
465+
const innerArray = new schema.Array(User);
466+
const outerArray = new schema.Array(innerArray);
467+
468+
const input = [[{ id: '1' }, { id: '2' }], [{ id: '3' }]];
469+
const output = normalize(outerArray, input);
470+
471+
expect(output.entities.User).toEqual({
472+
1: expect.objectContaining({ id: '1' }),
473+
2: expect.objectContaining({ id: '2' }),
474+
3: expect.objectContaining({ id: '3' }),
475+
});
476+
expect(output.result).toEqual([['1', '2'], ['3']]);
477+
});
478+
479+
test('Array of Union normalizes with hoisting', () => {
480+
const union = new schema.Union({ users: User, groups: Group }, 'type');
481+
const arrayOfUnion = new schema.Array(union);
482+
483+
const input = [
484+
{ id: '1', type: 'users' },
485+
{ id: '2', type: 'groups' },
486+
];
487+
const output = normalize(arrayOfUnion, input);
488+
489+
expect(output.entities.User['1']).toBeDefined();
490+
expect(output.entities.Group['2']).toBeDefined();
491+
expect(output.result).toEqual([
492+
{ id: '1', schema: 'users' },
493+
{ id: '2', schema: 'groups' },
494+
]);
495+
});
496+
497+
test('Array of Invalidate normalizes without hoisting (calls invalidate)', () => {
498+
const invalidate = new schema.Invalidate(User);
499+
const arrayOfInvalidate = new schema.Array(invalidate);
500+
501+
const input = [{ id: '1' }, { id: '2' }];
502+
const output = normalize(arrayOfInvalidate, input);
503+
504+
// Invalidate should mark entities as INVALID, not store them as objects
505+
expect(output.entities.User['1']).toEqual(expect.any(Symbol));
506+
expect(output.entities.User['2']).toEqual(expect.any(Symbol));
507+
expect(output.result).toEqual(['1', '2']);
508+
});
509+
});

0 commit comments

Comments
 (0)