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.
- package/README.md +148 -132
- package/package.json +1 -2
- package/src/core/cache/workspace.ts +26 -9
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +12 -8
- package/src/core/graph/current.ts +4 -6
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/slug.ts +13 -0
- package/src/core/types.ts +1 -0
- package/src/core/vault.ts +45 -467
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +15 -3
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -176
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +77 -167
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +37 -60
- package/src/web/client/note/note.model.ts +23 -0
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -81
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -123
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -121
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +24 -97
- package/src/web/client/tree/tree.model.ts +8 -56
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -415
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -178
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
package/src/web/server/routes.ts
CHANGED
|
@@ -1,56 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Route handling for the workspace server
|
|
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}
|
|
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
|
|
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 `
|
|
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 {
|
|
91
|
-
import {
|
|
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
|
|
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 (
|
|
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
|
-
*
|
|
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
|
|
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),
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|
|
575
|
-
*
|
|
576
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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
|
-
|
|
683
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|