From f74ba7e17988f88434fcdbcd31b7d4a37aa7c96c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 12 Aug 2026 15:04:08 -0400 Subject: [PATCH 01/12] docs: Replace atomic mutations with ACID integrity concept Explain atomicity, consistency, isolation, and durability for async frontend data, with interactive demos and a redirect from the old page. Co-authored-by: Cursor --- GOALS.md | 2 +- docs/core/concepts/acid.md | 267 +++++++++++++++++++++ docs/core/concepts/atomic-mutations.md | 40 --- docs/core/getting-started/mutations.md | 2 +- docs/core/shared/_acidCollections.mdx | 129 ++++++++++ docs/core/shared/_acidCreate.mdx | 96 ++++++++ docs/core/shared/_acidDelete.mdx | 82 +++++++ docs/core/shared/_acidFetchOrder.mdx | 113 +++++++++ docs/core/shared/_acidIdentity.mdx | 73 ++++++ docs/core/shared/_acidQuery.mdx | 70 ++++++ docs/core/shared/_acidRest.mdx | 102 ++++++++ docs/core/shared/_acidRollback.mdx | 54 +++++ docs/core/shared/_acidSideEffects.mdx | 88 +++++++ docs/core/shared/_acidSnapshot.mdx | 83 +++++++ docs/core/shared/_acidTransports.mdx | 80 ++++++ docs/core/shared/_acidUpdate.mdx | 92 +++++++ docs/core/shared/_acidValidate.mdx | 80 ++++++ docs/rest/api/Entity.md | 2 +- packages/core/package.json | 2 +- packages/endpoint/package.json | 2 +- packages/graphql/package.json | 2 +- packages/img/package.json | 2 +- packages/normalizr/package.json | 2 +- packages/react/package.json | 2 +- packages/rest/package.json | 2 +- packages/test/package.json | 2 +- packages/use-enhanced-reducer/package.json | 2 +- packages/vue/package.json | 2 +- website/docusaurus.config.ts | 9 +- website/sidebars.json | 2 +- website/src/components/Demo/index.tsx | 2 +- website/src/fixtures/acid.ts | 229 ++++++++++++++++++ 32 files changed, 1660 insertions(+), 57 deletions(-) create mode 100644 docs/core/concepts/acid.md delete mode 100644 docs/core/concepts/atomic-mutations.md create mode 100644 docs/core/shared/_acidCollections.mdx create mode 100644 docs/core/shared/_acidCreate.mdx create mode 100644 docs/core/shared/_acidDelete.mdx create mode 100644 docs/core/shared/_acidFetchOrder.mdx create mode 100644 docs/core/shared/_acidIdentity.mdx create mode 100644 docs/core/shared/_acidQuery.mdx create mode 100644 docs/core/shared/_acidRest.mdx create mode 100644 docs/core/shared/_acidRollback.mdx create mode 100644 docs/core/shared/_acidSideEffects.mdx create mode 100644 docs/core/shared/_acidSnapshot.mdx create mode 100644 docs/core/shared/_acidTransports.mdx create mode 100644 docs/core/shared/_acidUpdate.mdx create mode 100644 docs/core/shared/_acidValidate.mdx create mode 100644 website/src/fixtures/acid.ts diff --git a/GOALS.md b/GOALS.md index fd863dc91506..0c73f615855c 100644 --- a/GOALS.md +++ b/GOALS.md @@ -10,8 +10,8 @@ - Intuitive to agents and humans; skills and codemods for onboarding, debugging, migrations - Data binding directly in the component that renders the data - Strong data integrity guarantees + - ACID over async durable stores, plus reactivity — clear cause and effect, without losing work - Referential stability: unchanged data keeps the same object identity everywhere - - Atomic mutations; every view consistent without refetching - Types and runtime never silently diverge - Best performance in class - Networking overhead is the most expensive - minimize this first diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md new file mode 100644 index 000000000000..9e2eccb5ea04 --- /dev/null +++ b/docs/core/concepts/acid.md @@ -0,0 +1,267 @@ +--- +title: 'ACID: Integrity for frontend data' +sidebar_label: ACID +description: Atomic, consistent, isolated, durable async data — using the server as the store. +--- + + + + + +import AcidUpdate from '../shared/\_acidUpdate.mdx'; +import AcidCreate from '../shared/\_acidCreate.mdx'; +import AcidDelete from '../shared/\_acidDelete.mdx'; +import AcidRollback from '../shared/\_acidRollback.mdx'; +import AcidSideEffects from '../shared/\_acidSideEffects.mdx'; +import AcidIdentity from '../shared/\_acidIdentity.mdx'; +import AcidCollections from '../shared/\_acidCollections.mdx'; +import AcidQuery from '../shared/\_acidQuery.mdx'; +import AcidValidate from '../shared/\_acidValidate.mdx'; +import AcidTransports from '../shared/\_acidTransports.mdx'; +import AcidFetchOrder from '../shared/\_acidFetchOrder.mdx'; +import AcidSnapshot from '../shared/\_acidSnapshot.mdx'; +import AcidRest from '../shared/\_acidRest.mdx'; + +# ACID for frontend data + +Users expect **clear cause and effect**: actions have consequences, and those +consequences are obvious. Things should not appear, disappear, or change +on their own. A user's time is valuable — don't lose their work. + +[Relational databases](https://en.wikipedia.org/wiki/ACID) call these guarantees +ACID. The frontend store is that database for interactive data — but every +durable write is [asynchronous](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous). + +Reactive Data Client applies the same guarantees so every view agrees without +[refetching](../api/Controller.md#expireAll), mutations don't flash torn state, and +crashes don't lose data that reached a durable store like a REST server or +[IndexedDB](./managers.md#persistence). + +[Normalization](./normalization.md) is what makes this possible. + +## Atomicity + +A mutation is a single unit: it succeeds completely or fails completely. +Other components never observe it halfway. That prevents *temporal data +tearing* — flashes of inconsistent state as usages update one by one. + +### Update + +[Resource.update](/rest/api/resource#update) and +[Resource.partialUpdate](/rest/api/resource#partialupdate) merge the response +into the one copy of that entity. Every consumer of that [pk](/rest/api/Entity#pk) +updates together. [Read more about defining other update endpoints](/rest/guides/side-effects). + +Toggle a todo. Both lists update at once — no flash of one list lagging. + + + +### Create + +Created entities are immediately available. They are added to existing +[Collections](/rest/api/Collection) with +[.push](/rest/api/RestEndpoint#push), +[.unshift](/rest/api/RestEndpoint#unshift), or +[.assign](/rest/api/RestEndpoint#assign). + +Add a todo. It appears in both lists together — never invisible, never an +orphan, never a list hole. + + + +### Delete + +[schema.Invalidate](/rest/api/Invalidate) removes the entity. +[Resource.delete](/rest/api/resource#delete) provides such an endpoint. + +Delete a todo. It disappears from both lists in the same commit. + + + +### Rollback + +Optimistic updates apply as that same snapshot. If the network fails, they +roll back as that snapshot. + +Click add. The todo appears immediately, then vanishes when the server errors. + + + +### Side effects + +When a mutation changes more than one resource, include every changed entity +in the response. That is one commit. [Invalidating](../api/Controller.md#expireAll) +and refetching the others can fail partway — a flash of torn state. + +[See mutation side-effects](/rest/guides/side-effects) for the full pattern. + +Add a todo. The list and the user's count update together. + + + +## Consistency + +A write takes the store from one valid state to another. Invariants hold: +one copy of each entity, relationships join, invalid data is rejected. +That prevents *data tearing* — the same todo showing two different values. + +### Identity + +[Entity.pk()](/rest/api/Entity#pk) is the unique index. The same todo from +[getList](/rest/api/resource#getlist) and [get](/rest/api/resource#get) is the +**same object** — the same value, wherever it is embedded. + +Select a todo, then toggle it. `fromList === get` stays true. + + + +### Collections + +When [Collection.argsKey](/rest/api/Collection#argskey) and +[Collection.nestKey](/rest/api/Collection#nestkey) return the same shape, a nested +list and a top-level list are the **same array**. + +Toggle a todo. `user.todos === getList` stays true, and both columns update. + + + +### Query + +[Query](/rest/api/Query) derived values stay consistent for the same reason — +they read the entity table, not a copy. + +Toggle todos. The remaining count updates without refetching. + + + +### Validation + +[Entity.validate()](./validation.md) is the check constraint. Invalid responses +are not committed. + +Switch between payloads. Only the valid article renders. + + + +### Transports + +The same entity is the same value whether it arrived from fetch, initial +load, [Controller.set()](../api/Controller.md#set), or a +[websocket](./managers.md#data-stream). + +Click simulate websocket. Both lists update — no copy left behind. + + + +## Isolation + +Concurrent work leaves the store as if it ran in sequence. A slower +response cannot confuse a newer local edit. + +### Fetch order + +Overlapping fetches complete in any order. Reactive Data Client pairs each +[optimistic update](/rest/guides/optimistic-updates) with its own request and +commits in [fetchedAt](/docs/api/Snapshot#fetchedat) order. A late response cannot +clobber a newer commit. + +```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 this would show 0, then 2, then 1. Reactive Data Client +keeps 0, 1, 2. + +Click increment several times quickly. + + + +[Optimistic updates](/rest/guides/optimistic-updates) amplify these races; +Reactive Data Client handles them automatically. + +### Snapshots + +All hooks in one render read the same snapshot, so the tree never paints mixed +old and new values. + +Toggle a todo. `list` and `query` in that row always agree. + + + +## Durability + +Once work is committed, it stays committed through a crash or a closed +tab. Storing in memory is not enough — mutations must reach an async API. +Later retrievals reflect those updates. + +### REST + +`ctrl.fetch` is the commit path. Saving as you go (a toggle, an inline +edit) commits to the server. Use a form when the friction is the point — +publish, purchase. + +Toggle some todos, then simulate a crash. Data Client refetches from the +server and the work is still there. The local-only note is gone. + + + +In-flight optimistic updates are not the durable commit — the `fetch` is. + +### IndexedDB + +A [persist Manager](./managers.md#persistence) can replicate confirmed state to +[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) +for offline reloads. Restore it with +[DataProvider's initialState](../api/DataProvider.md#initialState). Drop +in-flight optimistic updates — they are not cloneable, and they are not the ack. + +```typescript +import type { Manager, Middleware } from '@data-client/react'; +import { set } from 'idb-keyval'; + +export default class PersistManager implements Manager { + declare protected timer?: ReturnType; + + middleware: Middleware = controller => next => async action => { + await next(action); + clearTimeout(this.timer); + this.timer = setTimeout(() => { + const state = { ...controller.getState(), optimistic: [] }; + set('data-client', state); + }, 1000); + }; + + cleanup() { + clearTimeout(this.timer); + } +} +``` + +```tsx +import { get } from 'idb-keyval'; + +const initialState = await get('data-client'); + +createRoot(document.body).render( + + + , +); +``` + +:::info[Reactivity] + +ACID makes writes trustworthy. [useLive()](../api/useLive.md), +[polling](/rest/api/Endpoint#pollfrequency), and +[push](./managers.md#data-stream) keep the UI a live function of the store. +Reactivity is how you watch the durable store; it is not a substitute for +reaching it. + +::: diff --git a/docs/core/concepts/atomic-mutations.md b/docs/core/concepts/atomic-mutations.md deleted file mode 100644 index a2b3b26c22f0..000000000000 --- a/docs/core/concepts/atomic-mutations.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: '⚛ Atomic Mutations: Safe, high performance async mutations' -sidebar_label: Atomic Mutations ---- - - - - - -# Safety beyond types - -When a user causes mutations like creating, updating, or deleting resources, it's important -to have those changed be reflected in the application. A simple publish cache -that has no underlying knowledge of the data structures would require a refetch of any endpoints -that are changed. This would reduce performance and put extra burden on the backend. - -However, like many other cases, a normalized cache - one with underlying knowledge of the relationships -between resources - is capable of keeping all data consistent and fresh without -any refetches. - -## Update - -Reactive Data Client uses your schema definitions to understand how to normalize response data into -an `entity table` and `result table`. Of course, this means that there is only ever one copy -of a given `entity`. Aside from providing consistency when using different response endpoints, -this means that by providing an accurate schema definition, Reactive Data Client can automatically keep -all data uses consistent and fresh. The default update endpoints [Resource.update](/rest/api/resource#update) and -[Resource.partialUpdate](/rest/api/resource#partialupdate) both do this automatically. [Read more about defining other -update endpoints](/rest/guides/side-effects) - -## Delete - -Reactive Data Client automatically deletes entity entries [schema.Invalidate](/rest/api/Invalidate) is used. -[Resource.delete](/rest/api/resource#delete) -provides such an endpoint. - -## Create - -Created entities are immediately available. They can also be added to existing [Collections](/rest/api/Collection) -with [.push](/rest/api/RestEndpoint#push), [.unshift](/rest/api/RestEndpoint#unshift), or [.assign](/rest/api/RestEndpoint#assign). \ No newline at end of file diff --git a/docs/core/getting-started/mutations.md b/docs/core/getting-started/mutations.md index 44f881adfc18..301db56a06b8 100644 --- a/docs/core/getting-started/mutations.md +++ b/docs/core/getting-started/mutations.md @@ -18,7 +18,7 @@ import VoteDemo from '../shared/\_VoteDemo.mdx'; # Data mutations -Using our [Create, Update, and Delete](/docs/concepts/atomic-mutations) endpoints with +Using our [Create, Update, and Delete](/docs/concepts/acid) endpoints with [Controller.fetch()](../api/Controller.md#fetch) reactively updates _all_ appropriate components atomically (at the same time). [useController()](../api/useController.md) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate). diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx new file mode 100644 index 000000000000..8e8a51089d29 --- /dev/null +++ b/docs/core/shared/_acidCollections.mdx @@ -0,0 +1,129 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidConsistencyFixtures, + getAcidConsistencyData, +} from '@site/src/fixtures/acid'; + + + +```ts title="api/Todo" {12-15} collapsed +import { Entity, RestEndpoint, Collection } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} + +export const userTodos = new Collection([Todo], { + argsKey: ({ userId }: { userId?: string }) => ({ userId }), + nestKey: (parent: { id: string }) => ({ userId: parent.id }), +}); + +export const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: userTodos, +}); + +export const updateTodo = new RestEndpoint({ + path: '/todos/:id', + method: 'PATCH', + schema: Todo, + getOptimisticResponse(snap, { id }, body) { + const cur = snap.get(Todo, { id }); + if (!cur) throw snap.abort; + return { ...cur, ...body }; + }, +}); +``` + +```ts title="api/User" collapsed +import { Entity, RestEndpoint } from '@data-client/rest'; +import { Todo, userTodos } from './api/Todo'; + +export class User extends Entity { + id = ''; + name = ''; + todos: Todo[] = []; + + static key = 'User'; + static schema = { + todos: userTodos, + }; +} + +export const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); +``` + +```tsx title="TodoList" {8} +import { useController, useSuspense } from '@data-client/react'; +import { getTodos, updateTodo } from './api/Todo'; +import { getUser } from './api/User'; + +function TodoList() { + const ctrl = useController(); + const user = useSuspense(getUser, { id: '1' }); + const todos = useSuspense(getTodos, { userId: '1' }); + const handleChange = (todo, e) => + ctrl.fetch( + updateTodo, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + return ( +
+

+ user.todos === getList: {String(user.todos === todos)} +

+
+
+ user.todos + {user.todos.map(todo => ( +
+ +
+ ))} +
+
+ getList + {todos.map(todo => ( +
+ +
+ ))} +
+
+
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx new file mode 100644 index 000000000000..eade292d37d9 --- /dev/null +++ b/docs/core/shared/_acidCreate.mdx @@ -0,0 +1,96 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="CreateTodo" collapsed +import { useController } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +export default function CreateTodo({ userId }: { userId: string }) { + const ctrl = useController(); + const handleKeyDown = e => { + if (e.key === 'Enter') { + ctrl.fetch(TodoResource.getList.push, { + userId, + title: e.currentTarget.value, + }); + e.currentTarget.value = ''; + } + }; + return ( +
+ + +
+ ); +} +``` + +```tsx title="TodoList" collapsed +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import CreateTodo from './CreateTodo'; + +export default function TodoList() { + const userId = '1'; + const todos = useSuspense(TodoResource.getList, { userId }); + return ( +
+ {todos.map(todo => ( +
+ {todo.title} +
+ ))} + +
+ ); +} +``` + +```tsx title="TodoPage" +import TodoList from './TodoList'; + +function TodoPage() { + return ( +
+ + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx new file mode 100644 index 000000000000..7e266670da1c --- /dev/null +++ b/docs/core/shared/_acidDelete.mdx @@ -0,0 +1,82 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" collapsed +import { useController } from '@data-client/react'; +import { TodoResource, type Todo } from './TodoResource'; + +export default function TodoItem({ todo }: { todo: Todo }) { + const ctrl = useController(); + const handleDelete = () => + ctrl.fetch(TodoResource.delete, { id: todo.id }); + return ( +
+ {todo.title} + +
+ ); +} +``` + +```tsx title="TodoList" collapsed +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; + +export default function TodoList() { + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +} +``` + +```tsx title="TodoPage" +import TodoList from './TodoList'; + +function TodoPage() { + return ( +
+ + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidFetchOrder.mdx b/docs/core/shared/_acidFetchOrder.mdx new file mode 100644 index 000000000000..e168042ca34a --- /dev/null +++ b/docs/core/shared/_acidFetchOrder.mdx @@ -0,0 +1,113 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { RestEndpoint } from '@data-client/rest'; + + 500 + Math.random() * 4500, +} +]} +getInitialInterceptorData={() => ({count: 0})} +row +> + +```ts title="count" collapsed +export class CountEntity extends Entity { + count = 0; + + pk() { + return `SINGLETON`; + } +} +export const getCount = new RestEndpoint({ + path: '/api/count', + schema: CountEntity, + name: 'get', +}); +``` + +```ts title="increment" {9-15} +import { CountEntity, getCount } from './count'; + +export const increment = new RestEndpoint({ + path: '/api/count/increment', + method: 'POST', + body: undefined, + name: 'increment', + schema: CountEntity, + getOptimisticResponse(snap) { + const data = snap.get(CountEntity, {}); + if (!data) throw snap.abort; + return { + count: data.count + 1, + }; + }, +}); +``` + +```tsx title="CounterPage" collapsed +import { useLoading } from '@data-client/react'; +import { getCount } from './count'; +import { increment } from './increment'; + +function CounterPage() { + const ctrl = useController(); + const { count } = useSuspense(getCount); + const [stateCount, setStateCount] = React.useState(0); + const [responseCount, setResponseCount] = React.useState(0); + const [clickHandler, loading, error] = useLoading(async () => { + setStateCount(stateCount + 1); + const val = await ctrl.fetch(increment); + setResponseCount(val.count); + setStateCount(val.count); + }); + return ( +
+

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

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

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

+
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx new file mode 100644 index 000000000000..69d505bee991 --- /dev/null +++ b/docs/core/shared/_acidIdentity.mdx @@ -0,0 +1,73 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoPage" {8-10} +import { useController, useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +function TodoPage() { + const ctrl = useController(); + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + const [id, setId] = React.useState(todos[0].id); + const todo = useSuspense(TodoResource.get, { id }); + const fromList = todos.find(item => item.id === id); + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id }, + { completed: e.currentTarget.checked }, + ); + return ( +
+

+ fromList === get: {String(fromList === todo)} +

+ {todos.map(item => ( +
setId(item.id)} + > + {item.id === id ? {item.title} : item.title} +
+ ))} + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx new file mode 100644 index 000000000000..6ddcc9b58f60 --- /dev/null +++ b/docs/core/shared/_acidQuery.mdx @@ -0,0 +1,70 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource, Query } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); + +export const remainingTodos = new Query( + TodoResource.getList.schema, + entries => entries.filter(todo => !todo.completed).length, +); +``` + +```tsx title="TodoList" {7} +import { useController, useQuery, useSuspense } from '@data-client/react'; +import { remainingTodos, TodoResource } from './TodoResource'; + +function TodoList() { + const ctrl = useController(); + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + const remaining = useQuery(remainingTodos, { userId: '1' }); + const handleChange = (todo, e) => + ctrl.fetch( + TodoResource.partialUpdate, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + return ( +
+

+ {remaining} remaining +

+ {todos.map(todo => ( +
+ +
+ ))} +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx new file mode 100644 index 000000000000..b763ba784fc8 --- /dev/null +++ b/docs/core/shared/_acidRest.mdx @@ -0,0 +1,102 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" collapsed +import { useController } from '@data-client/react'; +import { TodoResource, type Todo } from './TodoResource'; + +export default function TodoItem({ todo }: { todo: Todo }) { + const ctrl = useController(); + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + return ( +
+ +
+ ); +} +``` + +```tsx title="Session" collapsed +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; + +export default function Session() { + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + const [note, setNote] = React.useState(''); + return ( +
+ setNote(e.currentTarget.value)} + /> + {todos.map(todo => ( + + ))} +
+ ); +} +``` + +```tsx title="App" +import { useController } from '@data-client/react'; +import Session from './Session'; + +function App() { + const ctrl = useController(); + const [session, setSession] = React.useState(0); + const handleCrash = async () => { + await ctrl.resetEntireStore(); + setSession(s => s + 1); + }; + return ( +
+ + }> + + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidRollback.mdx b/docs/core/shared/_acidRollback.mdx new file mode 100644 index 000000000000..47c12d10beb2 --- /dev/null +++ b/docs/core/shared/_acidRollback.mdx @@ -0,0 +1,54 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidRollbackFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoList" +import { useController, useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +function TodoList() { + const ctrl = useController(); + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + const handleAdd = () => + ctrl.fetch(TodoResource.getList.push, { + userId: '1', + title: 'New todo', + }); + return ( +
+ {todos.map(todo => ( +
+ {todo.title} +
+ ))} + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidSideEffects.mdx b/docs/core/shared/_acidSideEffects.mdx new file mode 100644 index 000000000000..87985df9de33 --- /dev/null +++ b/docs/core/shared/_acidSideEffects.mdx @@ -0,0 +1,88 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidSideEffectFixtures, + getAcidSideEffectData, +} from '@site/src/fixtures/acid'; + + + +```ts title="api/Todo" collapsed +import { Entity, RestEndpoint, Collection } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} + +export const userTodos = new Collection([Todo], { + argsKey: ({ userId }: { userId?: string }) => ({ userId }), +}); + +export const getTodos = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string }, + schema: userTodos, +}); +``` + +```ts title="api/User" {18-21} +import { Entity, RestEndpoint } from '@data-client/rest'; +import { getTodos } from './api/Todo'; + +export class User extends Entity { + id = ''; + name = ''; + todoCount = 0; + + static key = 'User'; +} + +export const getUser = new RestEndpoint({ + path: '/users/:id', + schema: User, +}); + +export const createTodo = getTodos.push.extend({ + schema: { + todo: getTodos.push.schema, + user: User, + }, +}); +``` + +```tsx title="TodoPage" +import { useController, useSuspense } from '@data-client/react'; +import { getTodos } from './api/Todo'; +import { createTodo, getUser } from './api/User'; + +function TodoPage() { + const ctrl = useController(); + const user = useSuspense(getUser, { id: '1' }); + const todos = useSuspense(getTodos, { userId: '1' }); + const handleAdd = () => + ctrl.fetch(createTodo, { + userId: '1', + title: 'New todo', + }); + return ( +
+

+ {user.name} has {user.todoCount} todos +

+ {todos.map(todo => ( +
+ {todo.title} +
+ ))} + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidSnapshot.mdx b/docs/core/shared/_acidSnapshot.mdx new file mode 100644 index 000000000000..988a8dd1dfe0 --- /dev/null +++ b/docs/core/shared/_acidSnapshot.mdx @@ -0,0 +1,83 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" {8-9,20} +import { useController, useQuery, useSuspense } from '@data-client/react'; +import { Todo, TodoResource } from './TodoResource'; + +export default function TodoItem({ id }: { id: string }) { + const ctrl = useController(); + const fromList = useSuspense(TodoResource.getList, { + userId: '1', + }).find(todo => todo.id === id); + const fromQuery = useQuery(Todo, { id }); + if (!fromList) return null; + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id }, + { completed: e.currentTarget.checked }, + ); + return ( +
+ + + list={String(fromList.completed)} query= + {String(fromQuery?.completed)} same render= + {String(fromList.completed === fromQuery?.completed)} + +
+ ); +} +``` + +```tsx title="TodoList" collapsed +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; + +function TodoList() { + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidTransports.mdx b/docs/core/shared/_acidTransports.mdx new file mode 100644 index 000000000000..0939a9a1f45f --- /dev/null +++ b/docs/core/shared/_acidTransports.mdx @@ -0,0 +1,80 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoList" collapsed +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; + +export default function TodoList() { + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + return ( +
+ {todos.map(todo => ( +
+ {todo.title} +
+ ))} +
+ ); +} +``` + +```tsx title="TodoPage" +import { useController, useSuspense } from '@data-client/react'; +import { Todo, TodoResource } from './TodoResource'; +import TodoList from './TodoList'; + +function TodoPage() { + const ctrl = useController(); + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + const handlePush = () => { + const todo = todos[0]; + ctrl.set(Todo, { id: todo.id }, current => ({ + ...current, + title: `${current.title} (pushed)`, + })); + }; + return ( +
+ +
+ + +
+
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidUpdate.mdx b/docs/core/shared/_acidUpdate.mdx new file mode 100644 index 000000000000..153156e57d1b --- /dev/null +++ b/docs/core/shared/_acidUpdate.mdx @@ -0,0 +1,92 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { + acidTodoFixtures, + getAcidTodoData, +} from '@site/src/fixtures/acid'; + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = ''; + userId = ''; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```tsx title="TodoItem" collapsed +import { useController } from '@data-client/react'; +import { TodoResource, type Todo } from './TodoResource'; + +export default function TodoItem({ todo }: { todo: Todo }) { + const ctrl = useController(); + const handleChange = e => + ctrl.fetch( + TodoResource.partialUpdate, + { id: todo.id }, + { completed: e.currentTarget.checked }, + ); + return ( +
+ +
+ ); +} +``` + +```tsx title="TodoList" collapsed +import { useSuspense } from '@data-client/react'; +import { TodoResource } from './TodoResource'; +import TodoItem from './TodoItem'; + +export default function TodoList() { + const todos = useSuspense(TodoResource.getList, { userId: '1' }); + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +} +``` + +```tsx title="TodoPage" +import TodoList from './TodoList'; + +function TodoPage() { + return ( +
+ + +
+ ); +} +render(); +``` + +
diff --git a/docs/core/shared/_acidValidate.mdx b/docs/core/shared/_acidValidate.mdx new file mode 100644 index 000000000000..71eb596d4213 --- /dev/null +++ b/docs/core/shared/_acidValidate.mdx @@ -0,0 +1,80 @@ +import HooksPlayground from '@site/src/components/HooksPlayground'; +import { RestEndpoint } from '@data-client/rest'; + + + +```ts title="api/Article" {7-10} +export class Article extends Entity { + id = ''; + title = ''; + + static validate(processedEntity) { + if (!Object.hasOwn(processedEntity, 'title')) return 'missing title field'; + if (typeof processedEntity.title !== 'string') return 'title is wrong type'; + } +} + +export const getArticle = new RestEndpoint({ + path: '/article/:id', + schema: Article, +}); +``` + +```tsx title="ArticlePage" collapsed +import { getArticle } from './api/Article'; + +export default function ArticlePage({ id }: { id: string }) { + const article = useSuspense(getArticle, { id }); + return
{article.title}
; +} +``` + +```tsx title="Navigator" +import ArticlePage from './ArticlePage'; + +function Navigator() { + const [id, setId] = React.useState('1'); + return ( +
+ + + + }> + + +
+ ); +} +render( + + + , +); +``` + +
diff --git a/docs/rest/api/Entity.md b/docs/rest/api/Entity.md index 7797775da183..3f2685d990ca 100644 --- a/docs/rest/api/Entity.md +++ b/docs/rest/api/Entity.md @@ -32,7 +32,7 @@ import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; `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. +performance and [ACID](/docs/concepts/acid) integrity. `Entities` enable customizing the data processing lifecycle by defining its static members like [schema](#schema) and overriding its [lifecycle methods](#lifecycle). diff --git a/packages/core/package.json b/packages/core/package.json index 9357a83e9d50..8bf63eb44bff 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -127,7 +127,7 @@ "homepage": "https://dataclient.io", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/core" }, "bugs": { diff --git a/packages/endpoint/package.json b/packages/endpoint/package.json index a89034f59265..9c8a8269cd28 100644 --- a/packages/endpoint/package.json +++ b/packages/endpoint/package.json @@ -127,7 +127,7 @@ "license": "Apache-2.0", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/endpoint" }, "bugs": { diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 35e659c7061c..d54c9fcb7193 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -5,7 +5,7 @@ "homepage": "https://dataclient.io/docs/graphql", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/graphql" }, "bugs": { diff --git a/packages/img/package.json b/packages/img/package.json index 3598bbfda8f9..6dfb4df261c2 100644 --- a/packages/img/package.json +++ b/packages/img/package.json @@ -5,7 +5,7 @@ "homepage": "https://dataclient.io/docs/guides/img-media#just-images", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/img" }, "bugs": { diff --git a/packages/normalizr/package.json b/packages/normalizr/package.json index f5994b0036d7..e25c1fff654e 100644 --- a/packages/normalizr/package.json +++ b/packages/normalizr/package.json @@ -116,7 +116,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/normalizr" }, "bugs": { diff --git a/packages/react/package.json b/packages/react/package.json index d6a7bce90986..7a99917a1a76 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -5,7 +5,7 @@ "homepage": "https://dataclient.io", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/react" }, "bugs": { diff --git a/packages/rest/package.json b/packages/rest/package.json index 172db4399918..8752b71d9fe9 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -5,7 +5,7 @@ "homepage": "https://dataclient.io/rest", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/rest" }, "bugs": { diff --git a/packages/test/package.json b/packages/test/package.json index 2cf3c69d30e6..3010470ee6d1 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -5,7 +5,7 @@ "homepage": "https://dataclient.io/docs/guides/storybook", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/test" }, "bugs": { diff --git a/packages/use-enhanced-reducer/package.json b/packages/use-enhanced-reducer/package.json index 62a59ae855f2..af48d49f38a0 100644 --- a/packages/use-enhanced-reducer/package.json +++ b/packages/use-enhanced-reducer/package.json @@ -5,7 +5,7 @@ "homepage": "https://github.com/reactive/data-client/tree/master/packages/use-enhanced-reducer#readme", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/use-enhanced-reducer" }, "bugs": { diff --git a/packages/vue/package.json b/packages/vue/package.json index 086681ba4fd0..0b6e2ff73e54 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -5,7 +5,7 @@ "homepage": "https://dataclient.io", "repository": { "type": "git", - "url": "git+ssh://git@github.com:reactive/data-client.git", + "url": "git+ssh://git@github.com/reactive/data-client.git", "directory": "packages/vue" }, "bugs": { diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index ce617108a02d..695b40b6a2e3 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -1,3 +1,4 @@ +import type * as DocsPlugin from '@docusaurus/plugin-content-docs'; import type * as Preset from '@docusaurus/preset-classic'; import type * as PresetMermaid from '@docusaurus/theme-mermaid'; import type { Config } from '@docusaurus/types'; @@ -270,7 +271,7 @@ const config: Config = { /*onlyIncludeVersions: isDev ? ['current', ...versionsRest.slice(0, 4)] : ['current', ...versionsRest],*/ - }, + } satisfies DocsPlugin.Options, ], [ '@docusaurus/plugin-content-docs', @@ -298,7 +299,7 @@ const config: Config = { /*onlyIncludeVersions: isDev ? ['current', ...versionsRest.slice(0, 4)] : ['current', ...versionsRest],*/ - }, + } satisfies DocsPlugin.Options, ], [ '@docusaurus/plugin-client-redirects', @@ -329,6 +330,10 @@ const config: Config = { to: '/rest/guides/partial-entities', from: ['/rest/guides/summary-list'], }, + { + to: '/docs/concepts/acid', + from: ['/docs/concepts/atomic-mutations'], + }, { to: '/docs/getting-started/resource', from: ['/docs/getting-started/endpoint'], diff --git a/website/sidebars.json b/website/sidebars.json index 973399303366..bdad7cae2f60 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -62,7 +62,7 @@ }, { "type": "doc", - "id": "concepts/atomic-mutations" + "id": "concepts/acid" }, { "type": "doc", diff --git a/website/src/components/Demo/index.tsx b/website/src/components/Demo/index.tsx index 727131fc6b3d..f48b0e7c8966 100644 --- a/website/src/components/Demo/index.tsx +++ b/website/src/components/Demo/index.tsx @@ -23,7 +23,7 @@ export default function Demo() {

This updates all usages{' '} - + atomically and immediately {' '} with zero additional fetches. Reactive Data Client automatically diff --git a/website/src/fixtures/acid.ts b/website/src/fixtures/acid.ts new file mode 100644 index 000000000000..80d2617b9b08 --- /dev/null +++ b/website/src/fixtures/acid.ts @@ -0,0 +1,229 @@ +import { RestEndpoint } from '@data-client/rest'; +import type { Interceptor } from '@data-client/test'; +import { v4 as uuid } from 'uuid'; + +export type AcidTodo = { + id: string; + userId: string; + title: string; + completed: boolean; +}; + +export type AcidTodoState = { + todos: AcidTodo[]; +}; + +export type AcidUser = { + id: string; + name: string; +}; + +export type AcidConsistencyState = AcidTodoState & { + users: AcidUser[]; +}; + +const getTodoList = new RestEndpoint({ + path: '/todos', + searchParams: {} as { userId?: string | number } | undefined, +}); +const getTodo = new RestEndpoint({ + path: '/todos/:id', +}); +const partialUpdateTodo = new RestEndpoint({ + path: '/todos/:id', + method: 'PATCH', +}); +const createTodo = new RestEndpoint({ + path: '/todos', + method: 'POST', +}); +const deleteTodo = new RestEndpoint({ + path: '/todos/:id', + method: 'DELETE', +}); +const getUser = new RestEndpoint({ + path: '/users/:id', +}); + +export function getAcidTodoData(): AcidTodoState { + return { + todos: [ + { + id: '1', + userId: '1', + title: 'Write tests', + completed: false, + }, + { + id: '2', + userId: '1', + title: 'Ship it', + completed: false, + }, + { + id: '3', + userId: '1', + title: 'Take a break', + completed: true, + }, + ], + }; +} + +export function getAcidConsistencyData(): AcidConsistencyState { + return { + users: [{ id: '1', name: 'Bob' }], + todos: getAcidTodoData().todos, + }; +} + +export type AcidSideEffectState = AcidTodoState & { + users: (AcidUser & { todoCount: number })[]; +}; + +export function getAcidSideEffectData(): AcidSideEffectState { + const todos = getAcidTodoData().todos; + return { + todos, + users: [ + { + id: '1', + name: 'Bob', + todoCount: todos.filter(todo => todo.userId === '1').length, + }, + ], + }; +} + +export const acidTodoFixtures: Interceptor[] = [ + { + endpoint: getTodoList, + response(params) { + if (params?.userId != null) { + return this.todos.filter(todo => todo.userId == params.userId); + } + return this.todos; + }, + delay: 150, + }, + { + endpoint: getTodo, + response({ id }) { + return this.todos.find(todo => todo.id == id); + }, + delay: 150, + }, + { + endpoint: partialUpdateTodo, + response({ id }, body) { + const todo = this.todos.find(item => item.id == id); + if (!todo) return { id, ...body }; + Object.assign(todo, body); + return { ...todo }; + }, + delay: 150, + }, + { + endpoint: createTodo, + response(body) { + const todo = { + completed: false, + ...body, + id: uuid(), + }; + this.todos.push(todo); + return todo; + }, + delay: 150, + }, + { + endpoint: deleteTodo, + response({ id }) { + this.todos = this.todos.filter(todo => todo.id != id); + return { id }; + }, + delay: 150, + }, +]; + +export const acidConsistencyFixtures: Interceptor[] = [ + ...acidTodoFixtures, + { + endpoint: getUser, + response({ id }) { + const user = this.users.find(item => item.id == id); + if (!user) return { id, todos: [] }; + return { + ...user, + todos: this.todos.filter(todo => todo.userId == id), + }; + }, + delay: 150, + }, +]; + +export const acidRollbackFixtures: Interceptor[] = [ + { + endpoint: getTodoList, + response(params) { + if (params?.userId != null) { + return this.todos.filter(todo => todo.userId == params.userId); + } + return this.todos; + }, + delay: 150, + }, + { + endpoint: createTodo, + response() { + throw Object.assign(new Error('Internal Server Error'), { + status: 500, + }); + }, + delay: 500, + }, +]; + +export const acidSideEffectFixtures: Interceptor[] = [ + { + endpoint: getTodoList, + response(params) { + if (params?.userId != null) { + return this.todos.filter(todo => todo.userId == params.userId); + } + return this.todos; + }, + delay: 150, + }, + { + endpoint: getUser, + response({ id }) { + const user = this.users.find(item => item.id == id); + if (!user) return { id, todoCount: 0 }; + return { ...user }; + }, + delay: 150, + }, + { + endpoint: createTodo, + response(...args) { + const body = args.length > 1 ? args[1] : args[0]; + const todo = { + completed: false, + userId: '1', + ...body, + id: uuid(), + }; + this.todos.push(todo); + const user = this.users.find(item => item.id === '1'); + if (user) { + user.todoCount = this.todos.filter(item => item.userId === '1').length; + } + return { + todo, + user: user ? { ...user } : { id: '1', todoCount: 0 }, + }; + }, + delay: 150, + }, +]; From d414bcd03fdb7c6e3b0966e974a4ddae2e839ea8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 12 Aug 2026 15:51:30 -0400 Subject: [PATCH 02/12] docs: Deduplicate ACID playgrounds and persist example Reuse the existing optimistic-transform demo and list interceptor instead of copying them, and point durability at the managers persist example. Co-authored-by: Cursor --- docs/core/concepts/acid.md | 40 +--------- docs/core/shared/_acidFetchOrder.mdx | 113 --------------------------- website/src/fixtures/acid.ts | 47 ++++------- 3 files changed, 18 insertions(+), 182 deletions(-) delete mode 100644 docs/core/shared/_acidFetchOrder.mdx diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md index 9e2eccb5ea04..3dcb6c1e174d 100644 --- a/docs/core/concepts/acid.md +++ b/docs/core/concepts/acid.md @@ -18,7 +18,7 @@ import AcidCollections from '../shared/\_acidCollections.mdx'; import AcidQuery from '../shared/\_acidQuery.mdx'; import AcidValidate from '../shared/\_acidValidate.mdx'; import AcidTransports from '../shared/\_acidTransports.mdx'; -import AcidFetchOrder from '../shared/\_acidFetchOrder.mdx'; +import OptimisticTransform from '../../rest/shared/\_optimisticTransform.mdx'; import AcidSnapshot from '../shared/\_acidSnapshot.mdx'; import AcidRest from '../shared/\_acidRest.mdx'; @@ -33,7 +33,7 @@ ACID. The frontend store is that database for interactive data — but every durable write is [asynchronous](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous). Reactive Data Client applies the same guarantees so every view agrees without -[refetching](../api/Controller.md#expireAll), mutations don't flash torn state, and +refetching, mutations don't flash torn state, and crashes don't lose data that reached a durable store like a REST server or [IndexedDB](./managers.md#persistence). @@ -181,7 +181,7 @@ keeps 0, 1, 2. Click increment several times quickly. - + [Optimistic updates](/rest/guides/optimistic-updates) amplify these races; Reactive Data Client handles them automatically. @@ -222,40 +222,6 @@ for offline reloads. Restore it with [DataProvider's initialState](../api/DataProvider.md#initialState). Drop in-flight optimistic updates — they are not cloneable, and they are not the ack. -```typescript -import type { Manager, Middleware } from '@data-client/react'; -import { set } from 'idb-keyval'; - -export default class PersistManager implements Manager { - declare protected timer?: ReturnType; - - middleware: Middleware = controller => next => async action => { - await next(action); - clearTimeout(this.timer); - this.timer = setTimeout(() => { - const state = { ...controller.getState(), optimistic: [] }; - set('data-client', state); - }, 1000); - }; - - cleanup() { - clearTimeout(this.timer); - } -} -``` - -```tsx -import { get } from 'idb-keyval'; - -const initialState = await get('data-client'); - -createRoot(document.body).render( - - - , -); -``` - :::info[Reactivity] ACID makes writes trustworthy. [useLive()](../api/useLive.md), diff --git a/docs/core/shared/_acidFetchOrder.mdx b/docs/core/shared/_acidFetchOrder.mdx deleted file mode 100644 index e168042ca34a..000000000000 --- a/docs/core/shared/_acidFetchOrder.mdx +++ /dev/null @@ -1,113 +0,0 @@ -import HooksPlayground from '@site/src/components/HooksPlayground'; -import { RestEndpoint } from '@data-client/rest'; - - 500 + Math.random() * 4500, -} -]} -getInitialInterceptorData={() => ({count: 0})} -row -> - -```ts title="count" collapsed -export class CountEntity extends Entity { - count = 0; - - pk() { - return `SINGLETON`; - } -} -export const getCount = new RestEndpoint({ - path: '/api/count', - schema: CountEntity, - name: 'get', -}); -``` - -```ts title="increment" {9-15} -import { CountEntity, getCount } from './count'; - -export const increment = new RestEndpoint({ - path: '/api/count/increment', - method: 'POST', - body: undefined, - name: 'increment', - schema: CountEntity, - getOptimisticResponse(snap) { - const data = snap.get(CountEntity, {}); - if (!data) throw snap.abort; - return { - count: data.count + 1, - }; - }, -}); -``` - -```tsx title="CounterPage" collapsed -import { useLoading } from '@data-client/react'; -import { getCount } from './count'; -import { increment } from './increment'; - -function CounterPage() { - const ctrl = useController(); - const { count } = useSuspense(getCount); - const [stateCount, setStateCount] = React.useState(0); - const [responseCount, setResponseCount] = React.useState(0); - const [clickHandler, loading, error] = useLoading(async () => { - setStateCount(stateCount + 1); - const val = await ctrl.fetch(increment); - setResponseCount(val.count); - setStateCount(val.count); - }); - return ( -

-

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

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

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

-
- ); -} -render(); -``` - - diff --git a/website/src/fixtures/acid.ts b/website/src/fixtures/acid.ts index 80d2617b9b08..a51311b8141c 100644 --- a/website/src/fixtures/acid.ts +++ b/website/src/fixtures/acid.ts @@ -95,17 +95,19 @@ export function getAcidSideEffectData(): AcidSideEffectState { }; } -export const acidTodoFixtures: Interceptor[] = [ - { - endpoint: getTodoList, - response(params) { - if (params?.userId != null) { - return this.todos.filter(todo => todo.userId == params.userId); - } - return this.todos; - }, - delay: 150, +const getTodoListInterceptor: Interceptor = { + endpoint: getTodoList, + response(params) { + if (params?.userId != null) { + return this.todos.filter(todo => todo.userId == params.userId); + } + return this.todos; }, + delay: 150, +}; + +export const acidTodoFixtures: Interceptor[] = [ + getTodoListInterceptor, { endpoint: getTodo, response({ id }) { @@ -163,16 +165,7 @@ export const acidConsistencyFixtures: Interceptor[] = [ ]; export const acidRollbackFixtures: Interceptor[] = [ - { - endpoint: getTodoList, - response(params) { - if (params?.userId != null) { - return this.todos.filter(todo => todo.userId == params.userId); - } - return this.todos; - }, - delay: 150, - }, + getTodoListInterceptor, { endpoint: createTodo, response() { @@ -185,16 +178,7 @@ export const acidRollbackFixtures: Interceptor[] = [ ]; export const acidSideEffectFixtures: Interceptor[] = [ - { - endpoint: getTodoList, - response(params) { - if (params?.userId != null) { - return this.todos.filter(todo => todo.userId == params.userId); - } - return this.todos; - }, - delay: 150, - }, + getTodoListInterceptor, { endpoint: getUser, response({ id }) { @@ -206,8 +190,7 @@ export const acidSideEffectFixtures: Interceptor[] = [ }, { endpoint: createTodo, - response(...args) { - const body = args.length > 1 ? args[1] : args[0]; + response(body) { const todo = { completed: false, userId: '1', From 1ea3b0bbe45de79d7554e31b20f71b3e38ccb9d2 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Aug 2026 11:09:19 -0400 Subject: [PATCH 03/12] fix(website): Type homepage SVGs from SVGR imports Avoid dual @types/react mismatch between Docusaurus SVG modules and React.ComponentProps<'svg'>. Co-authored-by: Cursor --- website/src/components/HomepageFeatures.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/HomepageFeatures.tsx b/website/src/components/HomepageFeatures.tsx index d68234be891e..c6256afc0dfa 100644 --- a/website/src/components/HomepageFeatures.tsx +++ b/website/src/components/HomepageFeatures.tsx @@ -9,7 +9,7 @@ import ChemicalCompositionSvg from '../../static/img/chemical-composition.svg'; import FastCarSvg from '../../static/img/fast-car.svg'; import GrowingBarChartSvg from '../../static/img/growing-bar-chart.svg'; -type SvgComponent = React.ComponentType>; +type SvgComponent = typeof FastCarSvg; interface BaseFeature { description: React.ReactNode; From 68617433207e0792f3c6cfe565942623a407ba93 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Aug 2026 11:34:58 -0400 Subject: [PATCH 04/12] docs: Use issue-tracker and trade playgrounds for ACID Replace duplicate todo lists with list+detail issues and a bundled trade/account mutation so each guarantee shows up in a recognizable product UI. Co-authored-by: Cursor --- docs/core/concepts/acid.md | 35 ++-- docs/core/shared/_acidCollections.mdx | 117 ++++++------ docs/core/shared/_acidCreate.mdx | 102 +++++------ docs/core/shared/_acidDelete.mdx | 97 +++++----- docs/core/shared/_acidIdentity.mdx | 96 +++++----- docs/core/shared/_acidQuery.mdx | 69 ++++--- docs/core/shared/_acidRest.mdx | 86 ++++----- docs/core/shared/_acidRollback.mdx | 83 ++++++--- docs/core/shared/_acidSideEffects.mdx | 98 +++++----- docs/core/shared/_acidSnapshot.mdx | 79 ++++---- docs/core/shared/_acidTransports.mdx | 92 +++++----- docs/core/shared/_acidUpdate.mdx | 107 +++++------ website/src/fixtures/acid.ts | 255 ++++++++++++++------------ 13 files changed, 656 insertions(+), 660 deletions(-) diff --git a/docs/core/concepts/acid.md b/docs/core/concepts/acid.md index 3dcb6c1e174d..427c0cdf4812 100644 --- a/docs/core/concepts/acid.md +++ b/docs/core/concepts/acid.md @@ -52,7 +52,8 @@ tearing* — flashes of inconsistent state as usages update one by one. into the one copy of that entity. Every consumer of that [pk](/rest/api/Entity#pk) updates together. [Read more about defining other update endpoints](/rest/guides/side-effects). -Toggle a todo. Both lists update at once — no flash of one list lagging. +Close an issue. The list and the detail pane update together — no flash of +one view lagging. @@ -64,8 +65,8 @@ Created entities are immediately available. They are added to existing [.unshift](/rest/api/RestEndpoint#unshift), or [.assign](/rest/api/RestEndpoint#assign). -Add a todo. It appears in both lists together — never invisible, never an -orphan, never a list hole. +Open an issue. It appears in the list and is immediately readable with +[get](/rest/api/resource#get) — never invisible, never an orphan. @@ -74,7 +75,8 @@ orphan, never a list hole. [schema.Invalidate](/rest/api/Invalidate) removes the entity. [Resource.delete](/rest/api/resource#delete) provides such an endpoint. -Delete a todo. It disappears from both lists in the same commit. +Delete an issue. It disappears from the list and the detail pane in the same +commit. @@ -83,7 +85,7 @@ Delete a todo. It disappears from both lists in the same commit. Optimistic updates apply as that same snapshot. If the network fails, they roll back as that snapshot. -Click add. The todo appears immediately, then vanishes when the server errors. +Close an issue. It flips immediately, then snaps back when the server errors. @@ -95,7 +97,7 @@ and refetching the others can fail partway — a flash of torn state. [See mutation side-effects](/rest/guides/side-effects) for the full pattern. -Add a todo. The list and the user's count update together. +Buy DOGE. The trade list and the account balance update together. @@ -103,15 +105,15 @@ Add a todo. The list and the user's count update together. A write takes the store from one valid state to another. Invariants hold: one copy of each entity, relationships join, invalid data is rejected. -That prevents *data tearing* — the same todo showing two different values. +That prevents *data tearing* — the same issue showing two different values. ### Identity -[Entity.pk()](/rest/api/Entity#pk) is the unique index. The same todo from +[Entity.pk()](/rest/api/Entity#pk) is the unique index. The same issue from [getList](/rest/api/resource#getlist) and [get](/rest/api/resource#get) is the **same object** — the same value, wherever it is embedded. -Select a todo, then toggle it. `fromList === get` stays true. +Select an issue, then close it. `fromList === get` stays true. @@ -121,7 +123,7 @@ When [Collection.argsKey](/rest/api/Collection#argskey) and [Collection.nestKey](/rest/api/Collection#nestkey) return the same shape, a nested list and a top-level list are the **same array**. -Toggle a todo. `user.todos === getList` stays true, and both columns update. +Close an issue. `repo.issues === getList` stays true, and both columns update. @@ -130,7 +132,7 @@ Toggle a todo. `user.todos === getList` stays true, and both columns update. [Query](/rest/api/Query) derived values stay consistent for the same reason — they read the entity table, not a copy. -Toggle todos. The remaining count updates without refetching. +Close issues. The open count updates without refetching. @@ -149,7 +151,8 @@ The same entity is the same value whether it arrived from fetch, initial load, [Controller.set()](../api/Controller.md#set), or a [websocket](./managers.md#data-stream). -Click simulate websocket. Both lists update — no copy left behind. +Click **Alice closed this**. The list and the detail pane update — no copy +left behind. @@ -191,7 +194,7 @@ Reactive Data Client handles them automatically. All hooks in one render read the same snapshot, so the tree never paints mixed old and new values. -Toggle a todo. `list` and `query` in that row always agree. +Close an issue. `list` and `query` in that row always agree. @@ -203,12 +206,12 @@ Later retrievals reflect those updates. ### REST -`ctrl.fetch` is the commit path. Saving as you go (a toggle, an inline +`ctrl.fetch` is the commit path. Saving as you go (a close, an inline edit) commits to the server. Use a form when the friction is the point — publish, purchase. -Toggle some todos, then simulate a crash. Data Client refetches from the -server and the work is still there. The local-only note is gone. +Close some issues, type a draft comment, then simulate a crash. Data Client +refetches from the server and the closes are still there. The draft is gone. diff --git a/docs/core/shared/_acidCollections.mdx b/docs/core/shared/_acidCollections.mdx index 8e8a51089d29..4e8d26eac80c 100644 --- a/docs/core/shared/_acidCollections.mdx +++ b/docs/core/shared/_acidCollections.mdx @@ -1,86 +1,88 @@ import HooksPlayground from '@site/src/components/HooksPlayground'; import { - acidConsistencyFixtures, - getAcidConsistencyData, + acidCollectionFixtures, + getAcidCollectionData, } from '@site/src/fixtures/acid'; - + -```ts title="api/Todo" {12-15} collapsed +```ts title="IssueResource" {12-15} import { Entity, RestEndpoint, Collection } from '@data-client/rest'; -export class Todo extends Entity { +export class Issue extends Entity { id = ''; - userId = ''; + repoId = ''; title = ''; - completed = false; + state: 'open' | 'closed' = 'open'; - static key = 'Todo'; + static key = 'Issue'; } -export const userTodos = new Collection([Todo], { - argsKey: ({ userId }: { userId?: string }) => ({ userId }), - nestKey: (parent: { id: string }) => ({ userId: parent.id }), +export const repoIssues = new Collection([Issue], { + argsKey: ({ repoId }: { repoId?: string }) => ({ repoId }), + nestKey: (parent: { id: string }) => ({ repoId: parent.id }), }); -export const getTodos = new RestEndpoint({ - path: '/todos', - searchParams: {} as { userId?: string }, - schema: userTodos, +export const getIssues = new RestEndpoint({ + path: '/issues', + searchParams: {} as { repoId?: string }, + schema: repoIssues, }); -export const updateTodo = new RestEndpoint({ - path: '/todos/:id', +export const updateIssue = new RestEndpoint({ + path: '/issues/:id', method: 'PATCH', - schema: Todo, + schema: Issue, getOptimisticResponse(snap, { id }, body) { - const cur = snap.get(Todo, { id }); + const cur = snap.get(Issue, { id }); if (!cur) throw snap.abort; return { ...cur, ...body }; }, }); ``` -```ts title="api/User" collapsed +```ts title="RepoResource" collapsed import { Entity, RestEndpoint } from '@data-client/rest'; -import { Todo, userTodos } from './api/Todo'; +import { Issue, repoIssues } from './IssueResource'; -export class User extends Entity { +export class Repo extends Entity { id = ''; name = ''; - todos: Todo[] = []; + issues: Issue[] = []; - static key = 'User'; + static key = 'Repo'; static schema = { - todos: userTodos, + issues: repoIssues, }; } -export const getUser = new RestEndpoint({ - path: '/users/:id', - schema: User, +export const getRepo = new RestEndpoint({ + path: '/repos/:id', + schema: Repo, }); ``` -```tsx title="TodoList" {8} +```tsx title="IssuePage" collapsed import { useController, useSuspense } from '@data-client/react'; -import { getTodos, updateTodo } from './api/Todo'; -import { getUser } from './api/User'; +import { getIssues, updateIssue } from './IssueResource'; +import { getRepo } from './RepoResource'; -function TodoList() { +function IssuePage() { const ctrl = useController(); - const user = useSuspense(getUser, { id: '1' }); - const todos = useSuspense(getTodos, { userId: '1' }); - const handleChange = (todo, e) => + const repo = useSuspense(getRepo, { id: '1' }); + const issues = useSuspense(getIssues, { repoId: '1' }); + const handleToggle = issue => ctrl.fetch( - updateTodo, - { id: todo.id }, - { completed: e.currentTarget.checked }, + updateIssue, + { id: issue.id }, + { state: issue.state === 'open' ? 'closed' : 'open' }, ); return (

- user.todos === getList: {String(user.todos === todos)} + + repo.issues === getList: {String(repo.issues === issues)} +

- user.todos - {user.todos.map(todo => ( -
- + Repo page + {repo.issues.map(issue => ( +
+ {issue.title} + {issue.state} +
))}
- getList - {todos.map(todo => ( -
- + Issues tab + {issues.map(issue => ( +
+ {issue.title} + {issue.state}
))}
@@ -123,7 +116,7 @@ function TodoList() {
); } -render(); +render(); ``` diff --git a/docs/core/shared/_acidCreate.mdx b/docs/core/shared/_acidCreate.mdx index eade292d37d9..10bc55ad9895 100644 --- a/docs/core/shared/_acidCreate.mdx +++ b/docs/core/shared/_acidCreate.mdx @@ -1,82 +1,49 @@ import HooksPlayground from '@site/src/components/HooksPlayground'; import { - acidTodoFixtures, - getAcidTodoData, + acidIssueFixtures, + getAcidIssueData, } from '@site/src/fixtures/acid'; - + -```ts title="TodoResource" collapsed +```ts title="IssueResource" collapsed import { Entity, resource } from '@data-client/rest'; -export class Todo extends Entity { +export class Issue extends Entity { id = ''; - userId = ''; + repoId = ''; title = ''; - completed = false; + state: 'open' | 'closed' = 'open'; - static key = 'Todo'; + static key = 'Issue'; } -export const TodoResource = resource({ - path: '/todos/:id', - searchParams: {} as { userId?: string | number } | undefined, - schema: Todo, +export const IssueResource = resource({ + path: '/issues/:id', + searchParams: {} as { repoId?: string } | undefined, + schema: Issue, optimistic: true, }); ``` -```tsx title="CreateTodo" collapsed -import { useController } from '@data-client/react'; -import { TodoResource } from './TodoResource'; +```tsx title="IssuePage" {13} +import { useController, useSuspense } from '@data-client/react'; +import { IssueResource } from './IssueResource'; -export default function CreateTodo({ userId }: { userId: string }) { +function IssuePage() { const ctrl = useController(); + const issues = useSuspense(IssueResource.getList, { repoId: '1' }); + const issue = useSuspense(IssueResource.get, { + id: issues[issues.length - 1].id, + }); const handleKeyDown = e => { - if (e.key === 'Enter') { - ctrl.fetch(TodoResource.getList.push, { - userId, + if (e.key === 'Enter' && e.currentTarget.value.trim()) { + ctrl.fetch(IssueResource.getList.push, { + repoId: '1', title: e.currentTarget.value, }); e.currentTarget.value = ''; } }; - return ( -
- - -
- ); -} -``` - -```tsx title="TodoList" collapsed -import { useSuspense } from '@data-client/react'; -import { TodoResource } from './TodoResource'; -import CreateTodo from './CreateTodo'; - -export default function TodoList() { - const userId = '1'; - const todos = useSuspense(TodoResource.getList, { userId }); - return ( -
- {todos.map(todo => ( -
- {todo.title} -
- ))} - -
- ); -} -``` - -```tsx title="TodoPage" -import TodoList from './TodoList'; - -function TodoPage() { return (
- - +
+ {issues.map(item => ( +
+ {item.title} +
+ ))} +
+ +
+
+
+ Newest +
{issue.title}
+ {issue.state} +
); } -render(); +render(); ```
diff --git a/docs/core/shared/_acidDelete.mdx b/docs/core/shared/_acidDelete.mdx index 7e266670da1c..9d39602a3759 100644 --- a/docs/core/shared/_acidDelete.mdx +++ b/docs/core/shared/_acidDelete.mdx @@ -1,68 +1,45 @@ import HooksPlayground from '@site/src/components/HooksPlayground'; import { - acidTodoFixtures, - getAcidTodoData, + acidIssueFixtures, + getAcidIssueData, } from '@site/src/fixtures/acid'; - + -```ts title="TodoResource" collapsed +```ts title="IssueResource" collapsed import { Entity, resource } from '@data-client/rest'; -export class Todo extends Entity { +export class Issue extends Entity { id = ''; - userId = ''; + repoId = ''; title = ''; - completed = false; + state: 'open' | 'closed' = 'open'; - static key = 'Todo'; + static key = 'Issue'; } -export const TodoResource = resource({ - path: '/todos/:id', - searchParams: {} as { userId?: string | number } | undefined, - schema: Todo, +export const IssueResource = resource({ + path: '/issues/:id', + searchParams: {} as { repoId?: string } | undefined, + schema: Issue, optimistic: true, }); ``` -```tsx title="TodoItem" collapsed -import { useController } from '@data-client/react'; -import { TodoResource, type Todo } from './TodoResource'; +```tsx title="IssuePage" {14} +import { useController, useSuspense } from '@data-client/react'; +import { IssueResource } from './IssueResource'; -export default function TodoItem({ todo }: { todo: Todo }) { +function IssuePage() { const ctrl = useController(); - const handleDelete = () => - ctrl.fetch(TodoResource.delete, { id: todo.id }); - return ( -
- {todo.title} - -
+ const issues = useSuspense(IssueResource.getList, { repoId: '1' }); + const [id, setId] = React.useState(issues[0]?.id); + const selected = issues.find(item => item.id === id) ?? issues[0]; + const issue = useSuspense( + IssueResource.get, + selected ? { id: selected.id } : null, ); -} -``` - -```tsx title="TodoList" collapsed -import { useSuspense } from '@data-client/react'; -import { TodoResource } from './TodoResource'; -import TodoItem from './TodoItem'; - -export default function TodoList() { - const todos = useSuspense(TodoResource.getList, { userId: '1' }); - return ( -
- {todos.map(todo => ( - - ))} -
- ); -} -``` - -```tsx title="TodoPage" -import TodoList from './TodoList'; - -function TodoPage() { + const handleDelete = () => + ctrl.fetch(IssueResource.delete, { id: selected.id }); return (
- - +
+ {issues.map(item => ( +
setId(item.id)} + > + {item === selected ? + {item.title} + : item.title} +
+ ))} +
+
+ {issue ? +
+ {issue.title} + +
+ : No issues} +
); } -render(); +render(); ```
diff --git a/docs/core/shared/_acidIdentity.mdx b/docs/core/shared/_acidIdentity.mdx index 69d505bee991..ba8fab67ab81 100644 --- a/docs/core/shared/_acidIdentity.mdx +++ b/docs/core/shared/_acidIdentity.mdx @@ -1,73 +1,87 @@ import HooksPlayground from '@site/src/components/HooksPlayground'; import { - acidTodoFixtures, - getAcidTodoData, + acidIssueFixtures, + getAcidIssueData, } from '@site/src/fixtures/acid'; - + -```ts title="TodoResource" collapsed +```ts title="IssueResource" collapsed import { Entity, resource } from '@data-client/rest'; -export class Todo extends Entity { +export class Issue extends Entity { id = ''; - userId = ''; + repoId = ''; title = ''; - completed = false; + state: 'open' | 'closed' = 'open'; - static key = 'Todo'; + static key = 'Issue'; } -export const TodoResource = resource({ - path: '/todos/:id', - searchParams: {} as { userId?: string | number } | undefined, - schema: Todo, +export const IssueResource = resource({ + path: '/issues/:id', + searchParams: {} as { repoId?: string } | undefined, + schema: Issue, optimistic: true, }); ``` -```tsx title="TodoPage" {8-10} +```tsx title="IssuePage" {6,8,9,19} import { useController, useSuspense } from '@data-client/react'; -import { TodoResource } from './TodoResource'; +import { IssueResource } from './IssueResource'; -function TodoPage() { +function IssuePage() { const ctrl = useController(); - const todos = useSuspense(TodoResource.getList, { userId: '1' }); - const [id, setId] = React.useState(todos[0].id); - const todo = useSuspense(TodoResource.get, { id }); - const fromList = todos.find(item => item.id === id); - const handleChange = e => + const issues = useSuspense(IssueResource.getList, { repoId: '1' }); + const [id, setId] = React.useState(issues[0].id); + const issue = useSuspense(IssueResource.get, { id }); + const fromList = issues.find(item => item.id === id); + const handleToggle = () => ctrl.fetch( - TodoResource.partialUpdate, + IssueResource.partialUpdate, { id }, - { completed: e.currentTarget.checked }, + { state: issue.state === 'open' ? 'closed' : 'open' }, ); return (

- fromList === get: {String(fromList === todo)} + fromList === get: {String(fromList === issue)}

- {todos.map(item => ( -
setId(item.id)} - > - {item.id === id ? {item.title} : item.title} +
+
+ {issues.map(item => ( +
setId(item.id)} + > + {item.id === id ? + {item.title} + : item.title} + {item.state} +
+ ))}
- ))} - +
+
{issue.title}
+

+ {issue.state} +

+ +
+
); } -render(); +render(); ``` diff --git a/docs/core/shared/_acidQuery.mdx b/docs/core/shared/_acidQuery.mdx index 6ddcc9b58f60..bcc08c3ce641 100644 --- a/docs/core/shared/_acidQuery.mdx +++ b/docs/core/shared/_acidQuery.mdx @@ -1,70 +1,67 @@ import HooksPlayground from '@site/src/components/HooksPlayground'; import { - acidTodoFixtures, - getAcidTodoData, + acidIssueFixtures, + getAcidIssueData, } from '@site/src/fixtures/acid'; - + -```ts title="TodoResource" collapsed +```ts title="IssueResource" {18-21} import { Entity, resource, Query } from '@data-client/rest'; -export class Todo extends Entity { +export class Issue extends Entity { id = ''; - userId = ''; + repoId = ''; title = ''; - completed = false; + state: 'open' | 'closed' = 'open'; - static key = 'Todo'; + static key = 'Issue'; } -export const TodoResource = resource({ - path: '/todos/:id', - searchParams: {} as { userId?: string | number } | undefined, - schema: Todo, +export const IssueResource = resource({ + path: '/issues/:id', + searchParams: {} as { repoId?: string } | undefined, + schema: Issue, optimistic: true, }); -export const remainingTodos = new Query( - TodoResource.getList.schema, - entries => entries.filter(todo => !todo.completed).length, +export const openCount = new Query( + IssueResource.getList.schema, + entries => entries.filter(issue => issue.state === 'open').length, ); ``` -```tsx title="TodoList" {7} +```tsx title="IssuePage" collapsed import { useController, useQuery, useSuspense } from '@data-client/react'; -import { remainingTodos, TodoResource } from './TodoResource'; +import { openCount, IssueResource } from './IssueResource'; -function TodoList() { +function IssuePage() { const ctrl = useController(); - const todos = useSuspense(TodoResource.getList, { userId: '1' }); - const remaining = useQuery(remainingTodos, { userId: '1' }); - const handleChange = (todo, e) => + const issues = useSuspense(IssueResource.getList, { repoId: '1' }); + const open = useQuery(openCount, { repoId: '1' }); + const handleToggle = issue => ctrl.fetch( - TodoResource.partialUpdate, - { id: todo.id }, - { completed: e.currentTarget.checked }, + IssueResource.partialUpdate, + { id: issue.id }, + { state: issue.state === 'open' ? 'closed' : 'open' }, ); return (

- {remaining} remaining + {open} open

- {todos.map(todo => ( -
- + {issues.map(issue => ( +
+ {issue.title} + {issue.state} +
))}
); } -render(); +render(); ``` diff --git a/docs/core/shared/_acidRest.mdx b/docs/core/shared/_acidRest.mdx index b763ba784fc8..9046755928ea 100644 --- a/docs/core/shared/_acidRest.mdx +++ b/docs/core/shared/_acidRest.mdx @@ -1,82 +1,66 @@ import HooksPlayground from '@site/src/components/HooksPlayground'; import { - acidTodoFixtures, - getAcidTodoData, + acidIssueFixtures, + getAcidIssueData, } from '@site/src/fixtures/acid'; - + -```ts title="TodoResource" collapsed +```ts title="IssueResource" collapsed import { Entity, resource } from '@data-client/rest'; -export class Todo extends Entity { +export class Issue extends Entity { id = ''; - userId = ''; + repoId = ''; title = ''; - completed = false; + state: 'open' | 'closed' = 'open'; - static key = 'Todo'; + static key = 'Issue'; } -export const TodoResource = resource({ - path: '/todos/:id', - searchParams: {} as { userId?: string | number } | undefined, - schema: Todo, +export const IssueResource = resource({ + path: '/issues/:id', + searchParams: {} as { repoId?: string } | undefined, + schema: Issue, optimistic: true, }); ``` -```tsx title="TodoItem" collapsed -import { useController } from '@data-client/react'; -import { TodoResource, type Todo } from './TodoResource'; +```tsx title="Session" collapsed +import { useController, useSuspense } from '@data-client/react'; +import { IssueResource } from './IssueResource'; -export default function TodoItem({ todo }: { todo: Todo }) { +export default function Session() { const ctrl = useController(); - const handleChange = e => + const issues = useSuspense(IssueResource.getList, { repoId: '1' }); + const [draft, setDraft] = React.useState(''); + const handleToggle = issue => ctrl.fetch( - TodoResource.partialUpdate, - { id: todo.id }, - { completed: e.currentTarget.checked }, + IssueResource.partialUpdate, + { id: issue.id }, + { state: issue.state === 'open' ? 'closed' : 'open' }, ); - return ( -
- -
- ); -} -``` - -```tsx title="Session" collapsed -import { useSuspense } from '@data-client/react'; -import { TodoResource } from './TodoResource'; -import TodoItem from './TodoItem'; - -export default function Session() { - const todos = useSuspense(TodoResource.getList, { userId: '1' }); - const [note, setNote] = React.useState(''); return (
- setNote(e.currentTarget.value)} +