Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,18 @@ level-0 files:
await replica.restore({ outputPath: '/data/restored.db' });
```

Restore to a specific transaction ID:
Restore to a specific transaction ID, or to a point-in-time (the latest backup created at/before
the given time). Pass exactly one of `txID` or `timestamp`:

```ts
await replica.restore({ outputPath: '/data/app-at-txid.db', txID: 42n });
await replica.restore({ outputPath: '/data/app-at-time.db', timestamp: new Date('2025-06-01T12:00:00Z') });
```

Restoring to `txID`/`timestamp` is at **backup boundaries** (the end of an LTX file), and a target
with no matching backup throws `NoMatchingBackupError`. A "latest" restore over a non-contiguous
file set throws `RestoreGapError` rather than silently returning a partial database.

### Continuously restore (follow)

`follow()` restores the latest backup, then keeps the database up to date as new backups are
Expand Down Expand Up @@ -449,8 +455,6 @@ core to replicate, snapshot, compact, lease, and restore a SQLite database.
- **CLI + YAML config** — this is a *library*. The `litestream replicate/restore/ltx/reset/snapshots`
CLI and a `litestream.yml`-style config are out of scope for now.
- **Metrics/telemetry** — no Prometheus/OpenTelemetry instrumentation yet.
- **Point-in-time restore edge cases** — `txID`/timestamp restore is supported, but litestream's
full edge-case matrix is not exhaustively covered.
- **VFS real-time mirroring** — hooking into SQLite's VFS to mirror writes live is not implemented.
- **IPC control server** — litestream's Unix-socket IPC server is not implemented.

Expand Down
100 changes: 90 additions & 10 deletions src/replica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,28 @@ export class NoMatchingBackupError extends SqliteJsStreamError {
}
}

/** Base class for errors thrown by {@link Replica.restore}. Carries a stable code. */
export class RestoreError extends SqliteJsStreamError {
constructor(code: string, message: string) {
super(code, message);
this.name = 'RestoreError';
}
}

/**
* The replica's LTX files are not contiguous for a restore target, so restoring
* would silently drop a range of transactions (data-loss signal). Raised for a
* "latest" restore (`txID = 0`, no timestamp) when the available files have a
* gap. Preferring a per-target restore (`txID`/`timestamp`) lets you pick a
* contiguous range instead.
*/
export class RestoreGapError extends RestoreError {
constructor(message: string) {
super('ERR_RESTORE_GAP', message);
this.name = 'RestoreGapError';
}
}



export interface ReplicaRunOptions {
Expand Down Expand Up @@ -665,11 +687,27 @@ export class Replica {
await this.restore({ outputPath: this.path });
}

/** Restore reconstructs a SQLite database at `outputPath` from LTX files. */
/**
* Restore reconstructs a SQLite database at `outputPath` from LTX files.
*
* By default it restores the **latest** backup. You can restore to a specific
* point-in-time by passing exactly one of `txID` (a transaction boundary) or
* `timestamp` (the latest backup created at/before the given time), not both.
*/
async restore(opt: RestoreOptions): Promise<void> {
if (!opt.outputPath) throw new InvalidArgumentError('output path required');

const plan = await calcRestorePlan(this.client, opt.txID ?? 0n);
const txID = opt.txID ?? 0n;
if (txID < 0n) throw new InvalidArgumentError('txID must be >= 0');
const timestampMs = opt.timestamp?.getTime();
if (timestampMs !== undefined && Number.isNaN(timestampMs)) {
throw new InvalidArgumentError('invalid timestamp');
}
if (txID !== 0n && timestampMs !== undefined) {
throw new InvalidArgumentError('cannot restore by both txID and timestamp');
}

const plan = await calcRestorePlan(this.client, txID, timestampMs);
if (plan.length === 0) throw new NoMatchingBackupError();

await mkdir(nodePath.dirname(opt.outputPath), { recursive: true });
Expand Down Expand Up @@ -1288,22 +1326,53 @@ export class Replica {

/**
* CalcRestorePlan returns LTX files to apply, in order, to reconstruct a
* database up to `txID` (0 = latest). It prefers the latest level-9 snapshot
* before the target as the base, then appends contiguous files across all
* database up to a point-in-time target. It prefers the latest level-9 snapshot
* at or before the target as the base, then appends contiguous files across all
* compaction levels (0..maxLevel) that extend the range.
*
* Exactly one of `txID` (0 = latest) or `timestampMs` (ms since epoch) targets
* the restore point; passing both is an error. When `timestampMs` is set, only
* files created **before** it are eligible and clients are queried with
* `useMetadata: true` so `createdAt` is accurate.
*
* For the "latest" restore (`txID = 0`, no timestamp) a non-contiguous file set
* is a data-loss signal and raises {@link RestoreGapError} rather than
* silently restoring a partial database.
*/
export async function calcRestorePlan(
client: ReplicaClient,
txID: bigint,
timestampMs?: number,
): Promise<FileInfo[]> {
if (txID < 0n) throw new InvalidArgumentError('txID must be >= 0');
if (txID !== 0n && timestampMs !== undefined) {
throw new InvalidArgumentError('cannot restore by both txID and timestamp');
}
if (timestampMs !== undefined && Number.isNaN(timestampMs)) {
throw new InvalidArgumentError('invalid timestamp');
}

// For timestamp-based restore we query with useMetadata so `createdAt` is the
// accurate object creation time (S3 metadata), not just the listing time.
const useMetadata = timestampMs !== undefined;

/** True if `info` is at/before the requested restore point. */
const beforeTarget = (info: FileInfo): boolean => {
if (txID !== 0n && info.maxTXID > txID) return false;
if (timestampMs !== undefined && info.createdAt >= timestampMs) return false;
return true;
};

const plan: FileInfo[] = [];
let currentMax = 0n;

// Pick the latest level-9 snapshot at or before the target as the base.
let base: FileInfo | null = null;
for await (const info of client.ltxFiles({ level: SnapshotLevel, seek: 0n, useMetadata: false })) {
if (txID !== 0n && info.maxTXID > txID) continue;
if (!base || info.maxTXID > base.maxTXID) base = info;
for await (const info of client.ltxFiles({ level: SnapshotLevel, seek: 0n, useMetadata })) {
if (!beforeTarget(info)) continue;
if (!base || info.maxTXID > base.maxTXID || (info.maxTXID === base.maxTXID && info.createdAt > base.createdAt)) {
base = info;
}
}
if (base) {
plan.push(base);
Expand All @@ -1315,8 +1384,8 @@ export async function calcRestorePlan(
// by a file at any level; gather them all and prefer the highest level first.
const candidates: FileInfo[] = [];
for (let level = 0; level < SnapshotLevel; level++) {
for await (const info of client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
if (txID !== 0n && info.maxTXID > txID) continue;
for await (const info of client.ltxFiles({ level, seek: 0n, useMetadata })) {
if (!beforeTarget(info)) continue;
candidates.push(info);
}
}
Expand All @@ -1328,9 +1397,14 @@ export async function calcRestorePlan(
});

const seenRanges: Array<[bigint, bigint]> = [];
let gap = false;
for (const info of candidates) {
if (info.maxTXID <= currentMax) continue; // covered by the base or earlier
if (info.minTXID > currentMax + 1n) break; // non-contiguous gap
if (info.minTXID > currentMax + 1n) {
// Non-contiguous: no file continues from currentMax + 1.
gap = true;
break;
}
// Skip a candidate entirely covered by a higher-level file already in the plan.
if (seenRanges.some(([lo, hi]) => lo <= info.minTXID && info.maxTXID <= hi)) continue;
plan.push(info);
Expand All @@ -1344,6 +1418,12 @@ export async function calcRestorePlan(
if (txID !== 0n && currentMax < txID) {
throw new NoMatchingBackupError();
}
// A latest restore must be fully contiguous; a gap means the replica lost data.
if (txID === 0n && timestampMs === undefined && gap) {
throw new RestoreGapError(
`non-contiguous ltx files: contiguous range ends at txID ${currentMax} but the next file starts later`,
);
}
return plan;
}

Expand Down
190 changes: 190 additions & 0 deletions test/integration/pitr-restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { join } from 'node:path';

import {
FileReplicaClient,
InvalidArgumentError,
NoMatchingBackupError,
Replica,
RestoreGapError,
} from '../../src/index.ts';
import { createWALDB, makeTempDir } from '../helpers/make-db.ts';

/** Counts rows in a restored (standalone) SQLite database. */
function countRows(path: string): number {
const db = new DatabaseSync(path);
try {
const r = db.prepare('SELECT count(*) AS n FROM t').get() as { n: number };
return r.n;
} finally {
db.close();
}
}

/** Reads the highest `createdAt` (LTX header timestamp) across level-0 files. */
async function latestCreatedAt(client: FileReplicaClient): Promise<number> {
let max = 0;
for await (const f of client.ltxFiles({ level: 0, seek: 0n, useMetadata: false })) {
if (f.createdAt > max) max = f.createdAt;
}
return max;
}

test('pitr: restore to a specific txID boundary', async () => {
const t = createWALDB(['a', 'b', 'c']); // txID 1 -> 3 rows
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync(); // txID 1
t.db.exec("INSERT INTO t (name) VALUES ('d'), ('e')");
await replica.sync(); // txID 2 -> 5 rows
t.db.exec("INSERT INTO t (name) VALUES ('f')");
await replica.sync(); // txID 3 -> 6 rows

const outTxid1 = join(makeTempDir('restore-'), 'r1.db');
await replica.restore({ outputPath: outTxid1, txID: 1n });
assert.equal(countRows(outTxid1), 3, 'restore at txID=1 has first batch only');

const outTxid2 = join(makeTempDir('restore-'), 'r2.db');
await replica.restore({ outputPath: outTxid2, txID: 2n });
assert.equal(countRows(outTxid2), 5, 'restore at txID=2 has first two batches');

const outLatest = join(makeTempDir('restore-'), 'r3.db');
await replica.restore({ outputPath: outLatest }); // txID=0 => latest
assert.equal(countRows(outLatest), 6, 'latest restore has all rows');
} finally {
t.close();
}
});

test('pitr: restore to a timestamp picks the latest backup at/before it', async () => {
const t = createWALDB(['a', 'b', 'c']); // txID 1 -> 3 rows
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync(); // txID 1, createdAt = t1
// Guarantee distinct timestamps between the two syncs.
await new Promise((r) => setTimeout(r, 5));
const t1 = await latestCreatedAt(client);
assert.ok(t1 > 0);

t.db.exec("INSERT INTO t (name) VALUES ('d'), ('e')");
await replica.sync(); // txID 2, createdAt = t2 > t1
const t2 = await latestCreatedAt(client);
assert.ok(t2 > t1, 'expected strictly increasing timestamps');

// Target just after the first backup but before the second.
const outEarly = join(makeTempDir('restore-'), 'early.db');
await replica.restore({ outputPath: outEarly, timestamp: new Date(t1 + 1) });
assert.equal(countRows(outEarly), 3, 'timestamp before second sync keeps first batch only');

// Target after the second backup => latest.
const outLate = join(makeTempDir('restore-'), 'late.db');
await replica.restore({ outputPath: outLate, timestamp: new Date(t2 + 1000) });
assert.equal(countRows(outLate), 5, 'timestamp after second sync includes all rows');
} finally {
t.close();
}
});

test('pitr: timestamp before the earliest backup throws NoMatchingBackupError', async () => {
const t = createWALDB(['a', 'b']);
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync();
const first = await latestCreatedAt(client);
await assert.rejects(
replica.restore({ outputPath: join(makeTempDir('restore-'), 'out.db'), timestamp: new Date(first - 10_000) }),
NoMatchingBackupError,
);
} finally {
t.close();
}
});

test('pitr: txID beyond the latest backup throws NoMatchingBackupError', async () => {
const t = createWALDB(['a', 'b']);
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync();
await assert.rejects(
replica.restore({ outputPath: join(makeTempDir('restore-'), 'out.db'), txID: 999n }),
NoMatchingBackupError,
);
} finally {
t.close();
}
});

test('pitr: txID and timestamp are mutually exclusive', async () => {
const t = createWALDB(['a']);
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync();
await assert.rejects(
replica.restore({
outputPath: join(makeTempDir('restore-'), 'out.db'),
txID: 1n,
timestamp: new Date(),
}),
InvalidArgumentError,
);
} finally {
t.close();
}
});

test('pitr: negative txID is rejected', async () => {
const t = createWALDB(['a']);
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync();
await assert.rejects(
replica.restore({ outputPath: join(makeTempDir('restore-'), 'out.db'), txID: -1n }),
InvalidArgumentError,
);
} finally {
t.close();
}
});

test('pitr: latest restore raises RestoreGapError on non-contiguous files', async () => {
const t = createWALDB(['a', 'b']);
const client = new FileReplicaClient(makeTempDir('repl-'));
const replica = new Replica(t.path, client);

try {
await replica.sync(); // 1..1
t.db.exec("INSERT INTO t (name) VALUES ('c')");
await replica.sync(); // 2..2
t.db.exec("INSERT INTO t (name) VALUES ('d')");
await replica.sync(); // 3..3

// Delete the level-0 file for TXID 2 to leave a gap: 1..1, 3..3.
const toDelete = [];
for await (const f of client.ltxFiles({ level: 0, seek: 0n, useMetadata: false })) {
if (f.maxTXID === 2n) toDelete.push(f);
}
assert.equal(toDelete.length, 1);
await client.deleteLtxFiles(toDelete);

await assert.rejects(
replica.restore({ outputPath: join(makeTempDir('restore-'), 'out.db') }),
RestoreGapError,
);
} finally {
t.close();
}
});
Loading