Skip to content

Commit 959465a

Browse files
authored
feat(endpoint): allow shared Collection context keys (#3931)
Allow one Collection schema to provide both argsKey and nestKey so it can be reused top-level and nested while preserving shared collection state. Made-with: Cursor
1 parent bce280a commit 959465a

15 files changed

Lines changed: 396 additions & 131 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@data-client/endpoint': minor
3+
'@data-client/rest': minor
4+
'@data-client/graphql': minor
5+
'@data-client/normalizr': minor
6+
'@data-client/core': minor
7+
'@data-client/react': minor
8+
'@data-client/vue': minor
9+
---
10+
11+
Allow one `Collection` schema to be used both top-level and nested.
12+
13+
Before:
14+
15+
```ts
16+
const getTodos = new Collection([Todo], { argsKey });
17+
const userTodos = new Collection([Todo], { nestKey });
18+
```
19+
20+
After:
21+
22+
```ts
23+
const userTodos = new Collection([Todo], { argsKey, nestKey });
24+
```

.cursor/skills/data-client-schema/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export const EventResource = resource({
105105

106106
### pk routing
107107

108-
`pk()` uses `argsKey(...args)` or `nestKey(parent, key)`, then serializes the result. Without either option, it defaults to `argsKey: params => ({ ...params })`, using all endpoint args as the collection key.
108+
`pk()` uses `nestKey(parent, key)` when nested in an Entity and available; otherwise it uses `argsKey(...args)`, then serializes the result. Without options, it defaults to `argsKey: params => ({ ...params })`, using all endpoint args as the collection key. Provide both `argsKey` and `nestKey` to reuse one Collection definition top-level and nested.
109109

110110
- `argsKey` — derive pk from endpoint arguments (default)
111111
- `nestKey` — derive pk from parent entity for nested shared-state collections

.cursor/skills/data-client-v0.17-migration/SKILL.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,44 @@ These are rare; do them by hand:
146146

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

149+
### Optional Collection cleanup
150+
151+
`Collection` can now define both `argsKey` and `nestKey` on the same instance. During normalization it uses `argsKey` when top-level and `nestKey` when nested in an Entity, so paired definitions can be consolidated:
152+
153+
```ts
154+
// before: two separate but equivalent Collections
155+
export const getTodos = new RestEndpoint({
156+
path: '/todos',
157+
searchParams: {} as { userId?: string },
158+
schema: new Collection([Todo]),
159+
});
160+
161+
class User extends Entity {
162+
static schema = {
163+
todos: new Collection([Todo], {
164+
nestKey: parent => ({ userId: parent.id }),
165+
}),
166+
};
167+
}
168+
169+
// after: one shared Collection
170+
export const userTodos = new Collection([Todo], {
171+
nestKey: parent => ({ userId: parent.id }),
172+
});
173+
174+
export const getTodos = new RestEndpoint({
175+
path: '/todos',
176+
searchParams: {} as { userId?: string },
177+
schema: userTodos,
178+
});
179+
180+
class User extends Entity {
181+
static schema = {
182+
todos: userTodos,
183+
};
184+
}
185+
```
186+
149187
## Where to find affected code
150188

151189
Search for these patterns in your codebase:

docs/rest/api/Collection.md

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ delay: 150,
6060
},
6161
]}>
6262

63-
```ts title="api/Todo" {15} collapsed
63+
```ts title="api/Todo" {15-18,24} collapsed
6464
import { Entity, RestEndpoint, Collection } from '@data-client/rest';
6565

6666
export class Todo extends Entity {
@@ -72,16 +72,20 @@ export class Todo extends Entity {
7272
static key = 'Todo';
7373
}
7474

75+
export const userTodos = new Collection([Todo], {
76+
nestKey: (parent: { id: string }) => ({ userId: parent.id }),
77+
});
78+
7579
export const getTodos = new RestEndpoint({
7680
path: '/todos',
7781
searchParams: {} as { userId?: string },
78-
schema: new Collection([Todo]),
82+
schema: userTodos,
7983
});
8084
```
8185

82-
```ts title="api/User" {13-17} collapsed
86+
```ts title="api/User" {13} collapsed
8387
import { Entity, RestEndpoint, Collection } from '@data-client/rest';
84-
import { Todo } from './Todo';
88+
import { Todo, userTodos } from './Todo';
8589

8690
export class User extends Entity {
8791
id = '';
@@ -92,11 +96,7 @@ export class User extends Entity {
9296

9397
static key = 'User';
9498
static schema = {
95-
todos: new Collection([Todo], {
96-
nestKey: (parent, key) => ({
97-
userId: parent.id,
98-
}),
99-
}),
99+
todos: userTodos,
100100
};
101101
}
102102

@@ -247,7 +247,10 @@ await ctrl.fetch(StatsResource.getList.assign, {
247247

248248
## Options
249249

250-
One of `argsKey` or `nestKey` is used to compute the `Collection's` [pk](#pk).
250+
`argsKey` and `nestKey` compute the `Collection's` [pk](#pk). `argsKey` is used
251+
when the Collection is normalized as a top-level endpoint result; `nestKey` is
252+
used when the same Collection is nested in an [Entity](./Entity.md). Provide both
253+
to reuse one Collection definition in both contexts.
251254

252255
### argsKey(...args): Object {#argsKey}
253256

@@ -257,36 +260,37 @@ on Endpoint arguments.
257260
```ts {7-9}
258261
import { RestEndpoint, Collection } from '@data-client/rest';
259262

263+
const userTodos = new Collection([Todo], {
264+
argsKey: (urlParams: { userId?: string }) => ({
265+
...urlParams,
266+
}),
267+
nestKey: (parent: { id: string }) => ({
268+
userId: parent.id,
269+
}),
270+
});
271+
260272
const getTodos = new RestEndpoint({
261273
path: '/todos',
262274
searchParams: {} as { userId?: string },
263-
schema: new Collection([Todo], {
264-
argsKey: (urlParams: { userId?: string }) => ({
265-
...urlParams,
266-
}),
267-
}),
275+
schema: userTodos,
268276
});
269277
```
270278

279+
When omitted, `argsKey` defaults to `params => ({ ...params })`.
280+
271281
### nestKey(parent, key): Object {#nestKey}
272282

273283
Returns a serializable Object whose members uniquely define this collection based
274284
on the parent it is nested inside.
275285

276286
Nested `Collection's` [pk](#pk) are better defined by what they are nested inside. This allows
277287
the nested Collection to share its state with other instances whose key has the same value.
288+
When `argsKey` and `nestKey` return the same object shape, top-level and nested
289+
reads resolve to the same collection state.
278290

279-
```ts {28-30}
280-
import { Collection, Entity } from '@data-client/rest';
281-
282-
class Todo extends Entity {
283-
id = '';
284-
userId = '';
285-
title = '';
286-
completed = false;
287-
288-
static key = 'Todo';
289-
}
291+
```ts {13}
292+
import { Entity } from '@data-client/rest';
293+
import { Todo, userTodos } from './Todo';
290294

291295
class User extends Entity {
292296
id = '';
@@ -297,17 +301,21 @@ class User extends Entity {
297301

298302
static key = 'User';
299303
static schema = {
300-
todos: new Collection([Todo], {
301-
nestKey: (parent, key) => ({
302-
userId: parent.id,
303-
}),
304-
}),
304+
todos: userTodos,
305305
};
306306
}
307307
```
308308

309309
In this case, `user.todos` and getTodos() response (from the argsKey example) will always
310-
be the same (referentially equal) Array.
310+
be the same (referentially equal) Array. Add both key functions to the shared
311+
Collection definition:
312+
313+
```ts
314+
const userTodos = new Collection([Todo], {
315+
argsKey: ({ userId }: { userId?: string }) => ({ userId }),
316+
nestKey: (parent: User) => ({ userId: parent.id }),
317+
});
318+
```
311319

312320
### nonFilterArgumentKeys? {#nonFilterArgumentKeys}
313321

@@ -684,16 +692,24 @@ static mergeWithStore(
684692

685693
`mergeWithStore()` is called during normalization when a processed entity is already found in the store.
686694

687-
### pk: (parent?, key?, args?): pk? {#pk}
695+
### pk: (parent?, key?, args?, parentEntity?): pk? {#pk}
688696

689-
`pk()` calls [argsKey](#argsKey) or [nestKey](#nestKey) depending on which are specified, and
690-
then serializes the result for the pk string.
697+
`pk()` calls [nestKey](#nestKey) when nested in an Entity and available;
698+
otherwise it calls [argsKey](#argsKey). It then serializes the result for the pk
699+
string.
691700

692701
```ts
693-
pk(value: any, parent: any, key: string, args: readonly any[]) {
694-
const obj = this.argsKey
695-
? this.argsKey(...args)
696-
: this.nestKey(parent, key);
702+
pk(
703+
value: any,
704+
parent: any,
705+
key: string,
706+
args: readonly any[],
707+
parentEntity?: any,
708+
) {
709+
const obj =
710+
parentEntity && this.nestKey
711+
? this.nestKey(parent, key)
712+
: this.argsKey(...args);
697713
for (const key in obj) {
698714
if (typeof obj[key] !== 'string') obj[key] = `${obj[key]}`;
699715
}

packages/endpoint/src/schemaTypes.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,21 +88,30 @@ export interface CollectionInterface<
8888
/**
8989
* A unique identifier for each Collection
9090
*
91-
* Calls argsKey or nestKey depending on which are specified, and then serializes the result for the pk string.
91+
* Calls nestKey when nested in an Entity and available; otherwise calls
92+
* argsKey. The resulting object is serialized for the pk string.
9293
*
9394
* @param [parent] When normalizing, the object which included the entity
9495
* @param [key] When normalizing, the key where this entity was found
9596
* @param [args] ...args sent to Endpoint
97+
* @param [parentEntity] Entity class containing this Collection when nested
9698
* @see https://dataclient.io/docs/api/Collection#pk
9799
*/
98-
pk(value: any, parent: any, key: string, args: any[]): string;
100+
pk(
101+
value: any,
102+
parent: any,
103+
key: string,
104+
args: any[],
105+
parentEntity?: any,
106+
): string;
99107
normalize(
100108
input: any,
101109
parent: Parent,
102110
key: string,
103111
args: any[],
104112
visit: (...args: any) => any,
105113
delegate: INormalizeDelegate,
114+
parentEntity?: any,
106115
): string;
107116

108117
/** Creates new instance copying over defined values of arguments

0 commit comments

Comments
 (0)