pi-weave 0.1.7 → 0.1.8

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 (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The renderer seam (weave-workspace §7.5).
3
+ *
4
+ * > `src/web/client/graph/renderer.ts` exposes a narrow interface — `mount`,
5
+ * > `setGraph(nodes, edges, positions)`, `setHighlight(Set<id>)`, `onSelect`,
6
+ * > `fit`, `destroy` — with `SigmaRenderer` as the only implementation. If
7
+ * > sigma ever stops being the right answer, one file changes.
8
+ *
9
+ * That is handoff design goal #5 (decouple simulation / rendering /
10
+ * interaction) satisfied *structurally* rather than by discipline. The column
11
+ * above holds a {@link GraphRenderer} and never a `Sigma`; §8 keeps layout
12
+ * correctness independent of the renderer entirely.
13
+ *
14
+ * ## Why `new Sigma(...)` is a parameter
15
+ *
16
+ * §10 forbids a DOM test environment, and sigma needs a canvas and a WebGL
17
+ * context — so the naive shape of this file (interface, then a
18
+ * `createSigmaRenderer` that calls `new Sigma`) is a file that reports **0 %
19
+ * coverage as a whole**, because it cannot even be imported. Verified rather
20
+ * than assumed: `import Sigma from "sigma"` evaluates
21
+ * `WebGL2RenderingContext.BOOL` at module scope while building its default
22
+ * program table, so the import alone is a `ReferenceError` in Node, before a
23
+ * line of ours runs.
24
+ *
25
+ * That is a worse outcome than it looks. This repository's rule (§10, and
26
+ * `docs/testing.md` §L5.2) is that *untestable lines* are kept to a handful,
27
+ * not that untestable *files* are excluded — the one coverage exclusion that
28
+ * exists is a type-only module, and a blanket `src/web/client/**` exclude is
29
+ * explicitly "not acceptable". A whole renderer sitting outside the gate would
30
+ * be exactly the erosion that rule prevents.
31
+ *
32
+ * So the dependency is inverted. {@link SigmaLike} and {@link SigmaFactory}
33
+ * are the two-and-a-half-method port sigma satisfies structurally;
34
+ * {@link sigmaRenderer} is the entire renderer written against the port, and
35
+ * it is covered by ordinary unit tests with a recording fake. The only thing
36
+ * that genuinely cannot be tested is the four-line adapter in
37
+ * `renderer.dom.ts` that says `new Sigma(graph, container, settings)` — the
38
+ * same shape, and the same reasoning, as `api.dom.ts` for `fetch` and
39
+ * `domEventSource` for `EventSource`.
40
+ *
41
+ * §7.5's promise is unaffected. "One file changes" is still true, and it is
42
+ * now true of a file with no branches in it.
43
+ *
44
+ * ## Tier rules (§2)
45
+ *
46
+ * `src/web/client/**`. This file imports no npm package at all, which is what
47
+ * lets the root `tsconfig.json` project (no `DOM` lib) compile the tests that
48
+ * import it.
49
+ */
50
+
51
+ import type { Point } from "../../shared/layout";
52
+ import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode } from "./graph.model";
53
+ import { edgeReducer, graphSettings, nodeReducer } from "./graph.model";
54
+ import type { ProjectedGraph } from "./project";
55
+ import { positionsOf, project, syncPositions } from "./project";
56
+
57
+ // --- the seam -------------------------------------------------------------------
58
+
59
+ /**
60
+ * The narrow interface §7.5 specifies.
61
+ *
62
+ * `setGraph` takes a {@link RenderGraph} rather than §7.5's literal
63
+ * `(nodes, edges, positions)` triple, and that is the one deviation worth
64
+ * naming. The triple would make every implementation re-derive colours, sizes
65
+ * and labels from raw wire nodes — i.e. it would put the decisions §7.5 wants
66
+ * decoupled *behind* the seam. Passing the already-decided model keeps the
67
+ * argument list to one and the second implementation, if there ever is one,
68
+ * honest.
69
+ */
70
+ export interface GraphRenderer {
71
+ /** Attach to a container. Idempotent: mounting twice is a no-op. */
72
+ mount(container: RenderContainer): void;
73
+ /** Replace the drawn graph. Safe before {@link mount}. */
74
+ setGraph(graph: RenderGraph): void;
75
+ /**
76
+ * Move nodes without rebuilding.
77
+ *
78
+ * Separate from {@link setGraph} because a re-run of the simulation over an
79
+ * unchanged node set is the common case (drag, expand, resize) and
80
+ * rebuilding would drop the camera and every WebGL buffer.
81
+ */
82
+ setPositions(positions: ReadonlyMap<string, Point>): void;
83
+ /**
84
+ * Dim everything outside this set (§7.4). `null` clears the highlight.
85
+ *
86
+ * `null` is not the empty set: nothing selected means everything renders
87
+ * normally, an empty neighbourhood means everything dims. See
88
+ * `graph.model.ts`'s `nodeReducer`.
89
+ */
90
+ setHighlight(highlight: ReadonlySet<string> | null): void;
91
+ /** Called with a node id on click, and with `null` on a click off any node. */
92
+ onSelect(handler: (id: string | null) => void): void;
93
+ /** Frame the whole graph. The `[fit]` control. */
94
+ fit(): void;
95
+ /** Current positions, for warm-starting a re-run. */
96
+ positions(): Map<string, Point>;
97
+ /** Tear down. Idempotent, and must leave no listener behind. */
98
+ destroy(): void;
99
+ }
100
+
101
+ /**
102
+ * The container, as far as this seam is concerned.
103
+ *
104
+ * Sigma wants an `HTMLElement`, and this file may not say so — a test
105
+ * importing it drags the module into the **root** `tsconfig.json` project
106
+ * (`exclude` filters the initial glob, not what an included file imports), and
107
+ * that project has no `DOM` lib. The structural stand-in is the same trick
108
+ * `cssvars.ts`, `api.ts` and `live.ts` use; the one cast lives in
109
+ * `renderer.dom.ts`, which is compiled only by `tsconfig.web.json`.
110
+ */
111
+ export interface RenderContainer {
112
+ readonly clientWidth: number;
113
+ readonly clientHeight: number;
114
+ }
115
+
116
+ /**
117
+ * A renderer that draws nothing.
118
+ *
119
+ * Not a test double — a production path. The graph column renders before its
120
+ * container exists (the first pass), may never mount at all (the `medium`
121
+ * breakpoint collapses the column), and must keep working when it does not. A
122
+ * null object removes the `renderer === null` check from every call site,
123
+ * which is the check that is always missing from exactly one of them.
124
+ */
125
+ export function nullRenderer(): GraphRenderer {
126
+ return {
127
+ mount() {},
128
+ setGraph() {},
129
+ setPositions() {},
130
+ setHighlight() {},
131
+ onSelect() {},
132
+ fit() {},
133
+ positions() {
134
+ return new Map();
135
+ },
136
+ destroy() {},
137
+ };
138
+ }
139
+
140
+ /**
141
+ * How a {@link GraphRenderer} is obtained.
142
+ *
143
+ * Injected at the column rather than imported by it, so the graph column is
144
+ * driven by a recording fake in a test and by `createSigmaRenderer` in the
145
+ * browser — the same port-shaped injection `api.ts` uses for `fetch`.
146
+ */
147
+ export type RendererFactory = (scheme: ColorScheme) => GraphRenderer;
148
+
149
+ // --- the sigma port -----------------------------------------------------------------
150
+
151
+ /**
152
+ * The camera, as far as this module uses it.
153
+ *
154
+ * One method. `animatedReset` is what `[fit]` is (§7.4: "Pan / zoom / fit →
155
+ * `camera.animatedReset()`").
156
+ */
157
+ export interface CameraLike {
158
+ animatedReset(): Promise<void>;
159
+ }
160
+
161
+ /**
162
+ * The slice of `Sigma` this renderer drives.
163
+ *
164
+ * Structural, so the real class satisfies it without a cast and a fake is an
165
+ * object literal — the same reasoning `EventSourceLike` in `live.ts` records.
166
+ * Six methods, and every one of them is called below, so the port cannot grow
167
+ * a member nothing uses.
168
+ *
169
+ * The reducer signatures are deliberately stated over `RenderNode` /
170
+ * `RenderEdge` rather than sigma's `Partial<NodeDisplayData>`: those are the
171
+ * attributes `project.ts` actually writes, and naming sigma's types here would
172
+ * mean importing sigma.
173
+ */
174
+ export interface SigmaLike {
175
+ on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
176
+ on(event: "clickStage", handler: () => void): unknown;
177
+ setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
178
+ setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge) => EdgeDisplayOverride): unknown;
179
+ setGraph(graph: ProjectedGraph): unknown;
180
+ refresh(): unknown;
181
+ getCamera(): CameraLike;
182
+ kill(): void;
183
+ }
184
+
185
+ /**
186
+ * `new Sigma(graph, container, settings)`, as a port.
187
+ *
188
+ * The one line that cannot be tested, isolated behind a function type so that
189
+ * everything *around* it can be. `renderer.dom.ts` is the four-line adapter
190
+ * that supplies the real constructor.
191
+ */
192
+ export type SigmaFactory = (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike;
193
+
194
+ // --- the implementation --------------------------------------------------------------
195
+
196
+ /**
197
+ * The renderer §7.5 calls `SigmaRenderer`, over an injected constructor.
198
+ *
199
+ * Holds no decisions. Colours, sizes, labels, z-order, settings and the dim
200
+ * rules are `graph.model.ts`; the graphology instance is `project.ts`; which
201
+ * nodes are highlighted is core's `focusNeighborhood`. What is left is
202
+ * lifecycle: build lazily, delegate, tear down once.
203
+ *
204
+ * The scheme is fixed at construction rather than settable. A
205
+ * `prefers-color-scheme` flip mid-session is rare enough that rebuilding the
206
+ * renderer is the honest response, where a `setScheme` would mean re-deriving
207
+ * every node colour and re-projecting — a second code path for a case nobody
208
+ * hits.
209
+ */
210
+ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphRenderer {
211
+ let sigma: SigmaLike | null = null;
212
+ let graph: ProjectedGraph = project({ nodes: [], edges: [] });
213
+ let highlight: ReadonlySet<string> | null = null;
214
+ let select: (id: string | null) => void = () => {};
215
+
216
+ /**
217
+ * Push the current highlight into sigma's reducers (§7.4).
218
+ *
219
+ * Re-installed on every change rather than reading `highlight` through a
220
+ * closure, because `setSetting` is what tells sigma to schedule a render —
221
+ * mutating a captured variable would change the answer and repaint nothing
222
+ * until the next unrelated frame.
223
+ */
224
+ const applyReducers = (instance: SigmaLike): void => {
225
+ const nodes = nodeReducer(highlight);
226
+ const edges = edgeReducer(highlight);
227
+ instance.setSetting("nodeReducer", (id, data) => ({ ...data, ...nodes(id, data, scheme) }));
228
+ instance.setSetting("edgeReducer", (key, data) => ({ ...data, ...edges(key, data) }));
229
+ };
230
+
231
+ return {
232
+ mount(container) {
233
+ if (sigma !== null) return;
234
+ const instance = create(graph, container, graphSettings(scheme));
235
+ // §1.3's context bus: a click writes `selectedId`, and the note column,
236
+ // the tree and the context rail all recompute from it.
237
+ instance.on("clickNode", ({ node }) => select(node));
238
+ instance.on("clickStage", () => select(null));
239
+ applyReducers(instance);
240
+ sigma = instance;
241
+ },
242
+
243
+ setGraph(next) {
244
+ graph = project(next);
245
+ sigma?.setGraph(graph);
246
+ },
247
+
248
+ setPositions(positions) {
249
+ syncPositions(graph, positions);
250
+ // `refresh` re-reads the node attributes just written; `setGraph` would
251
+ // rebuild every WebGL buffer and reset the camera.
252
+ sigma?.refresh();
253
+ },
254
+
255
+ setHighlight(next) {
256
+ highlight = next;
257
+ if (sigma !== null) applyReducers(sigma);
258
+ },
259
+
260
+ onSelect(handler) {
261
+ select = handler;
262
+ },
263
+
264
+ fit() {
265
+ // `animatedReset` settles when the animation ends. Nothing awaits a
266
+ // camera move, and a rejection from a camera killed mid-flight is noise.
267
+ void sigma?.getCamera().animatedReset();
268
+ },
269
+
270
+ positions() {
271
+ return positionsOf(graph);
272
+ },
273
+
274
+ destroy() {
275
+ sigma?.kill();
276
+ sigma = null;
277
+ },
278
+ };
279
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Reading `prefers-color-scheme` (weave-workspace §7.4).
3
+ *
4
+ * WebGL cannot read a CSS custom property: sigma needs a concrete `#rrggbb`
5
+ * per node, so unlike every other column the graph has to know which palette
6
+ * the user is in rather than leaving it to the stylesheet. That is one media
7
+ * query, and it is here rather than inline in a component for §10's reason —
8
+ * a `matchMedia` call in a `.tsx` is a line no test can reach.
9
+ *
10
+ * The default is **dark**, matching `shell/theme.ts` (which is dark-first with
11
+ * a `prefers-color-scheme: light` override) rather than `page.ts`'s
12
+ * light-first pre-paint block. §15.1 records that disagreement as known debt;
13
+ * this file follows the sheet the user actually ends up looking at.
14
+ */
15
+
16
+ import type { ColorScheme } from "./graph.model";
17
+
18
+ /**
19
+ * The slice of `window` this module reads.
20
+ *
21
+ * `matchMedia` returns far more than `matches`, and naming `MediaQueryList`
22
+ * would make the module uncompilable by the root `tsconfig.json` project (no
23
+ * `DOM` lib), which is what a test importing it runs under. The narrow shape
24
+ * is the same trick `viewport.ts` and `cssvars.ts` use, and the real `window`
25
+ * satisfies it structurally.
26
+ */
27
+ export interface SchemeHost {
28
+ matchMedia(query: string): { readonly matches: boolean };
29
+ }
30
+
31
+ /** The query. Light is the exception; see the module header. */
32
+ export const LIGHT_QUERY = "(prefers-color-scheme: light)";
33
+
34
+ /**
35
+ * Which palette to draw in.
36
+ *
37
+ * A host without `matchMedia` — an old embedding, a partial test double —
38
+ * gets dark rather than an exception. A graph in the wrong palette is legible;
39
+ * a graph that threw at mount is not.
40
+ */
41
+ export function schemeOf(host: SchemeHost | null | undefined): ColorScheme {
42
+ if (host === null || host === undefined || typeof host.matchMedia !== "function") return "dark";
43
+ return host.matchMedia(LIGHT_QUERY).matches ? "light" : "dark";
44
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The liveness state machine, as pure data (weave-workspace §6).
3
+ *
4
+ * `src/web/server/sse.ts` pushes `{scope, stamp}` frames at the browser; this
5
+ * module decides two things about them, and nothing else:
6
+ *
7
+ * 1. **What must be refetched** — a {@link RefetchPlan}.
8
+ * 2. **What the status bar should say** — a `ConnectionState`.
9
+ *
10
+ * It is a `.model.ts` for the reason §10 gives: there is no DOM test
11
+ * environment in this repository and we may not add one, so every decision
12
+ * worth getting right lives in a pure function over plain objects and
13
+ * {@link ./live} is left with nothing but socket plumbing. Everything here
14
+ * runs under the root `tsconfig.json`, which has no `DOM` lib — hence no
15
+ * `EventSource`, no `MessageEvent`, and `readyState` arriving as a plain
16
+ * number.
17
+ *
18
+ * ## Frames are hints, not deltas
19
+ *
20
+ * §6 is explicit that macOS `fs.watch` coalesces and can drop events, so a
21
+ * frame means "something in this scope moved, re-read it" and never "here is
22
+ * what changed". That single sentence is why {@link planFor} returns *which
23
+ * endpoints to re-request* rather than a patch, and why there is no code here
24
+ * that tries to apply a frame to a graph in place. A client that treated
25
+ * frames as deltas would diverge silently the first time the OS dropped one,
26
+ * and the divergence would be invisible until someone noticed a stale node an
27
+ * hour later.
28
+ *
29
+ * ## Reconnect refetches everything
30
+ *
31
+ * The server keeps **no replay buffer** and ignores `Last-Event-ID` (see the
32
+ * `sse.ts` header). So a reopened stream carries no information about the gap,
33
+ * and the only correct response to "I was away for an unknown interval" is to
34
+ * re-request everything. That is what {@link reduceLive} produces for a
35
+ * reopen, and it is cheap rather than wasteful: the graph request carries
36
+ * `If-None-Match`, so an unchanged workspace costs one `304` with an empty
37
+ * body. Buffering would add retention, eviction and a resume path to arrive
38
+ * at the same fetch.
39
+ */
40
+
41
+ import type { ChangeEvent, ChangeScope } from "../shared/wire";
42
+ import { isChangeEvent } from "../shared/wire";
43
+ import type { ConnectionState } from "./state";
44
+
45
+ /** The SSE endpoint (§5.3). */
46
+ export const EVENTS_PATH = "/events";
47
+
48
+ // --- `EventSource.readyState`, without the DOM lib ------------------------------
49
+
50
+ /**
51
+ * The three `readyState` values, restated as local constants.
52
+ *
53
+ * `EventSource.CONNECTING` and friends are DOM globals and this module is
54
+ * compiled by a project with no `DOM` lib, so the numbers are written down
55
+ * here. They are fixed by the HTML specification and cannot drift — unlike,
56
+ * say, an HTTP status, there is no version of the standard in which `CLOSED`
57
+ * stops being `2`.
58
+ */
59
+ export const SOCKET_CONNECTING = 0;
60
+ export const SOCKET_OPEN = 1;
61
+ export const SOCKET_CLOSED = 2;
62
+
63
+ // --- refetch plans ---------------------------------------------------------------
64
+
65
+ /**
66
+ * Which endpoints a transition invalidates.
67
+ *
68
+ * Two booleans rather than a set of route names, because at P1 there are
69
+ * exactly two things the shell holds: the graph payload and the selected
70
+ * note's body. This will grow — P2 adds the `.okf` file view and the tag
71
+ * index — and the honest thing is to let it grow then rather than to invent a
72
+ * registry now for endpoints that do not exist.
73
+ */
74
+ export interface RefetchPlan {
75
+ /** Re-request `GET /api/graph`, conditionally on the held stamp. */
76
+ readonly graph: boolean;
77
+ /** Re-request `GET /api/note/:slug` for the current selection. */
78
+ readonly note: boolean;
79
+ }
80
+
81
+ /** Nothing to do. Returned for a deduped frame and for an idle transition. */
82
+ export const NO_REFETCH: RefetchPlan = { graph: false, note: false };
83
+
84
+ /** Whether a plan would issue no requests at all. */
85
+ export function isNoop(plan: RefetchPlan): boolean {
86
+ return !plan.graph && !plan.note;
87
+ }
88
+
89
+ /**
90
+ * Scope → what it can possibly have invalidated.
91
+ *
92
+ * The table is deliberately thin, and worth stating plainly rather than
93
+ * dressing up: **every** scope invalidates the graph, because the graph
94
+ * carries the vault's notes, the repository index *and* the git-state node
95
+ * plus its staleness report. The one real distinction is the note body — a
96
+ * `repo` or `git` change cannot alter the text of a note on disk, so the note
97
+ * column is left alone, while a `vault` change can and must re-read it.
98
+ *
99
+ * Inventing finer granularity here would be fiction. When P2 gives the client
100
+ * more than two things to hold, this table earns more rows.
101
+ *
102
+ * @param hasSelection whether a note is currently open in the note column;
103
+ * with nothing selected there is no note to refetch.
104
+ */
105
+ export function planFor(scope: ChangeScope, hasSelection: boolean): RefetchPlan {
106
+ return { graph: true, note: scope === "vault" && hasSelection };
107
+ }
108
+
109
+ /**
110
+ * The "I have been away" plan: everything the client holds.
111
+ *
112
+ * Used for a reopened stream and for the header's manual `⟳`. Both mean the
113
+ * same thing — the client cannot reason about what it missed — so they get
114
+ * the same answer rather than two subtly different ones.
115
+ */
116
+ export function planForEverything(hasSelection: boolean): RefetchPlan {
117
+ return { graph: true, note: hasSelection };
118
+ }
119
+
120
+ // --- state --------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Everything the liveness layer remembers.
124
+ *
125
+ * `stamp` is the dedupe key and doubles as the `If-None-Match` value the
126
+ * shell sends. It is kept across a disconnect on purpose: a reconnect
127
+ * refetches everything, but it refetches *conditionally*, so holding the last
128
+ * known stamp turns "refetch everything" into a `304` whenever nothing
129
+ * actually moved while we were away.
130
+ */
131
+ export interface LiveState {
132
+ readonly connection: ConnectionState;
133
+ /** Last stamp the client has applied, or `null` before the first graph. */
134
+ readonly stamp: string | null;
135
+ /**
136
+ * Whether the stream has ever been open.
137
+ *
138
+ * The discriminator between "first connect" and "reconnect", which is the
139
+ * only reason this flag exists. The first open needs no forced refetch —
140
+ * the shell fetches the graph on mount — while every later open does.
141
+ */
142
+ readonly opened: boolean;
143
+ }
144
+
145
+ /** Before the socket is created. Matches `state.ts`'s documented defaults. */
146
+ export function initialLiveState(): LiveState {
147
+ return { connection: "live", stamp: null, opened: false };
148
+ }
149
+
150
+ /**
151
+ * Record a stamp the client now holds.
152
+ *
153
+ * Called by the shell after any successful graph fetch, including the one on
154
+ * mount. That seeding is what makes the server's hello frame free: `sse.ts`
155
+ * sends the current stamp to every newly attached client, and a client that
156
+ * already fetched that stamp at mount would otherwise treat it as news and
157
+ * fetch a second time.
158
+ *
159
+ * The race is still possible — the hello frame can beat the mount fetch — and
160
+ * it is deliberately not defended against. Losing it costs one conditional
161
+ * GET that answers `304`, and the machinery to prevent that would be worth
162
+ * more than the request it saves.
163
+ */
164
+ export function withStamp(state: LiveState, stamp: string): LiveState {
165
+ return state.stamp === stamp ? state : { ...state, stamp };
166
+ }
167
+
168
+ // --- events -------------------------------------------------------------------------
169
+
170
+ /**
171
+ * What can happen to the stream.
172
+ *
173
+ * `error` carries the socket's `readyState` because that is the *only* way to
174
+ * tell the two failures apart: `EventSource` fires the same `error` event
175
+ * when it is about to retry (`CONNECTING`) and when it has given up
176
+ * (`CLOSED`). Reading the flag is not an optimisation — without it the status
177
+ * bar cannot distinguish "back in a moment" from "this session is over", and
178
+ * would have to pick one and be wrong half the time.
179
+ */
180
+ export type LiveEvent =
181
+ | { readonly type: "open" }
182
+ | { readonly type: "error"; readonly readyState: number }
183
+ | { readonly type: "frame"; readonly event: ChangeEvent }
184
+ /** The header's `⟳`. Not a socket event; refetches without touching state. */
185
+ | { readonly type: "refresh" }
186
+ /** The client closed the stream itself — navigation, or `stop()`. */
187
+ | { readonly type: "closed" };
188
+
189
+ /** The result of one transition: the next state, and what to go and fetch. */
190
+ export interface LiveTransition {
191
+ readonly state: LiveState;
192
+ readonly plan: RefetchPlan;
193
+ }
194
+
195
+ /**
196
+ * Map a socket `readyState` at the moment of an error to a status.
197
+ *
198
+ * `CLOSED` is terminal: `EventSource` only reaches it after it has stopped
199
+ * retrying, so `"offline"` is a statement of fact rather than a guess. Any
200
+ * other value means a retry is scheduled, which is `"reconnecting"`. An
201
+ * unrecognised number lands there too — the safe direction, because a status
202
+ * bar that says "reconnecting" while the client is in fact dead is a smaller
203
+ * lie than one that says "offline" while a retry is in flight, and the next
204
+ * `open` or `error` corrects it either way.
205
+ */
206
+ export function connectionForError(readyState: number): ConnectionState {
207
+ return readyState === SOCKET_CLOSED ? "offline" : "reconnecting";
208
+ }
209
+
210
+ /**
211
+ * The whole liveness state machine.
212
+ *
213
+ * @param hasSelection whether the note column currently holds a note. Passed
214
+ * in rather than stored, because it is owned by `selectedId` in
215
+ * `state.ts` and duplicating it here would create a second copy to keep in
216
+ * sync — the exact failure §1.3 avoids by having one signal.
217
+ */
218
+ export function reduceLive(state: LiveState, event: LiveEvent, hasSelection: boolean): LiveTransition {
219
+ switch (event.type) {
220
+ case "open": {
221
+ // A reopen means an unknown gap: see the module header. The first open
222
+ // is not a gap — nothing preceded it — so it only flips the status.
223
+ const plan = state.opened ? planForEverything(hasSelection) : NO_REFETCH;
224
+ return { state: { ...state, connection: "live", opened: true }, plan };
225
+ }
226
+
227
+ case "error":
228
+ return { state: { ...state, connection: connectionForError(event.readyState) }, plan: NO_REFETCH };
229
+
230
+ case "frame": {
231
+ // The dedupe that makes the hello frame free, and that absorbs the
232
+ // watcher's debounce emitting two frames for one save. The stamp is a
233
+ // content digest of the graph payload (§5.3), so an identical stamp
234
+ // provably means identical content — which is what entitles this line
235
+ // to skip a refetch. It was `generatedAt` until §15.6, and a timestamp
236
+ // max does *not* carry that guarantee: an edit that did not move the
237
+ // maximum was discarded right here, before the conditional GET that
238
+ // would have caught it ever ran.
239
+ if (event.event.stamp === state.stamp) return { state, plan: NO_REFETCH };
240
+ // The stamp is *not* recorded here. It becomes ours when the refetch it
241
+ // triggers succeeds — recording it now would mean a failed fetch left
242
+ // the client believing it holds data it never received, and the next
243
+ // frame carrying the same stamp would be deduped away.
244
+ return { state: { ...state, connection: "live" }, plan: planFor(event.event.scope, hasSelection) };
245
+ }
246
+
247
+ case "refresh":
248
+ return { state, plan: planForEverything(hasSelection) };
249
+
250
+ case "closed":
251
+ return { state: { ...state, connection: "offline" }, plan: NO_REFETCH };
252
+ }
253
+ }
254
+
255
+ // --- frame decoding ------------------------------------------------------------------
256
+
257
+ /**
258
+ * Decode one SSE `data:` line, or `null`.
259
+ *
260
+ * Total by design. This runs inside a socket callback on bytes that survived
261
+ * a server restart, a proxy and whatever else sits on loopback, so a
262
+ * malformed frame must cost a skipped refetch — never an unhandled rejection
263
+ * that kills the listener and silently ends liveness for the session.
264
+ * {@link isChangeEvent} does the structural half; this adds the `JSON.parse`
265
+ * that can throw.
266
+ */
267
+ export function parseFrame(data: string): ChangeEvent | null {
268
+ let parsed: unknown;
269
+ try {
270
+ parsed = JSON.parse(data);
271
+ } catch {
272
+ return null;
273
+ }
274
+ return isChangeEvent(parsed) ? parsed : null;
275
+ }