Repository navigation
refactor(storage): route Space writes through the Phase 4 portable seam - #83
Conversation
|
This PR is a rewrite of #77 |
Corrects overdesign found reviewing the phase 4 write seam, before the contract acquires more callers. Behavior, wire shapes, and the web client are unchanged. Remove three things with no production caller: - `SpaceRepository.compareAndSwap`. A record-only `OrderedSpaceWriter.apply` is the identical operation — same version precondition, same identity-field refusal, same result type, which the `SpaceMutationResult = SpaceWriteResult` alias had been admitting. The per-Space record port is now read-only `SpaceRecordRepository`, matching its `record` field. Its contract suite is retired; the single-winner race and next-record validation cases move to the ordered-writer suite, where the application actually relies on them. - `NodeRepository.readMany`. Its only implementation read and parsed every node in the Space regardless of how many ids were asked for, so the first real caller would have inherited a full-Space scan per batch. - `SpaceDeleteResult`. No repository returned it; it was composition's own result, hand-written as the union of the two port results it is built from. It becomes `SpaceDeleteOutcome` in `storage.ts`, derived rather than restated. Merge `catalog()` and `lifecycle()` into one namespace-scoped `spaces()`. They were the same kind of object — stateless, one fresh Workspace-bound instance per call — split along a read/write line that tracked the migration phases rather than the domain. The split forced the World rule to be resolved in two places and left `create` reading membership it could not name through the port. Per-Space `space(id)` is untouched: that boundary is real, since `create` cannot be scoped to a Space that does not exist. Narrow `StructuredBackendKind` to `'disk'` so an adapter's self-reported kind names only kinds that exist. The wider requestable vocabulary moves to `profile.ts` as `RequestedStructuredKind`, which is what preserves the actionable "not implemented yet" error for a configured sqlite or postgres. `authoritativeInsert` and the `write-suppressed` put outcome stay, now documented as adapter-shaped the way `duplicate-node` already was. Both exist for Disk's in-memory deletion fence and have no portable meaning a SQL adapter would produce; naming the shared abstraction needs a second adapter. Split the phase-labelled Disk test files into per-adapter suites beside the adapters they cover, so no working-process phase number persists in a filename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The structured port had members whose nouns were verbs and verbs that meant different things in different parts. This settles both, without a behavior change: every edit is a rename, a regrouping, or a member becoming a method. - The Space record flattens into the handle. `handle.record.read()` beside `handle.write(...)` put reading and writing one record at two levels; a Space *is* its versioned record, so the pair belongs on the Space. `SpaceRecordRepository` is gone, and the ordered writer that landed as `OrderedSpaceWriter.apply` is now `SpaceHandle.write`. - `history` groups events and intents — the past, and only the past. Change-review records drain on a decision and Runs carry live status, so they stay flat; `.history/` is a Disk catch-all, not a concept the backend-neutral contract should inherit. - One verb, one meaning: `changes.remove` -> `delete`, `intents.upsert` -> `put`, `tasks.insertTask` -> `tasks.create`. `create` and `put` stay distinct because they differ on a duplicate. - Runs nest in the Task ledger as `tasks.runs.create/update`. `read()` stays at the ledger: one file, one snapshot, and the Run-to-Task invariant keeps one owner. - Per-part interfaces are named for the part — `SpaceNodes`, `SpaceChanges`, `SpaceEvents`, `SpaceIntents`, `SpaceTasks`, `SpaceTaskRuns`, `SpaceHistory` — and the Disk adapters, their tests, and the contract suites follow the types they implement. Docs: §7.2 now carries the current surface and the verb rule, §12.4.2 records the reasoning, and §12.2.4 notes the Phase-2 spelling it kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routing the writers through the portable ports changed four things underneath them that the port shape did not require. Every executor batch rebuilt the node index, which reads and parses every sidecar in the Space: O(nodes) synchronous I/O inside the canvas mutex, on the hottest write path there is. A one-node mutation measured 0.63ms -> 5.34ms at 200 nodes and 1.55ms -> 19.70ms at 1000. writeNode's own probes already establish ownership per mutation at readdir cost. readNodeStrict parsed frontmatter strictly, so a node whose YAML a user broke in an external editor answered 500 on the content PUT and on DELETE while the lenient GET kept rendering it. It stays strict about reachability -- only ENOENT is absence -- and recovers malformed content the way readNode does. deleteNode refused a duplicated id, stranding the node in the state a user resolves by deleting and failing the enclosing executor batch wholesale. put must still refuse it; delete has no such ambiguity. beginDelete and rename resolved World through requireWorldCanvasId, so damage to World/ turned every ordinary delete and rename into a 500. They only ask whether this Space is the protected one. worldId() still raises. Three pieces of surface go with them, on the rule 12.4.1 applied. ownershipValidated guarded a branch both production callers bypassed. updateNode's retry loop had an unreachable continue and fabricated a rev-conflict that preprocessing turns into a thrown PERSIST_FAILED for a caller passing no expectRev. DiskDeltaLog had no production caller: the journal row is appended inside the ordered Space write, so its ordering guard never ran on the only path that writes the file, and wiring it in would have put a full log read on every batch. isDedupeVariant looked unreachable on the reasoning that a null-titled Space is filed under its unique canvasId, but allocation de-duplicates case-insensitively across the namespace, so a canvasId colliding with another Space's title is allocated "<canvasId> (2)". It stays, with the argument written down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream removed intent and sketch gesture recognition (fbafde3), which takes intent episodes out of the product. The structured port loses SpaceIntents, the Disk log adapter loses its intent half, and intent.route/intent.service/intent-store are deleted with upstream. That leaves SpaceHistory holding one member. The group existed to hold events beside intent episodes -- two kinds of past record under one noun -- so with intents gone it is a member whose only job is to hold one other member. Events move back onto the handle as `handle.events`. What 12.4.2 said stays flat is unchanged: change-review records and Tasks are not history, whatever Disk's .history/ directory happens to contain. Phase 4 kept its shape everywhere else. Conflicts resolved toward the phase-4 spelling (handle.read(), tasks.runs, changes.delete) with the intent surface dropped from each. external-watcher.real.test.ts, new from upstream, moves off the createCanvas export phase 4 removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of
|
| Stage | Result |
|---|---|
| Automated | 186 storage tests, 123 changed-caller tests, full server suite with 943 passed / 2 todo, and server typecheck passed. PR CI was green. |
| Curl/API | Verified Unicode writes, malformed-YAML recovery, duplicate sidecars, label conflicts, empty-body protection, structural/node CAS, ordered event batches, path sanitization, tombstones, and delete fencing with no resurrection. One hundred concurrent title races each produced one 200 and one 409. |
| Historical compatibility | Exact merge-base 973c22ce wrote structural state, Markdown, events, tasks, and runs; HEAD loaded and updated them. Released v0.2.2 wrote genuine canvas.json, Unicode Markdown, and events; HEAD migrated, loaded, and updated them successfully. |
| Chrome DevTools | Created and saved a note through the UI, hard-reloaded it, and confirmed Chinese, Greek, and emoji content persisted in both canvas and detail views. Reload requests were all 200 with no console warnings or errors. |
Not verified locally: crash/power-loss recovery, multi-process or distributed behavior, non-Disk backends, unknown remote outcomes, external directory rename behavior, or additional viewport sizes. Full monorepo lint/build was not rerun locally; green PR CI covered those checks.
Adversarial review performed by OpenAI Codex.
Summary
Why
Phase 3 established the read-side seams. The earlier Phase 4 prototype also introduced process-crash recovery and a new HTTP/SSE/web reconciliation protocol, which made it much larger than the backend abstraction it was supposed to deliver.
This revision fixes only the minimum portable write contract needed before a SQL backend can be written against it.
Port shape
StructuredStorevends two things:spaces()for the Space collection in one backend namespace, andspace(id)for one Space. ASpaceHandleis the versioned record —read()andwrite()sit directly on it — and its other members name the durable parts the Space holds:nodes,changes,tasks,events.Verbs mean one thing throughout:
read,list,create(rejects a duplicate),put(replaces one),append,update,delete, andwritefor the Space's own ordered multi-part mutation. Two review passes trimmed this surface before it acquired more callers; §12.4.1–2 record what came out and why.Behavioral notes
beginDeletesession that fences repository and composed blob mutations across the blob-first cleanup.Disk-behavior corrections (§12.4.3)
A review of the branch asked what routing the writers through the port changed underneath it. Four things had changed that the port never required, and all four are reverted:
writeNode's own probes already establish ownership atreaddircostreadNoderecovers it. The read stays strict about reachability: only ENOENT is absencedeleteNodethrew, stranding the node in the state a user resolves by deleting, and failing the whole enclosing executor batchbeginDelete/renameresolved World throughrequireWorldCanvasId(), so damage toWorld/turned every ordinary delete and rename into a 500worldId()still raisesThree pieces of surface went with them, on the rule §12.4.1 applied —
ownershipValidated(both production callers bypassed the branch it guarded),updateNode's retry loop (unreachablecontinue, and exhausting it fabricated arev-conflictthat preprocessing turns into a thrownPERSIST_FAILED), andDiskDeltaLog(no production caller; the journal row is appended inside the ordered write, so its guard never ran on the only path that writes the file).Merged
main(§12.4.4)mainbrought infeat!: remove intent and sketch gesture recognition, which deletes intent episodes from the product.SpaceIntentsand the Disk intent half go with them.That left
SpaceHistoryholding one member. The group was justified by two kinds of past record under one noun; with intents gone it is a member whose only job is to hold one other member, so events move back onto the handle ashandle.events. What §12.4.2 said stays flat is unchanged — change-review records and Tasks are not history, whatever Disk's.history/directory happens to contain.Validation
pnpm run checkpasses end to end: lint (0 errors), format:check, typecheck, every workspace's tests, i18n parity, agent-team skills, and license headers.apps/serveris 110 files / 943 passed / 2 todo.The executor-batch numbers above were measured directly, by timing 20 one-node ordered writes against a seeded Space with and without the rescan.