pi-weave 0.1.11 → 0.1.13

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 (75) hide show
  1. package/README.md +148 -132
  2. package/package.json +1 -2
  3. package/src/core/cache/workspace.ts +26 -9
  4. package/src/core/concurrency.ts +3 -6
  5. package/src/core/frontmatter.ts +0 -53
  6. package/src/core/graph/build.ts +12 -8
  7. package/src/core/graph/current.ts +4 -6
  8. package/src/core/graph/model.ts +1 -1
  9. package/src/core/graph/wikilinks.ts +3 -3
  10. package/src/core/index.ts +26 -27
  11. package/src/core/paths.ts +0 -7
  12. package/src/core/slug.ts +13 -0
  13. package/src/core/types.ts +1 -0
  14. package/src/core/vault.ts +45 -467
  15. package/src/core/view/detail.ts +1 -1
  16. package/src/core/view/health.ts +1 -1
  17. package/src/core/view/tree.ts +15 -3
  18. package/src/pi/index.ts +6 -85
  19. package/src/pi/summarize.ts +2 -2
  20. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  21. package/src/pi/viewer/tui/branding.ts +7 -148
  22. package/src/pi/viewer/tui/run.ts +3 -17
  23. package/src/pi/viewer/tui/surface/base.ts +24 -3
  24. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  25. package/src/pi/viewer/tui/workspace.ts +23 -351
  26. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  27. package/src/pi/viewer/web/run.ts +7 -117
  28. package/src/web/client/api.dom.ts +2 -2
  29. package/src/web/client/api.ts +14 -176
  30. package/src/web/client/bootstrap.ts +5 -14
  31. package/src/web/client/context/context.model.ts +9 -11
  32. package/src/web/client/dist/app.js +77 -167
  33. package/src/web/client/graph/dynamics.ts +5 -65
  34. package/src/web/client/graph/renderer.dom.ts +7 -8
  35. package/src/web/client/graph/renderer.ts +9 -35
  36. package/src/web/client/main.tsx +1 -1
  37. package/src/web/client/note/Note.tsx +37 -60
  38. package/src/web/client/note/note.model.ts +23 -0
  39. package/src/web/client/search/SearchPalette.tsx +45 -36
  40. package/src/web/client/search/search.model.ts +33 -454
  41. package/src/web/client/shell/Columns.tsx +13 -81
  42. package/src/web/client/shell/Header.tsx +2 -10
  43. package/src/web/client/shell/Shell.tsx +50 -123
  44. package/src/web/client/shell/StatusBar.tsx +1 -4
  45. package/src/web/client/shell/icons.model.ts +4 -7
  46. package/src/web/client/shell/keys.model.ts +5 -42
  47. package/src/web/client/shell/keys.ts +2 -2
  48. package/src/web/client/shell/shell.model.ts +10 -133
  49. package/src/web/client/shell/theme.model.ts +2 -2
  50. package/src/web/client/shell/theme.ts +33 -121
  51. package/src/web/client/state.ts +9 -89
  52. package/src/web/client/tree/Tree.tsx +24 -97
  53. package/src/web/client/tree/tree.model.ts +8 -56
  54. package/src/web/client/workspace.ts +72 -242
  55. package/src/web/server/page.ts +8 -10
  56. package/src/web/server/routes.ts +30 -415
  57. package/src/web/server/server.ts +6 -145
  58. package/src/web/shared/layout.ts +72 -624
  59. package/src/web/shared/wire.ts +10 -178
  60. package/src/core/sessions.ts +0 -929
  61. package/src/pi/sessionScan.ts +0 -104
  62. package/src/pi/viewer/tui/explorer.ts +0 -586
  63. package/src/web/client/live.model.ts +0 -275
  64. package/src/web/client/live.ts +0 -151
  65. package/src/web/client/note/Editor.tsx +0 -109
  66. package/src/web/client/note/editor.controller.ts +0 -151
  67. package/src/web/client/note/editor.model.ts +0 -686
  68. package/src/web/client/search/search.ts +0 -107
  69. package/src/web/client/shell/Divider.tsx +0 -44
  70. package/src/web/client/shell/cssvars.ts +0 -70
  71. package/src/web/client/shell/drag.model.ts +0 -170
  72. package/src/web/client/shell/layout.model.ts +0 -500
  73. package/src/web/client/shell/viewport.ts +0 -29
  74. package/src/web/server/sse.ts +0 -321
  75. package/src/web/server/watcher.ts +0 -507
@@ -1,56 +1,15 @@
1
1
  /**
2
- * Route handling for the workspace server (weave-workspace §5.3).
2
+ * Route handling for the workspace server.
3
3
  *
4
4
  * | Method | Path | Response |
5
5
  * | ------ | ------------------------ | -------------------------------------------- |
6
6
  * | GET | `/` | the HTML shell, with its per-response nonce |
7
7
  * | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
8
8
  * | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
9
- * | GET | `/api/note/:slug` | {@link NotePayload} — the note plus its revision |
10
- * | POST | `/api/note/:slug` | update; {@link NotePayload} or a `409` (§5.3, P5) |
11
- * | POST | `/api/note/:slug/rename` | rename; {@link NotePayload} or a `409` |
12
- * | DELETE | `/api/note/:slug` | {@link DeleteNoteResult} — hard delete |
9
+ * | GET | `/api/note/:slug` | {@link NotePayload} |
13
10
  * | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
14
11
  * | GET | `/api/search?q=` | {@link SearchPayload} |
15
12
  * | POST | `/api/open` | {@link OpenResult} — hand the note to `$EDITOR` |
16
- * | GET | `/events` | the SSE stream (delegated to an injected hub) |
17
- *
18
- * ## The write routes (P5)
19
- *
20
- * Three of them, and every one goes through the same security gate as every
21
- * read: {@link handleRequest} calls `security.authorize` **before** it routes,
22
- * so there is no way to add a route that skips it. That matters more for
23
- * writes than for reads, because §5.1's Origin rule is asymmetric — absent on
24
- * a `GET` is fine (a browser omits it on same-origin navigation), absent on
25
- * anything else is a `403`, and that asymmetry is the CSRF defence. It lives
26
- * in `checkOrigin` rather than here precisely so a new write route inherits
27
- * it rather than remembering it.
28
- *
29
- * `MutationResult` → status is the one mapping this file owns:
30
- *
31
- * | Core result | Status | Body |
32
- * | --------------------- | -----: | ---- |
33
- * | `ok` | `200` | {@link NotePayload}, re-read so the revision is the one just written |
34
- * | `reason: "missing"` | `404` | {@link ErrorPayload} |
35
- * | `reason: "conflict"` | `409` | {@link ConflictPayload} with the **current note and revision** |
36
- * | `reason: "collision"` | `409` | {@link ConflictPayload} with the taken slug |
37
- *
38
- * `409` for both failures, with `reason` telling them apart in the body. They
39
- * are the same *kind* of answer — "the vault is not in the state you thought
40
- * it was" — and the client's response to each is a question for the user, so
41
- * splitting them across two status codes would buy a distinction the HTTP
42
- * layer has no use for while making the client branch twice.
43
- *
44
- * ## Self-write suppression (§6)
45
- *
46
- * Every one of these writes lands in the vault the watcher is watching, so
47
- * without suppression a save is a change event, which is a broadcast, which
48
- * makes the client refetch the note it just saved — and, worse, arrive
49
- * mid-typing with a "the file changed" prompt about its own keystroke. The
50
- * watcher exposes `suppress(absPath, ms)` for exactly this;
51
- * {@link RouteDeps.suppress} is the injected form, called with the note's
52
- * absolute path **before** the mutation runs, so the window is already open
53
- * when the write hits the filesystem.
54
13
  *
55
14
  * ## Shape
56
15
  *
@@ -58,8 +17,7 @@
58
17
  * injected — and a `ServerResponse`. It never constructs a cache, reads an
59
18
  * environment variable, or knows what port it is on. That is what lets
60
19
  * `tests/web/routes.test.ts` drive the real thing over a real socket with a
61
- * temp vault, and what lets P1b's SSE hub arrive as a constructor argument
62
- * rather than an import.
20
+ * temp vault without browser-specific state.
63
21
  *
64
22
  * ## Two things this file deliberately never does
65
23
  *
@@ -73,10 +31,7 @@
73
31
  * **No path resolution of its own.** `/api/okf/:rel` and `/api/note/:slug`
74
32
  * carry untrusted path fragments straight from the URL. Both are handed to
75
33
  * the existing core guards — `readOkfFileForView` anchors under `<cwd>/.okf`
76
- * and `resolveNotePath` (via `getNoteWithRevision`) rejects anything that is not
77
- * a flat slug. Re-implementing either check here would be a second
78
- * implementation to keep in sync, which is how traversal bugs are actually
79
- * born.
34
+ * and `resolveNotePath` (via `getNote`) rejects unsafe slugs.
80
35
  */
81
36
 
82
37
  import { createHash } from "node:crypto";
@@ -87,20 +42,14 @@ import { WorkspaceCache } from "../../core/cache/workspace";
87
42
  import { readOkfFileForView } from "../../core/graph/current";
88
43
  import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
89
44
  import { openNoteInEditor } from "../../core/openInEditor";
90
- import type { MutationResult, RevisionedNote } from "../../core/vault";
91
- import { slugify } from "../../core/slug";
92
- import { deleteNote, getNoteWithRevision, renameNote, resolveNotePath, searchNotes, updateNote } from "../../core/vault";
45
+ import type { Note } from "../../core/types";
46
+ import { getNote, searchNotes } from "../../core/vault";
93
47
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
94
48
  import type {
95
- ChangeEvent,
96
- ConflictPayload,
97
- DeleteNoteResult,
98
49
  GraphPayload,
99
50
  NotePayload,
100
51
  OkfFilePayload,
101
52
  OpenResult,
102
- RenameNoteRequest,
103
- SaveNoteRequest,
104
53
  SearchPayload,
105
54
  ViewNote,
106
55
  } from "../shared/wire";
@@ -109,99 +58,16 @@ import { renderPage } from "./page";
109
58
  import type { RequestFacts, SecurityPolicy } from "./security";
110
59
  import { requestFacts } from "./security";
111
60
 
112
- /**
113
- * The SSE hub contract.
114
- *
115
- * Declared here, in the consumer, rather than in the implementation: this
116
- * file is what needs the capability, and stating it here means the hub can
117
- * be written, replaced or omitted without `routes.ts` changing. It is also
118
- * the seam that lets the route tests boot a server with **no** hub and
119
- * assert the `503`.
120
- *
121
- * Implemented by `src/web/server/sse.ts`.
122
- */
123
- export interface SseHub {
124
- /** Adopt a request/response pair as a long-lived event stream. */
125
- attach(req: IncomingMessage, res: ServerResponse): void;
126
- /** Fan a change out to every attached client. */
127
- broadcast(event: ChangeEvent): void;
128
- /** Currently attached clients. Drives the idle-shutdown timer (§5.4). */
129
- clientCount(): number;
130
- /** End every stream and stop the heartbeat. Idempotent. */
131
- close(): void;
132
- }
133
-
134
- /**
135
- * The file watcher contract.
136
- *
137
- * Same reasoning as {@link SseHub} — the server owns the lifecycle, so it
138
- * declares the shape it will start and stop, and P1b's `watcher.ts` supplies
139
- * it. Deliberately minimal: the watcher's *output* reaches the world through
140
- * the hub it was constructed with, not through a return value here.
141
- *
142
- * Implemented by `src/web/server/watcher.ts`.
143
- */
144
- export interface Watcher {
145
- /** Begin watching. Resolves once the watches are established. */
146
- start(): Promise<void>;
147
- /** Stop watching and release every handle. Idempotent. */
148
- close(): Promise<void>;
149
- /**
150
- * Ignore events for `absPath` briefly — the §6 self-write window.
151
- *
152
- * **Optional**, and that is a deliberate contract choice rather than
153
- * timidity. This interface is the *lifecycle* one: start, close. A future
154
- * watcher over a remote filesystem or a stamp poller may have no concept
155
- * of "a path I am about to write", and making suppression mandatory would
156
- * force it to implement a no-op to satisfy a contract it does not
157
- * participate in. `server.ts` bridges it to {@link RouteDeps.suppress}
158
- * when it is present, and writes simply happen unsuppressed when it is
159
- * not — which is the correct degradation: one spurious refetch, never a
160
- * lost edit.
161
- */
162
- suppress?(absPath: string): void;
163
- }
164
-
165
61
  /** Everything a route needs, injected. */
166
62
  export interface RouteDeps {
167
63
  cwd: string;
168
64
  vaultRoot: string;
169
- /** Random per-boot id, echoed into the page bootstrap. */
170
- session: string;
171
65
  cache: WorkspaceCache;
172
66
  security: SecurityPolicy;
173
- /** Absent → `/events` answers `503`. Wired by P1b. */
174
- sse?: SseHub | undefined;
175
67
  /** Absolute path of the committed bundle. Injectable for tests. */
176
68
  bundlePath: string;
177
69
  /** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
178
70
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
179
- /**
180
- * Read a note plus its revision. Defaults to core's `getNoteWithRevision`.
181
- *
182
- * A seam for the same reason {@link RouteDeps.openNote} is one, and it
183
- * earns its place on a specific branch: after a successful write this is
184
- * called again to obtain the revision of the bytes now on disk, and it can
185
- * legitimately return `null` — a `weave_note` delete or an `rm` in another
186
- * terminal, landing in the window between the write and the re-read. That
187
- * is a genuine race with a correct answer (`404`: the write happened, and
188
- * the note is gone anyway), and it is unreachable from a test without
189
- * being able to make the read fail on demand. The alternative was a
190
- * coverage-ignore comment over a branch that really can fire in
191
- * production, which is the wrong trade.
192
- */
193
- readNote?: ((slug: string) => Promise<RevisionedNote | null>) | undefined;
194
- /** Called when an SSE client attaches or detaches — resets the idle timer. */
195
- onActivity?: (() => void) | undefined;
196
- /**
197
- * Ignore filesystem events for `absPath` for a moment (§6).
198
- *
199
- * The watcher's `suppress`, injected. Absent in the route tests, which
200
- * boot without a watcher — a write with nothing to suppress is a write,
201
- * not an error, so this is optional rather than a required no-op the
202
- * caller has to supply.
203
- */
204
- suppress?: ((absPath: string) => void) | undefined;
205
71
  }
206
72
 
207
73
  const JSON_TYPE = "application/json; charset=utf-8";
@@ -314,7 +180,7 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown | null
314
180
  * `notes` is what the graph was built from. It is a separate argument because
315
181
  * the graph deliberately does not carry structured tags — `detail.tags` is a
316
182
  * comma-joined display string and re-parsing it here would be exactly the
317
- * "grow structure inside `detail`" move §4.2/§4.3 rule out. The caller
183
+ * "grow structure inside `detail`" move / rule out. The caller
318
184
  * already holds the notes (the cache read them to build the model), so this
319
185
  * costs nothing. Omitted → `tags: {}`, which is what a caller that genuinely
320
186
  * has no note list should ship.
@@ -335,7 +201,7 @@ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote
335
201
  dangling: model.danglingLinks,
336
202
  // Still `null`, and deliberately: server-side layout needs
337
203
  // `src/web/shared/layout`, which imports d3-force, and the server tier's
338
- // npm allowlist is empty (§9: the published package has zero runtime
204
+ // npm allowlist is empty (: the published package has zero runtime
339
205
  // dependencies). The client runs the identical `shared/layout` code
340
206
  // itself, so this is a division of labour rather than a gap.
341
207
  positions: null,
@@ -359,7 +225,7 @@ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote
359
225
  * front-matter tags without bumping `updated`, and deleting a note that is
360
226
  * not the newest. In each the payload differs and the old stamp did not, so a
361
227
  * conditional GET answered `304` and the client kept stale data — and, worse,
362
- * the SSE dedupe (which shares this key) discarded the frame that would have
228
+ * a client-side comparison could discard the update that would have
363
229
  * prompted a refetch. A digest changes if and only if the bytes change, which
364
230
  * is the property both consumers actually need.
365
231
  *
@@ -406,7 +272,7 @@ export function stampPayload(payload: GraphPayload): GraphPayload {
406
272
  * The digest function. SHA-256, truncated to 128 bits and hex-encoded.
407
273
  *
408
274
  * Truncation is safe here and worth the 32 bytes it saves on every ETag
409
- * header and every SSE frame: at 128 bits an accidental collision between two
275
+ * header values: at 128 bits an accidental collision between two
410
276
  * payloads is far below the probability of the cache being wrong for any
411
277
  * other reason. This is a cache validator, not a security boundary — nobody
412
278
  * is choosing our note contents to force a collision, and if they could, they
@@ -500,7 +366,7 @@ async function route(
500
366
  if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
501
367
  if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
502
368
  if (path.startsWith("/api/note/")) {
503
- const handled = await routeNote(deps, method, path.slice("/api/note/".length), req, res);
369
+ const handled = await routeNote(deps, method, path.slice("/api/note/".length), res);
504
370
  if (handled) return;
505
371
  }
506
372
  if (method === "GET" && path.startsWith("/api/okf/")) {
@@ -508,8 +374,6 @@ async function route(
508
374
  }
509
375
  if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
510
376
  if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
511
- if (method === "GET" && path === "/events") return attachSse(deps, req, res);
512
-
513
377
  sendText(res, 404, "not found\n");
514
378
  }
515
379
 
@@ -517,7 +381,7 @@ async function route(
517
381
 
518
382
  function sendShell(deps: RouteDeps, res: ServerResponse): void {
519
383
  const page = renderPage({
520
- bootstrap: { cwd: deps.cwd, vaultRoot: deps.vaultRoot, session: deps.session },
384
+ bootstrap: { cwd: deps.cwd },
521
385
  });
522
386
  res.writeHead(200, {
523
387
  ...baseHeaders(),
@@ -545,7 +409,7 @@ async function sendBundle(deps: RouteDeps, res: ServerResponse): Promise<void> {
545
409
  res.writeHead(200, {
546
410
  ...baseHeaders(),
547
411
  "content-type": "text/javascript; charset=utf-8",
548
- // §5.3. The artifact changes on rebuild and the server may outlive one.
412
+ // . The artifact changes on rebuild and the server may outlive one.
549
413
  "cache-control": "no-store",
550
414
  });
551
415
  res.end(source);
@@ -557,29 +421,26 @@ async function sendBundle(deps: RouteDeps, res: ServerResponse): Promise<void> {
557
421
  *
558
422
  * `WorkspaceCache` returns the *identical* snapshot object while nothing on
559
423
  * disk has moved, so this map turns a warm `/api/graph` into a pure lookup:
560
- * no `toGraphPayload`, no `JSON.stringify`, and — the point of §15.6's cost
424
+ * no `toGraphPayload`, no `JSON.stringify`, and — the point of 's cost
561
425
  * requirement — **no hashing at all**. The first request after a real change
562
426
  * gets a new snapshot object, misses, and pays once for the whole build.
563
427
  *
564
428
  * A `WeakMap` rather than a one-slot cache so that a request racing a rebuild
565
429
  * cannot evict the entry the other request is about to read, and so entries
566
430
  * for superseded snapshots are collected with them. The key is the snapshot
567
- * rather than the model because the payload depends on the notes too (§4.3).
431
+ * rather than the model because the payload depends on the notes too (§4.1).
568
432
  */
569
433
  const renderedGraphs = new WeakMap<WorkspaceSnapshot, { body: string; etag: string }>();
570
434
 
571
435
  /**
572
436
  * The stamp `/api/graph` would serve for this snapshot.
573
437
  *
574
- * Exported so the SSE liveness bridge broadcasts the **same** key the ETag
575
- * carries (§6). Two derivations of "the current stamp" would be two things to
576
- * keep in sync, and the failure mode is silent: frames that never match the
577
- * validator the client then sends. It shares {@link renderGraph}'s memo, so
578
- * asking for the stamp after the route has rendered the same snapshot — the
579
- * common case, since a change triggers both — costs nothing.
438
+ * Exported for tests and for the conditional client contract. It shares
439
+ * {@link renderGraph}'s memo, so asking for the stamp after the route has
440
+ * rendered the same snapshot costs nothing.
580
441
  */
581
442
  export function graphStamp(snapshot: WorkspaceSnapshot): string {
582
- // The memo stores the quoted ETag; the frame wants the bare digest.
443
+ // The memo stores the quoted ETag; the response exposes the bare digest.
583
444
  return renderGraph(snapshot).etag.slice(1, -1);
584
445
  }
585
446
 
@@ -598,7 +459,7 @@ function renderGraph(snapshot: WorkspaceSnapshot): { body: string; etag: string
598
459
  async function sendGraph(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
599
460
  // `snapshot()`, not `graph()`: the tag index has to be derived from the
600
461
  // same (already capped) note list the model was built from, or a tag could
601
- // name a slug this graph has no node for (§4.3).
462
+ // name a slug this graph has no node for (§4.2).
602
463
  const snapshot = await deps.cache.snapshot();
603
464
  const { body, etag } = renderGraph(snapshot);
604
465
 
@@ -657,66 +518,30 @@ function normalizeEtag(value: string): string {
657
518
  /**
658
519
  * Everything under `/api/note/`, in one place.
659
520
  *
660
- * Returns `false` for a method/shape this family does not serve, so the
661
- * caller falls through to its own `404` rather than this function owning a
662
- * second copy of the not-found response. That is also what keeps
663
- * `DELETE /api/graph` and `PUT /api/note/x` answering the same `404` as any
664
- * other unrouted request: the family claims a request or it does not.
665
- *
666
- * The `rest` after the slug is matched **exactly**, not by prefix. Slugs may
667
- * nest (`sessions/foo` — session memory lives in a vault subdirectory), so
668
- * the family has exactly one sub-resource path (`/rename`) and everything
669
- * else after a `/` belongs to the slug itself; anything else a caller
670
- * appends is a request for nothing and gets the `404` it deserves. Core's
671
- * `resolveNotePath` remains the traversal guard for whatever slug arrives.
521
+ * Returns `false` for methods this family does not serve, so the caller
522
+ * falls through to its own `404`. Core's `resolveNotePath` remains the
523
+ * traversal guard for whatever slug arrives.
672
524
  */
673
- const renameSuffix = "/rename";
674
-
675
525
  async function routeNote(
676
526
  deps: RouteDeps,
677
527
  method: string,
678
528
  target: string,
679
- req: IncomingMessage,
680
529
  res: ServerResponse,
681
530
  ): Promise<boolean> {
682
- // One sub-resource, matched at the end: a nested slug can itself contain
683
- // slashes, so the split is anchored at the end, not the first slash.
684
- const isRename = target.endsWith(renameSuffix);
685
- const slug = isRename ? target.slice(0, target.length - renameSuffix.length) : target;
686
- const rest = isRename ? renameSuffix : "";
687
-
688
- if (rest === "" && method === "GET") {
689
- await sendNote(deps, slug, res);
690
- return true;
691
- }
692
- if (rest === "" && method === "POST") {
693
- await saveNote(deps, slug, req, res);
694
- return true;
695
- }
696
- if (rest === "" && method === "DELETE") {
697
- await removeNote(deps, slug, res);
698
- return true;
699
- }
700
- if (rest === "/rename" && method === "POST") {
701
- await moveNote(deps, slug, req, res);
531
+ if (method === "GET") {
532
+ await sendNote(deps, target, res);
702
533
  return true;
703
534
  }
704
535
  return false;
705
536
  }
706
537
 
707
538
  async function sendNote(deps: RouteDeps, rawSlug: string, res: ServerResponse): Promise<void> {
708
- // Traversal is `resolveNotePath`'s job, inside `getNoteWithRevision`: an
539
+ // Traversal is `resolveNotePath`'s job, inside `getNote`: an
709
540
  // unsafe slug returns null before anything touches the disk. `%2e%2e%2f`
710
541
  // was already decoded by `parseTarget`, so what arrives here is the literal
711
542
  // `../` the guard is written to reject.
712
543
  //
713
- // `getNoteWithRevision` rather than `readNoteForView`, because the editor
714
- // cannot save safely without the revision it read at load, and fetching it
715
- // separately would leave a window in which the two disagree. It stats
716
- // before it reads, so a writer landing between the two calls yields a
717
- // revision *older* than the content — the save that follows is refused
718
- // rather than silently accepted (see core's own note on the ordering).
719
- const current = await readNote(deps, rawSlug);
544
+ const current = await getNote(deps.vaultRoot, rawSlug);
720
545
  if (current === null) {
721
546
  sendJson(res, 404, { error: "no such note" });
722
547
  return;
@@ -724,31 +549,9 @@ async function sendNote(deps: RouteDeps, rawSlug: string, res: ServerResponse):
724
549
  sendJson(res, 200, notePayload(current), { "cache-control": "no-store" });
725
550
  }
726
551
 
727
- /** {@link RouteDeps.readNote}, or core's. One resolution, used by both routes. */
728
- function readNote(deps: RouteDeps, slug: string): Promise<RevisionedNote | null> {
729
- const read = deps.readNote ?? ((s: string) => getNoteWithRevision(deps.vaultRoot, s));
730
- return read(slug);
731
- }
732
552
 
733
- /**
734
- * Core's `RevisionedNote` → the wire's {@link NotePayload}.
735
- *
736
- * The projection is `readNoteForView`'s, restated: a `Note` carries `body`,
737
- * the five managed fields **and** `frontMatter`, the verbatim block P5a added
738
- * so unknown keys survive a write. `frontMatter` must not cross the wire.
739
- * Not because it is secret, but because shipping it would invite a client to
740
- * send it back, and the moment a browser round-trips a user's raw metadata
741
- * through JSON the preservation guarantee stops being "the write path re-reads
742
- * the file" and becomes "the client remembered to return the block unedited".
743
- * The first is enforced by core; the second is a hope.
744
- *
745
- * So the field is dropped **explicitly**, by naming what is kept. A spread
746
- * with a `delete` would be exempt from the excess-property check and would
747
- * ship the block the day someone adds a sixth field — the same reasoning
748
- * `summarizeNote` gives for dropping it there.
749
- */
750
- function notePayload(current: RevisionedNote): NotePayload {
751
- const { note } = current;
553
+
554
+ function notePayload(note: Note): NotePayload {
752
555
  const view: ViewNote = {
753
556
  slug: note.slug,
754
557
  title: note.title,
@@ -758,185 +561,7 @@ function notePayload(current: RevisionedNote): NotePayload {
758
561
  tags: note.tags,
759
562
  source: note.source,
760
563
  };
761
- return { note: view, revision: current.revision };
762
- }
763
-
764
- /**
765
- * Answer a core {@link MutationResult}.
766
- *
767
- * The whole status mapping, in one function shared by both write routes, so
768
- * "a conflict is a 409" is a fact about this server rather than about
769
- * whichever handler was written most recently.
770
- *
771
- * A success re-reads the note through {@link getNoteWithRevision} rather than
772
- * returning the `Note` core handed back. Core's value is correct about
773
- * *content* and says nothing about *revision*, and the client's next save
774
- * needs a revision that matches the bytes now on disk — deriving one from the
775
- * write we just performed would mean re-implementing `revisionOf` out here,
776
- * against a stat this function does not have. The re-read costs one `stat`
777
- * plus one `readFile` on a file that is certainly in the page cache, and it
778
- * is the only way to hand back a revision that is true rather than inferred.
779
- *
780
- * The `null` branch is not dead code being defensive: between the write and
781
- * the re-read, a `weave_note` delete or an `rm` in another terminal can
782
- * genuinely remove the file. `404` is then the honest answer — the write did
783
- * happen, and the note is gone anyway.
784
- */
785
- async function sendMutation(deps: RouteDeps, result: MutationResult, res: ServerResponse): Promise<void> {
786
- if (!result.ok) {
787
- sendMutationFailure(result, res);
788
- return;
789
- }
790
- const written = await readNote(deps, result.note.slug);
791
- if (written === null) {
792
- sendJson(res, 404, { error: "no such note" });
793
- return;
794
- }
795
- sendJson(res, 200, notePayload(written), { "cache-control": "no-store" });
796
- }
797
-
798
- function sendMutationFailure(failure: Extract<MutationResult, { ok: false }>, res: ServerResponse): void {
799
- if (failure.reason === "missing") {
800
- sendJson(res, 404, { error: "no such note" });
801
- return;
802
- }
803
- const payload: ConflictPayload =
804
- failure.reason === "conflict"
805
- ? {
806
- error: "the note changed on disk since it was read",
807
- reason: "conflict",
808
- // The whole note, not just its revision (§11 P5.3). This is what
809
- // lets the client offer reload-or-overwrite without a second round
810
- // trip — and the second round trip is another window in which the
811
- // file moves again, which would make the prompt itself stale.
812
- current: notePayload(failure.current),
813
- }
814
- : { error: "a note with that slug already exists", reason: "collision", slug: failure.slug };
815
- sendJson(res, 409, payload, { "cache-control": "no-store" });
816
- }
817
-
818
- /**
819
- * Open the watcher's self-write window for a slug, if there is a watcher.
820
- *
821
- * Called **before** the mutation, never after: `fs.watch` can deliver an
822
- * event while the write syscall is still returning, and a window opened
823
- * afterwards is a window that opens second. Suppressing a path the write then
824
- * fails to touch costs nothing — the entry expires on its own.
825
- *
826
- * An unsafe slug resolves to `null` and is skipped rather than suppressed;
827
- * the mutation is about to refuse it anyway, and suppressing a path we could
828
- * not resolve would mean either fabricating one or passing `null` down to a
829
- * watcher that would `resolve()` it into the process's cwd.
830
- */
831
- function suppressSlug(deps: RouteDeps, slug: string): void {
832
- const path = resolveNotePath(deps.vaultRoot, slug);
833
- if (path !== null) deps.suppress?.(path);
834
- }
835
-
836
- async function saveNote(deps: RouteDeps, slug: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
837
- const body = await readJsonBody(req);
838
- const input = parseSaveRequest(body);
839
- if (input === null) {
840
- sendJson(res, 400, { error: "expected { body?: string, meta?: object, expectedRevision?: string }" });
841
- return;
842
- }
843
- suppressSlug(deps, slug);
844
- await sendMutation(deps, await updateNote(deps.vaultRoot, slug, input), res);
845
- }
846
-
847
- async function moveNote(deps: RouteDeps, slug: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
848
- const body = await readJsonBody(req);
849
- const target = typeof body === "object" && body !== null ? (body as Partial<RenameNoteRequest>).slug : undefined;
850
- if (typeof target !== "string" || target.length === 0) {
851
- sendJson(res, 400, { error: "expected { slug: string }" });
852
- return;
853
- }
854
- // Both ends: the file disappears from one path and appears at another, and
855
- // the watcher sees two events. Suppressing only the source would broadcast
856
- // the arrival, which is the same feedback loop with an extra step.
857
- //
858
- // `slugify` on the destination, because that is what `renameNote` will
859
- // apply before it touches the disk. Suppressing the *requested* string
860
- // would open the window over `notes/Alpha Renamed.md` while the write went
861
- // to `notes/alpha-renamed.md` — a suppression that is present, plausible
862
- // and useless, which is worse than an absent one.
863
- suppressSlug(deps, slug);
864
- suppressSlug(deps, slugify(target));
865
- await sendMutation(deps, await renameNote(deps.vaultRoot, slug, target), res);
866
- }
867
-
868
- async function removeNote(deps: RouteDeps, slug: string, res: ServerResponse): Promise<void> {
869
- suppressSlug(deps, slug);
870
- const result = await deleteNote(deps.vaultRoot, slug);
871
- if (!result.ok) {
872
- sendJson(res, 404, { error: "no such note" });
873
- return;
874
- }
875
- const payload: DeleteNoteResult = { deleted: true };
876
- sendJson(res, 200, payload, { "cache-control": "no-store" });
877
- }
878
-
879
- /**
880
- * Narrow a decoded request body to core's `UpdateNoteInput`, or `null`.
881
- *
882
- * An **allowlist**, field by field, and that is the point rather than
883
- * ceremony. `updateNote` spreads `input.meta` over the note's metadata, so
884
- * anything that reaches it reaches the front matter: passing the parsed body
885
- * straight through would let a local process `POST {"meta":{"created":"…"}}`
886
- * and rewrite a field the API deliberately does not expose, or
887
- * `{"meta":{"updated":"1970-…"}}` and make an edit look older than the state
888
- * it overwrote. Only `title`, `tags` and `source` are copied, and `source`
889
- * only when it is one of the three legal values — a note claiming
890
- * `source: "verified"` would render with a provenance badge nothing in the
891
- * palette matches.
892
- *
893
- * `null` for a body that is not an object, so the caller has exactly one
894
- * failure branch. An **empty** object is valid: a save with no fields bumps
895
- * `updated`, which is a meaningful (if unusual) request and not worth a
896
- * special case.
897
- */
898
- export function parseSaveRequest(value: unknown): SaveNoteRequest | null {
899
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
900
- const raw = value as Record<string, unknown>;
901
- const out: SaveNoteRequest = {};
902
-
903
- if (raw["body"] !== undefined) {
904
- if (typeof raw["body"] !== "string") return null;
905
- out.body = raw["body"];
906
- }
907
- if (raw["expectedRevision"] !== undefined) {
908
- if (typeof raw["expectedRevision"] !== "string") return null;
909
- out.expectedRevision = raw["expectedRevision"];
910
- }
911
- if (raw["meta"] !== undefined) {
912
- const meta = parseSaveMeta(raw["meta"]);
913
- if (meta === null) return null;
914
- out.meta = meta;
915
- }
916
- return out;
917
- }
918
-
919
- /** The three metadata fields a client may set. See {@link parseSaveRequest}. */
920
- function parseSaveMeta(value: unknown): NonNullable<SaveNoteRequest["meta"]> | null {
921
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
922
- const raw = value as Record<string, unknown>;
923
- const out: NonNullable<SaveNoteRequest["meta"]> = {};
924
-
925
- if (raw["title"] !== undefined) {
926
- if (typeof raw["title"] !== "string") return null;
927
- out.title = raw["title"];
928
- }
929
- if (raw["tags"] !== undefined) {
930
- const tags = raw["tags"];
931
- if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string")) return null;
932
- out.tags = tags as string[];
933
- }
934
- if (raw["source"] !== undefined) {
935
- const source = raw["source"];
936
- if (source !== "human" && source !== "agent" && source !== "generated") return null;
937
- out.source = source;
938
- }
939
- return out;
564
+ return { note: view };
940
565
  }
941
566
 
942
567
  async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
@@ -975,13 +600,3 @@ async function openNote(deps: RouteDeps, req: IncomingMessage, res: ServerRespon
975
600
  // in a network panel.
976
601
  sendJson(res, opened ? 200 : 404, payload, { "cache-control": "no-store" });
977
602
  }
978
-
979
- function attachSse(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): void {
980
- const hub = deps.sse;
981
- if (hub === undefined) {
982
- sendText(res, 503, "pi-weave: live updates unavailable\n");
983
- return;
984
- }
985
- hub.attach(req, res);
986
- deps.onActivity?.();
987
- }