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.
@@ -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 function buildFileBuffer(rows: Record<string, unknown>[], times: number[]): Buffer[];
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 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.
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
- // Past this estimated logical size a single rows array is split across multiple files, so no one
159
- // file (and therefore no single column blob, and no single Buffer.concat) ever approaches the
160
- // ~2GB Buffer length limit. The cap is deliberately the same as the merge cap: 800MB is the most
161
- // we'll ever hold for one file. The estimate is rough on purpose — it only needs to keep each
162
- // chunk comfortably under the limit, not be exact.
163
- const FILE_SPLIT_BYTES = 800 * 1024 * 1024;
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
- const header: FileHeader = { rowCount: rows.length, columns, minTime, maxTime };
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. When the caller hands us more
218
- // rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
219
- // what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
220
- // the caller just writes each as its own file. A normal-sized write returns a single buffer.
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 < rows.length; i++) {
228
- const rowBytes = estimateRowBytes(rows[i]);
229
- if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
230
- result.push(buildOneFile(rows.slice(chunkStart, i), times.slice(chunkStart, i)));
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(rows.slice(chunkStart), times.slice(chunkStart)));
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). Diagnostics only now.
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, 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
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
- throw new Error(`File not found: ${filePath}`);
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
- }