---
doc:
  title: One entity, two apps
guide:
  slug: entities
  brief: one entity across two apps
  description: >-
    Two of the person's apps writing about the same entity without copying
    it: which app a component lives in, how a page reads a sibling app, and
    how graph_query composes one bundle out of several.
---

# One entity, two apps

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 disagree** — `note.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 <https://yaks.fyi/docs.md>; the components themselves, and
`vocab.json`, are <https://yaks.fyi/docs/components.md>, and the filter grammar
in full is <https://yaks.fyi/docs/querying.md>.
