Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workspace-namespaces.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/03_filesystem_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<namespace>_` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions packages/computer/src/namespace.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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_");
});
});
70 changes: 70 additions & 0 deletions packages/computer/src/namespace.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 17 additions & 1 deletion packages/computer/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading