sliftutils 1.3.0 → 1.4.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/index.d.ts +147 -14
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +84 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +76 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +29 -8
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +565 -246
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +26 -3
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +124 -33
- package/storage/BulkDatabase2/mergeLock.d.ts +2 -0
- package/storage/BulkDatabase2/mergeLock.ts +48 -0
- package/storage/BulkDatabase2/streamLog.ts +21 -9
- package/storage/BulkDatabase2/syncClient.d.ts +2 -1
- package/storage/BulkDatabase2/syncClient.ts +19 -2
- package/storage/FileFolderAPI.tsx +6 -1
- package/yarn.lock +2213 -2213
|
@@ -2,22 +2,45 @@
|
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
export declare const KEY_COLUMN = "key";
|
|
4
4
|
export declare const EMPTY_BUFFER: Buffer;
|
|
5
|
-
export declare
|
|
5
|
+
export declare const ABSENT: unique symbol;
|
|
6
|
+
export declare const TARGET_FILE_BYTES: number;
|
|
7
|
+
export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): Buffer[];
|
|
6
8
|
export type BaseBulkDatabaseReader = {
|
|
7
9
|
rowCount: number;
|
|
8
10
|
totalBytes: number;
|
|
11
|
+
minTime: number;
|
|
12
|
+
maxTime: number;
|
|
13
|
+
minKey?: string;
|
|
14
|
+
maxKey?: string;
|
|
9
15
|
keys: string[];
|
|
10
16
|
columns: {
|
|
11
17
|
column: string;
|
|
12
18
|
byteSize: number;
|
|
13
19
|
}[];
|
|
14
|
-
|
|
20
|
+
keyTimes: Map<string, number>;
|
|
21
|
+
deleteTimes?: Map<string, number>;
|
|
15
22
|
getColumn: (column: string) => Promise<{
|
|
16
23
|
key: string;
|
|
17
24
|
value: unknown;
|
|
25
|
+
time: number;
|
|
18
26
|
}[]>;
|
|
19
|
-
getSingleField: (key: string, column: string) => Promise<
|
|
27
|
+
getSingleField: (key: string, column: string) => Promise<{
|
|
28
|
+
value: unknown;
|
|
29
|
+
time: number;
|
|
30
|
+
} | typeof ABSENT>;
|
|
31
|
+
};
|
|
32
|
+
export type BulkHeaderInfo = {
|
|
33
|
+
rowCount: number;
|
|
34
|
+
minTime: number;
|
|
35
|
+
maxTime: number;
|
|
36
|
+
minKey?: string;
|
|
37
|
+
maxKey?: string;
|
|
38
|
+
columns: {
|
|
39
|
+
column: string;
|
|
40
|
+
byteSize: number;
|
|
41
|
+
}[];
|
|
20
42
|
};
|
|
43
|
+
export declare function loadBulkHeader(getRange: (start: number, end: number) => Promise<Buffer>, totalBytes: number): Promise<BulkHeaderInfo>;
|
|
21
44
|
export declare function loadBulkDatabase(config: {
|
|
22
45
|
totalBytes: number;
|
|
23
46
|
getRange: (start: number, end: number) => Promise<Buffer>;
|
|
@@ -20,6 +20,10 @@ const TYPE_INT32_ARRAY = 10;
|
|
|
20
20
|
const TYPE_UINT32_ARRAY = 11;
|
|
21
21
|
const TYPE_FLOAT32_ARRAY = 12;
|
|
22
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;
|
|
23
27
|
|
|
24
28
|
const TYPED_ARRAY_TYPES: { type: number; ctor: { new(buffer: ArrayBuffer): ArrayBufferView; BYTES_PER_ELEMENT: number; name: string } }[] = [
|
|
25
29
|
{ type: TYPE_INT8_ARRAY, ctor: Int8Array },
|
|
@@ -35,12 +39,33 @@ const TYPED_ARRAY_TYPES: { type: number; ctor: { new(buffer: ArrayBuffer): Array
|
|
|
35
39
|
|
|
36
40
|
export const EMPTY_BUFFER = Buffer.alloc(0) as Buffer;
|
|
37
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
|
+
|
|
38
50
|
type FileHeader = {
|
|
39
51
|
rowCount: number;
|
|
40
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 merge planner pick files overlapping a time range. Absent (0)
|
|
55
|
+
// in files written before this existed.
|
|
56
|
+
minTime?: number;
|
|
57
|
+
maxTime?: number;
|
|
58
|
+
// Lexicographically smallest/largest key in this file (rows are stored key-sorted). Lets the merge
|
|
59
|
+
// planner group/dedup by key range and lets a single-key read skip files whose range excludes the
|
|
60
|
+
// key. Absent (undefined) in files written before this existed — treated as "spans all keys".
|
|
61
|
+
minKey?: string;
|
|
62
|
+
maxKey?: string;
|
|
41
63
|
};
|
|
42
64
|
|
|
43
65
|
function encodeValue(value: unknown): { type: number; bytes: Buffer } {
|
|
66
|
+
if (value === ABSENT) {
|
|
67
|
+
return { type: TYPE_ABSENT, bytes: EMPTY_BUFFER };
|
|
68
|
+
}
|
|
44
69
|
if (value === undefined || value === null) {
|
|
45
70
|
return { type: TYPE_UNDEFINED, bytes: EMPTY_BUFFER };
|
|
46
71
|
}
|
|
@@ -90,6 +115,7 @@ function decodeValue(type: number, bytes: Buffer): unknown {
|
|
|
90
115
|
return bytes[0] === 1;
|
|
91
116
|
}
|
|
92
117
|
if (type === TYPE_OBJECT) return JSON.parse(bytes.toString("utf8"));
|
|
118
|
+
if (type === TYPE_ABSENT) return ABSENT;
|
|
93
119
|
const entry = TYPED_ARRAY_TYPES.find(t => t.type === type);
|
|
94
120
|
if (!entry) {
|
|
95
121
|
throw new Error(`Expected a valid type tag, was ${type}`);
|
|
@@ -134,12 +160,12 @@ function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
|
|
|
134
160
|
return values;
|
|
135
161
|
}
|
|
136
162
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
const
|
|
163
|
+
// Target logical (uncompressed) size of one bulk file. A rows array bigger than this is split across
|
|
164
|
+
// multiple key-contiguous files, so files stay around this size (the merge policy's target) and no
|
|
165
|
+
// single column blob / Buffer.concat ever approaches the ~2GB Buffer length limit. The estimate is
|
|
166
|
+
// rough on purpose — it only needs to keep each chunk near the target, not be exact. A single row
|
|
167
|
+
// bigger than the target still becomes its own (oversized) file, since we never split within a key.
|
|
168
|
+
export const TARGET_FILE_BYTES = 256 * 1024 * 1024;
|
|
143
169
|
|
|
144
170
|
// Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
|
|
145
171
|
const PER_VALUE_OVERHEAD = 5;
|
|
@@ -162,7 +188,7 @@ function estimateRowBytes(row: Record<string, unknown>): number {
|
|
|
162
188
|
return total;
|
|
163
189
|
}
|
|
164
190
|
|
|
165
|
-
function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
191
|
+
function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer {
|
|
166
192
|
const columnNames: string[] = [];
|
|
167
193
|
const columnSet = new Set<string>();
|
|
168
194
|
for (const row of rows) {
|
|
@@ -172,58 +198,110 @@ function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
|
172
198
|
columnNames.push(field);
|
|
173
199
|
}
|
|
174
200
|
}
|
|
175
|
-
|
|
201
|
+
// A row that doesn't include a column stores ABSENT (fall-through), not undefined (a real value).
|
|
202
|
+
const blobs = columnNames.map(col => encodeBulkData(rows.map(row => col in row ? row[col] : ABSENT)));
|
|
203
|
+
// The hidden per-row time column.
|
|
204
|
+
columnNames.push(TIME_COLUMN);
|
|
205
|
+
blobs.push(encodeBulkData(times));
|
|
176
206
|
let offset = 0;
|
|
177
207
|
const columns = columnNames.map((name, i) => {
|
|
178
208
|
const entry = { name, offset, length: blobs[i].length };
|
|
179
209
|
offset += blobs[i].length;
|
|
180
210
|
return entry;
|
|
181
211
|
});
|
|
182
|
-
|
|
212
|
+
let minTime = times.length ? times[0] : 0;
|
|
213
|
+
let maxTime = minTime;
|
|
214
|
+
for (const t of times) { if (t < minTime) minTime = t; if (t > maxTime) maxTime = t; }
|
|
215
|
+
let minKey: string | undefined;
|
|
216
|
+
let maxKey: string | undefined;
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
const key = row[KEY_COLUMN] as string;
|
|
219
|
+
if (minKey === undefined || key < minKey) minKey = key;
|
|
220
|
+
if (maxKey === undefined || key > maxKey) maxKey = key;
|
|
221
|
+
}
|
|
222
|
+
const header: FileHeader = { rowCount: rows.length, columns, minTime, maxTime, minKey, maxKey };
|
|
183
223
|
const headerBuf = Buffer.from(JSON.stringify(header), "utf8");
|
|
184
224
|
const lengthPrefix = Buffer.alloc(4);
|
|
185
225
|
lengthPrefix.writeUInt32LE(headerBuf.length, 0);
|
|
186
226
|
return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
|
|
187
227
|
}
|
|
188
228
|
|
|
189
|
-
// Returns one complete, independent file buffer per chunk of rows.
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
229
|
+
// Returns one complete, independent file buffer per chunk of rows. Rows are first sorted by key, then
|
|
230
|
+
// partitioned into key-contiguous chunks of ~targetBytes each — so every returned file is key-sorted
|
|
231
|
+
// (tight minKey/maxKey for the read-skip + merge planner) and stays near the target size, and no
|
|
232
|
+
// single column blob / Buffer.concat approaches the ~2GB limit. The chunks have disjoint key ranges,
|
|
233
|
+
// so the caller just writes each as its own file. A normal-sized write returns a single buffer.
|
|
234
|
+
// `times[i]` is row i's write-time, stored per row so reads resolve a key to its latest value by time.
|
|
235
|
+
export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): Buffer[] {
|
|
236
|
+
if (rows.length === 0) return [buildOneFile([], [])];
|
|
237
|
+
// Sort rows + their times together by key so each output file is key-contiguous.
|
|
238
|
+
const order = rows.map((_, i) => i).sort((a, b) => {
|
|
239
|
+
const ka = rows[a][KEY_COLUMN] as string;
|
|
240
|
+
const kb = rows[b][KEY_COLUMN] as string;
|
|
241
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
242
|
+
});
|
|
243
|
+
const sortedRows = order.map(i => rows[i]);
|
|
244
|
+
const sortedTimes = order.map(i => times[i]);
|
|
195
245
|
const result: Buffer[] = [];
|
|
196
246
|
let chunkStart = 0;
|
|
197
247
|
let chunkBytes = 0;
|
|
198
|
-
for (let i = 0; i <
|
|
199
|
-
const rowBytes = estimateRowBytes(
|
|
200
|
-
if (i > chunkStart && chunkBytes + rowBytes >
|
|
201
|
-
result.push(buildOneFile(
|
|
248
|
+
for (let i = 0; i < sortedRows.length; i++) {
|
|
249
|
+
const rowBytes = estimateRowBytes(sortedRows[i]);
|
|
250
|
+
if (i > chunkStart && chunkBytes + rowBytes > targetBytes) {
|
|
251
|
+
result.push(buildOneFile(sortedRows.slice(chunkStart, i), sortedTimes.slice(chunkStart, i)));
|
|
202
252
|
chunkStart = i;
|
|
203
253
|
chunkBytes = 0;
|
|
204
254
|
}
|
|
205
255
|
chunkBytes += rowBytes;
|
|
206
256
|
}
|
|
207
|
-
result.push(buildOneFile(
|
|
257
|
+
result.push(buildOneFile(sortedRows.slice(chunkStart), sortedTimes.slice(chunkStart)));
|
|
208
258
|
return result;
|
|
209
259
|
}
|
|
210
260
|
|
|
211
261
|
export type BaseBulkDatabaseReader = {
|
|
212
262
|
rowCount: number;
|
|
213
263
|
totalBytes: number;
|
|
264
|
+
// Write-time bounds of this reader's data (0 if unknown — old bulk files).
|
|
265
|
+
minTime: number;
|
|
266
|
+
maxTime: number;
|
|
267
|
+
// Key-range bounds (undefined for old files / the stream reader — treat as "spans all keys").
|
|
268
|
+
minKey?: string;
|
|
269
|
+
maxKey?: string;
|
|
214
270
|
// Keys is special, it's always automatically decoded, even though it is stored as a normal column
|
|
215
271
|
keys: string[];
|
|
216
272
|
columns: { column: string; byteSize: number }[];
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
273
|
+
// Each key's row write-time (the time of its newest write in this reader). The join compares these
|
|
274
|
+
// across readers to resolve a key to its latest value.
|
|
275
|
+
keyTimes: Map<string, number>;
|
|
276
|
+
// Per-key tombstone time: the key was deleted at this time. The join treats a delete like any other
|
|
277
|
+
// event — a delete only wins if it's newer than every set for the key. Only the stream reader sets it.
|
|
278
|
+
deleteTimes?: Map<string, number>;
|
|
279
|
+
// Each key's value for the column plus the row's write-time. value may be ABSENT (the row never set
|
|
280
|
+
// this column — the join then falls through to an older reader for that key/column).
|
|
281
|
+
getColumn: (column: string) => Promise<{ key: string; value: unknown; time: number }[]>;
|
|
282
|
+
// The value + write-time for (key, column), or ABSENT if this reader has no such cell.
|
|
283
|
+
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | typeof ABSENT>;
|
|
225
284
|
};
|
|
226
285
|
|
|
286
|
+
// Reads just the file header (the 4-byte length + header JSON) — no column data. Used by the merge
|
|
287
|
+
// planner to get each file's row count, time range, and key range cheaply across many files.
|
|
288
|
+
export type BulkHeaderInfo = { rowCount: number; minTime: number; maxTime: number; minKey?: string; maxKey?: string; columns: { column: string; byteSize: number }[] };
|
|
289
|
+
export async function loadBulkHeader(getRange: (start: number, end: number) => Promise<Buffer>, totalBytes: number): Promise<BulkHeaderInfo> {
|
|
290
|
+
const headerLength = (await getRange(0, 4)).readUInt32LE(0);
|
|
291
|
+
if (headerLength <= 0 || headerLength > totalBytes) {
|
|
292
|
+
throw new Error(`Expected header length in (0, ${totalBytes}], was ${headerLength}`);
|
|
293
|
+
}
|
|
294
|
+
const header = JSON.parse((await getRange(4, 4 + headerLength)).toString("utf8")) as FileHeader;
|
|
295
|
+
return {
|
|
296
|
+
rowCount: header.rowCount,
|
|
297
|
+
minTime: header.minTime || 0,
|
|
298
|
+
maxTime: header.maxTime || 0,
|
|
299
|
+
minKey: header.minKey,
|
|
300
|
+
maxKey: header.maxKey,
|
|
301
|
+
columns: header.columns.filter(c => c.name !== TIME_COLUMN).map(c => ({ column: c.name, byteSize: c.length })),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
227
305
|
export async function loadBulkDatabase(config: {
|
|
228
306
|
totalBytes: number;
|
|
229
307
|
getRange: (start: number, end: number) => Promise<Buffer>;
|
|
@@ -254,20 +332,31 @@ export async function loadBulkDatabase(config: {
|
|
|
254
332
|
});
|
|
255
333
|
const keyIndex = new Map(keys.map((key, i) => [key, i]));
|
|
256
334
|
|
|
335
|
+
// Per-row write-times. Old files (written before this column existed) fall back to the file's header
|
|
336
|
+
// time (or 0) for every row — fine, since such files predate concurrent-time resolution.
|
|
337
|
+
const times: number[] = colByName.has(TIME_COLUMN)
|
|
338
|
+
? (await readWholeColumn(TIME_COLUMN)).map(v => typeof v === "number" ? v : 0)
|
|
339
|
+
: keys.map(() => header.maxTime || 0);
|
|
340
|
+
|
|
257
341
|
return {
|
|
258
342
|
rowCount,
|
|
259
343
|
totalBytes: config.totalBytes,
|
|
344
|
+
minTime: header.minTime || 0,
|
|
345
|
+
maxTime: header.maxTime || 0,
|
|
346
|
+
minKey: header.minKey,
|
|
347
|
+
maxKey: header.maxKey,
|
|
260
348
|
keys,
|
|
261
|
-
|
|
349
|
+
keyTimes: new Map(keys.map((key, i) => [key, times[i]])),
|
|
350
|
+
columns: header.columns.filter(c => c.name !== TIME_COLUMN).map(c => ({ column: c.name, byteSize: c.length })),
|
|
262
351
|
async getColumn(column) {
|
|
263
352
|
const values = await readWholeColumn(column);
|
|
264
|
-
return keys.map((key, i) => ({ key, value: values[i] }));
|
|
353
|
+
return keys.map((key, i) => ({ key, value: values[i], time: times[i] }));
|
|
265
354
|
},
|
|
266
355
|
async getSingleField(key, column) {
|
|
267
356
|
const row = keyIndex.get(key);
|
|
268
|
-
if (row === undefined) return
|
|
357
|
+
if (row === undefined) return ABSENT;
|
|
269
358
|
const col = colByName.get(column);
|
|
270
|
-
if (!col) return
|
|
359
|
+
if (!col) return ABSENT;
|
|
271
360
|
const colBase = dataBase + col.offset;
|
|
272
361
|
const offsetsBuf = await config.getRange(colBase + 4 * row, colBase + 4 * row + 8);
|
|
273
362
|
const start = offsetsBuf.readUInt32LE(0);
|
|
@@ -279,7 +368,9 @@ export async function loadBulkDatabase(config: {
|
|
|
279
368
|
if (end > start) {
|
|
280
369
|
bytes = await config.getRange(dataStart + start, dataStart + end);
|
|
281
370
|
}
|
|
282
|
-
|
|
371
|
+
const value = decodeValue(typeBuf[0], bytes);
|
|
372
|
+
if (value === ABSENT) return ABSENT;
|
|
373
|
+
return { value, time: times[row] };
|
|
283
374
|
},
|
|
284
375
|
};
|
|
285
376
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Best-effort "only one tab merges this collection at a time" guard. This is purely an efficiency
|
|
2
|
+
// measure: correctness does NOT depend on it. Reads resolve by per-row write-time, merges write new
|
|
3
|
+
// files before deleting consumed ones, and a duplicate-heavy region gets re-merged — so two tabs
|
|
4
|
+
// merging at once only waste work and briefly duplicate data, never corrupt or lose it. The lock just
|
|
5
|
+
// stops that wasted work. It uses localStorage (shared across same-origin tabs) and is a no-op where
|
|
6
|
+
// localStorage is unavailable (Node) — there concurrent merges are harmless, which the Node stress
|
|
7
|
+
// tests exercise. localStorage has no atomic compare-and-swap, so we write-then-reread to shrink the
|
|
8
|
+
// race window, and a TTL frees a lock left behind by a tab that crashed or closed mid-merge.
|
|
9
|
+
|
|
10
|
+
const LOCK_TTL_MS = 30 * 1000;
|
|
11
|
+
|
|
12
|
+
function getLocalStorage(): Storage | undefined {
|
|
13
|
+
try {
|
|
14
|
+
return typeof localStorage !== "undefined" ? localStorage : undefined;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined; // accessing localStorage can throw (e.g. disabled cookies)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function lockKey(collection: string): string {
|
|
21
|
+
return "bulkDatabase2-merge:" + collection;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which
|
|
25
|
+
// case we proceed and rely on the manifest backstop). Returns false if another tab holds a fresh lock.
|
|
26
|
+
export function tryAcquireMergeLock(collection: string, holderId: string): boolean {
|
|
27
|
+
const ls = getLocalStorage();
|
|
28
|
+
if (!ls) return true;
|
|
29
|
+
const key = lockKey(collection);
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const existing = ls.getItem(key);
|
|
32
|
+
if (existing) {
|
|
33
|
+
const t = parseInt(existing.slice(existing.lastIndexOf(":") + 1), 10);
|
|
34
|
+
if (Number.isFinite(t) && now - t < LOCK_TTL_MS) return false;
|
|
35
|
+
}
|
|
36
|
+
const token = holderId + ":" + now;
|
|
37
|
+
ls.setItem(key, token);
|
|
38
|
+
// Re-read to catch a racing setItem from another tab (best-effort, not atomic).
|
|
39
|
+
return ls.getItem(key) === token;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function releaseMergeLock(collection: string, holderId: string): void {
|
|
43
|
+
const ls = getLocalStorage();
|
|
44
|
+
if (!ls) return;
|
|
45
|
+
const key = lockKey(collection);
|
|
46
|
+
const existing = ls.getItem(key);
|
|
47
|
+
if (existing && existing.startsWith(holderId + ":")) ls.removeItem(key);
|
|
48
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import cborx from "cbor-x";
|
|
2
|
-
import { BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
|
|
2
|
+
import { ABSENT, BaseBulkDatabaseReader } from "./BulkDatabaseFormat";
|
|
3
3
|
|
|
4
4
|
// Tier-0 streaming format: an append log of whole-row writes and deletes (row-format, not columnar),
|
|
5
5
|
// so small mutations are a single cheap append instead of rewriting a columnar file. Each block is:
|
|
@@ -64,10 +64,12 @@ export function parseStream(buffer: Buffer): { entries: StreamEntry[]; badBytes:
|
|
|
64
64
|
return { entries, badBytes: buffer.length - pos };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Wraps streamed entries (already ordered oldest-first) as a BaseBulkDatabaseReader.
|
|
68
|
-
//
|
|
69
|
-
// (exposed via deletedKeys so the join suppresses it in older
|
|
70
|
-
//
|
|
67
|
+
// Wraps streamed entries (already ordered oldest-first) as a BaseBulkDatabaseReader. Each set MERGES
|
|
68
|
+
// its fields onto the key's current row (so a partial write/update only changes the columns it
|
|
69
|
+
// includes); a delete tombstones the key (exposed via deletedKeys so the join suppresses it in older
|
|
70
|
+
// bulk readers) and resets the merge. A column the merged row never set reads as ABSENT, so the join
|
|
71
|
+
// falls through to older readers for it. Also returns the latest timestamp seen per key (live or
|
|
72
|
+
// deleted), used for cross-tab conflict resolution.
|
|
71
73
|
export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: number): { reader: BaseBulkDatabaseReader; times: Map<string, number> } {
|
|
72
74
|
let byKey = new Map<string, Record<string, unknown>>();
|
|
73
75
|
let deletedKeys = new Set<string>();
|
|
@@ -79,7 +81,7 @@ export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: numb
|
|
|
79
81
|
times.set(entry.deletedKey, entry.time);
|
|
80
82
|
} else if (entry.row) {
|
|
81
83
|
let key = entry.row.key as string;
|
|
82
|
-
byKey.set(key, entry.row);
|
|
84
|
+
byKey.set(key, { ...byKey.get(key), ...entry.row });
|
|
83
85
|
deletedKeys.delete(key);
|
|
84
86
|
times.set(key, entry.time);
|
|
85
87
|
}
|
|
@@ -95,21 +97,31 @@ export function streamReaderFromEntries(entries: StreamEntry[], totalBytes: numb
|
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
let columns = columnNames.map(column => ({ column, byteSize: 0 }));
|
|
100
|
+
// Per-key tombstone times for the join's delete resolution.
|
|
101
|
+
let deleteTimes = new Map<string, number>();
|
|
102
|
+
for (let key of deletedKeys) deleteTimes.set(key, times.get(key) || 0);
|
|
103
|
+
let timeValues = [...times.values()];
|
|
98
104
|
let reader: BaseBulkDatabaseReader = {
|
|
99
105
|
totalBytes,
|
|
100
106
|
rowCount: keys.length,
|
|
107
|
+
minTime: timeValues.length ? Math.min(...timeValues) : 0,
|
|
108
|
+
maxTime: timeValues.length ? Math.max(...timeValues) : 0,
|
|
101
109
|
keys,
|
|
110
|
+
keyTimes: new Map(keys.map(key => [key, times.get(key) || 0])),
|
|
102
111
|
columns,
|
|
103
|
-
|
|
112
|
+
deleteTimes,
|
|
113
|
+
// A key's time is its latest write across all columns (per-key, not per-column). For a column
|
|
114
|
+
// it never set we return ABSENT so the join falls through to an older reader.
|
|
104
115
|
async getColumn(column) {
|
|
105
116
|
return keys.map(key => {
|
|
106
117
|
let row = byKey.get(key);
|
|
107
|
-
return { key, value: row && row[column] };
|
|
118
|
+
return { key, value: row && column in row ? row[column] : ABSENT, time: times.get(key) || 0 };
|
|
108
119
|
});
|
|
109
120
|
},
|
|
110
121
|
async getSingleField(key, column) {
|
|
111
122
|
let row = byKey.get(key);
|
|
112
|
-
|
|
123
|
+
if (!row || !(column in row)) return ABSENT;
|
|
124
|
+
return { value: row[column], time: times.get(key) || 0 };
|
|
113
125
|
},
|
|
114
126
|
};
|
|
115
127
|
return { reader, times };
|
|
@@ -5,5 +5,6 @@ export type RemoteWrite = {
|
|
|
5
5
|
value?: unknown;
|
|
6
6
|
};
|
|
7
7
|
export declare function isSyncSupported(): boolean;
|
|
8
|
-
export declare function connect(collection: string, onWrite: (write: RemoteWrite) => void): Promise<RemoteWrite[]>;
|
|
8
|
+
export declare function connect(collection: string, onWrite: (write: RemoteWrite) => void, onSeal?: () => void): Promise<RemoteWrite[]>;
|
|
9
9
|
export declare function broadcast(collection: string, write: RemoteWrite): void;
|
|
10
|
+
export declare function broadcastSeal(collection: string): void;
|
|
@@ -15,6 +15,8 @@ const RECENT_WINDOW_MS = 60_000;
|
|
|
15
15
|
type Channel = {
|
|
16
16
|
bc: BroadcastChannel;
|
|
17
17
|
subscribers: ((write: RemoteWrite) => void)[];
|
|
18
|
+
// Called when a peer asks everyone to seal (stop appending to) their current stream file.
|
|
19
|
+
sealSubscribers: (() => void)[];
|
|
18
20
|
// This tab's own recent writes, kept so it can answer another tab's "hello".
|
|
19
21
|
recent: RemoteWrite[];
|
|
20
22
|
};
|
|
@@ -41,7 +43,7 @@ function ensure(collection: string): Channel | undefined {
|
|
|
41
43
|
// BroadcastChannel never delivers a message back to the instance that sent it, so a tab never
|
|
42
44
|
// hears its own writes — only the other open tabs do.
|
|
43
45
|
const bc = new BroadcastChannel(`bulkDatabase2:${collection}`);
|
|
44
|
-
const created: Channel = { bc, subscribers: [], recent: [] };
|
|
46
|
+
const created: Channel = { bc, subscribers: [], sealSubscribers: [], recent: [] };
|
|
45
47
|
bc.onmessage = (event: MessageEvent) => {
|
|
46
48
|
const msg = event.data as { type: string; write?: RemoteWrite; writes?: RemoteWrite[] };
|
|
47
49
|
if (!msg) return;
|
|
@@ -49,6 +51,10 @@ function ensure(collection: string): Channel | undefined {
|
|
|
49
51
|
deliver(created, msg.write);
|
|
50
52
|
} else if (msg.type === "recent" && msg.writes) {
|
|
51
53
|
for (const write of msg.writes) deliver(created, write);
|
|
54
|
+
} else if (msg.type === "seal") {
|
|
55
|
+
// A peer is about to fold recent data; stop appending to our current stream file so it
|
|
56
|
+
// becomes complete and the merge can fold it whole. Our next write starts a fresh file.
|
|
57
|
+
for (const sub of created.sealSubscribers) sub();
|
|
52
58
|
} else if (msg.type === "hello") {
|
|
53
59
|
// Another tab just started; replay our recent writes so it doesn't miss any. The reply goes
|
|
54
60
|
// to every tab, but peers that already have a write ignore it (its timestamp isn't newer
|
|
@@ -64,10 +70,11 @@ function ensure(collection: string): Channel | undefined {
|
|
|
64
70
|
// Subscribe to remote writes for a collection. Recent writes from already-open tabs arrive through the
|
|
65
71
|
// same onWrite callback (as the reply to our hello), so the returned array is always empty — it's kept
|
|
66
72
|
// only for API compatibility with callers that await it.
|
|
67
|
-
export function connect(collection: string, onWrite: (write: RemoteWrite) => void): Promise<RemoteWrite[]> {
|
|
73
|
+
export function connect(collection: string, onWrite: (write: RemoteWrite) => void, onSeal?: () => void): Promise<RemoteWrite[]> {
|
|
68
74
|
const channel = ensure(collection);
|
|
69
75
|
if (!channel) return Promise.resolve([]);
|
|
70
76
|
channel.subscribers.push(onWrite);
|
|
77
|
+
if (onSeal) channel.sealSubscribers.push(onSeal);
|
|
71
78
|
channel.bc.postMessage({ type: "hello" });
|
|
72
79
|
return Promise.resolve([]);
|
|
73
80
|
}
|
|
@@ -79,3 +86,13 @@ export function broadcast(collection: string, write: RemoteWrite): void {
|
|
|
79
86
|
pruneRecent(channel);
|
|
80
87
|
channel.bc.postMessage({ type: "write", write });
|
|
81
88
|
}
|
|
89
|
+
|
|
90
|
+
// Ask every other open tab of this collection to seal (stop appending to) its current stream file, so
|
|
91
|
+
// a merge can fold recent data up to the present without racing an append. Best-effort: no-op in Node,
|
|
92
|
+
// and a peer that misses it just keeps appending — worst case the merge folds a prefix and the rest
|
|
93
|
+
// folds later (duplication, resolved by write-time), never data loss.
|
|
94
|
+
export function broadcastSeal(collection: string): void {
|
|
95
|
+
const channel = ensure(collection);
|
|
96
|
+
if (!channel) return;
|
|
97
|
+
channel.bc.postMessage({ type: "seal" });
|
|
98
|
+
}
|
|
@@ -183,7 +183,12 @@ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
|
|
|
183
183
|
// Create the file
|
|
184
184
|
await fs.promises.writeFile(filePath, Buffer.alloc(0));
|
|
185
185
|
} else if (!exists) {
|
|
186
|
-
|
|
186
|
+
// Tag as ENOENT so readWithRetry treats it as a genuinely-missing file (return undefined now)
|
|
187
|
+
// rather than a transient read failure to retry 6× with backoff — missing files are normal
|
|
188
|
+
// (a concurrent merge deletes a file mid-read), and retrying them is catastrophically slow.
|
|
189
|
+
const err = new Error(`File not found: ${filePath}`) as Error & { code?: string };
|
|
190
|
+
err.code = "ENOENT";
|
|
191
|
+
throw err;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
return new NodeJSFileHandleWrapper(filePath);
|