The store, from a page
Every app comes with a graph of its own and a client for reading and writing it
from the browser. This page is the whole of that client — what each function
accepts and returns, what you send when you save, what comes back when you read,
who may do either, and the HTTP endpoints underneath. The filter string itself —
what goes inside query('…') — has its own page:
Querying: the filter grammar.
The client, and every address relative
The platform serves one client beside every app, at ./api/client.js:
<script type="module">
import { apply, me, query, search, subscribe, upload }
from './api/client.js'
</script>Write that import as a relative path, and never write the app's own name into
any of the app's files. The code is copied when somebody installs your app, so
a page carrying /chores/api/client.js is a 404 the moment the copy lands at
/chore-chart/ — and it renders as bare HTML with nothing to explain why. The
platform gives every HTML page it serves a <base href> at the app's own
address (inside <head> if there is one, else after the doctype; a page with a
<base> of its own keeps it), which is what makes ./api/client.js and
./style.css resolve from any depth the page is opened at — a pretty path like
/recipes/42 included, where a relative URL would otherwise resolve against
/recipes/42/.
store(base) is the same six functions at an address you name. Every app in a
space shares one hostname, so a sibling app is a path — with or without its
trailing slash, and answering by its own access, whoever is asking:
import { store } from './api/client.js'
let lending = store('/lending/api/')
let loans = await lending.query('.loan!')The six functions
apply(bundles)
A bundle is one entity and the components you are writing on it. apply accepts
one bundle or an array of them (apply(b) or apply([b1, b2])) and POSTs them
to ./api/apply as {entities: [...]}. It returns:
{ ok: true,
changes: [ {eid, name, comp}, ... ],
aliases: { $cake: '4f3c…' } }changes is the flat JSON form of what landed, one entry per component written.
aliases maps each $alias you sent to the eid it minted. The whole array is
one batch — every bundle in one apply call, applied in one transaction — so if
any bundle is refused, nothing in that call is written.
If the app's store cannot apply a write right now (yaks.app itself is failing,
not your data), the write is kept and applied later, in the order it was sent.
apply then returns { ok: true, pending: true, changes: [], aliases: {} }.
Don't send the write again.
query(filter)
let recipes = await query('.recipe!&.doc?')A GET of ./api/query?<filter>, returning an array of rows — oldest first, in
the order they were written. An aggregate filter returns an object instead
(query('.doc!&.count!') → {count: 12}). The filter goes into the URL as you
wrote it, so a value carrying & or # needs encodeURIComponent around it;
# would otherwise start a fragment and take the rest of the filter with it.
search(text, filter?)
let lemony = await search('lemon')
let quick = await search('lemon', '.recipe!&.doc?')Full text over the app's doc rows — title and body, title weighted heavier —
in relevance order rather than creation order. The text is percent-encoded for
you and sent as a quoted phrase, so punctuation is safe to pass straight
through; a trailing * prefix-matches the last word (search('lem*')).
What a hit carries. A search term names no component, the way id= does
not, so a search with no filter returns the whole entity — every component the
row has. That is what lets a page draw cards from a search: the recipe's
minutes and serves are there, and a comment on a recipe can be told apart
from the recipe by the components it has.
Pass a filter and the ordinary rule is back — the answer is cut to the components the filter names, so name the ones you will draw:
await search('lemon', '.recipe!') // recipes, no titles
await search('lemon', '.recipe!&.doc?') // recipes with their titlesEither way a rank component rides along, which the store adds to the answer
only — never stored, never writable. rank.snip is a body snippet with each hit
wrapped between \x01 and \x02, and rank.title_hit is the title marked the
same way.
subscribe(filter, cb)
query that keeps returning. It hands back the stop function synchronously —
not a promise, so there is nothing to await. Its own section below.
upload(file, {name}?)
let file = await upload(input.files[0])
let file = await upload(blob, { name: 'cake.jpg' })Accepts a File off an <input type=file> or any Blob, POSTs the bytes to
./api/blob, and returns {eid, url, mime, bytes} plus w and h when the
bytes are a picture that declares its size (png, jpeg, gif, webp). mime is the
blob's own type; name defaults to file.name and is sent percent-encoded.
The bytes are stored under their SHA-256, so eid is that hash and the same
file twice is one upload and one row. 20 MB is the ceiling.
me()
{person, name, role, reads, writes, signIn}, returned to anyone — a signed-out
visitor at a private app included, which is the point. Below.
The bundle you save
An entity is a bundle: {entity: {eid}, ...components}. A component is a named
set of columns; the entity is whatever its components make it.
let saved = await apply({
entity: { eid: '$cake' },
doc: { title: 'Lemon cake', body: '3 lemons, 200g butter…' },
recipe: { serves: 8, minutes: 55 },
})entity.eidnaming an existing entity patches it.- A
$-prefixed eid is an alias local to this batch: it mints an entity, andsaved.aliases.$cakereports which one. The name is yours —$cake,$1,$row-7. - No
entitykey at all mints one too, silently, and nothing inaliasespoints at it. Use an alias whenever you need the eid afterwards. - An eid you minted yourself — a
crypto.randomUUID(), or a sha256 hex string — that names nothing yet and carries components defines that entity. - A new entity needs at least one component, and one bundle per entity per batch: the same eid twice is refused, so merge them into one bundle.
A $alias — or a whole nested bundle — stands in wherever an eid goes: in
entity.eid, in a column that references an entity, in an edge's child. An edge
is a link between two entities rather than a column, so edges are written under
edges:
await apply({
entity: { eid: '$note' },
doc: { body: 'needs a lemon' },
comment: { target: { entity: { eid: cake } } }, // or just `cake`
edges: [{ type: 'about', child: cake }],
})Both ends of an edge must exist, or be minted in the same batch. The type is
written the way the store declares it: the referencing edge is referenced,
never references — the latter is refused as unknown edge type: references.
Patching, clearing, deleting
Four different things, four different ways to write them:
// patch — send only what changes; every other column is left alone
await apply({ entity: { eid: cake }, recipe: { minutes: 45 } })
// clear one column — null on the column
await apply({ entity: { eid: cake }, recipe: { minutes: null } })
// drop the whole component — null instead of the object
await apply({ entity: { eid: cake }, recipe: null })
// delete the entity
await apply({ entity: { eid: cake }, tombstone: {} })A tombstone stands alone: it names an existing entity by eid and carries no
components and no edges (a dead entity takes no patch if it does), and it
needs an eid that already exists — there is nothing to delete behind a $alias.
Deleting is permanent, and it cascades to entities that exist only about the
deleted one.
A row you read can be handed straight back as a patch: the fields a read adds —
kind, rank, the stamps — are dropped on the way in, and a reference that
came back as {eid, name} writes as the eid it named.
Only one of you wins: $was
A batch is atomic, which tells you nothing about the read that came before it. Two tabs, a phone and a tab, a page and an agent — each reads 40 gold, each writes 50, and the second write is not wrong about anything except the world. That is the duplicate reward, and it is a read-modify-write with nothing holding the read.
So state what you based the write on. $was names, per component and per
column, the SHA-256 of the value you read, and the store refuses the whole batch
if that column has moved since. was() computes that hash, and is exported
beside apply:
import { apply, query, was } from './api/client.js'
let claim = async (eid) => {
let [me] = await query(`id=${eid}&.player!`)
// Already collected today: nothing to write, and nothing to race.
if (me.player.claimed == today()) return 'already claimed'
await apply({
entity: { eid },
player: { gold: me.player.gold + 10, claimed: today() },
// The guard: the day I read, as I read it. A second claim that read
// the same day loses here rather than paying out twice.
$was: { player: { claimed: await was(me.player.claimed) } },
})
return 'claimed'
}The refusal arrives as an ordinary throw, and its message names which column moved and what it holds now — so the page re-reads and decides again rather than clobbering a writer it never saw:
try { await claim(eid) } catch (e) {
if (/has moved since it was read/.test(e.message)) return claim(eid)
throw e
}Three things worth knowing. null is a guard too — "I read no value" — and it
is how a column that must still be empty is guarded, which is the shape of "mint
this once". Every column you name must be one the vocabulary declares, because a
guard on a column that does not exist would compare absent to absent and protect
nothing. And the whole batch is refused, never the part that moved: a title from
one writer and a body from another is the state this exists to make impossible.
Agents guard the same way, on the same column: graph_apply accepts $was
beside the components, and refuses with the same message.
What a row carries back
{ kind: 'recipe',
entity: { eid: '4f3c…' },
doc: { title: 'Lemon cake', body: '…' },
recipe: { serves: 8, minutes: 45 } }entity and kind name the row. Everything else is exactly the components the
filter named — by presence (.recipe!), by request (.doc?), or by a predicate
of its own (.recipe.minutes<=30). A component asserted absent (.archived=)
filters without asking for anything back. * asks for every component, which is
what you want when you are looking rather than drawing.
A column of yours that nothing has ever written is on the row with the value
null, not missing from it — so row.entry.mood is the test for "was this
written", never 'mood' in row.entry. The platform's own columns are no
exception: doc.title comes back null too, and doc.body — a content-addressed
blob — comes back null when there is none.
Three things a listing leaves out unless you name them: the platform's stamps
(created, updated, notified, opened, quarantined — .created! asks
for them back); the platform's own rows about the app (exception and error,
what the platform recorded when something broke — .exception! asks for those,
and asking for the stamps is not asking for these); and person rows, which
the store mints for whoever writes to it and query('.person!&.doc?') lists by
name.
subscribe in practice
let stop = subscribe('.task.status=open&.doc?', (rows) => draw(rows))
// …later
stop()What arrives is the whole row set, not a delta: on the first call, and again
after every committed write that touches the filter's answer, the callback is
handed the current rows sorted oldest first — the same shape and the same
components query() returns for that same filter. So redraw the list from what
arrives; never append to what you drew last time.
Ask for what you will draw. A subscription's rows carry the components its filter names, exactly like a query, so
.task.status=openalone gives you no titles to paint.The write can come from anywhere: this tab, another tab, the person's phone, their agent through an MCP tool call. One socket per store, opened on the first subscription and shared by every subscription after it, reconnecting on its own and re-declaring every subscription on open — no catch-up to write.
A row that is deleted, or that leaves the filter's answer, drops out of the set the callback gets. Nothing else changes.
stop()removes that one subscription; the last one to leave closes the socket. Call it onbeforeunload, or when the view it feeds is torn down.Subscribe to rows. An aggregate filter like
.count!has no rows to hand back, so the callback keeps being handed an empty array. Poll it withqueryinstead.Keep the count small. A socket carries its declarations in about 2 KB of state so it survives hibernation, and past that a declaration is refused — quietly, from the page's side.
A filter the store cannot serve fails on the socket, not in your code: it throws where the page's error reporter picks it up, rather than rejecting a promise you can catch. Try the filter through
query()first.A socket that will not open reports nothing at all.
subscribedoes not throw and has no promise to reject, so a page that only subscribes shows an empty screen for as long as the socket is down — no error, no callback. It retries on its own, backing off to every 15 seconds, and the first frame that arrives fills the page. Soqueryfirst for what you can draw now, thensubscribeto keep it true; the first callback replaces the rows you drew.draw(await query('.task.status=open&.doc?')) let stop = subscribe('.task.status=open&.doc?', draw)
Who may read, who may write
An app's access is one of three settings, given by app_new and app_set:
public— the default. Anyone with the link reads. Only a member (owner or editor) writes.open— anyone with the link reads and writes. The vote page, the shared list, the party wall.private— members only, both halves. The pages are hidden too, not just the data: a stranger asking for/diary/is sent to sign in, and someone signed in who is not a member gets the same answer a wrong address gets. The app's ownworker.jsruns ahead of that, andenv.APPlets it write as the app — a private app with a worker is one whose gatekeeper is its own code.
A viewer reads a private app and never writes it. Writing the app's own files
(PUT ./api/files/<path>) is outside this bargain: those bytes are always an
owner's or an editor's to change, whatever the app lets its visitors save.
Ask on load, not on refusal:
let who = await me()
if (!who.writes) show(`<a href="${who.signIn}">Sign in to post</a>`)
else if (!who.person) show('<input name="who" placeholder="Your name">')Both halves matter, and they are different people. On a public app a guest who
types first is bounced to sign in and comes back to an empty form. On an open
app the guest writes fine — but they are nobody the platform knows, so their
rows have no created.by at all. If that page wants a byline, it has to ask for
a name and save it in a column of its own.
What me() returns:
person— their eid,nullwhen signed out.name— what to call them,nullwhen signed out. A name, never an address.role—owner,editor,viewer, ornull.reads/writes— this app's access, already worked out for this caller.signIn— the platform login page, already carrying this page as its return address.nullonce they are in. It is offered even on anopenapp, where signing in is not the way through but a named guest may still be wanted.
The byline
The store stamps every row with who saved it, and a stamp is a component like any other: it comes back when the filter names it.
for (let e of await query('.doc!&.created!')) draw(e, e.created.by?.name)created.at is when. created.by is who — and where this store knows the
person, it returns {eid, name} rather than a bare eid, so one query draws a
list with its writers instead of painting "someone" and asking again.
This is a rule about references, not about that one stamp: any column that points at an entity comes back with the name when the store knows that entity as a person, a column of your own included.
The name is the one they chose at sign-in, or the front of their address if they
skipped the question. An address is never in the answer: an app's store learns
names and keeps no address book, so a public app answering .person! to a
stranger hands out no roster. Anything the store cannot name stays the bare eid
it always was — and a write still accepts that eid, so a row read and handed
straight back means the entity it named:
let [entry] = await query('.doc.title~=Fig&.created!')
await apply({
entity: { eid: entry.entity.eid },
task: {}, filed: { assignee: entry.created.by }, // {eid, name} writes as the eid
})A guest on an open app has nothing to name. Their created.by is null, which
is exactly what me() told the page before they typed.
The data it comes with
A store can start with rows in it. Write a seed.json beside index.html — a
JSON list of the same bundles apply accepts — and the first app_deploy
writes them into the app's store:
[ {"entity": {"eid": "$soup"}, "doc": {"title": "Lentil soup"},
"recipe": {"serves": 4}},
{"entity": {"eid": "$note"}, "doc": {"body": "double the cumin"},
"comment": {"target": "$soup"}} ]A seed.yml is the same list written as YAML, and is read the same way:
- entity: {eid: $soup}
doc: {title: Lentil soup}
recipe: {serves: 4}When there is a lot of it, write a seed/ folder of *.json (or *.yml) files
instead — seed/01-places.json, seed/02-menu.json — and upload them a call at
a time. All of them are one batch, read in filename order, so an alias minted
in one file resolves in the next and the pieces can point at each other. Either
layout works, and a seed.json with a seed/ folder beside it is still that
one batch, the file first.
Four things to know:
- It runs once per store, after the app's own
vocab.jsonis installed — so a seed may write components of your own — and it is marked as done. Deploy again and nothing is seeded: what the person has changed since is theirs. app_installgives the copy its own store, so the seed runs again there. That is how a published app arrives furnished in somebody else's space.- A bundle the store refuses refuses the whole deploy, and nothing is written.
The refusal names the file and the entry, then explains what was wrong:
seed/02-menu.json[7] was refused: unknown column: recipe.serving. A file that is not JSON names itself the same way. - The seed files are part of the app's inside, like
vocab.jsonandtools.json: they are never served to the web.app_filesreads them back.
Give a row a name and loading it twice is safe:
{"entity": {"eid": "$soup"}, "alias": {"name": "recipe:lentil-soup"},
"doc": {"title": "Lentil soup"}, "recipe": {"serves": 4}}A row carrying alias{name} lands on the entity that already holds that name
instead of writing a second one — so the same seed loaded again is a patch, not
a duplicate. The name works wherever an eid does, too: in a column that
references an entity, in id=, and in graph_show. Where a value could be read
as either, the eid wins.
For data the person is meant to edit, that is all there is to it. For a table of
constants your page reads — an emoji list, a lookup — a plain .js file beside
the page is simpler, and it is not data anyone can change.
Data that arrives later is store_load, which reads the same kind of file on
purpose: store_load(app, path) writes one JSON file already in the app —
data/cities.json — or every *.json and *.csv under a folder you name, into
the store now, as you. It is one batch, in filename order, aliases resolving
across the files, and a refusal names the file and the entry exactly as a seed's
does; unlike a seed there is no once-only mark, so calling it again loads the
file again — which is safe when the rows carry alias{name}, since a named row
lands on the entity that already holds the name and the second load is a patch
rather than a pile of copies. It applies whatever the file contains — a bundle
carrying tombstone: {} deletes that entity, and the store decides whether you
may. That makes an import two calls and nothing transcribed:
app_files(op: 'fetch') writes the bytes of a public dataset into the app, and
store_load puts them in the store, reporting the files it read and how many
entities it wrote.
Most data a person already has is a spreadsheet, and a .csv is the same call
with one more argument: a spreadsheet does not state what a row is, so as
does. Each row becomes one entity with that component, and the header row names
its columns.
id,name,serves
lentil,Lentil soup,4
fig,Fig tart,8store_load(app, path: 'data/menu.csv', as: 'recipe') writes those two as
recipe{name, serves}, each value coerced to the type vocab.json declares for
the column — serves is a number, a bool accepts true/yes/1 either way round,
and an empty cell is left unwritten rather than written null. A title or
body header lands in the row's doc; an id (or alias) column is the row's
name — alias{name}, which lands on the entity already holding it, so loading
the file again patches the same rows instead of minting a second set of them,
and the name works wherever an eid does. Leave it out and every load mints new
rows. A header the component has no column for is refused by name: rename it
with map {"Serves how many": "serves"}, or declare the column in vocab.json.
A cell that will not coerce is refused naming the row and the header, and the
whole file is one batch, exactly as a JSON load is.
The HTTP endpoints underneath
client.js is a wrapper over ordinary same-origin HTTP. Call these endpoints
directly from curl, from another page, or from your own worker.js through
env.STORE.
POST ./api/apply
content-type: application/json
{"entities": [ {"entity": {"eid": "$r"}, "doc": {"title": "Lemon cake"}} ]}
→ {"ok": true, "changes": [...], "aliases": {"$r": "4f3c…"}}
POST ./api/apply
content-type: application/x-ndjson
{"entity": {"eid": "$1"}, "doc": {"title": "Lemon cake"}}
{"entity": {"eid": "$2"}, "doc": {"title": "Fig tart"}}
→ {"entity": {"eid": "4f3c…"}, "doc": {"title": "Lemon cake"}}
{"entity": {"eid": "8b91…"}, "doc": {"title": "Fig tart"}}
GET ./api/query?.doc!
→ [ {"kind": "doc", "entity": {"eid": "4f3c…"},
"doc": {"title": "Lemon cake"}} ]
GET ./api/query?.doc!&.count!
→ {"count": 12}
GET ./api/me
→ {"person": null, "name": null, "role": null, "reads": true,
"writes": false, "signIn": "https://yaks.fyi/login?return=…"}
POST ./api/blob
content-type: image/jpeg ← the file's own type
x-yak-name: cake.jpg ← optional, percent-encoded
<the bytes>
→ {"eid": "9f2a…", "url": "/photos/api/blob/9f2a…",
"mime": "image/jpeg", "bytes": 51234, "w": 1600, "h": 1200}
GET ./api/blob/<eid>
→ the bytes, with that mime, cached foreverThe blob endpoint accepts a 64-character lowercase hex address and nothing else;
anything else is no_such_file. Bytes come back with the mime type and the
filename from their attachment row, nosniff, and a sandbox CSP, so an
uploaded page or SVG stays inert when someone opens it in a tab.
Loading a lot at once. A file too big for one apply goes to the same
endpoint as NDJSON — one bundle per line, blank lines skipped — and is applied
50 lines at a time, so neither the parse nor the transaction is ever the whole
file:
curl -X POST https://ada.yaks.fyi/cookbook/api/apply \
-H 'content-type: application/x-ndjson' \
--data-binary @rows.ndjsonThe answer is NDJSON too — one saved row per line, as each fifty commit — and it
is a 200 whatever happens, because the first rows are sent long before a later
line can be refused. So a refusal is the last line instead:
{"error": "Refused", "message": "unknown column: recipe.serving", "line": 137, "committed": 100}
— the line the bad bundle was on, and how many landed before it. Nothing after
that line is read, and the fifty it was in rolled back whole. One thing to
watch: an alias resolves inside its own run of 50 lines and nowhere else, so
write $cake and the row pointing at it near each other.
Each endpoint is governed by the app's access — its read rule for query,
blob/<eid> and the live socket, its write rule for apply and blob, and
me answered to everyone. The whole set, as the 404 itself lists, is apply,
query, me, graph, ws, blob, and files/<path>; a request for anything under
/api/ is the platform's, never your worker's.
What a refusal says
Two shapes reach the page, and the client turns both into a thrown Error.
A refusal from the platform's own endpoints is JSON, and the client throws the message alone:
{"error": {"code": "not_a_writer", "message": "sign in to change this app"}}
try { await apply(bundle) }
catch (e) { e.signIn ? location = e.signIn : show(e.message) }e.signIn is set only when signing in is the way through, and it already holds
this page as its return address. The codes from these endpoints: not_a_reader,
not_a_writer (401 to a stranger, 403 to a member who may not), too_large,
no_bytes, space_full, no_such_file, method_not_allowed, not_found.
A refusal from the store — an unknown component, a column that does not exist, a bundle that does not parse — is plain text with a 400, so the message the client throws is prefixed with the status and cut to 120 characters:
400 unknown column: doc.name — doc {title: text, body: text}
400 unknown component: recipy — a component of your own is declared…Both messages are written to be read: a bad column names the columns that do exist, and an undeclared component explains where one of your own comes from.
You need not wire any of this up to be told about it. The platform puts an error reporter in every page it serves, so a throw, an unhandled rejection, or a failed request is already on its way to the app's store and the person's agent. Catch what you want to show.
The mistakes
Asking for the wrong components. A row carries only the components its
filter named. query('.recipe!') returns recipes with no titles, and a page
drawing row.doc.title prints undefined for every one of them. Ask for the
title beside it: query('.recipe!&.doc?'). &.doc? is the way to ask for a
second component — .recipe.doc addresses a column of recipe, a different
question and one recipe has no answer to. subscribe returns the same
components a query does, so it is the same mistake there.
Reaching for localStorage. State a page keeps in the browser is invisible to
the person's other device, to anyone else looking at the same page, and to the
person's agent — and gone when they clear their browser. It is not a lighter
store; it is a store nobody else can see. Save a row instead. (In an app
installed from somebody else's release, which runs sandboxed, localStorage is
the platform's: each signed-in person's keys are kept in the app's store rather
than the browser: Publishing and installing an app.)
Writing the app's own name into the app. /chores/api/client.js,
/chores/style.css, fetch('/chores/api/query?…') — every one breaks the
moment somebody installs a copy at another address. Write ./api/client.js and
./style.css. The one address that legitimately names another app is a sibling
store you meant to read: store('/lending/api/'). An installed copy runs
sandboxed, and reads a sibling only as a stranger would.
Saving the same thing twice. Content addressing makes the same bytes one
blob and one attachment row — but a row of your own pointing at them is
still a second row, and the wall shows the photo twice. Look first:
let [seen] = await query(`.photo.blob=${file.eid}`)
if (!seen) await apply({ photo: { caption, blob: file.eid } })Finding out who is looking from a refusal. By then the guest has typed and
their work is gone. me() on load, every time.
The whole guide is at The guide; the filter string has its own page at Querying: the filter grammar.
