@dev.fast/whiteboard 0.5.0 → 0.5.2-preview.20261104.146
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/dist/{account-alias-Bw3PT3Qo.js → account-alias-BlYamXAG.js} +5 -5
- package/dist/{agent-cli-kHRrwO4n.js → agent-cli-D7fF5xmm.js} +9 -9
- package/dist/agent-cli-DLibAL4u.js +2 -0
- package/dist/{agent-client-CMacGbMp.js → agent-client-BCEbSPWU.js} +4 -4
- package/dist/{authoring-tools-CiLy46kh.js → authoring-tools-CkfxTVJm.js} +12 -8
- package/dist/{background-server-eZWXoVcI.js → background-server-BraP8pJ9.js} +4 -4
- package/dist/{background-server-BLNHbksB.js → background-server-CwToVN1g.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/{cli-Bxmiqutp.js → cli-BpadBaGU.js} +1 -1
- package/dist/{cli-runner-DQPuu7kJ.js → cli-runner-BebYF50a.js} +22 -22
- package/dist/cli.js +2 -2
- package/dist/{fuzzy-match-DZjeOaPY.js → fuzzy-match-CxcsLQGx.js} +3 -3
- package/dist/{headless-host-Dl2KZ9IT.js → headless-host-FAlbekFD.js} +561 -415
- package/dist/{headless-host-B4r0s1ks.js → headless-host-cLipIufH.js} +1 -1
- package/dist/{import-YAIU6rB0.js → import-CZ1958L4.js} +234 -62
- package/dist/{local-data-C58vzLZR.js → local-data-DdFkxFK_.js} +41 -16
- package/dist/{mcp-COYWiGVn.js → mcp-Claiy9xE.js} +9 -6
- package/dist/{package-paths-Cg6fnaGp.js → package-paths-Dk7MOXxN.js} +1 -1
- package/dist/{profile--BYIjPkc.js → profile-Bsgzi9PA.js} +2 -2
- package/dist/profile-BxdzBxHW.js +2 -0
- package/dist/{remote-attach-C1prLjmj.js → remote-attach-LGTpwpUv.js} +4 -4
- package/dist/{remote-extensions-Bw029iIS.js → remote-extensions-BvV29_gk.js} +1 -1
- package/dist/{remote-extensions-MUUgbLJn.js → remote-extensions-L95HNwIH.js} +2 -2
- package/dist/{remote-language-server-z3rdHcLL.js → remote-language-server-BAznkpeu.js} +4 -4
- package/dist/{remote-uninstall-B3Slwor1.js → remote-uninstall-CLP79Cuk.js} +5 -5
- package/dist/{request-origin-B3_XI2VA.js → request-origin-D6B-zrL8.js} +27 -5
- package/dist/{review-app-launcher-BZEbDKuK.js → review-app-launcher-tTe8bTwg.js} +2 -2
- package/dist/{review-home-paths-LdGS4SpR.js → review-home-paths-BmZX2qiz.js} +1 -1
- package/dist/{review-telemetry-x8u-9EZb.js → review-telemetry-DACDMfqq.js} +4 -4
- package/dist/server/desktop-host.js +26 -38
- package/dist/{server-discovery-WmuFl2S3.js → server-discovery-CV5qf1J8.js} +2 -2
- package/dist/sharing/index.d.ts +137 -14
- package/dist/sharing/index.js +1 -1
- package/dist/{src-B2b8cCoI.js → src-BQDlJXkG.js} +44 -5
- package/dist/{src-DLWol7GZ.js → src-CrrW7lcY.js} +16 -1
- package/dist/{stored-document-migration-iwIQ4l5G.js → stored-document-migration-D1wK4yID.js} +3 -3
- package/dist/{thread-state-B7L1mpN_.js → thread-state-WTqSborE.js} +1 -1
- package/dist/{tool-failure-RLR8m9hq.js → tool-failure-YOFGHJCZ.js} +319 -55
- package/instructions/animation.md +69 -5
- package/instructions/animations/before-after-structure/README.md +27 -0
- package/instructions/animations/before-after-structure/animation.html +89 -0
- package/instructions/animations/before-after-structure/block.json +10 -0
- package/instructions/animations/before-after-structure/frames.json +313 -0
- package/instructions/animations/cache-fill-evict/README.md +24 -0
- package/instructions/animations/cache-fill-evict/animation.html +81 -0
- package/instructions/animations/cache-fill-evict/block.json +13 -0
- package/instructions/animations/cache-fill-evict/frames.json +79 -0
- package/instructions/animations/cache-fill-evict/scenarios.json +17 -0
- package/instructions/animations/log-replay/README.md +25 -0
- package/instructions/animations/log-replay/animation.html +86 -0
- package/instructions/animations/log-replay/block.json +10 -0
- package/instructions/animations/log-replay/frames.json +143 -0
- package/instructions/animations/log-replay/scenarios.json +18 -0
- package/instructions/animations/queue-workers/README.md +23 -0
- package/instructions/animations/queue-workers/animation.html +107 -0
- package/instructions/animations/queue-workers/block.json +13 -0
- package/instructions/animations/queue-workers/frames.json +119 -0
- package/instructions/animations/queue-workers/scenarios.json +18 -0
- package/instructions/animations/race-two-lanes/README.md +22 -0
- package/instructions/animations/race-two-lanes/animation.html +97 -0
- package/instructions/animations/race-two-lanes/block.json +1 -0
- package/instructions/animations/race-two-lanes/frames.json +234 -0
- package/instructions/animations/race-two-lanes/scenarios.json +19 -0
- package/instructions/animations/request-across-layers/README.md +24 -0
- package/instructions/animations/request-across-layers/animation.html +85 -0
- package/instructions/animations/request-across-layers/block.json +17 -0
- package/instructions/animations/request-across-layers/frames.json +58 -0
- package/instructions/animations/request-across-layers/scenarios.json +12 -0
- package/instructions/animations/request-timing/README.md +22 -0
- package/instructions/animations/request-timing/animation.html +120 -0
- package/instructions/animations/request-timing/block.json +21 -0
- package/instructions/animations/request-timing/markers.json +32 -0
- package/instructions/animations/request-timing/scenarios.json +4 -0
- package/instructions/animations/retry-backoff/README.md +22 -0
- package/instructions/animations/retry-backoff/animation.html +122 -0
- package/instructions/animations/retry-backoff/block.json +10 -0
- package/instructions/animations/retry-backoff/frames.json +65 -0
- package/instructions/animations/retry-backoff/scenarios.json +12 -0
- package/instructions/animations/state-machine/README.md +24 -0
- package/instructions/animations/state-machine/animation.html +95 -0
- package/instructions/animations/state-machine/block.json +9 -0
- package/instructions/animations/state-machine/frames.json +79 -0
- package/instructions/animations/state-machine/scenarios.json +12 -0
- package/instructions/animations/video-platform-architecture/README.md +24 -0
- package/instructions/animations/video-platform-architecture/animation.html +76 -0
- package/instructions/animations/video-platform-architecture/block.json +17 -0
- package/instructions/animations/video-platform-architecture/frames.json +97 -0
- package/instructions/animations/video-platform-architecture/scenarios.json +28 -0
- package/instructions/whiteboard.d.ts +168 -14
- package/package.json +6 -6
- package/src/animation-stage-kit.ts +150 -10
- package/src/cli-runner.ts +4 -1
- package/src/fixtures/blocks/clip.json +19 -0
- package/src/review-api/README.md +1 -0
- package/src/review-api/agent-cli.ts +3 -2
- package/src/review-api/animation-drafts.ts +151 -47
- package/src/review-api/animation-references.ts +92 -0
- package/src/review-api/animation-render.ts +106 -0
- package/src/review-api/authoring-tools.ts +10 -3
- package/src/review-api/document-text.ts +27 -12
- package/src/review-api/http.ts +52 -6
- package/src/review-api/instructions.ts +1 -1
- package/src/review-api/mcp.ts +10 -5
- package/src/review-api/public-tools.ts +71 -2
- package/src/review-api/store.ts +89 -12
- package/src/server/desktop-server.ts +17 -5
- package/src/server/headless-host.ts +4 -0
- package/src/server/review-gateway.ts +1 -0
- package/src/server/review-server-core.ts +71 -12
- package/src/sharing/client.ts +129 -32
- package/src/sharing/export.ts +42 -5
- package/src/sharing/file.ts +8 -0
- package/src/sharing/host.ts +202 -73
- package/src/sharing/import.ts +125 -33
- package/src/ui-telemetry-events.ts +25 -1
- package/dist/agent-cli-B8KBHuh_.js +0 -2
- package/dist/profile-BjcXi-oH.js +0 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
/* Narrow end cards leave the cache its width and the edges room for a
|
|
3
|
+
message chip to read while it travels. */
|
|
4
|
+
.wb-scene :is(#caller, #source) { width: var(--wb-space-120); }
|
|
5
|
+
.wb-scene #caller { justify-self: start; }
|
|
6
|
+
.wb-scene #source { justify-self: end; }
|
|
7
|
+
</style>
|
|
8
|
+
<script>
|
|
9
|
+
const s = whiteboard.scene({ columns: 3 });
|
|
10
|
+
const caller = s.actor({ id: "caller", at: [1, 1], title: "Handler", note: "getUser(id)" });
|
|
11
|
+
const cache = s.store({
|
|
12
|
+
id: "cache",
|
|
13
|
+
at: [2, 1],
|
|
14
|
+
title: "UserCache",
|
|
15
|
+
note: "LRU · capacity 3",
|
|
16
|
+
form: "drum",
|
|
17
|
+
color: "teal",
|
|
18
|
+
code: "lookup",
|
|
19
|
+
});
|
|
20
|
+
const source = s.actor({
|
|
21
|
+
id: "source",
|
|
22
|
+
at: [3, 1],
|
|
23
|
+
title: "Postgres",
|
|
24
|
+
note: "~120 ms a read",
|
|
25
|
+
form: "cylinder",
|
|
26
|
+
color: "teal",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Items keep their order, so each scenario seeds its own entries, oldest first.
|
|
30
|
+
|
|
31
|
+
// Miss, then fill: there is room, so the loaded value joins as the newest.
|
|
32
|
+
cache.item("user:7", { enter: "request" });
|
|
33
|
+
cache.item("user:3", { enter: "request" });
|
|
34
|
+
cache.item("user:42", { enter: "fill", tone: "added" });
|
|
35
|
+
s.send(caller, cache, "get user:42", "request");
|
|
36
|
+
s.on("miss", cache, { text: "miss · 2 of 3", tone: "modified" });
|
|
37
|
+
s.on("miss", caller, { text: "awaiting value" });
|
|
38
|
+
s.send(cache, source, "load user:42", "miss");
|
|
39
|
+
s.on("miss", source, { tone: "accent" });
|
|
40
|
+
s.on("fill", source, { text: "row user:42", tone: "none" });
|
|
41
|
+
s.send(source, cache, "user:42", "fill");
|
|
42
|
+
s.on("fill", cache, { text: "stored · 3 of 3", tone: "none" });
|
|
43
|
+
s.send(cache, caller, "user:42", "serve");
|
|
44
|
+
s.on("serve", caller, { text: "served · 120 ms" });
|
|
45
|
+
|
|
46
|
+
// Hit: served from memory, and the entry moves to the newest end.
|
|
47
|
+
const hit = cache.item("user:7", { enter: "hit-request", exit: "hit-serve" });
|
|
48
|
+
cache.item("user:3", { enter: "hit-request" });
|
|
49
|
+
cache.item("user:42", { enter: "hit-request" });
|
|
50
|
+
cache.item("user:7", { enter: "hit-serve", tone: "added" });
|
|
51
|
+
s.send(caller, cache, "get user:7", "hit-request");
|
|
52
|
+
s.on("hit-request", source, { text: "not called", tone: "muted" });
|
|
53
|
+
s.on("hit-found", hit, { tone: "modified" });
|
|
54
|
+
s.on("hit-found", cache, { text: "hit · 3 of 3", tone: "added" });
|
|
55
|
+
s.send(cache, caller, "user:7", "hit-serve");
|
|
56
|
+
s.on("hit-serve", cache, { text: "user:3 is now oldest", tone: "none" });
|
|
57
|
+
s.on("hit-serve", caller, { text: "served · <1 ms" });
|
|
58
|
+
|
|
59
|
+
// Eviction: the cache is full, so the oldest entry leaves as the new one enters.
|
|
60
|
+
const oldest = cache.item("user:7", { enter: "evict-request", exit: "evict-fill" });
|
|
61
|
+
cache.item("user:3", { enter: "evict-request" });
|
|
62
|
+
cache.item("user:42", { enter: "evict-request" });
|
|
63
|
+
cache.item("user:51", { enter: "evict-fill", tone: "added" });
|
|
64
|
+
s.send(caller, cache, "get user:51", "evict-request");
|
|
65
|
+
s.on("evict-miss", cache, { text: "miss · 3 of 3, full", tone: "modified" });
|
|
66
|
+
s.on("evict-miss", oldest, { tone: "removed" });
|
|
67
|
+
s.on("evict-miss", caller, { text: "awaiting value" });
|
|
68
|
+
s.send(cache, source, "load user:51", "evict-miss");
|
|
69
|
+
s.on("evict-miss", source, { tone: "accent" });
|
|
70
|
+
s.on("evict-fill", source, { text: "row user:51", tone: "none" });
|
|
71
|
+
s.send(source, cache, "user:51", "evict-fill");
|
|
72
|
+
s.on("evict-fill", cache, { text: "evicted user:7", tone: "removed" });
|
|
73
|
+
s.send(cache, caller, "user:51", "evict-serve");
|
|
74
|
+
s.on("evict-serve", cache, { text: "stored · 3 of 3", tone: "none" });
|
|
75
|
+
s.on("evict-serve", caller, { text: "served · 120 ms" });
|
|
76
|
+
|
|
77
|
+
// Clicking the cache opens the range the current frame is about.
|
|
78
|
+
whiteboard.on("*", ({ frame }) => {
|
|
79
|
+
cache.element.dataset.wbOpen = frame.code?.[0] ?? "lookup";
|
|
80
|
+
});
|
|
81
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Read-through cache with LRU eviction",
|
|
3
|
+
"code": {
|
|
4
|
+
"lookup": {
|
|
5
|
+
"source": "head/src/cache.ts#L20-L48",
|
|
6
|
+
"label": "UserCache.get"
|
|
7
|
+
},
|
|
8
|
+
"evict": {
|
|
9
|
+
"source": "head/src/cache.ts#L52-L70",
|
|
10
|
+
"label": "UserCache.evictOldest"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "request",
|
|
4
|
+
"title": "The handler asks the cache",
|
|
5
|
+
"label": "GET",
|
|
6
|
+
"caption": "getUser(42) checks UserCache before touching the database.",
|
|
7
|
+
"code": ["lookup"]
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"key": "miss",
|
|
11
|
+
"title": "Miss: load from Postgres",
|
|
12
|
+
"label": "MISS",
|
|
13
|
+
"caption": "user:42 is not cached, so the cache loads it from the source. The handler waits on this read.",
|
|
14
|
+
"code": ["lookup"]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "fill",
|
|
18
|
+
"title": "The cache stores the row",
|
|
19
|
+
"label": "FILL",
|
|
20
|
+
"caption": "Two of three slots were taken, so user:42 joins as the newest entry and nothing is evicted.",
|
|
21
|
+
"code": ["lookup"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "serve",
|
|
25
|
+
"title": "The handler gets the value",
|
|
26
|
+
"label": "SERVE",
|
|
27
|
+
"caption": "The first read paid the database round trip; the next read of user:42 will not.",
|
|
28
|
+
"code": ["lookup"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"key": "hit-request",
|
|
32
|
+
"title": "The handler asks for user:7",
|
|
33
|
+
"label": "GET",
|
|
34
|
+
"caption": "The cache is full and already holds user:7.",
|
|
35
|
+
"code": ["lookup"]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"key": "hit-found",
|
|
39
|
+
"title": "Hit: Postgres is not called",
|
|
40
|
+
"label": "HIT",
|
|
41
|
+
"caption": "The value comes from memory. The read also counts as a use, which matters for eviction.",
|
|
42
|
+
"code": ["lookup"]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"key": "hit-serve",
|
|
46
|
+
"title": "Served, and user:7 is the newest",
|
|
47
|
+
"label": "SERVE",
|
|
48
|
+
"caption": "user:7 moves to the newest end, so user:3 is now the next to be evicted.",
|
|
49
|
+
"code": ["lookup"]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "evict-request",
|
|
53
|
+
"title": "The handler asks for user:51",
|
|
54
|
+
"label": "GET",
|
|
55
|
+
"caption": "All three slots are taken; user:7 is the least recently used.",
|
|
56
|
+
"code": ["lookup"]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"key": "evict-miss",
|
|
60
|
+
"title": "Miss on a full cache",
|
|
61
|
+
"label": "MISS",
|
|
62
|
+
"caption": "The cache loads user:51 and picks the oldest entry, user:7, to make room.",
|
|
63
|
+
"code": ["lookup", "evict"]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"key": "evict-fill",
|
|
67
|
+
"title": "user:7 leaves, user:51 enters",
|
|
68
|
+
"label": "EVICT",
|
|
69
|
+
"caption": "The cache never grows past its capacity: one entry out for each new one in.",
|
|
70
|
+
"code": ["evict"]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"key": "evict-serve",
|
|
74
|
+
"title": "The handler gets user:51",
|
|
75
|
+
"label": "SERVE",
|
|
76
|
+
"caption": "A later read of user:7 is a miss again and pays the database round trip.",
|
|
77
|
+
"code": ["lookup"]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "miss",
|
|
4
|
+
"label": "Miss, then fill",
|
|
5
|
+
"frames": ["request", "miss", "fill", "serve"]
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"key": "hit",
|
|
9
|
+
"label": "Hit",
|
|
10
|
+
"frames": ["hit-request", "hit-found", "hit-serve"]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"key": "evict",
|
|
14
|
+
"label": "Full: evict oldest",
|
|
15
|
+
"frames": ["evict-request", "evict-miss", "evict-fill", "evict-serve"]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Log replay
|
|
2
|
+
|
|
3
|
+
An append-only event log replays one event per frame into a derived accounts table, and a second scenario redelivers an event that the checkpoint guard skips, so the state visibly stays put.
|
|
4
|
+
|
|
5
|
+
## Use when
|
|
6
|
+
|
|
7
|
+
A change adds or alters an event-sourced projection, a consumer of an at-least-once queue, or any handler that folds a log into state. The reviewer needs to see which row each event touches, and that a duplicate or replayed event cannot change the result.
|
|
8
|
+
|
|
9
|
+
## Shows
|
|
10
|
+
|
|
11
|
+
- Left, the events seen so far, the current one highlighted; a redelivered event carries a `redelivered` chip.
|
|
12
|
+
- Right, the derived rows: a new row toned `added`, a changed balance `modified`, the touched row underlined, and a checkpoint badge.
|
|
13
|
+
- Under the table, the handler's outcome for the current event; it opens the code that decided it.
|
|
14
|
+
- Clean replay: four events build the state. Redelivered event: #2 arrives again, the row shows `unchanged`, the checkpoint does not move, and the replay ends in the same state as the clean one.
|
|
15
|
+
|
|
16
|
+
## Technique
|
|
17
|
+
|
|
18
|
+
Data-driven, with `scenarios` for the two paths; frames the paths share (#1 and #2) are declared once. Each frame's `data` holds the events seen so far, every account row with its tone, the touched row, the checkpoint and the outcome. One `whiteboard.on("*", ...)` handler redraws both tables and returns `whiteboard.animate` fades for what is new since `previous`: the appended event, a new row, a changed balance. Nothing animates on the right when an event is skipped, which is the point. `frame.code[0]` becomes the outcome chip's `data-wb-open`.
|
|
19
|
+
|
|
20
|
+
## Adapt
|
|
21
|
+
|
|
22
|
+
- Rewrite `frames.json` with your events and state rows; give each frame the whole log so far and the whole state, never a delta.
|
|
23
|
+
- Change the column headers and `row([...])` cells in `animation.html` if your state has more than two columns.
|
|
24
|
+
- For an out-of-order event instead of a duplicate, keep the same shape: mark the event in `events`, leave the state as it was, and say why in `result`.
|
|
25
|
+
- Name the handler and its guard in `block.json` `code` and list the one each frame exercises.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.replay { --wb-columns: 2; }
|
|
3
|
+
.seq { width: var(--wb-space-40); }
|
|
4
|
+
.wb-table tr[data-current] td { color: var(--wb-ink); background: var(--wb-accent-soft); }
|
|
5
|
+
.wb-table tr[data-touched] td { box-shadow: inset 0 -2px 0 var(--wb-accent); }
|
|
6
|
+
.aside { margin-left: var(--wb-space-6); }
|
|
7
|
+
.checkpoint { align-self: flex-start; }
|
|
8
|
+
.outcome { align-self: flex-start; margin-top: var(--wb-space-4); }
|
|
9
|
+
</style>
|
|
10
|
+
<div class="wb-grid replay">
|
|
11
|
+
<div class="wb-card">
|
|
12
|
+
<div class="wb-card-head"><i class="wb-card-dot"></i><span class="wb-card-title">events</span></div>
|
|
13
|
+
<div class="wb-card-note">append-only log, replayed in order</div>
|
|
14
|
+
<table class="wb-table">
|
|
15
|
+
<thead><tr><th class="seq">seq</th><th>event</th></tr></thead>
|
|
16
|
+
<tbody class="log"></tbody>
|
|
17
|
+
</table>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="wb-card">
|
|
20
|
+
<div class="wb-card-head"><i class="wb-card-dot"></i><span class="wb-card-title">accounts</span></div>
|
|
21
|
+
<div class="wb-card-note">derived state, built by apply(event)</div>
|
|
22
|
+
<span class="wb-badge checkpoint"></span>
|
|
23
|
+
<table class="wb-table">
|
|
24
|
+
<thead><tr><th>account</th><th>balance</th></tr></thead>
|
|
25
|
+
<tbody class="state"></tbody>
|
|
26
|
+
</table>
|
|
27
|
+
<span class="wb-chip outcome"></span>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<script>
|
|
31
|
+
const log = document.querySelector(".log");
|
|
32
|
+
const state = document.querySelector(".state");
|
|
33
|
+
const checkpoint = document.querySelector(".checkpoint");
|
|
34
|
+
|
|
35
|
+
function row(cells) {
|
|
36
|
+
const tr = document.createElement("tr");
|
|
37
|
+
for (const cell of cells) tr.insertCell().append(cell);
|
|
38
|
+
return tr;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function chip(text, tone, className = "aside") {
|
|
42
|
+
const el = document.createElement("span");
|
|
43
|
+
el.className = `wb-chip ${className}`;
|
|
44
|
+
el.dataset.tone = tone;
|
|
45
|
+
el.textContent = text;
|
|
46
|
+
return el;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const enter = (el) =>
|
|
50
|
+
whiteboard.animate(el, [{ opacity: 0, transform: "translateY(-4px)" }, { opacity: 1, transform: "none" }], 300);
|
|
51
|
+
|
|
52
|
+
whiteboard.on("*", ({ frame, previous }) => {
|
|
53
|
+
const { events, accounts, touched, applied, result } = frame.data;
|
|
54
|
+
|
|
55
|
+
const logRows = events.map((event, index) => {
|
|
56
|
+
const tr = row([`#${event.seq}`, event.text]);
|
|
57
|
+
if (event.redelivered) tr.cells[1].append(chip("redelivered", "muted"));
|
|
58
|
+
tr.toggleAttribute("data-current", index === events.length - 1);
|
|
59
|
+
return tr;
|
|
60
|
+
});
|
|
61
|
+
log.replaceChildren(...logRows);
|
|
62
|
+
|
|
63
|
+
const before = new Map((previous?.data.accounts ?? []).map((account) => [account.id, account.balance]));
|
|
64
|
+
const motion = [];
|
|
65
|
+
state.replaceChildren(
|
|
66
|
+
...accounts.map((account) => {
|
|
67
|
+
const tr = row([account.id, String(account.balance)]);
|
|
68
|
+
if (account.tone) tr.dataset.tone = account.tone;
|
|
69
|
+
if (account.note) tr.cells[1].append(chip(account.note, "muted"));
|
|
70
|
+
tr.toggleAttribute("data-touched", account.id === touched);
|
|
71
|
+
if (!before.has(account.id)) motion.push(enter(tr));
|
|
72
|
+
else if (before.get(account.id) !== account.balance) motion.push(enter(tr.cells[1]));
|
|
73
|
+
return tr;
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
checkpoint.textContent = `checkpoint #${applied}`;
|
|
78
|
+
// A fresh element per frame, so its code tooltip follows the frame's code name.
|
|
79
|
+
const outcome = chip(result.text, result.tone, "outcome");
|
|
80
|
+
if (frame.code) outcome.dataset.wbOpen = frame.code[0];
|
|
81
|
+
document.querySelector(".outcome").replaceWith(outcome);
|
|
82
|
+
|
|
83
|
+
const newEvents = logRows.slice(previous?.data.events.length ?? 0);
|
|
84
|
+
return [...newEvents.map(enter), enter(outcome), ...motion];
|
|
85
|
+
});
|
|
86
|
+
</script>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "open-a",
|
|
4
|
+
"title": "#1 opens account A",
|
|
5
|
+
"label": "OPEN A",
|
|
6
|
+
"code": ["apply"],
|
|
7
|
+
"caption": "apply() inserts A with a zero balance and moves the checkpoint to #1.",
|
|
8
|
+
"data": {
|
|
9
|
+
"events": [{ "seq": 1, "text": "AccountOpened A" }],
|
|
10
|
+
"accounts": [{ "id": "A", "balance": 0, "tone": "added" }],
|
|
11
|
+
"touched": "A",
|
|
12
|
+
"applied": 1,
|
|
13
|
+
"result": { "text": "#1 applied: insert A", "tone": "added" }
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "deposit-a",
|
|
18
|
+
"title": "#2 deposits into A",
|
|
19
|
+
"label": "DEPOSIT 50",
|
|
20
|
+
"code": ["apply"],
|
|
21
|
+
"caption": "Each event touches one row. The deposit raises A's balance and the checkpoint follows.",
|
|
22
|
+
"data": {
|
|
23
|
+
"events": [
|
|
24
|
+
{ "seq": 1, "text": "AccountOpened A" },
|
|
25
|
+
{ "seq": 2, "text": "Deposited A 50" }
|
|
26
|
+
],
|
|
27
|
+
"accounts": [{ "id": "A", "balance": 50, "tone": "modified" }],
|
|
28
|
+
"touched": "A",
|
|
29
|
+
"applied": 2,
|
|
30
|
+
"result": { "text": "#2 applied: A 0 → 50", "tone": "modified" }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"key": "open-b",
|
|
35
|
+
"title": "#3 opens account B",
|
|
36
|
+
"label": "OPEN B",
|
|
37
|
+
"code": ["apply"],
|
|
38
|
+
"caption": "A second account is a new row; A is untouched.",
|
|
39
|
+
"data": {
|
|
40
|
+
"events": [
|
|
41
|
+
{ "seq": 1, "text": "AccountOpened A" },
|
|
42
|
+
{ "seq": 2, "text": "Deposited A 50" },
|
|
43
|
+
{ "seq": 3, "text": "AccountOpened B" }
|
|
44
|
+
],
|
|
45
|
+
"accounts": [
|
|
46
|
+
{ "id": "A", "balance": 50 },
|
|
47
|
+
{ "id": "B", "balance": 0, "tone": "added" }
|
|
48
|
+
],
|
|
49
|
+
"touched": "B",
|
|
50
|
+
"applied": 3,
|
|
51
|
+
"result": { "text": "#3 applied: insert B", "tone": "added" }
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"key": "withdraw-a",
|
|
56
|
+
"title": "#4 withdraws from A",
|
|
57
|
+
"label": "WITHDRAW 20",
|
|
58
|
+
"code": ["apply"],
|
|
59
|
+
"caption": "The replay ends at checkpoint #4 with A at 30 and B at 0, the same state the live handler built.",
|
|
60
|
+
"data": {
|
|
61
|
+
"events": [
|
|
62
|
+
{ "seq": 1, "text": "AccountOpened A" },
|
|
63
|
+
{ "seq": 2, "text": "Deposited A 50" },
|
|
64
|
+
{ "seq": 3, "text": "AccountOpened B" },
|
|
65
|
+
{ "seq": 4, "text": "Withdrew A 20" }
|
|
66
|
+
],
|
|
67
|
+
"accounts": [
|
|
68
|
+
{ "id": "A", "balance": 30, "tone": "modified" },
|
|
69
|
+
{ "id": "B", "balance": 0 }
|
|
70
|
+
],
|
|
71
|
+
"touched": "A",
|
|
72
|
+
"applied": 4,
|
|
73
|
+
"result": { "text": "#4 applied: A 50 → 30", "tone": "modified" }
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"key": "redeliver",
|
|
78
|
+
"title": "#2 arrives again",
|
|
79
|
+
"label": "REDELIVER #2",
|
|
80
|
+
"code": ["guard"],
|
|
81
|
+
"duration": 4500,
|
|
82
|
+
"caption": "The broker redelivers #2. Its seq is not past the checkpoint, so apply() skips it: A stays at 50 and the checkpoint stays at #2.",
|
|
83
|
+
"data": {
|
|
84
|
+
"events": [
|
|
85
|
+
{ "seq": 1, "text": "AccountOpened A" },
|
|
86
|
+
{ "seq": 2, "text": "Deposited A 50" },
|
|
87
|
+
{ "seq": 2, "text": "Deposited A 50", "redelivered": true }
|
|
88
|
+
],
|
|
89
|
+
"accounts": [{ "id": "A", "balance": 50, "note": "unchanged" }],
|
|
90
|
+
"touched": "A",
|
|
91
|
+
"applied": 2,
|
|
92
|
+
"result": {
|
|
93
|
+
"text": "#2 ≤ checkpoint #2: skipped, no change",
|
|
94
|
+
"tone": "muted"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"key": "dup-open-b",
|
|
100
|
+
"title": "#3 opens account B",
|
|
101
|
+
"label": "OPEN B",
|
|
102
|
+
"code": ["apply"],
|
|
103
|
+
"caption": "Replay continues from the checkpoint as if the duplicate never arrived.",
|
|
104
|
+
"data": {
|
|
105
|
+
"events": [
|
|
106
|
+
{ "seq": 1, "text": "AccountOpened A" },
|
|
107
|
+
{ "seq": 2, "text": "Deposited A 50" },
|
|
108
|
+
{ "seq": 2, "text": "Deposited A 50", "redelivered": true },
|
|
109
|
+
{ "seq": 3, "text": "AccountOpened B" }
|
|
110
|
+
],
|
|
111
|
+
"accounts": [
|
|
112
|
+
{ "id": "A", "balance": 50 },
|
|
113
|
+
{ "id": "B", "balance": 0, "tone": "added" }
|
|
114
|
+
],
|
|
115
|
+
"touched": "B",
|
|
116
|
+
"applied": 3,
|
|
117
|
+
"result": { "text": "#3 applied: insert B", "tone": "added" }
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"key": "dup-withdraw-a",
|
|
122
|
+
"title": "#4 withdraws from A",
|
|
123
|
+
"label": "WITHDRAW 20",
|
|
124
|
+
"code": ["apply"],
|
|
125
|
+
"caption": "The replay ends with A at 30, B at 0 and checkpoint #4, the same state as the clean replay. Applying #2 twice would have left A at 80.",
|
|
126
|
+
"data": {
|
|
127
|
+
"events": [
|
|
128
|
+
{ "seq": 1, "text": "AccountOpened A" },
|
|
129
|
+
{ "seq": 2, "text": "Deposited A 50" },
|
|
130
|
+
{ "seq": 2, "text": "Deposited A 50", "redelivered": true },
|
|
131
|
+
{ "seq": 3, "text": "AccountOpened B" },
|
|
132
|
+
{ "seq": 4, "text": "Withdrew A 20" }
|
|
133
|
+
],
|
|
134
|
+
"accounts": [
|
|
135
|
+
{ "id": "A", "balance": 30, "tone": "modified" },
|
|
136
|
+
{ "id": "B", "balance": 0 }
|
|
137
|
+
],
|
|
138
|
+
"touched": "A",
|
|
139
|
+
"applied": 4,
|
|
140
|
+
"result": { "text": "#4 applied: A 50 → 30", "tone": "modified" }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "clean",
|
|
4
|
+
"label": "Clean replay",
|
|
5
|
+
"frames": ["open-a", "deposit-a", "open-b", "withdraw-a"]
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"key": "duplicate",
|
|
9
|
+
"label": "Redelivered event",
|
|
10
|
+
"frames": [
|
|
11
|
+
"open-a",
|
|
12
|
+
"deposit-a",
|
|
13
|
+
"redeliver",
|
|
14
|
+
"dup-open-b",
|
|
15
|
+
"dup-withdraw-a"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Bounded queue with two workers
|
|
2
|
+
|
|
3
|
+
A data-driven drawing of a producer, a bounded queue and two workers that shows jobs moving through it under steady load and under a burst that hits the bound.
|
|
4
|
+
|
|
5
|
+
## Use when
|
|
6
|
+
|
|
7
|
+
A change introduces or tunes a job queue, a worker pool or a channel with a capacity, and the reviewer needs to see what happens when producers outrun consumers.
|
|
8
|
+
|
|
9
|
+
## Shows
|
|
10
|
+
|
|
11
|
+
- Steady load: jobs are put, taken from the head by idle workers, and completed into Done; the queue stays short.
|
|
12
|
+
- Burst: the queue fills to its bound, `put()` blocks the producer, a completion frees a slot, and the waiting put goes through.
|
|
13
|
+
|
|
14
|
+
## Technique
|
|
15
|
+
|
|
16
|
+
Data-driven. Each frame's `data` holds the full state: the producer's next job and state, the queue contents (head first), the bound, each worker's job and the done list. One `whiteboard.on("*", ...)` handler redraws every card from it, then animates only what changed: it records where each job (`data-job`) was drawn before the redraw and glides it from there with `whiteboard.animate`, fading in jobs that are new. `whiteboard.arrow` draws the edges; the put edge takes `data-tone="removed"` while the producer is blocked. The producer and workers are `data-form="bars"`, parts of this codebase, and the queue a violet `data-form="stack"`, so the silhouettes tell them apart before the titles do.
|
|
17
|
+
|
|
18
|
+
## Adapt
|
|
19
|
+
|
|
20
|
+
- Edit only frames.json for new steps or scenarios; the script never names a frame key.
|
|
21
|
+
- Change `bound` in the data, or add a worker: a card with id `w3`, its arrow, and a third entry in each frame's `workers`.
|
|
22
|
+
- For a queue that drops or rejects instead of blocking, add a producer state with its own note and send the refused job to a "Dropped" well like Done.
|
|
23
|
+
- Point `code` in block.json at your put and take paths; the cards open them with `data-wb-open`.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<div class="wb-grid pipeline">
|
|
2
|
+
<div class="wb-card" id="producer" data-form="bars" data-wb-open="put">
|
|
3
|
+
<span class="wb-card-title">Producer</span>
|
|
4
|
+
<span class="wb-card-note"></span>
|
|
5
|
+
<div class="wb-items"></div>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="wb-card" id="queue" data-form="stack" data-color="violet" data-wb-open="put">
|
|
8
|
+
<span class="wb-card-title">Queue</span>
|
|
9
|
+
<span class="wb-card-note"></span>
|
|
10
|
+
<div class="wb-items"></div>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="workers">
|
|
13
|
+
<div class="wb-card" id="w1" data-form="bars" data-wb-open="take">
|
|
14
|
+
<span class="wb-card-title">Worker 1</span>
|
|
15
|
+
<span class="wb-card-note"></span>
|
|
16
|
+
<div class="wb-items"></div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="wb-card" id="w2" data-form="bars" data-wb-open="take">
|
|
19
|
+
<span class="wb-card-title">Worker 2</span>
|
|
20
|
+
<span class="wb-card-note"></span>
|
|
21
|
+
<div class="wb-items"></div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="wb-well done">
|
|
24
|
+
<span class="wb-caps">Done</span>
|
|
25
|
+
<div class="wb-items"></div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<style>
|
|
31
|
+
.pipeline { --wb-columns: 3; column-gap: var(--wb-space-48); align-items: center; }
|
|
32
|
+
.workers { display: flex; flex-direction: column; gap: var(--wb-space-12); }
|
|
33
|
+
.done { padding: var(--wb-space-6) var(--wb-space-12); }
|
|
34
|
+
.done .wb-items { min-height: var(--wb-space-20); }
|
|
35
|
+
/* Every job and empty slot is the same width, so the queue reads as slots. */
|
|
36
|
+
.slot { min-width: var(--wb-space-28); text-align: center; }
|
|
37
|
+
.slot:empty::before { content: "\00a0"; }
|
|
38
|
+
@media (max-width: 520px) {
|
|
39
|
+
.pipeline { grid-template-columns: minmax(0, 1fr); row-gap: var(--wb-space-40); }
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
const put = whiteboard.arrow("#producer", "#queue", { label: "put" });
|
|
45
|
+
whiteboard.arrow("#queue", "#w1");
|
|
46
|
+
whiteboard.arrow("#queue", "#w2");
|
|
47
|
+
|
|
48
|
+
const slot = (job, tone) => {
|
|
49
|
+
const el = document.createElement("span");
|
|
50
|
+
el.className = "wb-chip slot";
|
|
51
|
+
if (job) el.textContent = el.dataset.job = job;
|
|
52
|
+
if (tone) el.dataset.tone = tone;
|
|
53
|
+
return el;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const show = (selector, note, chips, tone) => {
|
|
57
|
+
const card = document.querySelector(selector);
|
|
58
|
+
card.querySelector(".wb-card-note").textContent = note;
|
|
59
|
+
card.querySelector(".wb-items").replaceChildren(...chips);
|
|
60
|
+
if (tone) card.dataset.tone = tone;
|
|
61
|
+
else delete card.dataset.tone;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
whiteboard.on("*", ({ frame }) => {
|
|
65
|
+
const { producer, queue, bound, workers, done } = frame.data;
|
|
66
|
+
|
|
67
|
+
// Where every job was drawn, so a job that changed place glides from there.
|
|
68
|
+
const was = new Map(
|
|
69
|
+
[...document.querySelectorAll("[data-job]")].map((el) => [el.dataset.job, el.getBoundingClientRect()]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const blocked = producer.state === "blocked";
|
|
73
|
+
const notes = { ready: "next", blocked: "put() blocks:", woken: "put() resumes:" };
|
|
74
|
+
show(
|
|
75
|
+
"#producer",
|
|
76
|
+
producer.next ? `${notes[producer.state]} ${producer.next}` : "idle",
|
|
77
|
+
[slot(producer.next, blocked ? "removed" : undefined)],
|
|
78
|
+
blocked ? "removed" : undefined,
|
|
79
|
+
);
|
|
80
|
+
if (blocked) put.dataset.tone = "removed";
|
|
81
|
+
else delete put.dataset.tone;
|
|
82
|
+
|
|
83
|
+
// Tail on the left, head on the right beside the workers.
|
|
84
|
+
const free = Array.from({ length: bound - queue.length }, () => slot(null, "muted"));
|
|
85
|
+
const full = queue.length === bound;
|
|
86
|
+
show(
|
|
87
|
+
"#queue",
|
|
88
|
+
`${queue.length} of ${bound}${full ? ", full" : ""}`,
|
|
89
|
+
[...free, ...queue.toReversed().map((job) => slot(job))],
|
|
90
|
+
full ? "modified" : undefined,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
workers.forEach((job, i) =>
|
|
94
|
+
show(`#w${i + 1}`, job ? `running ${job}` : "idle", [slot(job, job ? undefined : "muted")]),
|
|
95
|
+
);
|
|
96
|
+
document.querySelector(".done .wb-items").replaceChildren(...done.map((job) => slot(job, "added")));
|
|
97
|
+
|
|
98
|
+
return [...document.querySelectorAll("[data-job]")].flatMap((el) => {
|
|
99
|
+
const from = was.get(el.dataset.job);
|
|
100
|
+
if (!from) return whiteboard.animate(el, [{ opacity: 0 }, { opacity: 1 }], 300);
|
|
101
|
+
const to = el.getBoundingClientRect();
|
|
102
|
+
const [dx, dy] = [from.left - to.left, from.top - to.top];
|
|
103
|
+
if (!dx && !dy) return [];
|
|
104
|
+
return whiteboard.animate(el, [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], 600);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
</script>
|