diff --git a/.changeset/workspace-namespaces.md b/.changeset/workspace-namespaces.md new file mode 100644 index 00000000..18772a98 --- /dev/null +++ b/.changeset/workspace-namespaces.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`new Workspace({ storage, namespace })` keeps a Workspace's files in their own tables, so one durable object can hold more than one Workspace. Workspaces without a namespace keep using the existing tables. diff --git a/docs/03_filesystem_schema.md b/docs/03_filesystem_schema.md index 2e258179..7b0e6e65 100644 --- a/docs/03_filesystem_schema.md +++ b/docs/03_filesystem_schema.md @@ -13,6 +13,13 @@ ultimately hits one of these tables. All tables are prefixed with `vfs_` (or `_vfs_` for internal bookkeeping) so they don't collide with application-owned tables in the same DO storage. +A Workspace created with a `namespace` uses its own copy of every +table and index below, named with a `ws__` prefix: +`vfs_nodes` becomes `ws_scratch_vfs_nodes` for the namespace +`scratch`. The Workspace runtime's tables (`workspace_runtime_*`, +`computer_execution_runtime`) get the same prefix. A Workspace +without a namespace uses the names as written here. + Paths are resolved through an inode-style indirection (`vfs_dirents` → `vfs_nodes`), so the local namespace move in a rename is O(1) and hardlinks fall out for free. Directory rename sync still walks the diff --git a/packages/computer/README.md b/packages/computer/README.md index 81799049..13bd3175 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -185,6 +185,22 @@ const hits = await ws.fs.grep("TODO", "/", { ignoreCase: true }); See [`docs/04_filesystem_interface.md`](../../docs/04_filesystem_interface.md) for the full surface. +### Several workspaces in one durable object + +Pass a `namespace` to keep a Workspace's files apart from other +Workspaces on the same storage. Each namespace gets its own tables, +so a durable object can hold, say, a scratch area and a project: + +```ts +const project = new Workspace({ storage: ctx.storage }); +const scratch = new Workspace({ storage: ctx.storage, namespace: "scratch" }); +``` + +A namespace is 1 to 32 lowercase letters and digits. Without one, a +Workspace uses the default tables, where existing data already is. A +durable object still has one container, so give a container backend +to only one of the Workspaces. + ### Read-only mounts Pre-fill part of the tree from an R2 bucket. Files under the mount point diff --git a/packages/computer/src/namespace.test.ts b/packages/computer/src/namespace.test.ts new file mode 100644 index 00000000..47d45a0d --- /dev/null +++ b/packages/computer/src/namespace.test.ts @@ -0,0 +1,125 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; + +import { createGitClient } from "./git/index.js"; +import { namespacePrefix } from "./namespace.js"; +import { Workspace } from "./workspace.js"; + +function exists(ws: Workspace, path: string): Promise { + return ws.fs.stat(path).then( + () => true, + () => false, + ); +} + +function tables(storage: SQLiteTestStorage): string[] { + return storage.sql + .exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .toArray() + .map((row) => String((row as { name: unknown }).name)); +} + +describe("Workspace namespaces", () => { + it("keeps files apart between namespaces on one storage", async () => { + const storage = new SQLiteTestStorage(); + const main = new Workspace({ storage }); + const scratch = new Workspace({ storage, namespace: "scratch" }); + const project = new Workspace({ storage, namespace: "project" }); + + await main.fs.writeFile("/notes.md", "main"); + await scratch.fs.writeFile("/notes.md", "scratch"); + await project.fs.writeFile("/notes.md", "project"); + await scratch.fs.writeFile("/only-scratch.txt", "x"); + + expect(await main.fs.readFile("/notes.md", "utf8")).toBe("main"); + expect(await scratch.fs.readFile("/notes.md", "utf8")).toBe("scratch"); + expect(await project.fs.readFile("/notes.md", "utf8")).toBe("project"); + expect(await exists(main, "/only-scratch.txt")).toBe(false); + expect(await exists(project, "/only-scratch.txt")).toBe(false); + + await scratch.fs.rm("/notes.md"); + expect(await main.fs.readFile("/notes.md", "utf8")).toBe("main"); + expect(await project.fs.readFile("/notes.md", "utf8")).toBe("project"); + }); + + it("finds the same files again when reopened with the same namespace", async () => { + const storage = new SQLiteTestStorage(); + const first = new Workspace({ storage, namespace: "scratch" }); + await first.fs.mkdir("/a"); + await first.fs.writeFile("/a/b.txt", "kept"); + + const reopened = new Workspace({ storage, namespace: "scratch" }); + + expect(await reopened.fs.readFile("/a/b.txt", "utf8")).toBe("kept"); + expect(await exists(new Workspace({ storage }), "/a/b.txt")).toBe(false); + }); + + it("leaves the default tables where existing data already is", async () => { + const storage = new SQLiteTestStorage(); + await new Workspace({ storage }).fs.writeFile("/old.txt", "before namespaces"); + new Workspace({ storage, namespace: "scratch" }); + + const names = tables(storage); + + expect(names).toContain("vfs_nodes"); + expect(names).toContain("_vfs_mounts"); + expect(names).toContain("ws_scratch_vfs_nodes"); + expect(names).toContain("ws_scratch__vfs_mounts"); + expect(names).toContain("ws_scratch_computer_execution_runtime"); + expect(await new Workspace({ storage }).fs.readFile("/old.txt", "utf8")).toBe( + "before namespaces", + ); + }); + + it("creates every table and index under the namespace", async () => { + const storage = new SQLiteTestStorage(); + const scratch = new Workspace({ storage, namespace: "scratch" }); + await scratch.fs.writeFile("/a.txt", "a"); + + const names = storage.sql + .exec( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'index') AND sql IS NOT NULL AND name NOT LIKE 'sqlite_%'", + ) + .toArray() + .map((row) => String((row as { name: unknown }).name)); + + expect(names.length).toBeGreaterThan(0); + expect(names.filter((name) => !name.startsWith("ws_scratch_"))).toEqual([]); + }); + + it("never rewrites file names or contents", async () => { + const storage = new SQLiteTestStorage(); + const scratch = new Workspace({ storage, namespace: "scratch" }); + const text = "SELECT * FROM vfs_nodes; DROP TABLE _vfs_mounts;"; + + await scratch.fs.writeFile("/vfs_nodes.sql", text); + + expect(await scratch.fs.readFile("/vfs_nodes.sql", "utf8")).toBe(text); + expect((await scratch.fs.readdir("/")).map((entry) => entry.name)).toContain("vfs_nodes.sql"); + }); + + it("gives each namespace its own git repository", async () => { + const storage = new SQLiteTestStorage(); + const identity = { name: "T", email: "t@example.test" }; + const scratch = new Workspace({ storage, namespace: "scratch", git: createGitClient() }); + const project = new Workspace({ storage, namespace: "project", git: createGitClient() }); + + await scratch.git.init({ dir: "/repo" }); + await scratch.fs.writeFile("/repo/a.txt", "a\n"); + await scratch.git.add({ dir: "/repo", paths: ["a.txt"] }); + await scratch.git.commit({ dir: "/repo", message: "a", author: identity }); + + expect(await scratch.git.log({ dir: "/repo" })).toHaveLength(1); + expect((await project.git.cli({ argv: ["status"], cwd: "/repo" })).exitCode).not.toBe(0); + }); + + it("rejects namespaces that could clash", () => { + const storage = new SQLiteTestStorage(); + for (const namespace of ["", "Scratch", "a_b", "a-b", "x".repeat(33)]) { + expect(() => new Workspace({ storage, namespace })).toThrow( + "namespace must be 1 to 32 lowercase letters and digits", + ); + } + expect(namespacePrefix("scratch2")).toBe("ws_scratch2_"); + }); +}); diff --git a/packages/computer/src/namespace.ts b/packages/computer/src/namespace.ts new file mode 100644 index 00000000..74b0abe7 --- /dev/null +++ b/packages/computer/src/namespace.ts @@ -0,0 +1,70 @@ +// Workspace namespaces: more than one Workspace in one Durable +// Object's storage. +// +// Every table a Workspace owns has a fixed name (`vfs_nodes`, +// `_vfs_mounts`, `workspace_runtime_executions`, ...), and the SQL +// that reads and writes them is spread across the filesystem, sync, +// mount, and runtime code. Rather than thread a table prefix through +// all of it, a namespaced Workspace gets a storage handle that renames +// those tables on the way to SQLite. `vfs_nodes` becomes +// `ws_scratch_vfs_nodes` for the namespace `scratch`. +// +// The rename matches only identifiers this package creates. Values +// always travel as bindings, never inside the SQL text, so file data +// is never rewritten. + +import type { DurableObjectStorageLike } from "@cloudflare/dofs"; + +const NAMESPACE = /^[a-z0-9]{1,32}$/; + +// Tables, indexes, and migration scratch tables the Workspace +// creates. `\b` stops a match inside a longer identifier. +const WORKSPACE_IDENTIFIER = + /\b(?:_?vfs_[a-z0-9_]+|workspace_runtime_[a-z0-9_]+|computer_execution_runtime)\b/g; + +/** + * Check a namespace and return the table prefix for it. + * + * Namespaces are 1 to 32 lowercase letters and digits. They can't + * contain `_`, so no two namespaces map to the same table names, and + * none maps to the default ones. + */ +export function namespacePrefix(namespace: string): string { + if (!NAMESPACE.test(namespace)) { + throw new TypeError( + `Workspace: namespace must be 1 to 32 lowercase letters and digits, got ${JSON.stringify(namespace)}.`, + ); + } + return `ws_${namespace}_`; +} + +/** + * Wrap `storage` so the SQL a Workspace runs reads and writes its own + * copy of the Workspace tables. + * + * @param storage - The Durable Object storage to share. + * @param namespace - 1 to 32 lowercase letters and digits. + * @returns Storage that renames the Workspace tables for `namespace`. + */ +export function namespacedStorage( + storage: DurableObjectStorageLike, + namespace: string, +): DurableObjectStorageLike { + const prefix = namespacePrefix(namespace); + const rewrite = (query: string) => + query.replace(WORKSPACE_IDENTIFIER, (identifier) => prefix + identifier); + const namespaced: DurableObjectStorageLike = { + sql: { + exec: (query, ...bindings) => storage.sql.exec(rewrite(query), ...bindings), + }, + }; + if (storage.transactionSync !== undefined) { + const transactionSync = storage.transactionSync.bind(storage); + namespaced.transactionSync = (closure) => transactionSync(closure); + } + if (storage.transaction !== undefined) { + const transaction = storage.transaction.bind(storage); + namespaced.transaction = (closure) => transaction(closure); + } + return namespaced; +} diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index ba053da3..fd3afc96 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -42,6 +42,7 @@ import type { GitClient, GitClientFactory, GitIdentity } from "./git/index.js"; import { MountIndex } from "./mounts/index.js"; import { buildMountRegistry, type MountValue } from "./mounts/registry.js"; import type { Mount } from "./mounts/types.js"; +import { namespacedStorage } from "./namespace.js"; import { noopObserver, safeErrorMessage, type WorkspaceObserver, withSpan } from "./observe.js"; import { createSyncLogger, type SyncLogger } from "./observe/sync-telemetry.js"; import { @@ -82,6 +83,17 @@ export interface WorkspaceOptions { // Database against it and runs initializeSchema (idempotent). storage: DurableObjectStorageLike; + // Keeps this Workspace's files apart from other Workspaces on the + // same storage. Each namespace gets its own copy of the Workspace + // tables, so one Durable Object can hold, say, a scratch area and a + // project side by side. 1 to 32 lowercase letters and digits. Omit it + // for the default tables, which is where existing data already is. + // + // Only the files and the runtime's records are separate. A Durable + // Object still has one container, so give a container backend to + // only one of the Workspaces. + namespace?: string; + // Registered execution backends. The first is the default; // callers can select another by stable id. // Omit to construct a filesystem-only Workspace whose runtime @@ -305,7 +317,11 @@ export class Workspace { this.#artifacts = options.artifacts ? createArtifact(options.artifacts.binding, artifactsSessionId(options)) : createDisabledArtifactsClient(); - this.#db = new Database(options.storage); + this.#db = new Database( + options.namespace === undefined + ? options.storage + : namespacedStorage(options.storage, options.namespace), + ); initializeSchema(this.#db, this.#now); this.#executionRuntimes = new ExecutionRuntimeTracker( 1_024,