Reach one Space through one handle - #132
Merged
cxxxxxn (cxxxxxn) merged 4 commits intoAug 27, 2026
Merged
Conversation
This was referenced Aug 24, 2026
…strap `SpaceNodes` had one read — by id — so every reader wanting more than one node had to go around the port to the legacy Disk store. Add the three shapes the application actually asks for: `readMany` for a named selection, `list` for work that genuinely spans the Space, and `stream` for a reader that can show partial results while the rest arrives. `readMany` is the one that matters for cost. Most readers want a handful of named nodes — a selection to describe, a neighbourhood to render, one View to serve — and expressing those as a whole-Space scan makes an unrelated node somewhere else part of the bill. Disk resolves each id through the same strict read `read()` uses, so a selection sees exactly what reading each id would, including the index rebuild that finds an externally renamed sidecar. `SpaceRepository.ensureWorld()` is the backend-neutral bootstrap hook. Every backend meets an empty namespace the first time it is mounted, and a Workspace with no World has no Portal target, so ensuring one cannot stay a Disk step run before the store exists. It delegates to the same idempotent Disk primitive Workspace preparation calls — one writer for one file, since the legacy preparation path still runs before the mount. The node contract now asserts the four read shapes never disagree about a node: an adapter whose scan parsed more leniently than its single read, or minted a different revision, would pass a suite written against one shape alone. The Space-collection contract covers both bootstrap branches, which needs a harness that can open a namespace nobody has mounted yet. Carried from the earlier phase 4.6 line (470437a), which never reached a merged branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m
Reaching a Space meant calling two unrelated functions that never said they
addressed the same thing: `getStructuredStore().space(id)` for its record and
nodes, `canvasBlobs(id)` for its bytes, `spaceDirectory(id)` for its
directory. Three entry points, one subject.
`Storage.space(canvasId)` joins them, with a free `space()` shorthand on the
barrel. It is a composition-layer facade, not a port type: `StructuredStore`
and `BlobStore` keep their interfaces and their independence, and they are
joined in the layer that already owns every cross-store rule — the blob-put
precondition and the blob-first delete saga. The join cannot move down into a
port, because the two axes are configured independently, deletion ordering
deliberately keeps blob I/O outside any database transaction, and blob scopes
exist that have no Space at all.
The Disk directory becomes `diskTree`, typed by its absence rather than
hidden behind a parallel import or stubbed to throw. A caller branching on
`null` is told the truth once. It stays unportable and stays out of `ports/`:
a backend that keeps Spaces in tables has no tree, and promising one would
mean fabricating it. The fence is the Disk name plus the census in
`module-boundaries.test.ts`, which may shrink and must not grow — every entry
is a family §6.4.3 assigns a disposition.
Two things fall out. `import-node-src` asked storage where a Space was in
order to classify a path it had already resolved in sandbox coordinates; it
now asks `fs-sandbox` for its own root, which is not a storage question at
all. And `space()` composes from the receiver rather than a captured local,
so `{...storage, blobs: fake}` — the obvious way to stub one axis, and what
the artifact tests already do — gets Spaces built on the store it substituted
instead of silently keeping the original.
The four comments that named the retired `spaceDirectory()` now name the
member that replaced it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m
The gap between the portable contracts and a second structured adapter was one unnamed lump. §6.4 settles the two questions inside it — how the application reaches a Space, and what happens to the per-Space state that is still a file because it always was one — and §§12.6–12.8 build it as three change sets rather than one phase, so each lands and is reviewed on its own. §6.4.1 is the handle; §6.4.2 replaces the single "it is Disk-only" answer with four dispositions and says why A is the default and the cheap one; §6.4.3 assigns every current consumer; §6.4.4 makes the extension point a connection rather than a data API, which is what keeps `StructuredStore` from growing one member per feature. §12.6 records what is implemented: the completed node read surface, `ensureWorld()`, the `space(canvasId)` facade, and `diskTree`. §12.7 and §12.8 state what the two following change sets own, so the boundary between them is written down before either starts rather than after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m
Yuge Zhang (ultmaster)
force-pushed
the
feat/one-space-handle
branch
from
August 27, 2026 02:27
66ad6c7 to
0771529
Compare
Two members of the Space handle disagreed with the rest of it. `diskTree.directory()` resolved against whichever Workspace was active when it was called, so a handle retained across an activation handed back the *new* Workspace's directory while its structured and blob members correctly rejected as inactive — and that member is where the file tools' sandbox root and bundle export get real paths. It binds the Workspace at resolution now, exactly as `DiskSpaceNodes` does, so a retained tree refuses instead of following. That is what its own documentation already claimed. `list()` and `stream()` reached the legacy scan under compatibility semantics, where every read failure becomes absence. A node whose sidecar had been replaced by an unreadable directory raised EISDIR through `read()` and reported an empty collection through both scans: the contract asserting the four read shapes never disagree only compared them where every sidecar was readable. Making the scans strict needed the flag split first. `strict` conflated reachability with content, and rejecting malformed frontmatter is the one thing the portable read deliberately does not do — a sidecar is a hand-editable file, and a node whose YAML a user broke must stay repairable and deletable rather than becoming a 500 on every route that touches it. `NodeScanOptions` separates the axes: the portable repository asks for strict reachability and lenient records, which is `readNodeStrict`'s rule, while the World reference resolver and the Space preview keep both, because malformed content is an integrity failure to them. A strict `streamAllNodes` also had to stop delivering once it failed. Throwing from inside a worker rejects while its siblings are still calling `onNode`, pushing nodes at a consumer that has already unwound; the first failure is recorded and raised after the fan-out settles instead. The `diskTree` census matched `.diskTree`, so destructuring the member or reaching it by subscript left no trace in a list whose entire purpose is that it may shrink and must not grow. It matches the bare word now. Two claims in §12.6.2 no longer described the branch. The `getStructuredStore().space(id)` call sites did not migrate — §12.7 owns that population, and rewriting each site twice buys nothing — and "Workspace-bound" was aspirational until this change made it literal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gnXPPgTAqBY6vaPg2w4mE
Yuge Zhang (ultmaster)
added a commit
to ultmaster/Huabu
that referenced
this pull request
Aug 27, 2026
The unit suites beside each reader prove it calls the port. None of them shows that a request still answers the same way now that every read goes through `space(canvasId)`, so this adds one suite that drives a real temp Workspace, the Disk adapters, and the Canvas routes with nothing mocked — 34 scenarios over the four node-read shapes, the Space GET, the preview projection, duplicate Space directories, World references, search, bundle export/import, the per-node content routes, executor batches, and Workspace switching. Both behaviours this branch moved on purpose are pinned there: a duplicated Space directory fails loudly naming both, and a hand-damaged sidecar renders instead of 422'ing a preview. Writing it surfaced two things. **The bundle record filename could be `undefined`.** `space-import.ts` captured `[SPACE_JSON_FILENAME, 'canvas.json']` at module scope, and `layout.ts` → `workspace.ts` → the storage barrel → the composition root → `space-import.ts` is an import cycle. Entered through a Disk module, this file evaluates while `layout.ts` is still initializing and the array freezes that constant to `undefined` for the life of the process — every Space bundle import then answers 500 with nothing in the message to explain it. The production entry point's own order happens to be safe, which is why nothing caught it and why no static import in a test can express the failing case; the regression test resets the module registry and re-enters the cycle deliberately. Assembling the list per call is the fix. **`SpaceNodes.list()` documented the opposite of what it does.** The port said a record the scan cannot retrieve is omitted; microsoft#132 made the scan strict about reachability and the adapter has said so since, and the adapter is what runs. The rule the two collection shapes actually keep — strict about retrievability, lenient about content — is now stated once on the interface and both shapes are held to it by test, because a caller that could pick the scan which hides an environmental failure cannot tell a Space that lost a node from one it merely cannot read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFDPiFypeiho39r8Q7Z9uC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reaching a Space meant calling two unrelated functions that never said they addressed the same thing:
getStructuredStore().space(id)for its record and nodes,canvasBlobs(id)for its bytes,spaceDirectory(id)for its directory. Three entry points, one subject.This is the first of three change sets that close the application-side gap between the portable storage contracts and a second structured adapter. The direction is specified in
docs/proposals/multi-backend-storage.md§6.4; this PR is §12.6.Its own commits (
d72640d5,8e871726,66ad6c7a):SpaceNodeshad one read — by id — so any reader wanting more than one node had to go around the port to the legacy Disk store. It gainsreadMany()for a named selection,list()for work that genuinely spans the Space, andstream()for a reader that can show partial results. All four reads return the same records and the same opaque revisions, and the node contract now asserts they never disagree about a node.SpaceRepository.ensureWorld(). The backend-neutral World bootstrap. Every backend meets an empty namespace the first time it is opened, so ensuring a World cannot stay a Disk step run before the store exists. Idempotent, and deliberately narrower than "create if absent": an established malformed World stays the integrity errorworldId()reports.Storage.space(canvasId). A composition-layer facade, not a port type. The two ports keep their independence and are joined only where the cross-store rules already live — the blob-put precondition and the blob-first delete saga.diskTree. The Disk Space directory hangs off the same handle, typed by its absence (nullelsewhere) rather than hidden behind a parallel import or stubbed to throw.module-boundaries.test.tsholds its exact production consumer census, a list that may shrink and must not grow.import-node-srcleft that census immediately: it asked storage where a Space was in order to classify a path it had already resolved in sandbox coordinates, and now asksfs-sandboxfor its own root.Stacking. This builds on #130 (
feat/workspace-handles), whose branch does not exist onmicrosoft/Huabu, so GitHub cannot use it as a base. The diff below therefore also contains #130's seven commits — review from94efba35onward. Two PRs follow on top of this one.pnpm run checkpasses at this branch's tip.🤖 Generated with Claude Code
https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m