@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.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// adapters.mjs — the agent-tool side of drafts that land (docs/drafts-that-land.md §7).
|
|
2
|
+
//
|
|
3
|
+
// Two decisions, made here and nowhere else: MAY THIS PATH BE WRITTEN (before an edit) and
|
|
4
|
+
// WHICH DRAFT SHOULD SAVE NOW (after one). Every agent tool is a row in ADAPTERS naming its
|
|
5
|
+
// settings file, which tool names to hook, and how its payload spells a file path; the
|
|
6
|
+
// command each hook runs is the same `augur hook <pre|post>` for all of them. Adding a
|
|
7
|
+
// tool is adding a row.
|
|
8
|
+
//
|
|
9
|
+
// THE DENY RULE IS NARROW ON PURPOSE. A write is refused only inside a registered read-only
|
|
10
|
+
// copy, or inside a space checkout (a folder with space.json above it) under a unit home —
|
|
11
|
+
// `<opportunity>/prototypes/<name>/…` or `playground/<name>/…` — that is not a registered
|
|
12
|
+
// draft folder. Other projects, a space's design-system tiers, its research and its
|
|
13
|
+
// space.json are never touched: the rule exists so two sessions sharing one checkout stop
|
|
14
|
+
// carrying each other's half-done prototypes live, not to police a machine.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
// What identifies OUR entries in a settings file: the quoted script name and the event,
|
|
21
|
+
// exactly as `hookCommand` spells them — so an entry survives the engine moving on disk
|
|
22
|
+
// (the path changes, the tail does not) and nobody else's hook is ever mistaken for ours.
|
|
23
|
+
export const HOOK_TAG = 'hook.mjs"';
|
|
24
|
+
const OURS_RE = /hook\.mjs" (?:pre|post)$/;
|
|
25
|
+
export const HOOK_SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "hook.mjs");
|
|
26
|
+
export const UNIT_HOME_RE = /^(?:([^/]+)\/prototypes\/([^/]+)|(playground)\/([^/]+))(?:\/|$)/;
|
|
27
|
+
|
|
28
|
+
export function isInside(dir, p) {
|
|
29
|
+
const rel = path.relative(dir, p);
|
|
30
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The nearest folder above `p` holding a space.json, or null. */
|
|
34
|
+
export function spaceRootOf(p) {
|
|
35
|
+
let dir = path.dirname(path.resolve(p));
|
|
36
|
+
for (;;) {
|
|
37
|
+
if (fs.existsSync(path.join(dir, "space.json"))) return dir;
|
|
38
|
+
const up = path.dirname(dir);
|
|
39
|
+
if (up === dir) return null;
|
|
40
|
+
dir = up;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bare = (unit) => String(unit || "").replace(/^\/|\/$/g, "");
|
|
45
|
+
const homeToUnit = (m) => (m[3] ? `/${m[3]}/${m[4]}/` : `/${m[1]}/${m[2]}/`);
|
|
46
|
+
|
|
47
|
+
export function denyDecision({ filePath, cwd, registry, findSpaceRoot = spaceRootOf }) {
|
|
48
|
+
if (!filePath) return { allow: true };
|
|
49
|
+
const abs = path.resolve(cwd || process.cwd(), String(filePath));
|
|
50
|
+
for (const e of registry || []) {
|
|
51
|
+
if (!e || !e.dir || !isInside(e.dir, abs)) continue;
|
|
52
|
+
if (e.readOnly) {
|
|
53
|
+
return { deny: true, reason: `${abs} is a read-only copy of ${e.unit}. To edit it, open a draft: augur open ${bare(e.unit)} — then edit the folder that creates.` };
|
|
54
|
+
}
|
|
55
|
+
return { allow: true, draft: e };
|
|
56
|
+
}
|
|
57
|
+
const root = findSpaceRoot(abs);
|
|
58
|
+
if (!root) return { allow: true };
|
|
59
|
+
const rel = path.relative(root, abs).split(path.sep).join("/");
|
|
60
|
+
const m = UNIT_HOME_RE.exec(rel);
|
|
61
|
+
// The home folder itself (`checkout/prototypes/flow`) is a folder, not a file in one.
|
|
62
|
+
if (!m || rel === m[0].replace(/\/$/, "")) return { allow: true };
|
|
63
|
+
const unit = homeToUnit(m);
|
|
64
|
+
return { deny: true, reason: `${rel} is a prototype in a shared checkout, so edits go through a draft: run augur open ${bare(unit)}, edit the folder it creates (live at once at its own address), and augur land when it is ready.` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function saveDecision({ filePath, cwd, registry }) {
|
|
68
|
+
if (!filePath) return null;
|
|
69
|
+
const abs = path.resolve(cwd || process.cwd(), String(filePath));
|
|
70
|
+
for (const e of registry || []) {
|
|
71
|
+
if (!e || !e.dir || e.readOnly || !isInside(e.dir, abs)) continue;
|
|
72
|
+
if (isInside(path.join(e.dir, ".augur"), abs)) return null;
|
|
73
|
+
return { dir: e.dir, entry: e };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── the tools ────────────────────────────────────────────────────────────────
|
|
79
|
+
export const ADAPTERS = Object.freeze([
|
|
80
|
+
Object.freeze({
|
|
81
|
+
id: "claude-code",
|
|
82
|
+
name: "Claude Code",
|
|
83
|
+
settingsPath: (home) => path.join(home, ".claude", "settings.json"),
|
|
84
|
+
detect: (home) => fs.existsSync(path.join(home, ".claude")),
|
|
85
|
+
matcher: "Write|Edit|MultiEdit|NotebookEdit",
|
|
86
|
+
payload: (j) => {
|
|
87
|
+
const t = (j && j.tool_input) || {};
|
|
88
|
+
return { filePath: t.file_path || t.notebook_path || null, cwd: (j && j.cwd) || null, session: (j && j.session_id) || null };
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
export const hookCommand = (event) => `node "${HOOK_SCRIPT}" ${event}`;
|
|
94
|
+
const ours = (h) => !!(h && Array.isArray(h.hooks) && h.hooks.some((x) => x && typeof x.command === "string" && OURS_RE.test(x.command.trim())));
|
|
95
|
+
|
|
96
|
+
export function mergeHooks(settings, matcher, commands) {
|
|
97
|
+
const s = settings && typeof settings === "object" ? JSON.parse(JSON.stringify(settings)) : {};
|
|
98
|
+
s.hooks = s.hooks && typeof s.hooks === "object" ? s.hooks : {};
|
|
99
|
+
let changed = false;
|
|
100
|
+
for (const [event, command] of [["PreToolUse", commands.pre], ["PostToolUse", commands.post]]) {
|
|
101
|
+
const want = { matcher, hooks: [{ type: "command", command }] };
|
|
102
|
+
const list = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
|
|
103
|
+
const mine = list.filter(ours), theirs = list.filter((h) => !ours(h));
|
|
104
|
+
if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(want)) continue;
|
|
105
|
+
s.hooks[event] = [...theirs, want];
|
|
106
|
+
changed = true;
|
|
107
|
+
}
|
|
108
|
+
return { settings: s, changed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function stripHooks(settings) {
|
|
112
|
+
const s = settings && typeof settings === "object" ? JSON.parse(JSON.stringify(settings)) : {};
|
|
113
|
+
let changed = false;
|
|
114
|
+
for (const event of Object.keys(s.hooks || {})) {
|
|
115
|
+
const list = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
|
|
116
|
+
const theirs = list.filter((h) => !ours(h));
|
|
117
|
+
if (theirs.length === list.length) continue;
|
|
118
|
+
changed = true;
|
|
119
|
+
if (theirs.length) s.hooks[event] = theirs; else delete s.hooks[event];
|
|
120
|
+
}
|
|
121
|
+
return { settings: s, changed };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readJson(p) {
|
|
125
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) { return null; }
|
|
126
|
+
}
|
|
127
|
+
function writeJson(p, v) {
|
|
128
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
129
|
+
fs.writeFileSync(p + ".tmp", JSON.stringify(v, null, 2) + "\n");
|
|
130
|
+
fs.renameSync(p + ".tmp", p);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function applyToAdapters({ home = os.homedir(), dryRun = false } = {}, decide) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const a of ADAPTERS) {
|
|
136
|
+
if (!a.detect(home)) { out.push({ id: a.id, name: a.name, result: "absent" }); continue; }
|
|
137
|
+
const p = a.settingsPath(home);
|
|
138
|
+
const cur = readJson(p);
|
|
139
|
+
const r = decide(a, cur);
|
|
140
|
+
if (r.changed && !dryRun) writeJson(p, r.settings);
|
|
141
|
+
out.push({ id: a.id, name: a.name, result: r.changed ? r.did : "unchanged", path: p });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Install the two hooks for every tool present on this machine. Idempotent; keeps every other hook. */
|
|
147
|
+
export function installAdapters(opts = {}) {
|
|
148
|
+
return applyToAdapters(opts, (a, cur) => {
|
|
149
|
+
const r = mergeHooks(cur, a.matcher, { pre: hookCommand("pre"), post: hookCommand("post") });
|
|
150
|
+
const had = !!(cur && cur.hooks && Object.values(cur.hooks).some((l) => Array.isArray(l) && l.some(ours)));
|
|
151
|
+
return { ...r, did: had ? "updated" : "installed" };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export function removeAdapters(opts = {}) {
|
|
155
|
+
return applyToAdapters(opts, (a, cur) => ({ ...stripHooks(cur), did: "removed" }));
|
|
156
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* board-snapshot — read a board's AUTHORITATIVE document, which is not the one in KV.
|
|
3
|
+
*
|
|
4
|
+
* `MIG-board-snapshot-via-ws`. A board has two copies and only one of them is true. The
|
|
5
|
+
* room (a `BoardRoom` Durable Object) owns the document in its own storage; Workers KV
|
|
6
|
+
* holds a WRITE-THROUGH MIRROR, written on a dirty-alarm cadence — `PERSIST_MS`, 45
|
|
7
|
+
* seconds, `src/board-room.mjs`. The public `GET /__board` serves the mirror, and so does
|
|
8
|
+
* every state export, so a migration that copies boards from KV silently drops whatever was
|
|
9
|
+
* drawn in the last cycle. Nothing fails; the board simply arrives at its new home missing
|
|
10
|
+
* the end of the last session. `src/state-inventory.mjs` says as much about the `board:`
|
|
11
|
+
* family, and this module is the answer to it.
|
|
12
|
+
*
|
|
13
|
+
* THERE IS NO HTTP READ OF THE TRUTH. `BoardRoom.fetch()` handles the WebSocket upgrade and
|
|
14
|
+
* nothing else — no dump route, no `GET /doc`. The one moment the room hands out its whole
|
|
15
|
+
* document is the `welcome` frame it sends on join (`wireDoc()`), AFTER `await this.load()`,
|
|
16
|
+
* so joining as a real client is not a workaround: it is the only read there is.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ THE MIRROR IS STALER THAN THE 45-SECOND CADENCE SAYS, AND BY MORE THAN DOUBLE. The
|
|
19
|
+
* cadence bounds when the room WRITES the mirror. It does not bound when a reader SEES that
|
|
20
|
+
* write: the board rail's `kv.get` sets no `cacheTtl`, so it takes the Workers KV default
|
|
21
|
+
* read cache, and a key that is being polled keeps its cached (pre-edit) value warm. Measured
|
|
22
|
+
* on a live instance: an edit made at a moment when the mirror had just been written took
|
|
23
|
+
* **87 seconds** to appear on `GET /__board`, against a 45-second cadence. So the window in
|
|
24
|
+
* which a KV-sourced copy of a board is wrong is the write cadence PLUS the read cache, and a
|
|
25
|
+
* cutover that allowed 45 seconds of quiet would have allowed less than half of what it needed.
|
|
26
|
+
* `measureLag()` reports the gap in nodes rather than in seconds for exactly this reason: the
|
|
27
|
+
* seconds have two terms and only one of them is a constant anybody can look up.
|
|
28
|
+
*
|
|
29
|
+
* ── WHAT MAKES A READ HERE TRUSTWORTHY ────────────────────────────────────────
|
|
30
|
+
*
|
|
31
|
+
* A socket that connects and receives frames has proven almost nothing. A measurement
|
|
32
|
+
* harness in this repo once ran a client that skipped the seed handshake: the room relayed
|
|
33
|
+
* its ops without ever applying them, every frame arrived, nothing failed, and every number
|
|
34
|
+
* taken off it was wrong. So a read here is only reported when all of the following hold,
|
|
35
|
+
* and each one is checked rather than assumed:
|
|
36
|
+
*
|
|
37
|
+
* 1. A `welcome` frame arrived carrying an `sid`. The room sends it after `load()`, so
|
|
38
|
+
* the frame IS the proof that the document was brought up — a 101 alone is not.
|
|
39
|
+
* 2. It carried EXACTLY ONE of `doc` and `needDoc`. `needDoc` means the room genuinely
|
|
40
|
+
* holds no document (a board nobody has drawn on), which is an ANSWER — reported as
|
|
41
|
+
* `empty`, never as a failed read, and never as a null to be copied over a real board.
|
|
42
|
+
* 3. The document survives a structural check: nodes are an array, every node has an id,
|
|
43
|
+
* no id appears twice.
|
|
44
|
+
* 4. TWO INDEPENDENT SOCKETS, `settleMs` apart, returned the same document. A read taken
|
|
45
|
+
* while somebody is drawing is a read that will be missing the next stroke, and the
|
|
46
|
+
* cutover warning on the room-naming change is exactly this: move boards when they are
|
|
47
|
+
* quiet. A disagreement fails the read and is retried, rather than being sliced in half
|
|
48
|
+
* and reported as a snapshot.
|
|
49
|
+
*
|
|
50
|
+
* And a seed is verified the same way, from the other side: after the document is offered,
|
|
51
|
+
* the seeding socket is CLOSED FIRST and the destination is read back over a FRESH one. The
|
|
52
|
+
* close matters — the last socket out flushes and drops the room's RAM cache, so the
|
|
53
|
+
* verification read comes back through the room's storage rather than out of the memory of
|
|
54
|
+
* the object that was just handed it.
|
|
55
|
+
*
|
|
56
|
+
* ── THE TWO DEPLOYMENT SHAPES, AND WHY THIS ADDRESSES NEITHER ─────────────────
|
|
57
|
+
*
|
|
58
|
+
* A room is reached at `<origin>/__rt?path=…`, and that address is the same on both sides of
|
|
59
|
+
* the migration:
|
|
60
|
+
*
|
|
61
|
+
* BEFORE — no `ROOMS` binding. `rtProxy` forwards to a separate `augur-realtime-*` worker,
|
|
62
|
+
* which names the room after the bare path and whose mirror key is the legacy
|
|
63
|
+
* unscoped `board:<path>`.
|
|
64
|
+
* AFTER — `ROOMS` bound. The room is in the worker's own module graph, named
|
|
65
|
+
* `<workspace>:<path>` by `roomName()`, mirror key `board:<workspace>:<path>`.
|
|
66
|
+
*
|
|
67
|
+
* Which one a deployment is does not appear anywhere below, and must not: the worker
|
|
68
|
+
* resolves the room from the request it already has, the workspace segment is stamped by
|
|
69
|
+
* `resolveTenant` and never by a caller, and a reader that computed a room name would be a
|
|
70
|
+
* second place that decides the isolation boundary. Naming a path and letting the front door
|
|
71
|
+
* answer is what makes one reader work on both shapes.
|
|
72
|
+
*
|
|
73
|
+
* `direct: true` is the exception and it exists for one case: the OLD room when the old front
|
|
74
|
+
* door is no longer usable — a suspended workspace (`/__rt` is not on `SUSPENDED_ALLOWED`) or
|
|
75
|
+
* a hostname already renamed away (every request to it gets the unknown-host refusal). It
|
|
76
|
+
* speaks `/room?path=…` to a standalone realtime worker with its shared secret, and it can
|
|
77
|
+
* only ever reach the legacy unscoped rooms, because a folded-in room has no second address.
|
|
78
|
+
*
|
|
79
|
+
* ── WHAT A READ COSTS THE BOARD ───────────────────────────────────────────────
|
|
80
|
+
*
|
|
81
|
+
* Honestly: a join is not free of side effects, and pretending otherwise would hide the one
|
|
82
|
+
* that matters. Joining wakes the room, which loads its document (migrating a pre-existing
|
|
83
|
+
* board into storage the first time, by design), and LEAVING as the last socket flushes the
|
|
84
|
+
* KV mirror immediately. So a read of a quiet board leaves its mirror fresher than it found
|
|
85
|
+
* it, and never staler. It writes no node, sends no op and changes no version.
|
|
86
|
+
*/
|
|
87
|
+
import { createHash } from "node:crypto";
|
|
88
|
+
|
|
89
|
+
/** Long enough for a cold room on a slow link; short enough that a wedged read is not a hang. */
|
|
90
|
+
export const DEFAULT_TIMEOUT_MS = 20000;
|
|
91
|
+
/** The gap between the two observer joins. Under it, a live edit could slip through unseen. */
|
|
92
|
+
export const DEFAULT_SETTLE_MS = 750;
|
|
93
|
+
/**
|
|
94
|
+
* The room's WRITE cadence (`PERSIST_MS`). Not the age of what a reader sees — see the
|
|
95
|
+
* header: the read cache on top of it has been measured at nearly twice this again.
|
|
96
|
+
*/
|
|
97
|
+
export const MIRROR_CADENCE_MS = 45000;
|
|
98
|
+
|
|
99
|
+
/** A failure with a name, so a caller can branch on the reason rather than on a message. */
|
|
100
|
+
export class SnapshotError extends Error {
|
|
101
|
+
constructor(code, message, detail) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = "SnapshotError";
|
|
104
|
+
this.code = code;
|
|
105
|
+
if (detail !== undefined) this.detail = detail;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sleepReal = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
110
|
+
|
|
111
|
+
/** The room's address. `origin` may be given as http(s) or ws(s); both mean the same room. */
|
|
112
|
+
export function roomUrl({ origin, path, name = "board-snapshot", direct = false } = {}) {
|
|
113
|
+
if (!origin) throw new SnapshotError("bad-input", "an origin is required");
|
|
114
|
+
if (!path) throw new SnapshotError("bad-input", "a board path is required");
|
|
115
|
+
const base = String(origin).trim().replace(/\/+$/, "").replace(/^http/, "ws");
|
|
116
|
+
if (!/^wss?:\/\//.test(base)) throw new SnapshotError("bad-input", `not an origin: ${origin}`);
|
|
117
|
+
const route = direct ? "/room" : "/__rt";
|
|
118
|
+
return `${base}${route}?path=${encodeURIComponent(path)}&name=${encodeURIComponent(name)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The default socket opener. Injectable so the suite can drive a real room with no network. */
|
|
122
|
+
export const openWebSocket = (url, opts) => new WebSocket(url, opts);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Connect, and hand back a handle that can wait for a specific frame.
|
|
126
|
+
*
|
|
127
|
+
* The `welcome` is awaited here rather than left to the caller: a connection that has not
|
|
128
|
+
* produced one has not proven the room loaded, and no caller should be able to skip that.
|
|
129
|
+
*/
|
|
130
|
+
export async function connectRoom(open, { url, headers, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
131
|
+
const sock = open(url, headers && Object.keys(headers).length ? { headers } : undefined);
|
|
132
|
+
const frames = { welcome: 0, ops: 0, join: 0, leave: 0, other: 0, nonJson: 0 };
|
|
133
|
+
const queue = [];
|
|
134
|
+
const waiters = [];
|
|
135
|
+
let closed = false;
|
|
136
|
+
let failure = null;
|
|
137
|
+
|
|
138
|
+
const settle = () => {
|
|
139
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
140
|
+
const idx = queue.findIndex(waiters[i].pred);
|
|
141
|
+
if (idx < 0) continue;
|
|
142
|
+
const [m] = queue.splice(idx, 1);
|
|
143
|
+
waiters.splice(i, 1)[0].resolve(m);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const abort = (err) => {
|
|
147
|
+
failure = failure || err;
|
|
148
|
+
for (const w of waiters.splice(0)) w.reject(err);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
sock.addEventListener("message", (ev) => {
|
|
152
|
+
const raw = typeof ev.data === "string" ? ev.data : null;
|
|
153
|
+
if (raw === null) { frames.nonJson++; return; }
|
|
154
|
+
if (raw === "pong") return;
|
|
155
|
+
let m;
|
|
156
|
+
try { m = JSON.parse(raw); } catch (e) { frames.nonJson++; return; }
|
|
157
|
+
if (!m || typeof m.t !== "string") { frames.nonJson++; return; }
|
|
158
|
+
if (Object.prototype.hasOwnProperty.call(frames, m.t)) frames[m.t]++;
|
|
159
|
+
else frames.other++;
|
|
160
|
+
queue.push(m);
|
|
161
|
+
settle();
|
|
162
|
+
});
|
|
163
|
+
sock.addEventListener("close", (ev) => {
|
|
164
|
+
closed = true;
|
|
165
|
+
abort(new SnapshotError("closed", `the room closed the socket (${(ev && ev.code) || "?"})`));
|
|
166
|
+
});
|
|
167
|
+
sock.addEventListener("error", (ev) => {
|
|
168
|
+
abort(new SnapshotError("socket-failed", `socket failed: ${(ev && (ev.message || (ev.error && ev.error.message))) || "no detail"}`));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const next = (pred, ms = timeoutMs) =>
|
|
172
|
+
new Promise((resolve, reject) => {
|
|
173
|
+
if (failure) return reject(failure);
|
|
174
|
+
let timer = null;
|
|
175
|
+
const w = {
|
|
176
|
+
pred,
|
|
177
|
+
resolve: (v) => { if (timer) clearTimeout(timer); resolve(v); },
|
|
178
|
+
reject: (e) => { if (timer) clearTimeout(timer); reject(e); },
|
|
179
|
+
};
|
|
180
|
+
waiters.push(w);
|
|
181
|
+
settle();
|
|
182
|
+
if (!waiters.includes(w)) return; // a queued frame already matched
|
|
183
|
+
timer = setTimeout(() => {
|
|
184
|
+
const i = waiters.indexOf(w);
|
|
185
|
+
if (i >= 0) waiters.splice(i, 1);
|
|
186
|
+
reject(new SnapshotError("timeout", `waited ${ms}ms for a frame that never came`));
|
|
187
|
+
}, ms);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const handle = {
|
|
191
|
+
url,
|
|
192
|
+
frames,
|
|
193
|
+
next,
|
|
194
|
+
send: (msg) => {
|
|
195
|
+
if (closed) throw new SnapshotError("closed", "cannot send on a closed socket");
|
|
196
|
+
sock.send(JSON.stringify(msg));
|
|
197
|
+
},
|
|
198
|
+
close: () => { try { sock.close(); } catch (e) { /* already gone */ } },
|
|
199
|
+
get closed() { return closed; },
|
|
200
|
+
};
|
|
201
|
+
handle.welcome = await next((m) => m.t === "welcome");
|
|
202
|
+
return handle;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read a `welcome` and refuse anything ambiguous.
|
|
207
|
+
*
|
|
208
|
+
* The two-branch check is the point. `doc` and `needDoc` are mutually exclusive in the room
|
|
209
|
+
* (`board-room.mjs`: `if (this.doc) welcome.doc = …; else welcome.needDoc = true`), so
|
|
210
|
+
* neither and both are protocol violations — and a reader that treated "no doc field" as
|
|
211
|
+
* "empty board" would copy a null over a real board the day that changed.
|
|
212
|
+
*/
|
|
213
|
+
export function readWelcome(w) {
|
|
214
|
+
if (!w || w.t !== "welcome") throw new SnapshotError("no-welcome", "no welcome frame");
|
|
215
|
+
if (typeof w.sid !== "string" || !w.sid) throw new SnapshotError("welcome-without-sid", "the welcome carried no sid, so the join did not complete");
|
|
216
|
+
const hasDoc = !!(w.doc && typeof w.doc === "object" && Array.isArray(w.doc.nodes));
|
|
217
|
+
const needDoc = w.needDoc === true;
|
|
218
|
+
if (hasDoc && needDoc) throw new SnapshotError("welcome-ambiguous", "the welcome carried both a doc and needDoc");
|
|
219
|
+
if (!hasDoc && !needDoc) {
|
|
220
|
+
if (w.doc !== undefined) throw new SnapshotError("welcome-malformed-doc", "the welcome carried a doc that is not a document");
|
|
221
|
+
throw new SnapshotError("welcome-ambiguous", "the welcome carried neither a doc nor needDoc");
|
|
222
|
+
}
|
|
223
|
+
return { sid: w.sid, peers: Array.isArray(w.peers) ? w.peers.length : 0, empty: !hasDoc, doc: hasDoc ? w.doc : null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Structural check. Cheap, and it is the difference between a snapshot and a blob. */
|
|
227
|
+
export function checkDoc(doc) {
|
|
228
|
+
if (!doc || typeof doc !== "object") throw new SnapshotError("bad-doc", "not a document");
|
|
229
|
+
if (!Array.isArray(doc.nodes)) throw new SnapshotError("bad-doc", "the document has no nodes array");
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
for (const n of doc.nodes) {
|
|
232
|
+
if (!n || typeof n !== "object" || typeof n.id !== "string" || !n.id) {
|
|
233
|
+
throw new SnapshotError("bad-doc", "a node has no id");
|
|
234
|
+
}
|
|
235
|
+
if (seen.has(n.id)) throw new SnapshotError("bad-doc", `two nodes share the id ${n.id}`);
|
|
236
|
+
seen.add(n.id);
|
|
237
|
+
}
|
|
238
|
+
// ⚠️ `tombs` IS ALLOWED TO BE AN ARRAY, and refusing that refused a real board. The room
|
|
239
|
+
// keeps whatever passes `typeof d.tombs === "object"` (`adoptDoc`), an empty array passes,
|
|
240
|
+
// and boards seeded by older clients carry `tombs: []` — live, on a deployed instance,
|
|
241
|
+
// today. Every read of the map goes through `hasOwnProperty`, so an array behaves as the
|
|
242
|
+
// empty tombstone set it is. A stricter check here would reject the documents this exists
|
|
243
|
+
// to move, which is the one failure a migration reader cannot afford.
|
|
244
|
+
if (doc.tombs !== undefined && doc.tombs !== null && typeof doc.tombs !== "object") {
|
|
245
|
+
throw new SnapshotError("bad-doc", "tombs is neither a map nor absent");
|
|
246
|
+
}
|
|
247
|
+
return doc;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
251
|
+
const nodeSig = (n) => sha(JSON.stringify(n));
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Two digests, because two different questions get asked of one document.
|
|
255
|
+
*
|
|
256
|
+
* `content` ignores node order: it answers "are these the same nodes, at the same versions,
|
|
257
|
+
* with the same bytes". `full` includes the order, which is the board's z-order and is
|
|
258
|
+
* therefore real content — a board whose stacking changed is a board that changed.
|
|
259
|
+
* `clock` is in NEITHER: it is the room's own local counter, reset to 0 by `adoptDoc` when a
|
|
260
|
+
* fresh room takes a seed, so a destination that matches perfectly still reports a different
|
|
261
|
+
* one and a comparison that folded it in would call every correct migration a failure.
|
|
262
|
+
*/
|
|
263
|
+
export function digestDoc(doc) {
|
|
264
|
+
const ids = doc.nodes.map((n) => n.id);
|
|
265
|
+
const perNode = new Map(doc.nodes.map((n) => [n.id, nodeSig(n)]));
|
|
266
|
+
const sortedIds = [...ids].sort();
|
|
267
|
+
const tombs = Object.keys(doc.tombs || {}).sort().map((k) => `${k}=${(doc.tombs[k] || {}).v}`);
|
|
268
|
+
const head = `name=${JSON.stringify(doc.name || "")};nameV=${doc.nameV || 0};tombs=${tombs.join(",")}`;
|
|
269
|
+
return {
|
|
270
|
+
content: sha(`${head};nodes=${sortedIds.map((id) => `${id}:${perNode.get(id)}`).join(",")}`),
|
|
271
|
+
full: sha(`${head};order=${ids.join(",")};nodes=${ids.map((id) => `${id}:${perNode.get(id)}`).join(",")}`),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What a report says about a document without printing the whole of it. */
|
|
276
|
+
export function docSummary(doc) {
|
|
277
|
+
if (!doc) return { empty: true, nodes: 0 };
|
|
278
|
+
const d = digestDoc(doc);
|
|
279
|
+
return {
|
|
280
|
+
empty: false,
|
|
281
|
+
name: typeof doc.name === "string" ? doc.name : null,
|
|
282
|
+
nameV: doc.nameV || 0,
|
|
283
|
+
nodes: doc.nodes.length,
|
|
284
|
+
tombs: Object.keys(doc.tombs || {}).length,
|
|
285
|
+
clock: doc.clock || 0,
|
|
286
|
+
bytes: JSON.stringify(doc).length,
|
|
287
|
+
digest: d.content,
|
|
288
|
+
orderDigest: d.full,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Node-by-node, because "the bytes differ" is not an answer anybody can act on.
|
|
294
|
+
*
|
|
295
|
+
* `newerInA` is the one the migration exists for: nodes the room holds at a HIGHER version
|
|
296
|
+
* than the mirror does. That count, against a mirror read seconds earlier, is the size of
|
|
297
|
+
* what a KV-sourced migration would have dropped.
|
|
298
|
+
*/
|
|
299
|
+
export function compareDocs(a, b) {
|
|
300
|
+
if (!a && !b) return { same: true, bothEmpty: true, onlyInA: [], onlyInB: [], differing: [], newerInA: [], newerInB: [], orderChanged: false };
|
|
301
|
+
if (!a || !b) {
|
|
302
|
+
return {
|
|
303
|
+
same: false, bothEmpty: false, oneEmpty: !a ? "a" : "b",
|
|
304
|
+
onlyInA: a ? a.nodes.map((n) => n.id) : [],
|
|
305
|
+
onlyInB: b ? b.nodes.map((n) => n.id) : [],
|
|
306
|
+
differing: [], newerInA: [], newerInB: [], orderChanged: false,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const A = new Map(a.nodes.map((n) => [n.id, n]));
|
|
310
|
+
const B = new Map(b.nodes.map((n) => [n.id, n]));
|
|
311
|
+
const onlyInA = [...A.keys()].filter((id) => !B.has(id));
|
|
312
|
+
const onlyInB = [...B.keys()].filter((id) => !A.has(id));
|
|
313
|
+
const differing = [], newerInA = [], newerInB = [];
|
|
314
|
+
for (const [id, na] of A) {
|
|
315
|
+
const nb = B.get(id);
|
|
316
|
+
if (!nb) continue;
|
|
317
|
+
if (nodeSig(na) === nodeSig(nb)) continue;
|
|
318
|
+
const va = { v: na.v || 0, vn: na.vn || 0 }, vb = { v: nb.v || 0, vn: nb.vn || 0 };
|
|
319
|
+
differing.push({ id, a: va, b: vb });
|
|
320
|
+
const aWins = va.v > vb.v || (va.v === vb.v && va.vn > vb.vn);
|
|
321
|
+
(aWins ? newerInA : newerInB).push(id);
|
|
322
|
+
}
|
|
323
|
+
const da = digestDoc(a), db = digestDoc(b);
|
|
324
|
+
return {
|
|
325
|
+
same: da.full === db.full,
|
|
326
|
+
bothEmpty: false,
|
|
327
|
+
sameContent: da.content === db.content,
|
|
328
|
+
orderChanged: da.content === db.content && da.full !== db.full,
|
|
329
|
+
nameChanged: a.name !== b.name || (a.nameV || 0) !== (b.nameV || 0),
|
|
330
|
+
onlyInA, onlyInB, differing, newerInA, newerInB,
|
|
331
|
+
clock: { a: a.clock || 0, b: b.clock || 0 },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* ONE join, read, leave. The primitive; `snapshotRoom` is what an operator should use.
|
|
337
|
+
*/
|
|
338
|
+
export async function joinAndRead(open, { url, headers, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
339
|
+
const room = await connectRoom(open, { url, headers, timeoutMs });
|
|
340
|
+
try {
|
|
341
|
+
const w = readWelcome(room.welcome);
|
|
342
|
+
if (w.doc) checkDoc(w.doc);
|
|
343
|
+
return { ...w, frames: { ...room.frames } };
|
|
344
|
+
} finally {
|
|
345
|
+
room.close();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The authoritative document, read twice and only reported when the two reads agree.
|
|
351
|
+
*
|
|
352
|
+
* The first socket is HELD OPEN while the second joins, on purpose. It keeps the room from
|
|
353
|
+
* seeing its last client leave between the two reads — which would flush the mirror and drop
|
|
354
|
+
* the RAM cache, so the second read would answer a different question from the first.
|
|
355
|
+
*/
|
|
356
|
+
export async function snapshotRoom(open, {
|
|
357
|
+
origin, path, name = "board-snapshot", direct = false, headers,
|
|
358
|
+
settleMs = DEFAULT_SETTLE_MS, attempts = 3, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
359
|
+
allowUnstable = false, sleep = sleepReal,
|
|
360
|
+
} = {}) {
|
|
361
|
+
let last = null;
|
|
362
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
|
363
|
+
const a = await connectRoom(open, { url: roomUrl({ origin, path, name: `${name}-a`, direct }), headers, timeoutMs });
|
|
364
|
+
let b = null;
|
|
365
|
+
try {
|
|
366
|
+
const first = readWelcome(a.welcome);
|
|
367
|
+
if (first.doc) checkDoc(first.doc);
|
|
368
|
+
await sleep(settleMs);
|
|
369
|
+
b = await connectRoom(open, { url: roomUrl({ origin, path, name: `${name}-b`, direct }), headers, timeoutMs });
|
|
370
|
+
const second = readWelcome(b.welcome);
|
|
371
|
+
if (second.doc) checkDoc(second.doc);
|
|
372
|
+
const agreement = compareDocs(first.doc, second.doc);
|
|
373
|
+
last = {
|
|
374
|
+
ok: agreement.same,
|
|
375
|
+
stable: agreement.same,
|
|
376
|
+
attempt,
|
|
377
|
+
attempts: Math.max(1, attempts),
|
|
378
|
+
settleMs,
|
|
379
|
+
empty: second.empty,
|
|
380
|
+
doc: second.doc,
|
|
381
|
+
summary: docSummary(second.doc),
|
|
382
|
+
observers: [{ sid: first.sid, peers: first.peers, summary: docSummary(first.doc) }, { sid: second.sid, peers: second.peers, summary: docSummary(second.doc) }],
|
|
383
|
+
agreement,
|
|
384
|
+
};
|
|
385
|
+
if (agreement.same) return last;
|
|
386
|
+
} finally {
|
|
387
|
+
if (b) b.close();
|
|
388
|
+
a.close();
|
|
389
|
+
}
|
|
390
|
+
if (attempt < attempts) await sleep(settleMs);
|
|
391
|
+
}
|
|
392
|
+
if (allowUnstable) return last;
|
|
393
|
+
throw new SnapshotError(
|
|
394
|
+
"unstable",
|
|
395
|
+
`the board changed between two reads ${settleMs}ms apart, ${last.attempts} times over — it is being edited right now`,
|
|
396
|
+
last && last.agreement,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Offer a document to a room and then PROVE it landed, by reading it back over a socket the
|
|
402
|
+
* seeding one had already closed.
|
|
403
|
+
*
|
|
404
|
+
* A non-empty destination is refused by default. The room would reconcile the offer per node
|
|
405
|
+
* rather than adopt it — safe, and almost never what a cutover meant: seeding a destination
|
|
406
|
+
* that already holds a board means two boards are being merged, which is a decision, not a
|
|
407
|
+
* step. `merge: true` says it out loud.
|
|
408
|
+
*/
|
|
409
|
+
export async function seedRoom(open, {
|
|
410
|
+
origin, path, doc, name = "board-snapshot-seed", direct = false, headers,
|
|
411
|
+
merge = false, settleMs = DEFAULT_SETTLE_MS, timeoutMs = DEFAULT_TIMEOUT_MS, sleep = sleepReal,
|
|
412
|
+
} = {}) {
|
|
413
|
+
checkDoc(doc);
|
|
414
|
+
const url = roomUrl({ origin, path, name, direct });
|
|
415
|
+
const seeder = await connectRoom(open, { url, headers, timeoutMs });
|
|
416
|
+
let before;
|
|
417
|
+
try {
|
|
418
|
+
before = readWelcome(seeder.welcome);
|
|
419
|
+
if (!before.empty && !merge) {
|
|
420
|
+
throw new SnapshotError("destination-not-empty", `${path} already holds a board of ${before.doc.nodes.length} node(s) — pass merge to reconcile into it`, docSummary(before.doc));
|
|
421
|
+
}
|
|
422
|
+
seeder.send({ t: "doc", doc });
|
|
423
|
+
await sleep(settleMs);
|
|
424
|
+
} finally {
|
|
425
|
+
seeder.close();
|
|
426
|
+
}
|
|
427
|
+
// The close above is the reason this read is worth anything: the last socket out flushes
|
|
428
|
+
// and drops the room's RAM cache, so what comes back has been through storage.
|
|
429
|
+
await sleep(settleMs);
|
|
430
|
+
const back = await joinAndRead(open, { url: roomUrl({ origin, path, name: `${name}-verify`, direct }), headers, timeoutMs });
|
|
431
|
+
const comparison = compareDocs(doc, back.doc);
|
|
432
|
+
// TWO VERDICTS, because one would have to lie about a merge.
|
|
433
|
+
//
|
|
434
|
+
// `nodesLanded` is the question a seed is actually asking: is every node that was offered
|
|
435
|
+
// now there, at the version it was offered at. `identical` is the stronger claim that the
|
|
436
|
+
// destination document is the source document whole.
|
|
437
|
+
//
|
|
438
|
+
// On an EMPTY destination the two coincide — `adoptDoc` takes the offer wholesale, name
|
|
439
|
+
// included — and `ok` is the strong one. On a MERGE they must not: the room keeps its own
|
|
440
|
+
// name when the offer's `nameV` does not beat it, and keeps its own tombstones, both by the
|
|
441
|
+
// version rules and both correctly. Reporting that as a failed seed printed "0 never
|
|
442
|
+
// arrived, 0 differ" underneath the word FAILED, which is a report nobody can act on.
|
|
443
|
+
const nodesLanded = comparison.onlyInA.length === 0 && comparison.differing.length === 0;
|
|
444
|
+
const identical = comparison.sameContent === true;
|
|
445
|
+
return {
|
|
446
|
+
ok: before.empty ? identical : nodesLanded,
|
|
447
|
+
nodesLanded,
|
|
448
|
+
identical,
|
|
449
|
+
destinationWasEmpty: before.empty,
|
|
450
|
+
merged: !before.empty,
|
|
451
|
+
// What the destination kept that the offer did not carry. Empty on a fresh destination.
|
|
452
|
+
kept: { nodes: comparison.onlyInB, name: comparison.nameChanged ? back.doc.name : null, tombs: Object.keys((back.doc && back.doc.tombs) || {}).length },
|
|
453
|
+
offered: docSummary(doc),
|
|
454
|
+
landed: docSummary(back.doc),
|
|
455
|
+
comparison,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The KV mirror, as the public rail serves it. Read over HTTP, never out of a namespace: the
|
|
461
|
+
* key's spelling moves with the deployment shape (`board:<path>` before the rooms are folded
|
|
462
|
+
* in, `board:<workspace>:<path>` after) and the rail is the one thing that answers the same
|
|
463
|
+
* on both sides.
|
|
464
|
+
*/
|
|
465
|
+
export async function readMirror({ origin, path, fetchImpl = fetch } = {}) {
|
|
466
|
+
const base = String(origin).trim().replace(/\/+$/, "").replace(/^ws/, "http");
|
|
467
|
+
const url = `${base}/__board?path=${encodeURIComponent(path)}`;
|
|
468
|
+
const at = Date.now();
|
|
469
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" } });
|
|
470
|
+
if (!res.ok) throw new SnapshotError("mirror-unreadable", `GET /__board → ${res.status}`);
|
|
471
|
+
const body = await res.json();
|
|
472
|
+
if (body && body.warning === "no-kv-binding") throw new SnapshotError("mirror-unreadable", "the deployment binds no KV, so it has no mirror");
|
|
473
|
+
const doc = body && body.doc ? body.doc : null;
|
|
474
|
+
if (doc) checkDoc(doc);
|
|
475
|
+
return { at, doc, summary: docSummary(doc) };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* How far behind the mirror is, in the only units that mean anything: nodes.
|
|
480
|
+
*
|
|
481
|
+
* THE ORDER IS THE MEASUREMENT. The mirror is read FIRST, because reading the room can flush
|
|
482
|
+
* it — a snapshot taken of a quiet board leaves as its parting act the very write that would
|
|
483
|
+
* make the mirror look like it had been fresh all along. Reading it again afterwards is not
|
|
484
|
+
* redundant: the difference between the two mirror reads is that flush, made visible.
|
|
485
|
+
*/
|
|
486
|
+
export async function measureLag(open, {
|
|
487
|
+
origin, path, fetchImpl = fetch, sleep = sleepReal, ...opts
|
|
488
|
+
} = {}) {
|
|
489
|
+
const before = await readMirror({ origin, path, fetchImpl });
|
|
490
|
+
const room = await snapshotRoom(open, { origin, path, sleep, ...opts });
|
|
491
|
+
const after = await readMirror({ origin, path, fetchImpl });
|
|
492
|
+
const vsBefore = compareDocs(room.doc, before.doc);
|
|
493
|
+
const vsAfter = compareDocs(room.doc, after.doc);
|
|
494
|
+
const missed = vsBefore.onlyInA.length + vsBefore.newerInA.length;
|
|
495
|
+
return {
|
|
496
|
+
path,
|
|
497
|
+
origin,
|
|
498
|
+
cadenceMs: MIRROR_CADENCE_MS,
|
|
499
|
+
room: room.summary,
|
|
500
|
+
mirrorBefore: before.summary,
|
|
501
|
+
mirrorAfter: after.summary,
|
|
502
|
+
// What a KV-sourced migration started at this instant would have lost.
|
|
503
|
+
wouldHaveLost: { nodes: missed, missing: vsBefore.onlyInA, stale: vsBefore.newerInA },
|
|
504
|
+
mirrorWasBehind: !vsBefore.same,
|
|
505
|
+
mirrorCaughtUp: !vsBefore.same && vsAfter.same,
|
|
506
|
+
readFlushedTheMirror: before.summary.digest !== after.summary.digest,
|
|
507
|
+
vsBefore,
|
|
508
|
+
vsAfter,
|
|
509
|
+
snapshot: room,
|
|
510
|
+
};
|
|
511
|
+
}
|