Best Practices for Using the dofs Package in Cloudflare Computer

Initialize the schema once per Durable Object, wrap operations in withDB for automated cleanup, use SQLiteWorkspaceProvider for Node-compatible filesystem calls, and always release pending-write buffers to ensure atomic persistence.

The dofs package provides the core virtual filesystem that powers Cloudflare Computer, enabling durable, SQLite-backed storage with sync capabilities across Durable Objects. Whether you're building a workspace manager, implementing cross-DO file synchronization, or integrating with the computerd daemon via FUSE, following these best practices ensures reliability, performance, and compatibility with the sync protocol.


Architecture Overview: Three Independent Layers

The @cloudflare/dofs package is organized into three layers that can be used separately or composed together:

  • Database layer (Database, initializeSchema): Thin wrapper around Durable Object SQLite storage with schema initialization
  • Filesystem primitives (src/fs/*): Low-level operations like mkdir, writeFile, readFile, symlink, watch
  • SQLiteWorkspaceProvider (src/provider.ts): Node-style fs API adapter compatible with @platformatic/vfs, used by the FUSE mount

Understanding this structure helps you choose the right abstraction for your use case.


Initialize the Schema Exactly Once Per Durable Object

Every dofs-backed Durable Object must call initializeSchema before any other API. The schema creates the vfs_* tables required by all filesystem operations.

import { Database, initializeSchema } from "@cloudflare/dofs";

export class WorkspaceDO extends DurableObject {
  private readonly db: Database;

  constructor(state: DurableObjectState, env: Env) {
    super(state, env);
    this.db = new Database(state.storage);
    // Use a stable timestamp source for deterministic revision stamps
    initializeSchema(this.db, Date.now);
  }
}

Critical: Calling any filesystem method before initializeSchema throws "missing table" errors. The Date.now parameter provides a deterministic revision source for sync operations—avoid random or unstable timestamps.

As implemented in packages/dofs/README.md, this pattern is the foundation for all dofs usage in Cloudflare Computer.


Use SQLiteWorkspaceProvider for Familiar Node.js fs Operations

For most applications, SQLiteWorkspaceProvider offers the most practical interface. It exposes methods matching Node's fs module while maintaining full compatibility with the sync protocol.

import { withDB } from "@cloudflare/dofs/src/fs/with-db";
import { SQLiteWorkspaceProvider } from "@cloudflare/dofs/src/provider";

async function setupWorkspace() {
  await withDB(async db => {
    const provider = new SQLiteWorkspaceProvider(db, { now: () => Date.now() });

    // Standard Node-style operations
    provider.mkdirSync("/project", { mode: 0o755 });
    provider.writeFileSync("/project/config.json", JSON.stringify({ version: 1 }));
    
    console.log(provider.readFileSync("/project/config.json", "utf8"));
  });
}

The withDB helper in src/fs/with-db.ts automatically tears down temporary SQLite storage, making it ideal for tests and short-lived operations. For production Durable Objects, instantiate SQLiteWorkspaceProvider directly with your persistent Database instance.

The comprehensive test suite in src/provider.test.ts validates all provider methods, including edge cases for renames, symlinks, and error conditions.


Leverage Pending-Write Buffers for Bulk Operations

Large file writes benefit from buffering to batch SQLite operations into single transactions. This pattern prevents intermediate partial states and improves performance.

await withDB(db => {
  const p = new SQLiteWorkspaceProvider(db, { now: () => Date.now() });

  // Open buffer for a new file
  p.openWriteBufferForCreateSync("/large.bin", { mode: 0o644 });
  
  // Write chunks at arbitrary offsets
  p.writeRangeSync("/large.bin", Buffer.from("header data"), 0);
  p.writeRangeSync("/large.bin", Buffer.from("body content"), 1024);
  p.writeRangeSync("/large.bin", Buffer.from("footer"), 8192);
  
  // Atomically commit all writes
  p.releaseWriteBufferSync("/large.bin");
});

The buffer automatically flushes when operations require stable inodes—such as renameSync, linkSync, or unlinkSync. The test "pending-create flush on rename/link/unlink" in src/provider.test.ts (lines 70-95) verifies this behavior.

Always call releaseWriteBufferSync (or the async releaseWriteBuffer). Forgetting this leaves data in memory only, causing subsequent reads to see stale content.


Respect Capability Flags Before Using Advanced Features

SQLiteWorkspaceProvider advertises its capabilities through boolean flags. Check these before conditionally using features:

const provider = new SQLiteWorkspaceProvider(db, { now: () => Date.now() });

if (!provider.readonly) {
  provider.writeFileSync("/writable.txt", "data");
}

if (provider.supportsSymlinks) {
  provider.symlinkSync("/target", "/link");
}

if (provider.supportsWatch) {
  // Set up filesystem watchers
}

These flags mirror the "capability flags" test assertions in src/provider.test.ts (lines 33-40). Respecting them ensures your code works across different mount configurations, including read-only or symlink-disabled environments.


The dofs package implements POSIX-compatible semantics with specific behaviors you must account for:

Hard links share inodes. linkSync creates a second directory entry with automatic reference counting:

p.writeFileSync("/original.txt", "shared content");
p.linkSync("/original.txt", "/hardlink.txt");
// Both paths reference the same inode; nlink === 2

Renames record atomic changes. Use coalesceChanges to fetch sync entries describing the mutation:

import { coalesceChanges } from "@cloudflare/dofs/src/sync/coalesce";

p.renameSync("/old/path", "/new/path");

const changes = [];
for await (const entry of coalesceChanges(db, cursor)) {
  changes.push(entry); // Contains delete at old path, live entry at new path
}

Self-move protection throws EINVAL when renaming a directory into its own subtree. This check uses inode identity, not string prefix matching—preventing false positives with similar path names.

All error codes (ENOENT, EEXIST, ENOTEMPTY, ENOTDIR, EISDIR, EINVAL) are exported from src/errors.ts. Wrap operations in try/catch blocks and inspect these codes for robust error handling.


Use the Sync Protocol for Cross-DO Coordination

Files modified in one Durable Object can synchronize to others through the src/sync/* modules:

import { applyChanges } from "@cloudflare/dofs/src/sync/apply";
import { pushObjects } from "@cloudflare/dofs/src/sync/push";

// After local modifications, push to remote DOs
await pushObjects(db, ["blobHash1", "blobHash2"], { now: Date.now });
await applyChanges(db); // Apply any incoming changes

The sync helpers share the same Database instance, ensuring atomic revision stamps via incrementRev in src/rev.ts. For production use, the @cloudflare/computer-rpc package provides high-level RPC wiring—use raw sync primitives only when you need custom coordination logic.


Avoid Common Pitfalls

Pitfall Symptom Solution
Writing to read-only mount EACCES or silent failures Verify mode !== "read-only"; use invalidateReadOnlyMountCache after mode changes
Unreleased write buffer Data never persists, reads see stale content Always call releaseWriteBufferSync after writes
Non-atomic overwrites Partial data loss on crash Use writeFileSync for small files; buffer + renameSync for large atomic updates
Missing withFileTypes readdirSync returns strings instead of Dirent objects Pass { withFileTypes: true } when you need rich file metadata

The test "renameSync overwrite evicts the displaced destination's buffer" in src/provider.test.ts (lines 56-78) demonstrates the atomic rename pattern for safe file updates.


Testing and Debugging

Run the package test suite to verify behavior contracts:

npm test --workspace @cloudflare/dofs

For custom logic, use the withProvider helper pattern from src/provider.test.ts. It creates isolated SQLiteWorkspaceProvider instances backed by in-memory SQLite, matching production semantics with fast test execution.

The src/testing.ts and src/testing-recording.ts modules provide SQLiteTestStorage and RecordingStorage for building mocks and integration tests.


Summary

  • Initialize once: Call initializeSchema before any dofs API in your Durable Object constructor
  • Use withDB for tests: Ensures automatic cleanup of temporary SQLite storage
  • Prefer SQLiteWorkspaceProvider: Node-compatible API with full sync protocol support
  • Buffer large writes: Use openWriteBufferForCreateSync + releaseWriteBufferSync for atomic batch operations
  • Check capabilities: Inspect readonly, supportsSymlinks, supportsWatch before using features
  • Handle errors properly: Catch and interpret codes from src/errors.ts
  • Leverage sync primitives: Use pushObjects, applyChanges, and coalesceChanges for cross-DO coordination

Frequently Asked Questions

How do I set up dofs in a new Durable Object?

Install @cloudflare/dofs, import Database and initializeSchema, create a Database instance from state.storage, and call initializeSchema with a stable timestamp source. This prepares the SQLite schema for all subsequent filesystem operations.

What's the difference between filesystem primitives and SQLiteWorkspaceProvider?

The primitives in src/fs/* provide low-level operations for advanced use cases. SQLiteWorkspaceProvider in src/provider.ts composes these into a Node.js fs-compatible API that's easier to use and integrates with @platformatic/vfs and FUSE mounts.

How do I synchronize files between Durable Objects?

Use the sync protocol from src/sync/*: pushObjects to send changes, applyChanges to receive them, and coalesceChanges to query mutation history. These share the same Database instance for atomic revision tracking. For production RPC, use @cloudflare/computer-rpc rather than raw primitives.

Why are my large file writes not persisting?

You likely opened a write buffer with openWriteBufferForCreateSync but forgot to call releaseWriteBufferSync. Data in the pending-write buffer stays in memory until released or automatically flushed by certain operations. Always explicitly release buffers when writes complete.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →