npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@replayio-app-building/session-recorder

v0.17.1

Published

Capture browser sessions and create Replay recordings via simulation

Readme

@replayio-app-building/session-recorder

Capture browser sessions and create Replay recordings via simulation. This package provides a client-side API for capturing DOM events in the browser and a server-side API for storing session data, triggering recording creation, and managing recordings. It also includes a simulate-from-a-recording API for recreating a new recording from any existing base recording — replaying its session data (extracted or reconstructed) while gathering rrweb and injected-script data.

Installation

npm install @replayio-app-building/session-recorder

Client API

Import from @replayio-app-building/session-recorder.

Early initialization

The session recorder intercepts localStorage, indexedDB, fetch, and WebSocket via proxies. If your app reads from localStorage at module scope (e.g. to restore an auth token), those reads happen during JS module evaluation — before any function call in your entry file can run. To ensure the recorder captures these reads, import the init entry point before any application modules:

// main.ts — init MUST be the first import
import "@replayio-app-building/session-recorder/init";
import { store } from "./store"; // module-scope localStorage reads are now captured
import App from "./App";

The init import installs all capture proxies as a side effect. You still call startSession() later to get a session handle.

startSession(options?): SessionHandle

Begins capturing DOM events (mouse, keyboard, scroll, resize, mutations) in the current browser window. Returns a handle for retrieving the captured data. Calls initializeCapture() internally if it hasn't been called yet (e.g. via the init import).

import "@replayio-app-building/session-recorder/init";
import { startSession } from "@replayio-app-building/session-recorder";

const session = startSession();
// ... user interacts with the page ...
const data = session.getSessionData();

Options (StartSessionOptions, all optional):

  • captureSourceMaps?: booleanDefault false. When false, sourceMappingURL comments in captured text responses are not followed. Source maps are never requested by the page itself and carry no page-reconstruction value, so following them by default would charge every recorded session a needless multi-MB max-age=0 download. Set to true only if a downstream tool genuinely needs the maps inlined per session. Passing this to a later startSession() still takes effect even if initializeCapture() already ran via the /init import.

Returns: SessionHandle with:

  • sessionId — A UUID identifying this page-load capture session. Stable for the tab's lifetime. Do not reuse it for more than one upload — each recording must have its own id, otherwise the second upload collides on the server's session_recorder_data primary key and is rejected. Use snapshot() instead.
  • getSessionData() — Returns the captured SimulationData (array of SimulationPacket events)
  • snapshot() — Returns { sessionId, sessionData, releasedCount } where sessionId is a fresh UUID minted on every call, sessionData is the cumulative capture buffer up to the current endpoint, and releasedCount is the number of packets already dropped via release() before this snapshot (0 unless you use release()). Call this once per bug report / upload so every recording is stored under its own unique id while the single page-load buffer keeps accumulating.
  • release?(count)Optional / opt-in. Drops the first count packets — which you have already durably uploaded — from the front of the capture buffer and returns the new buffer length. Use this only in a streaming/continuous-upload consumer that POSTs data as it goes and wants memory to stay flat over a long session; it keeps the buffer from growing without bound. After releasing, snapshot().releasedCount reports how many packets preceded the returned buffer so the truncation is explicit rather than silent. Most consumers (one snapshot() per bug report) never need this.

Recording multiple sessions in one page load

startSession() mints one long-lived capture buffer per page load. A user may file several bug reports without reloading — each is a distinct recording (page-load → T1, page-load → T2) covering a different window, so each needs its own id. Take a snapshot() per report:

const session = startSession();

// First bug report — its own id, cumulative data so far.
const a = session.snapshot();
await uploadSession(a.sessionId, a.sessionData);

// ... user keeps interacting (the same buffer keeps growing) ...

// Second bug report — a NEW id, the now-larger cumulative buffer.
const b = session.snapshot();
await uploadSession(b.sessionId, b.sessionData);

Reusing session.sessionId for both uploads instead would make the second upload collide on the session_recorder_data primary key (the server answers 409 Conflict) and lose that recording.

markEndpoint(): void

Marks the end of the session recording. When getSessionData() is called, only events captured before the endpoint are included. This is useful when the interesting part of a session ends before the page is closed — for example, after a form submission completes or a checkout flow finishes.

import { startSession, markEndpoint, uploadSession } from "@replayio-app-building/session-recorder";

const session = startSession();
// ... user completes the checkout flow ...
markEndpoint();
// ... page may continue with confirmation animations, etc. ...
const data = session.getSessionData(); // only includes events before markEndpoint()
await uploadSession(session.sessionId, data);

If markEndpoint() is never called, getSessionData() returns all captured events as before. Calling markEndpoint() again moves the endpoint to the current position, so the last call wins.

uploadSession(sessionId, sessionData, endpoint?): Promise<{ session_id: string }>

Compresses and uploads captured session data to the server. The data is gzip-compressed before sending.

import "@replayio-app-building/session-recorder/init";
import { startSession, uploadSession } from "@replayio-app-building/session-recorder";

const session = startSession();
// ... capture interactions ...
const data = session.getSessionData();
const result = await uploadSession(session.sessionId, data);
console.log(result.session_id); // UUID of the stored session

Parameters:

  • sessionId — The session UUID (from startSession().sessionId)
  • sessionData — The captured SimulationData array
  • endpoint — Upload URL (default: "/api/upload-session")

Large sessions: uploadSession sends the whole gzipped session in one POST, which the platform rejects before the handler runs once the body exceeds its request-body cap. Any page with sizeable assets/responses can hit this. For those, use uploadSessionStreaming — it uploads the complete session in ordered sub-cap chunks (no data dropped) and stores the identical artifact.

uploadSessionStreaming(sessionId, sessionData, options?): Promise<{ session_id: string }>

Streaming alternative to uploadSession. uploadSession sends the whole gzipped session in a single POST, so sessions larger than the platform's synchronous request-body cap (~6 MB on Netlify) fail before the handler runs. uploadSessionStreaming removes that ceiling by splitting the gzipped payload into ordered sub-cap chunks: it first POSTs ?phase=init declaring the total compressed size, then POSTs each slice to ?phase=chunk in order with the last flagged final=true. The server buffers the slices and performs a single UploadThing upload once the final chunk arrives, storing the same single data_url as the non-streaming path — the stored artifact is identical.

import { startSession, uploadSessionStreaming } from "@replayio-app-building/session-recorder";

const session = startSession();
const data = session.getSessionData();
const result = await uploadSessionStreaming(session.sessionId, data, {
  chunkSize: 3 * 1024 * 1024, // optional; defaults to 3 MB
});

Parameters:

  • sessionId — The session UUID
  • sessionData — The captured SimulationData array
  • options.endpoint? — Upload URL (default: "/api/upload-session")
  • options.chunkSize? — Bytes per chunk POST; must stay under the platform's request-body cap (default: 3 MB, a safe margin below the measured ceiling where ~4.13 MB passes and ~4.64 MB fails). Larger sessions upload as more chunks — no data is dropped.

Served by the same createUploadSessionHandler endpoint (see Chunked / streaming upload). Requires the session_upload_chunks table.

Types

  • StartSessionOptions{ captureSourceMaps?: boolean } (all optional; passed to startSession())
  • CaptureConfig{ captureSourceMaps: boolean } (resolved capture config; StartSessionOptions is Partial<CaptureConfig>)
  • SessionHandle{ sessionId: string; getSessionData: () => SimulationData; snapshot: () => SessionSnapshot; release?: (count: number) => number }
  • SessionSnapshot{ sessionId: string; sessionData: SimulationData; releasedCount: number } (fresh id per snapshot() call)
  • SimulationData — Array of SimulationPacket events
  • SimulationPacket — Individual captured DOM event

Server API

Import from @replayio-app-building/session-recorder/server. All handler factories return (req: Request) => Promise<Response> functions suitable for use in Netlify Functions v2, Cloudflare Workers, or any platform with Web API Request/Response.

Each factory accepts a sql parameter — a Neon-style tagged-template SQL function:

type SqlFunction = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => Promise<unknown[]>;

createUploadSessionHandler(options)

Handles POST requests to store captured session data. Accepts JSON or gzip-compressed binary payloads. Compresses data with gzip, uploads it to UploadThing, and stores the resulting single UploadThing URL (https://<appId>.ufs.sh/f/<key>) in the session_recorder_data table. Requires the UPLOADTHING_TOKEN environment variable.

import { createUploadSessionHandler } from "@replayio-app-building/session-recorder/server";

const handler = createUploadSessionHandler({ sql });

Options:

  • sql — SQL function
  • authenticate?(req: Request) => Promise<boolean> — optional auth check

Request body (JSON): { session_id?: string, sessionData: unknown[] } Request body (binary): gzip-compressed JSON with the same shape

Response: { session_id: string } (201)

Resumable upload to UploadThing

The upload drives UploadThing's documented ranged ingest protocol directly (using generateKey / generateSignedURL from @uploadthing/shared) rather than the higher-level UTApi.uploadFiles helper, which issued a single non-resumable PUT and failed hard on large sessions.

Flow:

  1. Decode UPLOADTHING_TOKEN (base64 JSON { apiKey, appId, regions, ingestHost? }) and build the ingest base https://<regions[0]>.<ingestHost ?? "ingest.uploadthing.com">.
  2. Presign <ingestBase>/<key>, committing the total gzipped size up front via the x-ut-file-size header (required for ranged uploads).
  3. PUT the file in one request (Range: bytes=0-, body is the gzipped blob as multipart/form-data). The completing PUT returns JSON containing ufsUrl, which is stored as the data_url.
  4. Self-healing resume: on any PUT failure or timeout, the handler issues a HEAD to read x-ut-range-start (the bytes the server has already committed) and re-PUTs only the remaining tail from that offset, retrying up to a bounded number of times. A transient failure that commits partial bytes therefore shrinks the tail on the next attempt instead of failing the whole upload.

Protocol note: the ingest endpoint expects the whole file (or the whole remaining tail on resume) in a single PUT. It does not accept a stream of fixed-size sequential chunks — a second chunk against the same key is rejected with 409 File already exists. The resume mechanism above is the correct way to recover from a partial upload.

The stored output is identical to before (one UploadThing data_url), so createSessionRecordingHandler, the session_recorder_data schema, and any downstream recorder service are unaffected.

Chunked / streaming upload

The same handler also accepts a chunked protocol (driven by the client's uploadSessionStreaming), which removes the client→server request-body ceiling. It is selected by a phase query parameter; without one, the handler uses the backward-compatible single-POST path above.

  • POST …/upload-session?phase=init — JSON body { session_id, total_size }. Clears any stale partial upload for that session and acks.
  • POST …/upload-session?phase=chunk&session_id=…&index=N&offset=…&total_size=…&final=true|false — body is the raw gzipped slice (application/octet-stream, no Content-Encoding). Each slice is buffered (base64) into session_upload_chunks keyed by (session_id, chunk_index).

On the chunk flagged final=true, the buffered slices are read in order, concatenated into the complete gzipped blob, uploaded to UploadThing in a single whole-file upload (the same uploadToUploadThing path — because the ingest endpoint rejects incremental sequential PUTs, per the protocol note above), stored as the single data_url, and the buffer rows are deleted. The 201 response is the same { session_id }. Errors map to a 500, leaving the buffered chunks in place so a re-init + resend can retry.

Requires a session_upload_chunks table (see Database schema).

createSessionRecordingHandler(options)

Handles POST requests to create a Replay recording from a stored session. Updates the session_recorder_data row status and dispatches the work to a background recording process.

When sessionRecorderUrl is provided, delegates recording to an external session recorder service (e.g. this app's deployed instance) via its /api/create-recording and /api/get-session endpoints.

import { createSessionRecordingHandler } from "@replayio-app-building/session-recorder/server";

const handler = createSessionRecordingHandler({
  sql,
  baseUrl: "https://my-app.netlify.app",
  sessionRecorderUrl: "https://session-recorder-si6nol.netlify.app",
  pollTimeout: 60000,
});

Options:

  • sql — SQL function
  • baseUrl — Base URL of the calling app (used for webhook URLs)
  • authenticate? — Optional auth check
  • pollTimeout? — If set, the handler polls for completion up to this many milliseconds before returning. Without this, returns immediately with status 201.
  • sessionRecorderUrl? — Base URL of an external session recorder service. When set, recording is delegated to that service.

Request body: { session_id: string, check_description?: string }

  • session_id — The session UUID to create a recording from
  • check_description — Optional description of something that should be visible in both the original rrweb session data and the resulting simulation recording (e.g. a bug the user encountered). When provided, the recording pipeline verifies that the described behavior appears in the rrweb DOM snapshots and in the simulation recording. If the behavior is present in the rrweb data but missing from the simulation, a bug report is filed automatically.

Response: The session_recorder_data row as JSON (201 if returned immediately, 200 if poll completed)

External service protocol

When sessionRecorderUrl is provided, the handler:

  1. POSTs to {sessionRecorderUrl}/api/create-recording with:
    {
      "sessionId": "<uuid>",
      "sessionBlobUrl": "{baseUrl}/api/session-data/{sessionId}",
      "webhookUrl": "{baseUrl}/api/recording-complete?sessionId={sessionId}",
      "checkDescription": "<optional check_description from the request>"
    }
  2. If pollTimeout is set, polls {sessionRecorderUrl}/api/get-session?sessionId={sessionId} every 2 seconds until the session reaches a terminal status or the timeout expires.
  3. When the recording completes, the service calls the webhookUrl with the result.

createRecordingCompleteHandler(options)

Handles POST webhook callbacks when a recording finishes. Updates the session_recorder_data and recording_tasks tables.

import { createRecordingCompleteHandler } from "@replayio-app-building/session-recorder/server";

const handler = createRecordingCompleteHandler({ sql });

Options:

  • sql — SQL function
  • authenticate? — Optional auth check

Query parameter: sessionId (required) — the session ID

Request body:

  • { status: "recorded", recordingId: "<replay-recording-id>" } — on success
  • { status: "failed", error: "message" } — on failure

If the session has a caller_webhook_url, the handler forwards the completion payload to that URL via POST (best-effort).

createRecordingsHandler(options)

Handles GET requests to list or retrieve recordings, and DELETE requests to remove a recording.

import { createRecordingsHandler } from "@replayio-app-building/session-recorder/server";

const handler = createRecordingsHandler({ sql });

Options:

  • sql — SQL function
  • authenticate? — Optional auth check (applied to both GET and DELETE)

Routes:

  • GET /api/recordings — Lists all recordings (newest first) with data size info
  • GET /api/recordings/{sessionId} — Returns a single recording by session ID
  • DELETE /api/recordings/{sessionId} — Deletes a recording and every row keyed to the same session (recording logs, recording tasks, and any partial upload chunks). Bug reports are intentionally preserved. Returns { session_id, deleted: true }, or 404 if the recording does not exist.

createSessionDataHandler(options)

Handles GET requests to retrieve stored session data for a given session ID. Decompresses gzip data URLs before returning.

import { createSessionDataHandler } from "@replayio-app-building/session-recorder/server";

const handler = createSessionDataHandler({ sql });

Options:

  • sql — SQL function
  • authenticate? — Optional auth check

Route: GET /api/session-data/{sessionId}

Response: { session_id, data_url } — where data_url is a base64-encoded JSON data URL

Simulate from a recording

These two factories implement the "recreate a recording from a base recording" feature: given any Replay recording id (the base) plus an optional injected script, the pipeline

  1. harvests the base recording's session data — read from the session-recorder buffer if this package was running in the original page, otherwise reconstructed from the recording's network requests, clicks, and page state via Replay's time-travel,
  2. replays that session data to create a new Replay recording, and
  3. gathers two extra artifacts while doing so — the rrweb DOM event stream and whatever the injected script collected.

createSimulateRecordingHandler owns the HTTP + persistence surface over the recording_simulations table; the harvest and browser simulation run out-of-band (you trigger them from onSimulationCreated) and report their artifacts back through createSimulationCompleteHandler. This keeps the heavy, environment-specific work (reading a recording over the Replay protocol needs a raw Replay API key; running the browser simulation needs a worker) out of the package, while the package guarantees a consistent API and row shape.

POST /api/simulate-recording        { recordingId, injectedScript? }  -> { id }          (201)
GET  /api/simulate-recording        -> { simulations: [ ...summaries ] }
GET  /api/simulate-recording?id=ID  -> { simulation: { ...full row incl. artifacts } }
POST /api/simulation-complete?id=ID  { status, new_recording_id, rrweb_events, ... } -> { ok: true }

createSimulateRecordingHandler(options)

CRUD for recording simulations. A POST inserts a 'pending' row and fires onSimulationCreated, where you kick off your harvest + recording pipeline (e.g. POST to a background function). GET lists summaries (the heavy artifact columns are omitted); GET ?id=ID returns the full row including the stored artifacts.

import { createSimulateRecordingHandler } from "@replayio-app-building/session-recorder/server";

const handler = createSimulateRecordingHandler({
  sql,
  // Applied to POST (create) only; GET reads are left open.
  authenticate: async (req) => (await getUser(req)).authenticated,
  onSimulationCreated: async (simulationId, req) => {
    const base = new URL(req.url).origin;
    await fetch(`${base}/api/reconstruct-session-background`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ simulationId }),
    });
  },
});

Options:

  • sql — SQL function
  • authenticate? — Optional auth check, applied to the POST (create) path only
  • onSimulationCreated?(simulationId: string, req: Request) => Promise<void> | void. Called after the row is inserted, to start the harvest/recording pipeline. Best-effort: a thrown error is logged and swallowed so a trigger failure never fails the create.

POST body: { recordingId: string, injectedScript?: string }

  • recordingId — The base Replay recording id to recreate from (required)
  • injectedScript — Optional function body that runs in the recorded page during the new recording. It receives a stream argument (stream.push(type, payload), stream.onFlush(fn)); whatever it emits is stored as the simulation's injected_data artifact.

Responses: { id } (201) on create; { simulations } / { simulation } on GET; 404 for an unknown id; 401 if auth fails; 400 for a missing recordingId or invalid JSON.

createSimulationCompleteHandler(options)

Webhook the simulation worker calls when it finishes. Stores the four artifacts (or the failure) on the recording_simulations row. The body may be plain JSON or a gzipped JSON blob (Content-Encoding: gzip, or an octet-stream / gzip content type) — large rrweb streams compress well, so workers typically gzip it.

import { createSimulationCompleteHandler } from "@replayio-app-building/session-recorder/server";

const handler = createSimulationCompleteHandler({ sql });

Options:

  • sql — SQL function
  • authenticate? — Optional auth check

Query parameter: id (required) — the simulation id

Request body (SimulationReport):

  • status"complete" (default) or "failed"
  • new_recording_id — id of the freshly created Replay recording
  • session_data — the base recording's harvested session data (extracted or reconstructed)
  • rrweb_events — rrweb DOM events captured while creating the new recording
  • injected_data — whatever the injected script collected (omitted when no script ran)
  • session_packet_count, rrweb_event_count, injected_event_count — counts shown in list summaries
  • error_message — failure detail (with status: "failed")

Response: { ok: true }

The SimulationReport type is exported from @replayio-app-building/session-recorder/server.

Database Schema

The server handlers expect two tables: session_recorder_data and recording_tasks. Both are operated on through the sql function you pass in, so both must exist in the consumer's database — not just in the recording service. Two further tables are needed only for specific features: session_upload_chunks for the chunked / streaming upload path, and recording_simulations for the simulate-from-a-recording handlers.

session_recorder_data

| Column | Type | Description | |--------|------|-------------| | session_id | TEXT (PK) | UUID identifying the captured session | | data_url | TEXT | UploadThing URL pointing to gzip-compressed session data | | status | TEXT | 'pending', 'queued', 'processing', 'complete', or 'failed' | | recording_id | TEXT | Replay recording ID (set when complete) | | error_message | TEXT | Error details (set when failed) | | check_description | TEXT | Optional description of expected behavior to verify in the recording | | caller_webhook_url | TEXT | Webhook URL to notify when recording completes (set by external callers) | | created_at | TIMESTAMPTZ | Row creation time | | updated_at | TIMESTAMPTZ | Last update time |

recording_tasks

createSessionRecordingHandler inserts a row into this table when a recording is requested — on both the local-pipeline path and the external-service path (sessionRecorderUrl) — and createRecordingCompleteHandler updates it from the webhook. It must exist in the consumer's database; without it, createSessionRecordingHandler fails with relation "recording_tasks" does not exist.

| Column | Type | Description | |--------|------|-------------| | session_id | TEXT (PK) | UUID identifying the captured session | | data_url | TEXT | UploadThing URL pointing to gzip-compressed session data | | webhook_url | TEXT | Recording-complete webhook URL for this task | | status | TEXT | 'pending', 'assigned', or 'failed' | | created_at | TIMESTAMPTZ | Row creation time | | updated_at | TIMESTAMPTZ | Last update time |

CREATE TABLE IF NOT EXISTS recording_tasks (
  session_id  TEXT PRIMARY KEY,
  data_url    TEXT,
  webhook_url TEXT,
  status      TEXT NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

session_upload_chunks

Durable buffer for the chunked / streaming upload path. Each row holds one base64-encoded slice of a gzipped session; on the final chunk the slices are concatenated, uploaded to UploadThing in one shot, then deleted. Only required if a client uses uploadSessionStreaming.

| Column | Type | Description | |--------|------|-------------| | session_id | TEXT | UUID identifying the captured session (PK part 1) | | chunk_index | INTEGER | Ordinal position of this slice (PK part 2) | | data | TEXT | Base64 of the gzipped slice bytes | | created_at | TIMESTAMPTZ | Row creation time |

CREATE TABLE IF NOT EXISTS session_upload_chunks (
  session_id  TEXT NOT NULL,
  chunk_index INTEGER NOT NULL,
  data        TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (session_id, chunk_index)
);

recording_simulations

Queue + result store for the simulate-from-a-recording feature (createSimulateRecordingHandler / createSimulationCompleteHandler). Each row is its own work item: created 'pending', picked up by your worker, then reported back 'complete' / 'failed' with the four artifacts. Only required if you use those handlers.

| Column | Type | Description | |--------|------|-------------| | id | UUID (PK) | Simulation id (default gen_random_uuid()) | | source_recording_id | TEXT | The base Replay recording id to recreate from | | injected_script | TEXT | Optional function body run in the recorded page | | status | TEXT | 'pending', 'queued', 'processing', 'complete', or 'failed' | | new_recording_id | TEXT | The newly created Replay recording id (set when complete) | | session_data | JSONB | Harvested session data (extracted or reconstructed) | | rrweb_events | JSONB | rrweb DOM events captured while creating the new recording | | injected_data | JSONB | Whatever the injected script collected | | session_packet_count | INTEGER | Count shown in list summaries | | rrweb_event_count | INTEGER | Count shown in list summaries | | injected_event_count | INTEGER | Count shown in list summaries | | error_message | TEXT | Failure detail (set when failed) | | created_at | TIMESTAMPTZ | Row creation time | | updated_at | TIMESTAMPTZ | Last update time |

CREATE TABLE IF NOT EXISTS recording_simulations (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source_recording_id   TEXT NOT NULL,
  injected_script       TEXT,
  status                TEXT NOT NULL DEFAULT 'pending'
                          CHECK (status IN ('pending','queued','processing','complete','failed')),
  new_recording_id      TEXT,
  session_data          JSONB,
  rrweb_events          JSONB,
  injected_data         JSONB,
  session_packet_count  INTEGER,
  rrweb_event_count     INTEGER,
  injected_event_count  INTEGER,
  error_message         TEXT,
  created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at            TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

The consumer may add columns its own pipeline needs (this app, for example, adds a session_data_url for the server-side-harvested blob and an assigned_container_id). The handlers only read/write the columns above.