sliftutils 1.2.38 → 1.3.0
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/.claude/settings.local.json +5 -1
- package/index.d.ts +268 -0
- package/misc/getSecret.d.ts +1 -0
- package/misc/getSecret.ts +7 -0
- package/misc/https/httpsCerts.d.ts +17 -0
- package/misc/https/httpsCerts.ts +4 -4
- package/package.json +2 -2
- package/render-utils/mobxTyped.d.ts +1 -0
- package/render-utils/mobxTyped.ts +4 -0
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +8 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +41 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +84 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +797 -0
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +24 -0
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +285 -0
- package/storage/BulkDatabase2/blockCache.d.ts +17 -0
- package/storage/BulkDatabase2/blockCache.ts +173 -0
- package/storage/BulkDatabase2/streamLog.d.ts +25 -0
- package/storage/BulkDatabase2/streamLog.ts +116 -0
- package/storage/BulkDatabase2/syncClient.d.ts +9 -0
- package/storage/BulkDatabase2/syncClient.ts +81 -0
- package/storage/DiskCollection.d.ts +2 -0
- package/storage/DiskCollection.ts +17 -1
- package/storage/FileFolderAPI.d.ts +56 -0
- package/storage/FileFolderAPI.tsx +93 -19
- package/yarn.lock +4 -4
|
@@ -0,0 +1,24 @@
|
|
|
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 function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
|
|
6
|
+
export type BaseBulkDatabaseReader = {
|
|
7
|
+
rowCount: number;
|
|
8
|
+
totalBytes: number;
|
|
9
|
+
keys: string[];
|
|
10
|
+
columns: {
|
|
11
|
+
column: string;
|
|
12
|
+
byteSize: number;
|
|
13
|
+
}[];
|
|
14
|
+
deletedKeys?: Set<string>;
|
|
15
|
+
getColumn: (column: string) => Promise<{
|
|
16
|
+
key: string;
|
|
17
|
+
value: unknown;
|
|
18
|
+
}[]>;
|
|
19
|
+
getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
|
|
20
|
+
};
|
|
21
|
+
export declare function loadBulkDatabase(config: {
|
|
22
|
+
totalBytes: number;
|
|
23
|
+
getRange: (start: number, end: number) => Promise<Buffer>;
|
|
24
|
+
}): Promise<BaseBulkDatabaseReader>;
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
|
|
24
|
+
const TYPED_ARRAY_TYPES: { type: number; ctor: { new(buffer: ArrayBuffer): ArrayBufferView; BYTES_PER_ELEMENT: number; name: string } }[] = [
|
|
25
|
+
{ type: TYPE_INT8_ARRAY, ctor: Int8Array },
|
|
26
|
+
{ type: TYPE_UINT8_ARRAY, ctor: Uint8Array },
|
|
27
|
+
{ type: TYPE_UINT8_CLAMPED_ARRAY, ctor: Uint8ClampedArray },
|
|
28
|
+
{ type: TYPE_INT16_ARRAY, ctor: Int16Array },
|
|
29
|
+
{ type: TYPE_UINT16_ARRAY, ctor: Uint16Array },
|
|
30
|
+
{ type: TYPE_INT32_ARRAY, ctor: Int32Array },
|
|
31
|
+
{ type: TYPE_UINT32_ARRAY, ctor: Uint32Array },
|
|
32
|
+
{ type: TYPE_FLOAT32_ARRAY, ctor: Float32Array },
|
|
33
|
+
{ type: TYPE_FLOAT64_ARRAY, ctor: Float64Array },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const EMPTY_BUFFER = Buffer.alloc(0) as Buffer;
|
|
37
|
+
|
|
38
|
+
type FileHeader = {
|
|
39
|
+
rowCount: number;
|
|
40
|
+
columns: { name: string; offset: number; length: number }[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function encodeValue(value: unknown): { type: number; bytes: Buffer } {
|
|
44
|
+
if (value === undefined || value === null) {
|
|
45
|
+
return { type: TYPE_UNDEFINED, bytes: EMPTY_BUFFER };
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
return { type: TYPE_STRING, bytes: Buffer.from(value, "utf8") };
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "number") {
|
|
51
|
+
const bytes = Buffer.alloc(8);
|
|
52
|
+
bytes.writeDoubleLE(value, 0);
|
|
53
|
+
return { type: TYPE_NUMBER, bytes };
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "boolean") {
|
|
56
|
+
return { type: TYPE_BOOLEAN, bytes: Buffer.from([value && 1 || 0]) };
|
|
57
|
+
}
|
|
58
|
+
if (ArrayBuffer.isView(value)) {
|
|
59
|
+
if (value instanceof DataView) {
|
|
60
|
+
throw new Error(`DataView values are not supported, store a typed array instead`);
|
|
61
|
+
}
|
|
62
|
+
const entry = TYPED_ARRAY_TYPES.find(t => value instanceof t.ctor);
|
|
63
|
+
if (!entry) {
|
|
64
|
+
throw new Error(`Unsupported typed array type ${value.constructor.name}`);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: entry.type,
|
|
68
|
+
bytes: Buffer.from(value.buffer, value.byteOffset, value.byteLength),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (typeof value !== "object") {
|
|
72
|
+
throw new Error(`Unsupported value type ${typeof value}`);
|
|
73
|
+
}
|
|
74
|
+
return { type: TYPE_OBJECT, bytes: Buffer.from(JSON.stringify(value), "utf8") };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function decodeValue(type: number, bytes: Buffer): unknown {
|
|
78
|
+
if (type === TYPE_UNDEFINED) return undefined;
|
|
79
|
+
if (type === TYPE_STRING) return bytes.toString("utf8");
|
|
80
|
+
if (type === TYPE_NUMBER) {
|
|
81
|
+
if (bytes.length !== 8) {
|
|
82
|
+
throw new Error(`Expected 8 bytes for a number, was ${bytes.length}`);
|
|
83
|
+
}
|
|
84
|
+
return bytes.readDoubleLE(0);
|
|
85
|
+
}
|
|
86
|
+
if (type === TYPE_BOOLEAN) {
|
|
87
|
+
if (bytes.length !== 1) {
|
|
88
|
+
throw new Error(`Expected 1 byte for a boolean, was ${bytes.length}`);
|
|
89
|
+
}
|
|
90
|
+
return bytes[0] === 1;
|
|
91
|
+
}
|
|
92
|
+
if (type === TYPE_OBJECT) return JSON.parse(bytes.toString("utf8"));
|
|
93
|
+
const entry = TYPED_ARRAY_TYPES.find(t => t.type === type);
|
|
94
|
+
if (!entry) {
|
|
95
|
+
throw new Error(`Expected a valid type tag, was ${type}`);
|
|
96
|
+
}
|
|
97
|
+
const ctor = entry.ctor;
|
|
98
|
+
if (type === TYPE_UINT8_ARRAY) return Buffer.from(bytes);
|
|
99
|
+
if (bytes.length % ctor.BYTES_PER_ELEMENT !== 0) {
|
|
100
|
+
throw new Error(`Expected byte length divisible by ${ctor.BYTES_PER_ELEMENT} for ${ctor.name}, was ${bytes.length}`);
|
|
101
|
+
}
|
|
102
|
+
// Copy to a fresh ArrayBuffer so the typed array view is aligned regardless of where the bytes landed in the source buffer.
|
|
103
|
+
const aligned = new ArrayBuffer(bytes.length);
|
|
104
|
+
new Uint8Array(aligned).set(bytes);
|
|
105
|
+
return new ctor(aligned);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function encodeBulkData(data: unknown[]): Buffer {
|
|
109
|
+
const n = data.length;
|
|
110
|
+
const offsets = Buffer.alloc(4 * (n + 1));
|
|
111
|
+
const types = Buffer.alloc(n);
|
|
112
|
+
const parts: Buffer[] = [];
|
|
113
|
+
let pos = 0;
|
|
114
|
+
for (let i = 0; i < n; i++) {
|
|
115
|
+
const { type, bytes } = encodeValue(data[i]);
|
|
116
|
+
offsets.writeUInt32LE(pos, 4 * i);
|
|
117
|
+
types[i] = type;
|
|
118
|
+
parts.push(bytes);
|
|
119
|
+
pos += bytes.length;
|
|
120
|
+
}
|
|
121
|
+
offsets.writeUInt32LE(pos, 4 * n);
|
|
122
|
+
return Buffer.concat([offsets, types, ...parts]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
|
|
126
|
+
const indexSize = 4 * (rowCount + 1) + rowCount;
|
|
127
|
+
const values: unknown[] = [];
|
|
128
|
+
for (let i = 0; i < rowCount; i++) {
|
|
129
|
+
const start = blob.readUInt32LE(4 * i);
|
|
130
|
+
const end = blob.readUInt32LE(4 * (i + 1));
|
|
131
|
+
const type = blob[4 * (rowCount + 1) + i];
|
|
132
|
+
values.push(decodeValue(type, blob.subarray(indexSize + start, indexSize + end)));
|
|
133
|
+
}
|
|
134
|
+
return values;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Past this estimated logical size a single rows array is split across multiple files, so no one
|
|
138
|
+
// file (and therefore no single column blob, and no single Buffer.concat) ever approaches the
|
|
139
|
+
// ~2GB Buffer length limit. The cap is deliberately the same as the merge cap: 800MB is the most
|
|
140
|
+
// we'll ever hold for one file. The estimate is rough on purpose — it only needs to keep each
|
|
141
|
+
// chunk comfortably under the limit, not be exact.
|
|
142
|
+
const FILE_SPLIT_BYTES = 800 * 1024 * 1024;
|
|
143
|
+
|
|
144
|
+
// Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
|
|
145
|
+
const PER_VALUE_OVERHEAD = 5;
|
|
146
|
+
|
|
147
|
+
function estimateValueBytes(value: unknown): number {
|
|
148
|
+
if (value === undefined || value === null) return 0;
|
|
149
|
+
if (typeof value === "string") return value.length * 2;
|
|
150
|
+
if (typeof value === "number") return 8;
|
|
151
|
+
if (typeof value === "boolean") return 1;
|
|
152
|
+
if (ArrayBuffer.isView(value)) return value.byteLength;
|
|
153
|
+
if (typeof value === "object") return JSON.stringify(value).length;
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function estimateRowBytes(row: Record<string, unknown>): number {
|
|
158
|
+
let total = 0;
|
|
159
|
+
for (const value of Object.values(row)) {
|
|
160
|
+
total += estimateValueBytes(value) + PER_VALUE_OVERHEAD;
|
|
161
|
+
}
|
|
162
|
+
return total;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
166
|
+
const columnNames: string[] = [];
|
|
167
|
+
const columnSet = new Set<string>();
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
for (const field of Object.keys(row)) {
|
|
170
|
+
if (columnSet.has(field)) continue;
|
|
171
|
+
columnSet.add(field);
|
|
172
|
+
columnNames.push(field);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const blobs = columnNames.map(col => encodeBulkData(rows.map(row => row[col])));
|
|
176
|
+
let offset = 0;
|
|
177
|
+
const columns = columnNames.map((name, i) => {
|
|
178
|
+
const entry = { name, offset, length: blobs[i].length };
|
|
179
|
+
offset += blobs[i].length;
|
|
180
|
+
return entry;
|
|
181
|
+
});
|
|
182
|
+
const header: FileHeader = { rowCount: rows.length, columns };
|
|
183
|
+
const headerBuf = Buffer.from(JSON.stringify(header), "utf8");
|
|
184
|
+
const lengthPrefix = Buffer.alloc(4);
|
|
185
|
+
lengthPrefix.writeUInt32LE(headerBuf.length, 0);
|
|
186
|
+
return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Returns one complete, independent file buffer per chunk of rows. When the caller hands us more
|
|
190
|
+
// rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
|
|
191
|
+
// what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
|
|
192
|
+
// the caller just writes each as its own file. A normal-sized write returns a single buffer.
|
|
193
|
+
export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[] {
|
|
194
|
+
if (rows.length === 0) return [buildOneFile([])];
|
|
195
|
+
const result: Buffer[] = [];
|
|
196
|
+
let chunkStart = 0;
|
|
197
|
+
let chunkBytes = 0;
|
|
198
|
+
for (let i = 0; i < rows.length; i++) {
|
|
199
|
+
const rowBytes = estimateRowBytes(rows[i]);
|
|
200
|
+
if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
|
|
201
|
+
result.push(buildOneFile(rows.slice(chunkStart, i)));
|
|
202
|
+
chunkStart = i;
|
|
203
|
+
chunkBytes = 0;
|
|
204
|
+
}
|
|
205
|
+
chunkBytes += rowBytes;
|
|
206
|
+
}
|
|
207
|
+
result.push(buildOneFile(rows.slice(chunkStart)));
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type BaseBulkDatabaseReader = {
|
|
212
|
+
rowCount: number;
|
|
213
|
+
totalBytes: number;
|
|
214
|
+
// Keys is special, it's always automatically decoded, even though it is stored as a normal column
|
|
215
|
+
keys: string[];
|
|
216
|
+
columns: { column: string; byteSize: number }[];
|
|
217
|
+
// Keys this reader tombstones (deleted). A newer reader's deletion suppresses the key in all
|
|
218
|
+
// older readers. Bulk readers never set this; the tier-0 stream reader does.
|
|
219
|
+
deletedKeys?: Set<string>;
|
|
220
|
+
getColumn: (column: string) => Promise<{
|
|
221
|
+
key: string;
|
|
222
|
+
value: unknown;
|
|
223
|
+
}[]>;
|
|
224
|
+
getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export async function loadBulkDatabase(config: {
|
|
228
|
+
totalBytes: number;
|
|
229
|
+
getRange: (start: number, end: number) => Promise<Buffer>;
|
|
230
|
+
}): Promise<BaseBulkDatabaseReader> {
|
|
231
|
+
const headerLength = (await config.getRange(0, 4)).readUInt32LE(0);
|
|
232
|
+
if (headerLength <= 0 || headerLength > config.totalBytes) {
|
|
233
|
+
throw new Error(`Expected header length in (0, ${config.totalBytes}], was ${headerLength}`);
|
|
234
|
+
}
|
|
235
|
+
const header = JSON.parse((await config.getRange(4, 4 + headerLength)).toString("utf8")) as FileHeader;
|
|
236
|
+
const dataBase = 4 + headerLength;
|
|
237
|
+
const rowCount = header.rowCount;
|
|
238
|
+
const colByName = new Map(header.columns.map(c => [c.name, c]));
|
|
239
|
+
|
|
240
|
+
async function readWholeColumn(column: string): Promise<unknown[]> {
|
|
241
|
+
const col = colByName.get(column);
|
|
242
|
+
if (!col) {
|
|
243
|
+
throw new Error(`Expected column ${column}, file only has: ${header.columns.map(c => c.name).join(", ")}`);
|
|
244
|
+
}
|
|
245
|
+
const blob = await config.getRange(dataBase + col.offset, dataBase + col.offset + col.length);
|
|
246
|
+
return decodeBulkData(blob, rowCount);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const keys = (await readWholeColumn(KEY_COLUMN)).map(v => {
|
|
250
|
+
if (typeof v !== "string") {
|
|
251
|
+
throw new Error(`Expected string key, was ${typeof v}: ${JSON.stringify(v)?.slice(0, 500)}`);
|
|
252
|
+
}
|
|
253
|
+
return v;
|
|
254
|
+
});
|
|
255
|
+
const keyIndex = new Map(keys.map((key, i) => [key, i]));
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
rowCount,
|
|
259
|
+
totalBytes: config.totalBytes,
|
|
260
|
+
keys,
|
|
261
|
+
columns: header.columns.map(c => ({ column: c.name, byteSize: c.length })),
|
|
262
|
+
async getColumn(column) {
|
|
263
|
+
const values = await readWholeColumn(column);
|
|
264
|
+
return keys.map((key, i) => ({ key, value: values[i] }));
|
|
265
|
+
},
|
|
266
|
+
async getSingleField(key, column) {
|
|
267
|
+
const row = keyIndex.get(key);
|
|
268
|
+
if (row === undefined) return undefined;
|
|
269
|
+
const col = colByName.get(column);
|
|
270
|
+
if (!col) return undefined;
|
|
271
|
+
const colBase = dataBase + col.offset;
|
|
272
|
+
const offsetsBuf = await config.getRange(colBase + 4 * row, colBase + 4 * row + 8);
|
|
273
|
+
const start = offsetsBuf.readUInt32LE(0);
|
|
274
|
+
const end = offsetsBuf.readUInt32LE(4);
|
|
275
|
+
const typePos = colBase + 4 * (rowCount + 1) + row;
|
|
276
|
+
const typeBuf = await config.getRange(typePos, typePos + 1);
|
|
277
|
+
const dataStart = colBase + 4 * (rowCount + 1) + rowCount;
|
|
278
|
+
let bytes = EMPTY_BUFFER;
|
|
279
|
+
if (end > start) {
|
|
280
|
+
bytes = await config.getRange(dataStart + start, dataStart + end);
|
|
281
|
+
}
|
|
282
|
+
return decodeValue(typeBuf[0], bytes);
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -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,25 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
|
|
4
|
+
export declare const STREAM_EXTENSION = ".stream";
|
|
5
|
+
export type StreamEntry = {
|
|
6
|
+
time: number;
|
|
7
|
+
row?: Record<string, unknown>;
|
|
8
|
+
deletedKey?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function frameRows(entries: {
|
|
11
|
+
time: number;
|
|
12
|
+
row: Record<string, unknown>;
|
|
13
|
+
}[]): Buffer;
|
|
14
|
+
export declare function frameDeletes(entries: {
|
|
15
|
+
time: number;
|
|
16
|
+
key: string;
|
|
17
|
+
}[]): Buffer;
|
|
18
|
+
export declare function parseStream(buffer: Buffer): {
|
|
19
|
+
entries: StreamEntry[];
|
|
20
|
+
badBytes: number;
|
|
21
|
+
};
|
|
22
|
+
export declare function streamReaderFromEntries(entries: StreamEntry[], totalBytes: number): {
|
|
23
|
+
reader: BaseBulkDatabaseReader;
|
|
24
|
+
times: Map<string, number>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import cborx from "cbor-x";
|
|
2
|
+
import { BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
|
|
3
|
+
|
|
4
|
+
// Tier-0 streaming format: an append log of whole-row writes and deletes (row-format, not columnar),
|
|
5
|
+
// so small mutations are a single cheap append instead of rewriting a columnar file. Each block is:
|
|
6
|
+
//
|
|
7
|
+
// [u32 len][CBOR({ t, v }) or CBOR({ t, d })][u32 len]
|
|
8
|
+
//
|
|
9
|
+
// `t` is a per-write unique timestamp (getTimeUnique); `v` is a set row; `d` is a deleted key. Because
|
|
10
|
+
// every thread streams to its own file, the only way to recover the global mutation order — needed
|
|
11
|
+
// for newest-wins — is a per-write timestamp, with ties broken by file name. The trailing length must
|
|
12
|
+
// match the leading one; a torn/incomplete append leaves a mismatched or missing suffix, so we stop
|
|
13
|
+
// there and report trailing bad bytes instead of throwing. structuredClone preserves typed arrays.
|
|
14
|
+
|
|
15
|
+
export const STREAM_EXTENSION = ".stream";
|
|
16
|
+
|
|
17
|
+
const cborEncoder = new cborx.Encoder({ structuredClone: true });
|
|
18
|
+
|
|
19
|
+
export type StreamEntry = { time: number; row?: Record<string, unknown>; deletedKey?: string };
|
|
20
|
+
|
|
21
|
+
function frame(payload: Buffer): Buffer {
|
|
22
|
+
let len = Buffer.alloc(4);
|
|
23
|
+
len.writeUInt32LE(payload.length, 0);
|
|
24
|
+
return Buffer.concat([len, payload, len]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// We deliberately do NOT batch/debounce writes here — each write is framed and appended (and flushed)
|
|
28
|
+
// immediately. If a caller makes many individual writes and that causes lag, the fix is for them to
|
|
29
|
+
// call writeBatch/deleteBatch with the whole set, not for us to silently coalesce. Caller-side
|
|
30
|
+
// batching is strictly faster than anything we could do (it knows the full set up front), and not
|
|
31
|
+
// batching gives the lowest possible latency for callers who genuinely want single writes — which
|
|
32
|
+
// matters because the tab can be closed at any moment, so we want each write on disk as soon as
|
|
33
|
+
// possible rather than sitting in a pending buffer that a close would lose.
|
|
34
|
+
|
|
35
|
+
// Times are assigned by the caller (BulkDatabase2) so the exact same timestamp lands on disk, in the
|
|
36
|
+
// in-memory overlay, and in the cross-tab broadcast — keeping the global write order consistent.
|
|
37
|
+
export function frameRows(entries: { time: number; row: Record<string, unknown> }[]): Buffer {
|
|
38
|
+
return Buffer.concat(entries.map(e => frame(Buffer.from(cborEncoder.encode({ t: e.time, v: e.row })))));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function frameDeletes(entries: { time: number; key: string }[]): Buffer {
|
|
42
|
+
return Buffer.concat(entries.map(e => frame(Buffer.from(cborEncoder.encode({ t: e.time, d: e.key })))));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseStream(buffer: Buffer): { entries: StreamEntry[]; badBytes: number } {
|
|
46
|
+
let entries: StreamEntry[] = [];
|
|
47
|
+
let pos = 0;
|
|
48
|
+
while (pos + 4 <= buffer.length) {
|
|
49
|
+
let len = buffer.readUInt32LE(pos);
|
|
50
|
+
let payloadStart = pos + 4;
|
|
51
|
+
let suffixStart = payloadStart + len;
|
|
52
|
+
if (suffixStart + 4 > buffer.length) break;
|
|
53
|
+
if (buffer.readUInt32LE(suffixStart) !== len) break;
|
|
54
|
+
let decoded: { t: number; v?: Record<string, unknown>; d?: string } | undefined;
|
|
55
|
+
try {
|
|
56
|
+
decoded = cborEncoder.decode(buffer.subarray(payloadStart, suffixStart));
|
|
57
|
+
} catch {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
if (decoded && decoded.d !== undefined) entries.push({ time: decoded.t, deletedKey: decoded.d });
|
|
61
|
+
else if (decoded && decoded.v) entries.push({ time: decoded.t, row: decoded.v });
|
|
62
|
+
pos = suffixStart + 4;
|
|
63
|
+
}
|
|
64
|
+
return { entries, badBytes: buffer.length - pos };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Wraps streamed entries (already ordered oldest-first) as a BaseBulkDatabaseReader. Applies
|
|
68
|
+
// set/delete newest-wins: later entries overwrite earlier ones, and a delete tombstones the key
|
|
69
|
+
// (exposed via deletedKeys so the join suppresses it in older bulk readers). Also returns the latest
|
|
70
|
+
// timestamp seen per key (live or deleted), used for cross-tab conflict resolution.
|
|
71
|
+
export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: number): { reader: BaseBulkDatabaseReader; times: Map<string, number> } {
|
|
72
|
+
let byKey = new Map<string, Record<string, unknown>>();
|
|
73
|
+
let deletedKeys = new Set<string>();
|
|
74
|
+
let times = new Map<string, number>();
|
|
75
|
+
for (let entry of entries) {
|
|
76
|
+
if (entry.deletedKey !== undefined) {
|
|
77
|
+
byKey.delete(entry.deletedKey);
|
|
78
|
+
deletedKeys.add(entry.deletedKey);
|
|
79
|
+
times.set(entry.deletedKey, entry.time);
|
|
80
|
+
} else if (entry.row) {
|
|
81
|
+
let key = entry.row.key as string;
|
|
82
|
+
byKey.set(key, entry.row);
|
|
83
|
+
deletedKeys.delete(key);
|
|
84
|
+
times.set(key, entry.time);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let keys = [...byKey.keys()];
|
|
88
|
+
let columnNames: string[] = [];
|
|
89
|
+
let seen = new Set<string>();
|
|
90
|
+
for (let row of byKey.values()) {
|
|
91
|
+
for (let field of Object.keys(row)) {
|
|
92
|
+
if (seen.has(field)) continue;
|
|
93
|
+
seen.add(field);
|
|
94
|
+
columnNames.push(field);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let columns = columnNames.map(column => ({ column, byteSize: 0 }));
|
|
98
|
+
let reader: BaseBulkDatabaseReader = {
|
|
99
|
+
totalBytes,
|
|
100
|
+
rowCount: keys.length,
|
|
101
|
+
keys,
|
|
102
|
+
columns,
|
|
103
|
+
deletedKeys,
|
|
104
|
+
async getColumn(column) {
|
|
105
|
+
return keys.map(key => {
|
|
106
|
+
let row = byKey.get(key);
|
|
107
|
+
return { key, value: row && row[column] };
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
async getSingleField(key, column) {
|
|
111
|
+
let row = byKey.get(key);
|
|
112
|
+
return row && row[column];
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return { reader, times };
|
|
116
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type RemoteWrite = {
|
|
2
|
+
key: string;
|
|
3
|
+
time: number;
|
|
4
|
+
deleted?: boolean;
|
|
5
|
+
value?: unknown;
|
|
6
|
+
};
|
|
7
|
+
export declare function isSyncSupported(): boolean;
|
|
8
|
+
export declare function connect(collection: string, onWrite: (write: RemoteWrite) => void): Promise<RemoteWrite[]>;
|
|
9
|
+
export declare function broadcast(collection: string, write: RemoteWrite): void;
|