@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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +202 -0
- package/NOTICE +22 -0
- package/README.md +482 -2
- package/dist/backends/file/index.d.ts +4 -0
- package/dist/backends/file/index.d.ts.map +1 -0
- package/dist/backends/file/index.js +3 -0
- package/dist/backends/file/leaser.d.ts +20 -0
- package/dist/backends/file/leaser.d.ts.map +1 -0
- package/dist/backends/file/leaser.js +123 -0
- package/dist/backends/file/replica-client.d.ts +38 -0
- package/dist/backends/file/replica-client.d.ts.map +1 -0
- package/dist/backends/file/replica-client.js +209 -0
- package/dist/db.d.ts +80 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +484 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +66 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/leaser.d.ts +55 -0
- package/dist/leaser.d.ts.map +1 -0
- package/dist/leaser.js +60 -0
- package/dist/ltx/byte-reader.d.ts +18 -0
- package/dist/ltx/byte-reader.d.ts.map +1 -0
- package/dist/ltx/byte-reader.js +38 -0
- package/dist/ltx/bytes.d.ts +20 -0
- package/dist/ltx/bytes.d.ts.map +1 -0
- package/dist/ltx/bytes.js +69 -0
- package/dist/ltx/checksum.d.ts +20 -0
- package/dist/ltx/checksum.d.ts.map +1 -0
- package/dist/ltx/checksum.js +42 -0
- package/dist/ltx/compaction-level.d.ts +58 -0
- package/dist/ltx/compaction-level.d.ts.map +1 -0
- package/dist/ltx/compaction-level.js +115 -0
- package/dist/ltx/compactor.d.ts +33 -0
- package/dist/ltx/compactor.d.ts.map +1 -0
- package/dist/ltx/compactor.js +137 -0
- package/dist/ltx/constants.d.ts +49 -0
- package/dist/ltx/constants.d.ts.map +1 -0
- package/dist/ltx/constants.js +58 -0
- package/dist/ltx/crc64.d.ts +29 -0
- package/dist/ltx/crc64.d.ts.map +1 -0
- package/dist/ltx/crc64.js +57 -0
- package/dist/ltx/decoder.d.ts +55 -0
- package/dist/ltx/decoder.d.ts.map +1 -0
- package/dist/ltx/decoder.js +236 -0
- package/dist/ltx/encoder.d.ts +49 -0
- package/dist/ltx/encoder.d.ts.map +1 -0
- package/dist/ltx/encoder.js +159 -0
- package/dist/ltx/file-info.d.ts +24 -0
- package/dist/ltx/file-info.d.ts.map +1 -0
- package/dist/ltx/file-info.js +74 -0
- package/dist/ltx/filename.d.ts +13 -0
- package/dist/ltx/filename.d.ts.map +1 -0
- package/dist/ltx/filename.js +23 -0
- package/dist/ltx/header.d.ts +17 -0
- package/dist/ltx/header.d.ts.map +1 -0
- package/dist/ltx/header.js +115 -0
- package/dist/ltx/index.d.ts +24 -0
- package/dist/ltx/index.d.ts.map +1 -0
- package/dist/ltx/index.js +25 -0
- package/dist/ltx/lz4.d.ts +41 -0
- package/dist/ltx/lz4.d.ts.map +1 -0
- package/dist/ltx/lz4.js +264 -0
- package/dist/ltx/page-index.d.ts +26 -0
- package/dist/ltx/page-index.d.ts.map +1 -0
- package/dist/ltx/page-index.js +118 -0
- package/dist/ltx/page.d.ts +27 -0
- package/dist/ltx/page.d.ts.map +1 -0
- package/dist/ltx/page.js +66 -0
- package/dist/ltx/sink.d.ts +17 -0
- package/dist/ltx/sink.d.ts.map +1 -0
- package/dist/ltx/sink.js +21 -0
- package/dist/ltx/stream.d.ts +64 -0
- package/dist/ltx/stream.d.ts.map +1 -0
- package/dist/ltx/stream.js +248 -0
- package/dist/ltx/streaming-compactor.d.ts +34 -0
- package/dist/ltx/streaming-compactor.d.ts.map +1 -0
- package/dist/ltx/streaming-compactor.js +140 -0
- package/dist/ltx/trailer.d.ts +12 -0
- package/dist/ltx/trailer.d.ts.map +1 -0
- package/dist/ltx/trailer.js +47 -0
- package/dist/ltx/types.d.ts +79 -0
- package/dist/ltx/types.d.ts.map +1 -0
- package/dist/ltx/types.js +70 -0
- package/dist/replica-client.d.ts +92 -0
- package/dist/replica-client.d.ts.map +1 -0
- package/dist/replica-client.js +122 -0
- package/dist/replica-url.d.ts +20 -0
- package/dist/replica-url.d.ts.map +1 -0
- package/dist/replica-url.js +49 -0
- package/dist/replica.d.ts +379 -0
- package/dist/replica.d.ts.map +1 -0
- package/dist/replica.js +1253 -0
- package/dist/store.d.ts +34 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +60 -0
- package/dist/util/fs.d.ts +28 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +73 -0
- package/dist/util/logger.d.ts +10 -0
- package/dist/util/logger.d.ts.map +1 -0
- package/dist/util/logger.js +7 -0
- package/dist/util/stream.d.ts +11 -0
- package/dist/util/stream.d.ts.map +1 -0
- package/dist/util/stream.js +25 -0
- package/dist/wal/checksum.d.ts +17 -0
- package/dist/wal/checksum.d.ts.map +1 -0
- package/dist/wal/checksum.js +30 -0
- package/dist/wal/constants.d.ts +8 -0
- package/dist/wal/constants.d.ts.map +1 -0
- package/dist/wal/constants.js +7 -0
- package/dist/wal/index.d.ts +5 -0
- package/dist/wal/index.d.ts.map +1 -0
- package/dist/wal/index.js +4 -0
- package/dist/wal/reader.d.ts +61 -0
- package/dist/wal/reader.d.ts.map +1 -0
- package/dist/wal/reader.js +207 -0
- package/package.json +68 -3
- package/src/backends/file/index.ts +3 -0
- package/src/backends/file/leaser.ts +145 -0
- package/src/backends/file/replica-client.ts +246 -0
- package/src/db.ts +553 -0
- package/src/errors.ts +74 -0
- package/src/index.ts +26 -0
- package/src/leaser.ts +86 -0
- package/src/ltx/byte-reader.ts +46 -0
- package/src/ltx/bytes.ts +87 -0
- package/src/ltx/checksum.ts +46 -0
- package/src/ltx/compaction-level.ts +135 -0
- package/src/ltx/compactor.ts +167 -0
- package/src/ltx/constants.ts +71 -0
- package/src/ltx/crc64.ts +65 -0
- package/src/ltx/decoder.ts +282 -0
- package/src/ltx/encoder.ts +200 -0
- package/src/ltx/file-info.ts +81 -0
- package/src/ltx/filename.ts +27 -0
- package/src/ltx/header.ts +126 -0
- package/src/ltx/index.ts +27 -0
- package/src/ltx/lz4.ts +279 -0
- package/src/ltx/page-index.ts +140 -0
- package/src/ltx/page.ts +80 -0
- package/src/ltx/sink.ts +33 -0
- package/src/ltx/stream.ts +286 -0
- package/src/ltx/streaming-compactor.ts +169 -0
- package/src/ltx/trailer.ts +58 -0
- package/src/ltx/types.ts +152 -0
- package/src/lz4js.d.ts +27 -0
- package/src/replica-client.ts +222 -0
- package/src/replica-url.ts +72 -0
- package/src/replica.ts +1480 -0
- package/src/store.ts +71 -0
- package/src/util/fs.ts +82 -0
- package/src/util/logger.ts +15 -0
- package/src/util/stream.ts +28 -0
- package/src/wal/checksum.ts +39 -0
- package/src/wal/constants.ts +10 -0
- package/src/wal/index.ts +5 -0
- package/src/wal/reader.ts +264 -0
package/src/ltx/crc64.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRC-64 ISO implementation, byte-compatible with Go's `hash/crc64` using
|
|
3
|
+
* `crc64.MakeTable(crc64.ISO)`. This is what superfly/ltx uses for all LTX
|
|
4
|
+
* checksums (page checksums, rolling DB checksum, file checksum).
|
|
5
|
+
*
|
|
6
|
+
* NOTE: LTX uses CRC-64 ISO, NOT ECMA. The litestream docs are misleading;
|
|
7
|
+
* the actual code (`crc64.New(crc64.MakeTable(crc64.ISO))`) uses ISO.
|
|
8
|
+
*
|
|
9
|
+
* Algorithm matches Go's reflected `crc64.Update`:
|
|
10
|
+
* - init / xorout = all ones (0xFFFFFFFFFFFFFFFF)
|
|
11
|
+
* - polynomial register value = 0xD800000000000000
|
|
12
|
+
* - table built by right-shifting (reflected), update as
|
|
13
|
+
* `tab[byte(crc) ^ v] ^ (crc >> 8)`
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ISO_POLY = 0xd800000000000000n;
|
|
17
|
+
const MASK64 = 0xffffffffffffffffn;
|
|
18
|
+
|
|
19
|
+
/** Builds the 256-entry CRC table for a reflected polynomial. */
|
|
20
|
+
function makeTable(poly: bigint): bigint[] {
|
|
21
|
+
const table: bigint[] = new Array(256);
|
|
22
|
+
for (let i = 0; i < 256; i++) {
|
|
23
|
+
let crc = BigInt(i);
|
|
24
|
+
for (let j = 0; j < 8; j++) {
|
|
25
|
+
crc = (crc & 1n) !== 0n ? (crc >> 1n) ^ poly : crc >> 1n;
|
|
26
|
+
}
|
|
27
|
+
table[i] = crc;
|
|
28
|
+
}
|
|
29
|
+
return table;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ISO_TABLE = makeTable(ISO_POLY);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Crc64 is a streaming CRC-64 hasher, equivalent to Go's `hash/crc64`
|
|
36
|
+
* (ISO table). Call `update()` with successive byte chunks and `sum64()` to
|
|
37
|
+
* obtain the running checksum. `reset()` re-initializes the hasher.
|
|
38
|
+
*/
|
|
39
|
+
export class Crc64 {
|
|
40
|
+
private crc: bigint = MASK64;
|
|
41
|
+
|
|
42
|
+
update(buf: Uint8Array): void {
|
|
43
|
+
let crc = this.crc;
|
|
44
|
+
for (let i = 0; i < buf.length; i++) {
|
|
45
|
+
crc = ISO_TABLE[Number((crc ^ BigInt(buf[i])) & 0xffn)] ^ (crc >> 8n);
|
|
46
|
+
}
|
|
47
|
+
this.crc = crc;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
reset(): void {
|
|
51
|
+
this.crc = MASK64;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns the digest of all data written so far. */
|
|
55
|
+
sum64(): bigint {
|
|
56
|
+
return this.crc ^ MASK64;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Convenience: computes the CRC-64 ISO of a single buffer. */
|
|
61
|
+
export function crc64ISO(buf: Uint8Array): bigint {
|
|
62
|
+
const h = new Crc64();
|
|
63
|
+
h.update(buf);
|
|
64
|
+
return h.sum64();
|
|
65
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LTX decoder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Go's `ltx.Decoder`. Reads header, page frames, page index, and
|
|
5
|
+
* trailer from an in-memory buffer, verifying the file checksum and (for
|
|
6
|
+
* checksum-tracking snapshots) the post-apply rolling checksum.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ChecksumFlag,
|
|
11
|
+
HeaderSize,
|
|
12
|
+
PageHeaderFlagSize,
|
|
13
|
+
PageHeaderSize,
|
|
14
|
+
TrailerChecksumOffset,
|
|
15
|
+
TrailerSize,
|
|
16
|
+
lockPgno,
|
|
17
|
+
} from './constants.ts';
|
|
18
|
+
import { Crc64 } from './crc64.ts';
|
|
19
|
+
import { u32BE } from './bytes.ts';
|
|
20
|
+
import { ByteReader } from './byte-reader.ts';
|
|
21
|
+
import type { ByteSink } from './sink.ts';
|
|
22
|
+
import type { Header, PageHeader, PageIndex, Trailer } from './types.ts';
|
|
23
|
+
import { isSnapshot } from './types.ts';
|
|
24
|
+
import { unmarshalHeader, validateHeader } from './header.ts';
|
|
25
|
+
import { unmarshalPageHeader } from './page.ts';
|
|
26
|
+
import { unmarshalTrailer } from './trailer.ts';
|
|
27
|
+
import { parsePageIndex } from './page-index.ts';
|
|
28
|
+
import { decompressBlock } from './lz4.ts';
|
|
29
|
+
import { checksumPage } from './checksum.ts';
|
|
30
|
+
import { LTXFormatError } from '../errors.ts';
|
|
31
|
+
|
|
32
|
+
const stateHeader = 'header';
|
|
33
|
+
const statePage = 'page';
|
|
34
|
+
const stateClose = 'close';
|
|
35
|
+
const stateClosed = 'closed';
|
|
36
|
+
|
|
37
|
+
function noChecksum(h: Header): boolean {
|
|
38
|
+
return (h.flags & 0x00000002) !== 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Decoder reads an LTX file from an in-memory buffer. */
|
|
42
|
+
export class Decoder {
|
|
43
|
+
private r: ByteReader;
|
|
44
|
+
private header!: Header;
|
|
45
|
+
private trailer: Trailer = { postApplyChecksum: 0n, fileChecksum: 0n };
|
|
46
|
+
private pageIndex: PageIndex | null = null;
|
|
47
|
+
private state = stateHeader;
|
|
48
|
+
private hash = new Crc64();
|
|
49
|
+
private pageSeq = new Crc64();
|
|
50
|
+
private chksum = 0n;
|
|
51
|
+
private pageN = 0;
|
|
52
|
+
private retainPageIndex: boolean;
|
|
53
|
+
|
|
54
|
+
constructor(buf: Uint8Array) {
|
|
55
|
+
this.r = new ByteReader(buf);
|
|
56
|
+
this.retainPageIndex = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** SetRetainPageIndex controls whether Close materializes the page-index map. */
|
|
60
|
+
setRetainPageIndex(retain: boolean): void {
|
|
61
|
+
this.retainPageIndex = retain;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** N returns the number of source bytes read. */
|
|
65
|
+
N(): number {
|
|
66
|
+
return this.r.pos;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** PageN returns the number of pages decoded. */
|
|
70
|
+
PageN(): number {
|
|
71
|
+
return this.pageN;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Header returns a copy of the header. */
|
|
75
|
+
Header(): Header {
|
|
76
|
+
return this.header;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Trailer returns the trailer. File checksum valid after Close(). */
|
|
80
|
+
Trailer(): Trailer {
|
|
81
|
+
return this.trailer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** PageIndex returns the page index (after Close), or null if not retained. */
|
|
85
|
+
PageIndex(): PageIndex | null {
|
|
86
|
+
return this.pageIndex;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** PostApplyPos returns the replication position after applying this file. */
|
|
90
|
+
PostApplyPos() {
|
|
91
|
+
return {
|
|
92
|
+
txID: this.header.maxTXID,
|
|
93
|
+
postApplyChecksum: this.trailer.postApplyChecksum,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** DecodeHeader reads and validates the header frame. */
|
|
98
|
+
decodeHeader(): void {
|
|
99
|
+
const b = this.r.readFull(HeaderSize);
|
|
100
|
+
this.header = unmarshalHeader(b);
|
|
101
|
+
this.hash.update(b);
|
|
102
|
+
this.state = statePage;
|
|
103
|
+
validateHeader(this.header);
|
|
104
|
+
if (!noChecksum(this.header)) {
|
|
105
|
+
this.chksum = ChecksumFlag;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* DecodePage reads the next page frame into `data`. Returns the page header,
|
|
111
|
+
* or null when the end-of-pages marker is reached.
|
|
112
|
+
*/
|
|
113
|
+
decodePage(data: Uint8Array): PageHeader | null {
|
|
114
|
+
if (this.state === stateClosed) throw new LTXFormatError('ltx decoder closed');
|
|
115
|
+
if (this.state === stateClose) return null;
|
|
116
|
+
if (this.state !== statePage) {
|
|
117
|
+
throw new LTXFormatError(`cannot read page header, expected ${this.state}`);
|
|
118
|
+
}
|
|
119
|
+
if (data.length !== this.header.pageSize) {
|
|
120
|
+
throw new LTXFormatError(`invalid page buffer size: ${data.length}, expecting ${this.header.pageSize}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const b = this.r.readFull(PageHeaderSize);
|
|
124
|
+
const hdr = unmarshalPageHeader(b);
|
|
125
|
+
this.hash.update(b);
|
|
126
|
+
|
|
127
|
+
// An empty page header marks the end of the page block.
|
|
128
|
+
if (hdr.pgno === 0 && hdr.flags === 0) {
|
|
129
|
+
this.state = stateClose;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (hdr.pgno > this.header.commit) {
|
|
134
|
+
throw new LTXFormatError(`page number ${hdr.pgno} out-of-bounds for commit size ${this.header.commit}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (hdr.flags & PageHeaderFlagSize) {
|
|
138
|
+
// New block format: read size prefix, then LZ4 block data.
|
|
139
|
+
const sizeBuf = this.r.readFull(4);
|
|
140
|
+
this.hash.update(sizeBuf);
|
|
141
|
+
const dataSize = (sizeBuf[0] << 24 | sizeBuf[1] << 16 | sizeBuf[2] << 8 | sizeBuf[3]) >>> 0;
|
|
142
|
+
const compressed = this.r.readFull(dataSize);
|
|
143
|
+
const decompressed = decompressBlock(compressed, this.header.pageSize);
|
|
144
|
+
data.set(decompressed);
|
|
145
|
+
} else {
|
|
146
|
+
throw new LTXFormatError('legacy LZ4 frame format not supported');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.hash.update(data);
|
|
150
|
+
this.hashPgo(this.pageSeq, hdr.pgno);
|
|
151
|
+
this.pageN++;
|
|
152
|
+
|
|
153
|
+
// Track the rolling DB checksum for checksum-tracking snapshots.
|
|
154
|
+
if (isSnapshot(this.header) && !noChecksum(this.header)) {
|
|
155
|
+
if (hdr.pgno !== lockPgno(this.header.pageSize)) {
|
|
156
|
+
this.chksum = ChecksumFlag | (this.chksum ^ checksumPage(hdr.pgno, data));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return hdr;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Verify reads the entire file, discarding page data (for validation). */
|
|
164
|
+
verify(): void {
|
|
165
|
+
this.decodeHeader();
|
|
166
|
+
const data = new Uint8Array(this.header.pageSize);
|
|
167
|
+
for (;;) {
|
|
168
|
+
if (this.decodePage(data) === null) break;
|
|
169
|
+
}
|
|
170
|
+
this.close();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** DecodeDatabaseTo writes the snapshot as a SQLite database to `sink`. */
|
|
174
|
+
decodeDatabaseTo(sink: ByteSink): void {
|
|
175
|
+
this.decodeHeader();
|
|
176
|
+
const hdr = this.header;
|
|
177
|
+
if (!isSnapshot(hdr)) {
|
|
178
|
+
throw new LTXFormatError('cannot decode non-snapshot LTX file to SQLite database');
|
|
179
|
+
}
|
|
180
|
+
const lockPg = lockPgno(hdr.pageSize);
|
|
181
|
+
const data = new Uint8Array(hdr.pageSize);
|
|
182
|
+
for (let pgno = 1; pgno <= hdr.commit; pgno++) {
|
|
183
|
+
if (pgno === lockPg) {
|
|
184
|
+
sink.write(new Uint8Array(hdr.pageSize));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const ph = this.decodePage(data);
|
|
188
|
+
if (ph === null) {
|
|
189
|
+
throw new LTXFormatError(`unexpected end of pages at pgno ${pgno}`);
|
|
190
|
+
}
|
|
191
|
+
if (ph.pgno !== pgno) {
|
|
192
|
+
throw new LTXFormatError(`unexpected pgno while decoding page: read ${ph.pgno}, expected ${pgno}`);
|
|
193
|
+
}
|
|
194
|
+
sink.write(data);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Expect one final read to be the end-of-pages marker.
|
|
198
|
+
if (this.decodePage(data) !== null) {
|
|
199
|
+
throw new LTXFormatError('unexpected page after end of database');
|
|
200
|
+
}
|
|
201
|
+
this.close();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Close verifies the page index and trailer, then checks the file checksum. */
|
|
205
|
+
close(): void {
|
|
206
|
+
if (this.state === stateClosed) return;
|
|
207
|
+
if (this.state !== stateClose) {
|
|
208
|
+
throw new LTXFormatError(`cannot close, expected ${this.state}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const index: PageIndex = new Map();
|
|
212
|
+
let n = 0;
|
|
213
|
+
let nextOffset = HeaderSize;
|
|
214
|
+
const seq = new Crc64();
|
|
215
|
+
const self = this;
|
|
216
|
+
|
|
217
|
+
parsePageIndex(
|
|
218
|
+
this.r,
|
|
219
|
+
(buf) => self.hash.update(buf),
|
|
220
|
+
(pgno, offset, size) => {
|
|
221
|
+
if (pgno > self.header.commit) {
|
|
222
|
+
throw new LTXFormatError(`page index pgno ${pgno} exceeds commit ${self.header.commit}`);
|
|
223
|
+
}
|
|
224
|
+
if (offset < nextOffset) {
|
|
225
|
+
throw new LTXFormatError(
|
|
226
|
+
`page index pgno ${pgno} offset ${offset} overlaps previous frame ending at ${nextOffset}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (size <= PageHeaderSize) {
|
|
230
|
+
throw new LTXFormatError(`page index pgno ${pgno} has invalid frame size ${size}`);
|
|
231
|
+
}
|
|
232
|
+
nextOffset = offset + size;
|
|
233
|
+
n++;
|
|
234
|
+
self.hashPgo(seq, pgno);
|
|
235
|
+
if (index) {
|
|
236
|
+
index.set(pgno, {
|
|
237
|
+
level: 0,
|
|
238
|
+
minTXID: self.header.minTXID,
|
|
239
|
+
maxTXID: self.header.maxTXID,
|
|
240
|
+
offset,
|
|
241
|
+
size,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (n !== this.pageN) {
|
|
248
|
+
throw new LTXFormatError(`page index has ${n} entries but ${this.pageN} pages were decoded`);
|
|
249
|
+
}
|
|
250
|
+
if (seq.sum64() !== this.pageSeq.sum64()) {
|
|
251
|
+
throw new LTXFormatError('page index does not match the decoded page numbers');
|
|
252
|
+
}
|
|
253
|
+
this.pageIndex = this.retainPageIndex ? index : null;
|
|
254
|
+
|
|
255
|
+
// Read the trailer; everything except the file checksum field is hashed.
|
|
256
|
+
const t = this.r.readFull(TrailerSize);
|
|
257
|
+
this.hash.update(t.subarray(0, TrailerChecksumOffset));
|
|
258
|
+
this.trailer = unmarshalTrailer(t);
|
|
259
|
+
if (this.r.remaining > 0) {
|
|
260
|
+
throw new LTXFormatError('unexpected data after trailer');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const computed = ChecksumFlag | this.hash.sum64();
|
|
264
|
+
if (computed !== this.trailer.fileChecksum) {
|
|
265
|
+
throw new LTXFormatError('file checksum mismatch');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isSnapshot(this.header) && !noChecksum(this.header)) {
|
|
269
|
+
if (this.trailer.postApplyChecksum !== this.chksum) {
|
|
270
|
+
throw new LTXFormatError(
|
|
271
|
+
`post-apply checksum in trailer (${this.trailer.postApplyChecksum.toString(16)}) does not match calculated checksum (${this.chksum.toString(16)})`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
this.state = stateClosed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private hashPgo(h: Crc64, pgno: number): void {
|
|
280
|
+
h.update(u32BE(pgno));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LTX encoder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Go's `ltx.Encoder`. The encoder streams header, page frames, page
|
|
5
|
+
* index, and trailer to a `ByteSink`, maintaining a running CRC-64 ISO file
|
|
6
|
+
* checksum over all logical content (header, page headers, compressed-size
|
|
7
|
+
* prefixes, *decompressed* page data, page index bytes, and the trailer's
|
|
8
|
+
* post-apply checksum) plus a byte counter for page-index offsets.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
ChecksumFlag,
|
|
13
|
+
PageHeaderFlagSize,
|
|
14
|
+
PageHeaderSize,
|
|
15
|
+
TrailerChecksumOffset,
|
|
16
|
+
TrailerSize,
|
|
17
|
+
lockPgno,
|
|
18
|
+
} from './constants.ts';
|
|
19
|
+
import { Crc64 } from './crc64.ts';
|
|
20
|
+
import { u32BE, u64BE } from './bytes.ts';
|
|
21
|
+
import type { ByteSink } from './sink.ts';
|
|
22
|
+
import type { Header, PageHeader, Trailer } from './types.ts';
|
|
23
|
+
import { marshalHeader, validateHeader } from './header.ts';
|
|
24
|
+
import { marshalPageHeader, validatePageHeader } from './page.ts';
|
|
25
|
+
import { marshalTrailer, validateTrailer } from './trailer.ts';
|
|
26
|
+
import { encodePageIndexRecord, encodeUvarint } from './page-index.ts';
|
|
27
|
+
import { compressBlock } from './lz4.ts';
|
|
28
|
+
import { LTXFormatError } from '../errors.ts';
|
|
29
|
+
|
|
30
|
+
const stateHeader = 'header';
|
|
31
|
+
const statePage = 'page';
|
|
32
|
+
const stateClosed = 'closed';
|
|
33
|
+
|
|
34
|
+
interface IndexEntry {
|
|
35
|
+
pgno: number;
|
|
36
|
+
offset: number;
|
|
37
|
+
size: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Encoder writes an LTX file to a ByteSink. */
|
|
41
|
+
export class Encoder {
|
|
42
|
+
private sink: ByteSink;
|
|
43
|
+
private state: string = stateHeader;
|
|
44
|
+
private header!: Header;
|
|
45
|
+
private trailer: Trailer = { postApplyChecksum: 0n, fileChecksum: 0n };
|
|
46
|
+
private hash = new Crc64();
|
|
47
|
+
private index: IndexEntry[] = [];
|
|
48
|
+
private n = 0;
|
|
49
|
+
private prevPgno = 0;
|
|
50
|
+
private pagesWritten = 0;
|
|
51
|
+
|
|
52
|
+
constructor(sink: ByteSink) {
|
|
53
|
+
this.sink = sink;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Returns the number of bytes written to the sink so far. */
|
|
57
|
+
N(): number {
|
|
58
|
+
return this.n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Returns a shallow copy of the header. */
|
|
62
|
+
Header(): Header {
|
|
63
|
+
return this.header;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns the trailer (file checksum valid after Close()). */
|
|
67
|
+
Trailer(): Trailer {
|
|
68
|
+
return this.trailer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns the replication position after applying this file. */
|
|
72
|
+
PostApplyPos() {
|
|
73
|
+
return {
|
|
74
|
+
txID: this.header.maxTXID,
|
|
75
|
+
postApplyChecksum: this.trailer.postApplyChecksum,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** SetPostApplyChecksum sets the database checksum to record in the trailer. */
|
|
80
|
+
setPostApplyChecksum(chk: bigint): void {
|
|
81
|
+
this.trailer.postApplyChecksum = chk;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** EncodeHeader writes the header frame and initializes the checksum. */
|
|
85
|
+
encodeHeader(h: Header): void {
|
|
86
|
+
if (this.state === stateClosed) throw new LTXFormatError('ltx encoder closed');
|
|
87
|
+
if (this.state !== stateHeader) {
|
|
88
|
+
throw new LTXFormatError(`cannot encode header frame, expected ${this.state}`);
|
|
89
|
+
}
|
|
90
|
+
validateHeader(h);
|
|
91
|
+
this.header = h;
|
|
92
|
+
this.hash = new Crc64();
|
|
93
|
+
this.write(marshalHeader(h));
|
|
94
|
+
this.state = statePage;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* EncodePage writes a single page frame. `data` must be exactly the page
|
|
99
|
+
* size and `hdr.pgno` must be in ascending order and not the lock page.
|
|
100
|
+
*/
|
|
101
|
+
encodePage(hdr: PageHeader, data: Uint8Array): void {
|
|
102
|
+
if (this.state === stateClosed) throw new LTXFormatError('ltx encoder closed');
|
|
103
|
+
if (this.state !== statePage) {
|
|
104
|
+
throw new LTXFormatError(`cannot encode page header, expected ${this.state}`);
|
|
105
|
+
}
|
|
106
|
+
if (hdr.pgno > this.header.commit) {
|
|
107
|
+
throw new LTXFormatError(
|
|
108
|
+
`page number ${hdr.pgno} out-of-bounds for commit size ${this.header.commit}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
validatePageHeader(hdr);
|
|
112
|
+
if (data.length !== this.header.pageSize) {
|
|
113
|
+
throw new LTXFormatError(
|
|
114
|
+
`invalid page buffer size: ${data.length}, expecting ${this.header.pageSize}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lockPg = lockPgno(this.header.pageSize);
|
|
119
|
+
if (hdr.pgno === lockPg) {
|
|
120
|
+
throw new LTXFormatError(`cannot encode lock page: pgno=${hdr.pgno}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Enforce page ordering.
|
|
124
|
+
if (this.header.minTXID === 1n) {
|
|
125
|
+
// Snapshot files must include all pages up to commit, in order.
|
|
126
|
+
if (this.prevPgno === 0 && hdr.pgno !== 1) {
|
|
127
|
+
throw new LTXFormatError('snapshot transaction file must start with page number 1');
|
|
128
|
+
}
|
|
129
|
+
if (this.prevPgno === lockPg - 1) {
|
|
130
|
+
if (hdr.pgno !== this.prevPgno + 2) {
|
|
131
|
+
throw new LTXFormatError(
|
|
132
|
+
`nonsequential page numbers in snapshot transaction (skip lock page): ${this.prevPgno},${hdr.pgno}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
} else if (this.prevPgno !== 0 && hdr.pgno !== this.prevPgno + 1) {
|
|
136
|
+
throw new LTXFormatError(
|
|
137
|
+
`nonsequential page numbers in snapshot transaction: ${this.prevPgno},${hdr.pgno}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
if (this.prevPgno >= hdr.pgno) {
|
|
142
|
+
throw new LTXFormatError(`out-of-order page numbers: ${this.prevPgno},${hdr.pgno}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const offset = this.n;
|
|
147
|
+
const compressed = compressBlock(data);
|
|
148
|
+
|
|
149
|
+
const fhdr: PageHeader = { pgno: hdr.pgno, flags: hdr.flags | PageHeaderFlagSize };
|
|
150
|
+
this.write(marshalPageHeader(fhdr));
|
|
151
|
+
this.write(u32BE(compressed.length));
|
|
152
|
+
this.sink.write(compressed);
|
|
153
|
+
this.hash.update(data); // hash the raw (uncompressed) page data
|
|
154
|
+
this.n += compressed.length;
|
|
155
|
+
|
|
156
|
+
this.pagesWritten++;
|
|
157
|
+
this.prevPgno = hdr.pgno;
|
|
158
|
+
this.index.push({ pgno: hdr.pgno, offset, size: this.n - offset });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Close writes the end-of-pages marker, page index, and trailer. */
|
|
162
|
+
close(): void {
|
|
163
|
+
if (this.state === stateClosed) return;
|
|
164
|
+
if (this.state !== statePage) {
|
|
165
|
+
throw new LTXFormatError(`cannot close, expected ${this.state}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Empty page header terminates the page block.
|
|
169
|
+
const zero = new Uint8Array(PageHeaderSize);
|
|
170
|
+
this.write(zero);
|
|
171
|
+
|
|
172
|
+
// Write the page index.
|
|
173
|
+
const start = this.n;
|
|
174
|
+
for (const e of this.index) {
|
|
175
|
+
this.write(encodePageIndexRecord(e.pgno, e.offset, e.size));
|
|
176
|
+
}
|
|
177
|
+
this.write(encodeUvarint(0n)); // end marker
|
|
178
|
+
this.write(u64BE(BigInt(this.n - start))); // size of index varint bytes
|
|
179
|
+
|
|
180
|
+
// Marshal trailer (post-apply checksum set, file checksum pending).
|
|
181
|
+
const t = marshalTrailer(this.trailer);
|
|
182
|
+
this.hash.update(t.subarray(0, TrailerChecksumOffset));
|
|
183
|
+
this.n += TrailerChecksumOffset;
|
|
184
|
+
this.trailer.fileChecksum = ChecksumFlag | this.hash.sum64();
|
|
185
|
+
validateTrailer(this.trailer, this.header);
|
|
186
|
+
|
|
187
|
+
// Remarshal with the file checksum and write to the sink (not hashed).
|
|
188
|
+
this.sink.write(marshalTrailer(this.trailer));
|
|
189
|
+
this.n += TrailerSize - TrailerChecksumOffset;
|
|
190
|
+
|
|
191
|
+
this.state = stateClosed;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Writes to the sink, feeding the checksum and byte counter. */
|
|
195
|
+
private write(b: Uint8Array): void {
|
|
196
|
+
this.sink.write(b);
|
|
197
|
+
this.hash.update(b);
|
|
198
|
+
this.n += b.length;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LTX file metadata and iteration over a set of LTX files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FileInfo, TXID } from './types.ts';
|
|
6
|
+
import { isContiguous } from './types.ts';
|
|
7
|
+
|
|
8
|
+
/** Sorts FileInfo by (level, minTXID, maxTXID), in place. */
|
|
9
|
+
export function sortFileInfo(a: FileInfo[]): FileInfo[] {
|
|
10
|
+
a.sort((x, y) => {
|
|
11
|
+
if (x.level !== y.level) return x.level - y.level;
|
|
12
|
+
if (x.minTXID !== y.minTXID) return x.minTXID < y.minTXID ? -1 : 1;
|
|
13
|
+
return x.maxTXID < y.maxTXID ? -1 : x.maxTXID > y.maxTXID ? 1 : 0;
|
|
14
|
+
});
|
|
15
|
+
return a;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** MaxTXID returns the largest maxTXID in a slice, or 0n if empty. */
|
|
19
|
+
export function maxTXID(a: readonly FileInfo[]): TXID {
|
|
20
|
+
if (a.length === 0) return 0n;
|
|
21
|
+
let m = a[0].maxTXID;
|
|
22
|
+
for (const info of a) {
|
|
23
|
+
if (info.maxTXID > m) m = info.maxTXID;
|
|
24
|
+
}
|
|
25
|
+
return m;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** SliceFileIterator iterates over a sorted slice of LTX file metadata. */
|
|
29
|
+
export class FileInfoSliceIterator {
|
|
30
|
+
private a: FileInfo[];
|
|
31
|
+
private init = false;
|
|
32
|
+
private current: FileInfo | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(a: FileInfo[]) {
|
|
35
|
+
this.a = sortFileInfo([...a]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
next(): boolean {
|
|
39
|
+
if (!this.init) {
|
|
40
|
+
this.init = true;
|
|
41
|
+
if (this.a.length === 0) {
|
|
42
|
+
this.current = null;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
this.current = this.a[0];
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
this.a = this.a.slice(1);
|
|
49
|
+
if (this.a.length === 0) {
|
|
50
|
+
this.current = null;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
this.current = this.a[0];
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
err(): Error | null {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
item(): FileInfo | null {
|
|
62
|
+
return this.current;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
/* nothing to release */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Returns all LTX files from an iterator as an array. */
|
|
71
|
+
export function sliceFileIterator(itr: FileInfoSliceIterator): FileInfo[] {
|
|
72
|
+
const out: FileInfo[] = [];
|
|
73
|
+
while (itr.next()) {
|
|
74
|
+
const item = itr.item();
|
|
75
|
+
if (item) out.push(item);
|
|
76
|
+
}
|
|
77
|
+
itr.close();
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { isContiguous };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LTX filename parsing/formatting.
|
|
3
|
+
*
|
|
4
|
+
* Names are `{minTXID:016x}-{maxTXID:016x}.ltx`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatTXID } from './types.ts';
|
|
8
|
+
import { LTXFormatError } from '../errors.ts';
|
|
9
|
+
|
|
10
|
+
const FILENAME_RE = /^([0-9a-f]{16})-([0-9a-f]{16})\.ltx$/;
|
|
11
|
+
|
|
12
|
+
/** FormatFilename returns an LTX filename representing a TXID range. */
|
|
13
|
+
export function formatFilename(minTXID: bigint, maxTXID: bigint): string {
|
|
14
|
+
return `${formatTXID(minTXID)}-${formatTXID(maxTXID)}.ltx`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** ParseFilename parses a transaction range from an LTX filename. */
|
|
18
|
+
export function parseFilename(name: string): { minTXID: bigint; maxTXID: bigint } {
|
|
19
|
+
const m = FILENAME_RE.exec(name);
|
|
20
|
+
if (m === null) {
|
|
21
|
+
throw new LTXFormatError(`invalid ltx filename: ${name}`);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
minTXID: BigInt(`0x${m[1]}`),
|
|
25
|
+
maxTXID: BigInt(`0x${m[2]}`),
|
|
26
|
+
};
|
|
27
|
+
}
|