sliftutils 1.7.101 → 1.7.103

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 (29) hide show
  1. package/index.d.ts +227 -7
  2. package/package.json +1 -1
  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/TransactionFile.ts.cache +41 -9
  11. package/storage/remoteStorage/ArchivesRemote.ts +3 -3
  12. package/storage/remoteStorage/blobStore.d.ts +32 -0
  13. package/storage/remoteStorage/blobStore.ts +124 -11
  14. package/storage/remoteStorage/createArchives.d.ts +40 -1
  15. package/storage/remoteStorage/createArchives.ts +84 -0
  16. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  17. package/storage/remoteStorage/dist/blobStore.ts.cache +127 -12
  18. package/storage/remoteStorage/dist/createArchives.ts.cache +52 -3
  19. package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
  20. package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -3
  21. package/storage/remoteStorage/dist/storeSync.ts.cache +47 -2
  22. package/storage/remoteStorage/spec.md +4 -0
  23. package/storage/remoteStorage/storageController.d.ts +10 -0
  24. package/storage/remoteStorage/storageController.ts +37 -3
  25. package/storage/remoteStorage/storageLogs.d.ts +29 -0
  26. package/storage/remoteStorage/storageLogs.ts +96 -0
  27. package/storage/remoteStorage/storageServerState.ts +17 -2
  28. package/storage/remoteStorage/storeSync.d.ts +1 -0
  29. package/storage/remoteStorage/storeSync.ts +42 -0
package/index.d.ts CHANGED
@@ -2170,12 +2170,18 @@ declare module "sliftutils/storage/IArchives" {
2170
2170
  internal?: boolean;
2171
2171
  /** Also return size-0 results (tombstones - an empty file IS a missing file) instead of treating them as absent. Off by default, matching getInfo's flag of the same name. Synchronization passes this so a DELETED file (with its write time) is distinguishable from a file that never existed. */
2172
2172
  includeTombstones?: boolean;
2173
+ /** Reads files that are MARKED for deletion (deleted, but with their bytes still in the deletion history - see SetConfig.undelete for restoring them). The actual content comes back, unlike includeTombstones, which only reports that a deletion happened. */
2174
+ includeMarked?: boolean;
2175
+ /** Read from EXACTLY this source - its config url, as ArchivesChain.getFileSources lists them - with no fallback to any other. For comparing the copies different sources hold (combine with internal to read only that server's own disk, skipping its holder resolution). Multi-source archives only; throws when no configured source has the url. */
2176
+ sourceUrl?: string;
2173
2177
  /** How many extra times the WHOLE operation is retried after every source in a pass failed - any error counts (the wrong-window/route markers still get their config re-resolve first). Only applies to fallback dispatch (multi-source, not noFallbacks), where it defaults to 3; the noFallbacks/write-node path already retries on its own deadline. Multi-part uploads additionally retry per part regardless of this. */
2174
2178
  retries?: number;
2175
2179
  };
2176
2180
  export type FindConfig = {
2177
2181
  shallow?: boolean;
2178
2182
  type?: "files" | "folders";
2183
+ /** Also list files MARKED for deletion (see GetConfig.includeMarked). */
2184
+ includeMarked?: boolean;
2179
2185
  /** Listings normally come ONLY from the authoritative sources (the same nodes writes go to - read-your-writes). With fallbacks, a failing shard's routes are covered by the next source holding them (e.g. a wide read replica) instead of the call failing - high availability at the cost of possibly missing just-written data. Single-source archives ignore the flag. */
2180
2186
  fallbacks?: boolean;
2181
2187
  };
@@ -2198,6 +2204,8 @@ declare module "sliftutils/storage/IArchives" {
2198
2204
  noFallbacks?: boolean;
2199
2205
  /** See GetConfig.retries. */
2200
2206
  retries?: number;
2207
+ /** See GetConfig.sourceUrl: answer from EXACTLY this source. */
2208
+ sourceUrl?: string;
2201
2209
  };
2202
2210
  export type ChangesAfterConfig = {
2203
2211
  time: number;
@@ -2216,6 +2224,8 @@ declare module "sliftutils/storage/IArchives" {
2216
2224
  fallbacks?: boolean;
2217
2225
  /** See GetConfig.retries. */
2218
2226
  retries?: number;
2227
+ /** The set is not a write at all: it RESTORES a file marked for deletion, flipping its index entry back to live (with a fresh write time, so the restore outranks the deletion everywhere it propagated) - the bytes never left the disk, so reads just work again. The data buffer is ignored (a 1-byte placeholder satisfies the empty-buffer rule); use IArchives.undelete rather than passing this yourself. Throws when the key has no marked deletion to restore (its history was dropped, or it was never deleted). */
2228
+ undelete?: boolean;
2219
2229
  };
2220
2230
  /** setLargeFile's config: a SetConfig (it IS a set - the same immutability, ordering, internal, and fallbacks rules apply) plus the stream carrying the bytes. */
2221
2231
  export type SetLargeFileConfig = SetConfig & {
@@ -2245,6 +2255,12 @@ declare module "sliftutils/storage/IArchives" {
2245
2255
  fileCount: number;
2246
2256
  byteCount: number;
2247
2257
  };
2258
+ /** Files MARKED for deletion (deleted, bytes still kept as history - see SetConfig.undelete): how many, how big, and the delete time of the oldest one - which is how far back the deletion history reaches. */
2259
+ markedIndex?: {
2260
+ fileCount: number;
2261
+ byteCount: number;
2262
+ oldestDeleteTime?: number;
2263
+ };
2248
2264
  indexSources?: {
2249
2265
  debugName: string;
2250
2266
  fileCount: number;
@@ -2339,6 +2355,8 @@ declare module "sliftutils/storage/IArchives" {
2339
2355
  del(fileName: string, config?: DelConfig): Promise<void>;
2340
2356
  /** Moves a file to a new path within THIS archives, backend-side where the backend can (backblaze copies server-side, disk renames, the storage server relocates node-side) - the bytes never travel through the caller. The destination is stamped with a FRESH write time, even when the underlying operation (a rename) would preserve the old one, so the moved file cannot immediately lose to something newer sitting at its new path (e.g. the tombstone of an earlier deletion there); the source is then deleted, exactly like del. THROWS when the source file does not exist. Optional - callers go through moveArchiveFile (archiveHelpers.ts), which falls back to copy + confirm + delete. */
2341
2357
  move?(config: MoveFileConfig): Promise<void>;
2358
+ /** Restores a deleted file whose bytes are still in the deletion history (see SetConfig.undelete, which this rides on). Only index-backed stores keep a deletion history, so only they support this. THROWS when there is nothing to restore. */
2359
+ undelete?(fileName: string): Promise<void>;
2342
2360
  /** Streams a file too large to hold in memory. getNextData returns undefined when done. This only needs to be called when you CANNOT materialize the entire file in memory - if you can, just call set: above LARGE_SET_THRESHOLD it streams through setLargeFile internally, keeping the client responsive and not overwhelming the server. The rest of the config is a plain SetConfig and means exactly what it means on set (that is what makes a large set behave like a small one instead of quietly losing immutability, ordering, internal, or fallbacks semantics as the file crosses the threshold); backends that stamp their own times (backblaze) accept and ignore lastModified. THROWS when the stream produces no data at all - same rule as set: an empty file IS a deletion and would read back as missing. */
2343
2361
  setLargeFile(config: SetLargeFileConfig): Promise<void>;
2344
2362
  /** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. url as in get2. Size-0 entries (tombstones) report undefined unless config.includeTombstones. */
@@ -2631,6 +2649,87 @@ declare module "sliftutils/storage/StorageObservableAsync" {
2631
2649
 
2632
2650
  }
2633
2651
 
2652
+ declare module "sliftutils/storage/StreamingLogs" {
2653
+ /// <reference types="node" />
2654
+ /// <reference types="node" />
2655
+ /** 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. */
2656
+ export type LogFileInfo = {
2657
+ name: string;
2658
+ /** Bytes on disk (compressed bytes for compressed files) */
2659
+ size: number;
2660
+ pid: number;
2661
+ processStartTime: number;
2662
+ threadId: string;
2663
+ /** When the process started writing this file */
2664
+ startTime: number;
2665
+ /** When the file was compressed - absent while it is still being streamed to */
2666
+ endTime?: number;
2667
+ /** How many entries it holds - only counted at compression, so absent on live files */
2668
+ entryCount?: number;
2669
+ compressed: boolean;
2670
+ /** Whether the writing process is still alive (always false for compressed files) */
2671
+ active: boolean;
2672
+ };
2673
+ /**
2674
+ * A searcher over one log file (as readFileCompressed returns it - always LZ4) that never decodes
2675
+ * the whole thing: JSON escapes line breaks inside strings, so the only ACTUAL line breaks in the
2676
+ * file are the separators between log statements - a plain substring search over the raw text,
2677
+ * bounded out to the surrounding line breaks, finds exactly the matching statements, and only THOSE
2678
+ * are decoded and returned as objects. Every search string must appear in a statement's raw JSON for
2679
+ * it to match (so search for values as they are encoded - e.g. quoted).
2680
+ */
2681
+ export declare function createLogSearcher(data: Buffer): (searches: string[]) => unknown[];
2682
+ /** 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. */
2683
+ export declare function decodeLogFile(data: Buffer): unknown[];
2684
+ export declare class StreamingLogs {
2685
+ private config;
2686
+ constructor(config: {
2687
+ folder: string;
2688
+ /** Included in every file name - see misc/https/certs.ts getOwnThreadId. Processes without one write "none". */
2689
+ threadId?: string;
2690
+ maxFileBytes?: number;
2691
+ totalLimitBytes?: number;
2692
+ });
2693
+ private processStartTime;
2694
+ private threadId;
2695
+ private pending;
2696
+ private flushTimer;
2697
+ private maintenanceTimer;
2698
+ private writeChain;
2699
+ private currentPath;
2700
+ private currentBytes;
2701
+ private disposed;
2702
+ /** Queues one entry (anything JSON-serializable). Never throws - logging must not take down the caller. */
2703
+ log(entry: unknown): void;
2704
+ private scheduleFlush;
2705
+ flush(): Promise<void>;
2706
+ private writePending;
2707
+ /**
2708
+ * Compresses one finished log file: LZ4 into a temp SUBFOLDER (never a temp name in the log
2709
+ * folder itself - a crashed write must not leave a corrupt file where the maintenance scans are),
2710
+ * renamed into place (same drive, so the rename is atomic), verified, and only THEN is the
2711
+ * original deleted - at no point is the data in fewer than one complete file.
2712
+ */
2713
+ private compressFile;
2714
+ listFiles(): Promise<LogFileInfo[]>;
2715
+ /** 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). */
2716
+ readFileCompressed(name: string): Promise<Buffer>;
2717
+ private scheduleMaintenance;
2718
+ /**
2719
+ * Shared upkeep of the folder - every process using it runs this, so nothing depends on any one
2720
+ * process surviving: dead processes' uncompressed files are compressed for them; duplicate
2721
+ * compressions of one stream (two maintainers racing) are resolved by keeping the OLDEST copy;
2722
+ * an uncompressed original whose compression already exists is deleted (its compressor died
2723
+ * between rename and unlink); and the folder's total size is brought under its budget by
2724
+ * deleting the oldest files.
2725
+ */
2726
+ runMaintenance(): Promise<void>;
2727
+ private enforceTotalLimit;
2728
+ dispose(): void;
2729
+ }
2730
+
2731
+ }
2732
+
2634
2733
  declare module "sliftutils/storage/TransactionFile" {
2635
2734
  /** A live value: what was stored, when it was written (the caller's ordering), and when we last changed it (ours). */
2636
2735
  export type LogEntry<T> = {
@@ -2638,10 +2737,12 @@ declare module "sliftutils/storage/TransactionFile" {
2638
2737
  time: number;
2639
2738
  changedAt: number;
2640
2739
  };
2641
- /** A deleted key: when it was deleted, and when we learned. The value is gone; the time is the point of a tombstone. */
2642
- export type LogTombstone = {
2740
+ /** 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). */
2741
+ export type LogTombstone<T> = {
2643
2742
  time: number;
2644
2743
  changedAt: number;
2744
+ value?: T;
2745
+ valueTime?: number;
2645
2746
  };
2646
2747
  export declare class TransactionFile<T> {
2647
2748
  private filePath;
@@ -2660,8 +2761,8 @@ declare module "sliftutils/storage/TransactionFile" {
2660
2761
  };
2661
2762
  /** The live value, or undefined when the key does not exist here (deleted included - a deletion is an absence, see getDeleted for its time). */
2662
2763
  get(key: string): LogEntry<T> | undefined;
2663
- /** When the key was deleted, if it was. Absent both here and in get means we have never heard of it. */
2664
- getDeleted(key: string): LogTombstone | undefined;
2764
+ /** 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. */
2765
+ getDeleted(key: string): LogTombstone<T> | undefined;
2665
2766
  /** The time the key last changed either way, or 0 if we have never heard of it - what a new write has to beat. */
2666
2767
  timeOf(key: string): number;
2667
2768
  /** O(1), and counts only what exists. */
@@ -2670,11 +2771,15 @@ declare module "sliftutils/storage/TransactionFile" {
2670
2771
  /** 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. */
2671
2772
  entries(): IterableIterator<[string, LogEntry<T>]>;
2672
2773
  /** 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. */
2673
- deletedEntries(): IterableIterator<[string, LogTombstone]>;
2774
+ deletedEntries(): IterableIterator<[string, LogTombstone<T>]>;
2674
2775
  /** 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. */
2675
2776
  set(key: string, value: T, time: number): boolean;
2676
- /** Deletes as of `time`, keeping the tombstone. Returns false when something at least as new is already here. */
2777
+ /** 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. */
2677
2778
  delete(key: string, time: number): boolean;
2779
+ /** 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. */
2780
+ unmark(key: string, time: number): boolean;
2781
+ /** Drops a marked deletion's kept value (its history has been physically removed), leaving a plain tombstone with the same delete time. */
2782
+ dropValue(key: string): void;
2678
2783
  /** 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. */
2679
2784
  purge(key: string): void;
2680
2785
  private applySet;
@@ -3310,6 +3415,9 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3310
3415
  import { StoreSync } from "./storeSync";
3311
3416
  import { StoreConfig } from "./storeConfig";
3312
3417
  export declare const WINDOW_END_FLUSH_MARGIN: number;
3418
+ export declare const HISTORY_MIN_BYTES: number;
3419
+ /** The multiple of a store's live bytes its deletion history may grow to. Async so it can later become dynamic and user-configurable; for now it is a constant. */
3420
+ export declare function getHistoryFactor(): Promise<number>;
3313
3421
  /** What we store about a file. Its times are not in here: the index keeps those for every key, deleted ones included (see TransactionFile). */
3314
3422
  type IndexValue = {
3315
3423
  size: number;
@@ -3368,6 +3476,8 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3368
3476
  /** Asks the client whose request created this store what routing config it intended for our name. Only used when init finds NO configuration in our folder: a store only ever exists because a config names it, so the requester has that config - asking for it lazily is the same information as passing the config on every call, without the per-call kilobytes. */
3369
3477
  requestRoutingConfig?: (() => Promise<RemoteConfig | undefined>) | undefined;
3370
3478
  onWriteCounted?: ((kind: "original" | "flushed", bytes: number) => void) | undefined;
3479
+ /** A synchronization transfer: "sync get" is bytes pulled off a source (the backblaze download bill), "sync set" is bytes pushed to one. Injected because sync traffic never passes through the API controller, so nothing else can count it. */
3480
+ onSyncTransfer?: ((operation: "sync get" | "sync set", path: string, bytes: number) => void) | undefined;
3371
3481
  resolveSourceUrl?: ((url: string) => IArchives) | undefined;
3372
3482
  } | undefined);
3373
3483
  /** This store's folder, unwrapped: the same bytes slot 0 serves, but reached without its write delay. Used for the two things that cannot go through a buffered source - reading our own routing config before we have any sources, and streaming a large upload that must not sit in memory. */
@@ -3406,6 +3516,7 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3406
3516
  };
3407
3517
  internal?: boolean;
3408
3518
  includeTombstones?: boolean;
3519
+ includeMarked?: boolean;
3409
3520
  }): Promise<{
3410
3521
  data: Buffer;
3411
3522
  writeTime: number;
@@ -3417,6 +3528,7 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3417
3528
  lastModified?: number;
3418
3529
  forceSetImmutable?: boolean;
3419
3530
  internal?: boolean;
3531
+ undelete?: boolean;
3420
3532
  }): Promise<void>;
3421
3533
  del(config: {
3422
3534
  path: string;
@@ -3446,6 +3558,11 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3446
3558
  fileCount: number;
3447
3559
  byteCount: number;
3448
3560
  };
3561
+ marked: {
3562
+ fileCount: number;
3563
+ byteCount: number;
3564
+ oldestDeleteTime?: number;
3565
+ };
3449
3566
  sources: {
3450
3567
  debugName: string;
3451
3568
  fileCount: number;
@@ -3533,6 +3650,24 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3533
3650
  writeTime: number;
3534
3651
  changedAt: number;
3535
3652
  }]>;
3653
+ /** A file MARKED for deletion: its kept index value plus when it was deleted. Undefined when the key is live, never existed, or its history was already dropped. */
3654
+ getMarkedEntry(key: string): (IndexEntry & {
3655
+ deleteTime: number;
3656
+ }) | undefined;
3657
+ /** Every file marked for deletion - the deletion history, walked by retention and by includeMarked listings. */
3658
+ markedEntries(): IterableIterator<[string, IndexEntry & {
3659
+ deleteTime: number;
3660
+ }]>;
3661
+ /** The deletion history's totals: how many marked files, their bytes, and the delete time of the OLDEST one - which is how far back the history reaches. */
3662
+ markedTotals(): {
3663
+ fileCount: number;
3664
+ byteCount: number;
3665
+ oldestDeleteTime?: number;
3666
+ };
3667
+ /** Physically removes a marked file's bytes from our disk and drops its kept value, leaving a plain tombstone that ages out normally - retention calling time on the oldest history. */
3668
+ dropMarkedHistory(key: string): Promise<void>;
3669
+ /** See SetConfig.undelete: flips a marked deletion back to live (fresh write time, so the restore outranks the deletion everywhere it propagated) - the bytes never left the disk, so reads just work again. Internal restores are a peer's propagation and tolerate having nothing to restore (this node may never have held the file); a caller's restore throws instead. */
3670
+ private undeleteKey;
3536
3671
  /** How many files we hold, deletions excluded. */
3537
3672
  indexSize(): number;
3538
3673
  /** Totals over the files we hold, broken down by the slot holding each (entries can name a source that is no longer configured, which counts towards the total but no slot). */
@@ -3554,6 +3689,8 @@ declare module "sliftutils/storage/remoteStorage/blobStore" {
3554
3689
  setIndexDeleted(key: string, writeTime: number): boolean;
3555
3690
  /** Forgets a key entirely, tombstone included. NOT a deletion: it says nothing happened to the file, only that we no longer know anything about it - for an entry whose holder turned out not to have it, and for a tombstone old enough that everyone has heard. */
3556
3691
  purgeIndexEntry(key: string): void;
3692
+ /** Counts a synchronization transfer in the server's access statistics (see getStore's wiring): "sync get" for bytes pulled off a source, "sync set" for bytes pushed to one. */
3693
+ noteSyncTransfer(operation: "sync get" | "sync set", path: string, bytes: number): void;
3557
3694
  /**
3558
3695
  * Every write, however it is stamped, has to be one we are actually meant to hold - because the
3559
3696
  * alternative is not a smaller problem, it is a silent one. A write that lands on a store that
@@ -3782,8 +3919,9 @@ declare module "sliftutils/storage/remoteStorage/cliArgs" {
3782
3919
  declare module "sliftutils/storage/remoteStorage/createArchives" {
3783
3920
  /// <reference types="node" />
3784
3921
  /// <reference types="node" />
3785
- import { IArchives, RemoteConfig, RemoteConfigBase, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig } from "../IArchives";
3922
+ import { IArchives, RemoteConfig, RemoteConfigBase, SourceConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig } from "../IArchives";
3786
3923
  import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
3924
+ import { LogFileInfo } from "../StreamingLogs";
3787
3925
  /** The address, port, account, and bucket name a bucket routing URL addresses. Throws when the URL isn't a hosted bucket routing URL (https://host:port/file/<account>/<bucketName>/storage/storagerouting.json). */
3788
3926
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
3789
3927
  /** A client for ONE source - see storeSources.ts. Re-exported here because a chain is built out of them. */
@@ -3808,6 +3946,9 @@ declare module "sliftutils/storage/remoteStorage/createArchives" {
3808
3946
  machineId: string;
3809
3947
  ip: string;
3810
3948
  } | undefined>;
3949
+ /** The sources that can serve a file right now, in dispatch order - the first is the write node, the one a plain read asks first. Each entry's url is what GetConfig.sourceUrl / GetInfoConfig.sourceUrl accept, so listing these and then reading with sourceUrl compares the copies the sources actually hold. */
3950
+ getFileSources(fileName: string): Promise<SourceConfig[]>;
3951
+ private runOnSource;
3811
3952
  get(fileName: string, config?: GetConfig): Promise<Buffer | undefined>;
3812
3953
  /** get2, but trying sources in latency order (fastest first) instead of config order. While this is much faster, it might miss immediate writes: the write node is no longer tried first, so a lagging replica may answer with a slightly older value. Exclusive with noFallbacks (which only considers one source - the write node - so there is no order to speed up); passing both throws. */
3813
3954
  getFast(fileName: string, config?: GetConfig): Promise<{
@@ -3849,6 +3990,8 @@ declare module "sliftutils/storage/remoteStorage/createArchives" {
3849
3990
  set(fileName: string, data: Buffer, config?: SetConfig): Promise<string>;
3850
3991
  private setRoutingConfig;
3851
3992
  del(fileName: string, config?: DelConfig): Promise<void>;
3993
+ /** See IArchives.undelete: restores a file marked for deletion, dispatched to the write node as SetConfig.undelete (the write node propagates the restore to its peers itself). */
3994
+ undelete(fileName: string): Promise<void>;
3852
3995
  /** See IArchives.move. When one node is the write target for BOTH paths, that node moves the file itself - the bytes never come through us - with the same wrong-window/route re-resolution as any write. When the paths route to different shards no single node holds both, so the move degrades to a copy through us plus a delete, CONFIRMED at the destination before the source is touched. No smart timeout on the node-side move: it can be a big file's worth of node-side work, which the upload-sized deadlines would misjudge. */
3853
3996
  move(config: MoveFileConfig): Promise<void>;
3854
3997
  private getVariableShardTargets;
@@ -3899,6 +4042,39 @@ declare module "sliftutils/storage/remoteStorage/createArchives" {
3899
4042
  }): Promise<{
3900
4043
  clearedBuckets: number;
3901
4044
  }>;
4045
+ /** The operation-log files ONE storage server holds (every server logs only its own operations - see listAllServerLogFiles for the whole fleet). The names carry pid/thread/time-range/entry-count metadata; see LogFileInfo. */
4046
+ export declare function listServerLogFiles(config: {
4047
+ url: string;
4048
+ account: string;
4049
+ }): Promise<LogFileInfo[]>;
4050
+ /** Every server's log files at once, one entry per url - a server that cannot answer reports its error instead of failing the rest. */
4051
+ export declare function listAllServerLogFiles(config: {
4052
+ urls: string[];
4053
+ account: string;
4054
+ }): Promise<{
4055
+ url: string;
4056
+ files?: LogFileInfo[];
4057
+ error?: string;
4058
+ }[]>;
4059
+ /** Downloads the named log files off one server and decodes them into the logged objects (the wire always carries them LZ4-compressed - live files are compressed in memory server-side). */
4060
+ export declare function getServerLogs(config: {
4061
+ url: string;
4062
+ account: string;
4063
+ names: string[];
4064
+ }): Promise<{
4065
+ name: string;
4066
+ entries: unknown[];
4067
+ }[]>;
4068
+ /** getServerLogs, but SEARCHED instead of fully decoded: the raw JSON text is substring-matched (every search string must appear in a statement - see createLogSearcher), and only the matching statements are decoded into objects. Far cheaper than decoding whole files to look for one path or caller. */
4069
+ export declare function searchServerLogs(config: {
4070
+ url: string;
4071
+ account: string;
4072
+ names: string[];
4073
+ searches: string[];
4074
+ }): Promise<{
4075
+ name: string;
4076
+ entries: unknown[];
4077
+ }[]>;
3902
4078
  export declare function getBucketInfo(config: {
3903
4079
  url: string;
3904
4080
  }): Promise<ArchivesConfig>;
@@ -4172,6 +4348,7 @@ declare module "sliftutils/storage/remoteStorage/storageController" {
4172
4348
  import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, FindConfig, SourceConfig } from "../IArchives";
4173
4349
  import { ActiveBucketInfo, ServerBucketInfo } from "./storageServerState";
4174
4350
  import { AccessTotals, AccessSummaryState } from "./accessStats";
4351
+ import { LogFileInfo } from "../StreamingLogs";
4175
4352
  import type { SummaryEntry } from "../../treeSummary";
4176
4353
  export declare const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
4177
4354
  export declare const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
@@ -4253,6 +4430,7 @@ declare module "sliftutils/storage/remoteStorage/storageController" {
4253
4430
  };
4254
4431
  internal?: boolean;
4255
4432
  includeTombstones?: boolean;
4433
+ includeMarked?: boolean;
4256
4434
  }) => Promise<{
4257
4435
  data: Buffer;
4258
4436
  writeTime: number;
@@ -4267,6 +4445,7 @@ declare module "sliftutils/storage/remoteStorage/storageController" {
4267
4445
  lastModified?: number;
4268
4446
  forceSetImmutable?: boolean;
4269
4447
  internal?: boolean;
4448
+ undelete?: boolean;
4270
4449
  }) => Promise<void>;
4271
4450
  del: (config: {
4272
4451
  account: string;
@@ -4351,6 +4530,13 @@ declare module "sliftutils/storage/remoteStorage/storageController" {
4351
4530
  account: string;
4352
4531
  bucketName: string;
4353
4532
  }) => Promise<ArchivesSyncStatus>;
4533
+ listLogFiles: (config: {
4534
+ account: string;
4535
+ }) => Promise<LogFileInfo[]>;
4536
+ getLogFile: (config: {
4537
+ account: string;
4538
+ name: string;
4539
+ }) => Promise<Buffer>;
4354
4540
  startLargeFile: (config: {
4355
4541
  account: string;
4356
4542
  bucketName: string;
@@ -4380,6 +4566,39 @@ declare module "sliftutils/storage/remoteStorage/storageController" {
4380
4566
 
4381
4567
  }
4382
4568
 
4569
+ declare module "sliftutils/storage/remoteStorage/storageLogs" {
4570
+ /// <reference types="node" />
4571
+ /// <reference types="node" />
4572
+ import { LogFileInfo } from "../StreamingLogs";
4573
+ export declare const LOGS_FOLDER_NAME = "logs";
4574
+ /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. */
4575
+ export declare function logMutation(entry: {
4576
+ op: string;
4577
+ account: string;
4578
+ bucketName: string;
4579
+ store?: string;
4580
+ path: string;
4581
+ toPath?: string;
4582
+ size?: number;
4583
+ writeTime?: number;
4584
+ callerId?: string;
4585
+ internal?: boolean;
4586
+ }): void;
4587
+ /** A synchronization key point: scans and full syncs starting/finishing, reconciles, boundary scans - what an operator greps for to see whether the fleet is converging. */
4588
+ export declare function logSyncEvent(entry: {
4589
+ event: string;
4590
+ store: string;
4591
+ source?: string;
4592
+ [key: string]: unknown;
4593
+ }): void;
4594
+ export declare function logStorageError(message: string): void;
4595
+ /** The log files this server holds - see StreamingLogs.listFiles. Empty on processes with no storage folder. */
4596
+ export declare function listStorageLogFiles(): Promise<LogFileInfo[]>;
4597
+ /** One log file's bytes, always LZ4-compressed - see StreamingLogs.readFileCompressed (decode with decodeLogFile). */
4598
+ export declare function readStorageLogFile(name: string): Promise<Buffer>;
4599
+
4600
+ }
4601
+
4383
4602
  declare module "sliftutils/storage/remoteStorage/storageServer" {
4384
4603
  import "./accessPage";
4385
4604
  export type HostStorageServerConfig = {
@@ -4616,6 +4835,7 @@ declare module "sliftutils/storage/remoteStorage/storeSync" {
4616
4835
  private copySourceFiles;
4617
4836
  private enforceDiskLimit;
4618
4837
  private cleanupTombstones;
4838
+ private enforceHistoryLimit;
4619
4839
  }
4620
4840
 
4621
4841
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.7.101",
3
+ "version": "1.7.103",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -75,12 +75,18 @@ export type GetConfig = {
75
75
  internal?: boolean;
76
76
  /** Also return size-0 results (tombstones - an empty file IS a missing file) instead of treating them as absent. Off by default, matching getInfo's flag of the same name. Synchronization passes this so a DELETED file (with its write time) is distinguishable from a file that never existed. */
77
77
  includeTombstones?: boolean;
78
+ /** Reads files that are MARKED for deletion (deleted, but with their bytes still in the deletion history - see SetConfig.undelete for restoring them). The actual content comes back, unlike includeTombstones, which only reports that a deletion happened. */
79
+ includeMarked?: boolean;
80
+ /** Read from EXACTLY this source - its config url, as ArchivesChain.getFileSources lists them - with no fallback to any other. For comparing the copies different sources hold (combine with internal to read only that server's own disk, skipping its holder resolution). Multi-source archives only; throws when no configured source has the url. */
81
+ sourceUrl?: string;
78
82
  /** How many extra times the WHOLE operation is retried after every source in a pass failed - any error counts (the wrong-window/route markers still get their config re-resolve first). Only applies to fallback dispatch (multi-source, not noFallbacks), where it defaults to 3; the noFallbacks/write-node path already retries on its own deadline. Multi-part uploads additionally retry per part regardless of this. */
79
83
  retries?: number;
80
84
  };
81
85
  export type FindConfig = {
82
86
  shallow?: boolean;
83
87
  type?: "files" | "folders";
88
+ /** Also list files MARKED for deletion (see GetConfig.includeMarked). */
89
+ includeMarked?: boolean;
84
90
  /** Listings normally come ONLY from the authoritative sources (the same nodes writes go to - read-your-writes). With fallbacks, a failing shard's routes are covered by the next source holding them (e.g. a wide read replica) instead of the call failing - high availability at the cost of possibly missing just-written data. Single-source archives ignore the flag. */
85
91
  fallbacks?: boolean;
86
92
  };
@@ -103,6 +109,8 @@ export type GetInfoConfig = {
103
109
  noFallbacks?: boolean;
104
110
  /** See GetConfig.retries. */
105
111
  retries?: number;
112
+ /** See GetConfig.sourceUrl: answer from EXACTLY this source. */
113
+ sourceUrl?: string;
106
114
  };
107
115
  export type ChangesAfterConfig = {
108
116
  time: number;
@@ -121,6 +129,8 @@ export type SetConfig = {
121
129
  fallbacks?: boolean;
122
130
  /** See GetConfig.retries. */
123
131
  retries?: number;
132
+ /** The set is not a write at all: it RESTORES a file marked for deletion, flipping its index entry back to live (with a fresh write time, so the restore outranks the deletion everywhere it propagated) - the bytes never left the disk, so reads just work again. The data buffer is ignored (a 1-byte placeholder satisfies the empty-buffer rule); use IArchives.undelete rather than passing this yourself. Throws when the key has no marked deletion to restore (its history was dropped, or it was never deleted). */
133
+ undelete?: boolean;
124
134
  };
125
135
  /** setLargeFile's config: a SetConfig (it IS a set - the same immutability, ordering, internal, and fallbacks rules apply) plus the stream carrying the bytes. */
126
136
  export type SetLargeFileConfig = SetConfig & {
@@ -150,6 +160,12 @@ export type ArchivesConfig = {
150
160
  fileCount: number;
151
161
  byteCount: number;
152
162
  };
163
+ /** Files MARKED for deletion (deleted, bytes still kept as history - see SetConfig.undelete): how many, how big, and the delete time of the oldest one - which is how far back the deletion history reaches. */
164
+ markedIndex?: {
165
+ fileCount: number;
166
+ byteCount: number;
167
+ oldestDeleteTime?: number;
168
+ };
153
169
  indexSources?: {
154
170
  debugName: string;
155
171
  fileCount: number;
@@ -244,6 +260,8 @@ export interface IArchives {
244
260
  del(fileName: string, config?: DelConfig): Promise<void>;
245
261
  /** Moves a file to a new path within THIS archives, backend-side where the backend can (backblaze copies server-side, disk renames, the storage server relocates node-side) - the bytes never travel through the caller. The destination is stamped with a FRESH write time, even when the underlying operation (a rename) would preserve the old one, so the moved file cannot immediately lose to something newer sitting at its new path (e.g. the tombstone of an earlier deletion there); the source is then deleted, exactly like del. THROWS when the source file does not exist. Optional - callers go through moveArchiveFile (archiveHelpers.ts), which falls back to copy + confirm + delete. */
246
262
  move?(config: MoveFileConfig): Promise<void>;
263
+ /** Restores a deleted file whose bytes are still in the deletion history (see SetConfig.undelete, which this rides on). Only index-backed stores keep a deletion history, so only they support this. THROWS when there is nothing to restore. */
264
+ undelete?(fileName: string): Promise<void>;
247
265
  /** Streams a file too large to hold in memory. getNextData returns undefined when done. This only needs to be called when you CANNOT materialize the entire file in memory - if you can, just call set: above LARGE_SET_THRESHOLD it streams through setLargeFile internally, keeping the client responsive and not overwhelming the server. The rest of the config is a plain SetConfig and means exactly what it means on set (that is what makes a large set behave like a small one instead of quietly losing immutability, ordering, internal, or fallbacks semantics as the file crosses the threshold); backends that stamp their own times (backblaze) accept and ignore lastModified. THROWS when the stream produces no data at all - same rule as set: an empty file IS a deletion and would read back as missing. */
248
266
  setLargeFile(config: SetLargeFileConfig): Promise<void>;
249
267
  /** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. url as in get2. Size-0 entries (tombstones) report undefined unless config.includeTombstones. */
@@ -110,6 +110,10 @@ export type GetConfig = {
110
110
  internal?: boolean;
111
111
  /** Also return size-0 results (tombstones - an empty file IS a missing file) instead of treating them as absent. Off by default, matching getInfo's flag of the same name. Synchronization passes this so a DELETED file (with its write time) is distinguishable from a file that never existed. */
112
112
  includeTombstones?: boolean;
113
+ /** Reads files that are MARKED for deletion (deleted, but with their bytes still in the deletion history - see SetConfig.undelete for restoring them). The actual content comes back, unlike includeTombstones, which only reports that a deletion happened. */
114
+ includeMarked?: boolean;
115
+ /** Read from EXACTLY this source - its config url, as ArchivesChain.getFileSources lists them - with no fallback to any other. For comparing the copies different sources hold (combine with internal to read only that server's own disk, skipping its holder resolution). Multi-source archives only; throws when no configured source has the url. */
116
+ sourceUrl?: string;
113
117
  /** How many extra times the WHOLE operation is retried after every source in a pass failed - any error counts (the wrong-window/route markers still get their config re-resolve first). Only applies to fallback dispatch (multi-source, not noFallbacks), where it defaults to 3; the noFallbacks/write-node path already retries on its own deadline. Multi-part uploads additionally retry per part regardless of this. */
114
118
  retries?: number;
115
119
  };
@@ -117,6 +121,8 @@ export type GetConfig = {
117
121
  export type FindConfig = {
118
122
  shallow?: boolean;
119
123
  type?: "files" | "folders";
124
+ /** Also list files MARKED for deletion (see GetConfig.includeMarked). */
125
+ includeMarked?: boolean;
120
126
  /** Listings normally come ONLY from the authoritative sources (the same nodes writes go to - read-your-writes). With fallbacks, a failing shard's routes are covered by the next source holding them (e.g. a wide read replica) instead of the call failing - high availability at the cost of possibly missing just-written data. Single-source archives ignore the flag. */
121
127
  fallbacks?: boolean;
122
128
  };
@@ -141,6 +147,8 @@ export type GetInfoConfig = {
141
147
  noFallbacks?: boolean;
142
148
  /** See GetConfig.retries. */
143
149
  retries?: number;
150
+ /** See GetConfig.sourceUrl: answer from EXACTLY this source. */
151
+ sourceUrl?: string;
144
152
  };
145
153
 
146
154
  export type ChangesAfterConfig = {
@@ -161,6 +169,8 @@ export type SetConfig = {
161
169
  fallbacks?: boolean;
162
170
  /** See GetConfig.retries. */
163
171
  retries?: number;
172
+ /** The set is not a write at all: it RESTORES a file marked for deletion, flipping its index entry back to live (with a fresh write time, so the restore outranks the deletion everywhere it propagated) - the bytes never left the disk, so reads just work again. The data buffer is ignored (a 1-byte placeholder satisfies the empty-buffer rule); use IArchives.undelete rather than passing this yourself. Throws when the key has no marked deletion to restore (its history was dropped, or it was never deleted). */
173
+ undelete?: boolean;
164
174
  };
165
175
 
166
176
  /** setLargeFile's config: a SetConfig (it IS a set - the same immutability, ordering, internal, and fallbacks rules apply) plus the stream carrying the bytes. */
@@ -193,6 +203,8 @@ export type ArchivesConfig = {
193
203
  remoteConfig?: RemoteConfig;
194
204
  // Live index totals (tombstones excluded), kept up to date in memory on every mutation and recomputed on load - so any drift heals on restart
195
205
  index?: { fileCount: number; byteCount: number };
206
+ /** Files MARKED for deletion (deleted, bytes still kept as history - see SetConfig.undelete): how many, how big, and the delete time of the oldest one - which is how far back the deletion history reaches. */
207
+ markedIndex?: { fileCount: number; byteCount: number; oldestDeleteTime?: number };
196
208
  // The same totals broken down by which source currently holds each file's bytes (the first entry is the server's own disk)
197
209
  indexSources?: { debugName: string; fileCount: number; byteCount: number }[];
198
210
  // The server's configured readerDiskLimit, when it runs as a bounded read cache
@@ -322,6 +334,8 @@ export interface IArchives {
322
334
  del(fileName: string, config?: DelConfig): Promise<void>;
323
335
  /** Moves a file to a new path within THIS archives, backend-side where the backend can (backblaze copies server-side, disk renames, the storage server relocates node-side) - the bytes never travel through the caller. The destination is stamped with a FRESH write time, even when the underlying operation (a rename) would preserve the old one, so the moved file cannot immediately lose to something newer sitting at its new path (e.g. the tombstone of an earlier deletion there); the source is then deleted, exactly like del. THROWS when the source file does not exist. Optional - callers go through moveArchiveFile (archiveHelpers.ts), which falls back to copy + confirm + delete. */
324
336
  move?(config: MoveFileConfig): Promise<void>;
337
+ /** Restores a deleted file whose bytes are still in the deletion history (see SetConfig.undelete, which this rides on). Only index-backed stores keep a deletion history, so only they support this. THROWS when there is nothing to restore. */
338
+ undelete?(fileName: string): Promise<void>;
325
339
  /** Streams a file too large to hold in memory. getNextData returns undefined when done. This only needs to be called when you CANNOT materialize the entire file in memory - if you can, just call set: above LARGE_SET_THRESHOLD it streams through setLargeFile internally, keeping the client responsive and not overwhelming the server. The rest of the config is a plain SetConfig and means exactly what it means on set (that is what makes a large set behave like a small one instead of quietly losing immutability, ordering, internal, or fallbacks semantics as the file crosses the threshold); backends that stamp their own times (backblaze) accept and ignore lastModified. THROWS when the stream produces no data at all - same rule as set: an empty file IS a deletion and would read back as missing. */
326
340
  setLargeFile(config: SetLargeFileConfig): Promise<void>;
327
341
  /** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. url as in get2. Size-0 entries (tombstones) report undefined unless config.includeTombstones. */
@@ -0,0 +1,77 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ /** 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. */
4
+ export type LogFileInfo = {
5
+ name: string;
6
+ /** Bytes on disk (compressed bytes for compressed files) */
7
+ size: number;
8
+ pid: number;
9
+ processStartTime: number;
10
+ threadId: string;
11
+ /** When the process started writing this file */
12
+ startTime: number;
13
+ /** When the file was compressed - absent while it is still being streamed to */
14
+ endTime?: number;
15
+ /** How many entries it holds - only counted at compression, so absent on live files */
16
+ entryCount?: number;
17
+ compressed: boolean;
18
+ /** Whether the writing process is still alive (always false for compressed files) */
19
+ active: boolean;
20
+ };
21
+ /**
22
+ * A searcher over one log file (as readFileCompressed returns it - always LZ4) that never decodes
23
+ * the whole thing: JSON escapes line breaks inside strings, so the only ACTUAL line breaks in the
24
+ * file are the separators between log statements - a plain substring search over the raw text,
25
+ * bounded out to the surrounding line breaks, finds exactly the matching statements, and only THOSE
26
+ * are decoded and returned as objects. Every search string must appear in a statement's raw JSON for
27
+ * it to match (so search for values as they are encoded - e.g. quoted).
28
+ */
29
+ export declare function createLogSearcher(data: Buffer): (searches: string[]) => unknown[];
30
+ /** 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. */
31
+ export declare function decodeLogFile(data: Buffer): unknown[];
32
+ export declare class StreamingLogs {
33
+ private config;
34
+ constructor(config: {
35
+ folder: string;
36
+ /** Included in every file name - see misc/https/certs.ts getOwnThreadId. Processes without one write "none". */
37
+ threadId?: string;
38
+ maxFileBytes?: number;
39
+ totalLimitBytes?: number;
40
+ });
41
+ private processStartTime;
42
+ private threadId;
43
+ private pending;
44
+ private flushTimer;
45
+ private maintenanceTimer;
46
+ private writeChain;
47
+ private currentPath;
48
+ private currentBytes;
49
+ private disposed;
50
+ /** Queues one entry (anything JSON-serializable). Never throws - logging must not take down the caller. */
51
+ log(entry: unknown): void;
52
+ private scheduleFlush;
53
+ flush(): Promise<void>;
54
+ private writePending;
55
+ /**
56
+ * Compresses one finished log file: LZ4 into a temp SUBFOLDER (never a temp name in the log
57
+ * folder itself - a crashed write must not leave a corrupt file where the maintenance scans are),
58
+ * renamed into place (same drive, so the rename is atomic), verified, and only THEN is the
59
+ * original deleted - at no point is the data in fewer than one complete file.
60
+ */
61
+ private compressFile;
62
+ listFiles(): Promise<LogFileInfo[]>;
63
+ /** 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). */
64
+ readFileCompressed(name: string): Promise<Buffer>;
65
+ private scheduleMaintenance;
66
+ /**
67
+ * Shared upkeep of the folder - every process using it runs this, so nothing depends on any one
68
+ * process surviving: dead processes' uncompressed files are compressed for them; duplicate
69
+ * compressions of one stream (two maintainers racing) are resolved by keeping the OLDEST copy;
70
+ * an uncompressed original whose compression already exists is deleted (its compressor died
71
+ * between rename and unlink); and the folder's total size is brought under its budget by
72
+ * deleting the oldest files.
73
+ */
74
+ runMaintenance(): Promise<void>;
75
+ private enforceTotalLimit;
76
+ dispose(): void;
77
+ }