Skip to content

Commit 98fa7c4

Browse files
author
agent
committed
dofs, computer: expose rename on the public filesystem
The store has implemented transactional file, directory, and symbolic link moves for some time, covering destination replacement, non-empty directories, read-only mounts, tombstones, revision stamping, and subtree tracking. None of that reached Workspace.fs, so a caller had to copy the source and then delete it, and the Worker shell used that fallback for mv. A failure between the two steps left the entry at both paths or a directory half copied. WorkspaceFilesystem now forwards rename, and WorkspaceFilesystemStub mirrors it with the usual filesystem observation span, so the Workers RPC surface matches the in-process one. The shell adapter calls it and keeps copy-then-delete only for a destination rename refuses to replace, which is what the shell expects when it merges a tree. No new method crosses the Cap'n Web boundary: the existing synchronisation protocol already carries the resulting live entries and tombstones. Closes #120.
1 parent 9c3b249 commit 98fa7c4

11 files changed

Lines changed: 180 additions & 8 deletions

File tree

.changeset/expose-fs-rename.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudflare/dofs": minor
3+
"@cloudflare/computer": minor
4+
---
5+
6+
`Workspace.fs` gains `rename(oldPath, newPath)`, exposing the store's existing transactional move through the public surface and through `WorkspaceFilesystemStub`. An existing destination is replaced when the two ends agree on kind — a file or symbolic link for a file or symbolic link, an empty directory for a directory — and the operation reports `ENOENT`, `ENOTEMPTY`, `EISDIR`, `ENOTDIR`, `EINVAL`, and `EROFS` as documented in `docs/04_filesystem_interface.md`. The Worker shell's `mv` now calls it, so an interrupted move no longer leaves the entry at both paths or a directory half copied.

docs/12_worker_backend.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,9 @@ policy. The backend does not own the external runtime's lifecycle.
223223
- **No hard links, no `utimes`.** The adapter throws `ENOSYS` on
224224
`link` (the store has no hard-link model) and no-ops on
225225
`utimes` (no atime column). `chmod`, `symlink`, `readlink`,
226-
and `lstat` all work end-to-end against the DO's store.
226+
`lstat`, and `rename` all work end-to-end against the DO's
227+
store, so `mv` moves an entry in one operation instead of
228+
copying and then deleting it.
227229
- **No cross-request reattach.** `ShellWorker.getExec` always
228230
returns ENOENT; `killExec` is a no-op. Each exec is scoped to
229231
its own call. The previous in-isolate event log shape didn't

packages/computer/src/backends/worker-shell/adapter.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,34 @@ describe("WorkspaceFsAdapter — composites", () => {
275275
expect(await workspace.fs.readFile("/dst", "utf8")).toBe("hello");
276276
await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
277277
});
278+
279+
it("mv moves through the store's rename rather than copy and delete", async () => {
280+
await workspace.fs.writeFile("/src", "hello");
281+
const rename = vi.spyOn(stub, "rename");
282+
const writeFile = vi.spyOn(stub, "writeFile");
283+
await adapter.mv("/src", "/dst");
284+
expect(rename).toHaveBeenCalledWith("/src", "/dst");
285+
expect(writeFile).not.toHaveBeenCalled();
286+
});
287+
288+
it("mv moves a directory tree in one operation", async () => {
289+
await workspace.fs.mkdir("/src/inner", { recursive: true });
290+
await workspace.fs.writeFile("/src/inner/b", "b");
291+
await adapter.mv("/src", "/dst");
292+
expect(await workspace.fs.readFile("/dst/inner/b", "utf8")).toBe("b");
293+
await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
294+
});
295+
296+
it("mv falls back to copy and delete when rename cannot replace the destination", async () => {
297+
await workspace.fs.mkdir("/src", { recursive: true });
298+
await workspace.fs.writeFile("/src/a", "a");
299+
await workspace.fs.mkdir("/dst", { recursive: true });
300+
await workspace.fs.writeFile("/dst/keep", "keep");
301+
await adapter.mv("/src", "/dst");
302+
expect(await workspace.fs.readFile("/dst/a", "utf8")).toBe("a");
303+
expect(await workspace.fs.readFile("/dst/keep", "utf8")).toBe("keep");
304+
await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
305+
});
278306
});
279307

280308
describe("WorkspaceFsAdapter — pure utilities", () => {

packages/computer/src/backends/worker-shell/adapter.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
//
44
// The adapter is a thin façade. Operations that map one-for-one
55
// (writeFile, readdir, mkdir, rm, chmod, symlink, readlink, stat,
6-
// lstat) forward directly. Operations the stub doesn't expose —
7-
// appendFile, cp, mv, exists — synthesize from the available
6+
// lstat, rename) forward directly. Operations the stub doesn't
7+
// expose — appendFile, cp — synthesize from the available
88
// primitives. Hard links and utimes aren't supported: link throws
99
// ENOSYS so a script that depends on them fails loudly; utimes
1010
// is a documented no-op because the store has no atime column.
@@ -50,6 +50,7 @@ export interface WorkspaceFs {
5050
rm(path: string, options?: RmOptions): Promise<void>;
5151
chmod(path: string, mode: number): Promise<void>;
5252
symlink(target: string, path: string): Promise<void>;
53+
rename(oldPath: string, newPath: string): Promise<void>;
5354
}
5455

5556
// Matches the subset of just-bash's IFileSystem the adapter
@@ -213,10 +214,19 @@ export class WorkspaceFsAdapter {
213214
}
214215

215216
async mv(src: string, dest: string): Promise<void> {
216-
// The store doesn't have a native rename today, so model mv as
217-
// copy+delete. POSIX mv is atomic when src and dest live on
218-
// the same filesystem; this approach isn't, but it matches
219-
// what just-bash's other adapters do.
217+
// The store renames in one transaction, so an interrupted move can
218+
// no longer leave the bytes at both paths or a directory half
219+
// copied. A destination that rename refuses to replace — a
220+
// non-empty directory, or a directory and a non-directory in
221+
// either order — still falls back to copy-then-delete, which is
222+
// what the shell's own `mv` expects when it merges a tree.
223+
try {
224+
await this.#fs.rename(src, dest);
225+
return;
226+
} catch (err) {
227+
const code = (err as { code?: string }).code;
228+
if (code !== "ENOTEMPTY" && code !== "EISDIR" && code !== "ENOTDIR") throw err;
229+
}
220230
await this.cp(src, dest, { recursive: true });
221231
await this.#fs.rm(src, { recursive: true });
222232
}

packages/computer/src/stub.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,50 @@ describe("WorkspaceStub", () => {
259259
});
260260
});
261261

262+
it("fs.rename moves a file in one call", async () => {
263+
await withStub(async (ws) => {
264+
const stub = ws.stub();
265+
await ws.fs.writeFile("/old.txt", "payload");
266+
await stub.fs.rename("/old.txt", "/new.txt");
267+
expect(await ws.fs.readFile("/new.txt", "utf8")).toBe("payload");
268+
await expect(ws.fs.stat("/old.txt")).rejects.toMatchObject({ code: "ENOENT" });
269+
});
270+
});
271+
272+
it("fs.rename moves a directory subtree", async () => {
273+
await withStub(async (ws) => {
274+
const stub = ws.stub();
275+
await ws.fs.mkdir("/src/nested", { recursive: true });
276+
await ws.fs.writeFile("/src/nested/a.txt", "a");
277+
await stub.fs.rename("/src", "/dst");
278+
expect(await ws.fs.readFile("/dst/nested/a.txt", "utf8")).toBe("a");
279+
await expect(ws.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" });
280+
});
281+
});
282+
283+
it("fs.rename replaces an existing file and reports POSIX errors", async () => {
284+
await withStub(async (ws) => {
285+
const stub = ws.stub();
286+
await ws.fs.writeFile("/a.txt", "a");
287+
await ws.fs.writeFile("/b.txt", "b");
288+
await stub.fs.rename("/a.txt", "/b.txt");
289+
expect(await ws.fs.readFile("/b.txt", "utf8")).toBe("a");
290+
291+
await expect(stub.fs.rename("/missing", "/somewhere")).rejects.toMatchObject({
292+
code: "ENOENT",
293+
});
294+
295+
await ws.fs.mkdir("/dir");
296+
await ws.fs.writeFile("/dir/child.txt", "c");
297+
await ws.fs.writeFile("/file.txt", "f");
298+
await expect(stub.fs.rename("/file.txt", "/dir")).rejects.toMatchObject({ code: "EISDIR" });
299+
await ws.fs.mkdir("/other");
300+
await expect(stub.fs.rename("/other", "/dir")).rejects.toMatchObject({ code: "ENOTEMPTY" });
301+
await expect(stub.fs.rename("/dir", "/file.txt")).rejects.toMatchObject({ code: "ENOTDIR" });
302+
await expect(stub.fs.rename("/file.txt", "/")).rejects.toMatchObject({ code: "EINVAL" });
303+
});
304+
});
305+
262306
it("fs.stat propagates ENOENT for missing paths", async () => {
263307
await withStub(async (ws) => {
264308
const stub = ws.stub();

packages/computer/src/stub.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,19 @@ export class WorkspaceFilesystemStub extends RpcTarget {
268268
);
269269
}
270270

271+
// Move a path in one store operation. Replaces the copy-then-delete
272+
// dance callers used to write, so a failure can no longer leave both
273+
// ends behind. Overwrite and error behavior is documented in
274+
// docs/04_filesystem_interface.md.
275+
rename(oldPath: string, newPath: string): Promise<void> {
276+
return withSpan(
277+
this.#ws.observer,
278+
"workspace.fs.rename",
279+
{ "workspace.fs.path": oldPath, "workspace.fs.destination": newPath },
280+
() => this.#ws.fs.rename(oldPath, newPath),
281+
);
282+
}
283+
271284
chmod(path: string, mode: number): Promise<void> {
272285
return withSpan(
273286
this.#ws.observer,

packages/computer/tests/worker-backend.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ describe("WorkerShellBackend end-to-end", () => {
111111
expect(text).toBe("from inside the shell\n");
112112
});
113113

114+
it("moves a file with mv through the host's rename over Workers RPC", async () => {
115+
const id = freshId();
116+
await write(id, "/workspace/old.txt", "payload");
117+
// `test -e` on the source proves the adapter renamed rather than
118+
// copying and leaving both ends behind.
119+
const result = await exec(id, "mv old.txt new.txt && cat new.txt && test -e old.txt");
120+
expect(result.exitCode).not.toBe(0);
121+
expect(result.stdout).toBe("payload");
122+
expect(await read(id, "/workspace/new.txt")).toBe("payload");
123+
});
124+
114125
it("reports a non-zero exit code with stderr captured", async () => {
115126
const id = freshId();
116127
const result = await exec(id, "ls /nope 2>&1; echo done");

packages/dofs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Durable Object SQLite-backed virtual filesystem for Cloudflare Computer.
1515
This package exposes a JavaScript module, not a CLI. It bundles three layers that can be used independently:
1616

1717
- A `Database` wrapper around Durable Object SQL storage plus `initializeSchema` for the `vfs_*` tables.
18-
- Filesystem primitives under `src/fs/*` (`mkdir`, `writeFile`, `readFile`, `rm`, `readdir`, `stat`, `lstat`, `chmod`, `find`, `ls`, `grep`, `symlink`, `readlink`, `gc`, `watch`) operating on a `Database`.
18+
- Filesystem primitives under `src/fs/*` (`mkdir`, `writeFile`, `readFile`, `rm`, `rename`, `readdir`, `stat`, `lstat`, `chmod`, `find`, `ls`, `grep`, `symlink`, `readlink`, `gc`, `watch`) operating on a `Database`.
1919
- `SQLiteWorkspaceProvider`, a `@platformatic/vfs` adapter that composes those primitives into a node-shaped filesystem (fd table, positional `readSync`/`writeSync`, `watchSync`, symlinks). This is what `computerd` mounts via FUSE.
2020
- Sync protocol building blocks operating on the same `Database`: `applyChanges`, `stageBlob`, `materialiseChange`, `coalesceChanges`, `fetchChanges`, `fetchObjects`, `hasObjects`, `pushObjects`, `buildManifest`, `currentRev`, `compareChangeCursors`, `readWatermark`/`writeWatermark`, `assertAppliedPushCursor`, and the opt-in ignore matcher `isIgnored` (the default ignore list is empty). The wire wiring lives in `@cloudflare/computer-rpc`.
2121

packages/dofs/src/fs/filesystem.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@ describe("WorkspaceFilesystem", () => {
115115
});
116116
});
117117

118+
it("rename moves a file, a directory tree and a symbolic link", async () => {
119+
await withFs(async (fs) => {
120+
await fs.writeFile("/old.txt", "payload");
121+
await fs.rename("/old.txt", "/new.txt");
122+
expect(await fs.readFile("/new.txt", "utf8")).toBe("payload");
123+
await expect(fs.stat("/old.txt")).rejects.toMatchObject({ code: "ENOENT" });
124+
125+
await fs.mkdir("/tree/inner", { recursive: true });
126+
await fs.writeFile("/tree/inner/a.txt", "a");
127+
await fs.rename("/tree", "/moved");
128+
expect(await fs.readFile("/moved/inner/a.txt", "utf8")).toBe("a");
129+
await expect(fs.stat("/tree")).rejects.toMatchObject({ code: "ENOENT" });
130+
131+
await fs.symlink("/new.txt", "/link");
132+
await fs.rename("/link", "/link2");
133+
expect(await fs.readlink("/link2")).toBe("/new.txt");
134+
});
135+
});
136+
137+
it("rename replaces a file and reports the documented errors", async () => {
138+
await withFs(async (fs) => {
139+
await fs.writeFile("/a.txt", "a");
140+
await fs.writeFile("/b.txt", "b");
141+
await fs.rename("/a.txt", "/b.txt");
142+
expect(await fs.readFile("/b.txt", "utf8")).toBe("a");
143+
144+
await expect(fs.rename("/missing", "/elsewhere")).rejects.toMatchObject({ code: "ENOENT" });
145+
await expect(fs.rename("/b.txt", "/no/such/dir/b.txt")).rejects.toMatchObject({
146+
code: "ENOENT",
147+
});
148+
149+
await fs.mkdir("/full");
150+
await fs.writeFile("/full/child", "c");
151+
await fs.mkdir("/empty");
152+
await expect(fs.rename("/empty", "/full")).rejects.toMatchObject({ code: "ENOTEMPTY" });
153+
await expect(fs.rename("/b.txt", "/full")).rejects.toMatchObject({ code: "EISDIR" });
154+
await expect(fs.rename("/full", "/b.txt")).rejects.toMatchObject({ code: "ENOTDIR" });
155+
await expect(fs.rename("/b.txt", "/")).rejects.toMatchObject({ code: "EINVAL" });
156+
});
157+
});
158+
118159
it("chmod updates the stored mode", async () => {
119160
await withFs(async (fs) => {
120161
await fs.writeFile("/a", "hi");

packages/dofs/src/fs/filesystem.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { type MkdirOptions, mkdir } from "./mkdir.js";
2222
import { type ReaddirOptions, readdir, type WorkspaceDirentResult } from "./readdir.js";
2323
import { type ReadFileOptions, readFile } from "./readFile.js";
2424
import { readlink } from "./readlink.js";
25+
import { rename } from "./rename.js";
2526
import { type RmOptions, rm } from "./rm.js";
2627
import { lstat, stat, type WorkspaceStatResult } from "./stat.js";
2728
import { symlink } from "./symlink.js";
@@ -121,6 +122,21 @@ export class WorkspaceFilesystem {
121122
rm(this.db, path, options);
122123
}
123124

125+
// Move a file, directory or symbolic link in one transaction. An
126+
// existing destination is replaced when the two ends agree on kind:
127+
// a file or symbolic link replaces a file or symbolic link, and a
128+
// directory replaces an empty directory.
129+
//
130+
// Errors: ENOENT when the source is missing or the destination's
131+
// parent does not exist, ENOTEMPTY when the destination is a
132+
// non-empty directory, EISDIR when a non-directory would replace a
133+
// directory, ENOTDIR when a directory would replace a
134+
// non-directory, EINVAL for the root at either end or a directory
135+
// moved into itself, and EROFS under a read-only mount.
136+
async rename(oldPath: string, newPath: string): Promise<void> {
137+
rename(this.db, oldPath, newPath);
138+
}
139+
124140
// Change the permission bits on a path. Follows symlinks like
125141
// POSIX chmod — the change lands on the target, not the link.
126142
// The supplied mode is masked to twelve bits.

0 commit comments

Comments
 (0)