sliftutils 1.2.39 → 1.3.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.
@@ -0,0 +1,32 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ export declare const KEY_COLUMN = "key";
4
+ export declare const EMPTY_BUFFER: Buffer;
5
+ export declare const ABSENT: unique symbol;
6
+ export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[]): Buffer[];
7
+ export type BaseBulkDatabaseReader = {
8
+ rowCount: number;
9
+ totalBytes: number;
10
+ minTime: number;
11
+ maxTime: number;
12
+ keys: string[];
13
+ columns: {
14
+ column: string;
15
+ byteSize: number;
16
+ }[];
17
+ keyTimes: Map<string, number>;
18
+ deleteTimes?: Map<string, number>;
19
+ getColumn: (column: string) => Promise<{
20
+ key: string;
21
+ value: unknown;
22
+ time: number;
23
+ }[]>;
24
+ getSingleField: (key: string, column: string) => Promise<{
25
+ value: unknown;
26
+ time: number;
27
+ } | typeof ABSENT>;
28
+ };
29
+ export declare function loadBulkDatabase(config: {
30
+ totalBytes: number;
31
+ getRange: (start: number, end: number) => Promise<Buffer>;
32
+ }): Promise<BaseBulkDatabaseReader>;
@@ -0,0 +1,331 @@
1
+ // File format (all integers little-endian):
2
+ // u32 headerLength, then headerLength bytes of JSON: { rowCount, columns: [{ name, offset, length }] }, where offset is relative to the end of the header.
3
+ // Each column blob is: u32 offsets[rowCount + 1] (byte offsets into the column's data section), u8 types[rowCount], then the data section (each value's bytes, concatenated).
4
+ // Values are encoded with an explicit type tag per value (see the TYPE_ constants). Reads only fetch the byte ranges they need.
5
+
6
+ export const KEY_COLUMN = "key";
7
+
8
+ // Every tag is an explicit constant baked into files on disk. NEVER renumber an existing tag — only add new ones — or every previously written file silently decodes wrong.
9
+ const TYPE_UNDEFINED = 0;
10
+ const TYPE_STRING = 1;
11
+ const TYPE_NUMBER = 2;
12
+ const TYPE_BOOLEAN = 3;
13
+ const TYPE_OBJECT = 4;
14
+ const TYPE_INT8_ARRAY = 5;
15
+ const TYPE_UINT8_ARRAY = 6;
16
+ const TYPE_UINT8_CLAMPED_ARRAY = 7;
17
+ const TYPE_INT16_ARRAY = 8;
18
+ const TYPE_UINT16_ARRAY = 9;
19
+ const TYPE_INT32_ARRAY = 10;
20
+ const TYPE_UINT32_ARRAY = 11;
21
+ const TYPE_FLOAT32_ARRAY = 12;
22
+ const TYPE_FLOAT64_ARRAY = 13;
23
+ // A cell whose row never set this column at all — as opposed to TYPE_UNDEFINED, an explicitly stored
24
+ // undefined. On read, ABSENT falls through to older readers for that column; a stored undefined stops
25
+ // the fall-through (it's a real value that clears the column).
26
+ const TYPE_ABSENT = 14;
27
+
28
+ const TYPED_ARRAY_TYPES: { type: number; ctor: { new(buffer: ArrayBuffer): ArrayBufferView; BYTES_PER_ELEMENT: number; name: string } }[] = [
29
+ { type: TYPE_INT8_ARRAY, ctor: Int8Array },
30
+ { type: TYPE_UINT8_ARRAY, ctor: Uint8Array },
31
+ { type: TYPE_UINT8_CLAMPED_ARRAY, ctor: Uint8ClampedArray },
32
+ { type: TYPE_INT16_ARRAY, ctor: Int16Array },
33
+ { type: TYPE_UINT16_ARRAY, ctor: Uint16Array },
34
+ { type: TYPE_INT32_ARRAY, ctor: Int32Array },
35
+ { type: TYPE_UINT32_ARRAY, ctor: Uint32Array },
36
+ { type: TYPE_FLOAT32_ARRAY, ctor: Float32Array },
37
+ { type: TYPE_FLOAT64_ARRAY, ctor: Float64Array },
38
+ ];
39
+
40
+ export const EMPTY_BUFFER = Buffer.alloc(0) as Buffer;
41
+
42
+ // Sentinel a reader returns for a cell whose row never set this column, so the join can fall through
43
+ // to an older reader for that column. Distinct from a stored undefined, which is a real clearing value.
44
+ export const ABSENT = Symbol("absent");
45
+
46
+ // Hidden per-row column holding each row's write-time (so reads can resolve a key to its latest value
47
+ // by actual time). NUL-prefixed so it can't collide with a user column; excluded from `columns`.
48
+ const TIME_COLUMN = String.fromCharCode(0) + "t";
49
+
50
+ type FileHeader = {
51
+ rowCount: number;
52
+ columns: { name: string; offset: number; length: number }[];
53
+ // Oldest/newest write-time of the data in this file (from the stream entries it was folded from,
54
+ // carried through merges). Lets the reader order bulk files by actual data recency and lets merges
55
+ // assert that two files' time ranges never overlap. Absent (0) in files written before this existed.
56
+ minTime?: number;
57
+ maxTime?: number;
58
+ };
59
+
60
+ function encodeValue(value: unknown): { type: number; bytes: Buffer } {
61
+ if (value === ABSENT) {
62
+ return { type: TYPE_ABSENT, bytes: EMPTY_BUFFER };
63
+ }
64
+ if (value === undefined || value === null) {
65
+ return { type: TYPE_UNDEFINED, bytes: EMPTY_BUFFER };
66
+ }
67
+ if (typeof value === "string") {
68
+ return { type: TYPE_STRING, bytes: Buffer.from(value, "utf8") };
69
+ }
70
+ if (typeof value === "number") {
71
+ const bytes = Buffer.alloc(8);
72
+ bytes.writeDoubleLE(value, 0);
73
+ return { type: TYPE_NUMBER, bytes };
74
+ }
75
+ if (typeof value === "boolean") {
76
+ return { type: TYPE_BOOLEAN, bytes: Buffer.from([value && 1 || 0]) };
77
+ }
78
+ if (ArrayBuffer.isView(value)) {
79
+ if (value instanceof DataView) {
80
+ throw new Error(`DataView values are not supported, store a typed array instead`);
81
+ }
82
+ const entry = TYPED_ARRAY_TYPES.find(t => value instanceof t.ctor);
83
+ if (!entry) {
84
+ throw new Error(`Unsupported typed array type ${value.constructor.name}`);
85
+ }
86
+ return {
87
+ type: entry.type,
88
+ bytes: Buffer.from(value.buffer, value.byteOffset, value.byteLength),
89
+ };
90
+ }
91
+ if (typeof value !== "object") {
92
+ throw new Error(`Unsupported value type ${typeof value}`);
93
+ }
94
+ return { type: TYPE_OBJECT, bytes: Buffer.from(JSON.stringify(value), "utf8") };
95
+ }
96
+
97
+ function decodeValue(type: number, bytes: Buffer): unknown {
98
+ if (type === TYPE_UNDEFINED) return undefined;
99
+ if (type === TYPE_STRING) return bytes.toString("utf8");
100
+ if (type === TYPE_NUMBER) {
101
+ if (bytes.length !== 8) {
102
+ throw new Error(`Expected 8 bytes for a number, was ${bytes.length}`);
103
+ }
104
+ return bytes.readDoubleLE(0);
105
+ }
106
+ if (type === TYPE_BOOLEAN) {
107
+ if (bytes.length !== 1) {
108
+ throw new Error(`Expected 1 byte for a boolean, was ${bytes.length}`);
109
+ }
110
+ return bytes[0] === 1;
111
+ }
112
+ if (type === TYPE_OBJECT) return JSON.parse(bytes.toString("utf8"));
113
+ if (type === TYPE_ABSENT) return ABSENT;
114
+ const entry = TYPED_ARRAY_TYPES.find(t => t.type === type);
115
+ if (!entry) {
116
+ throw new Error(`Expected a valid type tag, was ${type}`);
117
+ }
118
+ const ctor = entry.ctor;
119
+ if (type === TYPE_UINT8_ARRAY) return Buffer.from(bytes);
120
+ if (bytes.length % ctor.BYTES_PER_ELEMENT !== 0) {
121
+ throw new Error(`Expected byte length divisible by ${ctor.BYTES_PER_ELEMENT} for ${ctor.name}, was ${bytes.length}`);
122
+ }
123
+ // Copy to a fresh ArrayBuffer so the typed array view is aligned regardless of where the bytes landed in the source buffer.
124
+ const aligned = new ArrayBuffer(bytes.length);
125
+ new Uint8Array(aligned).set(bytes);
126
+ return new ctor(aligned);
127
+ }
128
+
129
+ function encodeBulkData(data: unknown[]): Buffer {
130
+ const n = data.length;
131
+ const offsets = Buffer.alloc(4 * (n + 1));
132
+ const types = Buffer.alloc(n);
133
+ const parts: Buffer[] = [];
134
+ let pos = 0;
135
+ for (let i = 0; i < n; i++) {
136
+ const { type, bytes } = encodeValue(data[i]);
137
+ offsets.writeUInt32LE(pos, 4 * i);
138
+ types[i] = type;
139
+ parts.push(bytes);
140
+ pos += bytes.length;
141
+ }
142
+ offsets.writeUInt32LE(pos, 4 * n);
143
+ return Buffer.concat([offsets, types, ...parts]);
144
+ }
145
+
146
+ function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
147
+ const indexSize = 4 * (rowCount + 1) + rowCount;
148
+ const values: unknown[] = [];
149
+ for (let i = 0; i < rowCount; i++) {
150
+ const start = blob.readUInt32LE(4 * i);
151
+ const end = blob.readUInt32LE(4 * (i + 1));
152
+ const type = blob[4 * (rowCount + 1) + i];
153
+ values.push(decodeValue(type, blob.subarray(indexSize + start, indexSize + end)));
154
+ }
155
+ return values;
156
+ }
157
+
158
+ // Past this estimated logical size a single rows array is split across multiple files, so no one
159
+ // file (and therefore no single column blob, and no single Buffer.concat) ever approaches the
160
+ // ~2GB Buffer length limit. The cap is deliberately the same as the merge cap: 800MB is the most
161
+ // we'll ever hold for one file. The estimate is rough on purpose — it only needs to keep each
162
+ // chunk comfortably under the limit, not be exact.
163
+ const FILE_SPLIT_BYTES = 800 * 1024 * 1024;
164
+
165
+ // Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
166
+ const PER_VALUE_OVERHEAD = 5;
167
+
168
+ function estimateValueBytes(value: unknown): number {
169
+ if (value === undefined || value === null) return 0;
170
+ if (typeof value === "string") return value.length * 2;
171
+ if (typeof value === "number") return 8;
172
+ if (typeof value === "boolean") return 1;
173
+ if (ArrayBuffer.isView(value)) return value.byteLength;
174
+ if (typeof value === "object") return JSON.stringify(value).length;
175
+ return 0;
176
+ }
177
+
178
+ function estimateRowBytes(row: Record<string, unknown>): number {
179
+ let total = 0;
180
+ for (const value of Object.values(row)) {
181
+ total += estimateValueBytes(value) + PER_VALUE_OVERHEAD;
182
+ }
183
+ return total;
184
+ }
185
+
186
+ function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer {
187
+ const columnNames: string[] = [];
188
+ const columnSet = new Set<string>();
189
+ for (const row of rows) {
190
+ for (const field of Object.keys(row)) {
191
+ if (columnSet.has(field)) continue;
192
+ columnSet.add(field);
193
+ columnNames.push(field);
194
+ }
195
+ }
196
+ // A row that doesn't include a column stores ABSENT (fall-through), not undefined (a real value).
197
+ const blobs = columnNames.map(col => encodeBulkData(rows.map(row => col in row ? row[col] : ABSENT)));
198
+ // The hidden per-row time column.
199
+ columnNames.push(TIME_COLUMN);
200
+ blobs.push(encodeBulkData(times));
201
+ let offset = 0;
202
+ const columns = columnNames.map((name, i) => {
203
+ const entry = { name, offset, length: blobs[i].length };
204
+ offset += blobs[i].length;
205
+ return entry;
206
+ });
207
+ let minTime = times.length ? times[0] : 0;
208
+ let maxTime = minTime;
209
+ for (const t of times) { if (t < minTime) minTime = t; if (t > maxTime) maxTime = t; }
210
+ const header: FileHeader = { rowCount: rows.length, columns, minTime, maxTime };
211
+ const headerBuf = Buffer.from(JSON.stringify(header), "utf8");
212
+ const lengthPrefix = Buffer.alloc(4);
213
+ lengthPrefix.writeUInt32LE(headerBuf.length, 0);
214
+ return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
215
+ }
216
+
217
+ // Returns one complete, independent file buffer per chunk of rows. When the caller hands us more
218
+ // rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
219
+ // what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
220
+ // the caller just writes each as its own file. A normal-sized write returns a single buffer.
221
+ // `times[i]` is row i's write-time, stored per row so reads resolve a key to its latest value by time.
222
+ export function buildFileBuffer(rows: Record<string, unknown>[], times: number[]): Buffer[] {
223
+ if (rows.length === 0) return [buildOneFile([], [])];
224
+ const result: Buffer[] = [];
225
+ let chunkStart = 0;
226
+ let chunkBytes = 0;
227
+ for (let i = 0; i < rows.length; i++) {
228
+ const rowBytes = estimateRowBytes(rows[i]);
229
+ if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
230
+ result.push(buildOneFile(rows.slice(chunkStart, i), times.slice(chunkStart, i)));
231
+ chunkStart = i;
232
+ chunkBytes = 0;
233
+ }
234
+ chunkBytes += rowBytes;
235
+ }
236
+ result.push(buildOneFile(rows.slice(chunkStart), times.slice(chunkStart)));
237
+ return result;
238
+ }
239
+
240
+ export type BaseBulkDatabaseReader = {
241
+ rowCount: number;
242
+ totalBytes: number;
243
+ // Write-time bounds of this reader's data (0 if unknown — old bulk files). Diagnostics only now.
244
+ minTime: number;
245
+ maxTime: number;
246
+ // Keys is special, it's always automatically decoded, even though it is stored as a normal column
247
+ keys: string[];
248
+ columns: { column: string; byteSize: number }[];
249
+ // Each key's row write-time (the time of its newest write in this reader). The join compares these
250
+ // across readers to resolve a key to its latest value.
251
+ keyTimes: Map<string, number>;
252
+ // Per-key tombstone time: the key was deleted at this time. The join treats a delete like any other
253
+ // event — a delete only wins if it's newer than every set for the key. Only the stream reader sets it.
254
+ deleteTimes?: Map<string, number>;
255
+ // Each key's value for the column plus the row's write-time. value may be ABSENT (the row never set
256
+ // this column — the join then falls through to an older reader for that key/column).
257
+ getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
258
+ // The value + write-time for (key, column), or ABSENT if this reader has no such cell.
259
+ getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | typeof ABSENT>;
260
+ };
261
+
262
+ export async function loadBulkDatabase(config: {
263
+ totalBytes: number;
264
+ getRange: (start: number, end: number) => Promise<Buffer>;
265
+ }): Promise<BaseBulkDatabaseReader> {
266
+ const headerLength = (await config.getRange(0, 4)).readUInt32LE(0);
267
+ if (headerLength <= 0 || headerLength > config.totalBytes) {
268
+ throw new Error(`Expected header length in (0, ${config.totalBytes}], was ${headerLength}`);
269
+ }
270
+ const header = JSON.parse((await config.getRange(4, 4 + headerLength)).toString("utf8")) as FileHeader;
271
+ const dataBase = 4 + headerLength;
272
+ const rowCount = header.rowCount;
273
+ const colByName = new Map(header.columns.map(c => [c.name, c]));
274
+
275
+ async function readWholeColumn(column: string): Promise<unknown[]> {
276
+ const col = colByName.get(column);
277
+ if (!col) {
278
+ throw new Error(`Expected column ${column}, file only has: ${header.columns.map(c => c.name).join(", ")}`);
279
+ }
280
+ const blob = await config.getRange(dataBase + col.offset, dataBase + col.offset + col.length);
281
+ return decodeBulkData(blob, rowCount);
282
+ }
283
+
284
+ const keys = (await readWholeColumn(KEY_COLUMN)).map(v => {
285
+ if (typeof v !== "string") {
286
+ throw new Error(`Expected string key, was ${typeof v}: ${JSON.stringify(v)?.slice(0, 500)}`);
287
+ }
288
+ return v;
289
+ });
290
+ const keyIndex = new Map(keys.map((key, i) => [key, i]));
291
+
292
+ // Per-row write-times. Old files (written before this column existed) fall back to the file's header
293
+ // time (or 0) for every row — fine, since such files predate concurrent-time resolution.
294
+ const times: number[] = colByName.has(TIME_COLUMN)
295
+ ? (await readWholeColumn(TIME_COLUMN)).map(v => typeof v === "number" ? v : 0)
296
+ : keys.map(() => header.maxTime || 0);
297
+
298
+ return {
299
+ rowCount,
300
+ totalBytes: config.totalBytes,
301
+ minTime: header.minTime || 0,
302
+ maxTime: header.maxTime || 0,
303
+ keys,
304
+ keyTimes: new Map(keys.map((key, i) => [key, times[i]])),
305
+ columns: header.columns.filter(c => c.name !== TIME_COLUMN).map(c => ({ column: c.name, byteSize: c.length })),
306
+ async getColumn(column) {
307
+ const values = await readWholeColumn(column);
308
+ return keys.map((key, i) => ({ key, value: values[i], time: times[i] }));
309
+ },
310
+ async getSingleField(key, column) {
311
+ const row = keyIndex.get(key);
312
+ if (row === undefined) return ABSENT;
313
+ const col = colByName.get(column);
314
+ if (!col) return ABSENT;
315
+ const colBase = dataBase + col.offset;
316
+ const offsetsBuf = await config.getRange(colBase + 4 * row, colBase + 4 * row + 8);
317
+ const start = offsetsBuf.readUInt32LE(0);
318
+ const end = offsetsBuf.readUInt32LE(4);
319
+ const typePos = colBase + 4 * (rowCount + 1) + row;
320
+ const typeBuf = await config.getRange(typePos, typePos + 1);
321
+ const dataStart = colBase + 4 * (rowCount + 1) + rowCount;
322
+ let bytes = EMPTY_BUFFER;
323
+ if (end > start) {
324
+ bytes = await config.getRange(dataStart + start, dataStart + end);
325
+ }
326
+ const value = decodeValue(typeBuf[0], bytes);
327
+ if (value === ABSENT) return ABSENT;
328
+ return { value, time: times[row] };
329
+ },
330
+ };
331
+ }
@@ -0,0 +1,17 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ export type GetRange = (start: number, end: number) => Promise<Buffer>;
4
+ export declare function encodeCompressedBlocks(data: Buffer): Buffer;
5
+ export declare class BlockCache {
6
+ private blocks;
7
+ private indexes;
8
+ clear(): void;
9
+ private touch;
10
+ private readIndex;
11
+ open(fileId: string, fileSize: number, rawGetRange: GetRange): Promise<{
12
+ uncompressedSize: number;
13
+ getRange: GetRange;
14
+ }>;
15
+ private makeGetRange;
16
+ }
17
+ export declare const blockCache: BlockCache;
@@ -0,0 +1,173 @@
1
+ import { LZ4 } from "socket-function/src/lz4/LZ4";
2
+
3
+ // Block-aligned, promise-deduped, decompressing range cache for BulkDatabase2's on-disk files.
4
+ //
5
+ // On-disk format (produced by encodeCompressedBlocks): the logical (uncompressed) bulk buffer is
6
+ // split into fixed BLOCK_SIZE blocks; each block is LZ4-compressed unless that wouldn't shrink it by
7
+ // at least 2x, in which case it's stored raw. The file starts with a JSON index mapping each block to
8
+ // its stored length + a compressed flag, so a reader can seek straight to any block:
9
+ //
10
+ // [u32 indexLength][index JSON][block 0 stored bytes][block 1 stored bytes]...
11
+ //
12
+ // The cache presents a *logical* getRange (over uncompressed bytes) so BulkDatabase2's reader is
13
+ // oblivious to compression. It reads compressed bytes via the underlying getRange, decompresses, and
14
+ // caches the uncompressed blocks. Reads are promise-deduped and contiguous missing blocks are
15
+ // coalesced into one underlying read. Files are immutable, so cached blocks are valid forever.
16
+ //
17
+ // Per-block compression mainly helps slow storage (HDD): fewer bytes off disk per block, at the cost
18
+ // of a fast in-memory LZ4 decompress.
19
+
20
+ const BLOCK_SIZE = 256 * 1024;
21
+ const MAX_BLOCKS = Math.floor((512 * 1024 * 1024) / BLOCK_SIZE);
22
+ const MIN_COMPRESSION_RATIO = 2;
23
+
24
+ const EMPTY = Buffer.alloc(0) as Buffer;
25
+
26
+ export type GetRange = (start: number, end: number) => Promise<Buffer>;
27
+
28
+ type BlockMeta = { len: number; c: 0 | 1 };
29
+ type FileIndex = {
30
+ uncompressedSize: number;
31
+ blockSize: number;
32
+ blocks: BlockMeta[];
33
+ // Stored byte offset of each block (offsets[i]..offsets[i]+blocks[i].len), filled in on parse.
34
+ offsets: number[];
35
+ };
36
+
37
+ // Writes a logical bulk buffer in the compressed-block format described above.
38
+ export function encodeCompressedBlocks(data: Buffer): Buffer {
39
+ let blocks: BlockMeta[] = [];
40
+ let storedParts: Buffer[] = [];
41
+ let blockCount = Math.ceil(data.length / BLOCK_SIZE);
42
+ for (let i = 0; i < blockCount; i++) {
43
+ let raw = data.subarray(i * BLOCK_SIZE, Math.min((i + 1) * BLOCK_SIZE, data.length));
44
+ let compressed = LZ4.compress(raw);
45
+ // Only keep the compressed form if it shrinks the block by at least MIN_COMPRESSION_RATIO.
46
+ if (compressed.length * MIN_COMPRESSION_RATIO <= raw.length) {
47
+ storedParts.push(compressed);
48
+ blocks.push({ len: compressed.length, c: 1 });
49
+ } else {
50
+ storedParts.push(raw);
51
+ blocks.push({ len: raw.length, c: 0 });
52
+ }
53
+ }
54
+ let indexBuf = Buffer.from(JSON.stringify({ uncompressedSize: data.length, blockSize: BLOCK_SIZE, blocks }), "utf8");
55
+ let prefix = Buffer.alloc(4);
56
+ prefix.writeUInt32LE(indexBuf.length, 0);
57
+ return Buffer.concat([prefix, indexBuf, ...storedParts]);
58
+ }
59
+
60
+ export class BlockCache {
61
+ // Uncompressed blocks; insertion order is LRU order (re-inserted on access).
62
+ private blocks = new Map<string, Promise<Buffer>>();
63
+ // Parsed file index per fileId (small; not subject to the block LRU).
64
+ private indexes = new Map<string, Promise<FileIndex>>();
65
+
66
+ public clear() {
67
+ this.blocks.clear();
68
+ this.indexes.clear();
69
+ }
70
+
71
+ private touch(key: string, value: Promise<Buffer>) {
72
+ this.blocks.delete(key);
73
+ this.blocks.set(key, value);
74
+ while (this.blocks.size > MAX_BLOCKS) {
75
+ let oldest = this.blocks.keys().next().value;
76
+ if (oldest === undefined) break;
77
+ this.blocks.delete(oldest);
78
+ }
79
+ }
80
+
81
+ // Reads + validates the file index. Rejects the WHOLE file if it isn't exactly the size its index
82
+ // implies — a truncated/partial write (e.g. a crash mid-write) is detected here rather than
83
+ // silently returning corrupted values for the rows near the end.
84
+ private async readIndex(rawGetRange: GetRange, fileSize: number): Promise<FileIndex> {
85
+ let head = await rawGetRange(0, 4);
86
+ if (head.length < 4) throw new Error(`bulk file too short for an index header (${fileSize} bytes)`);
87
+ let indexLength = head.readUInt32LE(0);
88
+ if (indexLength <= 0 || 4 + indexLength > fileSize) {
89
+ throw new Error(`bulk file index length ${indexLength} is invalid for a ${fileSize}-byte file`);
90
+ }
91
+ let indexBuf = await rawGetRange(4, 4 + indexLength);
92
+ let parsed = JSON.parse(indexBuf.toString("utf8")) as { uncompressedSize: number; blockSize: number; blocks: BlockMeta[] };
93
+ let dataBase = 4 + indexLength;
94
+ let offsets: number[] = [];
95
+ let offset = dataBase;
96
+ for (let block of parsed.blocks) {
97
+ offsets.push(offset);
98
+ offset += block.len;
99
+ }
100
+ // The blocks must account for exactly the rest of the file. Any mismatch means the file is
101
+ // truncated or otherwise corrupt — reject it entirely.
102
+ if (offset !== fileSize) {
103
+ throw new Error(`bulk file is ${fileSize} bytes but its index implies ${offset} (truncated/corrupt)`);
104
+ }
105
+ return { ...parsed, offsets };
106
+ }
107
+
108
+ // Opens an immutable file: reads + validates its index (cached) and returns the logical
109
+ // (uncompressed) size plus a logical getRange that the caller can use exactly like an
110
+ // uncompressed file. `fileSize` is the actual on-disk byte length, used to detect truncation.
111
+ public async open(fileId: string, fileSize: number, rawGetRange: GetRange): Promise<{ uncompressedSize: number; getRange: GetRange }> {
112
+ let indexPromise = this.indexes.get(fileId);
113
+ if (!indexPromise) {
114
+ indexPromise = this.readIndex(rawGetRange, fileSize);
115
+ this.indexes.set(fileId, indexPromise);
116
+ }
117
+ let index = await indexPromise;
118
+ return { uncompressedSize: index.uncompressedSize, getRange: this.makeGetRange(fileId, index, rawGetRange) };
119
+ }
120
+
121
+ private makeGetRange(fileId: string, index: FileIndex, rawGetRange: GetRange): GetRange {
122
+ let blockSize = index.blockSize;
123
+ return async (start, end) => {
124
+ if (end <= start) return EMPTY;
125
+ let firstBlock = Math.floor(start / blockSize);
126
+ let lastBlock = Math.floor((end - 1) / blockSize);
127
+
128
+ let parts: Buffer[] = [];
129
+ let block = firstBlock;
130
+ while (block <= lastBlock) {
131
+ let key = `${fileId}:${block}`;
132
+ let cached = this.blocks.get(key);
133
+ if (cached) {
134
+ this.touch(key, cached);
135
+ parts.push(await cached);
136
+ block++;
137
+ continue;
138
+ }
139
+ // Coalesce a run of consecutive missing blocks into one underlying (compressed) read.
140
+ let runStart = block;
141
+ let runEnd = block + 1;
142
+ while (runEnd <= lastBlock && !this.blocks.has(`${fileId}:${runEnd}`)) runEnd++;
143
+ let compStart = index.offsets[runStart];
144
+ let lastMeta = index.blocks[runEnd - 1];
145
+ let compEnd = index.offsets[runEnd - 1] + lastMeta.len;
146
+ let runCompressed = rawGetRange(compStart, compEnd);
147
+ // Register a per-block promise BEFORE awaiting so concurrent reads dedupe onto it.
148
+ for (let i = runStart; i < runEnd; i++) {
149
+ let meta = index.blocks[i];
150
+ let relOffset = index.offsets[i] - compStart;
151
+ let blockPromise = runCompressed.then(buf => {
152
+ let stored = buf.subarray(relOffset, relOffset + meta.len);
153
+ if (meta.c) return LZ4.decompress(stored);
154
+ return stored;
155
+ });
156
+ this.touch(`${fileId}:${i}`, blockPromise);
157
+ }
158
+ for (let i = runStart; i < runEnd; i++) {
159
+ let promise = this.blocks.get(`${fileId}:${i}`);
160
+ if (promise) parts.push(await promise);
161
+ }
162
+ block = runEnd;
163
+ }
164
+
165
+ let combined = parts.length === 1 && parts[0] || Buffer.concat(parts);
166
+ let sliceStart = start - firstBlock * blockSize;
167
+ return combined.subarray(sliceStart, sliceStart + (end - start));
168
+ };
169
+ }
170
+ }
171
+
172
+ // Shared across every BulkDatabase2 collection/file (the 512MB budget is global).
173
+ export const blockCache = new BlockCache();
@@ -0,0 +1,17 @@
1
+ export declare const MANIFEST_EXTENSION = ".manifest";
2
+ export type Manifest = {
3
+ startTime: number;
4
+ validBulkFiles: string[];
5
+ ignoredStreamFiles: string[];
6
+ readFiles: string[];
7
+ };
8
+ export declare function isManifestName(name: string): boolean;
9
+ export declare function manifestFileName(startTime: number, writerId: string, counter: number): string;
10
+ export declare function parseManifestStartTime(name: string): number | undefined;
11
+ export declare function chooseManifest(manifests: {
12
+ name: string;
13
+ manifest: Manifest;
14
+ }[]): {
15
+ name: string;
16
+ manifest: Manifest;
17
+ } | undefined;
@@ -0,0 +1,59 @@
1
+ // A manifest records which bulk files are valid, decoupling "a file exists on disk" from "a file is
2
+ // part of the database." Every operation that changes the bulk layout (rollover, merge, direct write)
3
+ // writes a brand-new manifest instead of mutating/deleting in place, so changes are atomic from a
4
+ // reader's point of view: a reader sees either the old manifest or the new one, never a half-applied
5
+ // state. Manifests are immutable once written and never clobbered.
6
+ //
7
+ // Resolution: read every manifest, pick the one with the newest startTime (the time its writer
8
+ // snapshotted the directory). The latest starter has the most up-to-date view, so its decision wins;
9
+ // an older-starting writer that finishes later is ignored, and its freshly-written files are simply
10
+ // orphaned (cleaned up later) — its inputs were never marked consumed, so no data is lost.
11
+ //
12
+ // Back-compat: if there is no manifest at all, every bulk file is valid (old databases just work).
13
+ // Stream files are always valid unless a manifest lists them as already merged into a bulk file
14
+ // (ignoredStreamFiles); they carry their own per-write timestamps, so they self-resolve regardless.
15
+
16
+ export const MANIFEST_EXTENSION = ".manifest";
17
+
18
+ export type Manifest = {
19
+ // When the writer snapshotted the directory (its read time). Newest startTime wins.
20
+ startTime: number;
21
+ // The full set of bulk files that are valid as of this manifest (not a delta).
22
+ validBulkFiles: string[];
23
+ // Stream files already folded into a bulk file — ignore them on read; cleanup deletes them later.
24
+ ignoredStreamFiles: string[];
25
+ // The filenames the writer saw at startTime (diagnostics + lets cleanup reason about what existed).
26
+ readFiles: string[];
27
+ };
28
+
29
+ export function isManifestName(name: string): boolean {
30
+ return name.endsWith(MANIFEST_EXTENSION);
31
+ }
32
+
33
+ // manifest_<startTime>_<writerId>_<counter>.manifest — writerId keeps two processes from colliding on
34
+ // a name when they start in the same millisecond.
35
+ export function manifestFileName(startTime: number, writerId: string, counter: number): string {
36
+ return `manifest_${startTime}_${writerId}_${counter}${MANIFEST_EXTENSION}`;
37
+ }
38
+
39
+ export function parseManifestStartTime(name: string): number | undefined {
40
+ if (!name.endsWith(MANIFEST_EXTENSION)) return undefined;
41
+ const parts = name.slice(0, -MANIFEST_EXTENSION.length).split("_");
42
+ if (parts[0] !== "manifest") return undefined;
43
+ const startTime = parseInt(parts[1], 10);
44
+ return Number.isFinite(startTime) ? startTime : undefined;
45
+ }
46
+
47
+ // Picks the authoritative manifest: newest startTime, ties broken by name for determinism. Returns
48
+ // undefined if there are none (callers then treat every bulk file as valid).
49
+ export function chooseManifest(manifests: { name: string; manifest: Manifest }[]): { name: string; manifest: Manifest } | undefined {
50
+ let chosen: { name: string; manifest: Manifest } | undefined;
51
+ for (const entry of manifests) {
52
+ if (!chosen
53
+ || entry.manifest.startTime > chosen.manifest.startTime
54
+ || (entry.manifest.startTime === chosen.manifest.startTime && entry.name > chosen.name)) {
55
+ chosen = entry;
56
+ }
57
+ }
58
+ return chosen;
59
+ }
@@ -0,0 +1,2 @@
1
+ export declare function tryAcquireMergeLock(collection: string, holderId: string): boolean;
2
+ export declare function releaseMergeLock(collection: string, holderId: string): void;