This repository has no description
ziran docs STORE.md
6.0 kB
Markdown
at main

The Ziran Store & the doc = space model #

As shipped 2026-09-05. Companion to docs/SPACES.md (sync) and docs/WEB-APP.md.

Doc = space #

Every document is its own space (space.ziran.space), named after the document, holding exactly one space.ziran.doc record (plus checkpoint, ops, presence). Consequences:

  • The Docs list is "all doc-spaces I can see" (via listSpaces + polling), plus any legacy docs still in the home space.
  • Inviting someone to a document IS inviting them to its space — invitations say the document's name; there is no separate space to explain.
  • Deleting a document you own deletes its space (deleteSpace); the provider's poll loop forgets not-found spaces instead of erroring.
  • "Go live" on a desktop/home doc creates the doc's own space with no picker (share() defaults the space name to the doc name).
  • Multi-doc spaces remain readable (legacy rows render per-doc); they will return as a first-class concept later.

The home space remains as the self space: install records (space.ziran.install) and legacy docs. Browser tabs still hold nothing durable.

Store data model #

Publishing writes to the publisher's ordinary AT repo (public, indexable, no space credential to read):

  • Collection space.ziran.tile, rkey = the store name (slug).
  • Record: { $type, name, title, summary?, kind: 'model'|'app', tile, cid, prev?, createdAt } where tile is the manifest with every resource uploaded as a repo blob and src replaced by a blob ref (checkpoint shape — see src/sync/spaces/checkpoint.ts), and cid is the DRISL CID of that manifest. prev chains to the version this publish replaced.
  • kind is model iff the manifest has a model field; otherwise app.
  • Publication stamps the identity into the manifest: id (and model.id) become <did>/<name>; rendered everywhere as @handle/<name>.
  • Screenshots: manifest screenshots: [{ src }] entries (tile resources) surface on the store card.
  • Reads are unauthenticated XRPC (com.atproto.repo.listRecords/getRecord, com.atproto.sync.getBlob) against the publisher's PDS.
  • Install = a space.ziran.install record in the installer's home space (publisher DID, name, kind, title, icon CID, pds, order) — the sidebar reads it on any device.
  • Instantiating fetches every blob, verifies CIDs, and assembles with the normal tile writer (src/store/store.ts).

OAuth grants (permission set v-current, SCOPE_VERSION 8): home space gains space.ziran.install; a repo permission grants create/update/delete on space.ziran.tile. After changing lexicons/space/ziran/permissions.json run deno task lex:publish and sign in again everywhere.

The examples are published under the Ziran (lexicon-authority) account: deno task store:publish-examples (same credentials as lex:publish). The web app's featured shelf lists that account (FEATURED_PUBLISHERS in src/web/webbackend.ts).

Root UI #

Three tabs + an always-present right sidebar (src/components/):

  • Docs (zn-docs-table) — sortable, filterable table: icon, name, creator, contributors, created, updated, liveness, context menu.
  • Store (zn-store) — cards; routes ride the fragment: #view=store, …&pub=<did|@handle> (publisher profile + their tiles), …&tile=<name> (detail with screenshots + Install).
  • Tools (zn-tools) — Publish (working: pick a .tile, name it, publish under your identity), Dev mode and Import (skeletal).
  • Sidebar (zn-sidebar) — accordion of Models and Apps: installed store tiles (+ built-in starters during the transition), click to open/instantiate, Edit mode to reorder/remove, store links when empty.
  • Search — the header field searches everything and shows categorised results (Documents / Models & apps / Store) in place of the active tab.

The index #

index.ziran.space (deploy/index, deno task deploy:index) answers "what is published, network-wide": a poll-based crawl of the PDS — where every ziran account lives during the alpha — diffing com.atproto.sync.listRepos revs (paginated) and re-reading space.ziran.tile only for changed repos, every 60s. Memory-only state (a restart re-crawls in seconds); public data only. API: GET /tiles?q=&publisher=&kind=, GET /healthz; CORS open. The crawl/query core is src/store/indexer.ts, tested in the suite and staged verbatim into the service (deno task stage:index). The web app's shelf and search use the index and fall back to the featured publishers' PDSes when it doesn't answer; publisher and detail pages always read the publisher's PDS directly (canonical). When accounts spread across PDSes, the growth path is feeding this same core from a firehose consumer (Tap or kin) instead of the poll.

Timestamps come from the records themselves: every ziran record carries createdAt (ops included), presence carries at; a doc's "last update" is the freshest datestamp its space's records show. Nothing infers dates from TIDs.

The CLI #

ziran (src/cli/ziran.ts) — atile's sibling for the store. In-repo: deno task ziran <command>; anywhere: deno install -gAf --name ziran <repo>/src/cli/ziran.ts.

  • ziran publish <file.tile> [--name slug] — publish under your identity (slug defaults to the file stem); re-publishing a name chains versions.
  • ziran unpublish <name> — retract.
  • ziran list [publisher] — what a publisher offers (default: you).

Identity: --as/--password/--pds flags, then ZIRAN_HANDLE/ZIRAN_PASSWORD/ ZIRAN_PDS, then STORE_PUBLISHER_*, then .env.store-publisher (cwd or repo root). An app password works; the PDS resolves from the handle. More commands land here as they exist.

Tests: src/store/store.test.ts (publish → list → instantiate → version chain), src/store/indexer.test.ts (crawl → incremental re-crawl → query), src/cli/cli.test.ts (the CLI as a real subprocess: publish/list/version/ unpublish), scopes.test.ts (grants), plus the provider/webhost suites.