diff --git a/.agents/skills/data-client-endpoint-setup/SKILL.md b/.agents/skills/data-client-endpoint-setup/SKILL.md index 99333d9bc513..10f2819c8fe2 100644 --- a/.agents/skills/data-client-endpoint-setup/SKILL.md +++ b/.agents/skills/data-client-endpoint-setup/SKILL.md @@ -336,4 +336,6 @@ Both hooks and controller methods take endpoint as first argument, with the endp ## References +Vue projects: read `.vue.md` instead of `.md` when it exists. + - [Endpoint](references/Endpoint.md) - Full Endpoint API diff --git a/.agents/skills/data-client-endpoint-setup/references.json b/.agents/skills/data-client-endpoint-setup/references.json new file mode 100644 index 000000000000..315136b7fa95 --- /dev/null +++ b/.agents/skills/data-client-endpoint-setup/references.json @@ -0,0 +1,9 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "Endpoint.md": "docs/rest/api/Endpoint.md" + } +} diff --git a/.agents/skills/data-client-endpoint-setup/references/Endpoint.md b/.agents/skills/data-client-endpoint-setup/references/Endpoint.md deleted file mode 120000 index e0107c9af2d1..000000000000 --- a/.agents/skills/data-client-endpoint-setup/references/Endpoint.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Endpoint.md \ No newline at end of file diff --git a/.agents/skills/data-client-endpoint-setup/references/Endpoint.md b/.agents/skills/data-client-endpoint-setup/references/Endpoint.md new file mode 100644 index 000000000000..5c11648ed4fd --- /dev/null +++ b/.agents/skills/data-client-endpoint-setup/references/Endpoint.md @@ -0,0 +1,613 @@ + + +# Endpoint + +`Endpoint` are for any asynchronous function (one that returns a Promise). + +`Endpoints` define a strongly typed standard interface of relevant metadata and lifecycles +useful for Reactive Data Client and other stores. + +Package: [@data-client/endpoint](https://www.npmjs.com/package/@data-client/endpoint) + +> **Tip** +> +> Endpoint is a protocol independent class. Try using the protocol specific patterns +> [REST](https://dataclient.io/rest/api/RestEndpoint), [GraphQL](https://dataclient.io/graphql/api/GQLEndpoint), +> or [getImage](https://dataclient.io/docs/guides/img-media#just-images) instead. + +
+ +Interface + +**Interface** + +```typescript +export interface EndpointInterface< + F extends FetchFunction = FetchFunction, + S extends Schema | undefined = Schema | undefined, + M extends true | undefined = true | undefined, +> extends EndpointExtraOptions { + (...args: Parameters): InferReturn; + key(...args: Parameters): string; + readonly sideEffect?: M; + readonly schema?: S; +} +``` + +**Class** + +```typescript +class Endpoint Promise> + implements EndpointInterface +{ + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + fetch: F; + + extend(options: EndpointOptions): Endpoint; +} + +export interface EndpointOptions extends EndpointExtraOptions { + key?: (params: any) => string; + sideEffect?: true | undefined; + schema?: Schema; +} +``` + +**EndpointExtraOptions** + +```typescript +export interface EndpointExtraOptions { + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + readonly getOptimisticResponse?: ( + snap: SnapshotInterface, + ...args: Parameters + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + /** User-land extra data to send */ + readonly extra?: any; +} +``` + +
+ +## Usage + +`Endpoint` makes existing async functions usable in any Reactive Data Client context with full TypeScript enforcement. + +```ts title="interface" +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} +``` + +```ts title="api" {11} +import { Todo } from './interface'; + +const getTodoOriginal = (id: number): Promise => + Promise.resolve({ + id, + title: 'delectus aut autem ' + id, + completed: false, + userId: 1, + }); + +export const getTodo = new Endpoint(getTodoOriginal); +``` + +```tsx title="React" +import { getTodo } from './api'; + +function TodoDetail() { + const todo = useSuspense(getTodo, 1); + return
{todo.title}
; +} +render(); +``` + +### Configuration sharing + +Use [Endpoint.extend()](#extend) instead of `{...getTodo}` (spread) + +```ts +const getTodoNormalized = getTodo.extend({ schema: Todo }); +const getTodoUpdatingEveryFiveSeconds = getTodo.extend({ pollFrequency: 5000 }); +``` + +## Lifecycle + +### Success + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--resolves-->Controller.resolve + Controller.resolve("Controller.resolve(response)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:SET_RESPONSE + direction LR + normalize2(normalize)-->update2("Endpoint.update()") + end + managers--SET_RESPONSE-->reducer:SET_RESPONSE + click key "/rest/api/Endpoint#key" + click NetworkManager "/docs/api/NetworkManager" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click update2 "/rest/api/Endpoint#update" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +### Error + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--rejects-->Controller.resolve + Controller.resolve("Controller.resolve(error)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:reduceError + direction LR + filterOptimistic(filterOptimistic)-->errorPolicy("Endpoint.errorPolicy()") + end + managers--SET_RESPONSE:error-->reducer:reduceError + click key "/rest/api/Endpoint#key" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click errorPolicy "/rest/api/Endpoint#errorpolicy" + click NetworkManager "/docs/api/NetworkManager" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +## Endpoint Members + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### key: (params) => string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.name} ${JSON.stringify(params)}`; +``` + +> **Warning: Overrides** +> +> When overriding `key`, be sure to also include an updated [testKey](#testKey) if +> you intend on using that method. + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver) + +### name: string {#name} + +Used in [key](#key) to distinguish endpoints. Should be globally unique. + +Defaults to `this.fetch.name` + +> **Warning** +> +> This may break in production builds that change function names. +> This is often know as [function name mangling](https://terser.org/docs/api-reference#mangle-options). +> +> In these cases you can override `name` or disable function mangling. + +### sideEffect: boolean {#sideeffect} + +Used to indicate endpoint might have side-effects (non-idempotent). This restricts it +from being used with [useSuspense()](https://dataclient.io/docs/api/useSuspense) or [useFetch()](https://dataclient.io/docs/api/useFetch) as those can hit the +endpoint an unpredictable number of times. + +### schema: Schema {#schema} + +Declarative definition of how to [process responses](https://dataclient.io/rest/api/schema) + +- [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) + +Not providing this option means no entities will be extracted. + +```tsx +import { Entity } from '@data-client/normalizr'; +import { Endpoint } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new Endpoint( + ({ id }) ⇒ fetch(`/users/${id}`), + { schema: User } +); +``` + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/docs/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/docs/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/docs/api/useSubscription) or +[useLive()](https://dataclient.io/docs/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` + +### extend(options): Endpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new Endpoint(({ id }) ⇒ fetch(`/users/${id}`)); + +const getUserNormalized = getUser.extend({ schema: User }); +``` + +In addition to the members, `fetch` can be sent to override the fetch function. + +## Examples + +**Basic** + +```typescript +import { Endpoint } from '@data-client/endpoint'; + +const UserDetail = new Endpoint( + ({ id }) ⇒ fetch(`/users/${id}`).then(res => res.json()) +); +``` + +**With Schema** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserDetail = new Endpoint( + ({ id }) ⇒ fetch(`/users/${id}`).then(res => res.json()), + { schema: User } +); +``` + +**List** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserList = new Endpoint( + () ⇒ fetch(`/users/`).then(res => res.json()), + { schema: [User] } +); +``` + +**React** + +```tsx +function UserProfile() { + const user = useSuspense(UserDetail, { id }); + const ctrl = useController(); + + return ctrl.fetch(UserDetail)} />; +} +``` + +**JS/Node Schema** + +```typescript +const user = await UserDetail({ id: '5' }); +console.log(user); +``` + +### Additional + +- [Pagination](https://dataclient.io/rest/guides/pagination) +- [Mocking unfinished endpoints](https://dataclient.io/rest/guides/mocking-unfinished) +- [Optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) + +## Motivation + +There is a distinction between + +- What are networking API is + - How to make a request, expected response fields, etc. +- How it is used + - Binding data, polling, triggering imperative fetch, etc. + +Thus, there are many benefits to creating a distinct seperation of concerns between +these two concepts. + +With `TypeScript Standard Endpoints`, we define a standard for declaring in +TypeScript the definition of a networking API. + +- Allows API authors to publish npm packages containing their API interfaces +- Definitions can be consumed by any supporting library, allowing easy consumption across libraries like Vue, React, Angular +- Writing codegen pipelines becomes much easier as the output is minimal +- Product developers can use the definitions in a multitude of contexts where behaviors vary +- Product developers can easily share code across platforms with distinct behaviors needs like React Native and React Web + +### What's in an Endpoint + +- A function that resolves the results +- A function to uniquely store those results +- Optional: information about how to store the data in a normalized cache +- Optional: whether the request could have side effects - to prevent repeat calls diff --git a/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md b/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md new file mode 100644 index 000000000000..4a12c1d55555 --- /dev/null +++ b/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md @@ -0,0 +1,608 @@ + + +# Endpoint + +`Endpoint` are for any asynchronous function (one that returns a Promise). + +`Endpoints` define a strongly typed standard interface of relevant metadata and lifecycles +useful for Reactive Data Client and other stores. + +Package: [@data-client/endpoint](https://www.npmjs.com/package/@data-client/endpoint) + +> **Tip** +> +> Endpoint is a protocol independent class. Try using the protocol specific patterns +> [REST](https://dataclient.io/rest/api/RestEndpoint), [GraphQL](https://dataclient.io/graphql/api/GQLEndpoint), +> or [getImage](https://dataclient.io/docs/guides/img-media#just-images) instead. + +
+ +Interface + +**Interface** + +```typescript +export interface EndpointInterface< + F extends FetchFunction = FetchFunction, + S extends Schema | undefined = Schema | undefined, + M extends true | undefined = true | undefined, +> extends EndpointExtraOptions { + (...args: Parameters): InferReturn; + key(...args: Parameters): string; + readonly sideEffect?: M; + readonly schema?: S; +} +``` + +**Class** + +```typescript +class Endpoint Promise> + implements EndpointInterface +{ + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + fetch: F; + + extend(options: EndpointOptions): Endpoint; +} + +export interface EndpointOptions extends EndpointExtraOptions { + key?: (params: any) => string; + sideEffect?: true | undefined; + schema?: Schema; +} +``` + +**EndpointExtraOptions** + +```typescript +export interface EndpointExtraOptions { + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + readonly getOptimisticResponse?: ( + snap: SnapshotInterface, + ...args: Parameters + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + /** User-land extra data to send */ + readonly extra?: any; +} +``` + +
+ +## Usage + +`Endpoint` makes existing async functions usable in any Reactive Data Client context with full TypeScript enforcement. + +```ts title="interface" +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} +``` + +```ts title="api" {11} +import { Todo } from './interface'; + +const getTodoOriginal = (id: number): Promise => + Promise.resolve({ + id, + title: 'delectus aut autem ' + id, + completed: false, + userId: 1, + }); + +export const getTodo = new Endpoint(getTodoOriginal); +``` + +```tsx title="React" +import { getTodo } from './api'; + +function TodoDetail() { + const todo = useSuspense(getTodo, 1); + return
{todo.title}
; +} +render(); +``` + +### Configuration sharing + +Use [Endpoint.extend()](#extend) instead of `{...getTodo}` (spread) + +```ts +const getTodoNormalized = getTodo.extend({ schema: Todo }); +const getTodoUpdatingEveryFiveSeconds = getTodo.extend({ pollFrequency: 5000 }); +``` + +## Lifecycle + +### Success + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--resolves-->Controller.resolve + Controller.resolve("Controller.resolve(response)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:SET_RESPONSE + direction LR + normalize2(normalize)-->update2("Endpoint.update()") + end + managers--SET_RESPONSE-->reducer:SET_RESPONSE + click key "/rest/api/Endpoint#key" + click NetworkManager "/docs/api/NetworkManager" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click update2 "/rest/api/Endpoint#update" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +### Error + +```mermaid +flowchart LR + subgraph Controller.fetch + direction TB + key("Endpoint.key(...args)")--->dispatch("dispatch(FETCH)") + end + subgraph managers + NetworkManager-->endpoint("endpoint(...args)") + endpoint--rejects-->Controller.resolve + Controller.resolve("Controller.resolve(error)")-->dispatchR("dispatch(SET_RESPONSE)") + end + managers--FETCH-->reducer:FETCH + Controller.fetch--FETCH-->managers + subgraph reducer:FETCH + optimistic("Endpoint.?getOptimisticResponse()")-->SET_RESPONSE + subgraph SET_RESPONSE + normalize(normalize)-->update("Endpoint.update()") + end + end + subgraph reducer:reduceError + direction LR + filterOptimistic(filterOptimistic)-->errorPolicy("Endpoint.errorPolicy()") + end + managers--SET_RESPONSE:error-->reducer:reduceError + click key "/rest/api/Endpoint#key" + click optimistic "/rest/api/Endpoint#getoptimisticresponse" + click update "/rest/api/Endpoint#update" + click errorPolicy "/rest/api/Endpoint#errorpolicy" + click NetworkManager "/docs/api/NetworkManager" + click dispatch "/docs/api/Actions#fetch" + click dispatchR "/docs/api/Actions#set_response" +``` + +## Endpoint Members + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### key: (params) => string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.name} ${JSON.stringify(params)}`; +``` + +> **Warning: Overrides** +> +> When overriding `key`, be sure to also include an updated [testKey](#testKey) if +> you intend on using that method. + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver) + +### name: string {#name} + +Used in [key](#key) to distinguish endpoints. Should be globally unique. + +Defaults to `this.fetch.name` + +> **Warning** +> +> This may break in production builds that change function names. +> This is often know as [function name mangling](https://terser.org/docs/api-reference#mangle-options). +> +> In these cases you can override `name` or disable function mangling. + +### sideEffect: boolean {#sideeffect} + +Used to indicate endpoint might have side-effects (non-idempotent). This restricts it +from being used with [useSuspense()](https://dataclient.io/vue/api/useSuspense) or [useFetch()](https://dataclient.io/vue/api/useFetch) as those can hit the +endpoint an unpredictable number of times. + +### schema: Schema {#schema} + +Declarative definition of how to [process responses](https://dataclient.io/rest/api/schema) + +- [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) + +Not providing this option means no entities will be extracted. + +```tsx +import { Entity } from '@data-client/normalizr'; +import { Endpoint } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new Endpoint( + ({ id }) ⇒ fetch(`/users/${id}`), + { schema: User } +); +``` + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/vue/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/vue/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/vue/api/useSubscription) or +[useLive()](https://dataclient.io/vue/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` + +### extend(options): Endpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new Endpoint(({ id }) ⇒ fetch(`/users/${id}`)); + +const getUserNormalized = getUser.extend({ schema: User }); +``` + +In addition to the members, `fetch` can be sent to override the fetch function. + +## Examples + +**Basic** + +```typescript +import { Endpoint } from '@data-client/endpoint'; + +const UserDetail = new Endpoint( + ({ id }) ⇒ fetch(`/users/${id}`).then(res => res.json()) +); +``` + +**With Schema** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserDetail = new Endpoint( + ({ id }) ⇒ fetch(`/users/${id}`).then(res => res.json()), + { schema: User } +); +``` + +**List** + +```typescript +import { Endpoint, Entity } from '@data-client/endpoint'; + +class User extends Entity { + id = ''; + username = ''; +} + +const UserList = new Endpoint( + () ⇒ fetch(`/users/`).then(res => res.json()), + { schema: [User] } +); +``` + +**React** + +```tsx +function UserProfile() { + const user = useSuspense(UserDetail, { id }); + const ctrl = useController(); + + return ctrl.fetch(UserDetail)} />; +} +``` + +**JS/Node Schema** + +```typescript +const user = await UserDetail({ id: '5' }); +console.log(user); +``` + +### Additional + +- [Pagination](https://dataclient.io/rest/guides/pagination) +- [Mocking unfinished endpoints](https://dataclient.io/rest/guides/mocking-unfinished) +- [Optimistic updates](https://dataclient.io/rest/guides/optimistic-updates) + +## Motivation + +There is a distinction between + +- What are networking API is + - How to make a request, expected response fields, etc. +- How it is used + - Binding data, polling, triggering imperative fetch, etc. + +Thus, there are many benefits to creating a distinct seperation of concerns between +these two concepts. + +With `TypeScript Standard Endpoints`, we define a standard for declaring in +TypeScript the definition of a networking API. + +- Allows API authors to publish npm packages containing their API interfaces +- Definitions can be consumed by any supporting library, allowing easy consumption across libraries like Vue, React, Angular +- Writing codegen pipelines becomes much easier as the output is minimal +- Product developers can use the definitions in a multitude of contexts where behaviors vary +- Product developers can easily share code across platforms with distinct behaviors needs like React Native and React Web + +### What's in an Endpoint + +- A function that resolves the results +- A function to uniquely store those results +- Optional: information about how to store the data in a normalized cache +- Optional: whether the request could have side effects - to prevent repeat calls diff --git a/.agents/skills/data-client-graphql-setup/references.json b/.agents/skills/data-client-graphql-setup/references.json new file mode 100644 index 000000000000..8e5ef323deff --- /dev/null +++ b/.agents/skills/data-client-graphql-setup/references.json @@ -0,0 +1,9 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "auth.md": "docs/graphql/auth.md" + } +} diff --git a/.agents/skills/data-client-graphql-setup/references/auth.md b/.agents/skills/data-client-graphql-setup/references/auth.md deleted file mode 120000 index 67a8c026606e..000000000000 --- a/.agents/skills/data-client-graphql-setup/references/auth.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/graphql/auth.md \ No newline at end of file diff --git a/.agents/skills/data-client-graphql-setup/references/auth.md b/.agents/skills/data-client-graphql-setup/references/auth.md new file mode 100644 index 000000000000..4457f20969d1 --- /dev/null +++ b/.agents/skills/data-client-graphql-setup/references/auth.md @@ -0,0 +1,61 @@ + + +# GraphQL Authentication + +## Cookie Auth + +Here's an example using simple cookie auth: + +```ts title="schema/endpoint.ts" +export const gql = new GQLEndpoint('https://nosy-baritone.glitch.me', { + getRequestInit(body: any): Promise { + return { + ...super.getRequestInit(body), + credentials: 'same-origin', + }; + } +}); +export default gql; +``` + +## Access Tokens + +Here we'll use a member variable to track the access token and send it +in a header. + +```ts title="schema/endpoint.ts" +export const gql = new GQLEndpoint('https://nosy-baritone.glitch.me', { + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': this.accessToken, + }; + }, +}); +export default gql; +``` + +Then be sure to set the access token upon login: + +```ts +import gql from 'schema/endpoint'; + +function Auth() { + const handleLogin = useCallback( + async e => { + const { accessToken } = await login(new FormData(e.target)); + // success! + gql.accessToken = accessToken; + }, + [login], + ); + + return ; +} +``` + +## 401 Logout Handling + +In case a users authorization expires, the server will typically responsd to indicate +as such. The standard way of doing this is with a 401. [LogoutManager](https://dataclient.io/docs/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-manager/SKILL.md b/.agents/skills/data-client-manager/SKILL.md index c61ab9b78ed7..69d0dc36e490 100644 --- a/.agents/skills/data-client-manager/SKILL.md +++ b/.agents/skills/data-client-manager/SKILL.md @@ -34,6 +34,8 @@ Minimal working examples for each use case live in [references/managers.md](refe ## References +Vue projects: read `.vue.md` instead of `.md` when it exists. + For detailed API documentation, see the [references](references/) directory: - [Manager](references/Manager.md) - Manager interface and lifecycle diff --git a/.agents/skills/data-client-manager/references.json b/.agents/skills/data-client-manager/references.json new file mode 100644 index 000000000000..536ba83ff54c --- /dev/null +++ b/.agents/skills/data-client-manager/references.json @@ -0,0 +1,14 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "Actions.md": "docs/core/api/Actions.md", + "Controller.md": "docs/core/api/Controller.md", + "LogoutManager.md": "docs/core/api/LogoutManager.md", + "Manager.md": "docs/core/api/Manager.md", + "getDefaultManagers.md": "docs/core/api/getDefaultManagers.md", + "managers.md": "docs/core/concepts/managers.md" + } +} diff --git a/.agents/skills/data-client-manager/references/Actions.md b/.agents/skills/data-client-manager/references/Actions.md deleted file mode 120000 index 9804fdfe2f2a..000000000000 --- a/.agents/skills/data-client-manager/references/Actions.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Actions.md \ No newline at end of file diff --git a/.agents/skills/data-client-manager/references/Actions.md b/.agents/skills/data-client-manager/references/Actions.md new file mode 100644 index 000000000000..e5f7dcdc3a2a --- /dev/null +++ b/.agents/skills/data-client-manager/references/Actions.md @@ -0,0 +1,276 @@ + + +# Actions + +Actions are minimal descriptions of store updates. + +They are [dispatched by Controller methods](./Controller.md#action-dispatchers) -> +[read and consumed by Manager middleware](./Manager.md#reading-and-consuming-actions) -> +processed by [reducers](https://react.dev/reference/react/useReducer) registered with [DataProvider](https://dataclient.io/docs/api/DataProvider) +to update the store's state. + +Many actions use the same meta information: + +```ts +interface ActionMeta { + readonly fetchedAt: number; + readonly date: number; + readonly expiresAt: number; +} +``` + +## FETCH + +```ts +interface FetchMeta { + fetchedAt: number; + resolve: (value?: any | PromiseLike) => void; + reject: (reason?: any) => void; + promise: PromiseLike; +} + +interface FetchAction { + type: typeof actionTypes.FETCH; + endpoint: Endpoint; + args: readonly [...Parameters]; + key: string; + meta: FetchMeta; +} +``` + +```js +{ + type: 'rdc/fetch', + key: 'GET https://jsonplaceholder.typicode.com/todos?userId=1', + args: [ + { + userId: 1 + } + ], + endpoint: Endpoint('User.getList'), + meta: { + fetchedAt: '5:09:41.975 PM', + resolve: function (){}, + reject: function (){}, + promise: {} + } +} +``` + +Sent by [Controller.fetch()](./Controller.md#fetch), [Controller.fetchIfStale()](./Controller.md#fetchIfStale), +[useSuspense()](https://dataclient.io/docs/api/useSuspense), [useDLE()](https://dataclient.io/docs/api/useDLE), [useLive()](https://dataclient.io/docs/api/useLive), [useFetch()](https://dataclient.io/docs/api/useFetch) + +Read by [NetworkManager](https://dataclient.io/docs/api/NetworkManager) + +## SET + +```ts +interface SetAction { + type: typeof actionTypes.SET; + schema: Queryable; + args: readonly any[]; + meta: ActionMeta; + value: {} | ((previousValue: Denormalize) => {}); +} +``` + +```js +{ + type: 'rdc/set', + value: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: true + }, + args: [ + { + id: 1 + } + ], + schema: Todo, + meta: { + fetchedAt: '5:18:26.394 PM', + date: '5:18:26.636 PM', + expiresAt: '6:18:26.636 PM' + } +} +``` + +Sent by [Controller.set()](./Controller.md#set) + +## SET\_RESPONSE + +```ts +interface SetResponseAction { + type: typeof actionTypes.SET_RESPONSE; + endpoint: Endpoint; + args: readonly any[]; + key: string; + meta: ActionMeta; + response: ResolveType | Error; + error: boolean; +} +``` + +```js +{ + type: 'rdc/setresponse', + key: 'PATCH https://jsonplaceholder.typicode.com/todos/1', + response: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: true + }, + args: [ + { + id: 1 + }, + { + completed: true + } + ], + endpoint: Endpont('Todo.partialUpdate'), + meta: { + fetchedAt: '5:18:26.394 PM', + date: '5:18:26.636 PM', + expiresAt: '6:18:26.636 PM' + }, + error: false +} +``` + +Sent by [Controller.setResponse()](./Controller.md#setResponse), [NetworkManager](https://dataclient.io/docs/api/NetworkManager) + +Read by [NetworkManager](https://dataclient.io/docs/api/NetworkManager), [LogoutManager](./LogoutManager.md) + +## RESET + +```ts +interface ResetAction { + type: typeof actionTypes.RESET; + date: number; +} +``` + +```js +{ + type: 'rdc/reset', + date: '5:09:41.975 PM', +} +``` + +Sent by [Controller.resetEntireStore()](./Controller.md#resetEntireStore) + +Read by [NetworkManager](https://dataclient.io/docs/api/NetworkManager) + +## SUBSCRIBE + +```ts +interface SubscribeAction { + type: typeof actionTypes.SUBSCRIBE; + endpoint: Endpoint; + args: readonly any[]; + key: string; +} +``` + +```js +{ + type: 'rdc/subscribe', + key: 'GET https://api.exchange.coinbase.com/products/BTC-USD/ticker', + args: [ + { + product_id: 'BTC-USD' + } + ], + endpoint: Endpoint('https://api.exchange.coinbase.com/products/:product_id/ticker'), +} +``` + +Sent by [Controller.subscribe()](./Controller.md#subscribe), [useSubscription()](https://dataclient.io/docs/api/useSubscription), [useLive()](https://dataclient.io/docs/api/useLive) + +Read by [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) + +## UNSUBSCRIBE + +```ts +interface UnsubscribeAction { + type: typeof actionTypes.UNSUBSCRIBE; + endpoint: Endpoint; + args: readonly any[]; + key: string; +} +``` + +```js +{ + type: 'rdc/unsubscribe', + key: 'GET https://api.exchange.coinbase.com/products/BTC-USD/ticker', + args: [ + { + product_id: 'BTC-USD' + } + ], + endpoint: Endpoint('https://api.exchange.coinbase.com/products/:product_id/ticker'), +} +``` + +Sent by [Controller.unsubscribe()](./Controller.md#unsubscribe), [useSubscription()](https://dataclient.io/docs/api/useSubscription), [useLive()](https://dataclient.io/docs/api/useLive) + +Read by [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) + +## INVALIDATE + +```ts +interface InvalidateAction { + type: typeof actionTypes.INVALIDATE; + key: string; +} +``` + +```js +{ + type: 'rdc/invalidate', + key: 'GET https://jsonplaceholder.typicode.com/todos?userId=1', +} +``` + +Sent by [Controller.invalidate()](./Controller.md#invalidate) + +## INVALIDATEALL + +```ts +interface InvalidateAllAction { + type: typeof actionTypes.INVALIDATEALL; + testKey: (key: string) => boolean; +} +``` + +```js +{ + type: 'rdc/invalidateall', + testKey: Endpoint('User.getList'), +} +``` + +Sent by [Controller.invalidateAll()](./Controller.md#invalidateAll) + +## EXPIREALL + +```ts +interface ExpireAllAction { + type: typeof actionTypes.EXPIREALL; + testKey: (key: string) => boolean; +} +``` + +```js +{ + type: 'rdc/expireall', + testKey: Endpoint('User.getList'), +} +``` + +Sent by [Controller.expireAll()](./Controller.md#expireAll) diff --git a/.agents/skills/data-client-manager/references/Actions.vue.md b/.agents/skills/data-client-manager/references/Actions.vue.md new file mode 100644 index 000000000000..bdf040663376 --- /dev/null +++ b/.agents/skills/data-client-manager/references/Actions.vue.md @@ -0,0 +1,276 @@ + + +# Actions + +Actions are minimal descriptions of store updates. + +They are [dispatched by Controller methods](./Controller.vue.md#action-dispatchers) -> +[read and consumed by Manager middleware](./Manager.vue.md#reading-and-consuming-actions) -> +processed by [reducers](https://react.dev/reference/react/useReducer) registered with [DataClientPlugin](https://dataclient.io/vue/getting-started/installation) +to update the store's state. + +Many actions use the same meta information: + +```ts +interface ActionMeta { + readonly fetchedAt: number; + readonly date: number; + readonly expiresAt: number; +} +``` + +## FETCH + +```ts +interface FetchMeta { + fetchedAt: number; + resolve: (value?: any | PromiseLike) => void; + reject: (reason?: any) => void; + promise: PromiseLike; +} + +interface FetchAction { + type: typeof actionTypes.FETCH; + endpoint: Endpoint; + args: readonly [...Parameters]; + key: string; + meta: FetchMeta; +} +``` + +```js +{ + type: 'rdc/fetch', + key: 'GET https://jsonplaceholder.typicode.com/todos?userId=1', + args: [ + { + userId: 1 + } + ], + endpoint: Endpoint('User.getList'), + meta: { + fetchedAt: '5:09:41.975 PM', + resolve: function (){}, + reject: function (){}, + promise: {} + } +} +``` + +Sent by [Controller.fetch()](./Controller.vue.md#fetch), [Controller.fetchIfStale()](./Controller.vue.md#fetchIfStale), +[useSuspense()](https://dataclient.io/vue/api/useSuspense), [useDLE()](https://dataclient.io/vue/api/useDLE), [useLive()](https://dataclient.io/vue/api/useLive), [useFetch()](https://dataclient.io/vue/api/useFetch) + +Read by [NetworkManager](https://dataclient.io/vue/api/NetworkManager) + +## SET + +```ts +interface SetAction { + type: typeof actionTypes.SET; + schema: Queryable; + args: readonly any[]; + meta: ActionMeta; + value: {} | ((previousValue: Denormalize) => {}); +} +``` + +```js +{ + type: 'rdc/set', + value: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: true + }, + args: [ + { + id: 1 + } + ], + schema: Todo, + meta: { + fetchedAt: '5:18:26.394 PM', + date: '5:18:26.636 PM', + expiresAt: '6:18:26.636 PM' + } +} +``` + +Sent by [Controller.set()](./Controller.vue.md#set) + +## SET\_RESPONSE + +```ts +interface SetResponseAction { + type: typeof actionTypes.SET_RESPONSE; + endpoint: Endpoint; + args: readonly any[]; + key: string; + meta: ActionMeta; + response: ResolveType | Error; + error: boolean; +} +``` + +```js +{ + type: 'rdc/setresponse', + key: 'PATCH https://jsonplaceholder.typicode.com/todos/1', + response: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: true + }, + args: [ + { + id: 1 + }, + { + completed: true + } + ], + endpoint: Endpont('Todo.partialUpdate'), + meta: { + fetchedAt: '5:18:26.394 PM', + date: '5:18:26.636 PM', + expiresAt: '6:18:26.636 PM' + }, + error: false +} +``` + +Sent by [Controller.setResponse()](./Controller.vue.md#setResponse), [NetworkManager](https://dataclient.io/vue/api/NetworkManager) + +Read by [NetworkManager](https://dataclient.io/vue/api/NetworkManager), [LogoutManager](./LogoutManager.md) + +## RESET + +```ts +interface ResetAction { + type: typeof actionTypes.RESET; + date: number; +} +``` + +```js +{ + type: 'rdc/reset', + date: '5:09:41.975 PM', +} +``` + +Sent by [Controller.resetEntireStore()](./Controller.vue.md#resetEntireStore) + +Read by [NetworkManager](https://dataclient.io/vue/api/NetworkManager) + +## SUBSCRIBE + +```ts +interface SubscribeAction { + type: typeof actionTypes.SUBSCRIBE; + endpoint: Endpoint; + args: readonly any[]; + key: string; +} +``` + +```js +{ + type: 'rdc/subscribe', + key: 'GET https://api.exchange.coinbase.com/products/BTC-USD/ticker', + args: [ + { + product_id: 'BTC-USD' + } + ], + endpoint: Endpoint('https://api.exchange.coinbase.com/products/:product_id/ticker'), +} +``` + +Sent by [Controller.subscribe()](./Controller.vue.md#subscribe), [useSubscription()](https://dataclient.io/vue/api/useSubscription), [useLive()](https://dataclient.io/vue/api/useLive) + +Read by [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager) + +## UNSUBSCRIBE + +```ts +interface UnsubscribeAction { + type: typeof actionTypes.UNSUBSCRIBE; + endpoint: Endpoint; + args: readonly any[]; + key: string; +} +``` + +```js +{ + type: 'rdc/unsubscribe', + key: 'GET https://api.exchange.coinbase.com/products/BTC-USD/ticker', + args: [ + { + product_id: 'BTC-USD' + } + ], + endpoint: Endpoint('https://api.exchange.coinbase.com/products/:product_id/ticker'), +} +``` + +Sent by [Controller.unsubscribe()](./Controller.vue.md#unsubscribe), [useSubscription()](https://dataclient.io/vue/api/useSubscription), [useLive()](https://dataclient.io/vue/api/useLive) + +Read by [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager) + +## INVALIDATE + +```ts +interface InvalidateAction { + type: typeof actionTypes.INVALIDATE; + key: string; +} +``` + +```js +{ + type: 'rdc/invalidate', + key: 'GET https://jsonplaceholder.typicode.com/todos?userId=1', +} +``` + +Sent by [Controller.invalidate()](./Controller.vue.md#invalidate) + +## INVALIDATEALL + +```ts +interface InvalidateAllAction { + type: typeof actionTypes.INVALIDATEALL; + testKey: (key: string) => boolean; +} +``` + +```js +{ + type: 'rdc/invalidateall', + testKey: Endpoint('User.getList'), +} +``` + +Sent by [Controller.invalidateAll()](./Controller.vue.md#invalidateAll) + +## EXPIREALL + +```ts +interface ExpireAllAction { + type: typeof actionTypes.EXPIREALL; + testKey: (key: string) => boolean; +} +``` + +```js +{ + type: 'rdc/expireall', + testKey: Endpoint('User.getList'), +} +``` + +Sent by [Controller.expireAll()](./Controller.vue.md#expireAll) diff --git a/.agents/skills/data-client-manager/references/Controller.md b/.agents/skills/data-client-manager/references/Controller.md deleted file mode 120000 index 359c06beecd7..000000000000 --- a/.agents/skills/data-client-manager/references/Controller.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Controller.md \ No newline at end of file diff --git a/.agents/skills/data-client-manager/references/Controller.md b/.agents/skills/data-client-manager/references/Controller.md new file mode 100644 index 000000000000..a17432382582 --- /dev/null +++ b/.agents/skills/data-client-manager/references/Controller.md @@ -0,0 +1,653 @@ + + +# Controller + +`Controller` is a singleton providing safe access to the Reactive Data Client [flux store and lifecycle](./Manager.md#control-flow). +`Controller` memoizes all store access, allowing a global referential equality guarantee and the fastest rendering +and retrieval performance. + +`Controller` is provided: + +- [Managers](./Manager.md) as the first argument in [Manager.middleware](./Manager.md#middleware) +- React with [useController()](https://dataclient.io/docs/api/useController) +- [Unit testing hooks](https://dataclient.io/docs/guides/unit-testing-hooks) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook#controller) + +```ts +class Controller { + /*************** Action Dispatchers ***************/ + fetch(endpoint, ...args): ReturnType; + fetchIfStale(endpoint, ...args): ReturnType | undefined; + expireAll({ testKey }): Promise; + invalidate(endpoint, ...args): Promise; + invalidateAll({ testKey }): Promise; + resetEntireStore(): Promise; + set(queryable, ...args, value): Promise; + set([Entity], rows): Promise; + setResponse(endpoint, ...args, response): Promise; + setError(endpoint, ...args, error): Promise; + resolve(endpoint, { args, response, fetchedAt, error }): Promise; + subscribe(endpoint, ...args): Promise; + unsubscribe(endpoint, ...args): Promise; + /*************** Data Access ***************/ + get(queryable, ...args, state): Denormalized; + getResponse(endpoint, ...args, state): { data; expiryStatus; expiresAt }; + getError(endpoint, ...args, state): ErrorTypes | undefined; + snapshot(state: State, fetchedAt?: number): SnapshotInterface; + getState(): State; +} +``` + +## Action Dispatchers + +### fetch(endpoint, ...args) {#fetch} + +Fetches the endpoint with given args, updating the Reactive Data Client cache with +the response or error upon completion. + +**Create** + +```tsx +function CreatePost() { + const ctrl = useController(); + + return ( +
+ ctrl.fetch(PostResource.getList.push, new FormData(e.target)) + } + > + {/* ... */} +
+ ); +} +``` + +**Update** + +```tsx +function UpdatePost({ id }: { id: string }) { + const ctrl = useController(); + + return ( +
+ ctrl.fetch(PostResource.update, { id }, new FormData(e.target)) + } + > + {/* ... */} +
+ ); +} +``` + +**Delete** + +```tsx +function PostListItem({ post }: { post: PostResource }) { + const ctrl = useController(); + + const handleDelete = useCallback( + async e => { + await ctrl.fetch(PostResource.delete, { id: post.id }); + history.push('/'); + }, + [ctrl, id], + ); + + return ( +
+

{post.title}

+ +
+ ); +} +``` + +> **Tip** +> +> `fetch` has the same return value as the [Endpoint](https://dataclient.io/rest/api/Endpoint) passed to it. +> When using schemas, the denormalized value is returned +> +> ```ts +> import { useController } from '@data-client/react'; +> +> const post = await controller.fetch( +> PostResource.getList.push, +> createPayload, +> ); +> post.title; +> post.pk(); +> ``` + +#### Endpoint.sideEffect + +[sideEffect](https://dataclient.io/rest/api/Endpoint#sideeffect) changes the behavior + +##### true + +- Resolves _before_ [committing](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom) Reactive Data Client cache updates. (React 16, 17) +- Each call will always cause a new fetch. + +##### false | undefined + +- Resolves _after_ [committing](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom) Reactive Data Client cache updates. +- Identical requests are deduplicated globally; allowing only one inflight request at a time. + - To ensure a _new_ request is started, make sure to abort any existing inflight requests. + +### fetchIfStale(endpoint, ...args) {#fetchIfStale} + +Fetches only if endpoint is considered '[stale](https://dataclient.io/docs/concepts/expiry-policy#stale)'. + +This can be useful when prefetching data, as it avoids overfetching fresh data. + +An [example](https://stackblitz.com/github/reactive/data-client/tree/master/examples/github-app?file=src%2Frouting%2Froutes.tsx) with a fetch-as-you-render router: + +```ts +{ + name: 'IssueList', + component: lazyPage('IssuesPage'), + title: 'issue list', + resolveData: async ( + controller: Controller, + { owner, repo }: { owner: string; repo: string }, + searchParams: URLSearchParams, + ) => { + const q = searchParams?.get('q') || 'is:issue is:open'; + await controller.fetchIfStale(IssueResource.search, { + owner, + repo, + q, + }); + }, +}, +``` + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/routing/routes.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/routing/routes.tsx)) + +### expireAll({ testKey }) {#expireAll} + +Sets all responses' [expiry status](https://dataclient.io/docs/concepts/expiry-policy) matching `testKey` to [Stale](https://dataclient.io/docs/concepts/expiry-policy#stale). + +This is sometimes useful to trigger refresh of only data presently shown +when there are many parameterizations in cache. + +```tsx +import { type Controller, useController } from '@data-client/react'; + +const createTradeHandler = (ctrl: Controller) => async trade => { + await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade)); + ctrl.expireAll(AccountResource.get); + ctrl.expireAll(AccountResource.getList); +}; + +function CreateTrade({ id }: { id: string }) { + const handleTrade = createTradeHandler(useController()); + + return ( +
+ + + + + ); +} +``` + +> **Tip** +> +> To reduce load, improve performance, and improve state consistency; it can often be +> better to [include mutation sideeffects in the mutation response](https://dataclient.io/rest/guides/side-effects). + +### invalidate(endpoint, ...args) {#invalidate} + +Forces refetching and suspense on [useSuspense](https://dataclient.io/docs/api/useSuspense) with the same Endpoint +and parameters. + +```tsx +function ArticleName({ id }: { id: string }) { + const article = useSuspense(ArticleResource.get, { id }); + const ctrl = useController(); + + return ( +
+

{article.title}

+ +

+ ); +} +``` + +> **Tip** +> +> To refresh while continuing to display stale data - [Controller.fetch](#fetch). + +> **Tip: Invalidate many endpoints at once** +> +> Use [schema.Invalidate](https://dataclient.io/rest/api/Invalidate) to invalidate every endpoint that contains a given entity. +> +> For REST try using [Resource.delete](https://dataclient.io/rest/api/resource#delete) +> +> ```ts +> // deletes MyResource(5) +> // this will resuspend MyResource.get({id: '5'}) +> // and remove it from MyResource.getList +> controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); +> ``` + +### invalidateAll({ testKey }) {#invalidateAll} + +[Invalidates](https://dataclient.io/docs/concepts/expiry-policy#invalid) all [endpoint keys](https://dataclient.io/rest/api/RestEndpoint#key) matching `testKey`. + +```tsx +function ArticleName({ id }: { id: string }) { + const article = useSuspense(ArticleResource.get, { id }); + const ctrl = useController(); + + return ( +
+

{article.title}

+ +

+ ); +} +``` + +> **Tip** +> +> To refresh while continuing to display stale data - [Controller.expireAll](#expireAll) instead. + +Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. + +```tsx +const myDomain = 'http://test.com'; +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +function useLogout() { + const ctrl = useController(); + return () => ctrl.invalidateAll({ testKey }); +} +``` + +It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](./LogoutManager.md) +as well. + +```ts +import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; +import { unAuth } from '../authentication'; + +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +const managers = [ + new LogoutManager({ + handleLogout(controller) { + // call custom unAuth function we defined + unAuth(); + // still reset the store + controller.invalidateAll({ testKey }); + }, + }), + ...getDefaultManagers(), +]; + +createRoot(document.body).render( + + + , +); +``` + +### resetEntireStore() {#resetEntireStore} + +Resets/clears the entire Reactive Data Client cache. All inflight requests will not resolve. + +This is typically used when logging out or changing authenticated users. + +```tsx +const USER_NUMBER_ONE: string = "1111"; + +function UserName() { + const user = useSuspense(CurrentUserResource.get); + const ctrl = useController(); + + const becomeAdmin = useCallback(() => { + // Changes the current user + impersonateUser(USER_NUMBER_ONE); + ctrl.resetEntireStore(); + }, [ctrl]); + return ( +
+

{user.name}

+ +

+ ); +} +``` + +### set(queryable, ...args, value) {#set} + +Updates any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schema](https://dataclient.io/rest/api/schema#schema-overview), or many entities at once with an [Array](https://dataclient.io/rest/api/Array) or [Values](https://dataclient.io/rest/api/Values) schema. + +```ts +ctrl.set( + Todo, + // which Todo to update + { id: '5' }, + // merge this data into the Todo in the store + { id: '5', title: 'tell me friends how great Data Client is' }, +); +``` + +The value is typed by the schema: an [Entity](https://dataclient.io/rest/api/Entity) takes its fields (numbers and strings may be either), +while a [Collection](https://dataclient.io/rest/api/Collection) or [All](https://dataclient.io/rest/api/All) takes a list of rows. A [Query](https://dataclient.io/rest/api/Query) +takes the input of the schema it wraps, since `set()` normalizes that schema rather than reversing `process()`. + +```ts +ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]); +``` + +> **Note: Type checking limits** +> +> To keep type checking fast for large [Unions](https://dataclient.io/rest/api/Union), a Union row is checked against the +> combined fields of all its members rather than against one member. Each field's type is still checked, +> but a row that mixes fields from different members (like `{ type: 'first', secondField: 1 }`) is not +> an error. Make sure the fields you set belong to the member the row's discriminator selects. + +Functions can be used in the value when derived data is used. This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). + +```ts +const id = '2'; +ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); +``` + +#### set(\[Entity], rows) {#set-array} + +Pass an [Array](https://dataclient.io/rest/api/Array) schema (`[Todo]` or `new schema.Array(Todo)`) and a list of rows to update +many entities in one store update. Each row merges with its stored entity; entities not in the list are untouched. + +```ts +ctrl.set( + [Todo], + [ + { id: '5', completed: true }, + { id: '6', completed: false }, + ], +); +``` + +Rows are typed by the Entity's fields; numbers and strings may be either, and object, array and Date values are not +checked since rows are raw input. + +For lists that mix Entity types, use a [Union](https://dataclient.io/rest/api/Union); each row is stored by its `type`: + +```ts +const Feed = new schema.Union({ post: Post, comment: Comment }, 'type'); + +ctrl.set( + [Feed], + [ + { id: '1', type: 'post', title: 'Hello' }, + { id: '7', type: 'comment', body: 'Nice!' }, + ], +); +``` + +To delete many entities at once, use [Invalidate](https://dataclient.io/rest/api/Invalidate#batch-invalidation); rows only need their pk +fields: + +```ts +ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]); +``` + +[Values](https://dataclient.io/rest/api/Values) schemas take an object of rows instead: + +```ts +ctrl.set(new schema.Values(Todo), { + '5': { id: '5', completed: true }, + '6': { id: '6', completed: false }, +}); +``` + +Array and Values schemas take no `args` (so [Entity.pk()](https://dataclient.io/rest/api/Entity#pk) and [Entity.process()](https://dataclient.io/rest/api/Entity#process) +receive `[]`) and no updater function. Rows that share a pk merge in list order, without +[Entity.shouldReorder()](https://dataclient.io/rest/api/Entity#shouldreorder). Use this instead of calling `set()` once per row, such as when +[batching high-frequency stream updates](./managers.md#batching). + +### setResponse(endpoint, ...args, response) {#setResponse} + +Stores `response` in cache for given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args. + +Any components suspending for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args will resolve. + +If data already exists for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args, it will be updated. + +```tsx +const ctrl = useController(); + +useEffect(() => { + const websocket = new Websocket(url); + + websocket.onmessage = event => + ctrl.setResponse( + EndpointLookup[event.endpoint], + ...event.args, + event.data, + ); + + return () => websocket.close(); +}); +``` + +This shows a proof of concept in React; however a [Manager websockets implementation](./managers.md#data-stream) +would be much more robust. + +### setError(endpoint, ...args, error) {#setError} + +Stores the result of [Endpoint](https://dataclient.io/rest/api/Endpoint) and args as the error provided. + +### resolve(endpoint, { args, response, fetchedAt, error }) {#resolve} + +Resolves a specific fetch, storing the `response` in cache. + +This is similar to setResponse, except it triggers resolution of an inflight fetch. +This means the corresponding optimistic update will no longer be applies. + +This is used in [NetworkManager](https://dataclient.io/docs/api/NetworkManager), and should be used when +processing fetch requests. + +### subscribe(endpoint, ...args) {#subscribe} + +Marks a new subscription to a given [Endpoint](https://dataclient.io/rest/api/Endpoint). This should increment the subscription. + +[useSubscription](https://dataclient.io/docs/api/useSubscription) and [useLive](https://dataclient.io/docs/api/useLive) call this on mount. + +This might be useful for custom hooks to sub/unsub based on other factors. + +```tsx +const controller = useController(); +const key = endpoint.key(...args); + +useEffect(() => { + controller.subscribe(endpoint, ...args); + return () => controller.unsubscribe(endpoint, ...args); +}, [controller, key]); +``` + +### unsubscribe(endpoint, ...args) {#unsubscribe} + +Marks completion of subscription to a given [Endpoint](https://dataclient.io/rest/api/Endpoint). This should +decrement the subscription and if the count reaches 0, more updates won't be received automatically. + +[useSubscription](https://dataclient.io/docs/api/useSubscription) and [useLive](https://dataclient.io/docs/api/useLive) call this on unmount. + +## Data Access + +### get(schema, ...args, state) {#get} + +Looks up any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schema](https://dataclient.io/rest/api/schema#schema-overview) in `state`. + +#### Example + +This is used in [useQuery](https://dataclient.io/docs/api/useQuery) and can be used in +[Managers](./Manager.md) to safely access the store. + +```tsx title="useQuery.ts" +import { + useController, + useCacheState, + type Queryable, + type SchemaArgs, + type DenormalizeNullable, +} from '@data-client/core'; + +/** Oversimplified useQuery */ +function useQuery( + schema: S, + ...args: SchemaArgs +): DenormalizeNullable | undefined { + const state = useCacheState(); + const controller = useController(); + + return controller.get(schema, ...args, state); +} +``` + +### getResponse(endpoint, ...args, state) {#getResponse} + +```ts title="returns" +{ + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; +} +``` + +Gets the (globally referentially stable) response for a given endpoint/args pair from state given. + +#### data + +The denormalize response data. Guarantees global referential stability for all members. + +#### [expiryStatus](https://dataclient.io/docs/concepts/expiry-policy#expiry-status) + +```ts +export enum ExpiryStatus { + Invalid = 1, + InvalidIfStale, + Valid, +} +``` + +##### Valid + +- Will never suspend. +- Might fetch if data is stale + +##### InvalidIfStale + +- Will suspend if data is stale. +- Might fetch if data is stale + +##### Invalid + +- Will always suspend +- Will always fetch + +#### expiresAt + +A number representing time when it expires. Compare to Date.now(). + +#### Example + +This is used in [useCache](https://dataclient.io/docs/api/useCache), [useSuspense](https://dataclient.io/docs/api/useSuspense) and can be used in +[Managers](./Manager.md) to lookup a response with the state provided. + +```tsx title="useCache.ts" +import { + useController, + StateContext, + EndpointInterface, +} from '@data-client/core'; + +/** Oversimplified useCache */ +function useCache( + endpoint: E, + ...args: readonly [...Parameters] +) { + const state = useContext(StateContext); + const controller = useController(); + return controller.getResponse(endpoint, ...args, state).data; +} +``` + +```tsx title="MyManager.ts" +import type { Manager, Middleware, actionTypes } from '@data-client/core'; +import type { EndpointInterface } from '@data-client/endpoint'; + +export default class MyManager implements Manager { + middleware: Middleware = controller => { + return next => async action => { + if (action.type === actionTypes.FETCH) { + console.log('The existing response of the requested fetch'); + console.log( + controller.getResponse( + action.endpoint, + ...(action.meta.args as Parameters), + controller.getState(), + ).data, + ); + } + next(action); + }; + }; + + cleanup() { + this.websocket.close(); + } +} +``` + +### getError(endpoint, ...args, state) {#getError} + +Gets the error, if any, for a given endpoint. Returns undefined for no errors. + +### snapshot(state, fetchedAt) {#snapshot} + +Returns a [Snapshot](https://dataclient.io/docs/api/Snapshot). + +### getState() {#getState} + +Gets the internal state of Reactive Data Client that has _already been [committed](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom)_. + +> **Warning** +> +> This should only be used in event handlers or [Managers](./Manager.md). +> +> Using getState() in React's render lifecycle can result in data tearing. + +```tsx +const controller = useController(); + +const updateHandler = useCallback( + async updatePayload => { + const response = await controller.fetch( + MyResource.update, + { id }, + updatePayload, + ); + // the fetch has completed, but react has not yet re-rendered + // this lets use sequence after the next re-render + // we're working on a better solution to this specific case + setTimeout(() => { + const { data: denormalized } = controller.getResponse( + MyResource.update, + { id }, + updatePayload, + controller.getState(), + ); + redirect(denormalized.getterUrl); + }, 40); + }, + [id], +); +``` diff --git a/.agents/skills/data-client-manager/references/Controller.vue.md b/.agents/skills/data-client-manager/references/Controller.vue.md new file mode 100644 index 000000000000..7202b5440324 --- /dev/null +++ b/.agents/skills/data-client-manager/references/Controller.vue.md @@ -0,0 +1,653 @@ + + +# Controller + +`Controller` is a singleton providing safe access to the Reactive Data Client [flux store and lifecycle](./Manager.vue.md#control-flow). +`Controller` memoizes all store access, allowing a global referential equality guarantee and the fastest rendering +and retrieval performance. + +`Controller` is provided: + +- [Managers](./Manager.vue.md) as the first argument in [Manager.middleware](./Manager.vue.md#middleware) +- Vue with [useController()](https://dataclient.io/vue/api/useController) +- Unit testing composables with `renderDataCompose()` from `@data-client/vue/test` + +```ts +class Controller { + /*************** Action Dispatchers ***************/ + fetch(endpoint, ...args): ReturnType; + fetchIfStale(endpoint, ...args): ReturnType | undefined; + expireAll({ testKey }): Promise; + invalidate(endpoint, ...args): Promise; + invalidateAll({ testKey }): Promise; + resetEntireStore(): Promise; + set(queryable, ...args, value): Promise; + set([Entity], rows): Promise; + setResponse(endpoint, ...args, response): Promise; + setError(endpoint, ...args, error): Promise; + resolve(endpoint, { args, response, fetchedAt, error }): Promise; + subscribe(endpoint, ...args): Promise; + unsubscribe(endpoint, ...args): Promise; + /*************** Data Access ***************/ + get(queryable, ...args, state): Denormalized; + getResponse(endpoint, ...args, state): { data; expiryStatus; expiresAt }; + getError(endpoint, ...args, state): ErrorTypes | undefined; + snapshot(state: State, fetchedAt?: number): SnapshotInterface; + getState(): State; +} +``` + +## Action Dispatchers + +### fetch(endpoint, ...args) {#fetch} + +Fetches the endpoint with given args, updating the Reactive Data Client cache with +the response or error upon completion. + +**Create** + +```tsx +function CreatePost() { + const ctrl = useController(); + + return ( +
+ ctrl.fetch(PostResource.getList.push, new FormData(e.target)) + } + > + {/* ... */} +
+ ); +} +``` + +**Update** + +```tsx +function UpdatePost({ id }: { id: string }) { + const ctrl = useController(); + + return ( +
+ ctrl.fetch(PostResource.update, { id }, new FormData(e.target)) + } + > + {/* ... */} +
+ ); +} +``` + +**Delete** + +```tsx +function PostListItem({ post }: { post: PostResource }) { + const ctrl = useController(); + + const handleDelete = useCallback( + async e => { + await ctrl.fetch(PostResource.delete, { id: post.id }); + history.push('/'); + }, + [ctrl, id], + ); + + return ( +
+

{post.title}

+ +
+ ); +} +``` + +> **Tip** +> +> `fetch` has the same return value as the [Endpoint](https://dataclient.io/rest/api/Endpoint) passed to it. +> When using schemas, the denormalized value is returned +> +> ```ts +> import { useController } from '@data-client/react'; +> +> const post = await controller.fetch( +> PostResource.getList.push, +> createPayload, +> ); +> post.title; +> post.pk(); +> ``` + +#### Endpoint.sideEffect + +[sideEffect](https://dataclient.io/rest/api/Endpoint#sideeffect) changes the behavior + +##### true + +- Resolves _before_ [committing](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom) Reactive Data Client cache updates. (React 16, 17) +- Each call will always cause a new fetch. + +##### false | undefined + +- Resolves _after_ [committing](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom) Reactive Data Client cache updates. +- Identical requests are deduplicated globally; allowing only one inflight request at a time. + - To ensure a _new_ request is started, make sure to abort any existing inflight requests. + +### fetchIfStale(endpoint, ...args) {#fetchIfStale} + +Fetches only if endpoint is considered '[stale](https://dataclient.io/vue/concepts/expiry-policy#stale)'. + +This can be useful when prefetching data, as it avoids overfetching fresh data. + +An [example](https://stackblitz.com/github/reactive/data-client/tree/master/examples/github-app?file=src%2Frouting%2Froutes.tsx) with a fetch-as-you-render router: + +```ts +{ + name: 'IssueList', + component: lazyPage('IssuesPage'), + title: 'issue list', + resolveData: async ( + controller: Controller, + { owner, repo }: { owner: string; repo: string }, + searchParams: URLSearchParams, + ) => { + const q = searchParams?.get('q') || 'is:issue is:open'; + await controller.fetchIfStale(IssueResource.search, { + owner, + repo, + q, + }); + }, +}, +``` + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/routing/routes.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/routing/routes.tsx)) + +### expireAll({ testKey }) {#expireAll} + +Sets all responses' [expiry status](https://dataclient.io/vue/concepts/expiry-policy) matching `testKey` to [Stale](https://dataclient.io/vue/concepts/expiry-policy#stale). + +This is sometimes useful to trigger refresh of only data presently shown +when there are many parameterizations in cache. + +```tsx +import { type Controller, useController } from '@data-client/react'; + +const createTradeHandler = (ctrl: Controller) => async trade => { + await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade)); + ctrl.expireAll(AccountResource.get); + ctrl.expireAll(AccountResource.getList); +}; + +function CreateTrade({ id }: { id: string }) { + const handleTrade = createTradeHandler(useController()); + + return ( +
+ + + + + ); +} +``` + +> **Tip** +> +> To reduce load, improve performance, and improve state consistency; it can often be +> better to [include mutation sideeffects in the mutation response](https://dataclient.io/rest/guides/side-effects). + +### invalidate(endpoint, ...args) {#invalidate} + +Forces refetching and suspense on [useSuspense](https://dataclient.io/vue/api/useSuspense) with the same Endpoint +and parameters. + +```tsx +function ArticleName({ id }: { id: string }) { + const article = useSuspense(ArticleResource.get, { id }); + const ctrl = useController(); + + return ( +
+

{article.title}

+ +

+ ); +} +``` + +> **Tip** +> +> To refresh while continuing to display stale data - [Controller.fetch](#fetch). + +> **Tip: Invalidate many endpoints at once** +> +> Use [schema.Invalidate](https://dataclient.io/rest/api/Invalidate) to invalidate every endpoint that contains a given entity. +> +> For REST try using [Resource.delete](https://dataclient.io/rest/api/resource#delete) +> +> ```ts +> // deletes MyResource(5) +> // this will resuspend MyResource.get({id: '5'}) +> // and remove it from MyResource.getList +> controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); +> ``` + +### invalidateAll({ testKey }) {#invalidateAll} + +[Invalidates](https://dataclient.io/vue/concepts/expiry-policy#invalid) all [endpoint keys](https://dataclient.io/rest/api/RestEndpoint#key) matching `testKey`. + +```tsx +function ArticleName({ id }: { id: string }) { + const article = useSuspense(ArticleResource.get, { id }); + const ctrl = useController(); + + return ( +
+

{article.title}

+ +

+ ); +} +``` + +> **Tip** +> +> To refresh while continuing to display stale data - [Controller.expireAll](#expireAll) instead. + +Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. + +```tsx +const myDomain = 'http://test.com'; +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +function useLogout() { + const ctrl = useController(); + return () => ctrl.invalidateAll({ testKey }); +} +``` + +It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](./LogoutManager.md) +as well. + +```ts +import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; +import { unAuth } from '../authentication'; + +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +const managers = [ + new LogoutManager({ + handleLogout(controller) { + // call custom unAuth function we defined + unAuth(); + // still reset the store + controller.invalidateAll({ testKey }); + }, + }), + ...getDefaultManagers(), +]; + +createRoot(document.body).render( + + + , +); +``` + +### resetEntireStore() {#resetEntireStore} + +Resets/clears the entire Reactive Data Client cache. All inflight requests will not resolve. + +This is typically used when logging out or changing authenticated users. + +```tsx +const USER_NUMBER_ONE: string = "1111"; + +function UserName() { + const user = useSuspense(CurrentUserResource.get); + const ctrl = useController(); + + const becomeAdmin = useCallback(() => { + // Changes the current user + impersonateUser(USER_NUMBER_ONE); + ctrl.resetEntireStore(); + }, [ctrl]); + return ( +
+

{user.name}

+ +

+ ); +} +``` + +### set(queryable, ...args, value) {#set} + +Updates any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schema](https://dataclient.io/rest/api/schema#schema-overview), or many entities at once with an [Array](https://dataclient.io/rest/api/Array) or [Values](https://dataclient.io/rest/api/Values) schema. + +```ts +ctrl.set( + Todo, + // which Todo to update + { id: '5' }, + // merge this data into the Todo in the store + { id: '5', title: 'tell me friends how great Data Client is' }, +); +``` + +The value is typed by the schema: an [Entity](https://dataclient.io/rest/api/Entity) takes its fields (numbers and strings may be either), +while a [Collection](https://dataclient.io/rest/api/Collection) or [All](https://dataclient.io/rest/api/All) takes a list of rows. A [Query](https://dataclient.io/rest/api/Query) +takes the input of the schema it wraps, since `set()` normalizes that schema rather than reversing `process()`. + +```ts +ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]); +``` + +> **Note: Type checking limits** +> +> To keep type checking fast for large [Unions](https://dataclient.io/rest/api/Union), a Union row is checked against the +> combined fields of all its members rather than against one member. Each field's type is still checked, +> but a row that mixes fields from different members (like `{ type: 'first', secondField: 1 }`) is not +> an error. Make sure the fields you set belong to the member the row's discriminator selects. + +Functions can be used in the value when derived data is used. This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). + +```ts +const id = '2'; +ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); +``` + +#### set(\[Entity], rows) {#set-array} + +Pass an [Array](https://dataclient.io/rest/api/Array) schema (`[Todo]` or `new schema.Array(Todo)`) and a list of rows to update +many entities in one store update. Each row merges with its stored entity; entities not in the list are untouched. + +```ts +ctrl.set( + [Todo], + [ + { id: '5', completed: true }, + { id: '6', completed: false }, + ], +); +``` + +Rows are typed by the Entity's fields; numbers and strings may be either, and object, array and Date values are not +checked since rows are raw input. + +For lists that mix Entity types, use a [Union](https://dataclient.io/rest/api/Union); each row is stored by its `type`: + +```ts +const Feed = new schema.Union({ post: Post, comment: Comment }, 'type'); + +ctrl.set( + [Feed], + [ + { id: '1', type: 'post', title: 'Hello' }, + { id: '7', type: 'comment', body: 'Nice!' }, + ], +); +``` + +To delete many entities at once, use [Invalidate](https://dataclient.io/rest/api/Invalidate#batch-invalidation); rows only need their pk +fields: + +```ts +ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]); +``` + +[Values](https://dataclient.io/rest/api/Values) schemas take an object of rows instead: + +```ts +ctrl.set(new schema.Values(Todo), { + '5': { id: '5', completed: true }, + '6': { id: '6', completed: false }, +}); +``` + +Array and Values schemas take no `args` (so [Entity.pk()](https://dataclient.io/rest/api/Entity#pk) and [Entity.process()](https://dataclient.io/rest/api/Entity#process) +receive `[]`) and no updater function. Rows that share a pk merge in list order, without +[Entity.shouldReorder()](https://dataclient.io/rest/api/Entity#shouldreorder). Use this instead of calling `set()` once per row, such as when +[batching high-frequency stream updates](./managers.vue.md#batching). + +### setResponse(endpoint, ...args, response) {#setResponse} + +Stores `response` in cache for given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args. + +Any components suspending for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args will resolve. + +If data already exists for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args, it will be updated. + +```tsx +const ctrl = useController(); + +useEffect(() => { + const websocket = new Websocket(url); + + websocket.onmessage = event => + ctrl.setResponse( + EndpointLookup[event.endpoint], + ...event.args, + event.data, + ); + + return () => websocket.close(); +}); +``` + +This shows a proof of concept in React; however a [Manager websockets implementation](./managers.vue.md#data-stream) +would be much more robust. + +### setError(endpoint, ...args, error) {#setError} + +Stores the result of [Endpoint](https://dataclient.io/rest/api/Endpoint) and args as the error provided. + +### resolve(endpoint, { args, response, fetchedAt, error }) {#resolve} + +Resolves a specific fetch, storing the `response` in cache. + +This is similar to setResponse, except it triggers resolution of an inflight fetch. +This means the corresponding optimistic update will no longer be applies. + +This is used in [NetworkManager](https://dataclient.io/vue/api/NetworkManager), and should be used when +processing fetch requests. + +### subscribe(endpoint, ...args) {#subscribe} + +Marks a new subscription to a given [Endpoint](https://dataclient.io/rest/api/Endpoint). This should increment the subscription. + +[useSubscription](https://dataclient.io/vue/api/useSubscription) and [useLive](https://dataclient.io/vue/api/useLive) call this on mount. + +This might be useful for custom hooks to sub/unsub based on other factors. + +```tsx +const controller = useController(); +const key = endpoint.key(...args); + +useEffect(() => { + controller.subscribe(endpoint, ...args); + return () => controller.unsubscribe(endpoint, ...args); +}, [controller, key]); +``` + +### unsubscribe(endpoint, ...args) {#unsubscribe} + +Marks completion of subscription to a given [Endpoint](https://dataclient.io/rest/api/Endpoint). This should +decrement the subscription and if the count reaches 0, more updates won't be received automatically. + +[useSubscription](https://dataclient.io/vue/api/useSubscription) and [useLive](https://dataclient.io/vue/api/useLive) call this on unmount. + +## Data Access + +### get(schema, ...args, state) {#get} + +Looks up any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schema](https://dataclient.io/rest/api/schema#schema-overview) in `state`. + +#### Example + +This is used in [useQuery](https://dataclient.io/vue/api/useQuery) and can be used in +[Managers](./Manager.vue.md) to safely access the store. + +```tsx title="useQuery.ts" +import { + useController, + useCacheState, + type Queryable, + type SchemaArgs, + type DenormalizeNullable, +} from '@data-client/core'; + +/** Oversimplified useQuery */ +function useQuery( + schema: S, + ...args: SchemaArgs +): DenormalizeNullable | undefined { + const state = useCacheState(); + const controller = useController(); + + return controller.get(schema, ...args, state); +} +``` + +### getResponse(endpoint, ...args, state) {#getResponse} + +```ts title="returns" +{ + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; +} +``` + +Gets the (globally referentially stable) response for a given endpoint/args pair from state given. + +#### data + +The denormalize response data. Guarantees global referential stability for all members. + +#### [expiryStatus](https://dataclient.io/vue/concepts/expiry-policy#expiry-status) + +```ts +export enum ExpiryStatus { + Invalid = 1, + InvalidIfStale, + Valid, +} +``` + +##### Valid + +- Will never suspend. +- Might fetch if data is stale + +##### InvalidIfStale + +- Will suspend if data is stale. +- Might fetch if data is stale + +##### Invalid + +- Will always suspend +- Will always fetch + +#### expiresAt + +A number representing time when it expires. Compare to Date.now(). + +#### Example + +This is used in [useCache](https://dataclient.io/vue/api/useCache), [useSuspense](https://dataclient.io/vue/api/useSuspense) and can be used in +[Managers](./Manager.vue.md) to lookup a response with the state provided. + +```tsx title="useCache.ts" +import { + useController, + StateContext, + EndpointInterface, +} from '@data-client/core'; + +/** Oversimplified useCache */ +function useCache( + endpoint: E, + ...args: readonly [...Parameters] +) { + const state = useContext(StateContext); + const controller = useController(); + return controller.getResponse(endpoint, ...args, state).data; +} +``` + +```tsx title="MyManager.ts" +import type { Manager, Middleware, actionTypes } from '@data-client/core'; +import type { EndpointInterface } from '@data-client/endpoint'; + +export default class MyManager implements Manager { + middleware: Middleware = controller => { + return next => async action => { + if (action.type === actionTypes.FETCH) { + console.log('The existing response of the requested fetch'); + console.log( + controller.getResponse( + action.endpoint, + ...(action.meta.args as Parameters), + controller.getState(), + ).data, + ); + } + next(action); + }; + }; + + cleanup() { + this.websocket.close(); + } +} +``` + +### getError(endpoint, ...args, state) {#getError} + +Gets the error, if any, for a given endpoint. Returns undefined for no errors. + +### snapshot(state, fetchedAt) {#snapshot} + +Returns a [Snapshot](https://dataclient.io/vue/api/Snapshot). + +### getState() {#getState} + +Gets the internal state of Reactive Data Client that has _already been [committed](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom)_. + +> **Warning** +> +> This should only be used in event handlers or [Managers](./Manager.vue.md). +> +> Using getState() in React's render lifecycle can result in data tearing. + +```tsx +const controller = useController(); + +const updateHandler = useCallback( + async updatePayload => { + const response = await controller.fetch( + MyResource.update, + { id }, + updatePayload, + ); + // the fetch has completed, but react has not yet re-rendered + // this lets use sequence after the next re-render + // we're working on a better solution to this specific case + setTimeout(() => { + const { data: denormalized } = controller.getResponse( + MyResource.update, + { id }, + updatePayload, + controller.getState(), + ); + redirect(denormalized.getterUrl); + }, 40); + }, + [id], +); +``` diff --git a/.agents/skills/data-client-manager/references/LogoutManager.md b/.agents/skills/data-client-manager/references/LogoutManager.md deleted file mode 120000 index ec785cabcad5..000000000000 --- a/.agents/skills/data-client-manager/references/LogoutManager.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/LogoutManager.md \ No newline at end of file diff --git a/.agents/skills/data-client-manager/references/LogoutManager.md b/.agents/skills/data-client-manager/references/LogoutManager.md new file mode 100644 index 000000000000..06428975d00b --- /dev/null +++ b/.agents/skills/data-client-manager/references/LogoutManager.md @@ -0,0 +1,196 @@ + + +# LogoutManager + +Logs out based on fetch responses. By default this is triggered by [401 (Unauthorized)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) status responses. + +> **Info: implements** +> +> `LogoutManager` implements [Manager](./Manager.md) + +## Usage + +**Web** + +```tsx title="/index.tsx" +import { + DataProvider, + LogoutManager, + getDefaultManagers, +} from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +const managers = [new LogoutManager(), ...getDefaultManagers()]; + +createRoot(document.body).render( + + + , +); +``` + +**React Native** + +```tsx title="/index.tsx" +import { + DataProvider, + LogoutManager, + getDefaultManagers, +} from '@data-client/react'; +import { AppRegistry } from 'react-native'; + +const managers = [new LogoutManager(), ...getDefaultManagers()]; + +const Root = () => ( + + + +); +AppRegistry.registerComponent('MyApp', () => Root); +``` + +**NextJS** + +```tsx title="app/Provider.tsx" +'use client'; +import { LogoutManager, getDefaultManagers } from '@data-client/react'; +import { DataProvider } from '@data-client/react/nextjs'; + +const managers = [new LogoutManager(), ...getDefaultManagers()]; + +export default function Provider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} +``` + +```tsx title="app/_layout.tsx" +import Provider from './Provider'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +**Expo** + +```tsx title="app/Provider.tsx" +import { + LogoutManager, + getDefaultManagers, + DataProvider, +} from '@data-client/react'; +import { + DarkTheme, + DefaultTheme, + ThemeProvider, +} from '@react-navigation/native'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +const managers = [new LogoutManager(), ...getDefaultManagers()]; + +export default function Provider({ + children, +}: { + children: React.ReactNode; +}) { + const colorScheme = useColorScheme(); + + return ( + + {children} + + ); +} +``` + +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import 'react-native-reanimated'; + +import Provider from './Provider'; + +export default function RootLayout() { + return ( + + + + + + + ); +} +``` + +### Custom logout handler + +```ts +import { unAuth } from '../authentication'; + +const managers = [ + new LogoutManager({ + handleLogout(controller) { + // call custom unAuth function we defined + unAuth(); + // still reset the store + controller.resetEntireStore(); + }, + }), + ...getDefaultManagers(), +]; +``` + +> **Tip** +> +> Use [controller.invalidateAll](./Controller.md#invalidateAll) to only clear part of the cache. +> +> ```ts +> import { unAuth } from '../authentication'; +> +> const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); +> +> const managers = [ +> new LogoutManager({ +> handleLogout(controller) { +> // call custom unAuth function we defined +> unAuth(); +> // still reset the store +> controller.invalidateAll({ testKey }); +> }, +> }), +> ...getDefaultManagers(), +> ]; +> ``` + +## Members + +### handleLogout(controller) + +By default simply calls [controller.resetEntireStore()](./Controller.md#resetEntireStore) + +This should be sufficient if login state is determined by a user entity existance in the Reactive Data Client store. However, +you can override this method via inheritance if more should be done. + +### shouldLogout(error) + +```ts +protected shouldLogout(error: UnknownError) { + // 401 indicates reauthorization is needed + return error.status === 401; +} +``` + +## Github Example + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/RootProvider.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/RootProvider.tsx)) diff --git a/.agents/skills/data-client-manager/references/Manager.md b/.agents/skills/data-client-manager/references/Manager.md deleted file mode 120000 index 19346728e9a1..000000000000 --- a/.agents/skills/data-client-manager/references/Manager.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Manager.md \ No newline at end of file diff --git a/.agents/skills/data-client-manager/references/Manager.md b/.agents/skills/data-client-manager/references/Manager.md new file mode 100644 index 000000000000..96746db6c055 --- /dev/null +++ b/.agents/skills/data-client-manager/references/Manager.md @@ -0,0 +1,318 @@ + + +# Manager + +`Managers` are singletons that handle global side-effects. Kind of like [useEffect()](https://react.dev/reference/react/useEffect) for the central data +store. + +The default managers orchestrate the complex asynchronous behavior that Data Client +provides out of the box. These can easily be configured with [getDefaultManagers()](./getDefaultManagers.md), and +extended with your own custom `Managers`. + +Managers must implement [middleware](#middleware), which hooks them into the central store's +[control flow](#control-flow). Additionally, [cleanup()](#cleanup) and [init()](#init) hook into the +store's lifecycle for setup/teardown behaviors. + +```typescript +type Dispatch = (action: ActionTypes) => Promise; + +type Middleware = (controller: Controller) => (next: Dispatch) => Dispatch; + +interface Manager { + middleware: Middleware; + cleanup(): void; + init?: (state: State) => void; +} +``` + +## Lifecycle + +### middleware + +`middleware` is very similar to a [redux middleware](https://redux.js.org/advanced/middleware). +The only differences is that the `next()` function returns a `Promise`. This promise resolves when the reducer update is +[committed](https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/#general-algorithm) +when using \. This is necessary since the commit phase is asynchronously scheduled. This enables building +managers that perform work after the DOM is updated and also with the newly computed state. + +Since redux is fully synchronous, an adapter must be placed in front of Reactive Data Client style middleware to +ensure they can consume a promise. Conversely, redux middleware must be changed to pass through promises. + +Middlewares will [intercept actions](#reading-and-consuming-actions) that are dispatched and then potentially [dispatch their own actions](#dispatching-actions) as well. +To read more about middlewares, see the [redux documentation](https://redux.js.org/advanced/middleware). + +### init(state) {#init} + +Called with initial state after provider is mounted. Can be useful to run setup at start that +relies on state actually existing. + +### cleanup() + +Provides any cleanup of dangling resources after manager is no longer in use. + +## Adding managers to Reactive Data Client {#adding} + +Use the [managers](https://dataclient.io/docs/api/DataProvider#managers) prop of [DataProvider](https://dataclient.io/docs/api/DataProvider). Be +sure to hoist to _module level_ or wrap in a _useMemo()_ to ensure they are not recreated. Managers +have internal state, so it is important to not constantly recreate them. + +**Web** + +```tsx title="/index.tsx" +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +createRoot(document.body).render( + + + , +); +``` + +**React Native** + +```tsx title="/index.tsx" +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { AppRegistry } from 'react-native'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +const Root = () => ( + + + +); +AppRegistry.registerComponent('MyApp', () => Root); +``` + +**NextJS** + +```tsx title="app/Provider.tsx" +'use client'; +import { getDefaultManagers } from '@data-client/react'; +import { DataProvider } from '@data-client/react/nextjs'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +export default function Provider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} +``` + +```tsx title="app/_layout.tsx" +import Provider from './Provider'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +**Expo** + +```tsx title="app/Provider.tsx" +import { getDefaultManagers, DataProvider } from '@data-client/react'; +import { + DarkTheme, + DefaultTheme, + ThemeProvider, +} from '@react-navigation/native'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +export default function Provider({ + children, +}: { + children: React.ReactNode; +}) { + const colorScheme = useColorScheme(); + + return ( + + {children} + + ); +} +``` + +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import 'react-native-reanimated'; + +import Provider from './Provider'; + +export default function RootLayout() { + return ( + + + + + + + ); +} +``` + +## Control flow + +Managers integrate with the DataProvider store with their lifecycles and middleware. They orchestrate complex control +flows by interfacing via intercepting and dispatching [actions](./Actions.md), as well as reading the internal state. + +The job of `middleware` is to dispatch actions, respond to [actions](./Actions.md), or both. + +### Dispatching Actions + +[Controller](./Controller.md) provides type-safe action dispatchers. + +```ts title="CurrentTime" +import { Entity } from '@data-client/endpoint'; + +export default class CurrentTime extends Entity { + id = 0; + time = 0; +} +``` + +```ts title="TimeManager" +import type { Manager, Middleware } from '@data-client/core'; +import CurrentTime from './CurrentTime'; + +export default class TimeManager implements Manager { + protected declare intervalID?: ReturnType; + + middleware: Middleware = controller => { + this.intervalID = setInterval(() => { + controller.set(CurrentTime, { id: 1 }, { id: 1, time: Date.now() }); + }, 1000); + + return next => async action => next(action); + }; + + cleanup() { + clearInterval(this.intervalID); + } +} +``` + +### Reading and Consuming Actions + +`actionTypes` includes all constants to distinguish between different [actions](./Actions.md). + +```ts +import type { Manager, Middleware } from '@data-client/react'; +import { actionTypes } from '@data-client/react'; + +export default class LoggingManager implements Manager { + middleware: Middleware = controller => next => async action => { + switch (action.type) { + case actionTypes.SET_RESPONSE: + if (action.endpoint.sideEffect) { + console.info( + `${action.endpoint.name} ${JSON.stringify(action.response)}`, + ); + // wait for state update to be committed to React + await next(action); + // get the data from the store, which may be merged with existing state + const { data } = controller.getResponse( + action.endpoint, + ...action.args, + controller.getState(), + ); + console.info(`${action.endpoint.name} ${JSON.stringify(data)}`); + return; + } + // actions must be explicitly passed to next middleware + default: + return next(action); + } + }; + + cleanup() {} +} +``` + +In conditional blocks, the action [type narrows](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#working-with-union-types), +encouraging safe access to its members. + +In case we want to 'handle' a certain [action](./Actions.md), we can 'consume' it by not calling next. + +```ts title="isEntity" +import type { Schema, EntityInterface } from '@data-client/core'; + +export default function isEntity(schema: Schema): schema is EntityInterface { + return schema !== null && (schema as any).pk !== undefined; +} +``` + +```ts title="SubsManager" +import type { Manager, Middleware, EntityInterface } from '@data-client/react'; +import { actionTypes } from '@data-client/react'; +import isEntity from './isEntity'; + +export default class CustomSubsManager implements Manager { + protected declare entities: Record; + + middleware: Middleware = controller => next => async action => { + switch (action.type) { + case actionTypes.SUBSCRIBE: + case actionTypes.UNSUBSCRIBE: + const { schema } = action.endpoint; + // only process registered entities + if (schema && isEntity(schema) && schema.key in this.entities) { + if (action.type === actionTypes.SUBSCRIBE) { + this.subscribe(schema.key, action.args[0]?.product_id); + } else { + this.unsubscribe(schema.key, action.args[0]?.product_id); + } + + // consume subscription if we use it + return Promise.resolve(); + } + default: + return next(action); + } + }; + + cleanup() {} + + subscribe(channel: string, product_id: string) {} + unsubscribe(channel: string, product_id: string) {} +} +``` + +By `return Promise.resolve();` instead of calling `next(action)`, we prevent managers listed +after this one from seeing that [action](./Actions.md). + +Types: [`FETCH`](./Actions.md#fetch), [`SET`](./Actions.md#set), [`SET_RESPONSE`](./Actions.md#set_response), +[`RESET`](./Actions.md#reset), [`SUBSCRIBE`](./Actions.md#subscribe), [`UNSUBSCRIBE`](./Actions.md#unsubscribe), +[`INVALIDATE`](./Actions.md#invalidate), [`INVALIDATEALL`](./Actions.md#invalidateall), [`EXPIREALL`](./Actions.md#expireall) + +## Use cases + +Minimal examples for common Manager use cases: + +- [Logging](./managers.md#middleware-logging) +- [Error reporting (monitoring)](./managers.md#error-reporting) +- [Metrics (fetch timing)](./managers.md#metrics) +- [Notifications (toasts)](./managers.md#notifications) +- [Refresh on focus or reconnect](./managers.md#refresh-on-focus) +- [Cross-tab synchronization](./managers.md#cross-tab-sync) +- [Offline persistence](./managers.md#persistence) +- [Data streams (websockets/SSE)](./managers.md#data-stream) +- [Authentication: logout on 401](./LogoutManager.md) +- [Periodic updates (interval/ticker)](#dispatching-actions) +- [Custom transport subscriptions](#reading-and-consuming-actions) diff --git a/.agents/skills/data-client-manager/references/Manager.vue.md b/.agents/skills/data-client-manager/references/Manager.vue.md new file mode 100644 index 000000000000..88ec2bad20b3 --- /dev/null +++ b/.agents/skills/data-client-manager/references/Manager.vue.md @@ -0,0 +1,317 @@ + + +# Manager + +`Managers` are singletons that handle global side-effects. Kind of like [useEffect()](https://react.dev/reference/react/useEffect) for the central data +store. + +The default managers orchestrate the complex asynchronous behavior that Data Client +provides out of the box. These can easily be configured with [getDefaultManagers()](./getDefaultManagers.vue.md), and +extended with your own custom `Managers`. + +Managers must implement [middleware](#middleware), which hooks them into the central store's +[control flow](#control-flow). Additionally, [cleanup()](#cleanup) and [init()](#init) hook into the +store's lifecycle for setup/teardown behaviors. + +```typescript +type Dispatch = (action: ActionTypes) => Promise; + +type Middleware = (controller: Controller) => (next: Dispatch) => Dispatch; + +interface Manager { + middleware: Middleware; + cleanup(): void; + init?: (state: State) => void; +} +``` + +## Lifecycle + +### middleware + +`middleware` is very similar to a [redux middleware](https://redux.js.org/advanced/middleware). +The only differences is that the `next()` function returns a `Promise`. This promise resolves when the reducer update is +[committed](https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/#general-algorithm) +when using \. This is necessary since the commit phase is asynchronously scheduled. This enables building +managers that perform work after the DOM is updated and also with the newly computed state. + +Since redux is fully synchronous, an adapter must be placed in front of Reactive Data Client style middleware to +ensure they can consume a promise. Conversely, redux middleware must be changed to pass through promises. + +Middlewares will [intercept actions](#reading-and-consuming-actions) that are dispatched and then potentially [dispatch their own actions](#dispatching-actions) as well. +To read more about middlewares, see the [redux documentation](https://redux.js.org/advanced/middleware). + +### init(state) {#init} + +Called with initial state after provider is mounted. Can be useful to run setup at start that +relies on state actually existing. + +### cleanup() + +Provides any cleanup of dangling resources after manager is no longer in use. + +## Adding managers to Reactive Data Client {#adding} + +Use the `managers` option of [DataClientPlugin](https://dataclient.io/vue/getting-started/installation). The plugin is +installed once per app, so managers are created once. + +**Web** + +```tsx title="/index.tsx" +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +createRoot(document.body).render( + + + , +); +``` + +**React Native** + +```tsx title="/index.tsx" +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { AppRegistry } from 'react-native'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +const Root = () => ( + + + +); +AppRegistry.registerComponent('MyApp', () => Root); +``` + +**NextJS** + +```tsx title="app/Provider.tsx" +'use client'; +import { getDefaultManagers } from '@data-client/react'; +import { DataProvider } from '@data-client/react/nextjs'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +export default function Provider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} +``` + +```tsx title="app/_layout.tsx" +import Provider from './Provider'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +**Expo** + +```tsx title="app/Provider.tsx" +import { getDefaultManagers, DataProvider } from '@data-client/react'; +import { + DarkTheme, + DefaultTheme, + ThemeProvider, +} from '@react-navigation/native'; +import { useColorScheme } from '@/hooks/useColorScheme'; + +const managers = [...getDefaultManagers(), new MyManager()]; + +export default function Provider({ + children, +}: { + children: React.ReactNode; +}) { + const colorScheme = useColorScheme(); + + return ( + + {children} + + ); +} +``` + +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import 'react-native-reanimated'; + +import Provider from './Provider'; + +export default function RootLayout() { + return ( + + + + + + + ); +} +``` + +## Control flow + +Managers integrate with the DataProvider store with their lifecycles and middleware. They orchestrate complex control +flows by interfacing via intercepting and dispatching [actions](./Actions.vue.md), as well as reading the internal state. + +The job of `middleware` is to dispatch actions, respond to [actions](./Actions.vue.md), or both. + +### Dispatching Actions + +[Controller](./Controller.vue.md) provides type-safe action dispatchers. + +```ts title="CurrentTime" +import { Entity } from '@data-client/endpoint'; + +export default class CurrentTime extends Entity { + id = 0; + time = 0; +} +``` + +```ts title="TimeManager" +import type { Manager, Middleware } from '@data-client/core'; +import CurrentTime from './CurrentTime'; + +export default class TimeManager implements Manager { + protected declare intervalID?: ReturnType; + + middleware: Middleware = controller => { + this.intervalID = setInterval(() => { + controller.set(CurrentTime, { id: 1 }, { id: 1, time: Date.now() }); + }, 1000); + + return next => async action => next(action); + }; + + cleanup() { + clearInterval(this.intervalID); + } +} +``` + +### Reading and Consuming Actions + +`actionTypes` includes all constants to distinguish between different [actions](./Actions.vue.md). + +```ts +import type { Manager, Middleware } from '@data-client/react'; +import { actionTypes } from '@data-client/react'; + +export default class LoggingManager implements Manager { + middleware: Middleware = controller => next => async action => { + switch (action.type) { + case actionTypes.SET_RESPONSE: + if (action.endpoint.sideEffect) { + console.info( + `${action.endpoint.name} ${JSON.stringify(action.response)}`, + ); + // wait for state update to be committed to React + await next(action); + // get the data from the store, which may be merged with existing state + const { data } = controller.getResponse( + action.endpoint, + ...action.args, + controller.getState(), + ); + console.info(`${action.endpoint.name} ${JSON.stringify(data)}`); + return; + } + // actions must be explicitly passed to next middleware + default: + return next(action); + } + }; + + cleanup() {} +} +``` + +In conditional blocks, the action [type narrows](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#working-with-union-types), +encouraging safe access to its members. + +In case we want to 'handle' a certain [action](./Actions.vue.md), we can 'consume' it by not calling next. + +```ts title="isEntity" +import type { Schema, EntityInterface } from '@data-client/core'; + +export default function isEntity(schema: Schema): schema is EntityInterface { + return schema !== null && (schema as any).pk !== undefined; +} +``` + +```ts title="SubsManager" +import type { Manager, Middleware, EntityInterface } from '@data-client/react'; +import { actionTypes } from '@data-client/react'; +import isEntity from './isEntity'; + +export default class CustomSubsManager implements Manager { + protected declare entities: Record; + + middleware: Middleware = controller => next => async action => { + switch (action.type) { + case actionTypes.SUBSCRIBE: + case actionTypes.UNSUBSCRIBE: + const { schema } = action.endpoint; + // only process registered entities + if (schema && isEntity(schema) && schema.key in this.entities) { + if (action.type === actionTypes.SUBSCRIBE) { + this.subscribe(schema.key, action.args[0]?.product_id); + } else { + this.unsubscribe(schema.key, action.args[0]?.product_id); + } + + // consume subscription if we use it + return Promise.resolve(); + } + default: + return next(action); + } + }; + + cleanup() {} + + subscribe(channel: string, product_id: string) {} + unsubscribe(channel: string, product_id: string) {} +} +``` + +By `return Promise.resolve();` instead of calling `next(action)`, we prevent managers listed +after this one from seeing that [action](./Actions.vue.md). + +Types: [`FETCH`](./Actions.vue.md#fetch), [`SET`](./Actions.vue.md#set), [`SET_RESPONSE`](./Actions.vue.md#set_response), +[`RESET`](./Actions.vue.md#reset), [`SUBSCRIBE`](./Actions.vue.md#subscribe), [`UNSUBSCRIBE`](./Actions.vue.md#unsubscribe), +[`INVALIDATE`](./Actions.vue.md#invalidate), [`INVALIDATEALL`](./Actions.vue.md#invalidateall), [`EXPIREALL`](./Actions.vue.md#expireall) + +## Use cases + +Minimal examples for common Manager use cases: + +- [Logging](./managers.vue.md#middleware-logging) +- [Error reporting (monitoring)](./managers.vue.md#error-reporting) +- [Metrics (fetch timing)](./managers.vue.md#metrics) +- [Notifications (toasts)](./managers.vue.md#notifications) +- [Refresh on focus or reconnect](./managers.vue.md#refresh-on-focus) +- [Cross-tab synchronization](./managers.vue.md#cross-tab-sync) +- [Offline persistence](./managers.vue.md#persistence) +- [Data streams (websockets/SSE)](./managers.vue.md#data-stream) +- [Authentication: logout on 401](./LogoutManager.md) +- [Periodic updates (interval/ticker)](#dispatching-actions) +- [Custom transport subscriptions](#reading-and-consuming-actions) diff --git a/.agents/skills/data-client-manager/references/getDefaultManagers.md b/.agents/skills/data-client-manager/references/getDefaultManagers.md deleted file mode 120000 index 8cb0cf50caab..000000000000 --- a/.agents/skills/data-client-manager/references/getDefaultManagers.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/getDefaultManagers.md \ No newline at end of file diff --git a/.agents/skills/data-client-manager/references/getDefaultManagers.md b/.agents/skills/data-client-manager/references/getDefaultManagers.md new file mode 100644 index 000000000000..f4ff0422869f --- /dev/null +++ b/.agents/skills/data-client-manager/references/getDefaultManagers.md @@ -0,0 +1,114 @@ + + +# getDefaultManagers() + +`getDefaultManagers` returns an Array of [Managers](./Manager.md) to be sent to [\](https://dataclient.io/docs/api/DataProvider). + +This makes it simple to configure and add custom [Managers](./Manager.md), while remaining robust against +any potential changes to the default managers. + +Currently returns \[[DevToolsManager](https://dataclient.io/docs/api/DevToolsManager)\*, [NetworkManager](https://dataclient.io/docs/api/NetworkManager), [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager)]. + +\*(`DevToolsManager` is excluded in production builds.) + +## Usage + +```tsx +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +const managers = getDefaultManagers({ + // set fallback expiry time to an hour + networkManager: { dataExpiryLength: 1000 * 60 * 60 }, +}); + +createRoot(document.body).render( + + + , +); +``` + +See [DataProvider](https://dataclient.io/docs/api/DataProvider) for details on usage in different environments. + +## Arguments + +Each argument represents a configuration of the manager. It can be of three possible types: + +- Any plain object is used as options to be sent to the manager's constructor. +- An instance of the manager to be used directly. +- `null`. When sent will exclude the manager. + +```ts +getDefaultManagers({ + devToolsManager: { trace: true }, + networkManager: new NetworkManager({ errorExpiryLength: 1 }), + subscriptionManager: null, +}); +``` + +### networkManager + +> **Note** +> +> `null` is not allowed here since NetworkManager is required + +`dataExpiryLength` is used as a fallback when an Endpoint does not have [dataExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointdataexpirylength) defined. + +`errorExpiryLength` is used as a fallback when an Endpoint does not have [errorExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointerrorexpirylength) defined. + +### devToolsManager + +[Arguments](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md) +to send to redux devtools. + +### subscriptionManager + +A class that implements `SubscriptionConstructable` like [PollingSubscription](https://dataclient.io/docs/api/PollingSubscription) + +## Examples + +### Tracing actions + +For example, we can enable the [trace](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace) option to help track down where actions are dispatched from. This has a large performance impact, so it is normally disabled. + +```ts +const managers = getDefaultManagers({ + devToolsManager: { trace: true }, +}); +``` + +### Manager inheritance + +Sending manager instances allows us to customize managers using inheritance. + +```ts +import { IdlingNetworkManager } from '@data-client/react'; + +const managers = getDefaultManagers({ + networkManager: new IdlingNetworkManager(), +}); +``` + +`IdlingNetworkManager` can prevent stuttering by delaying [sideEffect](https://dataclient.io/rest/api/Endpoint#sideeffect)-free (read-only/GET) fetches +until animations are complete. This works in web using [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback), and react native using InteractionManager.runAfterInteractions. + +### Disabling + +Using `null` will remove managers completely. [NetworkManager](https://dataclient.io/docs/api/NetworkManager) cannot be removed this way. + +```ts +const managers = getDefaultManagers({ + devToolsManager: null, + subscriptionManager: null, +}); +``` + +Here we disable every manager except [NetworkManager](https://dataclient.io/docs/api/NetworkManager). + +### Coin App + +New prices are streamed in many times a second; to reduce devtool spam, we set it +to ignore [SET](./Controller.md#set) actions for `Ticker`. + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/index.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/index.tsx), [`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts), [`src/getManagers.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/getManagers.ts)) diff --git a/.agents/skills/data-client-manager/references/getDefaultManagers.vue.md b/.agents/skills/data-client-manager/references/getDefaultManagers.vue.md new file mode 100644 index 000000000000..a51bbe1226ad --- /dev/null +++ b/.agents/skills/data-client-manager/references/getDefaultManagers.vue.md @@ -0,0 +1,112 @@ + + +# getDefaultManagers() + +`getDefaultManagers` returns an Array of [Managers](./Manager.vue.md) to be sent to [DataClientPlugin](https://dataclient.io/vue/getting-started/installation#add-provider-at-top-level-component). + +This makes it simple to configure and add custom [Managers](./Manager.vue.md), while remaining robust against +any potential changes to the default managers. + +Currently returns \[[DevToolsManager](https://dataclient.io/vue/api/DevToolsManager)\*, [NetworkManager](https://dataclient.io/vue/api/NetworkManager), [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager)]. + +\*(`DevToolsManager` is excluded in production builds.) + +## Usage + +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import App from './App.vue'; + +const managers = getDefaultManagers({ + // set fallback expiry time to an hour + networkManager: { dataExpiryLength: 1000 * 60 * 60 }, +}); + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +When `managers` is omitted, `DataClientPlugin` uses `getDefaultManagers()` with no arguments. +See [installation](https://dataclient.io/vue/getting-started/installation#add-provider-at-top-level-component) for the +other `DataClientPlugin` options. + +## Arguments + +Each argument represents a configuration of the manager. It can be of three possible types: + +- Any plain object is used as options to be sent to the manager's constructor. +- An instance of the manager to be used directly. +- `null`. When sent will exclude the manager. + +```ts +getDefaultManagers({ + devToolsManager: { trace: true }, + networkManager: new NetworkManager({ errorExpiryLength: 1 }), + subscriptionManager: null, +}); +``` + +### networkManager + +> **Note** +> +> `null` is not allowed here since NetworkManager is required + +`dataExpiryLength` is used as a fallback when an Endpoint does not have [dataExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointdataexpirylength) defined. + +`errorExpiryLength` is used as a fallback when an Endpoint does not have [errorExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointerrorexpirylength) defined. + +### devToolsManager + +[Arguments](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md) +to send to redux devtools. + +### subscriptionManager + +A class that implements `SubscriptionConstructable` like [PollingSubscription](https://dataclient.io/vue/api/PollingSubscription) + +## Examples + +### Tracing actions + +For example, we can enable the [trace](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace) option to help track down where actions are dispatched from. This has a large performance impact, so it is normally disabled. + +```ts +const managers = getDefaultManagers({ + devToolsManager: { trace: true }, +}); +``` + +### Manager inheritance + +Sending manager instances allows us to customize managers using inheritance. + +```ts +import { NetworkManager, type FetchAction } from '@data-client/vue'; + +class LoggingNetworkManager extends NetworkManager { + protected handleFetch(action: FetchAction) { + console.log('fetching', action.key); + return super.handleFetch(action); + } +} + +const managers = getDefaultManagers({ + networkManager: new LoggingNetworkManager(), +}); +``` + +### Disabling + +Using `null` will remove managers completely. [NetworkManager](https://dataclient.io/vue/api/NetworkManager) cannot be removed this way. + +```ts +const managers = getDefaultManagers({ + devToolsManager: null, + subscriptionManager: null, +}); +``` + +Here we disable every manager except [NetworkManager](https://dataclient.io/vue/api/NetworkManager). diff --git a/.agents/skills/data-client-manager/references/managers.md b/.agents/skills/data-client-manager/references/managers.md deleted file mode 120000 index f3b40bc752e6..000000000000 --- a/.agents/skills/data-client-manager/references/managers.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/concepts/managers.md \ No newline at end of file diff --git a/.agents/skills/data-client-manager/references/managers.md b/.agents/skills/data-client-manager/references/managers.md new file mode 100644 index 000000000000..5c6ba6986693 --- /dev/null +++ b/.agents/skills/data-client-manager/references/managers.md @@ -0,0 +1,380 @@ + + +# Managers and Middleware + +Reactive Data Client uses the [flux store](https://facebookarchive.github.io/flux/docs/in-depth-overview/) pattern, which is +characterized by an easy to [understand and debug](https://dataclient.io/docs/getting-started/debugging) the store's [undirectional data flow](https://en.wikipedia.org/wiki/Unidirectional_Data_Flow_\(computer_science\)). State updates are performed by a [reducer function](https://github.com/reactive/data-client/blob/master/packages/core/src/state/reducer/createReducer.ts#L19). + +In flux architectures, it is critical all functions in the flux loop are [pure](https://react.dev/learn/keeping-components-pure). +Managers provide centralized orchestration of side effects. In other words, they are the means to interface +with the world outside Data Client. + +For instance, [NetworkManager](https://dataclient.io/docs/api/NetworkManager) orchestrates data fetching and [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) +keeps track of which resources are subscribed with [useLive](https://dataclient.io/docs/api/useLive) or [useSubscription](https://dataclient.io/docs/api/useSubscription). By centralizing control, [NetworkManager](https://dataclient.io/docs/api/NetworkManager) automatically deduplicates fetches, and [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) +will keep only actively rendered resources updated. + +This makes [Managers](./Manager.md) the best way to integrate additional side-effects like +[logging](#middleware-logging), [error reporting](#error-reporting), [metrics](#metrics), +[notifications](#notifications), [data streams](#data-stream), [refreshing on focus or reconnect](#refresh-on-focus), +[cross-tab synchronization](#cross-tab-sync), and [offline persistence](#persistence). +They can also be customized to change core behaviors. + +| Default managers | | +| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| [NetworkManager](https://dataclient.io/docs/api/NetworkManager) | Turns fetch dispatches into network calls | +| [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) | Handles polling [subscriptions](https://dataclient.io/docs/getting-started/data-dependency#subscriptions) | +| [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager) | Enables [debugging](https://dataclient.io/docs/getting-started/debugging) | +| Extra managers | | +| [LogoutManager](./LogoutManager.md) | Handles HTTP `401` (or other logout conditions) | + +## Examples + +Reactive Data Client improves type-safety and ergonomics by performing dispatches and store access with +its [Controller](./Controller.md) + +### Middleware logging + +```typescript +import type { Manager, Middleware } from '@data-client/core'; + +export default class LoggingManager implements Manager { + middleware: Middleware = controller => next => async action => { + console.log('before', action, controller.getState()); + await next(action); + console.log('after', action, controller.getState()); + }; + + cleanup() {} +} +``` + +### Error reporting {#error-reporting} + +Report failed fetches to monitoring services like [Sentry](https://sentry.io) by inspecting +[SET\_RESPONSE](./Actions.md#set_response) actions with `error` set. + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; +import { captureException } from '@sentry/react'; + +export default class ErrorReportManager implements Manager { + middleware: Middleware = controller => next => async action => { + if (action.type === actionTypes.SET_RESPONSE && action.error) + captureException(action.response, { + extra: { endpoint: action.endpoint.name, args: action.args }, + }); + return next(action); + }; + + cleanup() {} +} +``` + +### Metrics {#metrics} + +Track fetch timing by observing [FETCH](./Actions.md#fetch) actions. `action.meta.promise` +resolves when the fetch completes. + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; +import { trackTiming } from './analytics'; + +export default class MetricsManager implements Manager { + middleware: Middleware = controller => next => async action => { + if (action.type === actionTypes.FETCH) { + const start = performance.now(); + action.meta.promise.finally(() => { + trackTiming(action.endpoint.name, performance.now() - start); + }); + } + return next(action); + }; + + cleanup() {} +} +``` + +### Notifications (toasts) {#notifications} + +Show a toast when any [mutation](https://dataclient.io/rest/guides/side-effects) succeeds or fails. + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; +import { toast } from './toast'; + +export default class ToastManager implements Manager { + middleware: Middleware = controller => next => async action => { + if ( + action.type === actionTypes.SET_RESPONSE && + action.endpoint.sideEffect + ) { + if (action.error) toast.error(`${action.endpoint.name} failed`); + else toast.success(`${action.endpoint.name} succeeded`); + } + return next(action); + }; + + cleanup() {} +} +``` + +### Refresh on focus or reconnect {#refresh-on-focus} + +[Controller.expireAll()](./Controller.md#expireAll) marks data as [Stale](https://dataclient.io/docs/concepts/expiry-policy#stale), +triggering refetch of any _actively rendered_ data without suspending ([stale-while-revalidate](https://dataclient.io/docs/concepts/expiry-policy)). +[init()](./Manager.md#init) and [cleanup()](./Manager.md#cleanup) manage the event listeners. + +```typescript +import type { Manager, Middleware, Controller } from '@data-client/react'; + +export default class RefreshManager implements Manager { + declare protected controller: Controller; + protected handle = () => + this.controller.expireAll({ testKey: () => true }); + + middleware: Middleware = controller => { + this.controller = controller; + return next => async action => next(action); + }; + + init() { + window.addEventListener('focus', this.handle); + window.addEventListener('online', this.handle); + } + + cleanup() { + window.removeEventListener('focus', this.handle); + window.removeEventListener('online', this.handle); + } +} +``` + +### Cross-tab synchronization {#cross-tab-sync} + +When a mutation succeeds in one tab, mark data stale in all other tabs using +[BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; + +export default class TabSyncManager implements Manager { + protected channel = new BroadcastChannel('data-client'); + + middleware: Middleware = controller => { + this.channel.onmessage = () => + controller.expireAll({ testKey: () => true }); + return next => async action => { + if ( + action.type === actionTypes.SET_RESPONSE && + action.endpoint.sideEffect && + !action.error + ) + this.channel.postMessage('mutation'); + return next(action); + }; + }; + + cleanup() { + this.channel.close(); + } +} +``` + +### Offline persistence {#persistence} + +Persist the store with [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) +(here via [idb-keyval](https://www.npmjs.com/package/idb-keyval)); restore it with +[DataProvider's initialState](https://dataclient.io/docs/api/DataProvider#initialState). IndexedDB writes are +asynchronous and use [structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) +instead of blocking the main thread with JSON serialization like `localStorage` would. +Debouncing writes keeps rapid action bursts cheap. Consider [expiry times](https://dataclient.io/docs/concepts/expiry-policy) +when restoring. + +```typescript +import type { Manager, Middleware } from '@data-client/react'; +import { set } from 'idb-keyval'; + +export default class PersistManager implements Manager { + declare protected timer?: ReturnType; + + middleware: Middleware = controller => next => async action => { + await next(action); + // debounce: persist at most once per second + clearTimeout(this.timer); + this.timer = setTimeout(() => { + // in-flight optimistic updates reference functions, so are not persistable + const state = { ...controller.getState(), optimistic: [] }; + set('data-client', state); + }, 1000); + }; + + cleanup() { + clearTimeout(this.timer); + } +} +``` + +```tsx +import { get } from 'idb-keyval'; + +const initialState = await get('data-client'); + +createRoot(document.body).render( + + + , +); +``` + +### Middleware data stream (push-based) {#data-stream} + +Adding a manager to process data pushed from the server by [websockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) +or [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) ensures +we can maintain fresh data when the data updates are independent of user action. For example, a trading app's +price, or a real-time collaborative editor. + +```typescript +import { type Manager, type Middleware, Controller } from '@data-client/react'; +import type { Entity } from '@data-client/rest'; + +export default class StreamManager implements Manager { + declare protected controller: Controller; + declare protected evtSource: WebSocket; // | EventSource; + declare protected createEventSource: () => WebSocket | EventSource; + declare protected entities: Record; + + constructor( + createEventSource: () => WebSocket | EventSource, + entities: Record, + ) { + this.createEventSource = createEventSource; + this.entities = entities; + } + + middleware: Middleware = controller => { + this.controller = controller; + return next => async action => next(action); + } + + connect() { + this.evtSource = this.createEventSource(); + this.evtSource.onmessage = event => { + try { + const msg = JSON.parse(event.data); + if (msg.type in this.entities) + this.controller.set(this.entities[msg.type], ...msg.args, msg.data); + } catch (e) { + console.error('Failed to handle message'); + console.error(e); + } + }; + } + + init() { + this.connect(); + } + + cleanup() { + this.evtSource?.close(); + } +} +``` + +[Controller.set()](./Controller.md#set) allows directly updating [Querable Schemas](https://dataclient.io/rest/api/schema#queryable) +directly with `event.data`. + +#### Batching high-frequency updates {#batching} + +Streams like exchange tickers can send hundreds of messages per second, and connections often start with a large snapshot. +Rather than calling `set()` per message, buffer them and write each batch with an [Array](https://dataclient.io/rest/api/Array) schema. +[Controller.set(\[Entity\], rows)](./Controller.md#set-array) normalizes every row in one store update. + +```typescript +export default class StreamManager implements Manager { + // ... + protected buffer: Record = {}; + declare protected flushTimeout?: ReturnType; + + connect() { + this.evtSource = this.createEventSource(); + this.evtSource.onmessage = event => { + const msg = JSON.parse(event.data); + if (msg.type in this.entities) { + (this.buffer[msg.type] ??= []).push(msg.data); + this.flushTimeout ??= setTimeout(this.flush, 50); + } + }; + } + + flush = () => { + const buffer = this.buffer; + this.buffer = {}; + this.flushTimeout = undefined; + for (const type in buffer) { + this.controller.set([this.entities[type]], buffer[type]); + } + }; + + cleanup() { + this.evtSource?.close(); + clearTimeout(this.flushTimeout); + this.flushTimeout = undefined; + this.buffer = {}; + } +} +``` + +Rows in one batch that share a pk merge in order and skip [Entity.shouldReorder()](https://dataclient.io/rest/api/Entity#shouldreorder), +so buffer only the latest message per pk when order matters. + +#### Skipping DevTools for high-frequency updates + +When using WebSockets or other real-time data sources, you may want to skip logging +certain high-frequency actions to [DevToolsManager](https://dataclient.io/docs/api/DevToolsManager) to avoid +overwhelming the browser extension. + +```typescript +import { getDefaultManagers, actionTypes } from '@data-client/react'; +import StreamManager from './StreamManager'; +import { Ticker } from './Ticker'; + +export default function getManagers() { + return [ + new StreamManager( + () => new WebSocket('wss://ws-feed.example.com'), + { ticker: Ticker }, + ), + ...getDefaultManagers({ + devToolsManager: { + // Increase latency buffer for high-frequency updates + latency: 1000, + // Skip WebSocket SET actions to avoid log spam + // (batched writes use the [Ticker] schema) + predicate: (state, action) => + action.type !== actionTypes.SET || + (action.schema !== Ticker && action.schema[0] !== Ticker), + }, + }), + ]; +} +``` + +### Coin App + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/getManagers.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/getManagers.ts), [`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts), [`src/pages/AssetDetail/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/AssetDetail/AssetPrice.tsx), [`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts)) diff --git a/.agents/skills/data-client-manager/references/managers.vue.md b/.agents/skills/data-client-manager/references/managers.vue.md new file mode 100644 index 000000000000..13a4ad7d80c2 --- /dev/null +++ b/.agents/skills/data-client-manager/references/managers.vue.md @@ -0,0 +1,380 @@ + + +# Managers and Middleware + +Reactive Data Client uses the [flux store](https://facebookarchive.github.io/flux/docs/in-depth-overview/) pattern, which is +characterized by an easy to [understand and debug](https://dataclient.io/vue/getting-started/debugging) the store's [undirectional data flow](https://en.wikipedia.org/wiki/Unidirectional_Data_Flow_\(computer_science\)). State updates are performed by a [reducer function](https://github.com/reactive/data-client/blob/master/packages/core/src/state/reducer/createReducer.ts#L19). + +In flux architectures, it is critical all functions in the flux loop are [pure](https://react.dev/learn/keeping-components-pure). +Managers provide centralized orchestration of side effects. In other words, they are the means to interface +with the world outside Data Client. + +For instance, [NetworkManager](https://dataclient.io/vue/api/NetworkManager) orchestrates data fetching and [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager) +keeps track of which resources are subscribed with [useLive](https://dataclient.io/vue/api/useLive) or [useSubscription](https://dataclient.io/vue/api/useSubscription). By centralizing control, [NetworkManager](https://dataclient.io/vue/api/NetworkManager) automatically deduplicates fetches, and [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager) +will keep only actively rendered resources updated. + +This makes [Managers](./Manager.vue.md) the best way to integrate additional side-effects like +[logging](#middleware-logging), [error reporting](#error-reporting), [metrics](#metrics), +[notifications](#notifications), [data streams](#data-stream), [refreshing on focus or reconnect](#refresh-on-focus), +[cross-tab synchronization](#cross-tab-sync), and [offline persistence](#persistence). +They can also be customized to change core behaviors. + +| Default managers | | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| [NetworkManager](https://dataclient.io/vue/api/NetworkManager) | Turns fetch dispatches into network calls | +| [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager) | Handles polling [subscriptions](https://dataclient.io/vue/getting-started/data-dependency#subscriptions) | +| [DevToolsManager](https://dataclient.io/vue/api/DevToolsManager) | Enables [debugging](https://dataclient.io/vue/getting-started/debugging) | +| Extra managers | | +| [LogoutManager](./LogoutManager.md) | Handles HTTP `401` (or other logout conditions) | + +## Examples + +Reactive Data Client improves type-safety and ergonomics by performing dispatches and store access with +its [Controller](./Controller.vue.md) + +### Middleware logging + +```typescript +import type { Manager, Middleware } from '@data-client/core'; + +export default class LoggingManager implements Manager { + middleware: Middleware = controller => next => async action => { + console.log('before', action, controller.getState()); + await next(action); + console.log('after', action, controller.getState()); + }; + + cleanup() {} +} +``` + +### Error reporting {#error-reporting} + +Report failed fetches to monitoring services like [Sentry](https://sentry.io) by inspecting +[SET\_RESPONSE](./Actions.vue.md#set_response) actions with `error` set. + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; +import { captureException } from '@sentry/react'; + +export default class ErrorReportManager implements Manager { + middleware: Middleware = controller => next => async action => { + if (action.type === actionTypes.SET_RESPONSE && action.error) + captureException(action.response, { + extra: { endpoint: action.endpoint.name, args: action.args }, + }); + return next(action); + }; + + cleanup() {} +} +``` + +### Metrics {#metrics} + +Track fetch timing by observing [FETCH](./Actions.vue.md#fetch) actions. `action.meta.promise` +resolves when the fetch completes. + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; +import { trackTiming } from './analytics'; + +export default class MetricsManager implements Manager { + middleware: Middleware = controller => next => async action => { + if (action.type === actionTypes.FETCH) { + const start = performance.now(); + action.meta.promise.finally(() => { + trackTiming(action.endpoint.name, performance.now() - start); + }); + } + return next(action); + }; + + cleanup() {} +} +``` + +### Notifications (toasts) {#notifications} + +Show a toast when any [mutation](https://dataclient.io/rest/guides/side-effects) succeeds or fails. + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; +import { toast } from './toast'; + +export default class ToastManager implements Manager { + middleware: Middleware = controller => next => async action => { + if ( + action.type === actionTypes.SET_RESPONSE && + action.endpoint.sideEffect + ) { + if (action.error) toast.error(`${action.endpoint.name} failed`); + else toast.success(`${action.endpoint.name} succeeded`); + } + return next(action); + }; + + cleanup() {} +} +``` + +### Refresh on focus or reconnect {#refresh-on-focus} + +[Controller.expireAll()](./Controller.vue.md#expireAll) marks data as [Stale](https://dataclient.io/vue/concepts/expiry-policy#stale), +triggering refetch of any _actively rendered_ data without suspending ([stale-while-revalidate](https://dataclient.io/vue/concepts/expiry-policy)). +[init()](./Manager.vue.md#init) and [cleanup()](./Manager.vue.md#cleanup) manage the event listeners. + +```typescript +import type { Manager, Middleware, Controller } from '@data-client/react'; + +export default class RefreshManager implements Manager { + declare protected controller: Controller; + protected handle = () => + this.controller.expireAll({ testKey: () => true }); + + middleware: Middleware = controller => { + this.controller = controller; + return next => async action => next(action); + }; + + init() { + window.addEventListener('focus', this.handle); + window.addEventListener('online', this.handle); + } + + cleanup() { + window.removeEventListener('focus', this.handle); + window.removeEventListener('online', this.handle); + } +} +``` + +### Cross-tab synchronization {#cross-tab-sync} + +When a mutation succeeds in one tab, mark data stale in all other tabs using +[BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel). + +```typescript +import { + type Manager, + type Middleware, + actionTypes, +} from '@data-client/react'; + +export default class TabSyncManager implements Manager { + protected channel = new BroadcastChannel('data-client'); + + middleware: Middleware = controller => { + this.channel.onmessage = () => + controller.expireAll({ testKey: () => true }); + return next => async action => { + if ( + action.type === actionTypes.SET_RESPONSE && + action.endpoint.sideEffect && + !action.error + ) + this.channel.postMessage('mutation'); + return next(action); + }; + }; + + cleanup() { + this.channel.close(); + } +} +``` + +### Offline persistence {#persistence} + +Persist the store with [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) +(here via [idb-keyval](https://www.npmjs.com/package/idb-keyval)); restore it with +DataClientPlugin's `initialState` option. IndexedDB writes are +asynchronous and use [structured clone](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) +instead of blocking the main thread with JSON serialization like `localStorage` would. +Debouncing writes keeps rapid action bursts cheap. Consider [expiry times](https://dataclient.io/vue/concepts/expiry-policy) +when restoring. + +```typescript +import type { Manager, Middleware } from '@data-client/react'; +import { set } from 'idb-keyval'; + +export default class PersistManager implements Manager { + declare protected timer?: ReturnType; + + middleware: Middleware = controller => next => async action => { + await next(action); + // debounce: persist at most once per second + clearTimeout(this.timer); + this.timer = setTimeout(() => { + // in-flight optimistic updates reference functions, so are not persistable + const state = { ...controller.getState(), optimistic: [] }; + set('data-client', state); + }, 1000); + }; + + cleanup() { + clearTimeout(this.timer); + } +} +``` + +```tsx +import { get } from 'idb-keyval'; + +const initialState = await get('data-client'); + +createRoot(document.body).render( + + + , +); +``` + +### Middleware data stream (push-based) {#data-stream} + +Adding a manager to process data pushed from the server by [websockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) +or [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) ensures +we can maintain fresh data when the data updates are independent of user action. For example, a trading app's +price, or a real-time collaborative editor. + +```typescript +import { type Manager, type Middleware, Controller } from '@data-client/react'; +import type { Entity } from '@data-client/rest'; + +export default class StreamManager implements Manager { + declare protected controller: Controller; + declare protected evtSource: WebSocket; // | EventSource; + declare protected createEventSource: () => WebSocket | EventSource; + declare protected entities: Record; + + constructor( + createEventSource: () => WebSocket | EventSource, + entities: Record, + ) { + this.createEventSource = createEventSource; + this.entities = entities; + } + + middleware: Middleware = controller => { + this.controller = controller; + return next => async action => next(action); + } + + connect() { + this.evtSource = this.createEventSource(); + this.evtSource.onmessage = event => { + try { + const msg = JSON.parse(event.data); + if (msg.type in this.entities) + this.controller.set(this.entities[msg.type], ...msg.args, msg.data); + } catch (e) { + console.error('Failed to handle message'); + console.error(e); + } + }; + } + + init() { + this.connect(); + } + + cleanup() { + this.evtSource?.close(); + } +} +``` + +[Controller.set()](./Controller.vue.md#set) allows directly updating [Querable Schemas](https://dataclient.io/rest/api/schema#queryable) +directly with `event.data`. + +#### Batching high-frequency updates {#batching} + +Streams like exchange tickers can send hundreds of messages per second, and connections often start with a large snapshot. +Rather than calling `set()` per message, buffer them and write each batch with an [Array](https://dataclient.io/rest/api/Array) schema. +[Controller.set(\[Entity\], rows)](./Controller.vue.md#set-array) normalizes every row in one store update. + +```typescript +export default class StreamManager implements Manager { + // ... + protected buffer: Record = {}; + declare protected flushTimeout?: ReturnType; + + connect() { + this.evtSource = this.createEventSource(); + this.evtSource.onmessage = event => { + const msg = JSON.parse(event.data); + if (msg.type in this.entities) { + (this.buffer[msg.type] ??= []).push(msg.data); + this.flushTimeout ??= setTimeout(this.flush, 50); + } + }; + } + + flush = () => { + const buffer = this.buffer; + this.buffer = {}; + this.flushTimeout = undefined; + for (const type in buffer) { + this.controller.set([this.entities[type]], buffer[type]); + } + }; + + cleanup() { + this.evtSource?.close(); + clearTimeout(this.flushTimeout); + this.flushTimeout = undefined; + this.buffer = {}; + } +} +``` + +Rows in one batch that share a pk merge in order and skip [Entity.shouldReorder()](https://dataclient.io/rest/api/Entity#shouldreorder), +so buffer only the latest message per pk when order matters. + +#### Skipping DevTools for high-frequency updates + +When using WebSockets or other real-time data sources, you may want to skip logging +certain high-frequency actions to [DevToolsManager](https://dataclient.io/vue/api/DevToolsManager) to avoid +overwhelming the browser extension. + +```typescript +import { getDefaultManagers, actionTypes } from '@data-client/react'; +import StreamManager from './StreamManager'; +import { Ticker } from './Ticker'; + +export default function getManagers() { + return [ + new StreamManager( + () => new WebSocket('wss://ws-feed.example.com'), + { ticker: Ticker }, + ), + ...getDefaultManagers({ + devToolsManager: { + // Increase latency buffer for high-frequency updates + latency: 1000, + // Skip WebSocket SET actions to avoid log spam + // (batched writes use the [Ticker] schema) + predicate: (state, action) => + action.type !== actionTypes.SET || + (action.schema !== Ticker && action.schema[0] !== Ticker), + }, + }), + ]; +} +``` + +### Coin App + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/getManagers.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/getManagers.ts), [`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts), [`src/pages/AssetDetail/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/AssetDetail/AssetPrice.tsx), [`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts)) diff --git a/.agents/skills/data-client-react-testing/references.json b/.agents/skills/data-client-react-testing/references.json new file mode 100644 index 000000000000..291ba6aa1c99 --- /dev/null +++ b/.agents/skills/data-client-react-testing/references.json @@ -0,0 +1,14 @@ +{ + "frameworks": [ + "react" + ], + "docs": { + "Fixtures.md": "docs/core/api/Fixtures.md", + "MockResolver.md": "docs/core/api/MockResolver.md", + "makeRenderDataHook.md": "docs/core/api/makeRenderDataHook.md", + "mockInitialState.md": "docs/core/api/mockInitialState.md", + "renderDataHook.md": "docs/core/api/renderDataHook.md", + "unit-testing-components.md": "docs/core/guides/unit-testing-components.md", + "unit-testing-hooks.md": "docs/core/guides/unit-testing-hooks.md" + } +} diff --git a/.agents/skills/data-client-react-testing/references/Fixtures.md b/.agents/skills/data-client-react-testing/references/Fixtures.md deleted file mode 120000 index 7671d7554dd6..000000000000 --- a/.agents/skills/data-client-react-testing/references/Fixtures.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Fixtures.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/Fixtures.md b/.agents/skills/data-client-react-testing/references/Fixtures.md new file mode 100644 index 000000000000..ff3b8dbf67ce --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/Fixtures.md @@ -0,0 +1,217 @@ + + +# Fixtures and Interceptors + +Fixtures and Interceptors allow universal data mocking without the need for monkeypatching +fetch behaviors. Fixtures define static responses to specific endpoint arg combinations. This +allows them to be used in static contexts like [mockInitialState()](./mockInitialState.md). +Interceptors are functions run and match a fetch pattern. This restricts them to being used only +in dynamic response contexts like [MockResolver](./MockResolver.md). + +## SuccessFixture + +Represents a successful response + +```ts +export interface SuccessFixture { + endpoint; + args; + response; + error?; + delay?; +} +``` + +```ts +export interface SuccessFixture< + E extends EndpointInterface = EndpointInterface, +> { + readonly endpoint: E; + readonly args: Parameters; + readonly response: + | ResolveType + | ((...args: Parameters) => ResolveType); + readonly error?: false; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { count: 0 }, +}; +``` + +## ErrorFixtures + +Represents a failed/errored response + +```ts +export interface ErrorFixture { + endpoint; + args; + response; + error; + delay?; +} +``` + +```ts +export interface ErrorFixture { + readonly endpoint: E; + readonly args: Parameters; + readonly response: any; + readonly error: true; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countErrorFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { message: 'Not found', status: 404 }, + error: true, +}; +``` + +## Interceptor + +Interceptors will match a request based on its [`testKey()`](https://dataclient.io/rest/api/RestEndpoint#testKey) method, then +compute the response dynamically using the `response()` method. + +```ts +interface ResponseInterceptor { + endpoint; + response(...args); + delay?; + delayCollapse?; +} + +interface FetchInterceptor { + endpoint; + fetchResponse(input, init); + delay?; + delayCollapse?; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +interface ResponseInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + } = EndpointInterface & { testKey(key: string): boolean }, +> { + readonly endpoint: E; + response(this: T, ...args: Parameters): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +interface FetchInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + } = EndpointInterface & { + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + }, +> { + readonly endpoint: E; + fetchResponse(this: T, input: RequestInfo, init: RequestInit): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + response() { + return { + count: (this.count = this.count + 1), + }; + }, + delay: () => 500 + Math.random() * 4500, +}; +``` + +## Arguments + +### endpoint + +The endpoint to match. + +### args + +(Fixtures only) The args to match. + +### response(...args) {#response} + +Determines what the response for this mock should be. If a function it will be run. + +Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) + +`this` can be used to store simulated server-side data. It is initialized using [getInitialInterceptorData](./MockResolver.md#getinitialinterceptordata). It's important to not use arrow functions when using this as they disallow `this` binding. + +### fetchResponse(input, init) {#fetchResponse} + +When provided, will construct a response() method to be used based on overriding +(by calling [.extend](https://dataclient.io/rest/api/RestEndpoint#extend)) [fetchResponse](https://dataclient.io/rest/api/RestEndpoint#fetchResponse). + +Simply return the value expected, rather than an actual HTTP [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + fetchResponse(input, init) { + return { + count: (this.count = this.count + 1), + updatedAt: JSON.parse(init.body).updatedAt, + }; + }, +}; +``` + +This can be useful when you want to use the body generated in a custom [getRequestInit()](https://dataclient.io/rest/api/RestEndpoint#getRequestInit) + +### delay: number {#delay} + +This is the number of miliseconds to wait before resolving the promise. This can be useful +when simulating race conditions. + +When a function is sent, its return value is used as the number of miliseconds. + +### delayCollapse: boolean {#delayCollapse} + +`true`: Runs response() after [delay](#delay) time + +`false`: Runs response() immediately, then resolves it after [delay](#delay) time + +This can be useful for simulating server-processing delays. diff --git a/.agents/skills/data-client-react-testing/references/MockResolver.md b/.agents/skills/data-client-react-testing/references/MockResolver.md deleted file mode 120000 index 0ad8973a5f08..000000000000 --- a/.agents/skills/data-client-react-testing/references/MockResolver.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/MockResolver.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/MockResolver.md b/.agents/skills/data-client-react-testing/references/MockResolver.md new file mode 100644 index 000000000000..6a96a2b60763 --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/MockResolver.md @@ -0,0 +1,99 @@ + + +# \ + +```typescript +function MockResolver(props: { + children: React.ReactNode; + fixtures: (Fixture | Interceptor)[]; + getInitialInterceptorData: () => T; +}): JSX.Element; +``` + +\ enables easy loading of fixtures to see what different network responses might look like. +This is useful for [storybook](https://dataclient.io/docs/guides/storybook) as well as component testing. + +## Arguments + +### fixtures + +```ts +(Fixture | Interceptor)[] +``` + +This prop specifies the [fixtures or interceptors](./Fixtures.md) to use data from. Each item represents a fetch defined by the +[Endpoint](https://dataclient.io/rest/api/Endpoint) and params. `Result` contains the JSON response expected from said fetch. + +### getInitialInterceptorData + +Function that initializes the `this` attribute for all interceptors. + +```ts + 500 + Math.random() * 4500, + }, + ]} + getInitialInterceptorData={() => ({ count: 0 })} +> + {children} + +``` + +## Example + +```tsx +import { MockResolver } from '@data-client/test'; + +import ArticleResource from 'resources/ArticleResource'; +import MyComponentToTest from 'components/MyComponentToTest'; + +const results = [ + // fixture + { + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }] as const, + response: [ + { + id: 5, + content: 'have a merry christmas', + author: 2, + contributors: [], + }, + { + id: 532, + content: 'never again', + author: 23, + contributors: [5], + }, + ], + }, + // interceptor + { + endpoint: ArticleResource.partialUpdate, + response: ({ id }, body) => ({ + ...body, + id, + }), + }, +]; + +const Template: Story = () => ( + + + +); + +export const MyStory = Template.bind({}); +``` diff --git a/.agents/skills/data-client-react-testing/references/makeRenderDataHook.md b/.agents/skills/data-client-react-testing/references/makeRenderDataHook.md deleted file mode 120000 index 4313ed7d0335..000000000000 --- a/.agents/skills/data-client-react-testing/references/makeRenderDataHook.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/makeRenderDataHook.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/makeRenderDataHook.md b/.agents/skills/data-client-react-testing/references/makeRenderDataHook.md new file mode 100644 index 000000000000..bbba74c279eb --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/makeRenderDataHook.md @@ -0,0 +1,80 @@ + + +# makeRenderDataHook() + +```typescript +function makeRenderDataHook( + Provider: React.ComponentType, +): RenderDataClientFunction; +``` + +`makeRenderDataHook()` is useful to test hooks that rely on the `Reactive Data Client`. It creates a renderDataClient() +function that mirrors [@testing-library/react-hooks](https://github.com/testing-library/react-hooks-testing-library)'s [renderHook()](https://react-hooks-testing-library.com/reference/api#renderhook-options) but does so with a `` boundary +as well as in a `` context. + +## Arguments + +### Provider + +```typescript +interface ProviderProps { + children: React.ReactNode; + managers: Manager[]; + initialState: State; + Controller: typeof Controller; +} +``` + +The Reactive Data Client [\](https://dataclient.io/docs/api/DataProvider) + +- `import { DataProvider } from @data-client/react;` +- `import { DataProvider } from @data-client/react/redux;` + +## Example + +```typescript +import { DataProvider } from '@data-client/react/redux'; +import { makeRenderDataHook } from '@data-client/test'; + +const response = { + id: 5, + title: 'hi ho', + content: 'whatever', + tags: ['a', 'best', 'react'], +}; + +beforeEach(() => { + renderDataHook = makeRenderDataHook(DataProvider); +}); + +it('should resolve useSuspense()', async () => { + const { result, waitFor } = renderDataHook( + () => { + return useSuspense(ArticleResource.get, response); + }, + { + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ ...response, id }), + }, + { + endpoint: ArticleResource.partialUpdate, + response: ({ id }, body) => ({ ...body, id }), + }, + ], + }, + ); + // this indicates suspense + expect(result.current).toBeUndefined(); + await waitFor(() => expect(result.current).toBeDefined()); + expect(result.current instanceof ArticleResource).toBe(true); + expect(result.current.title).toBe(response.title); + await controller.fetch( + ArticleResource.partialUpdate, + { id: response.id }, + { title: 'updated title' }, + ); + expect(result.current.title).toBe('updated title'); +}); +``` diff --git a/.agents/skills/data-client-react-testing/references/mockInitialState.md b/.agents/skills/data-client-react-testing/references/mockInitialState.md deleted file mode 120000 index 77c27641d374..000000000000 --- a/.agents/skills/data-client-react-testing/references/mockInitialState.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/mockInitialState.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/mockInitialState.md b/.agents/skills/data-client-react-testing/references/mockInitialState.md new file mode 100644 index 000000000000..f67b05b3eec0 --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/mockInitialState.md @@ -0,0 +1,60 @@ + + +# mockInitialState() + +```typescript +function mockInitialState(results: Fixture[]): State; +``` + +`mockInitialState()` makes it easy to construct prefill the cache with [fixtures](./Fixtures.md). It's +used in [\](./MockResolver.md) to process the results prop. However, this +can also be useful to send into a normal provider when testing more complete flows +that need to handle `dispatches` (and thus fetch). + +### Arguments + +#### results + +```typescript +export type Fixture = SuccessFixture | ErrorFixture; +``` + +This prop specifies the [fixtures](./Fixtures.md) to use data from. Each item represents a fetch defined by the +[Endpoint](https://dataclient.io/rest/api/Endpoint) and params. `Result` contains the JSON response expected from said fetch. + +This can be used as the initialState prop for [\](https://dataclient.io/docs/api/DataProvider) + +## Example + +```typescript +import { DataProvider } from '@data-client/react'; +import { mockInitialState } from '@data-client/test'; + +import ArticleResource from 'resources/ArticleResource'; +import MyComponentToTest from 'components/MyComponentToTest'; + +const results = [ + { + request: ArticleResource.getList, + params: { maxResults: 10 }, + result: [ + { + id: 5, + content: 'have a merry christmas', + author: 2, + contributors: [], + }, + { + id: 532, + content: 'never again', + author: 23, + contributors: [5], + }, + ], + }, +]; + + + +; +``` diff --git a/.agents/skills/data-client-react-testing/references/renderDataHook.md b/.agents/skills/data-client-react-testing/references/renderDataHook.md deleted file mode 120000 index a93b28bb3a13..000000000000 --- a/.agents/skills/data-client-react-testing/references/renderDataHook.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/renderDataHook.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/renderDataHook.md b/.agents/skills/data-client-react-testing/references/renderDataHook.md new file mode 100644 index 000000000000..abd0784dd120 --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/renderDataHook.md @@ -0,0 +1,249 @@ + + +# renderDataHook() + +`renderDataHook()` is useful to test hooks that rely on the `Reactive Data Client`. It mirrors [@testing-library/react-hooks](https://github.com/testing-library/react-hooks-testing-library)'s [renderHook()](https://react-hooks-testing-library.com/reference/api#renderhook-options) but does so with a `` boundary +as well as in a `` context. + +> **Note** +> +> `renderDataHook()` creates a Provider context with new manager instances. This means each call +> to `renderDataHook()` will result in a completely fresh cache state as well as manager state. + +
+ +Type + +```typescript +type RenderDataHook = { + ( + callback: (props: P) => R, + options?: { + initialProps?: P; + initialFixtures?: Fixture[]; + resolverFixtures?: (Fixture | Interceptor)[]; + getInitialInterceptorData?: () => T; + wrapper?: React.ComponentType; + }, + ): { + rerender: (props?: Props) => void; + result: { + current: Result; + error?: Error; + }; + unmount: () => void; + controller: Controller; + cleanup(): void; + allSettled(): Promise; + /* @deprecated */ + waitForNextUpdate: (options?: waitForOptions) => Promise; + waitFor( + callback: () => Promise | T, + options?: waitForOptions, + ): Promise; + }; + /** cleanup is automatic; only needed for ordering (e.g., before jest.useRealTimers()) */ + cleanup(): void; + allSettled(): Promise; +}; +``` + +
+ +## Usage + +```typescript +import { renderDataHook } from '@data-client/test'; + +const response = { + id: 5, + title: 'hi ho', + content: 'whatever', + tags: ['a', 'best', 'react'], +}; + +it('useSuspense() should render the response', async () => { + const { result, waitFor } = renderDataHook( + () => { + return useSuspense(ArticleResource.get, { id: 5 }); + }, + { + initialFixtures: [ + { + endpoint: ArticleResource.get, + args: [{ id: 5 }], + response, + }, + ], + }, + ); + expect(result.current instanceof ArticleResource).toBe(true); + expect(result.current.title).toBe(payload.title); +}); +``` + +## Arguments + +### callback + +Hook to run inside React. Return value will become available in [result.current](#result) + +### options.initialFixtures + +Can be used to prime the cache if test expects cache values to already be filled. Takes an +[array of fixtures](./Fixtures.md) + +This has the same effect as initializing [\](https://dataclient.io/docs/api/DataProvider) with [mockInitialState()](./mockInitialState.md) + +### options.resolverFixtures + +These [fixtures or interceptors](./Fixtures.md) are used to resolve any new requests. This is most useful for mocking imperative fetches like mutations, but can also allow testing suspending states or transitions. + +Works by adding [MockResolver](./MockResolver.md) as a wrapper. + +### options.getInitialInterceptorData + +Function that initializes the `this` attribute for all interceptors. + +### options.initialProps + +The initial values to pass to the callback function + +### options.wrapper + +Pass a React Component as the wrapper option to have it rendered around the inner element + +## Returns + +### controller + +[Controller](https://dataclient.io/docs/api/Controller) to dispatch imperative effects + +```ts +it('should update', async () => { + const id = 5; + const payload = { title: 'first item', id, completed: false }; + const { result, controller } = renderDataHook( + () => { + return useSuspense(TodoResource.getList); + }, + { + initialFixtures: [ + { + endpoint: TodoResource.getList, + args: [], + response: [payload], + }, + ], + { + endpoint: TodoResource.update, + response: body => body, + }, + }, + ); + expect(result.current).toEqual([TodoResource.fromJS(payload)]); + await act(() => { + await controller.fetch(TodoResource.update, { + id, + title: 'updated title', + }); + }); + expect(result.current[0].title).toBe('updated title'); +}); +``` + +### cleanup() + +Cleans up all managers used in this render. +This is especially important when mocking timers, as Reactive Data Client's internals rely on real timers to +avoid race conditions. + +Cleanup runs automatically after each test via a module-level `afterEach` hook (similar to `@testing-library/react`). +Manual calls are only needed when you must control cleanup ordering within a test body -- for example, +cleaning up before switching from fake timers to real timers: + +```ts +it('should handle polling', async () => { + jest.useFakeTimers(); + const { result } = renderDataHook(/* ... */); + // ... assertions ... + renderDataHook.cleanup(); // must run while fake timers are still active + jest.useRealTimers(); +}); +``` + +### allSettled() + +Returns a promise that resolves once all inflight requests are completed. + +Also available on the return value of each `renderDataHook()` call. + +### result + +- `current` (`any`) - the return value of the `callback` function +- `error` (`Error`) - the error that was thrown if the `callback` function threw an error during rendering + +### waitFor + +Returns a `Promise` that resolves if the provided callback executes without exception and returns a truthy or undefined value. It is safe to use the result of renderDataHook in the callback to perform assertion or to test values. + +### waitForNextUpdate + +> **Warning: Deprecated** +> +> Use waitFor instead + +Returns a `Promise` that resolves the next time the hook renders, commonly when state is updated as the result of a asynchronous action. + +### rerender + +(`function([newProps])`) - function to rerender the test component including any hooks called in the `callback` function. If `newProps` are passed, the will replace the `initialProps` passed the the `callback` function for future renders. + +### unmount + +(`function()`) - function to unmount the test component, commonly used to trigger cleanup effects for `useEffect` hooks. + +## Examples + +```typescript +import { DataProvider } from '@data-client/react'; +import { renderDataHook } from '@data-client/test'; + +const response = { + id: 5, + title: 'hi ho', + content: 'whatever', + tags: ['a', 'best', 'react'], +}; + +it('should resolve useSuspense()', async () => { + const { result, waitFor } = renderDataHook( + () => { + return useSuspense(ArticleResource.get, response); + }, + { + resolverFixtures: [ + { + endpoint: ArticleResource.get, + response: ({ id }) => ({ ...response, id }), + }, + { + endpoint: ArticleResource.partialUpdate, + response: ({ id }, body) => ({ ...body, id }), + }, + ], + }, + ); + // this indicates suspense + expect(result.current).toBeUndefined(); + await waitFor(() => expect(result.current).toBeDefined()); + expect(result.current instanceof ArticleResource).toBe(true); + expect(result.current.title).toBe(response.title); + await controller.fetch( + ArticleResource.partialUpdate, + { id: response.id }, + { title: 'updated title' }, + ); + expect(result.current.title).toBe('updated title'); +}); +``` diff --git a/.agents/skills/data-client-react-testing/references/unit-testing-components.md b/.agents/skills/data-client-react-testing/references/unit-testing-components.md deleted file mode 120000 index 06542cbd343e..000000000000 --- a/.agents/skills/data-client-react-testing/references/unit-testing-components.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/guides/unit-testing-components.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/unit-testing-components.md b/.agents/skills/data-client-react-testing/references/unit-testing-components.md new file mode 100644 index 000000000000..cdf8ef4ccfc8 --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/unit-testing-components.md @@ -0,0 +1,111 @@ + + +# Unit testing components + +> **Warning** +> +> Be careful when using [jest.mock](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) on modules like Reactive Data Client. Eliminating expected +> exports can lead to hard-to trace +> errors like `TypeError: Class extends value undefined is not a function or null`. +> +> Instead either do a [partial mock](https://jestjs.io/docs/mock-functions#mocking-partials), +> or better [mockResolvedValue](https://jestjs.io/docs/mock-functions#mocking-modules) on your +> endpoints. + +If you need to add unit tests to your components to check some behavior you might want +avoid dealing with network fetch cycle as that is probably orthogonal to what your are +trying to test. Using [\](https://dataclient.io/docs/api/DataProvider) with [mockInitialState](./mockInitialState.md) and [Fixtures](./Fixtures.md) in our tests allow +us to prime the cache with provided fixtures so the components will immediately render +with said results. + +Testing user interactions that trigger mutations can be aided with the use of [\](./MockResolver.md) +and [Interceptors](./Fixtures.md#interceptor) + +```typescript title="__tests__/fixtures.ts" +export default { + full: [ + { + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }] as const, + response: [ + { + id: 5, + content: 'have a merry christmas', + author: 2, + contributors: [], + }, + { + id: 532, + content: 'never again', + author: 23, + contributors: [5], + }, + ], + }, + { + endpoint: ArticleResource.update, + args: [{ id: 532 }] as const, + response({ id }, body) { + return { + id, + ...body, + }; + }, + }, + ], + empty: [ + { + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }] as const, + response: [], + }, + ], + error: [ + { + endpoint: ArticleResource.getList, + args: [{ maxResults: 10 }] as const, + response: { message: 'Bad request', status: 400, name: 'Not Found' }, + error: true, + }, + ], + loading: [], +}; +``` + +```tsx title="__tests__/ArticleList.tsx" +import { DataProvider, AsyncBoundary } from '@data-client/react'; +import { render, waitFor } from '@testing-library/react'; +import { MockResolver, mockInitialState } from '@data-client/test'; + +import ArticleList from 'components/ArticleList'; +import results from './fixtures'; + +describe('', () => { + it('renders', () => { + const tree = ( + + + + ); + const { findByText } = render(tree); + const content = findByText(results.full.result[0].content); + expect(content).toBeDefined(); + }); + + it('suspends then resolves', async () => { + const tree = ( + + + + + + + + ); + const { findByText } = render(tree); + expect(findByText('loading')).toBeDefined(); + + await waitFor(expect(findByText(results.full.result[0].content)).toBeDefined()); + }) +}); +``` diff --git a/.agents/skills/data-client-react-testing/references/unit-testing-hooks.md b/.agents/skills/data-client-react-testing/references/unit-testing-hooks.md deleted file mode 120000 index d3d67757a231..000000000000 --- a/.agents/skills/data-client-react-testing/references/unit-testing-hooks.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/guides/unit-testing-hooks.md \ No newline at end of file diff --git a/.agents/skills/data-client-react-testing/references/unit-testing-hooks.md b/.agents/skills/data-client-react-testing/references/unit-testing-hooks.md new file mode 100644 index 000000000000..5469d8a8ee2b --- /dev/null +++ b/.agents/skills/data-client-react-testing/references/unit-testing-hooks.md @@ -0,0 +1,135 @@ + + +# Unit testing hooks + +> **Warning** +> +> Be careful when using [jest.mock](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) on modules like Reactive Data Client. Eliminating expected +> exports can lead to hard-to trace +> errors like `TypeError: Class extends value undefined is not a function or null`. +> +> Instead either do a [partial mock](https://jestjs.io/docs/mock-functions#mocking-partials), +> or better [mockResolvedValue](https://jestjs.io/docs/mock-functions#mocking-modules) on your +> endpoints. + +Hooks allow you to pull complex behaviors out of your components into succinct, +composable functions. This makes testing component behavior potentially much +easier. But how does this work if you want to use hooks from `Reactive Data Client`? + +We have provided some simple utilities to reduce boilerplate for unit tests +that are wrappers around [@testing-library/react-hooks](https://github.com/testing-library/react-hooks-testing-library)'s [renderHook()](https://react-hooks-testing-library.com/reference/api#renderhook-options). + +We want a [renderDataHook()](./renderDataHook.md) function that renders in the context of both +a `Provider` and `Suspense` boundary. + +These will generally be done during test setup. Cleanup runs automatically after each test. + +> **Note** +> +> `renderDataHook()` creates a Provider context with new manager instances. This means each call +> to `renderDataHook()` will result in a completely fresh cache state as well as manager state. + +### Polyfill fetch in node < 18 + +Node doesn't come with fetch out of the box, so we need to be sure to polyfill it. + +```bash +npm install --save-dev whatwg-fetch +``` + +### Jest + +```js +// jest.config.js +module.exports = { + // other things + setupFiles: ['./testSetup.js'], +}; +``` + +```js +// testSetup.js +require('whatwg-fetch'); +``` + +### Example: + +**@data-client/react** + +```typescript +import nock from 'nock'; +import { renderDataHook } from '@data-client/test'; + +describe('useSuspense()', () => { + beforeEach(() => { + nock(/.*/) + .persist() + .defaultReplyHeaders({ + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + }) + .options(/.*/) + .reply(200) + .get(`/article/0`) + .reply(403, {}); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should throw errors on bad network', async () => { + const { result, waitFor } = renderDataHook(() => { + return useSuspense(ArticleResource.get, { + title: '0', + }); + }); + expect(result.current).toBeUndefined(); + await waitFor(() => expect(result.current).toBeDefined()); + expect(result.error).toBeDefined(); + expect((result.error as any).status).toBe(403); + }); +}); +``` + +**@data-client/react/redux** + +```typescript +import nock from 'nock'; +import { makeRenderDataHook } from '@data-client/test'; +import { DataProvider } from '@data-client/react/redux'; + +describe('useSuspense()', () => { + let renderDataHook: ReturnType; + + beforeEach(() => { + nock(/.*/) + .persist() + .defaultReplyHeaders({ + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + }) + .options(/.*/) + .reply(200) + .get(`/article/0`) + .reply(403, {}); + renderDataHook = makeRenderDataHook(DataProvider); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should throw errors on bad network', async () => { + const { result, waitFor } = renderDataHook(() => { + return useSuspense(ArticleResource.get, { + title: '0', + }); + }); + expect(result.current).toBeUndefined(); + await waitFor(() => expect(result.current).toBeDefined()); + expect(result.error).toBeDefined(); + expect((result.error as any).status).toBe(403); + }); +}); +``` diff --git a/.agents/skills/data-client-react/references.json b/.agents/skills/data-client-react/references.json new file mode 100644 index 000000000000..7c223436f5d6 --- /dev/null +++ b/.agents/skills/data-client-react/references.json @@ -0,0 +1,28 @@ +{ + "frameworks": [ + "react" + ], + "docs": { + "Actions.md": "docs/core/api/Actions.md", + "AsyncBoundary.md": "docs/core/api/AsyncBoundary.md", + "Controller.md": "docs/core/api/Controller.md", + "DataProvider.md": "docs/core/api/DataProvider.md", + "_AsyncBoundary.md": "docs/core/shared/_AsyncBoundary.mdx", + "_VoteDemo.md": "docs/core/shared/_VoteDemo.mdx", + "_pagination.md": "docs/core/shared/_pagination.mdx", + "_useLive.md": "docs/core/shared/_useLive.mdx", + "_useLoading.md": "docs/core/shared/_useLoading.mdx", + "data-dependency.md": "docs/core/getting-started/data-dependency.md", + "mutations.md": "docs/core/getting-started/mutations.md", + "useCache.md": "docs/core/api/useCache.md", + "useController.md": "docs/core/api/useController.md", + "useDLE.md": "docs/core/api/useDLE.md", + "useDebounce.md": "docs/core/api/useDebounce.md", + "useFetch.md": "docs/core/api/useFetch.md", + "useLive.md": "docs/core/api/useLive.md", + "useLoading.md": "docs/core/api/useLoading.md", + "useQuery.md": "docs/core/api/useQuery.md", + "useSubscription.md": "docs/core/api/useSubscription.md", + "useSuspense.md": "docs/core/api/useSuspense.md" + } +} diff --git a/.agents/skills/data-client-react/references/Actions.md b/.agents/skills/data-client-react/references/Actions.md deleted file mode 120000 index 921a154aab5d..000000000000 --- a/.agents/skills/data-client-react/references/Actions.md +++ /dev/null @@ -1 +0,0 @@ -../../data-client-manager/references/Actions.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/Actions.md b/.agents/skills/data-client-react/references/Actions.md new file mode 100644 index 000000000000..8ed0495815c0 --- /dev/null +++ b/.agents/skills/data-client-react/references/Actions.md @@ -0,0 +1,276 @@ + + +# Actions + +Actions are minimal descriptions of store updates. + +They are [dispatched by Controller methods](./Controller.md#action-dispatchers) -> +[read and consumed by Manager middleware](https://dataclient.io/docs/api/Manager#reading-and-consuming-actions) -> +processed by [reducers](https://react.dev/reference/react/useReducer) registered with [DataProvider](./DataProvider.md) +to update the store's state. + +Many actions use the same meta information: + +```ts +interface ActionMeta { + readonly fetchedAt: number; + readonly date: number; + readonly expiresAt: number; +} +``` + +## FETCH + +```ts +interface FetchMeta { + fetchedAt: number; + resolve: (value?: any | PromiseLike) => void; + reject: (reason?: any) => void; + promise: PromiseLike; +} + +interface FetchAction { + type: typeof actionTypes.FETCH; + endpoint: Endpoint; + args: readonly [...Parameters]; + key: string; + meta: FetchMeta; +} +``` + +```js +{ + type: 'rdc/fetch', + key: 'GET https://jsonplaceholder.typicode.com/todos?userId=1', + args: [ + { + userId: 1 + } + ], + endpoint: Endpoint('User.getList'), + meta: { + fetchedAt: '5:09:41.975 PM', + resolve: function (){}, + reject: function (){}, + promise: {} + } +} +``` + +Sent by [Controller.fetch()](./Controller.md#fetch), [Controller.fetchIfStale()](./Controller.md#fetchIfStale), +[useSuspense()](./useSuspense.md), [useDLE()](./useDLE.md), [useLive()](./useLive.md), [useFetch()](./useFetch.md) + +Read by [NetworkManager](https://dataclient.io/docs/api/NetworkManager) + +## SET + +```ts +interface SetAction { + type: typeof actionTypes.SET; + schema: Queryable; + args: readonly any[]; + meta: ActionMeta; + value: {} | ((previousValue: Denormalize) => {}); +} +``` + +```js +{ + type: 'rdc/set', + value: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: true + }, + args: [ + { + id: 1 + } + ], + schema: Todo, + meta: { + fetchedAt: '5:18:26.394 PM', + date: '5:18:26.636 PM', + expiresAt: '6:18:26.636 PM' + } +} +``` + +Sent by [Controller.set()](./Controller.md#set) + +## SET\_RESPONSE + +```ts +interface SetResponseAction { + type: typeof actionTypes.SET_RESPONSE; + endpoint: Endpoint; + args: readonly any[]; + key: string; + meta: ActionMeta; + response: ResolveType | Error; + error: boolean; +} +``` + +```js +{ + type: 'rdc/setresponse', + key: 'PATCH https://jsonplaceholder.typicode.com/todos/1', + response: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: true + }, + args: [ + { + id: 1 + }, + { + completed: true + } + ], + endpoint: Endpont('Todo.partialUpdate'), + meta: { + fetchedAt: '5:18:26.394 PM', + date: '5:18:26.636 PM', + expiresAt: '6:18:26.636 PM' + }, + error: false +} +``` + +Sent by [Controller.setResponse()](./Controller.md#setResponse), [NetworkManager](https://dataclient.io/docs/api/NetworkManager) + +Read by [NetworkManager](https://dataclient.io/docs/api/NetworkManager), [LogoutManager](https://dataclient.io/docs/api/LogoutManager) + +## RESET + +```ts +interface ResetAction { + type: typeof actionTypes.RESET; + date: number; +} +``` + +```js +{ + type: 'rdc/reset', + date: '5:09:41.975 PM', +} +``` + +Sent by [Controller.resetEntireStore()](./Controller.md#resetEntireStore) + +Read by [NetworkManager](https://dataclient.io/docs/api/NetworkManager) + +## SUBSCRIBE + +```ts +interface SubscribeAction { + type: typeof actionTypes.SUBSCRIBE; + endpoint: Endpoint; + args: readonly any[]; + key: string; +} +``` + +```js +{ + type: 'rdc/subscribe', + key: 'GET https://api.exchange.coinbase.com/products/BTC-USD/ticker', + args: [ + { + product_id: 'BTC-USD' + } + ], + endpoint: Endpoint('https://api.exchange.coinbase.com/products/:product_id/ticker'), +} +``` + +Sent by [Controller.subscribe()](./Controller.md#subscribe), [useSubscription()](./useSubscription.md), [useLive()](./useLive.md) + +Read by [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) + +## UNSUBSCRIBE + +```ts +interface UnsubscribeAction { + type: typeof actionTypes.UNSUBSCRIBE; + endpoint: Endpoint; + args: readonly any[]; + key: string; +} +``` + +```js +{ + type: 'rdc/unsubscribe', + key: 'GET https://api.exchange.coinbase.com/products/BTC-USD/ticker', + args: [ + { + product_id: 'BTC-USD' + } + ], + endpoint: Endpoint('https://api.exchange.coinbase.com/products/:product_id/ticker'), +} +``` + +Sent by [Controller.unsubscribe()](./Controller.md#unsubscribe), [useSubscription()](./useSubscription.md), [useLive()](./useLive.md) + +Read by [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager) + +## INVALIDATE + +```ts +interface InvalidateAction { + type: typeof actionTypes.INVALIDATE; + key: string; +} +``` + +```js +{ + type: 'rdc/invalidate', + key: 'GET https://jsonplaceholder.typicode.com/todos?userId=1', +} +``` + +Sent by [Controller.invalidate()](./Controller.md#invalidate) + +## INVALIDATEALL + +```ts +interface InvalidateAllAction { + type: typeof actionTypes.INVALIDATEALL; + testKey: (key: string) => boolean; +} +``` + +```js +{ + type: 'rdc/invalidateall', + testKey: Endpoint('User.getList'), +} +``` + +Sent by [Controller.invalidateAll()](./Controller.md#invalidateAll) + +## EXPIREALL + +```ts +interface ExpireAllAction { + type: typeof actionTypes.EXPIREALL; + testKey: (key: string) => boolean; +} +``` + +```js +{ + type: 'rdc/expireall', + testKey: Endpoint('User.getList'), +} +``` + +Sent by [Controller.expireAll()](./Controller.md#expireAll) diff --git a/.agents/skills/data-client-react/references/AsyncBoundary.md b/.agents/skills/data-client-react/references/AsyncBoundary.md deleted file mode 120000 index 9698dc7419ab..000000000000 --- a/.agents/skills/data-client-react/references/AsyncBoundary.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/AsyncBoundary.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/AsyncBoundary.md b/.agents/skills/data-client-react/references/AsyncBoundary.md new file mode 100644 index 000000000000..a50542c3953f --- /dev/null +++ b/.agents/skills/data-client-react/references/AsyncBoundary.md @@ -0,0 +1,205 @@ + + +# \ + +Handles loading and error conditions of Suspense. + +In React 18, this will create a [concurrent split](https://react.dev/reference/react/useTransition), and in 16 and 17 it will show loading fallbacks. If there is an irrecoverable error, it will show an error fallback. + +> **Tip** +> +> Learn more about boundary placement by learning how to [co-locate data dependencies](./data-dependency.md) + +## Usage + +Place `AsyncBoundary` [at or above navigational boundaries](./data-dependency.md#boundaries) like **pages, routes, or modals**. + +**React Router** + +```tsx {9,11} title="Dashboard.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Outlet } from 'react-router'; + +export default function Dashboard() { + return ( +
+

Dashboard

+
+ + + +
+
+ ); +} +``` + +**NextJS** + +```tsx {12} title="app/dashboard/layout.tsx" +import { AsyncBoundary } from '@data-client/react'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Dashboard

+
+ {children} +
+
+ ); +} +``` + +**Expo** + +```tsx {15,17} title="app/dashboard/_layout.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Slot } from 'expo-router'; + +export default function DashboardLayout() { + return ( + + } + > + + + + + ); +} +``` + +**Antd Modal** + +```tsx title="ModalOpen.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Button, Modal } from 'antd'; + +export default function ModalOpen() { + return ( + <> + + + + + + + + ); +} +``` + +Then [useSuspense()](./useSuspense.md) in the components that render the data. Any errors or loading state +from _any_ descendant of the `` will be rendered at the ``. This consolidation +of fallback UI improves performance and usability. + +```ts +function SuspendingComponent() { + const data = useSuspense(getMyThing); + + return
{data.text}
; +} +``` + +## Props + +```ts +interface BoundaryProps { + children: React.ReactNode; + fallback?: React.ReactNode; + errorClassName?: string; + errorComponent?: React.ComponentType<{ + error: NetworkError; + resetErrorBoundary: () => void; + className?: string; + }>; + listen?: (resetListener: () => void) => () => void; +} +``` + +### fallback + +Any renderable (React Node) element to show when loading + +### errorComponent + +Component to handle caught errors + +#### Custom fallback example {#custom-fallback} + +```tsx +import React from 'react'; +import { DataProvider, AsyncBoundary } from '@data-client/react'; + +function ErrorPage({ + error, + className, + resetErrorBoundary, +}: { + error: Error; + resetErrorBoundary: () => void; + className?: string; +}) { + return ( +
+      {error.message} 
+    
+ ); +} + +export default function App() { + return ( + + + + + + ); +} +``` + +### errorClassName + +`className` to forward to [errorComponent](#errorcomponent) + +### listen + +Subscription handler to reset error state on events like URL location changes. This is great +for placing a boundary to wrap routing components. + +An example using [Anansi Router](https://www.npmjs.com/package/@anansi/router), which uses +[history](https://www.npmjs.com/package/history) subscription. + +```tsx +import { useController } from '@anansi/router'; +import { AsyncBoundary } from '@data-client/react'; + +function App() { + const { history } = useController(); + return ( +
+ +
+ + + +
+
+ ); +} +``` diff --git a/.agents/skills/data-client-react/references/Controller.md b/.agents/skills/data-client-react/references/Controller.md deleted file mode 120000 index 359c06beecd7..000000000000 --- a/.agents/skills/data-client-react/references/Controller.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Controller.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/Controller.md b/.agents/skills/data-client-react/references/Controller.md new file mode 100644 index 000000000000..eaf1bc603301 --- /dev/null +++ b/.agents/skills/data-client-react/references/Controller.md @@ -0,0 +1,653 @@ + + +# Controller + +`Controller` is a singleton providing safe access to the Reactive Data Client [flux store and lifecycle](https://dataclient.io/docs/api/Manager#control-flow). +`Controller` memoizes all store access, allowing a global referential equality guarantee and the fastest rendering +and retrieval performance. + +`Controller` is provided: + +- [Managers](https://dataclient.io/docs/api/Manager) as the first argument in [Manager.middleware](https://dataclient.io/docs/api/Manager#middleware) +- React with [useController()](./useController.md) +- [Unit testing hooks](https://dataclient.io/docs/guides/unit-testing-hooks) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook#controller) + +```ts +class Controller { + /*************** Action Dispatchers ***************/ + fetch(endpoint, ...args): ReturnType; + fetchIfStale(endpoint, ...args): ReturnType | undefined; + expireAll({ testKey }): Promise; + invalidate(endpoint, ...args): Promise; + invalidateAll({ testKey }): Promise; + resetEntireStore(): Promise; + set(queryable, ...args, value): Promise; + set([Entity], rows): Promise; + setResponse(endpoint, ...args, response): Promise; + setError(endpoint, ...args, error): Promise; + resolve(endpoint, { args, response, fetchedAt, error }): Promise; + subscribe(endpoint, ...args): Promise; + unsubscribe(endpoint, ...args): Promise; + /*************** Data Access ***************/ + get(queryable, ...args, state): Denormalized; + getResponse(endpoint, ...args, state): { data; expiryStatus; expiresAt }; + getError(endpoint, ...args, state): ErrorTypes | undefined; + snapshot(state: State, fetchedAt?: number): SnapshotInterface; + getState(): State; +} +``` + +## Action Dispatchers + +### fetch(endpoint, ...args) {#fetch} + +Fetches the endpoint with given args, updating the Reactive Data Client cache with +the response or error upon completion. + +**Create** + +```tsx +function CreatePost() { + const ctrl = useController(); + + return ( +
+ ctrl.fetch(PostResource.getList.push, new FormData(e.target)) + } + > + {/* ... */} +
+ ); +} +``` + +**Update** + +```tsx +function UpdatePost({ id }: { id: string }) { + const ctrl = useController(); + + return ( +
+ ctrl.fetch(PostResource.update, { id }, new FormData(e.target)) + } + > + {/* ... */} +
+ ); +} +``` + +**Delete** + +```tsx +function PostListItem({ post }: { post: PostResource }) { + const ctrl = useController(); + + const handleDelete = useCallback( + async e => { + await ctrl.fetch(PostResource.delete, { id: post.id }); + history.push('/'); + }, + [ctrl, id], + ); + + return ( +
+

{post.title}

+ +
+ ); +} +``` + +> **Tip** +> +> `fetch` has the same return value as the [Endpoint](https://dataclient.io/rest/api/Endpoint) passed to it. +> When using schemas, the denormalized value is returned +> +> ```ts +> import { useController } from '@data-client/react'; +> +> const post = await controller.fetch( +> PostResource.getList.push, +> createPayload, +> ); +> post.title; +> post.pk(); +> ``` + +#### Endpoint.sideEffect + +[sideEffect](https://dataclient.io/rest/api/Endpoint#sideeffect) changes the behavior + +##### true + +- Resolves _before_ [committing](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom) Reactive Data Client cache updates. (React 16, 17) +- Each call will always cause a new fetch. + +##### false | undefined + +- Resolves _after_ [committing](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom) Reactive Data Client cache updates. +- Identical requests are deduplicated globally; allowing only one inflight request at a time. + - To ensure a _new_ request is started, make sure to abort any existing inflight requests. + +### fetchIfStale(endpoint, ...args) {#fetchIfStale} + +Fetches only if endpoint is considered '[stale](https://dataclient.io/docs/concepts/expiry-policy#stale)'. + +This can be useful when prefetching data, as it avoids overfetching fresh data. + +An [example](https://stackblitz.com/github/reactive/data-client/tree/master/examples/github-app?file=src%2Frouting%2Froutes.tsx) with a fetch-as-you-render router: + +```ts +{ + name: 'IssueList', + component: lazyPage('IssuesPage'), + title: 'issue list', + resolveData: async ( + controller: Controller, + { owner, repo }: { owner: string; repo: string }, + searchParams: URLSearchParams, + ) => { + const q = searchParams?.get('q') || 'is:issue is:open'; + await controller.fetchIfStale(IssueResource.search, { + owner, + repo, + q, + }); + }, +}, +``` + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/routing/routes.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/routing/routes.tsx)) + +### expireAll({ testKey }) {#expireAll} + +Sets all responses' [expiry status](https://dataclient.io/docs/concepts/expiry-policy) matching `testKey` to [Stale](https://dataclient.io/docs/concepts/expiry-policy#stale). + +This is sometimes useful to trigger refresh of only data presently shown +when there are many parameterizations in cache. + +```tsx +import { type Controller, useController } from '@data-client/react'; + +const createTradeHandler = (ctrl: Controller) => async trade => { + await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade)); + ctrl.expireAll(AccountResource.get); + ctrl.expireAll(AccountResource.getList); +}; + +function CreateTrade({ id }: { id: string }) { + const handleTrade = createTradeHandler(useController()); + + return ( +
+ + + + + ); +} +``` + +> **Tip** +> +> To reduce load, improve performance, and improve state consistency; it can often be +> better to [include mutation sideeffects in the mutation response](https://dataclient.io/rest/guides/side-effects). + +### invalidate(endpoint, ...args) {#invalidate} + +Forces refetching and suspense on [useSuspense](./useSuspense.md) with the same Endpoint +and parameters. + +```tsx +function ArticleName({ id }: { id: string }) { + const article = useSuspense(ArticleResource.get, { id }); + const ctrl = useController(); + + return ( +
+

{article.title}

+ +

+ ); +} +``` + +> **Tip** +> +> To refresh while continuing to display stale data - [Controller.fetch](#fetch). + +> **Tip: Invalidate many endpoints at once** +> +> Use [schema.Invalidate](https://dataclient.io/rest/api/Invalidate) to invalidate every endpoint that contains a given entity. +> +> For REST try using [Resource.delete](https://dataclient.io/rest/api/resource#delete) +> +> ```ts +> // deletes MyResource(5) +> // this will resuspend MyResource.get({id: '5'}) +> // and remove it from MyResource.getList +> controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' }); +> ``` + +### invalidateAll({ testKey }) {#invalidateAll} + +[Invalidates](https://dataclient.io/docs/concepts/expiry-policy#invalid) all [endpoint keys](https://dataclient.io/rest/api/RestEndpoint#key) matching `testKey`. + +```tsx +function ArticleName({ id }: { id: string }) { + const article = useSuspense(ArticleResource.get, { id }); + const ctrl = useController(); + + return ( +
+

{article.title}

+ +

+ ); +} +``` + +> **Tip** +> +> To refresh while continuing to display stale data - [Controller.expireAll](#expireAll) instead. + +Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache. + +```tsx +const myDomain = 'http://test.com'; +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +function useLogout() { + const ctrl = useController(); + return () => ctrl.invalidateAll({ testKey }); +} +``` + +It's usually a good idea to also clear cache on 401 (unauthorized) with [LogoutManager](https://dataclient.io/docs/api/LogoutManager) +as well. + +```ts +import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; +import { unAuth } from '../authentication'; + +const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); + +const managers = [ + new LogoutManager({ + handleLogout(controller) { + // call custom unAuth function we defined + unAuth(); + // still reset the store + controller.invalidateAll({ testKey }); + }, + }), + ...getDefaultManagers(), +]; + +createRoot(document.body).render( + + + , +); +``` + +### resetEntireStore() {#resetEntireStore} + +Resets/clears the entire Reactive Data Client cache. All inflight requests will not resolve. + +This is typically used when logging out or changing authenticated users. + +```tsx +const USER_NUMBER_ONE: string = "1111"; + +function UserName() { + const user = useSuspense(CurrentUserResource.get); + const ctrl = useController(); + + const becomeAdmin = useCallback(() => { + // Changes the current user + impersonateUser(USER_NUMBER_ONE); + ctrl.resetEntireStore(); + }, [ctrl]); + return ( +
+

{user.name}

+ +

+ ); +} +``` + +### set(queryable, ...args, value) {#set} + +Updates any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schema](https://dataclient.io/rest/api/schema#schema-overview), or many entities at once with an [Array](https://dataclient.io/rest/api/Array) or [Values](https://dataclient.io/rest/api/Values) schema. + +```ts +ctrl.set( + Todo, + // which Todo to update + { id: '5' }, + // merge this data into the Todo in the store + { id: '5', title: 'tell me friends how great Data Client is' }, +); +``` + +The value is typed by the schema: an [Entity](https://dataclient.io/rest/api/Entity) takes its fields (numbers and strings may be either), +while a [Collection](https://dataclient.io/rest/api/Collection) or [All](https://dataclient.io/rest/api/All) takes a list of rows. A [Query](https://dataclient.io/rest/api/Query) +takes the input of the schema it wraps, since `set()` normalizes that schema rather than reversing `process()`. + +```ts +ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]); +``` + +> **Note: Type checking limits** +> +> To keep type checking fast for large [Unions](https://dataclient.io/rest/api/Union), a Union row is checked against the +> combined fields of all its members rather than against one member. Each field's type is still checked, +> but a row that mixes fields from different members (like `{ type: 'first', secondField: 1 }`) is not +> an error. Make sure the fields you set belong to the member the row's discriminator selects. + +Functions can be used in the value when derived data is used. This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). + +```ts +const id = '2'; +ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 })); +``` + +#### set(\[Entity], rows) {#set-array} + +Pass an [Array](https://dataclient.io/rest/api/Array) schema (`[Todo]` or `new schema.Array(Todo)`) and a list of rows to update +many entities in one store update. Each row merges with its stored entity; entities not in the list are untouched. + +```ts +ctrl.set( + [Todo], + [ + { id: '5', completed: true }, + { id: '6', completed: false }, + ], +); +``` + +Rows are typed by the Entity's fields; numbers and strings may be either, and object, array and Date values are not +checked since rows are raw input. + +For lists that mix Entity types, use a [Union](https://dataclient.io/rest/api/Union); each row is stored by its `type`: + +```ts +const Feed = new schema.Union({ post: Post, comment: Comment }, 'type'); + +ctrl.set( + [Feed], + [ + { id: '1', type: 'post', title: 'Hello' }, + { id: '7', type: 'comment', body: 'Nice!' }, + ], +); +``` + +To delete many entities at once, use [Invalidate](https://dataclient.io/rest/api/Invalidate#batch-invalidation); rows only need their pk +fields: + +```ts +ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]); +``` + +[Values](https://dataclient.io/rest/api/Values) schemas take an object of rows instead: + +```ts +ctrl.set(new schema.Values(Todo), { + '5': { id: '5', completed: true }, + '6': { id: '6', completed: false }, +}); +``` + +Array and Values schemas take no `args` (so [Entity.pk()](https://dataclient.io/rest/api/Entity#pk) and [Entity.process()](https://dataclient.io/rest/api/Entity#process) +receive `[]`) and no updater function. Rows that share a pk merge in list order, without +[Entity.shouldReorder()](https://dataclient.io/rest/api/Entity#shouldreorder). Use this instead of calling `set()` once per row, such as when +[batching high-frequency stream updates](https://dataclient.io/docs/concepts/managers#batching). + +### setResponse(endpoint, ...args, response) {#setResponse} + +Stores `response` in cache for given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args. + +Any components suspending for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args will resolve. + +If data already exists for the given [Endpoint](https://dataclient.io/rest/api/Endpoint) and args, it will be updated. + +```tsx +const ctrl = useController(); + +useEffect(() => { + const websocket = new Websocket(url); + + websocket.onmessage = event => + ctrl.setResponse( + EndpointLookup[event.endpoint], + ...event.args, + event.data, + ); + + return () => websocket.close(); +}); +``` + +This shows a proof of concept in React; however a [Manager websockets implementation](https://dataclient.io/docs/concepts/managers#data-stream) +would be much more robust. + +### setError(endpoint, ...args, error) {#setError} + +Stores the result of [Endpoint](https://dataclient.io/rest/api/Endpoint) and args as the error provided. + +### resolve(endpoint, { args, response, fetchedAt, error }) {#resolve} + +Resolves a specific fetch, storing the `response` in cache. + +This is similar to setResponse, except it triggers resolution of an inflight fetch. +This means the corresponding optimistic update will no longer be applies. + +This is used in [NetworkManager](https://dataclient.io/docs/api/NetworkManager), and should be used when +processing fetch requests. + +### subscribe(endpoint, ...args) {#subscribe} + +Marks a new subscription to a given [Endpoint](https://dataclient.io/rest/api/Endpoint). This should increment the subscription. + +[useSubscription](./useSubscription.md) and [useLive](./useLive.md) call this on mount. + +This might be useful for custom hooks to sub/unsub based on other factors. + +```tsx +const controller = useController(); +const key = endpoint.key(...args); + +useEffect(() => { + controller.subscribe(endpoint, ...args); + return () => controller.unsubscribe(endpoint, ...args); +}, [controller, key]); +``` + +### unsubscribe(endpoint, ...args) {#unsubscribe} + +Marks completion of subscription to a given [Endpoint](https://dataclient.io/rest/api/Endpoint). This should +decrement the subscription and if the count reaches 0, more updates won't be received automatically. + +[useSubscription](./useSubscription.md) and [useLive](./useLive.md) call this on unmount. + +## Data Access + +### get(schema, ...args, state) {#get} + +Looks up any [Queryable](https://dataclient.io/rest/api/schema#queryable) [Schema](https://dataclient.io/rest/api/schema#schema-overview) in `state`. + +#### Example + +This is used in [useQuery](./useQuery.md) and can be used in +[Managers](https://dataclient.io/docs/api/Manager) to safely access the store. + +```tsx title="useQuery.ts" +import { + useController, + useCacheState, + type Queryable, + type SchemaArgs, + type DenormalizeNullable, +} from '@data-client/core'; + +/** Oversimplified useQuery */ +function useQuery( + schema: S, + ...args: SchemaArgs +): DenormalizeNullable | undefined { + const state = useCacheState(); + const controller = useController(); + + return controller.get(schema, ...args, state); +} +``` + +### getResponse(endpoint, ...args, state) {#getResponse} + +```ts title="returns" +{ + data: DenormalizeNullable; + expiryStatus: ExpiryStatus; + expiresAt: number; +} +``` + +Gets the (globally referentially stable) response for a given endpoint/args pair from state given. + +#### data + +The denormalize response data. Guarantees global referential stability for all members. + +#### [expiryStatus](https://dataclient.io/docs/concepts/expiry-policy#expiry-status) + +```ts +export enum ExpiryStatus { + Invalid = 1, + InvalidIfStale, + Valid, +} +``` + +##### Valid + +- Will never suspend. +- Might fetch if data is stale + +##### InvalidIfStale + +- Will suspend if data is stale. +- Might fetch if data is stale + +##### Invalid + +- Will always suspend +- Will always fetch + +#### expiresAt + +A number representing time when it expires. Compare to Date.now(). + +#### Example + +This is used in [useCache](./useCache.md), [useSuspense](./useSuspense.md) and can be used in +[Managers](https://dataclient.io/docs/api/Manager) to lookup a response with the state provided. + +```tsx title="useCache.ts" +import { + useController, + StateContext, + EndpointInterface, +} from '@data-client/core'; + +/** Oversimplified useCache */ +function useCache( + endpoint: E, + ...args: readonly [...Parameters] +) { + const state = useContext(StateContext); + const controller = useController(); + return controller.getResponse(endpoint, ...args, state).data; +} +``` + +```tsx title="MyManager.ts" +import type { Manager, Middleware, actionTypes } from '@data-client/core'; +import type { EndpointInterface } from '@data-client/endpoint'; + +export default class MyManager implements Manager { + middleware: Middleware = controller => { + return next => async action => { + if (action.type === actionTypes.FETCH) { + console.log('The existing response of the requested fetch'); + console.log( + controller.getResponse( + action.endpoint, + ...(action.meta.args as Parameters), + controller.getState(), + ).data, + ); + } + next(action); + }; + }; + + cleanup() { + this.websocket.close(); + } +} +``` + +### getError(endpoint, ...args, state) {#getError} + +Gets the error, if any, for a given endpoint. Returns undefined for no errors. + +### snapshot(state, fetchedAt) {#snapshot} + +Returns a [Snapshot](https://dataclient.io/docs/api/Snapshot). + +### getState() {#getState} + +Gets the internal state of Reactive Data Client that has _already been [committed](https://react.dev/learn/render-and-commit#step-3-react-commits-changes-to-the-dom)_. + +> **Warning** +> +> This should only be used in event handlers or [Managers](https://dataclient.io/docs/api/Manager). +> +> Using getState() in React's render lifecycle can result in data tearing. + +```tsx +const controller = useController(); + +const updateHandler = useCallback( + async updatePayload => { + const response = await controller.fetch( + MyResource.update, + { id }, + updatePayload, + ); + // the fetch has completed, but react has not yet re-rendered + // this lets use sequence after the next re-render + // we're working on a better solution to this specific case + setTimeout(() => { + const { data: denormalized } = controller.getResponse( + MyResource.update, + { id }, + updatePayload, + controller.getState(), + ); + redirect(denormalized.getterUrl); + }, 40); + }, + [id], +); +``` diff --git a/.agents/skills/data-client-react/references/DataProvider.md b/.agents/skills/data-client-react/references/DataProvider.md deleted file mode 120000 index 3baec4fe4f46..000000000000 --- a/.agents/skills/data-client-react/references/DataProvider.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/DataProvider.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/DataProvider.md b/.agents/skills/data-client-react/references/DataProvider.md new file mode 100644 index 000000000000..e04910c1b113 --- /dev/null +++ b/.agents/skills/data-client-react/references/DataProvider.md @@ -0,0 +1,199 @@ + + +# \ + +Manages state, providing all context needed to use the hooks. Should be placed as high as possible +in application tree as any usage of the hooks is only possible for components below the provider +in the React tree. + +**Web** + +```tsx title="index.tsx" +import { DataProvider } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +createRoot(document.body).render( + + + , +); +``` + +Alternatively [integrate state with redux](https://dataclient.io/docs/guides/redux) + +**React Native** + +```tsx title="index.tsx" +import { DataProvider } from '@data-client/react'; +import { AppRegistry } from 'react-native'; + +const Root = () => ( + + + +); +AppRegistry.registerComponent('MyApp', () => Root); +``` + +Alternatively [integrate state with redux](https://dataclient.io/docs/guides/redux) + +**NextJS** + +[Full NextJS Guide](https://dataclient.io/docs/guides/ssr#nextjs) + +```tsx title="app/layout.tsx" +import { DataProvider } from '@data-client/react/nextjs'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +**Expo** + +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import { DataProvider } from '@data-client/react'; + +export default function RootLayout() { + return ( + + + + + + ); +} +``` + +**Anansi** + +[Anansi](https://github.com/ntucker/anansi) (beta) is a fully composable framework for React development with optional +Server Side Rendering. + +```bash title="bash" +npx @anansi/cli hatch my-project +``` + +Anansi includes Reactive Data Client automatically. + +## Props + +```typescript +interface ProviderProps { + children: ReactNode; + managers?: Manager[]; + initialState?: State; + Controller?: typeof Controller; + devButton?: + | 'bottom-right' + | 'bottom-left' + | 'top-right' + | 'top-left' + | null; +} +``` + +### initialState: State\ {#initialState} + +```typescript +export interface State { + readonly entities: { + readonly [entityKey: string]: { readonly [pk: string]: T } | undefined; + }; + readonly endpoints: { + readonly [key: string]: unknown | PK[] | PK | undefined; + }; + readonly indexes: NormalizedIndex; + readonly meta: { + readonly [key: string]: { + readonly date: number; + readonly error?: ErrorTypes; + readonly expiresAt: number; + readonly prevExpiresAt?: number; + readonly invalidated?: boolean; + readonly errorPolicy?: 'hard' | 'soft' | undefined; + }; + }; + readonly entitiesMeta: { + readonly [entityKey: string]: { + readonly [pk: string]: { + readonly date: number; + readonly expiresAt: number; + readonly fetchedAt: number; + }; + }; + }; + readonly optimistic: (SetResponseAction | OptimisticAction)[]; + readonly lastReset: number; +} +``` + +Instead of starting with an empty cache, you can provide your own initial state. This can +be useful for testing, or rehydrating the cache state when using server side rendering. + +### managers?: Manager\[] {#managers} + +List of [Manager](https://dataclient.io/docs/api/Manager)s use. This is the main extensibility point of the provider. + +[getDefaultManagers()](https://dataclient.io/docs/api/getDefaultManagers) can be used to extend the default managers. + +Default Production: + +```typescript +[new NetworkManager(), new SubscriptionManager(PollingSubscription)]; +``` + +Default Development: + +```typescript +[ + new DevToolsManager(), + new NetworkManager(), + new SubscriptionManager(PollingSubscription), +]; +``` + +### Controller: typeof Controller {#Controller} + +This allows you to extend [Controller](./Controller.md) to provide additional functionality. +This might be useful if you have additional actions you want to dispatch to custom [Managers](https://dataclient.io/docs/api/Manager) + +```tsx +class MyController extends Controller { + doSomething = () => { + console.log('hi'); + }; +} + +const RealApp = ( + + + +); +``` + +### devButton + +In development, a small button will appear that gives easy access to [browser devtools](https://dataclient.io/docs/getting-started/debugging) if +installed. This option configures where it shows up, or if null will disable it altogether. + +`'bottom-right' | 'bottom-left' | 'top-right'| 'top-left' | null` = `'bottom-right'` + +```tsx title="Disable button" + + + +``` + +```tsx title="Place in top right corner" + + + +``` diff --git a/.agents/skills/data-client-react/references/_AsyncBoundary.md b/.agents/skills/data-client-react/references/_AsyncBoundary.md deleted file mode 120000 index 57d2b9fdece5..000000000000 --- a/.agents/skills/data-client-react/references/_AsyncBoundary.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_AsyncBoundary.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/_AsyncBoundary.md b/.agents/skills/data-client-react/references/_AsyncBoundary.md new file mode 100644 index 000000000000..bc7a7030075f --- /dev/null +++ b/.agents/skills/data-client-react/references/_AsyncBoundary.md @@ -0,0 +1,89 @@ + + +**React Router** + +```tsx {9,11} title="Dashboard.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Outlet } from 'react-router'; + +export default function Dashboard() { + return ( +
+

Dashboard

+
+ + + +
+
+ ); +} +``` + +**NextJS** + +```tsx {12} title="app/dashboard/layout.tsx" +import { AsyncBoundary } from '@data-client/react'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Dashboard

+
+ {children} +
+
+ ); +} +``` + +**Expo** + +```tsx {15,17} title="app/dashboard/_layout.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Slot } from 'expo-router'; + +export default function DashboardLayout() { + return ( + + } + > + + + + + ); +} +``` + +**Antd Modal** + +```tsx title="ModalOpen.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Button, Modal } from 'antd'; + +export default function ModalOpen() { + return ( + <> + + + + + + + + ); +} +``` diff --git a/.agents/skills/data-client-react/references/_VoteDemo.md b/.agents/skills/data-client-react/references/_VoteDemo.md deleted file mode 120000 index f3bd45e85171..000000000000 --- a/.agents/skills/data-client-react/references/_VoteDemo.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_VoteDemo.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/_VoteDemo.md b/.agents/skills/data-client-react/references/_VoteDemo.md new file mode 100644 index 000000000000..4a419317f07f --- /dev/null +++ b/.agents/skills/data-client-react/references/_VoteDemo.md @@ -0,0 +1,129 @@ + + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-react/references/_pagination.md b/.agents/skills/data-client-react/references/_pagination.md deleted file mode 120000 index 44c3e22eb030..000000000000 --- a/.agents/skills/data-client-react/references/_pagination.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_pagination.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/_pagination.md b/.agents/skills/data-client-react/references/_pagination.md new file mode 100644 index 000000000000..fcb5f18ee51c --- /dev/null +++ b/.agents/skills/data-client-react/references/_pagination.md @@ -0,0 +1,111 @@ + + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```tsx title="PostItem" +import { type Post } from './Post'; + +export default function PostItem({ post }: Props) { + return ( +
+ +
+

{post.title}

+ by {post.author.name} +
+
+ ); +} + +interface Props { + post: Post; +} +``` + +```tsx title="LoadMore" {7} +import { useController, useLoading } from '@data-client/react'; +import { PostResource } from './Post'; + +export default function LoadMore({ cursor }: { cursor: string }) { + const ctrl = useController(); + const [loadPage, isPending] = useLoading( + () => ctrl.fetch(PostResource.getList.getPage, { cursor }), + [cursor], + ); + return ( +
+ +
+ ); +} +``` + +```tsx title="PostList" {7} +import { useSuspense } from '@data-client/react'; +import PostItem from './PostItem'; +import LoadMore from './LoadMore'; +import { PostResource } from './Post'; + +export default function PostList() { + const { posts, cursor } = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( + + ))} + {cursor ? : null} +
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-react/references/_useLive.md b/.agents/skills/data-client-react/references/_useLive.md deleted file mode 120000 index eb946942cad6..000000000000 --- a/.agents/skills/data-client-react/references/_useLive.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_useLive.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/_useLive.md b/.agents/skills/data-client-react/references/_useLive.md new file mode 100644 index 000000000000..c4c5fb9d15b1 --- /dev/null +++ b/.agents/skills/data-client-react/references/_useLive.md @@ -0,0 +1,59 @@ + + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```tsx title="AssetPrice" {5} +import { useLive } from '@data-client/react'; +import { getTicker } from './Ticker'; + +function AssetPrice({ productId }: Props) { + const ticker = useLive(getTicker, { productId }); + return ( +
+ {productId}{' '} + +
+ ); +} +interface Props { + productId: string; +} +render(); +``` diff --git a/.agents/skills/data-client-react/references/_useLoading.md b/.agents/skills/data-client-react/references/_useLoading.md deleted file mode 120000 index 9ed6b996c6bc..000000000000 --- a/.agents/skills/data-client-react/references/_useLoading.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_useLoading.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/_useLoading.md b/.agents/skills/data-client-react/references/_useLoading.md new file mode 100644 index 000000000000..fe5b737f58aa --- /dev/null +++ b/.agents/skills/data-client-react/references/_useLoading.md @@ -0,0 +1,118 @@ + + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```tsx title="PostDetail" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; + +export default function PostDetail({ id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+ +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +``` + +```tsx title="PostForm" +export default function PostForm({ onSubmit, loading, error }) { + const handleSubmit = e => { + e.preventDefault(); + const data = new FormData(e.target); + onSubmit(data); + }; + return ( +
+ + + {error ? ( +
{error.message}
+ ) : null} +
+ +
+ + ); +} +``` + +```tsx title="PostCreate" {7} +import { useLoading, useController } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostForm from './PostForm'; + +export default function PostCreate({ navigateToPost }) { + const ctrl = useController(); + const [handleSubmit, loading, error] = useLoading( + async data => { + const post = await ctrl.fetch(PostResource.getList.push, data); + navigateToPost(post.id); + }, + [ctrl], + ); + return ( + + ); +} +``` + +```tsx title="Navigation" +import PostCreate from './PostCreate'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [id, setId] = React.useState(undefined); + if (id) { + return ( +
+ +
+ +
+
+ ); + } + return ; +} +render(); +``` diff --git a/.agents/skills/data-client-react/references/data-dependency.md b/.agents/skills/data-client-react/references/data-dependency.md deleted file mode 120000 index 1613833f6d57..000000000000 --- a/.agents/skills/data-client-react/references/data-dependency.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/getting-started/data-dependency.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/data-dependency.md b/.agents/skills/data-client-react/references/data-dependency.md new file mode 100644 index 000000000000..d28ab8c5a625 --- /dev/null +++ b/.agents/skills/data-client-react/references/data-dependency.md @@ -0,0 +1,426 @@ + + +# Rendering Asynchronous Data + +Make your components reusable by binding the data where you **use** it with the one-line [useSuspense()](./useSuspense.md), +which guarantees data like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'page', +}); +``` + +```tsx title="PostDetail" {5} +import { useSuspense } from '@data-client/react'; +import { PostResource } from './Resources'; + +export default function PostDetail({ setRoute, id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+
+
+ + {post.author.name} +
+

{post.title}

+
+
+

{post.body}

+ { + e.preventDefault(); + setRoute('list'); + }} + > + « Back + +
+ ); +} +``` + +```tsx title="PostItem" +import { type Post } from './Resources'; + +export default function PostItem({ post, setRoute }: Props) { + return ( + + ); +} + +interface Props { + post: Post; + setRoute: Function; +} +``` + +```tsx title="PostList" {6} +import { useSuspense } from '@data-client/react'; +import PostItem from './PostItem'; +import { PostResource } from './Resources'; + +export default function PostList({ setRoute }) { + const posts = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( + + ))} +
+ ); +} +``` + +```tsx title="Navigation" +import { useController, useLoading } from '@data-client/react'; +import { PostResource } from './Resources'; +import PostList from './PostList'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [route, setRoute] = React.useState('list'); + if (route.startsWith('detail')) + return ; + + return ( + <> + + + + ); +} + +function LoadMore() { + const ctrl = useController(); + const posts = useQuery(PostResource.getList.schema); + const [nextPage, isPending] = useLoading(() => + ctrl.fetch(PostResource.getList.getPage, { page: 2 }), + ); + if (!posts || posts.length % 3 !== 0) return null; + return ( +
+ +
+ ); +} +render(); +``` + +[](https://react.dev/learn/passing-data-deeply-with-context) + +Do not [prop drill](https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props). Instead, [useSuspense()](./useSuspense.md) in the components that render the data from it. This is +known as _data co-location_. + +Do not hide data binding hooks inside custom hooks. Instead, put tightly coupled data transformations +in [Query](https://dataclient.io/rest/api/Query) — data logic belongs with the data model, where it stays visible, reusable, +and free to change independently of the view. + +Instead of writing complex update functions or invalidations cascades, Reactive Data Client automatically updates +bound components immediately upon [data change](./mutations.md). This is known as _reactive programming_. + +## Loading and Error {#async-fallbacks} + +You might have noticed the return type shows the value is always there. [useSuspense()](./useSuspense.md) operates very much +like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables +us to make error/loading disjoint from data usage. + +### Async Boundaries {#boundaries} + +Instead we place [\](./AsyncBoundary.md) to handling loading and error conditions at or above navigational boundaries like **pages, +routes, or [modals](https://www.appcues.com/blog/modal-dialog-windows)**. + +**React Router** + +```tsx {9,11} title="Dashboard.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Outlet } from 'react-router'; + +export default function Dashboard() { + return ( +
+

Dashboard

+
+ + + +
+
+ ); +} +``` + +**NextJS** + +```tsx {12} title="app/dashboard/layout.tsx" +import { AsyncBoundary } from '@data-client/react'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Dashboard

+
+ {children} +
+
+ ); +} +``` + +**Expo** + +```tsx {15,17} title="app/dashboard/_layout.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Slot } from 'expo-router'; + +export default function DashboardLayout() { + return ( + + } + > + + + + + ); +} +``` + +**Antd Modal** + +```tsx title="ModalOpen.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Button, Modal } from 'antd'; + +export default function ModalOpen() { + return ( + <> + + + + + + + + ); +} +``` + +React 18's [useTransition](https://react.dev/reference/react/useTransition) and [Server Side Rendering](https://dataclient.io/docs/guides/ssr) +powered routers or navigation means never seeing a loading fallback again. In React 16 and 17 fallbacks can be centralized +to eliminate redundant loading indicators while keeping components reusable. + +[\](./AsyncBoundary.md) also allows [Server Side Rendering](https://dataclient.io/docs/guides/ssr) to incrementally stream HTML, +greatly reducing [TTFB](https://web.dev/ttfb/). [Reactive Data Client SSR's](https://dataclient.io/docs/guides/ssr) automatic store hydration +means immediate user interactivity with **zero** client-side fetches on first load. + +AsyncBoundary's [error fallback](./AsyncBoundary.md#errorcomponent) and [loading fallback](./AsyncBoundary.md#fallback) can both +be customized. + +### Stateful + +You may find cases where it's still useful to use a stateful approach to fallbacks when using React 16 and 17. +For these cases, or compatibility with some component libraries, [useDLE()](./useDLE.md) - \[D]ata \[L]oading \[E]rror - is provided. + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```tsx title="ProfileList" +import { useDLE } from '@data-client/react'; +import { ProfileResource } from './ProfileResource'; + +function ProfileList(): JSX.Element { + const { data, loading, error } = useDLE(ProfileResource.getList); + if (error) return
Error {`${error.status}`}
; + if (loading || !data) return ; + return ( +
+ {data.map(profile => ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ))} +
+ ); +} +render(); +``` + +Since [useDLE](./useDLE.md) does not [useSuspense](./useSuspense.md), you won't be able to easily centrally +orchestrate loading and error code. Additionally, React 18 features like [useTransition](https://react.dev/reference/react/useTransition), +and [incrementally streaming SSR](https://dataclient.io/docs/guides/ssr) won't work with components that use it. + +## Conditional + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useSuspense(TodoResource.get, id ? { id } : null); +> ``` + +## Subscriptions + +When data is likely to change due to external factor; [useSubscription()](./useSubscription.md) +ensures continual updates while a component is mounted. [useLive()](./useLive.md) calls both +[useSubscription()](./useSubscription.md) and [useSuspense()](./useSuspense.md), making it quite +easy to use fresh data. + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```tsx title="AssetPrice" {5} +import { useLive } from '@data-client/react'; +import { getTicker } from './Ticker'; + +function AssetPrice({ productId }: Props) { + const ticker = useLive(getTicker, { productId }); + return ( +
+ {productId}{' '} + +
+ ); +} +interface Props { + productId: string; +} +render(); +``` + +Subscriptions are orchestrated by [Managers](https://dataclient.io/docs/api/Manager). Out of the box, +polling based subscriptions can be used by adding [pollFrequency](https://dataclient.io/rest/api/Endpoint#pollfrequency) to an Endpoint or Resource. +For pushed based networking protocols like SSE and websockets, see the [example stream manager](https://dataclient.io/docs/concepts/managers#data-stream). + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + pollFrequency: 2000, +}); +``` diff --git a/.agents/skills/data-client-react/references/mutations.md b/.agents/skills/data-client-react/references/mutations.md deleted file mode 120000 index aaa624e6eb92..000000000000 --- a/.agents/skills/data-client-react/references/mutations.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/getting-started/mutations.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/mutations.md b/.agents/skills/data-client-react/references/mutations.md new file mode 100644 index 000000000000..8499d9c2c6e2 --- /dev/null +++ b/.agents/skills/data-client-react/references/mutations.md @@ -0,0 +1,371 @@ + + +# Data mutations + +Using our [Create, Update, and Delete](https://dataclient.io/docs/concepts/atomic-mutations) endpoints with +[Controller.fetch()](./Controller.md#fetch) reactively updates _all_ appropriate components atomically (at the same time). + +[useController()](./useController.md) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate). + +[//]: # "TODO: Add create, and delete examples as well (in tabs)" + +```ts title="TodoResource" +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = 0; + userId = 0; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" {7-11,13-15} +import { useController } from '@data-client/react'; +import { TodoResource, type Todo } from './TodoResource'; + +export default function TodoItem({ todo }: { todo: Todo }) { + const ctrl = useController(); + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + const handleDelete = () => + ctrl.fetch(TodoResource.delete, { + id: todo.id, + }); + return ( +
+ + +
+ ); +} +``` + +```tsx title="CreateTodo" {8-11} +import { useController } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +export default function CreateTodo({ userId }: { userId: number }) { + const ctrl = useController(); + const handleKeyDown = async e => { + if (e.key === 'Enter') { + ctrl.fetch(TodoResource.getList.push, { + userId, + title: e.currentTarget.value, + }); + e.currentTarget.value = ''; + } + }; + return ( +
+ + +
+ ); +} +``` + +```tsx title="TodoList" +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; +import CreateTodo from './CreateTodo'; + +function TodoList() { + const userId = 1; + const todos = useSuspense(TodoResource.getList, { userId }); + return ( +
+ {todos.map(todo => ( + + ))} + +
+ ); +} +render(); +``` + +Rather than triggering invalidation cascades or using manually written update functions, +Data Client reactively updates appropriate components using the fetch response. + +## Optimistic mutations based on previous state {#optimistic-updates} + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[getOptimisticResponse](https://dataclient.io/rest/guides/optimistic-updates) is just like [setState with an updater function](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). [Snapshot](https://dataclient.io/docs/api/Snapshot) provides typesafe access to the previous store value, +which we use to return the _expected_ fetch response. + +Reactive Data Client ensures [data integrity against any possible networking failure or race condition](https://dataclient.io/rest/guides/optimistic-updates#optimistic-transforms), so don't +worry about network failures, multiple mutation calls editing the same data, or other common +problems in asynchronous programming. + +## Tracking mutation loading + +[useLoading()](./useLoading.md) enhances async functions by tracking their loading and error states. + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```tsx title="PostDetail" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; + +export default function PostDetail({ id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+ +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +``` + +```tsx title="PostForm" +export default function PostForm({ onSubmit, loading, error }) { + const handleSubmit = e => { + e.preventDefault(); + const data = new FormData(e.target); + onSubmit(data); + }; + return ( +
+ + + {error ? ( +
{error.message}
+ ) : null} +
+ +
+ + ); +} +``` + +```tsx title="PostCreate" {7} +import { useLoading, useController } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostForm from './PostForm'; + +export default function PostCreate({ navigateToPost }) { + const ctrl = useController(); + const [handleSubmit, loading, error] = useLoading( + async data => { + const post = await ctrl.fetch(PostResource.getList.push, data); + navigateToPost(post.id); + }, + [ctrl], + ); + return ( + + ); +} +``` + +```tsx title="Navigation" +import PostCreate from './PostCreate'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [id, setId] = React.useState(undefined); + if (id) { + return ( +
+ +
+ +
+
+ ); + } + return ; +} +render(); +``` diff --git a/.agents/skills/data-client-react/references/useCache.md b/.agents/skills/data-client-react/references/useCache.md deleted file mode 120000 index 3659e1fe1516..000000000000 --- a/.agents/skills/data-client-react/references/useCache.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useCache.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useCache.md b/.agents/skills/data-client-react/references/useCache.md new file mode 100644 index 000000000000..8a31af9902e6 --- /dev/null +++ b/.agents/skills/data-client-react/references/useCache.md @@ -0,0 +1,141 @@ + + +# useCache() + +Data rendering without the fetch. + +Access any [Endpoint](https://dataclient.io/rest/api/Endpoint)'s response. If the response does not exist, returns +`undefined`. This can be used to check for an `Endpoint's` existance like for authentication. + +`useCache()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +```ts title="UserResource" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; + isAdmin = false; + + static key = 'User'; +} +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}).extend('current', { + path: '/user', + schema: User, +}); +``` + +```tsx title="Unauthed" +import { useLoading } from '@data-client/react'; +import { UserResource } from './UserResource'; + +export default function Unauthed() { + const ctrl = useController(); + const [handleLogin, loading] = useLoading( + (e: any) => ctrl.fetch(UserResource.current), + [], + ); + return ( +
+

Not authorized

+ {loading ? ( + 'logging in...' + ) : ( + + )} +
+ ); +} +``` + +```tsx title="Authorized" +import { User, UserResource } from './UserResource'; + +export default function Authorized({ user }: { user: User }) { + const ctrl = useController(); + const handleLogout = (e: any) => ctrl.invalidate(UserResource.current); + + return ( +
+

Welcome, {user.name}!

+ +
+ ); +} +``` + +```tsx title="Entry" +import { UserResource } from './UserResource'; +import Unauthed from './Unauthed'; +import Authorized from './Authorized'; + +function AuthorizedPage() { + // currentUser as User | undefined + const currentUser = useCache(UserResource.current); + // user is not logged in + if (!currentUser) return ; + // currentUser as User (typeguarded) + return ; +} +render(); +``` + +See [truthiness narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing) for +more information about type handling + +## Behavior + +| Expiry Status | Returns | Conditions | +| ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | `undefined` | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](https://dataclient.io/docs/concepts/expiry-policy#endpointinvalidifstale) | +| Stale | denormalized | (first-render, arg change) & [expiry < now](https://dataclient.io/docs/concepts/expiry-policy) | +| Valid | denormalized | fetch completion | +| | `undefined` | `null` used as second argument | + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useCache(TodoResource.get, id ? { id } : null); +> ``` + +## Types + +```typescript +function useCache( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): Denormalize | null; +``` + +```typescript +function useCache< + E extends Pick< + EndpointInterface, + 'key' | 'schema' | 'invalidIfStale' + >, + Args extends readonly [...Parameters] | readonly [null], +>(endpoint: E, ...args: Args): DenormalizeNullable; +``` + +## Examples + +### Github Navbar login/logout + +Our current user only exists when we are authenticated. Thus we can `useCache(UserResource.current)` +to determine whether to show the login or logout navigation buttons. + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/User.ts`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/User.ts), [`src/navigation/NavBar.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/navigation/NavBar.tsx)) + +### Github Comment Authorization + +Here we only show commenting form if the user is authenticated. + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/User.ts`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/User.ts), [`src/pages/IssueDetail/CreateComment.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueDetail/CreateComment.tsx)) diff --git a/.agents/skills/data-client-react/references/useController.md b/.agents/skills/data-client-react/references/useController.md deleted file mode 120000 index 68fa2a5921fb..000000000000 --- a/.agents/skills/data-client-react/references/useController.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useController.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useController.md b/.agents/skills/data-client-react/references/useController.md new file mode 100644 index 000000000000..5be18b8bd9f4 --- /dev/null +++ b/.agents/skills/data-client-react/references/useController.md @@ -0,0 +1,153 @@ + + +# useController() + +[Controller](./Controller.md) provides type-safe methods to access and dispatch actions to the store. + +For instance [fetch](./Controller.md#fetch), [invalidate](./Controller.md#invalidate), +and [setResponse](./Controller.md#setResponse) + +```tsx +import { useController } from '@data-client/react'; + +function MyComponent({ id }) { + const ctrl = useController(); + + const handleRefresh = useCallback( + async e => { + await ctrl.fetch(MyResource.get, { id }); + }, + [fetch, id], + ); + + const handleSuspend = useCallback( + async e => { + await ctrl.invalidate(MyResource.get, { id }); + }, + [invalidate, id], + ); + + const handleLogout = useCallback( + async e => { + ctrl.resetEntireStore(); + }, + [resetEntireStore], + ); +} +``` + +## Examples + +### Form submission + +[fetch](./Controller.md#fetch) returns the denormalized response, matching [useSuspense()](./useSuspense.md)'s return type. This allows using Entity methods like `pk()`. + +```tsx +function CreatePost() { + const ctrl = useController(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const post = await ctrl.fetch( + PostResource.getList.push, + new FormData(e.target as HTMLFormElement), + ); + post.title; + post.computedField; + navigate(`/post/${post.pk()}`); + }; + + return
{/* fields */}
; +} +``` + +### Direct entity update + +Use [set](./Controller.md#set) for immediate updates without network requests. Supports functional updates to avoid race conditions. + +```tsx +function VoteButton({ articleId }: { articleId: string }) { + const ctrl = useController(); + + return ( + + ); +} +``` + +### Invalidate after mutation + +Force refetch of related data using [invalidate](./Controller.md#invalidate) or [expireAll](./Controller.md#expireAll). + +```tsx +function ClearUserCache({ userId }: { userId: string }) { + const ctrl = useController(); + + const handleClear = async () => { + // invalidate() causes suspense; expireAll() refetches silently + ctrl.expireAll(UserResource.get); + ctrl.expireAll(UserResource.getList); + }; + + return ; +} +``` + +> **Tip** +> +> For better performance and consistency, prefer [including side effect updates in mutation responses](https://dataclient.io/rest/guides/side-effects). + +### Prefetching + +Use [fetchIfStale](./Controller.md#fetchIfStale) to prefetch without overfetching fresh data. + +```tsx +function ArticleLink({ id }: { id: string }) { + const ctrl = useController(); + + return ( + ctrl.fetchIfStale(ArticleResource.get, { id })} + > + Read more + + ); +} +``` + +### Websocket updates + +Populate cache with external data via [set](./Controller.md#set). + +```tsx +function useWebsocket(url: string) { + const ctrl = useController(); + + useEffect(() => { + const ws = new WebSocket(url); + ws.onmessage = event => { + const { entity, args, data } = JSON.parse(event.data); + ctrl.set(EntityMap[entity], args, data); + }; + return () => ws.close(); + }, [ctrl, url]); +} +``` + +> **Warning** +> +> For production use, implement a [Manager for data streams](https://dataclient.io/docs/concepts/managers#data-stream) rather than component-level effects. Managers handle connection lifecycle globally and work with SSR. + +### Todo App + +Example app: [todo-app](https://github.com/reactive/data-client/tree/master/examples/todo-app) ([`src/resources/TodoResource.ts`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/resources/TodoResource.ts), [`src/pages/Home/TodoListItem.tsx`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/pages/Home/TodoListItem.tsx)) diff --git a/.agents/skills/data-client-react/references/useDLE.md b/.agents/skills/data-client-react/references/useDLE.md deleted file mode 120000 index fa87e066feb2..000000000000 --- a/.agents/skills/data-client-react/references/useDLE.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useDLE.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useDLE.md b/.agents/skills/data-client-react/references/useDLE.md new file mode 100644 index 000000000000..90e4bf75c6dc --- /dev/null +++ b/.agents/skills/data-client-react/references/useDLE.md @@ -0,0 +1,277 @@ + + +# useDLE() - \[D]ata \[L]oading \[E]rror + +High performance async data rendering without overfetching. With fetch meta data. + +In case you cannot use [suspense](./data-dependency.md#async-fallbacks), useDLE() is just like [useSuspense()](./useSuspense.md) but returns \[D]ata \[L]oading \[E]rror values. + +`useDLE()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```tsx title="ProfileList" +import { useDLE } from '@data-client/react'; +import { ProfileResource } from './ProfileResource'; + +function ProfileList(): JSX.Element { + const { data, loading, error } = useDLE(ProfileResource.getList); + if (error) return
Error {`${error.status}`}
; + if (loading || !data) return ; + return ( +
+ {data.map(profile => ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ))} +
+ ); +} +render(); +``` + +## Behavior + +| Expiry Status | Fetch | Data | Loading | Error | Conditions | +| ------------- | --------------- | ------------ | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | yes1 | `undefined` | true | false | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](https://dataclient.io/docs/concepts/expiry-policy#endpointinvalidifstale) | +| Stale | yes1 | denormalized | false | false | (first-render, arg change) & [expiry < now](https://dataclient.io/docs/concepts/expiry-policy) | +| Valid | no | denormalized | false | maybe2 | fetch completion | +| | no | `undefined` | false | false | `null` used as second argument | + +> **Note** +> +> 1. Identical fetches are automatically deduplicated +> 2. [Hard errors](https://dataclient.io/docs/concepts/error-policy#hard) to be [caught](./data-dependency.md#async-fallbacks) by [Error Boundaries](./AsyncBoundary.md) + +> **Info: React Native** +> +> When using React Navigation, useDLE() will trigger fetches on focus if the data is considered +> stale. + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useDLE(TodoResource.get, id ? { id } : null); +> ``` + +## Types + +```typescript +function useDLE( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): { + data: Denormalize; + loading: boolean; + error: Error | undefined; +}; +``` + +```typescript +function useDLE< + E extends EndpointInterface< + FetchFunction, + Schema | undefined, + undefined + >, + Args extends readonly [...Parameters] | readonly [null], +>( + endpoint: E, + ...args: Args +): { + data: DenormalizeNullable; + loading: boolean; + error: Error | undefined; +}; +``` + +## Examples + +### Detail + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```tsx title="ProfileDetail" +import { useDLE } from '@data-client/react'; +import { ProfileResource } from './ProfileResource'; + +function ProfileDetail(): JSX.Element { + const { + data: profile, + loading, + error, + } = useDLE(ProfileResource.get, { id: 1 }); + if (error) return
Error {`${error.status}`}
; + if (loading || !profile) return ; + return ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ); +} +render(); +``` + +### Conditional + +`null` will avoid binding and fetching data + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + userId = 0; + title = ''; + body = ''; + + static key = 'Post'; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="PostWithAuthor" +import { PostResource, UserResource } from './Resources'; + +export default function PostWithAuthor({ id }: { id: string }) { + const postDLE = useDLE(PostResource.get, { id }); + if (postDLE.error) return
Error {`${postDLE.error.status}`}
; + if (postDLE.loading || !postDLE.data) return ; + const authorDLE = useDLE( + UserResource.get, + postDLE.data.userId + ? { + id: postDLE.data.userId, + } + : null, + ); + if (authorDLE.error) + return
Error {`${authorDLE.error.status}`}
; + if (authorDLE.loading || !authorDLE.data) return ; + + return
{authorDLE.data.username}
; +} +``` + +### Embedded data + +When entities are stored in [nested structures](https://dataclient.io/rest/guides/relational-data#nesting), that structure will remain. + +```typescript title="api/Post" +export class PaginatedPost extends Entity { + id = ''; + title = ''; + content = ''; + + static key = 'PaginatedPost'; +} + +export const getPosts = new RestEndpoint({ + path: '/post', + searchParams: { page: '' }, + schema: { + results: new Collection([PaginatedPost]), + nextPage: '', + lastPage: '', + }, +}); +``` + +```tsx title="ArticleList" {12} +import { useDLE } from '@data-client/react'; +import { getPosts } from './api/Post'; + +export default function ArticleList({ page }: { page: string }) { + const { data, loading, error } = useDLE(getPosts, { page }); + if (error) return
Error {`${error.status}`}
; + if (loading || !data) return ; + const { results: posts, nextPage, lastPage } = data; + return ( +
+ {posts.map(post => ( +
{post.title}
+ ))} +
+ ); +} +``` + +### Github Reactions + +`useDLE()` allows us to declaratively fetch reactions on any issue page the moment we navigate to it. This allows +us to not block the issues page from showing if the reactions are not completed loading. + +It's usually better to wrap cases like this in new [Suspense Boundaries](./data-dependency.md#boundaries). +However, our component library `ant design` does not allow this. + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/Reaction.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Reaction.tsx), [`src/pages/IssueDetail/index.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueDetail/index.tsx)) diff --git a/.agents/skills/data-client-react/references/useDebounce.md b/.agents/skills/data-client-react/references/useDebounce.md deleted file mode 120000 index f4d37592acfe..000000000000 --- a/.agents/skills/data-client-react/references/useDebounce.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useDebounce.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useDebounce.md b/.agents/skills/data-client-react/references/useDebounce.md new file mode 100644 index 000000000000..2ac00768bb68 --- /dev/null +++ b/.agents/skills/data-client-react/references/useDebounce.md @@ -0,0 +1,120 @@ + + +# useDebounce() + +Delays updating the parameters by [debouncing](https://css-tricks.com/debouncing-throttling-explained-examples/). + +Useful to avoid spamming network requests when parameters might change quickly (like a typeahead field). + +> **Tip: React 18+** +> +> When loading new data, the [AsyncBoundary](./AsyncBoundary.md) will continue rendering the previous data until it is ready. +> `isPending` will be true while loading. + +## Usage + +```ts title="IssueQuery" +import { RestEndpoint, Entity, Collection } from '@data-client/rest'; + +export class Issue extends Entity { + number = 0; + repository_url = ''; + labels_url = ''; + html_url = ''; + body = ''; + title = ''; + state: 'open' | 'closed' = 'open'; + locked = false; + comments = 0; + created_at = Temporal.Instant.fromEpochMilliseconds(0); + updated_at = Temporal.Instant.fromEpochMilliseconds(0); + closed_at: Temporal.Instant | null = null; + authorAssociation = 'NONE'; + pullRequest: Record | null = null; + declare draft?: boolean; + + static schema = { + created_at: Temporal.Instant.from, + updated_at: Temporal.Instant.from, + closed_at: Temporal.Instant.from, + }; + + pk() { + return [this.repository_url, this.number].join(','); + } +} + +export const issueQuery = new RestEndpoint({ + urlPrefix: 'https://api.github.com', + path: '/search/issues', + searchParams: {} as { q: string }, + paginationField: 'page', + schema: { + incomplete_results: false, + items: new Collection([Issue]), + total_count: 0, + }, +}); +``` + +```tsx title="IssueList" +import { useSuspense } from '@data-client/react'; +import { issueQuery } from './IssueQuery'; + +function IssueList({ query, owner, repo }) { + const q = `${query} repo:${owner}/${repo}`; + const response = useSuspense(issueQuery, { q }); + return ( + <> + + {response.total_count} results + + {response.items.slice(0, 5).map(issue => ( + + ))} + + ); +} +export default React.memo(IssueList) as typeof IssueList; +``` + +```tsx title="SearchIssues" {8} +import { AsyncBoundary } from '@data-client/react'; +import { useDebounce } from '@data-client/react'; +import IssueList from './IssueList'; + +export default function SearchIssues() { + const [query, setQuery] = React.useState(''); + const handleChange = e => setQuery(e.currentTarget.value); + const [debouncedQuery, isPending] = useDebounce(query, 200); + return ( + <> + + + + }> + + + + ); +} +render(); +``` + +## Types + +```typescript +function useDebounce(value: T, delay: number, updatable?: boolean): T; +``` diff --git a/.agents/skills/data-client-react/references/useFetch.md b/.agents/skills/data-client-react/references/useFetch.md deleted file mode 120000 index b3c249d05780..000000000000 --- a/.agents/skills/data-client-react/references/useFetch.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useFetch.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useFetch.md b/.agents/skills/data-client-react/references/useFetch.md new file mode 100644 index 000000000000..abfb50978add --- /dev/null +++ b/.agents/skills/data-client-react/references/useFetch.md @@ -0,0 +1,162 @@ + + +# useFetch() + +Fetch an Endpoint if it is not in cache or stale. Returns a thenable that works with +[React.use()](https://react.dev/reference/react/use) -- `use(useFetch(endpoint, args))` operates +like [useSuspense()](./useSuspense.md), suspending when data is loading, returning denormalized data when +available, and re-suspending on [invalidation](./Controller.md#invalidate). + +## Usage + +### Parallel data loading + +Since `useFetch()` and `use()` are separate calls, multiple fetches start in parallel — even when the first `use()` suspends. See the [parallel fetches example](#parallel-data-loading) below. + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + title = ''; + body = ''; + static key = 'Post'; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); + +export class Comment extends Entity { + id = 0; + postId = 0; + author = ''; + text = ''; + static key = 'Comment'; +} +export const CommentResource = resource({ + path: '/comments/:id', + searchParams: {} as { postId: number }, + schema: Comment, +}); +``` + +```tsx title="PostWithComments" {7-13} +import { use } from 'react'; +import { useFetch } from '@data-client/react'; +import { PostResource, CommentResource } from './Resources'; + +function PostWithComments({ id }: { id: number }) { + // Both fetches start in parallel + const postPromise = useFetch(PostResource.get, { id }); + const commentsPromise = useFetch(CommentResource.getList, { + postId: id, + }); + + // use() reads the results — if the first suspends, + // the second fetch is already in-flight + const post = use(postPromise); + const comments = use(commentsPromise); + + return ( +
+

{post.title}

+

{post.body}

+

Comments

+ {comments.map(comment => ( +
+ {comment.author}: {comment.text} +
+ ))} +
+ ); +} +render(); +``` + +### Prefetching + +`useFetch()` can also be used standalone to ensure resources are available early in a render tree before they are needed. + +> **Tip** +> +> Use in combination with a data-binding hook ([useCache()](./useCache.md), [useSuspense()](./useSuspense.md), [useDLE()](./useDLE.md), [useLive()](./useLive.md)) +> in another component. + +```tsx +function MasterPost({ id }: { id: number }) { + useFetch(PostResource.get, { id }); + // ... +} +``` + +## Behavior + +| Expiry Status | Fetch | `use()` behavior | `resolved` | Conditions | +| ------------- | --------------- | ---------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | yes1 | suspends | `false` | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate) | +| Stale | yes1 | suspends | `false` | (first-render, arg change) & [expiry < now](https://dataclient.io/docs/concepts/expiry-policy) | +| Valid | no | returns data | `true` | fetch completion | +| Error | no | throws error | `true` | fetch failed, caught by [Error Boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) | +| | no | `undefined` | | `null` used as second argument | + +When the store updates (e.g., via mutations or [Controller.set()](./Controller.md#set)), the component +re-renders and `useFetch()` returns updated denormalized data automatically. + +> **Note** +> +> 1. Identical fetches are automatically deduplicated + +> **Info: React Native** +> +> When using React Navigation, useFetch() will trigger fetches on focus if the data is considered +> stale. + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useFetch(TodoResource.get, id ? { id } : null); +> ``` + +## Types + +```typescript +function useFetch( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): (PromiseLike & { resolved: boolean }) | undefined; +``` + +```typescript +function useFetch< + E extends EndpointInterface< + FetchFunction, + Schema | undefined, + undefined + >, + Args extends readonly [...Parameters] | readonly [null], +>(endpoint: E, ...args: Args): UsablePromise>; +``` + +## Examples + +### Checking fetch status + +Use `promise.resolved` to check whether data is still loading: + +```tsx +function MasterPost({ id }: { id: number }) { + const promise = useFetch(PostResource.get, { id }); + if (!promise.resolved) { + // fetch is in-flight + } + // ... +} +``` + +### NextJS Preload + +To prevent fetch waterfalls in NextJS, sometimes you might need to add [preloads](https://nextjs.org/docs/app/building-your-application/data-fetching/patterns#preloading-data) to top level routes. diff --git a/.agents/skills/data-client-react/references/useLive.md b/.agents/skills/data-client-react/references/useLive.md deleted file mode 120000 index fa4e4d082b41..000000000000 --- a/.agents/skills/data-client-react/references/useLive.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useLive.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useLive.md b/.agents/skills/data-client-react/references/useLive.md new file mode 100644 index 000000000000..990c2bdccd02 --- /dev/null +++ b/.agents/skills/data-client-react/references/useLive.md @@ -0,0 +1,119 @@ + + +# useLive() + +Async rendering of remotely triggered data mutations. + +[useSuspense()](./useSuspense.md) + [useSubscription()](./useSubscription.md) in one hook. + +`useLive()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```tsx title="AssetPrice" {5} +import { useLive } from '@data-client/react'; +import { getTicker } from './Ticker'; + +function AssetPrice({ productId }: Props) { + const ticker = useLive(getTicker, { productId }); + return ( +
+ {productId}{' '} + +
+ ); +} +interface Props { + productId: string; +} +render(); +``` + +## Behavior + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useLive(TodoResource.get, id ? { id } : null); +> ``` + +> **Info: React Native** +> +> When using React Navigation, useLive() will trigger fetches on focus if the data is considered +> stale. useLive() will also sub/unsub with focus/unfocus respectively. + +## Types + +```typescript +function useLive( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): Denormalize; +``` + +```typescript +function useLive< + E extends EndpointInterface< + FetchFunction, + Schema | undefined, + undefined + >, + Args extends readonly [...Parameters] | readonly [null], +>( + endpoint: E, + ...args: Args +): E['schema'] extends Exclude + ? Denormalize + : ReturnType; +``` + +## Examples + +### Bitcoin Price (polling) + +When our component with `useLive` is rendered, `getTicker` will fetch at [pollFrequency](https://dataclient.io/rest/api/RestEndpoint#pollfrequency) +miliseconds. + +Example app: [nextjs](https://github.com/reactive/data-client/tree/master/examples/nextjs) ([`resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/nextjs/resources/Ticker.ts), [`components/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/nextjs/components/AssetPrice.tsx)) diff --git a/.agents/skills/data-client-react/references/useLoading.md b/.agents/skills/data-client-react/references/useLoading.md deleted file mode 120000 index fdce9d784bd7..000000000000 --- a/.agents/skills/data-client-react/references/useLoading.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useLoading.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useLoading.md b/.agents/skills/data-client-react/references/useLoading.md new file mode 100644 index 000000000000..dedfa3de69fc --- /dev/null +++ b/.agents/skills/data-client-react/references/useLoading.md @@ -0,0 +1,167 @@ + + +# useLoading() + +Helps track loading and error state of imperative async functions. + +> **Tip** +> +> [useSuspense()](./useSuspense.md) or [useDLE()](./useDLE.md) are better for GET/read endpoints. + +## Usage + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```tsx title="PostDetail" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; + +export default function PostDetail({ id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+ +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +``` + +```tsx title="PostForm" +export default function PostForm({ onSubmit, loading, error }) { + const handleSubmit = e => { + e.preventDefault(); + const data = new FormData(e.target); + onSubmit(data); + }; + return ( +
+ + + {error ? ( +
{error.message}
+ ) : null} +
+ +
+ + ); +} +``` + +```tsx title="PostCreate" {7} +import { useLoading, useController } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostForm from './PostForm'; + +export default function PostCreate({ navigateToPost }) { + const ctrl = useController(); + const [handleSubmit, loading, error] = useLoading( + async data => { + const post = await ctrl.fetch(PostResource.getList.push, data); + navigateToPost(post.id); + }, + [ctrl], + ); + return ( + + ); +} +``` + +```tsx title="Navigation" +import PostCreate from './PostCreate'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [id, setId] = React.useState(undefined); + if (id) { + return ( +
+ +
+ +
+
+ ); + } + return ; +} +render(); +``` + +Like [useCallback](https://react.dev/reference/react/useCallback), takes a dependency list to +ensure referential consistency of the function. + +## Eslint + +> **Tip: Eslint configuration** +> +> Since we use the deps list, be sure to add useLoading to the 'additionalHooks' configuration +> of [react-hooks/exhaustive-deps](https://www.npmjs.com/package/eslint-plugin-react-hooks) rule if you use it. +> +> ```js +> { +> "rules": { +> // ... +> "react-hooks/exhaustive-deps": ["warn", { +> "additionalHooks": "(useLoading)" +> }] +> } +> } +> ``` + +## Types + +```typescript +export default function useLoading< + F extends (...args: any) => Promise, +>(func: F, deps: readonly any[] = []): [F, boolean]; +``` + +## Examples + +### Github pagination + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/Issue.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Issue.tsx), [`src/pages/IssueList.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueList.tsx), [`src/pages/NextPage.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/NextPage.tsx)) + +### Github comment form submission + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/pages/IssueDetail/CreateComment.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueDetail/CreateComment.tsx), [`src/pages/IssueDetail/CommentForm.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueDetail/CommentForm.tsx)) diff --git a/.agents/skills/data-client-react/references/useQuery.md b/.agents/skills/data-client-react/references/useQuery.md deleted file mode 120000 index 8cbb935c7781..000000000000 --- a/.agents/skills/data-client-react/references/useQuery.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useQuery.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useQuery.md b/.agents/skills/data-client-react/references/useQuery.md new file mode 100644 index 000000000000..9081cce553f6 --- /dev/null +++ b/.agents/skills/data-client-react/references/useQuery.md @@ -0,0 +1,314 @@ + + +# useQuery() + +Data rendering without the fetch. + +Access any [Queryable Schema](https://dataclient.io/rest/api/schema#queryable)'s store value; like [Entity](https://dataclient.io/rest/api/Entity), [All](https://dataclient.io/rest/api/All), [Collection](https://dataclient.io/rest/api/Collection), [Query](https://dataclient.io/rest/api/Query), +[Union](https://dataclient.io/rest/api/Union), and [Scalar](https://dataclient.io/rest/api/Scalar). [Lazy](https://dataclient.io/rest/api/Lazy) fields also work via their [`.query`](https://dataclient.io/rest/api/Lazy#query) accessor. +If the value does not exist, returns `undefined`. + +`useQuery()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. Returns `undefined` +when data is [Invalid](https://dataclient.io/docs/concepts/expiry-policy#invalid). + +> **Tip** +> +> [Queries](https://dataclient.io/rest/api/Query) are a great companion to efficiently render aggregate computations like those that use [groupBy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/groupBy#browser_compatibility), +> [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map), [reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce), and [filter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter). + +## Usage + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +See [truthiness narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing) for +more information about type handling + +## Types + +```typescript +function useQuery( + schema: Queryable, + ...args: SchemaArgs +): DenormalizeNullable | undefined; +``` + +```typescript +function useQuery( + schema: S, + ...args: SchemaArgs +): DenormalizeNullable | undefined; +``` + +### Queryable + +[Queryable](https://dataclient.io/rest/api/schema#queryable) schemas require an `queryKey()` method that returns something. These include +[Entity](https://dataclient.io/rest/api/Entity), [All](https://dataclient.io/rest/api/All), [Collection](https://dataclient.io/rest/api/Collection), [Query](https://dataclient.io/rest/api/Query), +[Union](https://dataclient.io/rest/api/Union), and [Scalar](https://dataclient.io/rest/api/Scalar). [Lazy](https://dataclient.io/rest/api/Lazy) fields produce a Queryable via their [`.query`](https://dataclient.io/rest/api/Lazy#query) accessor. + +```ts +interface Queryable { + queryKey( + args: readonly any[], + queryKey: (...args: any) => any, + getEntity: GetEntity, + getIndex: GetIndex, + // Must be non-void + ): {}; +} +``` + +## Examples + +### Sorting & Filtering + +[Query](https://dataclient.io/rest/api/Query) provides programmatic access to the Reactive Data Client store. + +```ts title="UserResource" +export class User extends Entity { + id = ''; + name = ''; + isAdmin = false; + + static key = 'User'; +} +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="UsersPage" {22} +import { Query } from '@data-client/rest'; +import { useQuery, useFetch } from '@data-client/react'; +import { UserResource, User } from './UserResource'; + +interface Args { + asc: boolean; + isAdmin?: boolean; +} +const sortedUsers = new Query( + new All(User), + (entries, { asc, isAdmin }: Args = { asc: false }) => { + let sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + if (isAdmin !== undefined) + sorted = sorted.filter(user => user.isAdmin === isAdmin); + if (asc) return sorted; + return sorted.reverse(); + }, +); + +function UsersPage() { + useFetch(UserResource.getList); + const users = useQuery(sortedUsers, { asc: true }); + if (!users) return
No users in cache yet
; + return ( +
+ {users.map(user => ( +
{user.name}
+ ))} +
+ ); +} +render(); +``` + +### Remaining Todo total + +[Queries](https://dataclient.io/rest/api/Query) can also be used to compute aggregates + +Example app: [todo-app](https://github.com/reactive/data-client/tree/master/examples/todo-app) ([`src/resources/TodoResource.ts`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/resources/TodoResource.ts), [`src/pages/Home/TodoStats.tsx`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/pages/Home/TodoStats.tsx)) + +### Lazy relationships + +[Lazy](https://dataclient.io/rest/api/Lazy) fields keep raw IDs during parent denormalization. Use [`.query`](https://dataclient.io/rest/api/Lazy#query) with `useQuery` to resolve them on demand, +isolating re-renders to only the components that need the related data. + +```ts title="Resources" +export class Building extends Entity { + id = ''; + name = ''; + + static key = 'Building'; +} + +export class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + buildings: new Lazy([Building]), + }; + static key = 'Department'; +} + +export const DepartmentResource = resource({ + path: '/departments/:id', + schema: Department, +}); +``` + +```tsx title="DepartmentsPage" {7} +import { useQuery, useFetch } from '@data-client/react'; +import { DepartmentResource, Department } from './Resources'; + +function BuildingList({ dept }: { dept: Department }) { + const buildings = useQuery( + Department.schema.buildings.query, + dept.buildings, + ); + if (!buildings) return null; + return {buildings.map(b => b.name).join(', ')}; +} + +function DepartmentsPage() { + useFetch(DepartmentResource.getList); + const departments = useQuery(new All(Department)); + if (!departments) return
Loading...
; + return ( +
+ {departments.map(dept => ( +
+ {dept.name}: +
+ ))} +
+ ); +} +render(); +``` + +### Data fallbacks + +In this case `Ticker` is constantly updated from a websocket stream. However, there is no bulk/list +fetch for `Ticker` - making it inefficient for getting the prices on a list view. + +So in this case we can fetch a list of `Stats` as a fallback since it has price data as well. + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/pages/Home/CurrencyList.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/Home/CurrencyList.tsx), [`src/resources/fallbackQueries.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/fallbackQueries.ts), [`src/pages/Home/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/Home/AssetPrice.tsx)) diff --git a/.agents/skills/data-client-react/references/useSubscription.md b/.agents/skills/data-client-react/references/useSubscription.md deleted file mode 120000 index 9035c9f29606..000000000000 --- a/.agents/skills/data-client-react/references/useSubscription.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useSubscription.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useSubscription.md b/.agents/skills/data-client-react/references/useSubscription.md new file mode 100644 index 000000000000..ff27a09dc4bc --- /dev/null +++ b/.agents/skills/data-client-react/references/useSubscription.md @@ -0,0 +1,113 @@ + + +# useSubscription() + +Great for keeping resources up-to-date with frequent changes. + +When using the default [polling subscriptions](https://dataclient.io/docs/api/PollingSubscription), frequency must be set in +[Endpoint](https://dataclient.io/rest/api/Endpoint), otherwise will have no effect. + +> **Tip** +> +> [useLive()](./useLive.md) is a terser way to use in combination with [useSuspense()](./useSuspense.md), + +## Usage + +```typescript title="api/Price" +import { RestEndpoint, Entity } from '@data-client/rest'; + +export class Price extends Entity { + symbol = ''; + price = '0.0'; + // ... + + pk() { + return this.symbol; + } +} + +export const getPrice = new RestEndpoint({ + urlPrefix: 'http://test.com', + path: '/price/:symbol', + schema: Price, + pollFrequency: 5000, +}); +``` + +```tsx title="MasterPrice" +import { useSuspense, useSubscription } from '@data-client/react'; +import { getPrice } from 'api/Price'; + +function MasterPrice({ symbol }: { symbol: string }) { + const price = useSuspense(getPrice, { symbol }); + useSubscription(getPrice, { symbol }); + // ... +} +``` + +## Behavior + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useSubscription(TodoResource.get, id ? { id } : null); +> ``` + +> **Info: React Native** +> +> When using React Navigation, useSubscription() will sub/unsub with focus/unfocus respectively. + +## Types + +```typescript +function useSubscription( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): void; +``` + +```typescript +function useSubscription< + E extends EndpointInterface< + FetchFunction, + Schema | undefined, + undefined + >, + Args extends readonly [...Parameters] | readonly [null], +>(endpoint: E, ...args: Args): void; +``` + +## Examples + +### Only subscribe while element is visible + +```tsx title="MasterPrice.tsx" +import { useIntersectionObserver } from '@uidotdev/usehooks'; +import { useSuspense, useSubscription } from '@data-client/react'; +import { getPrice } from 'api/Price'; + +function MasterPrice({ symbol }: { symbol: string }) { + const price = useSuspense(getPrice, { symbol }); + const [ref, entry] = useIntersectionObserver(); + // null params means don't subscribe + useSubscription(getPrice, entry?.isIntersecting ? { symbol } : null); + + return
{price.price}
; +} +``` + +When `null` is sent as the second argument, the subscription is deactivated. Of course, +if other components are still subscribed the data updates will still be active. + +[useIntersectionObserver()](https://usehooks.com/useintersectionobserver) uses [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), which is very performant. [ref](https://react.dev/reference/react/useRef) allows +us to access the [DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model). + +### Crypto prices (websockets) + +We implemented our own `StreamManager` to handle our custom websocket protocol. Here we listen to the [subcribe/unsubcribe +actions](./Actions.md#subscribe) sent by `useSubscription` to ensure we only listen to updates for components that are rendered. + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts), [`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts), [`src/pages/Home/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/Home/AssetPrice.tsx)) diff --git a/.agents/skills/data-client-react/references/useSuspense.md b/.agents/skills/data-client-react/references/useSuspense.md deleted file mode 120000 index 4542002302e8..000000000000 --- a/.agents/skills/data-client-react/references/useSuspense.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/useSuspense.md \ No newline at end of file diff --git a/.agents/skills/data-client-react/references/useSuspense.md b/.agents/skills/data-client-react/references/useSuspense.md new file mode 100644 index 000000000000..f39f3fbc6429 --- /dev/null +++ b/.agents/skills/data-client-react/references/useSuspense.md @@ -0,0 +1,445 @@ + + +# useSuspense() + +High performance async data rendering without overfetching. + +`useSuspense()` is like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) for React components. This means the remainder of the component only runs after the data has loaded, avoiding the complexity of handling loading and error conditions. Instead, fallback handling is +[centralized](./data-dependency.md#boundaries) with a singular [AsyncBoundary](./AsyncBoundary.md). + +`useSuspense()` is reactive to data [mutations](./mutations.md); rerendering only when necessary. + +## Usage + +**Rest** + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```tsx title="ProfileDetail" +import { useSuspense } from '@data-client/react'; +import { ProfileResource } from './ProfileResource'; + +function ProfileDetail(): JSX.Element { + const profile = useSuspense(ProfileResource.get, { id: 1 }); + return ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ); +} +render(); +``` + +**Promise** + +```typescript title="Profile" +import { Endpoint } from '@data-client/endpoint'; + +export const getProfile = new Endpoint( + (id: number) => + Promise.resolve({ + id, + fullName: 'Jing Chen', + bio: 'Creator of Flux Architecture', + avatar: 'https://avatars.githubusercontent.com/u/5050204?v=4', + }), + { + key(id) { + return `getProfile${id}`; + }, + }, +); +``` + +```tsx title="ProfileDetail" +import { useSuspense } from '@data-client/react'; +import { getProfile } from './Profile'; + +function ProfileDetail(): JSX.Element { + const profile = useSuspense(getProfile, 1); + return ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ); +} +render(); +``` + +## Behavior + +Cache policy is [Stale-While-Revalidate](https://tools.ietf.org/html/rfc5861) by default but also [configurable](https://dataclient.io/docs/concepts/expiry-policy). + +| Expiry Status | Fetch | Suspend | Error | Conditions | +| ------------- | --------------- | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | yes1 | yes | no | not in store, [deletion](https://dataclient.io/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](https://dataclient.io/docs/concepts/expiry-policy#endpointinvalidifstale) | +| Stale | yes1 | no | no | (first-render, arg change) & [expiry < now](https://dataclient.io/docs/concepts/expiry-policy) | +| Valid | no | no | maybe2 | fetch completion | +| | no | no | no | `null` used as second argument | + +> **Note** +> +> 1. Identical fetches are automatically deduplicated +> 2. [Hard errors](https://dataclient.io/docs/concepts/error-policy#hard) to be [caught](./data-dependency.md#async-fallbacks) by [Error Boundaries](./AsyncBoundary.md) + +> **Info: React Native** +> +> When using React Navigation, useSuspense() will trigger fetches on focus if the data is considered +> stale. + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useSuspense(TodoResource.get, id ? { id } : null); +> ``` + +## Types + +```typescript +function useSuspense( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): Denormalize; +``` + +```typescript +function useSuspense< + E extends EndpointInterface< + FetchFunction, + Schema | undefined, + undefined + >, + Args extends readonly [...Parameters] | readonly [null], +>( + endpoint: E, + ...args: Args +): E['schema'] extends Exclude + ? Denormalize + : ReturnType; +``` + +## Examples + +### List + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```tsx title="ProfileList" {5} +import { useSuspense } from '@data-client/react'; +import { ProfileResource } from './ProfileResource'; + +function ProfileList(): JSX.Element { + const profiles = useSuspense(ProfileResource.getList); + return ( +
+ {profiles.map(profile => ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ))} +
+ ); +} +render(); +``` + +### Pagination + +Reactive [pagination](https://dataclient.io/rest/guides/pagination) is achieved with [mutable schemas](https://dataclient.io/rest/api/Collection) + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```tsx title="PostItem" +import { type Post } from './Post'; + +export default function PostItem({ post }: Props) { + return ( +
+ +
+

{post.title}

+ by {post.author.name} +
+
+ ); +} + +interface Props { + post: Post; +} +``` + +```tsx title="LoadMore" {7} +import { useController, useLoading } from '@data-client/react'; +import { PostResource } from './Post'; + +export default function LoadMore({ cursor }: { cursor: string }) { + const ctrl = useController(); + const [loadPage, isPending] = useLoading( + () => ctrl.fetch(PostResource.getList.getPage, { cursor }), + [cursor], + ); + return ( +
+ +
+ ); +} +``` + +```tsx title="PostList" {7} +import { useSuspense } from '@data-client/react'; +import PostItem from './PostItem'; +import LoadMore from './LoadMore'; +import { PostResource } from './Post'; + +export default function PostList() { + const { posts, cursor } = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( + + ))} + {cursor ? : null} +
+ ); +} +render(); +``` + +### Sequential + +When fetch parameters depend on data from another resource. + +```tsx +function PostWithAuthor() { + const post = useSuspense(PostResource.get, { id }); + const author = useSuspense(UserResource.get, { + id: post.userId, + }); +} +``` + +### Conditional + +`null` will avoid binding and fetching data + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + userId = 0; + title = ''; + body = ''; + + static key = 'Post'; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="PostWithAuthor" {7-11} +import { PostResource, UserResource } from './Resources'; + +export default function PostWithAuthor({ id }: { id: string }) { + const post = useSuspense(PostResource.get, { id }); + const author = useSuspense( + UserResource.get, + post.userId + ? { + id: post.userId, + } + : null, + ); + // author as User | undefined + if (!author) return; +} +``` + +### Embedded data + +When entities are stored in [nested structures](https://dataclient.io/rest/guides/relational-data#nesting), that structure will remain. + +```typescript title="api/Post" {12-16} +export class PaginatedPost extends Entity { + id = ''; + title = ''; + content = ''; + + static key = 'PaginatedPost'; +} + +export const getPosts = new RestEndpoint({ + path: '/post', + searchParams: { page: '' }, + schema: { + posts: new Collection([PaginatedPost]), + nextPage: '', + lastPage: '', + }, +}); +``` + +```tsx title="ArticleList" {5-7} +import { getPosts } from './api/Post'; + +export default function ArticleList({ page }: { page: string }) { + const { + posts, + nextPage, + lastPage, + } = useSuspense(getPosts, { page }); + return ( +
+ {posts.map(post => ( +
{post.title}
+ ))} +
+ ); +} +``` + +### Server Side Rendering + +[Server Side Rendering](https://dataclient.io/docs/guides/ssr) to incrementally stream HTML, +greatly reducing [TTFB](https://web.dev/ttfb/). [Reactive Data Client SSR's](https://dataclient.io/docs/guides/ssr) automatic store hydration +means immediate user interactivity with **zero** client-side fetches on first load. + +Example app: [nextjs](https://github.com/reactive/data-client/tree/master/examples/nextjs) ([`resources/TodoResource.ts`](https://github.com/reactive/data-client/blob/master/examples/nextjs/resources/TodoResource.ts), [`components/todo/TodoList.tsx`](https://github.com/reactive/data-client/blob/master/examples/nextjs/components/todo/TodoList.tsx)) + +Usage in components is identical, which means you can easily share components between SSR and non-SSR +applications, as well as migrate to SSR without needing data-client code changes. + +### Concurrent Mode + +In React 18 navigating with [startTransition](https://react.dev/reference/react/useTransition#starttransition) allows [AsyncBoundaries](./AsyncBoundary.md) to +continue showing the previous screen while the new data loads. Combined with +[streaming server side rendering](https://dataclient.io/docs/guides/ssr), this eliminates the need to flash annoying +loading indicators - improving the user experience. + +Click one of the names to navigate to their todos. Here long loading states are indicated by the +less intrusive _loading bar_, like [YouTube](https://youtube.com) and [Robinhood](https://robinhood.com) use. + +Example app: [todo-app](https://github.com/reactive/data-client/tree/master/examples/todo-app) ([`src/pages/Home/TodoList.tsx`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/pages/Home/TodoList.tsx), [`src/pages/Home/index.tsx`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/pages/Home/index.tsx), [`src/useNavigationState.ts`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/useNavigationState.ts)) + +If you need help adding this to your own custom router, check out the [official React guide](https://react.dev/reference/react/useTransition#building-a-suspense-enabled-router) diff --git a/.agents/skills/data-client-rest-setup/SKILL.md b/.agents/skills/data-client-rest-setup/SKILL.md index 10313d3760b2..eb7d30fab4c6 100644 --- a/.agents/skills/data-client-rest-setup/SKILL.md +++ b/.agents/skills/data-client-rest-setup/SKILL.md @@ -299,6 +299,8 @@ If the codebase already validates responses with Zod/Yup, prefer **Entity as the ## References +Vue projects: read `.vue.md` instead of `.md` when it exists. + - [RestEndpoint](references/RestEndpoint.md) - Full RestEndpoint API - [resource](references/resource.md) - Resource factory function - [Authentication Guide](references/auth.md) - Auth patterns and examples diff --git a/.agents/skills/data-client-rest-setup/references.json b/.agents/skills/data-client-rest-setup/references.json new file mode 100644 index 000000000000..178ebede0cef --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references.json @@ -0,0 +1,13 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "RestEndpoint.md": "docs/rest/api/RestEndpoint.md", + "auth.md": "docs/rest/guides/auth.md", + "django.md": "docs/rest/guides/django.md", + "hookifyResource.md": "docs/rest/api/hookifyResource.md", + "resource.md": "docs/rest/api/resource.md" + } +} diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md deleted file mode 120000 index 022543a002ca..000000000000 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/RestEndpoint.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md new file mode 100644 index 000000000000..bef749d6e9e8 --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md @@ -0,0 +1,1422 @@ + + +# RestEndpoint + +`RestEndpoints` are for [HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP) based protocols like REST. + +> **Info: extends** +> +> `RestEndpoint` extends [Endpoint](https://dataclient.io/rest/api/Endpoint) + +
+ +Interface + +**RestEndpoint** + +```typescript +interface RestGenerics { + readonly path: string; + readonly schema?: Schema | undefined; + readonly method?: string; + readonly body?: any; + readonly searchParams?: any; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + process?(value: any, ...args: any): any; +} + +export class RestEndpoint extends Endpoint { + /* Prepare fetch */ + readonly path: string; + readonly urlPrefix: string; + readonly requestInit: RequestInit; + readonly method: string; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + readonly signal: AbortSignal | undefined; + url(...args: Parameters): string; + searchToString(searchParams: Record): string; + getRequestInit( + this: any, + body?: RequestInit['body'] | Record, + ): Promise | RequestInit; + getHeaders(headers: HeadersInit): Promise | HeadersInit; + + /* Perform/process fetch */ + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + parseResponse(response: Response): Promise; + process(value: any, ...args: Parameters): any; + + testKey(key: string): boolean; +} +``` + +**Endpoint** + +```typescript +class Endpoint Promise> { + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + readonly getOptimisticResponse?: ( + snap: SnapshotInterface, + ...args: Parameters + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + + testKey(key: string): boolean; +} +``` + +
+ +## Usage + +All options are supported as arguments to the constructor, [extend](#extend), and as overrides when using [inheritance](#inheritance) + +### Simplest retrieval + +```ts +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +``` + +```ts +const todo = await getTodo({ id: 1 }); +``` + +### Configuration sharing + +Use [RestEndpoint.extend()](#extend) instead of `{...getTodo}` ([Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)) + +```ts +const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +### Managing state + +```ts path=Todo.ts +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; +} + +export const getTodo = new RestEndpoint({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +export const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +Using a [Schema](https://dataclient.io/rest/api/schema) enables [automatic data consistency](https://dataclient.io/docs/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/docs/api/Controller#expireAll). + +### Typing + +```ts title="Comment" +export class Comment extends Entity { + id = ''; + title = ''; + body = ''; + postId = ''; + + static key = 'Comment'; +} +``` + +```ts title="Usage" +import { Comment } from './Comment'; + +const getComments = new RestEndpoint({ + path: '/posts/:postId/comments', + schema: new Collection([Comment]), + searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, +}); + +// Hover your mouse over 'comments' to see its type +const comments = useSuspense(getComments, { + postId: '5', + sortBy: 'votes', +}); + +const ctrl = useController(); +const createComment = async data => + ctrl.fetch(getComments.push, { postId: '5' }, data); +``` + +#### Resolution/Return + +[schema](#schema) determines the return value when used with data-binding hooks like [useSuspense](https://dataclient.io/docs/api/useSuspense), [useDLE](https://dataclient.io/docs/api/useDLE), [useCache](https://dataclient.io/docs/api/useCache) +or when used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +```ts title="Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +``` + +```ts title="getTodo.ts" +import { Todo } from './Todo'; + +const getTodo = new RestEndpoint({ path: '/', schema: Todo }); +// Hover your mouse over 'todo' to see its type +const todo = useSuspense(getTodo); + +async () => { + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +[process](#process) determines the resolution value when the endpoint is called directly. For +`RestEndpoints` without a schema, it also determines the return type of [hooks](https://dataclient.io/docs/api/useSuspense) and [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch). + +```ts path="process.ts" +interface TodoInterface { + title: string; + completed: boolean; +} +const getTodo = new RestEndpoint({ + path: '/', + process(value): TodoInterface { + return value; + }, +}); +async () => { + // todo is TodoInterface + const todo = await getTodo(); + + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +#### Function Parameters + +[path](#path) used to construct the url determines the type of the first argument. If it has no patterns, +then the 'first' argument is skipped. + +```ts +const getRoot = new RestEndpoint({ path: '/' }); +getRoot(); +const getById = new RestEndpoint({ path: '/:id' }); +// both number and string types work as they are serialized into strings to construct the url +getById({ id: 5 }); +getById({ id: '5' }); +``` + +[method](#method) determines whether there is a second argument to be sent as the [body](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body). + +```ts path=method.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', +}); +update({ id: 5 }, { title: 'updated', completed: true }); +``` + +However, this is typed as 'any' so it won't catch typos. + +[body](#body) can be used to type the argument after the url parameters. It is only used for typing so the +value sent does not matter. `undefined` value can be used to 'disable' the second argument. + +```ts path=body.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: {} as TodoInterface, +}); +update({ id: 5 }, { title: 'updated', completed: true }); +// `undefined` disables 'body' argument +const rpc = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: undefined, +}); +rpc({ id: 5 }); +``` + +[searchParams](#searchParams) can be used in a similar way to `body` to specify types extra parameters, used +for the GET searchParams/queryParams in a [url()](#url). + +```ts +const getUsers = new RestEndpoint({ + path: '/:group/user/:id', + searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' }, +}); +getUsers.url({ group: 'big', id: '5', sort: 'asc' }) === + '/big/user/5?sort=asc'; +getUsers.url({ + group: 'big', + id: '5', + sort: 'desc', + isAdmin: true, +}) === '/big/user/5?isAdmin=true&sort=desc'; +``` + +## Fetch Lifecycle + +RestEndpoint adds to Endpoint by providing customizations for a provided fetch method using +[inheritance](#inheritance) or [.extend()](#extend). + +```mermaid +flowchart TB + URL-->response + INIT-->response + subgraph Prepare Fetch + subgraph URL + direction BT + urlPrefix-->url("url(urlParams)") + path-->url + searchToString("searchToString()")-->url + searchParams-->searchToString("searchToString()") + end + subgraph INIT + direction BT + getHeaders("getHeaders()")-->reqinit("getRequestInit(body)") + method-->reqinit + signal-->reqinit + end + end + subgraph Perform Fetch + response("fetchResponse()")-->parse("parseResponse()") + parse-->process("process()") + end + click url "/rest/api/RestEndpoint#url" + click searchToString "/rest/api/RestEndpoint#searchToString" + click searchParams "/rest/api/RestEndpoint#searchParams" + click urlPrefix "/rest/api/RestEndpoint#urlPrefix" + click path "/rest/api/RestEndpoint#path" + click getHeaders "/rest/api/RestEndpoint#getHeaders" + click method "/rest/api/RestEndpoint#method" + click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" + click reqinit "/rest/api/RestEndpoint#getRequestInit" + click response "/rest/api/RestEndpoint#fetchResponse" + click parse "/rest/api/RestEndpoint#parseResponse" + click process "/rest/api/RestEndpoint#process" +``` + +```ts title="fetch implementation for RestEndpoint" +function fetch(...args) { + const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; + const body = this.#hasBody ? args[args.length - 1] : undefined; + return this.fetchResponse( + this.url(urlParams), + await this.getRequestInit(body), + ) + .then(response => this.parseResponse(response)) + .then(res => this.process(res, ...args)); +} +``` + +## Prepare Fetch + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### url(params): string {#url} + +`urlPrefix` + `path template` + '?' + searchToString(`searchParams`) + +`url()` uses the `params` to fill in the [path template](#path). Any unused `params` members are then used +as [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka 'GET' params - the stuff after `?`). + +
+ +Implementation + +```typescript +import { getUrlBase, getUrlTokens } from '@data-client/rest'; + +url(urlParams = {}) { + const urlBase = getUrlBase(this.path)(urlParams); + const tokens = getUrlTokens(this.path); + const searchParams = {}; + Object.keys(urlParams).forEach(k => { + if (!tokens.has(k)) { + searchParams[k] = urlParams[k]; + } + }); + if (Object.keys(searchParams).length) { + return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; + } + return `${this.urlPrefix}${urlBase}`; +} +``` + +
+ +### searchToString(searchParams): string {#searchToString} + +Constructs the [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) component of [url](#url). + +By default uses the standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) global. + +[searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka queryParams) are sorted to maintain determinism. + +
+ +Implementation + +```typescript +searchToString(searchParams) { + const params = new URLSearchParams(searchParams); + params.sort(); + return params.toString(); +} +``` + +
+ +#### Using `qs` library + +To encode complex objects in the searchParams, you can use the [qs](https://github.com/ljharb/qs) library. + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +class QSEndpoint extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="QSEndpoint" {7} +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export default class QSEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="getFoo" +import QSEndpoint from './QSEndpoint'; + +const getFoo = new QSEndpoint({ + path: '/foo', + searchParams: {} as { a: Record }, +}); + +getFoo({ a: { b: 'c' } }); +``` + +### path: string {#path} + +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) to build +urls using the parameters passed. This also informs the types so they are properly enforced. + +#### Parameters + +`:` prefixed words are parameter names. Both strings and numbers are accepted as values, +since they are serialized into the url string. + +```ts +const getThing = new RestEndpoint({ path: '/:group/things/:id' }); +getThing({ group: 'first', id: 77 }); +``` + +#### Optional parameters + +Wrap the optional segment (including its prefix) in `{}` to make it [optional](https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#optional). +The type of optional parameters becomes `string | number | undefined`. + +```ts +const optional = new RestEndpoint({ + path: '/:group/things{/:number}', +}); +optional({ group: 'first' }); +optional({ group: 'first', number: 'fifty' }); +``` + +Multiple optional segments can be chained with different prefixes: + +```ts +const ep = new RestEndpoint({ + path: '{/:attr1}{-:attr2}{-:attr3}', +}); + +ep({ attr1: 'hi' }); +ep({ attr2: 'hi' }); +ep({ attr1: 'hi', attr3: 'ho' }); +``` + +#### Wildcards (repeating parameters) + +`*name` matches one-or-more path segments. Wrap in `{}` to make it zero-or-more (optional). +Wildcard parameters are typed as `string[]` (arrays), since they represent multiple path segments. + +```ts +const files = new RestEndpoint({ path: '/files/*path' }); +files({ path: ['documents', 'reports', 'q4'] }); +// URL: /files/documents/reports/q4 + +const optionalFiles = new RestEndpoint({ path: '/files{/*path}' }); +optionalFiles({}); +// URL: /files +optionalFiles({ path: ['documents'] }); +// URL: /files/documents +``` + +#### Quoted parameter names + +Parameter names must be valid JavaScript identifiers. Names containing special characters +like `-` or `.` must be quoted with double quotes: + +```ts +const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' }); +ep({ 'with-dash': 'hello', 'my.param': 'world' }); +``` + +#### Escaping special characters + +Characters `{}()*:` and `\\` are special in path-to-regexp and must be escaped with `\\` when used as literals. + +```ts +const getSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', +}); +getSite({ slug: 'first' }); +``` + +`?` and `+` are **not** special in path-to-regexp v8 and do not need escaping. +This means query strings can be embedded in the path without escaping `?`: + +```ts +const search = new RestEndpoint({ + path: '/search?{q=:q}{&page=:page}', +}); +search({ q: 'test', page: 1 }); +// URL: /search?q=test&page=1 +``` + +> **Info** +> +> Types are inferred automatically from `path`. +> +> Additional parameters can be specified with [searchParams](#searchParams) +> and [body](#body). + +### searchParams {#searchParams} + +`searchParams` can be to specify types extra parameters, used for the GET searchParams/queryParams in a [url()](#url). + +The actual **value is not used** in any way - this only determines [typing](#typing). + +```typescript title="getFoo" +const getReactSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + searchParams: {} as { isReact: boolean }, +}); + +getReactSite({ slug: 'cool', isReact: true }); +``` + +### body {#body} + +`body` can be used to set a second argument for mutation endpoints. The actual **value is not +used** in any way - this only determines [typing](#typing). + +This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'. + +```ts {4} +const updateSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + method: 'POST', + body: {} as { url: string }, +}); + +updateSite({ slug: 'cool' }, { url: '/' }); +``` + +### paginationField + +If specified, will add [getPage](#getpage) method on the `RestEndpoint`. [Pagination guide](https://dataclient.io/rest/guides/pagination). Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection). + +### urlPrefix: string = '' {#urlPrefix} + +Prepends this to the compiled [path](#path) + +#### Inheritance defaults + +```typescript +export class MyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // this allows us to override the prefix in production environments, with a dev fallback + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; +} +``` + +[Learn more about inheritance patterns](#inheritance) for RestEndpoint + +#### Instance overrides + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:product_id/ticker', + schema: Ticker, +}); +``` + +#### Dynamic prefix + +> **Tip** +> +> For a dynamic prefix, try overriding the url() method instead: +> +> ```ts +> const getTodo = new RestEndpoint({ +> path: '/todo/:id', +> url(...args) { +> return dynamicPrefix() + super.url(...args); +> }, +> }); +> ``` + +### method: string = 'GET' {#method} + +[Method](https://developer.mozilla.org/en-US/docs/Web/API/Request/method) is part of the HTTP protocol. +REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this +to inform `sideEffect` and whether the endpoint should use a `body` payload. Setting +`sideEffect` explicitly will override this behavior, allowing for non-standard API designs. + +`GET` is 'readonly', other methods imply sideEffects. + +`GET` and `DELETE` both default to no `body`. + +> **Tip: How method affects function Parameters** +> +> `method` only influences parameters in the RestEndpoint constructor and _not_ [.extend()](#extend). +> This allows non-standard method-body combinations. +> +> `body` will default to `any`. You can always set body explicitly to take full control. `undefined` can be used +> to indicate there is no body. +> +> ```ts +> (id: string, myPayload: Record) => { +> const standardCreate = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> }); +> standardCreate({ id }, myPayload); +> const nonStandardEndpoint = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> body: undefined, +> }); +> // no second 'body' argument, because body was set to 'undefined' +> nonStandardEndpoint({ id }); +> }; +> ``` + +### getRequestInit(body): RequestInit {#getRequestInit} + +Prepares [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) used in fetch. +This is sent to [fetchResponse](#fetchResponse) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getRequestInit(body) { +> return { +> ...(await super.getRequestInit(body)), +> method: await getMethod(), +> }; +> } +> } +> +> async function getMethod() { +> return 'GET'; +> } +> ``` + +### getHeaders(headers: HeadersInit): HeadersInit {#getHeaders} + +Called by [getRequestInit](#getRequestInit) to determine [HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) + +This is often useful for [authentication](./auth.md) + +> **Warning** +> +> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getHeaders(headers: HeadersInit) { +> return { +> ...headers, +> 'Access-Token': await getAuthToken(), +> }; +> } +> } +> +> async function getAuthToken() { +> return 'example'; +> } +> ``` + +## Handle fetch + +### fetchResponse(input, init): Promise {#fetchResponse} + +Performs the [fetch(input, init)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) call. When +[response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is not `true` (like 404), +will throw a NetworkError. + +### content {#content} + +Controls how the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) body is parsed. +When set, the return type is inferred automatically, and `schema` is constrained to `undefined` +for non-JSON content types. + +| Value | Parses via | Return type | +| --------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | +| `'json'` | [response.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) | `any` | +| `'blob'` | [response.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) | `Blob` | +| `'text'` | [response.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text) | `string` | +| `'arrayBuffer'` | [response.arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer) | `ArrayBuffer` | +| `'stream'` | `response.body` | `ReadableStream` | +| _unset_ | Auto-detect from Content-Type header | `any` | + +When `content` is not set, `parseResponse` auto-detects the response type from the +`Content-Type` header: JSON types call `.json()`, binary types (images, `application/octet-stream`, +PDFs, etc.) call `.blob()`, and text-like types call `.text()`. + +#### File downloads {#file-download} + +For file downloads, set `content: 'blob'`. The return type is `Blob` and `schema` must be +`undefined` (binary data cannot be normalized). Use `dataExpiryLength: 0` to avoid caching +large blobs in memory. + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +To extract the filename from the `Content-Disposition` header, override `parseResponse`: + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +See [file download guide](https://dataclient.io/rest/guides/network-transform#file-download) for complete usage with browser download trigger. + +### parseResponse(response): Promise {#parseResponse} + +Takes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and parses the body. + +When [`content`](#content) is set, it controls parsing directly. Otherwise, auto-detection runs +based on the [`Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): +JSON types call [.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json), binary +types call [.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob), and text-like +types call [.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text). + +If `status` is 204, resolves as `null`. + +Override this for advanced cases like extracting headers alongside the body. + +### process(value, ...args): any {#process} + +Perform any transforms with the parsed result. Defaults to identity function (do nothing). + +> **Tip** +> +> The return type of process can be used to set the return type of the endpoint fetch: +> +> ```ts title="getTodo.ts" {4} +> export const getTodo = new RestEndpoint({ +> path: '/todos/:id', +> // The identity function is the default value; so we aren't changing any runtime behavior +> process(value): TodoInterface { +> return value; +> }, +> }); +> +> interface TodoInterface { +> id: string; +> title: string; +> completed: boolean; +> } +> ``` +> +> ```ts title="useTodo.ts" +> import { getTodo } from './getTodo'; +> +> async (id: string) => { +> // hover title to see it is a string +> // see TS autocomplete by deleting `.title` and retyping the `.` +> const title = (await getTodo({ id })).title; +> }; +> ``` + +## Endpoint Lifecycle + +### schema?: Schema {#schema} + +[Declarative data lifecycle](https://dataclient.io/rest/api/schema) + +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) +- [Race condition handling](https://dataclient.io/rest/api/Entity#shouldreorder) +- [Validation](https://dataclient.io/rest/api/Entity#validate) + +```tsx +import { Entity, RestEndpoint } from '@data-client/rest'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +### key(urlParams): string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.method} ${this.url(urlParams)}`; +``` + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver), +[Controller.expireAll()](https://dataclient.io/docs/api/Controller#expireAll), and [Controller.invalidateAll()](https://dataclient.io/docs/api/Controller#invalidateAll). + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/docs/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/docs/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/docs/api/useSubscription) or +[useLive()](https://dataclient.io/docs/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` + +## extend(options): RestEndpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const UserDetailNormalized = getUser.extend({ + schema: User, + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + }, +}); +``` + +## Specialized extenders + +These convenience accessors create new endpoints for common [Collection](https://dataclient.io/rest/api/Collection) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](https://dataclient.io/rest/api/Collection). + +### push + +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](https://dataclient.io/rest/api/Collection#push) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the end of the list +const newTodo = await ctrl.fetch( + getTodos.push, + { userId: '1' }, + { title: 'Buy groceries' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the end of the list +const newUser = await ctrl.fetch( + UserResource.getList.push, + { group: 'five' }, + { username: 'newuser', email: 'new@example.com' }, +); +``` + +### unshift + +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](https://dataclient.io/rest/api/Collection#unshift) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the beginning of the list +const newTodo = await ctrl.fetch( + getTodos.unshift, + { userId: '1' }, + { title: 'Urgent task' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the start of the list +const newUser = await ctrl.fetch( + UserResource.getList.unshift, + { group: 'five' }, + { username: 'priorityuser', email: 'priority@example.com' }, +); +``` + +### assign + +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](https://dataclient.io/rest/api/Collection#assign) + +```tsx +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); + +// POST /products/stats - add/update entries in the Values collection +await ctrl.fetch(getStats.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +```tsx +const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + schema: new Collection(new Values(Stats)), + }, +}); + +// POST /products/stats - add/update entries +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +### remove + +Creates a PATCH endpoint that removes Entities from a [Collection](https://dataclient.io/rest/api/Collection) and updates them with the response. + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](https://dataclient.io/rest/api/Collection#remove) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: new Collection([Todo]), +}); + +// PATCH /todos - removes Todo from collection AND updates the entity +await ctrl.fetch(getTodos.remove, {}, { id: '123', completed: true }); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users - removes user from 'five' group list +// AND updates the user entity with response data (e.g., new group) +await ctrl.fetch( + UserResource.getList.remove, + { group: 'five' }, + { id: '2', group: 'newgroup' }, +); +``` + +To use the remove schema with a different endpoint (e.g., DELETE): + +```ts +const deleteAndRemove = MyResource.delete.extend({ + schema: MyResource.getList.schema.remove, +}); +``` + +### move + +Creates a PATCH endpoint that moves Entities between [Collections](https://dataclient.io/rest/api/Collection). It removes from +collections matching the entity's existing state and adds to collections matching the new values +(from the body/last arg). + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](https://dataclient.io/rest/api/Collection#move) + +```ts title="TaskResource" +import { Entity, resource } from '@data-client/rest'; + +export class Task extends Entity { + id = ''; + title = ''; + status = 'backlog'; + pk() { return this.id; } + static key = 'Task'; +} +export const TaskResource = resource({ + path: '/tasks/:id', + searchParams: {} as { status: string }, + schema: Task, + optimistic: true, +}); +``` + +```tsx title="TaskCard" {5-9} +import { useController } from '@data-client/react'; +import { TaskResource, type Task } from './TaskResource'; + +export default function TaskCard({ task }: { task: Task }) { + const handleMove = () => ctrl.fetch( + TaskResource.getList.move, + { id: task.id }, + { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' }, + ); + const ctrl = useController(); + return ( +
+ {task.title} + +
+ ); +} +``` + +```tsx title="TaskBoard" +import { useSuspense } from '@data-client/react'; +import { TaskResource } from './TaskResource'; +import TaskCard from './TaskCard'; + +function TaskBoard() { + const backlog = useSuspense(TaskResource.getList, { status: 'backlog' }); + const inProgress = useSuspense(TaskResource.getList, { status: 'in-progress' }); + return ( +
+
+

Backlog

+ {backlog.map(task => )} +
+
+

Active

+ {inProgress.map(task => )} +
+
+ ); +} +render(); +``` + +The remove filter is based on the entity's **existing** values in the store. +The add filter is based on the merged entity values (existing + body). +This uses the same [createCollectionFilter](https://dataclient.io/rest/api/Collection#createcollectionfilter) logic as push/remove. + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group +await ctrl.fetch( + UserResource.getList.move, + { group: 'five', id: '2' }, + { id: '2', group: 'ten' }, +); +``` + +### getPage + +An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: Todo, + paginationField: 'page', +}); + +const todos = useSuspense(getTodos); +return ( + + // fetches url `/todos?page=${nextPage}` + ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + } + /> +); +``` + +See [pagination guide](https://dataclient.io/rest/guides/pagination) for more info. + +### paginated(paginationfield) {#paginated} + +Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific +page, to append to this endpoint. See [Infinite Scrolling Pagination](https://dataclient.io/rest/guides/pagination#infinite-scrolling) for more info. + +```ts +const getNextPage = getList.paginated('cursor'); +``` + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +### paginated(removeCursor) {#paginated-function} + +```typescript +function paginated( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], +): PaginationEndpoint; +``` + +The function form allows any argument processing. This is the equivalent of sending `cursor` string like above. + +```ts +const getNextPage = getList.paginated( + ({ cursor, ...rest }: { cursor: string | number }) => + (Object.keys(rest).length ? [rest] : []) as any, +); +``` + +`removeCusor` is a function that takes the arguments sent in fetch of `getNextPage` and returns +the arguments to update `getList`. + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +## Inheritance + +Make sure you use `RestGenerics` to keep types working. + +```ts +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.github.com'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + } +} +``` diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md new file mode 100644 index 000000000000..cf3111648e47 --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md @@ -0,0 +1,1417 @@ + + +# RestEndpoint + +`RestEndpoints` are for [HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP) based protocols like REST. + +> **Info: extends** +> +> `RestEndpoint` extends [Endpoint](https://dataclient.io/rest/api/Endpoint) + +
+ +Interface + +**RestEndpoint** + +```typescript +interface RestGenerics { + readonly path: string; + readonly schema?: Schema | undefined; + readonly method?: string; + readonly body?: any; + readonly searchParams?: any; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + process?(value: any, ...args: any): any; +} + +export class RestEndpoint extends Endpoint { + /* Prepare fetch */ + readonly path: string; + readonly urlPrefix: string; + readonly requestInit: RequestInit; + readonly method: string; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + readonly signal: AbortSignal | undefined; + url(...args: Parameters): string; + searchToString(searchParams: Record): string; + getRequestInit( + this: any, + body?: RequestInit['body'] | Record, + ): Promise | RequestInit; + getHeaders(headers: HeadersInit): Promise | HeadersInit; + + /* Perform/process fetch */ + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + parseResponse(response: Response): Promise; + process(value: any, ...args: Parameters): any; + + testKey(key: string): boolean; +} +``` + +**Endpoint** + +```typescript +class Endpoint Promise> { + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + readonly getOptimisticResponse?: ( + snap: SnapshotInterface, + ...args: Parameters + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + + testKey(key: string): boolean; +} +``` + +
+ +## Usage + +All options are supported as arguments to the constructor, [extend](#extend), and as overrides when using [inheritance](#inheritance) + +### Simplest retrieval + +```ts +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +``` + +```ts +const todo = await getTodo({ id: 1 }); +``` + +### Configuration sharing + +Use [RestEndpoint.extend()](#extend) instead of `{...getTodo}` ([Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)) + +```ts +const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +### Managing state + +```ts path=Todo.ts +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; +} + +export const getTodo = new RestEndpoint({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +export const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +Using a [Schema](https://dataclient.io/rest/api/schema) enables [automatic data consistency](https://dataclient.io/vue/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/vue/api/Controller#expireAll). + +### Typing + +```ts title="Comment" +export class Comment extends Entity { + id = ''; + title = ''; + body = ''; + postId = ''; + + static key = 'Comment'; +} +``` + +```ts title="Usage" +import { Comment } from './Comment'; + +const getComments = new RestEndpoint({ + path: '/posts/:postId/comments', + schema: new Collection([Comment]), + searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, +}); + +// Hover your mouse over 'comments' to see its type +const comments = useSuspense(getComments, { + postId: '5', + sortBy: 'votes', +}); + +const ctrl = useController(); +const createComment = async data => + ctrl.fetch(getComments.push, { postId: '5' }, data); +``` + +#### Resolution/Return + +[schema](#schema) determines the return value when used with data-binding hooks like [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE), [useCache](https://dataclient.io/vue/api/useCache) +or when used with [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) + +```ts title="Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +``` + +```ts title="getTodo.ts" +import { Todo } from './Todo'; + +const getTodo = new RestEndpoint({ path: '/', schema: Todo }); +// Hover your mouse over 'todo' to see its type +const todo = useSuspense(getTodo); + +async () => { + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +[process](#process) determines the resolution value when the endpoint is called directly. For +`RestEndpoints` without a schema, it also determines the return type of [hooks](https://dataclient.io/vue/api/useSuspense) and [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch). + +```ts path="process.ts" +interface TodoInterface { + title: string; + completed: boolean; +} +const getTodo = new RestEndpoint({ + path: '/', + process(value): TodoInterface { + return value; + }, +}); +async () => { + // todo is TodoInterface + const todo = await getTodo(); + + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +#### Function Parameters + +[path](#path) used to construct the url determines the type of the first argument. If it has no patterns, +then the 'first' argument is skipped. + +```ts +const getRoot = new RestEndpoint({ path: '/' }); +getRoot(); +const getById = new RestEndpoint({ path: '/:id' }); +// both number and string types work as they are serialized into strings to construct the url +getById({ id: 5 }); +getById({ id: '5' }); +``` + +[method](#method) determines whether there is a second argument to be sent as the [body](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body). + +```ts path=method.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', +}); +update({ id: 5 }, { title: 'updated', completed: true }); +``` + +However, this is typed as 'any' so it won't catch typos. + +[body](#body) can be used to type the argument after the url parameters. It is only used for typing so the +value sent does not matter. `undefined` value can be used to 'disable' the second argument. + +```ts path=body.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: {} as TodoInterface, +}); +update({ id: 5 }, { title: 'updated', completed: true }); +// `undefined` disables 'body' argument +const rpc = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: undefined, +}); +rpc({ id: 5 }); +``` + +[searchParams](#searchParams) can be used in a similar way to `body` to specify types extra parameters, used +for the GET searchParams/queryParams in a [url()](#url). + +```ts +const getUsers = new RestEndpoint({ + path: '/:group/user/:id', + searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' }, +}); +getUsers.url({ group: 'big', id: '5', sort: 'asc' }) === + '/big/user/5?sort=asc'; +getUsers.url({ + group: 'big', + id: '5', + sort: 'desc', + isAdmin: true, +}) === '/big/user/5?isAdmin=true&sort=desc'; +``` + +## Fetch Lifecycle + +RestEndpoint adds to Endpoint by providing customizations for a provided fetch method using +[inheritance](#inheritance) or [.extend()](#extend). + +```mermaid +flowchart TB + URL-->response + INIT-->response + subgraph Prepare Fetch + subgraph URL + direction BT + urlPrefix-->url("url(urlParams)") + path-->url + searchToString("searchToString()")-->url + searchParams-->searchToString("searchToString()") + end + subgraph INIT + direction BT + getHeaders("getHeaders()")-->reqinit("getRequestInit(body)") + method-->reqinit + signal-->reqinit + end + end + subgraph Perform Fetch + response("fetchResponse()")-->parse("parseResponse()") + parse-->process("process()") + end + click url "/rest/api/RestEndpoint#url" + click searchToString "/rest/api/RestEndpoint#searchToString" + click searchParams "/rest/api/RestEndpoint#searchParams" + click urlPrefix "/rest/api/RestEndpoint#urlPrefix" + click path "/rest/api/RestEndpoint#path" + click getHeaders "/rest/api/RestEndpoint#getHeaders" + click method "/rest/api/RestEndpoint#method" + click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" + click reqinit "/rest/api/RestEndpoint#getRequestInit" + click response "/rest/api/RestEndpoint#fetchResponse" + click parse "/rest/api/RestEndpoint#parseResponse" + click process "/rest/api/RestEndpoint#process" +``` + +```ts title="fetch implementation for RestEndpoint" +function fetch(...args) { + const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; + const body = this.#hasBody ? args[args.length - 1] : undefined; + return this.fetchResponse( + this.url(urlParams), + await this.getRequestInit(body), + ) + .then(response => this.parseResponse(response)) + .then(res => this.process(res, ...args)); +} +``` + +## Prepare Fetch + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### url(params): string {#url} + +`urlPrefix` + `path template` + '?' + searchToString(`searchParams`) + +`url()` uses the `params` to fill in the [path template](#path). Any unused `params` members are then used +as [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka 'GET' params - the stuff after `?`). + +
+ +Implementation + +```typescript +import { getUrlBase, getUrlTokens } from '@data-client/rest'; + +url(urlParams = {}) { + const urlBase = getUrlBase(this.path)(urlParams); + const tokens = getUrlTokens(this.path); + const searchParams = {}; + Object.keys(urlParams).forEach(k => { + if (!tokens.has(k)) { + searchParams[k] = urlParams[k]; + } + }); + if (Object.keys(searchParams).length) { + return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; + } + return `${this.urlPrefix}${urlBase}`; +} +``` + +
+ +### searchToString(searchParams): string {#searchToString} + +Constructs the [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) component of [url](#url). + +By default uses the standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) global. + +[searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka queryParams) are sorted to maintain determinism. + +
+ +Implementation + +```typescript +searchToString(searchParams) { + const params = new URLSearchParams(searchParams); + params.sort(); + return params.toString(); +} +``` + +
+ +#### Using `qs` library + +To encode complex objects in the searchParams, you can use the [qs](https://github.com/ljharb/qs) library. + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +class QSEndpoint extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="QSEndpoint" {7} +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export default class QSEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="getFoo" +import QSEndpoint from './QSEndpoint'; + +const getFoo = new QSEndpoint({ + path: '/foo', + searchParams: {} as { a: Record }, +}); + +getFoo({ a: { b: 'c' } }); +``` + +### path: string {#path} + +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) to build +urls using the parameters passed. This also informs the types so they are properly enforced. + +#### Parameters + +`:` prefixed words are parameter names. Both strings and numbers are accepted as values, +since they are serialized into the url string. + +```ts +const getThing = new RestEndpoint({ path: '/:group/things/:id' }); +getThing({ group: 'first', id: 77 }); +``` + +#### Optional parameters + +Wrap the optional segment (including its prefix) in `{}` to make it [optional](https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#optional). +The type of optional parameters becomes `string | number | undefined`. + +```ts +const optional = new RestEndpoint({ + path: '/:group/things{/:number}', +}); +optional({ group: 'first' }); +optional({ group: 'first', number: 'fifty' }); +``` + +Multiple optional segments can be chained with different prefixes: + +```ts +const ep = new RestEndpoint({ + path: '{/:attr1}{-:attr2}{-:attr3}', +}); + +ep({ attr1: 'hi' }); +ep({ attr2: 'hi' }); +ep({ attr1: 'hi', attr3: 'ho' }); +``` + +#### Wildcards (repeating parameters) + +`*name` matches one-or-more path segments. Wrap in `{}` to make it zero-or-more (optional). +Wildcard parameters are typed as `string[]` (arrays), since they represent multiple path segments. + +```ts +const files = new RestEndpoint({ path: '/files/*path' }); +files({ path: ['documents', 'reports', 'q4'] }); +// URL: /files/documents/reports/q4 + +const optionalFiles = new RestEndpoint({ path: '/files{/*path}' }); +optionalFiles({}); +// URL: /files +optionalFiles({ path: ['documents'] }); +// URL: /files/documents +``` + +#### Quoted parameter names + +Parameter names must be valid JavaScript identifiers. Names containing special characters +like `-` or `.` must be quoted with double quotes: + +```ts +const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' }); +ep({ 'with-dash': 'hello', 'my.param': 'world' }); +``` + +#### Escaping special characters + +Characters `{}()*:` and `\\` are special in path-to-regexp and must be escaped with `\\` when used as literals. + +```ts +const getSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', +}); +getSite({ slug: 'first' }); +``` + +`?` and `+` are **not** special in path-to-regexp v8 and do not need escaping. +This means query strings can be embedded in the path without escaping `?`: + +```ts +const search = new RestEndpoint({ + path: '/search?{q=:q}{&page=:page}', +}); +search({ q: 'test', page: 1 }); +// URL: /search?q=test&page=1 +``` + +> **Info** +> +> Types are inferred automatically from `path`. +> +> Additional parameters can be specified with [searchParams](#searchParams) +> and [body](#body). + +### searchParams {#searchParams} + +`searchParams` can be to specify types extra parameters, used for the GET searchParams/queryParams in a [url()](#url). + +The actual **value is not used** in any way - this only determines [typing](#typing). + +```typescript title="getFoo" +const getReactSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + searchParams: {} as { isReact: boolean }, +}); + +getReactSite({ slug: 'cool', isReact: true }); +``` + +### body {#body} + +`body` can be used to set a second argument for mutation endpoints. The actual **value is not +used** in any way - this only determines [typing](#typing). + +This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'. + +```ts {4} +const updateSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + method: 'POST', + body: {} as { url: string }, +}); + +updateSite({ slug: 'cool' }, { url: '/' }); +``` + +### paginationField + +If specified, will add [getPage](#getpage) method on the `RestEndpoint`. [Pagination guide](https://dataclient.io/rest/guides/pagination). Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection). + +### urlPrefix: string = '' {#urlPrefix} + +Prepends this to the compiled [path](#path) + +#### Inheritance defaults + +```typescript +export class MyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // this allows us to override the prefix in production environments, with a dev fallback + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; +} +``` + +[Learn more about inheritance patterns](#inheritance) for RestEndpoint + +#### Instance overrides + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:product_id/ticker', + schema: Ticker, +}); +``` + +#### Dynamic prefix + +> **Tip** +> +> For a dynamic prefix, try overriding the url() method instead: +> +> ```ts +> const getTodo = new RestEndpoint({ +> path: '/todo/:id', +> url(...args) { +> return dynamicPrefix() + super.url(...args); +> }, +> }); +> ``` + +### method: string = 'GET' {#method} + +[Method](https://developer.mozilla.org/en-US/docs/Web/API/Request/method) is part of the HTTP protocol. +REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this +to inform `sideEffect` and whether the endpoint should use a `body` payload. Setting +`sideEffect` explicitly will override this behavior, allowing for non-standard API designs. + +`GET` is 'readonly', other methods imply sideEffects. + +`GET` and `DELETE` both default to no `body`. + +> **Tip: How method affects function Parameters** +> +> `method` only influences parameters in the RestEndpoint constructor and _not_ [.extend()](#extend). +> This allows non-standard method-body combinations. +> +> `body` will default to `any`. You can always set body explicitly to take full control. `undefined` can be used +> to indicate there is no body. +> +> ```ts +> (id: string, myPayload: Record) => { +> const standardCreate = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> }); +> standardCreate({ id }, myPayload); +> const nonStandardEndpoint = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> body: undefined, +> }); +> // no second 'body' argument, because body was set to 'undefined' +> nonStandardEndpoint({ id }); +> }; +> ``` + +### getRequestInit(body): RequestInit {#getRequestInit} + +Prepares [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) used in fetch. +This is sent to [fetchResponse](#fetchResponse) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getRequestInit(body) { +> return { +> ...(await super.getRequestInit(body)), +> method: await getMethod(), +> }; +> } +> } +> +> async function getMethod() { +> return 'GET'; +> } +> ``` + +### getHeaders(headers: HeadersInit): HeadersInit {#getHeaders} + +Called by [getRequestInit](#getRequestInit) to determine [HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) + +This is often useful for [authentication](./auth.md) + +> **Warning** +> +> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getHeaders(headers: HeadersInit) { +> return { +> ...headers, +> 'Access-Token': await getAuthToken(), +> }; +> } +> } +> +> async function getAuthToken() { +> return 'example'; +> } +> ``` + +## Handle fetch + +### fetchResponse(input, init): Promise {#fetchResponse} + +Performs the [fetch(input, init)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) call. When +[response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is not `true` (like 404), +will throw a NetworkError. + +### content {#content} + +Controls how the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) body is parsed. +When set, the return type is inferred automatically, and `schema` is constrained to `undefined` +for non-JSON content types. + +| Value | Parses via | Return type | +| --------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | +| `'json'` | [response.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) | `any` | +| `'blob'` | [response.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) | `Blob` | +| `'text'` | [response.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text) | `string` | +| `'arrayBuffer'` | [response.arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer) | `ArrayBuffer` | +| `'stream'` | `response.body` | `ReadableStream` | +| _unset_ | Auto-detect from Content-Type header | `any` | + +When `content` is not set, `parseResponse` auto-detects the response type from the +`Content-Type` header: JSON types call `.json()`, binary types (images, `application/octet-stream`, +PDFs, etc.) call `.blob()`, and text-like types call `.text()`. + +#### File downloads {#file-download} + +For file downloads, set `content: 'blob'`. The return type is `Blob` and `schema` must be +`undefined` (binary data cannot be normalized). Use `dataExpiryLength: 0` to avoid caching +large blobs in memory. + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +To extract the filename from the `Content-Disposition` header, override `parseResponse`: + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +See [file download guide](https://dataclient.io/rest/guides/network-transform#file-download) for complete usage with browser download trigger. + +### parseResponse(response): Promise {#parseResponse} + +Takes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and parses the body. + +When [`content`](#content) is set, it controls parsing directly. Otherwise, auto-detection runs +based on the [`Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): +JSON types call [.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json), binary +types call [.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob), and text-like +types call [.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text). + +If `status` is 204, resolves as `null`. + +Override this for advanced cases like extracting headers alongside the body. + +### process(value, ...args): any {#process} + +Perform any transforms with the parsed result. Defaults to identity function (do nothing). + +> **Tip** +> +> The return type of process can be used to set the return type of the endpoint fetch: +> +> ```ts title="getTodo.ts" {4} +> export const getTodo = new RestEndpoint({ +> path: '/todos/:id', +> // The identity function is the default value; so we aren't changing any runtime behavior +> process(value): TodoInterface { +> return value; +> }, +> }); +> +> interface TodoInterface { +> id: string; +> title: string; +> completed: boolean; +> } +> ``` +> +> ```ts title="useTodo.ts" +> import { getTodo } from './getTodo'; +> +> async (id: string) => { +> // hover title to see it is a string +> // see TS autocomplete by deleting `.title` and retyping the `.` +> const title = (await getTodo({ id })).title; +> }; +> ``` + +## Endpoint Lifecycle + +### schema?: Schema {#schema} + +[Declarative data lifecycle](https://dataclient.io/rest/api/schema) + +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](https://dataclient.io/rest/api/schema) to expect [Entities](https://dataclient.io/rest/api/Entity) +- Functions to [deserialize fields](https://dataclient.io/rest/guides/network-transform#deserializing-fields) +- [Race condition handling](https://dataclient.io/rest/api/Entity#shouldreorder) +- [Validation](https://dataclient.io/rest/api/Entity#validate) + +```tsx +import { Entity, RestEndpoint } from '@data-client/rest'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +### key(urlParams): string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.method} ${this.url(urlParams)}`; +``` + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver), +[Controller.expireAll()](https://dataclient.io/vue/api/Controller#expireAll), and [Controller.invalidateAll()](https://dataclient.io/vue/api/Controller#invalidateAll). + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](https://dataclient.io/vue/concepts/expiry-policy#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](https://dataclient.io/vue/concepts/error-policy) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/vue/api/useSubscription) or +[useLive()](https://dataclient.io/vue/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[Optimistic update guide](https://dataclient.io/rest/guides/optimistic-updates) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](https://dataclient.io/rest/api/Collection) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` + +## extend(options): RestEndpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const UserDetailNormalized = getUser.extend({ + schema: User, + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + }, +}); +``` + +## Specialized extenders + +These convenience accessors create new endpoints for common [Collection](https://dataclient.io/rest/api/Collection) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](https://dataclient.io/rest/api/Collection). + +### push + +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](https://dataclient.io/rest/api/Collection#push) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the end of the list +const newTodo = await ctrl.fetch( + getTodos.push, + { userId: '1' }, + { title: 'Buy groceries' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the end of the list +const newUser = await ctrl.fetch( + UserResource.getList.push, + { group: 'five' }, + { username: 'newuser', email: 'new@example.com' }, +); +``` + +### unshift + +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](https://dataclient.io/rest/api/Collection#unshift) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the beginning of the list +const newTodo = await ctrl.fetch( + getTodos.unshift, + { userId: '1' }, + { title: 'Urgent task' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the start of the list +const newUser = await ctrl.fetch( + UserResource.getList.unshift, + { group: 'five' }, + { username: 'priorityuser', email: 'priority@example.com' }, +); +``` + +### assign + +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](https://dataclient.io/rest/api/Collection). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](https://dataclient.io/rest/api/Collection#assign) + +```tsx +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); + +// POST /products/stats - add/update entries in the Values collection +await ctrl.fetch(getStats.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +```tsx +const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + schema: new Collection(new Values(Stats)), + }, +}); + +// POST /products/stats - add/update entries +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +### remove + +Creates a PATCH endpoint that removes Entities from a [Collection](https://dataclient.io/rest/api/Collection) and updates them with the response. + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](https://dataclient.io/rest/api/Collection#remove) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: new Collection([Todo]), +}); + +// PATCH /todos - removes Todo from collection AND updates the entity +await ctrl.fetch(getTodos.remove, {}, { id: '123', completed: true }); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users - removes user from 'five' group list +// AND updates the user entity with response data (e.g., new group) +await ctrl.fetch( + UserResource.getList.remove, + { group: 'five' }, + { id: '2', group: 'newgroup' }, +); +``` + +To use the remove schema with a different endpoint (e.g., DELETE): + +```ts +const deleteAndRemove = MyResource.delete.extend({ + schema: MyResource.getList.schema.remove, +}); +``` + +### move + +Creates a PATCH endpoint that moves Entities between [Collections](https://dataclient.io/rest/api/Collection). It removes from +collections matching the entity's existing state and adds to collections matching the new values +(from the body/last arg). + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](https://dataclient.io/rest/api/Collection#move) + +```ts title="TaskResource" +import { Entity, resource } from '@data-client/rest'; + +export class Task extends Entity { + id = ''; + title = ''; + status = 'backlog'; + pk() { return this.id; } + static key = 'Task'; +} +export const TaskResource = resource({ + path: '/tasks/:id', + searchParams: {} as { status: string }, + schema: Task, + optimistic: true, +}); +``` + +```tsx title="TaskCard" {5-9} +import { useController } from '@data-client/react'; +import { TaskResource, type Task } from './TaskResource'; + +export default function TaskCard({ task }: { task: Task }) { + const handleMove = () => ctrl.fetch( + TaskResource.getList.move, + { id: task.id }, + { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' }, + ); + const ctrl = useController(); + return ( +
+ {task.title} + +
+ ); +} +``` + +```tsx title="TaskBoard" +import { useSuspense } from '@data-client/react'; +import { TaskResource } from './TaskResource'; +import TaskCard from './TaskCard'; + +function TaskBoard() { + const backlog = useSuspense(TaskResource.getList, { status: 'backlog' }); + const inProgress = useSuspense(TaskResource.getList, { status: 'in-progress' }); + return ( +
+
+

Backlog

+ {backlog.map(task => )} +
+
+

Active

+ {inProgress.map(task => )} +
+
+ ); +} +render(); +``` + +The remove filter is based on the entity's **existing** values in the store. +The add filter is based on the merged entity values (existing + body). +This uses the same [createCollectionFilter](https://dataclient.io/rest/api/Collection#createcollectionfilter) logic as push/remove. + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group +await ctrl.fetch( + UserResource.getList.move, + { group: 'five', id: '2' }, + { id: '2', group: 'ten' }, +); +``` + +### getPage + +An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema +must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: Todo, + paginationField: 'page', +}); + +const todos = useSuspense(getTodos); +return ( + + // fetches url `/todos?page=${nextPage}` + ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + } + /> +); +``` + +See [pagination guide](https://dataclient.io/rest/guides/pagination) for more info. + +### paginated(paginationfield) {#paginated} + +Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific +page, to append to this endpoint. See [Infinite Scrolling Pagination](https://dataclient.io/rest/guides/pagination#infinite-scrolling) for more info. + +```ts +const getNextPage = getList.paginated('cursor'); +``` + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +### paginated(removeCursor) {#paginated-function} + +```typescript +function paginated( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], +): PaginationEndpoint; +``` + +The function form allows any argument processing. This is the equivalent of sending `cursor` string like above. + +```ts +const getNextPage = getList.paginated( + ({ cursor, ...rest }: { cursor: string | number }) => + (Object.keys(rest).length ? [rest] : []) as any, +); +``` + +`removeCusor` is a function that takes the arguments sent in fetch of `getNextPage` and returns +the arguments to update `getList`. + +Schema must also contain a [Collection](https://dataclient.io/rest/api/Collection) + +## Inheritance + +Make sure you use `RestGenerics` to keep types working. + +```ts +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.github.com'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + } +} +``` diff --git a/.agents/skills/data-client-rest-setup/references/auth.md b/.agents/skills/data-client-rest-setup/references/auth.md deleted file mode 120000 index 245b1d93a236..000000000000 --- a/.agents/skills/data-client-rest-setup/references/auth.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/auth.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest-setup/references/auth.md b/.agents/skills/data-client-rest-setup/references/auth.md new file mode 100644 index 000000000000..632daad8277e --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/auth.md @@ -0,0 +1,383 @@ + + +# Rest Authentication + +All network requests are run through the [getRequestInit](./RestEndpoint.md#getRequestInit) optionally +defined in your [RestEndpoint](./RestEndpoint.md). + +## Cookie Auth (credentials) + +Here's an example using simple [cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) auth by sending [fetch credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included): + +```ts title="AuthdEndpoint" {9} +import { RestEndpoint } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +See [Django Integration](./django.md) for an example that also includes [CSRF protection](https://docs.djangoproject.com/en/5.0/howto/csrf/#using-csrf-protection-with-ajax). + +## Access Tokens or JWT + +**static member** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; +``` + +```ts title="AuthdEndpoint" {7,15,22} +import { RestEndpoint } from '@data-client/rest'; +import { login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare static accessToken?: string; + + getHeaders(headers: HeadersInit) { + // TypeScript doesn't infer properly + const EP = this.constructor as typeof AuthdEndpoint; + if (!EP.accessToken) return headers; + return { + ...headers, + 'Access-Token': EP.accessToken, + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + AuthdEndpoint.accessToken = accessToken; +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**async function** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +// imagine this used an async API like indexedDB +export const getAuthToken = async () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': await getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**function singleton** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +export const getAuthToken = () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +## Auth Headers from React Context + +> **Warning** +> +> Using React Context for state that is not displayed (like auth tokens) is not recommended. +> This will result in unnecessary re-renders and application complexity. + +**Resource** + +We can transform any [Resource](./resource.md) into one that uses hooks to create endpoints +by using [hookifyResource](./hookifyResource.md) + +```ts title="resources/Post.ts" +import { resource, hookifyResource } from '@data-client/rest'; + +// Post defined here + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = useAuthContext(); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as hooks in our React Components + +```tsx +import { useSuspense } from '@data-client/react'; +import { PostResource } from 'resources/Post'; + +function PostDetail({ id }) { + const post = useSuspense(PostResource.useGet(), { id }); + return
{post.title}
; +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur during a function render. +> +> ```tsx +> function CreatePost() { +> const controller = useController(); +> const createPost = PostResource.useCreate(); +> +> return ( +>
onSubmit={e => controller.fetch(createPost, new FormData(e.target))} +> > +> {/* ... */} +>
+> ); +> } +> ``` + +**RestEndpoint** + +We will first provide an easy way of using the context to alter the fetch headers. + +```ts title="api/AuthdEndpoint.ts" +import { RestEndpoint } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare accessToken?: string; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': this.accessToken, + }; + } +} +``` + +Next we will [extend](./RestEndpoint.md#extend) to generate a new endpoint with this context injected. + +```tsx +function useEndpoint(endpoint: RestEndpoint) { + const accessToken = useAuthContext(); + return useMemo( + () => endpoint.extend({ accessToken }), + [endpoint, accessToken], + ); +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur during a function render. +> +> ```tsx +> function CreatePost() { +> const controller = useController(); +> const createPost = useEndpoint(PostResource.create); +> +> return ( +>
onSubmit={e => +> controller.fetch(createPost, {}, new FormData(e.target)) +> } +> > +> {/* ... */} +>
+> ); +> } +> ``` + +## Code organization + +If much of your `Resources` share a similar auth mechanism, you might +try extending from a base class that defines such common customizations. + +## 401 Logout Handling + +In case a users authorization expires, the server will typically responsd to indicate +as such. The standard way of doing this is with a 401. [LogoutManager](https://dataclient.io/docs/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-rest-setup/references/axios-migration.md b/.agents/skills/data-client-rest-setup/references/axios-migration.md index 0de7bef34b5e..128df3e55535 100644 --- a/.agents/skills/data-client-rest-setup/references/axios-migration.md +++ b/.agents/skills/data-client-rest-setup/references/axios-migration.md @@ -477,6 +477,6 @@ Search patterns for locating axios usage in a codebase: - [Axios Migration Guide](https://dataclient.io/rest/guides/axios-migration) — full documentation with interactive examples - [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference - [hookifyResource](https://dataclient.io/rest/api/hookifyResource) — context-based auth via hooks -- [NetworkError](https://dataclient.io/rest/api/NetworkError) — error class +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class - [Authentication guide](https://dataclient.io/rest/guides/auth) — token and cookie patterns - [Abort guide](https://dataclient.io/rest/guides/abort) — cancellation patterns diff --git a/.agents/skills/data-client-rest-setup/references/django.md b/.agents/skills/data-client-rest-setup/references/django.md deleted file mode 120000 index df605b9d896b..000000000000 --- a/.agents/skills/data-client-rest-setup/references/django.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/django.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest-setup/references/django.md b/.agents/skills/data-client-rest-setup/references/django.md new file mode 100644 index 000000000000..211b1f5ac631 --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/django.md @@ -0,0 +1,72 @@ + + +# Django Integration + +## Cookie Auth + CSRF + +Django add protection against Cross Site Request Forgery, by [requiring the 'X-CSRFToken' header in requests](https://docs.djangoproject.com/en/5.0/howto/csrf/#using-csrf-protection-with-ajax). + +Additionally Django's authentication uses [cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies), so we need to send credentials [fetch credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included). If you use +an auth type other than the default 'django.contrib.auth', see [authentication guide](./auth.md) for more examples. + +```ts title="getCookie" +export default function getCookie(name): string { + let cookieValue = ''; + if (document.cookie && document.cookie != '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) == (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} +``` + +```ts title="DjangoEndpoint" +import { RestEndpoint } from '@data-client/rest'; +import getCookie from './getCookie'; + +export default class DjangoEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } + getHeaders(headers: HeadersInit) { + if (this.method === 'GET') return headers; + return { + ...headers, + 'X-CSRFToken': getCookie('csrftoken'), + }; + } +} +``` + +```ts title="MyResource" {15} +import { resource, Entity } from '@data-client/rest'; +import DjangoEndpoint from './DjangoEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: DjangoEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` diff --git a/.agents/skills/data-client-rest-setup/references/fetch-migration.md b/.agents/skills/data-client-rest-setup/references/fetch-migration.md index 4e61ac13e67b..6944e3ae9048 100644 --- a/.agents/skills/data-client-rest-setup/references/fetch-migration.md +++ b/.agents/skills/data-client-rest-setup/references/fetch-migration.md @@ -130,5 +130,5 @@ try { ... } catch (err) { ## Reference - [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference -- [NetworkError](https://dataclient.io/rest/api/NetworkError) — error class +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class - [Authentication guide](https://dataclient.io/rest/guides/auth) — token and cookie patterns diff --git a/.agents/skills/data-client-rest-setup/references/got-migration.md b/.agents/skills/data-client-rest-setup/references/got-migration.md index 4fbd60065905..d52ae67120a2 100644 --- a/.agents/skills/data-client-rest-setup/references/got-migration.md +++ b/.agents/skills/data-client-rest-setup/references/got-migration.md @@ -145,5 +145,5 @@ const UserResource = resource({ ## Reference - [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference -- [NetworkError](https://dataclient.io/rest/api/NetworkError) — error class +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class - [Pagination guide](https://dataclient.io/rest/guides/pagination) — cursor/offset pagination patterns diff --git a/.agents/skills/data-client-rest-setup/references/hookifyResource.md b/.agents/skills/data-client-rest-setup/references/hookifyResource.md deleted file mode 120000 index 47febcf14ac2..000000000000 --- a/.agents/skills/data-client-rest-setup/references/hookifyResource.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/hookifyResource.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest-setup/references/hookifyResource.md b/.agents/skills/data-client-rest-setup/references/hookifyResource.md new file mode 100644 index 000000000000..79cd113df5a4 --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/hookifyResource.md @@ -0,0 +1,219 @@ + + +# hookifyResource + +`hookifyResource()` Turns any [Resource](./resource.md) (collection of [RestEndpoints](./RestEndpoint.md)) into a collection +of hooks that return [RestEndpoints](./RestEndpoint.md). + +> **Info** +> +> TypeScript >=4.3 is required for generative types to work correctly. + +```ts title="resources/Article" +import React from 'react'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +const AuthContext = React.createContext(''); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = React.useContext(AuthContext); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```tsx title="ArticleDetail" +import { ArticleResource } from './resources/Article'; + +function ArticleDetail({ id }) { + const article = useSuspense(ArticleResource.useGet(), { id }); + const updateArticle = ArticleResource.useUpdate(); + const ctrl = useController(); + const onSubmit = (body: any) => ctrl.fetch(updateArticle, { id }, body); + + return ; +} +render(); +``` + +## Members + +Assuming you use the unchanged result of [resource()](./resource.md), these will be your methods + +### useGet() + +- method: 'GET' +- path: `path` +- schema: [schema](https://dataclient.io/rest/api/Entity) + +```typescript +// GET //test.com/api/abc/xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGet()({ + group: 'abc', + id: 'xyz', +}); +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate) + +### useGetList() + +- method: 'GET' +- path: `shortenPath(path)` + - Removes the last `:param` or `*wildcard` token: + ```ts + hookifyResource(resource({ path: '/:first/:second' })).useGetList() + .path === '/:first'; + hookifyResource(resource({ path: '/:first' })).useGetList().path === + '/'; + hookifyResource(resource({ path: '/:owner/*path' })).useGetList() + .path === '/:owner'; + ``` +- schema: [\[schema\]](https://dataclient.io/rest/api/Array) + +```typescript +// GET //test.com/api/abc?isExtra=xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList()({ + group: 'abc', + isExtra: 'xyz', +}); +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate) + +### useGetList().push {#push} + +[push](./RestEndpoint.md#push) creates a new entity and pushes it to the end of useGetList(). + +- method: 'POST' +- path: `shortenPath(path)` +- schema: `useGetList().schema.push` + +```typescript +// POST //test.com/api/abc +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList().push({ group: 'abc' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useGetList().unshift {#unshift} + +[unshift](./RestEndpoint.md#unshift) creates a new entity and pushes it to the beginning of useGetList(). + +- method: 'POST' +- path: `shortenPath(path)` +- schema: `useGetList().schema.unshift` + +```typescript +// POST //test.com/api/abc +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList().unshift({ group: 'abc' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useGetList().getPage {#getpage} + +[getPage](./RestEndpoint.md#getpage) retrieves another [page](https://dataclient.io/rest/guides/pagination#infinite-scrolling) appending to useGetList() ensuring there are no duplicates. + +- method: 'GET' +- args: `shortenPath(path) & { [paginationField]: string | number } & searchParams` +- schema: [new Collection(\[schema\]).addWith(paginatedMerge, paginatedFilter(removeCursor))](https://dataclient.io/rest/api/Collection) + +```typescript +// GET //test.com/api/abc?isExtra=xyz&page=2 +hookifyResource( + resource({ + urlPrefix: '//test.com', + path: '/api/:group/:id', + paginationField: 'page', + }), +).useGetList().getPage({ + group: 'abc', + isExtra: 'xyz', + page: '2', +}); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useUpdate() + +- method: 'PUT' +- path: `path` +- schema: `schema` + +```typescript +// PUT //test.com/api/abc/xyz +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useUpdate()({ group: 'abc', id: 'xyz' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### usePartialUpdate() + +- method: 'PATCH' +- path: `path` +- schema: `schema` + +```typescript +// PATCH //test.com/api/abc/xyz +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).usePartialUpdate()({ group: 'abc', id: 'xyz' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useDelete() + +- method: 'DELETE' +- path: `path` +- schema: [new Invalidate(schema)](https://dataclient.io/rest/api/Invalidate) +- process: + ```ts + (value, params) { + return value && Object.keys(value).length ? value : params; + }, + ``` + +```typescript +// DELETE //test.com/api/abc/xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useDelete()({ + group: 'abc', + id: 'xyz', +}); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) diff --git a/.agents/skills/data-client-rest-setup/references/ky-migration.md b/.agents/skills/data-client-rest-setup/references/ky-migration.md index 05267f70c0c8..b7ec2c6ba6ae 100644 --- a/.agents/skills/data-client-rest-setup/references/ky-migration.md +++ b/.agents/skills/data-client-rest-setup/references/ky-migration.md @@ -140,4 +140,4 @@ try { ... } catch (err) { ## Reference - [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference -- [NetworkError](https://dataclient.io/rest/api/NetworkError) — error class +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class diff --git a/.agents/skills/data-client-rest-setup/references/resource.md b/.agents/skills/data-client-rest-setup/references/resource.md deleted file mode 120000 index ad7725422f72..000000000000 --- a/.agents/skills/data-client-rest-setup/references/resource.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/resource.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest-setup/references/resource.md b/.agents/skills/data-client-rest-setup/references/resource.md new file mode 100644 index 000000000000..3b8ae6f0eb92 --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/resource.md @@ -0,0 +1,816 @@ + + +# Resource + +`Resources` are a collection of [RestEndpoints](./RestEndpoint.md) that operate on a common +data by sharing a [schema](https://dataclient.io/rest/api/schema) + +## Usage + +```ts title="resources/Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} + +const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +``` + +```ts title="Resources start with 6 Endpoints" +const todo = useSuspense(TodoResource.get, { id: '5' }); +const todos = useSuspense(TodoResource.getList); +controller.fetch(TodoResource.getList.push, { + title: 'finish installing reactive data client', +}); +controller.fetch( + TodoResource.update, + { id: '5' }, + { ...todo, completed: true }, +); +controller.fetch( + TodoResource.partialUpdate, + { id: '5' }, + { completed: true }, +); +controller.fetch(TodoResource.delete, { id: '5' }); +``` + +## Arguments + +```ts +{ + path: string; + schema: Schema; + urlPrefix?: string; + body?: any; + searchParams?: any; + paginationField?: string; + optimistic?: boolean; + Endpoint?: typeof RestEndpoint; + Collection?: typeof Collection; +} & EndpointExtraOptions +``` + +### path + +Passed to [RestEndpoint.path](./RestEndpoint.md#path) for single item [endpoints](#members). +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) syntax — see +[RestEndpoint.path](./RestEndpoint.md#path) for full details on +[optional parameters](./RestEndpoint.md#path), [wildcards](./RestEndpoint.md#path), +[quoted names](./RestEndpoint.md#path), and [escaping](./RestEndpoint.md#path). + +Create ([getList.push](#push)/[getList.unshift](#unshift)) and [getList](#getlist) remove the last `:param` or `*wildcard` token. + +```ts +const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', +}); + +// GET /react/posts/abc +PostResource.get({ group: 'react', id: 'abc' }); +// PATCH /react/posts/abc +PostResource.partialUpdate({ group: 'react', id: 'abc' }, { title: 'This new title' }); +// GET /react/posts +PostResource.getList({ group: 'react' }); +``` + +Optional parameters use `{}` syntax: + +```ts +const PostResource = resource({ + schema: Post, + path: '/:group/posts{/:id}', +}); + +PostResource.get({ group: 'react', id: 'abc' }); +PostResource.getList({ group: 'react' }); +``` + +Wildcard parameters are also supported as the last token: + +```ts +const FileResource = resource({ + schema: File, + path: '/repos/:owner/*path', +}); + +// GET /repos/john/src/index.ts +FileResource.get({ owner: 'john', path: ['src', 'index.ts'] }); +// GET /repos/john +FileResource.getList({ owner: 'john' }); +``` + +### schema + +Passed to [RestEndpoint.schema](./RestEndpoint.md#schema) representing a single item. This is usually +an [Entity](https://dataclient.io/rest/api/Entity) or [Union](https://dataclient.io/rest/api/Union). + +- [getList](#getlist) uses an [Array](https://dataclient.io/rest/api/Array) [Collection](https://dataclient.io/rest/api/Collection) of the schema. +- [delete](#delete) uses a [Invalidate](https://dataclient.io/rest/api/Invalidate) of the schema. + +### urlPrefix + +Passed to [RestEndpoint.urlPrefix](./RestEndpoint.md#urlPrefix) + +### searchParams + +Passed to [RestEndpoint.searchParams](./RestEndpoint.md#searchParams) for [getList](#getlist) and [getList.push](#push) + +### body + +Passed to [RestEndpoint.body](./RestEndpoint.md#body) for [getList.push](#push) [update](#update) and [partialUpdate](#partialupdate) + +### paginationField + +If specified, will add [Resource.getList.getPage](#getpage) method on the `Resource`. + +### nonFilterArgumentKeys + +Pass-through option to [Collection.nonFilterArgumentKeys](https://dataclient.io/rest/api/Collection#nonFilterArgumentKeys) +for [getList](#getlist) schema. + +```ts +const PostResource = resource({ + path: '/:group/posts/:id', + searchParams: {} as { orderBy?: string; author?: string }, + schema: Post, + nonFilterArgumentKeys: ['orderBy'], +}); +``` + +`RegExp` and function forms are also supported: + +```ts +resource({ + path: '/:group/posts/:id', + searchParams: {} as { orderBy?: string; author?: string }, + schema: Post, + nonFilterArgumentKeys: /orderBy/, +}); +``` + +### optimistic + +`true` makes all mutation endpoints [optimistic](https://dataclient.io/rest/guides/optimistic-updates), making UI +updates immediate, even before fetch completion. + +### Endpoint + +Class used to construct the members. + +```ts +import { RestEndpoint } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} +const TodoResource = resource({ + path: '/todos/:id', + schema: Todo, + Endpoint: AuthdEndpoint, +}); +``` + +### Collection + +[Collection Class](https://dataclient.io/rest/api/Collection) used to construct [getList](#getlist) schema. +Use this when you need to customize collection behavior beyond +[`nonFilterArgumentKeys`](#nonfilterargumentkeys), like changing move merge logic. + +```ts +import { resource, Collection, unshift } from '@data-client/rest'; + +class MyCollection< + S extends any[] | PolymorphicInterface = any, + Parent extends any[] = [urlParams: any, body?: any], +> extends Collection { + constructor(schema: S) { + super(schema); + // prepend moved items instead of appending + this.move = this.moveWith(unshift); + } +} +const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string; orderBy?: string } | undefined, + schema: Todo, + Collection: MyCollection, +}); +``` + +### [EndpointExtraOptions](./RestEndpoint.md#dataexpirylength) + +dataExpiryLength, errorExpiryLength, errorPolicy, invalidIfStale, pollFrequency + +## Members + +These provide the standard [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) +[endpoints](https://dataclient.io/rest/api/Endpoint)s common in [REST](https://www.restapitutorial.com/) APIs. Feel free to [customize or add +new endpoints](#extend-new) based to match your API. + +```ts +const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, + paginationField: 'page', +}); +``` + +| Name | Method | Args | Schema | +| --------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------- | +| [get](#get) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; id: string}]` | [Post](https://dataclient.io/rest/api/Entity) | +| [getList](#getlist) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; author?: string}]` | [Collection(\[Post\])](https://dataclient.io/rest/api/Collection) | +| [getList.push](#push) | [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) | `[{group: string; author?: string}, Partial]` | [Collection(\[Post\]).push](https://dataclient.io/rest/api/Collection#push) | +| [getList.unshift](#unshift) | [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) | `[{group: string; author?: string}, Partial]` | [Collection(\[Post\]).unshift](https://dataclient.io/rest/api/Collection#unshift) | +| [getList.getPage](#getpage) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; author?: string; page: string}]` | [Collection(\[Post\]).addWith](https://dataclient.io/rest/api/Collection#addWith) | +| [getList.move](#move) | [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) | `[{group: string; id: string }, Partial]` | [Collection(\[Post\]).move](https://dataclient.io/rest/api/Collection#move) | +| [update](#update) | [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) | `[{group: string; id: string }, Partial]` | [Post](https://dataclient.io/rest/api/Entity) | +| [partialUpdate](#update) | [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) | `[{group: string; id: string }, Partial]` | [Post](https://dataclient.io/rest/api/Entity) | +| [delete](#delete) | [DELETE](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) | `[{group: string; id: string }]` | [Invalidate(Post)](https://dataclient.io/rest/api/Invalidate) | + +### get + +Retrieve a singular entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.get({ + group: 'react', + id: '1', +}); +``` + +| Field | Value | +| :----: | ----------------- | +| method | 'GET' | +| path | [path](#path) | +| schema | [schema](#schema) | + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate), [Controller.expireAll](https://dataclient.io/docs/api/Controller#expireAll) + +### getList + +Retrieve a list of entities. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList({ + group: 'react', + author: 'clara', +}); +``` + +| Field | Value | +| :-------------: | ----------------------------------------------------------------------- | +| method | 'GET' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| paginationField | [paginationField](#paginationfield) | +| schema | [new Collection(\[schema\])](https://dataclient.io/rest/api/Collection) | + +```ts +resource({ path: '/:first/:second' }).getList.path === '/:first'; +resource({ path: '/:first' }).getList.path === '/'; +resource({ path: '/:owner/*path' }).getList.path === '/:owner'; +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate), [Controller.expireAll](https://dataclient.io/docs/api/Controller#expireAll) + +### getList.push {#push} + +[RestEndpoint.push](./RestEndpoint.md#push) creates a new entity and pushes it to the end of getList. Use [getList.unshift](#unshift) +to place at the beginning instead. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.push( + { group: 'react', author: 'clara' }, + { title: 'winning' }, +); +``` + +| Field | Value | +| :----------: | --------------------------------------------------------------------- | +| method | 'POST' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| body | [body](#body) | +| schema | getList.[schema.push](https://dataclient.io/rest/api/Collection#push) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### getList.unshift {#unshift} + +[RestEndpoint.unshift](./RestEndpoint.md#unshift) creates a new entity and pushes it to the beginning of getList. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.unshift( + { group: 'react', author: 'clara' }, + { title: 'winning' }, +); +``` + +| Field | Value | +| :----------: | --------------------------------------------------------------------------- | +| method | 'POST' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| body | [body](#body) | +| schema | getList.[schema.unshift](https://dataclient.io/rest/api/Collection#unshift) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### getList.getPage {#getpage} + +[RestEndpoint.getPage](./RestEndpoint.md#getpage) retrieves another [page](https://dataclient.io/rest/guides/pagination#infinite-scrolling) appending to getList ensuring there are no duplicates. + +This member is only available when [paginationField](#paginationfield) is specified. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, + paginationField: 'page', +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.getPage({ + group: 'react', + author: 'clara', + page: 2, +}); +``` + +| Field | Value | +| :-------------: | --------------------------------------------------------------------------- | +| method | 'GET' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| paginationField | [paginationField](#paginationfield) | +| schema | [getList.schema.addWith](https://dataclient.io/rest/api/Collection#addWith) | + +args: `PathToArgs(shortenPath(path)) & searchParams & \{ [paginationField]: string | number \}` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### getList.move {#move} + +[RestEndpoint.move](./RestEndpoint.md#move) moves an entity between [Collections](https://dataclient.io/rest/api/Collection) by removing it from +collections matching its old state and adding it to collections matching the new values from the body. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.move( + { group: 'react', id: '1' }, + { group: 'vue' }, +); +``` + +| Field | Value | +| :----: | --------------------------------------------------------------------- | +| method | 'PATCH' | +| path | [path](#path) | +| body | [body](#body) | +| schema | getList.[schema.move](https://dataclient.io/rest/api/Collection#move) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### update + +Update an entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.update( + { group: 'react', id: '1' }, + { title: 'updated title', author: 'clara' }, +); +``` + +| Field | Value | +| :----: | ----------------- | +| method | 'PUT' | +| path | [path](#path) | +| body | [body](#body) | +| schema | [schema](#schema) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### partialUpdate + +Update some subset of fields of an entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.partialUpdate( + { group: 'react', id: '1' }, + { title: 'updated title' }, +); +``` + +| Field | Value | +| :----: | ----------------- | +| method | 'PATCH' | +| path | [path](#path) | +| body | [body](#body) | +| schema | [schema](#schema) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### delete + +Deletes an entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.delete({ group: 'react', id: '1' }); +``` + +| Field | Value | +| :-----: | -------------------------------------------------------------------------------------------- | +| method | 'DELETE' | +| path | [path](#path) | +| schema | [new Invalidate(schema)](https://dataclient.io/rest/api/Invalidate) | +| process | ```ts +(value, params) { + return value && Object.keys(value).length ? value : params; +}, +``` | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +#### Response + +```json +{ "id": "xyz" } +``` + +Response should either be the [pk](https://dataclient.io/rest/api/Entity#pk) as a string (like `'xyz'`). Or an object with the members needed to compute +[Entity.pk](https://dataclient.io/rest/api/Entity#pk) (like `{id: 'xyz'}`). + +If no response is provided, the `process` implementation will attempt to use the url parameters sent as an object to compute +the [Entity.pk](https://dataclient.io/rest/api/Entity#pk). This enables the default implementation to still work with no response, so long as standard +arguments are used. + +This allows [Invalidate](https://dataclient.io/rest/api/Invalidate) to remove the entity from the [entity table](https://dataclient.io/docs/concepts/normalization) + +### extend() {#extend} + +`resource` builds a great starting point, but often endpoints need to be [further customized](./RestEndpoint.md#typing). + +`extend()` is polymorphic with three forms: + +#### Function form (to get BaseResource/super) {#extend-function} + +This is the most flexible, but also the most verbose. + +```ts +export const IssueResource= resource({ + path: '/repos/:owner/:repo/issues/:number', + schema: Issue, + pollFrequency: 60000, + searchParams: {} as IssueFilters | undefined, +}).extend(BaseResource => ({ + search: BaseResource.getList.extend({ + path: '/search/issues?{q=:q}%20repo\\::owner/:repo{&page=:page}', + schema: { + results: { + incompleteResults: false, + items: BaseIssueResource.getList.schema.results, + totalCount: 0, + }, + link: '', + }, + }) +)}); +``` + +#### Batch extension of known members {#extend-override} + +This only works with existing members. + +```ts +export const CommentResource = resource({ + path: '/repos/:owner/:repo/issues/comments/:id', + schema: Comment, +}).extend({ + getList: { path: '/repos/:owner/:repo/issues/:number/comments' }, + update: { body: { body: '' } }, +}); +``` + +#### Adding new members {#extend-new} + +This can only add one endpoint at a time. + +```ts +export const UserResource = createGithubResource({ + path: '/users/:login', + schema: User, +}).extend('current', { + path: '/user', + schema: User, +}); +``` + +#### Github CommentResource + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/pages/IssueDetail/CommentsList.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueDetail/CommentsList.tsx), [`src/resources/Comment.ts`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Comment.ts)) + +## Function Inheritance Patterns + +To reuse code related to `Resource` definitions, you can create your own function that calls resource(). +This has similar effects as class-based inheritance, with the added benefit of allowing for complete +typing overrides. + +```typescript +import { + resource, + RestEndpoint, + Collection, + type EndpointExtraOptions, + type RestGenerics, + type ResourceGenerics, + type ResourceOptions, +} from '@data-client/rest'; + +export class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; + + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} + +export function myResource({ + schema, + Endpoint = AuthdEndpoint, + ...extraOptions +}: Readonly & ResourceOptions) { + return resource({ + Endpoint, + schema, + ...extraOptions, + }).extend({ + getList: { + schema: { + results: new Collection([schema]), + total: 0, + limit: 0, + skip: 0, + }, + }, + }); +} +``` + +### GraphQL + REST Hybrid + +When your API provides both REST and GraphQL endpoints, you can mix them in a single resource. +Use [Entity.process()](https://dataclient.io/rest/api/Entity#process) to normalize different response shapes. + +```typescript +import { GQLEndpoint } from '@data-client/graphql'; +import { Entity, resource } from '@data-client/rest'; + +const gql = new GQLEndpoint('https://api.myservice.com/graphql'); + +export class Repository extends Entity { + id = ''; + name = ''; + owner = { login: '' }; + stargazersCount = 0; + forksCount = 0; + + pk() { + return `${this.owner.login}/${this.name}`; + } + + static key = 'Repository'; +} + +/** Normalizes GraphQL response shape to match REST Entity */ +export class GqlRepository extends Repository { + static process(input: any, parent: any, key: string | undefined) { + // GraphQL uses different field names than REST + if ('stargazerCount' in input) { + return { + ...input, + stargazersCount: input.stargazerCount, + forksCount: input.forkCount, + }; + } + return input; + } +} + +export const RepositoryResource = resource({ + path: '/repos/:owner/:repo', + schema: Repository, +}).extend(base => ({ + // REST endpoint for single repo + get: base.get, + // GraphQL endpoint for user's pinned repos + getByPinned: gql.query( + (v: { login: string }) => `query ($login: String!) { + user(login: $login) { + pinnedItems(first: 6, types: REPOSITORY) { + nodes { + ... on Repository { + id + name + owner { login } + stargazerCount + forkCount + } + } + } + } + }`, + { user: { pinnedItems: { nodes: [GqlRepository] } } }, + ), +})); +``` + +#### Github Example + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/Base.ts`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Base.ts)) diff --git a/.agents/skills/data-client-rest-setup/references/superagent-migration.md b/.agents/skills/data-client-rest-setup/references/superagent-migration.md index 1276584385dd..a4148b4107c1 100644 --- a/.agents/skills/data-client-rest-setup/references/superagent-migration.md +++ b/.agents/skills/data-client-rest-setup/references/superagent-migration.md @@ -142,4 +142,4 @@ const uploadFile = new RestEndpoint({ ## Reference - [RestEndpoint API](https://dataclient.io/rest/api/RestEndpoint) — lifecycle methods reference -- [NetworkError](https://dataclient.io/rest/api/NetworkError) — error class +- [NetworkError](https://dataclient.io/rest/api/RestEndpoint#fetchResponse) — error class diff --git a/.agents/skills/data-client-rest/SKILL.md b/.agents/skills/data-client-rest/SKILL.md index 490e8e63b7ae..88f1d5cf6e7e 100644 --- a/.agents/skills/data-client-rest/SKILL.md +++ b/.agents/skills/data-client-rest/SKILL.md @@ -167,6 +167,8 @@ export const IssueResource = resource({ # References +Vue projects: read `.vue.md` instead of `.md` when it exists. + For detailed API documentation, see the [references](references/) directory: - [resource](references/resource.md) - Create CRUD endpoints diff --git a/.agents/skills/data-client-rest/references.json b/.agents/skills/data-client-rest/references.json new file mode 100644 index 000000000000..d46afd30e0c4 --- /dev/null +++ b/.agents/skills/data-client-rest/references.json @@ -0,0 +1,31 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "Collection.md": "docs/rest/api/Collection.md", + "Entity.md": "docs/rest/api/Entity.md", + "Fixtures.md": "docs/core/api/Fixtures.md", + "RestEndpoint.md": "docs/rest/api/RestEndpoint.md", + "_AsyncBoundary.md": "docs/core/shared/_AsyncBoundary.mdx", + "_EndpointLifecycle.md": "docs/rest/api/_EndpointLifecycle.mdx", + "_VoteDemo.md": "docs/core/shared/_VoteDemo.mdx", + "_entity_lifecycle_methods.md": "docs/rest/shared/_entity_lifecycle_methods.mdx", + "_optimisticTransform.md": "docs/rest/shared/_optimisticTransform.mdx", + "_pagination.md": "docs/core/shared/_pagination.mdx", + "_useLive.md": "docs/core/shared/_useLive.mdx", + "_useLoading.md": "docs/core/shared/_useLoading.mdx", + "auth.md": "docs/rest/guides/auth.md", + "data-dependency.md": "docs/core/getting-started/data-dependency.md", + "error-policy.md": "docs/core/concepts/error-policy.md", + "expiry-policy.md": "docs/core/concepts/expiry-policy.md", + "hookifyResource.md": "docs/rest/api/hookifyResource.md", + "mutations.md": "docs/core/getting-started/mutations.md", + "network-transform.md": "docs/rest/guides/network-transform.md", + "optimistic-updates.md": "docs/rest/guides/optimistic-updates.md", + "pagination.md": "docs/rest/guides/pagination.md", + "resource.md": "docs/rest/api/resource.md", + "schema.md": "docs/rest/api/schema.md" + } +} diff --git a/.agents/skills/data-client-rest/references/Collection.md b/.agents/skills/data-client-rest/references/Collection.md deleted file mode 120000 index 19ac7e11e9a7..000000000000 --- a/.agents/skills/data-client-rest/references/Collection.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Collection.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/Collection.md b/.agents/skills/data-client-rest/references/Collection.md new file mode 100644 index 000000000000..b83a03fdfadd --- /dev/null +++ b/.agents/skills/data-client-rest/references/Collection.md @@ -0,0 +1,659 @@ + + +# Collection + +`Collections` define mutable [Lists (Array)](https://dataclient.io/rest/api/Array) or [Maps (Values)](https://dataclient.io/rest/api/Values). + +This means they can grow and shrink. You can add to `Collection(Array)` with [.push](#push) or [.unshift](#unshift), +remove from `Collection(Array)` with [.remove](#remove), add to `Collections(Values)` with [.assign](#assign), +and move between collections with [.move](#move). + +[RestEndpoint](./RestEndpoint.md) provides [.push](./RestEndpoint.md#push), [.unshift](./RestEndpoint.md#unshift), [.assign](./RestEndpoint.md#assign), [.remove](./RestEndpoint.md#remove), [.move](./RestEndpoint.md#move) +and [.getPage](./RestEndpoint.md#getpage)/ [.paginated()](./RestEndpoint.md#paginated) extenders when using `Collections` + +## Usage + +```ts title="api/Todo" {12-14,19} +import { Entity, RestEndpoint, Collection } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = 0; + title = ''; + completed = false; + + 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: userTodos, +}); +``` + +```ts title="api/User" {13,19} +import { Entity, RestEndpoint, Collection } from '@data-client/rest'; +import { Todo, userTodos } from './Todo'; + +export class User extends Entity { + id = ''; + name = ''; + username = ''; + email = ''; + todos: Todo[] = []; + + static key = 'User'; + static schema = { + todos: userTodos, + }; +} + +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new Collection([User]), +}); +``` + +```tsx title="NewTodo" {10-14} +import { useController } from '@data-client/react'; +import { getTodos } from './api/Todo'; + +export default function NewTodo({ userId }: { userId?: string }) { + const ctrl = useController(); + const [unshift, setUnshift] = React.useState(false); + + const handlePress = async e => { + if (e.key === 'Enter') { + const createTodo = unshift ? getTodos.unshift : getTodos.push; + ctrl.fetch(createTodo, { + title: e.currentTarget.value, + userId, + }); + e.currentTarget.value = ''; + } + }; + + return ( +
+ + +
+ ); +} +``` + +```tsx title="TodoList" +import { type Todo } from './api/Todo'; +import NewTodo from './NewTodo'; + +export default function TodoList({ + todos, + userId, +}: { + todos: Todo[]; + userId: string; +}) { + return ( +
+ {todos.map(todo => ( +
{todo.title}
+ ))} + +
+ ); +} +``` + +```tsx title="UserList" +import { useSuspense } from '@data-client/react'; +import { getUsers } from './api/User'; +import TodoList from './TodoList'; + +function UserList() { + const users = useSuspense(getUsers); + return ( +
+ {users.map(user => ( +
+

{user.name}

+ +
+ ))} +
+ ); +} +render(); +``` + +### Collection with Values + +When an API returns keyed objects rather than arrays, combine `Collection` with [Values](https://dataclient.io/rest/api/Values) +to enable mutations on the result. + +```typescript +import { Entity, resource, Collection, Values } from '@data-client/rest'; + +class Stats extends Entity { + product_id = ''; + volume = 0; + price = 0; + + pk() { + return this.product_id; + } + + static key = 'Stats'; +} + +export const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + // Collection wraps Values to enable .push, .assign, etc. + schema: new Collection(new Values(Stats)), + process(value) { + // Transform nested response structure + Object.keys(value).forEach(key => { + value[key] = { + ...value[key].stats_24hour, + product_id: key, + }; + }); + return value; + }, + }, +}); +``` + +This allows adding or updating entries with [.assign](./Collection.md#assign). The body is an object +where keys are the collection keys and values are the entity data to merge: + +```typescript +// Local-only update with ctrl.set() +ctrl.set(StatsResource.getList.schema.assign, {}, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); + +// Network request with ctrl.fetch() - see RestEndpoint.assign +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +## Options + +`argsKey` and `nestKey` compute a `Collection` [pk](#pk). `argsKey` is used +when a `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} + +Returns a serializable Object whose members uniquely define this collection based +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: 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. + +A nested `Collection` [pk](#pk) is usually best defined by what it is nested +inside. This allows nested `Collection` instances to share state when their keys +have the same value. When `argsKey` and `nestKey` return the same object shape, +top-level and nested reads resolve to the same collection state. + +```ts {13} +import { Entity } from '@data-client/rest'; +import { Todo, userTodos } from './Todo'; + +class User extends Entity { + id = ''; + name = ''; + username = ''; + email = ''; + todos: Todo[] = []; + + static key = 'User'; + static schema = { + todos: userTodos, + }; +} +``` + +In this case, `user.todos` and the `getTodos()` response from the `argsKey` +example are always 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} + +A convenient alternative to [argsKey](#argsKey) + +`nonFilterArgumentKeys` defines a test to determine which [argument keys](#argsKey) +are _not_ used for filtering the results. For instance, if your API uses +'orderBy' to choose a sort - this argument would not influence which +entities are included in the response. + +```ts +const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Collection([Post], { + nonFilterArgumentKeys(key) { + return key === 'orderBy'; + }, + }), +}); +``` + +For convenience you can also use a RegExp or list of strings: + +```ts +const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Collection([Post], { + nonFilterArgumentKeys: /orderBy/, + }), +}); +``` + +```ts +const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Collection([Post], { + nonFilterArgumentKeys: ['orderBy'], + }), +}); +``` + +In this case, `author` and `group` are considered 'filter' argument keys, +which means they will influence whether a newly created should be added +to those lists. On the other hand, `orderBy` does not need to match +when `push` is called. + +```ts title="getPosts" {14} +import { Entity, Query, Collection, RestEndpoint } from '@data-client/rest'; + +class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +export const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Query( + new Collection([Post], { + nonFilterArgumentKeys: /orderBy/, + }), + (posts, { orderBy } = {}) => { + if (orderBy) { + return [...posts].sort((a, b) => a[orderBy].localeCompare(b[orderBy])); + } + return posts; + }, + ) +}); +``` + +```tsx title="PostListLayout" +import { useLoading } from '@data-client/react'; + +export default function PostListLayout({ + postsByBob, + postsSorted, + addPost, +}) { + const [handleSubmit, loading] = useLoading(addPost); + return ( +
+

{group: 'react', author: 'bob'}

+
    + {postsByBob.map(post => ( +
  • + {post.title} by {post.author} +
  • + ))} +
+

{group: 'react', orderBy: 'title'}

+
    + {postsSorted.map(post => ( +
  • + {post.title} by {post.author} +
  • + ))} +
+
+
Group: React
+ Author: + + + + + +
+ ); +} +``` + +```tsx title="PostList" +import { useSuspense, useController } from '@data-client/react'; +import { getPosts } from './getPosts'; +import PostListLayout from './PostListLayout'; + +function PostList() { + const postsByBob = useSuspense(getPosts, { + group: 'react', + author: 'bob', + }); + const postsSorted = useSuspense(getPosts, { + group: 'react', + orderBy: 'title', + }); + + const ctrl = useController(); + + const addPost = (e) => { + e.preventDefault(); + return ctrl.fetch( + getPosts.push, + { group: 'react' }, + new FormData(e.currentTarget), + ); + } + return ( + + ); +} +render(); +``` + +### createCollectionFilter? + +Sets a default `createCollectionFilter` for [addWith()](#addWith), +[push](#push), [unshift](#unshift), and [assign](#assign). + +This is used by these creation schemas to determine which collections to add to. + +Default: + +```ts +createCollectionFilter(...args: Args) { + return (collectionKey: Record) => + Object.entries(collectionKey).every( + ([key, value]) => + this.nonFilterArgumentKeys(key) || + // strings are canonical form. See pk() above for value transformation + `${args[0][key]}` === value || + `${args[1]?.[key]}` === value, + ); +} +``` + +## Methods + +These creation/removal schemas can be used with [Controller.set()](https://dataclient.io/docs/api/Controller#set) for local-only +updates without network requests. For network-based mutations, see [RestEndpoint's specialized extenders](./RestEndpoint.md#push). + +### push + +A creation schema that places new item(s) at the _end_ of this collection. + +```ts +// Add a new todo to the end of the list (local only, no network request) +ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' }); +``` + +### unshift + +A creation schema that places new item(s) at the _start_ of this collection. + +```ts +// Add a new todo to the beginning of the list (local only) +ctrl.set(getTodos.schema.unshift, { userId: '1' }, { id: '999', title: 'New Todo' }); +``` + +### remove + +A schema that removes item(s) from a collection by value. + +The entity value is normalized to extract its pk, which is then matched against collection members. +Items are removed from all collections matching the provided args (filtered by [createCollectionFilter](#createcollectionfilter)). + +```ts +// Remove from collections matching { userId: '1' } (local only) +ctrl.set(getTodos.schema.remove, { userId: '1' }, { id: '123' }); +``` + +```ts +// Remove from all collections (empty args matches all) +ctrl.set(getTodos.schema.remove, {}, { id: '123' }); +``` + +For network-based removal that also updates the entity, see [RestEndpoint.remove](./RestEndpoint.md#remove). + +### move + +A schema that moves item(s) between collections. It removes the entity from collections matching +its _existing_ state and adds it to collections matching the entity's _new_ state (derived from the last arg). + +This works for both `Collection(Array)` and `Collection(Values)`. + +```ts +// Move todo from userId '1' collection to userId '2' collection (local only) +ctrl.set( + getTodos.schema.move, + { id: '10', userId: '2', title: 'Moved todo' }, + [{ id: '10' }, { userId: '2' }], +); +``` + +The remove filter uses the entity's **existing** values in the store to determine which collections +it currently belongs to. The add filter uses the merged entity values (existing + last arg) to determine +where it should be placed. + +For network-based moves, see [RestEndpoint.move](./RestEndpoint.md#move). + +### assign + +A creation schema that [assigns](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) +its members to a `Collection(Values)`. Only available for Collections wrapping [Values](https://dataclient.io/rest/api/Values). + +```ts +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); + +// Add/update entries in a Values collection (local only) +ctrl.set(getStats.schema.assign, {}, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +### addWith(merge, createCollectionFilter): CreationSchema {#addWith} + +Constructs a custom creation schema for this collection. This is used by +[push](#push), [unshift](#unshift), [assign](#assign) and [paginate](./RestEndpoint.md#paginated) + +#### merge(collection, creation) + +This [merges](#merge) the value with the existing collection + +#### createCollectionFilter + +This function is used to determine which collections to add to. It +uses the Object returned from [argsKey](#argsKey) or [nestKey](#nestKey) to +determine if that collection should get the newly created values from this schema. + +Because arguments may be serializable types like `number`, we recommend using `==` comparisons, +e.g., `'10' == 10` + +```typescript +(...args) => + collectionKey => + boolean; +``` + +### moveWith(merge): MoveSchema {#moveWith} + +Constructs a custom move schema for this collection. This is analogous to [addWith](#addWith) +but for [move](#move) operations. The `merge` function controls how entities are added to +their destination collection, while the remove behavior is automatically derived from +the collection type (Array or Values). + +This is useful when you need to control the insertion position of moved items +(e.g., prepending instead of appending). + +#### merge(collection, moved) + +Controls how the moved entity is added to its destination collection. + +The exported [`unshift`](#unshift-merge) merge function places items at the start: + +```ts +import { Collection, unshift, type CollectionOptions } from '@data-client/rest'; +import type { PolymorphicInterface } from '@data-client/endpoint'; + +class MyCollection< + S extends any[] | PolymorphicInterface = any, + Args extends any[] = any[], + Parent = any, +> extends Collection { + constructor(schema: S, options?: CollectionOptions) { + super(schema, options); + // Prepend moved items instead of appending + this.move = this.moveWith(unshift); + } +} +``` + +### unshift (merge function) {#unshift-merge} + +A merge function that places incoming items at the _start_ of the collection. +Use with [moveWith](#moveWith) or [addWith](#addWith) to control insertion order. + +```ts +import { unshift } from '@data-client/rest'; +``` + +## Lifecycle Methods + +### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldReorder} + +```typescript +static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return incomingMeta.fetchedAt < existingMeta.fetchedAt; +} +``` + +`true` return value will reorder incoming vs in-store entity argument order in merge. With +the default merge, this will cause the fields of existing entities to override those of incoming, +rather than the other way around. + +### static merge(existing, incoming): mergedValue {#merge} + +```typescript +static merge(existing: any, incoming: any) { + return incoming; +} +``` + +### static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue {#mergeWithStore} + +```typescript +static mergeWithStore( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +): any; +``` + +`mergeWithStore()` is called during normalization when a processed entity is already found in the store. + +### pk: (parent?, key?, args?, parentEntity?): pk? {#pk} + +`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[], + 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]}`; + } + return JSON.stringify(obj); +} +``` diff --git a/.agents/skills/data-client-rest/references/Entity.md b/.agents/skills/data-client-rest/references/Entity.md deleted file mode 120000 index afd13a1e1184..000000000000 --- a/.agents/skills/data-client-rest/references/Entity.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Entity.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/Entity.md b/.agents/skills/data-client-rest/references/Entity.md new file mode 100644 index 000000000000..2cdcbb3d1485 --- /dev/null +++ b/.agents/skills/data-client-rest/references/Entity.md @@ -0,0 +1,753 @@ + + +# Entity + +```ts +{ + Article: { + '1': { + id: '1', + title: 'Entities define data', + } + } +} +``` + +`Entity` defines a single _unique_ object. + +[Entity.key](#key) + [Entity.pk()](#pk) (primary key) enable a [flat lookup table](https://react.dev/learn/choosing-the-state-structure#principles-for-structuring-state) store, enabling high +performance, data consistency and atomic mutations. + +`Entities` enable customizing the data processing lifecycle by defining its static members like [schema](#schema) +and overriding its [lifecycle methods](#lifecycle). + +## Usage + +```typescript title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + username = ''; + + static key = 'User'; + pk() { + return this.id; + } +} +``` + +```typescript title="Article" +import { Entity } from '@data-client/rest'; +import { User } from './User'; + +export class Article extends Entity { + id = ''; + title = ''; + content = ''; + author = User.fromJS(); + tags: string[] = []; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static key = 'Article'; + pk() { + return this.id; + } + + static schema = { + author: User, + createdAt: Temporal.Instant.from, + }; +} +``` + +[static schema](#schema) is a declarative definition of fields to process. +In this case, `author` is another `Entity` to be extracted, and `createdAt` will be converted +from a string to a [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) +object. + +> **Tip** +> +> Entities are bound to Endpoints using [resource.schema](./resource.md#schema) or +> [RestEndpoint.schema](./RestEndpoint.md#schema) + +> **Tip** +> +> If you already have your classes defined, [EntityMixin](https://dataclient.io/rest/api/EntityMixin) can also be +> used to make Entities. + +Other static members overrides allow customizing the data lifecycle as seen below. + +## Members + +### pk(parent?, key?, args?): string | number | undefined {#pk} + +pk stands for [_primary key_](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-PRIMARY-KEYS), uniquely identifying an `Entity` instance. +By default this returns the an Entity's `id` field. + +Override this method to use other fields, or to for other cases like +multicolumn primary keys. + +#### undefined value + +A `undefined` can be used as a default to indicate the entity has not been created yet. +This is useful when initializing a creation form using [Entity.fromJS()](#fromJS) +directly. If `pk()` returns `undefined` it is considered not persisted to the server, +and thus will not be kept in the cache. + +#### Other uses + +Since `pk()` is unique, it provides a consistent way of defining [JSX list keys](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) + +```tsx +//.... +return ( +
+ {results.map(result => ( + + ))} +
+); +``` + +#### Composite Primary Keys + +When a single field isn't enough to uniquely identify an entity, you can combine multiple +fields into a composite key. This is common for nested resources or resources with +multi-part identifiers. + +```typescript +export class Issue extends Entity { + number = 0; + owner = ''; + repo = ''; + repositoryUrl = ''; + title = ''; + + pk() { + // Composite key from owner, repo, and issue number + return `${this.owner}/${this.repo}/${this.number}`; + } + + static key = 'Issue'; +} +``` + +When entity data doesn't include all key parts directly, you can extract them from related +fields or endpoint arguments using [Entity.process()](#process): + +```typescript +export class Issue extends Entity { + number = 0; + owner = ''; + repo = ''; + repositoryUrl = ''; // Contains: https://api.github.com/repos/{owner}/{repo} + title = ''; + + pk() { + // Use owner/repo from process() which extracts from repositoryUrl + return `${this.owner}/${this.repo}/${this.number}`; + } + + static key = 'Issue'; + + static process(input: any, parent: any, key: string, args: any[]) { + // Extract owner and repo from the repositoryUrl + const match = input.repositoryUrl?.match(/repos\/([^/]+)\/([^/]+)/); + const owner = args[0]?.owner ?? match?.[1]; + const repo = args[0]?.repo ?? match?.[2]; + return { ...input, owner, repo }; + } +} +``` + +#### Singleton Entities + +What if there is only ever once instance of a Entity for your entire application? You +don't really need to distinguish between each instance, so likely there was no `id` or +similar field defined in the API. In these cases you can just return a literal like +'the\_only\_one'. + +```typescript +pk() { + return 'the_only_one'; +} +``` + +In case you have + +```typescript +const get = new RestEndpoint({ + path: '/options', + schema: OptionsEntity, +}); +export const OptionsResource = { + get, + partialUpdate: get.extend({ method: 'PATCH' }), +} +``` + +### static key: string {#key} + +This defines the key for the Entity kind, rather than an instance. This needs to be a globally +unique value. + +> **Warning** +> +> This defaults to `this.name`; however this may break in production builds that change class names. +> This is often know as [class name mangling](https://terser.org/docs/api-reference#mangle-options). +> +> In these cases you can override `key` or disable class name mangling. + +```ts +class User extends Entity { + id = ''; + username = ''; + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +### static schema: { \[k: keyof this]: Schema } {#schema} + +Defines [related entity](https://dataclient.io/rest/guides/relational-data) members, or +[field deserialization](./network-transform.md#deserializing-fields) like Date and BigNumber. + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {16-20} +import { Entity } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = ''; + author = User.fromJS(); + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + content = ''; + title = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + createdAt: Temporal.Instant.from, + }; +} +``` + +```tsx title="PostPage" +import { Post } from './Post'; + +export const getPost = new RestEndpoint({ + path: '/posts/:id', + schema: Post, +}); +function PostPage() { + const post = useSuspense(getPost, { id: '123' }); + return ( +
+

+ {post.content} - {post.author.name} +

+ +
+ ); +} +render(); +``` + +#### Optional members + +Entities references here whose default values in the Record definition itself are +considered 'optional' + +```typescript +class User extends Entity { + friend: User | null = null; // this field is optional + lastUpdated = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + friend: User, + lastUpdated: Temporal.Instant.from, + }; +} +``` + +### static indexes?: (keyof this)\[] {#indexes} + +Indexes enable increased performance when doing lookups based on those parameters. Add +fieldnames (like `slug`, `username`) to the list that you want to send as params to lookup +later. + +> **Note** +> +> Don't add your primary key like `id` to the indexes list, as that will already be optimized. + +#### useSuspense() + +With [useSuspense()](https://dataclient.io/docs/api/useSuspense) this will eagerly infer the results from entities table if possible, +rendering without needing to complete the fetch. This is typically helpful when the entities +cache has already been populated by another request like a list request. + +```typescript +export class User extends Entity { + id: number | undefined = undefined; + username = ''; + email = ''; + isAdmin = false; + + static indexes = ['username' as const]; +} +export const UserResource = resource({ + path: '/user/:id', + schema: User, +}); +``` + +```tsx +const user = useSuspense(UserResource.get, { username: 'bob' }); +``` + +#### useQuery() + +With [useQuery()](https://dataclient.io/docs/api/useQuery), this enables accessing results retrieved inside other requests - even +if there is no endpoint it can be fetched from. + +```typescript +class LatestPrice extends Entity { + id = ''; + symbol = ''; + price = '0.0'; + + static indexes = ['symbol' as const]; +} +``` + +```typescript +class Asset extends Entity { + id = ''; + price = ''; + + static schema = { + price: LatestPrice, + }; +} +const getAssets = new RestEndpoint({ + path: '/assets', + schema: [Asset], +}); +``` + +Some top level component: + +```tsx +const assets = useSuspense(getAssets); +``` + +Nested below: + +```tsx +const price = useQuery(LatestPrice, { symbol: 'BTC' }); +``` + +### static maxEntityDepth?: number {#maxEntityDepth} + +Limits entity nesting depth during denormalization to prevent stack overflow +in large bidirectional entity graphs. **Default: 128** + +When bidirectional relationships create chains with many unique entities +(e.g., `Department → Building → Department → ...`), denormalization can recurse +thousands of levels deep. `maxEntityDepth` truncates resolution at the specified +depth — entities beyond the limit are returned with nested foreign keys left as +unresolved ids rather than fully denormalized objects. + +```typescript +class Department extends Entity { + id = ''; + name = ''; + buildings: Building[] = []; + + pk() { return this.id; } + static key = 'Department'; + static maxEntityDepth = 16; + + static schema = { + buildings: [Building], + }; +} +``` + +> **Tip** +> +> Set this on entities that participate in deep or wide bidirectional relationships. +> Normal entity graphs (depth < 10) never approach the default limit. +> +> For relationships that don't need eager denormalization, [Lazy](https://dataclient.io/rest/api/Lazy) +> skips resolution entirely and lets you resolve on demand via [useQuery](https://dataclient.io/docs/api/useQuery). + +## Lifecycle + +```mermaid +flowchart BT + subgraph Controller.getResponse + queryKey("Entity.queryKey()")---pk2 + pk2("Entity.pk()")---Entity.createIfValid + subgraph Entity.createIfValid + direction TB + validate2("Entity.validate()")---fromJS("Entity.fromJS()") + end + Entity.createIfValid-->denormNest("Entity.denormalize") + end + subgraph Controller.setResponse + direction LR + subgraph Entity.normalize + direction TB + process("Entity.process()")-->pk("Entity.pk()") + pk---validate("Entity.validate()") + process-->validate + validate---normNest("normalize(this.schema)") + normNest-->mergeEntity("delegate.mergeEntity()") + end + Entity.normalize--processedEntity-->INSTORE + subgraph INSTORE["Found In Store"] + subgraph Entity.mergeWithStore + direction TB + shouldupdate("Entity.shouldUpdate()")---shouldreorder("Entity.shouldReorder()") + shouldreorder---merge("Entity.merge()") + end + Entity.mergeWithStore---mergeMetaWithStore("Entity.mergeMetaWithStore()") + end + end + click process "/rest/api/Entity#process" + click pk "/rest/api/Entity#pk" + click pk2 "/rest/api/Entity#pk" + click fromJS "/rest/api/Entity#fromJS" + click validate "/rest/api/Entity#validate" + click validate2 "/rest/api/Entity#validate" + click mergeMetaWithStore "/rest/api/Entity#mergeMetaWithStore" + click shouldupdate "/rest/api/Entity#shouldupdate" + click shouldreorder "/rest/api/Entity#shouldreorder" + click mergewithstore "/rest/api/Entity#mergeWithStore" + click merge "/rest/api/Entity#merge" + click queryKey "/rest/api/Entity#queryKey" +``` + +### static fromJS(props): Entity {#fromJS} + +Factory method that copies props to a new instance. Use this instead of `new MyEntity()`, +to ensure default props are overridden. + +### static process(input, parent, key, args): processedEntity {#process} + +Run at the start of normalization for this entity. Return value is saved in store +and sent to [pk()](#pk). + +**Defaults** to simply copying the response (`{...input}`) + +How to override to [build reverse-lookups for relational data](https://dataclient.io/rest/guides/relational-data#reverse-lookups) + +#### Case of the missing id + +```ts +class Stream extends Entity { + username = ''; + title = ''; + game = ''; + currentViewers = 0; + live = false; + + pk() { + return this.username; + } + static key = 'Stream'; + + static process(value, parent, key, args) { + // super.process creates a copy of value + const processed = super.process(value, parent, key, args); + processed.username = args[0]?.username; + return processed; + } +} +``` + +#### Dynamic Invalidation + +Returning `undefined` from [Entity.process](#process) +will cause the `Entity` to be [invalidated](./expiry-policy.md#invalidate-entity). +This this allows us to invalidate dynamically; based on the particular response data. + +```ts +class PriceLevel extends Entity { + price = 0; + amount = 0; + + pk() { + return this.price; + } + + static process( + input: [number, number], + parent: any, + key: string | undefined, + ): any { + const [price, amount] = input; + if (amount === 0) return undefined; + return { price, amount }; + } +} +``` + +### static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue {#mergeWithStore} + +```typescript +static mergeWithStore( + existingMeta: { + date: number; + fetchedAt: number; + }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + const shouldUpdate = this.shouldUpdate( + existingMeta, + incomingMeta, + existing, + incoming, + ); + + if (shouldUpdate) { + // distinct types are not mergeable (like delete symbol), so just replace + if (typeof incoming !== typeof existing) { + return incoming; + } else { + return this.shouldReorder( + existingMeta, + incomingMeta, + existing, + incoming, + ) + ? this.merge(incoming, existing) + : this.merge(existing, incoming); + } + } else { + return existing; + } +} +``` + +`mergeWithStore()` is called during normalization when a processed entity is already found in the store. + +This calls [shouldUpdate()](#shouldupdate), [shouldReorder()](#shouldreorder) and potentially [merge()](#merge) + +### static shouldUpdate(existingMeta, incomingMeta, existing, incoming): boolean {#shouldupdate} + +```typescript +static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return existingMeta.fetchedAt <= incomingMeta.fetchedAt; +} +``` + +#### Preventing updates + +shouldUpdate can also be used to short-circuit an entity update. + +```typescript +import deepEqual from 'deep-equal'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; + published = false; + + static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, + ) { + return !deepEqual(incoming, existing); + } +} +``` + +### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldreorder} + +```typescript +static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return incomingMeta.fetchedAt < existingMeta.fetchedAt; +} +``` + +`true` return value will reorder incoming vs in-store entity argument order in merge. With +the default merge, this will cause the fields of existing entities to override those of incoming, +rather than the other way around. + +#### Example + +```typescript +class LatestPriceEntity extends Entity { + id = ''; + updatedAt = 0; + price = '0.0'; + symbol = ''; + + pk() { + return this.id; + } + + static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: { updatedAt: number }, + incoming: { updatedAt: number }, + ) { + return incoming.updatedAt < existing.updatedAt; + } +} +``` + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts)) + +### static merge(existing, incoming): mergedValue {#merge} + +```typescript +static merge(existing: any, incoming: any) { + return { + ...existing, + ...incoming, + }; +} +``` + +Merge is used to handle cases when an incoming entity is already found. This is called directly +when the same entity is found in one response. By default it is also called when [mergeWithStore()](#mergeWithStore) +determines the incoming entity should be merged with an entity already persisted in the Reactive Data Client store. + +How to override to [build reverse-lookups for relational data](https://dataclient.io/rest/guides/relational-data#reverse-lookups) + +### static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming): meta {#mergeMetaWithStore} + +```typescript +static mergeMetaWithStore( + existingMeta: { + expiresAt: number; + date: number; + fetchedAt: number; + }, + incomingMeta: { expiresAt: number; date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) + ? existingMeta + : incomingMeta; +} +``` + +`mergeMetaWithStore()` is called during normalization when a processed entity is already found in the store. + +### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} + +This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. + +Overriding can allow customization or disabling of this behavior altogether. + +Returning `undefined` will disallow this behavior. + +Returning `pk` string will attempt to lookup this entity and use in the response. + +When used, expiry policy is computed based on the entity's own meta data. + +By **default** uses the first argument to lookup in [pk()](#pk) and [indexes](./Entity.md#indexes) + +#### getEntity(key, pk?) + +Gets all entities of a type with one argument, or a single entity with two + +```ts title="One argument" +const entitiesEntry = getEntity(this.schema.key); +if (entitiesEntry === undefined) return INVALID; +return Object.values(entitiesEntry).map( + entity => entity && this.schema.pk(entity), +); +``` + +```ts title="Two arguments" +if (getEntity(this.key, id)) return id; +``` + +#### getIndex(key, indexName, value) + +Returns the index entry (value->pk map) + +```ts +const value = args[0][indexName]; +return getIndex(schema.key, indexName, value)[value]; +``` + +### static createIfValid(processedEntity): Entity | undefined {#createIfValid} + +Called when denormalizing an entity. This will create an instance of this class +if it is deemed 'valid'. + +`undefined` return will result in [Invalid expiry status](./expiry-policy.md#expiry-status), +like [Invalidate](https://dataclient.io/rest/api/Invalidate). + +[`Invalid`](./expiry-policy.md#expiry-status) expiry generally means hooks will enter a loading state and attempt a new fetch. + +```ts +static createIfValid(props): AbstractInstanceType | undefined { + if (this.validate(props)) { + return undefined as any; + } + return this.fromJS(props); +} +``` + +### static validate(processedEntity): errorMessage? {#validate} + +Runs during both normalize and denormalize. Returning a string indicates an error (the string is the message). + +During normalization a validation failure will result in an error for that fetch. + +During denormalization a validation failure will mark that result as 'invalid' and thus +will block on fetching a result. + +By **default** does some basic field existance checks in development mode only. Override to +disable or customize. + +[Using validation for endpoints with incomplete fields](https://dataclient.io/rest/guides/partial-entities) diff --git a/.agents/skills/data-client-rest/references/Fixtures.md b/.agents/skills/data-client-rest/references/Fixtures.md deleted file mode 120000 index 7671d7554dd6..000000000000 --- a/.agents/skills/data-client-rest/references/Fixtures.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Fixtures.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/Fixtures.md b/.agents/skills/data-client-rest/references/Fixtures.md new file mode 100644 index 000000000000..2b6d2ce010d6 --- /dev/null +++ b/.agents/skills/data-client-rest/references/Fixtures.md @@ -0,0 +1,217 @@ + + +# Fixtures and Interceptors + +Fixtures and Interceptors allow universal data mocking without the need for monkeypatching +fetch behaviors. Fixtures define static responses to specific endpoint arg combinations. This +allows them to be used in static contexts like [mockInitialState()](https://dataclient.io/docs/api/mockInitialState). +Interceptors are functions run and match a fetch pattern. This restricts them to being used only +in dynamic response contexts like [MockResolver](https://dataclient.io/docs/api/MockResolver). + +## SuccessFixture + +Represents a successful response + +```ts +export interface SuccessFixture { + endpoint; + args; + response; + error?; + delay?; +} +``` + +```ts +export interface SuccessFixture< + E extends EndpointInterface = EndpointInterface, +> { + readonly endpoint: E; + readonly args: Parameters; + readonly response: + | ResolveType + | ((...args: Parameters) => ResolveType); + readonly error?: false; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { count: 0 }, +}; +``` + +## ErrorFixtures + +Represents a failed/errored response + +```ts +export interface ErrorFixture { + endpoint; + args; + response; + error; + delay?; +} +``` + +```ts +export interface ErrorFixture { + readonly endpoint: E; + readonly args: Parameters; + readonly response: any; + readonly error: true; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countErrorFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { message: 'Not found', status: 404 }, + error: true, +}; +``` + +## Interceptor + +Interceptors will match a request based on its [`testKey()`](./RestEndpoint.md#testKey) method, then +compute the response dynamically using the `response()` method. + +```ts +interface ResponseInterceptor { + endpoint; + response(...args); + delay?; + delayCollapse?; +} + +interface FetchInterceptor { + endpoint; + fetchResponse(input, init); + delay?; + delayCollapse?; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +interface ResponseInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + } = EndpointInterface & { testKey(key: string): boolean }, +> { + readonly endpoint: E; + response(this: T, ...args: Parameters): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +interface FetchInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + } = EndpointInterface & { + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + }, +> { + readonly endpoint: E; + fetchResponse(this: T, input: RequestInfo, init: RequestInit): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + response() { + return { + count: (this.count = this.count + 1), + }; + }, + delay: () => 500 + Math.random() * 4500, +}; +``` + +## Arguments + +### endpoint + +The endpoint to match. + +### args + +(Fixtures only) The args to match. + +### response(...args) {#response} + +Determines what the response for this mock should be. If a function it will be run. + +Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) + +`this` can be used to store simulated server-side data. It is initialized using [getInitialInterceptorData](https://dataclient.io/docs/api/MockResolver#getinitialinterceptordata). It's important to not use arrow functions when using this as they disallow `this` binding. + +### fetchResponse(input, init) {#fetchResponse} + +When provided, will construct a response() method to be used based on overriding +(by calling [.extend](./RestEndpoint.md#extend)) [fetchResponse](./RestEndpoint.md#fetchResponse). + +Simply return the value expected, rather than an actual HTTP [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + fetchResponse(input, init) { + return { + count: (this.count = this.count + 1), + updatedAt: JSON.parse(init.body).updatedAt, + }; + }, +}; +``` + +This can be useful when you want to use the body generated in a custom [getRequestInit()](./RestEndpoint.md#getRequestInit) + +### delay: number {#delay} + +This is the number of miliseconds to wait before resolving the promise. This can be useful +when simulating race conditions. + +When a function is sent, its return value is used as the number of miliseconds. + +### delayCollapse: boolean {#delayCollapse} + +`true`: Runs response() after [delay](#delay) time + +`false`: Runs response() immediately, then resolves it after [delay](#delay) time + +This can be useful for simulating server-processing delays. diff --git a/.agents/skills/data-client-rest/references/Fixtures.vue.md b/.agents/skills/data-client-rest/references/Fixtures.vue.md new file mode 100644 index 000000000000..7255ee2f3dbd --- /dev/null +++ b/.agents/skills/data-client-rest/references/Fixtures.vue.md @@ -0,0 +1,217 @@ + + +# Fixtures and Interceptors + +Fixtures and Interceptors allow universal data mocking without the need for monkeypatching +fetch behaviors. Fixtures define static responses to specific endpoint arg combinations. This +allows them to be used in static contexts like [mockInitialState()](https://dataclient.io/vue/api/mockInitialState). +Interceptors are functions run and match a fetch pattern. This restricts them to being used only +in dynamic response contexts like `MockPlugin`. + +## SuccessFixture + +Represents a successful response + +```ts +export interface SuccessFixture { + endpoint; + args; + response; + error?; + delay?; +} +``` + +```ts +export interface SuccessFixture< + E extends EndpointInterface = EndpointInterface, +> { + readonly endpoint: E; + readonly args: Parameters; + readonly response: + | ResolveType + | ((...args: Parameters) => ResolveType); + readonly error?: false; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { count: 0 }, +}; +``` + +## ErrorFixtures + +Represents a failed/errored response + +```ts +export interface ErrorFixture { + endpoint; + args; + response; + error; + delay?; +} +``` + +```ts +export interface ErrorFixture { + readonly endpoint: E; + readonly args: Parameters; + readonly response: any; + readonly error: true; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countErrorFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { message: 'Not found', status: 404 }, + error: true, +}; +``` + +## Interceptor + +Interceptors will match a request based on its [`testKey()`](./RestEndpoint.vue.md#testKey) method, then +compute the response dynamically using the `response()` method. + +```ts +interface ResponseInterceptor { + endpoint; + response(...args); + delay?; + delayCollapse?; +} + +interface FetchInterceptor { + endpoint; + fetchResponse(input, init); + delay?; + delayCollapse?; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +interface ResponseInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + } = EndpointInterface & { testKey(key: string): boolean }, +> { + readonly endpoint: E; + response(this: T, ...args: Parameters): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +interface FetchInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + } = EndpointInterface & { + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + }, +> { + readonly endpoint: E; + fetchResponse(this: T, input: RequestInfo, init: RequestInit): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + response() { + return { + count: (this.count = this.count + 1), + }; + }, + delay: () => 500 + Math.random() * 4500, +}; +``` + +## Arguments + +### endpoint + +The endpoint to match. + +### args + +(Fixtures only) The args to match. + +### response(...args) {#response} + +Determines what the response for this mock should be. If a function it will be run. + +Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) + +`this` can be used to store simulated server-side data. It is initialized using `getInitialInterceptorData`. It's important to not use arrow functions when using this as they disallow `this` binding. + +### fetchResponse(input, init) {#fetchResponse} + +When provided, will construct a response() method to be used based on overriding +(by calling [.extend](./RestEndpoint.vue.md#extend)) [fetchResponse](./RestEndpoint.vue.md#fetchResponse). + +Simply return the value expected, rather than an actual HTTP [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + fetchResponse(input, init) { + return { + count: (this.count = this.count + 1), + updatedAt: JSON.parse(init.body).updatedAt, + }; + }, +}; +``` + +This can be useful when you want to use the body generated in a custom [getRequestInit()](./RestEndpoint.vue.md#getRequestInit) + +### delay: number {#delay} + +This is the number of miliseconds to wait before resolving the promise. This can be useful +when simulating race conditions. + +When a function is sent, its return value is used as the number of miliseconds. + +### delayCollapse: boolean {#delayCollapse} + +`true`: Runs response() after [delay](#delay) time + +`false`: Runs response() immediately, then resolves it after [delay](#delay) time + +This can be useful for simulating server-processing delays. diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.md b/.agents/skills/data-client-rest/references/RestEndpoint.md deleted file mode 120000 index 022543a002ca..000000000000 --- a/.agents/skills/data-client-rest/references/RestEndpoint.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/RestEndpoint.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.md b/.agents/skills/data-client-rest/references/RestEndpoint.md new file mode 100644 index 000000000000..1c8f5fcbd064 --- /dev/null +++ b/.agents/skills/data-client-rest/references/RestEndpoint.md @@ -0,0 +1,1422 @@ + + +# RestEndpoint + +`RestEndpoints` are for [HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP) based protocols like REST. + +> **Info: extends** +> +> `RestEndpoint` extends [Endpoint](https://dataclient.io/rest/api/Endpoint) + +
+ +Interface + +**RestEndpoint** + +```typescript +interface RestGenerics { + readonly path: string; + readonly schema?: Schema | undefined; + readonly method?: string; + readonly body?: any; + readonly searchParams?: any; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + process?(value: any, ...args: any): any; +} + +export class RestEndpoint extends Endpoint { + /* Prepare fetch */ + readonly path: string; + readonly urlPrefix: string; + readonly requestInit: RequestInit; + readonly method: string; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + readonly signal: AbortSignal | undefined; + url(...args: Parameters): string; + searchToString(searchParams: Record): string; + getRequestInit( + this: any, + body?: RequestInit['body'] | Record, + ): Promise | RequestInit; + getHeaders(headers: HeadersInit): Promise | HeadersInit; + + /* Perform/process fetch */ + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + parseResponse(response: Response): Promise; + process(value: any, ...args: Parameters): any; + + testKey(key: string): boolean; +} +``` + +**Endpoint** + +```typescript +class Endpoint Promise> { + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + readonly getOptimisticResponse?: ( + snap: SnapshotInterface, + ...args: Parameters + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + + testKey(key: string): boolean; +} +``` + +
+ +## Usage + +All options are supported as arguments to the constructor, [extend](#extend), and as overrides when using [inheritance](#inheritance) + +### Simplest retrieval + +```ts +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +``` + +```ts +const todo = await getTodo({ id: 1 }); +``` + +### Configuration sharing + +Use [RestEndpoint.extend()](#extend) instead of `{...getTodo}` ([Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)) + +```ts +const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +### Managing state + +```ts path=Todo.ts +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; +} + +export const getTodo = new RestEndpoint({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +export const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +Using a [Schema](./schema.md) enables [automatic data consistency](https://dataclient.io/docs/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/docs/api/Controller#expireAll). + +### Typing + +```ts title="Comment" +export class Comment extends Entity { + id = ''; + title = ''; + body = ''; + postId = ''; + + static key = 'Comment'; +} +``` + +```ts title="Usage" +import { Comment } from './Comment'; + +const getComments = new RestEndpoint({ + path: '/posts/:postId/comments', + schema: new Collection([Comment]), + searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, +}); + +// Hover your mouse over 'comments' to see its type +const comments = useSuspense(getComments, { + postId: '5', + sortBy: 'votes', +}); + +const ctrl = useController(); +const createComment = async data => + ctrl.fetch(getComments.push, { postId: '5' }, data); +``` + +#### Resolution/Return + +[schema](#schema) determines the return value when used with data-binding hooks like [useSuspense](https://dataclient.io/docs/api/useSuspense), [useDLE](https://dataclient.io/docs/api/useDLE), [useCache](https://dataclient.io/docs/api/useCache) +or when used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +```ts title="Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +``` + +```ts title="getTodo.ts" +import { Todo } from './Todo'; + +const getTodo = new RestEndpoint({ path: '/', schema: Todo }); +// Hover your mouse over 'todo' to see its type +const todo = useSuspense(getTodo); + +async () => { + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +[process](#process) determines the resolution value when the endpoint is called directly. For +`RestEndpoints` without a schema, it also determines the return type of [hooks](https://dataclient.io/docs/api/useSuspense) and [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch). + +```ts path="process.ts" +interface TodoInterface { + title: string; + completed: boolean; +} +const getTodo = new RestEndpoint({ + path: '/', + process(value): TodoInterface { + return value; + }, +}); +async () => { + // todo is TodoInterface + const todo = await getTodo(); + + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +#### Function Parameters + +[path](#path) used to construct the url determines the type of the first argument. If it has no patterns, +then the 'first' argument is skipped. + +```ts +const getRoot = new RestEndpoint({ path: '/' }); +getRoot(); +const getById = new RestEndpoint({ path: '/:id' }); +// both number and string types work as they are serialized into strings to construct the url +getById({ id: 5 }); +getById({ id: '5' }); +``` + +[method](#method) determines whether there is a second argument to be sent as the [body](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body). + +```ts path=method.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', +}); +update({ id: 5 }, { title: 'updated', completed: true }); +``` + +However, this is typed as 'any' so it won't catch typos. + +[body](#body) can be used to type the argument after the url parameters. It is only used for typing so the +value sent does not matter. `undefined` value can be used to 'disable' the second argument. + +```ts path=body.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: {} as TodoInterface, +}); +update({ id: 5 }, { title: 'updated', completed: true }); +// `undefined` disables 'body' argument +const rpc = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: undefined, +}); +rpc({ id: 5 }); +``` + +[searchParams](#searchParams) can be used in a similar way to `body` to specify types extra parameters, used +for the GET searchParams/queryParams in a [url()](#url). + +```ts +const getUsers = new RestEndpoint({ + path: '/:group/user/:id', + searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' }, +}); +getUsers.url({ group: 'big', id: '5', sort: 'asc' }) === + '/big/user/5?sort=asc'; +getUsers.url({ + group: 'big', + id: '5', + sort: 'desc', + isAdmin: true, +}) === '/big/user/5?isAdmin=true&sort=desc'; +``` + +## Fetch Lifecycle + +RestEndpoint adds to Endpoint by providing customizations for a provided fetch method using +[inheritance](#inheritance) or [.extend()](#extend). + +```mermaid +flowchart TB + URL-->response + INIT-->response + subgraph Prepare Fetch + subgraph URL + direction BT + urlPrefix-->url("url(urlParams)") + path-->url + searchToString("searchToString()")-->url + searchParams-->searchToString("searchToString()") + end + subgraph INIT + direction BT + getHeaders("getHeaders()")-->reqinit("getRequestInit(body)") + method-->reqinit + signal-->reqinit + end + end + subgraph Perform Fetch + response("fetchResponse()")-->parse("parseResponse()") + parse-->process("process()") + end + click url "/rest/api/RestEndpoint#url" + click searchToString "/rest/api/RestEndpoint#searchToString" + click searchParams "/rest/api/RestEndpoint#searchParams" + click urlPrefix "/rest/api/RestEndpoint#urlPrefix" + click path "/rest/api/RestEndpoint#path" + click getHeaders "/rest/api/RestEndpoint#getHeaders" + click method "/rest/api/RestEndpoint#method" + click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" + click reqinit "/rest/api/RestEndpoint#getRequestInit" + click response "/rest/api/RestEndpoint#fetchResponse" + click parse "/rest/api/RestEndpoint#parseResponse" + click process "/rest/api/RestEndpoint#process" +``` + +```ts title="fetch implementation for RestEndpoint" +function fetch(...args) { + const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; + const body = this.#hasBody ? args[args.length - 1] : undefined; + return this.fetchResponse( + this.url(urlParams), + await this.getRequestInit(body), + ) + .then(response => this.parseResponse(response)) + .then(res => this.process(res, ...args)); +} +``` + +## Prepare Fetch + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### url(params): string {#url} + +`urlPrefix` + `path template` + '?' + searchToString(`searchParams`) + +`url()` uses the `params` to fill in the [path template](#path). Any unused `params` members are then used +as [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka 'GET' params - the stuff after `?`). + +
+ +Implementation + +```typescript +import { getUrlBase, getUrlTokens } from '@data-client/rest'; + +url(urlParams = {}) { + const urlBase = getUrlBase(this.path)(urlParams); + const tokens = getUrlTokens(this.path); + const searchParams = {}; + Object.keys(urlParams).forEach(k => { + if (!tokens.has(k)) { + searchParams[k] = urlParams[k]; + } + }); + if (Object.keys(searchParams).length) { + return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; + } + return `${this.urlPrefix}${urlBase}`; +} +``` + +
+ +### searchToString(searchParams): string {#searchToString} + +Constructs the [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) component of [url](#url). + +By default uses the standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) global. + +[searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka queryParams) are sorted to maintain determinism. + +
+ +Implementation + +```typescript +searchToString(searchParams) { + const params = new URLSearchParams(searchParams); + params.sort(); + return params.toString(); +} +``` + +
+ +#### Using `qs` library + +To encode complex objects in the searchParams, you can use the [qs](https://github.com/ljharb/qs) library. + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +class QSEndpoint extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="QSEndpoint" {7} +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export default class QSEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="getFoo" +import QSEndpoint from './QSEndpoint'; + +const getFoo = new QSEndpoint({ + path: '/foo', + searchParams: {} as { a: Record }, +}); + +getFoo({ a: { b: 'c' } }); +``` + +### path: string {#path} + +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) to build +urls using the parameters passed. This also informs the types so they are properly enforced. + +#### Parameters + +`:` prefixed words are parameter names. Both strings and numbers are accepted as values, +since they are serialized into the url string. + +```ts +const getThing = new RestEndpoint({ path: '/:group/things/:id' }); +getThing({ group: 'first', id: 77 }); +``` + +#### Optional parameters + +Wrap the optional segment (including its prefix) in `{}` to make it [optional](https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#optional). +The type of optional parameters becomes `string | number | undefined`. + +```ts +const optional = new RestEndpoint({ + path: '/:group/things{/:number}', +}); +optional({ group: 'first' }); +optional({ group: 'first', number: 'fifty' }); +``` + +Multiple optional segments can be chained with different prefixes: + +```ts +const ep = new RestEndpoint({ + path: '{/:attr1}{-:attr2}{-:attr3}', +}); + +ep({ attr1: 'hi' }); +ep({ attr2: 'hi' }); +ep({ attr1: 'hi', attr3: 'ho' }); +``` + +#### Wildcards (repeating parameters) + +`*name` matches one-or-more path segments. Wrap in `{}` to make it zero-or-more (optional). +Wildcard parameters are typed as `string[]` (arrays), since they represent multiple path segments. + +```ts +const files = new RestEndpoint({ path: '/files/*path' }); +files({ path: ['documents', 'reports', 'q4'] }); +// URL: /files/documents/reports/q4 + +const optionalFiles = new RestEndpoint({ path: '/files{/*path}' }); +optionalFiles({}); +// URL: /files +optionalFiles({ path: ['documents'] }); +// URL: /files/documents +``` + +#### Quoted parameter names + +Parameter names must be valid JavaScript identifiers. Names containing special characters +like `-` or `.` must be quoted with double quotes: + +```ts +const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' }); +ep({ 'with-dash': 'hello', 'my.param': 'world' }); +``` + +#### Escaping special characters + +Characters `{}()*:` and `\\` are special in path-to-regexp and must be escaped with `\\` when used as literals. + +```ts +const getSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', +}); +getSite({ slug: 'first' }); +``` + +`?` and `+` are **not** special in path-to-regexp v8 and do not need escaping. +This means query strings can be embedded in the path without escaping `?`: + +```ts +const search = new RestEndpoint({ + path: '/search?{q=:q}{&page=:page}', +}); +search({ q: 'test', page: 1 }); +// URL: /search?q=test&page=1 +``` + +> **Info** +> +> Types are inferred automatically from `path`. +> +> Additional parameters can be specified with [searchParams](#searchParams) +> and [body](#body). + +### searchParams {#searchParams} + +`searchParams` can be to specify types extra parameters, used for the GET searchParams/queryParams in a [url()](#url). + +The actual **value is not used** in any way - this only determines [typing](#typing). + +```typescript title="getFoo" +const getReactSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + searchParams: {} as { isReact: boolean }, +}); + +getReactSite({ slug: 'cool', isReact: true }); +``` + +### body {#body} + +`body` can be used to set a second argument for mutation endpoints. The actual **value is not +used** in any way - this only determines [typing](#typing). + +This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'. + +```ts {4} +const updateSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + method: 'POST', + body: {} as { url: string }, +}); + +updateSite({ slug: 'cool' }, { url: '/' }); +``` + +### paginationField + +If specified, will add [getPage](#getpage) method on the `RestEndpoint`. [Pagination guide](./pagination.md). Schema +must also contain a [Collection](./Collection.md). + +### urlPrefix: string = '' {#urlPrefix} + +Prepends this to the compiled [path](#path) + +#### Inheritance defaults + +```typescript +export class MyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // this allows us to override the prefix in production environments, with a dev fallback + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; +} +``` + +[Learn more about inheritance patterns](#inheritance) for RestEndpoint + +#### Instance overrides + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:product_id/ticker', + schema: Ticker, +}); +``` + +#### Dynamic prefix + +> **Tip** +> +> For a dynamic prefix, try overriding the url() method instead: +> +> ```ts +> const getTodo = new RestEndpoint({ +> path: '/todo/:id', +> url(...args) { +> return dynamicPrefix() + super.url(...args); +> }, +> }); +> ``` + +### method: string = 'GET' {#method} + +[Method](https://developer.mozilla.org/en-US/docs/Web/API/Request/method) is part of the HTTP protocol. +REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this +to inform `sideEffect` and whether the endpoint should use a `body` payload. Setting +`sideEffect` explicitly will override this behavior, allowing for non-standard API designs. + +`GET` is 'readonly', other methods imply sideEffects. + +`GET` and `DELETE` both default to no `body`. + +> **Tip: How method affects function Parameters** +> +> `method` only influences parameters in the RestEndpoint constructor and _not_ [.extend()](#extend). +> This allows non-standard method-body combinations. +> +> `body` will default to `any`. You can always set body explicitly to take full control. `undefined` can be used +> to indicate there is no body. +> +> ```ts +> (id: string, myPayload: Record) => { +> const standardCreate = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> }); +> standardCreate({ id }, myPayload); +> const nonStandardEndpoint = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> body: undefined, +> }); +> // no second 'body' argument, because body was set to 'undefined' +> nonStandardEndpoint({ id }); +> }; +> ``` + +### getRequestInit(body): RequestInit {#getRequestInit} + +Prepares [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) used in fetch. +This is sent to [fetchResponse](#fetchResponse) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getRequestInit(body) { +> return { +> ...(await super.getRequestInit(body)), +> method: await getMethod(), +> }; +> } +> } +> +> async function getMethod() { +> return 'GET'; +> } +> ``` + +### getHeaders(headers: HeadersInit): HeadersInit {#getHeaders} + +Called by [getRequestInit](#getRequestInit) to determine [HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) + +This is often useful for [authentication](./auth.md) + +> **Warning** +> +> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getHeaders(headers: HeadersInit) { +> return { +> ...headers, +> 'Access-Token': await getAuthToken(), +> }; +> } +> } +> +> async function getAuthToken() { +> return 'example'; +> } +> ``` + +## Handle fetch + +### fetchResponse(input, init): Promise {#fetchResponse} + +Performs the [fetch(input, init)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) call. When +[response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is not `true` (like 404), +will throw a NetworkError. + +### content {#content} + +Controls how the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) body is parsed. +When set, the return type is inferred automatically, and `schema` is constrained to `undefined` +for non-JSON content types. + +| Value | Parses via | Return type | +| --------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | +| `'json'` | [response.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) | `any` | +| `'blob'` | [response.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) | `Blob` | +| `'text'` | [response.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text) | `string` | +| `'arrayBuffer'` | [response.arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer) | `ArrayBuffer` | +| `'stream'` | `response.body` | `ReadableStream` | +| _unset_ | Auto-detect from Content-Type header | `any` | + +When `content` is not set, `parseResponse` auto-detects the response type from the +`Content-Type` header: JSON types call `.json()`, binary types (images, `application/octet-stream`, +PDFs, etc.) call `.blob()`, and text-like types call `.text()`. + +#### File downloads {#file-download} + +For file downloads, set `content: 'blob'`. The return type is `Blob` and `schema` must be +`undefined` (binary data cannot be normalized). Use `dataExpiryLength: 0` to avoid caching +large blobs in memory. + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +To extract the filename from the `Content-Disposition` header, override `parseResponse`: + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +See [file download guide](./network-transform.md#file-download) for complete usage with browser download trigger. + +### parseResponse(response): Promise {#parseResponse} + +Takes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and parses the body. + +When [`content`](#content) is set, it controls parsing directly. Otherwise, auto-detection runs +based on the [`Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): +JSON types call [.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json), binary +types call [.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob), and text-like +types call [.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text). + +If `status` is 204, resolves as `null`. + +Override this for advanced cases like extracting headers alongside the body. + +### process(value, ...args): any {#process} + +Perform any transforms with the parsed result. Defaults to identity function (do nothing). + +> **Tip** +> +> The return type of process can be used to set the return type of the endpoint fetch: +> +> ```ts title="getTodo.ts" {4} +> export const getTodo = new RestEndpoint({ +> path: '/todos/:id', +> // The identity function is the default value; so we aren't changing any runtime behavior +> process(value): TodoInterface { +> return value; +> }, +> }); +> +> interface TodoInterface { +> id: string; +> title: string; +> completed: boolean; +> } +> ``` +> +> ```ts title="useTodo.ts" +> import { getTodo } from './getTodo'; +> +> async (id: string) => { +> // hover title to see it is a string +> // see TS autocomplete by deleting `.title` and retyping the `.` +> const title = (await getTodo({ id })).title; +> }; +> ``` + +## Endpoint Lifecycle + +### schema?: Schema {#schema} + +[Declarative data lifecycle](./schema.md) + +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](./schema.md) to expect [Entities](./Entity.md) +- Functions to [deserialize fields](./network-transform.md#deserializing-fields) +- [Race condition handling](./Entity.md#shouldreorder) +- [Validation](./Entity.md#validate) + +```tsx +import { Entity, RestEndpoint } from '@data-client/rest'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +### key(urlParams): string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.method} ${this.url(urlParams)}`; +``` + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver), +[Controller.expireAll()](https://dataclient.io/docs/api/Controller#expireAll), and [Controller.invalidateAll()](https://dataclient.io/docs/api/Controller#invalidateAll). + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](./expiry-policy.md#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](./error-policy.md) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/docs/api/useSubscription) or +[useLive()](https://dataclient.io/docs/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[Optimistic update guide](./optimistic-updates.md) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](./Collection.md) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` + +## extend(options): RestEndpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const UserDetailNormalized = getUser.extend({ + schema: User, + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + }, +}); +``` + +## Specialized extenders + +These convenience accessors create new endpoints for common [Collection](./Collection.md) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](./Collection.md). + +### push + +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](./Collection.md). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](./Collection.md#push) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the end of the list +const newTodo = await ctrl.fetch( + getTodos.push, + { userId: '1' }, + { title: 'Buy groceries' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the end of the list +const newUser = await ctrl.fetch( + UserResource.getList.push, + { group: 'five' }, + { username: 'newuser', email: 'new@example.com' }, +); +``` + +### unshift + +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](./Collection.md). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](./Collection.md#unshift) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the beginning of the list +const newTodo = await ctrl.fetch( + getTodos.unshift, + { userId: '1' }, + { title: 'Urgent task' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the start of the list +const newUser = await ctrl.fetch( + UserResource.getList.unshift, + { group: 'five' }, + { username: 'priorityuser', email: 'priority@example.com' }, +); +``` + +### assign + +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](./Collection.md). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](./Collection.md#assign) + +```tsx +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); + +// POST /products/stats - add/update entries in the Values collection +await ctrl.fetch(getStats.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +```tsx +const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + schema: new Collection(new Values(Stats)), + }, +}); + +// POST /products/stats - add/update entries +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +### remove + +Creates a PATCH endpoint that removes Entities from a [Collection](./Collection.md) and updates them with the response. + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](./Collection.md#remove) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: new Collection([Todo]), +}); + +// PATCH /todos - removes Todo from collection AND updates the entity +await ctrl.fetch(getTodos.remove, {}, { id: '123', completed: true }); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users - removes user from 'five' group list +// AND updates the user entity with response data (e.g., new group) +await ctrl.fetch( + UserResource.getList.remove, + { group: 'five' }, + { id: '2', group: 'newgroup' }, +); +``` + +To use the remove schema with a different endpoint (e.g., DELETE): + +```ts +const deleteAndRemove = MyResource.delete.extend({ + schema: MyResource.getList.schema.remove, +}); +``` + +### move + +Creates a PATCH endpoint that moves Entities between [Collections](./Collection.md). It removes from +collections matching the entity's existing state and adds to collections matching the new values +(from the body/last arg). + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](./Collection.md#move) + +```ts title="TaskResource" +import { Entity, resource } from '@data-client/rest'; + +export class Task extends Entity { + id = ''; + title = ''; + status = 'backlog'; + pk() { return this.id; } + static key = 'Task'; +} +export const TaskResource = resource({ + path: '/tasks/:id', + searchParams: {} as { status: string }, + schema: Task, + optimistic: true, +}); +``` + +```tsx title="TaskCard" {5-9} +import { useController } from '@data-client/react'; +import { TaskResource, type Task } from './TaskResource'; + +export default function TaskCard({ task }: { task: Task }) { + const handleMove = () => ctrl.fetch( + TaskResource.getList.move, + { id: task.id }, + { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' }, + ); + const ctrl = useController(); + return ( +
+ {task.title} + +
+ ); +} +``` + +```tsx title="TaskBoard" +import { useSuspense } from '@data-client/react'; +import { TaskResource } from './TaskResource'; +import TaskCard from './TaskCard'; + +function TaskBoard() { + const backlog = useSuspense(TaskResource.getList, { status: 'backlog' }); + const inProgress = useSuspense(TaskResource.getList, { status: 'in-progress' }); + return ( +
+
+

Backlog

+ {backlog.map(task => )} +
+
+

Active

+ {inProgress.map(task => )} +
+
+ ); +} +render(); +``` + +The remove filter is based on the entity's **existing** values in the store. +The add filter is based on the merged entity values (existing + body). +This uses the same [createCollectionFilter](./Collection.md#createcollectionfilter) logic as push/remove. + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group +await ctrl.fetch( + UserResource.getList.move, + { group: 'five', id: '2' }, + { id: '2', group: 'ten' }, +); +``` + +### getPage + +An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema +must also contain a [Collection](./Collection.md) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: Todo, + paginationField: 'page', +}); + +const todos = useSuspense(getTodos); +return ( + + // fetches url `/todos?page=${nextPage}` + ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + } + /> +); +``` + +See [pagination guide](./pagination.md) for more info. + +### paginated(paginationfield) {#paginated} + +Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific +page, to append to this endpoint. See [Infinite Scrolling Pagination](./pagination.md#infinite-scrolling) for more info. + +```ts +const getNextPage = getList.paginated('cursor'); +``` + +Schema must also contain a [Collection](./Collection.md) + +### paginated(removeCursor) {#paginated-function} + +```typescript +function paginated( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], +): PaginationEndpoint; +``` + +The function form allows any argument processing. This is the equivalent of sending `cursor` string like above. + +```ts +const getNextPage = getList.paginated( + ({ cursor, ...rest }: { cursor: string | number }) => + (Object.keys(rest).length ? [rest] : []) as any, +); +``` + +`removeCusor` is a function that takes the arguments sent in fetch of `getNextPage` and returns +the arguments to update `getList`. + +Schema must also contain a [Collection](./Collection.md) + +## Inheritance + +Make sure you use `RestGenerics` to keep types working. + +```ts +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.github.com'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + } +} +``` diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md new file mode 100644 index 000000000000..68aaab5ec636 --- /dev/null +++ b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md @@ -0,0 +1,1417 @@ + + +# RestEndpoint + +`RestEndpoints` are for [HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP) based protocols like REST. + +> **Info: extends** +> +> `RestEndpoint` extends [Endpoint](https://dataclient.io/rest/api/Endpoint) + +
+ +Interface + +**RestEndpoint** + +```typescript +interface RestGenerics { + readonly path: string; + readonly schema?: Schema | undefined; + readonly method?: string; + readonly body?: any; + readonly searchParams?: any; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + process?(value: any, ...args: any): any; +} + +export class RestEndpoint extends Endpoint { + /* Prepare fetch */ + readonly path: string; + readonly urlPrefix: string; + readonly requestInit: RequestInit; + readonly method: string; + readonly paginationField?: string; + readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream'; + readonly signal: AbortSignal | undefined; + url(...args: Parameters): string; + searchToString(searchParams: Record): string; + getRequestInit( + this: any, + body?: RequestInit['body'] | Record, + ): Promise | RequestInit; + getHeaders(headers: HeadersInit): Promise | HeadersInit; + + /* Perform/process fetch */ + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + parseResponse(response: Response): Promise; + process(value: any, ...args: Parameters): any; + + testKey(key: string): boolean; +} +``` + +**Endpoint** + +```typescript +class Endpoint Promise> { + constructor(fetchFunction: F, options: EndpointOptions); + + key(...args: Parameters): string; + + readonly sideEffect?: true; + + readonly schema?: Schema; + + /** Default data expiry length, will fall back to NetworkManager default if not defined */ + readonly dataExpiryLength?: number; + /** Default error expiry length, will fall back to NetworkManager default if not defined */ + readonly errorExpiryLength?: number; + /** Poll with at least this frequency in miliseconds */ + readonly pollFrequency?: number; + /** Marks cached resources as invalid if they are stale */ + readonly invalidIfStale?: boolean; + /** Enables optimistic updates for this request - uses return value as assumed network response */ + readonly getOptimisticResponse?: ( + snap: SnapshotInterface, + ...args: Parameters + ) => ResolveType; + /** Determines whether to throw or fallback to */ + readonly errorPolicy?: (error: any) => 'soft' | undefined; + + testKey(key: string): boolean; +} +``` + +
+ +## Usage + +All options are supported as arguments to the constructor, [extend](#extend), and as overrides when using [inheritance](#inheritance) + +### Simplest retrieval + +```ts +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +``` + +```ts +const todo = await getTodo({ id: 1 }); +``` + +### Configuration sharing + +Use [RestEndpoint.extend()](#extend) instead of `{...getTodo}` ([Object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)) + +```ts +const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +### Managing state + +```ts path=Todo.ts +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; +} + +export const getTodo = new RestEndpoint({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +export const updateTodo = getTodo.extend({ method: 'PUT' }); +``` + +Using a [Schema](./schema.md) enables [automatic data consistency](https://dataclient.io/vue/concepts/normalization) without the need to hurt performance with [refetching](https://dataclient.io/vue/api/Controller#expireAll). + +### Typing + +```ts title="Comment" +export class Comment extends Entity { + id = ''; + title = ''; + body = ''; + postId = ''; + + static key = 'Comment'; +} +``` + +```ts title="Usage" +import { Comment } from './Comment'; + +const getComments = new RestEndpoint({ + path: '/posts/:postId/comments', + schema: new Collection([Comment]), + searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, +}); + +// Hover your mouse over 'comments' to see its type +const comments = useSuspense(getComments, { + postId: '5', + sortBy: 'votes', +}); + +const ctrl = useController(); +const createComment = async data => + ctrl.fetch(getComments.push, { postId: '5' }, data); +``` + +#### Resolution/Return + +[schema](#schema) determines the return value when used with data-binding hooks like [useSuspense](https://dataclient.io/vue/api/useSuspense), [useDLE](https://dataclient.io/vue/api/useDLE), [useCache](https://dataclient.io/vue/api/useCache) +or when used with [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) + +```ts title="Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +``` + +```ts title="getTodo.ts" +import { Todo } from './Todo'; + +const getTodo = new RestEndpoint({ path: '/', schema: Todo }); +// Hover your mouse over 'todo' to see its type +const todo = useSuspense(getTodo); + +async () => { + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +[process](#process) determines the resolution value when the endpoint is called directly. For +`RestEndpoints` without a schema, it also determines the return type of [hooks](https://dataclient.io/vue/api/useSuspense) and [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch). + +```ts path="process.ts" +interface TodoInterface { + title: string; + completed: boolean; +} +const getTodo = new RestEndpoint({ + path: '/', + process(value): TodoInterface { + return value; + }, +}); +async () => { + // todo is TodoInterface + const todo = await getTodo(); + + const ctrl = useController(); + const todo2 = await ctrl.fetch(getTodo); +}; +``` + +#### Function Parameters + +[path](#path) used to construct the url determines the type of the first argument. If it has no patterns, +then the 'first' argument is skipped. + +```ts +const getRoot = new RestEndpoint({ path: '/' }); +getRoot(); +const getById = new RestEndpoint({ path: '/:id' }); +// both number and string types work as they are serialized into strings to construct the url +getById({ id: 5 }); +getById({ id: '5' }); +``` + +[method](#method) determines whether there is a second argument to be sent as the [body](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body). + +```ts path=method.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', +}); +update({ id: 5 }, { title: 'updated', completed: true }); +``` + +However, this is typed as 'any' so it won't catch typos. + +[body](#body) can be used to type the argument after the url parameters. It is only used for typing so the +value sent does not matter. `undefined` value can be used to 'disable' the second argument. + +```ts path=body.ts +export const update = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: {} as TodoInterface, +}); +update({ id: 5 }, { title: 'updated', completed: true }); +// `undefined` disables 'body' argument +const rpc = new RestEndpoint({ + path: '/:id', + method: 'PUT', + body: undefined, +}); +rpc({ id: 5 }); +``` + +[searchParams](#searchParams) can be used in a similar way to `body` to specify types extra parameters, used +for the GET searchParams/queryParams in a [url()](#url). + +```ts +const getUsers = new RestEndpoint({ + path: '/:group/user/:id', + searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' }, +}); +getUsers.url({ group: 'big', id: '5', sort: 'asc' }) === + '/big/user/5?sort=asc'; +getUsers.url({ + group: 'big', + id: '5', + sort: 'desc', + isAdmin: true, +}) === '/big/user/5?isAdmin=true&sort=desc'; +``` + +## Fetch Lifecycle + +RestEndpoint adds to Endpoint by providing customizations for a provided fetch method using +[inheritance](#inheritance) or [.extend()](#extend). + +```mermaid +flowchart TB + URL-->response + INIT-->response + subgraph Prepare Fetch + subgraph URL + direction BT + urlPrefix-->url("url(urlParams)") + path-->url + searchToString("searchToString()")-->url + searchParams-->searchToString("searchToString()") + end + subgraph INIT + direction BT + getHeaders("getHeaders()")-->reqinit("getRequestInit(body)") + method-->reqinit + signal-->reqinit + end + end + subgraph Perform Fetch + response("fetchResponse()")-->parse("parseResponse()") + parse-->process("process()") + end + click url "/rest/api/RestEndpoint#url" + click searchToString "/rest/api/RestEndpoint#searchToString" + click searchParams "/rest/api/RestEndpoint#searchParams" + click urlPrefix "/rest/api/RestEndpoint#urlPrefix" + click path "/rest/api/RestEndpoint#path" + click getHeaders "/rest/api/RestEndpoint#getHeaders" + click method "/rest/api/RestEndpoint#method" + click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal" + click reqinit "/rest/api/RestEndpoint#getRequestInit" + click response "/rest/api/RestEndpoint#fetchResponse" + click parse "/rest/api/RestEndpoint#parseResponse" + click process "/rest/api/RestEndpoint#process" +``` + +```ts title="fetch implementation for RestEndpoint" +function fetch(...args) { + const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; + const body = this.#hasBody ? args[args.length - 1] : undefined; + return this.fetchResponse( + this.url(urlParams), + await this.getRequestInit(body), + ) + .then(response => this.parseResponse(response)) + .then(res => this.process(res, ...args)); +} +``` + +## Prepare Fetch + +Members double as options (second constructor arg). While none are required, the first few +have defaults. + +### url(params): string {#url} + +`urlPrefix` + `path template` + '?' + searchToString(`searchParams`) + +`url()` uses the `params` to fill in the [path template](#path). Any unused `params` members are then used +as [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka 'GET' params - the stuff after `?`). + +
+ +Implementation + +```typescript +import { getUrlBase, getUrlTokens } from '@data-client/rest'; + +url(urlParams = {}) { + const urlBase = getUrlBase(this.path)(urlParams); + const tokens = getUrlTokens(this.path); + const searchParams = {}; + Object.keys(urlParams).forEach(k => { + if (!tokens.has(k)) { + searchParams[k] = urlParams[k]; + } + }); + if (Object.keys(searchParams).length) { + return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; + } + return `${this.urlPrefix}${urlBase}`; +} +``` + +
+ +### searchToString(searchParams): string {#searchToString} + +Constructs the [searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) component of [url](#url). + +By default uses the standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) global. + +[searchParams](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) (aka queryParams) are sorted to maintain determinism. + +
+ +Implementation + +```typescript +searchToString(searchParams) { + const params = new URLSearchParams(searchParams); + params.sort(); + return params.toString(); +} +``` + +
+ +#### Using `qs` library + +To encode complex objects in the searchParams, you can use the [qs](https://github.com/ljharb/qs) library. + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +class QSEndpoint extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="QSEndpoint" {7} +import { RestEndpoint, RestGenerics } from '@data-client/rest'; +import qs from 'qs'; + +export default class QSEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + searchToString(searchParams) { + return qs.stringify(searchParams); + } +} +``` + +```typescript title="getFoo" +import QSEndpoint from './QSEndpoint'; + +const getFoo = new QSEndpoint({ + path: '/foo', + searchParams: {} as { a: Record }, +}); + +getFoo({ a: { b: 'c' } }); +``` + +### path: string {#path} + +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) to build +urls using the parameters passed. This also informs the types so they are properly enforced. + +#### Parameters + +`:` prefixed words are parameter names. Both strings and numbers are accepted as values, +since they are serialized into the url string. + +```ts +const getThing = new RestEndpoint({ path: '/:group/things/:id' }); +getThing({ group: 'first', id: 77 }); +``` + +#### Optional parameters + +Wrap the optional segment (including its prefix) in `{}` to make it [optional](https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#optional). +The type of optional parameters becomes `string | number | undefined`. + +```ts +const optional = new RestEndpoint({ + path: '/:group/things{/:number}', +}); +optional({ group: 'first' }); +optional({ group: 'first', number: 'fifty' }); +``` + +Multiple optional segments can be chained with different prefixes: + +```ts +const ep = new RestEndpoint({ + path: '{/:attr1}{-:attr2}{-:attr3}', +}); + +ep({ attr1: 'hi' }); +ep({ attr2: 'hi' }); +ep({ attr1: 'hi', attr3: 'ho' }); +``` + +#### Wildcards (repeating parameters) + +`*name` matches one-or-more path segments. Wrap in `{}` to make it zero-or-more (optional). +Wildcard parameters are typed as `string[]` (arrays), since they represent multiple path segments. + +```ts +const files = new RestEndpoint({ path: '/files/*path' }); +files({ path: ['documents', 'reports', 'q4'] }); +// URL: /files/documents/reports/q4 + +const optionalFiles = new RestEndpoint({ path: '/files{/*path}' }); +optionalFiles({}); +// URL: /files +optionalFiles({ path: ['documents'] }); +// URL: /files/documents +``` + +#### Quoted parameter names + +Parameter names must be valid JavaScript identifiers. Names containing special characters +like `-` or `.` must be quoted with double quotes: + +```ts +const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' }); +ep({ 'with-dash': 'hello', 'my.param': 'world' }); +``` + +#### Escaping special characters + +Characters `{}()*:` and `\\` are special in path-to-regexp and must be escaped with `\\` when used as literals. + +```ts +const getSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', +}); +getSite({ slug: 'first' }); +``` + +`?` and `+` are **not** special in path-to-regexp v8 and do not need escaping. +This means query strings can be embedded in the path without escaping `?`: + +```ts +const search = new RestEndpoint({ + path: '/search?{q=:q}{&page=:page}', +}); +search({ q: 'test', page: 1 }); +// URL: /search?q=test&page=1 +``` + +> **Info** +> +> Types are inferred automatically from `path`. +> +> Additional parameters can be specified with [searchParams](#searchParams) +> and [body](#body). + +### searchParams {#searchParams} + +`searchParams` can be to specify types extra parameters, used for the GET searchParams/queryParams in a [url()](#url). + +The actual **value is not used** in any way - this only determines [typing](#typing). + +```typescript title="getFoo" +const getReactSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + searchParams: {} as { isReact: boolean }, +}); + +getReactSite({ slug: 'cool', isReact: true }); +``` + +### body {#body} + +`body` can be used to set a second argument for mutation endpoints. The actual **value is not +used** in any way - this only determines [typing](#typing). + +This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'. + +```ts {4} +const updateSite = new RestEndpoint({ + path: 'https\\://site.com/:slug', + method: 'POST', + body: {} as { url: string }, +}); + +updateSite({ slug: 'cool' }, { url: '/' }); +``` + +### paginationField + +If specified, will add [getPage](#getpage) method on the `RestEndpoint`. [Pagination guide](./pagination.vue.md). Schema +must also contain a [Collection](./Collection.md). + +### urlPrefix: string = '' {#urlPrefix} + +Prepends this to the compiled [path](#path) + +#### Inheritance defaults + +```typescript +export class MyEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + // this allows us to override the prefix in production environments, with a dev fallback + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; +} +``` + +[Learn more about inheritance patterns](#inheritance) for RestEndpoint + +#### Instance overrides + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:product_id/ticker', + schema: Ticker, +}); +``` + +#### Dynamic prefix + +> **Tip** +> +> For a dynamic prefix, try overriding the url() method instead: +> +> ```ts +> const getTodo = new RestEndpoint({ +> path: '/todo/:id', +> url(...args) { +> return dynamicPrefix() + super.url(...args); +> }, +> }); +> ``` + +### method: string = 'GET' {#method} + +[Method](https://developer.mozilla.org/en-US/docs/Web/API/Request/method) is part of the HTTP protocol. +REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this +to inform `sideEffect` and whether the endpoint should use a `body` payload. Setting +`sideEffect` explicitly will override this behavior, allowing for non-standard API designs. + +`GET` is 'readonly', other methods imply sideEffects. + +`GET` and `DELETE` both default to no `body`. + +> **Tip: How method affects function Parameters** +> +> `method` only influences parameters in the RestEndpoint constructor and _not_ [.extend()](#extend). +> This allows non-standard method-body combinations. +> +> `body` will default to `any`. You can always set body explicitly to take full control. `undefined` can be used +> to indicate there is no body. +> +> ```ts +> (id: string, myPayload: Record) => { +> const standardCreate = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> }); +> standardCreate({ id }, myPayload); +> const nonStandardEndpoint = new RestEndpoint({ +> path: '/:id', +> method: 'POST', +> body: undefined, +> }); +> // no second 'body' argument, because body was set to 'undefined' +> nonStandardEndpoint({ id }); +> }; +> ``` + +### getRequestInit(body): RequestInit {#getRequestInit} + +Prepares [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) used in fetch. +This is sent to [fetchResponse](#fetchResponse) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getRequestInit(body) { +> return { +> ...(await super.getRequestInit(body)), +> method: await getMethod(), +> }; +> } +> } +> +> async function getMethod() { +> return 'GET'; +> } +> ``` + +### getHeaders(headers: HeadersInit): HeadersInit {#getHeaders} + +Called by [getRequestInit](#getRequestInit) to determine [HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) + +This is often useful for [authentication](./auth.md) + +> **Warning** +> +> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) + +> **Tip: async** +> +> ```ts +> import { RestEndpoint, RestGenerics } from '@data-client/rest'; +> +> export default class AuthdEndpoint< +> O extends RestGenerics = any, +> > extends RestEndpoint { +> async getHeaders(headers: HeadersInit) { +> return { +> ...headers, +> 'Access-Token': await getAuthToken(), +> }; +> } +> } +> +> async function getAuthToken() { +> return 'example'; +> } +> ``` + +## Handle fetch + +### fetchResponse(input, init): Promise {#fetchResponse} + +Performs the [fetch(input, init)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) call. When +[response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) is not `true` (like 404), +will throw a NetworkError. + +### content {#content} + +Controls how the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) body is parsed. +When set, the return type is inferred automatically, and `schema` is constrained to `undefined` +for non-JSON content types. + +| Value | Parses via | Return type | +| --------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | +| `'json'` | [response.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) | `any` | +| `'blob'` | [response.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob) | `Blob` | +| `'text'` | [response.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text) | `string` | +| `'arrayBuffer'` | [response.arrayBuffer()](https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer) | `ArrayBuffer` | +| `'stream'` | `response.body` | `ReadableStream` | +| _unset_ | Auto-detect from Content-Type header | `any` | + +When `content` is not set, `parseResponse` auto-detects the response type from the +`Content-Type` header: JSON types call `.json()`, binary types (images, `application/octet-stream`, +PDFs, etc.) call `.blob()`, and text-like types call `.text()`. + +#### File downloads {#file-download} + +For file downloads, set `content: 'blob'`. The return type is `Blob` and `schema` must be +`undefined` (binary data cannot be normalized). Use `dataExpiryLength: 0` to avoid caching +large blobs in memory. + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +To extract the filename from the `Content-Disposition` header, override `parseResponse`: + +```ts +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +See [file download guide](./network-transform.md#file-download) for complete usage with browser download trigger. + +### parseResponse(response): Promise {#parseResponse} + +Takes the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and parses the body. + +When [`content`](#content) is set, it controls parsing directly. Otherwise, auto-detection runs +based on the [`Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type): +JSON types call [.json()](https://developer.mozilla.org/en-US/docs/Web/API/Response/json), binary +types call [.blob()](https://developer.mozilla.org/en-US/docs/Web/API/Response/blob), and text-like +types call [.text()](https://developer.mozilla.org/en-US/docs/Web/API/Response/text). + +If `status` is 204, resolves as `null`. + +Override this for advanced cases like extracting headers alongside the body. + +### process(value, ...args): any {#process} + +Perform any transforms with the parsed result. Defaults to identity function (do nothing). + +> **Tip** +> +> The return type of process can be used to set the return type of the endpoint fetch: +> +> ```ts title="getTodo.ts" {4} +> export const getTodo = new RestEndpoint({ +> path: '/todos/:id', +> // The identity function is the default value; so we aren't changing any runtime behavior +> process(value): TodoInterface { +> return value; +> }, +> }); +> +> interface TodoInterface { +> id: string; +> title: string; +> completed: boolean; +> } +> ``` +> +> ```ts title="useTodo.ts" +> import { getTodo } from './getTodo'; +> +> async (id: string) => { +> // hover title to see it is a string +> // see TS autocomplete by deleting `.title` and retyping the `.` +> const title = (await getTodo({ id })).title; +> }; +> ``` + +## Endpoint Lifecycle + +### schema?: Schema {#schema} + +[Declarative data lifecycle](./schema.md) + +- Global data consistency and performance with [DRY](https://www.plutora.com/blog/understanding-the-dry-dont-repeat-yourself-principle) state: [where](./schema.md) to expect [Entities](./Entity.md) +- Functions to [deserialize fields](./network-transform.md#deserializing-fields) +- [Race condition handling](./Entity.md#shouldreorder) +- [Validation](./Entity.md#validate) + +```tsx +import { Entity, RestEndpoint } from '@data-client/rest'; + +class User extends Entity { + id = ''; + username = ''; +} + +const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +### key(urlParams): string {#key} + +Serializes the parameters. This is used to build a lookup key in global stores. + +Default: + +```typescript +`${this.method} ${this.url(urlParams)}`; +``` + +### testKey(key): boolean {#testKey} + +Returns `true` if the provided (fetch) [key](#key) matches this endpoint. + +This is used for mock interceptors with with [\](https://dataclient.io/docs/api/MockResolver), +[Controller.expireAll()](https://dataclient.io/vue/api/Controller#expireAll), and [Controller.invalidateAll()](https://dataclient.io/vue/api/Controller#invalidateAll). + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](./expiry-policy.vue.md#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](./error-policy.vue.md) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/vue/api/useSubscription) or +[useLive()](https://dataclient.io/vue/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[Optimistic update guide](./optimistic-updates.md) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](./Collection.md) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` + +## extend(options): RestEndpoint {#extend} + +Can be used to further customize the endpoint definition + +```typescript +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const UserDetailNormalized = getUser.extend({ + schema: User, + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + }, +}); +``` + +## Specialized extenders + +These convenience accessors create new endpoints for common [Collection](./Collection.md) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](./Collection.md). + +### push + +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](./Collection.md). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](./Collection.md#push) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the end of the list +const newTodo = await ctrl.fetch( + getTodos.push, + { userId: '1' }, + { title: 'Buy groceries' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the end of the list +const newUser = await ctrl.fetch( + UserResource.getList.push, + { group: 'five' }, + { username: 'newuser', email: 'new@example.com' }, +); +``` + +### unshift + +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](./Collection.md). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](./Collection.md#unshift) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: new Collection([Todo]), +}); + +// POST /todos - adds new Todo to the beginning of the list +const newTodo = await ctrl.fetch( + getTodos.unshift, + { userId: '1' }, + { title: 'Urgent task' }, +); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// POST /groups/five/users - adds new User to the start of the list +const newUser = await ctrl.fetch( + UserResource.getList.unshift, + { group: 'five' }, + { username: 'priorityuser', email: 'priority@example.com' }, +); +``` + +### assign + +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](./Collection.md). + +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](./Collection.md#assign) + +```tsx +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); + +// POST /products/stats - add/update entries in the Values collection +await ctrl.fetch(getStats.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +```tsx +const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + schema: new Collection(new Values(Stats)), + }, +}); + +// POST /products/stats - add/update entries +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +### remove + +Creates a PATCH endpoint that removes Entities from a [Collection](./Collection.md) and updates them with the response. + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](./Collection.md#remove) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: new Collection([Todo]), +}); + +// PATCH /todos - removes Todo from collection AND updates the entity +await ctrl.fetch(getTodos.remove, {}, { id: '123', completed: true }); +``` + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users - removes user from 'five' group list +// AND updates the user entity with response data (e.g., new group) +await ctrl.fetch( + UserResource.getList.remove, + { group: 'five' }, + { id: '2', group: 'newgroup' }, +); +``` + +To use the remove schema with a different endpoint (e.g., DELETE): + +```ts +const deleteAndRemove = MyResource.delete.extend({ + schema: MyResource.getList.schema.remove, +}); +``` + +### move + +Creates a PATCH endpoint that moves Entities between [Collections](./Collection.md). It removes from +collections matching the entity's existing state and adds to collections matching the new values +(from the body/last arg). + +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](./Collection.md#move) + +```ts title="TaskResource" +import { Entity, resource } from '@data-client/rest'; + +export class Task extends Entity { + id = ''; + title = ''; + status = 'backlog'; + pk() { return this.id; } + static key = 'Task'; +} +export const TaskResource = resource({ + path: '/tasks/:id', + searchParams: {} as { status: string }, + schema: Task, + optimistic: true, +}); +``` + +```tsx title="TaskCard" {5-9} +import { useController } from '@data-client/react'; +import { TaskResource, type Task } from './TaskResource'; + +export default function TaskCard({ task }: { task: Task }) { + const handleMove = () => ctrl.fetch( + TaskResource.getList.move, + { id: task.id }, + { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' }, + ); + const ctrl = useController(); + return ( +
+ {task.title} + +
+ ); +} +``` + +```tsx title="TaskBoard" +import { useSuspense } from '@data-client/react'; +import { TaskResource } from './TaskResource'; +import TaskCard from './TaskCard'; + +function TaskBoard() { + const backlog = useSuspense(TaskResource.getList, { status: 'backlog' }); + const inProgress = useSuspense(TaskResource.getList, { status: 'in-progress' }); + return ( +
+
+

Backlog

+ {backlog.map(task => )} +
+
+

Active

+ {inProgress.map(task => )} +
+
+ ); +} +render(); +``` + +The remove filter is based on the entity's **existing** values in the store. +The add filter is based on the merged entity values (existing + body). +This uses the same [createCollectionFilter](./Collection.md#createcollectionfilter) logic as push/remove. + +```tsx +const UserResource = resource({ + path: '/groups/:group/users/:id', + schema: User, +}); + +// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group +await ctrl.fetch( + UserResource.getList.move, + { group: 'five', id: '2' }, + { id: '2', group: 'ten' }, +); +``` + +### getPage + +An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema +must also contain a [Collection](./Collection.md) + +```tsx +const getTodos = new RestEndpoint({ + path: '/todos', + schema: Todo, + paginationField: 'page', +}); + +const todos = useSuspense(getTodos); +return ( + + // fetches url `/todos?page=${nextPage}` + ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + } + /> +); +``` + +See [pagination guide](./pagination.vue.md) for more info. + +### paginated(paginationfield) {#paginated} + +Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific +page, to append to this endpoint. See [Infinite Scrolling Pagination](./pagination.vue.md#infinite-scrolling) for more info. + +```ts +const getNextPage = getList.paginated('cursor'); +``` + +Schema must also contain a [Collection](./Collection.md) + +### paginated(removeCursor) {#paginated-function} + +```typescript +function paginated( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], +): PaginationEndpoint; +``` + +The function form allows any argument processing. This is the equivalent of sending `cursor` string like above. + +```ts +const getNextPage = getList.paginated( + ({ cursor, ...rest }: { cursor: string | number }) => + (Object.keys(rest).length ? [rest] : []) as any, +); +``` + +`removeCusor` is a function that takes the arguments sent in fetch of `getNextPage` and returns +the arguments to update `getList`. + +Schema must also contain a [Collection](./Collection.md) + +## Inheritance + +Make sure you use `RestGenerics` to keep types working. + +```ts +import { RestEndpoint, type RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = 'https://api.github.com'; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': getAuth(), + }; + } +} +``` diff --git a/.agents/skills/data-client-rest/references/_AsyncBoundary.md b/.agents/skills/data-client-rest/references/_AsyncBoundary.md deleted file mode 120000 index 57d2b9fdece5..000000000000 --- a/.agents/skills/data-client-rest/references/_AsyncBoundary.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_AsyncBoundary.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_AsyncBoundary.md b/.agents/skills/data-client-rest/references/_AsyncBoundary.md new file mode 100644 index 000000000000..bc7a7030075f --- /dev/null +++ b/.agents/skills/data-client-rest/references/_AsyncBoundary.md @@ -0,0 +1,89 @@ + + +**React Router** + +```tsx {9,11} title="Dashboard.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Outlet } from 'react-router'; + +export default function Dashboard() { + return ( +
+

Dashboard

+
+ + + +
+
+ ); +} +``` + +**NextJS** + +```tsx {12} title="app/dashboard/layout.tsx" +import { AsyncBoundary } from '@data-client/react'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Dashboard

+
+ {children} +
+
+ ); +} +``` + +**Expo** + +```tsx {15,17} title="app/dashboard/_layout.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Slot } from 'expo-router'; + +export default function DashboardLayout() { + return ( + + } + > + + + + + ); +} +``` + +**Antd Modal** + +```tsx title="ModalOpen.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Button, Modal } from 'antd'; + +export default function ModalOpen() { + return ( + <> + + + + + + + + ); +} +``` diff --git a/.agents/skills/data-client-rest/references/_AsyncBoundary.vue.md b/.agents/skills/data-client-rest/references/_AsyncBoundary.vue.md new file mode 100644 index 000000000000..2238bdce177e --- /dev/null +++ b/.agents/skills/data-client-rest/references/_AsyncBoundary.vue.md @@ -0,0 +1,30 @@ + + +```html title="Dashboard.vue" {13-20} + + + +``` diff --git a/.agents/skills/data-client-rest/references/_EndpointLifecycle.md b/.agents/skills/data-client-rest/references/_EndpointLifecycle.md deleted file mode 120000 index 5fa9f0835671..000000000000 --- a/.agents/skills/data-client-rest/references/_EndpointLifecycle.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/_EndpointLifecycle.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_EndpointLifecycle.md b/.agents/skills/data-client-rest/references/_EndpointLifecycle.md new file mode 100644 index 000000000000..0e2a31d10440 --- /dev/null +++ b/.agents/skills/data-client-rest/references/_EndpointLifecycle.md @@ -0,0 +1,232 @@ + + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](./expiry-policy.md#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](./error-policy.md) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/docs/api/useSubscription) or +[useLive()](https://dataclient.io/docs/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[Optimistic update guide](./optimistic-updates.md) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](./Collection.md) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` diff --git a/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md b/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md new file mode 100644 index 000000000000..db21ef3b27db --- /dev/null +++ b/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md @@ -0,0 +1,227 @@ + + +### dataExpiryLength?: number {#dataexpirylength} + +Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager. + +[Learn more about expiry time](./expiry-policy.vue.md#expiry-time) + +### errorExpiryLength?: number {#errorexpirylength} + +Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager. + +### errorPolicy?: (error: any) => 'soft' | undefined {#errorpolicy} + +'soft' will use stale data (if exists) in case of error; undefined or not providing option will result +in error. + +[Learn more about errorPolicy](./error-policy.vue.md) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +### invalidIfStale: boolean {#invalidifstale} + +Indicates stale data should be considered unusable and thus not be returned from the cache. This means +that useSuspense() will suspend when data is stale even if it already exists in cache. + +### pollFrequency: number {#pollfrequency} + +Frequency in millisecond to poll at. Requires using [useSubscription()](https://dataclient.io/vue/api/useSubscription) or +[useLive()](https://dataclient.io/vue/api/useLive) to have an effect. + +### getOptimisticResponse: (snap, ...args) => expectedResponse {#getoptimisticresponse} + +When provided, any fetches with this endpoint will behave as though the `expectedResponse` return value +from this function was a succesful network response. When the actual fetch completes (regardless +of failure or success), the optimistic update will be replaced with the actual network response. + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[Optimistic update guide](./optimistic-updates.md) + +### update() {#update} + +```ts +(normalizedResponseOfThis, ...args) => + ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) }) +``` + +> **Tip** +> +> Try using [Collections](./Collection.md) instead. +> +> They are much easier to use and more robust! + +```ts title="UpdateType.ts" +type UpdateFunction< + Source extends EndpointInterface, + Updaters extends Record = Record, +> = ( + source: ResultEntry, + ...args: Parameters +) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] }; +``` + +Simplest case: + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId: string) => ({ + [userList.key()]: (users = []) => [newUserId, ...users], + }), +}); +``` + +More updates: + +```typescript title="Component.tsx" +const allusers = useSuspense(userList); +const adminUsers = useSuspense(userList, { admin: true }); +``` + +The endpoint below ensures the new user shows up immediately in the usages above. + +```ts title="userEndpoint.ts" +const createUser = new RestEndpoint({ + path: '/user', + method: 'POST', + schema: User, + update: (newUserId, newUser) => { + const updates = { + [userList.key()]: (users = []) => [newUserId, ...users], + ]; + if (newUser.isAdmin) { + updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users]; + } + return updates; + }, +}); +``` diff --git a/.agents/skills/data-client-rest/references/_VoteDemo.md b/.agents/skills/data-client-rest/references/_VoteDemo.md deleted file mode 120000 index f3bd45e85171..000000000000 --- a/.agents/skills/data-client-rest/references/_VoteDemo.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_VoteDemo.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_VoteDemo.md b/.agents/skills/data-client-rest/references/_VoteDemo.md new file mode 100644 index 000000000000..4a419317f07f --- /dev/null +++ b/.agents/skills/data-client-rest/references/_VoteDemo.md @@ -0,0 +1,129 @@ + + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/_VoteDemo.vue.md b/.agents/skills/data-client-rest/references/_VoteDemo.vue.md new file mode 100644 index 000000000000..cf888d755a76 --- /dev/null +++ b/.agents/skills/data-client-rest/references/_VoteDemo.vue.md @@ -0,0 +1,124 @@ + + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` diff --git a/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.md b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.md deleted file mode 120000 index 0381b232b1da..000000000000 --- a/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/shared/_entity_lifecycle_methods.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.md b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.md new file mode 100644 index 000000000000..796616750375 --- /dev/null +++ b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.md @@ -0,0 +1,299 @@ + + +### static fromJS(props): Entity {#fromJS} + +Factory method that copies props to a new instance. Use this instead of `new MyEntity()`, +to ensure default props are overridden. + +### static process(input, parent, key, args): processedEntity {#process} + +Run at the start of normalization for this entity. Return value is saved in store +and sent to [pk()](#pk). + +**Defaults** to simply copying the response (`{...input}`) + +How to override to [build reverse-lookups for relational data](https://dataclient.io/rest/guides/relational-data#reverse-lookups) + +#### Case of the missing id + +```ts +class Stream extends Entity { + username = ''; + title = ''; + game = ''; + currentViewers = 0; + live = false; + + pk() { + return this.username; + } + static key = 'Stream'; + + static process(value, parent, key, args) { + // super.process creates a copy of value + const processed = super.process(value, parent, key, args); + processed.username = args[0]?.username; + return processed; + } +} +``` + +#### Dynamic Invalidation + +Returning `undefined` from [Entity.process](#process) +will cause the `Entity` to be [invalidated](./expiry-policy.md#invalidate-entity). +This this allows us to invalidate dynamically; based on the particular response data. + +```ts +class PriceLevel extends Entity { + price = 0; + amount = 0; + + pk() { + return this.price; + } + + static process( + input: [number, number], + parent: any, + key: string | undefined, + ): any { + const [price, amount] = input; + if (amount === 0) return undefined; + return { price, amount }; + } +} +``` + +### static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue {#mergeWithStore} + +```typescript +static mergeWithStore( + existingMeta: { + date: number; + fetchedAt: number; + }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + const shouldUpdate = this.shouldUpdate( + existingMeta, + incomingMeta, + existing, + incoming, + ); + + if (shouldUpdate) { + // distinct types are not mergeable (like delete symbol), so just replace + if (typeof incoming !== typeof existing) { + return incoming; + } else { + return this.shouldReorder( + existingMeta, + incomingMeta, + existing, + incoming, + ) + ? this.merge(incoming, existing) + : this.merge(existing, incoming); + } + } else { + return existing; + } +} +``` + +`mergeWithStore()` is called during normalization when a processed entity is already found in the store. + +This calls [shouldUpdate()](#shouldupdate), [shouldReorder()](#shouldreorder) and potentially [merge()](#merge) + +### static shouldUpdate(existingMeta, incomingMeta, existing, incoming): boolean {#shouldupdate} + +```typescript +static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return existingMeta.fetchedAt <= incomingMeta.fetchedAt; +} +``` + +#### Preventing updates + +shouldUpdate can also be used to short-circuit an entity update. + +```typescript +import deepEqual from 'deep-equal'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; + published = false; + + static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, + ) { + return !deepEqual(incoming, existing); + } +} +``` + +### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldreorder} + +```typescript +static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return incomingMeta.fetchedAt < existingMeta.fetchedAt; +} +``` + +`true` return value will reorder incoming vs in-store entity argument order in merge. With +the default merge, this will cause the fields of existing entities to override those of incoming, +rather than the other way around. + +#### Example + +```typescript +class LatestPriceEntity extends Entity { + id = ''; + updatedAt = 0; + price = '0.0'; + symbol = ''; + + pk() { + return this.id; + } + + static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: { updatedAt: number }, + incoming: { updatedAt: number }, + ) { + return incoming.updatedAt < existing.updatedAt; + } +} +``` + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts)) + +### static merge(existing, incoming): mergedValue {#merge} + +```typescript +static merge(existing: any, incoming: any) { + return { + ...existing, + ...incoming, + }; +} +``` + +Merge is used to handle cases when an incoming entity is already found. This is called directly +when the same entity is found in one response. By default it is also called when [mergeWithStore()](#mergeWithStore) +determines the incoming entity should be merged with an entity already persisted in the Reactive Data Client store. + +How to override to [build reverse-lookups for relational data](https://dataclient.io/rest/guides/relational-data#reverse-lookups) + +### static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming): meta {#mergeMetaWithStore} + +```typescript +static mergeMetaWithStore( + existingMeta: { + expiresAt: number; + date: number; + fetchedAt: number; + }, + incomingMeta: { expiresAt: number; date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) + ? existingMeta + : incomingMeta; +} +``` + +`mergeMetaWithStore()` is called during normalization when a processed entity is already found in the store. + +### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} + +This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. + +Overriding can allow customization or disabling of this behavior altogether. + +Returning `undefined` will disallow this behavior. + +Returning `pk` string will attempt to lookup this entity and use in the response. + +When used, expiry policy is computed based on the entity's own meta data. + +By **default** uses the first argument to lookup in [pk()](#pk) and [indexes](./Entity.md#indexes) + +#### getEntity(key, pk?) + +Gets all entities of a type with one argument, or a single entity with two + +```ts title="One argument" +const entitiesEntry = getEntity(this.schema.key); +if (entitiesEntry === undefined) return INVALID; +return Object.values(entitiesEntry).map( + entity => entity && this.schema.pk(entity), +); +``` + +```ts title="Two arguments" +if (getEntity(this.key, id)) return id; +``` + +#### getIndex(key, indexName, value) + +Returns the index entry (value->pk map) + +```ts +const value = args[0][indexName]; +return getIndex(schema.key, indexName, value)[value]; +``` + +### static createIfValid(processedEntity): Entity | undefined {#createIfValid} + +Called when denormalizing an entity. This will create an instance of this class +if it is deemed 'valid'. + +`undefined` return will result in [Invalid expiry status](./expiry-policy.md#expiry-status), +like [Invalidate](https://dataclient.io/rest/api/Invalidate). + +[`Invalid`](./expiry-policy.md#expiry-status) expiry generally means hooks will enter a loading state and attempt a new fetch. + +```ts +static createIfValid(props): AbstractInstanceType | undefined { + if (this.validate(props)) { + return undefined as any; + } + return this.fromJS(props); +} +``` + +### static validate(processedEntity): errorMessage? {#validate} + +Runs during both normalize and denormalize. Returning a string indicates an error (the string is the message). + +During normalization a validation failure will result in an error for that fetch. + +During denormalization a validation failure will mark that result as 'invalid' and thus +will block on fetching a result. + +By **default** does some basic field existance checks in development mode only. Override to +disable or customize. + +[Using validation for endpoints with incomplete fields](https://dataclient.io/rest/guides/partial-entities) diff --git a/.agents/skills/data-client-rest/references/_optimisticTransform.md b/.agents/skills/data-client-rest/references/_optimisticTransform.md deleted file mode 120000 index 10b664931d3e..000000000000 --- a/.agents/skills/data-client-rest/references/_optimisticTransform.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/shared/_optimisticTransform.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_optimisticTransform.md b/.agents/skills/data-client-rest/references/_optimisticTransform.md new file mode 100644 index 000000000000..6f3dc8ac197f --- /dev/null +++ b/.agents/skills/data-client-rest/references/_optimisticTransform.md @@ -0,0 +1,86 @@ + + +```ts title="count" +export class CountEntity extends Entity { + count = 0; + + pk() { + return `SINGLETON`; + } +} +export const getCount = new RestEndpoint({ + path: '/api/count', + schema: CountEntity, + name: 'get', +}); +``` + +```ts title="increment" {9-15} +import { CountEntity, getCount } from './count'; + +export const increment = new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + name: 'increment', + schema: CountEntity, + getOptimisticResponse(snap) { + const data = snap.get(CountEntity, {}); + if (!data) throw snap.abort; + return { + count: data.count + 1, + }; + }, +}); +``` + +```tsx title="CounterPage" +import { useLoading } from '@data-client/react'; +import { getCount } from './count'; +import { increment } from './increment'; + +function CounterPage() { + const ctrl = useController(); + const { count } = useSuspense(getCount); + const [stateCount, setStateCount] = React.useState(0); + const [responseCount, setResponseCount] = React.useState(0); + const [clickHandler, loading, error] = useLoading(async () => { + setStateCount(stateCount + 1); + const val = await ctrl.fetch(increment); + setResponseCount(val.count); + setStateCount(val.count); + }); + return ( +
+

+ Click the button multiple times quickly to trigger the race + condition +

+ + + + + + + + + + + + + + + + + + +
OptimisticNormal
Data Client:{count}
Other:{stateCount}{responseCount}
+ +

{loading ? ' ...loading' : ''}

+
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/_pagination.md b/.agents/skills/data-client-rest/references/_pagination.md deleted file mode 120000 index 44c3e22eb030..000000000000 --- a/.agents/skills/data-client-rest/references/_pagination.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_pagination.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_pagination.md b/.agents/skills/data-client-rest/references/_pagination.md new file mode 100644 index 000000000000..fcb5f18ee51c --- /dev/null +++ b/.agents/skills/data-client-rest/references/_pagination.md @@ -0,0 +1,111 @@ + + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```tsx title="PostItem" +import { type Post } from './Post'; + +export default function PostItem({ post }: Props) { + return ( +
+ +
+

{post.title}

+ by {post.author.name} +
+
+ ); +} + +interface Props { + post: Post; +} +``` + +```tsx title="LoadMore" {7} +import { useController, useLoading } from '@data-client/react'; +import { PostResource } from './Post'; + +export default function LoadMore({ cursor }: { cursor: string }) { + const ctrl = useController(); + const [loadPage, isPending] = useLoading( + () => ctrl.fetch(PostResource.getList.getPage, { cursor }), + [cursor], + ); + return ( +
+ +
+ ); +} +``` + +```tsx title="PostList" {7} +import { useSuspense } from '@data-client/react'; +import PostItem from './PostItem'; +import LoadMore from './LoadMore'; +import { PostResource } from './Post'; + +export default function PostList() { + const { posts, cursor } = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( + + ))} + {cursor ? : null} +
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/_pagination.vue.md b/.agents/skills/data-client-rest/references/_pagination.vue.md new file mode 100644 index 000000000000..f258e6b2e6bc --- /dev/null +++ b/.agents/skills/data-client-rest/references/_pagination.vue.md @@ -0,0 +1,108 @@ + + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```html title="PostItem.vue" + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="PostList.vue" + + + +``` diff --git a/.agents/skills/data-client-rest/references/_useLive.md b/.agents/skills/data-client-rest/references/_useLive.md deleted file mode 120000 index eb946942cad6..000000000000 --- a/.agents/skills/data-client-rest/references/_useLive.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_useLive.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_useLive.md b/.agents/skills/data-client-rest/references/_useLive.md new file mode 100644 index 000000000000..c4c5fb9d15b1 --- /dev/null +++ b/.agents/skills/data-client-rest/references/_useLive.md @@ -0,0 +1,59 @@ + + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```tsx title="AssetPrice" {5} +import { useLive } from '@data-client/react'; +import { getTicker } from './Ticker'; + +function AssetPrice({ productId }: Props) { + const ticker = useLive(getTicker, { productId }); + return ( +
+ {productId}{' '} + +
+ ); +} +interface Props { + productId: string; +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/_useLive.vue.md b/.agents/skills/data-client-rest/references/_useLive.vue.md new file mode 100644 index 000000000000..74e89472eafc --- /dev/null +++ b/.agents/skills/data-client-rest/references/_useLive.vue.md @@ -0,0 +1,57 @@ + + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```html title="AssetPrice.vue" {6} + + + +``` diff --git a/.agents/skills/data-client-rest/references/_useLoading.md b/.agents/skills/data-client-rest/references/_useLoading.md deleted file mode 120000 index 9ed6b996c6bc..000000000000 --- a/.agents/skills/data-client-rest/references/_useLoading.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/shared/_useLoading.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/_useLoading.md b/.agents/skills/data-client-rest/references/_useLoading.md new file mode 100644 index 000000000000..fe5b737f58aa --- /dev/null +++ b/.agents/skills/data-client-rest/references/_useLoading.md @@ -0,0 +1,118 @@ + + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```tsx title="PostDetail" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; + +export default function PostDetail({ id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+ +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +``` + +```tsx title="PostForm" +export default function PostForm({ onSubmit, loading, error }) { + const handleSubmit = e => { + e.preventDefault(); + const data = new FormData(e.target); + onSubmit(data); + }; + return ( +
+ + + {error ? ( +
{error.message}
+ ) : null} +
+ +
+ + ); +} +``` + +```tsx title="PostCreate" {7} +import { useLoading, useController } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostForm from './PostForm'; + +export default function PostCreate({ navigateToPost }) { + const ctrl = useController(); + const [handleSubmit, loading, error] = useLoading( + async data => { + const post = await ctrl.fetch(PostResource.getList.push, data); + navigateToPost(post.id); + }, + [ctrl], + ); + return ( + + ); +} +``` + +```tsx title="Navigation" +import PostCreate from './PostCreate'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [id, setId] = React.useState(undefined); + if (id) { + return ( +
+ +
+ +
+
+ ); + } + return ; +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/_useLoading.vue.md b/.agents/skills/data-client-rest/references/_useLoading.vue.md new file mode 100644 index 000000000000..aea12d095d10 --- /dev/null +++ b/.agents/skills/data-client-rest/references/_useLoading.vue.md @@ -0,0 +1,123 @@ + + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```html title="PostDetail.vue" + + + +``` + +```html title="PostForm.vue" + + + +``` + +```html title="PostCreate.vue" {9-14} + + + +``` + +```html title="Navigation.vue" + + + +``` diff --git a/.agents/skills/data-client-rest/references/auth.md b/.agents/skills/data-client-rest/references/auth.md deleted file mode 120000 index 245b1d93a236..000000000000 --- a/.agents/skills/data-client-rest/references/auth.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/auth.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/auth.md b/.agents/skills/data-client-rest/references/auth.md new file mode 100644 index 000000000000..cfc1cac3e94b --- /dev/null +++ b/.agents/skills/data-client-rest/references/auth.md @@ -0,0 +1,383 @@ + + +# Rest Authentication + +All network requests are run through the [getRequestInit](./RestEndpoint.md#getRequestInit) optionally +defined in your [RestEndpoint](./RestEndpoint.md). + +## Cookie Auth (credentials) + +Here's an example using simple [cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) auth by sending [fetch credentials](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included): + +```ts title="AuthdEndpoint" {9} +import { RestEndpoint } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +See [Django Integration](https://dataclient.io/rest/guides/django) for an example that also includes [CSRF protection](https://docs.djangoproject.com/en/5.0/howto/csrf/#using-csrf-protection-with-ajax). + +## Access Tokens or JWT + +**static member** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; +``` + +```ts title="AuthdEndpoint" {7,15,22} +import { RestEndpoint } from '@data-client/rest'; +import { login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare static accessToken?: string; + + getHeaders(headers: HeadersInit) { + // TypeScript doesn't infer properly + const EP = this.constructor as typeof AuthdEndpoint; + if (!EP.accessToken) return headers; + return { + ...headers, + 'Access-Token': EP.accessToken, + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + AuthdEndpoint.accessToken = accessToken; +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**async function** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +// imagine this used an async API like indexedDB +export const getAuthToken = async () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': await getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +**function singleton** + +```ts title="login" +export const login = async (data: FormData) => + ( + await fetch('/login', { method: 'POST', body: data }) + ).json() as Promise<{ + accessToken: string; + }>; + +let token = ''; +export const getAuthToken = () => token; +export const setAuthToken = (accessToken: string) => { + token = accessToken; +}; +``` + +```ts title="AuthdEndpoint" {10,17} +import { RestEndpoint } from '@data-client/rest'; +import { getAuthToken, setAuthToken, login } from './login'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getHeaders(headers: HeadersInit) { + return { + ...headers, + 'Access-Token': getAuthToken(), + }; + } +} + +export const handleLogin = async e => { + const { accessToken } = await login(new FormData(e.target)); + setAuthToken(accessToken); +}; +``` + +```tsx title="Auth" +import { handleLogin } from './AuthdEndpoint'; + +export default function Auth() { + return ; +} +``` + +```ts title="MyResource" +import { resource, Entity } from '@data-client/rest'; +import AuthdEndpoint from './AuthdEndpoint'; + +class MyEntity extends Entity { + id = ''; + title = ''; + pk() { + return this.id; + } +} + +export const MyResource = resource({ + path: '/my/:id', + schema: MyEntity, + Endpoint: AuthdEndpoint, +}); +``` + +```ts title="Usage" column +import { MyResource } from './MyResource'; +MyResource.get({ id: 1 }); +``` + +## Auth Headers from React Context + +> **Warning** +> +> Using React Context for state that is not displayed (like auth tokens) is not recommended. +> This will result in unnecessary re-renders and application complexity. + +**Resource** + +We can transform any [Resource](./resource.md) into one that uses hooks to create endpoints +by using [hookifyResource](./hookifyResource.md) + +```ts title="resources/Post.ts" +import { resource, hookifyResource } from '@data-client/rest'; + +// Post defined here + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = useAuthContext(); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as hooks in our React Components + +```tsx +import { useSuspense } from '@data-client/react'; +import { PostResource } from 'resources/Post'; + +function PostDetail({ id }) { + const post = useSuspense(PostResource.useGet(), { id }); + return
{post.title}
; +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur during a function render. +> +> ```tsx +> function CreatePost() { +> const controller = useController(); +> const createPost = PostResource.useCreate(); +> +> return ( +>
onSubmit={e => controller.fetch(createPost, new FormData(e.target))} +> > +> {/* ... */} +>
+> ); +> } +> ``` + +**RestEndpoint** + +We will first provide an easy way of using the context to alter the fetch headers. + +```ts title="api/AuthdEndpoint.ts" +import { RestEndpoint } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + declare accessToken?: string; + + getHeaders(headers: HeadersInit): HeadersInit { + return { + ...headers, + 'Access-Token': this.accessToken, + }; + } +} +``` + +Next we will [extend](./RestEndpoint.md#extend) to generate a new endpoint with this context injected. + +```tsx +function useEndpoint(endpoint: RestEndpoint) { + const accessToken = useAuthContext(); + return useMemo( + () => endpoint.extend({ accessToken }), + [endpoint, accessToken], + ); +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur during a function render. +> +> ```tsx +> function CreatePost() { +> const controller = useController(); +> const createPost = useEndpoint(PostResource.create); +> +> return ( +>
onSubmit={e => +> controller.fetch(createPost, {}, new FormData(e.target)) +> } +> > +> {/* ... */} +>
+> ); +> } +> ``` + +## Code organization + +If much of your `Resources` share a similar auth mechanism, you might +try extending from a base class that defines such common customizations. + +## 401 Logout Handling + +In case a users authorization expires, the server will typically responsd to indicate +as such. The standard way of doing this is with a 401. [LogoutManager](https://dataclient.io/docs/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-rest/references/data-dependency.md b/.agents/skills/data-client-rest/references/data-dependency.md deleted file mode 120000 index 1613833f6d57..000000000000 --- a/.agents/skills/data-client-rest/references/data-dependency.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/getting-started/data-dependency.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/data-dependency.md b/.agents/skills/data-client-rest/references/data-dependency.md new file mode 100644 index 000000000000..22e8a1bd1370 --- /dev/null +++ b/.agents/skills/data-client-rest/references/data-dependency.md @@ -0,0 +1,426 @@ + + +# Rendering Asynchronous Data + +Make your components reusable by binding the data where you **use** it with the one-line [useSuspense()](https://dataclient.io/docs/api/useSuspense), +which guarantees data like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'page', +}); +``` + +```tsx title="PostDetail" {5} +import { useSuspense } from '@data-client/react'; +import { PostResource } from './Resources'; + +export default function PostDetail({ setRoute, id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+
+
+ + {post.author.name} +
+

{post.title}

+
+
+

{post.body}

+ { + e.preventDefault(); + setRoute('list'); + }} + > + « Back + +
+ ); +} +``` + +```tsx title="PostItem" +import { type Post } from './Resources'; + +export default function PostItem({ post, setRoute }: Props) { + return ( + + ); +} + +interface Props { + post: Post; + setRoute: Function; +} +``` + +```tsx title="PostList" {6} +import { useSuspense } from '@data-client/react'; +import PostItem from './PostItem'; +import { PostResource } from './Resources'; + +export default function PostList({ setRoute }) { + const posts = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( + + ))} +
+ ); +} +``` + +```tsx title="Navigation" +import { useController, useLoading } from '@data-client/react'; +import { PostResource } from './Resources'; +import PostList from './PostList'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [route, setRoute] = React.useState('list'); + if (route.startsWith('detail')) + return ; + + return ( + <> + + + + ); +} + +function LoadMore() { + const ctrl = useController(); + const posts = useQuery(PostResource.getList.schema); + const [nextPage, isPending] = useLoading(() => + ctrl.fetch(PostResource.getList.getPage, { page: 2 }), + ); + if (!posts || posts.length % 3 !== 0) return null; + return ( +
+ +
+ ); +} +render(); +``` + +[](https://react.dev/learn/passing-data-deeply-with-context) + +Do not [prop drill](https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props). Instead, [useSuspense()](https://dataclient.io/docs/api/useSuspense) in the components that render the data from it. This is +known as _data co-location_. + +Do not hide data binding hooks inside custom hooks. Instead, put tightly coupled data transformations +in [Query](https://dataclient.io/rest/api/Query) — data logic belongs with the data model, where it stays visible, reusable, +and free to change independently of the view. + +Instead of writing complex update functions or invalidations cascades, Reactive Data Client automatically updates +bound components immediately upon [data change](./mutations.md). This is known as _reactive programming_. + +## Loading and Error {#async-fallbacks} + +You might have noticed the return type shows the value is always there. [useSuspense()](https://dataclient.io/docs/api/useSuspense) operates very much +like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables +us to make error/loading disjoint from data usage. + +### Async Boundaries {#boundaries} + +Instead we place [\](https://dataclient.io/docs/api/AsyncBoundary) to handling loading and error conditions at or above navigational boundaries like **pages, +routes, or [modals](https://www.appcues.com/blog/modal-dialog-windows)**. + +**React Router** + +```tsx {9,11} title="Dashboard.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Outlet } from 'react-router'; + +export default function Dashboard() { + return ( +
+

Dashboard

+
+ + + +
+
+ ); +} +``` + +**NextJS** + +```tsx {12} title="app/dashboard/layout.tsx" +import { AsyncBoundary } from '@data-client/react'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+

Dashboard

+
+ {children} +
+
+ ); +} +``` + +**Expo** + +```tsx {15,17} title="app/dashboard/_layout.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Slot } from 'expo-router'; + +export default function DashboardLayout() { + return ( + + } + > + + + + + ); +} +``` + +**Antd Modal** + +```tsx title="ModalOpen.tsx" +import { AsyncBoundary } from '@data-client/react'; +import { Button, Modal } from 'antd'; + +export default function ModalOpen() { + return ( + <> + + + + + + + + ); +} +``` + +React 18's [useTransition](https://react.dev/reference/react/useTransition) and [Server Side Rendering](https://dataclient.io/docs/guides/ssr) +powered routers or navigation means never seeing a loading fallback again. In React 16 and 17 fallbacks can be centralized +to eliminate redundant loading indicators while keeping components reusable. + +[\](https://dataclient.io/docs/api/AsyncBoundary) also allows [Server Side Rendering](https://dataclient.io/docs/guides/ssr) to incrementally stream HTML, +greatly reducing [TTFB](https://web.dev/ttfb/). [Reactive Data Client SSR's](https://dataclient.io/docs/guides/ssr) automatic store hydration +means immediate user interactivity with **zero** client-side fetches on first load. + +AsyncBoundary's [error fallback](https://dataclient.io/docs/api/AsyncBoundary#errorcomponent) and [loading fallback](https://dataclient.io/docs/api/AsyncBoundary#fallback) can both +be customized. + +### Stateful + +You may find cases where it's still useful to use a stateful approach to fallbacks when using React 16 and 17. +For these cases, or compatibility with some component libraries, [useDLE()](https://dataclient.io/docs/api/useDLE) - \[D]ata \[L]oading \[E]rror - is provided. + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```tsx title="ProfileList" +import { useDLE } from '@data-client/react'; +import { ProfileResource } from './ProfileResource'; + +function ProfileList(): JSX.Element { + const { data, loading, error } = useDLE(ProfileResource.getList); + if (error) return
Error {`${error.status}`}
; + if (loading || !data) return ; + return ( +
+ {data.map(profile => ( +
+ +
+

{profile.fullName}

+

{profile.bio}

+
+
+ ))} +
+ ); +} +render(); +``` + +Since [useDLE](https://dataclient.io/docs/api/useDLE) does not [useSuspense](https://dataclient.io/docs/api/useSuspense), you won't be able to easily centrally +orchestrate loading and error code. Additionally, React 18 features like [useTransition](https://react.dev/reference/react/useTransition), +and [incrementally streaming SSR](https://dataclient.io/docs/guides/ssr) won't work with components that use it. + +## Conditional + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = useSuspense(TodoResource.get, id ? { id } : null); +> ``` + +## Subscriptions + +When data is likely to change due to external factor; [useSubscription()](https://dataclient.io/docs/api/useSubscription) +ensures continual updates while a component is mounted. [useLive()](https://dataclient.io/docs/api/useLive) calls both +[useSubscription()](https://dataclient.io/docs/api/useSubscription) and [useSuspense()](https://dataclient.io/docs/api/useSuspense), making it quite +easy to use fresh data. + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```tsx title="AssetPrice" {5} +import { useLive } from '@data-client/react'; +import { getTicker } from './Ticker'; + +function AssetPrice({ productId }: Props) { + const ticker = useLive(getTicker, { productId }); + return ( +
+ {productId}{' '} + +
+ ); +} +interface Props { + productId: string; +} +render(); +``` + +Subscriptions are orchestrated by [Managers](https://dataclient.io/docs/api/Manager). Out of the box, +polling based subscriptions can be used by adding [pollFrequency](https://dataclient.io/rest/api/Endpoint#pollfrequency) to an Endpoint or Resource. +For pushed based networking protocols like SSE and websockets, see the [example stream manager](https://dataclient.io/docs/concepts/managers#data-stream). + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + pollFrequency: 2000, +}); +``` diff --git a/.agents/skills/data-client-rest/references/data-dependency.vue.md b/.agents/skills/data-client-rest/references/data-dependency.vue.md new file mode 100644 index 000000000000..d2d69dbad63e --- /dev/null +++ b/.agents/skills/data-client-rest/references/data-dependency.vue.md @@ -0,0 +1,364 @@ + + +# Rendering Asynchronous Data + +Make your components reusable by binding the data where you **use** it with the one-line [useSuspense()](https://dataclient.io/vue/api/useSuspense), +which guarantees data with [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). + +```ts title="Resources" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'page', +}); +``` + +```html title="PostDetail.vue" {7} + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="PostList.vue" {7} + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="Navigation.vue" + + + +``` + +[](https://react.dev/learn/passing-data-deeply-with-context) + +Do not [prop drill](https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props). Instead, [useSuspense()](https://dataclient.io/vue/api/useSuspense) in the components that render the data from it. This is +known as _data co-location_. + +Do not hide data binding hooks inside custom hooks. Instead, put tightly coupled data transformations +in [Query](https://dataclient.io/rest/api/Query) — data logic belongs with the data model, where it stays visible, reusable, +and free to change independently of the view. + +Instead of writing complex update functions or invalidations cascades, Reactive Data Client automatically updates +bound components immediately upon [data change](./mutations.vue.md). This is known as _reactive programming_. + +## Loading and Error {#async-fallbacks} + +You might have noticed the return type shows the value is always there. [useSuspense()](https://dataclient.io/vue/api/useSuspense) operates very much +with [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables +us to make error/loading disjoint from data usage. + +### Async Boundaries {#boundaries} + +Instead we place Vue's built-in [\](https://vuejs.org/guide/built-ins/suspense.html) along with [onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured) to handling loading and error conditions at or above navigational boundaries like **pages, +routes, or [modals](https://www.appcues.com/blog/modal-dialog-windows)**. + +```html title="Dashboard.vue" {13-20} + + + +``` + +Centralizing fallbacks this way eliminates redundant loading indicators while keeping components reusable. +The loading fallback is customized with the `#fallback` slot of [\](https://vuejs.org/guide/built-ins/suspense.html#loading-state), +and the error fallback by rendering what you choose from [onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured). + +### Stateful + +You may find cases where it's still useful to use a stateful approach to fallbacks. +For these cases, or compatibility with some component libraries, [useDLE()](https://dataclient.io/vue/api/useDLE) - \[D]ata \[L]oading \[E]rror - is provided. + +```typescript title="ProfileResource" +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileList.vue" {5} + + + +``` + +Since [useDLE](https://dataclient.io/vue/api/useDLE) does not [useSuspense](https://dataclient.io/vue/api/useSuspense), you won't be able to easily centrally +orchestrate loading and error code. + +## Conditional + +> **Tip: Conditional Dependencies** +> +> Use `null` as the second argument to any Data Client hook means "do nothing." +> +> ```typescript +> // todo could be undefined if id is undefined +> const todo = await useSuspense( +> TodoResource.get, +> computed(() => (id.value ? { id: id.value } : null)), +> ); +> ``` + +## Subscriptions + +When data is likely to change due to external factor; [useSubscription()](https://dataclient.io/vue/api/useSubscription) +ensures continual updates while a component is mounted. [useLive()](https://dataclient.io/vue/api/useLive) calls both +[useSubscription()](https://dataclient.io/vue/api/useSubscription) and [useSuspense()](https://dataclient.io/vue/api/useSuspense), making it quite +easy to use fresh data. + +```typescript title="Ticker" {32} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```html title="AssetPrice.vue" {6} + + + +``` + +Subscriptions are orchestrated by [Managers](https://dataclient.io/vue/api/Manager). Out of the box, +polling based subscriptions can be used by adding [pollFrequency](https://dataclient.io/rest/api/Endpoint#pollfrequency) to an Endpoint or Resource. +For pushed based networking protocols like SSE and websockets, see the [example stream manager](https://dataclient.io/vue/concepts/managers#data-stream). + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + pollFrequency: 2000, +}); +``` diff --git a/.agents/skills/data-client-rest/references/error-policy.md b/.agents/skills/data-client-rest/references/error-policy.md deleted file mode 120000 index 360fc55c2165..000000000000 --- a/.agents/skills/data-client-rest/references/error-policy.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/concepts/error-policy.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/error-policy.md b/.agents/skills/data-client-rest/references/error-policy.md new file mode 100644 index 000000000000..09f04c75e261 --- /dev/null +++ b/.agents/skills/data-client-rest/references/error-policy.md @@ -0,0 +1,146 @@ + + +# Endpoint Error Policy + +[Endpoint.errorPolicy](https://dataclient.io/rest/api/Endpoint#errorpolicy) controls cache behavior upon a fetch rejection. +It uses the rejection error to determine whether it should be treated as 'soft' or 'hard' error. + +### Soft + +Soft errors will continue showing valid data if it exists. However, if no previous data is in the store, +it will reject with `error`. In this case [useSuspense()](https://dataclient.io/docs/api/useSuspense) throws the +error to be caught by the nearest [ErrorBoundary](https://dataclient.io/docs/api/ErrorBoundary) or [AsyncBoundary](https://dataclient.io/docs/api/AsyncBoundary) + +### Hard + +Hard errors always reject with `error` - even when data has previously made available. + +'hard' | `undefined` can both be used to indicate this state. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```ts title="getUpdated" +import { lastUpdated } from './api/lastUpdated'; + +export const getUpdated = lastUpdated.extend({ + fetch(this: any, arg) { + // fail once with FAKE_ERROR when it is set + const error = this.FAKE_ERROR; + this.FAKE_ERROR = undefined; + return error ? Promise.reject(error) : lastUpdated(arg); + }, + errorPolicy: error => + error.status >= 500 ? ('soft' as const) : ('hard' as const), + FAKE_ERROR: undefined as Error | undefined, +}); + +export const createError = (status: number) => + Object.assign(new Error('fake error'), { status }); +``` + +```tsx title="TimePage" +import { getUpdated } from './getUpdated'; + +export default function TimePage({ id }) { + const { updatedAt } = useSuspense(getUpdated, { id }); + return ( +
+ API time:{' '} + +
+ ); +} +``` + +```tsx title="ShowTime" +import { getUpdated, createError } from './getUpdated'; +import TimePage from './TimePage'; + +function ShowTime() { + const ctrl = useController(); + return ( +
+ loading...
}> + +
+
+ + + + +
+ + ); +} + +render( + + + , +); +``` + +### Policy for RestEndpoint + +Since `500`s indicate a failure of the server, we want to use stale data +if it exists. On the other hand, something like a `4xx` indicates 'user error', which +means the error indicates something about application flow - like if a record is deleted, resulting +in `404`. Keeping the record around would be inaccurate. + +Since this is the typical behavior for REST APIs, this is the default policy in [@data-client/rest](https://www.npmjs.com/package/@data-client/rest) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +`undefined` is another way of specifying a [hard error](#hard) diff --git a/.agents/skills/data-client-rest/references/error-policy.vue.md b/.agents/skills/data-client-rest/references/error-policy.vue.md new file mode 100644 index 000000000000..413bbcfe0db8 --- /dev/null +++ b/.agents/skills/data-client-rest/references/error-policy.vue.md @@ -0,0 +1,129 @@ + + +# Endpoint Error Policy + +[Endpoint.errorPolicy](https://dataclient.io/rest/api/Endpoint#errorpolicy) controls cache behavior upon a fetch rejection. +It uses the rejection error to determine whether it should be treated as 'soft' or 'hard' error. + +### Soft + +Soft errors will continue showing valid data if it exists. However, if no previous data is in the store, +it will reject with `error`. In this case [useSuspense()](https://dataclient.io/vue/api/useSuspense) throws the +error to be caught by the nearest [onErrorCaptured()](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured) + +### Hard + +Hard errors always reject with `error` - even when data has previously made available. + +'hard' | `undefined` can both be used to indicate this state. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```ts title="getUpdated" +import { lastUpdated } from './api/lastUpdated'; + +export const getUpdated = lastUpdated.extend({ + fetch(this: any, arg) { + // fail once with FAKE_ERROR when it is set + const error = this.FAKE_ERROR; + this.FAKE_ERROR = undefined; + return error ? Promise.reject(error) : lastUpdated(arg); + }, + errorPolicy: error => + error.status >= 500 ? ('soft' as const) : ('hard' as const), + FAKE_ERROR: undefined as Error | undefined, +}); + +export const createError = (status: number) => + Object.assign(new Error('fake error'), { status }); +``` + +```html title="TimePage.vue" + + + +``` + +```html title="ShowTime.vue" + + + +``` + +### Policy for RestEndpoint + +Since `500`s indicate a failure of the server, we want to use stale data +if it exists. On the other hand, something like a `4xx` indicates 'user error', which +means the error indicates something about application flow - like if a record is deleted, resulting +in `404`. Keeping the record around would be inaccurate. + +Since this is the typical behavior for REST APIs, this is the default policy in [@data-client/rest](https://www.npmjs.com/package/@data-client/rest) + +```ts +errorPolicy(error) { + return error.status >= 500 ? 'soft' : undefined; +} +``` + +`undefined` is another way of specifying a [hard error](#hard) diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md deleted file mode 120000 index e55b95037158..000000000000 --- a/.agents/skills/data-client-rest/references/expiry-policy.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/concepts/expiry-policy.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/expiry-policy.md b/.agents/skills/data-client-rest/references/expiry-policy.md new file mode 100644 index 000000000000..0ed3ee46bf31 --- /dev/null +++ b/.agents/skills/data-client-rest/references/expiry-policy.md @@ -0,0 +1,585 @@ + + +# Endpoint Expiry Policy + +By default, Reactive Data Client cache policy can be described as [stale-while-revalidate](https://web.dev/stale-while-revalidate/). +This means that when data is available it can avoid blocking the application by using the stale data. However, in the background +it will still refresh the data if old enough. + +## Expiry status + +### Fresh + +Data in this state is considered new enough that it doesn't need to fetch. + +### Stale + +Data is still allowed to be shown, however Reactive Data Client might attempt to revalidate by fetching again. + +[useSuspense()](https://dataclient.io/docs/api/useSuspense) considers fetching on mount as well as when its parameters change. +In these cases it will fetch if the data is considered stale. + +> **Info: React Native** +> +> When using React Navigation, [focus events](https://reactnavigation.org/docs/use-focus-effect/) also trigger fetches for stale data. + +### Invalid + +Data should not be shown. Any components needing this data will trigger fetch and suspense. If +no components care about this data no action will be taken. + +## Expiry Time + +### Endpoint.dataExpiryLength + +[Endpoint.dataExpiryLength](https://dataclient.io/rest/api/Endpoint#dataexpirylength) sets how long (in miliseconds) it takes for data +to transition from '[fresh](#fresh)' to '[stale](#stale)' status. Try setting it to a very low number like '50' +to make it becomes [stale](#stale) almost instantly; or a very large number to stay around for a long time. + +Toggling between 'first' and 'second' changes the parameters. If the data is still considered fresh +you will continue to see the old time without any refresh. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```ts title="getUpdated" +import { lastUpdated } from './api/lastUpdated'; + +export const getUpdated = lastUpdated.extend({ dataExpiryLength: 10000 }); +``` + +```tsx title="TimePage" +import { getUpdated } from './getUpdated'; + +export default function TimePage({ id }) { + const { updatedAt } = useSuspense(getUpdated, { id }); + return ( +
+ API time for {id}:{' '} + +
+ ); +} +``` + +```tsx title="Navigator" +import TimePage from './TimePage'; + +function Navigator() { + const [id, setId] = React.useState('1'); + const handleChange = e => setId(e.currentTarget.value); + return ( +
+
+ + +
+ loading...
}> + +
+
+ Current Time: +
+ + ); +} +render(); +``` + +
+ +@data-client/rest + +Long cache lifetime + +```typescript title="LongLivingResource.ts" +import { + RestEndpoint, + RestGenerics, + resource, +} from '@data-client/rest'; + +// We can now use LongLivingEndpoint to create endpoints that will be cached for one hour +class LongLivingEndpoint< + O extends RestGenerics, +> extends RestEndpoint { + dataExpiryLength = 60 * 60 * 1000; // one hour +} + +const LongLivingResource = resource({ + path: '/:id', + Endpoint: LongLivingEndpoint, +}); +``` + +Never retry on error + +```typescript title="NoRetryResource.ts" +import { + RestEndpoint, + RestGenerics, + resource, +} from '@data-client/rest'; + +// We can now use NoRetryEndpoint to create endpoints that will be cached for one hour +class NoRetryEndpoint< + O extends RestGenerics, +> extends RestEndpoint { + errorExpiryLength = Infinity; +} + +const NoRetryResource = resource({ + path: '/:id', + Endpoint: NoRetryEndpoint, +}); +``` + +
+ +### Endpoint.invalidIfStale + +[Endpoint.invalidIfStale](https://dataclient.io/rest/api/Endpoint#invalidifstale) eliminates the '[stale](#stale)' status, making data +that expires immediately be considered '[invalid](#invalid)'. + +This is demonstrated by the component suspending once its data goes stale. If the data is still +within the expiry time it just continues to display it. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```ts title="getUpdated" +import { lastUpdated } from './api/lastUpdated'; + +export const getUpdated = lastUpdated.extend({ + invalidIfStale: true, + dataExpiryLength: 5000, +}); +``` + +```tsx title="TimePage" +import { getUpdated } from './getUpdated'; + +export default function TimePage({ id }) { + const { updatedAt } = useSuspense(getUpdated, { id }); + return ( +
+ API time for {id}:{' '} + +
+ ); +} +``` + +```tsx title="Navigator" +import TimePage from './TimePage'; + +function Navigator() { + const [id, setId] = React.useState('1'); + const handleChange = e => setId(e.currentTarget.value); + return ( +
+
+ + +
+ loading...
}> + +
+
+ Current Time: +
+ + ); +} +render(); +``` + +## Force refresh + +We sometimes want to fetch new data; while continuing to show the old (stale) data. + +### A specific endpoint + +[Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) can be used to trigger a fetch while still showing +the previous data. This can be done even with 'fresh' data. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```tsx title="ShowTime" +import { lastUpdated } from './api/lastUpdated'; + +function ShowTime() { + const { updatedAt } = useSuspense(lastUpdated, { id: '1' }); + const ctrl = useController(); + return ( +
+ {' '} + +
+ ); +} +render(); +``` + +### Refresh visible endpoints + +[Controller.expireAll()](https://dataclient.io/docs/api/Controller#expireAll) sets all responses' [expiry status](#expiry-status) matching `testKey` to [Stale](#stale). + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```tsx title="ShowTime" +import { lastUpdated } from './api/lastUpdated'; + +export default function ShowTime({ id }: { id: string }) { + const { updatedAt } = useSuspense(lastUpdated, { id }); + const ctrl = useController(); + return ( +
+ {id}{' '} + +
+ ); +} +``` + +```tsx title="Loading" +export default function Loading({ id }: { id: string }) { + return
{id} Loading...
; +} +``` + +```tsx title="Demo" +import { AsyncBoundary } from '@data-client/react'; + +import { lastUpdated } from './api/lastUpdated'; +import ShowTime from './ShowTime'; +import Loading from './Loading'; + +function Demo() { + const ctrl = useController(); + return ( +
+ }> + + + }> + + + }> + + + + + +
+ ); +} +render(); +``` + +## Invalidate (re-suspend) {#invalidate} + +Both [endpoints](https://dataclient.io/rest/api/Endpoint) and [entities](./Entity.md) can be targetted to be invalidated. + +### A specific endpoint {#invalidate-endpoint} + +In this example [invalidating the endpoint](https://dataclient.io/docs/api/Controller#invalidate) shows the loading fallback since the data is not allowed to be displayed. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```tsx title="ShowTime" +import { lastUpdated } from './api/lastUpdated'; + +export default function ShowTime({ id }: { id: string }) { + const { updatedAt } = useSuspense(lastUpdated, { id }); + const ctrl = useController(); + return ( +
+ {id}{' '} + +
+ ); +} +``` + +```tsx title="Loading" +export default function Loading({ id }: { id: string }) { + return
{id} Loading...
; +} +``` + +```tsx title="Demo" +import { AsyncBoundary } from '@data-client/react'; + +import { lastUpdated } from './api/lastUpdated'; +import ShowTime from './ShowTime'; +import Loading from './Loading'; + +function Demo() { + const ctrl = useController(); + return ( +
+ }> + + + }> + + + }> + + + + + +
+ ); +} +render(); +``` + +### Any endpoint with an entity {#invalidate-entity} + +Using the [Invalidate schema](https://dataclient.io/rest/api/Invalidate) allows us to invalidate _any_ endpoint that includes that relies on that [entity](./Entity.md) in their +response. If the endpoint uses the entity in an [Array](https://dataclient.io/rest/api/Array), it will simply be removed from that [Array](https://dataclient.io/rest/api/Array). + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```tsx title="TimePage" +import { lastUpdated } from './api/lastUpdated'; + +export default function TimePage({ id }) { + const { updatedAt } = useSuspense(lastUpdated, { id }); + return ( +
+ API time for {id}:{' '} + +
+ ); +} +``` + +```tsx title="ShowTime" +import { Invalidate } from '@data-client/rest'; +import { useLoading } from '@data-client/react'; +import { TimedEntity } from './api/lastUpdated'; +import TimePage from './TimePage'; + +const InvalidateTimedEntity = new Invalidate(TimedEntity); +export const deleteLastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + method: 'DELETE', + schema: InvalidateTimedEntity, +}); + +function ShowTime() { + const ctrl = useController(); + const [handleDelete, loadingDelete] = useLoading( + () => ctrl.fetch(deleteLastUpdated, { id: '1' }), + [], + ); + return ( +
+ loading...
}> + +
+
+ Current Time: +
+ + + + + ); +} +render(); +``` + +[Controller.fetch()](https://dataclient.io/docs/api/Controller#fetch) lets us update the server and store. +We can use [Controller.setResponse()](https://dataclient.io/docs/api/Controller#setResponse) or [Controller.set()](https://dataclient.io/docs/api/Controller#set) +when we want to change the local store directly. + +#### Conditional Invalidation based on data + +If `invalidation` should happen only sometimes, based on the response data, we can +return `undefined` from [Entity.process](./Entity.md#process). + +```ts +class PriceLevel extends Entity { + price = 0; + amount = 0; + + pk() { + return this.price; + } + + static process( + input: [number, number], + parent: any, + key: string | undefined, + ): any { + const [price, amount] = input; + if (amount === 0) return undefined; + return { price, amount }; + } +} +``` diff --git a/.agents/skills/data-client-rest/references/expiry-policy.vue.md b/.agents/skills/data-client-rest/references/expiry-policy.vue.md new file mode 100644 index 000000000000..8d608580b39a --- /dev/null +++ b/.agents/skills/data-client-rest/references/expiry-policy.vue.md @@ -0,0 +1,513 @@ + + +# Endpoint Expiry Policy + +By default, Reactive Data Client cache policy can be described as [stale-while-revalidate](https://web.dev/stale-while-revalidate/). +This means that when data is available it can avoid blocking the application by using the stale data. However, in the background +it will still refresh the data if old enough. + +## Expiry status + +### Fresh + +Data in this state is considered new enough that it doesn't need to fetch. + +### Stale + +Data is still allowed to be shown, however Reactive Data Client might attempt to revalidate by fetching again. + +[useSuspense()](https://dataclient.io/vue/api/useSuspense) considers fetching on mount as well as when its parameters change. +In these cases it will fetch if the data is considered stale. + +### Invalid + +Data should not be shown. Any components needing this data will trigger fetch and suspense. If +no components care about this data no action will be taken. + +## Expiry Time + +### Endpoint.dataExpiryLength + +[Endpoint.dataExpiryLength](https://dataclient.io/rest/api/Endpoint#dataexpirylength) sets how long (in miliseconds) it takes for data +to transition from '[fresh](#fresh)' to '[stale](#stale)' status. Try setting it to a very low number like '50' +to make it becomes [stale](#stale) almost instantly; or a very large number to stay around for a long time. + +Toggling between 'first' and 'second' changes the parameters. If the data is still considered fresh +you will continue to see the old time without any refresh. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```ts title="getUpdated" +import { lastUpdated } from './api/lastUpdated'; + +export const getUpdated = lastUpdated.extend({ dataExpiryLength: 10000 }); +``` + +```html title="TimePage.vue" + + + +``` + +```html title="Navigator.vue" + + + +``` + +
+ +@data-client/rest + +Long cache lifetime + +```typescript title="LongLivingResource.ts" +import { + RestEndpoint, + RestGenerics, + resource, +} from '@data-client/rest'; + +// We can now use LongLivingEndpoint to create endpoints that will be cached for one hour +class LongLivingEndpoint< + O extends RestGenerics, +> extends RestEndpoint { + dataExpiryLength = 60 * 60 * 1000; // one hour +} + +const LongLivingResource = resource({ + path: '/:id', + Endpoint: LongLivingEndpoint, +}); +``` + +Never retry on error + +```typescript title="NoRetryResource.ts" +import { + RestEndpoint, + RestGenerics, + resource, +} from '@data-client/rest'; + +// We can now use NoRetryEndpoint to create endpoints that will be cached for one hour +class NoRetryEndpoint< + O extends RestGenerics, +> extends RestEndpoint { + errorExpiryLength = Infinity; +} + +const NoRetryResource = resource({ + path: '/:id', + Endpoint: NoRetryEndpoint, +}); +``` + +
+ +### Endpoint.invalidIfStale + +[Endpoint.invalidIfStale](https://dataclient.io/rest/api/Endpoint#invalidifstale) eliminates the '[stale](#stale)' status, making data +that expires immediately be considered '[invalid](#invalid)'. + +This is demonstrated by the component suspending once its data goes stale. If the data is still +within the expiry time it just continues to display it. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```ts title="getUpdated" +import { lastUpdated } from './api/lastUpdated'; + +export const getUpdated = lastUpdated.extend({ + invalidIfStale: true, + dataExpiryLength: 5000, +}); +``` + +```html title="TimePage.vue" + + + +``` + +```html title="Navigator.vue" + + + +``` + +## Force refresh + +We sometimes want to fetch new data; while continuing to show the old (stale) data. + +### A specific endpoint + +[Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) can be used to trigger a fetch while still showing +the previous data. This can be done even with 'fresh' data. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```html title="ShowTime.vue" + + + +``` + +### Refresh visible endpoints + +[Controller.expireAll()](https://dataclient.io/vue/api/Controller#expireAll) sets all responses' [expiry status](#expiry-status) matching `testKey` to [Stale](#stale). + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```html title="ShowTime.vue" + + + +``` + +```html title="Demo.vue" + + + +``` + +## Invalidate {#invalidate} + +Both [endpoints](https://dataclient.io/rest/api/Endpoint) and [entities](./Entity.md) can be targetted to be invalidated. + +Invalidated data always refetches, even when it is fresh. Vue can't suspend a component again once its +setup has run, so mounted components keep showing their previous data until the refetch resolves. +Meanwhile [useCache()](https://dataclient.io/vue/api/useCache) returns `undefined` and [useDLE()](https://dataclient.io/vue/api/useDLE)'s `loading` +is `true`. Components mounted after invalidation suspend until the new data arrives. + +### A specific endpoint {#invalidate-endpoint} + +In this example [invalidating the endpoint](https://dataclient.io/vue/api/Controller#invalidate) refetches it, even though its data is still fresh. + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```html title="ShowTime.vue" + + + +``` + +```html title="Demo.vue" + + + +``` + +### Any endpoint with an entity {#invalidate-entity} + +Using the [Invalidate schema](https://dataclient.io/rest/api/Invalidate) allows us to invalidate _any_ endpoint that includes that relies on that [entity](./Entity.md) in their +response. If the endpoint uses the entity in an [Array](https://dataclient.io/rest/api/Array), it will simply be removed from that [Array](https://dataclient.io/rest/api/Array). + +```ts title="api/lastUpdated" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class TimedEntity extends Entity { + id = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + updatedAt: Temporal.Instant.from, + }; +} + +export const lastUpdated = new RestEndpoint({ + path: '/api/currentTime/:id', + schema: TimedEntity, +}); +``` + +```html title="TimePage.vue" + + + +``` + +```html title="ShowTime.vue" + + + +``` + +[Controller.fetch()](https://dataclient.io/vue/api/Controller#fetch) lets us update the server and store. +We can use [Controller.setResponse()](https://dataclient.io/vue/api/Controller#setResponse) or [Controller.set()](https://dataclient.io/vue/api/Controller#set) +when we want to change the local store directly. + +#### Conditional Invalidation based on data + +If `invalidation` should happen only sometimes, based on the response data, we can +return `undefined` from [Entity.process](./Entity.md#process). + +```ts +class PriceLevel extends Entity { + price = 0; + amount = 0; + + pk() { + return this.price; + } + + static process( + input: [number, number], + parent: any, + key: string | undefined, + ): any { + const [price, amount] = input; + if (amount === 0) return undefined; + return { price, amount }; + } +} +``` diff --git a/.agents/skills/data-client-rest/references/hookifyResource.md b/.agents/skills/data-client-rest/references/hookifyResource.md deleted file mode 120000 index 47febcf14ac2..000000000000 --- a/.agents/skills/data-client-rest/references/hookifyResource.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/hookifyResource.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/hookifyResource.md b/.agents/skills/data-client-rest/references/hookifyResource.md new file mode 100644 index 000000000000..b9f983224eab --- /dev/null +++ b/.agents/skills/data-client-rest/references/hookifyResource.md @@ -0,0 +1,219 @@ + + +# hookifyResource + +`hookifyResource()` Turns any [Resource](./resource.md) (collection of [RestEndpoints](./RestEndpoint.md)) into a collection +of hooks that return [RestEndpoints](./RestEndpoint.md). + +> **Info** +> +> TypeScript >=4.3 is required for generative types to work correctly. + +```ts title="resources/Article" +import React from 'react'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +const AuthContext = React.createContext(''); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = React.useContext(AuthContext); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```tsx title="ArticleDetail" +import { ArticleResource } from './resources/Article'; + +function ArticleDetail({ id }) { + const article = useSuspense(ArticleResource.useGet(), { id }); + const updateArticle = ArticleResource.useUpdate(); + const ctrl = useController(); + const onSubmit = (body: any) => ctrl.fetch(updateArticle, { id }, body); + + return ; +} +render(); +``` + +## Members + +Assuming you use the unchanged result of [resource()](./resource.md), these will be your methods + +### useGet() + +- method: 'GET' +- path: `path` +- schema: [schema](./Entity.md) + +```typescript +// GET //test.com/api/abc/xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGet()({ + group: 'abc', + id: 'xyz', +}); +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate) + +### useGetList() + +- method: 'GET' +- path: `shortenPath(path)` + - Removes the last `:param` or `*wildcard` token: + ```ts + hookifyResource(resource({ path: '/:first/:second' })).useGetList() + .path === '/:first'; + hookifyResource(resource({ path: '/:first' })).useGetList().path === + '/'; + hookifyResource(resource({ path: '/:owner/*path' })).useGetList() + .path === '/:owner'; + ``` +- schema: [\[schema\]](https://dataclient.io/rest/api/Array) + +```typescript +// GET //test.com/api/abc?isExtra=xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList()({ + group: 'abc', + isExtra: 'xyz', +}); +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate) + +### useGetList().push {#push} + +[push](./RestEndpoint.md#push) creates a new entity and pushes it to the end of useGetList(). + +- method: 'POST' +- path: `shortenPath(path)` +- schema: `useGetList().schema.push` + +```typescript +// POST //test.com/api/abc +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList().push({ group: 'abc' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useGetList().unshift {#unshift} + +[unshift](./RestEndpoint.md#unshift) creates a new entity and pushes it to the beginning of useGetList(). + +- method: 'POST' +- path: `shortenPath(path)` +- schema: `useGetList().schema.unshift` + +```typescript +// POST //test.com/api/abc +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useGetList().unshift({ group: 'abc' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useGetList().getPage {#getpage} + +[getPage](./RestEndpoint.md#getpage) retrieves another [page](./pagination.md#infinite-scrolling) appending to useGetList() ensuring there are no duplicates. + +- method: 'GET' +- args: `shortenPath(path) & { [paginationField]: string | number } & searchParams` +- schema: [new Collection(\[schema\]).addWith(paginatedMerge, paginatedFilter(removeCursor))](./Collection.md) + +```typescript +// GET //test.com/api/abc?isExtra=xyz&page=2 +hookifyResource( + resource({ + urlPrefix: '//test.com', + path: '/api/:group/:id', + paginationField: 'page', + }), +).useGetList().getPage({ + group: 'abc', + isExtra: 'xyz', + page: '2', +}); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useUpdate() + +- method: 'PUT' +- path: `path` +- schema: `schema` + +```typescript +// PUT //test.com/api/abc/xyz +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useUpdate()({ group: 'abc', id: 'xyz' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### usePartialUpdate() + +- method: 'PATCH' +- path: `path` +- schema: `schema` + +```typescript +// PATCH //test.com/api/abc/xyz +// BODY { "title": "winning" } +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).usePartialUpdate()({ group: 'abc', id: 'xyz' }, { title: 'winning' }); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### useDelete() + +- method: 'DELETE' +- path: `path` +- schema: [new Invalidate(schema)](https://dataclient.io/rest/api/Invalidate) +- process: + ```ts + (value, params) { + return value && Object.keys(value).length ? value : params; + }, + ``` + +```typescript +// DELETE //test.com/api/abc/xyz +hookifyResource( + resource({ urlPrefix: '//test.com', path: '/api/:group/:id' }), +).useDelete()({ + group: 'abc', + id: 'xyz', +}); +``` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) diff --git a/.agents/skills/data-client-rest/references/mutations.md b/.agents/skills/data-client-rest/references/mutations.md deleted file mode 120000 index aaa624e6eb92..000000000000 --- a/.agents/skills/data-client-rest/references/mutations.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/getting-started/mutations.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/mutations.md b/.agents/skills/data-client-rest/references/mutations.md new file mode 100644 index 000000000000..35fba03f1eca --- /dev/null +++ b/.agents/skills/data-client-rest/references/mutations.md @@ -0,0 +1,371 @@ + + +# Data mutations + +Using our [Create, Update, and Delete](https://dataclient.io/docs/concepts/atomic-mutations) endpoints with +[Controller.fetch()](https://dataclient.io/docs/api/Controller#fetch) reactively updates _all_ appropriate components atomically (at the same time). + +[useController()](https://dataclient.io/docs/api/useController) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate). + +[//]: # "TODO: Add create, and delete examples as well (in tabs)" + +```ts title="TodoResource" +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = 0; + userId = 0; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" {7-11,13-15} +import { useController } from '@data-client/react'; +import { TodoResource, type Todo } from './TodoResource'; + +export default function TodoItem({ todo }: { todo: Todo }) { + const ctrl = useController(); + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + const handleDelete = () => + ctrl.fetch(TodoResource.delete, { + id: todo.id, + }); + return ( +
+ + +
+ ); +} +``` + +```tsx title="CreateTodo" {8-11} +import { useController } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +export default function CreateTodo({ userId }: { userId: number }) { + const ctrl = useController(); + const handleKeyDown = async e => { + if (e.key === 'Enter') { + ctrl.fetch(TodoResource.getList.push, { + userId, + title: e.currentTarget.value, + }); + e.currentTarget.value = ''; + } + }; + return ( +
+ + +
+ ); +} +``` + +```tsx title="TodoList" +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; +import CreateTodo from './CreateTodo'; + +function TodoList() { + const userId = 1; + const todos = useSuspense(TodoResource.getList, { userId }); + return ( +
+ {todos.map(todo => ( + + ))} + +
+ ); +} +render(); +``` + +Rather than triggering invalidation cascades or using manually written update functions, +Data Client reactively updates appropriate components using the fetch response. + +## Optimistic mutations based on previous state {#optimistic-updates} + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```tsx title="PostItem" {7} +import { useController } from '@data-client/react'; +import { PostResource, type Post } from './PostResource'; + +export default function PostItem({ post }: Props) { + const ctrl = useController(); + const handleVote = () => { + ctrl.fetch(PostResource.vote, { id: post.id }); + }; + return ( +
+
+ + + {post.votes} + + +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +interface Props { + post: Post; +} +``` + +```tsx title="TotalVotes" {11} +import { Query } from '@data-client/rest'; +import { useQuery } from '@data-client/react'; +import { PostResource } from './PostResource'; + +const queryTotalVotes = new Query( + PostResource.getList.schema, + posts => posts.reduce((total, post) => total + post.votes, 0), +); + +export default function TotalVotes({ userId }: Props) { + const totalVotes = useQuery(queryTotalVotes, { userId }); + return ( +
+ {totalVotes} votes total +
+ ); +} +interface Props { + userId: number; +} +``` + +```tsx title="PostList" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostItem from './PostItem'; +import TotalVotes from './TotalVotes'; + +function PostList() { + const userId = 2; + const posts = useSuspense(PostResource.getList, { userId }); + return ( +
+ {posts.map(post => ( + + ))} + +
+ ); +} +render(); +``` + +[getOptimisticResponse](./optimistic-updates.md) is just like [setState with an updater function](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). [Snapshot](https://dataclient.io/docs/api/Snapshot) provides typesafe access to the previous store value, +which we use to return the _expected_ fetch response. + +Reactive Data Client ensures [data integrity against any possible networking failure or race condition](./optimistic-updates.md#optimistic-transforms), so don't +worry about network failures, multiple mutation calls editing the same data, or other common +problems in asynchronous programming. + +## Tracking mutation loading + +[useLoading()](https://dataclient.io/docs/api/useLoading) enhances async functions by tracking their loading and error states. + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```tsx title="PostDetail" +import { useSuspense } from '@data-client/react'; +import { PostResource } from './PostResource'; + +export default function PostDetail({ id }) { + const post = useSuspense(PostResource.get, { id }); + return ( +
+
+ +
+
+

{post.title}

+

{post.body}

+
+
+ ); +} +``` + +```tsx title="PostForm" +export default function PostForm({ onSubmit, loading, error }) { + const handleSubmit = e => { + e.preventDefault(); + const data = new FormData(e.target); + onSubmit(data); + }; + return ( +
+ + + {error ? ( +
{error.message}
+ ) : null} +
+ +
+ + ); +} +``` + +```tsx title="PostCreate" {7} +import { useLoading, useController } from '@data-client/react'; +import { PostResource } from './PostResource'; +import PostForm from './PostForm'; + +export default function PostCreate({ navigateToPost }) { + const ctrl = useController(); + const [handleSubmit, loading, error] = useLoading( + async data => { + const post = await ctrl.fetch(PostResource.getList.push, data); + navigateToPost(post.id); + }, + [ctrl], + ); + return ( + + ); +} +``` + +```tsx title="Navigation" +import PostCreate from './PostCreate'; +import PostDetail from './PostDetail'; + +function Navigation() { + const [id, setId] = React.useState(undefined); + if (id) { + return ( +
+ +
+ +
+
+ ); + } + return ; +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/mutations.vue.md b/.agents/skills/data-client-rest/references/mutations.vue.md new file mode 100644 index 000000000000..895ade51a74f --- /dev/null +++ b/.agents/skills/data-client-rest/references/mutations.vue.md @@ -0,0 +1,376 @@ + + +# Data mutations + +Using our [Create, Update, and Delete](https://dataclient.io/vue/concepts/atomic-mutations) endpoints with +[Controller.fetch()](https://dataclient.io/vue/api/Controller#fetch) reactively updates _all_ appropriate components atomically (at the same time). + +[useController()](https://dataclient.io/vue/api/useController) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate). + +[//]: # "TODO: Add create, and delete examples as well (in tabs)" + +```ts title="TodoResource" +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = 0; + userId = 0; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```html title="TodoItem.vue" {8-12,14-16} + + + +``` + +```html title="CreateTodo.vue" {10-13} + + + +``` + +```html title="TodoList.vue" + + + +``` + +Rather than triggering invalidation cascades or using manually written update functions, +Data Client reactively updates appropriate components using the fetch response. + +## Optimistic mutations based on previous state {#optimistic-updates} + +```ts title="Post" +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" {9} + + + +``` + +```html title="TotalVotes.vue" {13} + + + +``` + +```html title="PostList.vue" + + + +``` + +[getOptimisticResponse](./optimistic-updates.md) is just like [setState with an updater function](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). [Snapshot](https://dataclient.io/vue/api/Snapshot) provides typesafe access to the previous store value, +which we use to return the _expected_ fetch response. + +Reactive Data Client ensures [data integrity against any possible networking failure or race condition](./optimistic-updates.md#optimistic-transforms), so don't +worry about network failures, multiple mutation calls editing the same data, or other common +problems in asynchronous programming. + +## Tracking mutation loading + +[useLoading()](https://dataclient.io/vue/api/useLoading) enhances async functions by tracking their loading and error states. + +```ts title="PostResource" +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```html title="PostDetail.vue" + + + +``` + +```html title="PostForm.vue" + + + +``` + +```html title="PostCreate.vue" {9-14} + + + +``` + +```html title="Navigation.vue" + + + +``` diff --git a/.agents/skills/data-client-rest/references/network-transform.md b/.agents/skills/data-client-rest/references/network-transform.md deleted file mode 120000 index 5feb94894d95..000000000000 --- a/.agents/skills/data-client-rest/references/network-transform.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/network-transform.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/network-transform.md b/.agents/skills/data-client-rest/references/network-transform.md new file mode 100644 index 000000000000..962988046c39 --- /dev/null +++ b/.agents/skills/data-client-rest/references/network-transform.md @@ -0,0 +1,364 @@ + + +# Transforming data on fetch + +All network requests flow through the `fetch()` method, so any transforms needed can simply +be done by overriding it with a call to super. + +> **Tip** +> +> Note: If you retain control over the API design, generally it's preferred to +> update the data sent over the network. Keeping the client as `thin` as possible +> is helpful to both performance and complexity. +> +> That said, in many cases you want to consume APIs you don't have control over - +> be they public APIs, or due to internal organizational structure. + +## Snakes to camels + +Commonly APIs are designed with keys using `snake_case`, but many in typescript/javascript +prefer `camelCase`. This snippet lets us make the transform needed. + +```typescript title="CamelResource.ts" +import { camelCase, snakeCase } from 'lodash'; +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +function deeplyApplyKeyTransform(obj: any, transform: (key: string) => string) { + const ret: Record = Array.isArray(obj) ? [] : {}; + Object.keys(obj).forEach(key => { + if (obj[key] != null && typeof obj[key] === 'object') { + ret[transform(key)] = deeplyApplyKeyTransform(obj[key], transform); + } else { + ret[transform(key)] = obj[key]; + } + }); + return ret; +} + +class CamelEndpoint extends RestEndpoint { + getRequestInit(body) { + // we'll need to do the inverse operation when sending data back to the server + if (body) { + return super.getRequestInit(deeplyApplyKeyTransform(body, snakeCase)); + } + return super.getRequestInit(body); + } + process(value) { + return deeplyApplyKeyTransform(value, camelCase); + } +} +``` + +## Deserializing fields + +In many cases, data sent through JSON is serialized into strings since JSON +only has a few primitive types. Common examples include [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) +for dates or even strings for decimals that require high precision ([floats can be lossy](https://floating-point-gui.de/)). +Keeping data in the serialized form is often fine, especially if it is only being used to +be displayed. However, this can be problematic when derived data is computed like adding time to a date +or multiplying two numbers. + +In this case, simply use the [static schema](./Entity.md#schema) with [Temporal.Instant](https://tc39.es/proposal-temporal/) and [BigNumber](https://github.com/MikeMcl/bignumber.js) + +```tsx title="api/Price" +import BigNumber from 'bignumber.js'; + +export class ExchangePrice extends Entity { + exchangePair = ''; + updatedAt = Temporal.Instant.fromEpochMilliseconds(0); + price = new BigNumber(0); + pk() { + return this.exchangePair; + } + static key = 'ExchangePrice'; + + static schema = { + updatedAt: Temporal.Instant.from, + price: BigNumber, + }; +} +export const getPrice = new RestEndpoint({ + path: '/price/:exchangePair', + schema: ExchangePrice, +}); +``` + +```tsx title="PricePage" +import { getPrice } from './api/Price'; + +function PricePage() { + const currentPrice = useSuspense(getPrice, { + exchangePair: 'btc-usd', + }); + return ( +
+ ${currentPrice.price.toFormat(2)} as of{' '} + +
+ ); +} +render(); +``` + +### Deserializing Date + +In case you want to use legacy [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date), +you can turn the constructor into a function [schema](./schema.md). + +```ts +export class ExchangePrice extends Entity { + exchangePair = ''; + updatedAt = new Date(0); + price = new BigNumber(0); + pk() { + return this.exchangePair; + } + static key = 'ExchangePrice'; + + static schema = { + updatedAt: iso => new Date(iso), + price: BigNumber, + }; +} +``` + +## Case of the missing `Id` + +You now want to interface with a great new streaming site called `mystreamsite.tv`. It has +a simple API to retireve information about current streams. You can get a stream with the +url pattern `https://mystreamsite.tv/[username]/`. However, for some reason they don't +return the username in the response body! You want to be able to refer to it and it's +the only uniquely defining identifier for the class. + +We can simply parse the username from the request url itself and add that to the +response. + +```json title="GET https://mystreamsite.tv/ntucker/" +{ + "title": "When I'm Grandmaster, I will play faster.", + "game": "Starcraft II", + "current_viewers": 1337, + "live": true +} +``` + +```typescript title="api/Stream.ts" +const USERNAME_MATCHER = /.*\/([^\/]+)\/?/; + +class Stream extends Entity { + username = ''; + title = ''; + game = ''; + currentViewers = 0; + live = false; + + pk() { + return this.username; + } + static key = 'Stream'; +} + +const getStream = new RestEndpoint({ + urlPrefix: 'https://mystreamsite.tv', + path: '/:username', + schema: Stream, + process(value, { username }) { + value.username = username; + return value; + }, +}); +``` + +### Ticker prices + +Here's a real world example of an API that does where ticket data does not include its primary key `product_id`. + +We use [RestEndpoint.process()](./RestEndpoint.md#process) to add the `product_id` member from its argument. + +```typescript title="Ticker" {28-31} +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```tsx title="AssetPrice" {5} +import { useLive } from '@data-client/react'; +import { getTicker } from './Ticker'; + +function AssetPrice({ productId }: Props) { + const ticker = useLive(getTicker, { productId }); + return ( +
+ {productId}{' '} + +
+ ); +} +interface Props { + productId: string; +} +render(); +``` + +## Using HTTP Headers + +HTTP [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) are accessible in the fetch +[Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). [RestEndpoint.fetchResponse()](./RestEndpoint.md#fetchResponse) +can be used to construct [RestEndpoint](./RestEndpoint.md). + +Sometimes this is used for cursor based [pagination](./pagination.md#tokens-in-http-headers). + +```typescript +import { RestEndpoint, RestGenerics } from '@data-client/rest'; + +class GithubEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async parseResponse(response: Response) { + const results = await super.parseResponse(response); + if ( + (response.headers && response.headers.has('link')) || + Array.isArray(results) + ) { + return { + link: response.headers.get('link'), + results, + }; + } + return results; + } +} +``` + +## File download {#file-download} + +For endpoints that return binary data (files, images, PDFs), set +[`content: 'blob'`](./RestEndpoint.md#content). The return type is `Blob` and +`schema` defaults to `undefined` (binary data isn't normalizable). Use `dataExpiryLength: 0` +to avoid caching large blobs in memory. + +```typescript title="downloadFile.ts" +import { RestEndpoint } from '@data-client/rest'; + +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, +}); +``` + +```tsx title="DownloadButton.tsx" +import { useController } from '@data-client/react'; +import { downloadFile } from './downloadFile'; + +function DownloadButton({ id }: { id: string }) { + const ctrl = useController(); + + const handleDownload = async () => { + const blob: Blob = await ctrl.fetch(downloadFile, { id }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'download'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ; +} +``` + +To extract the filename from the `Content-Disposition` header, override +[parseResponse](./RestEndpoint.md#parseResponse): + +```typescript title="downloadFile.ts" +import { RestEndpoint } from '@data-client/rest'; + +const downloadFile = new RestEndpoint({ + path: '/files/:id/download', + content: 'blob', + dataExpiryLength: 0, + async parseResponse(response) { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + const filename = + disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download'; + return { blob, filename }; + }, + process(value): { blob: Blob; filename: string } { + return value; + }, +}); +``` + +For `ArrayBuffer` responses (useful for processing binary data in-memory), use +`content: 'arrayBuffer'` the same way. + +## Name calling + +Sometimes an API might change a key name, or choose one you don't like. Of course +you have much better naming standards, so instead of your `Resource` class definition +and all your code, you just want to remap that key. + +```typescript title="ArticleResource.ts" +class RenamedEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + getRequestInit(body) { + if (body && 'carrotsUsed' in body) { + const newBody = { + ...body, + carrotsUSedIsThisNameTooLong: carrotsUsed, + }; + delete newBody.carrotsUsed; + return super.getRequestInit(newBody); + } + return super.getRequestInit(body); + } + process(value) { + if ('carrotsUsedIsThisNameTooLong' in value) { + // ok to mutate jsonResponse since we control it + value.carrotsUsed = value.carrotsUsedIsThisNameTooLong; + delete value.carrotsUsedIsThisNameTooLong; + } + return value; + } +} +``` diff --git a/.agents/skills/data-client-rest/references/optimistic-updates.md b/.agents/skills/data-client-rest/references/optimistic-updates.md deleted file mode 120000 index 751a435607f5..000000000000 --- a/.agents/skills/data-client-rest/references/optimistic-updates.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/optimistic-updates.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/optimistic-updates.md b/.agents/skills/data-client-rest/references/optimistic-updates.md new file mode 100644 index 000000000000..b03e4ff713bf --- /dev/null +++ b/.agents/skills/data-client-rest/references/optimistic-updates.md @@ -0,0 +1,410 @@ + + +# Optimistic Updates + +Optimistic updates enable highly responsive and fast interfaces by avoiding network wait times. +An update is optimistic by assuming the network is successful. + +Doing this amplifies and creates new race conditions; thankfully Reactive Data Client automatically +handles these for you. + +## Resources + +[resource()](./resource.md) can be configured by setting [optimistic: true](./resource.md#optimistic). + +```ts title="TodoResource" {16} +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = 0; + userId = 0; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" +import { useController } from '@data-client/react'; +import { TodoResource, type Todo } from './TodoResource'; + +export default function TodoItem({ todo }: { todo: Todo }) { + const ctrl = useController(); + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + const handleDelete = () => + ctrl.fetch(TodoResource.delete, { + id: todo.id, + }); + return ( +
+ + +
+ ); +} +``` + +```tsx title="CreateTodo" +import { useController } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +export default function CreateTodo({ userId }: { userId: number }) { + const ctrl = useController(); + const handleKeyDown = async e => { + if (e.key === 'Enter') { + ctrl.fetch(TodoResource.getList.push, { + userId, + title: e.currentTarget.value, + }); + e.currentTarget.value = ''; + } + }; + return ( +
+ + +
+ ); +} +``` + +```tsx title="TodoList" +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; +import CreateTodo from './CreateTodo'; + +function TodoList() { + const userId = 1; + const todos = useSuspense(TodoResource.getList, { userId }); + return ( +
+ {todos.map(todo => ( + + ))} + +
+ ); +} +render(); +``` + +This makes all mutations optimistic using some sensible default implementations that handle most cases. + +### update/getList.push/getList.unshift + +```ts +function optimisticUpdate( + snap: SnapshotInterface, + params: any, + body: any, +) { + return { + ...params, + ...ensureBodyPojo(body), + }; +} + +function ensureBodyPojo(body: any) { + return body instanceof FormData + ? Object.fromEntries((body as any).entries()) + : body; +} +``` + +For creates (push/unshift) this typically results in no `id` in the response to compute a pk. +Data Client will create a random `pk` to make this work. + +Until the object is actually created, doing mutations on that object generally does not work. +Therefore, it may be prudent in these cases to disable further mutations until the actual +`POST` is completed. One way to determine this is to simply look for the existance of +a real `id` in the entity. + +### partialUpdate + +```ts +function optimisticPartial(schema: Queryable) { + return function (snap: SnapshotInterface, params: any, body: any) { + const data = snap.get(schema, params); + if (!data) throw snap.abort; + return { + ...params, + ...data, + // even tho we don't always have two arguments, the extra one will simply be undefined which spreads fine + ...ensurePojo(body), + }; + }; +} +``` + +Partial updates do not send the entire body, so we can use the entity from +the store to compute the expected response. [Snapshots](https://dataclient.io/docs/api/Snapshot) +give us safe access to the existing store value that is robust against any +race conditions. + +### delete + +```ts +function optimisticDelete(snap: SnapshotInterface, params: any) { + return params; +} +``` + +In case you do not want all endpoints to be optimistic, or if you have unusual API designs, +you can set [getOptimisticResponse()](./RestEndpoint.md#getoptimisticresponse) using +[Resource.extend()](./resource.md#extend) + +## Optimistic Transforms + +Sometimes user actions should result in data transformations that are dependent on the previous state of data. +The simplest examples of this are toggling a boolean, or incrementing a counter; but the same principal applies to +more complicated transforms. To make it more obvious we're using a simple counter here. + +```ts title="count" +export class CountEntity extends Entity { + count = 0; + + pk() { + return `SINGLETON`; + } +} +export const getCount = new RestEndpoint({ + path: '/api/count', + schema: CountEntity, + name: 'get', +}); +``` + +```ts title="increment" {9-15} +import { CountEntity, getCount } from './count'; + +export const increment = new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + name: 'increment', + schema: CountEntity, + getOptimisticResponse(snap) { + const data = snap.get(CountEntity, {}); + if (!data) throw snap.abort; + return { + count: data.count + 1, + }; + }, +}); +``` + +```tsx title="CounterPage" +import { useLoading } from '@data-client/react'; +import { getCount } from './count'; +import { increment } from './increment'; + +function CounterPage() { + const ctrl = useController(); + const { count } = useSuspense(getCount); + const [stateCount, setStateCount] = React.useState(0); + const [responseCount, setResponseCount] = React.useState(0); + const [clickHandler, loading, error] = useLoading(async () => { + setStateCount(stateCount + 1); + const val = await ctrl.fetch(increment); + setResponseCount(val.count); + setStateCount(val.count); + }); + return ( +
+

+ Click the button multiple times quickly to trigger the race + condition +

+ + + + + + + + + + + + + + + + + + +
OptimisticNormal
Data Client:{count}
Other:{stateCount}{responseCount}
+ +

{loading ? ' ...loading' : ''}

+
+ ); +} +render(); +``` + +Reactive Data Client automatically handles all race conditions due to network timings. Reactive Data Client both tracks +fetch timings, pairs responses with their respective optimistic update and rollsback in case of resolution or +rejection/failure. + +You can see how this is problematic for other libraries even without optimistic updates; +but optimistic updates make it even worse. + +### Example race condition + +Here's an example of the race condition. Here we request an increment twice; but the first response comes back to +client after the second response. + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Server + Client->>+Server: Increment from 0 + Client->>+Server: Increment from 1 + Server->>-Client: Response: 2 + Server->>-Client: Response: 1 +``` + +With other libraries and no optimistic updates this would result in showing 0, then, 2, then 1. + +If the other library does have optimistic updates, it should show 0, 1, 2, 2, then 1. + +In both cases we end up showing an incorrect state, and along the way see weird janky state updates. + +### Compensating for Server timing variations {#server-timings} + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Server + Client->>Server: Request timing + Note over Client,Server: Server timing + Server->>Client: Response timing +``` + +There are three timings which can vary in an async mutation. + +1. Request timing +2. Server timing +3. Response timing + +Reactive Data Client is able to automatically handling the network timings, aka request and response timing. Typically this +is sufficient, as servers tend to process requests received first before others. However, in case persist order +varies from request order in the server this could cause another race condition. + +This can be be solved by maintaining a [total order](https://en.wikipedia.org/wiki/Total_order). Because the +servers and clients can potentially has different times, we will need to track time from a consistent perspective. +Since we are performing optimistic updates this means we must use the client's clock. This means we will send the request +timing to the server in an `updatedAt` header via [getRequestInit()](./RestEndpoint.md#getRequestInit). The server should then ensure processing based on that order, and +then store this `updatedAt` in the entity to return in any request. + +Overriding [shouldReorder](./Entity.md#shouldreorder), we can reorder out-of-order responses based on the +server timestamp. + +We use [snap.fetchedAt](https://dataclient.io/docs/api/Snapshot#fetchedat) in our [getOptimisticResponse](./RestEndpoint.md#getoptimisticresponse). This respresents the moment the fetch is triggered, which will be the same time the `updatedAt` header is computed. + +```ts title="count" {9-11} +export class CountEntity extends Entity { + count = 0; + updatedAt = 0; + + pk() { + return `SINGLETON`; + } + + static shouldReorder(existingMeta, incomingMeta, existing, incoming) { + return incoming.updatedAt < existing.updatedAt; + } +} +export const getCount = new RestEndpoint({ + path: '/api/count', + schema: CountEntity, + name: 'get', +}); +``` + +```ts title="increment" {9-15,21} +import { CountEntity } from './count'; + +export const increment = new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + name: 'increment', + schema: CountEntity, + getRequestInit() { + // this is a substitute for super.getRequestInit() + // since we aren't in a class context + return RestEndpoint.prototype.getRequestInit.call(this, { + updatedAt: Date.now(), + }); + }, + getOptimisticResponse(snap) { + const data = snap.get(CountEntity, {}); + if (!data) throw snap.abort; + return { + count: data.count + 1, + updatedAt: snap.fetchedAt, + }; + }, +}); +``` + +```tsx title="CounterPage" +import { useLoading } from '@data-client/react'; +import { getCount } from './count'; +import { increment } from './increment'; + +function CounterPage() { + const ctrl = useController(); + const { count } = useSuspense(getCount); + const [n, setN] = React.useState(count); + const [clickHandler, loading, error] = useLoading(() => { + setN(n => n + 1); + return ctrl.fetch(increment); + }); + return ( +
+

+ Click the button multiple times quickly to trigger the + potential race condition. This time our vector clock protects + us. +

+
+ Data Client: {count} Should be: {n} +
+ + {loading ? ' ...loading' : ''} +
+
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-rest/references/pagination.md b/.agents/skills/data-client-rest/references/pagination.md deleted file mode 120000 index 70595753b5d3..000000000000 --- a/.agents/skills/data-client-rest/references/pagination.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/pagination.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/pagination.md b/.agents/skills/data-client-rest/references/pagination.md new file mode 100644 index 000000000000..1098c009ec8e --- /dev/null +++ b/.agents/skills/data-client-rest/references/pagination.md @@ -0,0 +1,374 @@ + + +# Rest Pagination + +## Expanding Lists + +In case you want to append results to your existing list, rather than move to another page +[Resource.getList.getPage](./resource.md#getpage) can be used as long as [paginationField](./resource.md#paginationfield) was provided. + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```tsx title="PostItem" +import { type Post } from './Post'; + +export default function PostItem({ post }: Props) { + return ( +
+ +
+

{post.title}

+ by {post.author.name} +
+
+ ); +} + +interface Props { + post: Post; +} +``` + +```tsx title="LoadMore" {7} +import { useController, useLoading } from '@data-client/react'; +import { PostResource } from './Post'; + +export default function LoadMore({ cursor }: { cursor: string }) { + const ctrl = useController(); + const [loadPage, isPending] = useLoading( + () => ctrl.fetch(PostResource.getList.getPage, { cursor }), + [cursor], + ); + return ( +
+ +
+ ); +} +``` + +```tsx title="PostList" {7} +import { useSuspense } from '@data-client/react'; +import PostItem from './PostItem'; +import LoadMore from './LoadMore'; +import { PostResource } from './Post'; + +export default function PostList() { + const { posts, cursor } = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( + + ))} + {cursor ? : null} +
+ ); +} +render(); +``` + +Don't forget to define our [Resource's](./resource.md) [paginationField](./resource.md#paginationfield) and +correct [schema](./resource.md#schema)! + +```ts title="Post" +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +### Github Issues Demo + +Our `NextPage` component has a click handler that calls [RestEndpoint.getPage](./RestEndpoint.md#getpage). +Scroll to the bottom of the preview to click _"Load more"_ to append the next page of issues. + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/Issue.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Issue.tsx), [`src/pages/NextPage.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/NextPage.tsx)) + +### Using RestEndpoint Directly + +Here we explore a real world example using [cosmos validators list](https://rest.cosmos.directory/stargaze/cosmos/staking/v1beta1/validators). + +Since validators only have one Endpoint, we use [RestEndpoint](./RestEndpoint.md) instead of [resource](./resource.md). By using [Collections](./Collection.md) and [paginationField](./RestEndpoint.md#paginationfield), we can call [RestEndpoint.getPage](./RestEndpoint.md#getpage) +to append the next page of validators to our list. + +```ts title="Validator" {46-50} +import { Collection, Entity, RestEndpoint, schema } from '@data-client/rest'; + +export class Validator extends Entity { + operator_address = ''; + consensus_pubkey = { '@type': '', key: '' }; + jailed = false; + status = 'BOND_STATUS_BONDED'; + tokens = '0'; + delegator_shares = '0'; + description = { + moniker: '', + identity: '', + website: 'https://fake.com', + security_contact: '', + details: '', + }; + unbonding_height = '0'; + unbonding_time = Temporal.Instant.fromEpochMilliseconds(0); + comission = { + commission_rates: { rate: 0, max_rate: 0, max_change_rate: 0 }, + update_time: Temporal.Instant.fromEpochMilliseconds(0), + }; + min_self_delegation = '0'; + + pk() { + return this.operator_address; + } + + static schema = { + unbonding_time: Temporal.Instant.from, + comission: { + commission_rates: { + rate: Number, + max_rate: Number, + max_change_rate: Number, + }, + update_time: Temporal.Instant.from, + }, + }; +} + +export const getValidators = new RestEndpoint({ + urlPrefix: 'https://rest.cosmos.directory', + path: '/stargaze/cosmos/staking/v1beta1/validators', + searchParams: {} as { 'pagination.limit': string }, + paginationField: 'pagination.key', + schema: { + validators: new Collection([Validator]), + pagination: { next_key: '', total: '' }, + }, +}); +``` + +```tsx title="ValidatorItem" +import { type Validator } from './Validator'; + +export default function ValidatorItem({ validator }: Props) { + return ( +
+
+

{validator.description.moniker}

+ + + {validator.description.website} + + +

{validator.description.details}

+
+
+ ); +} + +interface Props { + validator: Validator; +} +``` + +```tsx title="LoadMore" {8-11} +import { useController, useLoading } from '@data-client/react'; +import { getValidators } from './Validator'; + +export default function LoadMore({ next_key, limit }) { + const ctrl = useController(); + const [handleLoadMore, isPending] = useLoading( + () => + ctrl.fetch(getValidators.getPage, { + 'pagination.limit': limit, + 'pagination.key': next_key, + }), + [next_key, limit], + ); + if (!next_key) return null; + return ( +
+ +
+ ); +} +``` + +```tsx title="ValidatorList" +import { useSuspense } from '@data-client/react'; +import ValidatorItem from './ValidatorItem'; +import { getValidators } from './Validator'; +import LoadMore from './LoadMore'; + +const PAGE_LIMIT = '3'; + +export default function ValidatorList() { + const { validators, pagination } = useSuspense(getValidators, { + 'pagination.limit': PAGE_LIMIT, + }); + + return ( +
+ {validators.map(validator => ( + + ))} + +
+ ); +} +render(); +``` + +### Infinite Scrolling + +Since UI behaviors vary widely, and implementations vary from platform (react-native or web), +we'll just assume a `Pagination` component is built, that uses a callback to trigger next +page fetching. On web, it is recommended to use something based on [Intersection Observers](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) + +```tsx +import { useSuspense, useController } from '@data-client/react'; +import { PostResource } from 'resources/Post'; + +function NewsList() { + const { results, cursor } = useSuspense(PostResource.getList); + const ctrl = useController(); + + return ( + + ctrl.fetch(PostResource.getList.getPage, { cursor }) + } + > + + + ); +} +``` + +## Tokens in HTTP Headers + +In some cases the pagination tokens will be embeded in HTTP headers, rather than part of the payload. In this +case you'll need to customize the [parseResponse()](./RestEndpoint.md#parseResponse) function +for [getList](./resource.md#getlist) so the pagination headers are included fetch object. + +We show the custom `getList` below. All other parts of the above example remain the same. + +Pagination token is stored in the header `link` for this example. + +```typescript +import { Collection, Resource } from '@data-client/rest'; + +export const ArticleResource = resource({ + path: '/articles/:id', + schema: Article, +}).extend(Base => ({ + getList: Base.getList.extend({ + schema: { results: [Article], link: '' }, + async parseResponse(response: Response) { + const results = await Base.getList.parseResponse(response); + if ( + (response.headers && response.headers.has('link')) || + Array.isArray(results) + ) { + return { + link: response.headers.get('link'), + results, + }; + } + return results; + }, + }), +})); +``` + +### Code organization + +If much of your API share a similar pagination, you might +try a custom Endpoint class that shares this logic. + +```ts title="resources/PagingEndpoint.ts" +import { Collection, RestEndpoint, type RestGenerics } from '@data-client/rest'; + +export class PagingEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async parseResponse(response: Response) { + const results = await super.parseResponse(response); + if ( + (response.headers && response.headers.has('link')) || + Array.isArray(results) + ) { + return { + link: response.headers.get('link'), + results, + }; + } + return results; + } +} +``` + +```ts title="resources/MyResource.ts" +import { Collection, Entity, resource } from '@data-client/rest'; + +import { PagingEndpoint } from './PagingEndpoint'; + +export const MyResource = resource({ + path: '/stuff/:id', + schema: MyEntity, + Endpoint: PagingEndpoint, +}); +``` diff --git a/.agents/skills/data-client-rest/references/pagination.vue.md b/.agents/skills/data-client-rest/references/pagination.vue.md new file mode 100644 index 000000000000..ab85bbe1ea2f --- /dev/null +++ b/.agents/skills/data-client-rest/references/pagination.vue.md @@ -0,0 +1,371 @@ + + +# Rest Pagination + +## Expanding Lists + +In case you want to append results to your existing list, rather than move to another page +[Resource.getList.getPage](./resource.md#getpage) can be used as long as [paginationField](./resource.md#paginationfield) was provided. + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} +import { Entity, resource, Collection } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +```html title="PostItem.vue" + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="PostList.vue" + + + +``` + +Don't forget to define our [Resource's](./resource.md) [paginationField](./resource.md#paginationfield) and +correct [schema](./resource.md#schema)! + +```ts title="Post" +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new Collection([Post]), cursor: '' }, +}); +``` + +### Github Issues Demo + +Our `NextPage` component has a click handler that calls [RestEndpoint.getPage](./RestEndpoint.vue.md#getpage). +Scroll to the bottom of the preview to click _"Load more"_ to append the next page of issues. + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/Issue.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Issue.tsx), [`src/pages/NextPage.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/NextPage.tsx)) + +### Using RestEndpoint Directly + +Here we explore a real world example using [cosmos validators list](https://rest.cosmos.directory/stargaze/cosmos/staking/v1beta1/validators). + +Since validators only have one Endpoint, we use [RestEndpoint](./RestEndpoint.vue.md) instead of [resource](./resource.md). By using [Collections](./Collection.md) and [paginationField](./RestEndpoint.vue.md#paginationfield), we can call [RestEndpoint.getPage](./RestEndpoint.vue.md#getpage) +to append the next page of validators to our list. + +```ts title="Validator" {46-50} +import { Collection, Entity, RestEndpoint, schema } from '@data-client/rest'; + +export class Validator extends Entity { + operator_address = ''; + consensus_pubkey = { '@type': '', key: '' }; + jailed = false; + status = 'BOND_STATUS_BONDED'; + tokens = '0'; + delegator_shares = '0'; + description = { + moniker: '', + identity: '', + website: 'https://fake.com', + security_contact: '', + details: '', + }; + unbonding_height = '0'; + unbonding_time = Temporal.Instant.fromEpochMilliseconds(0); + comission = { + commission_rates: { rate: 0, max_rate: 0, max_change_rate: 0 }, + update_time: Temporal.Instant.fromEpochMilliseconds(0), + }; + min_self_delegation = '0'; + + pk() { + return this.operator_address; + } + + static schema = { + unbonding_time: Temporal.Instant.from, + comission: { + commission_rates: { + rate: Number, + max_rate: Number, + max_change_rate: Number, + }, + update_time: Temporal.Instant.from, + }, + }; +} + +export const getValidators = new RestEndpoint({ + urlPrefix: 'https://rest.cosmos.directory', + path: '/stargaze/cosmos/staking/v1beta1/validators', + searchParams: {} as { 'pagination.limit': string }, + paginationField: 'pagination.key', + schema: { + validators: new Collection([Validator]), + pagination: { next_key: '', total: '' }, + }, +}); +``` + +```tsx title="ValidatorItem" +import { type Validator } from './Validator'; + +export default function ValidatorItem({ validator }: Props) { + return ( +
+
+

{validator.description.moniker}

+ + + {validator.description.website} + + +

{validator.description.details}

+
+
+ ); +} + +interface Props { + validator: Validator; +} +``` + +```tsx title="LoadMore" {8-11} +import { useController, useLoading } from '@data-client/react'; +import { getValidators } from './Validator'; + +export default function LoadMore({ next_key, limit }) { + const ctrl = useController(); + const [handleLoadMore, isPending] = useLoading( + () => + ctrl.fetch(getValidators.getPage, { + 'pagination.limit': limit, + 'pagination.key': next_key, + }), + [next_key, limit], + ); + if (!next_key) return null; + return ( +
+ +
+ ); +} +``` + +```tsx title="ValidatorList" +import { useSuspense } from '@data-client/react'; +import ValidatorItem from './ValidatorItem'; +import { getValidators } from './Validator'; +import LoadMore from './LoadMore'; + +const PAGE_LIMIT = '3'; + +export default function ValidatorList() { + const { validators, pagination } = useSuspense(getValidators, { + 'pagination.limit': PAGE_LIMIT, + }); + + return ( +
+ {validators.map(validator => ( + + ))} + +
+ ); +} +render(); +``` + +### Infinite Scrolling + +Since UI behaviors vary widely, and implementations vary from platform (react-native or web), +we'll just assume a `Pagination` component is built, that uses a callback to trigger next +page fetching. On web, it is recommended to use something based on [Intersection Observers](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) + +```tsx +import { useSuspense, useController } from '@data-client/react'; +import { PostResource } from 'resources/Post'; + +function NewsList() { + const { results, cursor } = useSuspense(PostResource.getList); + const ctrl = useController(); + + return ( + + ctrl.fetch(PostResource.getList.getPage, { cursor }) + } + > + + + ); +} +``` + +## Tokens in HTTP Headers + +In some cases the pagination tokens will be embeded in HTTP headers, rather than part of the payload. In this +case you'll need to customize the [parseResponse()](./RestEndpoint.vue.md#parseResponse) function +for [getList](./resource.md#getlist) so the pagination headers are included fetch object. + +We show the custom `getList` below. All other parts of the above example remain the same. + +Pagination token is stored in the header `link` for this example. + +```typescript +import { Collection, Resource } from '@data-client/rest'; + +export const ArticleResource = resource({ + path: '/articles/:id', + schema: Article, +}).extend(Base => ({ + getList: Base.getList.extend({ + schema: { results: [Article], link: '' }, + async parseResponse(response: Response) { + const results = await Base.getList.parseResponse(response); + if ( + (response.headers && response.headers.has('link')) || + Array.isArray(results) + ) { + return { + link: response.headers.get('link'), + results, + }; + } + return results; + }, + }), +})); +``` + +### Code organization + +If much of your API share a similar pagination, you might +try a custom Endpoint class that shares this logic. + +```ts title="resources/PagingEndpoint.ts" +import { Collection, RestEndpoint, type RestGenerics } from '@data-client/rest'; + +export class PagingEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async parseResponse(response: Response) { + const results = await super.parseResponse(response); + if ( + (response.headers && response.headers.has('link')) || + Array.isArray(results) + ) { + return { + link: response.headers.get('link'), + results, + }; + } + return results; + } +} +``` + +```ts title="resources/MyResource.ts" +import { Collection, Entity, resource } from '@data-client/rest'; + +import { PagingEndpoint } from './PagingEndpoint'; + +export const MyResource = resource({ + path: '/stuff/:id', + schema: MyEntity, + Endpoint: PagingEndpoint, +}); +``` diff --git a/.agents/skills/data-client-rest/references/resource.md b/.agents/skills/data-client-rest/references/resource.md deleted file mode 120000 index ad7725422f72..000000000000 --- a/.agents/skills/data-client-rest/references/resource.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/resource.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/resource.md b/.agents/skills/data-client-rest/references/resource.md new file mode 100644 index 000000000000..4ef21fd64165 --- /dev/null +++ b/.agents/skills/data-client-rest/references/resource.md @@ -0,0 +1,816 @@ + + +# Resource + +`Resources` are a collection of [RestEndpoints](./RestEndpoint.md) that operate on a common +data by sharing a [schema](./schema.md) + +## Usage + +```ts title="resources/Todo.ts" +export class Todo extends Entity { + id = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} + +const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +``` + +```ts title="Resources start with 6 Endpoints" +const todo = useSuspense(TodoResource.get, { id: '5' }); +const todos = useSuspense(TodoResource.getList); +controller.fetch(TodoResource.getList.push, { + title: 'finish installing reactive data client', +}); +controller.fetch( + TodoResource.update, + { id: '5' }, + { ...todo, completed: true }, +); +controller.fetch( + TodoResource.partialUpdate, + { id: '5' }, + { completed: true }, +); +controller.fetch(TodoResource.delete, { id: '5' }); +``` + +## Arguments + +```ts +{ + path: string; + schema: Schema; + urlPrefix?: string; + body?: any; + searchParams?: any; + paginationField?: string; + optimistic?: boolean; + Endpoint?: typeof RestEndpoint; + Collection?: typeof Collection; +} & EndpointExtraOptions +``` + +### path + +Passed to [RestEndpoint.path](./RestEndpoint.md#path) for single item [endpoints](#members). +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) syntax — see +[RestEndpoint.path](./RestEndpoint.md#path) for full details on +[optional parameters](./RestEndpoint.md#path), [wildcards](./RestEndpoint.md#path), +[quoted names](./RestEndpoint.md#path), and [escaping](./RestEndpoint.md#path). + +Create ([getList.push](#push)/[getList.unshift](#unshift)) and [getList](#getlist) remove the last `:param` or `*wildcard` token. + +```ts +const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', +}); + +// GET /react/posts/abc +PostResource.get({ group: 'react', id: 'abc' }); +// PATCH /react/posts/abc +PostResource.partialUpdate({ group: 'react', id: 'abc' }, { title: 'This new title' }); +// GET /react/posts +PostResource.getList({ group: 'react' }); +``` + +Optional parameters use `{}` syntax: + +```ts +const PostResource = resource({ + schema: Post, + path: '/:group/posts{/:id}', +}); + +PostResource.get({ group: 'react', id: 'abc' }); +PostResource.getList({ group: 'react' }); +``` + +Wildcard parameters are also supported as the last token: + +```ts +const FileResource = resource({ + schema: File, + path: '/repos/:owner/*path', +}); + +// GET /repos/john/src/index.ts +FileResource.get({ owner: 'john', path: ['src', 'index.ts'] }); +// GET /repos/john +FileResource.getList({ owner: 'john' }); +``` + +### schema + +Passed to [RestEndpoint.schema](./RestEndpoint.md#schema) representing a single item. This is usually +an [Entity](./Entity.md) or [Union](https://dataclient.io/rest/api/Union). + +- [getList](#getlist) uses an [Array](https://dataclient.io/rest/api/Array) [Collection](./Collection.md) of the schema. +- [delete](#delete) uses a [Invalidate](https://dataclient.io/rest/api/Invalidate) of the schema. + +### urlPrefix + +Passed to [RestEndpoint.urlPrefix](./RestEndpoint.md#urlPrefix) + +### searchParams + +Passed to [RestEndpoint.searchParams](./RestEndpoint.md#searchParams) for [getList](#getlist) and [getList.push](#push) + +### body + +Passed to [RestEndpoint.body](./RestEndpoint.md#body) for [getList.push](#push) [update](#update) and [partialUpdate](#partialupdate) + +### paginationField + +If specified, will add [Resource.getList.getPage](#getpage) method on the `Resource`. + +### nonFilterArgumentKeys + +Pass-through option to [Collection.nonFilterArgumentKeys](./Collection.md#nonFilterArgumentKeys) +for [getList](#getlist) schema. + +```ts +const PostResource = resource({ + path: '/:group/posts/:id', + searchParams: {} as { orderBy?: string; author?: string }, + schema: Post, + nonFilterArgumentKeys: ['orderBy'], +}); +``` + +`RegExp` and function forms are also supported: + +```ts +resource({ + path: '/:group/posts/:id', + searchParams: {} as { orderBy?: string; author?: string }, + schema: Post, + nonFilterArgumentKeys: /orderBy/, +}); +``` + +### optimistic + +`true` makes all mutation endpoints [optimistic](./optimistic-updates.md), making UI +updates immediate, even before fetch completion. + +### Endpoint + +Class used to construct the members. + +```ts +import { RestEndpoint } from '@data-client/rest'; + +export default class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} +const TodoResource = resource({ + path: '/todos/:id', + schema: Todo, + Endpoint: AuthdEndpoint, +}); +``` + +### Collection + +[Collection Class](./Collection.md) used to construct [getList](#getlist) schema. +Use this when you need to customize collection behavior beyond +[`nonFilterArgumentKeys`](#nonfilterargumentkeys), like changing move merge logic. + +```ts +import { resource, Collection, unshift } from '@data-client/rest'; + +class MyCollection< + S extends any[] | PolymorphicInterface = any, + Parent extends any[] = [urlParams: any, body?: any], +> extends Collection { + constructor(schema: S) { + super(schema); + // prepend moved items instead of appending + this.move = this.moveWith(unshift); + } +} +const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string; orderBy?: string } | undefined, + schema: Todo, + Collection: MyCollection, +}); +``` + +### [EndpointExtraOptions](./RestEndpoint.md#dataexpirylength) + +dataExpiryLength, errorExpiryLength, errorPolicy, invalidIfStale, pollFrequency + +## Members + +These provide the standard [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) +[endpoints](https://dataclient.io/rest/api/Endpoint)s common in [REST](https://www.restapitutorial.com/) APIs. Feel free to [customize or add +new endpoints](#extend-new) based to match your API. + +```ts +const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, + paginationField: 'page', +}); +``` + +| Name | Method | Args | Schema | +| --------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------- | +| [get](#get) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; id: string}]` | [Post](./Entity.md) | +| [getList](#getlist) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; author?: string}]` | [Collection(\[Post\])](./Collection.md) | +| [getList.push](#push) | [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) | `[{group: string; author?: string}, Partial]` | [Collection(\[Post\]).push](./Collection.md#push) | +| [getList.unshift](#unshift) | [POST](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) | `[{group: string; author?: string}, Partial]` | [Collection(\[Post\]).unshift](./Collection.md#unshift) | +| [getList.getPage](#getpage) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; author?: string; page: string}]` | [Collection(\[Post\]).addWith](./Collection.md#addWith) | +| [getList.move](#move) | [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) | `[{group: string; id: string }, Partial]` | [Collection(\[Post\]).move](./Collection.md#move) | +| [update](#update) | [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) | `[{group: string; id: string }, Partial]` | [Post](./Entity.md) | +| [partialUpdate](#update) | [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) | `[{group: string; id: string }, Partial]` | [Post](./Entity.md) | +| [delete](#delete) | [DELETE](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) | `[{group: string; id: string }]` | [Invalidate(Post)](https://dataclient.io/rest/api/Invalidate) | + +### get + +Retrieve a singular entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.get({ + group: 'react', + id: '1', +}); +``` + +| Field | Value | +| :----: | ----------------- | +| method | 'GET' | +| path | [path](#path) | +| schema | [schema](#schema) | + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate), [Controller.expireAll](https://dataclient.io/docs/api/Controller#expireAll) + +### getList + +Retrieve a list of entities. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList({ + group: 'react', + author: 'clara', +}); +``` + +| Field | Value | +| :-------------: | --------------------------------------------- | +| method | 'GET' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| paginationField | [paginationField](#paginationfield) | +| schema | [new Collection(\[schema\])](./Collection.md) | + +```ts +resource({ path: '/:first/:second' }).getList.path === '/:first'; +resource({ path: '/:first' }).getList.path === '/'; +resource({ path: '/:owner/*path' }).getList.path === '/:owner'; +``` + +Commonly used with [useSuspense()](https://dataclient.io/docs/api/useSuspense), [Controller.invalidate](https://dataclient.io/docs/api/Controller#invalidate), [Controller.expireAll](https://dataclient.io/docs/api/Controller#expireAll) + +### getList.push {#push} + +[RestEndpoint.push](./RestEndpoint.md#push) creates a new entity and pushes it to the end of getList. Use [getList.unshift](#unshift) +to place at the beginning instead. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.push( + { group: 'react', author: 'clara' }, + { title: 'winning' }, +); +``` + +| Field | Value | +| :----------: | ------------------------------------------- | +| method | 'POST' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| body | [body](#body) | +| schema | getList.[schema.push](./Collection.md#push) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### getList.unshift {#unshift} + +[RestEndpoint.unshift](./RestEndpoint.md#unshift) creates a new entity and pushes it to the beginning of getList. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.unshift( + { group: 'react', author: 'clara' }, + { title: 'winning' }, +); +``` + +| Field | Value | +| :----------: | ------------------------------------------------- | +| method | 'POST' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| body | [body](#body) | +| schema | getList.[schema.unshift](./Collection.md#unshift) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### getList.getPage {#getpage} + +[RestEndpoint.getPage](./RestEndpoint.md#getpage) retrieves another [page](./pagination.md#infinite-scrolling) appending to getList ensuring there are no duplicates. + +This member is only available when [paginationField](#paginationfield) is specified. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, + paginationField: 'page', +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.getPage({ + group: 'react', + author: 'clara', + page: 2, +}); +``` + +| Field | Value | +| :-------------: | ------------------------------------------------- | +| method | 'GET' | +| path | removeLastArg([path](#path)) | +| searchParams | [searchParams](#searchparams) | +| paginationField | [paginationField](#paginationfield) | +| schema | [getList.schema.addWith](./Collection.md#addWith) | + +args: `PathToArgs(shortenPath(path)) & searchParams & \{ [paginationField]: string | number \}` + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### getList.move {#move} + +[RestEndpoint.move](./RestEndpoint.md#move) moves an entity between [Collections](./Collection.md) by removing it from +collections matching its old state and adding it to collections matching the new values from the body. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.getList.move( + { group: 'react', id: '1' }, + { group: 'vue' }, +); +``` + +| Field | Value | +| :----: | ------------------------------------------- | +| method | 'PATCH' | +| path | [path](#path) | +| body | [body](#body) | +| schema | getList.[schema.move](./Collection.md#move) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### update + +Update an entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.update( + { group: 'react', id: '1' }, + { title: 'updated title', author: 'clara' }, +); +``` + +| Field | Value | +| :----: | ----------------- | +| method | 'PUT' | +| path | [path](#path) | +| body | [body](#body) | +| schema | [schema](#schema) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### partialUpdate + +Update some subset of fields of an entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.partialUpdate( + { group: 'react', id: '1' }, + { title: 'updated title' }, +); +``` + +| Field | Value | +| :----: | ----------------- | +| method | 'PATCH' | +| path | [path](#path) | +| body | [body](#body) | +| schema | [schema](#schema) | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +### delete + +Deletes an entity. + +```typescript title="Post" +export default class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +``` + +```typescript title="Resource" +import Post from './Post'; +export const PostResource = resource({ + schema: Post, + path: '/:group/posts/:id', + searchParams: {} as { author?: string }, +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.delete({ group: 'react', id: '1' }); +``` + +| Field | Value | +| :-----: | -------------------------------------------------------------------------------------------- | +| method | 'DELETE' | +| path | [path](#path) | +| schema | [new Invalidate(schema)](https://dataclient.io/rest/api/Invalidate) | +| process | ```ts +(value, params) { + return value && Object.keys(value).length ? value : params; +}, +``` | + +Commonly used with [Controller.fetch](https://dataclient.io/docs/api/Controller#fetch) + +#### Response + +```json +{ "id": "xyz" } +``` + +Response should either be the [pk](./Entity.md#pk) as a string (like `'xyz'`). Or an object with the members needed to compute +[Entity.pk](./Entity.md#pk) (like `{id: 'xyz'}`). + +If no response is provided, the `process` implementation will attempt to use the url parameters sent as an object to compute +the [Entity.pk](./Entity.md#pk). This enables the default implementation to still work with no response, so long as standard +arguments are used. + +This allows [Invalidate](https://dataclient.io/rest/api/Invalidate) to remove the entity from the [entity table](https://dataclient.io/docs/concepts/normalization) + +### extend() {#extend} + +`resource` builds a great starting point, but often endpoints need to be [further customized](./RestEndpoint.md#typing). + +`extend()` is polymorphic with three forms: + +#### Function form (to get BaseResource/super) {#extend-function} + +This is the most flexible, but also the most verbose. + +```ts +export const IssueResource= resource({ + path: '/repos/:owner/:repo/issues/:number', + schema: Issue, + pollFrequency: 60000, + searchParams: {} as IssueFilters | undefined, +}).extend(BaseResource => ({ + search: BaseResource.getList.extend({ + path: '/search/issues?{q=:q}%20repo\\::owner/:repo{&page=:page}', + schema: { + results: { + incompleteResults: false, + items: BaseIssueResource.getList.schema.results, + totalCount: 0, + }, + link: '', + }, + }) +)}); +``` + +#### Batch extension of known members {#extend-override} + +This only works with existing members. + +```ts +export const CommentResource = resource({ + path: '/repos/:owner/:repo/issues/comments/:id', + schema: Comment, +}).extend({ + getList: { path: '/repos/:owner/:repo/issues/:number/comments' }, + update: { body: { body: '' } }, +}); +``` + +#### Adding new members {#extend-new} + +This can only add one endpoint at a time. + +```ts +export const UserResource = createGithubResource({ + path: '/users/:login', + schema: User, +}).extend('current', { + path: '/user', + schema: User, +}); +``` + +#### Github CommentResource + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/pages/IssueDetail/CommentsList.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/IssueDetail/CommentsList.tsx), [`src/resources/Comment.ts`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Comment.ts)) + +## Function Inheritance Patterns + +To reuse code related to `Resource` definitions, you can create your own function that calls resource(). +This has similar effects as class-based inheritance, with the added benefit of allowing for complete +typing overrides. + +```typescript +import { + resource, + RestEndpoint, + Collection, + type EndpointExtraOptions, + type RestGenerics, + type ResourceGenerics, + type ResourceOptions, +} from '@data-client/rest'; + +export class AuthdEndpoint< + O extends RestGenerics = any, +> extends RestEndpoint { + urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000'; + + async getRequestInit(body: any): Promise { + return { + ...(await super.getRequestInit(body)), + credentials: 'same-origin', + }; + } +} + +export function myResource({ + schema, + Endpoint = AuthdEndpoint, + ...extraOptions +}: Readonly & ResourceOptions) { + return resource({ + Endpoint, + schema, + ...extraOptions, + }).extend({ + getList: { + schema: { + results: new Collection([schema]), + total: 0, + limit: 0, + skip: 0, + }, + }, + }); +} +``` + +### GraphQL + REST Hybrid + +When your API provides both REST and GraphQL endpoints, you can mix them in a single resource. +Use [Entity.process()](./Entity.md#process) to normalize different response shapes. + +```typescript +import { GQLEndpoint } from '@data-client/graphql'; +import { Entity, resource } from '@data-client/rest'; + +const gql = new GQLEndpoint('https://api.myservice.com/graphql'); + +export class Repository extends Entity { + id = ''; + name = ''; + owner = { login: '' }; + stargazersCount = 0; + forksCount = 0; + + pk() { + return `${this.owner.login}/${this.name}`; + } + + static key = 'Repository'; +} + +/** Normalizes GraphQL response shape to match REST Entity */ +export class GqlRepository extends Repository { + static process(input: any, parent: any, key: string | undefined) { + // GraphQL uses different field names than REST + if ('stargazerCount' in input) { + return { + ...input, + stargazersCount: input.stargazerCount, + forksCount: input.forkCount, + }; + } + return input; + } +} + +export const RepositoryResource = resource({ + path: '/repos/:owner/:repo', + schema: Repository, +}).extend(base => ({ + // REST endpoint for single repo + get: base.get, + // GraphQL endpoint for user's pinned repos + getByPinned: gql.query( + (v: { login: string }) => `query ($login: String!) { + user(login: $login) { + pinnedItems(first: 6, types: REPOSITORY) { + nodes { + ... on Repository { + id + name + owner { login } + stargazerCount + forkCount + } + } + } + } + }`, + { user: { pinnedItems: { nodes: [GqlRepository] } } }, + ), +})); +``` + +#### Github Example + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/resources/Base.ts`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Base.ts)) diff --git a/.agents/skills/data-client-rest/references/schema.md b/.agents/skills/data-client-rest/references/schema.md deleted file mode 120000 index 1866c3f280b0..000000000000 --- a/.agents/skills/data-client-rest/references/schema.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/schema.md \ No newline at end of file diff --git a/.agents/skills/data-client-rest/references/schema.md b/.agents/skills/data-client-rest/references/schema.md new file mode 100644 index 000000000000..45b626e4642c --- /dev/null +++ b/.agents/skills/data-client-rest/references/schema.md @@ -0,0 +1,267 @@ + + +# Thinking in Schemas + +Consider a typical blog post. The API response for a single post might look something like this: + +```json +{ + "id": "123", + "author": { + "id": "1", + "name": "Paul" + }, + "title": "My awesome blog post", + "comments": [ + { + "id": "324", + "createdAt": "2013-05-29T00:00:00-04:00", + "commenter": { + "id": "2", + "name": "Nicole" + } + }, + { + "id": "544", + "createdAt": "2013-05-30T00:00:00-04:00", + "commenter": { + "id": "1", + "name": "Paul" + } + } + ] +} +``` + +## Declarative definitions + +We have two nested [entity](./Entity.md) types within our `article`: `users` and `comments`. Using various [schema](./Entity.md#schema), we can normalize all three entity types down: + +```typescript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { + id = ''; + name = ''; +} + +class Comment extends Entity { + id = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + commenter = User.fromJS(); + + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + comments: Comment[] = []; + + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +```javascript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { } + +class Comment extends Entity { + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +## Normalize + +```js +import { normalize } from '@data-client/normalizr'; + +const args = [{ id: '123' }]; +const normalizedData = normalize(Article, originalData, args); +``` + +Now, `normalizedData` will create a single serializable source of truth for all entities: + +```js +{ + result: "123", + entities: { + articles: { + "123": { + id: "123", + author: "1", + title: "My awesome blog post", + comments: [ "324", "544" ] + } + }, + users: { + "1": { "id": "1", "name": "Paul" }, + "2": { "id": "2", "name": "Nicole" } + }, + comments: { + "324": { + id: "324", + createdAt: "2013-05-29T00:00:00-04:00", + commenter: "2" + }, + "544": { + id: "544", + createdAt: "2013-05-30T00:00:00-04:00", + commenter: "1" + } + } + }, + // contents excluded for brevity + indexes, + entitiesMeta, +} +``` + +## Denormalize + +```js +import { denormalize } from '@data-client/normalizr'; + +const denormalizedData = denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +``` + +Now, `denormalizedData` will instantiate the classes, ensuring all instances of the same member (like `Paul`) are referentially equal: + +```js +Article { + id: '123', + title: 'My awesome blog post', + author: User { id: '1', name: 'Paul' }, + comments: [ + Comment { + id: '324', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '2', name: 'Nicole' }] + }, + Comment { + id: '544', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '1', name: 'Paul' }] + } + ] +} +``` + +### MemoCache + +`MemoCache` is a singleton that can be used to maintain referential equality between calls as well +as potentially improved performance by 2000%. Its methods are memoized. + +#### memo.denormalize + +```js +import { MemoCache } from '@data-client/normalizr'; + +// you can construct a new memo anytime you want to reset the cache +const memo = new MemoCache(); + +const { data, paths } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +const { data: data2 } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); + +// referential equality maintained between calls +assert(data === data2); +``` + +`memo.denormalize()` is just like [denormalize()](#denormalize) above but includes `paths` as part of the return value. `paths` +is an Array of paths of all entities included in the result. + +#### memo.query + +`memo.query()` allows denormalizing [Queryable](#queryable) based on args alone, rather than a normalized input. + +```ts +const data = memo.query( + Article, + args, + normalizedData, +); +``` + +## Queryable + +`Queryable` Schemas allow store access without an endpoint. They achieve this using the +[queryKey](./Entity.md#queryKey) method that produces the results normally stored in the endpoint cache. + +This enables their use in these additional cases: + +- [useQuery()](https://dataclient.io/docs/api/useQuery) - Rendering in React +- [schema.Query()](https://dataclient.io/rest/api/Query) - As input to produce a computed memoization. +- [ctrl.get](https://dataclient.io/docs/api/Controller#get)/[snap.get](https://dataclient.io/docs/api/Snapshot#get) + - [Managers](https://dataclient.io/docs/concepts/managers) + - React with [useController()](https://dataclient.io/docs/api/useController) + - [RestEndpoint.getOptimisticResponse](./RestEndpoint.md#getoptimisticresponse) + - [Unit testing hooks](https://dataclient.io/docs/guides/unit-testing-hooks) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook) +- [memo.query()](#memoquery) +- Improve performance of [useSuspense](https://dataclient.io/docs/api/useSuspense), [useDLE](https://dataclient.io/docs/api/useDLE) by rendering before endpoint resolution + +`Querables` include [Entity](./Entity.md), [All](https://dataclient.io/rest/api/All), [Collection](./Collection.md), [Query](https://dataclient.io/rest/api/Query), +[Union](https://dataclient.io/rest/api/Union), and [Scalar](https://dataclient.io/rest/api/Scalar). [Lazy](https://dataclient.io/rest/api/Lazy) fields produce a Queryable via their [`.query`](https://dataclient.io/rest/api/Lazy#query) accessor. + +```ts +interface Queryable { + queryKey( + args: readonly any[], + queryKey: (...args: any) => any, + getEntity: GetEntity, + getIndex: GetIndex, + // `{}` means non-void + ): {}; +} +``` + +## Schema Overview + +| Data Type | Mutable | Schema | Description | [Queryable](./schema.md#queryable) | +| ------------------------------------------------------------------- | ------- | --------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------- | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Entity](./Entity.md) | single _unique_ object | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Union(Entity)](https://dataclient.io/rest/api/Union) | polymorphic objects (`A \| B`) | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | 🛑 | [Object](https://dataclient.io/rest/api/Object) | statically known keys | 🛑 | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | | [Invalidate(Entity)](https://dataclient.io/rest/api/Invalidate) | [delete an entity](./expiry-policy.md#invalidate-entity) | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | ✅ | [Collection(Array)](./Collection.md) | growable lists | ✅ | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | 🛑 | [Array](https://dataclient.io/rest/api/Array) | immutable lists | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | | [All](https://dataclient.io/rest/api/All) | list of all entities of a kind | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | ✅ | [Collection(Values)](./Collection.md) | growable maps | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | 🛑 | [Values](https://dataclient.io/rest/api/Values) | immutable maps | 🛑 | +| [Scalar](https://en.wikipedia.org/wiki/Scalar_\(mathematics\)) | ✅ | [Scalar](https://dataclient.io/rest/api/Scalar) | lens-dependent entity fields | ✅ | +| any | | [Query(Queryable)](https://dataclient.io/rest/api/Query) | memoized custom transforms | ✅ | +| any | | [Lazy(Schema)](https://dataclient.io/rest/api/Lazy) | deferred denormalization | ✅ | diff --git a/.agents/skills/data-client-schema/SKILL.md b/.agents/skills/data-client-schema/SKILL.md index e7ec24c7e2d4..9e98b663394a 100644 --- a/.agents/skills/data-client-schema/SKILL.md +++ b/.agents/skills/data-client-schema/SKILL.md @@ -172,6 +172,8 @@ See [partial-entities](references/partial-entities.md) for patterns and examples # References +Vue projects: read `.vue.md` instead of `.md` when it exists. + For detailed API documentation, see the [references](references/) directory: - [Entity](references/Entity.md) - Normalized data class diff --git a/.agents/skills/data-client-schema/references.json b/.agents/skills/data-client-schema/references.json new file mode 100644 index 000000000000..bfd73d236b25 --- /dev/null +++ b/.agents/skills/data-client-schema/references.json @@ -0,0 +1,28 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "All.md": "docs/rest/api/All.md", + "Array.md": "docs/rest/api/Array.md", + "Collection.md": "docs/rest/api/Collection.md", + "Entity.md": "docs/rest/api/Entity.md", + "EntityMixin.md": "docs/rest/api/EntityMixin.md", + "Invalidate.md": "docs/rest/api/Invalidate.md", + "Lazy.md": "docs/rest/api/Lazy.md", + "Object.md": "docs/rest/api/Object.md", + "Query.md": "docs/rest/api/Query.md", + "Scalar.md": "docs/rest/api/Scalar.md", + "Union.md": "docs/rest/api/Union.md", + "Values.md": "docs/rest/api/Values.md", + "_ScalarDemo.md": "docs/rest/shared/_ScalarDemo.mdx", + "computed-properties.md": "docs/rest/guides/computed-properties.md", + "partial-entities.md": "docs/rest/guides/partial-entities.md", + "relational-data.md": "docs/rest/guides/relational-data.md", + "schema.md": "docs/rest/api/schema.md", + "side-effects.md": "docs/rest/guides/side-effects.md", + "sorting-client-side.md": "docs/rest/guides/sorting-client-side.md", + "validation.md": "docs/core/concepts/validation.md" + } +} diff --git a/.agents/skills/data-client-schema/references/All.md b/.agents/skills/data-client-schema/references/All.md deleted file mode 120000 index 8de907cdb111..000000000000 --- a/.agents/skills/data-client-schema/references/All.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/All.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/All.md b/.agents/skills/data-client-schema/references/All.md new file mode 100644 index 000000000000..c326ffeedc7e --- /dev/null +++ b/.agents/skills/data-client-schema/references/All.md @@ -0,0 +1,203 @@ + + +# All + +Retrieves all entities in cache as an Array. + +- `definition`: **required** A singular [Entity](./Entity.md) that this array contains _or_ a mapping of attribute values to [Entities](./Entity.md). +- `schemaAttribute`: _optional_ (required if `definition` is not a singular schema) The attribute on each entity found that defines what schema, per the definition mapping, 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. + +## Instance Methods + +- `define(definition)`: When used, the `definition` passed in will be merged with the original definition passed to the `All` constructor. This method tends to be useful for creating circular references in schema. + +## Usage + +To describe a simple array of a singular entity type: + +```tsx title="api/User" +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; +} +export const createUser = new RestEndpoint({ + path: '/users', + schema: User, + body: { name: '' }, + method: 'POST' +}); +``` + +```tsx title="NewUser" +import { useController } from '@data-client/react'; +import { createUser } from './api/User'; + +export default function NewUser() { + const ctrl = useController(); + const handlePress = React.useCallback( + async (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + ctrl.fetch(createUser, {name: e.currentTarget.value}); + e.currentTarget.value = ''; + } + }, + [fetch], + ); + return ; +} +``` + +```tsx title="UsersPage.tsx" +import { RestEndpoint, All } from '@data-client/rest'; +import { useSuspense } from '@data-client/react'; +import { User } from './api/User'; +import NewUser from './NewUser'; + +const getUsers = new RestEndpoint({ + path: '/users', + schema: new All(User), +}); + +function UsersPage() { + const users = useSuspense(getUsers); + return ( +
+ {users.map(user => ( +
{user.name}
+ ))} + +
+ ); +} +render(); +``` + +### Polymorphic types + +If your input data is an array of more than one type of entity, it is necessary to define a schema mapping. + +> **Note** +> +> If your data returns an object that you did not provide a mapping for, the original object will be returned in the result and an entity will not be created. + +#### string schemaAttribute + +```typescript title="api/Feed" +import { Entity, RestEndpoint, All } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + readonly id: number = 0; + declare readonly type: 'link' | 'post'; +} +export class Link extends FeedItem { + readonly type = 'link' as const; + readonly url: string = ''; + readonly title: string = ''; +} +export class Post extends FeedItem { + readonly type = 'post' as const; + readonly content: string = ''; +} +export const getFeed = new RestEndpoint({ + path: '/feed', + schema: new All( + { + link: Link, + post: Post, + }, + 'type', + ), +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { getFeed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(getFeed); + return ( +
+ {feedItems.map(item => + item.type === 'link' ? ( + + ) : ( + + ), + )} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return
{post.content}
; +} +render(); +``` + +#### function schemaAttribute + +The return values should match a key in the `definition`. Here we'll show the same behavior as the 'string' +case, except we'll append an 's'. + +```typescript title="api/Feed" +import { Entity, RestEndpoint, All } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + readonly id: number = 0; + declare readonly type: 'link' | 'post'; +} +export class Link extends FeedItem { + readonly type = 'link' as const; + readonly url: string = ''; + readonly title: string = ''; +} +export class Post extends FeedItem { + readonly type = 'post' as const; + readonly content: string = ''; +} +export const getFeed = new RestEndpoint({ + path: '/feed', + schema: new All( + { + links: Link, + posts: Post, + }, + (input: Link | Post, parent, key) => `${input.type}s`, + ), +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { getFeed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(getFeed); + return ( +
+ {feedItems.map(item => + item.type === 'link' ? ( + + ) : ( + + ), + )} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return
{post.content}
; +} +render(); +``` diff --git a/.agents/skills/data-client-schema/references/Array.md b/.agents/skills/data-client-schema/references/Array.md deleted file mode 120000 index 4647989540ee..000000000000 --- a/.agents/skills/data-client-schema/references/Array.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Array.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Array.md b/.agents/skills/data-client-schema/references/Array.md new file mode 100644 index 000000000000..172444768c24 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Array.md @@ -0,0 +1,195 @@ + + +# schema.Array + +Creates a schema to normalize an array of schemas. If the input value is an [Object](./Object.md) instead of an `Array`, +the normalized result will be an `Array` of the [Object](./Object.md)'s values. + +_Note: The same behavior can be defined with shorthand syntax: `[ mySchema ]`_ + +- `definition`: **required** A singular schema that this array contains _or_ a mapping of attribute values to schema. +- `schemaAttribute`: _optional_ (required if `definition` is not a singular schema) The attribute on each entity found that defines what schema, per the definition mapping, 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. + +> **Tip** +> +> For unbounded collections with `string` keys, use [schema.Values](./Values.md) + +> **Tip** +> +> Make it mutable (new items can be [pushed](./Collection.md#push)/[unshifted](./Collection.md#unshift)) with [Collections](./Collection.md) + +## Instance Methods + +- `define(definition)`: When used, the `definition` passed in will be merged with the original definition passed to the `Array` constructor. This method tends to be useful for creating circular references in schema. + +## Usage + +To describe a simple array of a singular entity type: + +```tsx title="Users.tsx" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; +import { useSuspense } from '@data-client/react'; + +export class User extends Entity { + id = ''; + name = ''; +} +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new schema.Array(User), +}); +function UsersPage() { + const users = useSuspense(getUsers); + return ( +
+ {users.map(user => ( +
{user.name}
+ ))} +
+ ); +} +render(); +``` + +### Updating many entities + +Use an Array with [Controller.set()](https://dataclient.io/docs/api/Controller#set-array) to write many entities in one store update, +without an endpoint. + +```ts +ctrl.set( + [User], + [ + { id: '123', name: 'Jim' }, + { id: '456', name: 'Jane' }, + ], +); +``` + +### Polymorphic types + +If your input data is an array of more than one type of entity, it is necessary to define a schema mapping. + +> **Note** +> +> If your data returns an object that you did not provide a mapping for, the original object will be returned in the result and an entity will not be created. + +#### string schemaAttribute + +```typescript title="api/Feed" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + readonly id: number = 0; + declare readonly type: 'link' | 'post'; +} +export class Link extends FeedItem { + readonly type = 'link' as const; + readonly url: string = ''; + readonly title: string = ''; +} +export class Post extends FeedItem { + readonly type = 'post' as const; + readonly content: string = ''; +} +export const getFeed = new RestEndpoint({ + path: '/feed', + schema: new schema.Array( + { + link: Link, + post: Post, + }, + 'type', + ), +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { getFeed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(getFeed); + return ( +
+ {feedItems.map(item => + item.type === 'link' ? ( + + ) : ( + + ), + )} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return
{post.content}
; +} +render(); +``` + +#### function schemaAttribute + +The return values should match a key in the `definition`. Here we'll show the same behavior as the 'string' +case, except we'll append an 's'. + +```typescript title="api/Feed" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + readonly id: number = 0; + declare readonly type: 'link' | 'post'; +} +export class Link extends FeedItem { + readonly type = 'link' as const; + readonly url: string = ''; + readonly title: string = ''; +} +export class Post extends FeedItem { + readonly type = 'post' as const; + readonly content: string = ''; +} +export const getFeed = new RestEndpoint({ + path: '/feed', + schema: new schema.Array( + { + links: Link, + posts: Post, + }, + (input: Link | Post, parent, key) => `${input.type}s`, + ), +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { getFeed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(getFeed); + return ( +
+ {feedItems.map(item => + item.type === 'link' ? ( + + ) : ( + + ), + )} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return
{post.content}
; +} +render(); +``` diff --git a/.agents/skills/data-client-schema/references/Collection.md b/.agents/skills/data-client-schema/references/Collection.md deleted file mode 120000 index 19ac7e11e9a7..000000000000 --- a/.agents/skills/data-client-schema/references/Collection.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Collection.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Collection.md b/.agents/skills/data-client-schema/references/Collection.md new file mode 100644 index 000000000000..ac665e71a095 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Collection.md @@ -0,0 +1,659 @@ + + +# Collection + +`Collections` define mutable [Lists (Array)](./Array.md) or [Maps (Values)](./Values.md). + +This means they can grow and shrink. You can add to `Collection(Array)` with [.push](#push) or [.unshift](#unshift), +remove from `Collection(Array)` with [.remove](#remove), add to `Collections(Values)` with [.assign](#assign), +and move between collections with [.move](#move). + +[RestEndpoint](https://dataclient.io/rest/api/RestEndpoint) provides [.push](https://dataclient.io/rest/api/RestEndpoint#push), [.unshift](https://dataclient.io/rest/api/RestEndpoint#unshift), [.assign](https://dataclient.io/rest/api/RestEndpoint#assign), [.remove](https://dataclient.io/rest/api/RestEndpoint#remove), [.move](https://dataclient.io/rest/api/RestEndpoint#move) +and [.getPage](https://dataclient.io/rest/api/RestEndpoint#getpage)/ [.paginated()](https://dataclient.io/rest/api/RestEndpoint#paginated) extenders when using `Collections` + +## Usage + +```ts title="api/Todo" {12-14,19} +import { Entity, RestEndpoint, Collection } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = 0; + title = ''; + completed = false; + + 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: userTodos, +}); +``` + +```ts title="api/User" {13,19} +import { Entity, RestEndpoint, Collection } from '@data-client/rest'; +import { Todo, userTodos } from './Todo'; + +export class User extends Entity { + id = ''; + name = ''; + username = ''; + email = ''; + todos: Todo[] = []; + + static key = 'User'; + static schema = { + todos: userTodos, + }; +} + +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new Collection([User]), +}); +``` + +```tsx title="NewTodo" {10-14} +import { useController } from '@data-client/react'; +import { getTodos } from './api/Todo'; + +export default function NewTodo({ userId }: { userId?: string }) { + const ctrl = useController(); + const [unshift, setUnshift] = React.useState(false); + + const handlePress = async e => { + if (e.key === 'Enter') { + const createTodo = unshift ? getTodos.unshift : getTodos.push; + ctrl.fetch(createTodo, { + title: e.currentTarget.value, + userId, + }); + e.currentTarget.value = ''; + } + }; + + return ( +
+ + +
+ ); +} +``` + +```tsx title="TodoList" +import { type Todo } from './api/Todo'; +import NewTodo from './NewTodo'; + +export default function TodoList({ + todos, + userId, +}: { + todos: Todo[]; + userId: string; +}) { + return ( +
+ {todos.map(todo => ( +
{todo.title}
+ ))} + +
+ ); +} +``` + +```tsx title="UserList" +import { useSuspense } from '@data-client/react'; +import { getUsers } from './api/User'; +import TodoList from './TodoList'; + +function UserList() { + const users = useSuspense(getUsers); + return ( +
+ {users.map(user => ( +
+

{user.name}

+ +
+ ))} +
+ ); +} +render(); +``` + +### Collection with Values + +When an API returns keyed objects rather than arrays, combine `Collection` with [Values](./Values.md) +to enable mutations on the result. + +```typescript +import { Entity, resource, Collection, Values } from '@data-client/rest'; + +class Stats extends Entity { + product_id = ''; + volume = 0; + price = 0; + + pk() { + return this.product_id; + } + + static key = 'Stats'; +} + +export const StatsResource = resource({ + urlPrefix: 'https://api.exchange.example.com', + path: '/products/:product_id/stats', + schema: Stats, +}).extend({ + getList: { + path: '/products/stats', + // Collection wraps Values to enable .push, .assign, etc. + schema: new Collection(new Values(Stats)), + process(value) { + // Transform nested response structure + Object.keys(value).forEach(key => { + value[key] = { + ...value[key].stats_24hour, + product_id: key, + }; + }); + return value; + }, + }, +}); +``` + +This allows adding or updating entries with [.assign](./Collection.md#assign). The body is an object +where keys are the collection keys and values are the entity data to merge: + +```typescript +// Local-only update with ctrl.set() +ctrl.set(StatsResource.getList.schema.assign, {}, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); + +// Network request with ctrl.fetch() - see RestEndpoint.assign +await ctrl.fetch(StatsResource.getList.assign, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, +}); +``` + +## Options + +`argsKey` and `nestKey` compute a `Collection` [pk](#pk). `argsKey` is used +when a `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} + +Returns a serializable Object whose members uniquely define this collection based +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: 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. + +A nested `Collection` [pk](#pk) is usually best defined by what it is nested +inside. This allows nested `Collection` instances to share state when their keys +have the same value. When `argsKey` and `nestKey` return the same object shape, +top-level and nested reads resolve to the same collection state. + +```ts {13} +import { Entity } from '@data-client/rest'; +import { Todo, userTodos } from './Todo'; + +class User extends Entity { + id = ''; + name = ''; + username = ''; + email = ''; + todos: Todo[] = []; + + static key = 'User'; + static schema = { + todos: userTodos, + }; +} +``` + +In this case, `user.todos` and the `getTodos()` response from the `argsKey` +example are always 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} + +A convenient alternative to [argsKey](#argsKey) + +`nonFilterArgumentKeys` defines a test to determine which [argument keys](#argsKey) +are _not_ used for filtering the results. For instance, if your API uses +'orderBy' to choose a sort - this argument would not influence which +entities are included in the response. + +```ts +const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Collection([Post], { + nonFilterArgumentKeys(key) { + return key === 'orderBy'; + }, + }), +}); +``` + +For convenience you can also use a RegExp or list of strings: + +```ts +const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Collection([Post], { + nonFilterArgumentKeys: /orderBy/, + }), +}); +``` + +```ts +const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Collection([Post], { + nonFilterArgumentKeys: ['orderBy'], + }), +}); +``` + +In this case, `author` and `group` are considered 'filter' argument keys, +which means they will influence whether a newly created should be added +to those lists. On the other hand, `orderBy` does not need to match +when `push` is called. + +```ts title="getPosts" {14} +import { Entity, Query, Collection, RestEndpoint } from '@data-client/rest'; + +class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} +export const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Query( + new Collection([Post], { + nonFilterArgumentKeys: /orderBy/, + }), + (posts, { orderBy } = {}) => { + if (orderBy) { + return [...posts].sort((a, b) => a[orderBy].localeCompare(b[orderBy])); + } + return posts; + }, + ) +}); +``` + +```tsx title="PostListLayout" +import { useLoading } from '@data-client/react'; + +export default function PostListLayout({ + postsByBob, + postsSorted, + addPost, +}) { + const [handleSubmit, loading] = useLoading(addPost); + return ( +
+

{group: 'react', author: 'bob'}

+
    + {postsByBob.map(post => ( +
  • + {post.title} by {post.author} +
  • + ))} +
+

{group: 'react', orderBy: 'title'}

+
    + {postsSorted.map(post => ( +
  • + {post.title} by {post.author} +
  • + ))} +
+
+
Group: React
+ Author: + + + + + +
+ ); +} +``` + +```tsx title="PostList" +import { useSuspense, useController } from '@data-client/react'; +import { getPosts } from './getPosts'; +import PostListLayout from './PostListLayout'; + +function PostList() { + const postsByBob = useSuspense(getPosts, { + group: 'react', + author: 'bob', + }); + const postsSorted = useSuspense(getPosts, { + group: 'react', + orderBy: 'title', + }); + + const ctrl = useController(); + + const addPost = (e) => { + e.preventDefault(); + return ctrl.fetch( + getPosts.push, + { group: 'react' }, + new FormData(e.currentTarget), + ); + } + return ( + + ); +} +render(); +``` + +### createCollectionFilter? + +Sets a default `createCollectionFilter` for [addWith()](#addWith), +[push](#push), [unshift](#unshift), and [assign](#assign). + +This is used by these creation schemas to determine which collections to add to. + +Default: + +```ts +createCollectionFilter(...args: Args) { + return (collectionKey: Record) => + Object.entries(collectionKey).every( + ([key, value]) => + this.nonFilterArgumentKeys(key) || + // strings are canonical form. See pk() above for value transformation + `${args[0][key]}` === value || + `${args[1]?.[key]}` === value, + ); +} +``` + +## Methods + +These creation/removal schemas can be used with [Controller.set()](https://dataclient.io/docs/api/Controller#set) for local-only +updates without network requests. For network-based mutations, see [RestEndpoint's specialized extenders](https://dataclient.io/rest/api/RestEndpoint#push). + +### push + +A creation schema that places new item(s) at the _end_ of this collection. + +```ts +// Add a new todo to the end of the list (local only, no network request) +ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' }); +``` + +### unshift + +A creation schema that places new item(s) at the _start_ of this collection. + +```ts +// Add a new todo to the beginning of the list (local only) +ctrl.set(getTodos.schema.unshift, { userId: '1' }, { id: '999', title: 'New Todo' }); +``` + +### remove + +A schema that removes item(s) from a collection by value. + +The entity value is normalized to extract its pk, which is then matched against collection members. +Items are removed from all collections matching the provided args (filtered by [createCollectionFilter](#createcollectionfilter)). + +```ts +// Remove from collections matching { userId: '1' } (local only) +ctrl.set(getTodos.schema.remove, { userId: '1' }, { id: '123' }); +``` + +```ts +// Remove from all collections (empty args matches all) +ctrl.set(getTodos.schema.remove, {}, { id: '123' }); +``` + +For network-based removal that also updates the entity, see [RestEndpoint.remove](https://dataclient.io/rest/api/RestEndpoint#remove). + +### move + +A schema that moves item(s) between collections. It removes the entity from collections matching +its _existing_ state and adds it to collections matching the entity's _new_ state (derived from the last arg). + +This works for both `Collection(Array)` and `Collection(Values)`. + +```ts +// Move todo from userId '1' collection to userId '2' collection (local only) +ctrl.set( + getTodos.schema.move, + { id: '10', userId: '2', title: 'Moved todo' }, + [{ id: '10' }, { userId: '2' }], +); +``` + +The remove filter uses the entity's **existing** values in the store to determine which collections +it currently belongs to. The add filter uses the merged entity values (existing + last arg) to determine +where it should be placed. + +For network-based moves, see [RestEndpoint.move](https://dataclient.io/rest/api/RestEndpoint#move). + +### assign + +A creation schema that [assigns](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) +its members to a `Collection(Values)`. Only available for Collections wrapping [Values](./Values.md). + +```ts +const getStats = new RestEndpoint({ + path: '/products/stats', + schema: new Collection(new Values(Stats)), +}); + +// Add/update entries in a Values collection (local only) +ctrl.set(getStats.schema.assign, {}, { + 'BTC-USD': { product_id: 'BTC-USD', volume: 1000 }, + 'ETH-USD': { product_id: 'ETH-USD', volume: 500 }, +}); +``` + +### addWith(merge, createCollectionFilter): CreationSchema {#addWith} + +Constructs a custom creation schema for this collection. This is used by +[push](#push), [unshift](#unshift), [assign](#assign) and [paginate](https://dataclient.io/rest/api/RestEndpoint#paginated) + +#### merge(collection, creation) + +This [merges](#merge) the value with the existing collection + +#### createCollectionFilter + +This function is used to determine which collections to add to. It +uses the Object returned from [argsKey](#argsKey) or [nestKey](#nestKey) to +determine if that collection should get the newly created values from this schema. + +Because arguments may be serializable types like `number`, we recommend using `==` comparisons, +e.g., `'10' == 10` + +```typescript +(...args) => + collectionKey => + boolean; +``` + +### moveWith(merge): MoveSchema {#moveWith} + +Constructs a custom move schema for this collection. This is analogous to [addWith](#addWith) +but for [move](#move) operations. The `merge` function controls how entities are added to +their destination collection, while the remove behavior is automatically derived from +the collection type (Array or Values). + +This is useful when you need to control the insertion position of moved items +(e.g., prepending instead of appending). + +#### merge(collection, moved) + +Controls how the moved entity is added to its destination collection. + +The exported [`unshift`](#unshift-merge) merge function places items at the start: + +```ts +import { Collection, unshift, type CollectionOptions } from '@data-client/rest'; +import type { PolymorphicInterface } from '@data-client/endpoint'; + +class MyCollection< + S extends any[] | PolymorphicInterface = any, + Args extends any[] = any[], + Parent = any, +> extends Collection { + constructor(schema: S, options?: CollectionOptions) { + super(schema, options); + // Prepend moved items instead of appending + this.move = this.moveWith(unshift); + } +} +``` + +### unshift (merge function) {#unshift-merge} + +A merge function that places incoming items at the _start_ of the collection. +Use with [moveWith](#moveWith) or [addWith](#addWith) to control insertion order. + +```ts +import { unshift } from '@data-client/rest'; +``` + +## Lifecycle Methods + +### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldReorder} + +```typescript +static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return incomingMeta.fetchedAt < existingMeta.fetchedAt; +} +``` + +`true` return value will reorder incoming vs in-store entity argument order in merge. With +the default merge, this will cause the fields of existing entities to override those of incoming, +rather than the other way around. + +### static merge(existing, incoming): mergedValue {#merge} + +```typescript +static merge(existing: any, incoming: any) { + return incoming; +} +``` + +### static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue {#mergeWithStore} + +```typescript +static mergeWithStore( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +): any; +``` + +`mergeWithStore()` is called during normalization when a processed entity is already found in the store. + +### pk: (parent?, key?, args?, parentEntity?): pk? {#pk} + +`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[], + 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]}`; + } + return JSON.stringify(obj); +} +``` diff --git a/.agents/skills/data-client-schema/references/Entity.md b/.agents/skills/data-client-schema/references/Entity.md deleted file mode 120000 index afd13a1e1184..000000000000 --- a/.agents/skills/data-client-schema/references/Entity.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Entity.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Entity.md b/.agents/skills/data-client-schema/references/Entity.md new file mode 100644 index 000000000000..ea9377aad65a --- /dev/null +++ b/.agents/skills/data-client-schema/references/Entity.md @@ -0,0 +1,753 @@ + + +# Entity + +```ts +{ + Article: { + '1': { + id: '1', + title: 'Entities define data', + } + } +} +``` + +`Entity` defines a single _unique_ object. + +[Entity.key](#key) + [Entity.pk()](#pk) (primary key) enable a [flat lookup table](https://react.dev/learn/choosing-the-state-structure#principles-for-structuring-state) store, enabling high +performance, data consistency and atomic mutations. + +`Entities` enable customizing the data processing lifecycle by defining its static members like [schema](#schema) +and overriding its [lifecycle methods](#lifecycle). + +## Usage + +```typescript title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + username = ''; + + static key = 'User'; + pk() { + return this.id; + } +} +``` + +```typescript title="Article" +import { Entity } from '@data-client/rest'; +import { User } from './User'; + +export class Article extends Entity { + id = ''; + title = ''; + content = ''; + author = User.fromJS(); + tags: string[] = []; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static key = 'Article'; + pk() { + return this.id; + } + + static schema = { + author: User, + createdAt: Temporal.Instant.from, + }; +} +``` + +[static schema](#schema) is a declarative definition of fields to process. +In this case, `author` is another `Entity` to be extracted, and `createdAt` will be converted +from a string to a [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) +object. + +> **Tip** +> +> Entities are bound to Endpoints using [resource.schema](https://dataclient.io/rest/api/resource#schema) or +> [RestEndpoint.schema](https://dataclient.io/rest/api/RestEndpoint#schema) + +> **Tip** +> +> If you already have your classes defined, [EntityMixin](./EntityMixin.md) can also be +> used to make Entities. + +Other static members overrides allow customizing the data lifecycle as seen below. + +## Members + +### pk(parent?, key?, args?): string | number | undefined {#pk} + +pk stands for [_primary key_](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-PRIMARY-KEYS), uniquely identifying an `Entity` instance. +By default this returns the an Entity's `id` field. + +Override this method to use other fields, or to for other cases like +multicolumn primary keys. + +#### undefined value + +A `undefined` can be used as a default to indicate the entity has not been created yet. +This is useful when initializing a creation form using [Entity.fromJS()](#fromJS) +directly. If `pk()` returns `undefined` it is considered not persisted to the server, +and thus will not be kept in the cache. + +#### Other uses + +Since `pk()` is unique, it provides a consistent way of defining [JSX list keys](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key) + +```tsx +//.... +return ( +
+ {results.map(result => ( + + ))} +
+); +``` + +#### Composite Primary Keys + +When a single field isn't enough to uniquely identify an entity, you can combine multiple +fields into a composite key. This is common for nested resources or resources with +multi-part identifiers. + +```typescript +export class Issue extends Entity { + number = 0; + owner = ''; + repo = ''; + repositoryUrl = ''; + title = ''; + + pk() { + // Composite key from owner, repo, and issue number + return `${this.owner}/${this.repo}/${this.number}`; + } + + static key = 'Issue'; +} +``` + +When entity data doesn't include all key parts directly, you can extract them from related +fields or endpoint arguments using [Entity.process()](#process): + +```typescript +export class Issue extends Entity { + number = 0; + owner = ''; + repo = ''; + repositoryUrl = ''; // Contains: https://api.github.com/repos/{owner}/{repo} + title = ''; + + pk() { + // Use owner/repo from process() which extracts from repositoryUrl + return `${this.owner}/${this.repo}/${this.number}`; + } + + static key = 'Issue'; + + static process(input: any, parent: any, key: string, args: any[]) { + // Extract owner and repo from the repositoryUrl + const match = input.repositoryUrl?.match(/repos\/([^/]+)\/([^/]+)/); + const owner = args[0]?.owner ?? match?.[1]; + const repo = args[0]?.repo ?? match?.[2]; + return { ...input, owner, repo }; + } +} +``` + +#### Singleton Entities + +What if there is only ever once instance of a Entity for your entire application? You +don't really need to distinguish between each instance, so likely there was no `id` or +similar field defined in the API. In these cases you can just return a literal like +'the\_only\_one'. + +```typescript +pk() { + return 'the_only_one'; +} +``` + +In case you have + +```typescript +const get = new RestEndpoint({ + path: '/options', + schema: OptionsEntity, +}); +export const OptionsResource = { + get, + partialUpdate: get.extend({ method: 'PATCH' }), +} +``` + +### static key: string {#key} + +This defines the key for the Entity kind, rather than an instance. This needs to be a globally +unique value. + +> **Warning** +> +> This defaults to `this.name`; however this may break in production builds that change class names. +> This is often know as [class name mangling](https://terser.org/docs/api-reference#mangle-options). +> +> In these cases you can override `key` or disable class name mangling. + +```ts +class User extends Entity { + id = ''; + username = ''; + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +### static schema: { \[k: keyof this]: Schema } {#schema} + +Defines [related entity](./relational-data.md) members, or +[field deserialization](https://dataclient.io/rest/guides/network-transform#deserializing-fields) like Date and BigNumber. + +```ts title="User" +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {16-20} +import { Entity } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = ''; + author = User.fromJS(); + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + content = ''; + title = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + createdAt: Temporal.Instant.from, + }; +} +``` + +```tsx title="PostPage" +import { Post } from './Post'; + +export const getPost = new RestEndpoint({ + path: '/posts/:id', + schema: Post, +}); +function PostPage() { + const post = useSuspense(getPost, { id: '123' }); + return ( +
+

+ {post.content} - {post.author.name} +

+ +
+ ); +} +render(); +``` + +#### Optional members + +Entities references here whose default values in the Record definition itself are +considered 'optional' + +```typescript +class User extends Entity { + friend: User | null = null; // this field is optional + lastUpdated = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + friend: User, + lastUpdated: Temporal.Instant.from, + }; +} +``` + +### static indexes?: (keyof this)\[] {#indexes} + +Indexes enable increased performance when doing lookups based on those parameters. Add +fieldnames (like `slug`, `username`) to the list that you want to send as params to lookup +later. + +> **Note** +> +> Don't add your primary key like `id` to the indexes list, as that will already be optimized. + +#### useSuspense() + +With [useSuspense()](https://dataclient.io/docs/api/useSuspense) this will eagerly infer the results from entities table if possible, +rendering without needing to complete the fetch. This is typically helpful when the entities +cache has already been populated by another request like a list request. + +```typescript +export class User extends Entity { + id: number | undefined = undefined; + username = ''; + email = ''; + isAdmin = false; + + static indexes = ['username' as const]; +} +export const UserResource = resource({ + path: '/user/:id', + schema: User, +}); +``` + +```tsx +const user = useSuspense(UserResource.get, { username: 'bob' }); +``` + +#### useQuery() + +With [useQuery()](https://dataclient.io/docs/api/useQuery), this enables accessing results retrieved inside other requests - even +if there is no endpoint it can be fetched from. + +```typescript +class LatestPrice extends Entity { + id = ''; + symbol = ''; + price = '0.0'; + + static indexes = ['symbol' as const]; +} +``` + +```typescript +class Asset extends Entity { + id = ''; + price = ''; + + static schema = { + price: LatestPrice, + }; +} +const getAssets = new RestEndpoint({ + path: '/assets', + schema: [Asset], +}); +``` + +Some top level component: + +```tsx +const assets = useSuspense(getAssets); +``` + +Nested below: + +```tsx +const price = useQuery(LatestPrice, { symbol: 'BTC' }); +``` + +### static maxEntityDepth?: number {#maxEntityDepth} + +Limits entity nesting depth during denormalization to prevent stack overflow +in large bidirectional entity graphs. **Default: 128** + +When bidirectional relationships create chains with many unique entities +(e.g., `Department → Building → Department → ...`), denormalization can recurse +thousands of levels deep. `maxEntityDepth` truncates resolution at the specified +depth — entities beyond the limit are returned with nested foreign keys left as +unresolved ids rather than fully denormalized objects. + +```typescript +class Department extends Entity { + id = ''; + name = ''; + buildings: Building[] = []; + + pk() { return this.id; } + static key = 'Department'; + static maxEntityDepth = 16; + + static schema = { + buildings: [Building], + }; +} +``` + +> **Tip** +> +> Set this on entities that participate in deep or wide bidirectional relationships. +> Normal entity graphs (depth < 10) never approach the default limit. +> +> For relationships that don't need eager denormalization, [Lazy](./Lazy.md) +> skips resolution entirely and lets you resolve on demand via [useQuery](https://dataclient.io/docs/api/useQuery). + +## Lifecycle + +```mermaid +flowchart BT + subgraph Controller.getResponse + queryKey("Entity.queryKey()")---pk2 + pk2("Entity.pk()")---Entity.createIfValid + subgraph Entity.createIfValid + direction TB + validate2("Entity.validate()")---fromJS("Entity.fromJS()") + end + Entity.createIfValid-->denormNest("Entity.denormalize") + end + subgraph Controller.setResponse + direction LR + subgraph Entity.normalize + direction TB + process("Entity.process()")-->pk("Entity.pk()") + pk---validate("Entity.validate()") + process-->validate + validate---normNest("normalize(this.schema)") + normNest-->mergeEntity("delegate.mergeEntity()") + end + Entity.normalize--processedEntity-->INSTORE + subgraph INSTORE["Found In Store"] + subgraph Entity.mergeWithStore + direction TB + shouldupdate("Entity.shouldUpdate()")---shouldreorder("Entity.shouldReorder()") + shouldreorder---merge("Entity.merge()") + end + Entity.mergeWithStore---mergeMetaWithStore("Entity.mergeMetaWithStore()") + end + end + click process "/rest/api/Entity#process" + click pk "/rest/api/Entity#pk" + click pk2 "/rest/api/Entity#pk" + click fromJS "/rest/api/Entity#fromJS" + click validate "/rest/api/Entity#validate" + click validate2 "/rest/api/Entity#validate" + click mergeMetaWithStore "/rest/api/Entity#mergeMetaWithStore" + click shouldupdate "/rest/api/Entity#shouldupdate" + click shouldreorder "/rest/api/Entity#shouldreorder" + click mergewithstore "/rest/api/Entity#mergeWithStore" + click merge "/rest/api/Entity#merge" + click queryKey "/rest/api/Entity#queryKey" +``` + +### static fromJS(props): Entity {#fromJS} + +Factory method that copies props to a new instance. Use this instead of `new MyEntity()`, +to ensure default props are overridden. + +### static process(input, parent, key, args): processedEntity {#process} + +Run at the start of normalization for this entity. Return value is saved in store +and sent to [pk()](#pk). + +**Defaults** to simply copying the response (`{...input}`) + +How to override to [build reverse-lookups for relational data](./relational-data.md#reverse-lookups) + +#### Case of the missing id + +```ts +class Stream extends Entity { + username = ''; + title = ''; + game = ''; + currentViewers = 0; + live = false; + + pk() { + return this.username; + } + static key = 'Stream'; + + static process(value, parent, key, args) { + // super.process creates a copy of value + const processed = super.process(value, parent, key, args); + processed.username = args[0]?.username; + return processed; + } +} +``` + +#### Dynamic Invalidation + +Returning `undefined` from [Entity.process](#process) +will cause the `Entity` to be [invalidated](https://dataclient.io/docs/concepts/expiry-policy#invalidate-entity). +This this allows us to invalidate dynamically; based on the particular response data. + +```ts +class PriceLevel extends Entity { + price = 0; + amount = 0; + + pk() { + return this.price; + } + + static process( + input: [number, number], + parent: any, + key: string | undefined, + ): any { + const [price, amount] = input; + if (amount === 0) return undefined; + return { price, amount }; + } +} +``` + +### static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue {#mergeWithStore} + +```typescript +static mergeWithStore( + existingMeta: { + date: number; + fetchedAt: number; + }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + const shouldUpdate = this.shouldUpdate( + existingMeta, + incomingMeta, + existing, + incoming, + ); + + if (shouldUpdate) { + // distinct types are not mergeable (like delete symbol), so just replace + if (typeof incoming !== typeof existing) { + return incoming; + } else { + return this.shouldReorder( + existingMeta, + incomingMeta, + existing, + incoming, + ) + ? this.merge(incoming, existing) + : this.merge(existing, incoming); + } + } else { + return existing; + } +} +``` + +`mergeWithStore()` is called during normalization when a processed entity is already found in the store. + +This calls [shouldUpdate()](#shouldupdate), [shouldReorder()](#shouldreorder) and potentially [merge()](#merge) + +### static shouldUpdate(existingMeta, incomingMeta, existing, incoming): boolean {#shouldupdate} + +```typescript +static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return existingMeta.fetchedAt <= incomingMeta.fetchedAt; +} +``` + +#### Preventing updates + +shouldUpdate can also be used to short-circuit an entity update. + +```typescript +import deepEqual from 'deep-equal'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; + published = false; + + static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, + ) { + return !deepEqual(incoming, existing); + } +} +``` + +### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldreorder} + +```typescript +static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return incomingMeta.fetchedAt < existingMeta.fetchedAt; +} +``` + +`true` return value will reorder incoming vs in-store entity argument order in merge. With +the default merge, this will cause the fields of existing entities to override those of incoming, +rather than the other way around. + +#### Example + +```typescript +class LatestPriceEntity extends Entity { + id = ''; + updatedAt = 0; + price = '0.0'; + symbol = ''; + + pk() { + return this.id; + } + + static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: { updatedAt: number }, + incoming: { updatedAt: number }, + ) { + return incoming.updatedAt < existing.updatedAt; + } +} +``` + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts)) + +### static merge(existing, incoming): mergedValue {#merge} + +```typescript +static merge(existing: any, incoming: any) { + return { + ...existing, + ...incoming, + }; +} +``` + +Merge is used to handle cases when an incoming entity is already found. This is called directly +when the same entity is found in one response. By default it is also called when [mergeWithStore()](#mergeWithStore) +determines the incoming entity should be merged with an entity already persisted in the Reactive Data Client store. + +How to override to [build reverse-lookups for relational data](./relational-data.md#reverse-lookups) + +### static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming): meta {#mergeMetaWithStore} + +```typescript +static mergeMetaWithStore( + existingMeta: { + expiresAt: number; + date: number; + fetchedAt: number; + }, + incomingMeta: { expiresAt: number; date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) + ? existingMeta + : incomingMeta; +} +``` + +`mergeMetaWithStore()` is called during normalization when a processed entity is already found in the store. + +### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} + +This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. + +Overriding can allow customization or disabling of this behavior altogether. + +Returning `undefined` will disallow this behavior. + +Returning `pk` string will attempt to lookup this entity and use in the response. + +When used, expiry policy is computed based on the entity's own meta data. + +By **default** uses the first argument to lookup in [pk()](#pk) and [indexes](./Entity.md#indexes) + +#### getEntity(key, pk?) + +Gets all entities of a type with one argument, or a single entity with two + +```ts title="One argument" +const entitiesEntry = getEntity(this.schema.key); +if (entitiesEntry === undefined) return INVALID; +return Object.values(entitiesEntry).map( + entity => entity && this.schema.pk(entity), +); +``` + +```ts title="Two arguments" +if (getEntity(this.key, id)) return id; +``` + +#### getIndex(key, indexName, value) + +Returns the index entry (value->pk map) + +```ts +const value = args[0][indexName]; +return getIndex(schema.key, indexName, value)[value]; +``` + +### static createIfValid(processedEntity): Entity | undefined {#createIfValid} + +Called when denormalizing an entity. This will create an instance of this class +if it is deemed 'valid'. + +`undefined` return will result in [Invalid expiry status](https://dataclient.io/docs/concepts/expiry-policy#expiry-status), +like [Invalidate](./Invalidate.md). + +[`Invalid`](https://dataclient.io/docs/concepts/expiry-policy#expiry-status) expiry generally means hooks will enter a loading state and attempt a new fetch. + +```ts +static createIfValid(props): AbstractInstanceType | undefined { + if (this.validate(props)) { + return undefined as any; + } + return this.fromJS(props); +} +``` + +### static validate(processedEntity): errorMessage? {#validate} + +Runs during both normalize and denormalize. Returning a string indicates an error (the string is the message). + +During normalization a validation failure will result in an error for that fetch. + +During denormalization a validation failure will mark that result as 'invalid' and thus +will block on fetching a result. + +By **default** does some basic field existance checks in development mode only. Override to +disable or customize. + +[Using validation for endpoints with incomplete fields](./partial-entities.md) diff --git a/.agents/skills/data-client-schema/references/EntityMixin.md b/.agents/skills/data-client-schema/references/EntityMixin.md deleted file mode 120000 index dd3773c826ba..000000000000 --- a/.agents/skills/data-client-schema/references/EntityMixin.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/EntityMixin.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/EntityMixin.md b/.agents/skills/data-client-schema/references/EntityMixin.md new file mode 100644 index 000000000000..5f406564f963 --- /dev/null +++ b/.agents/skills/data-client-schema/references/EntityMixin.md @@ -0,0 +1,477 @@ + + +# EntityMixin + +`Entity` defines a single _unique_ object. + +If you already have classes for your data-types, `EntityMixin` may be for you. + +```typescript {10} +import { EntityMixin } from '@data-client/rest'; + +export class Article { + id = ''; + title = ''; + content = ''; + tags: string[] = []; +} + +export class ArticleEntity extends EntityMixin(Article) {} +``` + +## Options + +The second argument to the mixin can be used to conveniently customize construction. If not specified the `Base` +class' static members will be used. Alternatively, just like with [Entity](./Entity.md), you can always specify +these as static members of the final class. + +```typescript +class User { + username = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); +} +class UserEntity extends EntityMixin(User, { + pk: 'username', + key: 'User', + schema: { createdAt: Temporal.Instant.from }, +}) {} +``` + +### pk: string | (value, parent?, key?, args?) => string | number | undefined = 'id' {#pk} + +Specifies the [Entity.pk](./Entity.md#pk) + +A `string` indicates the field to use for pk. + +A `function` is used just like [Entity.pk](./Entity.md#pk), but the first argument (`value`) is `this` + +Defaults to 'id'; which means pk is a required option _unless_ the `Base` class has a serializable `id` member. + +```typescript title="multi-column primary key" +class Thread { + forum = ''; + slug = ''; + content = ''; +} +class ThreadEntity extends EntityMixin(Thread, { + pk(value) { + return [value.forum, value.slug].join(','); + }, +}) {} +``` + +### key: string {#key} + +Specifies the [Entity.key](./Entity.md#key) + +### schema: {\[k\:string]: Schema} {#schema} + +Specifies the [Entity.schema](./Entity.md#schema) + +## const vs class + +If you don't need to further customize the entity, you can use a `const` declaration instead +of `extend` to another class. + +There is a subtle difference when referring to the `class token` in TypeScript - as +`class` declarations will refer to the instance type; whereas `const tokens` refer to the value, so you +must use `typeof`, but additionally typeof gives the class type, so you must layer `InstanceType` +on top. + +```typescript +import { schema } from '@data-client/rest'; + +export class Article { + id = ''; + title = ''; + content = ''; + tags: string[] = []; +} + +export class ArticleEntity extends EntityMixin(Article) {} +export const ArticleEntity2 = EntityMixin(Article); + +const article: ArticleEntity = ArticleEntity.fromJS(); +const articleFails: ArticleEntity2 = ArticleEntity2.fromJS(); +const articleWorks: InstanceType = + ArticleEntity2.fromJS(); +``` + +## Lifecycle + +```mermaid +flowchart BT + subgraph Controller.getResponse + queryKey("Entity.queryKey()")---pk2 + pk2("Entity.pk()")---Entity.createIfValid + subgraph Entity.createIfValid + direction TB + validate2("Entity.validate()")---fromJS("Entity.fromJS()") + end + Entity.createIfValid-->denormNest("Entity.denormalize") + end + subgraph Controller.setResponse + direction LR + subgraph Entity.normalize + direction TB + process("Entity.process()")-->pk("Entity.pk()") + pk---validate("Entity.validate()") + process-->validate + validate---normNest("normalize(this.schema)") + normNest-->mergeEntity("delegate.mergeEntity()") + end + Entity.normalize--processedEntity-->INSTORE + subgraph INSTORE["Found In Store"] + subgraph Entity.mergeWithStore + direction TB + shouldupdate("Entity.shouldUpdate()")---shouldreorder("Entity.shouldReorder()") + shouldreorder---merge("Entity.merge()") + end + Entity.mergeWithStore---mergeMetaWithStore("Entity.mergeMetaWithStore()") + end + end + click process "/rest/api/Entity#process" + click pk "/rest/api/Entity#pk" + click pk2 "/rest/api/Entity#pk" + click fromJS "/rest/api/Entity#fromJS" + click validate "/rest/api/Entity#validate" + click validate2 "/rest/api/Entity#validate" + click mergeMetaWithStore "/rest/api/Entity#mergeMetaWithStore" + click shouldupdate "/rest/api/Entity#shouldupdate" + click shouldreorder "/rest/api/Entity#shouldreorder" + click mergewithstore "/rest/api/Entity#mergeWithStore" + click merge "/rest/api/Entity#merge" + click queryKey "/rest/api/Entity#queryKey" +``` + +To override lifecycle methods like [process()](#process), you must use the `class ... extends EntityMixin(...) {}` form. +The `EntityMixin()` options only include [pk](#pk), [key](#key), and [schema](#schema)—lifecycle overrides live on the class itself. + +```typescript +import { EntityMixin } from '@data-client/rest'; + +export class Article { + id = ''; + title = ''; + content = ''; + tags: string[] = []; +} + +// ❌ Not supported (lifecycle methods are not EntityMixin options) +// export const ArticleEntity = EntityMixin(Article, { +// process(input) { +// return input; +// }, +// }); + +// ✅ Use a class when adding lifecycle methods +export class ArticleEntity extends EntityMixin(Article) { + static process(input: any, parent: any, key: string | undefined, args: any[]) { + const processed = super.process(input, parent, key, args); + processed.tags ??= []; + return processed; + } +} +``` + +### static fromJS(props): Entity {#fromJS} + +Factory method that copies props to a new instance. Use this instead of `new MyEntity()`, +to ensure default props are overridden. + +### static process(input, parent, key, args): processedEntity {#process} + +Run at the start of normalization for this entity. Return value is saved in store +and sent to [pk()](#pk). + +**Defaults** to simply copying the response (`{...input}`) + +How to override to [build reverse-lookups for relational data](./relational-data.md#reverse-lookups) + +#### Case of the missing id + +```ts +import { EntityMixin } from '@data-client/rest'; + +class Stream { + username = ''; + title = ''; + game = ''; + currentViewers = 0; + live = false; +} + +class StreamEntity extends EntityMixin(Stream) { + static key = 'Stream'; + + static process(value, parent, key, args) { + // super.process creates a copy of value + const processed = super.process(value, parent, key, args); + processed.username = args[0]?.username; + return processed; + } +} +``` + +#### Dynamic Invalidation + +Returning `undefined` from [Entity.process](#process) +will cause the `Entity` to be [invalidated](https://dataclient.io/docs/concepts/expiry-policy#invalidate-entity). +This this allows us to invalidate dynamically; based on the particular response data. + +```ts +import { EntityMixin } from '@data-client/rest'; + +class PriceLevel { + price = 0; + amount = 0; +} + +class PriceLevelEntity extends EntityMixin(PriceLevel) { + static process( + input: [number, number], + parent: any, + key: string | undefined, + ): any { + const [price, amount] = input; + if (amount === 0) return undefined; + return { price, amount }; + } +} +``` + +### static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue {#mergeWithStore} + +```typescript +static mergeWithStore( + existingMeta: { + date: number; + fetchedAt: number; + }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + const shouldUpdate = this.shouldUpdate( + existingMeta, + incomingMeta, + existing, + incoming, + ); + + if (shouldUpdate) { + // distinct types are not mergeable (like delete symbol), so just replace + if (typeof incoming !== typeof existing) { + return incoming; + } else { + return this.shouldReorder( + existingMeta, + incomingMeta, + existing, + incoming, + ) + ? this.merge(incoming, existing) + : this.merge(existing, incoming); + } + } else { + return existing; + } +} +``` + +`mergeWithStore()` is called during normalization when a processed entity is already found in the store. + +This calls [shouldUpdate()](#shouldupdate), [shouldReorder()](#shouldreorder) and potentially [merge()](#merge) + +### static shouldUpdate(existingMeta, incomingMeta, existing, incoming): boolean {#shouldupdate} + +```typescript +static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return existingMeta.fetchedAt <= incomingMeta.fetchedAt; +} +``` + +#### Preventing updates + +shouldUpdate can also be used to short-circuit an entity update. + +```typescript +import deepEqual from 'deep-equal'; +import { EntityMixin } from '@data-client/rest'; + +class Article { + id = ''; + title = ''; + content = ''; + published = false; +} + +class ArticleEntity extends EntityMixin(Article) { + static shouldUpdate( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, + ) { + return !deepEqual(incoming, existing); + } +} +``` + +### static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean {#shouldreorder} + +```typescript +static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return incomingMeta.fetchedAt < existingMeta.fetchedAt; +} +``` + +`true` return value will reorder incoming vs in-store entity argument order in merge. With +the default merge, this will cause the fields of existing entities to override those of incoming, +rather than the other way around. + +#### Example + +```typescript +import { EntityMixin } from '@data-client/rest'; + +class LatestPrice { + id = ''; + updatedAt = 0; + price = '0.0'; + symbol = ''; +} + +class LatestPriceEntity extends EntityMixin(LatestPrice) { + static shouldReorder( + existingMeta: { date: number; fetchedAt: number }, + incomingMeta: { date: number; fetchedAt: number }, + existing: { updatedAt: number }, + incoming: { updatedAt: number }, + ) { + return incoming.updatedAt < existing.updatedAt; + } +} +``` + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/resources/Ticker.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Ticker.ts)) + +### static merge(existing, incoming): mergedValue {#merge} + +```typescript +static merge(existing: any, incoming: any) { + return { + ...existing, + ...incoming, + }; +} +``` + +Merge is used to handle cases when an incoming entity is already found. This is called directly +when the same entity is found in one response. By default it is also called when [mergeWithStore()](#mergeWithStore) +determines the incoming entity should be merged with an entity already persisted in the Reactive Data Client store. + +How to override to [build reverse-lookups for relational data](./relational-data.md#reverse-lookups) + +### static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming): meta {#mergeMetaWithStore} + +```typescript +static mergeMetaWithStore( + existingMeta: { + expiresAt: number; + date: number; + fetchedAt: number; + }, + incomingMeta: { expiresAt: number; date: number; fetchedAt: number }, + existing: any, + incoming: any, +) { + return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) + ? existingMeta + : incomingMeta; +} +``` + +`mergeMetaWithStore()` is called during normalization when a processed entity is already found in the store. + +### static queryKey(args, queryKey, getEntity, getIndex): pk? {#queryKey} + +This method enables `Entities` to be [Queryable](./schema.md#queryable) - allowing store access without an endpoint. + +Overriding can allow customization or disabling of this behavior altogether. + +Returning `undefined` will disallow this behavior. + +Returning `pk` string will attempt to lookup this entity and use in the response. + +When used, expiry policy is computed based on the entity's own meta data. + +By **default** uses the first argument to lookup in [pk()](#pk) and [indexes](./Entity.md#indexes) + +#### getEntity(key, pk?) + +Gets all entities of a type with one argument, or a single entity with two + +```ts title="One argument" +const entitiesEntry = getEntity(this.schema.key); +if (entitiesEntry === undefined) return INVALID; +return Object.values(entitiesEntry).map( + entity => entity && this.schema.pk(entity), +); +``` + +```ts title="Two arguments" +if (getEntity(this.key, id)) return id; +``` + +#### getIndex(key, indexName, value) + +Returns the index entry (value->pk map) + +```ts +const value = args[0][indexName]; +return getIndex(schema.key, indexName, value)[value]; +``` + +### static createIfValid(processedEntity): Entity | undefined {#createIfValid} + +Called when denormalizing an entity. This will create an instance of this class +if it is deemed 'valid'. + +`undefined` return will result in [Invalid expiry status](https://dataclient.io/docs/concepts/expiry-policy#expiry-status), +like [Invalidate](./Invalidate.md). + +[`Invalid`](https://dataclient.io/docs/concepts/expiry-policy#expiry-status) expiry generally means hooks will enter a loading state and attempt a new fetch. + +```ts +static createIfValid(props): AbstractInstanceType | undefined { + if (this.validate(props)) { + return undefined as any; + } + return this.fromJS(props); +} +``` + +### static validate(processedEntity): errorMessage? {#validate} + +Runs during both normalize and denormalize. Returning a string indicates an error (the string is the message). + +During normalization a validation failure will result in an error for that fetch. + +During denormalization a validation failure will mark that result as 'invalid' and thus +will block on fetching a result. + +By **default** does some basic field existance checks in development mode only. Override to +disable or customize. + +[Using validation for endpoints with incomplete fields](./partial-entities.md) diff --git a/.agents/skills/data-client-schema/references/Invalidate.md b/.agents/skills/data-client-schema/references/Invalidate.md deleted file mode 120000 index 57a7bc021657..000000000000 --- a/.agents/skills/data-client-schema/references/Invalidate.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Invalidate.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Invalidate.md b/.agents/skills/data-client-schema/references/Invalidate.md new file mode 100644 index 000000000000..9c489dd5f948 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Invalidate.md @@ -0,0 +1,226 @@ + + +# Invalidate + +Describes entities to be marked as [INVALID](https://dataclient.io/docs/concepts/expiry-policy#invalid). This removes items from a +collection, or [forces suspense](https://dataclient.io/docs/concepts/expiry-policy#invalidate-entity) for endpoints where the entity is required. + +## Constructor + +```typescript +new Invalidate(entity) +new Invalidate(union) +new 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 + +```typescript title="api/User" +import { Entity, RestEndpoint, Collection, Invalidate } from '@data-client/rest'; + +class User extends Entity { + id = ''; + name = ''; +} +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new Collection([User]), +}); +export const deleteUser = new RestEndpoint({ + path: '/users/:id', + method: 'DELETE', + schema: new Invalidate(User), +}); +``` + +```tsx title="UserPage" +import { useSuspense, useController } from '@data-client/react'; +import { getUsers, deleteUser } from './api/User'; + +function UsersPage() { + const users = useSuspense(getUsers); + const ctrl = useController(); + return ( +
+ {users.map(user => ( +
+ {user.name}{' '} + ctrl.fetch(deleteUser, { id: user.id })} + > + ❌ + +
+ ))} +
+ ); +} +render(); +``` + +### Batch Invalidation + +Here we add another endpoint for deleting many entities at a time by wrapping +`Invalidate` in an array. `Data Client` can then `invalidate` every +entity from the response. + +```typescript title="Post" +import { Entity } from '@data-client/rest'; + +export default class Post extends Entity { + id = ''; + title = ''; + author = ''; +} +``` + +```typescript title="Resource" {9} +import { resource, Invalidate } from '@data-client/rest'; +import Post from './Post'; + +export const PostResource = resource({ + schema: Post, + path: '/posts/:id', +}).extend('deleteMany', { + path: '/posts', + body: [] as string[], + method: 'DELETE', + schema: [new Invalidate(Post)], +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.deleteMany(['5', '13', '7']); +``` + +Sometimes our backend returns nothing for 'DELETE'. In this +case, we can use [process](https://dataclient.io/rest/api/RestEndpoint#process) to build +a usable response from the argument `body`. + +```typescript title="Post" +import { Entity } from '@data-client/rest'; + +export default class Post extends Entity { + id = ''; + title = ''; + author = ''; +} +``` + +```typescript title="Resource" {10-13} +import { resource, Invalidate } from '@data-client/rest'; +import Post from './Post'; + +export const PostResource = resource({ + schema: Post, + path: '/posts/:id', +}).extend('deleteMany', { + path: '/posts', + body: [] as string[], + method: 'DELETE', + schema: [new Invalidate(Post)], + process(value, body) { + // use the body payload to inform which entities to delete + return body.map(id => ({ id })); + } +}); +``` + +```typescript title="Usage" column +import { PostResource } from './Resource'; +PostResource.deleteMany(['5', '13', '7']); +``` + +To delete many entities without an endpoint, such as from a websocket message, pass the same schema to +[Controller.set()](https://dataclient.io/docs/api/Controller#set-array): + +```ts +ctrl.set([new Invalidate(Post)], [{ id: '5' }, { id: '13' }, { id: '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 +import { Entity, RestEndpoint, Union, Invalidate } from '@data-client/rest'; + +class User extends Entity { + id = ''; + name = ''; + readonly type = 'users'; +} +class Group extends Entity { + id = ''; + groupname = ''; + readonly type = 'groups'; +} + +const MemberUnion = new Union( + { users: User, groups: Group }, + 'type' +); + +const deleteMember = new RestEndpoint({ + path: '/members/:id', + method: 'DELETE', + schema: new Invalidate(MemberUnion), +}); +``` + +#### string schemaAttribute + +Alternatively, define the polymorphic mapping inline with a string attribute: + +```typescript +import { RestEndpoint, Invalidate } from '@data-client/rest'; + +const deleteMember = new RestEndpoint({ + path: '/members/:id', + method: 'DELETE', + schema: new 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 +import { RestEndpoint, Invalidate } from '@data-client/rest'; + +const deleteMember = new RestEndpoint({ + path: '/members/:id', + method: 'DELETE', + schema: new 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() +will consider them invalid + +- For optional Entities, they are simply removed +- For required Entities, this invalidates the entire response re-triggering suspense. diff --git a/.agents/skills/data-client-schema/references/Lazy.md b/.agents/skills/data-client-schema/references/Lazy.md deleted file mode 120000 index e40397b7975c..000000000000 --- a/.agents/skills/data-client-schema/references/Lazy.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Lazy.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Lazy.md b/.agents/skills/data-client-schema/references/Lazy.md new file mode 100644 index 000000000000..b3dbf9601f7e --- /dev/null +++ b/.agents/skills/data-client-schema/references/Lazy.md @@ -0,0 +1,132 @@ + + +# Lazy + +`Lazy` wraps a schema to skip eager denormalization of relationship fields. During parent entity denormalization, the field retains its raw normalized value (primary keys/IDs). The relationship can then be resolved on demand via [useQuery](https://dataclient.io/docs/api/useQuery) using the `.query` accessor. + +This is useful for: + +- **Large bidirectional graphs** that would overflow the call stack during recursive denormalization +- **Performance optimization** by deferring resolution of relationships that aren't always needed +- **Memoization isolation** — changes to lazy entities don't invalidate the parent's denormalized form + +## Constructor + +```typescript +new Lazy(innerSchema) +``` + +- `innerSchema`: Any [Schema](./schema.md) — an [Entity](./Entity.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.md), etc. + +## Usage + +### Array relationship (most common) + +```typescript +import { Entity, Lazy } from '@data-client/rest'; + +class Building extends Entity { + id = ''; + name = ''; +} + +class Department extends Entity { + id = ''; + name = ''; + buildings: string[] = []; + + static schema = { + buildings: new Lazy([Building]), + }; +} +``` + +When a `Department` is denormalized, `dept.buildings` will contain raw primary keys (e.g., `['bldg-1', 'bldg-2']`) instead of resolved `Building` instances. + +To resolve the buildings, use [useQuery](https://dataclient.io/docs/api/useQuery) with the `.query` accessor: + +```tsx +function DepartmentBuildings({ dept }: { dept: Department }) { + // dept.buildings contains raw IDs: ['bldg-1', 'bldg-2'] + const buildings = useQuery(Department.schema.buildings.query, dept.buildings); + // buildings: Building[] | undefined + + if (!buildings) return null; + return ( +
    + {buildings.map(b =>
  • {b.name}
  • )} +
+ ); +} +``` + +### Single entity relationship + +```typescript +class Department extends Entity { + id = ''; + name = ''; + mainBuilding = ''; + + static schema = { + mainBuilding: new Lazy(Building), + }; +} +``` + +```tsx +// dept.mainBuilding is a raw PK string: 'bldg-1' +const building = useQuery( + Department.schema.mainBuilding.query, + { id: dept.mainBuilding }, +); +``` + +When the inner schema is an [Entity](./Entity.md) (or any schema with `queryKey`), `LazyQuery` delegates to its `queryKey` — so you pass the same args you'd use to query that entity directly. + +### Collection relationship + +```typescript +class Department extends Entity { + id = ''; + static schema = { + buildings: new Lazy(buildingsCollection), + }; +} +``` + +```tsx +const buildings = useQuery( + Department.schema.buildings.query, + ...collectionArgs, +); +``` + +## `.query` + +Returns a `LazyQuery` instance suitable for [useQuery](https://dataclient.io/docs/api/useQuery). The `LazyQuery`: + +- **`queryKey(args)`** — If the inner schema has a `queryKey` (Entity, Collection, etc.), delegates to it. Otherwise returns `args[0]` directly (for array/object schemas where you pass the raw normalized value). +- **`denormalize(input, delegate)`** — Delegates to the inner schema, resolving IDs into full entity instances. + +The `.query` getter always returns the same instance (cached). + +## How it works + +### Normalization + +`Lazy.normalize` delegates to the inner schema. Entities are stored in the normalized entity tables as usual — `Lazy` has no effect on normalization. + +### Denormalization (parent path) + +`Lazy.denormalize` is a **no-op** — it returns the input unchanged. When `EntityMixin.denormalize` iterates over schema fields and encounters a `Lazy` field, the `unvisit` dispatch calls `Lazy.denormalize`, which simply passes through the raw PKs. No nested entities are visited, no dependencies are registered in the cache. + +### Denormalization (useQuery path) + +When using `useQuery(lazyField.query, ...)`, `LazyQuery.denormalize` delegates to the inner schema via `unvisit`, resolving IDs into full entity instances through the normal denormalization pipeline. This runs in its own `MemoCache.query()` scope with independent dependency tracking and GC. + +## Performance characteristics + +- **Parent denormalization**: Fewer dependency hops (lazy entities excluded from deps). Faster cache hits. No invalidation when lazy entities change. +- **useQuery access**: Own memo scope with own `paths` and `countRef`. Changes to lazy entities only re-render components that called `useQuery`, not the parent. +- **No Proxy/getter overhead**: Raw IDs are plain values. Full resolution only happens through `useQuery`, using the normal denormalization path. diff --git a/.agents/skills/data-client-schema/references/Object.md b/.agents/skills/data-client-schema/references/Object.md deleted file mode 120000 index 5d8d7407468e..000000000000 --- a/.agents/skills/data-client-schema/references/Object.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Object.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Object.md b/.agents/skills/data-client-schema/references/Object.md new file mode 100644 index 000000000000..ca57a7ce9605 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Object.md @@ -0,0 +1,43 @@ + + +# schema.Object + +Define a plain object mapping that has values needing to be normalized into Entities. _Note: The same behavior can be defined with shorthand syntax: `{ ... }`_ + +- `definition`: **required** A definition of the nested entities found within this object. Defaults to empty object. + You _do not_ need to define any keys in your object other than those that hold other entities. All other values will be copied to the normalized output. + +> **Tip** +> +> `Objects` have statically known members. For unbounded Objects (arbitrary `string` keys), use [Values](./Values.md) + +#### Instance Methods + +- `define(definition)`: When used, the `definition` passed in will be merged with the original definition passed to the `Object` constructor. This method tends to be useful for creating circular references in schema. + +#### Usage + +```tsx title="UsersPage.tsx" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; +import { useSuspense } from '@data-client/react'; + +class User extends Entity { + id = ''; + name = ''; +} +const getUsers = new RestEndpoint({ + path: '/users', + schema: new schema.Object({ users: new schema.Array(User) }), +}); +function UsersPage() { + const { users } = useSuspense(getUsers); + return ( +
+ {users.map(user => ( +
{user.name}
+ ))} +
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-schema/references/Query.md b/.agents/skills/data-client-schema/references/Query.md deleted file mode 120000 index ef7db9a0d2b4..000000000000 --- a/.agents/skills/data-client-schema/references/Query.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Query.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Query.md b/.agents/skills/data-client-schema/references/Query.md new file mode 100644 index 000000000000..b6a49cc358cc --- /dev/null +++ b/.agents/skills/data-client-schema/references/Query.md @@ -0,0 +1,349 @@ + + +# Query + +`Query` provides programmatic access to the Reactive Data Client cache while maintaining +the same high performance and referential equality guarantees expected of Reactive Data Client. + +`Query` can be rendered using [schema lookup hook useQuery()](https://dataclient.io/docs/api/useQuery) + +## Query members + +### schema + +[Schema](./schema.md) used to retrieve/denormalize data from the Reactive Data Client cache. +This accepts any [Queryable](./schema.md#queryable) schema: [Entity](./Entity.md), [All](./All.md), [Collection](./Collection.md), [Query](./Query.md), +[Union](./Union.md), [Scalar](./Scalar.md), and [Object](./Object.md) schemas for joining multiple entities. +[Lazy](./Lazy.md) fields produce a Queryable via their [`.query`](./Lazy.md#query) accessor. + +### process(entries, ...args) {#process} + +Takes the (denormalized) response as entries and arguments and returns the new +response for use with [useQuery](https://dataclient.io/docs/api/useQuery) + +## Usage + +### Maintaining sort after creates {#sorting} + +```ts title="getPosts" {17-24} +import { Collection, Entity, Query, RestEndpoint } from '@data-client/rest'; + +export class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} + +export const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Query( + new Collection([Post], { + nonFilterArgumentKeys: /orderBy/, + }), + (posts, { orderBy } = {}) => { + if (orderBy) { + return [...posts].sort((a, b) => + a[orderBy].localeCompare(b[orderBy]), + ); + } + return posts; + }, + ), +}); +``` + +```tsx title="NewPost" +import { useLoading } from '@data-client/react'; +import { getPosts } from './getPosts'; + +export default function NewPost({ author }: Props) { + const ctrl = useController(); + + const [handlePress, loading] = useLoading(async e => { + if (e.key === 'Enter') { + const title = e.currentTarget.value; + e.currentTarget.value = ''; + await ctrl.fetch( + getPosts.push, + { group: 'react' }, + { + title, + author, + }, + ); + } + }); + + return ; +} +interface Props { + author: string; +} +``` + +```tsx title="PostList" {8} +import { useSuspense } from '@data-client/react'; +import { getPosts } from './getPosts'; +import NewPost from './NewPost'; + +export default function PostList({ author }: Props) { + const posts = useSuspense(getPosts, { + author, + orderBy: 'title', + group: 'react', + }); + return ( +
+ {posts.map(post => ( +
{post.title}
+ ))} + +
+ ); +} +interface Props { + author: string; +} +``` + +```tsx title="UserList" +import PostList from './PostList'; + +function UserList() { + const users = ['bob', 'clara']; + return ( +
+ {users.map(user => ( +
+

{user}

+ +
+ ))} +
+ ); +} +render(); +``` + +### Aggregates + +```ts title="resources/User" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; + isAdmin = false; +} +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="UsersPage" +import { All, Query } from '@data-client/rest'; +import { useQuery, useFetch } from '@data-client/react'; +import { UserResource, User } from './resources/User'; + +const countUsers = new Query( + new All(User), + (entries, { isAdmin } = {}) => { + if (isAdmin !== undefined) + return entries.filter(user => user.isAdmin === isAdmin).length; + return entries.length; + }, +); + +function UsersPage() { + useFetch(UserResource.getList); + const userCount = useQuery(countUsers); + const adminCount = useQuery(countUsers, { isAdmin: true }); + if (userCount === undefined) return
No users in cache yet
; + return ( +
+
Total users: {userCount}
+
Total admins: {adminCount}
+
+ ); +} +render(); +``` + +### Rearranging data with groupBy aggregations {#groupby} + +```ts title="resources/User" +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + username = ''; + name = ''; + email = ''; + website = ''; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```ts title="resources/Todo" +import { Entity, resource } from '@data-client/rest'; +import { User } from './User'; + +export class Todo extends Entity { + id = 0; + userId = 0; + user? = User.fromJS({}); + title = ''; + completed = false; + + static schema = { + user: User, + }; + static process(input) { + return { ...input, user: input.userId }; + } +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, + searchParams: {} as { userId?: string | number } | undefined, +}); +``` + +```tsx title="TodoByUser" +import { useQuery } from '@data-client/react'; +import { User } from './resources/User'; +import type { Todo } from './resources/Todo'; + +export default function TodoByUser({ userId, todos }: Props) { + const user = useQuery(User, { id: userId }); + // don't bother if no user is loaded yet + if (!user) return null; + return ( +
+

+ {user.name} has {tasksRemaining(todos)} tasks left +

+ {todos.slice(0, 3).map(todo => ( +
+ {todo.title} by {todo.user === user ? todo.user.name : ''} +
+ ))} +
+ ); +} +function tasksRemaining(todos: Todo[]) { + return todos.filter(({ completed }) => !completed).length; +} +interface Props { + userId: string; + todos: Todo[]; +} +``` + +```tsx title="TodoJoined" +import { Query } from '@data-client/rest'; +import { useQuery, useFetch, useSuspense } from '@data-client/react'; +import { TodoResource } from './resources/Todo'; +import { UserResource } from './resources/User'; +import TodoByUser from './TodoByUser'; + +const groupTodoByUser = new Query( + TodoResource.getList.schema, + todos => Object.groupBy(todos, todo => todo.userId), +); + +function TodosPage() { + useFetch(UserResource.getList); + useSuspense(TodoResource.getList); + useSuspense(UserResource.getList); + const todosByUser = useQuery(groupTodoByUser); + if (!todosByUser) return
Todos not found
; + return ( +
+ {Object.keys(todosByUser).slice(5).map(userId => ( + + ))} +
+ ); +} +render(); +``` + +### Object Schema Joins {#object-schema-joins} + +`Query` can take [Object Schemas](./Object.md), enabling joins across multiple entity types. This allows you to combine data from different entities in a single query. + +```ts title="resources/Ticker" +import { Entity, resource } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + price = 0; + pk() { return this.product_id; } +} + +export const TickerResource = resource({ + path: '/tickers/:product_id', + schema: Ticker, +}); +``` + +```ts title="resources/Stats" +import { Entity, resource } from '@data-client/rest'; + +export class Stats extends Entity { + product_id = ''; + last = 0; + pk() { return this.product_id; } +} + +export const StatsResource = resource({ + path: '/stats/:product_id', + schema: Stats, +}); +``` + +```tsx title="PriceDisplay" +import { Query } from '@data-client/rest'; +import { useQuery, useFetch } from '@data-client/react'; +import { TickerResource, Ticker } from './resources/Ticker'; +import { StatsResource, Stats } from './resources/Stats'; + +// Join Ticker and Stats by product_id +const queryPrice = new Query( + { ticker: Ticker, stats: Stats }, + ({ ticker, stats }) => ticker?.price ?? stats?.last, +); + +function PriceDisplay({ productId }: { productId: string }) { + useFetch(TickerResource.get, { product_id: productId }); + useFetch(StatsResource.get, { product_id: productId }); + const price = useQuery(queryPrice, { product_id: productId }); + + if (price === undefined) return
Loading...
; + return
Price: ${price}
; +} + +render(); +``` + +### Fallback joins + +In this case `Ticker` is constantly updated from a websocket stream. However, there is no bulk/list +fetch for `Ticker` - making it inefficient for getting the prices on a list view. + +So in this case we can fetch a list of `Stats` as a fallback since it has price data as well. + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/pages/Home/CurrencyList.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/Home/CurrencyList.tsx), [`src/pages/Home/AssetPrice.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/Home/AssetPrice.tsx), [`src/resources/fallbackQueries.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/fallbackQueries.ts)) diff --git a/.agents/skills/data-client-schema/references/Scalar.md b/.agents/skills/data-client-schema/references/Scalar.md deleted file mode 120000 index f68bf486582f..000000000000 --- a/.agents/skills/data-client-schema/references/Scalar.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Scalar.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Scalar.md b/.agents/skills/data-client-schema/references/Scalar.md new file mode 100644 index 000000000000..db433810efb6 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Scalar.md @@ -0,0 +1,382 @@ + + +# Scalar + +`Scalar` describes [Entity](./Entity.md) fields whose values depend on endpoint args, +such as portfolio-, currency-, or locale-specific columns on the same row. + +Use `Scalar` when the field belongs to an entity, but its value changes based on a +"lens" selected by the request. Multiple components can render the same entity with +different lens args at the same time, each receiving the correct scalar values. + +- `lens`: **required** Selects the lens value from endpoint args. +- `key`: **required** Namespaces this scalar's internal table. +- `entity`: Binds the scalar to an `Entity` when it is used outside of an + `Entity.schema` field. + +> **Note** +> +> `Scalar` is for scalar values like numbers, strings, booleans, or date-derived values. +> Use normal nested [schemas](./schema.md) for relationships to other entities. + +## Usage + +In this example, `pct_equity` and `shares` depend on the selected portfolio, while +`name` and `price` are stable properties of the `Company` entity. + +```ts title="api/Company" {11-20,26-28,34-36} +import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest'; + +export class Company extends Entity { + id = ''; + name = ''; + price = 0; + pct_equity = 0; + shares = 0; +} + +const PortfolioScalar = new Scalar({ + lens: args => args[0]?.portfolio, + key: 'portfolio', + entity: Company, +}); +Company.schema = { + pct_equity: PortfolioScalar, + shares: PortfolioScalar, +}; + +export const getCompanies = new RestEndpoint({ + path: '/companies', + searchParams: {} as { portfolio: string }, + // `portfolio` is a lens, not a filter — the returned Company list is the same + // regardless of lens. Dropping it from `argsKey` collapses every portfolio to + // one Collection pk, so `Collection.queryKey()` finds the list on every + // switch and `useSuspense` reuses it without refetching. + schema: new Collection([Company], { argsKey: () => ({}) }), +}); + +export const getPortfolioColumns = new RestEndpoint({ + path: '/companies/columns', + searchParams: {} as { portfolio: string }, + schema: new Collection([PortfolioScalar], { + argsKey: ({ portfolio }) => ({ portfolio }), + }), +}); +``` + +```tsx title="CompanyGrid" +import { type Company } from './api/Company'; + +export default function CompanyGrid({ companies }: { companies: Company[] }) { + return ( + + + + + + + + + + + {companies.map(c => ( + + + + + + + ))} + +
NamePrice% EquityShares
{c.name}${c.price.toFixed(2)}{formatPercent(c.pct_equity)}{formatShares(c.shares)}
+ ); +} + +function formatPercent(value: number | undefined) { + return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; +} + +function formatShares(value: number | undefined) { + return value === undefined ? 'loading...' : value.toLocaleString(); +} +``` + +```tsx title="PortfolioGrid" +import { useSuspense, useFetch } from '@data-client/react'; +import { getCompanies, getPortfolioColumns } from './api/Company'; +import CompanyGrid from './CompanyGrid'; + +function PortfolioGrid() { + const [portfolio, setPortfolio] = React.useState('A'); + // Fetches on first render, then re-denormalizes from cache on every + // portfolio switch. The Collection's `queryKey()` ignores `portfolio`, + // so there is no endpoint refetch on switch. + const companies = useSuspense(getCompanies, { portfolio }); + // The first render's `useSuspense` already populated `Scalar(portfolio)` + // for `firstPortfolio`, so we only fetch columns when the user switches + // away. `useFetch` then dedupes later revisits via its endpoint cache. + const firstPortfolio = React.useRef(portfolio).current; + useFetch( + getPortfolioColumns, + portfolio === firstPortfolio ? null : { portfolio }, + ); + + return ( +
+ + +
+ ); +} + +render(); +``` + +On first render, `getCompanies` fetches once to populate the Company entities and +the initial `Scalar(portfolio)` cells. Every later portfolio switch re-denormalizes +from the existing `Collection` entity with the new lens — no network fetch — and +`getPortfolioColumns` fetches only the lens-dependent cells for portfolios the +user actually visits. Revisit a portfolio already in cache and neither endpoint +fires again. + +Wrapping lists in [Collection](./Collection.md) is what makes this work: +`Array` has no `queryKey`, so `useSuspense(getCompanies, { portfolio: 'B' })` +would miss the endpoint cache and trigger a refetch. `Collection.queryKey()` +returns its pk when the `Collection` entity is in the store, so the reuse path +fires as long as the pk is stable across the cases you want to share. + +Here [`argsKey: () => ({})`](./Collection.md#argsKey) forces every portfolio to +the same `pk`, so one Collection entity serves all lenses. When an endpoint has +real filter args alongside the lens, keep the filters in the pk and drop only +the lens: + +```typescript +new Collection([Company], { + argsKey: ({ portfolio, ...filters }) => filters, +}); +``` + +[`nonFilterArgumentKeys`](./Collection.md#nonFilterArgumentKeys) is a separate +concern — it controls which args are ignored when a mutation like `push` or +`assign` matches existing collections — and does _not_ collapse pks. Use it for +sort or pagination args where results differ per value (distinct pks) but +creates should still reach every variant. + +`getPortfolioColumns` also uses `Collection`, but keeps `portfolio` in its pk +with `argsKey: ({ portfolio }) => ({ portfolio })` because each portfolio has a +distinct column response. `Scalar.entityPk()` derives each cell's Company id +from the array item (delegating to `Company.pk()` by default), so the endpoint +can use the natural REST shape: + +```typescript +[ + { id: '1', pct_equity: 0.5, shares: 10000 }, + { id: '2', pct_equity: 0.2, shares: 4000 }, +] +``` + +### Entity Fields + +Use `Scalar` in an `Entity.schema` field when lens-dependent values arrive as part +of the entity response. + +```typescript +import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest'; + +const PortfolioScalar = new Scalar({ + lens: args => args[0]?.portfolio, + key: 'portfolio', +}); + +class Company extends Entity { + id = ''; + price = 0; + pct_equity = 0; + shares = 0; + + static schema = { + pct_equity: PortfolioScalar, + shares: PortfolioScalar, + }; +} + +const getCompanies = new RestEndpoint({ + path: '/companies', + searchParams: {} as { portfolio: string }, + schema: new Collection([Company], { argsKey: () => ({}) }), +}); +``` + +A single unbound `Scalar` instance can be shared across multiple entity classes. +When used as an `Entity.schema` field, the parent entity is inferred during +normalization. + +### Values Endpoint + +Use [Values](./Values.md) when an endpoint returns only the scalar columns, keyed by +entity pk. Since this response has no enclosing entity schema, pass `entity` when +constructing the `Scalar`. + +```typescript +import { Entity, RestEndpoint, Scalar, Values } from '@data-client/rest'; + +const CompanyPortfolioScalar = new Scalar({ + lens: args => args[0]?.portfolio, + key: 'portfolio', + entity: Company, +}); + +const getPortfolioColumns = new RestEndpoint({ + path: '/companies/columns', + searchParams: {} as { portfolio: string }, + schema: new Values(CompanyPortfolioScalar), +}); + +// Response: { '1': { pct_equity: 0.5, shares: 32342 }, '2': { ... } } +``` + +Column-only endpoints write `Scalar(portfolio)` cells without modifying the +`Company` entities. A bound `Scalar` can still be used as an `Entity.schema` field; +the inferred parent entity takes precedence there. + +## Options + +```typescript +new Scalar({ lens, key, entity? }) +``` + +### lens(args): string | undefined {#lens} + +Selects the lens value from endpoint args, such as a portfolio ID. + +The lens value must be present when normalizing a response. Returning `undefined` +during normalize throws because the scalar cell cannot be stored under a retrievable +key. During denormalize, a missing lens returns `undefined` for that field. + +The returned value becomes part of the stored cell key and is also used for +cell lookup during [queryKey](#queryKey). It must be a string that does not +contain `|` — the `|` character is the cpk delimiter +(`entityKey|entityPk|lens`), and a lens containing `|` would collide with +other lenses that share the same trailing segment. + +### key: string {#key} + +Unique name for this scalar type. This namespaces the internal `Scalar` entity table. + +For example, `key: 'portfolio'` stores cells in `Scalar(portfolio)`. + +### entity?: Entity {#entity} + +Entity class this `Scalar` stores cells for. + +This is optional when the scalar is used as a field on `Entity.schema`, where the +parent entity is inferred. It is required for standalone usage such as +`new Values(PortfolioScalar)`. + +### entityPk(input, parent, key, args): string | number | undefined {#entityPk} + +Derives the bound Entity's primary key when `Scalar` is used standalone, such as +inside `Values`, `[Scalar]`, or `Collection([Scalar])`. The cell's actual pk +stored under `Scalar(key)` is the compound `entityKey|entityPk|lens` — this +method only supplies the `entityPk` piece. + +By default `entityPk()`: + +- returns the surrounding map `key` when it authoritatively addresses the + cell — i.e. `parent[key] === input`, as in `Values(Scalar)` where the map + key is the entity pk and the cell may not carry the pk fields — then +- delegates to the bound `Entity.pk(input, parent, key, args)` static so + `[Scalar]` and `Collection([Scalar])` array responses — including arrays + nested under a parent object schema like `{ stock: [Scalar] }`, and + custom or composite Entity pks — work out of the box. + +Override `entityPk()` in a subclass only when the response uses an id field the +`Entity.pk()` does not read: + +```typescript +class CompanyIdScalar extends Scalar { + entityPk(input: any) { + return input.companyId; + } +} +``` + +## Behavior + +### Normalize + +When normalizing an entity response, `Scalar` stores the field value in a separate +cell keyed by: + +```text +entityKey|entityPk|lensValue +``` + +The entity row keeps a lens-independent reference to that cell. This lets one +entity row point to different scalar values depending on the current endpoint args. + +When normalizing a `Values` response, each top-level key is treated as the entity pk, +and the response value is stored as that entity's scalar cell for the current lens. + +### Denormalize + +During denormalization, `Scalar` reads the current lens from endpoint args and looks +up the matching cell. If no matching lens or cell exists, the field denormalizes to +`undefined`. + +Because the lens participates in denormalization memoization, separate portfolio, +currency, or locale views cache independently while sharing the same base entity +data. + +### queryKey {#queryKey} + +`Scalar` is a [Queryable](./schema.md#queryable) schema. When used as a +top-level endpoint schema — or passed to [useQuery](https://dataclient.io/docs/api/useQuery), +[Controller.get](https://dataclient.io/docs/api/Controller#get), [schema.Query](./Query.md), or any +other Queryable consumer — it reports the cpks of all cells whose lens matches +the current args: + +- Returns an array of compound pks on hit. +- Returns `undefined` when the lens is `undefined`, the table is missing, or + no cell matches the current lens. + +The common case — `Scalar` nested as an `Entity.schema` field — never reaches +this method. Denormalization goes through the parent entity, so `queryKey` +is only consulted when `Scalar` is itself the root schema being queried. + +### Normalized Storage + +```typescript +entities['Company']['1'] = { + id: '1', + price: 100, + pct_equity: ['1', 'pct_equity', 'Company'], + shares: ['1', 'shares', 'Company'], +} + +entities['Scalar(portfolio)']['Company|1|portfolioA'] = { + pct_equity: 0.5, + shares: 32342, +} + +entities['Scalar(portfolio)']['Company|1|portfolioB'] = { + pct_equity: 0.3, + shares: 323, +} +``` + +## Related + +- [Entity](./Entity.md) — defines the base entity that scalar fields attach to +- [Values](./Values.md) — used for column-only endpoints (dictionary keyed by entity pk) +- [Union](./Union.md) — similar wrapper pattern for polymorphic entities +- [Queryable](./schema.md#queryable) — Scalar participates in [useQuery](https://dataclient.io/docs/api/useQuery), [Controller.get](https://dataclient.io/docs/api/Controller#get), and [schema.Query](./Query.md) diff --git a/.agents/skills/data-client-schema/references/Union.md b/.agents/skills/data-client-schema/references/Union.md deleted file mode 120000 index dfb17f519d3a..000000000000 --- a/.agents/skills/data-client-schema/references/Union.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Union.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Union.md b/.agents/skills/data-client-schema/references/Union.md new file mode 100644 index 000000000000..63f88958a361 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Union.md @@ -0,0 +1,153 @@ + + +# Union + +Describe a schema which is a union of multiple schemas. This is useful if you need the polymorphic behavior provided by [schema.Array](./Array.md) or [Values](./Values.md) but for non-collection fields. + +- `definition`: **required** An object mapping the definition of the nested entities found within the input array +- `schemaAttribute`: **required** The attribute on each entity found that defines what schema, per the definition mapping, 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. + +#### Instance Methods + +- `define(definition)`: When used, the `definition` passed in will be merged with the original definition passed to the `Union` constructor. This method tends to be useful for creating circular references in schema. + +> **Info: Naming** +> +> `Union` is named after the [set theory concept](https://en.wikipedia.org/wiki/Union_\(set_theory\)) just like [TypeScript Unions](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types) + +## Usage + +> **Note** +> +> If your data returns an object that you did not provide a mapping for, the original object will be returned in the result and an entity will not be created. + +```typescript title="api/Feed" +import { Entity, RestEndpoint, Union } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + id = 0; + declare type: 'link' | 'post'; +} +export class Link extends FeedItem { + type = 'link' as const; + url = ''; + title = ''; +} +export class Post extends FeedItem { + type = 'post' as const; + content = ''; +} + +export const feed = new RestEndpoint({ + path: '/feed', + schema: [ + new Union( + { + link: Link, + post: Post, + }, + 'type', + ), + ], +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { feed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(feed); + return ( +
+ {feedItems.map(item => + item.type === 'link' ? ( + + ) : ( + + ), + )} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return
{post.content}
; +} +render(); +``` + +### Function schemaAttribute + +When the discriminator value doesn't directly match schema keys, use a function to compute which schema to use. + +```typescript title="api/Feed" +import { Entity, RestEndpoint, Union } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + id = 0; + declare type: 'link' | 'post'; +} +export class LinkItem extends FeedItem { + type = 'link' as const; + url = ''; + title = ''; +} +export class PostItem extends FeedItem { + type = 'post' as const; + content = ''; +} + +export const feed = new RestEndpoint({ + path: '/feed', + schema: [ + new Union( + { + links: LinkItem, + posts: PostItem, + }, + (input: LinkItem | PostItem, parent: unknown, key: string) => `${input.type}s`, + ), + ], +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { feed, LinkItem, PostItem } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(feed); + return ( +
+ {feedItems.map(item => + item.type === 'link' ? ( + + ) : ( + + ), + )} +
+ ); +} +function LinkComponent({ link }: { link: LinkItem }) { + return {link.title}; +} +function PostComponent({ post }: { post: PostItem }) { + return
{post.content}
; +} +render(); +``` + +### Github Events + +Contribution activity comes from grouping github events by their type. Each type of Event has its +own distinct schema, which is why we use `Union` + +Example app: [github-app](https://github.com/reactive/data-client/tree/master/examples/github-app) ([`src/pages/ProfileDetail/UserEvents.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/pages/ProfileDetail/UserEvents.tsx), [`src/resources/Event.tsx`](https://github.com/reactive/data-client/blob/master/examples/github-app/src/resources/Event.tsx)) diff --git a/.agents/skills/data-client-schema/references/Values.md b/.agents/skills/data-client-schema/references/Values.md deleted file mode 120000 index a46e1fa8bbd7..000000000000 --- a/.agents/skills/data-client-schema/references/Values.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/Values.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/Values.md b/.agents/skills/data-client-schema/references/Values.md new file mode 100644 index 000000000000..d3453e989be2 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Values.md @@ -0,0 +1,191 @@ + + +# Values + +Like [Array](./Array.md), `Values` are unbounded in size. The definition here describes the types of values to expect, +with keys being any string. + +Describes a map whose values follow the given schema. + +- `definition`: **required** A singular schema that this array contains _or_ a mapping of schema to attribute values. +- `schemaAttribute`: _optional_ (required if `definition` is not a singular schema) The attribute on each entity found that defines what schema, per the definition mapping, 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. + +> **Tip** +> +> Make it mutable (new items can be [assigned](./Collection.md#assign)) with [Collections](./Collection.md) + +## Instance Methods + +- `define(definition)`: When used, the `definition` passed in will be merged with the original definition passed to the `Values` constructor. This method tends to be useful for creating circular references in schema. + +> **Info: Naming** +> +> `Values` is named after [Object.values()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_objects/Object/values) as +> its schemas are used for the value of an Object. + +## Usage + +```tsx title="ItemPage.tsx" +import { Entity, RestEndpoint, Values } from '@data-client/rest'; +import { useSuspense } from '@data-client/react'; + +export class Item extends Entity { + id = 0; +} +export const getItems = new RestEndpoint({ + path: '/items', + schema: new Values(Item), +}); +function ItemPage() { + const items = useSuspense(getItems); + return
{JSON.stringify(items, undefined, 2)}
; +} +render(); +``` + +### Updating many entities + +Use Values with [Controller.set()](https://dataclient.io/docs/api/Controller#set-array) to write many entities in one store update, +without an endpoint. + +```ts +ctrl.set(getItems.schema, { + firstThing: { id: 1 }, + secondThing: { id: 2 }, +}); +``` + +### Polymorphic types + +If your input data is an object that has values of more than one type of entity, but their schema is not easily defined by the key, you can use a mapping of schema, much like [Union](./Union.md) and [schema.Array](./Array.md). + +> **Note** +> +> If your data returns an object that you did not provide a mapping for, the original object will be returned in the result and an entity will not be created. + +#### string schemaAttribute + +```typescript title="api/Feed" +import { Entity, RestEndpoint, Values } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + id = 0; + declare readonly type: 'link' | 'post'; +} +export class Link extends FeedItem { + readonly type = 'link' as const; + readonly url: string = ''; + readonly title: string = ''; +} +export class Post extends FeedItem { + readonly type = 'post' as const; + readonly content: string = ''; +} +export const getFeed = new RestEndpoint({ + path: '/feed', + schema: new Values( + { + link: Link, + post: Post, + }, + 'type', + ), +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { getFeed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(getFeed); + return ( +
+ {Object.entries(feedItems).map(([key, item]) => ( +
+ {key}:{' '} + {item.type === 'link' ? ( + + ) : ( + + )} +
+ ))} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return {post.content}; +} +render(); +``` + +#### function schemaAttribute + +The return values should match a key in the `definition`. Here we'll show the same behavior as the 'string' +case, except we'll append an 's'. + +```typescript title="api/Feed" +import { Entity, RestEndpoint, Values } from '@data-client/rest'; + +export abstract class FeedItem extends Entity { + id = 0; + declare readonly type: 'link' | 'post'; +} +export class Link extends FeedItem { + readonly type = 'link' as const; + readonly url: string = ''; + readonly title: string = ''; +} +export class Post extends FeedItem { + readonly type = 'post' as const; + readonly content: string = ''; +} +export const getFeed = new RestEndpoint({ + path: '/feed', + schema: new Values( + { + links: Link, + posts: Post, + }, + (input: Link | Post, parent: unknown, key: string) => `${input.type}s`, + ), +}); +``` + +```tsx title="FeedList" +import { useSuspense } from '@data-client/react'; +import { getFeed, Link, Post } from './api/Feed'; + +function FeedList() { + const feedItems = useSuspense(getFeed); + return ( +
+ {Object.entries(feedItems).map(([key, item]) => ( +
+ {key}:{' '} + {item.type === 'link' ? ( + + ) : ( + + )} +
+ ))} +
+ ); +} +function LinkItem({ link }: { link: Link }) { + return {link.title}; +} +function PostItem({ post }: { post: Post }) { + return {post.content}; +} +render(); +``` diff --git a/.agents/skills/data-client-schema/references/_ScalarDemo.md b/.agents/skills/data-client-schema/references/_ScalarDemo.md deleted file mode 120000 index b34e531bd1de..000000000000 --- a/.agents/skills/data-client-schema/references/_ScalarDemo.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/shared/_ScalarDemo.mdx \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/_ScalarDemo.md b/.agents/skills/data-client-schema/references/_ScalarDemo.md new file mode 100644 index 000000000000..5c1a2c181233 --- /dev/null +++ b/.agents/skills/data-client-schema/references/_ScalarDemo.md @@ -0,0 +1,118 @@ + + +```ts title="api/Company" {11-20,26-28,34-36} +import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest'; + +export class Company extends Entity { + id = ''; + name = ''; + price = 0; + pct_equity = 0; + shares = 0; +} + +const PortfolioScalar = new Scalar({ + lens: args => args[0]?.portfolio, + key: 'portfolio', + entity: Company, +}); +Company.schema = { + pct_equity: PortfolioScalar, + shares: PortfolioScalar, +}; + +export const getCompanies = new RestEndpoint({ + path: '/companies', + searchParams: {} as { portfolio: string }, + // `portfolio` is a lens, not a filter — the returned Company list is the same + // regardless of lens. Dropping it from `argsKey` collapses every portfolio to + // one Collection pk, so `Collection.queryKey()` finds the list on every + // switch and `useSuspense` reuses it without refetching. + schema: new Collection([Company], { argsKey: () => ({}) }), +}); + +export const getPortfolioColumns = new RestEndpoint({ + path: '/companies/columns', + searchParams: {} as { portfolio: string }, + schema: new Collection([PortfolioScalar], { + argsKey: ({ portfolio }) => ({ portfolio }), + }), +}); +``` + +```tsx title="CompanyGrid" +import { type Company } from './api/Company'; + +export default function CompanyGrid({ companies }: { companies: Company[] }) { + return ( + + + + + + + + + + + {companies.map(c => ( + + + + + + + ))} + +
NamePrice% EquityShares
{c.name}${c.price.toFixed(2)}{formatPercent(c.pct_equity)}{formatShares(c.shares)}
+ ); +} + +function formatPercent(value: number | undefined) { + return value === undefined ? 'loading...' : `${(value * 100).toFixed(1)}%`; +} + +function formatShares(value: number | undefined) { + return value === undefined ? 'loading...' : value.toLocaleString(); +} +``` + +```tsx title="PortfolioGrid" +import { useSuspense, useFetch } from '@data-client/react'; +import { getCompanies, getPortfolioColumns } from './api/Company'; +import CompanyGrid from './CompanyGrid'; + +function PortfolioGrid() { + const [portfolio, setPortfolio] = React.useState('A'); + // Fetches on first render, then re-denormalizes from cache on every + // portfolio switch. The Collection's `queryKey()` ignores `portfolio`, + // so there is no endpoint refetch on switch. + const companies = useSuspense(getCompanies, { portfolio }); + // The first render's `useSuspense` already populated `Scalar(portfolio)` + // for `firstPortfolio`, so we only fetch columns when the user switches + // away. `useFetch` then dedupes later revisits via its endpoint cache. + const firstPortfolio = React.useRef(portfolio).current; + useFetch( + getPortfolioColumns, + portfolio === firstPortfolio ? null : { portfolio }, + ); + + return ( +
+ + +
+ ); +} + +render(); +``` diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md deleted file mode 120000 index cf5001c1f97f..000000000000 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/computed-properties.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md new file mode 100644 index 000000000000..3382fef28558 --- /dev/null +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -0,0 +1,112 @@ + + +# Computed Properties + +## Singular computations + +[Entity](./Entity.md) classes are just normal classes, so any common derived data can just be added as +getters to the class itself. + +```typescript +import { All, Entity, Query } from '@data-client/rest'; + +class User extends Entity { + id = ''; + firstName = ''; + lastName = ''; + username = ''; + email = ''; + + get fullName() { + return `${this.firstName} ${this.lastName}`; + } + + static key = 'User'; +} +``` + +If the computations are expensive feel free to add some +[memoization](https://github.com/anywhichway/nano-memoize). + +```typescript +import { All, Entity, Query } from '@data-client/rest'; +import memoize from 'nano-memoize'; + +class User extends Entity { + truelyExpensiveValue = memoize(() => { + // compute that expensive thing! + }); +} +``` + +> **Tip** +> +> If you simply want to [deserialize a field](https://dataclient.io/rest/guides/network-transform#deserializing-fields) to a more useful form like [Temporal.Instant](https://tc39.es/proposal-temporal/docs/instant.html) or [BigNumber](https://github.com/MikeMcl/bignumber.js), you can use +> the declarative [static schema](https://dataclient.io/rest/guides/network-transform#deserializing-fields). +> +> ```typescript +> import { All, Entity, Query } from '@data-client/rest'; +> import BigNumber from 'bignumber.js'; +> +> class User extends Entity { +> id = ''; +> firstName = ''; +> lastName = ''; +> createdAt = Temporal.Instant.fromEpochMilliseconds(0); +> lifetimeBlinkCount = BigNumber(0); +> +> static key = 'User'; +> +> static schema = { +> createdAt: Temporal.Instant.from, +> lifetimeBlinkCount: BigNumber, +> }; +> } +> ``` + +## Global computations + +[Query](./Query.md) can be used for computations of derived data from more than +one entity. We generally call these aggregates. + +```ts title="resources/User" +export class User extends Entity { + id = ''; + name = ''; + isAdmin = false; +} +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="UsersPage" +import { All, Query, schema } from '@data-client/rest'; +import { useQuery, useSuspense } from '@data-client/react'; +import { UserResource, User } from './resources/User'; + +const getUserCount = new Query( + new All(User), + (entries, { isAdmin } = {}) => { + if (isAdmin !== undefined) + return entries.filter(user => user.isAdmin === isAdmin).length; + return entries.length; + }, +); + +function UsersPage() { + useSuspense(UserResource.getList); + const userCount = useQuery(getUserCount); + const adminCount = useQuery(getUserCount, { isAdmin: true }); + // this should never happen since we suspense but typescript does not know that + if (userCount === undefined) return null; + return ( +
+
Total users: {userCount}
+
Total admins: {adminCount}
+
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-schema/references/partial-entities.md b/.agents/skills/data-client-schema/references/partial-entities.md deleted file mode 120000 index 5df889a2f9c0..000000000000 --- a/.agents/skills/data-client-schema/references/partial-entities.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/partial-entities.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/partial-entities.md b/.agents/skills/data-client-schema/references/partial-entities.md new file mode 100644 index 000000000000..b2679984c690 --- /dev/null +++ b/.agents/skills/data-client-schema/references/partial-entities.md @@ -0,0 +1,161 @@ + + +# Partial Entities + +Sometimes you have a [list endpoint](https://dataclient.io/rest/api/resource#getlist) whose entities only include +a subset of fields needed to summarize. + +```json title="ArticleSummary" +{ + "id": "1", + "title": "first" +} +``` + +```json title="Article" +{ + "id": "1", + "title": "first", + "content": "Imagine there was much more here.", + "createdAt": "2011-10-05T14:48:00.000Z" +} +``` + +In this case we can override [Entity.validate()](./Entity.md#validate) using [validateRequired()](https://dataclient.io/rest/api/validateRequired) to ensure +we have the full and complete response when needed (detail views), while keeping our state [DRY](https://deviq.com/principles/dont-repeat-yourself) and normalized to ensure data integrity. + +```typescript title="resources/Article" {12,24} +import { validateRequired, Collection, Entity, resource } from '@data-client/rest'; + +export class ArticleSummary extends Entity { + id = ''; + title = ''; + + // this ensures `Article` maps to the same entity + static key = 'Article'; + + static schema = { + createdAt: Temporal.Instant.from, + }; +} + +export class Article extends ArticleSummary { + content = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static validate(processedEntity) { + return validateRequired(processedEntity, this.defaults); + } +} + +export const ArticleResource = resource({ + path: '/article/:id', + schema: Article, +}).extend({ + getList: { + schema: new Collection([ArticleSummary]), + }, +}); +``` + +```tsx title="ArticleDetail" +import { ArticleResource } from './resources/Article'; + +function ArticleDetail({ id, onHome }: Props) { + const article = useSuspense(ArticleResource.get, { id }); + return ( +
+

+ + < + {' '} + {article.title} +

+
+

{article.content}

+
+ Created:{' '} + +
+
+
+ ); +} +interface Props { + id: string; + onHome: () => void; +} +function ArticleList() { + const [route, setRoute] = React.useState(''); + const articles = useSuspense(ArticleResource.getList); + if (!route) { + return ( +
+ {articles.map(article => ( +
setRoute(article.id)} + style={{ cursor: 'pointer', textDecoration: 'underline' }} + > + Click me: {article.title} +
+ ))} +
+ ); + } + return setRoute('')} />; +} + +render(); +``` + +## Detail data in nested entity + +It's often better to move expensive data into another entity to simplify conditional +logic. + +```typescript title="resources/Article.ts" +class ArticleSummary extends Entity { + id = ''; + title = ''; + content = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + createdAt: Temporal.Instant.from, + meta: ArticleMeta, + }; + + // this ensures `Article` maps to the same entity + static key = 'Article'; +} + +class Article extends ArticleSummary { + meta = ArticleMeta.fromJS(); + + static validate(processedEntity) { + return validateRequired(processedEntity, this.defaults); + } +} + +class ArticleMeta extends Entity { + viewCount = 0; + likeCount = 0; + relatedArticles: ArticleSummary[] = []; + + static schema = { + relatedArticles: [ArticleSummary], + }; +} + +const ArticleResource = resource({ + path: '/article/:id', + schema: Article, +}).extend({ + getList: { schema: new Collection([ArticleSummary]) }, +}); +``` diff --git a/.agents/skills/data-client-schema/references/relational-data.md b/.agents/skills/data-client-schema/references/relational-data.md deleted file mode 120000 index cd7fa8eb8dbf..000000000000 --- a/.agents/skills/data-client-schema/references/relational-data.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/relational-data.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/relational-data.md b/.agents/skills/data-client-schema/references/relational-data.md new file mode 100644 index 000000000000..645488918157 --- /dev/null +++ b/.agents/skills/data-client-schema/references/relational-data.md @@ -0,0 +1,474 @@ + + +# Relational data + +Reactive Data Client handles one-to-one, many-to-one and many-to-many relationships on [entities][1] +using [Entity.schema][3] + +## Nesting + +Nested members are hoisted during normalization when [Entity.schema][3] is defined. +They are then rejoined during denormalization + +
+ +Diagram + +```mermaid +erDiagram + USER ||--o{ POST : author + USER ||--o{ COMMENT : commenter + POST ||--o{ COMMENT : comments +``` + +  + +
+ +```typescript title="resources/Post" +import { Collection, Entity } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; +} + +export class Comment extends Entity { + id = ''; + content = ''; + commenter = User.fromJS(); + + static schema = { + commenter: User, + }; +} + +export class Post extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + comments: Comment[] = []; + + static schema = { + author: User, + comments: new Collection([Comment], { + nestKey: (parent, key) => ({ + postId: parent.id, + }), + }), + }; +} + +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```tsx title="PostPage" +import { PostResource } from './resources/Post'; + +function PostPage() { + const posts = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( +
+

+ {post.title} - {post.author.name} +

+
    + {post.comments.map(comment => ( +
  • + {comment.content}{' '} + + + {comment.commenter.name} + {comment.commenter === post.author ? ' [OP]' : ''} + + +
  • + ))} +
+
+ ))} +
+ ); +} +render(); +``` + +## Client side joins + +Nesting data when your endpoint doesn't. + +Even if the network responses don't nest data, we can perform client-side joins by specifying +the relationship in [Entity.schema](./Entity.md#schema) + +```ts title="resources/User" +export class User extends Entity { + id = 0; + username = ''; + name = ''; + email = ''; + website = ''; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```ts title="resources/Todo" +import { User } from './User'; + +export class Todo extends Entity { + id = 0; + userId = 0; + user? = User.fromJS(); + title = ''; + completed = false; + static schema = { + user: User, + }; + static process(todo) { + return { ...todo, user: todo.userId }; + } +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + schema: Todo, +}); +``` + +```tsx title="TodoJoined" +import { TodoResource } from './resources/Todo'; +import { UserResource } from './resources/User'; + +function TodosPage() { + useFetch(UserResource.getList); + const todos = useSuspense(TodoResource.getList); + return ( +
+ {todos.slice(17, 24).map(todo => ( +
+ {todo.title} by {todo.user?.name} +
+ ))} +
+ ); +} +render(); +``` + +### Key-based joins + +For more complex scenarios where related entities are fetched separately, use [Entity.process()](./Entity.md#process) +to create a reference key that links to another Entity. This is useful when: + +- Related data comes from different API endpoints +- You want to avoid over-fetching nested data +- The relationship is optional or varies by context + +```typescript +import { Entity, resource } from '@data-client/rest'; + +class Stats extends Entity { + product_id = ''; + volume = 0; + price = 0; + + pk() { + return this.product_id; + } + + static key = 'Stats'; +} + +class Currency extends Entity { + id = ''; + name = ''; + // Default value allows Currency to exist without Stats loaded + stats = Stats.fromJS(); + + pk() { + return this.id; + } + + static key = 'Currency'; + + // Create a reference key that links to Stats entity + static process(input: any, parent: any, key: string, args: any[]) { + // The stats field becomes a reference to Stats with pk `${id}-USD` + return { ...input, stats: `${input.id}-USD` }; + } + + static schema = { + // Stats will be looked up by the key from process() + stats: Stats, + }; +} +``` + +When both `CurrencyResource.getList` and `StatsResource.getList` are fetched, the `stats` +field will automatically resolve to the matching `Stats` entity. + +### Crypto price example + +Here we want to sort `Currencies` by their trade volume. However, trade volume is only available in the `Stats` +Entity. Even though `CurrencyResource.getList` fetch does not include `Stats` in the response, we can additionally +call `StatsResource.getList`, while adding it to our `Currency's` [Entity.schema](./Entity.md#schema) - enabling +`Stats` inclusion in our `Currency` Entity, which enables sorting with: + +```ts +entries.sort((a, b) => { + return b?.stats?.volume_usd - a?.stats?.volume_usd; +}); +``` + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/pages/Home/CurrencyList.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/pages/Home/CurrencyList.tsx), [`src/resources/Stats.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Stats.ts), [`src/resources/Currency.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/Currency.ts)) + +## Reverse lookups + +Nesting data when your endpoint doesn't (part 2). + +Even though a response may only nest in one direction, Reactive Data Client can handle reverse relationships +by overriding [Entity.process](./Entity.md#process). Additionally, [Entity.merge](./Entity.md#merge) +may need overriding to ensure deep merging of those expected fields. + +This allows you to traverse the relationship after processing only one fetch request, rather than having to fetch +each time you want access to a different view. + +```typescript title="resources/Post" +import { Collection, Entity } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; + posts: Post[] = []; + comments: Comment[] = []; + + static merge(existing, incoming) { + return { + ...existing, + ...incoming, + posts: [...(existing.posts || []), ...(incoming.posts || [])], + comments: [ + ...(existing.comments || []), + ...(incoming.comments || []), + ], + }; + } + + static process(value, parent, key) { + switch (key) { + case 'author': + return { ...value, posts: [parent.id] }; + case 'commenter': + return { ...value, comments: [parent.id] }; + default: + return { ...value }; + } + } +} + +export class Comment extends Entity { + id = ''; + content = ''; + commenter = User.fromJS(); + post = Post.fromJS(); + + static schema: Record = { + commenter: User, + }; + static process(value, parent, key) { + return { ...value, post: parent.id }; + } +} + +export class Post extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + comments: Comment[] = []; + + static schema = { + author: User, + comments: [Comment], + }; +} + +// with cirucular dependencies we must set schema after they are all defined +User.schema = { + posts: [Post], + comments: [Comment], +}; +Comment.schema = { + ...Comment.schema, + post: Post, +}; + +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + dataExpiryLength: Infinity, +}); +export const UserResource = resource({ + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="UserPage" +import { UserResource } from './resources/Post'; + +export default function UserPage({ setRoute, id }) { + const user = useSuspense(UserResource.get, { id }); + return ( +
+

+ setRoute('page')} style={{ cursor: 'pointer' }}> + < + {' '} + {user.name} +

+ {user.posts.length ? ( + <> +
Posts
+
    + {user.posts.map(post => ( +
  • {post.title}
  • + ))} +
+ + ) : null} +
Comments
+
    + {user.comments.map(comment => ( +
  • {comment.content}
  • + ))} +
+
+ ); +} +``` + +```tsx title="PostPage" +import { PostResource } from './resources/Post'; + +export default function PostPage({ setRoute }) { + const posts = useSuspense(PostResource.getList); + return ( +
+ {posts.map(post => ( +
+

+ {post.title} -{' '} + setRoute(`user/${post.author.id}`)} + style={{ cursor: 'pointer', textDecoration: 'underline' }} + > + {post.author.name} + +

+
    + {post.comments.map(comment => ( +
  • + {comment.content}{' '} + + + setRoute(`user/${comment.commenter.id}`) + } + style={{ + cursor: 'pointer', + textDecoration: 'underline', + }} + > + {comment.commenter.name} + {comment.commenter === post.author ? ' [OP]' : ''} + + +
  • + ))} +
+
+ ))} +
+ ); +} +``` + +```tsx title="Navigation" +import PostPage from './PostPage'; +import UserPage from './UserPage'; + +function Navigation() { + const [route, setRoute] = React.useState('posts'); + if (route.startsWith('user')) + return ; + + return ; +} +render(); +``` + +### Circular dependencies + +Because circular imports and circular class definitions are not allowed, sometimes it +will be necessary to define the [schema][3] after the [Entities][1] definition. + +```typescript title="resources/Post" +import { Collection, Entity } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + + static schema = { + author: User, + }; +} + +// both User and Post are now defined, so it's okay to refer to both of them +User.schema = { + // ensure we keep the 'createdAt' member + ...User.schema, + posts: [Post], +}; +``` + +```typescript title="resources/User" +import { Collection, Entity } from '@data-client/rest'; +import type { Post } from './Post'; +// we can only import the type else we break javascript imports +// thus we change the schema of UserResource above + +export class User extends Entity { + id = ''; + name = ''; + posts: Post[] = []; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema: Record = { + createdAt: Temporal.Instant.from, + }; +} +``` + +> **Tip** +> +> For bidirectional relationships that don't need eager denormalization, +> [Lazy](./Lazy.md) defers resolution and lets you resolve on demand +> via [useQuery](https://dataclient.io/docs/api/useQuery), avoiding deep recursion and improving +> memoization isolation. + +[1]: ./Entity.md + +[2]: https://dataclient.io/docs/api/useCache + +[3]: ./Entity.md#schema diff --git a/.agents/skills/data-client-schema/references/schema.md b/.agents/skills/data-client-schema/references/schema.md deleted file mode 120000 index 1866c3f280b0..000000000000 --- a/.agents/skills/data-client-schema/references/schema.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/api/schema.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/schema.md b/.agents/skills/data-client-schema/references/schema.md new file mode 100644 index 000000000000..2669fa771251 --- /dev/null +++ b/.agents/skills/data-client-schema/references/schema.md @@ -0,0 +1,267 @@ + + +# Thinking in Schemas + +Consider a typical blog post. The API response for a single post might look something like this: + +```json +{ + "id": "123", + "author": { + "id": "1", + "name": "Paul" + }, + "title": "My awesome blog post", + "comments": [ + { + "id": "324", + "createdAt": "2013-05-29T00:00:00-04:00", + "commenter": { + "id": "2", + "name": "Nicole" + } + }, + { + "id": "544", + "createdAt": "2013-05-30T00:00:00-04:00", + "commenter": { + "id": "1", + "name": "Paul" + } + } + ] +} +``` + +## Declarative definitions + +We have two nested [entity](./Entity.md) types within our `article`: `users` and `comments`. Using various [schema](./Entity.md#schema), we can normalize all three entity types down: + +```typescript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { + id = ''; + name = ''; +} + +class Comment extends Entity { + id = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + commenter = User.fromJS(); + + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + id = ''; + title = ''; + author = User.fromJS(); + comments: Comment[] = []; + + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +```javascript +import { schema, Entity } from '@data-client/endpoint'; +import { Temporal } from 'temporal-polyfill'; + +class User extends Entity { } + +class Comment extends Entity { + static schema = { + commenter: User, + createdAt: Temporal.Instant.from, + }; +} + +class Article extends Entity { + static schema = { + author: User, + comments: [Comment], + }; +} +``` + +## Normalize + +```js +import { normalize } from '@data-client/normalizr'; + +const args = [{ id: '123' }]; +const normalizedData = normalize(Article, originalData, args); +``` + +Now, `normalizedData` will create a single serializable source of truth for all entities: + +```js +{ + result: "123", + entities: { + articles: { + "123": { + id: "123", + author: "1", + title: "My awesome blog post", + comments: [ "324", "544" ] + } + }, + users: { + "1": { "id": "1", "name": "Paul" }, + "2": { "id": "2", "name": "Nicole" } + }, + comments: { + "324": { + id: "324", + createdAt: "2013-05-29T00:00:00-04:00", + commenter: "2" + }, + "544": { + id: "544", + createdAt: "2013-05-30T00:00:00-04:00", + commenter: "1" + } + } + }, + // contents excluded for brevity + indexes, + entitiesMeta, +} +``` + +## Denormalize + +```js +import { denormalize } from '@data-client/normalizr'; + +const denormalizedData = denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +``` + +Now, `denormalizedData` will instantiate the classes, ensuring all instances of the same member (like `Paul`) are referentially equal: + +```js +Article { + id: '123', + title: 'My awesome blog post', + author: User { id: '1', name: 'Paul' }, + comments: [ + Comment { + id: '324', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '2', name: 'Nicole' }] + }, + Comment { + id: '544', + createdAt: Instant [Temporal.Instant] {}, + commenter: [User { id: '1', name: 'Paul' }] + } + ] +} +``` + +### MemoCache + +`MemoCache` is a singleton that can be used to maintain referential equality between calls as well +as potentially improved performance by 2000%. Its methods are memoized. + +#### memo.denormalize + +```js +import { MemoCache } from '@data-client/normalizr'; + +// you can construct a new memo anytime you want to reset the cache +const memo = new MemoCache(); + +const { data, paths } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); +const { data: data2 } = memo.denormalize( + Article, + normalizedData.result, + normalizedData.entities, + args, +); + +// referential equality maintained between calls +assert(data === data2); +``` + +`memo.denormalize()` is just like [denormalize()](#denormalize) above but includes `paths` as part of the return value. `paths` +is an Array of paths of all entities included in the result. + +#### memo.query + +`memo.query()` allows denormalizing [Queryable](#queryable) based on args alone, rather than a normalized input. + +```ts +const data = memo.query( + Article, + args, + normalizedData, +); +``` + +## Queryable + +`Queryable` Schemas allow store access without an endpoint. They achieve this using the +[queryKey](./Entity.md#queryKey) method that produces the results normally stored in the endpoint cache. + +This enables their use in these additional cases: + +- [useQuery()](https://dataclient.io/docs/api/useQuery) - Rendering in React +- [schema.Query()](./Query.md) - As input to produce a computed memoization. +- [ctrl.get](https://dataclient.io/docs/api/Controller#get)/[snap.get](https://dataclient.io/docs/api/Snapshot#get) + - [Managers](https://dataclient.io/docs/concepts/managers) + - React with [useController()](https://dataclient.io/docs/api/useController) + - [RestEndpoint.getOptimisticResponse](https://dataclient.io/rest/api/RestEndpoint#getoptimisticresponse) + - [Unit testing hooks](https://dataclient.io/docs/guides/unit-testing-hooks) with [renderDataHook()](https://dataclient.io/docs/api/renderDataHook) +- [memo.query()](#memoquery) +- Improve performance of [useSuspense](https://dataclient.io/docs/api/useSuspense), [useDLE](https://dataclient.io/docs/api/useDLE) by rendering before endpoint resolution + +`Querables` include [Entity](./Entity.md), [All](./All.md), [Collection](./Collection.md), [Query](./Query.md), +[Union](./Union.md), and [Scalar](./Scalar.md). [Lazy](./Lazy.md) fields produce a Queryable via their [`.query`](./Lazy.md#query) accessor. + +```ts +interface Queryable { + queryKey( + args: readonly any[], + queryKey: (...args: any) => any, + getEntity: GetEntity, + getIndex: GetIndex, + // `{}` means non-void + ): {}; +} +``` + +## Schema Overview + +| Data Type | Mutable | Schema | Description | [Queryable](./schema.md#queryable) | +| ------------------------------------------------------------------- | ------- | ------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------- | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Entity](./Entity.md) | single _unique_ object | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | ✅ | [Union(Entity)](./Union.md) | polymorphic objects (`A \| B`) | ✅ | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | 🛑 | [Object](./Object.md) | statically known keys | 🛑 | +| [Object](https://en.wikipedia.org/wiki/Object_\(computer_science\)) | | [Invalidate(Entity)](./Invalidate.md) | [delete an entity](https://dataclient.io/docs/concepts/expiry-policy#invalidate-entity) | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | ✅ | [Collection(Array)](./Collection.md) | growable lists | ✅ | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | 🛑 | [Array](./Array.md) | immutable lists | 🛑 | +| [List](https://en.wikipedia.org/wiki/List_\(abstract_data_type\)) | | [All](./All.md) | list of all entities of a kind | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | ✅ | [Collection(Values)](./Collection.md) | growable maps | ✅ | +| [Map](https://en.wikipedia.org/wiki/Associative_array) | 🛑 | [Values](./Values.md) | immutable maps | 🛑 | +| [Scalar](https://en.wikipedia.org/wiki/Scalar_\(mathematics\)) | ✅ | [Scalar](./Scalar.md) | lens-dependent entity fields | ✅ | +| any | | [Query(Queryable)](./Query.md) | memoized custom transforms | ✅ | +| any | | [Lazy(Schema)](./Lazy.md) | deferred denormalization | ✅ | diff --git a/.agents/skills/data-client-schema/references/side-effects.md b/.agents/skills/data-client-schema/references/side-effects.md deleted file mode 120000 index 11ed646dedb6..000000000000 --- a/.agents/skills/data-client-schema/references/side-effects.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/side-effects.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/side-effects.md b/.agents/skills/data-client-schema/references/side-effects.md new file mode 100644 index 000000000000..0dcb305e1e8c --- /dev/null +++ b/.agents/skills/data-client-schema/references/side-effects.md @@ -0,0 +1,111 @@ + + +# Mutation Side-Effects + +When mutations update more than one resource, it may be tempting to simply +[expire all](https://dataclient.io/docs/api/Controller#expireAll) the other resources. + +However, we can still achieve the high performance atomic mutations if we +simply bundle _all_ updated resources in the mutation response, we can +avoid this slow networking cascade. + +Network Cascade + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Server + Client->>Server: POST Trade + Note over Client,Server: Backend performs trade + Server->>Client: New Trade Object + Note over Client,Server: Client Expires Account + Client->>Server: GET Account + Note over Client,Server: Lookup Account + Server->>Client: Account +``` + + Response Bundling + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Server + Client->>Server: POST Trade + Note over Client,Server: Backend performs trade + Server->>Client: Trade + Account +``` + +## Example + +You're running a crypto trading platform called `dogebase`. Every time +a user creates a trade, you need to update some balance information +in their accounts object. So upon `POST`ing to the `/trade/` endpoint, +you nest both the updated accounts object along with the trade you just +created. + +```json title="POST /trade/" +{ + "trade": { + "id": 2893232, + "user": 1, + "amount": "50.2335324", + "coin": "doge", + "created_at": "" + }, + "account": { + "id": 899, + "user": 1, + "balance": "1337.00", + "coin_value": "3.50" + } +} +``` + +To handle this, we just need to update the `schema` to include the custom +endpoint. + +```typescript title="resources/Trade.ts" +import { resource, Entity } from '@data-client/rest'; +import { Account } from './Account'; + +export class Trade extends Entity { + id = 0; + user = 0; + amount = '0'; + coin = ''; + created_at = ''; +} + +export const TradeResource = resource({ + path: '/trade/:id', + schema: Trade, +}).extend(Base => ({ + create: Base.getList.push.extend({ + schema: { + trade: Base.getList.push.schema, + account: Account, + }, + }), +})); +``` + +Now if when we use the [getList.push](https://dataclient.io/rest/api/resource#push) Endpoint generator method, +we will be happy knowing both the trade and account information will +be updated in the cache after the `POST` request is complete. + +```typescript title="CreateTrade.tsx" +export default function CreateTrade() { + const ctrl = useController(); + const handleSubmit = payload => + ctrl.fetch(TradeResource.create, payload); + //... +} +``` + +> **Note** +> +> Feel free to create completely new [RestEndpoint](https://dataclient.io/rest/api/RestEndpoint) methods for any custom +> endpoints you have. This endpoint tells `Reactive Data Client` how to process any +> request. diff --git a/.agents/skills/data-client-schema/references/sorting-client-side.md b/.agents/skills/data-client-schema/references/sorting-client-side.md deleted file mode 120000 index f299e71bdb39..000000000000 --- a/.agents/skills/data-client-schema/references/sorting-client-side.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/rest/guides/sorting-client-side.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/sorting-client-side.md b/.agents/skills/data-client-schema/references/sorting-client-side.md new file mode 100644 index 000000000000..d8d05bb08114 --- /dev/null +++ b/.agents/skills/data-client-schema/references/sorting-client-side.md @@ -0,0 +1,112 @@ + + +# Client Side Sorting + +Here we have an API that sorts based on the `orderBy` field. By wrapping our [Collection](./Collection.md) +in a [Query](./Query.md) that sorts, we can ensure we maintain the correct order after [pushing](https://dataclient.io/rest/api/RestEndpoint#push) +new posts. + +Our example code starts sorting by `title`. Try adding some posts and see them inserted in the correct sort +order. + +```ts title="getPosts" {17-24} +import { Collection, Entity, Query, RestEndpoint } from '@data-client/rest'; + +export class Post extends Entity { + id = ''; + title = ''; + group = ''; + author = ''; +} + +export const getPosts = new RestEndpoint({ + path: '/:group/posts', + searchParams: {} as { orderBy?: string; author?: string }, + schema: new Query( + new Collection([Post], { + nonFilterArgumentKeys: /orderBy/, + }), + (posts, { orderBy } = {}) => { + if (orderBy) { + return [...posts].sort((a, b) => + a[orderBy].localeCompare(b[orderBy]), + ); + } + return posts; + }, + ), +}); +``` + +```tsx title="NewPost" +import { useLoading } from '@data-client/react'; +import { getPosts } from './getPosts'; + +export default function NewPost({ author }: Props) { + const ctrl = useController(); + + const [handlePress, loading] = useLoading(async e => { + if (e.key === 'Enter') { + const title = e.currentTarget.value; + e.currentTarget.value = ''; + await ctrl.fetch( + getPosts.push, + { group: 'react' }, + { + title, + author, + }, + ); + } + }); + + return ; +} +interface Props { + author: string; +} +``` + +```tsx title="PostList" {8} +import { useSuspense } from '@data-client/react'; +import { getPosts } from './getPosts'; +import NewPost from './NewPost'; + +export default function PostList({ author }: Props) { + const posts = useSuspense(getPosts, { + author, + orderBy: 'title', + group: 'react', + }); + return ( +
+ {posts.map(post => ( +
{post.title}
+ ))} + +
+ ); +} +interface Props { + author: string; +} +``` + +```tsx title="UserList" +import PostList from './PostList'; + +function UserList() { + const users = ['bob', 'clara']; + return ( +
+ {users.map(user => ( +
+

{user}

+ +
+ ))} +
+ ); +} +render(); +``` diff --git a/.agents/skills/data-client-schema/references/validation.md b/.agents/skills/data-client-schema/references/validation.md deleted file mode 120000 index 014b049b79c5..000000000000 --- a/.agents/skills/data-client-schema/references/validation.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/concepts/validation.md \ No newline at end of file diff --git a/.agents/skills/data-client-schema/references/validation.md b/.agents/skills/data-client-schema/references/validation.md new file mode 100644 index 000000000000..5c9592998157 --- /dev/null +++ b/.agents/skills/data-client-schema/references/validation.md @@ -0,0 +1,161 @@ + + +# API Validation + +[Entity.validate()](./Entity.md#validate) is called during normalization and denormalization. +`undefined` indicates no error, and a string error message if there is an error. + +## Field check + +Validation happens after [Entity.process()](./Entity.md#process) but before [Entity.fromJS()](./Entity.md#fromJS), +thus operates on POJOs rather than an instance of the class. + +Here we can make sure the title field is included, and of the expected type. + +```typescript title="api/Article" +export class Article extends Entity { + id = ''; + title = ''; + + static validate(processedEntity) { + if (!Object.hasOwn(processedEntity, 'title')) return 'missing title field'; + if (typeof processedEntity.title !== 'string') return 'title is wrong type'; + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: Article, +}); +``` + +```tsx title="ArticlePage" +import { getArticle } from './api/Article'; + +function ArticlePage({ id }: { id: string }) { + const article = useSuspense(getArticle, { id }); + return
{article.title}
; +} + +render(); +``` + +### All fields check + +[validateRequired()](https://dataclient.io/rest/api/validateRequired) can be used to check if all defined fields are present. + +```tsx title="api/Article" +export class Article extends Entity { + id = ''; + title = ''; + + static validate(processedEntity) { + return validateRequired(processedEntity, this.defaults); + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: Article, +}); +``` + +```tsx title="ArticlePage" +import { getArticle } from './api/Article'; + +function ArticlePage({ id }: { id: string }) { + const article = useSuspense(getArticle, { id }); + return
{article.title}
; +} + +render(); +``` + +## Partial results + +Another great use of validation is mixing endpoints that return [incomplete objects](./partial-entities.md). This is often +useful when some fields consume lots of bandwidth or are computationally expensive for the backend. + +Consider using [validateRequired](https://dataclient.io/rest/api/validateRequired) to reduce code. + +```typescript title="api/Article" +export class ArticlePreview extends Entity { + id = ''; + title = ''; + + static key = 'Article'; +} +export const getArticleList = new RestEndpoint({ + path: '/article', + schema: [ArticlePreview], +}); + +export class ArticleFull extends ArticlePreview { + content = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + createdAt: Temporal.Instant.from, + }; + + static validate(processedEntity) { + if (!Object.hasOwn(processedEntity, 'content')) return 'Missing content'; + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: ArticleFull, +}); +``` + +```tsx title="ArticleDetail" +import { getArticle, getArticleList } from './api/Article'; + +function ArticleDetail({ id, onHome }: { id: string; onHome: () => void }) { + const article = useSuspense(getArticle, { id }); + return ( +
+

+ + < + {' '} + {article.title} +

+
+

{article.content}

+
+ Created:{' '} + +
+
+
+ ); +} +function ArticleList() { + const [route, setRoute] = React.useState(''); + const articles = useSuspense(getArticleList); + if (!route) { + return ( +
+ {articles.map(article => ( +
setRoute(article.id)} + style={{ cursor: 'pointer', textDecoration: 'underline' }} + > + Click me: {article.title} +
+ ))} +
+ ); + } + return setRoute('')} />; +} + +render(); +``` diff --git a/.agents/skills/data-client-schema/references/validation.vue.md b/.agents/skills/data-client-schema/references/validation.vue.md new file mode 100644 index 000000000000..6d312b5d9168 --- /dev/null +++ b/.agents/skills/data-client-schema/references/validation.vue.md @@ -0,0 +1,172 @@ + + +# API Validation + +[Entity.validate()](./Entity.md#validate) is called during normalization and denormalization. +`undefined` indicates no error, and a string error message if there is an error. + +## Field check + +Validation happens after [Entity.process()](./Entity.md#process) but before [Entity.fromJS()](./Entity.md#fromJS), +thus operates on POJOs rather than an instance of the class. + +Here we can make sure the title field is included, and of the expected type. + +```typescript title="api/Article" +export class Article extends Entity { + id = ''; + title = ''; + + static validate(processedEntity) { + if (!Object.hasOwn(processedEntity, 'title')) return 'missing title field'; + if (typeof processedEntity.title !== 'string') return 'title is wrong type'; + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: Article, +}); +``` + +```html title="ArticlePage.vue" + + + +``` + +### All fields check + +[validateRequired()](https://dataclient.io/rest/api/validateRequired) can be used to check if all defined fields are present. + +```tsx title="api/Article" +export class Article extends Entity { + id = ''; + title = ''; + + static validate(processedEntity) { + return validateRequired(processedEntity, this.defaults); + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: Article, +}); +``` + +```html title="ArticlePage.vue" + + + +``` + +## Partial results + +Another great use of validation is mixing endpoints that return [incomplete objects](./partial-entities.md). This is often +useful when some fields consume lots of bandwidth or are computationally expensive for the backend. + +Consider using [validateRequired](https://dataclient.io/rest/api/validateRequired) to reduce code. + +```typescript title="api/Article" +export class ArticlePreview extends Entity { + id = ''; + title = ''; + + static key = 'Article'; +} +export const getArticleList = new RestEndpoint({ + path: '/article', + schema: [ArticlePreview], +}); + +export class ArticleFull extends ArticlePreview { + content = ''; + createdAt = Temporal.Instant.fromEpochMilliseconds(0); + + static schema = { + createdAt: Temporal.Instant.from, + }; + + static validate(processedEntity) { + if (!Object.hasOwn(processedEntity, 'content')) return 'Missing content'; + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: ArticleFull, +}); +``` + +```html title="ArticleDetail.vue" + + + +``` + +```html title="ArticleList.vue" + + + +``` diff --git a/.agents/skills/data-client-setup/SKILL.md b/.agents/skills/data-client-setup/SKILL.md index 84d7e2420526..8b4f119f9e68 100644 --- a/.agents/skills/data-client-setup/SKILL.md +++ b/.agents/skills/data-client-setup/SKILL.md @@ -247,6 +247,8 @@ After core setup and protocol-specific setup: ## References +Vue projects: read `.vue.md` instead of `.md` when it exists. + For detailed API documentation, see the [references](references/) directory: - [DataProvider](references/DataProvider.md) - Root provider component diff --git a/.agents/skills/data-client-setup/references.json b/.agents/skills/data-client-setup/references.json new file mode 100644 index 000000000000..b45a88570842 --- /dev/null +++ b/.agents/skills/data-client-setup/references.json @@ -0,0 +1,11 @@ +{ + "frameworks": [ + "react", + "vue" + ], + "docs": { + "DataProvider.md": "docs/core/api/DataProvider.md", + "getDefaultManagers.md": "docs/core/api/getDefaultManagers.md", + "installation.md": "docs/core/getting-started/installation.md" + } +} diff --git a/.agents/skills/data-client-setup/references/DataProvider.md b/.agents/skills/data-client-setup/references/DataProvider.md deleted file mode 120000 index 3baec4fe4f46..000000000000 --- a/.agents/skills/data-client-setup/references/DataProvider.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/DataProvider.md \ No newline at end of file diff --git a/.agents/skills/data-client-setup/references/DataProvider.md b/.agents/skills/data-client-setup/references/DataProvider.md new file mode 100644 index 000000000000..73cee8e5d20a --- /dev/null +++ b/.agents/skills/data-client-setup/references/DataProvider.md @@ -0,0 +1,199 @@ + + +# \ + +Manages state, providing all context needed to use the hooks. Should be placed as high as possible +in application tree as any usage of the hooks is only possible for components below the provider +in the React tree. + +**Web** + +```tsx title="index.tsx" +import { DataProvider } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +createRoot(document.body).render( + + + , +); +``` + +Alternatively [integrate state with redux](https://dataclient.io/docs/guides/redux) + +**React Native** + +```tsx title="index.tsx" +import { DataProvider } from '@data-client/react'; +import { AppRegistry } from 'react-native'; + +const Root = () => ( + + + +); +AppRegistry.registerComponent('MyApp', () => Root); +``` + +Alternatively [integrate state with redux](https://dataclient.io/docs/guides/redux) + +**NextJS** + +[Full NextJS Guide](https://dataclient.io/docs/guides/ssr#nextjs) + +```tsx title="app/layout.tsx" +import { DataProvider } from '@data-client/react/nextjs'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +**Expo** + +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import { DataProvider } from '@data-client/react'; + +export default function RootLayout() { + return ( + + + + + + ); +} +``` + +**Anansi** + +[Anansi](https://github.com/ntucker/anansi) (beta) is a fully composable framework for React development with optional +Server Side Rendering. + +```bash title="bash" +npx @anansi/cli hatch my-project +``` + +Anansi includes Reactive Data Client automatically. + +## Props + +```typescript +interface ProviderProps { + children: ReactNode; + managers?: Manager[]; + initialState?: State; + Controller?: typeof Controller; + devButton?: + | 'bottom-right' + | 'bottom-left' + | 'top-right' + | 'top-left' + | null; +} +``` + +### initialState: State\ {#initialState} + +```typescript +export interface State { + readonly entities: { + readonly [entityKey: string]: { readonly [pk: string]: T } | undefined; + }; + readonly endpoints: { + readonly [key: string]: unknown | PK[] | PK | undefined; + }; + readonly indexes: NormalizedIndex; + readonly meta: { + readonly [key: string]: { + readonly date: number; + readonly error?: ErrorTypes; + readonly expiresAt: number; + readonly prevExpiresAt?: number; + readonly invalidated?: boolean; + readonly errorPolicy?: 'hard' | 'soft' | undefined; + }; + }; + readonly entitiesMeta: { + readonly [entityKey: string]: { + readonly [pk: string]: { + readonly date: number; + readonly expiresAt: number; + readonly fetchedAt: number; + }; + }; + }; + readonly optimistic: (SetResponseAction | OptimisticAction)[]; + readonly lastReset: number; +} +``` + +Instead of starting with an empty cache, you can provide your own initial state. This can +be useful for testing, or rehydrating the cache state when using server side rendering. + +### managers?: Manager\[] {#managers} + +List of [Manager](https://dataclient.io/docs/api/Manager)s use. This is the main extensibility point of the provider. + +[getDefaultManagers()](./getDefaultManagers.md) can be used to extend the default managers. + +Default Production: + +```typescript +[new NetworkManager(), new SubscriptionManager(PollingSubscription)]; +``` + +Default Development: + +```typescript +[ + new DevToolsManager(), + new NetworkManager(), + new SubscriptionManager(PollingSubscription), +]; +``` + +### Controller: typeof Controller {#Controller} + +This allows you to extend [Controller](https://dataclient.io/docs/api/Controller) to provide additional functionality. +This might be useful if you have additional actions you want to dispatch to custom [Managers](https://dataclient.io/docs/api/Manager) + +```tsx +class MyController extends Controller { + doSomething = () => { + console.log('hi'); + }; +} + +const RealApp = ( + + + +); +``` + +### devButton + +In development, a small button will appear that gives easy access to [browser devtools](https://dataclient.io/docs/getting-started/debugging) if +installed. This option configures where it shows up, or if null will disable it altogether. + +`'bottom-right' | 'bottom-left' | 'top-right'| 'top-left' | null` = `'bottom-right'` + +```tsx title="Disable button" + + + +``` + +```tsx title="Place in top right corner" + + + +``` diff --git a/.agents/skills/data-client-setup/references/getDefaultManagers.md b/.agents/skills/data-client-setup/references/getDefaultManagers.md deleted file mode 120000 index 8cb0cf50caab..000000000000 --- a/.agents/skills/data-client-setup/references/getDefaultManagers.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/getDefaultManagers.md \ No newline at end of file diff --git a/.agents/skills/data-client-setup/references/getDefaultManagers.md b/.agents/skills/data-client-setup/references/getDefaultManagers.md new file mode 100644 index 000000000000..8e5f01cb6919 --- /dev/null +++ b/.agents/skills/data-client-setup/references/getDefaultManagers.md @@ -0,0 +1,114 @@ + + +# getDefaultManagers() + +`getDefaultManagers` returns an Array of [Managers](https://dataclient.io/docs/api/Manager) to be sent to [\](./DataProvider.md). + +This makes it simple to configure and add custom [Managers](https://dataclient.io/docs/api/Manager), while remaining robust against +any potential changes to the default managers. + +Currently returns \[[DevToolsManager](https://dataclient.io/docs/api/DevToolsManager)\*, [NetworkManager](https://dataclient.io/docs/api/NetworkManager), [SubscriptionManager](https://dataclient.io/docs/api/SubscriptionManager)]. + +\*(`DevToolsManager` is excluded in production builds.) + +## Usage + +```tsx +import { DataProvider, getDefaultManagers } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +const managers = getDefaultManagers({ + // set fallback expiry time to an hour + networkManager: { dataExpiryLength: 1000 * 60 * 60 }, +}); + +createRoot(document.body).render( + + + , +); +``` + +See [DataProvider](./DataProvider.md) for details on usage in different environments. + +## Arguments + +Each argument represents a configuration of the manager. It can be of three possible types: + +- Any plain object is used as options to be sent to the manager's constructor. +- An instance of the manager to be used directly. +- `null`. When sent will exclude the manager. + +```ts +getDefaultManagers({ + devToolsManager: { trace: true }, + networkManager: new NetworkManager({ errorExpiryLength: 1 }), + subscriptionManager: null, +}); +``` + +### networkManager + +> **Note** +> +> `null` is not allowed here since NetworkManager is required + +`dataExpiryLength` is used as a fallback when an Endpoint does not have [dataExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointdataexpirylength) defined. + +`errorExpiryLength` is used as a fallback when an Endpoint does not have [errorExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointerrorexpirylength) defined. + +### devToolsManager + +[Arguments](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md) +to send to redux devtools. + +### subscriptionManager + +A class that implements `SubscriptionConstructable` like [PollingSubscription](https://dataclient.io/docs/api/PollingSubscription) + +## Examples + +### Tracing actions + +For example, we can enable the [trace](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace) option to help track down where actions are dispatched from. This has a large performance impact, so it is normally disabled. + +```ts +const managers = getDefaultManagers({ + devToolsManager: { trace: true }, +}); +``` + +### Manager inheritance + +Sending manager instances allows us to customize managers using inheritance. + +```ts +import { IdlingNetworkManager } from '@data-client/react'; + +const managers = getDefaultManagers({ + networkManager: new IdlingNetworkManager(), +}); +``` + +`IdlingNetworkManager` can prevent stuttering by delaying [sideEffect](https://dataclient.io/rest/api/Endpoint#sideeffect)-free (read-only/GET) fetches +until animations are complete. This works in web using [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback), and react native using InteractionManager.runAfterInteractions. + +### Disabling + +Using `null` will remove managers completely. [NetworkManager](https://dataclient.io/docs/api/NetworkManager) cannot be removed this way. + +```ts +const managers = getDefaultManagers({ + devToolsManager: null, + subscriptionManager: null, +}); +``` + +Here we disable every manager except [NetworkManager](https://dataclient.io/docs/api/NetworkManager). + +### Coin App + +New prices are streamed in many times a second; to reduce devtool spam, we set it +to ignore [SET](https://dataclient.io/docs/api/Controller#set) actions for `Ticker`. + +Example app: [coin-app](https://github.com/reactive/data-client/tree/master/examples/coin-app) ([`src/index.tsx`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/index.tsx), [`src/resources/StreamManager.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/resources/StreamManager.ts), [`src/getManagers.ts`](https://github.com/reactive/data-client/blob/master/examples/coin-app/src/getManagers.ts)) diff --git a/.agents/skills/data-client-setup/references/getDefaultManagers.vue.md b/.agents/skills/data-client-setup/references/getDefaultManagers.vue.md new file mode 100644 index 000000000000..362ad7d84a3f --- /dev/null +++ b/.agents/skills/data-client-setup/references/getDefaultManagers.vue.md @@ -0,0 +1,112 @@ + + +# getDefaultManagers() + +`getDefaultManagers` returns an Array of [Managers](https://dataclient.io/vue/api/Manager) to be sent to [DataClientPlugin](./installation.vue.md#add-provider-at-top-level-component). + +This makes it simple to configure and add custom [Managers](https://dataclient.io/vue/api/Manager), while remaining robust against +any potential changes to the default managers. + +Currently returns \[[DevToolsManager](https://dataclient.io/vue/api/DevToolsManager)\*, [NetworkManager](https://dataclient.io/vue/api/NetworkManager), [SubscriptionManager](https://dataclient.io/vue/api/SubscriptionManager)]. + +\*(`DevToolsManager` is excluded in production builds.) + +## Usage + +```ts title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin, getDefaultManagers } from '@data-client/vue'; +import App from './App.vue'; + +const managers = getDefaultManagers({ + // set fallback expiry time to an hour + networkManager: { dataExpiryLength: 1000 * 60 * 60 }, +}); + +const app = createApp(App); +app.use(DataClientPlugin, { managers }); +app.mount('#app'); +``` + +When `managers` is omitted, `DataClientPlugin` uses `getDefaultManagers()` with no arguments. +See [installation](./installation.vue.md#add-provider-at-top-level-component) for the +other `DataClientPlugin` options. + +## Arguments + +Each argument represents a configuration of the manager. It can be of three possible types: + +- Any plain object is used as options to be sent to the manager's constructor. +- An instance of the manager to be used directly. +- `null`. When sent will exclude the manager. + +```ts +getDefaultManagers({ + devToolsManager: { trace: true }, + networkManager: new NetworkManager({ errorExpiryLength: 1 }), + subscriptionManager: null, +}); +``` + +### networkManager + +> **Note** +> +> `null` is not allowed here since NetworkManager is required + +`dataExpiryLength` is used as a fallback when an Endpoint does not have [dataExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointdataexpirylength) defined. + +`errorExpiryLength` is used as a fallback when an Endpoint does not have [errorExpiryLength](https://dataclient.io/docs/concepts/expiry-policy#endpointerrorexpirylength) defined. + +### devToolsManager + +[Arguments](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md) +to send to redux devtools. + +### subscriptionManager + +A class that implements `SubscriptionConstructable` like [PollingSubscription](https://dataclient.io/vue/api/PollingSubscription) + +## Examples + +### Tracing actions + +For example, we can enable the [trace](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace) option to help track down where actions are dispatched from. This has a large performance impact, so it is normally disabled. + +```ts +const managers = getDefaultManagers({ + devToolsManager: { trace: true }, +}); +``` + +### Manager inheritance + +Sending manager instances allows us to customize managers using inheritance. + +```ts +import { NetworkManager, type FetchAction } from '@data-client/vue'; + +class LoggingNetworkManager extends NetworkManager { + protected handleFetch(action: FetchAction) { + console.log('fetching', action.key); + return super.handleFetch(action); + } +} + +const managers = getDefaultManagers({ + networkManager: new LoggingNetworkManager(), +}); +``` + +### Disabling + +Using `null` will remove managers completely. [NetworkManager](https://dataclient.io/vue/api/NetworkManager) cannot be removed this way. + +```ts +const managers = getDefaultManagers({ + devToolsManager: null, + subscriptionManager: null, +}); +``` + +Here we disable every manager except [NetworkManager](https://dataclient.io/vue/api/NetworkManager). diff --git a/.agents/skills/data-client-setup/references/installation.md b/.agents/skills/data-client-setup/references/installation.md deleted file mode 120000 index 7fca195ef422..000000000000 --- a/.agents/skills/data-client-setup/references/installation.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/getting-started/installation.md \ No newline at end of file diff --git a/.agents/skills/data-client-setup/references/installation.md b/.agents/skills/data-client-setup/references/installation.md new file mode 100644 index 000000000000..9798f8601179 --- /dev/null +++ b/.agents/skills/data-client-setup/references/installation.md @@ -0,0 +1,142 @@ + + +# Getting Started with Reactive Data Client + +```bash +npm install @data-client/react @data-client/test @data-client/rest +``` + +> **Tip: Use Agent Skills** +> +> Prefer to scaffold via your AI agent? See [Agent Skills](https://dataclient.io/docs/getting-started/agent-skills) and run `/data-client-setup`. + +## Add provider at top-level component {#add-provider-at-top-level-component} + +**Web** + +```tsx title="index.tsx" +import { DataProvider } from '@data-client/react'; +import { createRoot } from 'react-dom/client'; + +createRoot(document.body).render( + + + , +); +``` + +Alternatively [integrate state with redux](https://dataclient.io/docs/guides/redux) + +**React Native** + +```tsx title="index.tsx" +import { DataProvider } from '@data-client/react'; +import { AppRegistry } from 'react-native'; + +const Root = () => ( + + + +); +AppRegistry.registerComponent('MyApp', () => Root); +``` + +Alternatively [integrate state with redux](https://dataclient.io/docs/guides/redux) + +**NextJS** + +[Full NextJS Guide](https://dataclient.io/docs/guides/ssr#nextjs) + +```tsx title="app/layout.tsx" +import { DataProvider } from '@data-client/react/nextjs'; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +**Expo** + +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import { DataProvider } from '@data-client/react'; + +export default function RootLayout() { + return ( + + + + + + ); +} +``` + +**Anansi** + +[Anansi](https://github.com/ntucker/anansi) (beta) is a fully composable framework for React development with optional +Server Side Rendering. + +```bash title="bash" +npx @anansi/cli hatch my-project +``` + +Anansi includes Reactive Data Client automatically. + +[Next: Define Data »](https://dataclient.io/docs/getting-started/resource) + +## Example + +Example app: [todo-app](https://github.com/reactive/data-client/tree/master/examples/todo-app) ([`src/index.tsx`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/index.tsx), [`src/RootProvider.tsx`](https://github.com/reactive/data-client/blob/master/examples/todo-app/src/RootProvider.tsx)) + +## Supported Tools + +
+ +TypeScript 4.0+ + +TypeScript is optional, but requires at least version [4.0](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#variadic-tuple-types) and [strictNullChecks](https://www.typescriptlang.org/tsconfig#strictNullChecks) for full type enforcement. + +
+ +
+ +Older browser support + +If your application targets older browsers (a few years or more), be sure to load polyfills. +Typically this is done with [@babel/preset-env useBuiltIns: 'entry'](https://babeljs.io/docs/en/babel-preset-env#usebuiltins), +coupled with importing [core-js](https://www.npmjs.com/package/core-js) at the entrypoint of your application. + +This ensures only the needed polyfills for your browser support targets are included in your application bundle. + +For instance `TypeError: Object.hasOwn is not a function` + +
+ +
+ +Internet Explorer support + +If you see `Uncaught TypeError: Class constructor Resource cannot be invoked without 'new'`, +follow the instructions to [add legacy browser support to packages](https://dataclient.io/docs/guides/legacy-browser) + +
+ +
+ +ReactJS 16-19 and React Native + +ReactJS 16.2 and above is supported (the one with hooks!). React 18 provides improved [Suspense](https://dataclient.io/docs/api/useSuspense) +support and features. Both React Native, [React Navigation](https://reactnavigation.org/) and [Expo](https://docs.expo.dev) are supported. + +If you have a working project using other +React libraries, [feel free to share with others](https://github.com/reactive/data-client/discussions/2422) in our +discussions. + +
diff --git a/.agents/skills/data-client-setup/references/installation.vue.md b/.agents/skills/data-client-setup/references/installation.vue.md new file mode 100644 index 000000000000..bc39ca65a263 --- /dev/null +++ b/.agents/skills/data-client-setup/references/installation.vue.md @@ -0,0 +1,79 @@ + + +# Getting Started with Reactive Data Client + +> **Tip: Use Agent Skills** +> +> Prefer to scaffold via your AI agent? See [Agent Skills](https://dataclient.io/vue/getting-started/agent-skills) and run `/data-client-setup`. + +## Install the plugin {#add-provider-at-top-level-component} + +Install the [Vue plugin](https://vuejs.org/guide/reusability/plugins.html) when creating your app. + +```bash +npm install @data-client/vue @data-client/rest +``` + +```tsx title="main.ts" +import { createApp } from 'vue'; +import { DataClientPlugin } from '@data-client/vue'; + +const app = createApp(App); + +app.use(DataClientPlugin, { + // optional overrides + // managers: getDefaultManagers(), + // initialState, + // Controller, + // gcPolicy, +}); + +app.mount('#app'); +``` + +[Next: Define Data »](https://dataclient.io/vue/getting-started/resource) + +## Example + +Example app: [vue-todo-app](https://github.com/reactive/data-client/tree/master/examples/vue-todo-app) ([`src/main.ts`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/main.ts), [`src/pages/UserTodos.vue`](https://github.com/reactive/data-client/blob/master/examples/vue-todo-app/src/pages/UserTodos.vue)) + +## Supported Tools + +
+ +TypeScript 4.0+ + +TypeScript is optional, but requires at least version [4.0](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#variadic-tuple-types) and [strictNullChecks](https://www.typescriptlang.org/tsconfig#strictNullChecks) for full type enforcement. + +
+ +
+ +Older browser support + +If your application targets older browsers (a few years or more), be sure to load polyfills. +Typically this is done with [@babel/preset-env useBuiltIns: 'entry'](https://babeljs.io/docs/en/babel-preset-env#usebuiltins), +coupled with importing [core-js](https://www.npmjs.com/package/core-js) at the entrypoint of your application. + +This ensures only the needed polyfills for your browser support targets are included in your application bundle. + +For instance `TypeError: Object.hasOwn is not a function` + +
+ +
+ +Internet Explorer support + +If you see `Uncaught TypeError: Class constructor Resource cannot be invoked without 'new'`, +follow the instructions to [add legacy browser support to packages](https://dataclient.io/vue/guides/legacy-browser) + +
+ +
+ +Vue 3 + +`@data-client/vue` supports Vue 3 and is built on the [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html). + +
diff --git a/.agents/skills/data-client-vue-testing/SKILL.md b/.agents/skills/data-client-vue-testing/SKILL.md index 0f6e5bbd42e1..d986ccbb7020 100644 --- a/.agents/skills/data-client-vue-testing/SKILL.md +++ b/.agents/skills/data-client-vue-testing/SKILL.md @@ -9,7 +9,7 @@ license: Apache 2.0 ## Composable Testing with renderDataCompose() ```typescript -import { renderDataCompose } from '../test'; +import { renderDataCompose } from '@data-client/vue/test'; import { reactive, computed } from 'vue'; it('useQuery() should return cached data', () => { @@ -46,7 +46,7 @@ it('useQuery() should return cached data', () => { ## Component Testing with mountDataClient() ```typescript -import { mountDataClient } from '../test'; +import { mountDataClient } from '@data-client/vue/test'; import { defineComponent, h, reactive } from 'vue'; it('should render article component', async () => { @@ -369,7 +369,6 @@ expect(result.current?.value?.title).toBe('hi ho'); For detailed API documentation, see the [references](references/) directory: - [Fixtures](references/Fixtures.md) - Fixture format reference -- [unit-testing-hooks](references/unit-testing-hooks.md) - Hook/composable testing guide - [nock-http-mocking](references/nock-http-mocking.md) - Full nock setup, dynamic server state, request spying, errors, pitfalls - [polling-subscriptions](references/polling-subscriptions.md) - Fake-timer patterns for `useLive`/`useSubscription`/`pollFrequency`, unsubscribe verification, polling via nock diff --git a/.agents/skills/data-client-vue-testing/references.json b/.agents/skills/data-client-vue-testing/references.json new file mode 100644 index 000000000000..3e2b9c7b8c4d --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references.json @@ -0,0 +1,8 @@ +{ + "frameworks": [ + "vue" + ], + "docs": { + "Fixtures.md": "docs/core/api/Fixtures.md" + } +} diff --git a/.agents/skills/data-client-vue-testing/references/Fixtures.md b/.agents/skills/data-client-vue-testing/references/Fixtures.md deleted file mode 120000 index 7671d7554dd6..000000000000 --- a/.agents/skills/data-client-vue-testing/references/Fixtures.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/api/Fixtures.md \ No newline at end of file diff --git a/.agents/skills/data-client-vue-testing/references/Fixtures.md b/.agents/skills/data-client-vue-testing/references/Fixtures.md new file mode 100644 index 000000000000..b3d3f0b1ea75 --- /dev/null +++ b/.agents/skills/data-client-vue-testing/references/Fixtures.md @@ -0,0 +1,217 @@ + + +# Fixtures and Interceptors + +Fixtures and Interceptors allow universal data mocking without the need for monkeypatching +fetch behaviors. Fixtures define static responses to specific endpoint arg combinations. This +allows them to be used in static contexts like [mockInitialState()](https://dataclient.io/vue/api/mockInitialState). +Interceptors are functions run and match a fetch pattern. This restricts them to being used only +in dynamic response contexts like `MockPlugin`. + +## SuccessFixture + +Represents a successful response + +```ts +export interface SuccessFixture { + endpoint; + args; + response; + error?; + delay?; +} +``` + +```ts +export interface SuccessFixture< + E extends EndpointInterface = EndpointInterface, +> { + readonly endpoint: E; + readonly args: Parameters; + readonly response: + | ResolveType + | ((...args: Parameters) => ResolveType); + readonly error?: false; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { count: 0 }, +}; +``` + +## ErrorFixtures + +Represents a failed/errored response + +```ts +export interface ErrorFixture { + endpoint; + args; + response; + error; + delay?; +} +``` + +```ts +export interface ErrorFixture { + readonly endpoint: E; + readonly args: Parameters; + readonly response: any; + readonly error: true; + /** Number of miliseconds to wait before resolving */ + readonly delay?: number; +} +``` + +```ts +const countErrorFixture = { + endpoint: new RestEndpoint({ path: '/api/count' }), + args: [], + response: { message: 'Not found', status: 404 }, + error: true, +}; +``` + +## Interceptor + +Interceptors will match a request based on its [`testKey()`](https://dataclient.io/rest/api/RestEndpoint#testKey) method, then +compute the response dynamically using the `response()` method. + +```ts +interface ResponseInterceptor { + endpoint; + response(...args); + delay?; + delayCollapse?; +} + +interface FetchInterceptor { + endpoint; + fetchResponse(input, init); + delay?; + delayCollapse?; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +interface ResponseInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + } = EndpointInterface & { testKey(key: string): boolean }, +> { + readonly endpoint: E; + response(this: T, ...args: Parameters): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +interface FetchInterceptor< + T = any, + E extends EndpointInterface & { + update?: Updater; + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + } = EndpointInterface & { + testKey(key: string): boolean; + fetchResponse(input: RequestInfo, init: RequestInit): Promise; + extend(options: any): any; + }, +> { + readonly endpoint: E; + fetchResponse(this: T, input: RequestInfo, init: RequestInit): ResolveType; + /** Number of miliseconds (or function that returns) to wait before resolving */ + readonly delay?: number | ((...args: Parameters) => number); + /** Waits to run `response()` after `delay` time */ + readonly delayCollapse?: boolean; +} + +type Interceptor = ResponseInterceptor | FetchInterceptor; +``` + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + response() { + return { + count: (this.count = this.count + 1), + }; + }, + delay: () => 500 + Math.random() * 4500, +}; +``` + +## Arguments + +### endpoint + +The endpoint to match. + +### args + +(Fixtures only) The args to match. + +### response(...args) {#response} + +Determines what the response for this mock should be. If a function it will be run. + +Function running is called 'collapsing' after the mechanism in [Quantum Mechanics](https://www.wondriumdaily.com/copenhagen-interpretation-of-quantum-mechanics/) + +`this` can be used to store simulated server-side data. It is initialized using `getInitialInterceptorData`. It's important to not use arrow functions when using this as they disallow `this` binding. + +### fetchResponse(input, init) {#fetchResponse} + +When provided, will construct a response() method to be used based on overriding +(by calling [.extend](https://dataclient.io/rest/api/RestEndpoint#extend)) [fetchResponse](https://dataclient.io/rest/api/RestEndpoint#fetchResponse). + +Simply return the value expected, rather than an actual HTTP [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). + +```ts +const incrementInterceptor = { + endpoint: new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + }), + fetchResponse(input, init) { + return { + count: (this.count = this.count + 1), + updatedAt: JSON.parse(init.body).updatedAt, + }; + }, +}; +``` + +This can be useful when you want to use the body generated in a custom [getRequestInit()](https://dataclient.io/rest/api/RestEndpoint#getRequestInit) + +### delay: number {#delay} + +This is the number of miliseconds to wait before resolving the promise. This can be useful +when simulating race conditions. + +When a function is sent, its return value is used as the number of miliseconds. + +### delayCollapse: boolean {#delayCollapse} + +`true`: Runs response() after [delay](#delay) time + +`false`: Runs response() immediately, then resolves it after [delay](#delay) time + +This can be useful for simulating server-processing delays. diff --git a/.agents/skills/data-client-vue-testing/references/polling-subscriptions.md b/.agents/skills/data-client-vue-testing/references/polling-subscriptions.md index d916a2e7f2e7..f4609d6b3acc 100644 --- a/.agents/skills/data-client-vue-testing/references/polling-subscriptions.md +++ b/.agents/skills/data-client-vue-testing/references/polling-subscriptions.md @@ -23,7 +23,7 @@ Best for testing data-flow without involving the network layer. ```typescript import { computed, reactive, nextTick } from 'vue'; -import { renderDataCompose } from '../test'; +import { renderDataCompose } from '@data-client/vue/test'; it('subscribes and re-renders on poll', async () => { jest.useFakeTimers(); diff --git a/.agents/skills/data-client-vue-testing/references/unit-testing-hooks.md b/.agents/skills/data-client-vue-testing/references/unit-testing-hooks.md deleted file mode 120000 index d3d67757a231..000000000000 --- a/.agents/skills/data-client-vue-testing/references/unit-testing-hooks.md +++ /dev/null @@ -1 +0,0 @@ -../../../../docs/core/guides/unit-testing-hooks.md \ No newline at end of file diff --git a/.agents/skills/packages-documentation/SKILL.md b/.agents/skills/packages-documentation/SKILL.md index 42a5bafe1a2e..5445bd53faed 100644 --- a/.agents/skills/packages-documentation/SKILL.md +++ b/.agents/skills/packages-documentation/SKILL.md @@ -105,6 +105,7 @@ Before completing changes to public APIs in `/packages`: - [ ] Added migration notes for breaking changes - [ ] Updated TypeScript examples in documentation - [ ] Verified documentation builds correctly (if applicable) +- [ ] Updated agent skills in `.agents/skills` that cover the API: `references.json` for added/renamed/deleted pages, and `SKILL.md` examples (see `.cursor/rules/skills-sync.mdc`) ## Important Notes diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000000..ab7a022c5145 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.cursor/hooks/build-skills.js\"" + } + ] + } + ] + } +} diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 8e3e853c7fe1..bc046cb18538 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -5,6 +5,11 @@ { "command": "node .cursor/hooks/eslint-fix.js" } + ], + "beforeShellExecution": [ + { + "command": "node .cursor/hooks/build-skills.js" + } ] } } diff --git a/.cursor/hooks/build-skills.js b/.cursor/hooks/build-skills.js new file mode 100644 index 000000000000..4f343f1dcf69 --- /dev/null +++ b/.cursor/hooks/build-skills.js @@ -0,0 +1,132 @@ +/* global require */ +// Before an agent runs `git push` (Cursor `beforeShellExecution`, Claude Code +// `PreToolUse` on Bash), regenerates agent skill references when the branch +// touches their inputs, and holds the push until the result is committed. +// Runs once per push instead of per edit or turn, so any number of local +// commits can come first; CI's `skills` check is the backstop. +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +let payload = {}; +try { + payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); +} catch { + process.exit(0); +} +const command = payload.command ?? payload.tool_input?.command ?? ''; +// a git subcommand as a command (`git push`, `git -C dir push`, `cd x && git +// push`); not `git stash push`, `git -c commit.gpgsign=false`, a branch named +// fix-commit or a commit message mentioning push +const gitCommand = sub => + new RegExp( + `(?:^|[;&|(]\\s*)git(?:\\s+-[cC]\\s+\\S+|\\s+--?[\\w-]+(?:=\\S+)?)*\\s+${sub}(?![\\w.-])`, + 'm', + ); +if (!gitCommand('push').test(command)) process.exit(0); + +const projectDir = + process.env.CURSOR_PROJECT_DIR || + process.env.CLAUDE_PROJECT_DIR || + process.cwd(); +const git = (...args) => + execFileSync('git', args, { + cwd: projectDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trimEnd(); + +/** Docs some skill renders; partials (`_foo.mdx`) may be inlined anywhere */ +let skillDocs; +function isSkillDoc(file) { + if (!/^docs\/.*\.mdx?$/.test(file)) return false; + if (path.basename(file).startsWith('_')) return true; + if (!skillDocs) { + const skills = path.join(projectDir, '.agents/skills'); + skillDocs = new Set( + fs.readdirSync(skills).flatMap(skill => { + const manifest = path.join(skills, skill, 'references.json'); + return fs.existsSync(manifest) ? + Object.values(JSON.parse(fs.readFileSync(manifest, 'utf8')).docs) + : []; + }), + ); + } + return skillDocs.has(file.replace(/\.(react|vue)(\.mdx?)$/, '$2')); +} +const isInput = file => + isSkillDoc(file) || + /^\.agents\/skills\/[^/]+\/(references\.json|SKILL\.md)$/.test(file) || + file.startsWith('website/framework-docs/'); + +// files the branch changes relative to master, plus uncommitted ones when +// the same command commits before pushing (`git commit -am x && git push`) +try { + // renames as delete + add, so the old path counts too + const dirty = git( + 'status', + '--porcelain', + '--no-renames', + '--untracked-files=all', + ) + .split('\n') + .map(line => line.slice(3)) + .some(isInput); + // the generator reads the working tree, so it can only vouch for what's + // pushed when that includes these edits; otherwise leave it to CI + if (dirty && !gitCommand('commit').test(command)) process.exit(0); + const committed = git( + 'diff', + '--name-only', + '--no-renames', + 'origin/master...HEAD', + ) + .split('\n') + .some(isInput); + if (!dirty && !committed) process.exit(0); +} catch { + process.exit(0); +} + +let problems = ''; +try { + execFileSync('node', ['website/framework-docs/skillReferences.mjs'], { + cwd: projectDir, + stdio: ['ignore', 'ignore', 'pipe'], + }); +} catch (err) { + // dead links, missing variant notes or bad manifests + problems = String(err.stderr ?? '').trim(); +} +// includes references regenerated earlier but never committed +const uncommitted = git( + 'status', + '--porcelain', + '--untracked-files=all', + '--', + '.agents/skills/*/references/*', +); +if (!uncommitted && !problems) process.exit(0); + +const message = [ + uncommitted && + `Skill references generated from this branch's docs changes aren't committed. Commit them, then push again:\n${uncommitted}`, + problems && + `\`yarn build:skills\` found problems the skills CI check will fail on. Fix them, commit, then push again:\n${problems}`, +] + .filter(Boolean) + .join('\n\n'); +console.log( + JSON.stringify( + payload.hook_event_name === 'PreToolUse' ? + { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + } + : { permission: 'deny', userMessage: message, agentMessage: message }, + ), +); +process.exit(0); diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index aaf207295788..84a9c7171d3c 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -24,6 +24,7 @@ alwaysApply: false ## GitHub Actions (`.github/workflows/`) - Workflows install only needed workspaces via `./scripts/ci-install.sh [extra-workspace ...]`. +- `skills.yml` `paths` must cover every input of `website/framework-docs/skillReferences.mjs` (docs, skill manifests, the generator and its deps). - `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. - `benchmark-react.yml` caches Playwright browsers keyed on the resolved `playwright` version from `examples/benchmark-react`; bumping playwright invalidates the cache automatically. - Benchmark workflows (`benchmark.yml`, `benchmark-react.yml`) tune the host (CPU governor, swapoff) and pin CPUs with `taskset` — they must run directly on the runner, not in a `container:`. diff --git a/.cursor/rules/skills-sync.mdc b/.cursor/rules/skills-sync.mdc new file mode 100644 index 000000000000..b679b98add11 --- /dev/null +++ b/.cursor/rules/skills-sync.mdc @@ -0,0 +1,16 @@ +--- +description: Keep agent skills (.agents/skills) in sync when docs or public APIs change +globs: docs/**, .agents/skills/**, packages/*/src/index.ts +alwaysApply: false +--- + +# Skills sync + +Skill `references/*.md` files listed in a skill's `references.json` are generated from `docs/` by `yarn build:skills` (an agent hook runs it before `git push`; the `skills` CI check fails on drift and on dead `references/` links in `SKILL.md`). Everything else in a skill (`SKILL.md`, references without the generated header) is hand-written and only changes when you change it. + +- **Editing a doc**: never edit the generated reference. Edit the doc; references regenerate. +- **Adding a doc**: if a skill covers that API or topic (match by skill `description`), add the page to its `references.json` and link it from the skill's reference list in `SKILL.md`. New partials (`_foo.mdx`) need nothing; they're inlined. +- **Renaming, moving or deleting a doc**: update every `references.json` entry and `SKILL.md` link to it (`grep -rn '' .agents/skills`). The generator fails on a missing source. +- **Changing a public API** (rename, signature, new option, deprecation): grep `.agents/skills` for the old name and update `SKILL.md` examples and hand-written references in the same PR. Generated references only follow the docs. +- **Framework-specific pages**: a `frameworks: [react]` page is skipped for Vue; a skill whose `frameworks` lists `vue` must not rely on it. Skills covering both frameworks link `name.md`; `name.vue.md` exists only where Vue differs. +- App-level examples in skills import from `@data-client/react` or `@data-client/vue` (and `/test` subpaths), never `@data-client/core`. diff --git a/.github/workflows/skills.yml b/.github/workflows/skills.yml new file mode 100644 index 000000000000..c1b8c75867af --- /dev/null +++ b/.github/workflows/skills.yml @@ -0,0 +1,45 @@ +name: skills +on: + # master catches drift from lockfile bumps or PRs merged in sequence + push: + branches: + - master + paths: + - 'docs/**' + - '.agents/skills/**' + - 'website/framework-docs/**' + - 'website/package.json' + - 'yarn.lock' + - '.github/workflows/skills.yml' + pull_request: + branches: + - master + # Inputs of website/framework-docs/skillReferences.mjs + paths: + - 'docs/**' + - '.agents/skills/**' + - 'website/framework-docs/**' + - 'website/package.json' + - 'yarn.lock' + - '.github/workflows/skills.yml' + +concurrency: + group: skills-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + references: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 1 + - uses: actions/setup-node@v6 + with: + node-version: '26' + cache: 'yarn' + - name: Install packages + run: ./scripts/ci-install.sh website + - name: Check skill references match the docs + run: yarn build:skills --check diff --git a/AGENTS.md b/AGENTS.md index 600f1cc45e24..b73593a1a67b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ Any user-facing change in `packages/*` requires a changeset. Core packages are v - **Tests**: `packages/*/src/**/__tests__` - **Benchmarks**: `examples/benchmark` (Node: core/normalizr/endpoint throughput), `examples/benchmark-react` (browser: React rendering and data-library comparison). See `.cursor/rules/benchmarking.mdc` and each example’s README. - **Skills**: `.agents/skills/` (Cursor, Codex, and other agents) + - `references/*.md` listed in a skill's `references.json` are generated from `docs/`; edit the doc, never the reference. `yarn build:skills` regenerates them (an agent pre-push hook makes sure they are committed) and the `skills` CI check fails on drift. ## Key Principles diff --git a/docs/core/api/useDLE.md b/docs/core/api/useDLE.md index 73736a35dc06..18574d0883fe 100644 --- a/docs/core/api/useDLE.md +++ b/docs/core/api/useDLE.md @@ -127,17 +127,17 @@ below describes their `.value`. ::: -:::react +::::react -::::info[React Native] +:::info[React Native] When using React Navigation, useDLE() will trigger fetches on focus if the data is considered stale. -:::: - ::: +:::: + ## Types diff --git a/docs/core/api/useDebounce.md b/docs/core/api/useDebounce.md index a2afe1981a34..b23b35618ada 100644 --- a/docs/core/api/useDebounce.md +++ b/docs/core/api/useDebounce.md @@ -14,29 +14,29 @@ Delays updating the parameters by [debouncing](https://css-tricks.com/debouncing Useful to avoid spamming network requests when parameters might change quickly (like a typeahead field). -:::react +::::react -::::tip[React 18+] +:::tip[React 18+] When loading new data, the [AsyncBoundary](./AsyncBoundary.md) will continue rendering the previous data until it is ready. `isPending` will be true while loading. -:::: - ::: -:::vue +:::: -::::tip +::::vue + +:::tip `useDebounce()` returns [refs](https://vuejs.org/api/reactivity-core.html#ref), so the debounced value can be passed directly to other composables or components. `isPending` is true from the moment the input changes until the debounced value is updated. -:::: - ::: +:::: + ## Usage diff --git a/docs/core/api/useFetch.md b/docs/core/api/useFetch.md index 794ec1e552bd..7df83e5b10a4 100644 --- a/docs/core/api/useFetch.md +++ b/docs/core/api/useFetch.md @@ -233,17 +233,17 @@ The returned `Ref` is updated with a new promise whenever a fetch is triggered: ::: -:::react +::::react -::::info[React Native] +:::info[React Native] When using React Navigation, useFetch() will trigger fetches on focus if the data is considered stale. -:::: - ::: +:::: + ## Types diff --git a/docs/core/api/useLive.md b/docs/core/api/useLive.md index 25c2b8c8afda..458e68a8fe02 100644 --- a/docs/core/api/useLive.md +++ b/docs/core/api/useLive.md @@ -110,17 +110,17 @@ The subscription is removed automatically when the component unmounts. -:::react +::::react -::::info[React Native] +:::info[React Native] When using React Navigation, useLive() will trigger fetches on focus if the data is considered stale. useLive() will also sub/unsub with focus/unfocus respectively. -:::: - ::: +:::: + ## Types :::react diff --git a/docs/core/api/useLoading.md b/docs/core/api/useLoading.md index 3ba55c3638a8..c34c06c5d246 100644 --- a/docs/core/api/useLoading.md +++ b/docs/core/api/useLoading.md @@ -177,11 +177,11 @@ read at call time. ::: -:::react +::::react ## Eslint -::::tip[Eslint configuration] +:::tip[Eslint configuration] Since we use the deps list, be sure to add useLoading to the 'additionalHooks' configuration of [react-hooks/exhaustive-deps](https://www.npmjs.com/package/eslint-plugin-react-hooks) rule if you use it. @@ -197,10 +197,10 @@ of [react-hooks/exhaustive-deps](https://www.npmjs.com/package/eslint-plugin-rea } ``` -:::: - ::: +:::: + ## Types :::react diff --git a/docs/core/api/useSubscription.md b/docs/core/api/useSubscription.md index b720b0deffd8..f48dac0dffc2 100644 --- a/docs/core/api/useSubscription.md +++ b/docs/core/api/useSubscription.md @@ -81,16 +81,16 @@ function MasterPrice({ symbol }: { symbol: string }) { -:::react +::::react -::::info[React Native] +:::info[React Native] When using React Navigation, useSubscription() will sub/unsub with focus/unfocus respectively. -:::: - ::: +:::: + :::vue The subscription is created when the component is set up and removed when it unmounts. When diff --git a/docs/core/guides/abort.md b/docs/core/guides/abort.md index 4f2cbec7b511..4aa241a4bd0b 100644 --- a/docs/core/guides/abort.md +++ b/docs/core/guides/abort.md @@ -54,7 +54,7 @@ const AbortableUserDetail = UserDetail.extend({ abort.abort(); ``` -:::react +::::react ## Cancelling on params change @@ -66,12 +66,12 @@ change before the request is resolved. -::::warning[Warning] +:::warning[Warning] Be careful when using this with many disjoint components fetching the same arguments (Endpoint/params pair) to useSuspense(). This solution aborts fetches per-component, which means you might end up canceling a fetch that another component still cares about. -:::: - ::: + +:::: diff --git a/docs/rest/api/RestEndpoint.md b/docs/rest/api/RestEndpoint.md index b709d3554ba4..203cf2758205 100644 --- a/docs/rest/api/RestEndpoint.md +++ b/docs/rest/api/RestEndpoint.md @@ -1222,12 +1222,12 @@ return ( ); ``` -See [pagination guide](guides/pagination.md) for more info. +See [pagination guide](../guides/pagination.md) for more info. ### paginated(paginationfield) {#paginated} Creates a new endpoint with an extra `paginationfield` string that will be used to find the specific -page, to append to this endpoint. See [Infinite Scrolling Pagination](guides/pagination.md#infinite-scrolling) for more info. +page, to append to this endpoint. See [Infinite Scrolling Pagination](../guides/pagination.md#infinite-scrolling) for more info. ```ts const getNextPage = getList.paginated('cursor'); diff --git a/docs/rest/guides/network-transform.md b/docs/rest/guides/network-transform.md index e4c3e9463c8a..f905a2dd1efc 100644 --- a/docs/rest/guides/network-transform.md +++ b/docs/rest/guides/network-transform.md @@ -63,7 +63,7 @@ Keeping data in the serialized form is often fine, especially if it is only bein be displayed. However, this can be problematic when derived data is computed like adding time to a date or multiplying two numbers. -In this case, simply use the [static schema](api/Entity.md#schema) with [Temporal.Instant](https://tc39.es/proposal-temporal/) and [BigNumber](https://github.com/MikeMcl/bignumber.js) +In this case, simply use the [static schema](../api/Entity.md#schema) with [Temporal.Instant](https://tc39.es/proposal-temporal/) and [BigNumber](https://github.com/MikeMcl/bignumber.js) /references.json` into plain markdown with `docsToMarkdown.mjs`: framework +content resolved the same way (`remarkFramework.js`, front matter and `.vue.md` helpers from +`index.js`), Docusaurus' own MDX preprocessing, partials inlined, and playgrounds, tabs and embeds +reduced to their code. The +first framework in `frameworks` writes `.md`; later ones write `..md` only when +the page differs. Output is committed because skills install straight from the repo; the `skills` +workflow runs `yarn build:skills --check`, which also fails when a `SKILL.md` links to a +`references/` file that no longer exists, a reference is a symlink, or a skill has `.vue.md` variants its +`SKILL.md` never mentions. See `.cursor/rules/skills-sync.mdc` for what to update +when docs are added, renamed or deleted. + +Partials can use `props` in `{...}` expressions; the generator evaluates them with the props passed +where the partial is used. JSX inside an expression is only supported for ``; anything +else fails the build so it can't silently drop content. diff --git a/website/framework-docs/docsToMarkdown.mjs b/website/framework-docs/docsToMarkdown.mjs new file mode 100644 index 000000000000..181c1303c31e --- /dev/null +++ b/website/framework-docs/docsToMarkdown.mjs @@ -0,0 +1,539 @@ +/** + * Renders a docs page to plain markdown for one framework, the way the site + * renders it (remarkFramework.js, `frameworks:` front matter, `.vue.md` + * overrides, `vue_` front matter), with MDX partials inlined and site-only + * components (playgrounds, tabs, embeds) reduced to plain markdown. + * + * Used for agent skill references (skillReferences.mjs). + */ +import remarkComment from '@slorber/remark-comment'; +import { phrasing } from 'mdast-util-phrasing'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import remarkDirective from 'remark-directive'; +import remarkFrontmatter from 'remark-frontmatter'; +import remarkGfm from 'remark-gfm'; +import remarkMdx from 'remark-mdx'; +import remarkParse from 'remark-parse'; +import remarkStringify from 'remark-stringify'; +import { unified } from 'unified'; +import { visit } from 'unist-util-visit'; + +import { ROOT, SITE, rel } from './site.mjs'; + +const require = createRequire(import.meta.url); +const preprocessContent = + require('@docusaurus/mdx-loader/lib/preprocessor').default; + +const { + docIds, + docIdOf, + pageFrameworks, + rewriteFrontMatter, + frontMatterValue, +} = require('./index.js'); +const remarkFramework = require('./remarkFramework.js'); + +export { ROOT, SITE, rel }; + +/** docs folder -> route base per framework; keep in sync with docusaurus.config.ts */ +const ROUTES = [ + ['docs/core/', { react: '/docs/', vue: '/vue/' }], + ['docs/rest/', { react: '/rest/', vue: '/rest/' }], + ['docs/graphql/', { react: '/graphql/', vue: '/graphql/' }], +]; +const vueIds = docIds('vue'); +const MD = /\.mdx?$/; + +const processor = unified() + .use(remarkParse) + .use(remarkFrontmatter) + .use(remarkMdx) + .use(remarkComment) + .use(remarkGfm) + .use(remarkDirective); +const stringifier = unified() + .use(remarkStringify, { + bullet: '-', + emphasis: '_', + fences: true, + listItemIndent: 'one', + rule: '-', + }) + .use(remarkGfm) + .use(remarkDirective); + +function memoize(fn) { + const cache = new Map(); + return (...args) => { + const key = args.join('\0'); + if (!cache.has(key)) cache.set(key, fn(...args)); + return cache.get(key); + }; +} + +const read = memoize(file => fs.readFileSync(file, 'utf8')); +const exists = memoize(file => fs.existsSync(file)); + +/** Source for a framework: `foo.vue.md` replaces `foo.md` */ +const sourceFor = (file, framework) => { + const override = file.replace(MD, `.${framework}$&`); + return exists(override) ? override : file; +}; + +/** Page content with `_` front matter applied */ +const contentFor = memoize((file, framework) => + rewriteFrontMatter(read(sourceFor(file, framework)), framework), +); + +/** Parsed once per source; callers get a copy to transform */ +const parse = memoize(file => { + const input = preprocessContent({ + fileContent: read(file), + filePath: file, + markdownConfig: { mdx1Compat: { headingIds: true, admonitions: true } }, + admonitions: true, + }); + try { + return { input, tree: processor.parse(input) }; + } catch (error) { + throw new Error( + `${rel(file)}:${error.line}:${error.column}: ${error.message}`, + ); + } +}); + +/** Site route (no host) of a doc for a framework */ +export const routeOf = memoize((file, framework) => { + const relPath = rel(file).replace(/\.(react|vue)(\.mdx?)$/, '$2'); + const match = ROUTES.find(([dir]) => relPath.startsWith(dir)); + if (!match) return; + const [dir, bases] = match; + const docId = docIdOf(relPath.slice(dir.length), contentFor(file, framework)); + // Vue links to pages without a Vue version go to the React docs + const base = + dir === 'docs/core/' && framework === 'vue' && !vueIds.has(docId) ? + bases.react + : bases[framework]; + return `${base}${docId}`.replace(/\/index$/, '/'); +}); + +/** Relative doc links become site routes; absolute ones are left to remarkFramework */ +function routeLink(url, file, framework) { + if (/^([a-z]+:|#|\/)/i.test(url)) return url; + const [, target = '', hash = ''] = url.match(/^([^#?]*)(.*)$/); + const resolved = path.resolve(path.dirname(file), decodeURI(target)); + const doc = [resolved, `${resolved}.md`, `${resolved}.mdx`].find( + f => MD.test(f) && exists(f), + ); + if (doc) return routeOf(doc, framework) + hash; + // site-relative link to a page without an extension + const route = routeOf(file, framework); + return route ? + path.posix.join(path.posix.dirname(route), target) + hash + : url; +} + +/** Evaluates an MDX expression (JSX attribute or `{...}`) with the partial's props */ +function evaluate(expression, props, file) { + try { + return new Function('props', `return (${expression});`)(props); + } catch (error) { + throw new Error( + `${rel(file)}: can't evaluate {${expression}}: ${error.message}`, + ); + } +} + +function attrValue(attribute, props, file) { + if (attribute.value === null) return true; + if (typeof attribute.value === 'string') return attribute.value; + return evaluate(attribute.value.value, props, file); +} + +/** JSX attributes, evaluated when read (others may use the page's imports) */ +function attributesOf(node, props, file) { + const attrs = {}; + for (const attribute of node.attributes ?? []) { + if (attribute.type !== 'mdxJsxAttribute') continue; + Object.defineProperty(attrs, attribute.name, { + enumerable: true, + get: () => attrValue(attribute, props, file), + }); + } + return attrs; +} + +const text = value => ({ type: 'text', value }); +const paragraph = children => ({ type: 'paragraph', children }); +const html = value => ({ type: 'html', value }); + +/** Code without Docusaurus-only syntax (highlight markers, display options) */ +function codeBlock({ lang, value, title, meta = title && `title="${title}"` }) { + return { + type: 'code', + lang: lang ?? null, + meta: + meta?.replace(/\s*\b(collapsed|showLineNumbers)\b/g, '').trim() || null, + value: value + .split('\n') + .filter( + l => + !/^\s*(\/\/|#|\n\n${body}`; + const primary = path.join(skillDir, 'references', name); + // the first framework that has the page writes `.md` + if (!out.has(primary)) out.set(primary, content); + else if (comparable(out.get(primary)) !== comparable(content)) + out.set(primary.replace(MD, `.${framework}.md`), content); + } + } + // a framework's page links to that framework's version of other pages + for (const [file, content] of out) { + const framework = file.match(/\.(\w+)\.md$/)?.[1]; + if (!frameworks.includes(framework)) continue; + out.set( + file, + content.replace( + /(\]\(\.\/)([^)#\s]+)\.md(?=[)#])/g, + (link, open, name) => + ( + out.has( + path.join(skillDir, 'references', `${name}.${framework}.md`), + ) + ) ? + `${open}${name}.${framework}.md` + : link, + ), + ); + } + return out; +} + +/** Generated files currently on disk, by path */ +function generatedFiles(dir) { + if (!fs.existsSync(dir)) return new Map(); + return new Map( + fs + .readdirSync(dir) + .filter(f => f.endsWith('.md')) + .map(f => path.join(dir, f)) + .filter(f => !fs.lstatSync(f).isSymbolicLink()) + .map(f => [f, fs.readFileSync(f, 'utf8')]) + .filter(([, content]) => content.startsWith(HEADER)), + ); +} + +/** [file, content] for every file to write, with content null to delete */ +const changes = []; +for (const skill of fs.readdirSync(SKILLS).sort()) { + const skillDir = path.join(SKILLS, skill); + if (!fs.existsSync(path.join(skillDir, MANIFEST))) continue; + const out = generateSkill(skillDir); + const current = generatedFiles(path.join(skillDir, 'references')); + for (const file of current.keys()) + if (!out.has(file)) changes.push([file, null]); + for (const [file, content] of out) + if (current.get(file) !== content) changes.push([file, content]); +} + +if (process.argv.includes('--check')) { + if (changes.length) { + console.error( + `Skill references are out of date with the docs:\n ${changes.map(([f]) => rel(f)).join('\n ')}\nRun \`yarn build:skills\` and commit the result.`, + ); + process.exit(1); + } + console.log('Skill references are up to date.'); +} else { + for (const [file, content] of changes) { + // also replaces a symlink left from before references were generated + fs.rmSync(file, { force: true }); + if (content === null) continue; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + console.log(`Updated ${changes.length} skill reference files.`); +} + +const problems = fs.readdirSync(SKILLS).flatMap(skill => { + const skillMd = path.join(SKILLS, skill, 'SKILL.md'); + const refs = path.join(SKILLS, skill, 'references'); + const text = fs.existsSync(skillMd) ? fs.readFileSync(skillMd, 'utf8') : ''; + const files = fs.existsSync(refs) ? fs.readdirSync(refs) : []; + const manifest = path.join(SKILLS, skill, MANIFEST); + const { frameworks = [] } = + fs.existsSync(manifest) ? + JSON.parse(fs.readFileSync(manifest, 'utf8')) + : {}; + return [ + // links to references that no longer exist (renamed or removed docs) + ...[...text.matchAll(/\]\((references\/[^)#\s]+)/g)] + .map(([, link]) => link) + .filter(link => !fs.existsSync(path.join(SKILLS, skill, link))) + .map(link => `${rel(skillMd)} links to missing ${link}`), + // symlinked docs ship raw MDX; list them in references.json instead + ...files + .filter( + f => MD.test(f) && fs.lstatSync(path.join(refs, f)).isSymbolicLink(), + ) + .map(f => `${rel(refs)}/${f} is a symlink; add it to ${MANIFEST}`), + // agents only find framework variants if SKILL.md tells them to look + ...frameworks + .slice(1) + .filter(fw => files.some(f => f.endsWith(`.${fw}.md`))) + .filter(fw => !text.includes(`.${fw}.md`)) + .map( + fw => + `${rel(skillMd)} needs a note to read \`.${fw}.md\` instead of \`.md\` for ${fw} projects`, + ), + ]; +}); +if (problems.length) { + console.error(`Skill problems:\n ${[...new Set(problems)].join('\n ')}`); + process.exit(1); +} diff --git a/website/package.json b/website/package.json index d995a02e6aa1..9841379477eb 100644 --- a/website/package.json +++ b/website/package.json @@ -14,21 +14,33 @@ "rename-version": "docusaurus-rename-version", "swizzle": "docusaurus swizzle", "deploy": "USE_SSH=true docusaurus deploy", - "docusaurus": "docusaurus" + "docusaurus": "docusaurus", + "build:skills": "node framework-docs/skillReferences.mjs" }, "engines": { "node": ">=18.0" }, "devDependencies": { + "@docusaurus/mdx-loader": "^3.0.1", "@docusaurus/module-type-aliases": "^3.0.1", + "@slorber/remark-comment": "^1.0.0", "@tsconfig/docusaurus": "^2.0.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/react-helmet": "^6.1.5", "@types/react-router-dom": "^5.3.3", "@types/uuid": "^11.0.0", + "mdast-util-phrasing": "^4.0.0", "raw-loader": "^4.0.2", + "remark-directive": "^3.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "serve": "14.2.6", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0", "webpack": "^5.76.0" }, "dependencies": { diff --git a/yarn.lock b/yarn.lock index 518a7dfd9731..90f07d7a42ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3637,7 +3637,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/mdx-loader@npm:3.10.2": +"@docusaurus/mdx-loader@npm:3.10.2, @docusaurus/mdx-loader@npm:^3.0.1": version: 3.10.2 resolution: "@docusaurus/mdx-loader@npm:3.10.2" dependencies: @@ -23679,6 +23679,7 @@ __metadata: "@data-client/rest": "workspace:*" "@data-client/test": "workspace:*" "@docusaurus/core": "npm:^3.0.1" + "@docusaurus/mdx-loader": "npm:^3.0.1" "@docusaurus/module-type-aliases": "npm:^3.0.1" "@docusaurus/plugin-client-redirects": "npm:^3.0.1" "@docusaurus/plugin-content-docs": "npm:^3.0.1" @@ -23689,6 +23690,7 @@ __metadata: "@mdx-js/react": "npm:^3.1.0" "@monaco-editor/react": "npm:^4.8.0-rc.0" "@number-flow/react": "npm:^0.6.0" + "@slorber/remark-comment": "npm:^1.0.0" "@tsconfig/docusaurus": "npm:^2.0.0" "@types/react": "npm:19.2.17" "@types/react-dom": "npm:19.2.3" @@ -23698,6 +23700,7 @@ __metadata: "@typescript/native": "npm:typescript@7.0.2" bignumber.js: "npm:11.1.5" clsx: "npm:2.1.1" + mdast-util-phrasing: "npm:^4.0.0" monaco-editor: "npm:^0.56.0" parse-numeric-range: "npm:^1.3.0" raw-loader: "npm:^4.0.2" @@ -23705,9 +23708,17 @@ __metadata: react-dom: "npm:^19.0.0" react-json-tree: "npm:0.20.0" react-live: "npm:^4.0.0" + remark-directive: "npm:^3.0.0" + remark-frontmatter: "npm:^5.0.0" + remark-gfm: "npm:^4.0.0" + remark-mdx: "npm:^3.0.0" + remark-parse: "npm:^11.0.0" + remark-stringify: "npm:^11.0.0" serve: "npm:14.2.6" temporal-polyfill: "npm:^0.3.0" typescript: "npm:@typescript/typescript6@6.0.2" + unified: "npm:^11.0.4" + unist-util-visit: "npm:^5.0.0" uuid: "npm:^14.0.0" webpack: "npm:^5.76.0" languageName: unknown