@volter/world-runtime 3.0.25 → 3.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,6 +84,10 @@ export class LocalBranches {
84
84
  create: (at, ttl, _origin, parentKey, byKey) => this.create(parent, at, ttl, parentKey, byKey),
85
85
  remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent)
86
86
  return false; await this.remove(name); return true; },
87
+ board: async (name, frame) => {
88
+ const w = this.o.worlds.get(name);
89
+ return w && this.record(w)?.from === parent ? w.branchBoard(frame) : null;
90
+ },
87
91
  // a branch of this parent only, served in this process beside it: its own content plane's pass
88
92
  pagesPass: async (name, scope, land, grant) => {
89
93
  const w = this.o.worlds.get(name);
@@ -63,6 +63,7 @@ export type MountedWorld = {
63
63
  framer?: string;
64
64
  asker?: string;
65
65
  }) => string | null;
66
+ branchBoard: (frame?: string) => Promise<Response>;
66
67
  boot: (url: string) => Promise<void>;
67
68
  stop: () => Promise<void>;
68
69
  };
@@ -208,6 +209,8 @@ export type BranchDoors = {
208
209
  framer?: string;
209
210
  asker?: string;
210
211
  }) => Promise<string | null>;
212
+ /** the branch's own board, counting from its fork; a frame names its changes alone */
213
+ board?: (name: string, frame?: string) => Promise<Response | null>;
211
214
  };
212
215
  /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
213
216
  * label: lowercase letters, digits and single hyphens, 63 at most). A host maps a label back by the
@@ -444,6 +447,7 @@ export declare class WorldDoors {
444
447
  * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
445
448
  * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
446
449
  * one frame at a time (`board/changes`). */
450
+ branchBoard(frame?: string): Promise<Response>;
447
451
  private board;
448
452
  /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
449
453
  * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
@@ -18,7 +18,11 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
18
18
  // GET map | timeline | diff every twin and what it holds; the logs merged newest first; the changes since the base
19
19
  // GET/PUT clock, POST clock/advance the frozen instant every twin stamps from, forward only
20
20
  // GET/POST branches, DELETE branches/<org>/<world> the host's branches of this World ("as of" views)
21
+ // GET branches/<org>/<world>/board[/changes] a branch's pages and changes from its fork, through its host
22
+ // GET branches/<org>/<world>/pages?land=<path> a read pass to the branch's own content plane
23
+ // GET/PUT board, GET board/changes the pages and their changes; the boards as people arranged them
21
24
  // GET history?at=<instant> each twin's history cut at an instant (where such a branch starts)
25
+ // GET/POST marks where the World's changes count from; POST { note? } marks it now
22
26
  // GET /<org>/<world>/<vendor>/<path> the vendor's own screens, the pack's workspaces (its `GET /twin` lists them)
23
27
  // Tokens ride `x-volter-token`: the token opens everything, the read token only reads — on the vendor
24
28
  // wire, a twin that enforces it (`requestScopes: ['read']`) is handed the read token's other requests
@@ -33,7 +37,7 @@ import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '
33
37
  import { createHash } from 'node:crypto';
34
38
  import { dirname, join, resolve } from 'node:path';
35
39
  import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core/runtime';
36
- import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
40
+ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, listWorldMarks, markWorld, MARK_NOTE_MAX, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
37
41
  import { fetchFromOrigin } from "./origin.js";
38
42
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
39
43
  import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
@@ -215,6 +219,7 @@ export async function mountWorld(name, opts = {}) {
215
219
  handle: (request) => world.handle(request),
216
220
  site: (request, host) => world.site(request, host),
217
221
  pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
222
+ branchBoard: (frame) => world.branchBoard(frame),
218
223
  async boot(url) {
219
224
  keep();
220
225
  const port = Number(new URL(url).port) || 0;
@@ -1543,6 +1548,30 @@ export class WorldDoors {
1543
1548
  views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
1544
1549
  return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
1545
1550
  }
1551
+ // THE WORLD'S MARKS (`GET marks`, `POST marks { note? }`): where its changes count from. A World seeded through its
1552
+ // doors (a hosted one has no seed run to land its starting data as already there) is marked once it is set up, so
1553
+ // what it starts with is not counted as its changes; the latest mark is the base of the diff, the board's Changes
1554
+ // and a new changeset, as `volter-world mark <world>` makes it on this machine
1555
+ if (kind === 'marks' && !name && request.method === 'GET')
1556
+ return Response.json({ world: this.served, marks: listWorldMarks(this.name, this.worldRoot) });
1557
+ if (kind === 'marks' && !name && request.method === 'POST') {
1558
+ const body = (await request.json().catch(() => ({})));
1559
+ if (!body || typeof body !== 'object' || Array.isArray(body))
1560
+ return Response.json({ error: 'a mark is sent as a JSON object' }, { status: 400 });
1561
+ if (body.note !== undefined && (typeof body.note !== 'string' || body.note.length > MARK_NOTE_MAX))
1562
+ return Response.json({ error: `a mark's note is a string of at most ${MARK_NOTE_MAX} characters` }, { status: 400 });
1563
+ const vendors = Object.keys(this.up().services);
1564
+ // taken while no twin writes, so it stands at one position of every log
1565
+ const { mark, passed } = withHistoryLocks(vendors.map((vendor) => worldPaths(this.stateOf(vendor), this.controlRoot(vendor)).dir), () => {
1566
+ const delta = diffWorld(this.name, { root: this.worldRoot });
1567
+ // what another World pushed here is in its changeset already: never this World's uncut changes
1568
+ const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
1569
+ const passed = delta.actions.filter((a) => !received.has(a.action.id)).length;
1570
+ const mark = markWorld(this.name, { root: this.worldRoot, ...(typeof body.note === 'string' && body.note.trim() ? { note: body.note } : {}) });
1571
+ return { mark, passed };
1572
+ });
1573
+ return Response.json({ world: this.served, mark, passed }, { status: 201 });
1574
+ }
1546
1575
  if (kind === 'map' && !name && request.method === 'GET')
1547
1576
  return Response.json(await this.map());
1548
1577
  if (kind === 'board' && !name && request.method === 'GET')
@@ -2096,6 +2125,16 @@ export class WorldDoors {
2096
2125
  * and removes them; a host that makes none answers 404, and a view offers no "as of". */
2097
2126
  async branchesDoor(request, url, rest, via) {
2098
2127
  const branches = this.host.branches;
2128
+ if (rest[2] === 'board' && request.method === 'GET' && (rest.length === 3 || (rest.length === 4 && rest[3] === 'changes'))) {
2129
+ if (!branches)
2130
+ return Response.json({ error: `this host cannot read branches of ${this.served}` }, { status: 501 });
2131
+ const name = `${decodeURIComponent(rest[0])}/${decodeURIComponent(rest[1])}`;
2132
+ if (!(await branches.list()).some((b) => b.name === name))
2133
+ return Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
2134
+ if (!branches.board)
2135
+ return Response.json({ error: `this host cannot read ${name}'s board` }, { status: 501 });
2136
+ return (await branches.board(name, rest[3] === 'changes' ? url.searchParams.get('frame') ?? '' : undefined)) ?? Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
2137
+ }
2099
2138
  if (!branches)
2100
2139
  return Response.json({ error: `this host makes no branches of ${this.served}` }, { status: 404 });
2101
2140
  if (rest.length === 0 && request.method === 'GET') {
@@ -2230,18 +2269,26 @@ export class WorldDoors {
2230
2269
  * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
2231
2270
  * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
2232
2271
  * one frame at a time (`board/changes`). */
2233
- async board(url) {
2234
- const { frames, delta } = await this.boardFrames(url);
2235
- // the changes are counted from the World's base: what was last cut or received, and when
2272
+ async branchBoard(frame) {
2273
+ const url = new URL(this.host.browserOrigin?.() ?? 'http://world.local');
2274
+ if (frame !== undefined) {
2275
+ url.searchParams.set('frame', frame);
2276
+ return this.boardChanges(url, true);
2277
+ }
2278
+ return Response.json(await this.board(url, true));
2279
+ }
2280
+ async board(url, fork = false) {
2281
+ const { frames, delta } = await this.boardFrames(url, fork);
2282
+ // the World's last cut, or the branch's whole ledger when its parent is looking at it
2236
2283
  return { world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, frames: frames.map(({ frame, mine }) => ({ ...frame, changes: new Set(mine.map(actOf)).size, since: mine.map((c) => c.action.occurredAt).sort()[0] ?? null })), ...this.savedBoards() };
2237
2284
  }
2238
2285
  /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
2239
2286
  * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
2240
- async boardChanges(url) {
2287
+ async boardChanges(url, fork = false) {
2241
2288
  const key = url.searchParams.get('frame') ?? '';
2242
2289
  if (!key)
2243
2290
  return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
2244
- const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
2291
+ const found = (await this.boardFrames(url, fork)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
2245
2292
  if (!found)
2246
2293
  return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
2247
2294
  const acts = new Map();
@@ -2259,7 +2306,7 @@ export class WorldDoors {
2259
2306
  return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
2260
2307
  }
2261
2308
  /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
2262
- async boardFrames(url) {
2309
+ async boardFrames(url, fork = false) {
2263
2310
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
2264
2311
  const sitesAt = this.host.sitesTemplate?.() ?? null;
2265
2312
  // a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
@@ -2275,9 +2322,10 @@ export class WorldDoors {
2275
2322
  const own = this.host.browserOrigin?.() ?? null;
2276
2323
  return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
2277
2324
  };
2278
- const delta = diffWorld(this.name, { root: this.worldRoot });
2325
+ // inherited history is the parent log: the branch's own ledger starts at its fork, even after a cut
2326
+ const delta = diffWorld(this.name, { root: this.worldRoot, ...(fork ? { base: 'world-boot' } : {}) });
2279
2327
  const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
2280
- const changes = delta.actions.filter((a) => !received.has(a.action.id));
2328
+ const changes = fork ? delta.actions : delta.actions.filter((a) => !received.has(a.action.id));
2281
2329
  const frames = [];
2282
2330
  for (const vendor of Object.keys(this.up().services)) {
2283
2331
  const twin = this.host.twinFetch(vendor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.25",
3
+ "version": "3.0.27",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -68,14 +68,14 @@
68
68
  "node": ">=22.3"
69
69
  },
70
70
  "peerDependencies": {
71
- "@volter/world-core": "3.0.25",
72
- "@volter/world-console": "3.0.20"
71
+ "@volter/world-core": "3.0.27",
72
+ "@volter/world-console": "3.0.22"
73
73
  },
74
74
  "dependencies": {
75
75
  "@electric-sql/pglite": "0.5.8",
76
76
  "@electric-sql/pglite-pgvector": "0.0.9",
77
77
  "@volter/world-access": "3.0.1",
78
- "@volter/world-core": "3.0.25",
78
+ "@volter/world-core": "3.0.27",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
@@ -93,6 +93,10 @@ export class LocalBranches {
93
93
  list: async () => this.list(parent),
94
94
  create: (at, ttl, _origin, parentKey, byKey) => this.create(parent, at, ttl, parentKey, byKey),
95
95
  remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent) return false; await this.remove(name); return true; },
96
+ board: async (name, frame) => {
97
+ const w = this.o.worlds.get(name);
98
+ return w && this.record(w)?.from === parent ? w.branchBoard(frame) : null;
99
+ },
96
100
  // a branch of this parent only, served in this process beside it: its own content plane's pass
97
101
  pagesPass: async (name, scope, land, grant) => {
98
102
  const w = this.o.worlds.get(name);
@@ -18,7 +18,11 @@ import { getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant
18
18
  // GET map | timeline | diff every twin and what it holds; the logs merged newest first; the changes since the base
19
19
  // GET/PUT clock, POST clock/advance the frozen instant every twin stamps from, forward only
20
20
  // GET/POST branches, DELETE branches/<org>/<world> the host's branches of this World ("as of" views)
21
+ // GET branches/<org>/<world>/board[/changes] a branch's pages and changes from its fork, through its host
22
+ // GET branches/<org>/<world>/pages?land=<path> a read pass to the branch's own content plane
23
+ // GET/PUT board, GET board/changes the pages and their changes; the boards as people arranged them
21
24
  // GET history?at=<instant> each twin's history cut at an instant (where such a branch starts)
25
+ // GET/POST marks where the World's changes count from; POST { note? } marks it now
22
26
  // GET /<org>/<world>/<vendor>/<path> the vendor's own screens, the pack's workspaces (its `GET /twin` lists them)
23
27
  // Tokens ride `x-volter-token`: the token opens everything, the read token only reads — on the vendor
24
28
  // wire, a twin that enforces it (`requestScopes: ['read']`) is handed the read token's other requests
@@ -36,7 +40,7 @@ import {
36
40
  appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
37
41
  parentEntries, readBranchMeta, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
38
42
  } from '@volter/world-core/runtime';
39
- import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
43
+ import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, listWorldMarks, markWorld, MARK_NOTE_MAX, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
40
44
  import { fetchFromOrigin } from './origin.ts';
41
45
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
42
46
  import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
@@ -159,7 +163,7 @@ function configRefFor(worldRoot: string, name: string): string {
159
163
  /** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
160
164
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
161
165
  * with the call — and keeps them beside the world like `boot` does. */
162
- export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
166
+ export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
163
167
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
164
168
  /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
165
169
  * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
@@ -224,6 +228,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
224
228
  handle: (request) => world.handle(request),
225
229
  site: (request, host) => world.site(request, host),
226
230
  pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
231
+ branchBoard: (frame) => world.branchBoard(frame),
227
232
  async boot(url) {
228
233
  keep();
229
234
  const port = Number(new URL(url).port) || 0;
@@ -395,6 +400,8 @@ export type BranchDoors = {
395
400
  /** the address that opens a branch's content plane with a one-time pass at `scope`, landing on `land` (a path of the
396
401
  * branch): how its parent's console frames the branch's pages beside its own (the comparison) */
397
402
  pagesPass?: (name: string, scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => Promise<string | null>;
403
+ /** the branch's own board, counting from its fork; a frame names its changes alone */
404
+ board?: (name: string, frame?: string) => Promise<Response | null>;
398
405
  };
399
406
 
400
407
  /** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
@@ -1340,6 +1347,27 @@ export class WorldDoors {
1340
1347
  withHistoryLocks(vendors.map((vendor) => worldPaths(this.stateOf(vendor), this.controlRoot(vendor)).dir), () => { for (const vendor of vendors) views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
1341
1348
  return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
1342
1349
  }
1350
+ // THE WORLD'S MARKS (`GET marks`, `POST marks { note? }`): where its changes count from. A World seeded through its
1351
+ // doors (a hosted one has no seed run to land its starting data as already there) is marked once it is set up, so
1352
+ // what it starts with is not counted as its changes; the latest mark is the base of the diff, the board's Changes
1353
+ // and a new changeset, as `volter-world mark <world>` makes it on this machine
1354
+ if (kind === 'marks' && !name && request.method === 'GET') return Response.json({ world: this.served, marks: listWorldMarks(this.name, this.worldRoot) });
1355
+ if (kind === 'marks' && !name && request.method === 'POST') {
1356
+ const body = (await request.json().catch(() => ({}))) as { note?: unknown };
1357
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return Response.json({ error: 'a mark is sent as a JSON object' }, { status: 400 });
1358
+ if (body.note !== undefined && (typeof body.note !== 'string' || body.note.length > MARK_NOTE_MAX)) return Response.json({ error: `a mark's note is a string of at most ${MARK_NOTE_MAX} characters` }, { status: 400 });
1359
+ const vendors = Object.keys(this.up().services);
1360
+ // taken while no twin writes, so it stands at one position of every log
1361
+ const { mark, passed } = withHistoryLocks(vendors.map((vendor) => worldPaths(this.stateOf(vendor), this.controlRoot(vendor)).dir), () => {
1362
+ const delta = diffWorld(this.name, { root: this.worldRoot });
1363
+ // what another World pushed here is in its changeset already: never this World's uncut changes
1364
+ const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
1365
+ const passed = delta.actions.filter((a) => !received.has(a.action.id)).length;
1366
+ const mark = markWorld(this.name, { root: this.worldRoot, ...(typeof body.note === 'string' && body.note.trim() ? { note: body.note } : {}) });
1367
+ return { mark, passed };
1368
+ });
1369
+ return Response.json({ world: this.served, mark, passed }, { status: 201 });
1370
+ }
1343
1371
  if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
1344
1372
  if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
1345
1373
  if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET') return await this.boardChanges(url);
@@ -1771,6 +1799,13 @@ export class WorldDoors {
1771
1799
  * and removes them; a host that makes none answers 404, and a view offers no "as of". */
1772
1800
  private async branchesDoor(request: Request, url: URL, rest: string[], via: 'token' | 'key' | 'session' | null): Promise<Response> {
1773
1801
  const branches = this.host.branches;
1802
+ if (rest[2] === 'board' && request.method === 'GET' && (rest.length === 3 || (rest.length === 4 && rest[3] === 'changes'))) {
1803
+ if (!branches) return Response.json({ error: `this host cannot read branches of ${this.served}` }, { status: 501 });
1804
+ const name = `${decodeURIComponent(rest[0]!)}/${decodeURIComponent(rest[1]!)}`;
1805
+ if (!(await branches.list()).some((b) => b.name === name)) return Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
1806
+ if (!branches.board) return Response.json({ error: `this host cannot read ${name}'s board` }, { status: 501 });
1807
+ return (await branches.board(name, rest[3] === 'changes' ? url.searchParams.get('frame') ?? '' : undefined)) ?? Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
1808
+ }
1774
1809
  if (!branches) return Response.json({ error: `this host makes no branches of ${this.served}` }, { status: 404 });
1775
1810
  if (rest.length === 0 && request.method === 'GET') {
1776
1811
  // beside the host's snapshots, the World's own NAMED branches (`volter world branch`): one runs at a time, and
@@ -1877,17 +1912,22 @@ export class WorldDoors {
1877
1912
  * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1878
1913
  * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1879
1914
  * one frame at a time (`board/changes`). */
1880
- private async board(url: URL): Promise<unknown> {
1881
- const { frames, delta } = await this.boardFrames(url);
1882
- // the changes are counted from the World's base: what was last cut or received, and when
1915
+ async branchBoard(frame?: string): Promise<Response> {
1916
+ const url = new URL(this.host.browserOrigin?.() ?? 'http://world.local');
1917
+ if (frame !== undefined) { url.searchParams.set('frame', frame); return this.boardChanges(url, true); }
1918
+ return Response.json(await this.board(url, true));
1919
+ }
1920
+ private async board(url: URL, fork = false): Promise<unknown> {
1921
+ const { frames, delta } = await this.boardFrames(url, fork);
1922
+ // the World's last cut, or the branch's whole ledger when its parent is looking at it
1883
1923
  return { world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, frames: frames.map(({ frame, mine }) => ({ ...frame, changes: new Set(mine.map(actOf)).size, since: mine.map((c) => c.action.occurredAt).sort()[0] ?? null })), ...this.savedBoards() };
1884
1924
  }
1885
1925
  /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1886
1926
  * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1887
- private async boardChanges(url: URL): Promise<Response> {
1927
+ private async boardChanges(url: URL, fork = false): Promise<Response> {
1888
1928
  const key = url.searchParams.get('frame') ?? '';
1889
1929
  if (!key) return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1890
- const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1930
+ const found = (await this.boardFrames(url, fork)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1891
1931
  if (!found) return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1892
1932
  const acts = new Map<string, { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> }>();
1893
1933
  for (const c of found.mine) {
@@ -1902,7 +1942,7 @@ export class WorldDoors {
1902
1942
  return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1903
1943
  }
1904
1944
  /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1905
- private async boardFrames(url: URL): Promise<{ delta: ReturnType<typeof diffWorld>; frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: Array<ReturnType<typeof diffWorld>['actions'][number]> }> }> {
1945
+ private async boardFrames(url: URL, fork = false): Promise<{ delta: ReturnType<typeof diffWorld>; frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: Array<ReturnType<typeof diffWorld>['actions'][number]> }> }> {
1906
1946
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1907
1947
  const sitesAt = this.host.sitesTemplate?.() ?? null;
1908
1948
  // a site private to the World's people (a `{site}` template: a hosted World) opens through this World's own origin,
@@ -1915,9 +1955,10 @@ export class WorldDoors {
1915
1955
  const own = this.host.browserOrigin?.() ?? null;
1916
1956
  return own ? `${own}/-/${this.served}/sites/go?${new URLSearchParams({ host: hostname, land: rest }).toString()}` : null;
1917
1957
  };
1918
- const delta = diffWorld(this.name, { root: this.worldRoot });
1958
+ // inherited history is the parent log: the branch's own ledger starts at its fork, even after a cut
1959
+ const delta = diffWorld(this.name, { root: this.worldRoot, ...(fork ? { base: 'world-boot' } : {}) });
1919
1960
  const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
1920
- const changes = delta.actions.filter((a) => !received.has(a.action.id));
1961
+ const changes = fork ? delta.actions : delta.actions.filter((a) => !received.has(a.action.id));
1921
1962
  const frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: typeof changes }> = [];
1922
1963
  for (const vendor of Object.keys(this.up().services)) {
1923
1964
  const twin = this.host.twinFetch(vendor);