Skip to content

storage: introduce StorageAdapter seam + FileAdapter refactor (Phase 1 of #139) #140

@willwashburn

Description

@willwashburn

Phase 1 of #139.

Goal

Introduce a StorageAdapter interface inside @relayburn/ledger and refactor the existing filesystem code to live behind a FileAdapter implementation. Zero behavior change — same JSONL output, same sidecars, same locks, byte-identical artifacts. This phase is purely the seam; no new adapter ships.

This is the foundation every other phase builds on, and the phase with the largest regression surface (it touches writer.ts, reader.ts, content.ts, lock.ts, archive.ts).

Interface

packages/ledger/src/adapters/adapter.ts:

export interface StorageAdapter {
  readonly kind: 'file' | 'sqlite' | 'postgres' | 'http';

  appendTurns(turns: TurnRecord[]): Promise<void>;
  appendCompactions(events: CompactionEvent[]): Promise<void>;
  appendRelationships(records: SessionRelationshipRecord[]): Promise<void>;
  appendToolResultEvents(events: ToolResultEventRecord[]): Promise<void>;
  appendUserTurns(records: UserTurnRecord[]): Promise<void>;
  appendStamp(stamp: StampLine): Promise<void>;
  appendContent(records: ContentRecord[]): Promise<void>;

  queryTurns(q: Query): AsyncIterable<EnrichedTurn>;
  queryCompactions(q: Query): AsyncIterable<CompactionEvent>;
  queryRelationships(q: Query): AsyncIterable<SessionRelationshipRecord>;
  queryToolResultEvents(q: Query): AsyncIterable<ToolResultEventRecord>;
  queryUserTurns(q: Query): AsyncIterable<UserTurnRecord>;
  readContent(selector: ReadContentSelector): AsyncIterable<ContentLine>;
  listContentSessionIds(): Promise<string[]>;
  pruneContent(opts: PruneOptions): Promise<PruneResult>;

  withLock<T>(name: string, fn: () => Promise<T>): Promise<T>;

  init(): Promise<void>;
  close(): Promise<void>;
}

packages/ledger/src/adapters/factory.ts exposes a process-singleton getAdapter() that resolves from RELAYBURN_STORAGE (defaults to file).

Refactor

  • New: packages/ledger/src/adapters/{adapter,factory,file-adapter}.ts.
  • Modified: the public functions in writer.ts, reader.ts, content.ts, lock.ts become 1-line wrappers delegating to getAdapter(). Their internal helpers (appendLines, streamLines, loadIndex, FS lock impl) move into FileAdapter.
  • Modified: archive.ts becomes FileAdapter-internal — it's a JSONL-specific read cache and isn't part of the cross-adapter contract. Its public functions (buildArchive, openArchive, getArchiveStatus, rebuildArchive) stay re-exported for backwards compat but become no-ops on non-file adapters (relevant only in later phases).
  • Reused unchanged: index-sidecar.ts (pure dedup-hash functions used by every adapter), schema.ts (record types), cursors.ts/hwm.ts/plans.ts/config.ts (host-local state, stays on the filesystem).
  • Modified: index.ts re-exports StorageAdapter, getAdapter, the adapter-kind type.

Verification

  • All existing pnpm test suites (ledger.test.ts, content.test.ts, lock.test.ts, archive.test.ts, cursors.test.ts, plans.test.ts) pass unchanged — they exercise the FileAdapter through the unchanged public API.
  • relayburn ingest against a real ~/.claude directory produces a byte-identical ledger.jsonl and content/ tree before vs. after the refactor.
  • relayburn analyze --json returns identical output.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions