Skip to content

Reach one Space through one handle - #132

Merged
cxxxxxn (cxxxxxn) merged 4 commits into
microsoft:mainfrom
ultmaster:feat/one-space-handle
Aug 27, 2026
Merged

Reach one Space through one handle#132
cxxxxxn (cxxxxxn) merged 4 commits into
microsoft:mainfrom
ultmaster:feat/one-space-handle

Conversation

@ultmaster

Copy link
Copy Markdown
Contributor

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):

  • The node read surface, completed. SpaceNodes had one read — by id — so any reader wanting more than one node had to go around the port to the legacy Disk store. It gains readMany() for a named selection, list() for work that genuinely spans the Space, and stream() 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 error worldId() 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 (null elsewhere) rather than hidden behind a parallel import or stubbed to throw. module-boundaries.test.ts holds its exact production consumer census, a list that may shrink and must not grow.

import-node-src left 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 asks fs-sandbox for its own root.

Stacking. This builds on #130 (feat/workspace-handles), whose branch does not exist on microsoft/Huabu, so GitHub cannot use it as a base. The diff below therefore also contains #130's seven commits — review from 94efba35 onward. Two PRs follow on top of this one.

pnpm run check passes at this branch's tip.

🤖 Generated with Claude Code

https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m

Yuge Zhang (ultmaster) and others added 3 commits August 27, 2026 10:25
…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
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
@cxxxxxn
cxxxxxn (cxxxxxn) merged commit 127b820 into microsoft:main Aug 27, 2026
2 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants