@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/src/leaser.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Distributed leasing.
3
+ *
4
+ * When multiple replicas point at the same storage path, only one may be the
5
+ * active writer at a time. A `Leaser` acquires a lease on the replica's storage
6
+ * (a `lock.json` file / object); only the lease holder is the *primary* and may
7
+ * write. Other replicas become idle *followers* that poll until the lease is
8
+ * available.
9
+ *
10
+ * This mirrors litestream's `leaser.go`/`Lease` (a storage-agnostic interface)
11
+ * and `s3/leaser.go` (a conditional-write S3 implementation). Acquire uses
12
+ * `If-None-Match: *` when no lease exists and `If-Match: <etag>` (renew) or
13
+ * `If-Match: <etag>` (release) otherwise, so a stale writer can never clobber a
14
+ * live one.
15
+ */
16
+
17
+ import { SqliteJsStreamError } from './errors.ts';
18
+
19
+ /** A held lease. `etag` is the conditional-write token for renew/release. */
20
+ export class Lease {
21
+ generation: number;
22
+ expiresAt: number; // ms since epoch
23
+ owner: string;
24
+ etag: string | null = null;
25
+
26
+ constructor(generation: number, expiresAt: number, owner: string, etag: string | null = null) {
27
+ this.generation = generation;
28
+ this.expiresAt = expiresAt;
29
+ this.owner = owner;
30
+ this.etag = etag;
31
+ }
32
+
33
+ /** IsExpired reports whether the lease has passed its expiration. */
34
+ isExpired(now = Date.now()): boolean {
35
+ return now > this.expiresAt;
36
+ }
37
+
38
+ /** TTL returns the remaining time before expiration, in ms. */
39
+ ttl(now = Date.now()): number {
40
+ return this.expiresAt - now;
41
+ }
42
+ }
43
+
44
+ /** The lease is already held by another owner. */
45
+ export class LeaseExistsError extends SqliteJsStreamError {
46
+ owner: string;
47
+ expiresAt: number;
48
+
49
+ constructor(owner: string, expiresAt: number) {
50
+ super(
51
+ 'ERR_LEASE_EXISTS',
52
+ owner !== ''
53
+ ? `lease already held by ${owner} until ${new Date(expiresAt).toISOString()}`
54
+ : `lease already held until ${new Date(expiresAt).toISOString()}`,
55
+ );
56
+ this.name = 'LeaseExistsError';
57
+ this.owner = owner;
58
+ this.expiresAt = expiresAt;
59
+ }
60
+ }
61
+
62
+ /** The caller no longer holds the lease (lost it or never acquired it). */
63
+ export class ErrLeaseNotHeld extends SqliteJsStreamError {
64
+ constructor() {
65
+ super('ERR_LEASE_NOT_HELD', 'lease not held');
66
+ this.name = 'ErrLeaseNotHeld';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Leaser acquires, renews, and releases a lease on a storage path. The
72
+ * implementation must use conditional writes so concurrent acquirers cannot
73
+ * both win.
74
+ */
75
+ export interface Leaser {
76
+ /** Acquires the lease, throwing {@link LeaseExistsError} if held elsewhere. */
77
+ acquireLease(): Promise<Lease>;
78
+ /** Renews the lease, throwing {@link ErrLeaseNotHeld} if we lost it. */
79
+ renewLease(lease: Lease): Promise<Lease>;
80
+ /** Releases the lease, throwing {@link ErrLeaseNotHeld} if we lost it. */
81
+ releaseLease(lease: Lease): Promise<void>;
82
+ }
83
+
84
+ export const DefaultLeaseTTL = 30_000; // 30s
85
+ export const DefaultLeasePath = 'lock.json';
86
+ export const DefaultLeaseRenewalBuffer = 5_000; // renew before it expires
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A sequential byte reader over an in-memory buffer. Used by the LTX decoder
3
+ * to walk the page block, page index, and trailer while tracking position.
4
+ */
5
+
6
+ import { assertFull } from './bytes.ts';
7
+ import { LTXFormatError } from '../errors.ts';
8
+
9
+ export class ByteReader {
10
+ private buf: Uint8Array;
11
+ pos: number;
12
+
13
+ constructor(buf: Uint8Array, start = 0) {
14
+ this.buf = buf;
15
+ this.pos = start;
16
+ }
17
+
18
+ get length(): number {
19
+ return this.buf.length;
20
+ }
21
+
22
+ get remaining(): number {
23
+ return this.buf.length - this.pos;
24
+ }
25
+
26
+ /** Reads one byte, advancing the position. Throws on EOF. */
27
+ readByte(): number {
28
+ if (this.pos >= this.buf.length) {
29
+ throw new LTXFormatError('unexpected end of input');
30
+ }
31
+ return this.buf[this.pos++];
32
+ }
33
+
34
+ /** Reads exactly `n` bytes, advancing the position. Throws on short input. */
35
+ readFull(n: number): Uint8Array {
36
+ assertFull(this.buf, this.pos, n);
37
+ const out = this.buf.subarray(this.pos, this.pos + n);
38
+ this.pos += n;
39
+ return out;
40
+ }
41
+
42
+ /** Returns a view of the bytes in [start, end) without moving the position. */
43
+ slice(start: number, end: number): Uint8Array {
44
+ return this.buf.subarray(start, end);
45
+ }
46
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Big-endian byte helpers for LTX marshalling.
3
+ */
4
+
5
+ export function writeU8(buf: Uint8Array, off: number, v: number): void {
6
+ buf[off] = v & 0xff;
7
+ }
8
+
9
+ export function writeU16BE(buf: Uint8Array, off: number, v: number): void {
10
+ buf[off] = (v >>> 8) & 0xff;
11
+ buf[off + 1] = v & 0xff;
12
+ }
13
+
14
+ export function writeU32BE(buf: Uint8Array, off: number, v: number): void {
15
+ buf[off] = (v >>> 24) & 0xff;
16
+ buf[off + 1] = (v >>> 16) & 0xff;
17
+ buf[off + 2] = (v >>> 8) & 0xff;
18
+ buf[off + 3] = v & 0xff;
19
+ }
20
+
21
+ export function writeU64BE(buf: Uint8Array, off: number, v: bigint): void {
22
+ let n = v;
23
+ for (let i = 0; i < 8; i++) {
24
+ buf[off + (7 - i)] = Number(n & 0xffn);
25
+ n >>= 8n;
26
+ }
27
+ }
28
+
29
+ export function readU16BE(buf: Uint8Array, off: number): number {
30
+ return (buf[off] << 8) | buf[off + 1];
31
+ }
32
+
33
+ export function readU32BE(buf: Uint8Array, off: number): number {
34
+ return (
35
+ ((buf[off] | 0) << 24) >>> 0 |
36
+ (buf[off + 1] << 16) |
37
+ (buf[off + 2] << 8) |
38
+ buf[off + 3]
39
+ ) >>> 0;
40
+ }
41
+
42
+ export function readU32LE(buf: Uint8Array, off: number): number {
43
+ return (
44
+ buf[off] |
45
+ (buf[off + 1] << 8) |
46
+ (buf[off + 2] << 16) |
47
+ ((buf[off + 3] | 0) << 24)
48
+ ) >>> 0;
49
+ }
50
+
51
+ export function readU64BE(buf: Uint8Array, off: number): bigint {
52
+ let n = 0n;
53
+ for (let i = 0; i < 8; i++) {
54
+ n = (n << 8n) | BigInt(buf[off + i]);
55
+ }
56
+ return n;
57
+ }
58
+
59
+ /** Converts a number to a big-endian 4-byte array. */
60
+ export function u32BE(v: number): Uint8Array {
61
+ const b = new Uint8Array(4);
62
+ writeU32BE(b, 0, v);
63
+ return b;
64
+ }
65
+
66
+ /** Converts a number to a big-endian 8-byte array. */
67
+ export function u64BE(v: bigint): Uint8Array {
68
+ const b = new Uint8Array(8);
69
+ writeU64BE(b, 0, v);
70
+ return b;
71
+ }
72
+
73
+ /** Converts a number to a big-endian 2-byte array. */
74
+ export function u16BE(v: number): Uint8Array {
75
+ const b = new Uint8Array(2);
76
+ writeU16BE(b, 0, v);
77
+ return b;
78
+ }
79
+
80
+ /** Throws unless exactly `n` bytes are available to read. */
81
+ export function assertFull(buf: Uint8Array, off: number, n: number): void {
82
+ if (off + n > buf.length) {
83
+ throw new RangeError(
84
+ `short buffer: need ${n} bytes at offset ${off}, have ${buf.length - off}`,
85
+ );
86
+ }
87
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * LTX checksum helpers: per-page checksums and the rolling DB checksum.
3
+ */
4
+
5
+ import { ChecksumFlag, lockPgno } from './constants.ts';
6
+ import { Crc64 } from './crc64.ts';
7
+ import { u32BE } from './bytes.ts';
8
+
9
+ /**
10
+ * ChecksumPage returns a CRC-64 checksum that combines the page number with
11
+ * the page data: `ChecksumFlag | CRC64-ISO(pgno_be4 ++ data)`.
12
+ */
13
+ export function checksumPage(pgno: number, data: Uint8Array): bigint {
14
+ const h = new Crc64();
15
+ h.update(u32BE(pgno));
16
+ h.update(data);
17
+ return ChecksumFlag | h.sum64();
18
+ }
19
+
20
+ export function checksumPageWithHasher(h: Crc64, pgno: number, data: Uint8Array): bigint {
21
+ h.reset();
22
+ h.update(u32BE(pgno));
23
+ h.update(data);
24
+ return ChecksumFlag | h.sum64();
25
+ }
26
+
27
+ /**
28
+ * ChecksumReader computes the rolling DB checksum over a byte buffer split
29
+ * into pages of `pageSize`. The "lock page" is skipped entirely (its checksum
30
+ * contributes nothing), matching Go's `ChecksumReader`.
31
+ *
32
+ * For large databases this should later read page-by-page from a file rather
33
+ * than buffering the whole DB; provided as a buffer for Milestone 1.
34
+ */
35
+ export function checksumReader(data: Uint8Array, pageSize: number): bigint {
36
+ const lockPg = lockPgno(pageSize);
37
+ let chksum = 0n;
38
+ let offset = 0;
39
+ for (let pgno = 1; offset < data.length; pgno++, offset += pageSize) {
40
+ const page = data.subarray(offset, offset + pageSize);
41
+ if (pgno !== lockPg) {
42
+ chksum = ChecksumFlag | (chksum ^ checksumPage(pgno, page));
43
+ }
44
+ }
45
+ return chksum;
46
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Multi-level compaction configuration.
3
+ *
4
+ * Mirrors litestream's `compaction_level.go`. Litestream stores raw LTX files
5
+ * at level 0 and progressively merges them into higher levels at increasing
6
+ * time intervals. Each level is compacted on its own wall-clock bucket; a level
7
+ * is compacted at most once per bucket so repeated writes are coalesced.
8
+ *
9
+ * Defaults (matching litestream):
10
+ * - L0: raw LTX (no interval)
11
+ * - L1: every 30s
12
+ * - L2: every 5m
13
+ * - L3: every 1h
14
+ */
15
+
16
+ import { SnapshotLevel } from './constants.ts';
17
+ import { InvalidArgumentError } from '../errors.ts';
18
+
19
+ /** CompactionLevel describes a single compaction tier. */
20
+ export interface CompactionLevel {
21
+ /** The numeric level. Level 0 is raw, never compacted. */
22
+ level: number;
23
+ /** Frequency (ms) that files are compacted from the previous level. 0 for level 0. */
24
+ intervalMs: number;
25
+ }
26
+
27
+ /** CompactionLevels is an ordered set of non-snapshot compaction levels. */
28
+ export class CompactionLevels {
29
+ private levels: CompactionLevel[];
30
+
31
+ constructor(levels: CompactionLevel[]) {
32
+ this.levels = [...levels].sort((a, b) => a.level - b.level);
33
+ this.validate();
34
+ }
35
+
36
+ /** Returns the level configuration at `level`. */
37
+ level(level: number): CompactionLevel {
38
+ if (level === SnapshotLevel) {
39
+ throw new InvalidArgumentError('snapshot level has no compaction interval');
40
+ }
41
+ if (level < 0 || level > this.maxLevel()) {
42
+ throw new InvalidArgumentError(`level out of bounds: ${level}`);
43
+ }
44
+ return this.levels[level];
45
+ }
46
+
47
+ /** MaxLevel returns the highest non-snapshot compaction level. */
48
+ maxLevel(): number {
49
+ return this.levels.length - 1;
50
+ }
51
+
52
+ /** SnapshotLevel returns a pseudo level for full snapshots. */
53
+ snapshotLevel(): CompactionLevel {
54
+ return { level: SnapshotLevel, intervalMs: 0 };
55
+ }
56
+
57
+ /** PrevLevel returns the level that compacts into `level` (-1 if none). */
58
+ prevLevel(level: number): number {
59
+ if (level === SnapshotLevel) return this.maxLevel();
60
+ return level - 1;
61
+ }
62
+
63
+ /** NextLevel returns the level that `level` compacts into (-1 at the top). */
64
+ nextLevel(level: number): number {
65
+ if (level === SnapshotLevel) return -1;
66
+ if (level === this.maxLevel()) return SnapshotLevel;
67
+ return level + 1;
68
+ }
69
+
70
+ /** Validate checks the levels are ordered, bounded, and have sane intervals. */
71
+ validate(): void {
72
+ if (this.levels.length === 0) {
73
+ throw new InvalidArgumentError('at least one compaction level is required');
74
+ }
75
+ for (let i = 0; i < this.levels.length; i++) {
76
+ const lvl = this.levels[i];
77
+ if (lvl.level !== i) {
78
+ throw new InvalidArgumentError(
79
+ `compaction level number out of order: ${lvl.level}, expected ${i}`,
80
+ );
81
+ }
82
+ if (lvl.level > SnapshotLevel - 1) {
83
+ throw new InvalidArgumentError(`compaction level cannot exceed ${SnapshotLevel - 1}`);
84
+ }
85
+ if (lvl.level === 0 && lvl.intervalMs !== 0) {
86
+ throw new InvalidArgumentError('cannot set interval on compaction level zero');
87
+ }
88
+ if (lvl.level !== 0 && lvl.intervalMs <= 0) {
89
+ throw new InvalidArgumentError(`interval required for level ${lvl.level}`);
90
+ }
91
+ }
92
+ }
93
+
94
+ /** IsValidLevel returns true if `level` is a valid compaction level. */
95
+ isValidLevel(level: number): boolean {
96
+ if (level === SnapshotLevel) return true;
97
+ return level >= 0 && level < this.levels.length;
98
+ }
99
+
100
+ /** Returns a copy of the underlying levels. */
101
+ toArray(): CompactionLevel[] {
102
+ return this.levels.map((l) => ({ ...l }));
103
+ }
104
+ }
105
+
106
+ /** Canonical default compaction configuration (matches litestream). */
107
+ export function defaultCompactionLevels(): CompactionLevels {
108
+ return new CompactionLevels([
109
+ { level: 0, intervalMs: 0 },
110
+ { level: 1, intervalMs: 30_000 },
111
+ { level: 2, intervalMs: 300_000 },
112
+ { level: 3, intervalMs: 3_600_000 },
113
+ ]);
114
+ }
115
+
116
+ /**
117
+ * PrevCompactionAt returns the start of the current compaction bucket for
118
+ * `intervalMs`. Compaction for a level only proceeds once per bucket, so
119
+ * repeated syncs across a window collapse into one compacted file.
120
+ */
121
+ export function prevCompactionAt(now: number, intervalMs: number): number {
122
+ if (intervalMs <= 0) return now;
123
+ return Math.floor(now / intervalMs) * intervalMs;
124
+ }
125
+
126
+ /** NextCompactionAt returns the start of the next compaction bucket. */
127
+ export function nextCompactionAt(now: number, intervalMs: number): number {
128
+ return prevCompactionAt(now, intervalMs) + intervalMs;
129
+ }
130
+
131
+ /** Interval (ms) constants matching litestream's defaults. */
132
+ export const DefaultSnapshotIntervalMs = 24 * 60 * 60 * 1000; // 24h
133
+ export const DefaultSnapshotRetentionMs = 24 * 60 * 60 * 1000; // 24h
134
+ export const DefaultL0RetentionMs = 5 * 60 * 1000; // 5m
135
+ export const DefaultL0RetentionCheckIntervalMs = 15 * 1000; // 15s
@@ -0,0 +1,167 @@
1
+ /**
2
+ * LTX Compactor merges multiple LTX files (readers) into a single stream,
3
+ * where later files override earlier ones for the same page number.
4
+ *
5
+ * Mirrors Go's `ltx.Compactor`. Used by restore (snapshot + incremental L0
6
+ * files) and by multi-level compaction.
7
+ */
8
+
9
+ import type { Header, PageHeader, Trailer } from './types.ts';
10
+ import { isZeroPageHeader, isContiguous } from './types.ts';
11
+ import type { ByteSink } from './sink.ts';
12
+ import { Encoder } from './encoder.ts';
13
+ import { Decoder } from './decoder.ts';
14
+ import { InvalidArgumentError, LTXFormatError, PageSizeMismatchError } from '../errors.ts';
15
+
16
+ interface CompactorInput {
17
+ dec: Decoder;
18
+ hdr: PageHeader;
19
+ data: Uint8Array;
20
+ }
21
+
22
+ /** Compactor merges LTX readers into a single writer. */
23
+ export class Compactor {
24
+ private enc: Encoder;
25
+ private inputs: CompactorInput[] = [];
26
+ private _headerFlags = 0;
27
+ private allowNonContiguousTXIDs = false;
28
+
29
+ constructor(sink: ByteSink, readers: Uint8Array[]) {
30
+ this.enc = new Encoder(sink);
31
+ for (const r of readers) {
32
+ const dec = new Decoder(r);
33
+ dec.setRetainPageIndex(false);
34
+ this.inputs.push({ dec, hdr: { pgno: 0, flags: 0 }, data: new Uint8Array(0) });
35
+ }
36
+ }
37
+
38
+ get headerFlags(): number {
39
+ return this._headerFlags;
40
+ }
41
+
42
+ set headerFlags(flags: number) {
43
+ this._headerFlags = flags;
44
+ }
45
+
46
+ /** AllowNonContiguousTXIDs disables contiguous-TXID validation between inputs. */
47
+ setAllowNonContiguousTXIDs(v: boolean): void {
48
+ this.allowNonContiguousTXIDs = v;
49
+ }
50
+
51
+ /** Header returns the output header (after Compact). */
52
+ Header(): Header {
53
+ return this.enc.Header();
54
+ }
55
+
56
+ /** Trailer returns the output trailer (after Compact). */
57
+ Trailer(): Trailer {
58
+ return this.enc.Trailer();
59
+ }
60
+
61
+ /** Compact merges all input readers into a single LTX stream. */
62
+ compact(): void {
63
+ if (this.inputs.length === 0) {
64
+ throw new InvalidArgumentError('at least one input reader required');
65
+ }
66
+
67
+ // Read headers from all inputs.
68
+ for (let i = 0; i < this.inputs.length; i++) {
69
+ this.inputs[i].dec.decodeHeader();
70
+ }
71
+
72
+ // Validate page sizes & contiguous TXIDs.
73
+ for (let i = 1; i < this.inputs.length; i++) {
74
+ const prevHdr = this.inputs[i - 1].dec.Header();
75
+ const hdr = this.inputs[i].dec.Header();
76
+ if (prevHdr.pageSize !== hdr.pageSize) {
77
+ throw new PageSizeMismatchError(
78
+ `input files have mismatched page sizes: ${prevHdr.pageSize} != ${hdr.pageSize}`,
79
+ );
80
+ }
81
+ if (
82
+ !this.allowNonContiguousTXIDs &&
83
+ !isContiguous(prevHdr.maxTXID, hdr.minTXID, hdr.maxTXID)
84
+ ) {
85
+ throw new LTXFormatError(
86
+ `non-contiguous transaction ids in input files: (${prevHdr.minTXID},${prevHdr.maxTXID}) -> (${hdr.minTXID},${hdr.maxTXID})`,
87
+ );
88
+ }
89
+ }
90
+
91
+ const minHdr = this.inputs[0].dec.Header();
92
+ const maxHdr = this.inputs[this.inputs.length - 1].dec.Header();
93
+
94
+ this.enc.encodeHeader({
95
+ version: 3,
96
+ flags: this.headerFlags,
97
+ pageSize: minHdr.pageSize,
98
+ commit: maxHdr.commit,
99
+ minTXID: minHdr.minTXID,
100
+ maxTXID: maxHdr.maxTXID,
101
+ timestamp: maxHdr.timestamp,
102
+ preApplyChecksum: minHdr.preApplyChecksum,
103
+ walOffset: 0n,
104
+ walSize: 0n,
105
+ walSalt1: 0,
106
+ walSalt2: 0,
107
+ nodeID: 0n,
108
+ });
109
+
110
+ this.writePageBlock();
111
+
112
+ for (const input of this.inputs) {
113
+ input.dec.close();
114
+ }
115
+
116
+ const last = this.inputs[this.inputs.length - 1];
117
+ this.enc.setPostApplyChecksum(last.dec.Trailer().postApplyChecksum);
118
+ this.enc.close();
119
+ }
120
+
121
+ private writePageBlock(): void {
122
+ const pageSize = this.enc.Header().pageSize;
123
+ for (const input of this.inputs) {
124
+ input.data = new Uint8Array(pageSize);
125
+ }
126
+ for (;;) {
127
+ const pgno = this.fillPageBuffers();
128
+ if (pgno === 0) break;
129
+ this.writePageBuffer(pgno);
130
+ }
131
+ }
132
+
133
+ /** Fills input buffers and returns the smallest available page number. */
134
+ private fillPageBuffers(): number {
135
+ let pgno = 0;
136
+ for (const input of this.inputs) {
137
+ if (isZeroPageHeader(input.hdr)) {
138
+ const hdr = input.dec.decodePage(input.data);
139
+ if (hdr === null) continue; // end of page block for this input
140
+ input.hdr = hdr;
141
+ }
142
+ if (pgno === 0 || input.hdr.pgno < pgno) {
143
+ pgno = input.hdr.pgno;
144
+ }
145
+ }
146
+ return pgno;
147
+ }
148
+
149
+ /** Writes the buffer with a matching pgno from the latest input. */
150
+ private writePageBuffer(pgno: number): void {
151
+ const commit = this.enc.Header().commit;
152
+ let pageWritten = false;
153
+ for (let i = this.inputs.length - 1; i >= 0; i--) {
154
+ const input = this.inputs[i];
155
+ if (input.hdr.pgno !== pgno) continue;
156
+
157
+ const hdr = input.hdr;
158
+ const data = input.data;
159
+ input.hdr = { pgno: 0, flags: 0 };
160
+
161
+ if (pageWritten) continue;
162
+ if (pgno > commit) continue;
163
+ pageWritten = true;
164
+ this.enc.encodePage(hdr, data);
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * LTX format constants.
3
+ *
4
+ * Ground truth: superfly/ltx v0.5.2 (vendored by litestream). The byte layout
5
+ * here must match Go's `ltx` package exactly so files interoperate.
6
+ */
7
+
8
+ /** Magic is the first 4 bytes of every LTX file. */
9
+ export const Magic = 'LTX1';
10
+
11
+ /** Version is the current version of the LTX file format. */
12
+ export const Version = 3;
13
+
14
+ /** Size constants. */
15
+ export const HeaderSize = 100;
16
+ export const PageHeaderSize = 6;
17
+ export const TrailerSize = 16;
18
+
19
+ /** Checksum size & positions. */
20
+ export const ChecksumSize = 8;
21
+ export const TrailerChecksumOffset = TrailerSize - ChecksumSize;
22
+
23
+ /** ChecksumFlag ensures a checksum is non-zero. */
24
+ export const ChecksumFlag = 1n << 63n;
25
+
26
+ /** Entry byte offset of the PENDING_BYTE within a database. */
27
+ export const PENDING_BYTE = 0x40000000;
28
+
29
+ /** Header flags. */
30
+ export const HeaderFlagMask = 0x00000002;
31
+ export const HeaderFlagNoChecksum = 0x00000002;
32
+
33
+ /** Page header flags. */
34
+ /** PageHeaderFlagSize indicates a 4-byte size field follows the page header. */
35
+ export const PageHeaderFlagSize = 0x0001;
36
+
37
+ /** MaxPageSize is the maximum allowed SQLite page size. */
38
+ export const MaxPageSize = 65536;
39
+
40
+ /** SnapshotLevel is the LTX level reserved for full-database snapshots. */
41
+ export const SnapshotLevel = 9;
42
+
43
+ /** Error messages / codes. */
44
+ export const ErrInvalidFile = 'invalid LTX file';
45
+ export const ErrDecoderClosed = 'ltx decoder closed';
46
+ export const ErrEncoderClosed = 'ltx encoder closed';
47
+ export const ErrEncoderAborted = 'ltx encoder aborted';
48
+ export const ErrNoChecksum = 'no file checksum';
49
+ export const ErrInvalidChecksumFormat = 'invalid file checksum format';
50
+ export const ErrChecksumMismatch = 'file checksum mismatch';
51
+
52
+ /**
53
+ * LockPgno returns the page number at which the PENDING_BYTE exists.
54
+ * This page is never part of the LTX payload.
55
+ */
56
+ export function lockPgno(pageSize: number): number {
57
+ return Math.floor(PENDING_BYTE / pageSize) + 1;
58
+ }
59
+
60
+ /** IsValidPageSize returns true if sz is between 512 and 64K and a power of two. */
61
+ export function isValidPageSize(sz: number): boolean {
62
+ for (let i = 512; i <= MaxPageSize; i *= 2) {
63
+ if (sz === i) return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /** IsValidHeaderFlags returns true unless flags outside the valid mask are set. */
69
+ export function isValidHeaderFlags(flags: number): boolean {
70
+ return flags === (flags & HeaderFlagMask);
71
+ }