Skip to content

Commit 522c2eb

Browse files
committed
docs: reconcile PLAN.md with the implemented, driver-agnostic project
Update PLAN.md so it accurately describes the codebase: - §5 Public API: replace the stale DB/Litestream/Config-centric MVP sketch with the real driver-agnostic API (Replica(path, client), Store, FileReplicaClient, newReplicaClientFromURL, registerReplicaClientFactory, restoreV3, S3 addon). - §4 Project layout: reflect actual files (replica-url, leaser, restore-v3, compaction-level, stream, streaming-compactor, monorepo packages/backend-s3; drop config.ts/types.ts/s3-dir and the old util file names). - §0 Status + §7 Milestone 6+: track S3 addon, distributed leasing, streaming, and legacy v0.3.x restore-read as implemented; list remaining cloud backends. - §9 Out of scope: S3 and leasing are done; CLI/config/GCS/Azure/SFTP/NATS deferred. - §10 + §1 table: leaser is implemented (not '(later)'). - §3/§3 step 6 LZ4 note + §8: lz4js (block + frame) is the codec; mark previously open risks resolved; keep Go byte-parity encoding as the remaining interop gap. - §11 Definition of done: check off all boxes (128 tests: 110 core + 18 S3).
1 parent c654a2c commit 522c2eb

1 file changed

Lines changed: 115 additions & 70 deletions

File tree

‎PLAN.md‎

Lines changed: 115 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ Verified on Node `v24.21.0`.
4242
> writes **level-9 full snapshots** (`min=1`). `Restore` and `Store` use the `Compactor` to merge a
4343
> level-9 snapshot with subsequent level-0 files. See `test/integration/snapshot-store.test.ts`.
4444
45+
> **Post-MVP hardening & features completed:** streaming for large DBs (`StreamLtxReader` /
46+
> `AsyncByteReader` / `FileSink`, S3 multipart ≥5 MiB), multi-level compaction + retention/GC
47+
> (L0→…→L9 with litestream-style time-bucketed scheduling), a **pnpm monorepo** with an **S3 addon**
48+
> (`packages/backend-s3`), distributed **leasing** (`lock.json` conditional writes), and **legacy
49+
> v0.3.x generation restore-read**. Test suite: **128 tests (110 core + 18 S3)**, green on Node 24 & 26.
50+
4551
> **Interop note (Milestone 1):** Go's `superfly/ltx` golden file-checksum vectors
4652
> (`0xe27f4c18f0da2c10`, `0xd926967672f7ca53`) are pinned in `test/ltx/golden.test.ts`. Our `lz4js`
4753
> produces *valid* but not *byte-identical* LZ4 block bytes to Go's `pierrec/lz4/v4`, so our file
@@ -75,7 +81,7 @@ read pages/WAL via `fs.promises`, and use `node:sqlite` only for control + read
7581
| `replica.go` | Replication mechanics: position tracking, sync to client, restore plan | `src/replica.ts` |
7682
| `store.go` | Multi-DB coordination, snapshot/compaction scheduling, retention | `src/store.ts` |
7783
| `compactor.go` | Merge LTX readers into one stream (used for compaction & restore) | `src/ltx/compactor.ts` |
78-
| `leaser.go`, `s3/leaser.go` | Distributed lease (lock.json) — later phase | `src/leaser.ts` (later) |
84+
| `leaser.go`, `s3/leaser.go` | Distributed lease (lock.json) — ✅ implemented | `src/leaser.ts` + `src/backends/file/leaser.ts` + `packages/backend-s3/src/leaser.ts` |
7985
| `server.go` | Unix-socket IPC control server — out of scope for MVP | — |
8086
| `vfs.go` | Real-time SQLite VFS mirroring — out of scope for MVP | — |
8187

@@ -191,7 +197,7 @@ persists by reading the highest local L0 LTX file (its header carries `WALOffset
191197
| Concern | Choice | Why |
192198
|---------|--------|-----|
193199
| SQLite | **`node:sqlite`** (`DatabaseSync`) | Built-in (Node 24), no native compile. Confirm WAL/PRAGMA/read-txn/checkpoint behavior in tests. |
194-
| LZ4 **block** codec | **`lz4-napi` (native) with `lz4js` (pure-JS) fallback** | LTX uses block format. Follow `@platformatic/kafka`'s native→JS fallback pattern (`crc32c.ts`). |
200+
| LZ4 codec | **`lz4js` (pure JS)** for LTX **block** format; plus a **frame** codec (`compressFrameLz4`/`decompressFrameLz4`, pure JS) for legacy v0.3.x restore-reads | LTX uses block format. `lz4js` **decompression** is Go-interoperable, but our compressed bytes are valid-yet-not-byte-identical to Go's `pierrec/lz4/v4`, so file checksums differ. The frame codec interoperates with Go `pierrec/lz4` frames. |
195201
| CRC-64 ISO | **Implement in TS** (`src/ltx/crc64.ts`) | No good npm pkg; table-based, test against Go vectors (`"123456789"` ⇒ `b90956c775a41001`). |
196202
| Binary buffer accumulation | `@platformatic/dynamic-buffer` *(optional)* | Nice for streaming WAL/page/index; not required for MVP (plain `Buffer` is fine). |
197203
| Test runner | **`node:test`** + `node:assert/strict` in `.test.ts` | Built-in, runs `.ts` directly via type stripping. |
@@ -253,43 +259,54 @@ sqlite-js-stream/
253259
PLAN.md
254260
README.md
255261
src/
256-
index.ts # public entry: Litestream, DB, Replica, ReplicaClient, helpers
257-
types.ts # TXID, Checksum, Pos, SyncStatus, Config types
258-
db.ts # DB class
259-
replica.ts # Replica class
262+
index.ts # public entry: VERSION, Replica, Store, ReplicaClient, helpers,
263+
# LTX/WAL module re-exports, leasers, logger
264+
replica.ts # Replica class (driver-agnostic: takes a path + client)
260265
store.ts # Store class (multi-DB, scheduling)
261-
replica-client.ts # ReplicaClient interface + Fetch helpers
262-
config.ts # config parsing / replica URL
266+
replica-client.ts # ReplicaClient interface + ltxDir/ltxFilePath/fetch* helpers
267+
replica-url.ts # parseReplicaURLWithQuery + registerReplicaClientFactory +
268+
# newReplicaClientFromURL (scheme registry)
269+
db.ts # internal: node:sqlite snapshot pinning (NOT exported)
270+
leaser.ts # Leaser interface
271+
restore-v3.ts # legacy v0.3.x generation restore-read (GenerationClientV3)
263272
ltx/
264-
index.ts
273+
index.ts # re-exports the LTX module
265274
crc64.ts # CRC-64 ISO (table-based, reflected, init/xorout all-ones)
266275
checksum.ts # ChecksumPage, rolling checksum, ChecksumReader
267-
constants.ts # Magic, sizes, flags, LockPgno, filename regex
276+
constants.ts # Magic, sizes, flags, LockPgno, SnapshotLevel, filename regex
268277
types.ts # Header, PageHeader, PageIndexElem, Trailer, FileInfo, Pos
269278
header.ts # Header marshal/unmarshal/validate
270279
page.ts # PageHeader marshal/unmarshal
271280
page-index.ts # varint encode/decode, DecodePageIndex/EncodePageIndex
272281
trailer.ts # Trailer marshal/unmarshal/validate
273282
filename.ts # FormatFilename / ParseFilename / ParseTXID / ParseChecksum
274-
lz4.ts # LZ4 block adapter (native → js fallback)
283+
lz4.ts # LZ4 block adapter (lz4js) + LZ4 frame codec (v0.3 compat)
275284
encoder.ts # Encoder: header, page, index, trailer, file checksum
276285
decoder.ts # Decoder: header, page, index, trailer, DecodeDatabaseTo
277-
compactor.ts # Compactor: merge readers
286+
compactor.ts # in-memory Compactor: merge readers
287+
streaming-compactor.ts # page-by-page bounded-memory compactor (multi-level)
288+
stream.ts # StreamLtxReader / AsyncByteReader / FileSink
278289
file-info.ts # FileInfo, FileIterator, FileInfoSliceIterator
290+
compaction-level.ts # CompactionLevels, defaultCompactionLevels, bucket helpers
291+
byte-reader.ts # ByteReader helpers
292+
bytes.ts # byte-vector read/write helpers
293+
sink.ts # output sink for encoded LTX
279294
wal/
280-
index.ts
295+
index.ts # re-exports the WAL module
281296
reader.ts # WALReader: header/frames/checksum/pageMap, with-offset
282297
checksum.ts # WALChecksum
298+
constants.ts # WAL magic / salts / version constants
283299
backends/
284300
file/
285-
index.ts
286-
replica-client.ts # FileReplicaClient
287-
s3/ # later
301+
index.ts # re-exports the file backend
302+
replica-client.ts # FileReplicaClient (+ GenerationClientV3)
303+
leaser.ts # FileLeaser
288304
util/
289-
atomic-file.ts # write-to-temp + rename + fsync
290-
limit-read-closer.ts # partial reads
291-
resumable-reader.ts # resumable stream reads (later)
292-
sqlite.ts # node:sqlite helpers (open w/ WAL, page size, read txn)
305+
fs.ts # write-to-temp + rename + fsync, mkdir, rm
306+
stream.ts # stream plumbing / AsyncByteReader / FileSink
307+
logger.ts # Logger interface + noopLogger
308+
packages/
309+
backend-s3/ # monorepo addon: S3ReplicaClient + S3Leaser (+ scheme registry)
293310
test/
294311
helpers/
295312
make-db.ts # create a WAL db with known rows via node:sqlite
@@ -309,47 +326,65 @@ sqlite-js-stream/
309326

310327
## 5. Public API we expose (MVP)
311328

312-
```ts
313-
import { Litestream, DB, Replica, ReplicaClient, FileReplicaClient, Config } from '...'
314-
315-
type Config = {
316-
dbs: Array<{
317-
path: string
318-
replicas: Array<{ url: string }> // 'file:///data/repl'
319-
}>
320-
snapshotInterval?: number
321-
snapshotRetention?: number
322-
l0Retention?: number
323-
}
324-
325-
const ls = new Litestream(config)
326-
await ls.open() // opens all DBs, starts monitors
329+
The library is **driver-agnostic.** `Replica`/`Store` take a database **path** and read the DB file
330+
+ `-wal` directly from disk, so the application can use any SQLite driver (or none).
331+
`DB`/`Database`/`openDatabase`/`Litestream`/`Config` are intentionally **not** exported;
332+
`node:sqlite` is used internally only to pin a read-only snapshot.
327333

328-
const db = ls.getDB('/data/app.db') // or DB.open(path)
329-
await db.sync() // WAL → LTX, uploads via ReplicaClient
330-
const status = await db.syncStatus() // { localTXID, remoteTXID, inSync }
331-
await db.syncAndWait()
332-
await db.ensureExists() // restore local DB if missing, before open
333-
334-
const replica = db.replica // Replica
335-
await replica.restore({ outputPath: '/data/restored.db' }) // apply snapshot + L0
336-
await db.close()
334+
```ts
335+
import {
336+
Replica, Store, FileReplicaClient, FileLeaser,
337+
newReplicaClientFromURL, registerReplicaClientFactory,
338+
ReplicaClient, CompactionLevels, defaultCompactionLevels, calcRestorePlan,
339+
restoreV3, GenerationClientV3, compressFrameLz4, decompressFrameLz4,
340+
VERSION,
341+
} from 'sqlite-js-stream'
342+
343+
// File-backed replica: `path` is the SQLite DB; the client is a storage backend.
344+
const client = new FileReplicaClient('/data/repl')
345+
const replica = new Replica('/data/app.db', client) // reads app.db + app.db-wal directly
346+
347+
await replica.sync() // WAL → LTX, uploads via client
348+
await replica.snapshot() // full level-9 snapshot
349+
const status = await replica.syncStatus() // { localTXID, remoteTXID, inSync }
350+
await replica.syncAndWait()
351+
await replica.ensureExists() // restore local DB if missing, before open
352+
await replica.run({ intervalMs: 5000, signal }) // continuous sync (primary/follower w/ leaser)
353+
await replica.compact() // litestream-style per-level compaction schedule
354+
await replica.enforceRetention() // time-based snapshot + L0 retention / GC
355+
356+
await replica.restore({ outputPath: '/data/restored.db', timestamp }) // snapshot + L0 overlay
357+
replica.close()
358+
359+
// Multi-DB coordination
360+
const store = new Store()
361+
await store.openDB('/data/app.db', client)
362+
await store.run({ intervalMs }) // syncAll + snapshotAll + compactAll + retention
363+
store.close()
364+
365+
// URL-based factory + addon scheme registry
366+
registerReplicaClientFactory('s3', s3Factory) // S3 addon auto-registers 's3' on import
367+
const c = newReplicaClientFromURL('s3://bucket/prefix')
337368
```
338369

339370
`ReplicaClient` interface (port of `replica_client.go`):
340371
```ts
341372
interface ReplicaClient {
342373
type(): string
343-
init(ctx: AbortSignal): Promise<void>
344-
ltxFiles(opts: { level: number; seek: bigint; useMetadata: boolean }): AsyncIterable<FileInfo>
345-
openLtxFile(opts: { level: number; minTXID: bigint; maxTXID: bigint; offset: bigint; size: bigint }): Promise<Readable>
346-
writeLtxFile(opts: { level: number; minTXID: bigint; maxTXID: bigint }, body: Readable): Promise<FileInfo>
374+
init(): Promise<void>
375+
ltxFiles(opts: { level: number; seek: TXID; useMetadata: boolean }): AsyncIterable<FileInfo>
376+
openLtxFile(opts: { level: number; minTXID: TXID; maxTXID: TXID; offset: number; size: number }): Promise<Readable>
377+
writeLtxFile(opts: { level: number; minTXID: TXID; maxTXID: TXID }, body: AsyncIterable<Uint8Array | string>): Promise<FileInfo>
347378
deleteLtxFiles(infos: FileInfo[]): Promise<void>
348379
deleteAll(): Promise<void>
349380
setLogger(l: Logger): void
350381
}
351382
```
352383

384+
A backend may additionally implement `GenerationClientV3` to enable legacy v0.3.x restore-reads
385+
(`restoreV3`). The **S3 backend is a monorepo addon** (`@platformatic/sqlite-js-stream-backend-s3`)
386+
registered via `registerReplicaClientFactory('s3', …)` on import.
387+
353388
---
354389

355390
## 6. TDD methodology
@@ -393,7 +428,8 @@ Implement and unit-test, in order:
393428
`TXID`/`Checksum` string/JSON formats.
394429
4. **`header.ts` / `page.ts` / `trailer.ts`** — marshalling/unmarshalling + `Validate()`.
395430
5. **`page-index.ts`** — varint encode/decode; `EncodePageIndex`/`DecodePageIndex`.
396-
6. **`lz4.ts`** — block codec adapter (`lz4-napi`→`lz4js`, both expose block functions).
431+
6. **`lz4.ts`** — LZ4 codec (`lz4js` pure JS): LTX **block** adapter + a **frame** codec for
432+
legacy v0.3.x restore-reads.
397433
7. **`encoder.ts`** — `EncodeHeader`, `EncodePage` (compress + size prefix), `EncodePageIndex`,
398434
`Close` (compute file checksum). Support `HeaderFlagNoChecksum`.
399435
8. **`decoder.ts`** — `DecodeHeader`, `DecodePage`, `DecodePageIndex`, `Close` (verify file
@@ -501,21 +537,28 @@ snapshot + WAL produce the live database.
501537

502538
### Milestone 6+ — Advanced / optional (NOT required for MVP)
503539

504-
- **Cloud backends:** `s3/`, `gs/`, `abs/`, `sftp/`, `nats/` replica clients + their leaser
505-
(`lock.json` conditional writes).
540+
- **S3 backend** — ✅ **implemented** as a monorepo addon `packages/backend-s3`:
541+
`S3ReplicaClient` (multipart upload ≥5 MiB, `If-None-Match`/`If-Match` conditional writes,
542+
`GenerationClientV3` reads) + `S3Leaser`. Importing it registers the `s3` scheme so
543+
`newReplicaClientFromURL('s3://bucket/prefix')` resolves.
544+
- **More cloud backends** — *not yet built*: `gs/`, `abs/`, `sftp/`, `nats/`. Each is a new addon
545+
package following the `packages/backend-s3` pattern (implement `ReplicaClient` + optional
546+
`Leaser`, register the URL scheme on import).
506547
- **Distributed leasing** — ✅ **implemented** (`src/leaser.ts` + file/S3 leasers): a `Leaser`
507548
acquires/renews/releases a `lock.json` lease; `Replica.run()` becomes a **primary** (writes) or a
508549
**follower** (polls), gating all storage-write methods on lease ownership. File backend uses
509550
exclusive-create + content-hash etag; S3 uses `If-None-Match`/`If-Match` conditional writes.
551+
- **Legacy v0.3.x generation restore-read** — ✅ **implemented** (see milestone 5 note).
510552
- **VFS real-time mirroring** (`vfs.go` port — hook into SQLite VFS; likely deferred).
511553
- **IPC Unix-socket control server** (`server.go`).
512-
- **`litestream ltx`/CLI wrapper** only if a CLI is desired.
554+
- **CLI** (`litestream replicate/restore/ltx/reset/snapshots`) + YAML config — *not built*; library
555+
only for now.
513556

514557
---
515558

516559
## 8. Known risks & open questions
517560

518-
1. **`node:sqlite` concurrency semantics.** Whether a long-running read txn on one
561+
1. **`node:sqlite` concurrency semantics.** ✅ **resolved** (verified in `test/integration/checkpoint-concurrency.test.ts`). Whether a long-running read txn on one
519562
`DatabaseSync` genuinely blocks WAL truncation while a **separate** connection runs
520563
`wal_checkpoint`, and whether `PRAGMA wal_autocheckpoint` must be raised (litestream disables
521564
SQLite's auto-checkpoint so it can control checkpoints). Must be verified in Milestone 4 with
@@ -526,16 +569,18 @@ snapshot + WAL produce the live database.
526569
2. **Golden interoperability.** Go is not installed in the dev environment. We hand-derive fixtures
527570
and add a Go golden generator for CI. Until goldens are committed, interop is validated by
528571
round-trip + spec conformance only.
529-
3. **LZ4 block vs frame.** LTX requires **block** format; `@platformatic/wasm-utils` is frame-only.
530-
`lz4-napi` (block) and `lz4js` (block) both verified. Prefer `lz4-napi` for perf with `lz4js`
531-
fallback, but ensure the fallback path is tested for identical decode results.
532-
4. **CRC-64 ISO vs ECMA.** **ISO must be used** (mismatch = broken interop). Provide a direct vector
572+
3. **LZ4 block vs frame.** ✅ **both supported.** LTX uses **block** format; a pure-JS **frame**
573+
codec (`compressFrameLz4`/`decompressFrameLz4`) was added for legacy v0.3.x restore-reads.
574+
We use pure-JS `lz4js` for both block and frame. `lz4js` **decodes** Go-produced bytes
575+
correctly, so decompression interop is guaranteed; only byte-identical *encoding* would require
576+
a liblz4-compatible codec (`lz4-napi`/WASM), which is an optional future upgrade.
577+
4. **CRC-64 ISO vs ECMA.** ✅ **resolved** – **ISO is used** (mismatch = broken interop). Provide a direct vector
533578
test to lock this in.
534-
5. **TXID/position persistence.** Position is derived from local L0 LTX headers (no separate pos
579+
5. **TXID/position persistence.** ✅ **resolved** – position is derived from local L0 LTX headers (no separate pos
535580
file). Ensure we reconstruct `WALOffset`/salts on restart exactly.
536581
6. **Type stripping limits.** No `enum`/decorators/parameter properties; use `import type`; explicit
537582
`.ts` extensions. Add a lint guard (eslint rule or tsconfig) to prevent violations.
538-
7. **Memory/perf on large DBs.** ✅ **Streaming implemented (post-MVP).** `restore()` streams each
583+
7. **Memory/perf on large DBs.** ✅ **resolved** – **streaming implemented (post-MVP).** `restore()` streams each
539584
LTX file page-by-page into the output DB (snapshot base + incremental overlay) and
540585
`sync()`/`snapshot()` read pages from a file handle and stream the encoded LTX straight to disk
541586
(`FileSink`); the S3 addon uses multipart upload for objects > 5 MiB. Peak memory is bounded by
@@ -551,11 +596,11 @@ snapshot + WAL produce the live database.
551596

552597
## 9. Out of scope for MVP (documented, deferred)
553598

554-
- Cloud object-store backends (S3/GCS/Azure/SFTP/NATS).
555-
- Distributed leasing / lock.json.
599+
- Cloud object-store backends — **S3 done** (addon `packages/backend-s3`); GCS/Azure/SFTP/NATS deferred.
600+
- Distributed leasing / lock.json — **done** (`src/leaser.ts` + file/S3 leasers).
556601
- VFS real-time mirroring.
557602
- Unix-socket IPC server.
558-
- Full CLI (`litestream replicate`, `restore`, `ltx`, `reset`, `snapshots`).
603+
- CLI (`litestream replicate`, `restore`, `ltx`, `reset`, `snapshots`) + YAML config.
559604
- Metrics/telemetry (Prometheus) and rich diagnostics.
560605
- `--txid`/timestamp point-in-time restore edge cases and `follow` mode.
561606

@@ -572,21 +617,21 @@ snapshot + WAL produce the live database.
572617
| `db.go` | `src/db.ts` |
573618
| `replica.go` | `src/replica.ts` |
574619
| `store.go`, `compactor.go` | `src/store.ts`, `src/ltx/compactor.ts` |
575-
| `leaser.go`, `s3/leaser.go` | `src/leaser.ts` (later) |
620+
| `leaser.go`, `s3/leaser.go` | `src/leaser.ts` + `src/backends/file/leaser.ts` + `packages/backend-s3/src/leaser.ts` |
576621
| `vfs.go` | deferred |
577622
| `server.go` | deferred |
578623

579624
---
580625

581626
## 11. Definition of done for MVP (Milestones 0–4)
582627

583-
- [ ] `npm test` green; `tsc --noEmit` clean.
584-
- [ ] LTX encoder/decoder pass round-trip + golden + checksum-verification tests.
585-
- [ ] WAL reader parses real `node:sqlite` WAL files, `pageMap` correct.
586-
- [ ] `FileReplicaClient` writes/reads/deletes in the correct directory layout.
587-
- [ ] End-to-end test: SQLite DB → `sync()` → `file://` replica → `restore()` → valid DB.
588-
- [ ] `syncStatus`/`syncAndWait`/`ensureExists` work.
589-
- [ ] No checkpoint truncates WAL before we've synced it.
628+
- [x] `npm test` green (128 tests: 110 core + 18 S3); `tsc --noEmit` clean.
629+
- [x] LTX encoder/decoder pass round-trip + golden + checksum-verification tests.
630+
- [x] WAL reader parses real `node:sqlite` WAL files, `pageMap` correct.
631+
- [x] `FileReplicaClient` writes/reads/deletes in the correct directory layout.
632+
- [x] End-to-end test: SQLite DB → `sync()` → `file://` replica → `restore()` → valid DB.
633+
- [x] `syncStatus`/`syncAndWait`/`ensureExists` work.
634+
- [x] No checkpoint truncates WAL before we've synced it.
590635

591636
---
592637

0 commit comments

Comments
 (0)