sliftutils 1.3.1 → 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 +48 -38
- package/package.json +1 -1
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +13 -0
- package/storage/BulkDatabase2/BulkDatabase2.ts +12 -0
- package/storage/BulkDatabase2/BulkDatabaseBase.d.ts +17 -15
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +379 -326
- package/storage/BulkDatabase2/BulkDatabaseFormat.d.ts +16 -1
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +65 -20
- package/storage/BulkDatabase2/mergeLock.ts +6 -5
- package/storage/BulkDatabase2/syncClient.d.ts +2 -1
- package/storage/BulkDatabase2/syncClient.ts +19 -2
- package/storage/FileFolderAPI.tsx +6 -1
- package/storage/BulkDatabase2/manifest.d.ts +0 -17
- package/storage/BulkDatabase2/manifest.ts +0 -59
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
export declare const KEY_COLUMN = "key";
|
|
4
4
|
export declare const EMPTY_BUFFER: Buffer;
|
|
5
5
|
export declare const ABSENT: unique symbol;
|
|
6
|
-
export declare
|
|
6
|
+
export declare const TARGET_FILE_BYTES: number;
|
|
7
|
+
export declare function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes?: number): Buffer[];
|
|
7
8
|
export type BaseBulkDatabaseReader = {
|
|
8
9
|
rowCount: number;
|
|
9
10
|
totalBytes: number;
|
|
10
11
|
minTime: number;
|
|
11
12
|
maxTime: number;
|
|
13
|
+
minKey?: string;
|
|
14
|
+
maxKey?: string;
|
|
12
15
|
keys: string[];
|
|
13
16
|
columns: {
|
|
14
17
|
column: string;
|
|
@@ -26,6 +29,18 @@ export type BaseBulkDatabaseReader = {
|
|
|
26
29
|
time: number;
|
|
27
30
|
} | typeof ABSENT>;
|
|
28
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
|
+
}[];
|
|
42
|
+
};
|
|
43
|
+
export declare function loadBulkHeader(getRange: (start: number, end: number) => Promise<Buffer>, totalBytes: number): Promise<BulkHeaderInfo>;
|
|
29
44
|
export declare function loadBulkDatabase(config: {
|
|
30
45
|
totalBytes: number;
|
|
31
46
|
getRange: (start: number, end: number) => Promise<Buffer>;
|
|
@@ -51,10 +51,15 @@ type FileHeader = {
|
|
|
51
51
|
rowCount: number;
|
|
52
52
|
columns: { name: string; offset: number; length: number }[];
|
|
53
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
|
|
55
|
-
//
|
|
54
|
+
// carried through merges). Lets the merge planner pick files overlapping a time range. Absent (0)
|
|
55
|
+
// in files written before this existed.
|
|
56
56
|
minTime?: number;
|
|
57
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;
|
|
58
63
|
};
|
|
59
64
|
|
|
60
65
|
function encodeValue(value: unknown): { type: number; bytes: Buffer } {
|
|
@@ -155,12 +160,12 @@ function decodeBulkData(blob: Buffer, rowCount: number): unknown[] {
|
|
|
155
160
|
return values;
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
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;
|
|
164
169
|
|
|
165
170
|
// Per-value on-disk overhead: a 4-byte offset entry plus a 1-byte type tag.
|
|
166
171
|
const PER_VALUE_OVERHEAD = 5;
|
|
@@ -207,42 +212,61 @@ function buildOneFile(rows: Record<string, unknown>[], times: number[]): Buffer
|
|
|
207
212
|
let minTime = times.length ? times[0] : 0;
|
|
208
213
|
let maxTime = minTime;
|
|
209
214
|
for (const t of times) { if (t < minTime) minTime = t; if (t > maxTime) maxTime = t; }
|
|
210
|
-
|
|
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 };
|
|
211
223
|
const headerBuf = Buffer.from(JSON.stringify(header), "utf8");
|
|
212
224
|
const lengthPrefix = Buffer.alloc(4);
|
|
213
225
|
lengthPrefix.writeUInt32LE(headerBuf.length, 0);
|
|
214
226
|
return Buffer.concat([lengthPrefix, headerBuf, ...blobs]);
|
|
215
227
|
}
|
|
216
228
|
|
|
217
|
-
// Returns one complete, independent file buffer per chunk of rows.
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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.
|
|
221
234
|
// `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[] {
|
|
235
|
+
export function buildFileBuffer(rows: Record<string, unknown>[], times: number[], targetBytes = TARGET_FILE_BYTES): Buffer[] {
|
|
223
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]);
|
|
224
245
|
const result: Buffer[] = [];
|
|
225
246
|
let chunkStart = 0;
|
|
226
247
|
let chunkBytes = 0;
|
|
227
|
-
for (let i = 0; i <
|
|
228
|
-
const rowBytes = estimateRowBytes(
|
|
229
|
-
if (i > chunkStart && chunkBytes + rowBytes >
|
|
230
|
-
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)));
|
|
231
252
|
chunkStart = i;
|
|
232
253
|
chunkBytes = 0;
|
|
233
254
|
}
|
|
234
255
|
chunkBytes += rowBytes;
|
|
235
256
|
}
|
|
236
|
-
result.push(buildOneFile(
|
|
257
|
+
result.push(buildOneFile(sortedRows.slice(chunkStart), sortedTimes.slice(chunkStart)));
|
|
237
258
|
return result;
|
|
238
259
|
}
|
|
239
260
|
|
|
240
261
|
export type BaseBulkDatabaseReader = {
|
|
241
262
|
rowCount: number;
|
|
242
263
|
totalBytes: number;
|
|
243
|
-
// Write-time bounds of this reader's data (0 if unknown — old bulk files).
|
|
264
|
+
// Write-time bounds of this reader's data (0 if unknown — old bulk files).
|
|
244
265
|
minTime: number;
|
|
245
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;
|
|
246
270
|
// Keys is special, it's always automatically decoded, even though it is stored as a normal column
|
|
247
271
|
keys: string[];
|
|
248
272
|
columns: { column: string; byteSize: number }[];
|
|
@@ -259,6 +283,25 @@ export type BaseBulkDatabaseReader = {
|
|
|
259
283
|
getSingleField: (key: string, column: string) => Promise<{ value: unknown; time: number } | typeof ABSENT>;
|
|
260
284
|
};
|
|
261
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
|
+
|
|
262
305
|
export async function loadBulkDatabase(config: {
|
|
263
306
|
totalBytes: number;
|
|
264
307
|
getRange: (start: number, end: number) => Promise<Buffer>;
|
|
@@ -300,6 +343,8 @@ export async function loadBulkDatabase(config: {
|
|
|
300
343
|
totalBytes: config.totalBytes,
|
|
301
344
|
minTime: header.minTime || 0,
|
|
302
345
|
maxTime: header.maxTime || 0,
|
|
346
|
+
minKey: header.minKey,
|
|
347
|
+
maxKey: header.maxKey,
|
|
303
348
|
keys,
|
|
304
349
|
keyTimes: new Map(keys.map((key, i) => [key, times[i]])),
|
|
305
350
|
columns: header.columns.filter(c => c.name !== TIME_COLUMN).map(c => ({ column: c.name, byteSize: c.length })),
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Best-effort "only one tab merges this collection at a time" guard
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
6
7
|
// tests exercise. localStorage has no atomic compare-and-swap, so we write-then-reread to shrink the
|
|
7
8
|
// race window, and a TTL frees a lock left behind by a tab that crashed or closed mid-merge.
|
|
8
9
|
|
|
@@ -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);
|
|
@@ -1,17 +0,0 @@
|
|
|
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;
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|