tinyreplay.
Architecture

SQLite Schema

The tables, migration scheme, and indexes used by tinyreplay.

Everything lives in one SQLite file (tinyreplay.db) opened in WAL mode with foreign keys on. The schema is three tables and a handful of indexes.

sessions

One row per recorded session. Counters are maintained as batches arrive.

CREATE TABLE sessions (
  id          TEXT PRIMARY KEY,
  project_id  TEXT NOT NULL,
  started_at  INTEGER NOT NULL,
  ended_at    INTEGER,
  duration_ms INTEGER GENERATED ALWAYS AS (
    CASE WHEN ended_at IS NOT NULL THEN ended_at - started_at ELSE NULL END
  ) VIRTUAL,
  url         TEXT NOT NULL,
  user_agent  TEXT,
  viewport_w  INTEGER,
  viewport_h  INTEGER,
  device_type TEXT,
  page_count  INTEGER NOT NULL DEFAULT 1,
  event_count INTEGER NOT NULL DEFAULT 0,
  error_count INTEGER NOT NULL DEFAULT 0,
  metadata_recording_order INTEGER NOT NULL DEFAULT 0,
  metadata_recording_instance_id TEXT NOT NULL DEFAULT '',
  created_at  INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000)
);

duration_ms is a generated column - it is never written, only derived from started_at and ended_at. URL, viewport, user agent, and lifecycle start metadata belong to the accepted recorder lifecycle with the greatest (recording_order, recording_instance_id) tuple. ended_at is the maximum observed activity time across every accepted batch, so delayed delivery cannot move it backward.

events

The raw rrweb stream, stored as JSON batches. A batch has its own delivery id for retries; recorder lifecycle order and seq reconstruct the replay order.

CREATE TABLE events (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id  TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  seq         INTEGER NOT NULL,
  batch_id    TEXT NOT NULL,
  recording_instance_id TEXT NOT NULL,
  recording_order INTEGER NOT NULL,
  events_json TEXT NOT NULL,
  received_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000)
);

Cascade delete

ON DELETE CASCADE means deleting a session row removes all of its event batches automatically - this is what makes retention a single DELETE on sessions.

Legacy SDK compatibility

Payloads without the three delivery/lifecycle fields remain accepted. The server assigns them a random delivery id, legacy-<sessionId> recorder id, and recording order 0. Consequently legacy batches always replay before new-client batches (which start at order 1) in a shared session. Legacy retries cannot be deduplicated because they have no stable delivery id.

Indexes

CREATE INDEX idx_events_session_id    ON events(session_id);
CREATE UNIQUE INDEX idx_events_session_batch_id ON events(session_id, batch_id);
CREATE INDEX idx_sessions_started_at  ON sessions(started_at DESC);
CREATE INDEX idx_sessions_project_id  ON sessions(project_id);

These cover the hot paths: load a session's events, list sessions newest-first, and filter by project.

settings

Small dashboard settings are stored by key.

CREATE TABLE settings (
  key   TEXT PRIMARY KEY,
  value TEXT NOT NULL
);

The current server uses this table for values such as a persisted retention override.

Migrations

Schema changes use SQLite's PRAGMA user_version as a monotonic counter. On open, every migration whose 1-based index exceeds the stored version runs inside a transaction, then the version is stamped.

V1 creates sessions, events, and the indexes.

V2 adds error_count and backfills it in pure SQL via JSON1, guarded by json_valid so a corrupt old batch can't abort the deploy.

V3 creates settings for persisted dashboard settings.

V4 rebuilds events with non-null delivery and recorder-order fields, copies every historical row with legacy identities, and adds the unique (session_id, batch_id) idempotency index. No historical event rows are deleted.

V5 records the lifecycle that owns each session summary. Existing rows use the highest retained recorder lifecycle, preventing future delayed batches from rolling metadata backward.

The rule: append new migrations, never edit shipped ones.

V4 operational note

V4 rebuilds events inside one write transaction. It can temporarily require substantial additional database/WAL disk space and holds a write lock for the copy. SQLite drops indexes and triggers attached to a table when that table is dropped. The migration recreates TinyReplay's application-managed idx_events_session_id and idx_events_session_batch_id indexes; TinyReplay currently has no application-managed events triggers. Unknown out-of-band indexes and triggers are intentionally not preserved and are unsupported customizations.

Next

How those JSON batches become a playable session: the replay engine.