diff --git a/apps/server/src/modules/agent/conversation/prompt/attachments.ts b/apps/server/src/modules/agent/conversation/prompt/attachments.ts
index 6aab71eb0..3beedf85d 100644
--- a/apps/server/src/modules/agent/conversation/prompt/attachments.ts
+++ b/apps/server/src/modules/agent/conversation/prompt/attachments.ts
@@ -37,7 +37,7 @@ import { resolveImageUrl, MAX_INLINE_IMAGE_BYTES } from './image-inlining.js';
import { escapeXmlAttr, escapeXmlText } from './node-element.js';
import { isRasterizableImageMime } from '../../../../utils/mime.js';
import { ARTIFACT_URL_REGEX } from '../../../artifact/utils.js';
-import { canvasBlobs } from '../../../storage/index.js';
+import { space } from '../../../storage/index.js';
import type { AgentInputPart } from '@agenetes/protocol';
import type { ChatAttachment } from '@huabu/shared';
@@ -227,7 +227,7 @@ export async function buildAttachmentParts(
if (resolvedCanvasId && resolvedFilename) {
try {
const bytes =
- await canvasBlobs(resolvedCanvasId).read(resolvedFilename);
+ await space(resolvedCanvasId).blobs.read(resolvedFilename);
// Attachments are inlined as text; binary bytes simply
// decode to mojibake and the URL-only branch is used instead.
if (bytes) fileContent = bytes.toString('utf-8');
diff --git a/apps/server/src/modules/agent/memory/trigger.ts b/apps/server/src/modules/agent/memory/trigger.ts
index 6367f1c58..1b619b7ca 100644
--- a/apps/server/src/modules/agent/memory/trigger.ts
+++ b/apps/server/src/modules/agent/memory/trigger.ts
@@ -26,7 +26,7 @@ import { existsSync } from 'node:fs';
import { atomicWriteJson, mkdirp, readJson } from '../../../utils/fs.js';
import { createKeyedMutex } from '../../../utils/keyed-mutex.js';
-import { spaceDirectory } from '../../storage/index.js';
+import { space } from '../../storage/index.js';
import { memoryStatePath, canvasMemoryDir } from '../../workspace/paths.js';
/** Op-count threshold that triggers a memory analysis pass. */
@@ -83,7 +83,12 @@ export function writeMemoryState(canvasId: string, state: MemoryState): void {
// file. Same hazard for any in-flight memory worker that calls
// `markAnalyzed` post-delete. Skip the write when the canvas root
// is gone; losing one bookkeeping write is harmless.
- if (!existsSync(spaceDirectory(canvasId))) return;
+ // Disk-only by construction: the hazard is an ad-hoc file write
+ // recreating a directory the delete removed, and a backend with no
+ // directory has no such hazard. Phase 4.6 retires the guard entirely when
+ // this state moves onto the extension substrate (proposal §12.6.3).
+ const tree = space(canvasId).diskTree;
+ if (tree && !existsSync(tree.directory())) return;
mkdirp(canvasMemoryDir(canvasId));
atomicWriteJson(memoryStatePath(canvasId), state);
}
diff --git a/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts b/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts
index 18b80a5ea..62ef0b5d2 100644
--- a/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts
+++ b/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts
@@ -30,7 +30,7 @@ import { readFileSync, readdirSync, statSync, type Dirent } from 'node:fs';
import path from 'node:path';
import { parseFrontmatter } from '../../../../utils/markdown-frontmatter.js';
-import { getCanvasStore, spaceDirectory } from '../../../storage/index.js';
+import { getCanvasStore, space } from '../../../storage/index.js';
// ─── Always-skipped directory names ─────────────────────────────────────────
@@ -143,7 +143,18 @@ export function safeResolve(canvasId: string, rel: string): string {
) {
throw new Error(`Invalid canvasId: ${canvasId}`);
}
- const root = spaceDirectory(canvasId);
+ // The built-in file tools are Disk-only and stated as such (proposal
+ // §6.4.3, disposition A): off Disk the first-party agent reaches a Space
+ // over RFS/HTTP, which is what external agents already use. Refusing here
+ // is the backstop behind the capability matrix, not the primary check.
+ const tree = space(canvasId).diskTree;
+ if (!tree) {
+ throw new Error(
+ 'Built-in file tools need a Space directory, which the active ' +
+ 'structured backend does not provide.',
+ );
+ }
+ const root = tree.directory();
// Accept the clean virtual prefixes (`upload/`, `artifacts/`) as aliases
// for their hidden on-disk dirs so agents can reference either form.
const target = path.resolve(root, toPhysicalRel(rel));
@@ -155,6 +166,19 @@ export function safeResolve(canvasId: string, rel: string): string {
return target;
}
+/**
+ * The sandbox root for one Space.
+ *
+ * Exported because classifying a resolved path as "inside this Space" is a
+ * question about the sandbox, not about storage: the caller that asks is
+ * already working in sandbox coordinates, and routing it through storage
+ * would make it a consumer of a backend capability it has no stake in
+ * (proposal §6.4.3).
+ */
+export function sandboxRoot(canvasId: string): string {
+ return safeResolve(canvasId, '');
+}
+
/** Normalise a relative path to forward slashes. */
export function normalizeRel(rel: string): string {
return rel.split(path.sep).join('/');
diff --git a/apps/server/src/modules/agent/tools/handlers/image-generation.ts b/apps/server/src/modules/agent/tools/handlers/image-generation.ts
index 0c00a23d4..966913750 100644
--- a/apps/server/src/modules/agent/tools/handlers/image-generation.ts
+++ b/apps/server/src/modules/agent/tools/handlers/image-generation.ts
@@ -52,7 +52,7 @@ import {
} from '@huabu/shared';
import { getLogger } from '../../../../utils/logger.js';
-import { canvasBlobs } from '../../../storage/index.js';
+import { space } from '../../../storage/index.js';
import { getAzureImageConfig } from '../../llm.js';
import type { generateImageParamsSchema } from '../definitions.js';
@@ -131,7 +131,7 @@ export async function handleGenerateImage(
// ── Load reference artifacts upfront ──────────────────────────────────
// Any missing/invalid ref is an early hard error — better than sending
// a partial set to Azure and getting cryptic results.
- const blobs = canvasBlobs(args.canvasId);
+ const blobs = space(args.canvasId).blobs;
const refImages: Array<{ key: string; bytes: Buffer }> = [];
for (const key of refs) {
if (typeof key !== 'string' || !key.trim()) {
diff --git a/apps/server/src/modules/artifact/artifact.route.test.ts b/apps/server/src/modules/artifact/artifact.route.test.ts
index 3010fbec7..1bd48e187 100644
--- a/apps/server/src/modules/artifact/artifact.route.test.ts
+++ b/apps/server/src/modules/artifact/artifact.route.test.ts
@@ -23,7 +23,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import artifactRoute from './artifact.route.js';
import { createCanvas, deleteCanvas } from '../storage/compatibility/canvas.js';
import {
- canvasBlobs,
+ space,
getStorage,
resetStorageCache,
setStorageForTesting,
@@ -194,7 +194,7 @@ describe('artifact route', () => {
});
expect(upload.statusCode).toBe(500);
- expect(await canvasBlobs('missing').list()).toEqual([]);
+ expect(await space('missing').blobs.list()).toEqual([]);
await app.close();
});
@@ -234,7 +234,7 @@ describe('artifact route', () => {
const upload = await uploading;
expect(upload.statusCode).toBe(500);
expect(blocker.putCalls()).toBe(0);
- expect(await canvasBlobs('c1').list()).toEqual([]);
+ expect(await space('c1').blobs.list()).toEqual([]);
} finally {
blocker.releaseDelete();
blocker.restore();
@@ -244,7 +244,7 @@ describe('artifact route', () => {
it('serves a byte range so media nodes can seek', async () => {
const app = await buildApp();
- await canvasBlobs('c1').put('a.png', png);
+ await space('c1').blobs.put('a.png', png);
const res = await app.inject({
method: 'GET',
@@ -260,7 +260,7 @@ describe('artifact route', () => {
it('answers 304 for an unchanged artifact', async () => {
const app = await buildApp();
- await canvasBlobs('c1').put('a.png', png);
+ await space('c1').blobs.put('a.png', png);
const first = await app.inject({
method: 'GET',
@@ -332,7 +332,7 @@ describe('artifact route', () => {
it('clones an artifact into another canvas under a fresh key', async () => {
const app = await buildApp();
- await canvasBlobs('src-canvas').put('a.png', png);
+ await space('src-canvas').blobs.put('a.png', png);
const res = await app.inject({
method: 'POST',
@@ -346,8 +346,8 @@ describe('artifact route', () => {
expect(uri).toMatch(/\.png$/);
// Destination owns its own copy; the source is untouched.
- expect(await canvasBlobs('dst-canvas').read(uri)).toEqual(png);
- expect(await canvasBlobs('src-canvas').read('a.png')).toEqual(png);
+ expect(await space('dst-canvas').blobs.read(uri)).toEqual(png);
+ expect(await space('src-canvas').blobs.read('a.png')).toEqual(png);
await app.close();
});
diff --git a/apps/server/src/modules/artifact/artifact.route.ts b/apps/server/src/modules/artifact/artifact.route.ts
index 26354399a..1d9a8f70e 100644
--- a/apps/server/src/modules/artifact/artifact.route.ts
+++ b/apps/server/src/modules/artifact/artifact.route.ts
@@ -8,7 +8,7 @@ import { type FastifyPluginAsync } from 'fastify';
import { cloneArtifactBodySchema, createId } from '@huabu/shared';
import { sendBlob } from './send-blob.js';
-import { canvasBlobs } from '../storage/index.js';
+import { space } from '../storage/index.js';
import { extractHtmlFromMhtml, injectBaseHref } from '../web/mhtml.js';
import type {
@@ -61,7 +61,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
const name = `${id}${ext}`;
try {
- await canvasBlobs(canvasId).put(name, data.file);
+ await space(canvasId).blobs.put(name, data.file);
} catch (error) {
request.log.error({ err: error }, 'Failed to stream artifact to storage');
return reply.code(500).send({ message: 'Failed to save file' });
@@ -82,7 +82,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
'/:canvasId/artifact/:filename',
async (request, reply) => {
const { canvasId, filename } = request.params;
- const blobs = canvasBlobs(canvasId);
+ const blobs = space(canvasId).blobs;
const safeName = path.basename(filename);
// `.mhtml` snapshots are stored as proper multipart/related MHTML
@@ -151,7 +151,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
let buffer: Buffer | null;
try {
- buffer = await canvasBlobs(srcCanvasId).read(srcKey);
+ buffer = await space(srcCanvasId).blobs.read(srcKey);
} catch (err) {
request.log.error({ err }, 'Failed to read source artifact for clone');
return reply
@@ -167,7 +167,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
const name = `${id}${ext}`;
try {
- await canvasBlobs(dstCanvasId).put(name, buffer);
+ await space(dstCanvasId).blobs.put(name, buffer);
} catch (err) {
request.log.error({ err }, 'Failed to clone artifact');
return reply
diff --git a/apps/server/src/modules/artifact/utils.ts b/apps/server/src/modules/artifact/utils.ts
index 58ab48d45..e27b69aa8 100644
--- a/apps/server/src/modules/artifact/utils.ts
+++ b/apps/server/src/modules/artifact/utils.ts
@@ -7,7 +7,7 @@ import { ARTIFACT_URL_REGEX } from '@huabu/shared';
import { getLogger } from '../../utils/logger.js';
import { IMAGE_MIME_MAP } from '../../utils/mime.js';
-import { canvasBlobs } from '../storage/index.js';
+import { space } from '../storage/index.js';
const log = getLogger('artifact');
@@ -56,7 +56,7 @@ export async function resolveArtifactImageUrl(
if (!canvasId || !filename) return url;
try {
- const buffer = await canvasBlobs(canvasId).read(filename);
+ const buffer = await space(canvasId).blobs.read(filename);
if (!buffer) return url;
const ext = path.extname(filename).toLowerCase();
// Never guess `image/png` for an unknown extension: callers forward this
diff --git a/apps/server/src/modules/canvas/canvas-executor.test.ts b/apps/server/src/modules/canvas/canvas-executor.test.ts
index 8bf897503..67bd529f7 100644
--- a/apps/server/src/modules/canvas/canvas-executor.test.ts
+++ b/apps/server/src/modules/canvas/canvas-executor.test.ts
@@ -30,7 +30,7 @@ import { nodeRevisionOf } from '@huabu/shared/canvas-engine';
import { applyDeltasOnServer, executeOnServer } from './canvas-executor.js';
import {
- canvasBlobs,
+ space,
getCanvasStore,
getStructuredStore,
updateNode,
@@ -273,7 +273,7 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => {
src: 'old.svg',
content: '',
});
- await canvasBlobs('c1').put(
+ await space('c1').blobs.put(
'new.svg',
Buffer.from(
'',
@@ -329,7 +329,7 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => {
src: 'pic.svg',
content: '',
});
- await canvasBlobs('c1').put(
+ await space('c1').blobs.put(
'pic.svg',
Buffer.from(
'',
diff --git a/apps/server/src/modules/canvas/canvas-executor.ts b/apps/server/src/modules/canvas/canvas-executor.ts
index 30461bec6..a59a62b46 100644
--- a/apps/server/src/modules/canvas/canvas-executor.ts
+++ b/apps/server/src/modules/canvas/canvas-executor.ts
@@ -64,7 +64,7 @@ import {
} from './world-portal-policy.js';
import { getLogger } from '../../utils/logger.js';
import {
- canvasBlobs,
+ space,
getCanvasStore,
getStructuredStore,
withCanvasMutex,
@@ -375,7 +375,7 @@ async function aspectHeightForWidth(
width: number,
): Promise {
try {
- const dim = await readImageDimensions(canvasBlobs(canvasId), src);
+ const dim = await readImageDimensions(space(canvasId).blobs, src);
if (!dim?.width || !dim?.height || dim.width <= 0 || dim.height <= 0) {
return null;
}
diff --git a/apps/server/src/modules/canvas/canvas.route.test.ts b/apps/server/src/modules/canvas/canvas.route.test.ts
index bbfeac7a8..c34a7a81a 100644
--- a/apps/server/src/modules/canvas/canvas.route.test.ts
+++ b/apps/server/src/modules/canvas/canvas.route.test.ts
@@ -39,7 +39,7 @@ import canvasRoutes from './canvas.route.js';
import { withSpaceDirHandlesReleased } from '../storage/backends/disk/space-dir-handles.js';
import { createCanvas, deleteCanvas } from '../storage/compatibility/canvas.js';
import {
- canvasBlobs,
+ space,
getCanvasStore,
getStructuredStore,
resetStorageCache,
@@ -738,7 +738,7 @@ describe('Space export/import persistence', () => {
change,
]);
const blob = Buffer.from([0, 1, 2, 3, 255]);
- await canvasBlobs('c1').put('asset.bin', blob);
+ await space('c1').blobs.put('asset.bin', blob);
const app = await buildApp();
try {
@@ -790,7 +790,7 @@ describe('Space export/import persistence', () => {
expect(await importedSpace.changes.read('thread-export')).toEqual(
storedChanges,
);
- expect(await canvasBlobs(importedId).read('asset.bin')).toEqual(blob);
+ expect(await space(importedId).blobs.read('asset.bin')).toEqual(blob);
} finally {
await app.close();
}
diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts
index 38b531fdb..de99b02de 100644
--- a/apps/server/src/modules/canvas/canvas.route.ts
+++ b/apps/server/src/modules/canvas/canvas.route.ts
@@ -58,12 +58,11 @@ import {
suggestCanvasDir,
} from '../storage/canvas-dirs.js';
import {
- canvasBlobs,
+ space,
createSpace,
deleteSpace,
getCanvasStore,
getStructuredStore,
- spaceDirectory,
type CanvasFile,
type UpdateNodeOutcome,
updateNode,
@@ -342,7 +341,7 @@ async function singleArtifactProbe(
): Promise<(key: string) => boolean> {
const key = extractArtifactKey(src);
if (!key) return () => false;
- const exists = (await canvasBlobs(canvasId).hasMany([key])).has(key);
+ const exists = (await space(canvasId).blobs.hasMany([key])).has(key);
return (candidate) => candidate === key && exists;
}
@@ -532,7 +531,7 @@ async function hydrateNodeContent(
const present =
referenced.size === 0
? new Set()
- : await canvasBlobs(store.canvasId).hasMany([...referenced]);
+ : await space(store.canvasId).blobs.hasMany([...referenced]);
const artifactExists = (key: string): boolean => present.has(key);
return nodes.map((node) => {
@@ -1629,8 +1628,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => {
return reply.code(404).send({ message: 'Canvas not found' });
}
- const canvasDir = spaceDirectory(canvasId);
- if (!existsSync(canvasDir)) {
+ // The Space bundle is a Disk projection (proposal §6.4.3, disposition
+ // A); a portable export generated from records plus reachable blob
+ // references is a separate later design.
+ const tree = space(canvasId).diskTree;
+ const canvasDir = tree?.directory();
+ if (canvasDir === undefined || !existsSync(canvasDir)) {
return reply.code(404).send({ message: 'Canvas directory not found' });
}
diff --git a/apps/server/src/modules/canvas/external.route.ts b/apps/server/src/modules/canvas/external.route.ts
index 0984daa1c..6460a9ad8 100644
--- a/apps/server/src/modules/canvas/external.route.ts
+++ b/apps/server/src/modules/canvas/external.route.ts
@@ -17,7 +17,7 @@ import {
takeExternalNote,
} from './external-watcher.js';
import { parseFrontmatter } from '../../utils/markdown-frontmatter.js';
-import { spaceDirectory } from '../storage/index.js';
+import { space } from '../storage/index.js';
import type { FastifyPluginAsync } from 'fastify';
@@ -95,7 +95,14 @@ const externalRoutes: FastifyPluginAsync = async (fastify): Promise => {
return reply.code(404).send({ message: 'External note not found' });
}
- const abs = path.join(spaceDirectory(canvasId), item.relativePath);
+ // External-note claim is Disk-only (proposal §6.4.3, disposition A): it
+ // exists to adopt documents that arrived without going through the
+ // application, and no database backend has such an arrival path.
+ const tree = space(canvasId).diskTree;
+ if (!tree) {
+ return reply.code(404).send({ message: 'External note not found' });
+ }
+ const abs = path.join(tree.directory(), item.relativePath);
let raw: string;
try {
raw = await readFile(abs, 'utf8');
diff --git a/apps/server/src/modules/canvas/import-node-src.test.ts b/apps/server/src/modules/canvas/import-node-src.test.ts
index 089ad92ac..731608357 100644
--- a/apps/server/src/modules/canvas/import-node-src.test.ts
+++ b/apps/server/src/modules/canvas/import-node-src.test.ts
@@ -15,15 +15,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { importForeignNodeSources } from './import-node-src.js';
import { createCanvas } from '../storage/compatibility/canvas.js';
-import {
- canvasBlobs,
- getCanvasStore,
- spaceDirectory,
-} from '../storage/index.js';
+import { space, getCanvasStore } from '../storage/index.js';
import { setWorkspacePath } from '../workspace.js';
import type { CanvasCommand } from '@huabu/shared';
+/**
+ * The Space's Disk directory, or a test failure.
+ *
+ * These cases are Disk-specific by construction; the assertion states that
+ * rather than letting an optional-chained `undefined` quietly pass.
+ */
+function diskDirOf(canvasId: string): string {
+ const tree = space(canvasId).diskTree;
+ if (!tree) throw new Error('Expected the Disk backend in this test');
+ return tree.directory();
+}
+
let tmp: string;
beforeEach(() => {
@@ -49,7 +57,7 @@ afterEach(() => {
/** Stage a file under the canvas's hidden `.upload/` scratch dir. */
function stageUpload(canvasId: string, name: string, body: string): string {
- const uploadDir = path.join(spaceDirectory(canvasId), '.upload');
+ const uploadDir = path.join(diskDirOf(canvasId), '.upload');
mkdirSync(uploadDir, { recursive: true });
const abs = path.join(uploadDir, name);
writeFileSync(abs, body);
@@ -125,7 +133,7 @@ describe('importForeignNodeSources — web nodes', () => {
// …whose file exists in the artifact store…
expect(src).toBeDefined();
if (src === undefined) throw new Error('Expected a rewritten web src');
- expect(await canvasBlobs(canvasId).head(src)).not.toBeNull();
+ expect(await space(canvasId).blobs.head(src)).not.toBeNull();
// …and the staging upload was reclaimed (move semantics).
expect(existsSync(uploadAbs)).toBe(false);
});
@@ -221,7 +229,7 @@ describe('importForeignNodeSources — web nodes', () => {
expect(src).toMatch(/^artifact-[^/]+\.html$/);
expect(src).toBeDefined();
if (src === undefined) throw new Error('Expected a rewritten web src');
- expect(await canvasBlobs(canvasId).head(src)).not.toBeNull();
+ expect(await space(canvasId).blobs.head(src)).not.toBeNull();
expect(existsSync(uploadAbs)).toBe(false);
});
@@ -268,13 +276,13 @@ describe('importForeignNodeSources — media nodes (regression)', () => {
expect(src).toMatch(/^artifact-[^/]+\.png$/);
expect(src).toBeDefined();
if (src === undefined) throw new Error('Expected a rewritten image src');
- expect(await canvasBlobs(canvasId).head(src)).not.toBeNull();
+ expect(await space(canvasId).blobs.head(src)).not.toBeNull();
});
it('canonicalizes an artifact path that leaves and re-enters the Space', async () => {
const canvasId = 'c-image-reentered';
const store = getCanvasStore(canvasId);
- const spaceDir = spaceDirectory(canvasId);
+ const spaceDir = diskDirOf(canvasId);
const artifactsDir = path.join(spaceDir, '.artifacts');
mkdirSync(artifactsDir, { recursive: true });
writeFileSync(path.join(artifactsDir, 'pic.png'), 'existing artifact');
diff --git a/apps/server/src/modules/canvas/import-node-src.ts b/apps/server/src/modules/canvas/import-node-src.ts
index 39837f701..77f39c27f 100644
--- a/apps/server/src/modules/canvas/import-node-src.ts
+++ b/apps/server/src/modules/canvas/import-node-src.ts
@@ -39,9 +39,10 @@ import { getLogger } from '../../utils/logger.js';
import {
safeResolve,
isArtifactsRel,
+ sandboxRoot,
toPhysicalRel,
} from '../agent/tools/handlers/fs-sandbox.js';
-import { canvasBlobs, spaceDirectory } from '../storage/index.js';
+import { space } from '../storage/index.js';
import type { CanvasStore } from '../storage/index.js';
@@ -272,7 +273,7 @@ async function resolveImportedSrc(
// is judged by where it actually lands, while the helper still owns the
// virtual/physical `.artifacts` vocabulary. A nested path is not a blob key,
// so it falls through and is copied into the artifact root below.
- const resolvedPhysicalRel = path.relative(spaceDirectory(canvasId), absPath);
+ const resolvedPhysicalRel = path.relative(sandboxRoot(canvasId), absPath);
if (isArtifactsRel(resolvedPhysicalRel)) {
const key = path.basename(absPath);
const canonicalPath = safeResolve(
@@ -309,7 +310,7 @@ async function copyToArtifact(
const id = createId('artifact');
const key = `${id}${ext}`;
const buffer = await readFile(absPath);
- await canvasBlobs(store.canvasId).put(key, buffer);
+ await space(store.canvasId).blobs.put(key, buffer);
// Move semantics: reclaim RFS scratch uploads once they are safely
// stored. Never delete user node files or other canvas content —
@@ -365,7 +366,7 @@ async function downloadToArtifact(
}
const ext = pickDownloadExt(pathname, contentType);
const key = `${createId('artifact')}${ext}`;
- await canvasBlobs(store.canvasId).put(key, buffer);
+ await space(store.canvasId).blobs.put(key, buffer);
return key;
} catch (err) {
log.warn({ err, url }, 'Failed to download online node src into artifacts');
diff --git a/apps/server/src/modules/canvas/snapshot-nodes.ts b/apps/server/src/modules/canvas/snapshot-nodes.ts
index 9d79bbdc2..356f5c048 100644
--- a/apps/server/src/modules/canvas/snapshot-nodes.ts
+++ b/apps/server/src/modules/canvas/snapshot-nodes.ts
@@ -68,7 +68,7 @@ import {
import { getSketchRenderedSize } from '@huabu/shared/canvas-engine';
import { RASTERIZABLE_IMAGE_EXT_MIME } from '../../utils/mime.js';
-import { canvasBlobs, getCanvasStore } from '../storage/index.js';
+import { space, getCanvasStore } from '../storage/index.js';
import type {
SketchNodeData,
@@ -450,7 +450,7 @@ async function loadContextImage(
if (!mimeType) return null;
const { width, height } = nodeBoxSize(node);
if (width <= 0 || height <= 0) return null;
- const bytes = await canvasBlobs(store.canvasId).read(src);
+ const bytes = await space(store.canvasId).blobs.read(src);
if (!bytes) return null;
return { node, resolvedSrc: src, bytes, mimeType, width, height };
}
@@ -799,7 +799,7 @@ async function maybeResizeImageArtifact(
src: string,
maxEdge: number,
): Promise<{ src: string; width: number; height: number } | null> {
- const blobs = canvasBlobs(store.canvasId);
+ const blobs = space(store.canvasId).blobs;
const ext = path.extname(src).toLowerCase();
const mimeType = IMAGE_EXT_MIME[ext];
if (!mimeType) return null;
@@ -1060,10 +1060,10 @@ export async function snapshotNodesToArtifacts(
? `sketch-raster-${fingerprint}`
: `sketch-raster-${fingerprint}-${maxEdge}`;
const filename = `${id}.png`;
- const existing = await canvasBlobs(store.canvasId).head(filename);
+ const existing = await space(store.canvasId).blobs.head(filename);
if (!existing) {
const png = await renderClusterPng(built.svg, built.width);
- await canvasBlobs(store.canvasId).put(filename, png);
+ await space(store.canvasId).blobs.put(filename, png);
}
results.push({
src: filename,
diff --git a/apps/server/src/modules/canvas/write-coordinator.test.ts b/apps/server/src/modules/canvas/write-coordinator.test.ts
index a720b8a3a..1b9960f80 100644
--- a/apps/server/src/modules/canvas/write-coordinator.test.ts
+++ b/apps/server/src/modules/canvas/write-coordinator.test.ts
@@ -33,6 +33,26 @@ function fakeRepository(canvasId = 'c1') {
if (revision === null) throw new Error('test storage token is missing');
return { record, revision };
},
+ // This fake holds one node, so the collection reads are the same record
+ // under its own id. `updateNode` never calls them; they exist because the
+ // port has them.
+ async readMany(nodeIds): Promise