Data flow
A screen reads through one road: the OpenAPI document, a generated table, one client, a query cache, a shared entity store, and the scope of <xo-view>. A write travels back along the same road and refreshes what it touches.
The road from spec to screen
Nothing in the browser knows OpenAPI. xoong sync reads the document once, at development time, and writes a small table that the client uses at run time.
One road for every read. The arrows are the direction the data travels; a change in the store travels the last two arrows again, without a request.
The rest of this page follows the road from the top, then the write path, live updates and errors.
Sync: what the generated files hold
File (in .xoong/) | What it is for |
|---|---|
operations.json | The runtime table. Per operation: id, method, path template, the names of its query parameters, whether it has a body, the request schema (bodySchema, used by forms), and a response descriptor that says where entities sit in the answer. Plus the entities: which schemas are records and which property is their id. |
api.d.ts | TypeScript types for every operation, so createClient<Operations>() checks names, params and bodies. JavaScript users never load it. |
xoong.html-data.json | Completion and hover for xo-* names and your operation names in editors. See Tooling. |
{
"version": 1,
"operations": {
"listUsers": {
"id": "listUsers", "method": "GET", "path": "/users",
"query": ["limit", "sort"],
"response": { "t": "object", "props": { "items": { "t": "array", "items": { "t": "entity", "name": "User", "replace": true } } } }
},
"updateUser": {
"id": "updateUser", "method": "PATCH", "path": "/users/{id}",
"body": true,
"bodySchema": { "t": "object", "props": { "name": { "t": "string", "minLength": 2 }, "email": { "t": "string", "format": "email" } }, "required": ["name"] },
"response": { "t": "entity", "name": "User", "replace": true }
}
},
"entities": { "User": { "id": "id" } }
}import { configure } from '@xoong/data';
import '@xoong/data/view';
import operations from '/.xoong/operations.json' with { type: 'json' };
configure({ api: operations, baseUrl: '/api', streamUrl: '/api/events' });npx xoong sync openapi.json --hints xoong.hints.jsonAn operation is named by its operationId. Without one, sync builds a name from the method and path. A schema is an entity when it has a property named id, or one named by x-xoong-id in the document, or by a hints file (idNames, or schemas.X.id); an id may be several properties (a composite id). A response that uses the entity's own schema is stored by replacing the record (replace: true above); several schemas that share one entity through a hint merge field by field, because projections carry fewer fields. xoong doctor lists what the document leaves unclear, with a fix for each.
The client
configure() makes the one app-wide client (createClient()); getClient() returns it. Everything that talks to the API goes through it. A request is built like this:
- The path template is filled from params and every value is URL-encoded; a missing path parameter throws and names it. Only the query names the operation declares are sent; an array repeats the name.
headers()is called per request (it may be async: auth tokens).credentialsis passed tofetch. The body is JSON, orFormDataas it is for multipart operations.- Only GET is retried: on a network failure or a 502, 503 or 504, up to
retrytimes (2), waitingretryDelaymilliseconds (300) doubled each time. A write is never retried, so it cannot run twice. Fields that hints declare as idempotency keys get a fresh UUID per call. 204answersnull. A hintunwrap: "data"reads{ code, message, data }envelopes asdata.
A failure is always a XoongError with status (0 for a network failure), title, detail, code, retryAfterS and fields. It is built from application/problem+json, { error: { code, message, fields } }, FastAPI-style { detail: [{ loc, msg }] } or { errors: [{ pointer, detail }] }. A 401 calls onUnauthorized, then every failure calls onError.
| Option | Default | Meaning |
|---|---|---|
api | required | the contents of operations.json |
baseUrl, headers, credentials, fetch | where and how requests go out | |
staleTime | 30000 ms | how long a GET result counts as fresh |
gcTime | 300000 ms | how long a query that nothing watches is kept |
retry, retryDelay | 2, 300 ms | GET retries and the first delay |
evictEntities | true | drop records no cached query reaches when a query is collected |
invalidate | none | extra rules: mutation id to the operation ids to refresh |
onError, onUnauthorized, streamUrl | global error handler, 401 handler, the server-sent events endpoint |
The query cache
A read is a query. Its key is the operation id plus the canonical params: keys sorted, empty values dropped, every value as its text in the URL. So the same data is the same query, however you ask for it, and a developer never writes a key.
A read, top to bottom. Dashed arrows are answers or signal updates. The code is all you write; the people and the names of the services are examples. In motion, a dot travels along the arrows one after another, top to bottom, while the line of code that causes the step is lit and the screen changes with it: loading rows first, then the table. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.
const a = client.query('listUsers', { limit: 10 });
const b = client.query('GET /users', { limit: '10' });
a === b; // true: both are the query listUsers:{"limit":"10"}A query holds signals: status (idle, loading, success or error), error, the normalised root and data, a computed value that fills the records back in. It also holds the rules of its life:
- Deduplicated. Two views of the same query share one request.
- Observed. A view calls
retain()when it starts andrelease()when its params change or it goes away. Retaining fetches the query if it is not fresh (older thanstaleTime). While a refetch runs, the old data stays on screen and$loadingis true. - Cancelled. When the last observer lets go while the request is running, the request is aborted. That is not an error; the query returns to its previous state.
- Collected. A query nobody watches is dropped after
gcTime. Coming back before that shows the cached data at once.
The entity store
When a response arrives, the response descriptor tells normalize() where the records are. Each record goes into the store once, under Name#id, and the query keeps a reference ({ $ref: "User#1" }) in its place. Every record is one signal. A GET response is only one of four ways a record gets in:
Whatever the source, a record is stored once, so every reader of that record updates.
const q = client.query('listUsers', { limit: 10 });
await q.fetch();
q.root.peek(); // { items: [{ $ref: 'User#1' }, { $ref: 'User#2' }], total: 2 }
client.store.read('User#1'); // { id: 1, name: 'Ada', email: '...' } (a tracked read)
q.data.peek(); // the same list with the records filled inThe effect is sharing: a list, a detail page and a dashboard that show User#1 read the same signal, so one change reaches all of them. Reading the data (denormalize) resolves the references; a reference to a deleted record drops out of lists instead of leaving a hole. A union is stored under the right entity when the members can be told apart (a discriminator, a fixed tag value, or a property only one member has); otherwise the value stays inline and xoong doctor reports it (XO160).
derived(({ all, get }) => ...) is a computed over the store: all("Order") is every loaded order, from any query, and the result recomputes when one of them or the set changes. It never fetches; fetching is declared on a view.
One caveat. When a query is collected, records that no remaining cached query reaches (following nested references) are dropped, and a screen still reading them sees them as deleted. So a screen should show its data through a query. Turn it off with evictEntities: false.
The view and the DOM
<xo-view get> picks its query in an effect over its get attribute and its params (the p-* attributes, xo-param, the URL). When either changes, it releases the old query and retains the new one. Its scope has $loading (status is loading), $error (the XoongError), the response fields (items), and the params, so {id} works in an action. Text bindings read the scope inside effects, so only the nodes whose values changed are written. Rendering shows how.
<xo-view get="listUsers" p-limit="10">
<p xo-if="$loading" aria-busy="true">Loading…</p>
<p xo-if="$error" role="alert">{$error.code}: {$error.detail}</p>
<ul>
<li xo-each="items">
{name}
<button xo-click="POST /users/{id}/disable" xo-optimistic="status=disabled">Disable</button>
</li>
</ul>
</xo-view>The write path
A write is xo-click on a button or xo-submit on a form. Both end in client.mutate(op, params, body).
A write, top to bottom. Invalidation is by path; only queries that something is watching refetch at once. The people and the names of the services are examples. In motion, a dot travels along the arrows one after another, top to bottom, while the code that causes the step is lit and the screen changes with it: the question, then the row that says "disabled". A hollow dot rides a dashed arrow. The note about a failure is not played: it is the other road. Use the slider, or press a number, to look at one step.
- The handler. It ignores a second click while one call is running (and sets
aria-busy).xo-confirmasks first. Path and query params come from the scope: the current item first, then the view'sp-*params. The body is the form model's result, the form's fields,xo-body(a record from the scope), plusxo-withfields;xo-selectedruns once per ticked row or sends the ticked ids in one request. - Optimistic update. With
xo-optimistic="status=disabled"(or theoptimisticoption), the patch is applied to loaded records at once. If the call fails, only the fields that still hold the provisional value are put back, so a newer change from the server or another screen is never erased. - The response is normalised into the store, so every screen that shows the record updates. The call resolves with the denormalised result.
- Invalidation. Every cached query whose path overlaps the mutation's path is marked stale, and refetched now if something is watching it. Two paths overlap when one is a prefix of the other, segment by segment.
- Events. The view's host fires
xo-donewith the result, andxo-noticewhen the button has axo-noticemessage (shown by<xo-toast>).
| Cached query | After POST /users/1/disable |
|---|---|
GET /users | refreshed (a prefix of the mutation's path) |
GET /users/1 | refreshed, unless the response was that very record: it was just rewritten, so it is marked fresh instead |
GET /users/1/posts | kept (neither path is a prefix of the other) |
GET /users/2 | kept (differs at a segment) |
The rule is by path, so it needs no declaration. When it is not enough, name the extra queries once: configure({ invalidate: { shipOrder: ["getDashboard"] } }). Those are refreshed whatever their path.
await client.mutate('updateUser', { id: 1 }, { name: 'Ada L.' }, {
optimistic: [{ $ref: 'User#1', patch: { name: 'Ada L.' } }],
});A deleted record is not removed from the store by a DELETE call: the lists that contained it refetch and no longer name it. Only a stream delete event removes a record at once.
Server-known data: seed
When the server already has the answer (it rendered the page), put it in the cache instead of asking again. The server writes the JSON into the page as <script type="application/json"> (not code, so a strict CSP allows it), one module script calls client.seed(op, params, data), and the data is stored like any response, entities included. A view that asks for the same operation and params shows it at once and makes no request until it is stale.
// main.js: the server wrote the answer into the page as <script type="application/json" id="seed-users">
const data = JSON.parse(document.getElementById('seed-users').textContent);
getClient().seed('listUsers', { limit: 10 }, data);
// a view with get="listUsers" p-limit="10" shows it at once and makes no request until it is staleLive updates
With streamUrl set and live on a view, the client opens one server-sent events connection, shared by all live views and closed when the last one goes away. It is loaded lazily, so an app without live views never pays for it. Each message is one JSON event, applied to the store, not to a view: every screen showing that record updates.
{ "type": "patch", "entity": "User", "id": "42", "path": "/email", "value": "a@b.c", "version": 17 }
{ "type": "upsert", "entity": "User", "id": "42", "value": { "id": 42, "name": "Ada" }, "version": 18 }
{ "type": "delete", "entity": "User", "id": "42", "version": 19 }
{ "type": "resync" }patchchanges one field, addressed by a JSON Pointer (/email); it is ignored if the record is not loaded.upsertmerges fields into the record, or creates it.deleteremoves it.versionmust rise per record. An event with a version at or below the last applied one is dropped, and a delete keeps its version so a late event cannot bring the record back.resyncforgets the versions and marks every cached query stale. So does a reconnect after an interruption: events may have been missed, so what is on screen is refetched. Replay byLast-Event-IDis the browser'sEventSourcedoing its job; the client adds no replay of its own.
When something fails
- A read fails (after the retries): the query's status is
error,$errorholds theXoongError, any earlier data stays, andonErrorruns.xo-refreshon a button fetches again. - A write fails: the optimistic patch is undone,
onErrorruns, and the view's host firesxo-errorwith the error.<xo-toast>listens for it. In a form,error.fieldsare put on the fields they name (see Forms flow). - A screen mistake (an unknown operation name in
get) is not an API failure: it is logged withconsole.errortogether with the closest valid name,xo-errorfires, and the rest of the screen keeps working.
Where to look in the code
packages/cli/src/openapi.js: the analysis behindsync: entities, response descriptors, request schemaspackages/data/src/client.js: request building, retries,XoongError, queries,mutate,seed,streampackages/data/src/invalidate.js: the path-overlap rulepackages/data/src/store.js: normalisation, the entity signals, stream events, optimistic updates, evictionpackages/data/src/stream.js, default.js: the SSE connection and its sharingpackages/data/src/view.js:<xo-view>, scope variables, the action handlersdocs/data-layer.md: the longer design notes (parts of it, such as per-viewstaleand the gateway, are not built)