diff --git a/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md b/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md index 4a12c1d55555..d85bb98045de 100644 --- a/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md +++ b/.agents/skills/data-client-endpoint-setup/references/Endpoint.vue.md @@ -475,9 +475,12 @@ const createUser = new RestEndpoint({ More updates: -```typescript title="Component.tsx" -const allusers = useSuspense(userList); -const adminUsers = useSuspense(userList, { admin: true }); +```typescript title="Component.vue" +// start both fetches in parallel +useFetch(userList); +useFetch(userList, { admin: true }); +const allusers = await useSuspense(userList); +const adminUsers = await useSuspense(userList, { admin: true }); ``` The endpoint below ensures the new user shows up immediately in the usages above. diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md index bef749d6e9e8..12a9bf668ef6 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md @@ -1353,12 +1353,13 @@ const getTodos = new RestEndpoint({ }); const todos = useSuspense(getTodos); +const ctrl = useController(); return ( // fetches url `/todos?page=${nextPage}` - ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + ctrl.fetch(getTodos.getPage, { page: nextPage }) } /> ); diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md index cf3111648e47..47978e3b9e88 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md @@ -152,7 +152,7 @@ const getComments = new RestEndpoint({ }); // Hover your mouse over 'comments' to see its type -const comments = useSuspense(getComments, { +const comments = await useSuspense(getComments, { postId: '5', sortBy: 'votes', }); @@ -164,7 +164,7 @@ const createComment = async 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) +[schema](#schema) determines the return value when used with data-binding composables 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" @@ -182,7 +182,7 @@ import { Todo } from './Todo'; const getTodo = new RestEndpoint({ path: '/', schema: Todo }); // Hover your mouse over 'todo' to see its type -const todo = useSuspense(getTodo); +const todo = await useSuspense(getTodo); async () => { const ctrl = useController(); @@ -191,7 +191,7 @@ async () => { ``` [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). +`RestEndpoints` without a schema, it also determines the return type of [composables](https://dataclient.io/vue/api/useSuspense) and [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch). ```ts path="process.ts" interface TodoInterface { @@ -670,11 +670,11 @@ This is sent to [fetchResponse](#fetchResponse) 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) +This is often useful for [authentication](./auth.vue.md) > **Warning** > -> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) +> Don't use composables here. If you need to use composables, try using [hookifyResource](./hookifyResource.vue.md) > **Tip: async** > @@ -1052,9 +1052,12 @@ const createUser = new RestEndpoint({ More updates: -```typescript title="Component.tsx" -const allusers = useSuspense(userList); -const adminUsers = useSuspense(userList, { admin: true }); +```typescript title="Component.vue" +// start both fetches in parallel +useFetch(userList); +useFetch(userList, { admin: true }); +const allusers = await useSuspense(userList); +const adminUsers = await useSuspense(userList, { admin: true }); ``` The endpoint below ensures the new user shows up immediately in the usages above. @@ -1271,50 +1274,64 @@ export const TaskResource = resource({ }); ``` -```tsx title="TaskCard" {5-9} -import { useController } from '@data-client/react'; -import { TaskResource, type Task } from './TaskResource'; +```html title="TaskCard.vue" {7-16} + + + ``` -```tsx title="TaskBoard" -import { useSuspense } from '@data-client/react'; -import { TaskResource } from './TaskResource'; -import TaskCard from './TaskCard'; +```html title="TaskBoard.vue" + -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 => )} -
+ ``` The remove filter is based on the entity's **existing** values in the store. @@ -1340,23 +1357,26 @@ await ctrl.fetch( 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', -}); +```html + -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. diff --git a/.agents/skills/data-client-rest-setup/references/auth.md b/.agents/skills/data-client-rest-setup/references/auth.md index 632daad8277e..ae502b637dfb 100644 --- a/.agents/skills/data-client-rest-setup/references/auth.md +++ b/.agents/skills/data-client-rest-setup/references/auth.md @@ -256,7 +256,7 @@ import { MyResource } from './MyResource'; MyResource.get({ id: 1 }); ``` -## Auth Headers from React Context +## Auth Headers from React Context {#auth-headers-from-react-context} > **Warning** > diff --git a/.agents/skills/data-client-rest-setup/references/auth.vue.md b/.agents/skills/data-client-rest-setup/references/auth.vue.md new file mode 100644 index 000000000000..55063f9dc44b --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/auth.vue.md @@ -0,0 +1,395 @@ + + +# Rest Authentication + +All network requests are run through the [getRequestInit](./RestEndpoint.vue.md#getRequestInit) optionally +defined in your [RestEndpoint](./RestEndpoint.vue.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; +}; +``` + +```html title="Auth.vue" + + + +``` + +```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); +}; +``` + +```html title="Auth.vue" + + + +``` + +```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); +}; +``` + +```html title="Auth.vue" + + + +``` + +```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 provide/inject {#auth-headers-from-react-context} + +> **Warning** +> +> Using provide/inject 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.vue.md) into one that uses composables to create endpoints +by using [hookifyResource](./hookifyResource.vue.md) + +```ts title="resources/Post.ts" +import { inject } from 'vue'; +import { resource, hookifyResource } from '@data-client/rest'; + +// Post defined here + +export const AuthKey = Symbol('accessToken'); + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as composables in our Vue Components + +```html title="PostDetail.vue" + + + +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur at the top level of ` +> +> +> ``` + +**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.vue.md#extend) to generate a new endpoint with this context injected. + +```ts +import { inject } from 'vue'; + +function useEndpoint(endpoint: RestEndpoint) { + const accessToken = inject(AuthKey, ''); + return endpoint.extend({ accessToken }); +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur at the top level of ` +> +> +> ``` + +## 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/vue/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-rest-setup/references/hookifyResource.vue.md b/.agents/skills/data-client-rest-setup/references/hookifyResource.vue.md new file mode 100644 index 000000000000..04b8aa8651b6 --- /dev/null +++ b/.agents/skills/data-client-rest-setup/references/hookifyResource.vue.md @@ -0,0 +1,228 @@ + + +# hookifyResource + +`hookifyResource()` Turns any [Resource](./resource.vue.md) (collection of [RestEndpoints](./RestEndpoint.vue.md)) into a collection +of composables that return [RestEndpoints](./RestEndpoint.vue.md). + +> **Info** +> +> TypeScript >=4.3 is required for generative types to work correctly. + +```ts title="resources/Article" +import { inject } from 'vue'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +export const AuthKey = Symbol('accessToken'); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```html title="ArticleDetail.vue" + + + +``` + +Each `use*()` composable calls your function once, when the component is set up, so call them +at the top level of ` + + +``` + +```html title="TodoList.vue" + + + +``` + +```html title="UserList.vue" + + + +``` + +### 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.vue.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.vue.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; + }, + ) +}); +``` + +```html title="PostListLayout.vue" + + + +``` + +```html title="PostList.vue" + + + +``` + +### 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/vue/api/Controller#set) for local-only +updates without network requests. For network-based mutations, see [RestEndpoint's specialized extenders](./RestEndpoint.vue.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.vue.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.vue.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.vue.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.vue.md b/.agents/skills/data-client-rest/references/Entity.vue.md new file mode 100644 index 000000000000..8543cc6ee0b0 --- /dev/null +++ b/.agents/skills/data-client-rest/references/Entity.vue.md @@ -0,0 +1,756 @@ + + +# 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.vue.md#schema) or +> [RestEndpoint.schema](./RestEndpoint.vue.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 [`v-for` keys](https://vuejs.org/guide/essentials/list.html#maintaining-state-with-key) + +```html + +``` + +#### 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.vue.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, + }; +} +``` + +```html title="PostPage.vue" + + + + + +``` + +#### 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/vue/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, +}); +``` + +```ts +const user = await useSuspense(UserResource.get, { username: 'bob' }); +``` + +#### useQuery() + +With [useQuery()](https://dataclient.io/vue/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: + +```ts +const assets = await 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/vue/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.vue.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.vue.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.vue.md#expiry-status), +like [Invalidate](https://dataclient.io/rest/api/Invalidate). + +[`Invalid`](./expiry-policy.vue.md#expiry-status) expiry generally means composables 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/RestEndpoint.md b/.agents/skills/data-client-rest/references/RestEndpoint.md index 1c8f5fcbd064..ffb1a96ef716 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.md @@ -1353,12 +1353,13 @@ const getTodos = new RestEndpoint({ }); const todos = useSuspense(getTodos); +const ctrl = useController(); return ( // fetches url `/todos?page=${nextPage}` - ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + ctrl.fetch(getTodos.getPage, { page: nextPage }) } /> ); diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md index 68aaab5ec636..cc93aa214469 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md @@ -152,7 +152,7 @@ const getComments = new RestEndpoint({ }); // Hover your mouse over 'comments' to see its type -const comments = useSuspense(getComments, { +const comments = await useSuspense(getComments, { postId: '5', sortBy: 'votes', }); @@ -164,7 +164,7 @@ const createComment = async 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) +[schema](#schema) determines the return value when used with data-binding composables 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" @@ -182,7 +182,7 @@ import { Todo } from './Todo'; const getTodo = new RestEndpoint({ path: '/', schema: Todo }); // Hover your mouse over 'todo' to see its type -const todo = useSuspense(getTodo); +const todo = await useSuspense(getTodo); async () => { const ctrl = useController(); @@ -191,7 +191,7 @@ async () => { ``` [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). +`RestEndpoints` without a schema, it also determines the return type of [composables](https://dataclient.io/vue/api/useSuspense) and [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch). ```ts path="process.ts" interface TodoInterface { @@ -560,7 +560,7 @@ 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). +must also contain a [Collection](./Collection.vue.md). ### urlPrefix: string = '' {#urlPrefix} @@ -670,11 +670,11 @@ This is sent to [fetchResponse](#fetchResponse) 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) +This is often useful for [authentication](./auth.vue.md) > **Warning** > -> Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) +> Don't use composables here. If you need to use composables, try using [hookifyResource](./hookifyResource.vue.md) > **Tip: async** > @@ -758,7 +758,7 @@ const downloadFile = new RestEndpoint({ }); ``` -See [file download guide](./network-transform.md#file-download) for complete usage with browser download trigger. +See [file download guide](./network-transform.vue.md#file-download) for complete usage with browser download trigger. ### parseResponse(response): Promise {#parseResponse} @@ -814,10 +814,10 @@ Perform any transforms with the parsed result. Defaults to identity function (do [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) +- 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.vue.md) +- Functions to [deserialize fields](./network-transform.vue.md#deserializing-fields) +- [Race condition handling](./Entity.vue.md#shouldreorder) +- [Validation](./Entity.vue.md#validate) ```tsx import { Entity, RestEndpoint } from '@data-client/rest'; @@ -1012,7 +1012,7 @@ export const PostResource = resource({ ``` -[Optimistic update guide](./optimistic-updates.md) +[Optimistic update guide](./optimistic-updates.vue.md) ### update() {#update} @@ -1023,7 +1023,7 @@ export const PostResource = resource({ > **Tip** > -> Try using [Collections](./Collection.md) instead. +> Try using [Collections](./Collection.vue.md) instead. > > They are much easier to use and more robust! @@ -1052,9 +1052,12 @@ const createUser = new RestEndpoint({ More updates: -```typescript title="Component.tsx" -const allusers = useSuspense(userList); -const adminUsers = useSuspense(userList, { admin: true }); +```typescript title="Component.vue" +// start both fetches in parallel +useFetch(userList); +useFetch(userList, { admin: true }); +const allusers = await useSuspense(userList); +const adminUsers = await useSuspense(userList, { admin: true }); ``` The endpoint below ensures the new user shows up immediately in the usages above. @@ -1096,14 +1099,14 @@ const UserDetailNormalized = getUser.extend({ ## 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). +These convenience accessors create new endpoints for common [Collection](./Collection.vue.md) operations. +They only work when the `RestEndpoint`'s schema contains a [Collection](./Collection.vue.md). ### push -Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](./Collection.md). +Creates a POST endpoint that places newly created Entities at the _end_ of a [Collection](./Collection.vue.md). -Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](./Collection.md#push) +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.push](./Collection.vue.md#push) ```tsx const getTodos = new RestEndpoint({ @@ -1136,9 +1139,9 @@ const newUser = await ctrl.fetch( ### unshift -Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](./Collection.md). +Creates a POST endpoint that places newly created Entities at the _start_ of a [Collection](./Collection.vue.md). -Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](./Collection.md#unshift) +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.unshift](./Collection.vue.md#unshift) ```tsx const getTodos = new RestEndpoint({ @@ -1171,9 +1174,9 @@ const newUser = await ctrl.fetch( ### assign -Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](./Collection.md). +Creates a POST endpoint that merges Entities into a [Values](https://dataclient.io/rest/api/Values) [Collection](./Collection.vue.md). -Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](./Collection.md#assign) +Returns a new RestEndpoint with [method](#method): 'POST' and schema: [Collection.assign](./Collection.vue.md#assign) ```tsx const getStats = new RestEndpoint({ @@ -1208,9 +1211,9 @@ await ctrl.fetch(StatsResource.getList.assign, { ### remove -Creates a PATCH endpoint that removes Entities from a [Collection](./Collection.md) and updates them with the response. +Creates a PATCH endpoint that removes Entities from a [Collection](./Collection.vue.md) and updates them with the response. -Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](./Collection.md#remove) +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.remove](./Collection.vue.md#remove) ```tsx const getTodos = new RestEndpoint({ @@ -1247,11 +1250,11 @@ const deleteAndRemove = MyResource.delete.extend({ ### move -Creates a PATCH endpoint that moves Entities between [Collections](./Collection.md). It removes from +Creates a PATCH endpoint that moves Entities between [Collections](./Collection.vue.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) +Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collection.move](./Collection.vue.md#move) ```ts title="TaskResource" import { Entity, resource } from '@data-client/rest'; @@ -1271,55 +1274,69 @@ export const TaskResource = resource({ }); ``` -```tsx title="TaskCard" {5-9} -import { useController } from '@data-client/react'; -import { TaskResource, type Task } from './TaskResource'; +```html title="TaskCard.vue" {7-16} + + + ``` -```tsx title="TaskBoard" -import { useSuspense } from '@data-client/react'; -import { TaskResource } from './TaskResource'; -import TaskCard from './TaskCard'; +```html title="TaskBoard.vue" + -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 => )} -
+ ``` 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. +This uses the same [createCollectionFilter](./Collection.vue.md#createcollectionfilter) logic as push/remove. ```tsx const UserResource = resource({ @@ -1338,25 +1355,28 @@ await ctrl.fetch( ### getPage An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema -must also contain a [Collection](./Collection.md) +must also contain a [Collection](./Collection.vue.md) + +```html + -```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. @@ -1370,7 +1390,7 @@ page, to append to this endpoint. See [Infinite Scrolling Pagination](./paginati const getNextPage = getList.paginated('cursor'); ``` -Schema must also contain a [Collection](./Collection.md) +Schema must also contain a [Collection](./Collection.vue.md) ### paginated(removeCursor) {#paginated-function} @@ -1393,7 +1413,7 @@ const getNextPage = getList.paginated( `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) +Schema must also contain a [Collection](./Collection.vue.md) ## Inheritance diff --git a/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md b/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md index db21ef3b27db..7aebcd7178b5 100644 --- a/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md +++ b/.agents/skills/data-client-rest/references/_EndpointLifecycle.vue.md @@ -162,7 +162,7 @@ export const PostResource = resource({ ``` -[Optimistic update guide](./optimistic-updates.md) +[Optimistic update guide](./optimistic-updates.vue.md) ### update() {#update} @@ -173,7 +173,7 @@ export const PostResource = resource({ > **Tip** > -> Try using [Collections](./Collection.md) instead. +> Try using [Collections](./Collection.vue.md) instead. > > They are much easier to use and more robust! @@ -202,9 +202,12 @@ const createUser = new RestEndpoint({ More updates: -```typescript title="Component.tsx" -const allusers = useSuspense(userList); -const adminUsers = useSuspense(userList, { admin: true }); +```typescript title="Component.vue" +// start both fetches in parallel +useFetch(userList); +useFetch(userList, { admin: true }); +const allusers = await useSuspense(userList); +const adminUsers = await useSuspense(userList, { admin: true }); ``` The endpoint below ensures the new user shows up immediately in the usages above. diff --git a/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.md b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.md new file mode 100644 index 000000000000..b200c64bacf3 --- /dev/null +++ b/.agents/skills/data-client-rest/references/_entity_lifecycle_methods.vue.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.vue.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.vue.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.vue.md#expiry-status), +like [Invalidate](https://dataclient.io/rest/api/Invalidate). + +[`Invalid`](./expiry-policy.vue.md#expiry-status) expiry generally means composables 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.vue.md b/.agents/skills/data-client-rest/references/_optimisticTransform.vue.md new file mode 100644 index 000000000000..0cc4182e300c --- /dev/null +++ b/.agents/skills/data-client-rest/references/_optimisticTransform.vue.md @@ -0,0 +1,85 @@ + + +```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, + }; + }, +}); +``` + +```html title="CounterPage.vue" + + + +``` diff --git a/.agents/skills/data-client-rest/references/auth.md b/.agents/skills/data-client-rest/references/auth.md index cfc1cac3e94b..f64d5d40788a 100644 --- a/.agents/skills/data-client-rest/references/auth.md +++ b/.agents/skills/data-client-rest/references/auth.md @@ -256,7 +256,7 @@ import { MyResource } from './MyResource'; MyResource.get({ id: 1 }); ``` -## Auth Headers from React Context +## Auth Headers from React Context {#auth-headers-from-react-context} > **Warning** > diff --git a/.agents/skills/data-client-rest/references/auth.vue.md b/.agents/skills/data-client-rest/references/auth.vue.md new file mode 100644 index 000000000000..95ecb594871d --- /dev/null +++ b/.agents/skills/data-client-rest/references/auth.vue.md @@ -0,0 +1,395 @@ + + +# Rest Authentication + +All network requests are run through the [getRequestInit](./RestEndpoint.vue.md#getRequestInit) optionally +defined in your [RestEndpoint](./RestEndpoint.vue.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; +}; +``` + +```html title="Auth.vue" + + + +``` + +```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); +}; +``` + +```html title="Auth.vue" + + + +``` + +```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); +}; +``` + +```html title="Auth.vue" + + + +``` + +```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 provide/inject {#auth-headers-from-react-context} + +> **Warning** +> +> Using provide/inject 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.vue.md) into one that uses composables to create endpoints +by using [hookifyResource](./hookifyResource.vue.md) + +```ts title="resources/Post.ts" +import { inject } from 'vue'; +import { resource, hookifyResource } from '@data-client/rest'; + +// Post defined here + +export const AuthKey = Symbol('accessToken'); + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as composables in our Vue Components + +```html title="PostDetail.vue" + + + +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur at the top level of ` +> +> +> ``` + +**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.vue.md#extend) to generate a new endpoint with this context injected. + +```ts +import { inject } from 'vue'; + +function useEndpoint(endpoint: RestEndpoint) { + const accessToken = inject(AuthKey, ''); + return endpoint.extend({ accessToken }); +} +``` + +> **Warning** +> +> Using this means all endpoint calls must only occur at the top level of ` +> +> +> ``` + +## 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/vue/api/LogoutManager) +can be used to easily trigger any de-authorization cleanup. diff --git a/.agents/skills/data-client-rest/references/expiry-policy.vue.md b/.agents/skills/data-client-rest/references/expiry-policy.vue.md index 0a612772d846..652a30cd5333 100644 --- a/.agents/skills/data-client-rest/references/expiry-policy.vue.md +++ b/.agents/skills/data-client-rest/references/expiry-policy.vue.md @@ -333,7 +333,7 @@ export const lastUpdated = new RestEndpoint({ ## Invalidate {#invalidate} -Both [endpoints](https://dataclient.io/rest/api/Endpoint) and [entities](./Entity.md) can be targetted to be invalidated. +Both [endpoints](https://dataclient.io/rest/api/Endpoint) and [entities](./Entity.vue.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. @@ -404,7 +404,7 @@ export const lastUpdated = new RestEndpoint({ ### 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 +Using the [Invalidate schema](https://dataclient.io/rest/api/Invalidate) allows us to invalidate _any_ endpoint that includes that relies on that [entity](./Entity.vue.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" @@ -490,7 +490,7 @@ 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). +return `undefined` from [Entity.process](./Entity.vue.md#process). ```ts class PriceLevel extends Entity { diff --git a/.agents/skills/data-client-rest/references/hookifyResource.vue.md b/.agents/skills/data-client-rest/references/hookifyResource.vue.md new file mode 100644 index 000000000000..7a1fc9b74258 --- /dev/null +++ b/.agents/skills/data-client-rest/references/hookifyResource.vue.md @@ -0,0 +1,228 @@ + + +# hookifyResource + +`hookifyResource()` Turns any [Resource](./resource.vue.md) (collection of [RestEndpoints](./RestEndpoint.vue.md)) into a collection +of composables that return [RestEndpoints](./RestEndpoint.vue.md). + +> **Info** +> +> TypeScript >=4.3 is required for generative types to work correctly. + +```ts title="resources/Article" +import { inject } from 'vue'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +export const AuthKey = Symbol('accessToken'); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```html title="ArticleDetail.vue" + + + +``` + +Each `use*()` composable calls your function once, when the component is set up, so call them +at the top level of ` + + +``` + +### 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.vue.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, +}); +``` + +```html title="AssetPrice.vue" {7} + + + +``` + +## 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.vue.md#fetchResponse) +can be used to construct [RestEndpoint](./RestEndpoint.vue.md). + +Sometimes this is used for cursor based [pagination](./pagination.vue.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.vue.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, +}); +``` + +```html title="DownloadButton.vue" + + + +``` + +To extract the filename from the `Content-Disposition` header, override +[parseResponse](./RestEndpoint.vue.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.vue.md b/.agents/skills/data-client-rest/references/optimistic-updates.vue.md new file mode 100644 index 000000000000..336751619235 --- /dev/null +++ b/.agents/skills/data-client-rest/references/optimistic-updates.vue.md @@ -0,0 +1,409 @@ + + +# 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.vue.md) can be configured by setting [optimistic: true](./resource.vue.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, +}); +``` + +```html title="TodoItem.vue" + + + +``` + +```html title="CreateTodo.vue" + + + +``` + +```html title="TodoList.vue" + + + +``` + +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/vue/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.vue.md#getoptimisticresponse) using +[Resource.extend()](./resource.vue.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, + }; + }, +}); +``` + +```html title="CounterPage.vue" + + + +``` + +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.vue.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.vue.md#shouldreorder), we can reorder out-of-order responses based on the +server timestamp. + +We use [snap.fetchedAt](https://dataclient.io/vue/api/Snapshot#fetchedat) in our [getOptimisticResponse](./RestEndpoint.vue.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, + }; + }, +}); +``` + +```html title="CounterPage.vue" + + + +``` diff --git a/.agents/skills/data-client-rest/references/pagination.md b/.agents/skills/data-client-rest/references/pagination.md index 1098c009ec8e..484804daa383 100644 --- a/.agents/skills/data-client-rest/references/pagination.md +++ b/.agents/skills/data-client-rest/references/pagination.md @@ -292,7 +292,7 @@ function NewsList() { ctrl.fetch(PostResource.getList.getPage, { cursor }) } > - + ); } diff --git a/.agents/skills/data-client-rest/references/pagination.vue.md b/.agents/skills/data-client-rest/references/pagination.vue.md index ab85bbe1ea2f..38e670c66586 100644 --- a/.agents/skills/data-client-rest/references/pagination.vue.md +++ b/.agents/skills/data-client-rest/references/pagination.vue.md @@ -5,7 +5,7 @@ ## 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. +[Resource.getList.getPage](./resource.vue.md#getpage) can be used as long as [paginationField](./resource.vue.md#paginationfield) was provided. ```ts title="User" import { Entity } from '@data-client/rest'; @@ -114,8 +114,8 @@ export const PostResource = resource({ ``` -Don't forget to define our [Resource's](./resource.md) [paginationField](./resource.md#paginationfield) and -correct [schema](./resource.md#schema)! +Don't forget to define our [Resource's](./resource.vue.md) [paginationField](./resource.vue.md#paginationfield) and +correct [schema](./resource.vue.md#schema)! ```ts title="Post" export const PostResource = resource({ @@ -138,7 +138,7 @@ Example app: [github-app](https://github.com/reactive/data-client/tree/master/ex 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) +Since validators only have one Endpoint, we use [RestEndpoint](./RestEndpoint.vue.md) instead of [resource](./resource.vue.md). By using [Collections](./Collection.vue.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} @@ -195,78 +195,76 @@ export const getValidators = new RestEndpoint({ }); ``` -```tsx title="ValidatorItem" -import { type Validator } from './Validator'; - -export default function ValidatorItem({ validator }: Props) { - return ( -
-
-

{validator.description.moniker}

- - - {validator.description.website} - - -

{validator.description.details}

-
-
- ); -} +```html title="ValidatorItem.vue" + + + ``` -```tsx title="LoadMore" {8-11} -import { useController, useLoading } from '@data-client/react'; -import { getValidators } from './Validator'; +```html title="LoadMore.vue" {7-11} + + + ``` -```tsx title="ValidatorList" -import { useSuspense } from '@data-client/react'; -import ValidatorItem from './ValidatorItem'; -import { getValidators } from './Validator'; -import LoadMore from './LoadMore'; +```html title="ValidatorList.vue" + - return ( -
- {validators.map(validator => ( - - ))} - -
- ); -} -render(); + ``` ### Infinite Scrolling @@ -275,31 +273,29 @@ Since UI behaviors vary widely, and implementations vary from platform (react-na 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'; +```html title="NewsList.vue" + - 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. +for [getList](./resource.vue.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. diff --git a/.agents/skills/data-client-rest/references/resource.vue.md b/.agents/skills/data-client-rest/references/resource.vue.md new file mode 100644 index 000000000000..e277bf5592c0 --- /dev/null +++ b/.agents/skills/data-client-rest/references/resource.vue.md @@ -0,0 +1,816 @@ + + +# Resource + +`Resources` are a collection of [RestEndpoints](./RestEndpoint.vue.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 = await useSuspense(TodoResource.get, { id: '5' }); +const todos = await useSuspense(TodoResource.getList); +controller.fetch(TodoResource.getList.push, { + title: 'finish installing reactive data client', +}); +controller.fetch( + TodoResource.update, + { id: '5' }, + { ...todo.value, 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.vue.md#path) for single item [endpoints](#members). +Uses [path-to-regexp v8](https://github.com/pillarjs/path-to-regexp) syntax — see +[RestEndpoint.path](./RestEndpoint.vue.md#path) for full details on +[optional parameters](./RestEndpoint.vue.md#path), [wildcards](./RestEndpoint.vue.md#path), +[quoted names](./RestEndpoint.vue.md#path), and [escaping](./RestEndpoint.vue.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.vue.md#schema) representing a single item. This is usually +an [Entity](./Entity.vue.md) or [Union](https://dataclient.io/rest/api/Union). + +- [getList](#getlist) uses an [Array](https://dataclient.io/rest/api/Array) [Collection](./Collection.vue.md) of the schema. +- [delete](#delete) uses a [Invalidate](https://dataclient.io/rest/api/Invalidate) of the schema. + +### urlPrefix + +Passed to [RestEndpoint.urlPrefix](./RestEndpoint.vue.md#urlPrefix) + +### searchParams + +Passed to [RestEndpoint.searchParams](./RestEndpoint.vue.md#searchParams) for [getList](#getlist) and [getList.push](#push) + +### body + +Passed to [RestEndpoint.body](./RestEndpoint.vue.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.vue.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.vue.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.vue.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.vue.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.vue.md) | +| [getList](#getlist) | [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) | `[{group: string; author?: string}]` | [Collection(\[Post\])](./Collection.vue.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.vue.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.vue.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.vue.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.vue.md#move) | +| [update](#update) | [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) | `[{group: string; id: string }, Partial]` | [Post](./Entity.vue.md) | +| [partialUpdate](#update) | [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) | `[{group: string; id: string }, Partial]` | [Post](./Entity.vue.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/vue/api/useSuspense), [Controller.invalidate](https://dataclient.io/vue/api/Controller#invalidate), [Controller.expireAll](https://dataclient.io/vue/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.vue.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/vue/api/useSuspense), [Controller.invalidate](https://dataclient.io/vue/api/Controller#invalidate), [Controller.expireAll](https://dataclient.io/vue/api/Controller#expireAll) + +### getList.push {#push} + +[RestEndpoint.push](./RestEndpoint.vue.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.vue.md#push) | + +Commonly used with [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) + +### getList.unshift {#unshift} + +[RestEndpoint.unshift](./RestEndpoint.vue.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.vue.md#unshift) | + +Commonly used with [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) + +### getList.getPage {#getpage} + +[RestEndpoint.getPage](./RestEndpoint.vue.md#getpage) retrieves another [page](./pagination.vue.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.vue.md#addWith) | + +args: `PathToArgs(shortenPath(path)) & searchParams & \{ [paginationField]: string | number \}` + +Commonly used with [Controller.fetch](https://dataclient.io/vue/api/Controller#fetch) + +### getList.move {#move} + +[RestEndpoint.move](./RestEndpoint.vue.md#move) moves an entity between [Collections](./Collection.vue.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.vue.md#move) | + +Commonly used with [Controller.fetch](https://dataclient.io/vue/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/vue/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/vue/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/vue/api/Controller#fetch) + +#### Response + +```json +{ "id": "xyz" } +``` + +Response should either be the [pk](./Entity.vue.md#pk) as a string (like `'xyz'`). Or an object with the members needed to compute +[Entity.pk](./Entity.vue.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.vue.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/vue/concepts/normalization) + +### extend() {#extend} + +`resource` builds a great starting point, but often endpoints need to be [further customized](./RestEndpoint.vue.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.vue.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-schema/references/All.vue.md b/.agents/skills/data-client-schema/references/All.vue.md new file mode 100644 index 000000000000..66d95e02e4be --- /dev/null +++ b/.agents/skills/data-client-schema/references/All.vue.md @@ -0,0 +1,239 @@ + + +# All + +Retrieves all entities in cache as an Array. + +- `definition`: **required** A singular [Entity](./Entity.vue.md) that this array contains _or_ a mapping of attribute values to [Entities](./Entity.vue.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' +}); +``` + +```html title="NewUser.vue" + + + +``` + +```html title="UsersPage.vue" + + + + + +``` + +### 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', + ), +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` + +#### 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`, + ), +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/Array.vue.md b/.agents/skills/data-client-schema/references/Array.vue.md new file mode 100644 index 000000000000..3898492ca5c7 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Array.vue.md @@ -0,0 +1,232 @@ + + +# schema.Array + +Creates a schema to normalize an array of schemas. If the input value is an [Object](./Object.vue.md) instead of an `Array`, +the normalized result will be an `Array` of the [Object](./Object.vue.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.vue.md) + +> **Tip** +> +> Make it mutable (new items can be [pushed](./Collection.vue.md#push)/[unshifted](./Collection.vue.md#unshift)) with [Collections](./Collection.vue.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: + +```ts title="api/User" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; +} +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new schema.Array(User), +}); +``` + +```html title="UsersPage.vue" + + + +``` + +### Updating many entities + +Use an Array with [Controller.set()](https://dataclient.io/vue/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', + ), +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` + +#### 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`, + ), +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/Collection.vue.md b/.agents/skills/data-client-schema/references/Collection.vue.md new file mode 100644 index 000000000000..df0c00d06ee9 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Collection.vue.md @@ -0,0 +1,648 @@ + + +# Collection + +`Collections` define mutable [Lists (Array)](./Array.vue.md) or [Maps (Values)](./Values.vue.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]), +}); +``` + +```html title="NewTodo.vue" {12-16} + + + +``` + +```html title="TodoList.vue" + + + +``` + +```html title="UserList.vue" + + + +``` + +### Collection with Values + +When an API returns keyed objects rather than arrays, combine `Collection` with [Values](./Values.vue.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.vue.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.vue.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; + }, + ) +}); +``` + +```html title="PostListLayout.vue" + + + +``` + +```html title="PostList.vue" + + + +``` + +### 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/vue/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.vue.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.vue.md b/.agents/skills/data-client-schema/references/Entity.vue.md new file mode 100644 index 000000000000..38743c9ed9d8 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Entity.vue.md @@ -0,0 +1,756 @@ + + +# 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.vue.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 [`v-for` keys](https://vuejs.org/guide/essentials/list.html#maintaining-state-with-key) + +```html + +``` + +#### 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.vue.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, + }; +} +``` + +```html title="PostPage.vue" + + + + + +``` + +#### 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/vue/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, +}); +``` + +```ts +const user = await useSuspense(UserResource.get, { username: 'bob' }); +``` + +#### useQuery() + +With [useQuery()](https://dataclient.io/vue/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: + +```ts +const assets = await 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.vue.md) +> skips resolution entirely and lets you resolve on demand via [useQuery](https://dataclient.io/vue/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.vue.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/vue/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.vue.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.vue.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/vue/concepts/expiry-policy#expiry-status), +like [Invalidate](./Invalidate.vue.md). + +[`Invalid`](https://dataclient.io/vue/concepts/expiry-policy#expiry-status) expiry generally means composables 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.vue.md) diff --git a/.agents/skills/data-client-schema/references/EntityMixin.vue.md b/.agents/skills/data-client-schema/references/EntityMixin.vue.md new file mode 100644 index 000000000000..03b25e5842dd --- /dev/null +++ b/.agents/skills/data-client-schema/references/EntityMixin.vue.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.vue.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.vue.md#pk) + +A `string` indicates the field to use for pk. + +A `function` is used just like [Entity.pk](./Entity.vue.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.vue.md#key) + +### schema: {\[k\:string]: Schema} {#schema} + +Specifies the [Entity.schema](./Entity.vue.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.vue.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/vue/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.vue.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.vue.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/vue/concepts/expiry-policy#expiry-status), +like [Invalidate](./Invalidate.vue.md). + +[`Invalid`](https://dataclient.io/vue/concepts/expiry-policy#expiry-status) expiry generally means composables 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.vue.md) diff --git a/.agents/skills/data-client-schema/references/Invalidate.vue.md b/.agents/skills/data-client-schema/references/Invalidate.vue.md new file mode 100644 index 000000000000..40585a8c39a5 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Invalidate.vue.md @@ -0,0 +1,224 @@ + + +# Invalidate + +Describes entities to be marked as [INVALID](https://dataclient.io/vue/concepts/expiry-policy#invalid). This removes items from a +collection, or [forces suspense](https://dataclient.io/vue/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.vue.md) to invalidate. +- `union`: A [Union](./Union.vue.md) schema for polymorphic invalidation. +- `entityMap`: A mapping of schema keys to [Entities](./Entity.vue.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), +}); +``` + +```html title="UsersPage.vue" + + + +``` + +### 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/vue/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.vue.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 Vue, 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.vue.md b/.agents/skills/data-client-schema/references/Lazy.vue.md new file mode 100644 index 000000000000..b7bc6c53b92f --- /dev/null +++ b/.agents/skills/data-client-schema/references/Lazy.vue.md @@ -0,0 +1,139 @@ + + +# 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/vue/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.vue.md), an array shorthand like `[MyEntity]`, a [Collection](./Collection.vue.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/vue/api/useQuery) with the `.query` accessor: + +```html title="DepartmentBuildings.vue" + + + +``` + +### Single entity relationship + +```typescript +class Department extends Entity { + id = ''; + name = ''; + mainBuilding = ''; + + static schema = { + mainBuilding: new Lazy(Building), + }; +} +``` + +```ts +// dept.mainBuilding is a raw PK string: 'bldg-1' +const building = useQuery( + Department.schema.mainBuilding.query, + () => ({ id: props.dept.mainBuilding }), +); +``` + +When the inner schema is an [Entity](./Entity.vue.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/vue/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.vue.md b/.agents/skills/data-client-schema/references/Object.vue.md new file mode 100644 index 000000000000..7b4c21b7ecb6 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Object.vue.md @@ -0,0 +1,46 @@ + + +# 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.vue.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 + +```ts title="api/User" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; + +class User extends Entity { + id = ''; + name = ''; +} +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new schema.Object({ users: new schema.Array(User) }), +}); +``` + +```html title="UsersPage.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/Query.vue.md b/.agents/skills/data-client-schema/references/Query.vue.md new file mode 100644 index 000000000000..d8b30971bba2 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Query.vue.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 composable useQuery()](https://dataclient.io/vue/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.vue.md), [All](./All.vue.md), [Collection](./Collection.vue.md), [Query](./Query.vue.md), +[Union](./Union.vue.md), [Scalar](./Scalar.vue.md), and [Object](./Object.vue.md) schemas for joining multiple entities. +[Lazy](./Lazy.vue.md) fields produce a Queryable via their [`.query`](./Lazy.vue.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/vue/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; + }, + ), +}); +``` + +```html title="NewPost.vue" + + + +``` + +```html title="PostList.vue" {9} + + + +``` + +```html title="UserList.vue" + + + +``` + +### 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, +}); +``` + +```html title="UsersPage.vue" + + + + + +``` + +### 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, +}); +``` + +```html title="TodoByUser.vue" + + + +``` + +```html title="TodoJoined.vue" + + + + + +``` + +### Object Schema Joins {#object-schema-joins} + +`Query` can take [Object Schemas](./Object.vue.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, +}); +``` + +```html title="PriceDisplay.vue" + + + + + +``` + +### 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.vue.md b/.agents/skills/data-client-schema/references/Scalar.vue.md new file mode 100644 index 000000000000..53f6ce0be1b1 --- /dev/null +++ b/.agents/skills/data-client-schema/references/Scalar.vue.md @@ -0,0 +1,379 @@ + + +# Scalar + +`Scalar` describes [Entity](./Entity.vue.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 }), + }), +}); +``` + +```html title="CompanyGrid.vue" + + + +``` + +```html title="PortfolioGrid.vue" + + + +``` + +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.vue.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.vue.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.vue.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.vue.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/vue/api/useQuery), +[Controller.get](https://dataclient.io/vue/api/Controller#get), [schema.Query](./Query.vue.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.vue.md) — defines the base entity that scalar fields attach to +- [Values](./Values.vue.md) — used for column-only endpoints (dictionary keyed by entity pk) +- [Union](./Union.vue.md) — similar wrapper pattern for polymorphic entities +- [Queryable](./schema.md#queryable) — Scalar participates in [useQuery](https://dataclient.io/vue/api/useQuery), [Controller.get](https://dataclient.io/vue/api/Controller#get), and [schema.Query](./Query.vue.md) diff --git a/.agents/skills/data-client-schema/references/Union.vue.md b/.agents/skills/data-client-schema/references/Union.vue.md new file mode 100644 index 000000000000..fa3349c3543b --- /dev/null +++ b/.agents/skills/data-client-schema/references/Union.vue.md @@ -0,0 +1,180 @@ + + +# 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.vue.md) or [Values](./Values.vue.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', + ), + ], +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` + +### 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`, + ), + ], +}); +``` + +```html title="LinkComponent.vue" + + + +``` + +```html title="PostComponent.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/Values.vue.md b/.agents/skills/data-client-schema/references/Values.vue.md new file mode 100644 index 000000000000..5f3b0f7b476c --- /dev/null +++ b/.agents/skills/data-client-schema/references/Values.vue.md @@ -0,0 +1,228 @@ + + +# Values + +Like [Array](./Array.vue.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.vue.md#assign)) with [Collections](./Collection.vue.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 + +```ts title="api/Item" +import { Entity, RestEndpoint, Values } from '@data-client/rest'; + +export class Item extends Entity { + id = 0; +} +export const getItems = new RestEndpoint({ + path: '/items', + schema: new Values(Item), +}); +``` + +```html title="ItemPage.vue" + + + +``` + +### Updating many entities + +Use Values with [Controller.set()](https://dataclient.io/vue/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.vue.md) and [schema.Array](./Array.vue.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', + ), +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` + +#### 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`, + ), +}); +``` + +```html title="LinkItem.vue" + + + +``` + +```html title="PostItem.vue" + + + +``` + +```html title="FeedList.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md b/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md new file mode 100644 index 000000000000..819500e69a67 --- /dev/null +++ b/.agents/skills/data-client-schema/references/_ScalarDemo.vue.md @@ -0,0 +1,115 @@ + + +```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 }), + }), +}); +``` + +```html title="CompanyGrid.vue" + + + +``` + +```html title="PortfolioGrid.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/computed-properties.vue.md b/.agents/skills/data-client-schema/references/computed-properties.vue.md new file mode 100644 index 000000000000..8d2f35062982 --- /dev/null +++ b/.agents/skills/data-client-schema/references/computed-properties.vue.md @@ -0,0 +1,114 @@ + + +# Computed Properties + +## Singular computations + +[Entity](./Entity.vue.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.vue.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, +}); +``` + +```html title="UsersPage.vue" + + + + + +``` diff --git a/.agents/skills/data-client-schema/references/partial-entities.vue.md b/.agents/skills/data-client-schema/references/partial-entities.vue.md new file mode 100644 index 000000000000..00576a596e58 --- /dev/null +++ b/.agents/skills/data-client-schema/references/partial-entities.vue.md @@ -0,0 +1,163 @@ + + +# 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.vue.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]), + }, +}); +``` + +```html title="ArticleDetail.vue" + + + +``` + +```html title="ArticleList.vue" + + + +``` + +## 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.vue.md b/.agents/skills/data-client-schema/references/relational-data.vue.md new file mode 100644 index 000000000000..6b2747caab5e --- /dev/null +++ b/.agents/skills/data-client-schema/references/relational-data.vue.md @@ -0,0 +1,469 @@ + + +# 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, +}); +``` + +```html title="PostPage.vue" + + + +``` + +## 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.vue.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, +}); +``` + +```html title="TodoJoined.vue" + + + +``` + +### Key-based joins + +For more complex scenarios where related entities are fetched separately, use [Entity.process()](./Entity.vue.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.vue.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.vue.md#process). Additionally, [Entity.merge](./Entity.vue.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, +}); +``` + +```html title="UserPage.vue" + + + +``` + +```html title="PostPage.vue" + + + +``` + +```html title="Navigation.vue" + + + +``` + +### 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.vue.md) defers resolution and lets you resolve on demand +> via [useQuery](https://dataclient.io/vue/api/useQuery), avoiding deep recursion and improving +> memoization isolation. + +[1]: ./Entity.md + +[2]: https://dataclient.io/vue/api/useCache + +[3]: ./Entity.md#schema diff --git a/.agents/skills/data-client-schema/references/side-effects.vue.md b/.agents/skills/data-client-schema/references/side-effects.vue.md new file mode 100644 index 000000000000..21c0f1d443a3 --- /dev/null +++ b/.agents/skills/data-client-schema/references/side-effects.vue.md @@ -0,0 +1,114 @@ + + +# Mutation Side-Effects + +When mutations update more than one resource, it may be tempting to simply +[expire all](https://dataclient.io/vue/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. + +```html title="CreateTrade.vue" + +``` + +> **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.vue.md b/.agents/skills/data-client-schema/references/sorting-client-side.vue.md new file mode 100644 index 000000000000..404aec82a57c --- /dev/null +++ b/.agents/skills/data-client-schema/references/sorting-client-side.vue.md @@ -0,0 +1,108 @@ + + +# Client Side Sorting + +Here we have an API that sorts based on the `orderBy` field. By wrapping our [Collection](./Collection.vue.md) +in a [Query](./Query.vue.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; + }, + ), +}); +``` + +```html title="NewPost.vue" + + + +``` + +```html title="PostList.vue" {9} + + + +``` + +```html title="UserList.vue" + + + +``` diff --git a/.agents/skills/data-client-schema/references/validation.vue.md b/.agents/skills/data-client-schema/references/validation.vue.md index 6d312b5d9168..c4bc9b058df3 100644 --- a/.agents/skills/data-client-schema/references/validation.vue.md +++ b/.agents/skills/data-client-schema/references/validation.vue.md @@ -2,12 +2,12 @@ # API Validation -[Entity.validate()](./Entity.md#validate) is called during normalization and denormalization. +[Entity.validate()](./Entity.vue.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), +Validation happens after [Entity.process()](./Entity.vue.md#process) but before [Entity.fromJS()](./Entity.vue.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. @@ -77,7 +77,7 @@ export const getArticle = new RestEndpoint({ ## Partial results -Another great use of validation is mixing endpoints that return [incomplete objects](./partial-entities.md). This is often +Another great use of validation is mixing endpoints that return [incomplete objects](./partial-entities.vue.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. diff --git a/.agents/skills/data-client-setup/SKILL.md b/.agents/skills/data-client-setup/SKILL.md index 6fb2307d7099..8981e6379fcb 100644 --- a/.agents/skills/data-client-setup/SKILL.md +++ b/.agents/skills/data-client-setup/SKILL.md @@ -258,6 +258,7 @@ import { DataProvider } from '@data-client/react/nextjs'; ### Provider Not at Root The `DataProvider` must wrap all components that use data-client hooks. Place it at the topmost level possible. +In Vue, call `app.use(DataClientPlugin)` on the root app before `app.mount()`. ## Next Steps @@ -272,6 +273,6 @@ 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 +- [DataProvider](references/DataProvider.md) - React root provider component (Vue installs `DataClientPlugin` instead; see installation) - [installation](references/installation.md) - Installation guide - [getDefaultManagers](references/getDefaultManagers.md) - Default managers diff --git a/.agents/skills/data-client-setup/references/installation.vue.md b/.agents/skills/data-client-setup/references/installation.vue.md index bc39ca65a263..7d256c0dc00e 100644 --- a/.agents/skills/data-client-setup/references/installation.vue.md +++ b/.agents/skills/data-client-setup/references/installation.vue.md @@ -14,7 +14,7 @@ Install the [Vue plugin](https://vuejs.org/guide/reusability/plugins.html) when npm install @data-client/vue @data-client/rest ``` -```tsx title="main.ts" +```ts title="main.ts" import { createApp } from 'vue'; import { DataClientPlugin } from '@data-client/vue'; diff --git a/.agents/skills/data-client-vue/references/installation.md b/.agents/skills/data-client-vue/references/installation.md index bc39ca65a263..7d256c0dc00e 100644 --- a/.agents/skills/data-client-vue/references/installation.md +++ b/.agents/skills/data-client-vue/references/installation.md @@ -14,7 +14,7 @@ Install the [Vue plugin](https://vuejs.org/guide/reusability/plugins.html) when npm install @data-client/vue @data-client/rest ``` -```tsx title="main.ts" +```ts title="main.ts" import { createApp } from 'vue'; import { DataClientPlugin } from '@data-client/vue'; diff --git a/docs/core/shared/_installation.mdx b/docs/core/shared/_installation.mdx index 3fe14137d35b..4d2d34c19111 100644 --- a/docs/core/shared/_installation.mdx +++ b/docs/core/shared/_installation.mdx @@ -118,7 +118,7 @@ Anansi includes Reactive Data Client automatically. -```tsx title="main.ts" +```ts title="main.ts" import { createApp } from 'vue'; import { DataClientPlugin } from '@data-client/vue'; diff --git a/docs/rest/api/All.md b/docs/rest/api/All.md index 4f9389733c1e..2e3c64b64fc3 100644 --- a/docs/rest/api/All.md +++ b/docs/rest/api/All.md @@ -6,7 +6,7 @@ sidebar_label: All import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; # All @@ -27,7 +27,7 @@ Retrieves all entities in cache as an Array. To describe a simple array of a singular entity type: -); ``` - +::: + +:::vue + +```html title="NewUser.vue" collapsed + + + +``` + +```html title="UsersPage.vue" + + + + + +``` + +::: + + ### Polymorphic types @@ -118,7 +172,7 @@ If your data returns an object that you did not provide a mapping for, the origi #### string schemaAttribute -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + #### 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'. -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + diff --git a/docs/rest/api/Array.md b/docs/rest/api/Array.md index f45efc986d9c..440f50de530e 100644 --- a/docs/rest/api/Array.md +++ b/docs/rest/api/Array.md @@ -1,12 +1,13 @@ --- title: schema.Array - Declarative list data for React +vue_title: schema.Array - Declarative list data for Vue sidebar_label: schema.Array --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; # schema.Array @@ -42,7 +43,7 @@ Make it mutable (new items can be [pushed](./Collection.md#push)/[unshifted](./C To describe a simple array of a singular entity type: - +:::react + ```tsx title="Users.tsx" import { Entity, RestEndpoint, schema } from '@data-client/rest'; import { useSuspense } from '@data-client/react'; @@ -79,7 +82,41 @@ function UsersPage() { render(); ``` - +::: + +:::vue + +```ts title="api/User" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; + +export class User extends Entity { + id = ''; + name = ''; +} +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new schema.Array(User), +}); +``` + +```html title="UsersPage.vue" + + + +``` + +::: + + ### Updating many entities @@ -108,7 +145,7 @@ If your data returns an object that you did not provide a mapping for, the origi #### string schemaAttribute -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + #### 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'. -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + diff --git a/docs/rest/api/Collection.md b/docs/rest/api/Collection.md index 59f1153111b6..20f1088ef1e2 100644 --- a/docs/rest/api/Collection.md +++ b/docs/rest/api/Collection.md @@ -6,7 +6,7 @@ sidebar_label: Collection import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; import { v4 as uuid } from 'uuid'; import { postFixtures,getInitialInterceptorData } from '@site/src/fixtures/posts-collection'; @@ -24,7 +24,7 @@ and [.getPage](./RestEndpoint.md#getpage)/ [.paginated()](./RestEndpoint.md#pagi ## Usage -); ``` - +::: + +:::vue + +```html title="NewTodo.vue" {12-16} + + + +``` + +```html title="TodoList.vue" collapsed + + + +``` + +```html title="UserList.vue" collapsed + + + +``` + +::: + + ### Collection with Values @@ -369,7 +445,7 @@ 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'; @@ -397,6 +473,8 @@ export const getPosts = new RestEndpoint({ }); ``` +:::react + ```tsx title="PostListLayout" collapsed import { useLoading } from '@data-client/react'; @@ -479,7 +557,94 @@ function PostList() { render(); ``` - +::: + +:::vue + +```html title="PostListLayout.vue" collapsed + + + +``` + +```html title="PostList.vue" collapsed + + + +``` + +::: + + ### createCollectionFilter? diff --git a/docs/rest/api/Entity.md b/docs/rest/api/Entity.md index 7797775da183..4c3e82baf875 100644 --- a/docs/rest/api/Entity.md +++ b/docs/rest/api/Entity.md @@ -1,5 +1,6 @@ --- title: Entity - Declarative unique objects for React +vue_title: Entity - Declarative unique objects for Vue sidebar_label: Entity --- @@ -7,7 +8,7 @@ sidebar_label: Entity -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import LanguageTabs from '@site/src/components/LanguageTabs'; import { RestEndpoint } from '@data-client/rest'; import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; @@ -121,7 +122,9 @@ 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) +Since `pk()` is unique, it provides a consistent way of defining :react[[JSX list keys](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key)]:vue[[`v-for` keys](https://vuejs.org/guide/essentials/list.html#maintaining-state-with-key)] + +:::react ```tsx //.... @@ -134,6 +137,24 @@ return ( ); ``` +::: + +:::vue + +```html + +``` + +::: + #### Composite Primary Keys When a single field isn't enough to uniquely identify an entity, you can combine multiple @@ -243,7 +264,7 @@ class User extends Entity { Defines [related entity](/rest/guides/relational-data) members, or [field deserialization](/rest/guides/network-transform#deserializing-fields) like Date and BigNumber. -); ``` - +::: + +:::vue + +```html title="PostPage.vue" collapsed + + + + + +``` + +::: + + #### Optional members @@ -372,10 +428,22 @@ export const UserResource = resource({ }); ``` +:::react + ```tsx const user = useSuspense(UserResource.get, { username: 'bob' }); ``` +::: + +:::vue + +```ts +const user = await useSuspense(UserResource.get, { username: 'bob' }); +``` + +::: + #### useQuery() With [useQuery()](/docs/api/useQuery), this enables accessing results retrieved inside other requests - even @@ -408,10 +476,22 @@ const getAssets = new RestEndpoint({ Some top level component: +:::react + ```tsx const assets = useSuspense(getAssets); ``` +::: + +:::vue + +```ts +const assets = await useSuspense(getAssets); +``` + +::: + Nested below: ```tsx diff --git a/docs/rest/api/Invalidate.md b/docs/rest/api/Invalidate.md index 9e7c20e8984b..021f36fde3f8 100644 --- a/docs/rest/api/Invalidate.md +++ b/docs/rest/api/Invalidate.md @@ -3,7 +3,7 @@ title: Invalidate Schema - Invalidating Entities sidebar_label: Invalidate --- -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground'; @@ -31,7 +31,7 @@ new Invalidate(entityMap, schemaAttribute) ## Usage -); ``` - +::: + +:::vue + +```html title="UsersPage.vue" + + + +``` + +::: + + ### Batch Invalidation @@ -276,7 +308,7 @@ const deleteMember = new RestEndpoint({ ### Impact on useSuspense() -When entities are invalidated in a result currently being presented in React, useSuspense() +When entities are invalidated in a result currently being presented in :react[React]:vue[Vue], useSuspense() will consider them invalid - For optional Entities, they are simply removed diff --git a/docs/rest/api/Lazy.md b/docs/rest/api/Lazy.md index 04411b4749de..5886cbb6315d 100644 --- a/docs/rest/api/Lazy.md +++ b/docs/rest/api/Lazy.md @@ -47,6 +47,8 @@ When a `Department` is denormalized, `dept.buildings` will contain raw primary k To resolve the buildings, use [useQuery](/docs/api/useQuery) with the `.query` accessor: +:::react + ```tsx function DepartmentBuildings({ dept }: { dept: Department }) { // dept.buildings contains raw IDs: ['bldg-1', 'bldg-2'] @@ -62,6 +64,34 @@ function DepartmentBuildings({ dept }: { dept: Department }) { } ``` +::: + +:::vue + +```html title="DepartmentBuildings.vue" + + + +``` + +::: + ### Single entity relationship ```typescript @@ -76,6 +106,8 @@ class Department extends Entity { } ``` +:::react + ```tsx // dept.mainBuilding is a raw PK string: 'bldg-1' const building = useQuery( @@ -84,6 +116,20 @@ const building = useQuery( ); ``` +::: + +:::vue + +```ts +// dept.mainBuilding is a raw PK string: 'bldg-1' +const building = useQuery( + Department.schema.mainBuilding.query, + () => ({ id: props.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 diff --git a/docs/rest/api/Object.md b/docs/rest/api/Object.md index 479a6069b9d4..7b508c210cb2 100644 --- a/docs/rest/api/Object.md +++ b/docs/rest/api/Object.md @@ -1,10 +1,11 @@ --- title: schema.Object - Declarative Object data for React +vue_title: schema.Object - Declarative Object data for Vue sidebar_label: schema.Object --- import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; # schema.Object @@ -26,7 +27,7 @@ Define a plain object mapping that has values needing to be normalized into Enti #### Usage - +:::react + ```tsx title="UsersPage.tsx" import { Entity, RestEndpoint, schema } from '@data-client/rest'; import { useSuspense } from '@data-client/react'; @@ -60,4 +63,38 @@ function UsersPage() { render(); ``` - +::: + +:::vue + +```ts title="api/User" +import { Entity, RestEndpoint, schema } from '@data-client/rest'; + +class User extends Entity { + id = ''; + name = ''; +} +export const getUsers = new RestEndpoint({ + path: '/users', + schema: new schema.Object({ users: new schema.Array(User) }), +}); +``` + +```html title="UsersPage.vue" + + + +``` + +::: + + diff --git a/docs/rest/api/Query.md b/docs/rest/api/Query.md index 3ee56881599c..8fad4ed18961 100644 --- a/docs/rest/api/Query.md +++ b/docs/rest/api/Query.md @@ -8,7 +8,7 @@ sidebar_label: Query import { RestEndpoint } from '@data-client/rest'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import SortDemo from '../shared/\_SortDemo.mdx'; # Query @@ -16,7 +16,7 @@ import SortDemo from '../shared/\_SortDemo.mdx'; `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()](/docs/api/useQuery) +`Query` can be rendered using [schema lookup :react[hook]:vue[composable] useQuery()](/docs/api/useQuery) ## Query members @@ -40,7 +40,7 @@ response for use with [useQuery](/docs/api/useQuery) ### Aggregates -); ``` - +::: + +:::vue + +```html title="UsersPage.vue" + + + + + +``` + +::: + + ### Rearranging data with groupBy aggregations {#groupby} - + ```ts title="resources/User" collapsed import { Entity, resource } from '@data-client/rest'; @@ -145,6 +185,8 @@ export const TodoResource = resource({ }); ``` +:::react + ```tsx title="TodoByUser" collapsed import { useQuery } from '@data-client/react'; import { User } from './resources/User'; @@ -209,13 +251,80 @@ function TodosPage() { render(); ``` - +::: + +:::vue + +```html title="TodoByUser.vue" collapsed + + + +``` + +```html title="TodoJoined.vue" + + + + + +``` + +::: + + ### Object Schema Joins {#object-schema-joins} `Query` can take [Object Schemas](/rest/api/Object), enabling joins across multiple entity types. This allows you to combine data from different entities in a single query. -); ``` - +::: + +:::vue + +```html title="PriceDisplay.vue" + + + + + +``` + +::: + + ### Fallback joins diff --git a/docs/rest/api/RestEndpoint.md b/docs/rest/api/RestEndpoint.md index 203cf2758205..17d6cf4ce1c0 100644 --- a/docs/rest/api/RestEndpoint.md +++ b/docs/rest/api/RestEndpoint.md @@ -14,7 +14,7 @@ import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground'; import Grid from '@site/src/components/Grid'; import Link from '@docusaurus/Link'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; # RestEndpoint @@ -179,6 +179,8 @@ export class Comment extends Entity { } ``` +:::react + ```ts title="Usage" import { Comment } from './Comment'; @@ -199,11 +201,37 @@ const createComment = async data => ctrl.fetch(getComments.push, { postId: '5' }, data); ``` +::: + +:::vue + +```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 = await 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](/docs/api/useSuspense), [useDLE](/docs/api/useDLE), [useCache](/docs/api/useCache) +[schema](#schema) determines the return value when used with data-binding :react[hooks]:vue[composables] like [useSuspense](/docs/api/useSuspense), [useDLE](/docs/api/useDLE), [useCache](/docs/api/useCache) or when used with [Controller.fetch](/docs/api/Controller#fetch) @@ -218,6 +246,8 @@ export class Todo extends Entity { } ``` +:::react + ```ts title="getTodo.ts" import { Todo } from './Todo'; @@ -231,10 +261,29 @@ async () => { }; ``` +::: + +:::vue + +```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 = await 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](/docs/api/useSuspense) and [Controller.fetch](/docs/api/Controller#fetch). +`RestEndpoints` without a schema, it also determines the return type of :react[[hooks](/docs/api/useSuspense)]:vue[[composables](/docs/api/useSuspense)] and [Controller.fetch](/docs/api/Controller#fetch). @@ -739,7 +788,7 @@ This is often useful for [authentication](../guides/auth) :::warning -Don't use hooks here. If you need to use hooks, try using [hookifyResource](./hookifyResource.md) +Don't use :react[hooks]:vue[composables] here. If you need to use :react[hooks]:vue[composables], try using [hookifyResource](./hookifyResource.md) ::: @@ -1112,7 +1161,7 @@ Returns a new RestEndpoint with [method](#method): 'PATCH' and schema: [Collecti import { kanbanFixtures, getInitialInterceptorData } from '@site/src/fixtures/kanban'; - + ```ts title="TaskResource" collapsed import { Entity, resource } from '@data-client/rest'; @@ -1132,6 +1181,8 @@ export const TaskResource = resource({ }); ``` +:::react + ```tsx title="TaskCard" {5-9} import { useController } from '@data-client/react'; import { TaskResource, type Task } from './TaskResource'; @@ -1178,7 +1229,73 @@ function TaskBoard() { render(); ``` - +::: + +:::vue + +```html title="TaskCard.vue" {7-16} + + + +``` + +```html title="TaskBoard.vue" collapsed + + + +``` + +::: + + 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). @@ -1203,6 +1320,8 @@ await ctrl.fetch( An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema must also contain a [Collection](./Collection.md) +:::react + ```tsx const getTodos = new RestEndpoint({ path: '/todos', @@ -1211,17 +1330,46 @@ const getTodos = new RestEndpoint({ }); const todos = useSuspense(getTodos); +const ctrl = useController(); return ( // fetches url `/todos?page=${nextPage}` - ctrl.fetch(TodoResource.getList.getPage, { page: nextPage }) + ctrl.fetch(getTodos.getPage, { page: nextPage }) } /> ); ``` +::: + +:::vue + +```html + + + + + +``` + +::: + See [pagination guide](../guides/pagination.md) for more info. ### paginated(paginationfield) {#paginated} diff --git a/docs/rest/api/Scalar.md b/docs/rest/api/Scalar.md index 62e7689a2127..e8f5f3ba2353 100644 --- a/docs/rest/api/Scalar.md +++ b/docs/rest/api/Scalar.md @@ -33,10 +33,14 @@ In this example, `pct_equity` and `shares` depend on the selected portfolio, whi +:::react + The badge on the preview counts its React renders (click it to reset). Switching to a new portfolio renders twice, once for the switch and once when its columns arrive, while revisiting a cached portfolio renders once. +::: + 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 diff --git a/docs/rest/api/Union.md b/docs/rest/api/Union.md index 1eaa507019b0..17617d7ab01b 100644 --- a/docs/rest/api/Union.md +++ b/docs/rest/api/Union.md @@ -1,10 +1,11 @@ --- title: Union Schema - Declarative polymorphic data for React +vue_title: Union Schema - Declarative polymorphic data for Vue sidebar_label: Union --- import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; import StackBlitz from '@site/src/components/StackBlitz'; @@ -37,7 +38,7 @@ If your data returns an object that you did not provide a mapping for, the origi ::: -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + ### Function schemaAttribute When the discriminator value doesn't directly match schema keys, use a function to compute which schema to use. -); ``` - +::: + +:::vue + +```html title="LinkComponent.vue" collapsed + + + +``` + +```html title="PostComponent.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + + +:::react ### Github Events @@ -191,3 +298,5 @@ Contribution activity comes from grouping github events by their type. Each type own distinct schema, which is why we use `Union` + +::: diff --git a/docs/rest/api/Values.md b/docs/rest/api/Values.md index 001fe6297713..334a13f18d24 100644 --- a/docs/rest/api/Values.md +++ b/docs/rest/api/Values.md @@ -1,10 +1,11 @@ --- title: Values Schema - Declarative map data for React +vue_title: Values Schema - Declarative map data for Vue sidebar_label: Values --- import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint, Values } from '@data-client/rest'; # Values @@ -40,7 +41,7 @@ its schemas are used for the value of an Object. ## Usage - +:::react + ```tsx title="ItemPage.tsx" import { Entity, RestEndpoint, Values } from '@data-client/rest'; import { useSuspense } from '@data-client/react'; @@ -67,7 +70,38 @@ function ItemPage() { render(); ``` - +::: + +:::vue + +```ts title="api/Item" +import { Entity, RestEndpoint, Values } from '@data-client/rest'; + +export class Item extends Entity { + id = 0; +} +export const getItems = new RestEndpoint({ + path: '/items', + schema: new Values(Item), +}); +``` + +```html title="ItemPage.vue" + + + +``` + +::: + + ### Updating many entities @@ -93,7 +127,7 @@ If your data returns an object that you did not provide a mapping for, the origi #### string schemaAttribute -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + #### 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'. -); ``` - +::: + +:::vue + +```html title="LinkItem.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="FeedList.vue" collapsed + + + +``` + +::: + + diff --git a/docs/rest/api/_EndpointLifecycle.mdx b/docs/rest/api/_EndpointLifecycle.mdx index 56b9373ecdb6..655ec74fd883 100644 --- a/docs/rest/api/_EndpointLifecycle.mdx +++ b/docs/rest/api/_EndpointLifecycle.mdx @@ -87,11 +87,27 @@ const createUser = new RestEndpoint({ More updates: +:::react + ```typescript title="Component.tsx" const allusers = useSuspense(userList); const adminUsers = useSuspense(userList, { admin: true }); ``` +::: + +:::vue + +```typescript title="Component.vue" +// start both fetches in parallel +useFetch(userList); +useFetch(userList, { admin: true }); +const allusers = await useSuspense(userList); +const adminUsers = await useSuspense(userList, { admin: true }); +``` + +::: + The endpoint below ensures the new user shows up immediately in the usages above. ```ts title="userEndpoint.ts" diff --git a/docs/rest/api/hookifyResource.md b/docs/rest/api/hookifyResource.md index 99bf51b04bda..18a3e77e90c0 100644 --- a/docs/rest/api/hookifyResource.md +++ b/docs/rest/api/hookifyResource.md @@ -1,5 +1,6 @@ --- title: hookifyResource() - Collection of CRUD hook Endpoints +vue_title: hookifyResource() - Collection of CRUD composable Endpoints sidebar_label: hookifyResource --- @@ -8,13 +9,12 @@ sidebar_label: hookifyResource import LanguageTabs from '@site/src/components/LanguageTabs'; -import HooksPlayground from '@site/src/components/HooksPlayground'; import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; # hookifyResource `hookifyResource()` Turns any [Resource](./resource.md) (collection of [RestEndpoints](./RestEndpoint.md)) into a collection -of hooks that return [RestEndpoints](./RestEndpoint.md). +of :react[hooks]:vue[composables] that return [RestEndpoints](./RestEndpoint.md). :::info @@ -24,6 +24,8 @@ TypeScript >=4.3 is required for generative types to work correctly. +:::react + ```ts title="resources/Article" import React from 'react'; import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; @@ -67,8 +69,70 @@ function ArticleDetail({ id }) { render(); ``` +::: + +:::vue + +```ts title="resources/Article" +import { inject } from 'vue'; +import { Collection, Entity, Invalidate, hookifyResource, resource } from '@data-client/rest'; + +class Article extends Entity { + id = ''; + title = ''; + content = ''; +} +export const AuthKey = Symbol('accessToken'); + +const ArticleResourceBase = resource({ + urlPrefix: 'http://test.com', + path: '/article/:id', + schema: Article, +}); +export const ArticleResource = hookifyResource( + ArticleResourceBase, + function useInit() { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +```html title="ArticleDetail.vue" + + + +``` + +::: + +:::vue + +Each `use*()` composable calls your function once, when the component is set up, so call them +at the top level of ` + + +``` + +::: + ```ts title="MyResource" collapsed import { resource, Entity } from '@data-client/rest'; import AuthdEndpoint from './AuthdEndpoint'; @@ -177,6 +195,8 @@ export const handleLogin = async e => { }; ``` +:::react + ```tsx title="Auth" collapsed import { handleLogin } from './AuthdEndpoint'; @@ -185,6 +205,22 @@ export default function Auth() { } ``` +::: + +:::vue + +```html title="Auth.vue" collapsed + + + +``` + +::: + ```ts title="MyResource" collapsed import { resource, Entity } from '@data-client/rest'; import AuthdEndpoint from './AuthdEndpoint'; @@ -252,6 +288,8 @@ export const handleLogin = async e => { }; ``` +:::react + ```tsx title="Auth" collapsed import { handleLogin } from './AuthdEndpoint'; @@ -260,6 +298,22 @@ export default function Auth() { } ``` +::: + +:::vue + +```html title="Auth.vue" collapsed + + + +``` + +::: + ```ts title="MyResource" collapsed import { resource, Entity } from '@data-client/rest'; import AuthdEndpoint from './AuthdEndpoint'; @@ -289,11 +343,11 @@ MyResource.get({ id: 1 }); -## Auth Headers from React Context +## Auth Headers from :react[React Context]:vue[provide/inject] {#auth-headers-from-react-context} :::warning -Using React Context for state that is not displayed (like auth tokens) is not recommended. +Using :react[React Context]:vue[provide/inject] for state that is not displayed (like auth tokens) is not recommended. This will result in unnecessary re-renders and application complexity. ::: @@ -306,9 +360,11 @@ values={[ ]}> -We can transform any [Resource](../api/resource.md) into one that uses hooks to create endpoints +We can transform any [Resource](../api/resource.md) into one that uses :react[hooks]:vue[composables] to create endpoints by using [hookifyResource](../api/hookifyResource.md) +:::react + ```ts title="resources/Post.ts" import { resource, hookifyResource } from '@data-client/rest'; @@ -339,9 +395,56 @@ function PostDetail({ id }) { } ``` -:::warning +::: + +:::vue + +```ts title="resources/Post.ts" +import { inject } from 'vue'; +import { resource, hookifyResource } from '@data-client/rest'; -Using this means all endpoint calls must only occur during a function render. +// Post defined here + +export const AuthKey = Symbol('accessToken'); + +export const PostResource = hookifyResource( + resource({ path: '/posts/:id', schema: Post }), + function useInit(): RequestInit { + const accessToken = inject(AuthKey, ''); + return { + headers: { + 'Access-Token': accessToken, + }, + }; + }, +); +``` + +Then we can get the endpoints as composables in our Vue Components + +```html title="PostDetail.vue" + + + +``` + +::: + +::::warning + +Using this means all endpoint calls must only occur :react[during a function render]:vue[at the top level of ` + + +``` + +::: + +:::: + @@ -387,6 +512,8 @@ export default class AuthdEndpoint< Next we will [extend](../api/RestEndpoint.md#extend) to generate a new endpoint with this context injected. +:::react + ```tsx function useEndpoint(endpoint: RestEndpoint) { const accessToken = useAuthContext(); @@ -397,9 +524,26 @@ function useEndpoint(endpoint: RestEndpoint) { } ``` -:::warning +::: + +:::vue + +```ts +import { inject } from 'vue'; + +function useEndpoint(endpoint: RestEndpoint) { + const accessToken = inject(AuthKey, ''); + return endpoint.extend({ accessToken }); +} +``` + +::: -Using this means all endpoint calls must only occur during a function render. +::::warning + +Using this means all endpoint calls must only occur :react[during a function render]:vue[at the top level of ` + + +``` + +::: + +:::: + diff --git a/docs/rest/guides/computed-properties.md b/docs/rest/guides/computed-properties.md index 97433028bfab..03c63decbf4f 100644 --- a/docs/rest/guides/computed-properties.md +++ b/docs/rest/guides/computed-properties.md @@ -3,7 +3,7 @@ title: Computed Properties --- import { All, Query, RestEndpoint } from '@data-client/rest'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; ## Singular computations @@ -78,7 +78,7 @@ class User extends Entity { [Query](../api/Query.md) can be used for computations of derived data from more than one entity. We generally call these aggregates. -); ``` - +::: + +:::vue + +```html title="UsersPage.vue" + + + + + +``` + +::: + + diff --git a/docs/rest/guides/network-transform.md b/docs/rest/guides/network-transform.md index f905a2dd1efc..d4eea9246387 100644 --- a/docs/rest/guides/network-transform.md +++ b/docs/rest/guides/network-transform.md @@ -2,7 +2,7 @@ title: Transforming data on fetch --- -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { RestEndpoint } from '@data-client/rest'; All network requests flow through the `fetch()` method, so any transforms needed can simply @@ -65,7 +65,7 @@ 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) -); ``` - +::: + +:::vue + +```html title="PricePage.vue" + + + +``` + +::: + + ### Deserializing Date @@ -202,7 +230,7 @@ Here's a real world example of an API that does where ticket data does not inclu We use [RestEndpoint.process()](../api/RestEndpoint.md#process) to add the `product_id` member from its argument. - + ```typescript title="Ticker" {28-31} import { Entity, RestEndpoint } from '@data-client/rest'; @@ -240,6 +268,8 @@ export const getTicker = new RestEndpoint({ }); ``` +:::react + ```tsx title="AssetPrice" {5} collapsed import { useLive } from '@data-client/react'; import { getTicker } from './Ticker'; @@ -262,7 +292,36 @@ interface Props { render(); ``` - +::: + +:::vue + +```html title="AssetPrice.vue" {7} collapsed + + + +``` + +::: + + ## Using HTTP Headers @@ -311,6 +370,8 @@ const downloadFile = new RestEndpoint({ }); ``` +:::react + ```tsx title="DownloadButton.tsx" import { useController } from '@data-client/react'; import { downloadFile } from './downloadFile'; @@ -332,6 +393,36 @@ function DownloadButton({ id }: { id: string }) { } ``` +::: + +:::vue + +```html title="DownloadButton.vue" + + + +``` + +::: + To extract the filename from the `Content-Disposition` header, override [parseResponse](../api/RestEndpoint.md#parseResponse): diff --git a/docs/rest/guides/optimistic-updates.md b/docs/rest/guides/optimistic-updates.md index 18cf86413535..7db0a8ce1ee1 100644 --- a/docs/rest/guides/optimistic-updates.md +++ b/docs/rest/guides/optimistic-updates.md @@ -1,5 +1,6 @@ --- title: 100x faster React with Optimistic Updates +vue_title: 100x faster Vue with Optimistic Updates sidebar_label: Optimistic Updates --- @@ -7,7 +8,7 @@ sidebar_label: Optimistic Updates -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import {RestEndpoint} from '@data-client/rest'; import { todoFixtures } from '@site/src/fixtures/todos'; import OptimisticTransform from '../shared/\_optimisticTransform.mdx'; @@ -24,7 +25,7 @@ handles these for you. [resource()](../api/resource.md) can be configured by setting [optimistic: true](../api/resource.md#optimistic). - + ```ts title="TodoResource" {16} import { Entity, resource } from '@data-client/rest'; @@ -46,6 +47,8 @@ export const TodoResource = resource({ }); ``` +:::react + ```tsx title="TodoItem" collapsed import { useController } from '@data-client/react'; import { TodoResource, type Todo } from './TodoResource'; @@ -126,7 +129,92 @@ function TodoList() { render(); ``` - +::: + +:::vue + +```html title="TodoItem.vue" collapsed + + + +``` + +```html title="CreateTodo.vue" collapsed + + + +``` + +```html title="TodoList.vue" collapsed + + + +``` + +::: + + This makes all mutations optimistic using some sensible default implementations that handle most cases. @@ -263,7 +351,7 @@ server timestamp. We use [snap.fetchedAt](/docs/api/Snapshot#fetchedat) in our [getOptimisticResponse](../api/RestEndpoint.md#getoptimisticresponse). This respresents the moment the fetch is triggered, which will be the same time the `updatedAt` header is computed. -); ``` - +::: + +:::vue + +```html title="CounterPage.vue" collapsed + + + +``` + +::: + + diff --git a/docs/rest/guides/pagination.md b/docs/rest/guides/pagination.md index 0d8c3b12b0f7..cded854e310c 100644 --- a/docs/rest/guides/pagination.md +++ b/docs/rest/guides/pagination.md @@ -8,7 +8,7 @@ sidebar_label: Pagination import StackBlitz from '@site/src/components/StackBlitz'; -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import PaginationDemo from '../../core/shared/\_pagination.mdx'; # Rest Pagination @@ -49,7 +49,7 @@ Here we explore a real world example using [cosmos validators list](https://rest Since validators only have one Endpoint, we use [RestEndpoint](../api/RestEndpoint.md) instead of [resource](../api/resource.md). By using [Collections](../api/Collection.md) and [paginationField](../api/RestEndpoint.md#paginationfield), we can call [RestEndpoint.getPage](../api/RestEndpoint.md#getpage) to append the next page of validators to our list. - + ```ts title="Validator" {46-50} collapsed import { Collection, Entity, RestEndpoint, schema } from '@data-client/rest'; @@ -105,6 +105,8 @@ export const getValidators = new RestEndpoint({ }); ``` +:::react + ```tsx title="ValidatorItem" collapsed import { type Validator } from './Validator'; @@ -179,7 +181,85 @@ export default function ValidatorList() { render(); ``` - +::: + +:::vue + +```html title="ValidatorItem.vue" collapsed + + + +``` + +```html title="LoadMore.vue" {7-11} + + + +``` + +```html title="ValidatorList.vue" collapsed + + + +``` + +::: + + ### Infinite Scrolling @@ -187,6 +267,8 @@ Since UI behaviors vary widely, and implementations vary from platform (react-na 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) +:::react + ```tsx import { useSuspense, useController } from '@data-client/react'; import { PostResource } from 'resources/Post'; @@ -201,12 +283,36 @@ function NewsList() { ctrl.fetch(PostResource.getList.getPage, { cursor }) } > - + ); } ``` +::: + +:::vue + +```html title="NewsList.vue" + + + +``` + +::: + ## Tokens in HTTP Headers In some cases the pagination tokens will be embeded in HTTP headers, rather than part of the payload. In this diff --git a/docs/rest/guides/partial-entities.md b/docs/rest/guides/partial-entities.md index 8a31e984810c..cf3e6c88f800 100644 --- a/docs/rest/guides/partial-entities.md +++ b/docs/rest/guides/partial-entities.md @@ -2,7 +2,7 @@ title: Partial Entities --- -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { Collection, RestEndpoint } from '@data-client/rest'; import Grid from '@site/src/components/Grid'; @@ -32,7 +32,7 @@ a subset of fields needed to summarize. In this case we can override [Entity.validate()](../api/Entity.md#validate) using [validateRequired()](../api/validateRequired.md) 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. -); ``` - +::: + +:::vue + +```html title="ArticleDetail.vue" collapsed + + + +``` + +```html title="ArticleList.vue" collapsed + + + +``` + +::: + + ## Detail data in nested entity diff --git a/docs/rest/guides/relational-data.md b/docs/rest/guides/relational-data.md index c621a4f52995..47af6c307e1b 100644 --- a/docs/rest/guides/relational-data.md +++ b/docs/rest/guides/relational-data.md @@ -1,9 +1,10 @@ --- title: Simplified relational data rendering in React +vue_title: Simplified relational data rendering in Vue sidebar_label: Relational data --- -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { Collection, RestEndpoint } from '@data-client/rest'; import StackBlitz from '@site/src/components/StackBlitz'; @@ -33,7 +34,7 @@ erDiagram
 
-); ``` - +::: + +:::vue + +```html title="PostPage.vue" collapsed + + + +``` + +::: + + ## Client side joins @@ -176,7 +213,7 @@ 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](../api/Entity.md#schema) - + ```ts title="resources/User" collapsed export class User extends Entity { @@ -216,6 +253,8 @@ export const TodoResource = resource({ }); ``` +:::react + ```tsx title="TodoJoined" collapsed import { TodoResource } from './resources/Todo'; import { UserResource } from './resources/User'; @@ -236,7 +275,32 @@ function TodosPage() { render(); ``` - +::: + +:::vue + +```html title="TodoJoined.vue" collapsed + + + +``` + +::: + + ### Key-based joins @@ -316,7 +380,7 @@ 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. -); ``` - +::: + +:::vue + +```html title="UserPage.vue" collapsed + + + +``` + +```html title="PostPage.vue" collapsed + + + +``` + +```html title="Navigation.vue" collapsed + + + +``` + +::: + + ### Circular dependencies diff --git a/docs/rest/guides/side-effects.md b/docs/rest/guides/side-effects.md index e4df976c9d8d..6ef2abc0f9fc 100644 --- a/docs/rest/guides/side-effects.md +++ b/docs/rest/guides/side-effects.md @@ -104,6 +104,8 @@ Now if when we use the [getList.push](../api/resource.md#push) Endpoint generato we will be happy knowing both the trade and account information will be updated in the cache after the `POST` request is complete. +:::react + ```typescript title="CreateTrade.tsx" export default function CreateTrade() { const ctrl = useController(); @@ -113,6 +115,24 @@ export default function CreateTrade() { } ``` +::: + +:::vue + +```html title="CreateTrade.vue" + +``` + +::: + :::note Feel free to create completely new [RestEndpoint](../api/RestEndpoint.md) methods for any custom diff --git a/docs/rest/shared/_ScalarDemo.mdx b/docs/rest/shared/_ScalarDemo.mdx index 97a51c3e0b99..3e2499481c3a 100644 --- a/docs/rest/shared/_ScalarDemo.mdx +++ b/docs/rest/shared/_ScalarDemo.mdx @@ -1,7 +1,7 @@ -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { companyFixtures } from '@site/src/fixtures/companies'; - + ```ts title="api/Company" {11-20,26-28,34-36} import { Collection, Entity, RestEndpoint, Scalar } from '@data-client/rest'; @@ -43,6 +43,8 @@ export const getPortfolioColumns = new RestEndpoint({ }); ``` +:::react + ```tsx title="CompanyGrid" collapsed import { type Company } from './api/Company'; @@ -120,4 +122,84 @@ function PortfolioGrid() { render(); ``` - +::: + +:::vue + +```html title="CompanyGrid.vue" collapsed + + + +``` + +```html title="PortfolioGrid.vue" collapsed + + + +``` + +::: + + diff --git a/docs/rest/shared/_SortDemo.mdx b/docs/rest/shared/_SortDemo.mdx index 5947d83102ba..c47d60be6c8d 100644 --- a/docs/rest/shared/_SortDemo.mdx +++ b/docs/rest/shared/_SortDemo.mdx @@ -1,10 +1,10 @@ -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import { postFixtures, getInitialInterceptorData, } from '@site/src/fixtures/posts-collection'; - + ```ts title="getPosts" {17-24} import { Collection, Entity, Query, RestEndpoint } from '@data-client/rest'; @@ -35,6 +35,8 @@ export const getPosts = new RestEndpoint({ }); ``` +:::react + ```tsx title="NewPost" collapsed import { useLoading } from '@data-client/react'; import { getPosts } from './getPosts'; @@ -108,4 +110,79 @@ function UserList() { render(); ``` - +::: + +:::vue + +```html title="NewPost.vue" collapsed + + + +``` + +```html title="PostList.vue" collapsed {9} + + + +``` + +```html title="UserList.vue" collapsed + + + +``` + +::: + + diff --git a/docs/rest/shared/_entity_lifecycle_methods.mdx b/docs/rest/shared/_entity_lifecycle_methods.mdx index 2b7a90531bf4..6ad91f7ef830 100644 --- a/docs/rest/shared/_entity_lifecycle_methods.mdx +++ b/docs/rest/shared/_entity_lifecycle_methods.mdx @@ -363,7 +363,7 @@ if it is deemed 'valid'. `undefined` return will result in [Invalid expiry status](/docs/concepts/expiry-policy#expiry-status), like [Invalidate](/rest/api/Invalidate). -[`Invalid`](/docs/concepts/expiry-policy#expiry-status) expiry generally means hooks will enter a loading state and attempt a new fetch. +[`Invalid`](/docs/concepts/expiry-policy#expiry-status) expiry generally means :react[hooks]:vue[composables] will enter a loading state and attempt a new fetch. ```ts static createIfValid(props): AbstractInstanceType | undefined { diff --git a/docs/rest/shared/_optimisticTransform.mdx b/docs/rest/shared/_optimisticTransform.mdx index 14a1b9331a78..e648ccda654a 100644 --- a/docs/rest/shared/_optimisticTransform.mdx +++ b/docs/rest/shared/_optimisticTransform.mdx @@ -1,7 +1,7 @@ -import HooksPlayground from '@site/src/components/HooksPlayground'; +import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; import {RestEndpoint} from '@data-client/rest'; -); ``` - \ No newline at end of file +::: + +:::vue + +```html title="CounterPage.vue" collapsed + + + +``` + +::: + + \ No newline at end of file diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index f9dbc95a5271..06f1143b2a7f 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -20,6 +20,8 @@ const isDev = process.env.NODE_ENV === 'development'; // docs/core is shared by React (/docs) and Vue (/vue); see framework-docs/ const frameworkDocs = require('./framework-docs/index.js'); const remarkFramework = require('./framework-docs/remarkFramework.js'); +// Non-Vue instances render React; :::vue reaches Vue agents via skill references +const reactRemarkPlugins = [[remarkFramework, { framework: 'react' }]]; const vueDocs = frameworkDocs.generate('vue'); if (isDev) frameworkDocs.watch('vue'); @@ -221,9 +223,7 @@ const config: Config = { ], //routeBasePath: 'core', sidebarPath: require.resolve('./framework-docs/sidebars-react.js'), - beforeDefaultRemarkPlugins: [ - [remarkFramework, { framework: 'react' }], - ], + beforeDefaultRemarkPlugins: reactRemarkPlugins, showLastUpdateAuthor: true, showLastUpdateTime: true, editUrl: ({ locale, docPath }) => { @@ -300,6 +300,7 @@ const config: Config = { path: '../docs/rest', routeBasePath: 'rest', sidebarPath: require.resolve('./sidebars-rest.js'), + beforeDefaultRemarkPlugins: reactRemarkPlugins, showLastUpdateAuthor: true, showLastUpdateTime: true, editUrl: ({ locale, docPath }) => { @@ -328,6 +329,7 @@ const config: Config = { path: '../docs/graphql', routeBasePath: 'graphql', sidebarPath: require.resolve('./sidebars-graphql.js'), + beforeDefaultRemarkPlugins: reactRemarkPlugins, showLastUpdateAuthor: true, showLastUpdateTime: true, editUrl: ({ locale, docPath }) => {