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
24 changes: 24 additions & 0 deletions .changeset/collection-context-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@data-client/endpoint': minor
'@data-client/rest': minor
'@data-client/graphql': minor
'@data-client/normalizr': minor
'@data-client/core': minor
'@data-client/react': minor
'@data-client/vue': minor
---

Allow one `Collection` schema to be used both top-level and nested.

Before:

```ts
const getTodos = new Collection([Todo], { argsKey });
const userTodos = new Collection([Todo], { nestKey });
```

After:

```ts
const userTodos = new Collection([Todo], { argsKey, nestKey });
```
2 changes: 1 addition & 1 deletion .cursor/skills/data-client-schema/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const EventResource = resource({

### pk routing

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

- `argsKey` — derive pk from endpoint arguments (default)
- `nestKey` — derive pk from parent entity for nested shared-state collections
Expand Down
38 changes: 38 additions & 0 deletions .cursor/skills/data-client-v0.17-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,44 @@ These are rare; do them by hand:

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

### Optional Collection cleanup

`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:

```ts
// before: two separate but equivalent Collections
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});

class User extends Entity {
static schema = {
todos: new Collection([Todo], {
nestKey: parent => ({ userId: parent.id }),
}),
};
}

// after: one shared Collection
export const userTodos = new Collection([Todo], {
nestKey: parent => ({ userId: parent.id }),
});

export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: userTodos,
});

class User extends Entity {
static schema = {
todos: userTodos,
};
}
```

## Where to find affected code

Search for these patterns in your codebase:
Expand Down
94 changes: 55 additions & 39 deletions docs/rest/api/Collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ delay: 150,
},
]}>

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

export class Todo extends Entity {
Expand All @@ -72,16 +72,20 @@ export class Todo extends Entity {
static key = 'Todo';
}

export const userTodos = new Collection([Todo], {
nestKey: (parent: { id: string }) => ({ userId: parent.id }),
});

export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
schema: userTodos,
});
```

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

export class User extends Entity {
id = '';
Expand All @@ -92,11 +96,7 @@ export class User extends Entity {

static key = 'User';
static schema = {
todos: new Collection([Todo], {
nestKey: (parent, key) => ({
userId: parent.id,
}),
}),
todos: userTodos,
};
}

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

## Options

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

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

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

const userTodos = new Collection([Todo], {
argsKey: (urlParams: { userId?: string }) => ({
...urlParams,
}),
nestKey: (parent: { id: string }) => ({
userId: parent.id,
}),
});

const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo], {
argsKey: (urlParams: { userId?: string }) => ({
...urlParams,
}),
}),
schema: userTodos,
});
```

When omitted, `argsKey` defaults to `params => ({ ...params })`.

### nestKey(parent, key): Object {#nestKey}

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

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

```ts {28-30}
import { Collection, Entity } from '@data-client/rest';

class Todo extends Entity {
id = '';
userId = '';
title = '';
completed = false;

static key = 'Todo';
}
```ts {13}
import { Entity } from '@data-client/rest';
import { Todo, userTodos } from './Todo';

class User extends Entity {
id = '';
Expand All @@ -297,17 +301,21 @@ class User extends Entity {

static key = 'User';
static schema = {
todos: new Collection([Todo], {
nestKey: (parent, key) => ({
userId: parent.id,
}),
}),
todos: userTodos,
};
}
```

In this case, `user.todos` and getTodos() response (from the argsKey example) will always
be the same (referentially equal) Array.
be the same (referentially equal) Array. Add both key functions to the shared
Collection definition:

```ts
const userTodos = new Collection([Todo], {
argsKey: ({ userId }: { userId?: string }) => ({ userId }),
nestKey: (parent: User) => ({ userId: parent.id }),
});
```

### nonFilterArgumentKeys? {#nonFilterArgumentKeys}

Expand Down Expand Up @@ -684,16 +692,24 @@ static mergeWithStore(

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

### pk: (parent?, key?, args?): pk? {#pk}
### pk: (parent?, key?, args?, parentEntity?): pk? {#pk}

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

```ts
pk(value: any, parent: any, key: string, args: readonly any[]) {
const obj = this.argsKey
? this.argsKey(...args)
: this.nestKey(parent, key);
pk(
value: any,
parent: any,
key: string,
args: readonly any[],
parentEntity?: any,
) {
const obj =
parentEntity && this.nestKey
? this.nestKey(parent, key)
: this.argsKey(...args);
for (const key in obj) {
if (typeof obj[key] !== 'string') obj[key] = `${obj[key]}`;
}
Expand Down
13 changes: 11 additions & 2 deletions packages/endpoint/src/schemaTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,30 @@ export interface CollectionInterface<
/**
* A unique identifier for each Collection
*
* Calls argsKey or nestKey depending on which are specified, and then serializes the result for the pk string.
* Calls nestKey when nested in an Entity and available; otherwise calls
* argsKey. The resulting object is serialized for the pk string.
*
* @param [parent] When normalizing, the object which included the entity
* @param [key] When normalizing, the key where this entity was found
* @param [args] ...args sent to Endpoint
* @param [parentEntity] Entity class containing this Collection when nested
* @see https://dataclient.io/docs/api/Collection#pk
*/
pk(value: any, parent: any, key: string, args: any[]): string;
pk(
value: any,
parent: any,
key: string,
args: any[],
parentEntity?: any,
): string;
normalize(
input: any,
parent: Parent,
key: string,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
parentEntity?: any,
): string;

/** Creates new instance copying over defined values of arguments
Expand Down
Loading
Loading