feat(storage): make SQLite a profile the app can run on - #92
feat(storage): make SQLite a profile the app can run on#92Yuge Zhang (ultmaster) wants to merge 14 commits into
Conversation
fc1da3d to
2618a93
Compare
883d0ae to
2bc7a6b
Compare
`values.ts` held three unrelated concerns behind a name that described none of them: JSON codecs, `spaces` row statements, and collision-key allocation. Split it where a dependency boundary already ran. `rows.ts` owns everything that touches a stored column or `DatabaseSync`. `identity.ts` owns the pure title and label allocation rules, and imports no SQLite at all — which is what makes the cut a boundary rather than a preference. Several consumers now depend on less: `space-logs`, `space-tasks` and `structured-store` need only `rows.ts`, and `space-nodes` takes a single symbol from `identity.ts` in place of a mixed five-symbol block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2bc7a6b to
5569913
Compare
Phase 5 shipped an isolated SQLite adapter that proved the ports survive a database. It could not be selected, so nothing proved the harder claim behind it: that a deployment can run with no Workspace folder and no Space directories at all. `HUABU_STRUCTURED_BACKEND=sqlite HUABU_BLOB_BACKEND=sqlite` is now a real profile. Everything durable — Workspaces, Spaces, nodes, events, changes, Tasks, blob bytes, extension namespaces, and agent conversations — lives in one file under `<data dir>/storage/sqlite/`. What that took, and what each piece is: - A Workspace becomes a row. `spaces` carries a `workspace_id`, one connection serves every Workspace, and activation re-points the namespace rather than reopening anything — the "backend selection scope" decision the proposal settled but nothing had implemented. Handles bind the Workspace they were resolved in and refuse afterwards, as the Disk adapters already do with a retained path. `remove()` is a forget, not a delete: Disk honours the port's wording for free because the folder outlives the registry entry, and a database has to say so with a column. - A SQLite `BlobStore`, so bytes stop needing a folder. `put` buffers and then replaces one row, which gets the contract's replacement atomicity for free; `materialize()` spools to the OS temp directory and unlinks on release, which is the behaviour `BlobLease`'s post-release rule exists to keep honest. `blobs` deliberately has no foreign key to `spaces` — deletion order is the composition saga's, and that saga must also sweep orphans for a record that is already gone. - The Server stops assuming a Workspace is a place. `getWorkspaceKey()` is the identity leases and admission gates actually needed; `getWorkspacePath()` still refuses, for the callers that genuinely want a directory. World identity moves onto the composition root, so `isWorldCanvasId` answers for whichever backend is configured instead of scanning a directory index, and the World portal rules take the live Space ids as an argument rather than reading them off disk. - An agent conversation follows its Space. Agenetes takes its three storage ports at mount, so the mounted stores dispatch per namespace: a namespace with a `storage.root` keeps the file stores that wrote what is already there, a Space in SQLite gets tables that cascade from its extension namespace, and an unnamed namespace stays in memory as Agenetes intends. Because those ports are synchronous and `extension()` is not, the composition root also exposes `sqliteTree` — the synchronous form of the same resolution, named for the backend that has it and `null` elsewhere, with its own single-consumer census beside `diskTree`'s. - Four more capability rows, because the honest answer to a filesystem-shaped feature is still absence: Workspace folder selection, RFS's file plane, the Workspace memory document, and user-authored skills. Each refuses at its own call site in the words the startup log used. The `builtin-file-tools` rationale is corrected — it claimed RFS as the fallback, and RFS turns out to need the same directory. Two adapter defects found while testing the preview against Disk: - SQLite rejected an `undefined` own property that Disk drops, because Disk persists through `JSON.stringify`. A record writable on one backend and not the other is §13's silent-divergence risk in its most ordinary form — an optional field spread onto a node. The encoder now follows `JSON.stringify`; cycles and non-finite numbers still reject. - `stream()` materialized the whole collection before delivering the first node, which is the opposite of the latency shape the port describes, and an aborted scan had already paid for every row. It reads off a cursor now. `readMany` went from one statement per id to one per chunk. `PRODUCT_STORAGE_PROFILES` gains `sqlite/sqlite`, so the whole product suite runs against it unchanged — that was the point of writing it that way — plus a new case that everything is still there after a restart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gm37mhSirkohJJcnLR4SWs
… not The proposal's §12.9 described a preview that could not be selected, and the architecture doc had one Disk layout. Both now describe a second profile an operator can actually choose, which means the interesting half is the list of things it does not do. §12.9 is rewritten around that: the file it keeps everything in, the schema and the two decisions inside it that are not obvious (blobs carry no foreign key to `spaces`; encoding follows `JSON.stringify` because Disk does), the one synchronous exception the extension substrate needed and why it has a one-consumer census, and a table of every capability the profile gives up with the reason it is not emulated. Two further limits are named without being capability rows, because nothing refuses them: blob bytes are read and written whole, and one process holds one connection. The architecture doc gains the SQLite layout beside the Disk one — the tables, what an operator needs to know about the shared connection and the row-shaped Workspace, and the same list of what is unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gm37mhSirkohJJcnLR4SWs
|
Pushed What moved. Phase 5 used to end at an isolated adapter that proved the ports and could not be selected. It is now a profile the application runs on: That required a Workspace to become a row, a SQLite Two adapter defects, both found by running the preview against Disk and comparing:
What the profile does not do is the half worth reviewing: nine capability rows in Verification. Known gap, deliberate. Creating additional Workspaces by name has no UI on SQLite — the client sees 🤖 Generated with Claude Code |
…he records A structured backend was allowed to hold bytes: `HUABU_BLOB_BACKEND=sqlite` put every artifact, upload, guide document and memory body in a `blobs` table, and a cross-axis rule then required `structured=sqlite` to go with it. That is the wrong shape. Bytes are files — a local directory now, an object store later — and a deployment should be able to pair SQL records with ordinary files without either axis knowing about the other. So the SQLite blob adapter and its table are gone, `BlobBackendKind` is `'disk'`, `RequestedBlobKind` is `'disk' | 'azure'`, and the cross-axis rule has nothing left to enforce. `sqlite` records beside `disk` bytes is now an ordinary profile rather than a rejected one, and it is what `PRODUCT_STORAGE_PROFILES` runs the §12.8 suite against. That needs the Disk blob adapter to work where a Space has no folder. It takes its Space root as an argument instead of resolving the Disk record layout: with Disk records the root is `canvasRoot()` and every existing Workspace is addressed byte-for-byte as before; without them, composition supplies `<data dir>/storage/blobs/<workspaceId>/<canvasId>/` (`HUABU_BLOB_ROOT`) and the adapter writes the same area layout underneath it. Scope binding moves from the workspace *path* to `getWorkspaceKey()`, which both kinds of Workspace can answer. Because no structured delete will ever remove that directory, the delete saga removes it after sweeping the areas. Multiple Workspaces on SQLite were half-supported: the schema, the repository and activation all handled them, but `POST /api/workspaces` refused, because creating a Workspace meant adopting a folder. It now creates one from a name where there is no folder to adopt — `createNamedWorkspace` on the composition root, the counterpart to `adoptWorkspaceDirectory` — so a deployment can hold more than the one the Server opens for itself. `workspaceCreateSchema` makes `path` optional to say so; the Server decides which form its backend requires. Also drops the now-dead `SELECTABLE_STRUCTURED` gate (identical to `AVAILABLE_STRUCTURED` since SQLite became selectable), `LAZY_SAFE_BLOBS` (a file system has no connection to open), and two layout resolvers with no callers left. Verified against a running Server on `structured=sqlite blobs=disk`: create a Space, upload and fetch an artifact, create and activate a second Workspace by name, confirm the two byte roots are separate, and delete a Space and see its directory go with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
…ta dir
The Space byte root landed at `<data dir>/storage/blobs/`, which names a
concept where its neighbour `storage/sqlite/` names an implementation. It is
the Disk blob adapter's directory, so it belongs under `storage/disk/` with
the rest of that backend's state.
That puts two adapters in one directory — the structured store's
`workspaces.json` Workspace registry and the blob store's Space byte roots —
so they get separate subtrees and one file decides both:
storage/disk/
workspaces.json Disk structured store
blobs/<workspaceId>/… Disk blob store (HUABU_BLOB_ROOT moves this alone)
Keeping the registry out of `blobs/` is not tidiness. The blob store deletes
whole directories — an area on `deleteAll()`, a Space's root when its record
goes — and the registry is not its to delete. `backends/disk/data-dir.ts` is
the one place that can make that true, and `detached-blobs.test.ts` asserts it
as the path fact it is: the registry is inside the Disk area, outside the blob
root, and unmoved by `HUABU_BLOB_ROOT`.
Applied to the other backend too, so the rule is uniform rather than a
one-off: `sqliteDatabasePath` moves from the composition root into
`backends/sqlite/database.ts`, and `workspaceRegistryPath` from the Disk
workspace repository into its new neighbour. `storage.ts` now builds no
backend path at all — it supplies the active Workspace id and asks. A
module-boundary census pins that: only those two files may name
`'storage', 'disk'` or `'storage', 'sqlite'`.
Also drops `sqliteDatabasePath` from the storage barrel, which nothing outside
the module imported.
Verified on a running Server at `structured=sqlite blobs=disk`: bytes land in
`storage/disk/blobs/<workspaceId>/<canvasId>/.artifacts/`, and deleting the
Space removes that tree while the database is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
`blobs=disk` produced two different directories depending on the *structured* backend — inside the user's Space folder with Disk records, under `storage/disk/blobs/` with rows — and nothing said why that is one rule rather than two meanings for one config value. It is one rule: **a Space's bytes live with the Space.** The Space has two possible homes, so the rule has two outcomes. `blobs=disk` names a medium — bytes are local files — and the place is composition's to choose, which is the layer allowed to know both axes. The code now says so where the choice is made. `buildBlobStore` passes the root explicitly in both branches instead of leaning on a default, and `DiskBlobStore`'s root argument is required: a default there is the cross-axis decision made silently by whichever caller forgot to pass one. Why the Disk-records outcome is not merely legacy compatibility, recorded because it is easy to "fix" and break four things: `space-bundle-export` is the Space folder archived, `space-bundle-import` unzips into it, `reveal-space-folder` shows it, and `builtin-file-tools` sandbox on it. Each needs that folder to be complete. Relocating artifacts to a Server-owned root would hollow out all four while every one still reported as available. That is also a real cross-axis constraint waiting to bite. A blob backend that cannot co-locate — an object store — would put bytes outside the Space folder even on Disk records, and those four rows would then have to be keyed on the profile rather than on the structured kind. `capabilities.ts` says that where its matrix is defined, in place of the comment claiming no such second matrix could exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
…ut loud The hybrid profile gives a Space a real directory full of real files, so the matrix now has to answer a question it did not have before: does a file system for the bytes hand any Disk-only feature back? It does not, and the mechanism is already right — every refusal keys on `Space.diskTree` being `null`, which is a structured-backend fact, and stays `null` however many byte directories exist. Each of these features needs the Space's *record and node documents* to be files, and those are rows. The blob axis carries opaque bytes, which is not what any of them are about. What was wrong is what the matrix said. Five rationales argued from the absence of a directory — "Without a folder there is nothing to show", "No directory, no problem" — and those sentences are printed at startup and reused verbatim in the refusal a user sees. On this profile they are simply false. They now argue from what is actually missing: - reveal-space-folder: what a user means by "this" is the Space, and its byte directory is not the Space. - builtin-file-tools / space-file-plane: the documents at stake are the node sidecars under `nodes/`; the byte areas hold artifacts, not those. - external-note-discovery: `nodes/` is the tier a dropped note would arrive in, and no byte area is somewhere a user would drop one. - workspace-user-memory: the blob port has no Workspace-level scope, so the document has none to live in — not "there is nowhere on disk". - space-directory-handle-coordination: nothing renames a directory keyed by id, and nothing watches it, so there are no handles to arbitrate. - workspace-directory: the per-Workspace directory under the blob root is Server-owned byte storage, not a Workspace anyone could pick. `detached-blobs.test.ts` pins the fact all ten gates rest on: bytes are on the file system and `diskTree` is still `null`. `capabilities.test.ts` already ran against `sqlite/disk`, so it was answering this question; it now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
… on it A capability was keyed on the structured backend alone. That was defensible while the blob axis was one co-locating file system, and it stops being defensible the moment a second blob backend exists: `disk` records with an object store would keep every "needs a Space directory" row available, and `space-bundle-export` would archive a Space folder its artifacts had never been written to — reporting success while producing an incomplete bundle. `StorageCapability` now names both axes, and **omitting an axis means every backend on it serves the feature**. That default is the design, not a shortcut: a row that does not touch a Space's bytes must not need editing when a blob backend lands, and the rows that do are exactly the ones that should force a decision then. Four rows name the blob axis — bundle export, bundle import, the built-in file tools, and RFS — because each needs the bytes in the folder it archives, unzips into, or resolves a path inside. `reveal-space-folder` does not: the folder still holds the record and the node documents, so showing it is still showing the Space. Defects this found and fixes: - **Gates re-derived the requirement.** Bundle export, import, reveal, the file tools, and the external-note claim all inferred "is this available" from `diskTree` being non-null. That is a second copy of the rule, and it is how a row could grow a blob-axis requirement its own call site never learned about. Every refusal now asks `storageServes(id)`, and `diskTree` is left to supply the path it was always for. - **RFS asked the wrong profile.** It called `hasStorageCapability(parseStorageProfile(), …)`, reading the environment rather than the profile storage was opened with — a different answer as soon as a test or an embedder mounts an explicit one. `storageServes` binds the active profile, and `hasStorageCapability` leaves the barrel so no call site can pick a profile again. - **One row could never be refused.** `space-directory-handle-coordination` had no consumer anywhere: a Space with no directory registers no handle owner, so nothing ever asks. It was printed at boot as something the operator had lost, when the profile simply does not have the problem. Removed, and recorded as a backend property instead. `module-boundaries.test.ts` now fails if any declared row has no refusal outside `storage/`. - **The startup line named one axis.** It read "unavailable on the 'sqlite' structured backend" for a row that may be unavailable for either. It names the profile now. `capabilities.test.ts` asserts the pairing that motivates all of this — `disk`/`azure`, which no adapter serves and `validateStorageProfile` refuses, which is exactly why the matrix has to answer it correctly first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
`structured: ['disk']` beside `blobs: ['disk']` does not say how the two
combine. Read one way it is "these backends serve it"; read another it is a
set of supported profiles. The answer was `and` across axes and `or` within
one, and nothing in the shape said so — the evaluator was the only place to
find out.
The two lists become one clause each of a `StorageRequirement`:
requires: { structured: ['disk'], blobs: ['disk'] }
which reads as the conjunction it is. Every clause present must hold; any
listed backend satisfies its own clause; an absent clause requires nothing, so
every backend on that axis passes. The type doc gives all three rules and the
`{ structured: ['disk', 'postgres'] }` shape a second structured backend would
take, so the `or` is not something a reader has to infer from a single-element
list.
`capabilities.test.ts` asserts the semantics rather than describing them: the
hybrid profile meets `space-bundle-export`'s blob clause and fails its
structured one, `disk`/`azure` is the mirror image, and both are refused —
half a requirement is not a requirement met. `reveal-space-folder` names no
blob clause and survives `disk`/`azure`, which is the absent-clause rule doing
its job.
No behaviour change; the evaluator computed this already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
…omment Two defects reading the reveal gate. The comment above it had picked up a sentence about bundles needing their bytes, from the templated edit that moved these gates onto the matrix. Reveal has no blob clause, so the comment argued for a requirement the row does not have. The line under it still said "a backend without a folder has nothing to show", which stopped being true when the hybrid profile gave a Space a byte directory. The capability's summary was also wrong, and it is the sentence an operator reads at boot: it claimed "Reveal a Space in the OS file manager" while the route opens `<space>/nodes/`. That distinction is the whole reason the row has no blob clause — the folder is the node *documents*, so where the bytes went does not enter into it. Both now say what the feature is: open the folder of node documents so a user can settle a duplicate-markdown collision by hand. Off Disk a node is a row, so there is no folder of documents to open and the collision cannot arise — label uniqueness is a constraint rather than a filename. The byte areas are files, but hidden, Server-owned, and full of artifacts rather than documents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw
Pushed a revision: blobs are always a file system, and the capability matrix is keyed on the profileSeven commits ( 1. There is no SQLite blob store any more
2. Where the bytes go, and who decidesThe Disk blob adapter takes its Space root as a (required) argument. Composition chooses it, under one rule — a Space's bytes live with the Space — and a Space has two possible homes: On Disk records the Space is a folder, so bytes stay inside it — unchanged for every existing Workspace, and load-bearing: Each directory under 3. Multiple Workspaces on SQLite were half-builtThe schema, repository and activation all handled many, but Note The web client's Workspace UI is still folder-shaped, so on this profile it hides the picker and offers no way to add a Workspace. Multi-Workspace support is complete at the storage and HTTP layers; surfacing it is a separate UI change. 4. The capability matrix is keyed on the profile, and gated onPreviously keyed on the structured backend alone. That stops being defensible the moment a second blob backend exists: Each row now states a requirement with one clause per axis. Every clause present must hold (
Defects this review turned up, all fixed:
Proof
Still open, deliberatelyThe Agenetes conversation stores ( 🤖 Generated with Claude Code |
Note
Scope grew past the original preview. Phase 5 no longer stops at "an isolated adapter that proves the ports"; SQLite is now a profile the application can actually run on, with no Workspace folder and no Space directories.
What this is
HUABU_STRUCTURED_BACKEND=sqliteis a selectable profile. Every record is a row in one database file; a Space's bytes are ordinary files, because the blob axis is always a file system:Each directory under
storage/is named for the backend that owns it, and one file per backend decides its layout (backends/disk/data-dir.ts,backends/sqlite/database.ts); the composition root asks and builds no path of its own, which a module-boundary census pins.storage/disk/has two owners, so they get separate subtrees — the registry is never insideblobs/, because the blob store deletes whole directories and the registry is not its to delete.That split is the one design correction this branch makes against its own first draft: an earlier revision had
HUABU_BLOB_BACKEND=sqlitekeep bytes in ablobstable, with a cross-axis rule forcing the two together. Bytes are files — a local directory now, an object store later — so no structured backend is asked to hold them, the two axes share nothing, and any structured backend pairs with any blob backend.sqliterecords besidediskbytes is an ordinary profile, not a special case.Verified by hand against a running Server on
structured=sqlite blobs=disk: create a Space, add nodes through the executor, upload an image and fetch the bytes back, start an agent thread, create and activate a second Workspace by name, restart the process, delete a Space and watch its byte directory go with it.Supported
readMany,list,stream, put with revision CAS, strict/non-strict label allocation, deleteartifacts,guide,memory,uploads— the same Disk adapter and the same area layout, under a Server-owned root instead of a Space folderNot supported — declared, logged at startup, refused in the same words
Each row is an entry in
storage/capabilities.ts, printed at boot for the selected profile and reused verbatim by the refusal at its own call site.The matrix is keyed on the profile, both axes. Most entries need a Space or Workspace to be a real directory (a structured-backend property); four need the Space's bytes to be in that directory too, which is the blob backend's. Omitting an axis means every backend on it serves the feature — so a blob-agnostic row never needs editing when a blob backend lands, and the rows that do depend on it are exactly the ones forced to decide.
On the hybrid profile the answer is unchanged: a real file system for the bytes hands nothing back, because every row still needs the record and node documents to be files.
detached-blobs.test.tspins that, andcapabilities.test.tsruns the whole matrix againstsqlite/diskplus thedisk/azurepairing the two-axis keying exists for.workspace-directorycanChangeWorkspace: false. Creating one by name is supported — it is only the folder that is gone.space-bundle-export/space-bundle-import.huabu.zipround-tripreveal-space-folderbuiltin-file-toolsread/write/glob/grepnodes/, which are rows; the byte areas hold artifacts, not thosespace-file-planebuiltin-file-toolsrationale named RFS as the fallback; RFS needs the same directory, so that claim was wrongexternal-note-discovery.mddropped into a Space from outsideworkspace-user-memorysetting/user.mdworkspace-user-skillssetting/skills/<id>/SKILL.mdTwo further limits are properties of the backend rather than refusals, and a row nothing can refuse is not a capability:
fs.watchhandle. A Space that is a row is never filed under its title and nothing watches it, so nothing ever asks. It was a capability row; printing "unavailable" told an operator they had lost something when the profile simply does not have the problem.busy_timeoutmake a second reader survivable; no multi-process deletion fence or distributed transaction is promised.Two rules keep the call sites honest, and
module-boundaries.test.tschecks the first: a refusal asks the matrix (storageServes(id)on the composition root) rather than re-deriving the requirement fromdiskTree; a degradation does not, because it is not making the profile's promise.Postgres and Azure Blob still have no adapter.
Two defects found by testing the preview against Disk
JSON.stringify, which drops anundefinedown property; the SQLite encoder called it a non-JSON value and failed the write. An optional field spread onto a node is all it takes — exactly the silent divergence §13 warns about. The encoder now followsJSON.stringify; cycles, non-finite numbers, and non-plain objects still reject.stream()did not stream. It materialized the whole collection, then delivered — the opposite of the latency shape the port describes, and an aborted scan had already paid for every row. It reads off a cursor now, andreadManywent from one statement per id to one per chunk.Also added while making the profile selectable: WAL,
synchronous = NORMAL, and a boundedbusy_timeout, none of which the isolated preview needed.Design notes worth a reviewer's attention
blobs=disknames a medium, not a directory. Bytes are local files; where is composition's, under one rule — a Space's bytes live with the Space — and the Space has two possible homes. On Disk records it is a folder, so the bytes stay inside it: that is not just compatibility with existing Workspaces, it is whatspace-bundle-export,space-bundle-import,reveal-space-folderandbuiltin-file-toolsare made of, since each of them is the Space folder being complete. On rows there is no folder to be inside, so the adapter getsstorage/disk/blobs/<workspaceId>/<canvasId>/. The adapter's root argument is required rather than defaulted, so that cross-axis choice can't be made by a caller that forgot to pass one.storageServes(id)instead of re-deriving the requirement fromdiskTree, and a boundary census fails if a declared row has no refusal at all.workspaceRegistryPathmoved out of the Disk workspace repository andsqliteDatabasePathout of the composition root; both now sit with their backend. That is what lets the Disk structured store's registry and the Disk blob store's byte roots sharestorage/disk/without either being able to grow into the other, anddetached-blobs.test.tsasserts the separation as the path fact it is.getWorkspaceKey(), not the workspace path. A Workspace that is a row has no path to compare; the key means the same thing on both kinds and the Disk behaviour is unchanged (there it is the resolved path).createNamedWorkspace()is on the composition root, not the port. The counterpart toadoptWorkspaceDirectory: Disk could only serve "create by name" by inventing a folder the user never picked, and the port deliberately says nothing about where a Workspace is.workspaceCreateSchema.pathbecomes optional so the Server can say which form its backend requires.remove()on the Workspace repository is a forget, not a delete. The port says "without deleting any Workspace-owned data", which Disk honours for free because the folder outlives the registry entry. A database says it with aforgotten_atcolumn.Space.sqliteTreeis a seconddiskTree-shaped member. Agenetes's three storage ports are synchronous andextension()is not; rather than have that owner keep a cache warmed from an unrelated code path, the composition root exposes the synchronous form, typed by its absence. It has its own census inmodule-boundaries.test.tswith exactly one production consumer — a second one would mean the justification had drifted.isWorldCanvasIdused to scan the Disk directory index; the World portal rules now take the live Space ids as an argument, so the policy is pure and the census of Disk-layout consumers shrank by two.Scope
mainapps/server/src/modules, one optional field inpackages/shared's workspace-create schema,docs/architecture/canvas-storage.md, anddocs/proposals/multi-backend-storage.mdHUABU_STRUCTURED_BACKENDNote
The web client's Workspace UI is still folder-shaped, so on this profile it hides the picker (
canChangeWorkspace: false) and offers no way to add a Workspace. Multi-Workspace support here is complete at the storage and HTTP layers; surfacing it is a separate UI change.Proof
PRODUCT_STORAGE_PROFILESgainssqlite/disk, so the §12.8 product-boundary suite runs against it unchanged — that was the point of writing it that way — plus a new case that everything survives a restart.detached-blobs.test.ts): bytes land as real files under the Workspace-scoped root, one Workspace's root is not another's, deleting a Space leaves no directory behind, and the Workspace registry sits outside the blob root so no byte sweep can reach it.JSON.stringifyencoding parity, incremental streaming with early abort, batchedreadMany, Workspace scoping and handle invalidation across a switch, and forget-without-delete.lint,format:check,typecheck, andtestare green.Docs
docs/proposals/multi-backend-storage.md§12.9 is rewritten around the selectable profile, its byte root, and its capability table;docs/architecture/canvas-storage.mdgains the SQLite layout beside the Disk one.🤖 Generated with Claude Code
https://claude.ai/code/session_017Howod9Qp6y2sc4jPJY7Zw