@augurworks/augur 0.15.1

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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,881 @@
1
+ /* BoardRoom — one Durable Object per canvas board.
2
+ *
3
+ * WHERE THIS LIVES, AND WHY IT MOVED. The class used to sit in realtime/src/index.js,
4
+ * deployed as its own worker per instance. It is here because Workers requires a Durable
5
+ * Object class to be a named export of the DEPLOYED module graph, and the engine worker
6
+ * is about to become that graph (`src/entry.js`). Both entry points import it from here,
7
+ * so there is one copy of the document authority rather than two that drift.
8
+ *
9
+ * ⚠️ THE STANDALONE REALTIME WORKER IS STILL LIVE, and this file did not change that.
10
+ * Every instance today runs `augur-realtime-<instance>` with its own ROOMS binding, and
11
+ * the engine's /__rt proxies to it. That worker keeps its `/room` route and keeps
12
+ * importing this class. Deleting the route — which `A-boardroom-port` describes — belongs
13
+ * with the instance that first serves rooms from its own engine worker, because doing it
14
+ * before then takes canvas multiplayer off three live sites.
15
+ *
16
+ *
17
+ * The worker upgrades /room?path=<boardPath>&name=<who> to a WebSocket and hands it to
18
+ * the room named by the board path (the same key the KV doc uses). The room is a relay
19
+ * plus THE document authority: it fans cursor moves / node ops / editing focus out to
20
+ * the other sockets, applies every op to its own copy of the doc, and persists.
21
+ *
22
+ * THE ROOM OWNS THE DOC (2026-08-07 — was "room persists while live" since 2026-07-27).
23
+ * The document's source of truth is the DO's OWN SQLite-backed storage: one row per node
24
+ * (`n:<id>`), one meta row (`m` — name, tombstones, clock). DO storage is strongly
25
+ * consistent and survives hibernation, so the old stash/docreq/cold-alarm dances are
26
+ * gone with the failure modes they papered over. Workers KV keeps the SAME doc under the
27
+ * SAME key (`board:<path>`, or `board:<workspace>:<path>` where the engine worker names
28
+ * the workspace — src/board-key.mjs) but demoted to a WRITE-THROUGH MIRROR: it serves the
29
+ * public GET /__board and the solo fallback, and the room writes it on the old cadence (45s
30
+ * dirty-alarm + flush on empty) — never reads it back except once, to migrate a
31
+ * pre-existing board into storage (lazy, first touch, per board).
32
+ *
33
+ * WHOSE BOARD IT IS arrives in a header, from the worker that owns the room binding, and
34
+ * is remembered durably (`ws`). A room reached through the standalone realtime worker gets
35
+ * no header and keeps the unscoped key — the deployment that has not moved its rooms has
36
+ * not moved its keys either, and those two halves are one cutover on purpose. On a miss
37
+ * the scoped read falls through to the unscoped key and the next mirror write lands it
38
+ * scoped, so a board that predates the segment is picked up by being opened.
39
+ *
40
+ * VERSIONED NODES (per-node last-writer-wins on a version int, not a CRDT). Every node carries
41
+ * `v` (int, bumped by whoever mutates it) and `vn` (random tiebreak). The room applies
42
+ * an op only if it's NEWER than what it holds (v, then vn); losers get a corrective op
43
+ * back so every client converges on the same winner. Deletes leave a tombstone
44
+ * (id → {v,t}) so a stale upsert can't resurrect a deleted node; tombs prune after
45
+ * TOMB_TTL. A client "seed" ({t:"doc"}) is RECONCILED per-node under the same rules —
46
+ * never adopted wholesale — so a stale tab (slept laptop, frozen tab, eventual-consistent
47
+ * KV read) can no longer revert a board, while its genuinely-new offline edits merge in.
48
+ * Legacy compat: v-less ops from old clients are accepted and stamped (live edits keep
49
+ * working); v-less nodes inside a SEED count as v0 (stale-tab protection is the point).
50
+ *
51
+ * Rooms under /__test/ never touch storage OR KV — pure RAM relay (Playwright isolation);
52
+ * they keep the old docreq dance since RAM is all they have.
53
+ *
54
+ * Uses the WebSocket Hibernation API, so an idle board with open tabs costs ~nothing.
55
+ * `doc` in memory is a cache rebuilt from storage on demand; `dirty` (KV mirror pending)
56
+ * is durable because the alarm outlives the instance that armed it.
57
+ *
58
+ * Protocol (JSON, one object per message):
59
+ * client→room: {t:"cursor",x,y,drag?}|{t:"cursor",gone:true} · {t:"ops",ops:[...]} ·
60
+ * {t:"focus",id|null} · {t:"sel",ids:[...]} (live selection) ·
61
+ * {t:"status",text,state} (persistent work state under an agent cursor:
62
+ * state working|idle|attention; kept on the attachment for late joiners) ·
63
+ * {t:"chat",text} (cursor chat — pure ephemeral relay) ·
64
+ * {t:"view",v:{x,y,s,w,h}} (live viewport — pan/zoom/window; kept on the
65
+ * attachment so follow mode mirrors a peer the instant it starts) ·
66
+ * {t:"proto",id,ev} (demo sync in a live tile iframe) ·
67
+ * {t:"timer",do:"start"|"add"|"pause"|"resume"|"stop",ms?} ·
68
+ * {t:"music",do:"play"|"stop",track?,at?} (shared session — see below) ·
69
+ * {t:"doc",doc} (seed/merge offer — reconciled, see above)
70
+ * room→client: {t:"welcome",sid,color,peers,doc?,needDoc?,session?} · {t:"join"|"leave",peer} ·
71
+ * relayed cursor/ops/focus/sel/status/chat stamped with the sender's info ·
72
+ * {t:"session",timer,music} · {t:"doc",doc} · {t:"docreq"} (test rooms only)
73
+ * cursor.drag is the drag fast-path: [{id,x,y,w,h},…] geometry for nodes mid-drag,
74
+ * relayed verbatim on the cursor cadence (~20Hz) so remote drags glide instead of
75
+ * stepping at the 120ms ops tick. Ephemeral — the durable version rides the ops tick.
76
+ * Ops: {op:"upsert",node} · {op:"del",id,v?} · {op:"name",name,v?}. Accepted ops are
77
+ * broadcast (with room-stamped versions where the sender sent none); rejected ops earn
78
+ * the SENDER a corrective ops message carrying the winning state. The doc's `view` is
79
+ * per-user viewport — never synced (a fossil in old mirrors; clients use localStorage).
80
+ */
81
+
82
+ // The mirror's key, built by the SAME function the /__board rail builds it with. Two
83
+ // writers, one spelling — see src/board-key.mjs.
84
+ import { boardKvKey, RT_WORKSPACE_HEADER } from "./board-key.mjs";
85
+ import { verifyRoomTicket } from "./room-ticket.mjs";
86
+
87
+ const MAX_MSG = 8 * 1024 * 1024; // a node op can carry an inlined image; KV caps the doc at 20MB
88
+ // Frozen: a fixed palette, read by index and never written. Freezing at the declaration
89
+ // is what makes it provably not per-isolate state rather than a promise that it is not.
90
+ const COLORS = Object.freeze(["#e8590c", "#1971c2", "#2f9e44", "#9c36b5", "#e64980", "#f08c00", "#0c8599", "#6741d9"]);
91
+
92
+ // The board mirror's KV binding, under either name.
93
+ //
94
+ // The standalone realtime worker binds it as BOARD_KV; the engine worker binds the same
95
+ // namespace as COMMENTS, which its own template has always documented as "the same
96
+ // namespace the Pages project binds as COMMENTS". Folding realtime into the engine worker
97
+ // therefore removes a redundant binding rather than adding one — but the standalone worker
98
+ // is still deployed on three instances, so this accepts both names for as long as both
99
+ // deployments exist. COMMENTS wins where both are present, which is the engine worker.
100
+ const boardKv = (env) => (env && (env.COMMENTS || env.BOARD_KV)) || null;
101
+
102
+ const PERSIST_MS = 45000; // dirty → alarm → KV mirror write; ≤ ~80 writes/hour per hot board
103
+ const RETRY_MS = 5000; // re-arm delay after a FAILED mirror write (KV hiccup — retry soon)
104
+ const MAX_TIMER_MS = 99 * 60000 + 59000; // 99:59 — a session timer, not a scheduler
105
+ const STALE_TIMER_MS = 3600000; // an expired timer older than this is forgotten, not shown at 00:00
106
+ const TOMB_TTL = 45 * 86400000; // tombstones outlive any realistic stale tab, then prune
107
+ const TOMB_MAX = 5000; // hard cap — a board that deleted 5k nodes can afford resurrection risk
108
+ const NODE_CHUNK = 1800000; // storage rows cap at 2MB; larger node JSON splits into N:<id>:<i>
109
+ const PUT_BATCH = 100; // storage.put(object) accepts ≤128 keys per call
110
+
111
+ const vOf = (x) => (x && typeof x.v === "number" ? x.v : 0);
112
+ const vnOf = (x) => (x && typeof x.vn === "number" ? x.vn : 0);
113
+ // deterministic total order: version, then nonce. Equal v+vn = the same write (idempotent).
114
+ const beats = (a, b) => vOf(a) > vOf(b) || (vOf(a) === vOf(b) && vnOf(a) > vnOf(b));
115
+ const sameV = (a, b) => vOf(a) === vOf(b) && vnOf(a) === vnOf(b);
116
+ const rnd = () => Math.floor(Math.random() * 0x7fffffff);
117
+ // tombs is a plain object keyed by CLIENT-CHOSEN node ids — reads must not hit inherited
118
+ // members (an id like "constructor" made every create bounce as tombed) and writes must
119
+ // not follow the "__proto__" setter. Same rule client-side.
120
+ const tombAt = (t, id) => (t && Object.prototype.hasOwnProperty.call(t, id) ? t[id] : null);
121
+ const setTomb = (t, id, val) => Object.defineProperty(t, id, { value: val, writable: true, enumerable: true, configurable: true });
122
+ // cheap content hash (FNV-1a) — used only to detect "did KV move under us since our last
123
+ // mirror write", never for integrity
124
+ const hashStr = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36) + ":" + s.length; };
125
+
126
+ export class BoardRoom {
127
+ constructor(ctx, env) {
128
+ this.ctx = ctx;
129
+ this.env = env;
130
+ this.doc = null; // {name,nameV,nodes:[…],tombs:{id:{v,t}},clock} — cache over storage
131
+ this.byId = null; // Map id → node, rebuilt with doc
132
+ this.chunked = new Set(); // node ids stored as N:<id>:<i> overflow chunks (JSON > 1.8MB)
133
+ this.loadP = null; // in-flight load, so concurrent messages share one storage read
134
+ this.path = null; // board path cache (durable under "path")
135
+ this.workspace = null; // whose board this is, or "" for none (durable under "ws"); null = not read yet
136
+ this.ephemeral = false; // /__test/ room: RAM only, never storage, never KV
137
+ this.wantDoc = false; // test rooms only: a docreq is in flight, don't spam
138
+ this.dirty = false; // KV mirror is behind storage (mirrored durably — see markDirty)
139
+ this.alarmSet = false;
140
+ this.sweptAt = 0;
141
+ this.sess = null; // shared timer/music state (cache — reloaded from storage on demand)
142
+ this.sessQ = null; // serializes session mutations (read-modify-write over storage)
143
+ // clients ping every 25s; the runtime pongs WITHOUT waking the DO and stamps the
144
+ // socket, so sweep() can spot zombies (dropped transports whose close never fired —
145
+ // sends to them "succeed" into the void, so send-failure reaping can't catch them)
146
+ this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
147
+ }
148
+
149
+ // ---- the mirror's key ------------------------------------------------------
150
+ // Three reads and one write name this document, and they all come through here so the
151
+ // read-through and the scoped write cannot be applied to three of the four.
152
+
153
+ /** This board's workspace, "" for none. Durable, so an alarm wake knows it. */
154
+ async workspaceId() {
155
+ if (this.workspace === null) this.workspace = (await this.ctx.storage.get("ws")) || "";
156
+ return this.workspace;
157
+ }
158
+
159
+ /**
160
+ * The mirror as it stands in KV, or null.
161
+ *
162
+ * READ-THROUGH: a scoped miss falls back to the unscoped key, because a board written
163
+ * before the segment existed is still this board. The fallback is not a merge — the
164
+ * scoped document, once it exists, is the only one read — and the next mirror write
165
+ * lands scoped, so a board pays it once. It is skipped entirely where there is no
166
+ * workspace, which is every deployment that has not moved its rooms in here.
167
+ */
168
+ async kvGet() {
169
+ const kv = boardKv(this.env);
170
+ if (!kv || !this.path) return null;
171
+ const ws = await this.workspaceId();
172
+ if (ws) {
173
+ const scoped = await kv.get(boardKvKey(ws, this.path));
174
+ if (scoped !== null && scoped !== undefined) return scoped;
175
+ }
176
+ return await kv.get(boardKvKey("", this.path));
177
+ }
178
+
179
+ /** Write the mirror, under this board's own key. */
180
+ async kvPut(value) {
181
+ const kv = boardKv(this.env);
182
+ if (!kv || !this.path) return;
183
+ await kv.put(boardKvKey(await this.workspaceId(), this.path), value);
184
+ }
185
+
186
+ sweep() {
187
+ const now = Date.now();
188
+ if (now - this.sweptAt < 10000) return;
189
+ this.sweptAt = now;
190
+ for (const ws of this.ctx.getWebSockets()) {
191
+ if (!this.isLive(ws)) this.reap(ws); // no ping in ~3 intervals = dead
192
+ }
193
+ }
194
+
195
+ peers(excludeWs) {
196
+ const out = [];
197
+ for (const ws of this.ctx.getWebSockets()) {
198
+ if (ws === excludeWs) continue;
199
+ const a = ws.deserializeAttachment();
200
+ if (a) out.push({ sid: a.sid, name: a.name, color: a.color, avatar: a.avatar || null, kind: a.kind || null, pose: a.pose || null, focus: a.focus || null, sel: a.sel || null, status: a.status || null, view: a.view || null });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ broadcast(msg, exceptWs) {
206
+ const raw = typeof msg === "string" ? msg : JSON.stringify(msg);
207
+ const dead = [];
208
+ for (const ws of this.ctx.getWebSockets()) {
209
+ if (ws === exceptWs) continue;
210
+ try { ws.send(raw); } catch (e) { dead.push(ws); }
211
+ }
212
+ // a failed send = a zombie (an aborted handshake or vanished client whose close event
213
+ // never fired) — reap NOW or it haunts the peers list as a phantom presence chip
214
+ for (const ws of dead) this.reap(ws);
215
+ }
216
+
217
+ send(ws, msg) { try { ws.send(JSON.stringify(msg)); } catch (e) {} }
218
+
219
+ async fetch(request) {
220
+ const url = new URL(request.url);
221
+ const path = (url.searchParams.get("path") || "").slice(0, 600);
222
+ if (path) { this.path = path; this.ephemeral = path.indexOf("/__test/") === 0; this.ctx.storage.put("path", path); }
223
+ // The workspace, from the worker that holds the room binding. Remembered durably for
224
+ // the same reason `path` is: the alarm that mirrors this board can fire long after
225
+ // every socket has gone, with no request to read it from.
226
+ const ws = (request.headers.get(RT_WORKSPACE_HEADER) || "").slice(0, 128);
227
+ if (ws && ws !== this.workspace) { this.workspace = ws; this.ctx.storage.put("ws", ws); }
228
+ // Room ticket (A-room-tickets). When the deployment folds the rooms into the engine
229
+ // worker it configures ROOM_TICKET_SECRET, the worker mints a ticket bound to this
230
+ // workspace+path after its auth gate, and we refuse the Upgrade without a valid one —
231
+ // recomputing the MAC from the workspace WE were handed (not one a client can name) and
232
+ // the path on this URL, so a ticket for another board or another workspace cannot open
233
+ // this one. With no secret (the standalone realtime worker, offline, a raw build) the
234
+ // join is accepted exactly as before: tickets exist only where the rooms are bound.
235
+ if (this.env && this.env.ROOM_TICKET_SECRET) {
236
+ const ok = path && await verifyRoomTicket(
237
+ this.env.ROOM_TICKET_SECRET, url.searchParams.get("ticket") || "", { workspace: ws, path });
238
+ if (!ok) return new Response("forbidden", { status: 403 });
239
+ }
240
+ const name = (url.searchParams.get("name") || "Guest").slice(0, 60);
241
+ const sid = "p" + Math.random().toString(36).slice(2, 10);
242
+ // `kind=agent` marks a Claude collaboration client — clients render it as Clawd, not the
243
+ // arrow. Only an agent may PIN its color (so Clawd stays its brand hue instead of a
244
+ // palette slot); humans always take the next palette color (no color hijacking).
245
+ const kind = (url.searchParams.get("kind") || "").slice(0, 16) || null;
246
+ const reqColor = url.searchParams.get("color");
247
+ const pinned = kind === "agent" && reqColor && /^#[0-9a-fA-F]{6}$/.test(reqColor) ? reqColor : null;
248
+ const color = pinned || COLORS[this.ctx.getWebSockets().length % COLORS.length];
249
+ // an account avatar rides the join and is relayed to peers — same-origin PATHS only,
250
+ // so the room never becomes a vehicle for arbitrary external images
251
+ const reqAvatar = (url.searchParams.get("avatar") || "").slice(0, 300);
252
+ const avatar = reqAvatar.startsWith("/") ? reqAvatar : null;
253
+
254
+ // the doc comes up BEFORE the welcome goes out, so every joiner starts from the
255
+ // room's authoritative state — never from a possibly-stale KV read of their own
256
+ await this.load();
257
+
258
+ this.sweep();
259
+ const pair = new WebSocketPair();
260
+ const [client, server] = Object.values(pair);
261
+ this.ctx.acceptWebSocket(server);
262
+ server.serializeAttachment({ sid, name, color, avatar, kind, pose: null, focus: null, joined: Date.now() });
263
+
264
+ const welcome = { t: "welcome", sid, color, peers: this.peers(server) };
265
+ if (this.doc) welcome.doc = this.wireDoc();
266
+ else welcome.needDoc = true; // brand-new board (or a cold test room): the client seeds
267
+ // a joiner walks into a running timer mid-countdown — hand them the live values
268
+ const sess = await this.sessionState();
269
+ if (sess.timer || sess.music) welcome.session = this.sessionWire(sess);
270
+ this.send(server, welcome);
271
+ this.broadcast({ t: "join", peer: { sid, name, color, avatar, kind, pose: null, focus: null } }, server);
272
+
273
+ return new Response(null, { status: 101, webSocket: client });
274
+ }
275
+
276
+ async webSocketMessage(ws, raw) {
277
+ if (typeof raw !== "string" || raw.length > MAX_MSG) return;
278
+ this.sweep();
279
+ let msg;
280
+ try { msg = JSON.parse(raw); } catch (e) { return; }
281
+ const a = ws.deserializeAttachment();
282
+ if (!a || !msg || !msg.t) return;
283
+
284
+ if (msg.t === "cursor") {
285
+ const out = { t: "cursor", sid: a.sid, name: a.name, color: a.color, kind: a.kind || null, x: msg.x, y: msg.y, gone: !!msg.gone };
286
+ // drag fast-path: mid-drag geometry rides the cursor cadence (relay only — never
287
+ // applied to the room doc; the durable upserts follow on the sender's ops tick)
288
+ if (Array.isArray(msg.drag) && msg.drag.length && msg.drag.length <= 64) out.drag = msg.drag;
289
+ this.broadcast(out, ws);
290
+ return;
291
+ }
292
+ if (msg.t === "sel") {
293
+ // live selection (colored outlines on what each person has selected) — kept on the
294
+ // attachment so late joiners see it via peers()
295
+ a.sel = Array.isArray(msg.ids) ? msg.ids.slice(0, 200).filter((x) => typeof x === "string") : null;
296
+ if (a.sel && !a.sel.length) a.sel = null;
297
+ ws.serializeAttachment(a);
298
+ this.broadcast({ t: "sel", sid: a.sid, color: a.color, ids: a.sel }, ws);
299
+ return;
300
+ }
301
+ if (msg.t === "view") {
302
+ // live viewport (pan/zoom + window size) — follow mode mirrors it. Kept on the
303
+ // attachment so clicking Follow (or joining late) syncs before the peer next moves
304
+ const v = msg.v;
305
+ a.view = v && [v.x, v.y, v.s, v.w, v.h].every((n) => typeof n === "number" && isFinite(n)) && v.s > 0
306
+ ? { x: v.x, y: v.y, s: v.s, w: Math.max(1, v.w), h: Math.max(1, v.h) } : null;
307
+ ws.serializeAttachment(a);
308
+ this.broadcast({ t: "view", sid: a.sid, view: a.view }, ws);
309
+ return;
310
+ }
311
+ if (msg.t === "proto") {
312
+ // demo sync inside live prototype tiles — pure ephemeral relay, nothing stored
313
+ this.broadcast({ t: "proto", sid: a.sid, id: msg.id, ev: msg.ev }, ws);
314
+ return;
315
+ }
316
+ if (msg.t === "ops" && Array.isArray(msg.ops)) {
317
+ await this.load();
318
+ if (!this.doc) {
319
+ // only reachable in a test room (real rooms always load or start a doc): relay for
320
+ // liveness and ask the sender — who by definition has the state — for a snapshot
321
+ this.broadcast({ t: "ops", sid: a.sid, ops: msg.ops }, ws);
322
+ if (!this.wantDoc) { this.wantDoc = true; this.send(ws, { t: "docreq" }); }
323
+ return;
324
+ }
325
+ const r = this.applyOps(msg.ops, /*seedMode*/ false);
326
+ if (r.accepted.length) this.broadcast({ t: "ops", sid: a.sid, ops: r.accepted }, ws);
327
+ // the sender lost one or more races — hand them the winning state so they converge
328
+ // (everyone else already holds it or is about to via the accepted broadcast)
329
+ if (r.corrections.length) this.send(ws, { t: "ops", sid: "room", ops: r.corrections });
330
+ return;
331
+ }
332
+ if (msg.t === "focus") {
333
+ a.focus = msg.id || null;
334
+ ws.serializeAttachment(a);
335
+ this.broadcast({ t: "focus", sid: a.sid, name: a.name, color: a.color, id: a.focus }, ws);
336
+ return;
337
+ }
338
+ if (msg.t === "status") {
339
+ // persistent "what am I working on" line under a cursor — survives on the attachment
340
+ // so late joiners see it (unlike chat, which is a moment, not a state)
341
+ const text = (typeof msg.text === "string" ? msg.text : "").slice(0, 120);
342
+ const state = ["working", "idle", "attention", "done"].indexOf(msg.state) >= 0 ? msg.state : "working";
343
+ a.status = text || state !== "working" ? { text, state } : null;
344
+ ws.serializeAttachment(a);
345
+ this.broadcast({ t: "status", sid: a.sid, status: a.status }, ws);
346
+ return;
347
+ }
348
+ if (msg.t === "chat") {
349
+ // cursor chat: ephemeral, relayed and forgotten (no storage, no replay)
350
+ const text = (typeof msg.text === "string" ? msg.text : "").slice(0, 200);
351
+ if (text) this.broadcast({ t: "chat", sid: a.sid, name: a.name, color: a.color, kind: a.kind || null, text }, ws);
352
+ return;
353
+ }
354
+ if (msg.t === "timer" || msg.t === "music") {
355
+ // Serialized, not fire-and-forget: two people hitting "+1 min" in the same tick would
356
+ // otherwise both read the pre-write state and the second would overwrite the first's
357
+ // minute instead of stacking on it.
358
+ this.sessQ = (this.sessQ || Promise.resolve()).then(() => this.applySession(msg)).catch(() => {});
359
+ return;
360
+ }
361
+ if (msg.t === "kick") {
362
+ // Remove an AGENT from the board. Delivered only to the target, whose client ends its
363
+ // own process on receipt — a real eviction, not a UI one, which is the only kind worth
364
+ // having (hiding the avatar would leave the thing still editing the board).
365
+ // AGENTS ONLY: a human's tab belongs to that human, not to whoever else is in the room.
366
+ // Pure relay, nothing stored — a room that remembered its evictions would need an
367
+ // un-ban path and a policy about who may set one. The record that it happened belongs
368
+ // with the agent instead, in its own event log, where its next turn will read it.
369
+ const target = typeof msg.sid === "string" ? msg.sid : "";
370
+ if (!target) return;
371
+ for (const peer of this.ctx.getWebSockets()) {
372
+ const p = peer.deserializeAttachment();
373
+ if (!p || p.sid !== target || p.kind !== "agent") continue;
374
+ this.send(peer, { t: "kick", sid: target, by: a.name || "" });
375
+ }
376
+ return;
377
+ }
378
+ if (msg.t === "pose") {
379
+ // an agent's Clawd expression (idle/coding/sleeping/…) — stored per-session so late
380
+ // joiners see the right face, and relayed so everyone updates it live.
381
+ a.pose = (typeof msg.pose === "string" ? msg.pose : "").slice(0, 24) || null;
382
+ ws.serializeAttachment(a);
383
+ this.broadcast({ t: "pose", sid: a.sid, pose: a.pose }, ws);
384
+ return;
385
+ }
386
+ if (msg.t === "doc" && msg.doc && Array.isArray(msg.doc.nodes)) {
387
+ await this.load();
388
+ this.wantDoc = false;
389
+ if (!this.doc) {
390
+ // brand-new board (or a test room rebuilding after hibernation): adopt wholesale —
391
+ // there is nothing to reconcile against. Nodes keep the versions they came with
392
+ // (v-less stays v-less = v0), so the seeder's own later bumps still win cleanly.
393
+ this.adoptDoc(msg.doc);
394
+ return;
395
+ }
396
+ // A board exists → the seed is an OFFER, reconciled per-node under the version rules
397
+ // (v-less seed nodes count as v0). A slept-laptop tab reconnecting with last week's
398
+ // RAM merges its genuinely-new nodes and LOSES everything the board has since
399
+ // out-versioned — the exact opposite of the old wholesale adopt, which let the
400
+ // stalest client in the world overwrite everyone (the #1 loss bug of the audit).
401
+ const r = this.reconcileSeed(msg.doc);
402
+ if (r.accepted.length) this.broadcast({ t: "ops", sid: "room", ops: r.accepted }, ws);
403
+ // where the room won, the seeder is the one holding stale state — correct them
404
+ if (r.corrections.length) this.send(ws, { t: "ops", sid: "room", ops: r.corrections });
405
+ return;
406
+ }
407
+ }
408
+
409
+ // ---- session: the shared timer + music ------------------------------------
410
+ // Room-level state, unlike status/pose which hang off one socket: a room has ONE timer and
411
+ // ONE track, the same for everyone. Held in ctx.storage (not the doc) because a countdown
412
+ // is a moment, not board content — it must not land in the KV document, ride the ops tick,
413
+ // or turn up in undo.
414
+ //
415
+ // NO ALARM, deliberately. Expiry is computed by each client from the remaining ms below.
416
+ // This DO has a single alarm slot and it belongs to the KV persist rail; a timer that
417
+ // borrowed it would silently cancel a pending document write — losing board edits to
418
+ // show a countdown would be a terrible trade.
419
+ //
420
+ // Time goes on the wire as REMAINING MILLISECONDS AT SEND, never as an absolute deadline.
421
+ // A client stamps arrival with its own monotonic clock and counts down from there, so a
422
+ // laptop whose wall clock is ten minutes off still shows the same 04:56 as everyone else.
423
+ // The stored `at` is only ever differenced against this DO's own clock, so it stays exact
424
+ // across hibernation.
425
+ async sessionState() {
426
+ if (!this.sess) {
427
+ const s = (await this.ctx.storage.get("sess")) || {};
428
+ this.sess = { timer: s.timer || null, music: s.music || null };
429
+ // don't greet a joiner with last month's meeting frozen at 00:00
430
+ const t = this.sess.timer;
431
+ if (t && !t.running && t.remain <= 0 && Date.now() - t.at > STALE_TIMER_MS) this.sess.timer = null;
432
+ }
433
+ return this.sess;
434
+ }
435
+ // stored values rolled forward to this instant
436
+ sessionWire(s) {
437
+ const now = Date.now();
438
+ const out = { timer: null, music: null };
439
+ if (s.timer) {
440
+ const remain = s.timer.running ? Math.max(0, s.timer.remain - (now - s.timer.at)) : s.timer.remain;
441
+ // An EXPIRED countdown is not state: clients revert to idle the moment they hit 00:00
442
+ // (they announce it locally — the room broadcasts nothing at that instant), so a joiner
443
+ // must not be handed a frozen 00:00 the room they're joining stopped showing.
444
+ if (remain > 0) out.timer = { running: !!s.timer.running, remain, total: s.timer.total };
445
+ }
446
+ if (s.music) {
447
+ out.music = {
448
+ track: s.music.track,
449
+ playing: !!s.music.playing,
450
+ elapsed: s.music.playing ? s.music.elapsed + (now - s.music.at) : s.music.elapsed,
451
+ };
452
+ }
453
+ return out;
454
+ }
455
+ saveSession(s) {
456
+ this.sess = s;
457
+ this.ctx.storage.put("sess", s);
458
+ // to EVERYONE including the sender: the room's value is the authoritative one, so the
459
+ // person who clicked snaps to it too rather than trusting their own optimistic guess
460
+ this.broadcast({ t: "session", ...this.sessionWire(s) });
461
+ }
462
+ async applySession(msg) {
463
+ const s = await this.sessionState();
464
+ const now = Date.now();
465
+ if (msg.t === "timer") {
466
+ const cur = s.timer;
467
+ const remain = cur ? (cur.running ? Math.max(0, cur.remain - (now - cur.at)) : cur.remain) : 0;
468
+ const arg = Math.min(Math.max(Math.round(Number(msg.ms)) || 0, 0), MAX_TIMER_MS);
469
+ if (msg.do === "start") {
470
+ if (arg < 1000) return;
471
+ s.timer = { running: true, remain: arg, total: arg, at: now };
472
+ } else if (msg.do === "add") {
473
+ if (!cur || !arg) return;
474
+ const next = Math.min(remain + arg, MAX_TIMER_MS);
475
+ // adding time to a timer that already rang restarts it — that IS the point of
476
+ // "+1 min" at 00:00, and it's the only way back without re-entering the duration
477
+ s.timer = { running: cur.running || remain <= 0, remain: next, total: Math.max(cur.total, next), at: now };
478
+ } else if (msg.do === "pause") {
479
+ if (!cur || !cur.running || remain <= 0) return;
480
+ s.timer = { running: false, remain, total: cur.total, at: now };
481
+ } else if (msg.do === "resume") {
482
+ if (!cur || cur.running || remain <= 0) return;
483
+ s.timer = { running: true, remain, total: cur.total, at: now };
484
+ } else if (msg.do === "stop") {
485
+ if (!cur) return;
486
+ s.timer = null;
487
+ } else return;
488
+ } else {
489
+ const cur = s.music;
490
+ if (msg.do === "play") {
491
+ const track = (typeof msg.track === "string" ? msg.track : "").slice(0, 64);
492
+ if (!track) return;
493
+ // Resuming the same track picks up where it stopped; a NEW track takes the offset the
494
+ // caller chose. That offset is a random entry point, which is why it's the client's to
495
+ // pick and the room's to make authoritative — everyone must land on the same bar.
496
+ const same = cur && cur.track === track;
497
+ const elapsed = same
498
+ ? (cur.playing ? cur.elapsed + (now - cur.at) : cur.elapsed)
499
+ : Math.max(0, Math.round(Number(msg.at)) || 0);
500
+ s.music = { track, playing: true, elapsed, at: now };
501
+ } else if (msg.do === "stop") {
502
+ if (!cur || !cur.playing) return;
503
+ s.music = { track: cur.track, playing: false, elapsed: cur.elapsed + (now - cur.at), at: now };
504
+ } else return;
505
+ }
506
+ this.saveSession(s);
507
+ }
508
+
509
+ // ---- the document (storage-backed, version-ruled) --------------------------
510
+ wireDoc() {
511
+ // tombs ride along so a reconnecting client can apply deletions it slept through
512
+ return { name: this.doc.name, nameV: this.doc.nameV, nodes: this.doc.nodes, tombs: this.doc.tombs, clock: this.doc.clock };
513
+ }
514
+ indexDoc() {
515
+ this.byId = new Map();
516
+ for (const n of this.doc.nodes) this.byId.set(n.id, n);
517
+ }
518
+ async load() {
519
+ if (this.doc || this.ephemeral) return;
520
+ if (!this.loadP) this.loadP = this._load().finally(() => { this.loadP = null; });
521
+ return this.loadP;
522
+ }
523
+ async _load() {
524
+ if (!this.path) {
525
+ this.path = await this.ctx.storage.get("path");
526
+ this.ephemeral = !!this.path && this.path.indexOf("/__test/") === 0;
527
+ if (this.ephemeral) return;
528
+ }
529
+ const m = await this.ctx.storage.get("m");
530
+ if (m) {
531
+ const rows = await this.ctx.storage.list({ prefix: "n:" });
532
+ const nodes = [];
533
+ this.chunked = new Set();
534
+ for (const [key, val] of rows) {
535
+ let node = typeof val === "string" ? JSON.parse(val) : val;
536
+ if (node && node.__c) { // oversize node, stored chunked
537
+ const parts = [];
538
+ for (let i = 0; i < node.__c; i++) parts.push(await this.ctx.storage.get("N:" + key.slice(2) + ":" + i));
539
+ node = JSON.parse(parts.join(""));
540
+ if (node) this.chunked.add(node.id);
541
+ }
542
+ if (node && node.id) nodes.push(node);
543
+ }
544
+ // restore the persisted z-order (rows come back key-sorted); ids the order row
545
+ // doesn't know (mid-flight writes) sink to the end, stably
546
+ if (Array.isArray(m.order)) {
547
+ const pos = new Map(m.order.map((id, i) => [id, i]));
548
+ nodes.sort((a, b) => (pos.has(a.id) ? pos.get(a.id) : Infinity) - (pos.has(b.id) ? pos.get(b.id) : Infinity));
549
+ }
550
+ this.doc = { name: m.name || "Untitled canvas", nameV: m.nameV || 0, nodes, tombs: m.tombs || {}, clock: m.clock || 0 };
551
+ this.indexDoc();
552
+ // Fold the KV mirror back in (version-ruled, so a lagging mirror merges to nothing):
553
+ // solo clients and terminal scripts legitimately write /__board while the room is
554
+ // empty, and waking up storage-only would erase their work at the next mirror write.
555
+ try {
556
+ const raw = await this.kvGet();
557
+ const kv = raw ? JSON.parse(raw) : null;
558
+ if (kv && Array.isArray(kv.nodes)) this.reconcileSeed(kv); // no sender — corrections go nowhere, accepted ops are already durable
559
+ } catch (e) {}
560
+ return;
561
+ }
562
+ await this.migrate();
563
+ }
564
+ // One-time, per board: bring a pre-storage board into the DO. Sources, in trust order:
565
+ // the legacy in-storage stash IF it was dirty (a write the old code owed KV and may never
566
+ // have delivered), else the KV doc. When both exist they reconcile per-node — everything
567
+ // is v-less here, so ties go to whichever source the dirty flag says was newer.
568
+ async migrate() {
569
+ const stash = await this.ctx.storage.get("doc");
570
+ const wasDirty = !!(await this.ctx.storage.get("dirty"));
571
+ let kvDoc = null;
572
+ try {
573
+ const raw = await this.kvGet();
574
+ kvDoc = raw ? JSON.parse(raw) : null;
575
+ } catch (e) {}
576
+ let src = null;
577
+ if (kvDoc && stash && Array.isArray(kvDoc.nodes) && Array.isArray(stash.nodes)) {
578
+ const base = wasDirty ? stash : kvDoc, over = wasDirty ? kvDoc : stash;
579
+ const ids = new Map(base.nodes.map((n) => [n.id, n]));
580
+ for (const n of over.nodes) if (n && n.id && !ids.has(n.id)) ids.set(n.id, n); // union — deletions can't be told apart here, keep both
581
+ src = { name: base.name, nodes: [...ids.values()] };
582
+ } else src = kvDoc || stash || null;
583
+ if (!src || !Array.isArray(src.nodes)) return; // brand-new board — stays null until a client seeds
584
+ this.doc = {
585
+ name: src.name || "Untitled canvas",
586
+ nameV: src.nameV || 0,
587
+ nodes: src.nodes.filter((n) => n && n.id),
588
+ tombs: src.tombs || {},
589
+ clock: src.clock || 0,
590
+ };
591
+ this.indexDoc();
592
+ this.writeAll();
593
+ this.ctx.storage.delete("doc"); // the legacy stash's job is done
594
+ if (wasDirty) this.markDirty(); // the old code owed KV a write — we inherit the debt
595
+ }
596
+ putNode(puts, dels, n) {
597
+ const s = JSON.stringify(n);
598
+ if (s.length <= NODE_CHUNK) {
599
+ puts["n:" + n.id] = s;
600
+ if (this.chunked.delete(n.id)) dels.push(...this.chunkKeys(n.id)); // shrank back under the limit
601
+ return;
602
+ }
603
+ const k = Math.ceil(s.length / NODE_CHUNK);
604
+ for (let i = 0; i < k; i++) puts["N:" + n.id + ":" + i] = s.slice(i * NODE_CHUNK, (i + 1) * NODE_CHUNK);
605
+ puts["n:" + n.id] = JSON.stringify({ __c: k });
606
+ this.chunked.add(n.id);
607
+ }
608
+ chunkKeys(id) {
609
+ // over-delete up to the practical ceiling (16 × 1.8MB ≫ the 20MB doc cap); deleting a
610
+ // missing key is a no-op, and this only runs when a node crosses the chunk boundary
611
+ const out = [];
612
+ for (let i = 0; i < 16; i++) out.push("N:" + id + ":" + i);
613
+ return out;
614
+ }
615
+ metaRow() {
616
+ // `order` pins node z-order across cold loads — storage.list returns rows in KEY
617
+ // order, which silently re-stacked overlapping nodes after every hibernation
618
+ return { name: this.doc.name, nameV: this.doc.nameV, tombs: this.doc.tombs, clock: this.doc.clock, order: this.doc.nodes.map((n) => n.id) };
619
+ }
620
+ writeAll() {
621
+ const puts = { m: this.metaRow() }, dels = [];
622
+ for (const n of this.doc.nodes) this.putNode(puts, dels, n);
623
+ this.flushRows(puts, dels);
624
+ }
625
+ flushRows(puts, dels) {
626
+ if (this.ephemeral) return;
627
+ // no awaits between these — the runtime coalesces them into one atomic write batch
628
+ const keys = Object.keys(puts);
629
+ for (let i = 0; i < keys.length; i += PUT_BATCH) {
630
+ const slice = {};
631
+ for (const k of keys.slice(i, i + PUT_BATCH)) slice[k] = puts[k];
632
+ this.ctx.storage.put(slice).catch((e) => console.error("storage put failed", e));
633
+ }
634
+ for (let i = 0; i < dels.length; i += PUT_BATCH)
635
+ this.ctx.storage.delete(dels.slice(i, i + PUT_BATCH)).catch(() => {});
636
+ }
637
+ adoptDoc(d) {
638
+ this.doc = {
639
+ name: typeof d.name === "string" ? d.name.slice(0, 200) : "Untitled canvas",
640
+ nameV: vOf({ v: d.nameV }),
641
+ nodes: d.nodes.filter((n) => n && n.id),
642
+ tombs: d.tombs && typeof d.tombs === "object" ? d.tombs : {},
643
+ clock: 0,
644
+ };
645
+ this.indexDoc();
646
+ this.writeAll();
647
+ this.markDirty();
648
+ }
649
+ pruneTombs() {
650
+ const t = this.doc.tombs, now = Date.now(), ids = Object.keys(t);
651
+ if (ids.length <= TOMB_MAX && !ids.some((id) => now - (t[id].t || 0) > TOMB_TTL)) return;
652
+ const keep = ids.filter((id) => now - (t[id].t || 0) <= TOMB_TTL)
653
+ .sort((a, b) => (t[b].t || 0) - (t[a].t || 0)).slice(0, TOMB_MAX);
654
+ this.doc.tombs = {};
655
+ for (const id of keep) this.doc.tombs[id] = t[id];
656
+ }
657
+ // Apply a batch under the version rules. Returns what to broadcast (accepted, with
658
+ // room-stamped versions where the sender had none) and what to bounce back to the
659
+ // sender (corrections — the winning state for every op that lost).
660
+ // seedMode: nodes from a {t:"doc"} offer — v-less means v0 (stale until proven fresh),
661
+ // whereas a v-less LIVE op is a legacy client's real edit and is accepted + stamped.
662
+ applyOps(ops, seedMode) {
663
+ const accepted = [], corrections = [];
664
+ let metaDirty = false;
665
+ const puts = {}, dels = [];
666
+ for (const op of ops) {
667
+ if (!op) continue;
668
+ if (op.op === "upsert" && op.node && op.node.id) {
669
+ const n = op.node, id = n.id, cur = this.byId.get(id), tomb = tombAt(this.doc.tombs, id);
670
+ const versioned = typeof n.v === "number";
671
+ if (tomb && !(vOf(n) > tomb.v)) {
672
+ // deleted, and this write predates the delete — a stale resurrection attempt
673
+ if (!seedMode && !versioned) { /* legacy live edit of a deleted node: let the delete win */ }
674
+ // CORRECTIONS MUST STRICTLY OUT-VERSION THE LOSER (audit v2): on a tie the
675
+ // sender's own copy sits at the tomb's v, and its resurrection guard drops a
676
+ // del that doesn't exceed it — raise the tomb so the correction wins there too
677
+ if (versioned && vOf(n) === tomb.v) { tomb.v = vOf(n) + 1; setTomb(this.doc.tombs, id, tomb); metaDirty = true; }
678
+ corrections.push({ op: "del", id, v: tomb.v });
679
+ continue;
680
+ }
681
+ if (cur) {
682
+ if (!versioned && !seedMode) {
683
+ // legacy client's live edit: accept and stamp, so versioned clients converge
684
+ n.v = vOf(cur) + 1; n.vn = rnd();
685
+ } else if (sameV(n, cur)) {
686
+ // same version twice is an idempotent echo — UNLESS the content drifted, which
687
+ // only unversioned (legacy/migrated) writes can produce. The room's copy wins,
688
+ // but a correction at the SAME v/vn would be dropped by the sender's own LWW
689
+ // gate — bump the winner so the correction strictly wins everywhere.
690
+ if (JSON.stringify(n) !== JSON.stringify(cur)) {
691
+ cur.v = vOf(cur) + 1; cur.vn = rnd();
692
+ this.putNode(puts, dels, cur);
693
+ accepted.push({ op: "upsert", node: cur });
694
+ corrections.push({ op: "upsert", node: cur });
695
+ }
696
+ continue;
697
+ } else if (!beats(n, cur)) {
698
+ // a v tie with a lower vn still loses cleanly (the correction's higher vn
699
+ // beats the sender's copy) — only exact sameV needed the bump above
700
+ corrections.push({ op: "upsert", node: cur });
701
+ continue;
702
+ }
703
+ } else if (!versioned && !seedMode) {
704
+ n.v = tomb ? tomb.v + 1 : 1; n.vn = rnd();
705
+ }
706
+ if (tomb) { delete this.doc.tombs[id]; metaDirty = true; }
707
+ if (cur) this.doc.nodes[this.doc.nodes.indexOf(cur)] = n;
708
+ else this.doc.nodes.push(n);
709
+ this.byId.set(id, n);
710
+ this.putNode(puts, dels, n);
711
+ accepted.push({ op: "upsert", node: n });
712
+ } else if (op.op === "del" && op.id) {
713
+ const id = op.id, cur = this.byId.get(id), tomb = tombAt(this.doc.tombs, id);
714
+ const versioned = typeof op.v === "number";
715
+ let tombV;
716
+ if (cur) {
717
+ if (versioned && !(op.v > vOf(cur))) {
718
+ // the delete lost. On a TIE (concurrent delete + edit landing on the same v)
719
+ // the deleter's tomb sits at op.v == cur.v and its resurrection guard drops a
720
+ // corrective upsert that doesn't strictly exceed it — bump the survivor so the
721
+ // correction wins at the deleter too (audit v2: the deleter went blind forever)
722
+ if (op.v === vOf(cur)) {
723
+ cur.v = vOf(cur) + 1; cur.vn = rnd();
724
+ this.putNode(puts, dels, cur);
725
+ accepted.push({ op: "upsert", node: cur });
726
+ }
727
+ corrections.push({ op: "upsert", node: cur });
728
+ continue;
729
+ }
730
+ tombV = versioned ? op.v : vOf(cur) + 1;
731
+ this.doc.nodes.splice(this.doc.nodes.indexOf(cur), 1);
732
+ this.byId.delete(id);
733
+ dels.push("n:" + id);
734
+ if (this.chunked.delete(id)) dels.push(...this.chunkKeys(id));
735
+ } else {
736
+ // nothing to delete, but record/raise the tombstone anyway: it's what stops the
737
+ // node coming back when a slower peer's upsert for it arrives after this del
738
+ tombV = Math.max(versioned ? op.v : 1, tomb ? tomb.v + (versioned ? 0 : 1) : 0);
739
+ if (tomb && tombV <= tomb.v) continue;
740
+ }
741
+ setTomb(this.doc.tombs, id, { v: tombV, t: Date.now() });
742
+ metaDirty = true;
743
+ accepted.push({ op: "del", id, v: tombV });
744
+ } else if (op.op === "name" && typeof op.name === "string") {
745
+ const versioned = typeof op.v === "number";
746
+ const curV = this.doc.nameV;
747
+ if (versioned && op.v <= curV) {
748
+ // name has no vn tiebreak, so a TIE (both renamed offline to the same nameV) is
749
+ // unresolvable by the correction alone — the loser's client drops v <= its own.
750
+ // Bump the winning name past the tie so everyone, loser included, converges.
751
+ if (op.v === curV && op.name !== this.doc.name) {
752
+ this.doc.nameV = curV + 1;
753
+ metaDirty = true;
754
+ accepted.push({ op: "name", name: this.doc.name, v: this.doc.nameV });
755
+ }
756
+ corrections.push({ op: "name", name: this.doc.name, v: this.doc.nameV });
757
+ continue;
758
+ }
759
+ this.doc.name = op.name.slice(0, 200);
760
+ this.doc.nameV = versioned ? op.v : curV + 1;
761
+ metaDirty = true;
762
+ accepted.push({ op: "name", name: this.doc.name, v: this.doc.nameV });
763
+ }
764
+ }
765
+ if (accepted.length || metaDirty) {
766
+ this.doc.clock++;
767
+ this.pruneTombs();
768
+ puts.m = this.metaRow();
769
+ this.flushRows(puts, dels);
770
+ this.markDirty();
771
+ }
772
+ return { accepted, corrections };
773
+ }
774
+ // A {t:"doc"} offer against an existing board: every node becomes a seed-mode upsert,
775
+ // every tomb a del; nodes the room has that the offer lacks are LEFT ALONE (an absence
776
+ // in a stale snapshot is not a deletion — real deletions travel as tombs/ops).
777
+ reconcileSeed(d) {
778
+ const ops = [];
779
+ for (const n of d.nodes) if (n && n.id) ops.push({ op: "upsert", node: n });
780
+ if (d.tombs && typeof d.tombs === "object")
781
+ for (const id of Object.keys(d.tombs)) ops.push({ op: "del", id, v: vOf({ v: d.tombs[id] && d.tombs[id].v }) });
782
+ if (typeof d.name === "string" && typeof d.nameV === "number") ops.push({ op: "name", name: d.name, v: d.nameV });
783
+ const r = this.applyOps(ops, /*seedMode*/ true);
784
+ // corrections the seeder needs beyond op losses: the nodes it doesn't know exist
785
+ const known = new Set(d.nodes.map((n) => n && n.id));
786
+ for (const n of this.doc.nodes) if (!known.has(n.id)) r.corrections.push({ op: "upsert", node: n });
787
+ return r;
788
+ }
789
+
790
+ // ---- KV mirror (public GET + solo fallback read from it) -------------------
791
+ // Storage is the source of truth; KV gets a write-through copy on the old cadence: a
792
+ // dirty flag arms a 45s alarm, the last socket leaving flushes immediately. `dirty` is
793
+ // durable because the alarm outlives the instance that armed it (hibernation).
794
+ markDirty() {
795
+ if (this.ephemeral) return;
796
+ if (!this.dirty) { this.dirty = true; this.ctx.storage.put("dirty", 1); }
797
+ if (this.alarmSet) return;
798
+ this.alarmSet = true;
799
+ this.ctx.storage.setAlarm(Date.now() + PERSIST_MS);
800
+ }
801
+ async isDirty() {
802
+ if (this.dirty) return true;
803
+ this.dirty = !!(await this.ctx.storage.get("dirty"));
804
+ return this.dirty;
805
+ }
806
+ clearDirty() {
807
+ this.dirty = false;
808
+ return this.ctx.storage.delete("dirty");
809
+ }
810
+ async alarm() {
811
+ this.alarmSet = false;
812
+ if (this.ephemeral) return;
813
+ if (!(await this.isDirty())) return;
814
+ await this.load(); // hibernation wake: the doc is in storage, no client required
815
+ await this.mirror();
816
+ // still dirty = the write failed, or ops landed while it ran — either way, retry
817
+ if (this.dirty && !this.alarmSet) { this.alarmSet = true; this.ctx.storage.setAlarm(Date.now() + RETRY_MS); }
818
+ }
819
+ async mirror() {
820
+ if (!this.doc || !boardKv(this.env) || this.ephemeral) return;
821
+ if (!(await this.isDirty())) return;
822
+ await this.clearDirty(); // before the await: ops during the write re-set it
823
+ try {
824
+ // FOLD BEFORE OVERWRITE (audit v2): a solo client or terminal script may have
825
+ // written /__board while this room was HOT — the old blind put steamrolled that
826
+ // write and, since the room only reads KV on a COLD load, the edit was lost
827
+ // permanently (close-the-laptop-after-a-blip). If KV moved since OUR last mirror,
828
+ // reconcile it in (version-ruled, so a lagging copy merges to nothing) and let the
829
+ // live clients hear whatever was genuinely new.
830
+ const kvRaw = await this.kvGet();
831
+ if (kvRaw && hashStr(kvRaw) !== (await this.ctx.storage.get("mhash"))) {
832
+ try {
833
+ const kv = JSON.parse(kvRaw);
834
+ if (kv && Array.isArray(kv.nodes)) {
835
+ const r = this.reconcileSeed(kv);
836
+ if (r.accepted.length) this.broadcast({ t: "ops", sid: "room", ops: r.accepted });
837
+ }
838
+ } catch (e2) {}
839
+ }
840
+ const out = JSON.stringify(this.wireDoc());
841
+ await this.kvPut(out);
842
+ this.ctx.storage.put("mhash", hashStr(out));
843
+ } catch (e) {
844
+ console.error("KV mirror write failed", e);
845
+ this.dirty = true; this.ctx.storage.put("dirty", 1);
846
+ // a failed write deserves the FAST retry — the pending 45s cadence alarm would
847
+ // otherwise swallow the RETRY_MS re-arm (alarmSet is still true from markDirty)
848
+ this.alarmSet = true;
849
+ this.ctx.storage.setAlarm(Date.now() + RETRY_MS);
850
+ }
851
+ }
852
+
853
+ webSocketClose(ws) { this.reap(ws); }
854
+ webSocketError(ws) { this.reap(ws); }
855
+ // A socket counts as LIVE if it pinged inside ~3 keepalive intervals (sweep's rule). A
856
+ // zombie (dead transport whose close never fired) must not block the last-one-out flush.
857
+ isLive(ws) {
858
+ const ts = this.ctx.getWebSocketAutoResponseTimestamp(ws);
859
+ const a = ws.deserializeAttachment();
860
+ const seen = ts ? ts.getTime() : (a && a.joined) || 0;
861
+ return !seen || Date.now() - seen <= 75000;
862
+ }
863
+ reap(ws) {
864
+ const a = ws.deserializeAttachment();
865
+ try { ws.close(); } catch (e) {}
866
+ if (a) this.broadcast({ t: "leave", peer: { sid: a.sid, name: a.name, color: a.color } }, ws);
867
+ if (!this.ctx.getWebSockets().some((s) => s !== ws && this.isLive(s))) {
868
+ // last one out: mirror to KV NOW (don't wait for the alarm), then drop the RAM cache
869
+ // — storage keeps the doc, so an empty room costs nothing and forgets nothing. A
870
+ // failed mirror stays dirty and the alarm retry path picks it up (the DO wakes on
871
+ // alarms with zero sockets — that's what alarms are for).
872
+ this.load()
873
+ .then(() => this.mirror())
874
+ .catch(() => {})
875
+ .then(() => {
876
+ if (this.dirty && !this.alarmSet) { this.alarmSet = true; this.ctx.storage.setAlarm(Date.now() + RETRY_MS); }
877
+ this.doc = null; this.byId = null;
878
+ });
879
+ }
880
+ }
881
+ }