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.
@@ -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 function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
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
- deletedKeys?: Set<string>;
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<unknown | undefined>;
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
- // Past this estimated logical size a single rows array is split across multiple files, so no one
138
- // file (and therefore no single column blob, and no single Buffer.concat) ever approaches the
139
- // ~2GB Buffer length limit. The cap is deliberately the same as the merge cap: 800MB is the most
140
- // we'll ever hold for one file. The estimate is rough on purpose — it only needs to keep each
141
- // chunk comfortably under the limit, not be exact.
142
- const FILE_SPLIT_BYTES = 800 * 1024 * 1024;
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
- const blobs = columnNames.map(col => encodeBulkData(rows.map(row => row[col])));
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
- const header: FileHeader = { rowCount: rows.length, columns };
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. When the caller hands us more
190
- // rows than fit comfortably in one file we partition by row range — each returned buffer is exactly
191
- // what buildOneFile would produce if called with that subset, so the chunks have disjoint keys and
192
- // the caller just writes each as its own file. A normal-sized write returns a single buffer.
193
- export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[] {
194
- if (rows.length === 0) return [buildOneFile([])];
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 < rows.length; i++) {
199
- const rowBytes = estimateRowBytes(rows[i]);
200
- if (i > chunkStart && chunkBytes + rowBytes > FILE_SPLIT_BYTES) {
201
- result.push(buildOneFile(rows.slice(chunkStart, i)));
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(rows.slice(chunkStart)));
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
- // Keys this reader tombstones (deleted). A newer reader's deletion suppresses the key in all
218
- // older readers. Bulk readers never set this; the tier-0 stream reader does.
219
- deletedKeys?: Set<string>;
220
- getColumn: (column: string) => Promise<{
221
- key: string;
222
- value: unknown;
223
- }[]>;
224
- getSingleField: (key: string, column: string) => Promise<unknown | undefined>;
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
- columns: header.columns.map(c => ({ column: c.name, byteSize: c.length })),
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 undefined;
357
+ if (row === undefined) return ABSENT;
269
358
  const col = colByName.get(column);
270
- if (!col) return undefined;
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
- return decodeValue(typeBuf[0], bytes);
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,2 @@
1
+ export declare function tryAcquireMergeLock(collection: string, holderId: string): boolean;
2
+ export declare function releaseMergeLock(collection: string, holderId: string): void;
@@ -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. Applies
68
- // set/delete newest-wins: later entries overwrite earlier ones, and a delete tombstones the key
69
- // (exposed via deletedKeys so the join suppresses it in older bulk readers). Also returns the latest
70
- // timestamp seen per key (live or deleted), used for cross-tab conflict resolution.
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
- deletedKeys,
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
- return row && row[column];
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
- 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);