yaks.app

The store, from a page

← Documentation

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 titles

Either 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.eid naming an existing entity patches it.
  • A $-prefixed eid is an alias local to this batch: it mints an entity, and saved.aliases.$cake reports which one. The name is yours — $cake, $1, $row-7.
  • No entity key at all mints one too, silently, and nothing in aliases points 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=open alone 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 on beforeunload, 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 with query instead.

  • 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. subscribe does 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. So query first for what you can draw now, then subscribe to 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 own worker.js runs ahead of that, and env.APP lets 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, null when signed out.
  • name — what to call them, null when signed out. A name, never an address.
  • roleowner, editor, viewer, or null.
  • 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. null once they are in. It is offered even on an open app, 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.json is 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_install gives 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.json and tools.json: they are never served to the web. app_files reads 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,8

store_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 forever

The 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.ndjson

The 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.