Custom adapter

Implement IStorageAdapter to use any storage backend with @alineo-labs/sandbox.

IStorageAdapter is the interface that both SQLiteAdapter and PostgresAdapter implement. You can implement it yourself to use any storage backend — Redis, DynamoDB, a REST API, etc.

Interface

export interface IStorageAdapter {
  connect?(): Promise<void>;
  close?(): Promise<void>;
  append(entry: LedgerEntry): Promise<void>;
  readAll(name: string, sandboxId: string): Promise<LedgerEntry[]>;
  lastCheckpoint(name: string, sandboxId: string): Promise<LedgerEntry | null>;
  listSandboxDetails(name: string, opts?: ListSandboxOptions): Promise<SandboxDetails[]>;
  listAllSandboxDetails(opts?: ListSandboxOptions): Promise<SandboxDetails[]>;
  getSandboxDetails(name: string, sandboxId: string): Promise<SandboxDetails | null>;
  deleteSandbox(name: string, sandboxId: string): Promise<void>;
  listCheckpoints(name: string, sandboxId: string): Promise<CheckpointInfo[]>;
  getEnvironment(name: string): Promise<EnvironmentRecord | null>;
  saveEnvironment(record: EnvironmentRecord): Promise<void>;
  deleteEnvironment(name: string): Promise<void>;
  listEnvironments(): Promise<EnvironmentRecord[]>;
}

Import types from @alineo-labs/core:

import type {
  IStorageAdapter,
  LedgerEntry,
  LedgerEvent,
  SandboxDetails,
  ListSandboxOptions,
  CheckpointInfo,
  EnvironmentRecord,
} from "@alineo-labs/core";

Method reference

MethodRequiredDescription
connect()NoInitialize connections. Called lazily, once, the first time the client needs the adapter — not by any method you call yourself.
close()NoRelease connections. Called when the event loop drains naturally (beforeExit) — long-running servers never reach this, so the pool stays open for the process lifetime by design.
append(entry)YesPersist a single ledger event. Called during every live exec.
readAll(name, sandboxId)YesReturn all events for a session in ascending ts order. Used by resume().
lastCheckpoint(name, sandboxId)YesReturn the most recent sandbox.checkpoint_created entry, or null.
listSandboxDetails(name, opts?)YesReturn sessions with this name, newest first.
listAllSandboxDetails(opts?)YesReturn all sessions across all names, newest first.
getSandboxDetails(name, sandboxId)YesReturn a single session, or null.
deleteSandbox(name, sandboxId)YesDelete all events for a session.
listCheckpoints(name, sandboxId)YesReturn all checkpoints for a session, in creation order.
getEnvironment(name)YesReturn the cached record for a named environment, or null if not built yet.
saveEnvironment(record)YesUpsert an environment record after a successful build.
deleteEnvironment(name)YesRemove the record for a named environment. Does not delete the server-side snapshot.
listEnvironments()YesReturn all environment records, newest first.

LedgerEntry

interface LedgerEntry {
  ts: number; // Unix timestamp in milliseconds
  name: string; // sandbox name
  sandboxId: string;
  stepIndex: number; // -1 for sandbox-level events
  branch?: number; // parallel branch index
  event: LedgerEvent;
  payload?: unknown; // event-specific data
  error?: string;
}

Example: in-memory adapter

Useful for testing:

import type {
  IStorageAdapter,
  LedgerEntry,
  SandboxDetails,
  ListSandboxOptions,
  CheckpointInfo,
  EnvironmentRecord,
} from "@alineo-labs/core";
import { SandboxStatus, LedgerEvent } from "@alineo-labs/core";

export class MemoryAdapter implements IStorageAdapter {
  private readonly _events: LedgerEntry[] = [];
  private readonly _environments = new Map<string, EnvironmentRecord>();

  async append(entry: LedgerEntry): Promise<void> {
    this._events.push(entry);
  }

  async readAll(name: string, sandboxId: string): Promise<LedgerEntry[]> {
    return this._events
      .filter((e) => e.name === name && e.sandboxId === sandboxId)
      .sort((a, b) => a.ts - b.ts);
  }

  async lastCheckpoint(name: string, sandboxId: string): Promise<LedgerEntry | null> {
    const checkpoints = (await this.readAll(name, sandboxId)).filter(
      (e) => e.event === LedgerEvent.CheckpointCreated,
    );
    return checkpoints.at(-1) ?? null;
  }

  async listSandboxDetails(name: string, opts?: ListSandboxOptions): Promise<SandboxDetails[]> {
    return (await this.listAllSandboxDetails(opts)).filter((d) => d.name === name);
  }

  async listAllSandboxDetails(opts?: ListSandboxOptions): Promise<SandboxDetails[]> {
    const byId = new Map<string, LedgerEntry[]>();
    for (const e of this._events) {
      const key = e.sandboxId;
      if (!byId.has(key)) byId.set(key, []);
      byId.get(key)!.push(e);
    }

    let details: SandboxDetails[] = [];
    for (const [sandboxId, events] of byId) {
      const created = events.find((e) => e.event === LedgerEvent.SandboxCreated);
      const closed = events.find((e) => e.event === LedgerEvent.SandboxClosed);
      if (!created) continue;
      details.push({
        name: created.name,
        sandboxId,
        status: closed ? SandboxStatus.Completed : SandboxStatus.Running,
        startedAt: created.ts,
        completedAt: closed?.ts,
        execCount: events.filter((e) => e.event === LedgerEvent.ExecComplete).length,
      });
    }

    details.sort((a, b) => b.startedAt - a.startedAt);
    if (opts?.status) details = details.filter((d) => d.status === opts.status);
    if (opts?.before) details = details.filter((d) => d.startedAt < opts.before!);
    if (opts?.limit) details = details.slice(0, opts.limit);
    return details;
  }

  async getSandboxDetails(name: string, sandboxId: string): Promise<SandboxDetails | null> {
    const all = await this.listAllSandboxDetails();
    return all.find((d) => d.name === name && d.sandboxId === sandboxId) ?? null;
  }

  async deleteSandbox(name: string, sandboxId: string): Promise<void> {
    const toRemove = this._events
      .filter((e) => e.name === name && e.sandboxId === sandboxId)
      .map((e) => this._events.indexOf(e));
    for (const idx of toRemove.reverse()) {
      this._events.splice(idx, 1);
    }
  }

  async listCheckpoints(name: string, sandboxId: string): Promise<CheckpointInfo[]> {
    const events = await this.readAll(name, sandboxId);
    return events
      .filter((e) => e.event === LedgerEvent.CheckpointCreated)
      .map((e) => {
        const payload = e.payload as { snapshotId: string; name?: string };
        return { snapshotId: payload.snapshotId, tag: payload.name, createdAt: e.ts };
      });
  }

  async getEnvironment(name: string): Promise<EnvironmentRecord | null> {
    return this._environments.get(name) ?? null;
  }

  async saveEnvironment(record: EnvironmentRecord): Promise<void> {
    this._environments.set(record.name, record);
  }

  async deleteEnvironment(name: string): Promise<void> {
    this._environments.delete(name);
  }

  async listEnvironments(): Promise<EnvironmentRecord[]> {
    return [...this._environments.values()].sort((a, b) => b.builtAt - a.builtAt);
  }
}

Using a custom adapter

Pass it to Sandbox the same way as the built-in adapters:

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new MemoryAdapter(),
});
// No connect() call needed — the adapter is used as soon as the client is.