@volter/world-runtime 3.0.10 → 3.0.11

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.
@@ -358,9 +358,15 @@ export declare class WorldDoors {
358
358
  private newestEntry;
359
359
  /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
360
360
  * hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
361
- * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them; and
362
- * the boards as this World's people arranged them. */
361
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
362
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
363
+ * one frame at a time (`board/changes`). */
363
364
  private board;
365
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
366
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
367
+ private boardChanges;
368
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
369
+ private boardFrames;
364
370
  /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
365
371
  private savedBoards;
366
372
  /** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
@@ -95,14 +95,16 @@ const BOARD_BYTES = 256 * 1024;
95
95
  /** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
96
96
  /** A kept board document's version: its bytes' digest, or `none` before one is kept. */
97
97
  const boardVersion = (text) => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
98
+ /** A change's act: the request that made it (its writes share one correlation id), else the write itself. */
99
+ const actOf = (c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : c.action.id);
98
100
  function boardsOf(body) {
99
101
  const list = body?.boards;
100
102
  if (!Array.isArray(list) || list.length < 1 || list.length > 50)
101
103
  return 'boards: a list of 1-50 boards';
102
104
  const ids = new Set();
103
105
  for (const b of list) {
104
- if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed'].includes(k)))
105
- return 'a board has only id, name, include, hidden and placed';
106
+ if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed', 'sections'].includes(k)))
107
+ return 'a board has only id, name, include, hidden, placed and sections';
106
108
  if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id))
107
109
  return 'a board id is unique, a-z 0-9 -';
108
110
  ids.add(b.id);
@@ -119,6 +121,20 @@ function boardsOf(body) {
119
121
  if (!BOARD_KEY.test(k) || !v || typeof v !== 'object' || Object.keys(v).join() !== 'x,y' || !Object.values(v).every((n) => typeof n === 'number' && Number.isFinite(n)))
120
122
  return 'placed: { x, y } by frame name';
121
123
  }
124
+ if (b.sections !== undefined) {
125
+ const sections = b.sections;
126
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections) || Object.keys(sections).length > 500)
127
+ return 'sections: a section name to its region';
128
+ for (const [k, v] of Object.entries(sections)) {
129
+ const r = v;
130
+ // a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
131
+ if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^[a-z0-9-]+:section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
132
+ || !['x', 'y'].every((f) => typeof r[f] === 'number' && Math.abs(r[f]) <= 1e7) || !['w', 'h'].every((f) => typeof r[f] === 'number' && r[f] >= 0 && r[f] <= 1e6)
133
+ || (r.gone !== true && (r.w < 100 || r.h < 100))
134
+ || (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true))
135
+ return 'sections: { x, y, w, h, name?, gone? } by section name';
136
+ }
137
+ }
122
138
  }
123
139
  if (list[0].include !== undefined)
124
140
  return 'the first board is everything: it includes no list';
@@ -833,6 +849,11 @@ export class WorldDoors {
833
849
  }
834
850
  return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
835
851
  }
852
+ // a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
853
+ if ((request.headers.get('accept') ?? '').includes('text/html')) {
854
+ const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
855
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
856
+ }
836
857
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
837
858
  }
838
859
  async handle(request) {
@@ -1221,6 +1242,8 @@ export class WorldDoors {
1221
1242
  return Response.json(await this.map());
1222
1243
  if (kind === 'board' && !name && request.method === 'GET')
1223
1244
  return Response.json(await this.board(url));
1245
+ if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET')
1246
+ return await this.boardChanges(url);
1224
1247
  if (kind === 'board' && !name && request.method === 'PUT') {
1225
1248
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
1226
1249
  return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
@@ -1878,9 +1901,39 @@ export class WorldDoors {
1878
1901
  }
1879
1902
  /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
1880
1903
  * hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
1881
- * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them; and
1882
- * the boards as this World's people arranged them. */
1904
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1905
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1906
+ * one frame at a time (`board/changes`). */
1883
1907
  async board(url) {
1908
+ const { frames, delta } = await this.boardFrames(url);
1909
+ // the changes are counted from the World's base: what was last cut or received, and when
1910
+ 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() };
1911
+ }
1912
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1913
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1914
+ async boardChanges(url) {
1915
+ const key = url.searchParams.get('frame') ?? '';
1916
+ if (!key)
1917
+ return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1918
+ const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1919
+ if (!found)
1920
+ return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1921
+ const acts = new Map();
1922
+ for (const c of found.mine) {
1923
+ const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
1924
+ if (c.action.occurredAt > act.at)
1925
+ act.at = c.action.occurredAt;
1926
+ act.writes += 1;
1927
+ const named = Object.fromEntries(['title', 'name', 'hostname', 'email', 'text', 'subject', 'summary', 'description'].flatMap((k) => { const v = c.action.fields?.[k]; return typeof v === 'string' ? [[k, v.slice(0, 120)]] : []; }));
1928
+ if (act.entries.length < 50)
1929
+ act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
1930
+ acts.set(actOf(c), act);
1931
+ }
1932
+ const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
1933
+ return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1934
+ }
1935
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1936
+ async boardFrames(url) {
1884
1937
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1885
1938
  const routed = this.host.sitesOrigin?.() ?? null;
1886
1939
  const sites = routed ? new URL(routed) : null;
@@ -1898,12 +1951,13 @@ export class WorldDoors {
1898
1951
  catch {
1899
1952
  listed = [];
1900
1953
  }
1901
- // a pack that lays out no board yet keeps its vendor's home on it: its root workspace, else its first unparameterized one
1954
+ // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
1955
+ // one), whose changes are all of the vendor's
1902
1956
  if (!listed.length) {
1903
1957
  const screens = await this.screensOf(vendor);
1904
1958
  const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
1905
1959
  if (home)
1906
- frames.push({ vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` }, changes: changes.filter((c) => c.service === vendor).length ? new Set(changes.filter((c) => c.service === vendor).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id)).size : 0 });
1960
+ frames.push({ frame: { vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` } }, mine: changes.filter((c) => c.service === vendor) });
1907
1961
  }
1908
1962
  for (const f of listed) {
1909
1963
  let at = null;
@@ -1923,12 +1977,10 @@ export class WorldDoors {
1923
1977
  unseen = 'not-drawn';
1924
1978
  }
1925
1979
  const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
1926
- // one change per request that made it: a deploy's script, versions and assets are one act, its domain another
1927
- const acts = new Set(changes.filter((c) => c.service === vendor && covered.has(`${c.action.subject.type}:${c.action.subject.id}`)).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id));
1928
- frames.push({ vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}), changes: acts.size });
1980
+ frames.push({ frame: { vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}) }, mine: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
1929
1981
  }
1930
1982
  }
1931
- return { world: this.served, frames, ...this.savedBoards() };
1983
+ return { delta, frames };
1932
1984
  }
1933
1985
  /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
1934
1986
  savedBoards() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.10",
3
+ "version": "3.0.11",
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.10",
72
- "@volter/world-console": "3.0.5"
71
+ "@volter/world-core": "3.0.11",
72
+ "@volter/world-console": "3.0.6"
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.10",
78
+ "@volter/world-core": "3.0.11",
79
79
  "pg-gateway": "0.3.0-beta.4",
80
80
  "smol-toml": "^1.8.0",
81
81
  "yaml": "2.9.0"
@@ -84,19 +84,21 @@ export function keysFile(worldRoot: string): string { return join(worldRoot, sta
84
84
  export function boardFile(worldRoot: string): string { return join(worldRoot, stateDirName(), 'board.json'); }
85
85
  /** A board as the World keeps it: a frame or a section is named `<vendor>:<frame id>` or `<vendor>:section:<section id>`.
86
86
  * The first board is everything the packs lay out; another holds what it includes, a section with its frames to come. */
87
- type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }> };
87
+ type SavedBoard = { id: string; name: string; include?: string[]; hidden?: string[]; placed?: Record<string, { x: number; y: number }>; /** each section's region and name, by a pack's group or `user:<id>`; a pack's group removed is `gone` */ sections?: Record<string, { x: number; y: number; w: number; h: number; name?: string; gone?: true }> };
88
88
  const BOARD_KEY = /^[a-z0-9-]+:[^\s]{1,300}$/;
89
89
  /** The largest board document a World keeps. */
90
90
  const BOARD_BYTES = 256 * 1024;
91
91
  /** A board document from a request, or the reason it is refused: every field of its type, nothing else. */
92
92
  /** A kept board document's version: its bytes' digest, or `none` before one is kept. */
93
93
  const boardVersion = (text: string | null): string => (text === null ? 'none' : createHash('sha256').update(text).digest('hex').slice(0, 16));
94
+ /** A change's act: the request that made it (its writes share one correlation id), else the write itself. */
95
+ const actOf = (c: { action: { id: string; correlationId?: unknown } }): string => (typeof c.action.correlationId === 'string' ? c.action.correlationId : c.action.id);
94
96
  function boardsOf(body: unknown): SavedBoard[] | string {
95
97
  const list = (body as { boards?: unknown } | null)?.boards;
96
98
  if (!Array.isArray(list) || list.length < 1 || list.length > 50) return 'boards: a list of 1-50 boards';
97
99
  const ids = new Set<string>();
98
100
  for (const b of list as Array<Record<string, unknown>>) {
99
- if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed'].includes(k))) return 'a board has only id, name, include, hidden and placed';
101
+ if (!b || typeof b !== 'object' || Object.keys(b).some((k) => !['id', 'name', 'include', 'hidden', 'placed', 'sections'].includes(k))) return 'a board has only id, name, include, hidden, placed and sections';
100
102
  if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.id) || ids.has(b.id)) return 'a board id is unique, a-z 0-9 -';
101
103
  ids.add(b.id);
102
104
  if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) return 'a board name is 1-80 characters';
@@ -106,6 +108,18 @@ function boardsOf(body: unknown): SavedBoard[] | string {
106
108
  if (!placed || typeof placed !== 'object' || Array.isArray(placed) || Object.keys(placed).length > 2000) return 'placed: a frame name to its place';
107
109
  for (const [k, v] of Object.entries(placed)) if (!BOARD_KEY.test(k) || !v || typeof v !== 'object' || Object.keys(v).join() !== 'x,y' || !Object.values(v).every((n) => typeof n === 'number' && Number.isFinite(n))) return 'placed: { x, y } by frame name';
108
110
  }
111
+ if (b.sections !== undefined) {
112
+ const sections = b.sections as Record<string, unknown>;
113
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections) || Object.keys(sections).length > 500) return 'sections: a section name to its region';
114
+ for (const [k, v] of Object.entries(sections)) {
115
+ const r = v as Record<string, unknown> | null;
116
+ // a section is a pack's group (`<vendor>:section:<id>`) or one people drew (`user:<id>`), on the board's plane
117
+ if (!BOARD_KEY.test(k) || !(/^user:[a-z0-9]{1,32}$/.test(k) || /^[a-z0-9-]+:section:/.test(k)) || !r || typeof r !== 'object' || Object.keys(r).some((f) => !['x', 'y', 'w', 'h', 'name', 'gone'].includes(f))
118
+ || !['x', 'y'].every((f) => typeof r[f] === 'number' && Math.abs(r[f] as number) <= 1e7) || !['w', 'h'].every((f) => typeof r[f] === 'number' && (r[f] as number) >= 0 && (r[f] as number) <= 1e6)
119
+ || (r.gone !== true && ((r.w as number) < 100 || (r.h as number) < 100))
120
+ || (r.name !== undefined && (typeof r.name !== 'string' || !r.name.trim() || r.name.length > 80)) || (r.gone !== undefined && r.gone !== true)) return 'sections: { x, y, w, h, name?, gone? } by section name';
121
+ }
122
+ }
109
123
  }
110
124
  if ((list[0] as SavedBoard).include !== undefined) return 'the first board is everything: it includes no list';
111
125
  return list as SavedBoard[];
@@ -740,6 +754,11 @@ export class WorldDoors {
740
754
  if (location) { try { const to = new URL(location, `https://${host}`); if (to.hostname === host) out.set('location', `${to.pathname}${to.search}${to.hash}`); } catch { /* left as answered */ } }
741
755
  return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
742
756
  }
757
+ // a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
758
+ if ((request.headers.get('accept') ?? '').includes('text/html')) {
759
+ const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
760
+ return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
761
+ }
743
762
  return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
744
763
  }
745
764
 
@@ -1052,6 +1071,7 @@ export class WorldDoors {
1052
1071
  }
1053
1072
  if (kind === 'map' && !name && request.method === 'GET') return Response.json(await this.map());
1054
1073
  if (kind === 'board' && !name && request.method === 'GET') return Response.json(await this.board(url));
1074
+ if (kind === 'board' && name === 'changes' && !verb && request.method === 'GET') return await this.boardChanges(url);
1055
1075
  if (kind === 'board' && !name && request.method === 'PUT') {
1056
1076
  if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'the board is sent as application/json' }, { status: 415 });
1057
1077
  if (Number(request.headers.get('content-length') ?? '0') > BOARD_BYTES) return Response.json({ error: `the board is at most ${BOARD_BYTES} bytes` }, { status: 413 });
@@ -1565,16 +1585,42 @@ export class WorldDoors {
1565
1585
 
1566
1586
  /** THE BOARD: every twin's frames as its pack lays them out (`GET /twin/board`), each where this World shows it (a
1567
1587
  * hostname a twin answers as a site at its own origin, a vendor page under the twin's place, or nowhere: a frame no
1568
- * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them; and
1569
- * the boards as this World's people arranged them. */
1588
+ * screen draws yet) and the changes not yet cut on the records it covers, counted by the request that made them, with
1589
+ * when the first was made; and the boards as this World's people arranged them. A frame's changes themselves are read
1590
+ * one frame at a time (`board/changes`). */
1570
1591
  private async board(url: URL): Promise<unknown> {
1592
+ const { frames, delta } = await this.boardFrames(url);
1593
+ // the changes are counted from the World's base: what was last cut or received, and when
1594
+ 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() };
1595
+ }
1596
+ /** One frame's changes not yet cut, newest first: each the writes one request made, as words read them (the operation,
1597
+ * the record, its few naming fields), the frame named `<vendor>:<frame id>`. */
1598
+ private async boardChanges(url: URL): Promise<Response> {
1599
+ const key = url.searchParams.get('frame') ?? '';
1600
+ if (!key) return Response.json({ error: 'board/changes?frame=<vendor>:<frame id>' }, { status: 400 });
1601
+ const found = (await this.boardFrames(url)).frames.find(({ frame }) => `${frame.vendor}:${frame.id}` === key);
1602
+ if (!found) return Response.json({ error: `no frame ${key} on ${this.served}'s board` }, { status: 404 });
1603
+ const acts = new Map<string, { at: string; writes: number; entries: Array<{ op: string; operation?: string; subject: { type: string; id: string }; fields: Record<string, string> }> }>();
1604
+ for (const c of found.mine) {
1605
+ const act = acts.get(actOf(c)) ?? { at: c.action.occurredAt, writes: 0, entries: [] };
1606
+ if (c.action.occurredAt > act.at) act.at = c.action.occurredAt;
1607
+ act.writes += 1;
1608
+ const named = Object.fromEntries(['title', 'name', 'hostname', 'email', 'text', 'subject', 'summary', 'description'].flatMap((k) => { const v = (c.action.fields as Record<string, unknown> | undefined)?.[k]; return typeof v === 'string' ? [[k, v.slice(0, 120)]] : []; }));
1609
+ if (act.entries.length < 50) act.entries.push({ op: c.action.op, ...(c.action.operation ? { operation: c.action.operation } : {}), subject: c.action.subject, fields: named });
1610
+ acts.set(actOf(c), act);
1611
+ }
1612
+ const all = [...acts.values()].sort((a, b) => b.at.localeCompare(a.at));
1613
+ return Response.json({ frame: key, total: all.length, since: found.mine.map((c) => c.action.occurredAt).sort()[0] ?? null, acts: all.slice(0, 100) });
1614
+ }
1615
+ /** Every twin's frames, where each opens here, and each with the changes not yet cut that are its own. */
1616
+ 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]> }> }> {
1571
1617
  const claimed = new Map((await this.claimedHosts(url.origin)).map(({ vendor, hosts }) => [vendor, new Set(hosts)]));
1572
1618
  const routed = this.host.sitesOrigin?.() ?? null;
1573
1619
  const sites = routed ? new URL(routed) : null;
1574
1620
  const delta = diffWorld(this.name, { root: this.worldRoot });
1575
1621
  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)));
1576
1622
  const changes = delta.actions.filter((a) => !received.has(a.action.id));
1577
- const frames: unknown[] = [];
1623
+ const frames: Array<{ frame: Record<string, unknown> & { vendor: string; id: string }; mine: typeof changes }> = [];
1578
1624
  for (const vendor of Object.keys(this.up().services)) {
1579
1625
  const twin = this.host.twinFetch(vendor);
1580
1626
  let listed: BoardFrame[] = [];
@@ -1582,11 +1628,12 @@ export class WorldDoors {
1582
1628
  const answer = twin ? await twin(new Request('http://world.local/twin/board', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
1583
1629
  listed = answer?.ok ? (((await answer.json()) as { frames?: BoardFrame[] }).frames ?? []) : [];
1584
1630
  } catch { listed = []; }
1585
- // a pack that lays out no board yet keeps its vendor's home on it: its root workspace, else its first unparameterized one
1631
+ // a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
1632
+ // one), whose changes are all of the vendor's
1586
1633
  if (!listed.length) {
1587
1634
  const screens = await this.screensOf(vendor);
1588
1635
  const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
1589
- if (home) frames.push({ vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` }, changes: changes.filter((c) => c.service === vendor).length ? new Set(changes.filter((c) => c.service === vendor).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id)).size : 0 });
1636
+ if (home) frames.push({ frame: { vendor, id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, at: { screen: home.path.startsWith('/') ? home.path : `/${home.path}` } }, mine: changes.filter((c) => c.service === vendor) });
1590
1637
  }
1591
1638
  for (const f of listed) {
1592
1639
  let at: { site: string } | { screen: string } | null = null;
@@ -1599,12 +1646,10 @@ export class WorldDoors {
1599
1646
  if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
1600
1647
  } catch { at = null; unseen = 'not-drawn'; }
1601
1648
  const covered = new Set(f.covers.map((c) => `${c.type}:${c.id}`));
1602
- // one change per request that made it: a deploy's script, versions and assets are one act, its domain another
1603
- const acts = new Set(changes.filter((c) => c.service === vendor && covered.has(`${c.action.subject.type}:${c.action.subject.id}`)).map((c) => (typeof c.action.correlationId === 'string' ? c.action.correlationId : undefined) ?? c.action.id));
1604
- frames.push({ vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}), changes: acts.size });
1649
+ frames.push({ frame: { vendor, id: f.id, kind: f.kind, title: f.title, section: f.section, url: f.url, at, ...(unseen ? { unseen } : {}) }, mine: changes.filter((x) => x.service === vendor && covered.has(`${x.action.subject.type}:${x.action.subject.id}`)) });
1605
1650
  }
1606
1651
  }
1607
- return { world: this.served, frames, ...this.savedBoards() };
1652
+ return { delta, frames };
1608
1653
  }
1609
1654
  /** The boards as the World keeps them, and the version a save names: the default board when none was ever kept. */
1610
1655
  private savedBoards(): { boards: SavedBoard[]; version: string } {