sliftutils 1.7.102 → 1.7.104

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.
Files changed (32) hide show
  1. package/index.d.ts +223 -7
  2. package/package.json +2 -2
  3. package/storage/IArchives.d.ts +18 -0
  4. package/storage/IArchives.ts +14 -0
  5. package/storage/StreamingLogs.d.ts +77 -0
  6. package/storage/StreamingLogs.ts +412 -0
  7. package/storage/TransactionFile.d.ts +12 -6
  8. package/storage/TransactionFile.ts +44 -12
  9. package/storage/dist/IArchives.ts.cache +2 -2
  10. package/storage/dist/StreamingLogs.ts.cache +397 -0
  11. package/storage/dist/TransactionFile.ts.cache +41 -9
  12. package/storage/remoteStorage/ArchivesRemote.ts +3 -3
  13. package/storage/remoteStorage/blobStore.d.ts +28 -0
  14. package/storage/remoteStorage/blobStore.ts +117 -11
  15. package/storage/remoteStorage/createArchives.d.ts +40 -1
  16. package/storage/remoteStorage/createArchives.ts +84 -0
  17. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  18. package/storage/remoteStorage/dist/blobStore.ts.cache +122 -12
  19. package/storage/remoteStorage/dist/createArchives.ts.cache +87 -3
  20. package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
  21. package/storage/remoteStorage/dist/storageLogs.ts.cache +109 -0
  22. package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -4
  23. package/storage/remoteStorage/dist/storeSync.ts.cache +43 -2
  24. package/storage/remoteStorage/spec.md +4 -0
  25. package/storage/remoteStorage/storageController.d.ts +10 -0
  26. package/storage/remoteStorage/storageController.ts +37 -3
  27. package/storage/remoteStorage/storageLogs.d.ts +29 -0
  28. package/storage/remoteStorage/storageLogs.ts +96 -0
  29. package/storage/remoteStorage/storageServerState.ts +16 -2
  30. package/storage/remoteStorage/storeSync.d.ts +1 -0
  31. package/storage/remoteStorage/storeSync.ts +38 -0
  32. package/yarn.lock +4 -4
@@ -0,0 +1,412 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { sort } from "socket-function/src/misc";
4
+ import { LZ4 } from "socket-function/src/lz4/LZ4";
5
+
6
+ // Generic append-only JSON-lines logging over a folder. Every process streams to its OWN file (named by pid + process start time + thread id, which together identify a process incarnation), rotating to a new file at a size limit and LZ4-compressing the finished one. Every process using the folder also MAINTAINS it on a randomized interval: dead processes' files are compressed for them, duplicate compressions are resolved, and the oldest files are deleted once the folder outgrows its byte budget. Instantiate one per folder and call log().
7
+
8
+ // Appends are coalesced for this long; a crash loses at most this much
9
+ const FLUSH_DELAY = 5000;
10
+ // A file reaching this size is rotated out and compressed
11
+ const MAX_FILE_BYTES = 100 * 1024 * 1024;
12
+ // The folder's total budget; once exceeded, the oldest files are deleted
13
+ const TOTAL_LIMIT_BYTES = 30 * 1024 * 1024 * 1024;
14
+ // Maintenance runs at a random point in this range, so the processes sharing the folder drift apart instead of stepping on each other every pass
15
+ const MAINTENANCE_MIN_INTERVAL = 30 * 60 * 1000;
16
+ const MAINTENANCE_MAX_INTERVAL = 60 * 60 * 1000;
17
+ // A pid is only the same PROCESS if its start time matches the file name's - within this much, because our own start time is derived from process.uptime and the OS's is read off /proc
18
+ const PROCESS_START_TOLERANCE = 60 * 1000;
19
+ // If flushing fails persistently (disk gone), pending entries are capped rather than growing forever
20
+ const MAX_PENDING_ENTRIES = 10000;
21
+
22
+ const UNCOMPRESSED_EXT = ".jsonl";
23
+ const COMPRESSED_EXT = ".jsonl.lz4";
24
+
25
+ /** Everything a log file's NAME says about it (plus its on-disk size). Times bound the entries roughly: from is when its process started writing it, to is when it was compressed. */
26
+ export type LogFileInfo = {
27
+ name: string;
28
+ /** Bytes on disk (compressed bytes for compressed files) */
29
+ size: number;
30
+ pid: number;
31
+ processStartTime: number;
32
+ threadId: string;
33
+ /** When the process started writing this file */
34
+ startTime: number;
35
+ /** When the file was compressed - absent while it is still being streamed to */
36
+ endTime?: number;
37
+ /** How many entries it holds - only counted at compression, so absent on live files */
38
+ entryCount?: number;
39
+ compressed: boolean;
40
+ /** Whether the writing process is still alive (always false for compressed files) */
41
+ active: boolean;
42
+ };
43
+
44
+ type ParsedName = {
45
+ pid: number;
46
+ processStartTime: number;
47
+ threadId: string;
48
+ startTime: number;
49
+ endTime?: number;
50
+ entryCount?: number;
51
+ compressed: boolean;
52
+ /** The part identifying the ORIGINAL stream (pid+born+thread+from) - what duplicate compressions share */
53
+ originKey: string;
54
+ };
55
+
56
+ function parseLogFileName(name: string): ParsedName | undefined {
57
+ let match = /^pid(\d+)_born(\d+)_thread([\w-]+?)_from(\d+)(?:_to(\d+)_count(\d+)_id([a-z0-9]+))?\.jsonl(\.lz4)?$/.exec(name);
58
+ if (!match) return undefined;
59
+ let compressed = !!match[8];
60
+ // A compressed file without its compression fields (or the reverse) is not one of ours
61
+ if (compressed !== (match[5] !== undefined)) return undefined;
62
+ return {
63
+ pid: +match[1],
64
+ processStartTime: +match[2],
65
+ threadId: match[3],
66
+ startTime: +match[4],
67
+ endTime: match[5] !== undefined && +match[5] || undefined,
68
+ entryCount: match[6] !== undefined && +match[6] || undefined,
69
+ compressed,
70
+ originKey: `pid${match[1]}_born${match[2]}_thread${match[3]}_from${match[4]}`,
71
+ };
72
+ }
73
+
74
+ /** Whether the pid is alive AND is the same process incarnation the file name described. On Linux /proc gives the real start time; elsewhere a live pid is conservatively treated as the same process (never compress under a running process). */
75
+ async function isProcessAlive(pid: number, processStartTime: number): Promise<boolean> {
76
+ try {
77
+ process.kill(pid, 0);
78
+ } catch {
79
+ return false;
80
+ }
81
+ try {
82
+ let stat = await fs.promises.stat(`/proc/${pid}`);
83
+ return Math.abs(stat.ctimeMs - processStartTime) < PROCESS_START_TOLERANCE;
84
+ } catch {
85
+ return true;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A searcher over one log file (as readFileCompressed returns it - always LZ4) that never decodes
91
+ * the whole thing: JSON escapes line breaks inside strings, so the only ACTUAL line breaks in the
92
+ * file are the separators between log statements - a plain substring search over the raw text,
93
+ * bounded out to the surrounding line breaks, finds exactly the matching statements, and only THOSE
94
+ * are decoded and returned as objects. Every search string must appear in a statement's raw JSON for
95
+ * it to match (so search for values as they are encoded - e.g. quoted).
96
+ */
97
+ export function createLogSearcher(data: Buffer): (searches: string[]) => unknown[] {
98
+ let text = LZ4.decompress(data).toString("utf8");
99
+ return searches => {
100
+ if (!searches.length) {
101
+ throw new Error(`Provide at least one search string (an empty search would just be decodeLogFile)`);
102
+ }
103
+ let entries: unknown[] = [];
104
+ let index = 0;
105
+ while (true) {
106
+ let found = text.indexOf(searches[0], index);
107
+ if (found === -1) break;
108
+ let lineStart = text.lastIndexOf("\n", found) + 1;
109
+ let lineEnd = text.indexOf("\n", found);
110
+ if (lineEnd === -1) {
111
+ lineEnd = text.length;
112
+ }
113
+ // Past this line either way, so several matches inside one statement return it once
114
+ index = lineEnd + 1;
115
+ let line = text.slice(lineStart, lineEnd);
116
+ let matchesAll = true;
117
+ for (let other of searches.slice(1)) {
118
+ if (!line.includes(other)) {
119
+ matchesAll = false;
120
+ break;
121
+ }
122
+ }
123
+ if (!matchesAll) continue;
124
+ try {
125
+ entries.push(JSON.parse(line));
126
+ } catch {
127
+ // A torn final line (the writer crashed mid-append)
128
+ continue;
129
+ }
130
+ }
131
+ return entries;
132
+ };
133
+ }
134
+
135
+ /** Decodes a log file's bytes (as readFileCompressed returns them - always LZ4) back into the logged objects. A torn final line (the writer crashed mid-append) is skipped. */
136
+ export function decodeLogFile(data: Buffer): unknown[] {
137
+ let text = LZ4.decompress(data).toString("utf8");
138
+ let entries: unknown[] = [];
139
+ for (let line of text.split("\n")) {
140
+ if (!line) continue;
141
+ try {
142
+ entries.push(JSON.parse(line));
143
+ } catch {
144
+ continue;
145
+ }
146
+ }
147
+ return entries;
148
+ }
149
+
150
+ export class StreamingLogs {
151
+ constructor(private config: {
152
+ folder: string;
153
+ /** Included in every file name - see misc/https/certs.ts getOwnThreadId. Processes without one write "none". */
154
+ threadId?: string;
155
+ maxFileBytes?: number;
156
+ totalLimitBytes?: number;
157
+ }) {
158
+ this.scheduleMaintenance();
159
+ }
160
+
161
+ private processStartTime = Math.round(Date.now() - process.uptime() * 1000);
162
+ private threadId = (this.config.threadId || "none").replace(/[^\w-]/g, "_");
163
+ private pending: string[] = [];
164
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
165
+ private maintenanceTimer: ReturnType<typeof setTimeout> | undefined;
166
+ // Serializes everything that touches the current file, so appends, rotation, and in-memory compression of the live file never interleave
167
+ private writeChain: Promise<void> = Promise.resolve();
168
+ private currentPath: string | undefined;
169
+ private currentBytes = 0;
170
+ private disposed = false;
171
+
172
+ /** Queues one entry (anything JSON-serializable). Never throws - logging must not take down the caller. */
173
+ public log(entry: unknown): void {
174
+ try {
175
+ if (this.pending.length >= MAX_PENDING_ENTRIES) {
176
+ // The disk is broken (flushes are failing) - dropping the oldest beats growing forever
177
+ this.pending.shift();
178
+ }
179
+ this.pending.push(JSON.stringify(entry));
180
+ this.scheduleFlush();
181
+ } catch (e) {
182
+ console.warn(`Could not queue a log entry (${(e as Error).message})`);
183
+ }
184
+ }
185
+
186
+ private scheduleFlush(): void {
187
+ if (this.flushTimer !== undefined || this.disposed) return;
188
+ this.flushTimer = setTimeout(() => {
189
+ this.flushTimer = undefined;
190
+ void this.flush().catch((e: Error) => console.warn(`Flushing logs in ${this.config.folder} failed: ${e.stack ?? e}`));
191
+ }, FLUSH_DELAY);
192
+ (this.flushTimer as { unref?: () => void }).unref?.();
193
+ }
194
+
195
+ public async flush(): Promise<void> {
196
+ let result = this.writeChain.then(() => this.writePending());
197
+ // The chain only serializes; a failure belongs to the caller that asked for this flush, not every one after it
198
+ this.writeChain = result.catch(() => { });
199
+ await result;
200
+ }
201
+
202
+ private async writePending(): Promise<void> {
203
+ if (!this.pending.length) return;
204
+ let lines = this.pending;
205
+ this.pending = [];
206
+ let data = lines.join("\n") + "\n";
207
+ try {
208
+ if (!this.currentPath) {
209
+ await fs.promises.mkdir(this.config.folder, { recursive: true });
210
+ this.currentPath = path.join(this.config.folder, `pid${process.pid}_born${this.processStartTime}_thread${this.threadId}_from${Date.now()}${UNCOMPRESSED_EXT}`);
211
+ this.currentBytes = 0;
212
+ }
213
+ await fs.promises.appendFile(this.currentPath, data);
214
+ this.currentBytes += Buffer.byteLength(data);
215
+ } catch (e) {
216
+ this.pending = lines.concat(this.pending);
217
+ this.scheduleFlush();
218
+ throw e;
219
+ }
220
+ if (this.currentBytes >= (this.config.maxFileBytes || MAX_FILE_BYTES)) {
221
+ let finished = this.currentPath;
222
+ this.currentPath = undefined;
223
+ this.currentBytes = 0;
224
+ void this.compressFile(finished).catch((e: Error) => {
225
+ // Left uncompressed: maintenance (ours after death, or any peer's) compresses it later
226
+ console.warn(`Compressing rotated log ${finished} failed: ${e.stack ?? e}`);
227
+ });
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Compresses one finished log file: LZ4 into a temp SUBFOLDER (never a temp name in the log
233
+ * folder itself - a crashed write must not leave a corrupt file where the maintenance scans are),
234
+ * renamed into place (same drive, so the rename is atomic), verified, and only THEN is the
235
+ * original deleted - at no point is the data in fewer than one complete file.
236
+ */
237
+ private async compressFile(filePath: string): Promise<void> {
238
+ let name = path.basename(filePath);
239
+ let parsed = parseLogFileName(name);
240
+ if (!parsed || parsed.compressed) return;
241
+ let raw = await fs.promises.readFile(filePath);
242
+ let entryCount = 0;
243
+ for (let i = 0; i < raw.length; i++) {
244
+ if (raw[i] === 10) entryCount++;
245
+ }
246
+ let compressed = LZ4.compress(raw);
247
+ let id = Math.random().toString(36).slice(2, 10);
248
+ let finalName = `${parsed.originKey}_to${Date.now()}_count${entryCount}_id${id}${COMPRESSED_EXT}`;
249
+ let tempDir = path.join(this.config.folder, ".tmp");
250
+ await fs.promises.mkdir(tempDir, { recursive: true });
251
+ let tempPath = path.join(tempDir, finalName);
252
+ let finalPath = path.join(this.config.folder, finalName);
253
+ await fs.promises.writeFile(tempPath, compressed);
254
+ await fs.promises.rename(tempPath, finalPath);
255
+ // Paranoia before deleting the only other copy
256
+ await fs.promises.stat(finalPath);
257
+ await fs.promises.unlink(filePath);
258
+ console.log(`Compressed log ${name} -> ${finalName} (${raw.length} -> ${compressed.length} bytes, ${entryCount} entries)`);
259
+ }
260
+
261
+ // ── listing / reading ──
262
+
263
+ public async listFiles(): Promise<LogFileInfo[]> {
264
+ let names: string[];
265
+ try {
266
+ names = await fs.promises.readdir(this.config.folder);
267
+ } catch (e) {
268
+ if ((e as { code?: string }).code === "ENOENT") return [];
269
+ throw e;
270
+ }
271
+ let files: LogFileInfo[] = [];
272
+ for (let name of names) {
273
+ let parsed = parseLogFileName(name);
274
+ if (!parsed) continue;
275
+ let stat = await fs.promises.stat(path.join(this.config.folder, name)).catch(() => undefined);
276
+ if (!stat) continue;
277
+ let active = !parsed.compressed && await isProcessAlive(parsed.pid, parsed.processStartTime);
278
+ files.push({
279
+ name,
280
+ size: stat.size,
281
+ pid: parsed.pid,
282
+ processStartTime: parsed.processStartTime,
283
+ threadId: parsed.threadId,
284
+ startTime: parsed.startTime,
285
+ endTime: parsed.endTime,
286
+ entryCount: parsed.entryCount,
287
+ compressed: parsed.compressed,
288
+ active,
289
+ });
290
+ }
291
+ sort(files, x => x.startTime);
292
+ return files;
293
+ }
294
+
295
+ /** One file's bytes, ALWAYS LZ4-compressed: compressed files are sent as-is, a still-streaming file is flushed and compressed in memory (smaller over the network; decodeLogFile handles both identically). */
296
+ public async readFileCompressed(name: string): Promise<Buffer> {
297
+ let parsed = parseLogFileName(name);
298
+ if (!parsed || name.includes("/") || name.includes("\\")) {
299
+ throw new Error(`Not a log file name: ${JSON.stringify(name.slice(0, 200))}`);
300
+ }
301
+ let filePath = path.join(this.config.folder, name);
302
+ if (parsed.compressed) {
303
+ return await fs.promises.readFile(filePath);
304
+ }
305
+ // Flush so our own live file includes everything logged up to now
306
+ await this.flush();
307
+ return LZ4.compress(await fs.promises.readFile(filePath));
308
+ }
309
+
310
+ // ── maintenance ──
311
+
312
+ private scheduleMaintenance(): void {
313
+ if (this.disposed) return;
314
+ let wait = MAINTENANCE_MIN_INTERVAL + Math.random() * (MAINTENANCE_MAX_INTERVAL - MAINTENANCE_MIN_INTERVAL);
315
+ this.maintenanceTimer = setTimeout(() => {
316
+ void this.runMaintenance().catch((e: Error) => console.warn(`Log maintenance of ${this.config.folder} failed: ${e.stack ?? e}`)).finally(() => this.scheduleMaintenance());
317
+ }, wait);
318
+ (this.maintenanceTimer as { unref?: () => void }).unref?.();
319
+ }
320
+
321
+ /**
322
+ * Shared upkeep of the folder - every process using it runs this, so nothing depends on any one
323
+ * process surviving: dead processes' uncompressed files are compressed for them; duplicate
324
+ * compressions of one stream (two maintainers racing) are resolved by keeping the OLDEST copy;
325
+ * an uncompressed original whose compression already exists is deleted (its compressor died
326
+ * between rename and unlink); and the folder's total size is brought under its budget by
327
+ * deleting the oldest files.
328
+ */
329
+ public async runMaintenance(): Promise<void> {
330
+ let names: string[];
331
+ try {
332
+ names = await fs.promises.readdir(this.config.folder);
333
+ } catch (e) {
334
+ if ((e as { code?: string }).code === "ENOENT") return;
335
+ throw e;
336
+ }
337
+ let parsedByName = new Map<string, ParsedName>();
338
+ for (let name of names) {
339
+ let parsed = parseLogFileName(name);
340
+ if (parsed) parsedByName.set(name, parsed);
341
+ }
342
+ let compressedOrigins = new Map<string, string[]>();
343
+ for (let [name, parsed] of parsedByName) {
344
+ if (!parsed.compressed) continue;
345
+ let list = compressedOrigins.get(parsed.originKey);
346
+ if (!list) {
347
+ list = [];
348
+ compressedOrigins.set(parsed.originKey, list);
349
+ }
350
+ list.push(name);
351
+ }
352
+ // Duplicate compressions: keep the oldest (first written), delete the rest
353
+ for (let [origin, list] of compressedOrigins) {
354
+ if (list.length < 2) continue;
355
+ sort(list, name => parsedByName.get(name)?.endTime || 0);
356
+ for (let duplicate of list.slice(1)) {
357
+ console.log(`Deleting duplicate compressed log ${duplicate} (another maintainer compressed ${origin} first, keeping ${list[0]})`);
358
+ await fs.promises.unlink(path.join(this.config.folder, duplicate)).catch(() => { });
359
+ parsedByName.delete(duplicate);
360
+ }
361
+ }
362
+ for (let [name, parsed] of parsedByName) {
363
+ if (parsed.compressed) continue;
364
+ let filePath = path.join(this.config.folder, name);
365
+ if (filePath === this.currentPath) continue;
366
+ if (compressedOrigins.has(parsed.originKey)) {
367
+ // Already compressed by someone whose cleanup died between rename and unlink
368
+ console.log(`Deleting uncompressed log ${name}: its compressed version already exists`);
369
+ await fs.promises.unlink(filePath).catch(() => { });
370
+ continue;
371
+ }
372
+ // A live process owns its uncompressed file; only a DEAD process's files are compressed for it (our own pid always reads as alive, which correctly protects our current file - our rotated leftovers were excluded above only if current, so compress those too)
373
+ if (parsed.pid !== process.pid && await isProcessAlive(parsed.pid, parsed.processStartTime)) continue;
374
+ try {
375
+ await this.compressFile(filePath);
376
+ } catch (e) {
377
+ console.warn(`Compressing dead process's log ${name} failed (the next maintenance pass retries): ${(e as Error).stack ?? e}`);
378
+ }
379
+ }
380
+ await this.enforceTotalLimit();
381
+ }
382
+
383
+ private async enforceTotalLimit(): Promise<void> {
384
+ let limit = this.config.totalLimitBytes || TOTAL_LIMIT_BYTES;
385
+ let files = await this.listFiles();
386
+ let total = files.reduce((sum, x) => sum + x.size, 0);
387
+ if (total <= limit) return;
388
+ // Oldest first, by the end of what they cover; live files last (they are the newest data by definition)
389
+ let deletable = files.filter(x => !x.active);
390
+ sort(deletable, x => x.endTime || x.startTime);
391
+ let deleted = 0;
392
+ let deletedBytes = 0;
393
+ for (let file of deletable) {
394
+ if (total <= limit) break;
395
+ await fs.promises.unlink(path.join(this.config.folder, file.name)).catch(() => { });
396
+ total -= file.size;
397
+ deleted++;
398
+ deletedBytes += file.size;
399
+ }
400
+ console.log(`Logs in ${this.config.folder} outgrew their ${limit} byte budget: deleted the ${deleted} oldest files (${deletedBytes} bytes), ${total} bytes remain`);
401
+ }
402
+
403
+ public dispose(): void {
404
+ this.disposed = true;
405
+ if (this.flushTimer) {
406
+ clearTimeout(this.flushTimer);
407
+ }
408
+ if (this.maintenanceTimer) {
409
+ clearTimeout(this.maintenanceTimer);
410
+ }
411
+ }
412
+ }
@@ -4,10 +4,12 @@ export type LogEntry<T> = {
4
4
  time: number;
5
5
  changedAt: number;
6
6
  };
7
- /** A deleted key: when it was deleted, and when we learned. The value is gone; the time is the point of a tombstone. */
8
- export type LogTombstone = {
7
+ /** A deleted key: when it was deleted, and when we learned. When the key had a live value at deletion time it is MARKED rather than gone - the value (and its original write time) rides along, so the underlying data can still be read and the deletion can be undone (see unmark) until the history is dropped (see dropValue). */
8
+ export type LogTombstone<T> = {
9
9
  time: number;
10
10
  changedAt: number;
11
+ value?: T;
12
+ valueTime?: number;
11
13
  };
12
14
  export declare class TransactionFile<T> {
13
15
  private filePath;
@@ -26,8 +28,8 @@ export declare class TransactionFile<T> {
26
28
  };
27
29
  /** The live value, or undefined when the key does not exist here (deleted included - a deletion is an absence, see getDeleted for its time). */
28
30
  get(key: string): LogEntry<T> | undefined;
29
- /** When the key was deleted, if it was. Absent both here and in get means we have never heard of it. */
30
- getDeleted(key: string): LogTombstone | undefined;
31
+ /** When the key was deleted, if it was (value included when the deletion is still marked - see LogTombstone). Absent both here and in get means we have never heard of it. */
32
+ getDeleted(key: string): LogTombstone<T> | undefined;
31
33
  /** The time the key last changed either way, or 0 if we have never heard of it - what a new write has to beat. */
32
34
  timeOf(key: string): number;
33
35
  /** O(1), and counts only what exists. */
@@ -36,11 +38,15 @@ export declare class TransactionFile<T> {
36
38
  /** Live values only. Live, in insertion order - deleting during iteration is safe (JS skips entries removed before they are reached), which is what the passes that walk everything and prune as they go rely on. */
37
39
  entries(): IterableIterator<[string, LogEntry<T>]>;
38
40
  /** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
39
- deletedEntries(): IterableIterator<[string, LogTombstone]>;
41
+ deletedEntries(): IterableIterator<[string, LogTombstone<T>]>;
40
42
  /** Stores a value as of `time`. Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
41
43
  set(key: string, value: T, time: number): boolean;
42
- /** Deletes as of `time`, keeping the tombstone. Returns false when something at least as new is already here. */
44
+ /** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
43
45
  delete(key: string, time: number): boolean;
46
+ /** Undoes a marked deletion: the kept value becomes live again, as of `time` (a fresh time, so the restore outranks the deletion everywhere it propagated). Returns false when there is no marked value to restore, or something at least as new is already here. */
47
+ unmark(key: string, time: number): boolean;
48
+ /** Drops a marked deletion's kept value (its history has been physically removed), leaving a plain tombstone with the same delete time. */
49
+ dropValue(key: string): void;
44
50
  /** Forgets the key entirely, tombstone included - for a tombstone old enough that nobody needs to hear about the deletion any more, and for an entry that turned out never to have existed. Not a deletion: it leaves nothing behind to propagate. */
45
51
  purge(key: string): void;
46
52
  private applySet;
@@ -15,8 +15,8 @@ const FLUSH_DELAY = 500;
15
15
 
16
16
  /** A live value: what was stored, when it was written (the caller's ordering), and when we last changed it (ours). */
17
17
  export type LogEntry<T> = { value: T; time: number; changedAt: number };
18
- /** A deleted key: when it was deleted, and when we learned. The value is gone; the time is the point of a tombstone. */
19
- export type LogTombstone = { time: number; changedAt: number };
18
+ /** A deleted key: when it was deleted, and when we learned. When the key had a live value at deletion time it is MARKED rather than gone - the value (and its original write time) rides along, so the underlying data can still be read and the deletion can be undone (see unmark) until the history is dropped (see dropValue). */
19
+ export type LogTombstone<T> = { time: number; changedAt: number; value?: T; valueTime?: number };
20
20
 
21
21
  // One line of the log, with single-letter field names because there is one record per set/delete/purge ever made and the field names are most of a record's overhead. JSON.stringify escapes newlines inside keys and values, so a record can never contain the separator.
22
22
  type LogRecord<T> = {
@@ -28,6 +28,10 @@ type LogRecord<T> = {
28
28
  v?: T;
29
29
  // deleted, the file on disk is deleted, preserved when we compact
30
30
  d?: 1;
31
+ // marked for deletion: deleted, but the value (v) and its original write time (w) are kept, so the data is still readable and restorable. Preserved when we compact.
32
+ m?: 1;
33
+ // writeTime, the original write time of a marked-for-deletion value (t is the DELETE time on those records)
34
+ w?: number;
31
35
  // purged, the file on disk is deleted, not preserved when we compact
32
36
  p?: 1;
33
37
  };
@@ -36,7 +40,7 @@ export class TransactionFile<T> {
36
40
  constructor(private filePath: string) { }
37
41
 
38
42
  private values = new Map<string, LogEntry<T>>();
39
- private deleted = new Map<string, LogTombstone>();
43
+ private deleted = new Map<string, LogTombstone<T>>();
40
44
  // Records the log holds (written plus pending) - what compaction is decided against, NOT the number of keys
41
45
  private logRecords = 0;
42
46
  private pending: string[] = [];
@@ -70,6 +74,8 @@ export class TransactionFile<T> {
70
74
  if (record.p) {
71
75
  this.values.delete(record.k);
72
76
  this.deleted.delete(record.k);
77
+ } else if (record.m) {
78
+ this.applyDelete(record.k, record.t, record.t, record.v, record.w);
73
79
  } else if (record.d) {
74
80
  this.applyDelete(record.k, record.t, record.t);
75
81
  } else if (record.v !== undefined) {
@@ -86,8 +92,8 @@ export class TransactionFile<T> {
86
92
  public get(key: string): LogEntry<T> | undefined {
87
93
  return this.values.get(key);
88
94
  }
89
- /** When the key was deleted, if it was. Absent both here and in get means we have never heard of it. */
90
- public getDeleted(key: string): LogTombstone | undefined {
95
+ /** When the key was deleted, if it was (value included when the deletion is still marked - see LogTombstone). Absent both here and in get means we have never heard of it. */
96
+ public getDeleted(key: string): LogTombstone<T> | undefined {
91
97
  return this.deleted.get(key);
92
98
  }
93
99
  /** The time the key last changed either way, or 0 if we have never heard of it - what a new write has to beat. */
@@ -109,7 +115,7 @@ export class TransactionFile<T> {
109
115
  return this.values.entries();
110
116
  }
111
117
  /** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
112
- public deletedEntries(): IterableIterator<[string, LogTombstone]> {
118
+ public deletedEntries(): IterableIterator<[string, LogTombstone<T>]> {
113
119
  return this.deleted.entries();
114
120
  }
115
121
 
@@ -120,13 +126,35 @@ export class TransactionFile<T> {
120
126
  return true;
121
127
  }
122
128
 
123
- /** Deletes as of `time`, keeping the tombstone. Returns false when something at least as new is already here. */
129
+ /** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
124
130
  public delete(key: string, time: number): boolean {
125
- if (!this.applyDelete(key, time, Date.now())) return false;
126
- this.append({ k: key, t: time, d: 1 });
131
+ let live = this.values.get(key);
132
+ if (!this.applyDelete(key, time, Date.now(), live?.value, live?.time)) return false;
133
+ if (live) {
134
+ this.append({ k: key, t: time, m: 1, v: live.value, w: live.time });
135
+ } else {
136
+ this.append({ k: key, t: time, d: 1 });
137
+ }
138
+ return true;
139
+ }
140
+
141
+ /** Undoes a marked deletion: the kept value becomes live again, as of `time` (a fresh time, so the restore outranks the deletion everywhere it propagated). Returns false when there is no marked value to restore, or something at least as new is already here. */
142
+ public unmark(key: string, time: number): boolean {
143
+ let tombstone = this.deleted.get(key);
144
+ if (!tombstone || tombstone.value === undefined) return false;
145
+ if (!this.applySet(key, tombstone.value, time, Date.now())) return false;
146
+ this.append({ k: key, t: time, v: tombstone.value });
127
147
  return true;
128
148
  }
129
149
 
150
+ /** Drops a marked deletion's kept value (its history has been physically removed), leaving a plain tombstone with the same delete time. */
151
+ public dropValue(key: string): void {
152
+ let tombstone = this.deleted.get(key);
153
+ if (!tombstone || tombstone.value === undefined) return;
154
+ this.deleted.set(key, { time: tombstone.time, changedAt: Date.now() });
155
+ this.append({ k: key, t: tombstone.time, d: 1 });
156
+ }
157
+
130
158
  /** Forgets the key entirely, tombstone included - for a tombstone old enough that nobody needs to hear about the deletion any more, and for an entry that turned out never to have existed. Not a deletion: it leaves nothing behind to propagate. */
131
159
  public purge(key: string): void {
132
160
  let had = this.values.delete(key);
@@ -142,10 +170,10 @@ export class TransactionFile<T> {
142
170
  return true;
143
171
  }
144
172
 
145
- private applyDelete(key: string, time: number, changedAt: number): boolean {
173
+ private applyDelete(key: string, time: number, changedAt: number, value?: T, valueTime?: number): boolean {
146
174
  if (time < this.timeOf(key)) return false;
147
175
  this.values.delete(key);
148
- this.deleted.set(key, { time, changedAt });
176
+ this.deleted.set(key, { time, changedAt, value, valueTime });
149
177
  return true;
150
178
  }
151
179
 
@@ -206,7 +234,11 @@ export class TransactionFile<T> {
206
234
  records.push(JSON.stringify({ k: key, t: entry.time, v: entry.value } satisfies LogRecord<T>));
207
235
  }
208
236
  for (let [key, tombstone] of this.deleted) {
209
- records.push(JSON.stringify({ k: key, t: tombstone.time, d: 1 } satisfies LogRecord<T>));
237
+ if (tombstone.value !== undefined) {
238
+ records.push(JSON.stringify({ k: key, t: tombstone.time, m: 1, v: tombstone.value, w: tombstone.valueTime } satisfies LogRecord<T>));
239
+ } else {
240
+ records.push(JSON.stringify({ k: key, t: tombstone.time, d: 1 } satisfies LogRecord<T>));
241
+ }
210
242
  }
211
243
  // Cleared with the snapshot: every pending record is already reflected in the maps, so the snapshot contains it. Anything appended from here on is NOT in the snapshot, and lands in the new log after the rename.
212
244
  this.pending = [];