sliftutils 1.3.0 → 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.
- package/index.d.ts +133 -10
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +71 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +64 -2
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +24 -5
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +443 -177
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +11 -3
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +66 -20
- package/storage/BulkDatabase2/manifest.d.ts +17 -0
- package/storage/BulkDatabase2/manifest.ts +59 -0
- package/storage/BulkDatabase2/mergeLock.d.ts +2 -0
- package/storage/BulkDatabase2/mergeLock.ts +47 -0
- package/storage/BulkDatabase2/streamLog.ts +21 -9
- package/yarn.lock +2213 -2213
|
@@ -2,21 +2,29 @@
|
|
|
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 function buildFileBuffer(rows: Record<string, unknown>[], times: number[]): Buffer[];
|
|
6
7
|
export type BaseBulkDatabaseReader = {
|
|
7
8
|
rowCount: number;
|
|
8
9
|
totalBytes: number;
|
|
10
|
+
minTime: number;
|
|
11
|
+
maxTime: number;
|
|
9
12
|
keys: string[];
|
|
10
13
|
columns: {
|
|
11
14
|
column: string;
|
|
12
15
|
byteSize: number;
|
|
13
16
|
}[];
|
|
14
|
-
|
|
17
|
+
keyTimes: Map<string, number>;
|
|
18
|
+
deleteTimes?: Map<string, number>;
|
|
15
19
|
getColumn: (column: string) => Promise<{
|
|
16
20
|
key: string;
|
|
17
21
|
value: unknown;
|
|
22
|
+
time: number;
|
|
18
23
|
}[]>;
|
|
19
|
-
getSingleField: (key: string, column: string) => Promise<
|
|
24
|
+
getSingleField: (key: string, column: string) => Promise<{
|
|
25
|
+
value: unknown;
|
|
26
|
+
time: number;
|
|
27
|
+
} | typeof ABSENT>;
|
|
20
28
|
};
|
|
21
29
|
export declare function loadBulkDatabase(config: {
|
|
22
30
|
totalBytes: number;
|
|
@@ -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,28 @@ 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 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;
|
|
41
58
|
};
|
|
42
59
|
|
|
43
60
|
function encodeValue(value: unknown): { type: number; bytes: Buffer } {
|
|
61
|
+
if (value === ABSENT) {
|
|
62
|
+
return { type: TYPE_ABSENT, bytes: EMPTY_BUFFER };
|
|
63
|
+
}
|
|
44
64
|
if (value === undefined || value === null) {
|
|
45
65
|
return { type: TYPE_UNDEFINED, bytes: EMPTY_BUFFER };
|
|
46
66
|
}
|
|
@@ -90,6 +110,7 @@ function decodeValue(type: number, bytes: Buffer): unknown {
|
|
|
90
110
|
return bytes[0] === 1;
|
|
91
111
|
}
|
|
92
112
|
if (type === TYPE_OBJECT) return JSON.parse(bytes.toString("utf8"));
|
|
113
|
+
if (type === TYPE_ABSENT) return ABSENT;
|
|
93
114
|
const entry = TYPED_ARRAY_TYPES.find(t => t.type === type);
|
|
94
115
|
if (!entry) {
|
|
95
116
|
throw new Error(`Expected a valid type tag, was ${type}`);
|
|
@@ -162,7 +183,7 @@ function estimateRowBytes(row: Record<string, unknown>): number {
|
|
|
162
183
|
return total;
|
|
163
184
|
}
|
|
164
185
|
|
|
165
|
-
function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
186
|
+
function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer {
|
|
166
187
|
const columnNames: string[] = [];
|
|
167
188
|
const columnSet = new Set<string>();
|
|
168
189
|
for (const row of rows) {
|
|
@@ -172,14 +193,21 @@ function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
|
172
193
|
columnNames.push(field);
|
|
173
194
|
}
|
|
174
195
|
}
|
|
175
|
-
|
|
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));
|
|
176
201
|
let offset = 0;
|
|
177
202
|
const columns = columnNames.map((name, i) => {
|
|
178
203
|
const entry = { name, offset, length: blobs[i].length };
|
|
179
204
|
offset += blobs[i].length;
|
|
180
205
|
return entry;
|
|
181
206
|
});
|
|
182
|
-
|
|
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 };
|
|
183
211
|
const headerBuf = Buffer.from(JSON.stringify(header), "utf8");
|
|
184
212
|
const lengthPrefix = Buffer.alloc(4);
|
|
185
213
|
lengthPrefix.writeUInt32LE(headerBuf.length, 0);
|
|
@@ -190,38 +218,45 @@ function buildOneFile(rows: Record<string, unknown>[]): Buffer {
|
|
|
190
218
|
// rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
|
|
191
219
|
// what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
|
|
192
220
|
// the caller just writes each as its own file. A normal-sized write returns a single buffer.
|
|
193
|
-
|
|
194
|
-
|
|
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([], [])];
|
|
195
224
|
const result: Buffer[] = [];
|
|
196
225
|
let chunkStart = 0;
|
|
197
226
|
let chunkBytes = 0;
|
|
198
227
|
for (let i = 0; i < rows.length; i++) {
|
|
199
228
|
const rowBytes = estimateRowBytes(rows[i]);
|
|
200
229
|
if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
|
|
201
|
-
result.push(buildOneFile(rows.slice(chunkStart, i)));
|
|
230
|
+
result.push(buildOneFile(rows.slice(chunkStart, i), times.slice(chunkStart, i)));
|
|
202
231
|
chunkStart = i;
|
|
203
232
|
chunkBytes = 0;
|
|
204
233
|
}
|
|
205
234
|
chunkBytes += rowBytes;
|
|
206
235
|
}
|
|
207
|
-
result.push(buildOneFile(rows.slice(chunkStart)));
|
|
236
|
+
result.push(buildOneFile(rows.slice(chunkStart), times.slice(chunkStart)));
|
|
208
237
|
return result;
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
export type BaseBulkDatabaseReader = {
|
|
212
241
|
rowCount: number;
|
|
213
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;
|
|
214
246
|
// Keys is special, it's always automatically decoded, even though it is stored as a normal column
|
|
215
247
|
keys: string[];
|
|
216
248
|
columns: { column: string; byteSize: number }[];
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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>;
|
|
225
260
|
};
|
|
226
261
|
|
|
227
262
|
export async function loadBulkDatabase(config: {
|
|
@@ -254,20 +289,29 @@ export async function loadBulkDatabase(config: {
|
|
|
254
289
|
});
|
|
255
290
|
const keyIndex = new Map(keys.map((key, i) => [key, i]));
|
|
256
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
|
+
|
|
257
298
|
return {
|
|
258
299
|
rowCount,
|
|
259
300
|
totalBytes: config.totalBytes,
|
|
301
|
+
minTime: header.minTime || 0,
|
|
302
|
+
maxTime: header.maxTime || 0,
|
|
260
303
|
keys,
|
|
261
|
-
|
|
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 })),
|
|
262
306
|
async getColumn(column) {
|
|
263
307
|
const values = await readWholeColumn(column);
|
|
264
|
-
return keys.map((key, i) => ({ key, value: values[i] }));
|
|
308
|
+
return keys.map((key, i) => ({ key, value: values[i], time: times[i] }));
|
|
265
309
|
},
|
|
266
310
|
async getSingleField(key, column) {
|
|
267
311
|
const row = keyIndex.get(key);
|
|
268
|
-
if (row === undefined) return
|
|
312
|
+
if (row === undefined) return ABSENT;
|
|
269
313
|
const col = colByName.get(column);
|
|
270
|
-
if (!col) return
|
|
314
|
+
if (!col) return ABSENT;
|
|
271
315
|
const colBase = dataBase + col.offset;
|
|
272
316
|
const offsetsBuf = await config.getRange(colBase + 4 * row, colBase + 4 * row + 8);
|
|
273
317
|
const start = offsetsBuf.readUInt32LE(0);
|
|
@@ -279,7 +323,9 @@ export async function loadBulkDatabase(config: {
|
|
|
279
323
|
if (end > start) {
|
|
280
324
|
bytes = await config.getRange(dataStart + start, dataStart + end);
|
|
281
325
|
}
|
|
282
|
-
|
|
326
|
+
const value = decodeValue(typeBuf[0], bytes);
|
|
327
|
+
if (value === ABSENT) return ABSENT;
|
|
328
|
+
return { value, time: times[row] };
|
|
283
329
|
},
|
|
284
330
|
};
|
|
285
331
|
}
|
|
@@ -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,47 @@
|
|
|
1
|
+
// Best-effort "only one tab merges this collection at a time" guard, layered ON TOP of the manifest
|
|
2
|
+
// scheme (which guarantees correctness on its own). This is purely an efficiency measure: it stops two
|
|
3
|
+
// tabs doing the same compaction at once and racing to orphan each other's output. It uses
|
|
4
|
+
// localStorage (shared across same-origin tabs) and is a no-op where localStorage is unavailable
|
|
5
|
+
// (Node) — there the manifest backstop alone keeps things correct, which is also what the Node stress
|
|
6
|
+
// tests exercise. localStorage has no atomic compare-and-swap, so we write-then-reread to shrink the
|
|
7
|
+
// race window, and a TTL frees a lock left behind by a tab that crashed or closed mid-merge.
|
|
8
|
+
|
|
9
|
+
const LOCK_TTL_MS = 30 * 1000;
|
|
10
|
+
|
|
11
|
+
function getLocalStorage(): Storage | undefined {
|
|
12
|
+
try {
|
|
13
|
+
return typeof localStorage !== "undefined" ? localStorage : undefined;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined; // accessing localStorage can throw (e.g. disabled cookies)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function lockKey(collection: string): string {
|
|
20
|
+
return "bulkDatabase2-merge:" + collection;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Returns true if we now hold the lock (or there's no localStorage to coordinate through, in which
|
|
24
|
+
// case we proceed and rely on the manifest backstop). Returns false if another tab holds a fresh lock.
|
|
25
|
+
export function tryAcquireMergeLock(collection: string, holderId: string): boolean {
|
|
26
|
+
const ls = getLocalStorage();
|
|
27
|
+
if (!ls) return true;
|
|
28
|
+
const key = lockKey(collection);
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const existing = ls.getItem(key);
|
|
31
|
+
if (existing) {
|
|
32
|
+
const t = parseInt(existing.slice(existing.lastIndexOf(":") + 1), 10);
|
|
33
|
+
if (Number.isFinite(t) && now - t < LOCK_TTL_MS) return false;
|
|
34
|
+
}
|
|
35
|
+
const token = holderId + ":" + now;
|
|
36
|
+
ls.setItem(key, token);
|
|
37
|
+
// Re-read to catch a racing setItem from another tab (best-effort, not atomic).
|
|
38
|
+
return ls.getItem(key) === token;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function releaseMergeLock(collection: string, holderId: string): void {
|
|
42
|
+
const ls = getLocalStorage();
|
|
43
|
+
if (!ls) return;
|
|
44
|
+
const key = lockKey(collection);
|
|
45
|
+
const existing = ls.getItem(key);
|
|
46
|
+
if (existing && existing.startsWith(holderId + ":")) ls.removeItem(key);
|
|
47
|
+
}
|
|
@@ -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 };
|