diff --git a/deno.json b/deno.json
index bf2f717..a40bc13 100644
--- a/deno.json
+++ b/deno.json
@@ -16,9 +16,11 @@
"build:icons": "deno run -A tools/make-icons.ts",
"dev": "deno task build:ui && deno run -A --watch main.ts",
"relay": "deno run -A relay/main.ts",
- "test:spaces": "deno run -A src/sync/spaces/client.test.ts",
+ "test:spaces": "deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/provider.test.ts",
"devnet:spaces": "bash tools/spaces-devnet.sh",
- "test:sync": "deno run -A src/sync/spaces/client.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts",
+ "lex:publish": "deno run -A tools/spaces-publish-lexicons.ts",
+ "test:tiles": "deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts",
+ "test:sync": "deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts",
"desktop": "deno task build:ui && deno desktop --hmr -A --include static --include vendor --include tiles --include build main.ts",
"build": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --output dist/Ziran.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran.app",
"build:mac": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --target aarch64-apple-darwin --output dist/Ziran-arm64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-arm64.app",
@@ -79,6 +81,7 @@
"@atproto/oauth-client-node": "npm:@atproto/oauth-client-node@^0.5.2",
"@atproto/api": "npm:@atproto/api@^0.17.0",
"yjs": "npm:yjs@^13.6.27",
+ "y-prosemirror": "npm:y-prosemirror@^1.3.7",
"prosemirror-state": "npm:prosemirror-state@^1.4.3",
"prosemirror-view": "npm:prosemirror-view@^1.34.0",
"prosemirror-model": "npm:prosemirror-model@^1.22.0",
diff --git a/deno.lock b/deno.lock
index 5ba0286..21fe407 100644
--- a/deno.lock
+++ b/deno.lock
@@ -2,6 +2,7 @@
"version": "5",
"specifiers": {
"jsr:@std/internal@^1.0.14": "1.0.14",
+ "jsr:@std/path@1": "1.1.6",
"jsr:@std/path@^1.1.0": "1.1.6",
"npm:@atcute/car@^6.0.2": "6.0.2_@atcute+cbor@2.3.5__@atcute+cid@2.4.2_@atcute+cid@2.4.2",
"npm:@atcute/cbor@^2.3.5": "2.3.5_@atcute+cid@2.4.2",
@@ -40,6 +41,7 @@
"npm:prosemirror-transform@^1.9.0": "1.12.0",
"npm:prosemirror-view@^1.34.0": "1.42.2",
"npm:ts-mls@^1.6.2": "1.6.2_@noble+curves@2.3.0",
+ "npm:y-prosemirror@^1.3.7": "1.3.7_prosemirror-model@1.25.11_prosemirror-state@1.4.4_prosemirror-view@1.42.2_y-protocols@1.0.7__yjs@13.6.32_yjs@13.6.32",
"npm:yjs@^13.6.27": "13.6.32"
},
"jsr": {
@@ -1318,6 +1320,24 @@
"wrappy@1.0.2": {
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
+ "y-prosemirror@1.3.7_prosemirror-model@1.25.11_prosemirror-state@1.4.4_prosemirror-view@1.42.2_y-protocols@1.0.7__yjs@13.6.32_yjs@13.6.32": {
+ "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
+ "dependencies": [
+ "lib0",
+ "prosemirror-model",
+ "prosemirror-state",
+ "prosemirror-view",
+ "y-protocols",
+ "yjs"
+ ]
+ },
+ "y-protocols@1.0.7_yjs@13.6.32": {
+ "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
+ "dependencies": [
+ "lib0",
+ "yjs"
+ ]
+ },
"yjs@13.6.32": {
"integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==",
"dependencies": [
@@ -1364,6 +1384,7 @@
"npm:prosemirror-transform@^1.9.0",
"npm:prosemirror-view@^1.34.0",
"npm:ts-mls@^1.6.2",
+ "npm:y-prosemirror@^1.3.7",
"npm:yjs@^13.6.27"
]
}
diff --git a/docs/SYNC-DESIGN.md b/docs/SYNC-DESIGN.md
index afd4913..3455c0e 100644
--- a/docs/SYNC-DESIGN.md
+++ b/docs/SYNC-DESIGN.md
@@ -3,6 +3,11 @@
Decisions confirmed by Robin, 2026-08-06. Chosen direction: **Option C**
(relay substrate + MLS E2EE). See SYNC-OPTIONS.md for the research behind it.
+> **Status (2026-08-25):** this stack is built, tested, and **parked** behind
+> `ZIRAN_SYNC=relay`. The shipping provider is AT Proto Spaces
+> (SYNC-SPACES.md) — no E2EE, credible exit. This design remains the E2EE
+> return path once Spaces has taught us what the needs really are.
+
## Confirmed decisions
- **Relay**: one relay at `ziran.space`, single-tenant. Federation and
diff --git a/docs/SYNC-SPACES.md b/docs/SYNC-SPACES.md
index 3a2b40e..302422d 100644
--- a/docs/SYNC-SPACES.md
+++ b/docs/SYNC-SPACES.md
@@ -262,14 +262,135 @@ documents survive Ziran-the-service entirely.
`src/sync/spaces/fake.ts` (in-memory network: PLC + PDS + space host),
`src/sync/spaces/client.test.ts` — 22 offline checks green
(`deno task test:spaces`, also first in `test:sync`).
-- **D. Lexicons + record schemas** — `space.ziran.*` set, publish script,
- `_lexicon` DNS TXT.
-- **E. SpacesProvider** — provider seam extraction, registry v2, go-live
- space picker, manifest `at` field + stripping, lazy checkpoints, causal
- delivery, polling scheduler.
-- **F. Invites + UI** — inbox space auto-create, invite listening/accept,
- Documents column with location grouping, per-space membership UI.
-- **G. Tiles** — verify checklist commutativity, tictactoe parent discipline,
- richtext → y-prosemirror port, presence records.
-- **H. Docs + cross-device verification** on Robin's machines; flip default
- provider; record follow-ups for the post-alpha protocol.
+- **D. Lexicons + record schemas — DONE (2026-08-25)** except DNS.
+ `lexicons/space/ziran/*.json`: the `space.ziran.workspace` + `space.ziran.inbox`
+ space types and the info/doc/op/checkpoint/presence/invite records.
+ Published as `com.atproto.lexicon.schema` records (rkey = NSID, bulletin's
+ pattern; the spaces-alpha PDS validates the `space` def type natively) in
+ the authority repo `lexicons.pds.ziran.space`
+ (`did:plc:axdexdy7p7yvuunn2mvuvy32`) via `deno task lex:publish` —
+ idempotent (canonical-digest upsert); credentials in the gitignored
+ `.env.lex-authority` (recoverable via the PDS admin API if lost).
+ **Remaining Robin action:** DNS TXT `_lexicon.ziran.space` →
+ `"did=did:plc:axdexdy7p7yvuunn2mvuvy32"` (the publish script reports its
+ status). Clients keep `validate:false` regardless (alpha PDSes don't
+ resolve space-type NSIDs at createSpace time).
+- **E. SpacesProvider — DONE (2026-08-25).** The `CollabProvider` seam
+ (`src/sync/provider.ts`) now sits above both stacks: `RelayProvider`
+ (`src/server/sync.ts`, the whole relay+MLS stack, still the default) and
+ `SpacesProvider` (`src/sync/spaces/provider.ts`, behind `ZIRAN_SYNC=spaces`
+ until Phase H flips it). Landed: registry v2 (`sync/spaces.json`); causal
+ delivery (`causal.ts` — settled-set parent predicate, NOT a per-author
+ rkey watermark: one DID is many devices writing one repo, so rkeys are not
+ monotonic per author and only revs order a repo; dedup is per-doc rev
+ watermarks + an own-echo list); atile-style checkpoints (`checkpoint.ts`,
+ go-live mandatory + ~500-op/512KB threshold at most 1/hr + explicit
+ publish via `/api/docs/:id/sync/publish`); adoption (newest checkpoint →
+ verified blobs → tile writer → cursor-rewind replay); op batching via
+ `space.applyWrites` with client-assigned TIDs so intra-batch parent chains
+ are correct as written; polling (hot 2s / warm 30s, immediate after own
+ write, listSpaces sweep 60s); manifest `at` stamping (`setTileAt`) and
+ stripping in `writeModelTile`; the go-live space picker in zn-connections
+ (per-space membership + no-E2EE copy). Auth: `ZIRAN_SPACES_HANDLE/_PASSWORD/
+ _PDS` env password session (dev identity), else the OAuth session's fetch
+ (scope acceptance on space endpoints still unverified). Tests:
+ `src/sync/spaces/provider.test.ts` (28 checks vs the fake, in
+ `test:spaces`/`test:sync`) plus a live two-device smoke against
+ pds.ziran.space (share → discover → adopt → exchange, all green).
+ Recorded follow-up: the per-doc settled-rkey set grows with history —
+ compaction post-alpha. Ephemerals are a no-op until presence records (G);
+ spaces invites error politely until F.
+- **F. Invites + UI — DONE (2026-08-25).** Inbox space auto-created on
+ connect (`space.ziran.inbox/self`, public policy; the real PDS answers
+ `SpaceAlreadyExists` on the re-run). invite() = owner-only addMember +
+ role assignment on the doc record (author-authoritative) + a
+ `space.ziran.invite` delivered into the invitee's inbox; an identity with
+ no inbox yet gets membership anyway and the invite reports `pending` with
+ the link as the only path. `ziran://space?ref=…&doc=…` links adopt
+ directly (deeplink API + CLI args). Inbox polled ~60s; invitations
+ surface in the existing strip; accept = adopt (auto-dismissing any
+ matching invite however adoption happens); dismissals are app-local and
+ persisted (`dismissedInvites` — the record belongs to the inviter). UI:
+ the base window's Recent column is now **Documents**, sortable Most
+ recent | By location (foldable Local Device + per-space groups; a space
+ doc not on this machine shows with a fetch affordance that adopts);
+ zn-connections gains the spaces invite form (owner-only messaging,
+ per-space membership copy) and both sign-in prompts link to the
+ create-account page. `website/site/account/` is a static signup page for
+ pds.ziran.space (invite-code createAccount, CORS verified open).
+ **Wire-truth fix caught live:** `space.listRecords` returns
+ `{collection, rkey, cid, value}` — NO `uri` (fake + client updated to the
+ real shape; the earlier assumption came from the fake). Tests: provider
+ suite now 36 checks; live smoke covers share → second-device adopt →
+ cross-account invite → inbox discovery → accept → replay, all green
+ against pds.ziran.space.
+- **G. Tiles — DONE (2026-08-25).**
+ **Presence:** the provider putRecords `space.ziran.presence` (rkey = doc
+ rkey) every ~60s for every open shared doc; peers read beacons off the
+ oplog and mark a member present while theirs is younger than ~150s.
+ Roster members now carry `present`; the panel dims the dot for away
+ members. `sendEphemeral` stays a no-op — no cursors, as decided.
+ **Checklist:** op logic extracted to `tiles/checklist/logic.js` and made
+ genuinely convergent — `set` is LWW on an (at, author) stamp (concurrent
+ opposite toggles agree everywhere), item order is (at, id) rather than
+ arrival order, and ops apply optimistically (echoes are no-ops), which
+ also removes the own-action lag reducer tiles had.
+ **Tictactoe:** rewritten around `tiles/tictactoe/logic.js` — state is the
+ SET of ops seen and the board a deterministic fold (per slot, smallest-id
+ legal candidate wins), the parent-chain discipline taken to its fixed
+ point: any delivery order, echoes included, derives the same board;
+ optimistic local moves recompute if a concurrent claim wins. Legacy
+ `games` saves convert to ops on load.
+ **Richtext:** ported to Yjs via y-prosemirror (vendored into pm.js) — the
+ steps-with-arbiter machinery wanted a sequencer and is gone; updates ride
+ the causal log (`{t:'y'}` frames beside the existing comment op-CRDT),
+ persistence is the Y update form (`ydoc` + `comments` keys), legacy
+ ProseMirror-JSON saves migrate once (sharing travels the migrated file,
+ so peers never migrate separately), and on ready the tile ships its full
+ state once so offline-restart edits reach the log (a state-vector
+ handshake is the recorded refinement). Suggestion marks and comments
+ survive the port; richtext is no longer solo-only under Spaces.
+ **Tests:** `deno task test:tiles` — delivery-order permutation proofs for
+ both reducer tiles and a headless y-prosemirror pipeline check
+ (migration, mark preservation, cross-peer convergence, schema validity);
+ presence has fake-network coverage in the provider suite. All wired into
+ `test:sync`.
+- **H. Docs + verification + default flip — software side DONE (2026-08-25).**
+ `SpacesProvider` is now the **default**; the relay stack sits behind
+ `ZIRAN_SYNC=relay` (its suites pin that env; SYNC-DESIGN.md carries the
+ parked-status note). Whole-app verification: two real Ziran processes
+ (default config, env credentials, real pds.ziran.space account) driven
+ through the HTTP API — boot online, create from model, go live into a new
+ space, autonomous second-device discovery, adopt, bidirectional ops,
+ presence — all green. Website copy de-E2EE'd honestly (own-PDS sync +
+ credible exit is the story; encryption is labeled roadmap) and pointed at
+ the /account/ signup. Remaining for Robin: the human pass on his actual
+ machines (UI feel, echo latency under typing, invite flow between his
+ accounts), and the website deploy.
+
+## Post-alpha follow-ups (recorded, deliberately not now)
+
+- **E2EE return:** the relay+MLS stack (SYNC-DESIGN.md) — revisit once
+ Spaces alpha experience settles what the protocol needs really are.
+- **Settled-set compaction:** each doc's per-author delivered-rkey set grows
+ with history; compact once ops can be safely summarized (e.g. below the
+ newest universal checkpoint frontier).
+- **State-vector handshake for Yjs tiles:** richtext (and livetext) ship
+ their full Y state on ready so offline-restart edits reach the log;
+ replace with a persisted sent-vector diff to stop op-log growth.
+- **OAuth `space:` scopes:** move atsession.ts to the alpha OAuth packages
+ so real sign-ins reach the space endpoints; env-password sessions are the
+ proven interim path.
+- **Invitation privacy:** the public-policy inbox is enumerable — revisit
+ via `appAccess: allowList` once Ziran has a hosted OAuth client identity,
+ or via the E2EE return.
+- **Push:** a `notify.ziran.space` fan-out bridge as an accelerator over
+ polling (never a dependency).
+- **Oplog hygiene:** presence beacons and full-state ready-frames bloat the
+ oplog; lean on server-side compaction when it lands (the record set is the
+ durable truth), or prune client cadence.
+- **Cross-PDS vs the hosted alpha:** one live run against
+ `spaces-alpha.bsky.network` (Robin's account) to confirm nothing in the
+ hosted build surprises us; rate-limit ceilings under real typing load.
+- **Per-doc membership + protocol write-gates:** accepted losses vs the
+ relay; candidates for protocol proposals once the alpha has evidence.
diff --git a/lexicons/space/ziran/checkpoint.json b/lexicons/space/ziran/checkpoint.json
new file mode 100644
index 0000000..a1b8262
--- /dev/null
+++ b/lexicons/space/ziran/checkpoint.json
@@ -0,0 +1,39 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.checkpoint",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "An atile-style published snapshot of a document, written by any member into their own repo (rkey = the doc's rkey; putRecord replaces the author's previous checkpoint). Carries the tile manifest with every resource's src replaced by an atproto blob ref, so an invitee can instantiate the document without replaying history, then apply ops past the frontier. Checkpoints are rare: go-live, a size threshold, or an explicit publish.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["doc", "tile", "frontier", "createdAt"],
+ "properties": {
+ "doc": {
+ "type": "string",
+ "format": "tid",
+ "description": "rkey of the space.ziran.doc record this checkpoint snapshots."
+ },
+ "tile": {
+ "type": "unknown",
+ "description": "The tile manifest, with each resource's src replaced by an atproto blob ref."
+ },
+ "cid": {
+ "type": "string",
+ "format": "cid",
+ "description": "DRISL CID of the manifest, for end-to-end integrity verification."
+ },
+ "frontier": {
+ "type": "unknown",
+ "description": "Map from member DID to the last repo rev whose ops this checkpoint incorporates."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/space/ziran/doc.json b/lexicons/space/ziran/doc.json
new file mode 100644
index 0000000..cdc4c02
--- /dev/null
+++ b/lexicons/space/ziran/doc.json
@@ -0,0 +1,36 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.doc",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "A live document in a workspace, written by the document's creator: the partial manifest linking a Ziran tile to its online identity. Document discovery in a space is the union of these records across member repos. The record's author is the sole authority for the document's role assignments; roles are enforced by tiles, not by the protocol.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["name", "model", "createdAt"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Human-readable document name.",
+ "maxGraphemes": 200,
+ "maxLength": 2000
+ },
+ "model": {
+ "type": "string",
+ "format": "nsid",
+ "description": "Identifier of the tile model this document instantiates."
+ },
+ "roles": {
+ "type": "unknown",
+ "description": "Per-document role assignments in the tile's own vocabulary: a map from DID (or the key 'default') to a role name."
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/space/ziran/inbox.json b/lexicons/space/ziran/inbox.json
new file mode 100644
index 0000000..a380587
--- /dev/null
+++ b/lexicons/space/ziran/inbox.json
@@ -0,0 +1,15 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.inbox",
+ "defs": {
+ "main": {
+ "type": "space",
+ "key": "literal:self",
+ "name": "Ziran Invitation Inbox",
+ "description": "A public-policy space through which anyone can deliver a Ziran workspace invitation to this account. Invites are written into the sender's own repo; the recipient polls the space to discover them.",
+ "collections": [
+ "space.ziran.invite"
+ ]
+ }
+ }
+}
diff --git a/lexicons/space/ziran/info.json b/lexicons/space/ziran/info.json
new file mode 100644
index 0000000..9ff3944
--- /dev/null
+++ b/lexicons/space/ziran/info.json
@@ -0,0 +1,27 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.info",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Workspace metadata, written by the space authority (rkey self). Holds what simplespace itself has no field for.",
+ "key": "literal:self",
+ "record": {
+ "type": "object",
+ "required": ["name", "createdAt"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Human-readable workspace name.",
+ "maxGraphemes": 200,
+ "maxLength": 2000
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/space/ziran/invite.json b/lexicons/space/ziran/invite.json
new file mode 100644
index 0000000..376c2bf
--- /dev/null
+++ b/lexicons/space/ziran/invite.json
@@ -0,0 +1,43 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.invite",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "An invitation to a Ziran workspace, written by the inviter into their own repo inside the invitee's public-policy inbox space. The inviter must already have added the invitee as a member of the workspace; this record only makes that membership discoverable. Dismissal is app-local: the record belongs to the inviter.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["space", "name", "createdAt"],
+ "properties": {
+ "space": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "The workspace space the invitee has been added to."
+ },
+ "name": {
+ "type": "string",
+ "description": "Human-readable workspace name, so the invite renders without a credential mint.",
+ "maxGraphemes": 200,
+ "maxLength": 2000
+ },
+ "doc": {
+ "type": "string",
+ "format": "tid",
+ "description": "Optional rkey of a specific space.ziran.doc record to highlight."
+ },
+ "message": {
+ "type": "string",
+ "description": "Optional message from the inviter.",
+ "maxGraphemes": 1000,
+ "maxLength": 10000
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/space/ziran/op.json b/lexicons/space/ziran/op.json
new file mode 100644
index 0000000..36c1146
--- /dev/null
+++ b/lexicons/space/ziran/op.json
@@ -0,0 +1,51 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.op",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "One tp-sync frame for a document, written by any member into their own repo. Ops are never deleted: a document is always rebuildable from an initial checkpoint plus the full op history. Per-author order is the repo's rev order; cross-author order is causal via parents, with deterministic (rev, author) tiebreak between concurrent siblings.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["doc", "parents", "bytes"],
+ "properties": {
+ "doc": {
+ "type": "string",
+ "format": "tid",
+ "description": "rkey of the space.ziran.doc record this op belongs to."
+ },
+ "parents": {
+ "type": "array",
+ "description": "Op-DAG heads the author had seen when this op was created. Empty for a document's first op.",
+ "items": {
+ "type": "ref",
+ "ref": "#parent"
+ }
+ },
+ "bytes": {
+ "type": "bytes",
+ "description": "The tp-sync frame payload.",
+ "maxLength": 1000000
+ }
+ }
+ }
+ },
+ "parent": {
+ "type": "object",
+ "required": ["author", "rkey"],
+ "properties": {
+ "author": {
+ "type": "string",
+ "format": "did",
+ "description": "DID of the parent op's author."
+ },
+ "rkey": {
+ "type": "string",
+ "format": "tid",
+ "description": "rkey of the parent op record in the author's repo."
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/space/ziran/presence.json b/lexicons/space/ziran/presence.json
new file mode 100644
index 0000000..80c3a7a
--- /dev/null
+++ b/lexicons/space/ziran/presence.json
@@ -0,0 +1,27 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.presence",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "Liveness beacon, written by any member into their own repo while they have the document open (rkey = the doc's rkey; refreshed by putRecord roughly every minute). Peers treat the author as present while the timestamp is recent.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["doc", "at"],
+ "properties": {
+ "doc": {
+ "type": "string",
+ "format": "tid",
+ "description": "rkey of the space.ziran.doc record the author has open."
+ },
+ "at": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Time of the author's most recent liveness refresh."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/space/ziran/workspace.json b/lexicons/space/ziran/workspace.json
new file mode 100644
index 0000000..5440a2f
--- /dev/null
+++ b/lexicons/space/ziran/workspace.json
@@ -0,0 +1,19 @@
+{
+ "lexicon": 1,
+ "id": "space.ziran.workspace",
+ "defs": {
+ "main": {
+ "type": "space",
+ "key": "tid",
+ "name": "Ziran Workspace",
+ "description": "A shared workspace of live Ziran documents. Membership is space-wide: every member sees every document in the workspace, and each member's contributions are written to their own repo.",
+ "collections": [
+ "space.ziran.info",
+ "space.ziran.doc",
+ "space.ziran.op",
+ "space.ziran.checkpoint",
+ "space.ziran.presence"
+ ]
+ }
+ }
+}
diff --git a/main.ts b/main.ts
index 33397f4..1e39fd3 100644
--- a/main.ts
+++ b/main.ts
@@ -8,10 +8,13 @@
import { join } from '@std/path';
import { createHandler } from './src/server/api.ts';
import { ZiranStore } from './src/server/store.ts';
-import { SyncManager } from './src/server/sync.ts';
+import { RelayProvider } from './src/server/sync.ts';
+import type { CollabProvider } from './src/sync/provider.ts';
+import { SpacesProvider } from './src/sync/spaces/provider.ts';
+import { passwordSession, type SessionAuth, SpaceClient } from './src/sync/spaces/client.ts';
import { startTileServer } from './src/server/tileserver.ts';
import { focusWindow, hasDesktopWindows, openDocWindow, setupMainWindow } from './src/server/windows.ts';
-import { apiKeyFile, appDataDir, appPortFile } from './src/server/paths.ts';
+import { apiKeyFile, appDataDir, appPortFile, documentsDir, spacesRegistryFile } from './src/server/paths.ts';
// Deep links (ziran://…) and .tile files invoke the app as a NEW process
// (that is how OS file/URL associations work). When an instance is already
@@ -58,12 +61,73 @@ if (store.desktop) {
store.focusOsWindow = focusWindow;
}
-const sync = new SyncManager(store);
+const { AtSession } = await import('./src/server/atsession.ts');
+const at = new AtSession();
+
+// The collaboration provider (docs/SYNC-SPACES.md): AT Proto Spaces by
+// default — every member's contribution lives in their own PDS under a
+// standard protocol, no Ziran infrastructure in the critical path. The
+// relay+MLS stack stays intact behind ZIRAN_SYNC=relay (the E2EE return
+// path; see SYNC-DESIGN.md).
+const syncMode = Deno.env.get('ZIRAN_SYNC') === 'relay' ? 'relay' : 'spaces';
+const sync: CollabProvider = syncMode === 'spaces' ? makeSpacesProvider() : new RelayProvider(store);
store.syncSummary = () => sync.summary();
store.onDocMoved = (oldId, newId, newPath) => sync.moved(oldId, newId, newPath);
-const { AtSession } = await import('./src/server/atsession.ts');
-const at = new AtSession();
+function makeSpacesProvider(): SpacesProvider {
+ // Env credentials (dev, tests, alpha machines): a legacy password session —
+ // the space endpoints accept ACCESS_FULL tokens. Otherwise the OAuth
+ // sign-in's session signs own-PDS calls (its scope may not cover the space
+ // endpoints yet; a refusal shows up in the panel rather than crashing).
+ const envHandle = Deno.env.get('ZIRAN_SPACES_HANDLE');
+ const envPassword = Deno.env.get('ZIRAN_SPACES_PASSWORD');
+ const envPds = Deno.env.get('ZIRAN_SPACES_PDS');
+ const envAuth = Boolean(envHandle && envPassword && envPds);
+ let cached: { auth: SessionAuth; at: number } | null = null;
+ let oauthFetch: typeof fetch | undefined;
+ return new SpacesProvider({
+ host: {
+ doc: (id) => store.doc(id),
+ openPath: (p) => store.openPath(p),
+ isOpen: (id) => store.open.has(id),
+ broadcast: () => store.broadcast(),
+ broadcastEvent: (e) => store.broadcastEvent(e),
+ },
+ auth: async () => {
+ if (envAuth) {
+ // Access JWTs age out (~2h): re-mint well inside that.
+ if (!cached || Date.now() - cached.at > 45 * 60 * 1000) {
+ cached = { auth: await passwordSession(envPds!, envHandle!, envPassword!), at: Date.now() };
+ }
+ oauthFetch = undefined;
+ return cached.auth;
+ }
+ if (at.signedIn && at.did && at.pds) {
+ const fetchImpl = at.spacesFetch();
+ if (fetchImpl) {
+ oauthFetch = fetchImpl;
+ return { did: at.did, pds: at.pds.replace(/\/+$/, ''), headers: () => Promise.resolve({}) };
+ }
+ }
+ return null;
+ },
+ clientFor: (auth) => new SpaceClient({ auth, fetch: oauthFetch }),
+ registryFile: spacesRegistryFile(),
+ documentsDir: documentsDir(),
+ atInfo: () =>
+ at.signedIn
+ ? {
+ signedIn: true,
+ handle: at.handle ?? undefined,
+ did: at.did ?? undefined,
+ displayName: at.displayName ?? undefined,
+ hasAvatar: Boolean(at.avatar),
+ }
+ : { signedIn: false },
+ devIdentity: () => envAuth,
+ handle: () => envHandle ?? at.handle ?? undefined,
+ });
+}
// Bearer token: 127.0.0.1 is reachable by every local process, so the API
// only answers requests carrying this key. It reaches the UI exclusively
@@ -141,49 +205,55 @@ store.tileServerPort = await startTileServer();
store.broadcast();
console.log(`tile server on http://load.localhost:${store.tileServerPort}/`);
-// Collaboration relay. Signed in, you talk to the hosted relay — an invite
-// link is only shareable when it points somewhere the invitee can also
-// reach. Signed out (dev identities), a local embedded relay keeps solo and
-// same-machine work going. ZIRAN_RELAY overrides both.
-const HOSTED_RELAY = 'wss://relay.ziran.space/ws';
-const localRelayUrl = await (async () => {
- const port = 4191;
- const url = `ws://127.0.0.1:${port}/ws`;
- if (Deno.env.get('ZIRAN_RELAY')) return url; // overridden; don't bind ports
- try {
- const { startRelay, standardVerifier } = await import('./relay/main.ts');
- await startRelay({
- port,
- dataDir: join(appDataDir(), 'relay'),
- // The same door the hosted relay uses: atproto service-auth JWTs for
- // signed-in people, dev HMAC tokens otherwise.
- verifier: standardVerifier(),
- });
- console.log(`embedded relay on ${url}`);
- } catch {
- // Port busy: usually a second Ziran (which is how same-machine
- // collaboration works). Confirm it really is a relay before saying so.
- const relay = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1500) })
- .then((r) => r.ok, () => false);
- console.log(
- relay
- ? `joining the relay already running on ${url}`
- : `port ${port} is taken by something that is not a Ziran relay — live sessions are paused`,
- );
- }
- return url;
-})();
// AT sign-in restores any previous session before sync connects, so the
-// relay handshake uses the real identity when there is one.
+// backend handshake uses the real identity when there is one.
try {
await at.init(port);
} catch (err) {
console.warn('AT sign-in unavailable:', (err as Error).message);
}
-sync.init(
- () => Deno.env.get('ZIRAN_RELAY') ?? (at.signedIn ? HOSTED_RELAY : localRelayUrl),
- at,
-).catch((err) => console.warn('sync init failed:', (err as Error).message));
+
+if (sync instanceof RelayProvider) {
+ // Collaboration relay. Signed in, you talk to the hosted relay — an invite
+ // link is only shareable when it points somewhere the invitee can also
+ // reach. Signed out (dev identities), a local embedded relay keeps solo and
+ // same-machine work going. ZIRAN_RELAY overrides both.
+ const HOSTED_RELAY = 'wss://relay.ziran.space/ws';
+ const localRelayUrl = await (async () => {
+ const relayPort = 4191;
+ const url = `ws://127.0.0.1:${relayPort}/ws`;
+ if (Deno.env.get('ZIRAN_RELAY')) return url; // overridden; don't bind ports
+ try {
+ const { startRelay, standardVerifier } = await import('./relay/main.ts');
+ await startRelay({
+ port: relayPort,
+ dataDir: join(appDataDir(), 'relay'),
+ // The same door the hosted relay uses: atproto service-auth JWTs for
+ // signed-in people, dev HMAC tokens otherwise.
+ verifier: standardVerifier(),
+ });
+ console.log(`embedded relay on ${url}`);
+ } catch {
+ // Port busy: usually a second Ziran (which is how same-machine
+ // collaboration works). Confirm it really is a relay before saying so.
+ const relay = await fetch(`http://127.0.0.1:${relayPort}/health`, { signal: AbortSignal.timeout(1500) })
+ .then((r) => r.ok, () => false);
+ console.log(
+ relay
+ ? `joining the relay already running on ${url}`
+ : `port ${relayPort} is taken by something that is not a Ziran relay — live sessions are paused`,
+ );
+ }
+ return url;
+ })();
+ sync.init(
+ () => Deno.env.get('ZIRAN_RELAY') ?? (at.signedIn ? HOSTED_RELAY : localRelayUrl),
+ at,
+ ).catch((err) => console.warn('sync init failed:', (err as Error).message));
+} else if (sync instanceof SpacesProvider) {
+ at.onChange = () => sync.retryNow();
+ sync.init().catch((err) => console.warn('spaces sync init failed:', (err as Error).message));
+}
// Reopen last session's document windows (desktop mode only; in a browser,
// popup rules make silent restores hostile).
@@ -210,6 +280,20 @@ for (const arg of Deno.args) {
.catch((err) => console.warn('could not accept invitation:', (err as Error).message));
continue;
}
+ if (arg.startsWith('ziran://space?') && sync instanceof SpacesProvider) {
+ const params = new URL(arg).searchParams;
+ const ref = params.get('ref');
+ const docRkey = params.get('doc');
+ if (ref && docRkey) {
+ sync.adopt(ref, docRkey)
+ .then(async ({ path }) => {
+ const info = await store.openPath(path);
+ store.createWindow([info.id]);
+ })
+ .catch((err) => console.warn('could not fetch from the space link:', (err as Error).message));
+ }
+ continue;
+ }
if (!arg.endsWith('.tile')) continue;
try {
const info = await store.openPath(await Deno.realPath(arg));
diff --git a/src/components/zn-base-window.ts b/src/components/zn-base-window.ts
index cdc1974..acc8d7a 100644
--- a/src/components/zn-base-window.ts
+++ b/src/components/zn-base-window.ts
@@ -13,17 +13,20 @@ export class ZnBaseWindow extends LitElement {
query: { state: true },
dropping: { state: true },
menuRecent: { state: true },
+ docSort: { state: true },
};
declare private query: string;
declare private dropping: boolean;
declare private menuRecent: RecentEntry | undefined;
+ declare private docSort: 'recent' | 'location';
constructor() {
super();
this.query = '';
this.dropping = false;
this.menuRecent = undefined;
+ this.docSort = 'recent';
}
private onStore = () => this.requestUpdate();
@@ -369,6 +372,62 @@ export class ZnBaseWindow extends LitElement {
margin: 0 0 var(--sp-2);
padding-left: var(--sp-2);
}
+ .region-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-3);
+ }
+ .region-head h2 {
+ flex: 1;
+ }
+ .sort {
+ display: inline-flex;
+ gap: 2px;
+ border: 1px solid var(--line-soft);
+ border-radius: var(--r-full);
+ padding: 2px;
+ }
+ .sort button {
+ font: inherit;
+ font-size: var(--text-xs);
+ color: var(--ink-muted);
+ border: none;
+ background: none;
+ border-radius: var(--r-full);
+ padding: 2px 10px;
+ cursor: pointer;
+ }
+ .sort button[aria-pressed='true'] {
+ background: var(--panel);
+ color: var(--ink);
+ font-weight: var(--w-medium);
+ }
+ .sort button:focus-visible {
+ outline: 2px solid var(--signal);
+ outline-offset: 1px;
+ }
+ details.loc {
+ margin-bottom: var(--sp-1);
+ }
+ details.loc summary {
+ cursor: pointer;
+ font-size: var(--text-sm);
+ font-weight: var(--w-semibold);
+ color: var(--ink-muted);
+ padding: 6px var(--sp-2);
+ border-radius: var(--r-sm);
+ user-select: none;
+ }
+ details.loc summary:hover {
+ background: var(--panel);
+ }
+ details.loc summary:focus-visible {
+ outline: 2px solid var(--signal);
+ outline-offset: -2px;
+ }
+ details.loc summary .loc-count {
+ font-weight: var(--w-regular);
+ }
.region {
display: flex;
flex-direction: column;
@@ -650,8 +709,20 @@ export class ZnBaseWindow extends LitElement {
${this.renderInvitations()}
-
-
Recent
+
+
+
Documents
+
+ (this.docSort = 'recent')}
+ >Most recent
+ (this.docSort = 'location')}
+ >By location
+
+
`
- : recents.length
- ? recents.map((d) => this.renderRow(d, open.has(d.id), d === enterDoc))
- : html`
Nothing called “${this.query}” — try a model instead?
`}
+ : this.docSort === 'location'
+ ? this.renderByLocation(recents, open)
+ : recents.length
+ ? recents.map((d) => this.renderRow(d, open.has(d.id), d === enterDoc))
+ : html`
Nothing called “${this.query}” — try a model instead?
`}
@@ -718,7 +790,10 @@ export class ZnBaseWindow extends LitElement {
if (sync && !sync.relayOk && shared.length) {
return html`
- Live sync is paused — the relay is unreachable; Ziran keeps trying
+
+ Live sync is paused —
+ ${sync.provider === 'spaces' ? 'your PDS' : 'the relay'} is unreachable; Ziran keeps trying
+
`;
}
@@ -726,7 +801,11 @@ export class ZnBaseWindow extends LitElement {
return html`
${icons.check}
- ${liveCount} ${liveCount === 1 ? 'document' : 'documents'} live, end-to-end encrypted
+
+ ${liveCount} ${liveCount === 1 ? 'document' : 'documents'} live${sync?.provider === 'spaces'
+ ? ' in your spaces'
+ : ', end-to-end encrypted'}
+
`;
}
@@ -809,6 +888,8 @@ export class ZnBaseWindow extends LitElement {
Continue
Ziran opens your provider in the browser — no new account, no password here.
+ No AT identity yet?
+ Create one .
@@ -860,6 +941,44 @@ export class ZnBaseWindow extends LitElement {
: html`
${icons.tile} `;
}
+ /** By location: where each document actually lives — this machine alone,
+ or one of your spaces (whose documents you can fetch from here). */
+ private renderByLocation(recents: RecentEntry[], open: Set
) {
+ const sync = desk.sync;
+ const inSpace = new Map();
+ for (const [id, d] of Object.entries(sync?.docs ?? {})) {
+ if (d.space) inSpace.set(id, d.space);
+ }
+ const q = this.query.trim().toLowerCase();
+ const local = recents.filter((r) => !inSpace.has(r.id));
+ const spaces = sync?.spaces ?? [];
+ return html`
+
+ Local Device · ${local.length}
+ ${local.length
+ ? local.map((d) => this.renderRow(d, open.has(d.id)))
+ : html`Nothing lives only on this machine.
`}
+
+ ${spaces.map((s) => {
+ const here = recents.filter((r) => inSpace.get(r.id) === s.ref);
+ const away = s.available.filter((a) => !q || a.name.toLowerCase().includes(q));
+ return html`
+
+ ${s.name || 'unnamed space'} · ${here.length + away.length}
+ ${here.map((d) => this.renderRow(d, open.has(d.id)))}
+ ${away.map((a) => html`
+ desk.adoptDoc(s.ref, a.docRkey, a.name)}>
+ ${icons.tile}
+ ${a.name} — in this space, not on this machine yet
+
+ `)}
+ ${!here.length && !away.length ? html`No documents in this space yet.
` : nothing}
+
+ `;
+ })}
+ `;
+ }
+
private renderRow(d: RecentEntry, isOpen: boolean, enterTarget = false) {
return html`
this.requestUpdate();
@@ -219,6 +223,10 @@ export class ZnConnections extends LitElement {
background: var(--signal);
flex: none;
}
+ /* Known member, not currently around (Spaces presence aged out). */
+ .presence.away {
+ background: var(--line);
+ }
form.invite {
display: flex;
gap: var(--sp-2);
@@ -330,6 +338,7 @@ export class ZnConnections extends LitElement {
}
if (!sync?.relayOk) return this.renderRelayDown(Boolean(s));
if (!s) {
+ if (sync.provider === 'spaces') return this.renderGoLiveSpaces();
return html`
Only on this machine
@@ -341,6 +350,49 @@ export class ZnConnections extends LitElement {
`;
}
+ if (sync.provider === 'spaces') {
+ const spaceName = sync.spaces?.find((sp) => sp.ref === s.space)?.name || 'its space';
+ const open = s.roles?.filter((r) => r.invitable) ?? [];
+ return html`
+
+ ${s.members.map((m) => html`
+
+
+ ${m.handle ?? m.did}
+
+ ${m.did === sync.identity.did ? `${m.role} · you` : m.role}
+
+
+ `)}
+
+
+ Live in “${spaceName}” through your PDS. Everyone in this space sees every
+ document in it, and your other signed-in devices sync on their own.
+
+ ${open.length
+ ? html`
+
+ ${this.renderLastSpacesInvite()}
+ `
+ : html`
Only the space owner can invite people to this space.
`}
+ `;
+ }
if (s.live === false) {
return html`
@@ -401,18 +453,69 @@ export class ZnConnections extends LitElement {
`;
}
+ /** Going live into a space (Spaces provider): pick one of yours, or name a
+ new one. Membership is per-space, so the copy says exactly that. */
+ private renderGoLiveSpaces() {
+ const spaces = desk.sync?.spaces ?? [];
+ const wantsNew = this.shareSpaceRef === '';
+ return html`
+
+ Only on this machine
+
+
+
+ Going live puts this document in a space on your PDS. Everyone in a space —
+ every device, every person invited later — sees every document in it, and it
+ is not end-to-end encrypted: your PDS operator could read it.
+
+ `;
+ }
+
/** The relay is down: say which relay, why (when known), and offer to try
right now — a shrug with no handle on it is what "in shambles" feels
like from the outside. */
private renderRelayDown(shared: boolean) {
const sync = desk.sync;
+ const spaces = sync?.provider === 'spaces';
const relay = sync?.relay?.replace(/^wss?:\/\//, '').replace(/\/ws$/, '');
const detail = [relay, sync?.relayError].filter(Boolean).join(' — ');
+ const backendName = spaces ? 'your PDS' : 'the relay';
return html`
${shared
- ? 'Live sharing is paused: Ziran can’t reach the relay. Your edits stay on this machine and flow to the others when it reconnects.'
- : 'The relay is unreachable right now — Ziran keeps trying and will reconnect on its own. This document keeps working on this machine either way.'}
+ ? `Live sharing is paused: Ziran can’t reach ${backendName}. Your edits stay on this machine and flow to the others when it reconnects.`
+ : `${spaces ? 'Your PDS is' : 'The relay is'} unreachable right now — Ziran keeps trying and will reconnect on its own. This document keeps working on this machine either way.`}
${detail ? html`
${detail}
` : nothing}
@@ -444,6 +547,30 @@ export class ZnConnections extends LitElement {
`;
}
+ /** The spaces invite outcome: inviting into the space grants membership
+ immediately; the record in their inbox (or this link) is how they find
+ out about it. */
+ private renderLastSpacesInvite() {
+ if (!this.lastInvite) {
+ return html`
+ Inviting adds them to the whole space — they’ll see every document in it,
+ starting with this one.
+
`;
+ }
+ const { link, pending } = this.lastInvite;
+ return html`
+
+ ${link}
+ desk.copyText(link, 'Space link')}>Copy
+
+
+ ${pending
+ ? 'They can’t receive invitations yet — send them this link; it works the moment they sign in to Ziran.'
+ : 'They’re in: the invitation shows up in their Ziran, and this link is a second way there.'}
+
+ `;
+ }
+
/** Inline sign-in, so the way forward is right where the intent is. */
private renderSignInPrompt(reason: string) {
return html`
@@ -465,7 +592,11 @@ export class ZnConnections extends LitElement {
/>
Sign in
-
Ziran opens your provider in the browser — no new account, no password here.
+
+ Ziran opens your provider in the browser — no new account, no password here.
+ No AT identity yet?
+ Create one .
+
`;
}
diff --git a/src/server/api.ts b/src/server/api.ts
index 6505e9b..7e03a3a 100644
--- a/src/server/api.ts
+++ b/src/server/api.ts
@@ -6,7 +6,7 @@ import mime from 'mime';
import { parseTile, readResource, readTileData, renameTile, writeTileData } from './tilefile.ts';
import { pickOpen, pickSave, revealInFolder } from './dialogs.ts';
import type { ZiranStore } from './store.ts';
-import type { SyncManager } from './sync.ts';
+import type { CollabProvider } from '../sync/provider.ts';
import type { AtSession } from './atsession.ts';
const staticRoots = [
@@ -125,7 +125,7 @@ function safeEqual(a: string, b: string): boolean {
return diff === 0;
}
-export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSession): (req: Request) => Promise
{
+export function createHandler(store: ZiranStore, sync?: CollabProvider, at?: AtSession): (req: Request) => Promise {
return async (req: Request): Promise => {
const url = new URL(req.url);
const { pathname } = url;
@@ -229,6 +229,18 @@ export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSess
const { path } = await sync.accept(invite[1]!);
return windowed(await store.openPath(path));
}
+ // A space link (Spaces provider): adopt the doc straight from its ref.
+ if (arg.startsWith('ziran://space?')) {
+ if (!sync || !('adopt' in sync)) return err('space links need the Spaces provider', 503);
+ const params = new URL(arg).searchParams;
+ const ref = params.get('ref');
+ const docRkey = params.get('doc');
+ if (!ref || !docRkey) return err('malformed space link');
+ const { path } = await (sync as unknown as {
+ adopt(space: string, docRkey: string): Promise<{ path: string }>;
+ }).adopt(ref, docRkey);
+ return windowed(await store.openPath(path));
+ }
if (arg.endsWith('.tile')) {
return windowed(await store.openPath(await Deno.realPath(arg)));
}
@@ -361,7 +373,17 @@ export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSess
// ——— collaboration (the UI bridge; tiles never reach these) ———
if (rest === 'sync/share' && req.method === 'POST') {
if (!sync) return err('sync unavailable', 503);
- await sync.share(id!);
+ // Spaces: which space to go live into (or a new one's name).
+ const body = await req.json().catch(() => ({}));
+ await sync.share(id!, {
+ space: typeof body.space === 'string' ? body.space : undefined,
+ newSpaceName: typeof body.newSpaceName === 'string' ? body.newSpaceName : undefined,
+ });
+ return json({ ok: true });
+ }
+ if (rest === 'sync/publish' && req.method === 'POST') {
+ if (!sync || !('publish' in sync)) return err('publishing is not available here', 503);
+ await (sync as unknown as { publish(id: string): Promise }).publish(id!);
return json({ ok: true });
}
if (rest === 'sync/invite' && req.method === 'POST') {
@@ -396,6 +418,18 @@ export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSess
return json({ ok: true });
}
+ // Instantiate a document discovered in a space (another member's — or
+ // another of this account's devices'). Spaces provider only.
+ if (pathname === '/api/sync/adopt' && req.method === 'POST') {
+ if (!sync || !('adopt' in sync)) return err('adopting from a space is not available here', 503);
+ const { space, docRkey } = await req.json();
+ if (typeof space !== 'string' || typeof docRkey !== 'string') return err('space and docRkey required');
+ const { path } = await (sync as unknown as {
+ adopt(space: string, docRkey: string): Promise<{ path: string }>;
+ }).adopt(space, docRkey);
+ return windowed(await store.openPath(path));
+ }
+
if (pathname === '/api/at/signin' && req.method === 'POST') {
if (!at) return err('sign-in unavailable', 503);
const { identifier } = await req.json();
@@ -415,7 +449,8 @@ export function createHandler(store: ZiranStore, sync?: SyncManager, at?: AtSess
return json({ ok: true });
}
- const inviteAct = pathname.match(/^\/api\/invitations\/([0-9a-f]+)\/(accept|dismiss)$/);
+ // Tokens are hex (relay) or record rkeys (spaces TIDs).
+ const inviteAct = pathname.match(/^\/api\/invitations\/([\w.~-]+)\/(accept|dismiss)$/);
if (inviteAct && req.method === 'POST') {
if (!sync) return err('sync unavailable', 503);
const token = inviteAct[1]!;
diff --git a/src/server/atsession.ts b/src/server/atsession.ts
index 932881b..b6ae5b4 100644
--- a/src/server/atsession.ts
+++ b/src/server/atsession.ts
@@ -136,6 +136,7 @@ export class AtSession {
} catch {
this.handle = null;
}
+ this.pds = pds;
await this.#loadProfile(did, pds).catch(() => {});
}
@@ -201,6 +202,7 @@ export class AtSession {
this.handle = null;
this.displayName = null;
this.avatar = null;
+ this.pds = null;
this.#agent = undefined;
this.#session = undefined;
await Deno.remove(this.#currentFile).catch(() => {});
@@ -208,6 +210,27 @@ export class AtSession {
await this.onChange?.();
}
+ /** The signed-in account's PDS endpoint, when known. */
+ pds: string | null = null;
+
+ /** A fetch that signs own-PDS requests with the OAuth session (DPoP per
+ request) and passes everything else through untouched — the SpaceClient
+ injection point. Whether the session's scope satisfies the space
+ endpoints is the PDS's call; a 403 there surfaces in the sync summary. */
+ spacesFetch(): typeof fetch | null {
+ const session = this.#session;
+ const pds = this.pds;
+ if (!session || !pds) return null;
+ const base = pds.replace(/\/+$/, '');
+ return ((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input instanceof Request ? input.url : input);
+ if (url.startsWith(base)) {
+ return session.fetchHandler(url.slice(base.length), init);
+ }
+ return fetch(input as URL, init);
+ }) as typeof fetch;
+ }
+
/** Mint a relay token at the user's PDS (service-auth, ~60s, DID-signed). */
async relayToken(relayDid: string): Promise {
if (!this.#agent) throw new Error('not signed in');
diff --git a/src/server/deeplink.test.ts b/src/server/deeplink.test.ts
index 60136da..ccca6ba 100644
--- a/src/server/deeplink.test.ts
+++ b/src/server/deeplink.test.ts
@@ -21,6 +21,7 @@ function spawnApp(port: number, args: string[] = []): Deno.ChildProcess {
return new Deno.Command(Deno.execPath(), {
args: ['run', '-A', 'main.ts', ...args],
env: {
+ ZIRAN_SYNC: 'relay', // these suites exercise the relay stack
ZIRAN_DATA_DIR: join(tmp, 'app'),
ZIRAN_KEY: KEY,
ZIRAN_PORT: String(port),
diff --git a/src/server/paths.ts b/src/server/paths.ts
index 664597c..4b36b23 100644
--- a/src/server/paths.ts
+++ b/src/server/paths.ts
@@ -38,6 +38,8 @@ export const identityFile = () => join(syncDir(), 'identity.json');
the dev identity and the AT identity each keep their own. */
export const mlsIdentityFile = (did: string) => join(syncDir(), 'identities', `${encodeURIComponent(did)}.json`);
export const syncRegistryFile = () => join(syncDir(), 'docs.json');
+/** Registry v2 — the Spaces provider's spaces + docs (docs/SYNC-SPACES.md). */
+export const spacesRegistryFile = () => join(syncDir(), 'spaces.json');
export async function ensureDirs(): Promise {
for (const dir of [documentsDir(), modelsDir(), importsDir(), groupsDir()]) {
diff --git a/src/server/sync.ts b/src/server/sync.ts
index 162f1b4..5d87c68 100644
--- a/src/server/sync.ts
+++ b/src/server/sync.ts
@@ -9,6 +9,7 @@
// only this file's identity block and the relay's Verifier).
import { dirname, join } from '@std/path';
+import type { CollabProvider, ProviderSummary } from '../sync/provider.ts';
import type { Invitation, Member, Session, SyncManifest } from '../sync/protocol.ts';
import { fromB64, toB64 } from '../sync/protocol.ts';
import { RelaySyncTransport } from '../sync/relay-client.ts';
@@ -57,31 +58,9 @@ interface LiveDoc {
roster: Member[];
}
-export interface SyncSummary {
- identity: { did: string; handle: string };
- devIdentity: boolean;
- at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean };
- relayOk: boolean;
- /** The relay this runtime is (trying to be) on, ws(s) URL. */
- relay: string;
- /** Why the relay is unreachable, when it is — so the UI can say something
- truer than a shrug. */
- relayError?: string;
- invitations: Array>;
- docs: Record;
- }>;
-}
+export type SyncSummary = ProviderSummary;
-export class SyncManager {
+export class RelayProvider implements CollabProvider {
did = '';
handle = '';
relayUrl = '';
@@ -646,6 +625,7 @@ export class SyncManager {
};
}
return {
+ provider: 'relay',
identity: { did: this.did, handle: this.handle },
// An explicit ZIRAN_DID is developer mode: local dev identities may
// invite each other without AT sign-in. Without it, inviting requires
diff --git a/src/server/tilefile.ts b/src/server/tilefile.ts
index 8184e28..10f8f87 100644
--- a/src/server/tilefile.ts
+++ b/src/server/tilefile.ts
@@ -249,6 +249,15 @@ export async function readTileData(path: string, name: string): Promise {
+ const tile = await parseTile(path, { verify: false });
+ if (at) tile.manifest.at = at;
+ else delete tile.manifest.at;
+ await writeTile(tile.manifest, allResources(tile), path);
+}
+
/** Rename: set masl.name and rewrite in place. */
export async function renameTile(path: string, name: string): Promise {
const tile = await parseTile(path, { verify: false });
@@ -269,6 +278,8 @@ export async function writeModelTile(srcPath: string, destPath: string): Promise
const model = tile.manifest.model;
if (!model?.id?.trim()) throw new Error('tile has no model.id; only model-backed tiles can become models');
const masl = structuredClone(tile.manifest) as Masl;
+ // A model is a fresh starting point: its online identity does not follow.
+ delete masl.at;
for (const key of ['name', 'description', 'short_name', 'theme_color', 'background_color'] as const) {
if (model[key] !== undefined) masl[key] = model[key];
}
diff --git a/src/state/backend.ts b/src/state/backend.ts
index ae22e89..289c4df 100644
--- a/src/state/backend.ts
+++ b/src/state/backend.ts
@@ -116,7 +116,9 @@ export const backend = {
},
/* ——— collaboration ——— */
- syncShare: (docId: string): Promise => post(`/api/docs/${docId}/sync/share`),
+ syncShare: (docId: string, opts?: { space?: string; newSpaceName?: string }): Promise =>
+ post(`/api/docs/${docId}/sync/share`, opts ?? {}),
+ syncAdopt: (space: string, docRkey: string): Promise => post('/api/sync/adopt', { space, docRkey }),
syncRetry: (): Promise => post('/api/sync/retry'),
syncInvite: (
docId: string,
diff --git a/src/state/desk.ts b/src/state/desk.ts
index 43d8ecc..c601fb7 100644
--- a/src/state/desk.ts
+++ b/src/state/desk.ts
@@ -310,15 +310,27 @@ class DeskStore extends EventTarget {
return this.sync?.docs?.[docId];
}
- async startSharing(docId: string): Promise {
+ async startSharing(docId: string, opts?: { space?: string; newSpaceName?: string }): Promise {
try {
- await backend.syncShare(docId);
- this.toast('This document is now live — invite people from this panel');
+ await backend.syncShare(docId, opts);
+ this.toast(this.sync?.provider === 'spaces'
+ ? 'This document is live in its space — your other devices will see it'
+ : 'This document is now live — invite people from this panel');
} catch (err) {
this.toast(`Could not start sharing: ${(err as Error).message}`);
}
}
+ /** Instantiate a document another device (or member) put in a space. */
+ async adoptDoc(space: string, docRkey: string, name: string): Promise {
+ try {
+ this.handleOpened(await backend.syncAdopt(space, docRkey));
+ this.toast(`“${name}” is on this machine now — synced with its space`);
+ } catch (err) {
+ this.toast(`Could not fetch “${name}”: ${(err as Error).message}`);
+ }
+ }
+
/** Ask the runtime to try the relay right now instead of waiting out the
backoff timer. */
async syncRetry(): Promise {
diff --git a/src/sync/e2e.test.ts b/src/sync/e2e.test.ts
index c51c2b6..7a4fd0c 100644
--- a/src/sync/e2e.test.ts
+++ b/src/sync/e2e.test.ts
@@ -34,6 +34,7 @@ async function startApp(name: string, did: string, port: number): Promise {
const child = new Deno.Command(Deno.execPath(), {
args: ['run', '-A', 'main.ts'],
env: {
+ ZIRAN_SYNC: 'relay', // these suites exercise the relay stack
ZIRAN_DATA_DIR: join(tmp, name),
ZIRAN_KEY: key,
ZIRAN_PORT: String(port),
diff --git a/src/sync/newcomer.test.ts b/src/sync/newcomer.test.ts
index c6198d3..63c7058 100644
--- a/src/sync/newcomer.test.ts
+++ b/src/sync/newcomer.test.ts
@@ -24,6 +24,7 @@ async function startApp(name: string, did: string, port: number): Promise {
const child = new Deno.Command(Deno.execPath(), {
args: ['run', '-A', 'main.ts'],
env: {
+ ZIRAN_SYNC: 'relay', // these suites exercise the relay stack
ZIRAN_DATA_DIR: join(tmp, name), ZIRAN_KEY: `${name}-key`, ZIRAN_PORT: String(port),
ZIRAN_DID: did, ZIRAN_HANDLE: `${name}.test`,
ZIRAN_RELAY: relayUrl, ZIRAN_RELAY_SECRET: 'dev-secret',
diff --git a/src/sync/protocol.ts b/src/sync/protocol.ts
index c0e11b9..5ae4785 100644
--- a/src/sync/protocol.ts
+++ b/src/sync/protocol.ts
@@ -9,6 +9,10 @@ export interface Member {
did: string;
handle?: string;
role: string;
+ /** False when the member is known but not currently around (Spaces
+ presence records aging out). Absent = presence is implicit (relay
+ rosters only list connected members). */
+ present?: boolean;
}
/** A role's runtime-enforceable capabilities, declared in the tile manifest
diff --git a/src/sync/provider.ts b/src/sync/provider.ts
new file mode 100644
index 0000000..77a72f0
--- /dev/null
+++ b/src/sync/provider.ts
@@ -0,0 +1,93 @@
+// The CollabProvider seam: everything the runtime (API layer, store, UI
+// summary) needs from a collaboration backend, one level above SyncTransport.
+// Two implementations exist: RelayProvider (src/server/sync.ts — the
+// relay+MLS stack, behind ZIRAN_SYNC=relay) and SpacesProvider
+// (src/sync/spaces/provider.ts — AT Proto Spaces, docs/SYNC-SPACES.md).
+// main.ts picks one; api.ts and the UI program against this interface only.
+
+import type { Invitation, Member } from './protocol.ts';
+
+/** A decrypted/delivered frame waiting for a (re)attached tile. `seq` is the
+ provider's local delivery order for the doc — total on the relay,
+ per-machine causal order under Spaces — and is what `ackedSeq` snapshots. */
+export interface BufferedFrame {
+ seq: number;
+ author: string;
+ role: string;
+ b64: string;
+}
+
+export interface AttachState {
+ me: Member;
+ roster: Member[];
+ buffered: BufferedFrame[];
+}
+
+export interface DocSummary {
+ syncId: string;
+ role: string;
+ /** False when the doc is shared but its session isn't running right now
+ (backend unreachable, or rejoin failed); the chrome says "paused". */
+ live: boolean;
+ members: Member[];
+ inviteBase: string;
+ /** The document's own roles, as the chrome should offer them. */
+ roles: Array<{ name: string; label: string; invitable: boolean; full: boolean }>;
+ /** Spaces only: the space this doc lives in. */
+ space?: string;
+}
+
+export interface SpaceSummary {
+ ref: string;
+ name: string;
+ /** True when this identity owns the space (owner-only invites). */
+ mine: boolean;
+ /** Docs discovered in the space that are not instantiated locally yet
+ (the Documents column's "By location" raw material). */
+ available: Array<{ docRkey: string; name: string; creator: string }>;
+}
+
+export interface ProviderSummary {
+ provider: 'relay' | 'spaces';
+ identity: { did: string; handle: string };
+ devIdentity: boolean;
+ at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean };
+ /** The collaboration backend is reachable (relay socket up / PDS session
+ live). Field names kept from the relay era; the UI treats them as
+ "backend ok / backend address / why not". */
+ relayOk: boolean;
+ relay: string;
+ relayError?: string;
+ invitations: Array>;
+ docs: Record;
+ /** Spaces only: the identity's known spaces, for the go-live picker and
+ the Documents column. */
+ spaces?: SpaceSummary[];
+}
+
+export interface ShareOptions {
+ /** Spaces: an existing space ref to add the doc to. */
+ space?: string;
+ /** Spaces: create a new space with this name and add the doc to it. */
+ newSpaceName?: string;
+}
+
+/** The seam. All methods are keyed by the path-derived local doc id. */
+export interface CollabProvider {
+ summary(): ProviderSummary;
+ /** A file move re-keyed the path-derived id (store's move hook). */
+ moved(oldId: string, newId: string, newPath: string): void;
+ /** Start sharing an open document ("go live"). */
+ share(localId: string, opts?: ShareOptions): Promise;
+ invite(localId: string, identifier: string, role: string): Promise<{ invitation: Invitation; link: string }>;
+ accept(token: string): Promise<{ path: string }>;
+ dismiss(token: string): void;
+ /** Tile (re)announced itself: session state + frames past its ackedSeq. */
+ attach(localId: string): AttachState | null;
+ send(localId: string, b64: string): Promise;
+ ephemeral(localId: string, b64: string): void;
+ /** The tile persisted its state (putData): advance the snapshot point. */
+ onLocalDataWrite(localId: string): Promise;
+ /** A person asked to reconnect right now: skip any backoff. */
+ retryNow(): void;
+}
diff --git a/src/sync/reconnect.test.ts b/src/sync/reconnect.test.ts
index 92b65dc..1d5cb3d 100644
--- a/src/sync/reconnect.test.ts
+++ b/src/sync/reconnect.test.ts
@@ -23,6 +23,7 @@ const startTheRelay = (): Promise =>
const app = new Deno.Command(Deno.execPath(), {
args: ['run', '-A', 'main.ts'],
env: {
+ ZIRAN_SYNC: 'relay', // these suites exercise the relay stack
ZIRAN_DATA_DIR: join(tmp, 'app'), ZIRAN_KEY: 'k', ZIRAN_PORT: '4872',
ZIRAN_DID: 'did:ziran:rc-a', ZIRAN_HANDLE: 'rc.test',
ZIRAN_RELAY: relayUrl, ZIRAN_RELAY_SECRET: 'dev-secret',
diff --git a/src/sync/restart-invite.test.ts b/src/sync/restart-invite.test.ts
index 334fb3c..69256aa 100644
--- a/src/sync/restart-invite.test.ts
+++ b/src/sync/restart-invite.test.ts
@@ -36,6 +36,7 @@ function spawnApp(name: string, did: string, port: number): Deno.ChildProcess {
return new Deno.Command(Deno.execPath(), {
args: ['run', '-A', 'main.ts'],
env: {
+ ZIRAN_SYNC: 'relay', // these suites exercise the relay stack
ZIRAN_DATA_DIR: join(tmp, name),
ZIRAN_KEY: `${name}-key`,
ZIRAN_PORT: String(port),
diff --git a/src/sync/richtext-crdt.test.ts b/src/sync/richtext-crdt.test.ts
new file mode 100644
index 0000000..a421edb
--- /dev/null
+++ b/src/sync/richtext-crdt.test.ts
@@ -0,0 +1,142 @@
+// The richtext tile's CRDT layer, headless (Phase G): the y-prosemirror
+// pipeline the tile rides — legacy ProseMirror-JSON migration, mark
+// preservation, and convergence of concurrent Y updates exchanged in
+// different orders. The EditorView half is browser-only and is exercised by
+// opening the tile; everything the causal log touches is covered here.
+// Run: deno run -A src/sync/richtext-crdt.test.ts
+
+import * as Y from 'yjs';
+import { prosemirrorJSONToYDoc, yDocToProsemirrorJSON } from 'y-prosemirror';
+import { Schema } from 'prosemirror-model';
+import { schema as basic } from 'prosemirror-schema-basic';
+import { addListNodes } from 'prosemirror-schema-list';
+
+let failures = 0;
+const check = (label: string, ok: boolean, detail = '') => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
+ if (!ok) failures++;
+};
+
+// The tile's schema: basic + lists + comment & suggestion marks (same
+// definitions as tiles/richtext/index.html).
+const schema = new Schema({
+ nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'),
+ marks: basic.spec.marks.append({
+ comment: {
+ attrs: { id: {} },
+ inclusive: false,
+ toDOM: (m) => ['span', { 'data-cmt': m.attrs.id }, 0],
+ },
+ sug_ins: {
+ attrs: { id: {}, who: { default: '' } },
+ inclusive: true,
+ toDOM: (m) => ['ins', { 'data-sug': m.attrs.id }, 0],
+ },
+ sug_del: {
+ attrs: { id: {}, who: { default: '' } },
+ inclusive: false,
+ toDOM: (m) => ['del', { 'data-sug': m.attrs.id }, 0],
+ },
+ }),
+});
+
+// A legacy save: heading, list, and marked-up text (comment + suggestion).
+const legacyDoc = {
+ type: 'doc',
+ content: [
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: 'plain ' },
+ { type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
+ { type: 'text', text: ' noted', marks: [{ type: 'comment', attrs: { id: 'c1' } }] },
+ { type: 'text', text: ' added', marks: [{ type: 'sug_ins', attrs: { id: 's1', who: 'robin' } }] },
+ ],
+ },
+ {
+ type: 'bullet_list',
+ content: [{ type: 'list_item', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] }] }],
+ },
+ ],
+};
+
+/* ——— migration round-trip ——— */
+const migrated = prosemirrorJSONToYDoc(schema, legacyDoc, 'prosemirror');
+const roundTrip = yDocToProsemirrorJSON(migrated, 'prosemirror');
+check(
+ 'legacy ProseMirror JSON migrates to Yjs and back intact',
+ JSON.stringify(schema.nodeFromJSON(roundTrip)) === JSON.stringify(schema.nodeFromJSON(legacyDoc)),
+);
+check(
+ 'comment and suggestion marks survive migration',
+ JSON.stringify(roundTrip).includes('"c1"') && JSON.stringify(roundTrip).includes('"s1"'),
+);
+
+/* ——— the tile's persistence form: update bytes → fresh doc ——— */
+const stored = Y.encodeStateAsUpdate(migrated);
+const reloaded = new Y.Doc();
+Y.applyUpdate(reloaded, stored, 'load');
+check(
+ 'ydoc persists as an update and reloads identically',
+ JSON.stringify(yDocToProsemirrorJSON(reloaded, 'prosemirror')) === JSON.stringify(roundTrip),
+);
+
+/* ——— convergence: concurrent edits exchanged in both orders ——— */
+function fork(from: Y.Doc): Y.Doc {
+ const doc = new Y.Doc();
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(from));
+ return doc;
+}
+function firstText(doc: Y.Doc): Y.XmlText {
+ const frag = doc.getXmlFragment('prosemirror');
+ for (let i = 0; i < frag.length; i++) {
+ const node = frag.get(i);
+ if (node instanceof Y.XmlElement && node.length && node.get(0) instanceof Y.XmlText) {
+ return node.get(0) as Y.XmlText;
+ }
+ }
+ throw new Error('no text node');
+}
+
+const peerA = fork(migrated);
+const peerB = fork(migrated);
+const updates: Array<{ from: string; u: Uint8Array }> = [];
+peerA.on('update', (u: Uint8Array, origin: unknown) => {
+ if (origin !== 'remote') updates.push({ from: 'a', u });
+});
+peerB.on('update', (u: Uint8Array, origin: unknown) => {
+ if (origin !== 'remote') updates.push({ from: 'b', u });
+});
+firstText(peerA).insert(0, 'A-was-here ');
+firstText(peerB).insert(0, 'B-was-here ');
+// Deliver A's updates to B and B's to A — and once more in reverse order,
+// echoes included, into a third replica: everything must converge.
+const peerC = fork(migrated);
+for (const { from, u } of updates) {
+ Y.applyUpdate(from === 'a' ? peerB : peerA, u, 'remote');
+}
+for (const { u } of [...updates].reverse()) Y.applyUpdate(peerC, u, 'remote');
+for (const { u } of updates) Y.applyUpdate(peerC, u, 'remote'); // echo double-apply
+const jsonA = JSON.stringify(yDocToProsemirrorJSON(peerA, 'prosemirror'));
+const jsonB = JSON.stringify(yDocToProsemirrorJSON(peerB, 'prosemirror'));
+const jsonC = JSON.stringify(yDocToProsemirrorJSON(peerC, 'prosemirror'));
+check('concurrent edits converge across peers', jsonA === jsonB, 'A vs B differ');
+check('delivery order and echoes do not matter', jsonA === jsonC, 'C (reversed + echoed) differs');
+check(
+ 'both concurrent insertions survive the merge',
+ jsonA.includes('A-was-here') && jsonA.includes('B-was-here'),
+);
+
+/* ——— the merged doc still validates against the tile schema ——— */
+let valid = true;
+try {
+ schema.nodeFromJSON(yDocToProsemirrorJSON(peerA, 'prosemirror')).check();
+} catch (err) {
+ valid = false;
+ console.error(err);
+}
+check('merged document validates against the schema', valid);
+
+console.log(failures ? `\n${failures} FAILED` : '\nall green');
+Deno.exit(failures ? 1 : 0);
diff --git a/src/sync/signedout.test.ts b/src/sync/signedout.test.ts
index 82adce9..d6ef3c4 100644
--- a/src/sync/signedout.test.ts
+++ b/src/sync/signedout.test.ts
@@ -24,6 +24,7 @@ const KEY = 'so-key';
function spawnApp(withDevIdentity: boolean): Deno.ChildProcess {
const env: Record = {
+ ZIRAN_SYNC: 'relay', // this suite exercises the relay stack
ZIRAN_DATA_DIR: join(tmp, 'app'),
ZIRAN_KEY: KEY,
ZIRAN_PORT: String(PORT),
diff --git a/src/sync/spaces/causal.ts b/src/sync/spaces/causal.ts
new file mode 100644
index 0000000..74041da
--- /dev/null
+++ b/src/sync/spaces/causal.ts
@@ -0,0 +1,82 @@
+// Causal delivery for space.ziran.op records (docs/SYNC-SPACES.md, Ordering).
+//
+// Repos advance independently — there is no total order across authors. The
+// contract this buffer enforces:
+// - per-repo FIFO (repos are rev-ordered; we deliver each repo's ops in
+// fetch order, never skipping ahead);
+// - an op waits until every parent it names has been SETTLED — delivered to
+// the tile, echoed locally, or absorbed by the checkpoint we instantiated
+// from. The caller owns that knowledge (a persisted per-doc set) and
+// lends it to the buffer as a predicate;
+// - concurrent ready ops deliver in deterministic (rev, author) order.
+//
+// Membership is by exact (author, rkey), never a watermark: one DID can be
+// several devices writing the same repo with independently generated rkeys,
+// so rkeys are not monotonic per author, and a parent can be written after
+// our last fetch of its author — absence must hold the op, not satisfy it.
+
+export interface OpParent {
+ author: string;
+ rkey: string;
+}
+
+export interface CausalOp {
+ author: string;
+ rkey: string;
+ rev: string;
+ parents: OpParent[];
+ /** The op record value (doc, bytes, …), opaque to the buffer. */
+ value: Record;
+}
+
+export class CausalBuffer {
+ #settled: (author: string, rkey: string) => boolean;
+ #pending = new Map();
+
+ constructor(settled: (author: string, rkey: string) => boolean) {
+ this.#settled = settled;
+ }
+
+ /** Queue a fetched op. Ops must arrive per-author in ascending rev order
+ (which listRepoOps guarantees); the caller has already deduplicated. */
+ push(op: CausalOp): void {
+ const queue = this.#pending.get(op.author) ?? [];
+ if (queue.some((q) => q.rkey === op.rkey)) return;
+ queue.push(op);
+ this.#pending.set(op.author, queue);
+ }
+
+ /** Every op that can be delivered now, in deterministic (rev, author)
+ order. The caller settles each delivered op (so its children unlock);
+ the scan repeats until quiescent. */
+ drain(onDeliver: (op: CausalOp) => void): CausalOp[] {
+ const out: CausalOp[] = [];
+ for (;;) {
+ const ready: CausalOp[] = [];
+ for (const queue of this.#pending.values()) {
+ const head = queue[0];
+ if (head && head.parents.every((p) => this.#settled(p.author, p.rkey))) ready.push(head);
+ }
+ if (ready.length === 0) return out;
+ ready.sort((a, b) => (a.rev < b.rev ? -1 : a.rev > b.rev ? 1 : a.author < b.author ? -1 : 1));
+ const op = ready[0]!;
+ const queue = this.#pending.get(op.author)!;
+ queue.shift();
+ if (queue.length === 0) this.#pending.delete(op.author);
+ onDeliver(op);
+ out.push(op);
+ }
+ }
+
+ /** True while this exact op sits undelivered in a queue. */
+ pendingHas(author: string, rkey: string): boolean {
+ return this.#pending.get(author)?.some((q) => q.rkey === rkey) ?? false;
+ }
+
+ /** Ops fetched but not yet deliverable (missing parents). */
+ pendingCount(): number {
+ let n = 0;
+ for (const queue of this.#pending.values()) n += queue.length;
+ return n;
+ }
+}
diff --git a/src/sync/spaces/checkpoint.ts b/src/sync/spaces/checkpoint.ts
new file mode 100644
index 0000000..c3d10f1
--- /dev/null
+++ b/src/sync/spaces/checkpoint.ts
@@ -0,0 +1,103 @@
+// Atile-style checkpoints (docs/SYNC-SPACES.md, Data model): a published
+// snapshot of a document is its tile manifest with every resource — tile code
+// AND the self-storage data files — uploaded as a blob and the manifest `src`
+// replaced by an atproto blob ref. Never a full-.tile blob. The same artifact
+// a future "publish for static consumption" needs.
+//
+// Conventions follow @dasl/atile (tile-at.ts): blob CIDs are CIDv1/raw/sha-256
+// — identical to tile resource CIDs, so dedup and verification are string
+// comparisons — and the recorded `cid` is the DRISL (dCBOR) CID of the
+// manifest itself.
+
+import { encode as encodeCbor } from '@atcute/cbor';
+import { CODEC_DCBOR, create, toString as stringifyCID } from '@atcute/cid';
+import { type Masl, parseTile, readResource, writeTile } from '../../server/tilefile.ts';
+import { type BlobRef, rawCid, type SpaceClient } from './client.ts';
+
+export interface CheckpointRecord {
+ $type: 'space.ziran.checkpoint';
+ doc: string;
+ tile: Masl;
+ cid: string;
+ frontier: Record;
+ createdAt: string;
+}
+
+const blobCidOf = (entry: Record): string | undefined => {
+ const src = entry.src as { $link?: string; ref?: { $link?: string } } | undefined;
+ return src?.ref?.$link ?? src?.$link;
+};
+
+/** Publish the tile at `path` as this author's checkpoint for the doc
+ (rkey = the doc's rkey; putRecord replaces any previous own checkpoint).
+ Blobs whose CID is in `uploadedCids` are not re-uploaded — CIDs match by
+ construction. Returns the full set of blob CIDs now backing the record. */
+export async function publishCheckpoint(
+ client: SpaceClient,
+ space: string,
+ docRkey: string,
+ path: string,
+ frontier: Record,
+ uploadedCids: ReadonlySet,
+): Promise<{ blobCids: string[] }> {
+ const tile = await parseTile(path, { verify: false });
+ const manifest = structuredClone(tile.manifest) as Masl;
+ const blobCids: string[] = [];
+ for (const [resPath, entry] of Object.entries(manifest.resources)) {
+ const bytes = readResource(tile, resPath);
+ if (!bytes) throw new Error(`resource ${resPath} is missing from ${path}`);
+ const cid = await rawCid(bytes);
+ if (!uploadedCids.has(cid)) {
+ const blob = await client.uploadBlob(
+ bytes,
+ (entry as Record)['content-type'] ?? 'application/octet-stream',
+ );
+ if (blob.ref.$link !== cid) {
+ throw new Error(`PDS blob CID ${blob.ref.$link} does not match resource CID ${cid} for ${resPath}`);
+ }
+ }
+ blobCids.push(cid);
+ const ref: BlobRef = {
+ $type: 'blob',
+ ref: { $link: cid },
+ mimeType: (entry as Record)['content-type'] ?? 'application/octet-stream',
+ size: bytes.length,
+ };
+ manifest.resources[resPath] = { ...entry, src: ref as unknown as { $link: string } };
+ }
+ const manifestCid = stringifyCID(await create(CODEC_DCBOR, encodeCbor(manifest)));
+ const record: CheckpointRecord = {
+ $type: 'space.ziran.checkpoint',
+ doc: docRkey,
+ tile: manifest,
+ cid: manifestCid,
+ frontier,
+ createdAt: new Date().toISOString(),
+ };
+ await client.putRecord(space, 'space.ziran.checkpoint', docRkey, record as unknown as Record);
+ return { blobCids };
+}
+
+/** Rebuild a .tile file from a checkpoint: download every resource blob from
+ the checkpoint author's repo, verify each against its CID, and assemble
+ with the normal tile writer (which recomputes and re-verifies CIDs). */
+export async function instantiateFromCheckpoint(
+ client: SpaceClient,
+ space: string,
+ author: string,
+ checkpoint: CheckpointRecord,
+ destPath: string,
+): Promise {
+ const manifest = structuredClone(checkpoint.tile) as Masl;
+ const resources = new Map();
+ for (const [resPath, entry] of Object.entries(manifest.resources)) {
+ const cid = blobCidOf(entry);
+ if (!cid) throw new Error(`checkpoint resource ${resPath} has no blob ref`);
+ const bytes = await client.getBlob(space, author, cid);
+ if ((await rawCid(bytes)) !== cid) {
+ throw new Error(`blob for ${resPath} does not match its address ${cid}`);
+ }
+ resources.set(resPath, bytes);
+ }
+ await writeTile(manifest, resources, destPath);
+}
diff --git a/src/sync/spaces/client.ts b/src/sync/spaces/client.ts
index c124247..9babda8 100644
--- a/src/sync/spaces/client.ts
+++ b/src/sync/spaces/client.ts
@@ -63,6 +63,13 @@ export class XrpcError extends Error {
export type SpacePolicy = 'memberList' | 'public';
+export interface SpaceWrite {
+ action: 'create' | 'update' | 'delete';
+ collection: string;
+ rkey?: string;
+ record?: Record;
+}
+
export interface RepoHead {
did: string;
rev: string;
@@ -241,6 +248,26 @@ export class SpaceClient {
return cred;
}
+ /* ——— space discovery (the caller's own PDS) ——— */
+
+ /** Spaces this account holds a repo in (i.e. has written into) — the
+ same-account multi-device discovery path. */
+ async listSpaces(): Promise {
+ const out: string[] = [];
+ let cursor: string | undefined;
+ do {
+ const page = await this.#ownCall('com.atproto.space.listSpaces', {
+ params: cursor ? { cursor } : {},
+ });
+ for (const s of page.spaces ?? []) {
+ const uri = typeof s === 'string' ? s : (s.uri ?? s.space);
+ if (typeof uri === 'string') out.push(uri);
+ }
+ cursor = page.cursor;
+ } while (cursor);
+ return out;
+ }
+
/* ——— space management (simplespace, at the caller's own PDS) ——— */
async createSpace(type: string, opts: { skey?: string; policy: SpacePolicy; appAllowed?: string[] } ): Promise {
@@ -297,6 +324,26 @@ export class SpaceClient {
});
}
+ /** Batched writes in one commit — one rate-limit charge per record but a
+ single round-trip; op batching rides on this (adaptive batching is
+ load-bearing against the 1 MB JSON cap and write rate limits). */
+ async applyWrites(space: string, writes: SpaceWrite[]): Promise> {
+ const res = await this.#ownCall('com.atproto.space.applyWrites', {
+ body: {
+ space,
+ repo: this.auth.did,
+ validate: false,
+ writes: writes.map((w) => ({
+ $type: `com.atproto.space.applyWrites#${w.action}`,
+ collection: w.collection,
+ ...(w.rkey ? { rkey: w.rkey } : {}),
+ ...(w.record ? { value: w.record } : {}),
+ })),
+ },
+ });
+ return res.results ?? [];
+ }
+
async uploadBlob(bytes: Uint8Array, mimeType: string): Promise {
const res = await this.#ownCall('com.atproto.repo.uploadBlob', { rawBody: bytes, encoding: mimeType });
return res.blob as BlobRef;
@@ -341,15 +388,20 @@ export class SpaceClient {
}
}
- async listRecords(space: string, repo: string, collection: string): Promise }>> {
+ /** Live wire truth: entries carry `{collection, rkey, cid, value}` — no
+ uri (verified against pds.ziran.space); rkey is normalized here in case
+ other builds send a uri instead. */
+ async listRecords(space: string, repo: string, collection: string): Promise }>> {
const base = await this.resolvePds(repo);
- const out: Array<{ uri: string; cid: string; value: Record }> = [];
+ const out: Array<{ rkey: string; cid: string; value: Record }> = [];
let cursor: string | undefined;
do {
const params: Record = { space, repo, collection };
if (cursor) params.cursor = cursor;
const page = await this.#credCall(space, base, 'com.atproto.space.listRecords', params);
- out.push(...(page.records ?? []));
+ for (const r of page.records ?? []) {
+ out.push({ ...r, rkey: r.rkey ?? String(r.uri ?? '').split('/').pop() ?? '' });
+ }
cursor = page.cursor;
} while (cursor);
return out;
diff --git a/src/sync/spaces/fake.ts b/src/sync/spaces/fake.ts
index ae75664..1f0274e 100644
--- a/src/sync/spaces/fake.ts
+++ b/src/sync/spaces/fake.ts
@@ -206,6 +206,52 @@ export class FakeSpacesNetwork {
return json({ credential: token });
}
+ case 'com.atproto.space.listSpaces': {
+ const did = this.#authedDid(req);
+ if (!did) return err(401, 'AuthMissing');
+ const spaces = [...this.#spaces.entries()]
+ .filter(([, s]) => s.repos.has(did))
+ .map(([uri]) => ({ uri }));
+ return json({ spaces });
+ }
+
+ case 'com.atproto.space.applyWrites': {
+ const did = this.#authedDid(req);
+ if (!did) return err(401, 'AuthMissing');
+ const body = await req.json();
+ if (body.repo !== did) return err(403, 'Forbidden', 'repo must match authenticated user');
+ const space = this.#spaces.get(body.space);
+ if (!space) return err(400, 'SpaceNotFound');
+ const repo = this.#repo(space, did);
+ const results: Array> = [];
+ for (const w of body.writes ?? []) {
+ const kind = String(w.$type ?? '').split('#')[1];
+ const rev = tid();
+ if (kind === 'delete') {
+ if (repo.records.delete(`${w.collection}/${w.rkey}`)) {
+ repo.ops.push({ action: 'delete', collection: w.collection, rkey: w.rkey, rev });
+ repo.rev = rev;
+ }
+ results.push({ $type: 'com.atproto.space.applyWrites#deleteResult' });
+ continue;
+ }
+ const rkey = w.rkey ?? tid();
+ const key = `${w.collection}/${rkey}`;
+ const existed = repo.records.has(key);
+ if (kind === 'create' && existed) return err(400, 'InvalidRequest', 'record already exists');
+ const cid = `fakecid-${rev}`;
+ repo.records.set(key, { value: w.value, cid, rev });
+ repo.ops.push({ action: existed ? 'update' : 'create', collection: w.collection, rkey, rev, value: w.value });
+ repo.rev = rev;
+ results.push({
+ uri: `${body.space}/${did}/${w.collection}/${rkey}`,
+ cid,
+ $type: `com.atproto.space.applyWrites#${kind}Result`,
+ });
+ }
+ return json({ results });
+ }
+
case 'com.atproto.space.createRecord':
case 'com.atproto.space.putRecord': {
const did = this.#authedDid(req);
@@ -296,10 +342,12 @@ export class FakeSpacesNetwork {
if (who instanceof Response) return who;
const repo = space.repos.get(p.repo ?? '');
if (!repo) return err(400, 'RepoNotFound');
+ // Live wire truth: {collection, rkey, cid, value} — no uri.
const records = [...repo.records.entries()]
.filter(([k]) => k.startsWith(`${p.collection}/`))
.map(([k, r]) => ({
- uri: `${p.space}/${p.repo}/${k}`,
+ collection: p.collection,
+ rkey: k.slice(p.collection!.length + 1),
cid: r.cid,
value: r.value,
}));
diff --git a/src/sync/spaces/provider.test.ts b/src/sync/spaces/provider.test.ts
new file mode 100644
index 0000000..855b7c3
--- /dev/null
+++ b/src/sync/spaces/provider.test.ts
@@ -0,0 +1,342 @@
+// SpacesProvider against the in-memory fake network: the full Phase E loop —
+// share (go live), checkpoint, same-account second-device adoption, op
+// exchange with causal delivery, cross-member flow, restart persistence.
+// Run: deno run -A src/sync/spaces/provider.test.ts
+
+import { join } from '@std/path';
+import { CausalBuffer } from './causal.ts';
+import { passwordSession, SpaceClient } from './client.ts';
+import { FakeSpacesNetwork } from './fake.ts';
+import { SpacesProvider, type SpacesHost } from './provider.ts';
+import { docIdForPath, parseTile, writeTile } from '../../server/tilefile.ts';
+
+let failures = 0;
+const check = (label: string, ok: boolean, detail = '') => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
+ if (!ok) failures++;
+};
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const b64 = (s: string) => btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+const unb64 = (s: string) => atob(s.replace(/-/g, '+').replace(/_/g, '/'));
+
+/* ——— causal buffer unit checks ——— */
+{
+ const settled = new Set();
+ const buf = new CausalBuffer((a, r) => settled.has(`${a}/${r}`));
+ const drain = () => buf.drain((op) => settled.add(`${op.author}/${op.rkey}`));
+ // b2 depends on a1, which arrives later.
+ buf.push({ author: 'did:b', rkey: 'b2', rev: 'r4', parents: [{ author: 'did:a', rkey: 'a1' }], value: {} });
+ check('op waits for its parent', drain().length === 0 && buf.pendingCount() === 1);
+ buf.push({ author: 'did:a', rkey: 'a1', rev: 'r1', parents: [], value: {} });
+ const order = drain().map((o) => o.rkey);
+ check('parent unlocks the child, parent first', order.join(',') === 'a1,b2', order.join(','));
+ // Concurrent siblings deliver in (rev, author) order.
+ buf.push({ author: 'did:b', rkey: 'b9', rev: 'r9', parents: [], value: {} });
+ buf.push({ author: 'did:a', rkey: 'a5', rev: 'r5', parents: [], value: {} });
+ check(
+ 'concurrent siblings in deterministic (rev, author) order',
+ drain().map((o) => o.rkey).join(',') === 'a5,b9',
+ );
+ // A checkpoint-absorbed parent is settled by the caller before replay.
+ settled.add('did:b/ancient');
+ buf.push({ author: 'did:a', rkey: 'a6', rev: 'r6', parents: [{ author: 'did:b', rkey: 'ancient' }], value: {} });
+ check('checkpoint-absorbed parent is satisfied', drain().length === 1);
+ // An unknown parent holds the op — even if written after our last fetch.
+ buf.push({ author: 'did:a', rkey: 'a7', rev: 'r7', parents: [{ author: 'did:c', rkey: 'c1' }], value: {} });
+ check('an unknown parent holds the op', drain().length === 0);
+}
+
+/* ——— provider fixtures ——— */
+
+const net = new FakeSpacesNetwork();
+const resolver = { plcDirectory: net.url };
+
+interface Fixture {
+ provider: SpacesProvider;
+ events: Array>;
+ dir: string;
+ did: string;
+ openPath(path: string): Promise<{ id: string }>;
+}
+
+async function fixture(account: string, dirLabel: string): Promise {
+ const acct = net.account(account);
+ const dir = await Deno.makeTempDir({ prefix: `spaces-${dirLabel}-` });
+ const docs = new Map } }>();
+ const events: Array> = [];
+ const openPath = async (path: string) => {
+ const tile = await parseTile(path);
+ if (tile.damaged) throw new Error(`damaged tile: ${tile.damage}`);
+ const id = await docIdForPath(path);
+ docs.set(id, {
+ info: { path, name: (tile.manifest.name as string) ?? 'doc' },
+ tile: { manifest: tile.manifest as Record },
+ });
+ return { id };
+ };
+ const host: SpacesHost = {
+ doc: (id) => docs.get(id),
+ openPath,
+ isOpen: () => true,
+ broadcast: () => {},
+ broadcastEvent: (e) => events.push(e),
+ };
+ const provider = new SpacesProvider({
+ host,
+ auth: async () => await passwordSession(net.url, acct.handle, acct.password),
+ clientFor: (auth) => new SpaceClient({ auth, resolver }),
+ registryFile: join(dir, 'spaces.json'),
+ documentsDir: join(dir, 'documents'),
+ autoPoll: false,
+ });
+ await provider.init();
+ return { provider, events, dir, did: acct.did, openPath };
+}
+
+async function makeTile(dir: string, name: string): Promise {
+ const path = join(dir, `${name.replace(/\s+/g, '-')}.tile`);
+ await writeTile(
+ { name, model: { id: 'com.berjon.ziran.testtile' }, resources: {} },
+ new Map([
+ ['/', new TextEncoder().encode('t ')],
+ ['/.well-known/web-tiles-storage/state', new TextEncoder().encode('{}')],
+ ]),
+ path,
+ );
+ return path;
+}
+
+const msgs = (events: Array>, docId: string) =>
+ events.filter((e) => e.type === 'sync-msg' && e.docId === docId);
+
+/* ——— share on device A ——— */
+
+const a = await fixture('alice', 'deviceA');
+const tilePath = await makeTile(a.dir, 'Shared Doc');
+const { id: aDocId } = await a.openPath(tilePath);
+const rec = await a.provider.share(aDocId, { newSpaceName: 'Test Space' });
+check('share created a workspace space', rec.space.includes('/space/space.ziran.workspace/'), rec.space);
+check('doc record uri stamped into the tile manifest', (await parseTile(tilePath)).manifest.at === rec.at);
+
+const inspector = new SpaceClient({
+ auth: await passwordSession(net.url, net.account('alice').handle, net.account('alice').password),
+ resolver,
+});
+const cp = await inspector.getRecord(rec.space, a.did, 'space.ziran.checkpoint', rec.docRkey);
+check('go-live checkpoint published', cp.value.$type === 'space.ziran.checkpoint');
+const cpResources = (cp.value.tile as { resources: Record }).resources;
+check(
+ 'checkpoint resources are blob refs (atile-style, never a full tile)',
+ Object.values(cpResources).every((r) => r.src.$type === 'blob'),
+);
+
+await a.provider.send(aDocId, b64('one'));
+await a.provider.send(aDocId, b64('two'));
+await sleep(500); // quiet-flush window
+const aEcho = msgs(a.events, aDocId);
+check('own frames echo back with local seqs', aEcho.length === 2 && aEcho[0]!.seq === 1 && aEcho[1]!.seq === 2);
+check('echoed frames carry the owner role', aEcho.every((m) => m.author === a.did && m.role === 'author'));
+
+/* ——— same-account second device: discovery + adoption ——— */
+
+const b = await fixture('alice', 'deviceB');
+await b.provider.pollNow(true);
+const bSummary = b.provider.summary();
+check('device B discovers the space via listSpaces', bSummary.spaces?.some((s) => s.ref === rec.space) === true);
+const avail = bSummary.spaces?.find((s) => s.ref === rec.space)?.available ?? [];
+check('device B sees the doc as available', avail.some((d) => d.docRkey === rec.docRkey), JSON.stringify(avail));
+check('space name traveled via space.ziran.info', bSummary.spaces?.find((s) => s.ref === rec.space)?.name === 'Test Space');
+
+const adopted = await b.provider.adopt(rec.space, rec.docRkey);
+const bTile = await parseTile(adopted.path);
+check('adopted tile instantiates verified', !bTile.damaged, bTile.damage ?? '');
+check('adopted tile carries the doc at-uri', bTile.manifest.at === rec.at);
+const bMsgs = msgs(b.events, adopted.localId);
+check(
+ 'post-checkpoint ops backfilled to device B in order',
+ bMsgs.length === 2 && unb64(String(bMsgs[0]!.b64)) === 'one' && unb64(String(bMsgs[1]!.b64)) === 'two',
+ JSON.stringify(bMsgs.map((m) => m.seq)),
+);
+
+/* ——— cross-device exchange, same DID (rkeys interleave in one repo) ——— */
+
+await b.provider.send(adopted.localId, b64('three'));
+await sleep(500);
+await a.provider.pollNow(true);
+const aAll = msgs(a.events, aDocId);
+check('device A receives device B’s op exactly once', aAll.length === 3 && unb64(String(aAll[2]!.b64)) === 'three');
+await a.provider.pollNow(true);
+check('a second poll delivers nothing twice', msgs(a.events, aDocId).length === 3);
+await b.provider.pollNow(true);
+check('device B does not re-deliver its own echo', msgs(b.events, adopted.localId).length === 3);
+
+/* ——— cross-member flow ——— */
+
+const bobFx = await fixture('bob', 'bob');
+await inspector.addMember(rec.space, bobFx.did);
+await bobFx.provider.pollNow(true); // listSpaces won't show it: bob never wrote
+const bobAdopted = await bobFx.provider.adopt(rec.space, rec.docRkey);
+check(
+ 'a member adopts by ref before ever writing',
+ msgs(bobFx.events, bobAdopted.localId).length === 3,
+ String(msgs(bobFx.events, bobAdopted.localId).length),
+);
+await bobFx.provider.send(bobAdopted.localId, b64('four'));
+await sleep(500);
+await a.provider.pollNow(true);
+const aAfterBob = msgs(a.events, aDocId);
+check(
+ 'bob’s op reaches device A with his role',
+ aAfterBob.length === 4 && aAfterBob[3]!.author === bobFx.did && aAfterBob[3]!.role === 'editor',
+ JSON.stringify(aAfterBob[3] ?? {}),
+);
+
+// Causal wiring: bob's op should name known heads as parents (his provider
+// had delivered three ops before sending).
+const bobOps = await inspector.listRecords(rec.space, bobFx.did, 'space.ziran.op');
+const bobParents = (bobOps[0]?.value.parents ?? []) as Array<{ author: string }>;
+check('ops carry causal parents', bobParents.length > 0 && bobParents.every((p) => p.author === a.did));
+
+/* ——— acked state + restart persistence ——— */
+
+await a.provider.onLocalDataWrite(aDocId);
+const att = a.provider.attach(aDocId);
+check('after putData the buffered backlog is trimmed', att !== null && att.buffered.length === 0);
+
+const a2 = await fixture2Restart();
+async function fixture2Restart(): Promise {
+ a.provider.close();
+ const acct = net.account('alice');
+ const docs = new Map } }>();
+ const events: Array> = [];
+ const openPath = async (path: string) => {
+ const tile = await parseTile(path);
+ const id = await docIdForPath(path);
+ docs.set(id, {
+ info: { path, name: (tile.manifest.name as string) ?? 'doc' },
+ tile: { manifest: tile.manifest as Record },
+ });
+ return { id };
+ };
+ await openPath(tilePath);
+ const provider = new SpacesProvider({
+ host: { doc: (id) => docs.get(id), openPath, isOpen: () => true, broadcast: () => {}, broadcastEvent: (e) => events.push(e) },
+ auth: async () => await passwordSession(net.url, acct.handle, acct.password),
+ clientFor: (auth) => new SpaceClient({ auth, resolver }),
+ registryFile: join(a.dir, 'spaces.json'),
+ documentsDir: join(a.dir, 'documents'),
+ autoPoll: false,
+ });
+ await provider.init();
+ return { provider, events, dir: a.dir, did: acct.did, openPath };
+}
+await a2.provider.pollNow(true);
+check('restart re-delivers nothing already processed', msgs(a2.events, aDocId).length === 0);
+const att2 = a2.provider.attach(aDocId);
+check('restarted registry still knows the doc', att2 !== null && att2.me.role === 'author');
+await a2.provider.send(aDocId, b64('five'));
+await sleep(500);
+await b.provider.pollNow(true);
+// Device B has not polled since bob's op: it now catches both 'four' and 'five'.
+const bFinal = msgs(b.events, adopted.localId);
+check(
+ 'post-restart ops still flow to other devices',
+ bFinal.length === 5 && unb64(String(bFinal[3]!.b64)) === 'four' && unb64(String(bFinal[4]!.b64)) === 'five',
+ String(bFinal.length),
+);
+
+/* ——— explicit publish (third checkpoint trigger) ——— */
+
+await a2.provider.publish(aDocId);
+const cp2 = await inspector.getRecord(rec.space, a.did, 'space.ziran.checkpoint', rec.docRkey);
+check('explicit publish refreshes the checkpoint', String(cp2.value.createdAt) >= String(cp.value.createdAt));
+
+/* ——— presence (Phase G): beacons while open, aging out ——— */
+
+await a2.provider.pollNow(true); // fixture hosts report every doc open → beacon written
+const presence = await inspector.getRecord(rec.space, a.did, 'space.ziran.presence', rec.docRkey);
+check('presence beacon written while the doc is open', presence.value.$type === 'space.ziran.presence');
+await b.provider.pollNow(true);
+const bRoster = b.provider.attach(adopted.localId)?.roster ?? [];
+check(
+ 'fresh presence marks the member present',
+ bRoster.find((m) => m.did === a.did)?.present === true,
+ JSON.stringify(bRoster),
+);
+// A member whose beacon has aged past the freshness window reads as away.
+const bobClient = new SpaceClient({
+ auth: await passwordSession(net.url, net.account('bob').handle, net.account('bob').password),
+ resolver,
+});
+await bobClient.putRecord(rec.space, 'space.ziran.presence', rec.docRkey, {
+ $type: 'space.ziran.presence',
+ doc: rec.docRkey,
+ at: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
+});
+await b.provider.pollNow(true);
+const bRoster2 = b.provider.attach(adopted.localId)?.roster ?? [];
+check(
+ 'a stale beacon reads as away',
+ bRoster2.find((m) => m.did === bobFx.did)?.present === false,
+ JSON.stringify(bRoster2),
+);
+
+/* ——— invitations: inbox delivery, accept, dismissal persistence ——— */
+
+const carol = await fixture('carol', 'carol'); // init auto-creates her inbox
+const invited = await a2.provider.invite(aDocId, carol.did, 'editor');
+check('invite returns a space link', invited.link.startsWith('ziran://space?ref='), invited.link);
+check('invite delivered to an existing inbox', invited.invitation.pending !== true);
+
+const notOwner = await bobFx.provider.invite(bobAdopted.localId, 'did:plc:fakedave', 'editor').catch((e) => e);
+check('non-owner invite is refused', notOwner instanceof Error && /owner/.test(notOwner.message), String(notOwner));
+
+await carol.provider.pollNow(true);
+const carolInv = carol.provider.summary().invitations;
+check(
+ 'carol sees the invitation with the space name',
+ carolInv.length === 1 && carolInv[0]!.title === 'Test Space' && carolInv[0]!.inviterDid === a.did,
+ JSON.stringify(carolInv),
+);
+const { path: carolPath } = await carol.provider.accept(carolInv[0]!.token);
+check('accept instantiates the doc', (await parseTile(carolPath)).manifest.at === rec.at);
+const carolMsgs = msgs(carol.events, (await carol.openPath(carolPath)).id);
+check(
+ 'accept replays ops past the checkpoint frontier',
+ carolMsgs.length >= 1 && carolMsgs.some((m) => unb64(String(m.b64)) === 'five'),
+ JSON.stringify(carolMsgs.map((m) => unb64(String(m.b64)))),
+);
+check('accepting consumed the invitation', carol.provider.summary().invitations.length === 0);
+
+// Dismissal survives restart: the record still sits in the inbox (it belongs
+// to the inviter), but a fresh provider must not resurrect it.
+carol.provider.close();
+const carolAgain = new SpacesProvider({
+ host: {
+ doc: () => undefined,
+ openPath: async (p) => ({ id: await docIdForPath(p) }),
+ isOpen: () => true,
+ broadcast: () => {},
+ broadcastEvent: () => {},
+ },
+ auth: async () => await passwordSession(net.url, net.account('carol').handle, net.account('carol').password),
+ clientFor: (auth) => new SpaceClient({ auth, resolver }),
+ registryFile: join(carol.dir, 'spaces.json'),
+ documentsDir: join(carol.dir, 'documents'),
+ autoPoll: false,
+});
+await carolAgain.init();
+await carolAgain.pollNow(true);
+check('a handled invitation stays handled after restart', carolAgain.summary().invitations.length === 0);
+carolAgain.close();
+
+// Inviting someone with no inbox yet: membership lands, delivery cannot.
+const inboxless = await a2.provider.invite(aDocId, net.account('dave').did, 'editor');
+check('inviting an inboxless identity reports pending + link', inboxless.invitation.pending === true);
+
+b.provider.close();
+bobFx.provider.close();
+a2.provider.close();
+await net.close();
+console.log(failures ? `\n${failures} FAILED` : '\nall green');
+Deno.exit(failures ? 1 : 0);
diff --git a/src/sync/spaces/provider.ts b/src/sync/spaces/provider.ts
new file mode 100644
index 0000000..a44b7d5
--- /dev/null
+++ b/src/sync/spaces/provider.ts
@@ -0,0 +1,1041 @@
+// SpacesProvider: the CollabProvider built on AT Proto Spaces
+// (docs/SYNC-SPACES.md). No relay, no MLS, no E2EE — every member's
+// contribution lives in their own PDS repo under a standard protocol, and the
+// runtime pulls: listRepos for change detection, listRepoOps for increments,
+// checkpoints + blobs for instantiation. Ordering is causal (parents on ops),
+// not total; ephemerals are presence records (Phase G) — sendEphemeral is a
+// no-op here.
+//
+// The tile-facing contract is identical to the relay's: attributed frames
+// with a local delivery seq, `sync-msg`/`sync-ready`/`sync-roster` events
+// over the store's sockets, ackedSeq advanced when the tile persists. Tiles
+// cannot tell which provider is underneath.
+
+import { join } from '@std/path';
+import type {
+ AttachState,
+ BufferedFrame,
+ CollabProvider,
+ ProviderSummary,
+ ShareOptions,
+ SpaceSummary,
+} from '../provider.ts';
+import type { Invitation, Member, SyncManifest } from '../protocol.ts';
+import { fromB64, toB64 } from '../protocol.ts';
+import { authorityOf, fromBytesJson, type RepoOp, type SessionAuth, SpaceClient, toBytesJson, type XrpcError } from './client.ts';
+import { CausalBuffer, type CausalOp, type OpParent } from './causal.ts';
+import { type DocRec, loadRegistry, saveRegistry, type SpaceRec, type SpacesRegistry } from './registry.ts';
+import { type CheckpointRecord, instantiateFromCheckpoint, publishCheckpoint } from './checkpoint.ts';
+import { setTileAt } from '../../server/tilefile.ts';
+import { resolveIdentity } from '../atproto.ts';
+
+/** Runtime-enforced defaults when a tile's manifest declares no sync roles. */
+const DEFAULT_MANIFEST: SyncManifest = {
+ owner: 'author',
+ roles: {
+ author: { write: true, admin: true },
+ editor: { write: true },
+ watcher: { write: false },
+ },
+};
+
+/** Client-side TID (13-char base32-sortable, atproto's rkey scheme): op
+ batches need their rkeys BEFORE the write so the intra-batch parent chain
+ is correct in the records as written. */
+let tidLast = 0;
+function tid(): string {
+ let us = Date.now() * 1000;
+ if (us <= tidLast) us = tidLast + 1;
+ tidLast = us;
+ const value = BigInt(us) * 1024n + BigInt(Math.floor(Math.random() * 1024));
+ const alphabet = '234567abcdefghijklmnopqrstuvwxyz';
+ let out = '';
+ for (let shift = 60n; shift >= 0n; shift -= 5n) {
+ out += alphabet[Number((value >> shift) & 31n)];
+ }
+ return out;
+}
+
+/** Every signed-in identity gets a public-policy inbox space at a ref anyone
+ can construct from the DID alone — that is how invites travel, since
+ addMember does not notify the member. */
+const inboxRef = (did: string) => `at://${did}/space/space.ziran.inbox/self`;
+
+const HOT_POLL_MS = 2_000;
+const WARM_POLL_MS = 30_000;
+const LIST_SPACES_MS = 60_000;
+const INBOX_POLL_MS = 60_000;
+/** Presence: a timestamp record refreshed while a doc is open; peers count
+ an author present while it is younger than the freshness window. One
+ write per minute per member — the whole ephemeral story under Spaces
+ (cursor streaming is deliberately off). */
+const PRESENCE_WRITE_MS = 60_000;
+const PRESENCE_FRESH_MS = 150_000;
+const FLUSH_QUIET_MS = 300;
+const FLUSH_MAX_MS = 2_500;
+const CHECKPOINT_OPS = 500;
+const CHECKPOINT_BYTES = 512 * 1024;
+const CHECKPOINT_MIN_MS = 60 * 60 * 1000;
+
+export interface SpacesHost {
+ doc(localId: string): { info: { path: string; name: string }; tile: { manifest: Record } } | undefined;
+ openPath(path: string): Promise<{ id: string }>;
+ /** True when the doc is open in some window — drives hot polling. */
+ isOpen(localId: string): boolean;
+ broadcast(): void;
+ broadcastEvent(event: Record): void;
+}
+
+export interface SpacesProviderOptions {
+ host: SpacesHost;
+ /** The authenticated session, when there is one — consulted on every
+ (re)connect, so signing in/out swaps identities like the relay does. */
+ auth: () => Promise;
+ registryFile: string;
+ documentsDir: string;
+ clientFor?: (auth: SessionAuth) => SpaceClient;
+ /** AT block for the summary (sign-in state as the UI shows it). */
+ atInfo?: () => ProviderSummary['at'];
+ /** True when identity comes from developer/env credentials rather than a
+ real sign-in (the UI's invite gate treats it like ZIRAN_DID). */
+ devIdentity?: () => boolean;
+ handle?: () => string | undefined;
+ /** Tests: disable timers and drive polls with pollNow(). */
+ autoPoll?: boolean;
+}
+
+interface DiscoveredDoc {
+ docRkey: string;
+ name: string;
+ creator: string;
+ roles: Record;
+ at: string;
+}
+
+interface LiveDoc {
+ rec: DocRec;
+ causal: CausalBuffer;
+ /** In-memory mirror of rec.delivered, for O(1) settled checks. */
+ settled: Map>;
+ buffer: BufferedFrame[];
+ outbox: Uint8Array[];
+ flushTimer?: number;
+ firstQueuedAt?: number;
+ flushing: boolean;
+}
+
+export class SpacesProvider implements CollabProvider {
+ #opts: SpacesProviderOptions;
+ #registry: SpacesRegistry = { spaces: [], docs: [], dismissedInvites: [] };
+ #live = new Map(); // by localId
+ #client: SpaceClient | null = null;
+ #error: string | undefined;
+ /** Per space: discovered docs (adopted or not) and last seen writer set. */
+ #catalog = new Map>();
+ #writers = new Map();
+ #nextPoll = new Map();
+ #invitations = new Map; space: string; doc?: string }>();
+ /** `${space}/${docRkey}` → member did → last presence timestamp (ms). */
+ #presence = new Map>();
+ #lastPresenceWrite = new Map(); // by localId
+ #lastListSpaces = 0;
+ #lastInboxPoll = 0;
+ #inboxReady = false;
+ #tick: number | undefined;
+ #polling = false;
+
+ constructor(opts: SpacesProviderOptions) {
+ this.#opts = opts;
+ }
+
+ /* ——— boot / connection ——— */
+
+ async init(): Promise {
+ this.#registry = await loadRegistry(this.#opts.registryFile);
+ for (const rec of this.#registry.docs) this.#reviveDoc(rec);
+ await this.#connect();
+ if (this.#opts.autoPoll !== false) {
+ this.#tick = setInterval(() => {
+ this.pollNow().catch(() => {});
+ }, 1000) as unknown as number;
+ }
+ }
+
+ close(): void {
+ if (this.#tick !== undefined) clearInterval(this.#tick);
+ }
+
+ #reviveDoc(rec: DocRec): void {
+ const settled = new Map>(
+ Object.entries(rec.delivered).map(([author, rkeys]) => [author, new Set(rkeys)]),
+ );
+ this.#live.set(rec.localId, {
+ rec,
+ causal: new CausalBuffer((author, rkey) => settled.get(author)?.has(rkey) ?? false),
+ settled,
+ buffer: [],
+ outbox: [],
+ flushing: false,
+ });
+ }
+
+ /** Mark an op settled — delivered, echoed, or checkpoint-absorbed — in both
+ the in-memory predicate and the persisted registry record. */
+ #settle(live: LiveDoc, author: string, rkey: string): void {
+ let set = live.settled.get(author);
+ if (!set) {
+ set = new Set();
+ live.settled.set(author, set);
+ }
+ if (set.has(rkey)) return;
+ set.add(rkey);
+ (live.rec.delivered[author] ??= []).push(rkey);
+ }
+
+ async #connect(): Promise {
+ try {
+ const auth = await this.#opts.auth();
+ if (!auth) {
+ this.#client = null;
+ this.#error = undefined;
+ this.#opts.host.broadcast();
+ return;
+ }
+ if (this.#client?.auth.did !== auth.did) this.#inboxReady = false;
+ this.#client = this.#opts.clientFor?.(auth) ?? new SpaceClient({ auth });
+ this.#error = undefined;
+ await this.#refreshSpaces();
+ await this.#ensureInbox();
+ } catch (err) {
+ this.#client = null;
+ this.#error = (err as Error).message;
+ }
+ this.#opts.host.broadcast();
+ }
+
+ /** Create this identity's invitation inbox (public policy: anyone can mint
+ a credential and write an invite into their own repo there). Losing this
+ race to another device of ours is fine — either way it exists. */
+ async #ensureInbox(): Promise {
+ if (this.#inboxReady || !this.#client) return;
+ try {
+ await this.#client.createSpace('space.ziran.inbox', { skey: 'self', policy: 'public' });
+ this.#inboxReady = true;
+ } catch (err) {
+ const name = (err as XrpcError).error ?? '';
+ if (/exists/i.test(name) || /exists/i.test((err as Error).message)) {
+ this.#inboxReady = true;
+ return;
+ }
+ console.warn('spaces: could not create the invitation inbox:', (err as Error).message);
+ }
+ }
+
+ /** Same-account multi-device discovery: every space this DID has written
+ into, merged into the registry (names fill in from space.ziran.info as
+ polling reaches each authority repo). */
+ async #refreshSpaces(): Promise {
+ if (!this.#client) return;
+ this.#lastListSpaces = Date.now();
+ const refs = await this.#client.listSpaces();
+ let changed = false;
+ for (const ref of refs) {
+ if (!ref.includes('/space/space.ziran.workspace/')) continue;
+ if (!this.#registry.spaces.some((s) => s.ref === ref)) {
+ this.#registry.spaces.push({
+ ref,
+ name: '',
+ mine: ref.startsWith(`at://${this.#client.auth.did}/`),
+ repoCursors: {},
+ });
+ changed = true;
+ }
+ }
+ if (changed) await this.#persist();
+ }
+
+ /* ——— polling ——— */
+
+ /** One scheduler pass: refresh the space list occasionally, poll every due
+ space. Also the test entrypoint (autoPoll: false + await pollNow()). */
+ async pollNow(force = false): Promise {
+ if (this.#polling) return;
+ this.#polling = true;
+ try {
+ if (!this.#client) await this.#connect();
+ if (!this.#client) return;
+ if (force || Date.now() - this.#lastListSpaces > LIST_SPACES_MS) {
+ await this.#refreshSpaces().catch(() => {});
+ }
+ for (const space of this.#registry.spaces) {
+ const due = this.#nextPoll.get(space.ref) ?? 0;
+ if (!force && Date.now() < due) continue;
+ try {
+ await this.#pollSpace(space);
+ this.#error = undefined;
+ } catch (err) {
+ this.#error = (err as Error).message;
+ }
+ const hot = this.#registry.docs.some((d) => d.space === space.ref && this.#opts.host.isOpen(d.localId));
+ this.#nextPoll.set(space.ref, Date.now() + (hot ? HOT_POLL_MS : WARM_POLL_MS));
+ }
+ // Frames stranded by an earlier failed flush retry on the poll beat.
+ for (const [localId, live] of this.#live) {
+ if (live.outbox.length && !live.flushTimer && !live.flushing) {
+ await this.#flush(localId).catch(() => {});
+ }
+ }
+ await this.#writePresence().catch(() => {});
+ if (force || Date.now() - this.#lastInboxPoll > INBOX_POLL_MS) {
+ await this.#pollInbox().catch(() => {});
+ }
+ } finally {
+ this.#polling = false;
+ }
+ }
+
+ /** Sweep the own inbox space for invites written by other people (each in
+ the inviter's repo). Dismissals are app-local and persisted. */
+ async #pollInbox(): Promise {
+ const client = this.#client;
+ if (!client) return;
+ await this.#ensureInbox();
+ this.#lastInboxPoll = Date.now();
+ const inbox = inboxRef(client.auth.did);
+ let repos;
+ try {
+ repos = await client.listRepos(inbox);
+ } catch {
+ return; // no inbox yet (creation failed above); retried next sweep
+ }
+ let changed = false;
+ for (const repo of repos) {
+ if (repo.did === client.auth.did) continue;
+ const records = await client.listRecords(inbox, repo.did, 'space.ziran.invite').catch(() => []);
+ for (const record of records) {
+ const token = record.rkey;
+ if (this.#registry.dismissedInvites.includes(token) || this.#invitations.has(token)) continue;
+ const v = record.value as { space?: string; name?: string; doc?: string; createdAt?: string };
+ if (typeof v.space !== 'string') continue;
+ const doc = typeof v.doc === 'string' ? v.doc : undefined;
+ // Already here (adopted, or a known space when no doc is named)?
+ if (doc && this.#registry.docs.some((d) => d.space === v.space && d.docRkey === doc)) continue;
+ if (!doc && this.#registry.spaces.some((s) => s.ref === v.space)) continue;
+ this.#invitations.set(token, {
+ space: v.space,
+ doc,
+ inv: {
+ token,
+ docId: doc ?? '',
+ title: v.name ?? 'a shared space',
+ inviterDid: repo.did,
+ inviteeDid: client.auth.did,
+ role: 'member',
+ createdAt: Date.parse(v.createdAt ?? '') || Date.now(),
+ },
+ });
+ changed = true;
+ }
+ }
+ if (changed) this.#opts.host.broadcast();
+ }
+
+ async #pollSpace(space: SpaceRec): Promise {
+ const client = this.#client!;
+ const heads = await client.listRepos(space.ref);
+ this.#writers.set(space.ref, heads.map((h) => h.did));
+ let rosterChanged = false;
+ for (const head of heads) {
+ const cursor = space.repoCursors[head.did];
+ if (cursor && cursor === head.rev) continue;
+ const { ops } = await client.allRepoOps(space.ref, head.did, cursor);
+ const advancedTo = this.#processRepoOps(space, head.did, ops);
+ if (advancedTo !== undefined) space.repoCursors[head.did] = advancedTo;
+ rosterChanged = true;
+ }
+ if (rosterChanged) {
+ await this.#persist();
+ for (const rec of this.#registry.docs) {
+ if (rec.space !== space.ref) continue;
+ this.#opts.host.broadcastEvent({ type: 'sync-roster', docId: rec.localId, roster: this.#roster(rec) });
+ }
+ this.#opts.host.broadcast();
+ }
+ }
+
+ /** Feed one author's fetched oplog tail through discovery and causal
+ delivery. Returns the rev the cursor may advance to: past everything
+ delivered or irrelevant, stopping before the first op still held for a
+ missing parent (it will be refetched). */
+ #processRepoOps(space: SpaceRec, author: string, ops: RepoOp[]): string | undefined {
+ const catalog = this.#catalog.get(space.ref) ?? new Map();
+ this.#catalog.set(space.ref, catalog);
+ const docs = this.#registry.docs.filter((d) => d.space === space.ref);
+ const byRkey = new Map(docs.map((d) => [d.docRkey, d]));
+
+ for (const op of ops) {
+ if (op.collection === 'space.ziran.info' && op.value) {
+ const spaceRec = this.#registry.spaces.find((s) => s.ref === space.ref);
+ if (spaceRec && typeof op.value.name === 'string') spaceRec.name = op.value.name;
+ continue;
+ }
+ if (op.collection === 'space.ziran.presence' && op.value && op.action !== 'delete') {
+ // rkey = doc rkey; one record per member per doc, refreshed in place.
+ const at = Date.parse(String(op.value.at ?? ''));
+ if (Number.isFinite(at)) {
+ const key = `${space.ref}/${op.rkey}`;
+ const perDoc = this.#presence.get(key) ?? new Map();
+ this.#presence.set(key, perDoc);
+ if ((perDoc.get(author) ?? 0) < at) perDoc.set(author, at);
+ }
+ continue;
+ }
+ if (op.collection === 'space.ziran.doc' && op.value && op.action !== 'delete') {
+ catalog.set(op.rkey, {
+ docRkey: op.rkey,
+ name: String(op.value.name ?? 'untitled'),
+ creator: author,
+ roles: (op.value.roles as Record) ?? {},
+ at: `${space.ref}/${author}/space.ziran.doc/${op.rkey}`,
+ });
+ continue;
+ }
+ if (op.collection !== 'space.ziran.op' || !op.value || op.action === 'delete') continue;
+ const rec = byRkey.get(String(op.value.doc ?? ''));
+ if (!rec) continue; // not instantiated here; adoption backfills later
+ const live = this.#live.get(rec.localId);
+ if (!live) continue;
+ const processed = rec.processedRevs[author];
+ if (processed && op.rev <= processed) {
+ // Behind this doc's dedup line: absorbed by the checkpoint we came
+ // from, or refetched — either way settled history for the DAG.
+ this.#settle(live, author, op.rkey);
+ continue;
+ }
+ if (live.settled.get(author)?.has(op.rkey)) continue; // own echo etc.
+ live.causal.push({
+ author,
+ rkey: op.rkey,
+ rev: op.rev,
+ parents: (op.value.parents as OpParent[]) ?? [],
+ value: op.value,
+ });
+ }
+
+ for (const live of this.#live.values()) {
+ live.causal.drain((op) => {
+ this.#settle(live, op.author, op.rkey);
+ this.#deliver(live, op);
+ });
+ }
+
+ // Walk the stream in rev order, advancing the space cursor and each
+ // doc's processed-rev line past everything settled (delivered, deduped,
+ // or our own echo); stop before the first op still held for a missing
+ // parent, so it is refetched next poll.
+ let advancedTo: string | undefined;
+ for (const op of ops) {
+ if (op.collection === 'space.ziran.op' && op.value && op.action !== 'delete') {
+ const rec = byRkey.get(String(op.value.doc ?? ''));
+ const live = rec && this.#live.get(rec.localId);
+ if (rec && live) {
+ if (live.causal.pendingHas(author, op.rkey)) break; // held: refetch next poll
+ const processed = rec.processedRevs[author];
+ if (rec.unconfirmedOwn.includes(op.rkey)) {
+ rec.unconfirmedOwn = rec.unconfirmedOwn.filter((k) => k !== op.rkey);
+ if (!rec.deliveredRevs[author] || op.rev > rec.deliveredRevs[author]!) {
+ rec.deliveredRevs[author] = op.rev;
+ }
+ }
+ if (!processed || op.rev > processed) rec.processedRevs[author] = op.rev;
+ }
+ }
+ advancedTo = op.rev;
+ }
+ return advancedTo;
+ }
+
+ #deliver(live: LiveDoc, op: CausalOp): void {
+ const rec = live.rec;
+ const bytes = fromBytesJson(op.value.bytes);
+ const seq = ++rec.seq;
+ rec.deliveredRevs[op.author] = op.rev;
+ rec.heads = [
+ ...rec.heads.filter((h) => !op.parents.some((p) => p.author === h.author && p.rkey === h.rkey)),
+ { author: op.author, rkey: op.rkey },
+ ];
+ const frame: BufferedFrame = {
+ seq,
+ author: op.author,
+ role: this.#roleOf(rec, op.author),
+ b64: toB64(bytes),
+ };
+ live.buffer.push(frame);
+ this.#opts.host.broadcastEvent({ type: 'sync-msg', docId: rec.localId, ...frame });
+ }
+
+ #roleOf(rec: DocRec, did: string): string {
+ return rec.roles[did] ?? rec.roles.default ?? 'editor';
+ }
+
+ #roster(rec: DocRec): Member[] {
+ const writers = this.#writers.get(rec.space) ?? [];
+ const dids = new Set(writers);
+ const me = this.#client?.auth.did;
+ if (me) dids.add(me);
+ dids.add(rec.creator);
+ const perDoc = this.#presence.get(`${rec.space}/${rec.docRkey}`);
+ const now = Date.now();
+ return [...dids].map((did) => ({
+ did,
+ role: this.#roleOf(rec, did),
+ // This device is trivially here; everyone else is present while their
+ // presence record stays fresh.
+ present: did === me || now - (perDoc?.get(did) ?? 0) < PRESENCE_FRESH_MS,
+ }));
+ }
+
+ /** Refresh this device's presence record for every open shared doc (rkey =
+ the doc's rkey; putRecord replaces the previous beacon). */
+ async #writePresence(): Promise {
+ const client = this.#client;
+ if (!client) return;
+ const now = Date.now();
+ for (const rec of this.#registry.docs) {
+ if (!this.#opts.host.isOpen(rec.localId)) continue;
+ if (now - (this.#lastPresenceWrite.get(rec.localId) ?? 0) < PRESENCE_WRITE_MS) continue;
+ this.#lastPresenceWrite.set(rec.localId, now);
+ await client.putRecord(rec.space, 'space.ziran.presence', rec.docRkey, {
+ $type: 'space.ziran.presence',
+ doc: rec.docRkey,
+ at: new Date().toISOString(),
+ }).catch(() => {
+ this.#lastPresenceWrite.delete(rec.localId); // retried next beat
+ });
+ }
+ }
+
+ /* ——— sharing (go live) ——— */
+
+ async share(localId: string, opts: ShareOptions = {}): Promise {
+ const client = this.#requireClient();
+ const doc = this.#opts.host.doc(localId);
+ if (!doc) throw new Error('document is not open');
+ const existing = this.#registry.docs.find((d) => d.localId === localId);
+ if (existing) return existing;
+
+ // The space: an existing one I'm in, or a new named one created here.
+ let spaceRef = opts.space;
+ if (!spaceRef) {
+ const name = opts.newSpaceName?.trim();
+ if (!name) throw new Error('pick a space, or name a new one');
+ spaceRef = await client.createSpace('space.ziran.workspace', { policy: 'memberList' });
+ await client.putRecord(spaceRef, 'space.ziran.info', 'self', {
+ $type: 'space.ziran.info',
+ name,
+ createdAt: new Date().toISOString(),
+ });
+ this.#registry.spaces.push({ ref: spaceRef, name, mine: true, repoCursors: {} });
+ } else if (!this.#registry.spaces.some((s) => s.ref === spaceRef)) {
+ throw new Error('that space is not one of yours');
+ }
+
+ const sync = (doc.tile.manifest as { sync?: { roles?: SyncManifest['roles']; owner?: string } }).sync;
+ const manifest: SyncManifest = sync?.roles
+ ? { owner: sync.owner ?? Object.keys(sync.roles)[0]!, roles: sync.roles }
+ : DEFAULT_MANIFEST;
+ const defaultRole = Object.entries(manifest.roles)
+ .find(([name, spec]) => name !== manifest.owner && spec.write)?.[0] ?? manifest.owner;
+ const roles: Record = { [client.auth.did]: manifest.owner, default: defaultRole };
+
+ const model = (doc.tile.manifest as { model?: { id?: string } }).model?.id ?? 'com.berjon.ziran.unknown';
+ const { uri } = await client.createRecord(spaceRef, 'space.ziran.doc', {
+ $type: 'space.ziran.doc',
+ name: doc.info.name,
+ model,
+ roles,
+ createdAt: new Date().toISOString(),
+ });
+ const docRkey = uri.split('/').pop()!;
+
+ // The durable link between the .tile on disk and its online identity.
+ await setTileAt(doc.info.path, uri);
+
+ const rec: DocRec = {
+ localId,
+ path: doc.info.path,
+ at: uri,
+ space: spaceRef,
+ docRkey,
+ creator: client.auth.did,
+ role: manifest.owner,
+ roles,
+ manifest,
+ seq: 0,
+ ackedSeq: 0,
+ processedRevs: {},
+ unconfirmedOwn: [],
+ delivered: {},
+ deliveredRevs: {},
+ ackedRevs: {},
+ heads: [],
+ ownOpsSince: 0,
+ ownBytesSince: 0,
+ };
+ this.#registry.docs.push(rec);
+ this.#reviveDoc(rec);
+ await this.#persist();
+
+ // Go-live checkpoint (mandatory): carries the tile's resources so
+ // invitees and other devices can instantiate.
+ await this.#checkpoint(rec).catch((err) =>
+ console.warn(`spaces: go-live checkpoint failed for ${doc.info.name}:`, (err as Error).message)
+ );
+
+ this.#nextPoll.set(spaceRef, 0);
+ this.#opts.host.broadcastEvent({
+ type: 'sync-ready',
+ docId: localId,
+ me: this.#me(rec),
+ roster: this.#roster(rec),
+ });
+ this.#opts.host.broadcast();
+ return rec;
+ }
+
+ #me(rec: DocRec): Member {
+ const did = this.#client?.auth.did ?? rec.creator;
+ return { did, handle: this.#opts.handle?.(), role: this.#roleOf(rec, did) };
+ }
+
+ /* ——— adoption (instantiate a doc discovered in a space) ——— */
+
+ /** Instantiate a document another member (or another of this account's
+ devices) put in a space: newest checkpoint → blobs → verified .tile in
+ documents/ → registered and syncing from the checkpoint's frontier. */
+ async adopt(spaceRef: string, docRkey: string): Promise<{ path: string; localId: string }> {
+ const client = this.#requireClient();
+ const already = this.#registry.docs.find((d) => d.space === spaceRef && d.docRkey === docRkey);
+ if (already) return { path: already.path, localId: already.localId };
+ const catalog = this.#catalog.get(spaceRef);
+ let entry = catalog?.get(docRkey);
+ if (!entry) {
+ // Not seen by polling yet (fresh adopt by rkey): sweep member repos.
+ const repos = await client.listRepos(spaceRef);
+ for (const repo of repos) {
+ const records = await client.listRecords(spaceRef, repo.did, 'space.ziran.doc').catch(() => []);
+ const hit = records.find((r) => r.rkey === docRkey);
+ if (hit) {
+ entry = {
+ docRkey,
+ name: String(hit.value.name ?? 'untitled'),
+ creator: repo.did,
+ roles: (hit.value.roles as Record) ?? {},
+ at: `${spaceRef}/${repo.did}/space.ziran.doc/${docRkey}`,
+ };
+ break;
+ }
+ }
+ if (!entry) throw new Error('that document is not in the space');
+ }
+
+ // Newest checkpoint wins, from any author.
+ const repos = await client.listRepos(spaceRef);
+ let best: { author: string; checkpoint: CheckpointRecord } | undefined;
+ for (const repo of repos) {
+ try {
+ const { value } = await client.getRecord(spaceRef, repo.did, 'space.ziran.checkpoint', docRkey);
+ const checkpoint = value as unknown as CheckpointRecord;
+ if (!best || String(checkpoint.createdAt) > String(best.checkpoint.createdAt)) {
+ best = { author: repo.did, checkpoint };
+ }
+ } catch {
+ // this member never checkpointed the doc
+ }
+ }
+ if (!best) throw new Error('no checkpoint to instantiate from — ask the sharer to publish one');
+
+ const stem = entry.name.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'shared';
+ await Deno.mkdir(this.#opts.documentsDir, { recursive: true });
+ let path = join(this.#opts.documentsDir, `${stem}.tile`);
+ try {
+ await Deno.stat(path);
+ path = join(this.#opts.documentsDir, `${stem}-${docRkey.slice(-6)}.tile`);
+ } catch {
+ // free name
+ }
+ await instantiateFromCheckpoint(client, spaceRef, best.author, best.checkpoint, path);
+ await setTileAt(path, entry.at);
+
+ const info = await this.#opts.host.openPath(path);
+ const frontier = best.checkpoint.frontier ?? {};
+ // The tile's own declared roles (for labels); assignments still come from
+ // the doc record's author.
+ const opened = this.#opts.host.doc(info.id);
+ const sync = (opened?.tile.manifest as { sync?: { roles?: SyncManifest['roles']; owner?: string } })?.sync;
+ const manifest: SyncManifest = sync?.roles
+ ? { owner: sync.owner ?? Object.keys(sync.roles)[0]!, roles: sync.roles }
+ : DEFAULT_MANIFEST;
+ const rec: DocRec = {
+ localId: info.id,
+ path,
+ at: entry.at,
+ space: spaceRef,
+ docRkey,
+ creator: entry.creator,
+ role: entry.roles[client.auth.did] ?? entry.roles.default ?? 'editor',
+ roles: entry.roles,
+ manifest,
+ seq: 0,
+ ackedSeq: 0,
+ // Everything at or behind the checkpoint frontier is inside the tile
+ // we just instantiated: processed, delivered, and acked. The replay
+ // below settles those ops' rkeys as it re-encounters them.
+ processedRevs: { ...frontier },
+ unconfirmedOwn: [],
+ delivered: {},
+ deliveredRevs: { ...frontier },
+ ackedRevs: { ...frontier },
+ heads: [],
+ ownOpsSince: 0,
+ ownBytesSince: 0,
+ };
+ this.#registry.docs.push(rec);
+ this.#reviveDoc(rec);
+
+ // Backfill by rewinding the space cursors and letting the normal poll
+ // replay the oplog: this doc's post-frontier ops deliver, every other
+ // doc's ops fall to their processed-rev dedup line.
+ let spaceRec = this.#registry.spaces.find((s) => s.ref === spaceRef);
+ if (!spaceRec) {
+ spaceRec = { ref: spaceRef, name: '', mine: false, repoCursors: {} };
+ this.#registry.spaces.push(spaceRec);
+ }
+ spaceRec.repoCursors = {};
+ await this.#pollSpace(spaceRec);
+ // An invitation this adoption answers is done, however it was accepted.
+ for (const [token, entry] of this.#invitations) {
+ if (entry.space === spaceRef && (!entry.doc || entry.doc === docRkey)) {
+ this.#invitations.delete(token);
+ this.#registry.dismissedInvites.push(token);
+ }
+ }
+ await this.#persist();
+ this.#opts.host.broadcast();
+ return { path, localId: info.id };
+ }
+
+ /* ——— tile traffic ——— */
+
+ attach(localId: string): AttachState | null {
+ const live = this.#live.get(localId);
+ if (!live) return null;
+ return {
+ me: this.#me(live.rec),
+ roster: this.#roster(live.rec),
+ buffered: live.buffer.filter((m) => m.seq > live.rec.ackedSeq),
+ };
+ }
+
+ send(localId: string, b64: string): Promise {
+ const live = this.#live.get(localId);
+ if (!live) return Promise.reject(new Error('document is not in a live session'));
+ live.outbox.push(fromB64(b64));
+ live.firstQueuedAt ??= Date.now();
+ // Coalesce: a quiet flush after 300 ms, but never hold a frame past the
+ // max window under sustained typing.
+ if (live.flushTimer !== undefined) clearTimeout(live.flushTimer);
+ const overdue = Date.now() - live.firstQueuedAt >= FLUSH_MAX_MS;
+ live.flushTimer = setTimeout(
+ () => {
+ live.flushTimer = undefined;
+ this.#flush(localId).catch((err) => {
+ console.warn('spaces: op flush failed (kept for retry):', (err as Error).message);
+ });
+ },
+ overdue ? 0 : FLUSH_QUIET_MS,
+ ) as unknown as number;
+ return Promise.resolve(0);
+ }
+
+ /** Flush the doc's queued frames as one applyWrites batch, then deliver our
+ own echo through the same path every other frame takes. */
+ async #flush(localId: string): Promise {
+ const live = this.#live.get(localId);
+ if (!live || live.flushing || live.outbox.length === 0) return;
+ const client = this.#client;
+ if (!client) return; // parked; retried on the poll beat
+ live.flushing = true;
+ const frames = live.outbox.splice(0);
+ live.firstQueuedAt = undefined;
+ try {
+ const rec = live.rec;
+ const me = client.auth.did;
+ // Client-assigned rkeys, so the intra-batch parent chain is correct in
+ // the records as written: the first op carries the doc's current heads,
+ // each next op has the previous one as its sole parent.
+ const rkeys = frames.map(() => tid());
+ const writes = frames.map((bytes, i) => ({
+ action: 'create' as const,
+ collection: 'space.ziran.op',
+ rkey: rkeys[i]!,
+ record: {
+ $type: 'space.ziran.op',
+ doc: rec.docRkey,
+ parents: i === 0 ? rec.heads : [{ author: me, rkey: rkeys[i - 1]! }],
+ bytes: toBytesJson(bytes),
+ },
+ }));
+ await client.applyWrites(rec.space, writes);
+ // Own echo, through the same delivery path every other frame takes.
+ // Settling now (a) lets children reference these ops immediately and
+ // (b) makes the poll that surfaces them in the oplog drop them as
+ // duplicates; the unconfirmed list drives the frontier bookkeeping.
+ rec.unconfirmedOwn.push(...rkeys);
+ rkeys.forEach((rkey) => this.#settle(live, me, rkey));
+ frames.forEach((bytes, i) => {
+ const seq = ++rec.seq;
+ const frame: BufferedFrame = { seq, author: me, role: rec.role, b64: toB64(bytes) };
+ live.buffer.push(frame);
+ this.#opts.host.broadcastEvent({ type: 'sync-msg', docId: rec.localId, ...frame });
+ rec.ownOpsSince += 1;
+ rec.ownBytesSince += bytes.length;
+ });
+ rec.heads = [{ author: me, rkey: rkeys[rkeys.length - 1]! }];
+ await this.#persist();
+ await this.#maybeCheckpoint(rec);
+ this.#nextPoll.set(rec.space, 0); // pull others' work opportunistically
+ } catch (err) {
+ live.outbox.unshift(...frames); // kept; the poll beat retries
+ live.firstQueuedAt ??= Date.now();
+ throw err;
+ } finally {
+ live.flushing = false;
+ }
+ }
+
+ ephemeral(_localId: string, _b64: string): void {
+ // Presence records replace the ephemeral lane under Spaces (Phase G);
+ // cursor streaming is deliberately off.
+ }
+
+ async onLocalDataWrite(localId: string): Promise {
+ const live = this.#live.get(localId);
+ if (!live) return;
+ const rec = live.rec;
+ rec.ackedSeq = rec.seq;
+ rec.ackedRevs = { ...rec.deliveredRevs };
+ live.buffer = live.buffer.filter((m) => m.seq > rec.ackedSeq);
+ await this.#persist();
+ await this.#maybeCheckpoint(rec).catch(() => {});
+ }
+
+ /* ——— checkpoints (rare + lazy) ——— */
+
+ async #maybeCheckpoint(rec: DocRec): Promise {
+ const since = Date.now() - (rec.checkpoint?.at ?? 0);
+ const due = rec.ownOpsSince >= CHECKPOINT_OPS || rec.ownBytesSince >= CHECKPOINT_BYTES;
+ if (!due || since < CHECKPOINT_MIN_MS) return;
+ await this.#checkpoint(rec);
+ }
+
+ /** Publish this machine's checkpoint for the doc: the on-disk tile, whose
+ content is exactly ackedRevs' worth of ops — that snapshot is the
+ frontier a joiner replays from. */
+ async #checkpoint(rec: DocRec): Promise {
+ const client = this.#requireClient();
+ const { blobCids } = await publishCheckpoint(
+ client,
+ rec.space,
+ rec.docRkey,
+ rec.path,
+ rec.ackedRevs,
+ new Set(rec.checkpoint?.blobCids ?? []),
+ );
+ rec.checkpoint = { at: Date.now(), blobCids };
+ rec.ownOpsSince = 0;
+ rec.ownBytesSince = 0;
+ await this.#persist();
+ }
+
+ /** An explicit "publish now" (the design's third checkpoint trigger). */
+ async publish(localId: string): Promise {
+ const rec = this.#registry.docs.find((d) => d.localId === localId);
+ if (!rec) throw new Error('document is not shared');
+ await this.#checkpoint(rec);
+ }
+
+ /* ——— invitations ——— */
+
+ /** Invite an identity into the doc's space: addMember (owner-only — the
+ simplespace restriction), record their per-doc role if we authored the
+ doc record, then deliver a space.ziran.invite into THEIR public inbox
+ space. The ziran:// link is a co-equal out-of-band path. */
+ async invite(localId: string, identifier: string, role: string): Promise<{ invitation: Invitation; link: string }> {
+ const client = this.#requireClient();
+ const rec = this.#registry.docs.find((d) => d.localId === localId);
+ if (!rec) throw new Error('go live first — pick a space for this document, then invite people to it');
+ if (!rec.manifest.roles[role]) throw new Error(`this document has no “${role}” role`);
+ if (authorityOf(rec.space) !== client.auth.did) {
+ throw new Error('only the space owner can invite people — membership belongs to the space, not the document');
+ }
+ let did = identifier.trim();
+ if (!did.startsWith('did:')) did = (await resolveIdentity(did)).did;
+ if (did === client.auth.did) throw new Error('that is your own identity');
+ await client.addMember(rec.space, did);
+ // The doc record's author is the sole authority for role assignments.
+ if (rec.creator === client.auth.did) {
+ try {
+ const { value } = await client.getRecord(rec.space, client.auth.did, 'space.ziran.doc', rec.docRkey);
+ const roles = { ...((value.roles as Record) ?? {}), [did]: role };
+ await client.putRecord(rec.space, 'space.ziran.doc', rec.docRkey, { ...value, roles });
+ rec.roles = roles;
+ await this.#persist();
+ } catch (err) {
+ console.warn('spaces: could not record the role assignment:', (err as Error).message);
+ }
+ }
+ const spaceName = this.#registry.spaces.find((s) => s.ref === rec.space)?.name || 'a shared space';
+ const link = `ziran://space?ref=${encodeURIComponent(rec.space)}&doc=${rec.docRkey}`;
+ let pending = false;
+ try {
+ await client.createRecord(inboxRef(did), 'space.ziran.invite', {
+ $type: 'space.ziran.invite',
+ space: rec.space,
+ name: spaceName,
+ doc: rec.docRkey,
+ createdAt: new Date().toISOString(),
+ });
+ } catch {
+ // No inbox to deliver into (they have not run Ziran signed-in yet).
+ // Membership is already granted; the link works the moment they do.
+ pending = true;
+ }
+ this.#opts.host.broadcast();
+ return {
+ invitation: {
+ token: rec.docRkey,
+ docId: rec.docRkey,
+ title: this.#opts.host.doc(localId)?.info.name ?? 'document',
+ inviterDid: client.auth.did,
+ inviteeDid: did,
+ role,
+ createdAt: Date.now(),
+ pending,
+ },
+ link,
+ };
+ }
+
+ async accept(token: string): Promise<{ path: string }> {
+ const entry = this.#invitations.get(token);
+ if (!entry) throw new Error('invitation not found — it may have been withdrawn');
+ const result = entry.doc
+ ? await this.adopt(entry.space, entry.doc)
+ : await this.#adoptFirstAvailable(entry.space);
+ this.dismiss(token);
+ return { path: result.path };
+ }
+
+ /** An invite naming only a space: register it, sweep it, take what's there. */
+ async #adoptFirstAvailable(space: string): Promise<{ path: string; localId: string }> {
+ let spaceRec = this.#registry.spaces.find((s) => s.ref === space);
+ if (!spaceRec) {
+ spaceRec = { ref: space, name: '', mine: false, repoCursors: {} };
+ this.#registry.spaces.push(spaceRec);
+ }
+ await this.#pollSpace(spaceRec);
+ const first = [...(this.#catalog.get(space)?.values() ?? [])][0];
+ if (!first) throw new Error('nothing in that space yet — ask them to go live with a document first');
+ return await this.adopt(space, first.docRkey);
+ }
+
+ dismiss(token: string): void {
+ if (!this.#invitations.delete(token)) return;
+ this.#registry.dismissedInvites.push(token);
+ this.#persist().catch(() => {});
+ this.#opts.host.broadcast();
+ }
+
+ /* ——— bookkeeping ——— */
+
+ moved(oldId: string, newId: string, newPath: string): void {
+ const rec = this.#registry.docs.find((d) => d.localId === oldId);
+ if (!rec) return;
+ const live = this.#live.get(oldId);
+ rec.localId = newId;
+ rec.path = newPath;
+ if (live) {
+ this.#live.delete(oldId);
+ this.#live.set(newId, live);
+ }
+ this.#persist().catch(() => {});
+ }
+
+ retryNow(): void {
+ this.#error = undefined;
+ this.#connect()
+ .then(() => this.pollNow(true))
+ .catch(() => {});
+ }
+
+ async #persist(): Promise {
+ await saveRegistry(this.#opts.registryFile, this.#registry);
+ }
+
+ #requireClient(): SpaceClient {
+ if (!this.#client) {
+ throw new Error(
+ this.#error
+ ? `your PDS is unreachable (${this.#error}) — collaboration is paused`
+ : 'going live needs your AT identity — sign in first',
+ );
+ }
+ return this.#client;
+ }
+
+ /* ——— state for the UI ——— */
+
+ summary(): ProviderSummary {
+ const did = this.#client?.auth.did ?? '';
+ const docs: ProviderSummary['docs'] = {};
+ for (const rec of this.#registry.docs) {
+ // Only the space owner can invite (simplespace restriction).
+ const canInvite = Boolean(did) && authorityOf(rec.space) === did;
+ docs[rec.localId] = {
+ syncId: rec.at,
+ role: rec.role,
+ live: Boolean(this.#client) && !this.#error,
+ members: this.#roster(rec),
+ inviteBase: '',
+ space: rec.space,
+ roles: Object.entries(rec.manifest.roles).map(([name, spec]) => ({
+ name,
+ label: spec.label ?? name.charAt(0).toUpperCase() + name.slice(1),
+ invitable: canInvite && name !== rec.manifest.owner,
+ full: false,
+ })),
+ };
+ }
+ const spaces: SpaceSummary[] = this.#registry.spaces.map((s) => ({
+ ref: s.ref,
+ name: s.name || 'unnamed space',
+ mine: s.mine,
+ available: [...(this.#catalog.get(s.ref)?.values() ?? [])]
+ .filter((d) => !this.#registry.docs.some((r) => r.space === s.ref && r.docRkey === d.docRkey))
+ .map((d) => ({ docRkey: d.docRkey, name: d.name, creator: d.creator })),
+ }));
+ return {
+ provider: 'spaces',
+ identity: { did, handle: this.#opts.handle?.() ?? did },
+ devIdentity: this.#opts.devIdentity?.() ?? false,
+ at: this.#opts.atInfo?.() ?? { signedIn: false },
+ relayOk: Boolean(this.#client) && !this.#error,
+ relay: this.#client ? new URL(this.#client.auth.pds).host : 'your PDS',
+ relayError: this.#error,
+ invitations: [...this.#invitations.values()].map((entry) => entry.inv),
+ docs,
+ spaces,
+ };
+ }
+}
diff --git a/src/sync/spaces/registry.ts b/src/sync/spaces/registry.ts
new file mode 100644
index 0000000..d3cf90d
--- /dev/null
+++ b/src/sync/spaces/registry.ts
@@ -0,0 +1,99 @@
+// Registry v2 (docs/SYNC-SPACES.md, Code architecture): the durable local
+// state of the Spaces provider — known spaces and each shared doc's identity,
+// delivery cursors, and checkpoint bookkeeping. One JSON file, written
+// atomically; everything in it is reconstructible from the network except
+// localId/path bindings and ackedSeq (which mirror this machine's files).
+
+import { dirname, join } from '@std/path';
+import type { SyncManifest } from '../protocol.ts';
+import type { OpParent } from './causal.ts';
+
+export interface SpaceRec {
+ ref: string;
+ name: string;
+ mine: boolean;
+ /** member did → last fully processed oplog rev for this space (advanced
+ only past ops that were delivered or irrelevant, so a held op is
+ refetched next poll). */
+ repoCursors: Record;
+}
+
+export interface DocRec {
+ /** Path-derived local doc id (re-keyed on move). */
+ localId: string;
+ path: string;
+ /** at-uri of the space.ziran.doc record — the doc's online identity
+ (mirrored in the tile manifest's `at` field). */
+ at: string;
+ space: string;
+ docRkey: string;
+ /** DID of the doc record's author (sole authority for role assignments). */
+ creator: string;
+ /** My role, in the tile's vocabulary. */
+ role: string;
+ /** The doc record's role assignments: DID (or 'default') → role name. The
+ record's author is the sole authority for these; tiles enforce meaning. */
+ roles: Record;
+ manifest: SyncManifest;
+ /** Last local delivery seq handed to the tile bridge. */
+ seq: number;
+ /** Seq the tile's persisted state is known to reflect. */
+ ackedSeq: number;
+ /** author did → rev up to which this doc's slice of the oplog is fully
+ processed (delivered or deduplicated, contiguously). The dedup line: a
+ refetched op at or behind it is a duplicate. Rev-based because one DID
+ can be several devices writing the same repo — revs are the only
+ per-repo total order. */
+ processedRevs: Record;
+ /** Own op rkeys flushed and locally echoed, not yet observed coming back
+ in the oplog — the own-echo dedup set. */
+ unconfirmedOwn: string[];
+ /** author did → rkeys of every op settled for this doc (delivered, echoed,
+ or checkpoint-absorbed) — the causal buffer's parent predicate. Grows
+ with history; compaction is a recorded post-alpha follow-up. */
+ delivered: Record;
+ /** author did → rev of the last op delivered to the tile. */
+ deliveredRevs: Record;
+ /** deliveredRevs snapshotted at the tile's last putData — exactly what the
+ on-disk tile file reflects, hence the checkpoint frontier. */
+ ackedRevs: Record;
+ /** Current op-DAG frontier: parents for my next op. */
+ heads: OpParent[];
+ /** Own-checkpoint bookkeeping: threshold triggers + blob dedup. */
+ checkpoint?: {
+ at: number;
+ /** Blob CIDs the last own checkpoint uploaded (skip re-upload). */
+ blobCids: string[];
+ };
+ /** Own ops / bytes written since the last own checkpoint. */
+ ownOpsSince: number;
+ ownBytesSince: number;
+}
+
+export interface SpacesRegistry {
+ spaces: SpaceRec[];
+ docs: DocRec[];
+ /** Invite tokens (record rkeys) handled or dismissed — dismissal is
+ app-local: the record belongs to the inviter and cannot be deleted. */
+ dismissedInvites: string[];
+}
+
+export async function loadRegistry(file: string): Promise {
+ try {
+ const parsed = JSON.parse(await Deno.readTextFile(file));
+ return {
+ spaces: parsed.spaces ?? [],
+ docs: parsed.docs ?? [],
+ dismissedInvites: parsed.dismissedInvites ?? [],
+ };
+ } catch {
+ return { spaces: [], docs: [], dismissedInvites: [] };
+ }
+}
+
+export async function saveRegistry(file: string, registry: SpacesRegistry): Promise {
+ await Deno.mkdir(dirname(file), { recursive: true });
+ const tmp = join(dirname(file), `.${crypto.randomUUID().slice(0, 8)}.tmp`);
+ await Deno.writeTextFile(tmp, JSON.stringify(registry, null, 2));
+ await Deno.rename(tmp, file);
+}
diff --git a/src/sync/tiles-logic.test.ts b/src/sync/tiles-logic.test.ts
new file mode 100644
index 0000000..a8ef810
--- /dev/null
+++ b/src/sync/tiles-logic.test.ts
@@ -0,0 +1,156 @@
+// Convergence proofs-by-permutation for the reducer tiles' pure logic
+// (Phase G, docs/SYNC-SPACES.md): under Spaces there is no total order, only
+// causal delivery — so every causally-valid delivery order of the same ops
+// must land every peer in the same state, echoes and optimistic local
+// applies included.
+// Run: deno run -A src/sync/tiles-logic.test.ts
+
+import { applyOp, upgrade } from '../../tiles/checklist/logic.js';
+import { derive, opsFromLegacyGames, recordOp, winnerOf } from '../../tiles/tictactoe/logic.js';
+
+let failures = 0;
+const check = (label: string, ok: boolean, detail = '') => {
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
+ if (!ok) failures++;
+};
+
+function permutations(items: T[]): T[][] {
+ if (items.length <= 1) return [items];
+ const out: T[][] = [];
+ items.forEach((item, i) => {
+ for (const rest of permutations([...items.slice(0, i), ...items.slice(i + 1)])) {
+ out.push([item, ...rest]);
+ }
+ });
+ return out;
+}
+
+/* ——— checklist ——— */
+
+interface COp {
+ author: string;
+ op: Record;
+ /** Ops this one causally depends on (delivery must respect this). */
+ after?: number[];
+}
+
+const causallyValid = (order: number[], ops: COp[]) =>
+ order.every((idx, at) => (ops[idx]!.after ?? []).every((dep) => order.indexOf(dep) < at));
+
+function checklistOutcomes(ops: COp[]): Set {
+ const outcomes = new Set();
+ for (const order of permutations(ops.map((_, i) => i))) {
+ if (!causallyValid(order, ops)) continue;
+ let items: Array> = [];
+ for (const idx of order) items = applyOp(items, ops[idx]!.op, ops[idx]!.author);
+ outcomes.add(JSON.stringify(items.map((i) => [i.id, i.text, i.done])));
+ }
+ return outcomes;
+}
+
+{
+ // Concurrent adds from two authors: same order everywhere (at, id), not
+ // arrival order.
+ const adds: COp[] = [
+ { author: 'a', op: { op: 'add', id: 'x1', text: 'one', at: 100 } },
+ { author: 'b', op: { op: 'add', id: 'x2', text: 'two', at: 50 } },
+ { author: 'a', op: { op: 'add', id: 'x3', text: 'three', at: 100 } },
+ ];
+ const outcomes = checklistOutcomes(adds);
+ check('checklist: concurrent adds converge to one order', outcomes.size === 1, `${outcomes.size} outcomes`);
+
+ // Concurrent opposite toggles on one item: LWW stamp decides, not arrival.
+ const toggles: COp[] = [
+ { author: 'a', op: { op: 'add', id: 'i', text: 't', at: 1 } },
+ { author: 'a', op: { op: 'set', id: 'i', done: true, at: 10 }, after: [0] },
+ { author: 'b', op: { op: 'set', id: 'i', done: false, at: 10 }, after: [0] },
+ ];
+ const toggled = checklistOutcomes(toggles);
+ check('checklist: concurrent set-set converges', toggled.size === 1, [...toggled].join(' vs '));
+
+ // Remove concurrent with a toggle: gone either way.
+ const removes: COp[] = [
+ { author: 'a', op: { op: 'add', id: 'i', text: 't', at: 1 } },
+ { author: 'a', op: { op: 'remove', id: 'i' }, after: [0] },
+ { author: 'b', op: { op: 'set', id: 'i', done: true, at: 5 }, after: [0] },
+ ];
+ const removed = checklistOutcomes(removes);
+ check('checklist: remove vs concurrent set converges to removed', removed.size === 1 && removed.has('[]'));
+
+ // Echo double-apply (optimistic local + echo) is a no-op.
+ let items: Array> = [];
+ const op = { op: 'add', id: 'e', text: 'echo', at: 9 };
+ items = applyOp(items, op, 'a');
+ items = applyOp(items, op, 'a');
+ items = applyOp(items, { op: 'set', id: 'e', done: true, at: 10 }, 'a');
+ items = applyOp(items, { op: 'set', id: 'e', done: true, at: 10 }, 'a');
+ check('checklist: echoes are no-ops', items.length === 1 && items[0]!.done === true);
+
+ // Legacy saves gain ids and stamps without reordering.
+ const legacy = upgrade([{ text: 'old', done: false }, { text: 'older', done: true }], () => 'minted');
+ check('checklist: legacy items upgrade in place', legacy.length === 2 && legacy[0]!.id === 'minted');
+}
+
+/* ——— tictactoe ——— */
+
+{
+ // The fold is over a set: ANY ingestion order gives the same games.
+ const ops = [
+ { id: 'm1', op: 'move', game: 0, n: 0, cell: 4 },
+ { id: 'm2', op: 'move', game: 0, n: 1, cell: 0 },
+ { id: 'm3', op: 'move', game: 0, n: 2, cell: 8 },
+ { id: 'm4', op: 'move', game: 0, n: 3, cell: 2 },
+ ];
+ const roles = ['x', 'o', 'x', 'o'];
+ const boards = new Set();
+ for (const order of permutations(ops.map((_, i) => i))) {
+ let set: Array> = [];
+ for (const idx of order) set = recordOp(set, ops[idx]!, roles[idx]!);
+ boards.add(JSON.stringify(derive(set)));
+ }
+ check('tictactoe: any delivery order derives the same board', boards.size === 1, `${boards.size}`);
+
+ // Concurrent claims on the same slot: smallest op id wins everywhere, and
+ // a peer that optimistically applied the loser recomputes to the winner.
+ let a: Array> = [];
+ a = recordOp(a, { id: 'zz-late', op: 'move', game: 0, n: 0, cell: 8 }, 'x'); // optimistic loser first
+ a = recordOp(a, { id: 'aa-early', op: 'move', game: 0, n: 0, cell: 4 }, 'x');
+ let b: Array> = [];
+ b = recordOp(b, { id: 'aa-early', op: 'move', game: 0, n: 0, cell: 4 }, 'x');
+ b = recordOp(b, { id: 'zz-late', op: 'move', game: 0, n: 0, cell: 8 }, 'x');
+ check(
+ 'tictactoe: concurrent slot claims resolve identically',
+ JSON.stringify(derive(a)) === JSON.stringify(derive(b)) && derive(a)[0]![0] === 4,
+ JSON.stringify([derive(a), derive(b)]),
+ );
+
+ // Off-turn and watcher ops never win a slot.
+ let c: Array> = [];
+ c = recordOp(c, { id: 'w1', op: 'move', game: 0, n: 0, cell: 0 }, 'watcher');
+ c = recordOp(c, { id: 'o1', op: 'move', game: 0, n: 0, cell: 1 }, 'o');
+ check('tictactoe: watcher and off-turn moves are inert', derive(c)[0]!.length === 0);
+
+ // Echo dedup by id.
+ let d: Array> = [];
+ d = recordOp(d, { id: 'm', op: 'move', game: 0, n: 0, cell: 4 }, 'x');
+ d = recordOp(d, { id: 'm', op: 'move', game: 0, n: 0, cell: 4 }, 'x');
+ check('tictactoe: echoes are no-ops', d.length === 1);
+
+ // A win is still a win, and again opens the next game.
+ let e: Array> = [];
+ [[0, 'x'], [3, 'o'], [1, 'x'], [4, 'o'], [2, 'x']].forEach(([cell, role], n) => {
+ e = recordOp(e, { id: `g${n}`, op: 'move', game: 0, n, cell }, role as string);
+ });
+ const won = derive(e);
+ check('tictactoe: winner detected through the fold', winnerOf(won[0]!)?.role === 'x');
+ e = recordOp(e, { id: 'ag', op: 'again', game: 0 }, 'o');
+ check('tictactoe: again opens a fresh game', derive(e).length === 2 && derive(e)[1]!.length === 0);
+
+ // Legacy games convert and derive back to themselves.
+ const legacyGames = [[4, 0, 8, 2, 6], []];
+ const converted = derive(opsFromLegacyGames(legacyGames) as Array>);
+ check('tictactoe: legacy saves convert faithfully', JSON.stringify(converted) === JSON.stringify(legacyGames));
+}
+
+console.log(failures ? `\n${failures} FAILED` : '\nall green');
+Deno.exit(failures ? 1 : 0);
diff --git a/src/types.ts b/src/types.ts
index aa4f9e8..c3cd347 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -57,15 +57,26 @@ export interface PendingInvitation {
pending?: boolean;
}
+export interface SyncSpace {
+ ref: string;
+ name: string;
+ /** This identity owns the space (owner-only invites). */
+ mine: boolean;
+ /** Docs in the space that are not instantiated on this machine yet. */
+ available: Array<{ docRkey: string; name: string; creator: string }>;
+}
+
export interface SyncState {
+ /** Which collaboration backend is running (relay+MLS, or AT Proto Spaces). */
+ provider?: 'relay' | 'spaces';
identity: { did: string; handle: string };
- /** True only under an explicit ZIRAN_DID (developer mode). */
+ /** True under an explicit ZIRAN_DID or env Spaces credentials (dev mode). */
devIdentity?: boolean;
at: { signedIn: boolean; handle?: string; did?: string; displayName?: string; hasAvatar?: boolean };
+ /** The collaboration backend is reachable (field names kept from the relay
+ era: read as "backend ok / backend address / why not"). */
relayOk: boolean;
- /** The relay this runtime targets (ws/wss URL). */
relay?: string;
- /** Why the relay is unreachable, when it is. */
relayError?: string;
invitations: PendingInvitation[];
docs: Record;
+ /** Spaces: the space this document lives in. */
+ space?: string;
}>;
+ /** Spaces: this identity's known spaces (go-live picker, Documents column). */
+ spaces?: SyncSpace[];
}
export interface ServerState {
diff --git a/static/app.js b/static/app.js
index 1844a87..e2137ab 100644
--- a/static/app.js
+++ b/static/app.js
@@ -733,7 +733,11 @@ var backend = {
return data;
},
/* ——— collaboration ——— */
- syncShare: (docId) => post(`/api/docs/${docId}/sync/share`),
+ syncShare: (docId, opts) => post(`/api/docs/${docId}/sync/share`, opts ?? {}),
+ syncAdopt: (space, docRkey) => post("/api/sync/adopt", {
+ space,
+ docRkey
+ }),
syncRetry: () => post("/api/sync/retry"),
syncInvite: (docId, did, role) => post(`/api/docs/${docId}/sync/invite`, {
did,
@@ -1088,14 +1092,23 @@ var DeskStore = class extends EventTarget {
syncDoc(docId) {
return this.sync?.docs?.[docId];
}
- async startSharing(docId) {
+ async startSharing(docId, opts) {
try {
- await backend.syncShare(docId);
- this.toast("This document is now live \u2014 invite people from this panel");
+ await backend.syncShare(docId, opts);
+ this.toast(this.sync?.provider === "spaces" ? "This document is live in its space \u2014 your other devices will see it" : "This document is now live \u2014 invite people from this panel");
} catch (err) {
this.toast(`Could not start sharing: ${err.message}`);
}
}
+ /** Instantiate a document another device (or member) put in a space. */
+ async adoptDoc(space, docRkey, name) {
+ try {
+ this.handleOpened(await backend.syncAdopt(space, docRkey));
+ this.toast(`\u201C${name}\u201D is on this machine now \u2014 synced with its space`);
+ } catch (err) {
+ this.toast(`Could not fetch \u201C${name}\u201D: ${err.message}`);
+ }
+ }
/** Ask the runtime to try the relay right now instead of waiting out the
backoff timer. */
async syncRetry() {
@@ -1395,6 +1408,9 @@ var ZnBaseWindow = class extends i4 {
},
menuRecent: {
state: true
+ },
+ docSort: {
+ state: true
}
};
constructor() {
@@ -1402,6 +1418,7 @@ var ZnBaseWindow = class extends i4 {
this.query = "";
this.dropping = false;
this.menuRecent = void 0;
+ this.docSort = "recent";
}
onStore = () => this.requestUpdate();
connectedCallback() {
@@ -1743,6 +1760,62 @@ var ZnBaseWindow = class extends i4 {
margin: 0 0 var(--sp-2);
padding-left: var(--sp-2);
}
+ .region-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-3);
+ }
+ .region-head h2 {
+ flex: 1;
+ }
+ .sort {
+ display: inline-flex;
+ gap: 2px;
+ border: 1px solid var(--line-soft);
+ border-radius: var(--r-full);
+ padding: 2px;
+ }
+ .sort button {
+ font: inherit;
+ font-size: var(--text-xs);
+ color: var(--ink-muted);
+ border: none;
+ background: none;
+ border-radius: var(--r-full);
+ padding: 2px 10px;
+ cursor: pointer;
+ }
+ .sort button[aria-pressed='true'] {
+ background: var(--panel);
+ color: var(--ink);
+ font-weight: var(--w-medium);
+ }
+ .sort button:focus-visible {
+ outline: 2px solid var(--signal);
+ outline-offset: 1px;
+ }
+ details.loc {
+ margin-bottom: var(--sp-1);
+ }
+ details.loc summary {
+ cursor: pointer;
+ font-size: var(--text-sm);
+ font-weight: var(--w-semibold);
+ color: var(--ink-muted);
+ padding: 6px var(--sp-2);
+ border-radius: var(--r-sm);
+ user-select: none;
+ }
+ details.loc summary:hover {
+ background: var(--panel);
+ }
+ details.loc summary:focus-visible {
+ outline: 2px solid var(--signal);
+ outline-offset: -2px;
+ }
+ details.loc summary .loc-count {
+ font-weight: var(--w-regular);
+ }
.region {
display: flex;
flex-direction: column;
@@ -2021,8 +2094,20 @@ var ZnBaseWindow = class extends i4 {
${this.renderInvitations()}
-
-
Recent
+
+
+
Documents
+
+ this.docSort = "recent"}
+ >Most recent
+ this.docSort = "location"}
+ >By location
+
+
@@ -2080,7 +2164,10 @@ var ZnBaseWindow = class extends i4 {
if (sync && !sync.relayOk && shared.length) {
return b2`
- Live sync is paused — the relay is unreachable; Ziran keeps trying
+
+ Live sync is paused —
+ ${sync.provider === "spaces" ? "your PDS" : "the relay"} is unreachable; Ziran keeps trying
+
`;
}
@@ -2088,7 +2175,9 @@ var ZnBaseWindow = class extends i4 {
return b2`
${icons.check}
- ${liveCount} ${liveCount === 1 ? "document" : "documents"} live, end-to-end encrypted
+
+ ${liveCount} ${liveCount === 1 ? "document" : "documents"} live${sync?.provider === "spaces" ? " in your spaces" : ", end-to-end encrypted"}
+
`;
}
@@ -2165,6 +2254,8 @@ var ZnBaseWindow = class extends i4 {
Continue
Ziran opens your provider in the browser — no new account, no password here.
+ No AT identity yet?
+ Create one .
@@ -2205,6 +2296,41 @@ var ZnBaseWindow = class extends i4 {
tileIcon(url) {
return url ? b2`
` : b2`
${icons.tile} `;
}
+ /** By location: where each document actually lives — this machine alone,
+ or one of your spaces (whose documents you can fetch from here). */
+ renderByLocation(recents, open) {
+ const sync = desk.sync;
+ const inSpace = /* @__PURE__ */ new Map();
+ for (const [id, d3] of Object.entries(sync?.docs ?? {})) {
+ if (d3.space) inSpace.set(id, d3.space);
+ }
+ const q = this.query.trim().toLowerCase();
+ const local = recents.filter((r4) => !inSpace.has(r4.id));
+ const spaces = sync?.spaces ?? [];
+ return b2`
+
+ Local Device · ${local.length}
+ ${local.length ? local.map((d3) => this.renderRow(d3, open.has(d3.id))) : b2`Nothing lives only on this machine.
`}
+
+ ${spaces.map((s4) => {
+ const here = recents.filter((r4) => inSpace.get(r4.id) === s4.ref);
+ const away = s4.available.filter((a3) => !q || a3.name.toLowerCase().includes(q));
+ return b2`
+
+ ${s4.name || "unnamed space"} · ${here.length + away.length}
+ ${here.map((d3) => this.renderRow(d3, open.has(d3.id)))}
+ ${away.map((a3) => b2`
+ desk.adoptDoc(s4.ref, a3.docRkey, a3.name)}>
+ ${icons.tile}
+ ${a3.name} — in this space, not on this machine yet
+
+ `)}
+ ${!here.length && !away.length ? b2`No documents in this space yet.
` : A}
+
+ `;
+ })}
+ `;
+ }
renderRow(d3, isOpen, enterTarget = false) {
return b2`
this.requestUpdate();
connectedCallback() {
@@ -2548,6 +2678,10 @@ var ZnConnections = class extends i4 {
background: var(--signal);
flex: none;
}
+ /* Known member, not currently around (Spaces presence aged out). */
+ .presence.away {
+ background: var(--line);
+ }
form.invite {
display: flex;
gap: var(--sp-2);
@@ -2642,6 +2776,7 @@ var ZnConnections = class extends i4 {
}
if (!sync?.relayOk) return this.renderRelayDown(Boolean(s4));
if (!s4) {
+ if (sync.provider === "spaces") return this.renderGoLiveSpaces();
return b2`