diff --git a/README.md b/README.md index 76fcf81..3f0b529 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/src/replica.ts b/src/replica.ts index 73369c0..07cd1ef 100644 --- a/src/replica.ts +++ b/src/replica.ts @@ -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 { @@ -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 { 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 }); @@ -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 { + 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); @@ -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); } } @@ -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); @@ -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; } diff --git a/test/integration/pitr-restore.test.ts b/test/integration/pitr-restore.test.ts new file mode 100644 index 0000000..daf7182 --- /dev/null +++ b/test/integration/pitr-restore.test.ts @@ -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 { + 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(); + } +});