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.
@@ -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 function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[];
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
- deletedKeys?: Set<string>;
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<unknown | undefined>;
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
- const blobs = columnNames.map(col => encodeBulkData(rows.map(row => row[col])));
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
- const header: FileHeader = { rowCount: rows.length, columns };
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
- export function buildFileBuffer(rows: Record<string, unknown>[]): Buffer[] {
194
- if (rows.length === 0) return [buildOneFile([])];
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
- // 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>;
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
- columns: header.columns.map(c => ({ column: c.name, byteSize: c.length })),
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 undefined;
312
+ if (row === undefined) return ABSENT;
269
313
  const col = colByName.get(column);
270
- if (!col) return undefined;
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
- return decodeValue(typeBuf[0], bytes);
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,2 @@
1
+ export declare function tryAcquireMergeLock(collection: string, holderId: string): boolean;
2
+ export declare function releaseMergeLock(collection: string, holderId: string): void;
@@ -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. 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 };