@platformatic/sqlite-js-stream 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +22 -0
  4. package/README.md +482 -2
  5. package/dist/backends/file/index.d.ts +4 -0
  6. package/dist/backends/file/index.d.ts.map +1 -0
  7. package/dist/backends/file/index.js +3 -0
  8. package/dist/backends/file/leaser.d.ts +20 -0
  9. package/dist/backends/file/leaser.d.ts.map +1 -0
  10. package/dist/backends/file/leaser.js +123 -0
  11. package/dist/backends/file/replica-client.d.ts +38 -0
  12. package/dist/backends/file/replica-client.d.ts.map +1 -0
  13. package/dist/backends/file/replica-client.js +209 -0
  14. package/dist/db.d.ts +80 -0
  15. package/dist/db.d.ts.map +1 -0
  16. package/dist/db.js +484 -0
  17. package/dist/errors.d.ts +42 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +66 -0
  20. package/dist/index.d.ts +19 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +22 -0
  23. package/dist/leaser.d.ts +55 -0
  24. package/dist/leaser.d.ts.map +1 -0
  25. package/dist/leaser.js +60 -0
  26. package/dist/ltx/byte-reader.d.ts +18 -0
  27. package/dist/ltx/byte-reader.d.ts.map +1 -0
  28. package/dist/ltx/byte-reader.js +38 -0
  29. package/dist/ltx/bytes.d.ts +20 -0
  30. package/dist/ltx/bytes.d.ts.map +1 -0
  31. package/dist/ltx/bytes.js +69 -0
  32. package/dist/ltx/checksum.d.ts +20 -0
  33. package/dist/ltx/checksum.d.ts.map +1 -0
  34. package/dist/ltx/checksum.js +42 -0
  35. package/dist/ltx/compaction-level.d.ts +58 -0
  36. package/dist/ltx/compaction-level.d.ts.map +1 -0
  37. package/dist/ltx/compaction-level.js +115 -0
  38. package/dist/ltx/compactor.d.ts +33 -0
  39. package/dist/ltx/compactor.d.ts.map +1 -0
  40. package/dist/ltx/compactor.js +137 -0
  41. package/dist/ltx/constants.d.ts +49 -0
  42. package/dist/ltx/constants.d.ts.map +1 -0
  43. package/dist/ltx/constants.js +58 -0
  44. package/dist/ltx/crc64.d.ts +29 -0
  45. package/dist/ltx/crc64.d.ts.map +1 -0
  46. package/dist/ltx/crc64.js +57 -0
  47. package/dist/ltx/decoder.d.ts +55 -0
  48. package/dist/ltx/decoder.d.ts.map +1 -0
  49. package/dist/ltx/decoder.js +236 -0
  50. package/dist/ltx/encoder.d.ts +49 -0
  51. package/dist/ltx/encoder.d.ts.map +1 -0
  52. package/dist/ltx/encoder.js +159 -0
  53. package/dist/ltx/file-info.d.ts +24 -0
  54. package/dist/ltx/file-info.d.ts.map +1 -0
  55. package/dist/ltx/file-info.js +74 -0
  56. package/dist/ltx/filename.d.ts +13 -0
  57. package/dist/ltx/filename.d.ts.map +1 -0
  58. package/dist/ltx/filename.js +23 -0
  59. package/dist/ltx/header.d.ts +17 -0
  60. package/dist/ltx/header.d.ts.map +1 -0
  61. package/dist/ltx/header.js +115 -0
  62. package/dist/ltx/index.d.ts +24 -0
  63. package/dist/ltx/index.d.ts.map +1 -0
  64. package/dist/ltx/index.js +25 -0
  65. package/dist/ltx/lz4.d.ts +41 -0
  66. package/dist/ltx/lz4.d.ts.map +1 -0
  67. package/dist/ltx/lz4.js +264 -0
  68. package/dist/ltx/page-index.d.ts +26 -0
  69. package/dist/ltx/page-index.d.ts.map +1 -0
  70. package/dist/ltx/page-index.js +118 -0
  71. package/dist/ltx/page.d.ts +27 -0
  72. package/dist/ltx/page.d.ts.map +1 -0
  73. package/dist/ltx/page.js +66 -0
  74. package/dist/ltx/sink.d.ts +17 -0
  75. package/dist/ltx/sink.d.ts.map +1 -0
  76. package/dist/ltx/sink.js +21 -0
  77. package/dist/ltx/stream.d.ts +64 -0
  78. package/dist/ltx/stream.d.ts.map +1 -0
  79. package/dist/ltx/stream.js +248 -0
  80. package/dist/ltx/streaming-compactor.d.ts +34 -0
  81. package/dist/ltx/streaming-compactor.d.ts.map +1 -0
  82. package/dist/ltx/streaming-compactor.js +140 -0
  83. package/dist/ltx/trailer.d.ts +12 -0
  84. package/dist/ltx/trailer.d.ts.map +1 -0
  85. package/dist/ltx/trailer.js +47 -0
  86. package/dist/ltx/types.d.ts +79 -0
  87. package/dist/ltx/types.d.ts.map +1 -0
  88. package/dist/ltx/types.js +70 -0
  89. package/dist/replica-client.d.ts +92 -0
  90. package/dist/replica-client.d.ts.map +1 -0
  91. package/dist/replica-client.js +122 -0
  92. package/dist/replica-url.d.ts +20 -0
  93. package/dist/replica-url.d.ts.map +1 -0
  94. package/dist/replica-url.js +49 -0
  95. package/dist/replica.d.ts +379 -0
  96. package/dist/replica.d.ts.map +1 -0
  97. package/dist/replica.js +1253 -0
  98. package/dist/store.d.ts +34 -0
  99. package/dist/store.d.ts.map +1 -0
  100. package/dist/store.js +60 -0
  101. package/dist/util/fs.d.ts +28 -0
  102. package/dist/util/fs.d.ts.map +1 -0
  103. package/dist/util/fs.js +73 -0
  104. package/dist/util/logger.d.ts +10 -0
  105. package/dist/util/logger.d.ts.map +1 -0
  106. package/dist/util/logger.js +7 -0
  107. package/dist/util/stream.d.ts +11 -0
  108. package/dist/util/stream.d.ts.map +1 -0
  109. package/dist/util/stream.js +25 -0
  110. package/dist/wal/checksum.d.ts +17 -0
  111. package/dist/wal/checksum.d.ts.map +1 -0
  112. package/dist/wal/checksum.js +30 -0
  113. package/dist/wal/constants.d.ts +8 -0
  114. package/dist/wal/constants.d.ts.map +1 -0
  115. package/dist/wal/constants.js +7 -0
  116. package/dist/wal/index.d.ts +5 -0
  117. package/dist/wal/index.d.ts.map +1 -0
  118. package/dist/wal/index.js +4 -0
  119. package/dist/wal/reader.d.ts +61 -0
  120. package/dist/wal/reader.d.ts.map +1 -0
  121. package/dist/wal/reader.js +207 -0
  122. package/package.json +68 -3
  123. package/src/backends/file/index.ts +3 -0
  124. package/src/backends/file/leaser.ts +145 -0
  125. package/src/backends/file/replica-client.ts +246 -0
  126. package/src/db.ts +553 -0
  127. package/src/errors.ts +74 -0
  128. package/src/index.ts +26 -0
  129. package/src/leaser.ts +86 -0
  130. package/src/ltx/byte-reader.ts +46 -0
  131. package/src/ltx/bytes.ts +87 -0
  132. package/src/ltx/checksum.ts +46 -0
  133. package/src/ltx/compaction-level.ts +135 -0
  134. package/src/ltx/compactor.ts +167 -0
  135. package/src/ltx/constants.ts +71 -0
  136. package/src/ltx/crc64.ts +65 -0
  137. package/src/ltx/decoder.ts +282 -0
  138. package/src/ltx/encoder.ts +200 -0
  139. package/src/ltx/file-info.ts +81 -0
  140. package/src/ltx/filename.ts +27 -0
  141. package/src/ltx/header.ts +126 -0
  142. package/src/ltx/index.ts +27 -0
  143. package/src/ltx/lz4.ts +279 -0
  144. package/src/ltx/page-index.ts +140 -0
  145. package/src/ltx/page.ts +80 -0
  146. package/src/ltx/sink.ts +33 -0
  147. package/src/ltx/stream.ts +286 -0
  148. package/src/ltx/streaming-compactor.ts +169 -0
  149. package/src/ltx/trailer.ts +58 -0
  150. package/src/ltx/types.ts +152 -0
  151. package/src/lz4js.d.ts +27 -0
  152. package/src/replica-client.ts +222 -0
  153. package/src/replica-url.ts +72 -0
  154. package/src/replica.ts +1480 -0
  155. package/src/store.ts +71 -0
  156. package/src/util/fs.ts +82 -0
  157. package/src/util/logger.ts +15 -0
  158. package/src/util/stream.ts +28 -0
  159. package/src/wal/checksum.ts +39 -0
  160. package/src/wal/constants.ts +10 -0
  161. package/src/wal/index.ts +5 -0
  162. package/src/wal/reader.ts +264 -0
package/README.md CHANGED
@@ -1,3 +1,483 @@
1
- # Temporary Holding Version
1
+ # sqlite-js-stream
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A **Node.js port of [Litestream](https://github.com/benbjohnson/litestream)** — SQLite streaming
4
+ replication and disaster recovery — written in **TypeScript** and built on the built-in `node:sqlite`.
5
+
6
+ SQLite is the most widely deployed database on the planet but it only lives on a single machine.
7
+ If that machine dies, your database is gone. This library replicates a SQLite database's **WAL
8
+ (Write-Ahead Log)** file into immutable, chronological **LTX (Log Transaction)** files on a remote
9
+ replica (S3, a local directory, etc.), and can **restore** a database from those files.
10
+
11
+ ---
12
+
13
+ ## Requirements
14
+
15
+ - **Node.js `>= 24`** — required for the built-in `node:sqlite`.
16
+ - Runtime dependency: `lz4js` (pure-JS LZ4 **block** decoder), matching the LTX format.
17
+ Compression is a byte-identical TypeScript port of Go's `pierrec/lz4/v4` Fast block compressor.
18
+
19
+ The **published** package ships compiled JavaScript plus TypeScript declarations
20
+ (`dist/*.js` + `dist/*.d.ts`), built at publish time via `pnpm build`.
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ This repository is a **pnpm workspace**. The core library lives at the root and
27
+ upload strategies (replica backends) live under `packages/`:
28
+
29
+ ```bash
30
+ pnpm install # link the workspace
31
+ pnpm build # transpile src -> dist (.js + .d.ts), used by addons/tests
32
+ ```
33
+
34
+ The core package is published as **`@platformatic/sqlite-js-stream`** and the S3
35
+ addon as **`@platformatic/sqlite-js-stream-s3`**:
36
+
37
+ ```bash
38
+ pnpm add @platformatic/sqlite-js-stream
39
+ pnpm add @platformatic/sqlite-js-stream-s3 # optional addon
40
+ ```
41
+
42
+ Importing the addon registers the `s3` URL scheme with
43
+ `newReplicaClientFromURL('s3://bucket/prefix')`.
44
+
45
+ ---
46
+
47
+ ## Quick start
48
+
49
+ ```ts
50
+ import { Replica, FileReplicaClient } from '@platformatic/sqlite-js-stream';
51
+
52
+ // 1. Point the replica at your database path and a replica destination.
53
+ // The library reads the database + `-wal` directly and is agnostic to
54
+ // whichever SQLite driver your application uses.
55
+ const client = new FileReplicaClient('/data/replica');
56
+ const replica = new Replica('/data/app.db', client);
57
+
58
+ // 2. Sync builds a local LTX file (into <db>-litestream/ltx/0/) and uploads it.
59
+ await replica.sync();
60
+ ```
61
+
62
+ The database is written to by your **own** SQLite connection (using `node:sqlite`, `better-sqlite3`,
63
+ or anything else). After more writes, just `sync()` again:
64
+
65
+ ```ts
66
+ // ... your app writes rows with whatever driver you use ...
67
+ await replica.sync();
68
+
69
+ const status = await replica.syncStatus();
70
+ console.log(status.inSync, status.localTXID, status.remoteTXID); // true 3n 3n
71
+ ```
72
+
73
+ ### Take a full snapshot
74
+
75
+ Normal `sync()` writes **incremental** level-0 files. For a complete, point-in-time backup, take a
76
+ **level-9 snapshot** (a full database copy as an LTX file with `MinTXID = 1`):
77
+
78
+ ```ts
79
+ await replica.snapshot();
80
+ ```
81
+
82
+ ### Restore a database
83
+
84
+ Restore reconstructs a SQLite database by compacting a level-9 snapshot with the subsequent
85
+ level-0 files:
86
+
87
+ ```ts
88
+ await replica.restore({ outputPath: '/data/restored.db' });
89
+ ```
90
+
91
+ Restore to a specific transaction ID:
92
+
93
+ ```ts
94
+ await replica.restore({ outputPath: '/data/app-at-txid.db', txID: 42n });
95
+ ```
96
+
97
+ ### Continuously restore (follow)
98
+
99
+ `follow()` restores the latest backup, then keeps the database up to date as new backups are
100
+ replicated — maintaining a read-only replica (litestream's `restore -f`):
101
+
102
+ ```ts
103
+ const ac = new AbortController();
104
+ await replica.follow({ outputPath: '/data/replica.db', intervalMs: 1000, signal: ac.signal });
105
+ // later: ac.abort() to stop
106
+ ```
107
+
108
+ The applied TXID is recorded in a `-txid` sidecar (default `<outputPath>-txid`) so a later `follow()`
109
+ resumes instead of re-restoring. The output database should only be opened **read-only**.
110
+
111
+ ### Restore the local database if it is missing
112
+
113
+ ```ts
114
+ await replica.ensureExists();
115
+ ```
116
+
117
+ ### Manage many databases
118
+
119
+ ```ts
120
+ import { Store } from '@platformatic/sqlite-js-stream';
121
+
122
+ const store = new Store();
123
+ await store.openDB('/data/a.db', new FileReplicaClient('/data/replica-a'));
124
+ await store.openDB('/data/b.db', new FileReplicaClient('/data/replica-b'));
125
+
126
+ await store.syncAll(); // local sync + upload for every database
127
+ await store.snapshotAll(); // level-9 snapshots for every database
128
+
129
+ store.close();
130
+ ```
131
+
132
+ ### Replica URL
133
+
134
+ Replica clients are created from URLs via the factory registry:
135
+
136
+ ```ts
137
+ import { newReplicaClientFromURL, Replica } from '@platformatic/sqlite-js-stream';
138
+
139
+ const client = newReplicaClientFromURL('file:///data/replica');
140
+ const replica = new Replica('/data/app.db', client);
141
+ ```
142
+
143
+ The `file` scheme is built into the core. Additional schemes (S3, etc.) are
144
+ registered by importing the matching addon (see below).
145
+
146
+ ### S3 upload strategy
147
+
148
+ The `@platformatic/sqlite-js-stream-s3` addon provides an S3 replica client. Import it
149
+ once to register the `s3://` scheme, then create clients from URLs or directly:
150
+
151
+ ```ts
152
+ import { Replica, newReplicaClientFromURL } from '@platformatic/sqlite-js-stream';
153
+ import { S3ReplicaClient } from '@platformatic/sqlite-js-stream-s3';
154
+
155
+ // Via a URL (bucket from the host, prefix from the path).
156
+ const client = newReplicaClientFromURL('s3://my-bucket/backup?region=us-east-1');
157
+
158
+ // Or directly, with explicit AWS config.
159
+ const s3 = new S3ReplicaClient({
160
+ bucket: 'my-bucket',
161
+ prefix: 'backup',
162
+ region: 'us-east-1',
163
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
164
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
165
+ });
166
+
167
+ const replica = new Replica('/data/app.db', client);
168
+ await replica.run({ intervalMs: 1000 });
169
+ ```
170
+
171
+ Objects are stored as `{prefix}/ltx/{level}/{minTXID}-{maxTXID}.ltx` — the same
172
+ layout as the file backend, so a replica can move between strategies. Creation
173
+ timestamps are preserved as the `ltx-timestamp` object metadata.
174
+
175
+ > **Testing without AWS:** `S3ReplicaClient` accepts an injected `client`
176
+ > implementing `send(command)`. Pass an in-memory fake (see
177
+ > `packages/backend-s3/test/helpers/memory-s3.ts`) to exercise the addon without
178
+ > network access.
179
+
180
+ ### Continuous sync
181
+
182
+ For ongoing replication, `replica.run()` syncs immediately and then repeats on an
183
+ interval until the supplied `AbortSignal` is aborted. Sync errors are logged and the
184
+ loop keeps going, so a transient failure doesn't stop replication:
185
+
186
+ ```ts
187
+ import { Replica, FileReplicaClient } from '@platformatic/sqlite-js-stream';
188
+
189
+ const replica = new Replica('/data/app.db', new FileReplicaClient('/data/replica'));
190
+ const ac = new AbortController();
191
+
192
+ // Run continuously (sync now, then every 1s); resolves when aborted.
193
+ const runP = replica.run({ intervalMs: 1000, signal: ac.signal });
194
+
195
+ // ... later, to stop:
196
+ ac.abort();
197
+ await runP;
198
+ ```
199
+
200
+ If you want to keep replicating in the background without awaiting, call `run()` and
201
+ `catch` its promise (it resolves on abort):
202
+
203
+ ```ts
204
+ replica.run({ intervalMs: 1000, signal: ac.signal }).catch((err) => {
205
+ console.error('continuous sync stopped', err);
206
+ });
207
+ ```
208
+
209
+ `Store` also exposes `syncAll()`/`snapshotAll()` for one-shot operations; a continuous
210
+ loop can be driven by calling `replica.run()` per database, or via your own scheduler.
211
+
212
+ ### Replication from a worker thread
213
+
214
+ Run `replica.run()` in a **worker thread** so continuous replication doesn't compete with
215
+ your application's main thread. The worker opens its own **read-only** connection (via the
216
+ library) and reads the database + `-wal` files, while your app keeps writing from the main
217
+ thread on its own connection. Only the **path**s need to cross threads.
218
+
219
+ `replica-worker.ts`:
220
+
221
+ ```ts
222
+ import { parentPort } from 'node:worker_threads';
223
+ import { Replica, FileReplicaClient } from '@platformatic/sqlite-js-stream';
224
+
225
+ parentPort.on('message', async (msg) => {
226
+ if (msg?.type !== 'start') return;
227
+ const { dbPath, clientPath, intervalMs = 1000 } = msg;
228
+
229
+ const replica = new Replica(dbPath, new FileReplicaClient(clientPath));
230
+ const ac = new AbortController();
231
+ const runP = replica.run({ intervalMs, signal: ac.signal });
232
+
233
+ parentPort.postMessage({ type: 'started' });
234
+ parentPort.on('message', async (m) => {
235
+ if (m?.type === 'stop') {
236
+ ac.abort();
237
+ await runP;
238
+ parentPort.postMessage({ type: 'done' });
239
+ }
240
+ });
241
+ });
242
+ ```
243
+
244
+ Main thread:
245
+
246
+ ```ts
247
+ import { Worker } from 'node:worker_threads';
248
+
249
+ const worker = new Worker(new URL('./replica-worker.ts', import.meta.url));
250
+ const started = new Promise((resolve) => worker.on('message', (m) => m?.type === 'started' && resolve()));
251
+ const done = new Promise((resolve) => worker.on('message', (m) => m?.type === 'done' && resolve()));
252
+
253
+ worker.postMessage({ type: 'start', dbPath: '/data/app.db', clientPath: '/data/replica', intervalMs: 1000 });
254
+ await started;
255
+
256
+ // Your app keeps using node:sqlite (or better-sqlite3) from the main thread.
257
+ // ... write rows ...
258
+
259
+ // Shut the replication worker down gracefully.
260
+ worker.postMessage({ type: 'stop' });
261
+ await done;
262
+ await worker.terminate();
263
+ ```
264
+
265
+ > **Important:** the worker and your app each use their **own** connection to the same database
266
+ > file. Never hand the worker the same `DatabaseSync` handle your app writes with — a single
267
+ > connection holds one transaction at a time, so sharing a writable handle would merge the read
268
+ > snapshot with your writes (see *Concurrency & snapshot consistency* above).
269
+
270
+ ---
271
+
272
+ ## How it works
273
+
274
+ Litestream (and this port) never touches the database through the SQLite WAL/VFS as part of
275
+ replication. Instead it:
276
+
277
+ 1. **Reads the `-wal` file directly** (parsing frames, verifying the WAL salt + checksum) and the
278
+ **database file directly** via `fs`.
279
+ 2. **Builds an LTX file** per sync — a sequence of LZ4-compressed, checksummed pages that capture
280
+ the changed pages, tagged with a sequential **transaction ID**.
281
+ 3. **Uploads** those immutable LTX files to a replica.
282
+ 4. **Restores** by compacting a snapshot with the stream of level-0 files and writing a valid
283
+ SQLite file.
284
+
285
+ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design and
286
+ [`docs/LTX_FORMAT.md`](docs/LTX_FORMAT.md) for the on-disk format.
287
+
288
+ ### Concurrency & snapshot consistency
289
+
290
+ To keep snapshots consistent, `sync()`/`snapshot()` open a **separate, read-only** SQLite
291
+ connection to the same database and hold a short-lived read transaction while they read the
292
+ `-wal` + database files. SQLite will not checkpoint/truncate the WAL past an active reader, and the
293
+ pinned reader's `PRAGMA page_count` reports the commit including uncheckpointed WAL pages.
294
+
295
+ Your application is free to use its own SQLite driver and connections (`node:sqlite`,
296
+ `better-sqlite3`, …) independently — the library reads only the files and never touches them.
297
+
298
+ > **Do not point the library at your application's writable connection.**
299
+ >
300
+ > A single SQLite connection holds **one transaction at a time**. If the library were to use the
301
+ > same `DatabaseSync` handle as your writer:
302
+ > - its read snapshot would **not be isolated** from concurrent app writes (they'd merge into one
303
+ > transaction, and the library could read *uncommitted* rows or commit the app's writes), and
304
+ > - if your app had a transaction open, the library could not `BEGIN` its snapshot pin at all.
305
+ >
306
+ > The library has no API to accept an app-owned writable connection, and it deliberately opens its
307
+ > own read-only reader instead. Keep the library's reader and your app's writer on separate
308
+ > connections to the same database file.
309
+
310
+ ### Large databases & streaming
311
+
312
+ The replication path is **streaming** end-to-end, so peak memory is bounded by one page buffer
313
+ (plus the small LTX page index) rather than the database size:
314
+
315
+ - `sync()`/`snapshot()` read database **pages one at a time from a file handle** and stream the
316
+ encoded LTX straight to a temp file (never buffering the whole DB/WAL).
317
+ - `restore()` streams each LTX file's pages into the output database, overlaying incremental files
318
+ on the snapshot base — it never materializes a full merged file or the whole DB in memory.
319
+ - The S3 addon uploads objects larger than **5 MiB** with the **multipart** API, so a large snapshot
320
+ is never buffered on the upload side either.
321
+
322
+ ### Benchmarks
323
+
324
+ Reproducible, configurable benchmarks live in [`bench/bench-sync-restore.ts`](bench/bench-sync-restore.ts)
325
+ and measure `sync()`/`restore()` throughput plus peak process RSS:
326
+
327
+ ```bash
328
+ pnpm bench
329
+ # another size / batch:
330
+ ROWS=200000 BATCH=1000 pnpm bench
331
+ # demonstrate the streaming memory bound (peak RSS vs DB size):
332
+ SCALE=4 pnpm bench
333
+ ```
334
+
335
+ Conditions are disclosed in the output (rows, batch/transactions, source WAL bytes). Peak RSS is
336
+ process-wide (it includes the Node baseline), so the meaningful claim is that it stays **flat** as the
337
+ database grows — see the `SCALE` mode, which shows WAL bytes and peak RSS at two sizes.
338
+
339
+ ### Compaction & retention
340
+
341
+ Over time `sync()` writes one level-0 LTX file per transaction. To keep the replica bounded and
342
+ restore fast, compact those into higher levels and prune data that has been superseded:
343
+
344
+ ```ts
345
+ // Merge pending level-0 files into a level-1 file (and 1→2→3 up to maxCompactionLevel).
346
+ await replica.compact({ dstLevel: 1 });
347
+
348
+ // Time-based retention: delete snapshots older than 24h (always keeping the
349
+ // newest), cascade the boundary down through the other levels, and delete
350
+ // level-0 files that were compacted into level-1 once they age past 5m.
351
+ const result = await replica.enforceRetention({
352
+ snapshotRetentionMs: 24 * 60 * 60 * 1000,
353
+ l0RetentionMs: 5 * 60 * 1000,
354
+ });
355
+ ```
356
+
357
+ `Replica.run()` can drive all of this on their own schedules, and `Store` orchestrates it across
358
+ every replica.
359
+
360
+ ```ts
361
+ import { CompactionLevels } from '@platformatic/sqlite-js-stream';
362
+
363
+ // Each level is compacted at most once per wall-clock bucket (L1 every 30s,
364
+ // L2 every 5m, L3 every 1h) — mirrors litestream's per-level pacing.
365
+ const levels = new CompactionLevels([
366
+ { level: 0, intervalMs: 0 },
367
+ { level: 1, intervalMs: 30_000 },
368
+ { level: 2, intervalMs: 300_000 },
369
+ { level: 3, intervalMs: 3_600_000 },
370
+ ]);
371
+
372
+ await store.run({
373
+ intervalMs: 1000,
374
+ compactionLevels: levels,
375
+ snapshotIntervalMs: 24 * 60 * 60 * 1000,
376
+ snapshotRetentionMs: 24 * 60 * 60 * 1000,
377
+ l0RetentionMs: 5 * 60 * 1000,
378
+ signal,
379
+ });
380
+ ```
381
+
382
+ Compaction is **streaming** (bounded memory) and only deletes a lower-level file after the higher
383
+ level is written *and* uploaded, so a mid-compaction failure never loses data. Retention never deletes
384
+ files needed to restore any point within the retention window, and the live-head level-0 file is
385
+ always retained.
386
+
387
+ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#compaction--retention) for the full model.
388
+
389
+ ### Distributed leasing
390
+
391
+ When several replicas point at the same storage, only one may write at a time. Give the replica a
392
+ `Leaser` (a `lock.json` lease) and `run()` will make exactly one replica the active **primary**;
393
+ the others become idle **followers** that poll until the lease is free. Storage writes are gated on
394
+ lease ownership, so a secondary can never split the brain.
395
+
396
+ ```ts
397
+ import { FileLeaser } from '@platformatic/sqlite-js-stream';
398
+
399
+ replica.leaser = new FileLeaser('/data/replica'); // becomes primary if the lease is free
400
+ await replica.run({ intervalMs: 1000, signal });
401
+
402
+ // Query which mode we are in:
403
+ if (replica.isPrimary()) {
404
+ // we are writing
405
+ }
406
+ ```
407
+
408
+ The file backend uses an exclusive-create lease with a content-hash token; the S3 addon
409
+ (`import { S3Leaser } from '@platformatic/sqlite-js-stream-s3'`) uses S3 `If-None-Match`/`If-Match`
410
+ conditional writes so racy acquirers cannot both win.
411
+
412
+ ### ⚠️ This is a backup tool, not a distributed database
413
+
414
+ The lease coordinates **which host writes to storage**. It does **not** merge, compare, or reconcile
415
+ the actual data in your databases. Be very clear about what you're pointing at the same prefix.
416
+
417
+ **Multiple hosts are only safe if they hold the *same* database content** (e.g. a DR/standby host that
418
+ was restored from the same backup). The lease then prevents two hosts from both writing, and gives
419
+ you automatic failover when the primary stops.
420
+
421
+ **Do NOT point hosts that hold *different* data at the same prefix.** SQLite is a
422
+ single-writer, single-file database. If host A has `row id=1 → "X"` and host B has
423
+ `row id=1 → "Y"` and they share one prefix, here is what happens:
424
+
425
+ | Host A local DB | Host B local DB | What lands in S3 |
426
+ |----------------------|---------------------|------------------|
427
+ | `row id=1 → "X"` | `row id=1 → "Y"` | `"X"` (only if **A** is the primary) |
428
+
429
+ - Whichever host is the **primary** gets backed up. The other host's value is **never uploaded**, is
430
+ not merged, and is effectively **lost** from the backup's perspective.
431
+ - When you restore, you get exactly one host's state. There is **no conflict resolution**. `"Y"` is
432
+ simply gone.
433
+
434
+ **Choose the right setup:**
435
+
436
+ | If you need… | Do this |
437
+ |---------------------------------------------------------|----------|
438
+ | Each host owns a **different** database | Give each its own prefix, e.g. `app/${tenantId}` — separate backup per DB, no conflict. |
439
+ | Multiple app instances sharing one consistent dataset | Don't use SQLite + this replication. Use a client-server DB (Postgres/MySQL) or a real shared-file lock + single writer. |
440
+ | Same data on every host (failover / DR) | Keep exactly one writer. On failover, **restore the standby from S3 first**, then let it take the lease — don't let a stale local file start writing. |
441
+
442
+ ---
443
+
444
+ ## Deferred features
445
+
446
+ Not implemented — listed here so the roadmap is clear. None of these are required for the library
447
+ core to replicate, snapshot, compact, lease, and restore a SQLite database.
448
+
449
+ - **CLI + YAML config** — this is a *library*. The `litestream replicate/restore/ltx/reset/snapshots`
450
+ CLI and a `litestream.yml`-style config are out of scope for now.
451
+ - **Metrics/telemetry** — no Prometheus/OpenTelemetry instrumentation yet.
452
+ - **Point-in-time restore edge cases** — `txID`/timestamp restore is supported, but litestream's
453
+ full edge-case matrix is not exhaustively covered.
454
+ - **VFS real-time mirroring** — hooking into SQLite's VFS to mirror writes live is not implemented.
455
+ - **IPC control server** — litestream's Unix-socket IPC server is not implemented.
456
+
457
+ ---
458
+
459
+ ## Documentation
460
+
461
+ - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — system design (DB, Replica, ReplicaClient,
462
+ LTX, WAL, backends).
463
+ - [`docs/LTX_FORMAT.md`](docs/LTX_FORMAT.md) — the LTX binary format.
464
+ - [`test/`](test) — TDD test suite (`npm test`).
465
+
466
+ ---
467
+
468
+ ## Credits
469
+
470
+ This project is a **port of [Litestream](https://github.com/benbjohnson/litestream)**
471
+ by [Ben Johnson](https://github.com/benbjohnson) (Apache-2.0). The LTX file format and much of the
472
+ design come from the [`superfly/ltx`](https://github.com/superfly/ltx) library and the litestream
473
+ documentation (`docs/ARCHITECTURE.md`, `docs/SQLITE_INTERNALS.md`, `docs/LTX_FORMAT.md`,
474
+ `docs/PATTERNS.md`), which are adapted here with attribution.
475
+
476
+ The SQLite **WAL** and **LTX** formats are replicated faithfully so that this library can read LTX
477
+ files produced by Litestream and vice-versa.
478
+
479
+ ---
480
+
481
+ ## License
482
+
483
+ Apache License 2.0. See [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
@@ -0,0 +1,4 @@
1
+ /** File backend. */
2
+ export * from './replica-client.js';
3
+ export * from './leaser.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/file/index.ts"],"names":[],"mappings":"AAAA,oBAAoB;AACpB,cAAc,qBAAqB,CAAC;AACpC,cAAc,aAAa,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** File backend. */
2
+ export * from "./replica-client.js";
3
+ export * from "./leaser.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * File-backend Leaser.
3
+ *
4
+ * Stores the lease as `<path>/lock.json` and uses an exclusive create + a
5
+ * content hash as the conditional-write token. The file backend is primarily
6
+ * for local/single-host use; for real multi-host safety use the S3 leaser
7
+ * (which uses S3 `If-None-Match`/`If-Match` conditional writes).
8
+ */
9
+ import { Lease, type Leaser } from '../../leaser.js';
10
+ /** FileLeaser implements {@link Leaser} for a local directory. */
11
+ export declare class FileLeaser implements Leaser {
12
+ private lockPath;
13
+ private ttlMs;
14
+ constructor(path: string, lockName?: string, ttlMs?: number);
15
+ acquireLease(): Promise<Lease>;
16
+ renewLease(lease: Lease): Promise<Lease>;
17
+ releaseLease(lease: Lease): Promise<void>;
18
+ private owner;
19
+ }
20
+ //# sourceMappingURL=leaser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"leaser.d.ts","sourceRoot":"","sources":["../../../src/backends/file/leaser.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,EAIL,KAAK,EAEL,KAAK,MAAM,EACZ,MAAM,iBAAiB,CAAC;AA4CzB,kEAAkE;AAClE,qBAAa,UAAW,YAAW,MAAM;IACvC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,KAAK,CAAS;gBAEV,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAmB,EAAE,KAAK,SAAkB;IAKxE,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC;IA6C9B,UAAU,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAexC,YAAY,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;IAO/C,OAAO,CAAC,KAAK;CAGd"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * File-backend Leaser.
3
+ *
4
+ * Stores the lease as `<path>/lock.json` and uses an exclusive create + a
5
+ * content hash as the conditional-write token. The file backend is primarily
6
+ * for local/single-host use; for real multi-host safety use the S3 leaser
7
+ * (which uses S3 `If-None-Match`/`If-Match` conditional writes).
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ import { link, readFile, rename, rm, writeFile } from 'node:fs/promises';
11
+ import nodePath from 'node:path';
12
+ import { DefaultLeasePath, DefaultLeaseTTL, ErrLeaseNotHeld, Lease, LeaseExistsError, } from "../../leaser.js";
13
+ import { FileReplicaError } from "../../errors.js";
14
+ // Disambiguates temp files written in the same millisecond by this process.
15
+ let tmpSeq = 0;
16
+ function sha256(data) {
17
+ return createHash('sha256').update(data).digest('hex');
18
+ }
19
+ function encodeLease(l) {
20
+ return new TextEncoder().encode(JSON.stringify({
21
+ generation: l.generation,
22
+ expires_at: new Date(l.expiresAt).toISOString(),
23
+ owner: l.owner,
24
+ }));
25
+ }
26
+ function decodeLease(data, etag) {
27
+ const parsed = JSON.parse(new TextDecoder().decode(data));
28
+ const expiresAt = Date.parse(parsed.expires_at ?? '');
29
+ if (Number.isNaN(expiresAt)) {
30
+ throw new FileReplicaError('invalid lease file: bad expires_at');
31
+ }
32
+ return new Lease(parsed.generation ?? 1, expiresAt, parsed.owner ?? '', etag);
33
+ }
34
+ /** Reads and decodes the lease file, returning null if it does not exist. */
35
+ async function readLease(lockPath) {
36
+ try {
37
+ const data = await readFile(lockPath);
38
+ return decodeLease(data, sha256(data));
39
+ }
40
+ catch (err) {
41
+ if (err.code === 'ENOENT')
42
+ return null;
43
+ throw err;
44
+ }
45
+ }
46
+ /** FileLeaser implements {@link Leaser} for a local directory. */
47
+ export class FileLeaser {
48
+ lockPath;
49
+ ttlMs;
50
+ constructor(path, lockName = DefaultLeasePath, ttlMs = DefaultLeaseTTL) {
51
+ this.lockPath = nodePath.join(path, lockName);
52
+ this.ttlMs = ttlMs;
53
+ }
54
+ async acquireLease() {
55
+ const existing = await readLease(this.lockPath);
56
+ if (existing !== null && !existing.isExpired()) {
57
+ throw new LeaseExistsError(existing.owner, existing.expiresAt);
58
+ }
59
+ let generation = 1;
60
+ if (existing !== null)
61
+ generation = existing.generation + 1;
62
+ const lease = new Lease(generation, Date.now() + this.ttlMs, this.owner());
63
+ const bytes = encodeLease(lease);
64
+ if (existing === null) {
65
+ // Exclusive create so we do not clobber a concurrent acquirer. Write a
66
+ // temp file and hard-link it into place so the lock appears atomically
67
+ // with its full contents; a racing reader never sees an empty file.
68
+ const tmp = `${this.lockPath}.tmp-${process.pid}-${Date.now()}-${tmpSeq++}`;
69
+ await writeFile(tmp, bytes);
70
+ try {
71
+ await link(tmp, this.lockPath);
72
+ }
73
+ catch (err) {
74
+ const code = err.code;
75
+ if (code === 'EEXIST') {
76
+ const current = await readLease(this.lockPath);
77
+ if (current !== null && !current.isExpired()) {
78
+ throw new LeaseExistsError(current.owner, current.expiresAt);
79
+ }
80
+ // The other writer lost the race; retry the expired-overwrite path.
81
+ return this.acquireLease();
82
+ }
83
+ throw err;
84
+ }
85
+ finally {
86
+ await rm(tmp, { force: true });
87
+ }
88
+ }
89
+ else {
90
+ // Existing lease expired: atomically replace it.
91
+ const tmp = `${this.lockPath}.tmp-${process.pid}-${Date.now()}-${tmpSeq++}`;
92
+ await writeFile(tmp, bytes);
93
+ await rename(tmp, this.lockPath);
94
+ }
95
+ const written = await readLease(this.lockPath);
96
+ return written ?? lease;
97
+ }
98
+ async renewLease(lease) {
99
+ const current = await readLease(this.lockPath);
100
+ if (current === null)
101
+ throw new ErrLeaseNotHeld();
102
+ if (current.etag !== lease.etag)
103
+ throw new ErrLeaseNotHeld();
104
+ const renewed = new Lease(lease.generation, Date.now() + this.ttlMs, lease.owner);
105
+ const bytes = encodeLease(renewed);
106
+ const tmp = `${this.lockPath}.tmp-${process.pid}-${Date.now()}-${tmpSeq++}`;
107
+ await writeFile(tmp, bytes);
108
+ await rename(tmp, this.lockPath);
109
+ const written = await readLease(this.lockPath);
110
+ return written ?? renewed;
111
+ }
112
+ async releaseLease(lease) {
113
+ const current = await readLease(this.lockPath);
114
+ if (current === null)
115
+ return; // already released
116
+ if (current.etag !== lease.etag)
117
+ throw new ErrLeaseNotHeld();
118
+ await rm(this.lockPath, { force: true });
119
+ }
120
+ owner() {
121
+ return `${process.pid}`;
122
+ }
123
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * File replica client — replicates LTX files to a local directory.
3
+ *
4
+ * Layout: `<root>/ltx/<level>/<minTXID>-<maxTXID>.ltx`
5
+ * Writes are atomic: data is streamed to a `.tmp` file, fsync'd, then renamed,
6
+ * the directory is fsync'd, and the file mtime is set from the LTX header
7
+ * timestamp to preserve original creation time.
8
+ */
9
+ import { Readable } from 'node:stream';
10
+ import { type OpenLTXFileOptions, type ReplicaClient, type WriteLTXFileOptions } from '../../replica-client.js';
11
+ import type { FileInfo } from '../../ltx/index.js';
12
+ import type { Logger } from '../../util/logger.js';
13
+ export declare const ReplicaClientType = "file";
14
+ /** FileReplicaClient writes LTX files to a local directory. */
15
+ export declare class FileReplicaClient implements ReplicaClient {
16
+ private pathVal;
17
+ private logger;
18
+ constructor(path: string);
19
+ type(): string;
20
+ init(): Promise<void>;
21
+ setLogger(logger: Logger): void;
22
+ /** Returns the destination root path. */
23
+ path(): string;
24
+ ltxLevelDir(level: number): string;
25
+ ltxFilePath(level: number, minTXID: bigint, maxTXID: bigint): string;
26
+ ltxFiles(opts: {
27
+ level: number;
28
+ seek: bigint;
29
+ useMetadata: boolean;
30
+ }): AsyncIterable<FileInfo>;
31
+ openLtxFile(opts: OpenLTXFileOptions): Promise<Readable>;
32
+ writeLtxFile(opts: WriteLTXFileOptions, body: AsyncIterable<Uint8Array | string>): Promise<FileInfo>;
33
+ deleteLtxFiles(infos: FileInfo[]): Promise<void>;
34
+ deleteAll(): Promise<void>;
35
+ }
36
+ /** NewReplicaClientFromURL creates a FileReplicaClient from a URL path. */
37
+ export declare function NewReplicaClientFromURL(_scheme: string, _host: string, urlPath: string, _query: URLSearchParams | null, _userinfo: string | null): ReplicaClient;
38
+ //# sourceMappingURL=replica-client.d.ts.map