Tide

Steps

Transactional step functions for atomic operations

What Steps Do

Steps group multiple operations into an atomic unit. Either all operations in a step succeed and are committed together, or the step fails and all changes are rolled back. On replay, completed steps return their stored result instantly.

const result = await step("write-files", async () => {
  await writeFile("hello.txt", "Hello from step!");
  await writeFile("world.txt", "World from step!");
  return { hello: "Hello from step!", world: "World from step!" };
});

How Steps Work

Outside a step, each operation is individually committed to the journal. Inside a step, operations are buffered in a pending transaction. This means the journal goes from having individually committed entries to having a batch committed atomically.

Step Lifecycle (First Run):

  1. Snapshot VFS -- The current state of the Virtual File System is saved in memory. This is the rollback point if the step fails.
  2. Record op_step_begin -- Committed to journal immediately (marks step start)
  3. Execute step body -- All operations inside the step are buffered (journal entries + VFS changes)
  4. On success: Record op_step_complete with the return value, then commit ALL buffered entries atomically
  5. On failure: Discard all buffered entries, restore VFS from the pre-step snapshot, re-throw the error (or retry if retries configured)

Journal Structure

op_step_begin    {"step": "write-files"}           -- committed immediately
op_write_file    null                               -- buffered
op_write_file    null                               -- buffered
op_read_file     "Hello from step!"                 -- buffered
op_read_file     "World from step!"                 -- buffered
op_step_complete {"step": "write-files", "value": {...}} -- committed with all above

Step Replay

On replay, when a completed step is encountered:

  1. op_step_begin is consumed from journal, step name is verified
  2. Tide finds the matching op_step_complete by scanning forward
  3. The step body re-executes, with each internal operation replaying from the journal
  4. op_step_complete is consumed, in_step is cleared

Retries

Steps support automatic retries on failure:

const result = await step("call-api", async () => {
  const data = await someOperation();
  await writeFile("result.json", JSON.stringify(data));
  return data;
}, { retries: 3 });

When retries is set:

  • If the step body throws, the step is rolled back (VFS restored, buffered entries discarded)
  • The step re-enters from op_step_begin and the body executes again
  • This continues up to retries times
  • If all retries are exhausted, the error is re-thrown
  • Only the successful attempt survives in the journal

The StepOptions interface:

interface StepOptions {
  retries?: number;  // Maximum retry attempts on failure
}

Crash Recovery

If the process crashes inside a step:

  • The op_step_begin was committed, but the buffered operations were NOT committed
  • On restart, replay processes all entries before the step normally
  • When op_step_begin is reached during replay, Tide scans forward for op_step_complete
  • If no op_step_complete is found: the step never completed (crash recovery)
    • The journal is truncated to before the op_step_begin
    • Replay mode ends, execution goes live
    • The step re-executes from scratch
  • This is safe because the failed step's buffered entries were never persisted

Nested Steps

Nested steps are not supported. Calling step() inside another step() throws:

Nested steps are not supported

VFS and Steps

The VFS snapshot taken at step start captures the full in-memory filesystem state. On rollback:

  1. All VFS changes made during the step are discarded
  2. The VFS is replaced with a fresh MemoryFS hydrated from the snapshot
  3. The workflow continues with the filesystem in its pre-step state

Step Return Values

The step's return value is serialized with JSON.parse(JSON.stringify(result)) to ensure it's JSON-serializable. The serialized value is stored in the op_step_complete journal entry. On replay, this stored value is what gets returned.

Example: Chained Steps

// Step 1: Write files
const result = await step("write-files", async () => {
  await writeFile("hello.txt", "Hello from step!");
  await writeFile("world.txt", "World from step!");
  const h = await readFile("hello.txt");
  const w = await readFile("world.txt");
  return { hello: h, world: w };
});

console.log("Step result:", JSON.stringify(result));

// Files persist after the step
const hello = await readFile("hello.txt");

// Step 2: Build on previous state
const count = await step("count-files", async () => {
  const h = await readFile("hello.txt");
  const w = await readFile("world.txt");
  return { fileCount: 2, combined: h + " " + w };
});

// Step 3: Sleep inside a step
await step("timed-step", async () => {
  await sleep(500);
  return "done";
});

On this page