yaks.app

One entity, two apps

← Documentation

Two of the person's apps can write about the same thing without either one copying the other. This page is how: which app a component lives in, what a deploy reports when a component already has a home, what happens across spaces, how a page reads a sibling app, and how graph_query composes one bundle out of several stores.

The eid is the thing

An eid is a uuid the client mints, and it means the same entity in every store on the platform. Nothing registers it, nothing hands it out, nothing has to agree. A reading list saves the book; a lending app, on the same eid, saves who has it. They are one entity with two components, one per store — no copy, no sync, nothing to keep in step.

let piranesi = crypto.randomUUID()

graph_apply { app: 'reading-list', entities: [
  { entity: { eid: piranesi }, doc: { title: 'Piranesi' },
    book: { pages: 245 } } ] }

graph_apply { app: 'lending', entities: [
  { entity: { eid: piranesi }, loan: { to: 'Maya' } } ] }

The eid is the whole address. An app's store mints no number beside it — a number is a store's own counter, and would say #3 in one app and #17 in another for the same entity — so a thing has one name everywhere.

Which app a component lives in

A component lives with the app that declares it, so nothing has to be negotiated. book is the reading list's component wherever it is written; loan is the lending app's. A write is split up by component and each part is sent to the app that owns that component.

The platform's shared components — doc, comment, task, image, archived — belong to no app, so they go, in this order:

  1. the app the bundle names, where it sets "$app": "<slug>";
  2. the app where that entity already has that component;
  3. the app whose own component is in the same bundle — a title beside a recipe is the recipe's title, which is what makes writing a new entity one call.

$app is set on the bundle rather than on the call, because the bundles in one write may land in several apps at once — it is the bundle that goes to one app, not the whole write. When none of the three decides it, the write is refused rather than guessed:

which app should doc go in? say $app on the bundle — reading-list, lending

Every part is checked in its own store before any of them commits, so a refusal in one leaves the others unwritten. A $alias is minted once, when the write arrives, so a bundle landing in two stores lands under one eid.

graph_apply { entities: [
  { entity: { eid: piranesi },
    doc: { title: 'Piranesi (2020)' },   → reading-list, where it lives
    loan: { to: 'Bo' } } ] }             → lending, whose component it is

One component, one home

Declare book in a second app of the same space and nothing is planted twice. The first app in the space to declare a component is its home; a later manifest naming it is a use, not a second declaration. The deploy reports that:

book lives in reading-list; this app reads and writes it there
components: loan

book is missing from components: on purpose — the lending app homes only loan. Its store never plants a book table, so asking that store for one is a refusal, and the rows are all in one place:

graph_query { app: 'lending', filter: '.book!' }
→ unknown prop: .book

graph_query { app: 'reading-list', filter: '.book!' }
→ both books, however they were written

A column the borrower adds grows the home's table, additively, and is then writable from either app:

lending/vocab.json: { "$defs": {
  "book": { "properties": {
    "title": { "type": "string" },
    "isbn":  { "type": "string" } } },
  "loan": { "properties": {
    "to": { "type": "string" } } } } }
→ added: book.isbn

From an agent's tools and from an app's own commands, a borrowed component just works: graph_apply { app: 'lending', entities: [{ book: … }] } lands in the reading list's store, and a tools.json entry of the lending app may name book in its apply or its query. From a page it does not: ./api/apply and ./api/query are this app's own HTTP endpoints onto its own store, so a page that writes a borrowed component gets unknown component: book. Reach the home app by its address instead — store('/reading-list/api/'), below.

The one refusal: a shape conflict

Declaring a component another app homes is refused for exactly one reason — the same column with two types. The rows already written under the home's type are the record of what that column is, and no manifest may rewrite them.

vocab.json: book.pages is text in this app and number in reading-list,
  where book lives — a column keeps the type its rows were written under

The whole manifest is read before anything is planted, so that deploy moves nothing at all: not the home's column, not this app's own components, not its files.

Across spaces, a component means what its space declares

Within one space a component has one home. Across spaces there is no home to share, so the same name may describe two different shapes — and the answer tells you which.

Where the shapes agree, the name means one component and the bundle is one. A column only one side declares agrees by construction, since a vocabulary only ever grows.

Where they disagreenote.body is text in one space and number in another — the rows stay apart. The same eid comes back as two bundles, each naming the space it is answering for:

graph_query { filter: '.note!' }
→ [ { kind: 'note', space: 'shelf', entity: {…}, note: { body: 'lovely' } },
    { kind: 'note', space: 'stall', entity: {…}, note: { body: 3 } } ]

A write has no such option — one bundle cannot land in two meanings — so it asks you:

note means two things — shelf and stall declare it differently;
  name the app this goes in

And what is out of reach is simply absent. Another person's private app can hold a component on the very eid you are reading, and it never appears on your bundle: reach is the apps you may read, and nothing else.

Reading a sibling app from a page

client.js exports store(base), which is the same six functions pointed at an address you name:

import { query, store } from './api/client.js'

let lending = store('/lending/api/')
let loans = await lending.query('.loan!&.doc?')

Every app in a space shares one hostname, so the address is a path, not a URL — /lending/api/. It resolves against the page's own origin, and the trailing slash is optional (the endpoints are under it either way). Your own app is a path like any other, so store('/reading/api/') is the same store as the bare query you imported.

These are that app's own endpoints, so that app's access decides: a private sibling answers its members only, whoever is asking, and a public one accepts writes from a member and reads from anyone with the link. Nothing about being a neighbour grants anything.

graph_query with no app named

Name an app and you get that app's own answer, untouched. Leave app out and the question is asked of every app in reach — every app the person may read, in every space they belong to — and answered as one bundle per entity.

graph_query { filter: '.book!&.loan?' }
→ [ { kind: 'book', entity: { eid: '…' },
      book: { pages: 245 }, loan: { to: 'Maya' },
      _stores: { book: 'yourname/reading-list', loan: 'yourname/lending' } } ]
  • ! names which entities the answer is about; ? asks for a component beside them without filtering on it. So .book!&.loan? is every book, with its loan where it has one, and .book!&.loan! is only the books that are out — & is an intersection across apps exactly as within one.
  • _stores names which app holds which component. It appears only on a bundle that actually spans two apps, which is where you need it: to write one component back, you need to know whose it is.
  • kind is one of the app's own components, never a platform one — and when a row carries components from two apps, the one the filter required wins, so .book!&.loan? and .loan?&.book! both return books.
  • .count! counts entities, not rows: summing each store's own count would count a spanning entity twice.
  • limit= bounds each part before the parts are combined, so a mixed filter's window is the newest of each side, then the newest of what they had in common.
  • .distinct and .tally read one app at a time, and refuse when no app is named: name one with app, or ask for the rows and reduce them yourself.
  • A component nobody in reach declared is a refusal, not an empty answer: unknown prop: .sandwich.
  • search with no app named merges every app's ranked hits, best first.

A pair, end to end

Two apps, one shelf of books.

reading-list/vocab.json
{ "$defs": {
    "book": { "properties": {
      "author": { "type": "string" },
      "pages":  { "type": "number" } } } } }

lending/vocab.json
{ "$defs": {
    "book": { "properties": {
      "author": { "type": "string" } } },
    "loan": { "properties": {
      "to":  { "type": "string" },
      "due": { "type": "string", "format": "date-time" } } } } }

The lending deploy reports:

book lives in reading-list; this app reads and writes it there
components: loan

Now one call writes both halves:

graph_apply { entities: [
  { entity: { eid: '$b' },
    doc: { title: 'Solenoid' },
    book: { author: 'Mircea Cărtărescu', pages: 672 },
    loan: { to: 'Maya', due: '2026-10-01T00:00:00Z' } } ] }

doc and book land in the reading list; loan lands in lending; the alias is minted once, so both are the same entity. Each app's own page draws its own half — the reading list's query('.book!&.doc?') never mentions loans — and either page can borrow the other's view when it wants it:

let out = await store('/lending/api/').query('.loan!')
let due = new Map(out.map((r) => [r.entity.eid, r.loan.due]))

for (let b of await query('.book!&.doc?')) {
  draw(b.doc.title, due.get(b.entity.eid))
}

And the person's agent sees the whole thing at once, without naming an app:

graph_query { filter: '.book!&.loan!&.doc?' }
→ every book that is out, with its title and who has it

Neither app knows the other's schema. Delete the lending app and the books are untouched; delete a book and its loan goes with the entity.

The whole guide is at The guide; the components themselves, and vocab.json, are Components: the platform's, and your own, and the filter grammar in full is Querying: the filter grammar.