sliftutils 1.7.71 → 1.7.73

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 (62) hide show
  1. package/index.d.ts +506 -257
  2. package/misc/https/dist/hostServer.ts.cache +5 -2
  3. package/package.json +1 -1
  4. package/spec.txt +10 -0
  5. package/storage/ArchivesDisk.d.ts +4 -13
  6. package/storage/ArchivesDisk.ts +6 -4
  7. package/storage/IArchives.d.ts +15 -10
  8. package/storage/IArchives.ts +19 -6
  9. package/storage/backblaze.d.ts +3 -9
  10. package/storage/backblaze.ts +22 -19
  11. package/storage/dist/ArchivesDisk.ts.cache +5 -2
  12. package/storage/dist/IArchives.ts.cache +17 -3
  13. package/storage/dist/backblaze.ts.cache +35 -26
  14. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -9
  15. package/storage/remoteStorage/ArchivesRemote.ts +25 -20
  16. package/storage/remoteStorage/ArchivesUrl.d.ts +3 -9
  17. package/storage/remoteStorage/ArchivesUrl.ts +7 -5
  18. package/storage/remoteStorage/accessPage.tsx +4 -4
  19. package/storage/remoteStorage/accessStats.d.ts +2 -0
  20. package/storage/remoteStorage/accessStats.ts +32 -0
  21. package/storage/remoteStorage/blobStore.d.ts +193 -88
  22. package/storage/remoteStorage/blobStore.ts +535 -400
  23. package/storage/remoteStorage/bucketDisk.d.ts +18 -0
  24. package/storage/remoteStorage/bucketDisk.ts +66 -0
  25. package/storage/remoteStorage/createArchives.d.ts +4 -10
  26. package/storage/remoteStorage/createArchives.ts +103 -62
  27. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +28 -19
  28. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +7 -4
  29. package/storage/remoteStorage/dist/accessPage.tsx.cache +6 -6
  30. package/storage/remoteStorage/dist/accessStats.ts.cache +38 -3
  31. package/storage/remoteStorage/dist/blobStore.ts.cache +516 -374
  32. package/storage/remoteStorage/dist/bucketDisk.ts.cache +72 -0
  33. package/storage/remoteStorage/dist/createArchives.ts.cache +114 -57
  34. package/storage/remoteStorage/dist/intermediateSources.ts.cache +12 -12
  35. package/storage/remoteStorage/dist/remoteConfig.ts.cache +18 -2
  36. package/storage/remoteStorage/dist/serverConfig.ts.cache +116 -0
  37. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +6 -5
  38. package/storage/remoteStorage/dist/sourcesList.ts.cache +5 -2
  39. package/storage/remoteStorage/dist/storageController.ts.cache +223 -190
  40. package/storage/remoteStorage/dist/storageServer.ts.cache +9 -9
  41. package/storage/remoteStorage/dist/storageServerState.ts.cache +329 -566
  42. package/storage/remoteStorage/dist/storePlan.ts.cache +149 -0
  43. package/storage/remoteStorage/dist/validation.ts.cache +40 -0
  44. package/storage/remoteStorage/grantAccessCli.ts +1 -1
  45. package/storage/remoteStorage/intermediateSources.d.ts +3 -5
  46. package/storage/remoteStorage/intermediateSources.ts +14 -16
  47. package/storage/remoteStorage/remoteConfig.d.ts +2 -2
  48. package/storage/remoteStorage/remoteConfig.ts +19 -3
  49. package/storage/remoteStorage/serverConfig.d.ts +27 -0
  50. package/storage/remoteStorage/serverConfig.ts +109 -0
  51. package/storage/remoteStorage/sourceWrapper.d.ts +3 -3
  52. package/storage/remoteStorage/sourceWrapper.ts +7 -6
  53. package/storage/remoteStorage/sourcesList.ts +3 -0
  54. package/storage/remoteStorage/storageController.d.ts +118 -36
  55. package/storage/remoteStorage/storageController.ts +166 -168
  56. package/storage/remoteStorage/storageServer.ts +1 -1
  57. package/storage/remoteStorage/storageServerState.d.ts +52 -63
  58. package/storage/remoteStorage/storageServerState.ts +340 -584
  59. package/storage/remoteStorage/storePlan.d.ts +34 -0
  60. package/storage/remoteStorage/storePlan.ts +166 -0
  61. package/storage/remoteStorage/validation.d.ts +4 -0
  62. package/storage/remoteStorage/validation.ts +31 -0
@@ -4,17 +4,18 @@ import { runInfinitePoll, delay } from "socket-function/src/batching";
4
4
  import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
5
5
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
6
6
  import {
7
- IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, GetConfig, GetInfoConfig, assertValidLastModified,
8
- windowAcceptsWrites, SyncActivity, FULL_ROUTE, copyArchiveFile,
7
+ IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, assertValidLastModified,
8
+ windowAcceptsWrites, SyncActivity, FULL_ROUTE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, copyArchiveFile,
9
9
  } from "../IArchives";
10
10
  import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
11
11
  import { ArchivesBackblaze } from "../backblaze";
12
12
  import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
13
+ import { selectEntryAt } from "./storePlan";
13
14
  import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
14
15
  import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
15
16
  import { SourcesList } from "./sourcesList";
16
17
 
17
- // The storage engine of the remote storage server. Data lives in synchronization sources (at minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last modified time, size, and which source currently holds the data) in a BulkDatabase2, and synchronizes the index from all sources (see ArchivesSource in IArchives.ts). Every startup fully rescans each source's metadata, so the index self-heals; the file with the highest write time wins across all sources, so multiple sources need no stacking order.
18
+ // The storage engine of the remote storage server. Data lives in synchronization sources (at minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last modified time, size, and which source currently holds the data) in a BulkDatabase2, and synchronizes the index from all sources (see ArchivesSource in IArchives.ts). Every startup fully rescans each source's metadata, so the index self-heals; the file with the highest write time wins across all sources, so multiple sources need no stacking order. The store also holds its own routing entries (the self entries of the ONE route it serves), so it validates writes itself: valid windows, routes, immutability, and internal-push acceptance.
18
19
 
19
20
  export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
20
21
  const FAST_FLUSH_POLL = 1000 * 15;
@@ -45,26 +46,17 @@ const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
45
46
  // A reconcile pass skips failing files (one bad value must not stop the rest), but this many failures in a row means the target itself is down, so the pass aborts until the next scan cycle
46
47
  const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
47
48
  const RECONCILE_ERROR_LOG_LIMIT = 3;
49
+ const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
48
50
 
49
- export type WriteConfig = {
50
- // Resolve once the write is in memory; flush to the sources after writeDelay, coalescing writes to the same key (only the latest is written). Data is lost if the process crashes first.
51
- fast?: boolean;
52
- writeDelay?: number;
53
- // Stamps the write with this last-write time instead of now. Older than the current file's time no-ops; more than 15 minutes in the future throws. See IArchives.set.
54
- lastModified?: number;
55
- };
56
-
57
- // What the storage server needs from a bucket's store. BlobStore implements it fully; ArchivesDisk also satisfies it (used directly for rawDisk buckets), minus the optional index-backed methods.
51
+ // What the storage server needs from a bucket's store. BlobStore implements it fully (with validation from its routing entries); RawDiskStore adapts an ArchivesDisk for rawDisk buckets (no index, no sync, no validation - raw means raw).
58
52
  export type IBucketStore = {
59
- get(fileName: string, config?: GetConfig): Promise<Buffer | undefined>;
60
- get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
61
- // Internal (store-to-store) forms; see GetConfig.internal / SetConfig.internal. Absent on rawDisk stores, where the normal forms already ARE the disk.
62
- getInternal2?(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
63
- setInternal?(fileName: string, data: Buffer, config: { lastModified: number }): Promise<void>;
64
- set(fileName: string, data: Buffer, config?: WriteConfig): Promise<string>;
65
- del(fileName: string, config?: WriteConfig): Promise<void>;
66
- getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined>;
67
- findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
53
+ /** internal (store-to-store) reads answer purely from the local disk; see GetConfig.internal */
54
+ get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
55
+ /** internal (store-to-store) writes go to the local disk + index with no fan-out; see SetConfig.internal */
56
+ set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void>;
57
+ del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void>;
58
+ getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined>;
59
+ findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]>;
68
60
  getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
69
61
  getSyncStatus?(): Promise<ArchivesSyncStatus>;
70
62
  getSyncProgress?(): {
@@ -74,12 +66,52 @@ export type IBucketStore = {
74
66
  syncing: SyncActivity[];
75
67
  };
76
68
  computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }>;
77
- startLargeUpload(): Promise<string>;
78
- appendLargeUpload(id: string, data: Buffer): Promise<void>;
79
- finishLargeUpload(id: string, key: string, lastModified?: number): Promise<void>;
80
- cancelLargeUpload(id: string): Promise<void>;
69
+ /** path/lastModified let the store reject an upload into an immutable bucket before any bytes move */
70
+ startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string>;
71
+ appendLargeUpload(config: { id: string; data: Buffer }): Promise<void>;
72
+ finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void>;
73
+ cancelLargeUpload(config: { id: string }): Promise<void>;
81
74
  };
82
75
 
76
+ /** rawDisk buckets: the disk IS the store. No index, no synchronization, no window/route/immutability validation. */
77
+ export class RawDiskStore implements IBucketStore {
78
+ constructor(private disk: ArchivesDisk) { }
79
+
80
+ public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
81
+ return await this.disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
82
+ }
83
+ public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
84
+ await this.disk.set(config.path, config.data, { lastModified: config.lastModified });
85
+ }
86
+ public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
87
+ if (config.path === ROUTING_FILE) {
88
+ throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
89
+ }
90
+ await this.disk.del(config.path, { lastModified: config.lastModified });
91
+ }
92
+ public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
93
+ return await this.disk.getInfo(config.path, { includeTombstones: config.includeTombstones });
94
+ }
95
+ public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
96
+ return await this.disk.findInfo(config.prefix, { shallow: config.shallow, type: config.type });
97
+ }
98
+ public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
99
+ return await this.disk.getChangesAfter2(config);
100
+ }
101
+ public async startLargeUpload(): Promise<string> {
102
+ return await this.disk.startLargeUpload();
103
+ }
104
+ public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
105
+ await this.disk.appendLargeUpload(config.id, config.data);
106
+ }
107
+ public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
108
+ await this.disk.finishLargeUpload(config.id, config.path, config.lastModified);
109
+ }
110
+ public async cancelLargeUpload(config: { id: string }): Promise<void> {
111
+ await this.disk.cancelLargeUpload(config.id);
112
+ }
113
+ }
114
+
83
115
  type OverlayEntry = {
84
116
  // A zero-length buffer is a pending delete (tombstone)
85
117
  data: Buffer;
@@ -99,7 +131,7 @@ type IndexEntry = {
99
131
  writeTime: number;
100
132
  size: number;
101
133
  sourcesListIndex: number;
102
- // When WE last changed this entry (not the file's write time) — what getChangesAfter2 filters on, so late-arriving files with old write times are still reported as changes
134
+ // When WE last changed this entry (not the file's write time) — what getChangesAfter2 filters on, so late-arriving files with old write times are still reported
103
135
  changedAt: number;
104
136
  // In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU eviction. Starts as changedAt on load.
105
137
  lastAccess: number;
@@ -158,6 +190,13 @@ function newSourceState(): SourceState {
158
190
  };
159
191
  }
160
192
 
193
+ let lastWrongTargetLog = 0;
194
+ function logWrongTargetRejection(message: string): void {
195
+ if (Date.now() - lastWrongTargetLog < WRONG_TARGET_LOG_THROTTLE) return;
196
+ lastWrongTargetLog = Date.now();
197
+ console.log(message);
198
+ }
199
+
161
200
  export class BlobStore implements IBucketStore {
162
201
  constructor(
163
202
  private folder: string,
@@ -171,78 +210,12 @@ export class BlobStore implements IBucketStore {
171
210
  onWriteCounted?: (kind: "original" | "flushed", bytes: number) => void;
172
211
  // Resolves a persisted source URL (see ArchivesSource.url) to a cached IArchives, so entries whose holder is no longer configured can still be read
173
212
  resolveSourceUrl?: (url: string) => IArchives;
213
+ // This store's own entries in the routing config (all the same route, different valid windows). What set/del validate against: valid windows and routes for fresh writes, immutability, internal-push acceptance, and the fast/writeDelay flags. Empty = no validation (a store serving leftover disk data with no config).
214
+ entries?: HostedConfig[];
174
215
  }
175
216
  ) { }
176
217
 
177
- private stopped = { stop: false };
178
-
179
- // The index's BulkDatabase2 files live under <folder>/index. "blobIndex2": the "blobIndex" generation persisted sources-array positions as the holding source, which are not stable across runs - its entries are unusable, so it is simply never read again.
180
- private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex2", noopReactiveDeps, async (p: string) => {
181
- let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
182
- for (let part of p.split("/")) {
183
- if (part) base = await base.getDirectoryHandle(part, { create: true });
184
- }
185
- return wrapHandle(base);
186
- });
187
- // The in-memory copy of the index. All reads are served from it; changes are buffered in dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
188
- private mem = new Map<string, IndexEntry>();
189
- // Live totals over mem (tombstones excluded), adjusted on every mutation and recomputed on load - so any drift heals on restart. computeIndexTotals gives the walk-the-index truth.
190
- private indexFileCount = 0;
191
- private indexByteCount = 0;
192
- // The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
193
- private sourceFileCounts = this.sources.map(() => 0);
194
- private sourceByteCounts = this.sources.map(() => 0);
195
- // Background scans / full syncs currently in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
196
- private syncActivities = new Set<SyncActivity>();
197
- private dirty = new Map<string, IndexEntry | undefined>();
198
- private overlay = new Map<string, OverlayEntry>();
199
- private sourceStates = this.sources.map(() => newSourceState());
200
- private syncStarted = false;
201
- // The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
202
- private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
203
- // Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
204
- private slotSourcesListIndexes: number[] = [];
205
- private slotRegistrations: Promise<void>[] = [];
206
-
207
- private isLive(sourceIndex: number): boolean {
208
- return !!this.sources[sourceIndex] && !this.sourceStates[sourceIndex].dead;
209
- }
210
-
211
- private registerSlot(slot: number): Promise<void> {
212
- let existing = this.slotRegistrations[slot];
213
- if (existing) return existing;
214
- let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
215
- this.slotSourcesListIndexes[slot] = index;
216
- });
217
- this.slotRegistrations[slot] = registration;
218
- return registration;
219
- }
220
-
221
- // The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and runSourceSync guarantee that before any indexing happens)
222
- private sourcesListIndexOfSlot(slot: number): number {
223
- let index = this.slotSourcesListIndexes[slot];
224
- if (index === undefined) {
225
- throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
226
- }
227
- return index;
228
- }
229
-
230
- // The live slot currently serving a persistent sourcesListIndex, or undefined when no configured source has that URL anymore. Linear, but the sources list is tiny and this is always current (slots dying, or several slots sharing one URL across valid windows, need no bookkeeping).
231
- private slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
232
- for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
233
- if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
234
- }
235
- return undefined;
236
- }
237
-
238
- // The IArchives currently holding an entry's bytes: the live slot when the holder is still configured, otherwise resolved (cached) straight from its persisted URL - windows/routes decide when a source is scanned or written, but for reading bytes we know it holds, the URL alone is enough
239
- private async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
240
- let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
241
- if (slot !== undefined) return this.sources[slot].source;
242
- let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
243
- if (!url) return undefined;
244
- return this.config?.resolveSourceUrl?.(url);
245
- }
218
+ // #region Main interface
246
219
 
247
220
  public init = lazy(async () => {
248
221
  for (let i = 0; i < this.sources.length; i++) {
@@ -272,62 +245,281 @@ export class BlobStore implements IBucketStore {
272
245
  await this.flushIndex();
273
246
  }
274
247
 
275
- private async loadIndex(): Promise<void> {
276
- let [writeTimes, sizes, sourcesListIndexes] = await Promise.all([
277
- this.index.getColumn("writeTime"),
278
- this.index.getColumn("size"),
279
- this.index.getColumn("sourcesListIndex"),
280
- ]);
281
- let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
282
- let sourcesListIndexMap = new Map(sourcesListIndexes.map(x => [x.key, x.value]));
283
- for (let entry of writeTimes) {
284
- let size = sizeMap.get(entry.key);
285
- let sourcesListIndex = sourcesListIndexMap.get(entry.key);
286
- // Explicit checks, as 0 is a valid size and a valid sourcesListIndex
287
- if (size === undefined || sourcesListIndex === undefined) continue;
288
- // The routing config is only ever read off our own disk (see updateScanIndex), and a loaded bucket always has it there - a persisted entry pointing elsewhere is stale
289
- if (entry.key === ROUTING_FILE) {
290
- sourcesListIndex = this.sourcesListIndexOfSlot(0);
248
+ public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
249
+ if (config.internal) {
250
+ return await this.getInternal2(config);
251
+ }
252
+ await this.init();
253
+ let key = config.path;
254
+ let range = config.range;
255
+ let overlayEntry = this.overlay.get(key);
256
+ if (overlayEntry) {
257
+ // An empty file IS a missing file (tombstone)
258
+ if (overlayEntry.data.length === 0 && !config.includeTombstones) return undefined;
259
+ let data = overlayEntry.data;
260
+ let size = data.length;
261
+ if (range) {
262
+ data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
291
263
  }
292
- let full: IndexEntry = { writeTime: entry.value, size, sourcesListIndex, changedAt: entry.time, lastAccess: entry.time };
293
- this.mem.set(entry.key, full);
294
- this.countEntry(full, 1);
264
+ return { data, writeTime: overlayEntry.t, size };
295
265
  }
266
+ let entry = await this.getIndexEntry(key);
267
+ if (!entry) return undefined;
268
+ if (entry.size === 0) {
269
+ if (!config.includeTombstones) return undefined;
270
+ // A tombstone has no stored bytes - the index entry alone is the deletion, so the flag-caller gets its write time with empty data
271
+ return { data: Buffer.alloc(0), writeTime: entry.writeTime, size: 0 };
272
+ }
273
+ entry.lastAccess = Date.now();
274
+ let holderArchives = await this.getEntryHolder(entry);
275
+ let result: { data: Buffer; writeTime: number; size: number } | undefined;
276
+ let holderError: Error | undefined;
277
+ if (holderArchives) {
278
+ try {
279
+ // includeTombstones: the holder answering "deleted" (a tombstone, with its write time) must be distinguishable from "I lost the file" - the former is authoritative and must NOT fall back to older copies, which would resurrect the deletion for this read
280
+ let answer = await holderArchives.get2(key, { range, internal: true, includeTombstones: true });
281
+ if (answer && answer.data && !answer.data.length && !(range && answer.size) && answer.writeTime >= entry.writeTime) {
282
+ return undefined;
283
+ }
284
+ // NOTE: a ranged read of a real file can legitimately be empty (range past EOF), so only unranged emptiness means tombstone/absent
285
+ if (answer && answer.data && (answer.data.length || range && answer.size)) {
286
+ result = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
287
+ }
288
+ } catch (e) {
289
+ holderError = e as Error;
290
+ }
291
+ }
292
+ if (result) {
293
+ // Ranged reads can't populate a cache (they're partial)
294
+ if (this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
295
+ await this.cacheRead(key, result);
296
+ }
297
+ return result;
298
+ }
299
+ // The routing file is only ever read off our own disk - falling back to another source's copy would synchronize it between nodes through the read path, which it never is
300
+ if (key === ROUTING_FILE) {
301
+ if (holderError) throw holderError;
302
+ return undefined;
303
+ }
304
+ // The holder is down or lost the file. ANY other source's copy beats no value - even an OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
305
+ let holderSlot = this.slotForSourcesListIndex(entry.sourcesListIndex);
306
+ for (let i = 0; i < this.sources.length; i++) {
307
+ if (i === holderSlot || !this.isLive(i)) continue;
308
+ let fallback: { data: Buffer; writeTime: number; size: number } | undefined;
309
+ try {
310
+ let answer = await this.sources[i].source.get2(key, { internal: true });
311
+ // Empty data counts as absent - a tombstone, not content (this read is unranged, so emptiness is unambiguous)
312
+ if (answer && answer.data && answer.data.length) {
313
+ fallback = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
314
+ }
315
+ } catch {
316
+ continue;
317
+ }
318
+ if (!fallback) continue;
319
+ await this.cacheRead(key, fallback);
320
+ let data = fallback.data;
321
+ if (range) {
322
+ data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
323
+ }
324
+ return { data, writeTime: fallback.writeTime, size: fallback.size };
325
+ }
326
+ if (holderError) throw holderError;
327
+ // The holder answered "not there" and no other source has it either: the entry was stale
328
+ this.deleteIndexEntry(key);
329
+ return undefined;
296
330
  }
297
331
 
298
- private countEntry(entry: IndexEntry | undefined, direction: number): void {
299
- if (!entry || entry.size === 0) return;
300
- this.indexFileCount += direction;
301
- this.indexByteCount += entry.size * direction;
302
- // Entries can reference a source no longer configured (readable via getEntryHolder, but with no slot to count under)
303
- let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
304
- if (slot !== undefined) {
305
- this.sourceFileCounts[slot] += direction;
306
- this.sourceByteCounts[slot] += entry.size * direction;
332
+ public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
333
+ let { path: key, data } = config;
334
+ if (!data.length) {
335
+ throw new Error(`set was called with an empty buffer for ${JSON.stringify(key)} (store ${this.folder}): an empty file IS a deletion in this system and would read back as missing - call del instead`);
336
+ }
337
+ await this.init();
338
+ let writeTime = config.lastModified || Date.now();
339
+ let route = getRoute(key);
340
+ // The routing file defines the windows/routes, so they can't possibly apply to it (and it never flows through validation)
341
+ if (key !== ROUTING_FILE && this.entries.length) {
342
+ if (!config.lastModified) {
343
+ let timeValid = this.entries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
344
+ if (!timeValid.length) {
345
+ logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(this.entries.map(x => x.validWindow))} (a switchover moved the write target)`);
346
+ throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This store is not a valid write target at ${writeTime} (our valid windows: ${JSON.stringify(this.entries.map(x => x.validWindow))}, store ${this.folder}). Re-resolve the currently valid source and retry.`);
347
+ }
348
+ if (!timeValid.some(x => routeContains(x.route, route))) {
349
+ logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
350
+ throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
351
+ }
352
+ }
353
+ if (config.forceSetImmutable) {
354
+ if (!config.lastModified) {
355
+ throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
356
+ }
357
+ // Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
358
+ let self = selectEntryAt(this.entries, writeTime, route);
359
+ if (self?.immutable && await this.getInfo({ path: key })) return;
360
+ } else {
361
+ await this.assertMutable(key, writeTime);
362
+ }
363
+ }
364
+ if (config.internal) {
365
+ if (!config.lastModified) {
366
+ throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
367
+ }
368
+ this.assertInternalWriteAccepted(key, config.lastModified, route);
369
+ await this.setInternal(key, data, { lastModified: config.lastModified });
370
+ return;
307
371
  }
372
+ let self = this.entries.length && selectEntryAt(this.entries, writeTime, route) || undefined;
373
+ await this.setOrDelete(key, data, { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
308
374
  }
309
375
 
310
- private setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): void {
311
- let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
312
- this.countEntry(this.mem.get(key), -1);
313
- this.countEntry(full, 1);
314
- this.mem.set(key, full);
315
- this.dirty.set(key, full);
316
- this.config?.onIndexChanged?.(key);
376
+ public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
377
+ let key = config.path;
378
+ if (key === ROUTING_FILE) {
379
+ throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
380
+ }
381
+ await this.init();
382
+ if (config.internal) {
383
+ if (!config.lastModified) {
384
+ throw new Error(`Internal deletions must carry lastModified (they are synchronization pushes, ordered by their write time), deleting ${JSON.stringify(key)} (store ${this.folder})`);
385
+ }
386
+ this.assertInternalWriteAccepted(key, config.lastModified, getRoute(key));
387
+ // setInternal treats an empty buffer as exactly a deletion: disk removal plus a tombstone index entry, no fan-out
388
+ await this.setInternal(key, Buffer.alloc(0), { lastModified: config.lastModified });
389
+ return;
390
+ }
391
+ // Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is ordered by write time like any other write, propagates through synchronization, and expires after TOMBSTONE_EXPIRY
392
+ let writeTime = config.lastModified || Date.now();
393
+ let self = this.entries.length && selectEntryAt(this.entries, writeTime, getRoute(key)) || undefined;
394
+ await this.setOrDelete(key, Buffer.alloc(0), { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
317
395
  }
318
- private deleteIndexEntry(key: string): void {
319
- let existing = this.mem.get(key);
320
- if (!existing) return;
321
- this.countEntry(existing, -1);
322
- this.mem.delete(key);
323
- this.dirty.set(key, undefined);
396
+
397
+ public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
398
+ await this.init();
399
+ let key = config.path;
400
+ let overlayEntry = this.overlay.get(key);
401
+ if (overlayEntry) {
402
+ if (!overlayEntry.data.length && !config.includeTombstones) return undefined;
403
+ return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
404
+ }
405
+ let entry = await this.getIndexEntry(key);
406
+ if (!entry) return undefined;
407
+ if (!entry.size && !config.includeTombstones) return undefined;
408
+ return { writeTime: entry.writeTime, size: entry.size };
409
+ }
410
+
411
+ public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
412
+ await this.init();
413
+ await this.waitForRequiredScans();
414
+ let prefix = config.prefix;
415
+ let infos = new Map<string, ArchiveFileInfo>();
416
+ for (let [key, entry] of this.mem) {
417
+ if (!key.startsWith(prefix)) continue;
418
+ // Tombstones are missing files, so listings hide them
419
+ if (entry.size === 0) continue;
420
+ infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
421
+ }
422
+ for (let [key, overlayEntry] of this.overlay) {
423
+ if (!key.startsWith(prefix)) continue;
424
+ if (overlayEntry.data.length === 0) {
425
+ infos.delete(key);
426
+ continue;
427
+ }
428
+ infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
429
+ }
430
+ let files = applyFindInfoShape(Array.from(infos.values()), prefix, { shallow: config.shallow, type: config.type });
431
+ sort(files, x => x.path);
432
+ return files;
433
+ }
434
+
435
+ // All files changed after config.time — fast, straight from the in-memory index. Filters on when WE learned of the change (changedAt), so files synchronized late (with old write times) are still reported. Deletions ARE reported, as size-0 tombstone entries — that's how they propagate to stores syncing from us. config.routes lets a store syncing a partial shard ask for just its slice.
436
+ public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
437
+ await this.init();
438
+ await this.waitForRequiredScans();
439
+ let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
440
+ let files: ArchiveFileInfo[] = [];
441
+ for (let [key, entry] of this.mem) {
442
+ if (entry.changedAt <= config.time) continue;
443
+ if (this.overlay.has(key)) continue;
444
+ if (!inRoutes(key)) continue;
445
+ files.push({ path: key, createTime: entry.writeTime, size: entry.size });
446
+ }
447
+ for (let [key, overlayEntry] of this.overlay) {
448
+ if (overlayEntry.t <= config.time) continue;
449
+ if (!inRoutes(key)) continue;
450
+ files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
451
+ }
452
+ sort(files, x => x.path);
453
+ return files;
454
+ }
455
+
456
+ public async getSyncStatus(): Promise<ArchivesSyncStatus> {
457
+ await this.init();
458
+ return {
459
+ allScansComplete: this.sourceStates.every(x => x.scanComplete),
460
+ indexSize: this.mem.size,
461
+ sources: this.sources.map((x, i) => ({
462
+ debugName: x.source.getDebugName(),
463
+ validWindow: x.validWindow,
464
+ route: x.route,
465
+ noFullSync: x.noFullSync,
466
+ supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
467
+ initialScanComplete: this.sourceStates[i].scanComplete,
468
+ scannedCount: this.sourceStates[i].scannedCount,
469
+ })).filter((x, i) => this.isLive(i)),
470
+ };
471
+ }
472
+
473
+ /** The cheap always-current totals plus any in-progress background synchronization. */
474
+ public getSyncProgress(): {
475
+ index: { fileCount: number; byteCount: number };
476
+ sources: { debugName: string; fileCount: number; byteCount: number }[];
477
+ readerDiskLimit?: number;
478
+ syncing: SyncActivity[];
479
+ } {
480
+ return {
481
+ index: { fileCount: this.indexFileCount, byteCount: this.indexByteCount },
482
+ sources: this.sources.map((x, i) => ({
483
+ debugName: x.source.getDebugName(),
484
+ fileCount: this.sourceFileCounts[i],
485
+ byteCount: this.sourceByteCounts[i],
486
+ })).filter((x, i) => this.isLive(i)),
487
+ readerDiskLimit: this.config?.readerDiskLimit,
488
+ syncing: [...this.syncActivities],
489
+ };
490
+ }
491
+
492
+ /** Walks the whole index for exact totals - more expensive than getSyncProgress, but immune to any drift in the maintained counters (and loads the index first, so it's never cold zeros). */
493
+ public async computeIndexTotals(): Promise<{
494
+ fileCount: number;
495
+ byteCount: number;
496
+ sources: { debugName: string; fileCount: number; byteCount: number }[];
497
+ }> {
498
+ await this.init();
499
+ let fileCount = 0;
500
+ let byteCount = 0;
501
+ let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
502
+ for (let entry of this.mem.values()) {
503
+ if (entry.size === 0) continue;
504
+ fileCount++;
505
+ byteCount += entry.size;
506
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
507
+ if (slot !== undefined) {
508
+ sources[slot].fileCount++;
509
+ sources[slot].byteCount += entry.size;
510
+ }
511
+ }
512
+ return { fileCount, byteCount, sources: sources.filter((x, i) => this.isLive(i)) };
324
513
  }
325
514
 
326
515
  /** Applies a config change to the RUNNING store: windows/routes update in place, new sources are added (their sync starts immediately), and removed sources' slots go dead (their scans stop, their index entries drop). The store survives every routine config evolution - it is never destroyed for a source-list change, only for structural flips it cannot express (rawDisk). Pending fast writes are re-capped to the new flush deadline (flushing immediately when it has already passed). */
327
- public updateSources(specs: BlobSourceSpec[]): void {
516
+ public updateSources(specs: BlobSourceSpec[], entries?: HostedConfig[]): void {
328
517
  if (!specs.length || specs[0].identity !== "disk") {
329
518
  throw new Error(`updateSources expects the disk source first (identity "disk"), got ${JSON.stringify(specs.map(x => x.identity))} (store ${this.folder})`);
330
519
  }
520
+ if (entries) {
521
+ this.entries = entries;
522
+ }
331
523
  let setWindow = (i: number, window: [number, number]) => {
332
524
  let old = this.sources[i].validWindow;
333
525
  if (old[0] === window[0] && old[1] === window[1]) return;
@@ -392,29 +584,6 @@ export class BlobStore implements IBucketStore {
392
584
  }
393
585
  }
394
586
 
395
- // The slot stays in the arrays forever (index entries reference sources by slot number); it just goes dead - loops stop, and its index entries drop (other sources' scans re-find any copy that's still reachable through the new config)
396
- private removeSource(sourceIndex: number): void {
397
- let state = this.sourceStates[sourceIndex];
398
- let source = this.sources[sourceIndex].source;
399
- state.dead = true;
400
- state.stopped.stop = true;
401
- state.scanComplete = true;
402
- state.initialScan.resolve(undefined);
403
- let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
404
- // The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
405
- if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
406
- let dropped = 0;
407
- for (let [key, entry] of this.mem) {
408
- if (entry.sourcesListIndex !== sourcesListIndex) continue;
409
- this.deleteIndexEntry(key);
410
- dropped++;
411
- }
412
- console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
413
- return;
414
- }
415
- console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped (its URL is still served by another slot, so its index entries stay)`);
416
- }
417
-
418
587
  /** Rescans our own disk's metadata into the index - used around valid window handoffs, where another process wrote files to the shared folder that our index hasn't seen. */
419
588
  public async rescanBase(): Promise<void> {
420
589
  await this.init();
@@ -460,49 +629,181 @@ export class BlobStore implements IBucketStore {
460
629
  tally.new++;
461
630
  }
462
631
  }
463
- console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
632
+ console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
633
+ }
634
+
635
+ // Large uploads stream onto the local disk source directly (they may not fit in memory)
636
+ public async startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string> {
637
+ await this.init();
638
+ if (config?.path) {
639
+ await this.assertMutable(config.path, config.lastModified || Date.now());
640
+ }
641
+ return await this.getDiskSource().disk.startLargeUpload();
642
+ }
643
+ public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
644
+ await this.getDiskSource().disk.appendLargeUpload(config.id, config.data);
645
+ }
646
+ public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
647
+ let { disk, sourceIndex } = this.getDiskSource();
648
+ await disk.finishLargeUpload(config.id, config.path, config.lastModified);
649
+ this.overlay.delete(config.path);
650
+ // includeTombstones: a zero-byte upload is still a real file whose index entry must be written
651
+ let info = await disk.getInfo(config.path, { includeTombstones: true });
652
+ if (info) {
653
+ this.setIndexEntry(config.path, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
654
+ }
655
+ }
656
+ public async cancelLargeUpload(config: { id: string }): Promise<void> {
657
+ await this.getDiskSource().disk.cancelLargeUpload(config.id);
658
+ }
659
+
660
+ // #endregion
661
+
662
+ // #region Internals
663
+
664
+ private stopped = { stop: false };
665
+
666
+ // The index's BulkDatabase2 files live under <folder>/index. "blobIndex2": the "blobIndex" generation persisted sources-array positions as the holding source, which are not stable across runs - its entries are unusable, so it is simply never read again.
667
+ private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex2", noopReactiveDeps, async (p: string) => {
668
+ let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
669
+ for (let part of p.split("/")) {
670
+ if (part) base = await base.getDirectoryHandle(part, { create: true });
671
+ }
672
+ return wrapHandle(base);
673
+ });
674
+ // The in-memory copy of the index. All reads are served from it; changes are buffered in dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
675
+ private mem = new Map<string, IndexEntry>();
676
+ // Live totals over mem (tombstones excluded), adjusted on every mutation and recomputed on load - so any drift heals on restart. computeIndexTotals gives the walk-the-index truth.
677
+ private indexFileCount = 0;
678
+ private indexByteCount = 0;
679
+ // The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
680
+ private sourceFileCounts = this.sources.map(() => 0);
681
+ private sourceByteCounts = this.sources.map(() => 0);
682
+ // Background scans / full syncs currently in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
683
+ private syncActivities = new Set<SyncActivity>();
684
+ private dirty = new Map<string, IndexEntry | undefined>();
685
+ private overlay = new Map<string, OverlayEntry>();
686
+ private sourceStates = this.sources.map(() => newSourceState());
687
+ private syncStarted = false;
688
+ private entries = this.config?.entries || [];
689
+ // The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
690
+ private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
691
+ // Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
692
+ private slotSourcesListIndexes: number[] = [];
693
+ private slotRegistrations: Promise<void>[] = [];
694
+
695
+ private isLive(sourceIndex: number): boolean {
696
+ return !!this.sources[sourceIndex] && !this.sourceStates[sourceIndex].dead;
697
+ }
698
+
699
+ private registerSlot(slot: number): Promise<void> {
700
+ let existing = this.slotRegistrations[slot];
701
+ if (existing) return existing;
702
+ let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
703
+ this.slotSourcesListIndexes[slot] = index;
704
+ });
705
+ this.slotRegistrations[slot] = registration;
706
+ return registration;
707
+ }
708
+
709
+ // The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and runSourceSync guarantee that before any indexing happens)
710
+ private sourcesListIndexOfSlot(slot: number): number {
711
+ let index = this.slotSourcesListIndexes[slot];
712
+ if (index === undefined) {
713
+ throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
714
+ }
715
+ return index;
716
+ }
717
+
718
+ // The live slot currently serving a persistent sourcesListIndex, or undefined when no configured source has that URL anymore. Linear, but the sources list is tiny and this is always current (slots dying, or several slots sharing one URL across valid windows, need no bookkeeping).
719
+ private slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
720
+ for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
721
+ if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
722
+ }
723
+ return undefined;
724
+ }
725
+
726
+ // The IArchives currently holding an entry's bytes: the live slot when the holder is still configured, otherwise resolved (cached) straight from its persisted URL - windows/routes decide when a source is scanned or written, but for reading bytes we know it holds, the URL alone is enough
727
+ private async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
728
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
729
+ if (slot !== undefined) return this.sources[slot].source;
730
+ let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
731
+ if (!url) return undefined;
732
+ return this.config?.resolveSourceUrl?.(url);
733
+ }
734
+
735
+ private async loadIndex(): Promise<void> {
736
+ let [writeTimes, sizes, sourcesListIndexes] = await Promise.all([
737
+ this.index.getColumn("writeTime"),
738
+ this.index.getColumn("size"),
739
+ this.index.getColumn("sourcesListIndex"),
740
+ ]);
741
+ let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
742
+ let sourcesListIndexMap = new Map(sourcesListIndexes.map(x => [x.key, x.value]));
743
+ for (let entry of writeTimes) {
744
+ let size = sizeMap.get(entry.key);
745
+ let sourcesListIndex = sourcesListIndexMap.get(entry.key);
746
+ // Explicit checks, as 0 is a valid size and a valid sourcesListIndex
747
+ if (size === undefined || sourcesListIndex === undefined) continue;
748
+ // The routing config is only ever read off our own disk (see updateScanIndex), and a loaded bucket always has it there - a persisted entry pointing elsewhere is stale
749
+ if (entry.key === ROUTING_FILE) {
750
+ sourcesListIndex = this.sourcesListIndexOfSlot(0);
751
+ }
752
+ let full: IndexEntry = { writeTime: entry.value, size, sourcesListIndex, changedAt: entry.time, lastAccess: entry.time };
753
+ this.mem.set(entry.key, full);
754
+ this.countEntry(full, 1);
755
+ }
464
756
  }
465
757
 
466
- /** The cheap always-current totals plus any in-progress background synchronization. */
467
- public getSyncProgress(): {
468
- index: { fileCount: number; byteCount: number };
469
- sources: { debugName: string; fileCount: number; byteCount: number }[];
470
- readerDiskLimit?: number;
471
- syncing: SyncActivity[];
472
- } {
473
- return {
474
- index: { fileCount: this.indexFileCount, byteCount: this.indexByteCount },
475
- sources: this.sources.map((x, i) => ({
476
- debugName: x.source.getDebugName(),
477
- fileCount: this.sourceFileCounts[i],
478
- byteCount: this.sourceByteCounts[i],
479
- })).filter((x, i) => this.isLive(i)),
480
- readerDiskLimit: this.config?.readerDiskLimit,
481
- syncing: [...this.syncActivities],
482
- };
758
+ private countEntry(entry: IndexEntry | undefined, direction: number): void {
759
+ if (!entry || entry.size === 0) return;
760
+ this.indexFileCount += direction;
761
+ this.indexByteCount += entry.size * direction;
762
+ // Entries can reference a source no longer configured (readable via getEntryHolder, but with no slot to count under)
763
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
764
+ if (slot !== undefined) {
765
+ this.sourceFileCounts[slot] += direction;
766
+ this.sourceByteCounts[slot] += entry.size * direction;
767
+ }
483
768
  }
484
769
 
485
- /** Walks the whole index for exact totals - more expensive than getSyncProgress, but immune to any drift in the maintained counters (and loads the index first, so it's never cold zeros). */
486
- public async computeIndexTotals(): Promise<{
487
- fileCount: number;
488
- byteCount: number;
489
- sources: { debugName: string; fileCount: number; byteCount: number }[];
490
- }> {
491
- await this.init();
492
- let fileCount = 0;
493
- let byteCount = 0;
494
- let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
495
- for (let entry of this.mem.values()) {
496
- if (entry.size === 0) continue;
497
- fileCount++;
498
- byteCount += entry.size;
499
- let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
500
- if (slot !== undefined) {
501
- sources[slot].fileCount++;
502
- sources[slot].byteCount += entry.size;
770
+ private setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): void {
771
+ let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
772
+ this.countEntry(this.mem.get(key), -1);
773
+ this.countEntry(full, 1);
774
+ this.mem.set(key, full);
775
+ this.dirty.set(key, full);
776
+ this.config?.onIndexChanged?.(key);
777
+ }
778
+ private deleteIndexEntry(key: string): void {
779
+ let existing = this.mem.get(key);
780
+ if (!existing) return;
781
+ this.countEntry(existing, -1);
782
+ this.mem.delete(key);
783
+ this.dirty.set(key, undefined);
784
+ }
785
+
786
+ // The slot stays in the arrays forever (index entries reference sources by slot number); it just goes dead - loops stop, and its index entries drop (other sources' scans re-find any copy that's still reachable through the new config)
787
+ private removeSource(sourceIndex: number): void {
788
+ let state = this.sourceStates[sourceIndex];
789
+ let source = this.sources[sourceIndex].source;
790
+ state.dead = true;
791
+ state.stopped.stop = true;
792
+ state.scanComplete = true;
793
+ state.initialScan.resolve(undefined);
794
+ let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
795
+ // The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
796
+ if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
797
+ let dropped = 0;
798
+ for (let [key, entry] of this.mem) {
799
+ if (entry.sourcesListIndex !== sourcesListIndex) continue;
800
+ this.deleteIndexEntry(key);
801
+ dropped++;
503
802
  }
803
+ console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
804
+ return;
504
805
  }
505
- return { fileCount, byteCount, sources: sources.filter((x, i) => this.isLive(i)) };
806
+ console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped (its URL is still served by another slot, so its index entries stay)`);
506
807
  }
507
808
 
508
809
  private async flushIndex(): Promise<void> {
@@ -522,6 +823,26 @@ export class BlobStore implements IBucketStore {
522
823
  if (deletes.length) await this.index.deleteBatch(deletes);
523
824
  }
524
825
 
826
+ // ── validation (from this store's own routing entries) ──
827
+
828
+ private async assertMutable(key: string, writeTime: number): Promise<void> {
829
+ if (!this.entries.length) return;
830
+ let self = selectEntryAt(this.entries, writeTime, getRoute(key));
831
+ if (!self?.immutable) return;
832
+ if (await this.getInfo({ path: key })) {
833
+ throw new Error(`This store is immutable (at write time ${writeTime}) and ${JSON.stringify(key)} already exists, so it cannot be written to (store ${this.folder})`);
834
+ }
835
+ }
836
+
837
+ // See SetConfig.internal: the stamp must land inside SOME window+route this store is configured for (any window, including past ones - synchronization moves old data), so a confused peer can't stuff data onto a store that was never meant to hold it
838
+ private assertInternalWriteAccepted(key: string, writeTime: number, route: number): void {
839
+ if (!this.entries.length) return;
840
+ let covered = this.entries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
841
+ if (!covered) {
842
+ throw new Error(`Internal write of ${JSON.stringify(key)} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this store is configured for: ${JSON.stringify(this.entries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))} (store ${this.folder})`);
843
+ }
844
+ }
845
+
525
846
  // ── synchronization ──
526
847
 
527
848
  private async runSourceSync(sourceIndex: number): Promise<void> {
@@ -862,97 +1183,23 @@ export class BlobStore implements IBucketStore {
862
1183
  return this.mem.get(key);
863
1184
  }
864
1185
 
865
- // ── data operations ──
866
-
867
- public async get(key: string, config?: GetConfig): Promise<Buffer | undefined> {
868
- let result = await this.get2(key, config);
869
- return result && result.data || undefined;
870
- }
871
-
872
- public async get2(key: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
1186
+ /** Internal (store-to-store) read: purely the local disk, completely short-circuiting the index and holder resolution - the caller is another store, and chasing OUR remote holders while answering it is how infinite get loops between stores form. No window or route checks: if the bytes are on our disk, the caller may have them. Note fast writes still sitting in the overlay are invisible here; the caller re-finds them after our flush. */
1187
+ private async getInternal2(config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
873
1188
  await this.init();
874
- let range = config?.range;
875
- let overlayEntry = this.overlay.get(key);
876
- if (overlayEntry) {
877
- // An empty file IS a missing file (tombstone)
878
- if (overlayEntry.data.length === 0) return undefined;
879
- let data = overlayEntry.data;
880
- let size = data.length;
881
- if (range) {
882
- data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
883
- }
884
- return { data, writeTime: overlayEntry.t, size };
885
- }
886
- let entry = await this.getIndexEntry(key);
887
- if (!entry) return undefined;
888
- if (entry.size === 0) return undefined;
889
- entry.lastAccess = Date.now();
890
- let holderArchives = await this.getEntryHolder(entry);
891
- let result: { data: Buffer; writeTime: number; size: number } | undefined;
892
- let holderError: Error | undefined;
893
- if (holderArchives) {
894
- try {
895
- let answer = await holderArchives.get2(key, { range, internal: true });
896
- // Empty data counts as absent: an empty file IS a deletion (a tombstone stored on b2/disk), never content. NOTE: a ranged read of a real file can legitimately be empty (range past EOF), so only unranged emptiness means absent.
897
- if (answer && answer.data && (answer.data.length || range && answer.size)) {
898
- result = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
899
- }
900
- } catch (e) {
901
- holderError = e as Error;
902
- }
903
- }
904
- if (result) {
905
- // Ranged reads can't populate a cache (they're partial)
906
- if (this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
907
- await this.cacheRead(key, result);
1189
+ // includeTombstones forwards to the disk: a flag-caller (a peer store's synchronization) needs to see our deletions, not just our content. A tombstone deleted from disk entirely only lives in our index, so fall back to that.
1190
+ let result = await this.getDiskSource().disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
1191
+ if (!result || !result.data) {
1192
+ if (config.includeTombstones) {
1193
+ let entry = this.mem.get(config.path);
1194
+ if (entry && !entry.size) return { data: Buffer.alloc(0), writeTime: entry.writeTime, size: 0 };
908
1195
  }
909
- return result;
910
- }
911
- // The routing file is only ever read off our own disk - falling back to another source's copy would synchronize it between nodes through the read path, which it never is
912
- if (key === ROUTING_FILE) {
913
- if (holderError) throw holderError;
914
1196
  return undefined;
915
1197
  }
916
- // The holder is down or lost the file. ANY other source's copy beats no value - even an OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
917
- let holderSlot = this.slotForSourcesListIndex(entry.sourcesListIndex);
918
- for (let i = 0; i < this.sources.length; i++) {
919
- if (i === holderSlot || !this.isLive(i)) continue;
920
- let fallback: { data: Buffer; writeTime: number; size: number } | undefined;
921
- try {
922
- let answer = await this.sources[i].source.get2(key, { internal: true });
923
- // Empty data counts as absent - a tombstone, not content (this read is unranged, so emptiness is unambiguous)
924
- if (answer && answer.data && answer.data.length) {
925
- fallback = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
926
- }
927
- } catch {
928
- continue;
929
- }
930
- if (!fallback) continue;
931
- await this.cacheRead(key, fallback);
932
- let data = fallback.data;
933
- if (range) {
934
- data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
935
- }
936
- return { data, writeTime: fallback.writeTime, size: fallback.size };
937
- }
938
- if (holderError) throw holderError;
939
- // The holder answered "not there" and no other source has it either: the entry was stale
940
- this.deleteIndexEntry(key);
941
- return undefined;
942
- }
943
-
944
- /** Internal (store-to-store) read: purely the local disk, completely short-circuiting the index and holder resolution - the caller is another store, and chasing OUR remote holders while answering it is how infinite get loops between stores form. No window or route checks: if the bytes are on our disk, the caller may have them. Note fast writes still sitting in the overlay are invisible here; the caller re-finds them after our flush. */
945
- public async getInternal2(key: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
946
- await this.init();
947
- let result = await this.getDiskSource().disk.get2(key, { range: config?.range });
948
- if (!result || !result.data) return undefined;
949
- // An empty file on disk is a tombstone, not content (a ranged read of a real file can be legitimately empty though - range past EOF)
950
- if (!result.data.length && !(config?.range && result.size)) return undefined;
951
1198
  return { data: result.data, writeTime: result.writeTime, size: result.size };
952
1199
  }
953
1200
 
954
- /** Internal (store-to-store) write: the local disk plus our index, with NO downstream fan-out - the pushing store owns propagation, and fanning its pushes back out is how write loops between stores form. Window/route acceptance is the caller's (writeBucketFile's) job; only-take-latest still applies here. */
955
- public async setInternal(key: string, data: Buffer, config: { lastModified: number }): Promise<void> {
1201
+ /** Internal (store-to-store) write: the local disk plus our index, with NO downstream fan-out - the pushing store owns propagation, and fanning its pushes back out is how write loops between stores form. Only-take-latest still applies here. */
1202
+ private async setInternal(key: string, data: Buffer, config: { lastModified: number }): Promise<void> {
956
1203
  await this.init();
957
1204
  assertValidLastModified(config.lastModified);
958
1205
  let overlayEntry = this.overlay.get(key);
@@ -974,33 +1221,20 @@ export class BlobStore implements IBucketStore {
974
1221
  this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
975
1222
  }
976
1223
 
977
- public async set(key: string, data: Buffer, config?: WriteConfig): Promise<string> {
978
- if (!data.length) {
979
- throw new Error(`set was called with an empty buffer for ${JSON.stringify(key)} (store ${this.folder}): an empty file IS a deletion in this system and would read back as missing - call del instead`);
980
- }
981
- return await this.setOrDelete(key, data, config);
982
- }
983
-
984
- public async del(key: string, config?: WriteConfig): Promise<void> {
985
- // Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is ordered by write time like any other write, propagates through synchronization, and expires after TOMBSTONE_EXPIRY
986
- await this.setOrDelete(key, Buffer.alloc(0), config);
987
- }
988
-
989
1224
  // The shared engine of set and del: an empty buffer is exactly a deletion here, which is why the empty-buffer rejection lives in set (the public API), not in this machinery
990
- private async setOrDelete(key: string, data: Buffer, config?: WriteConfig): Promise<string> {
991
- await this.init();
1225
+ private async setOrDelete(key: string, data: Buffer, config: { fast?: boolean; writeDelay?: number; lastModified?: number }): Promise<void> {
992
1226
  this.config?.onWriteCounted?.("original", data.length);
993
- let lastModified = config?.lastModified;
1227
+ let lastModified = config.lastModified;
994
1228
  if (lastModified) {
995
1229
  assertValidLastModified(lastModified);
996
1230
  let overlayEntry = this.overlay.get(key);
997
1231
  let entry = this.mem.get(key);
998
1232
  let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
999
1233
  // An older write never overwrites a newer one (see IArchives.set)
1000
- if (lastModified < currentTime) return key;
1234
+ if (lastModified < currentTime) return;
1001
1235
  }
1002
1236
  let writeTime = lastModified || Date.now();
1003
- if (config?.fast) {
1237
+ if (config.fast) {
1004
1238
  // A writeDelay of zero is a real choice (no delay at all), so only an omitted delay gets the default
1005
1239
  let writeDelay = config.writeDelay;
1006
1240
  if (writeDelay === undefined) {
@@ -1011,15 +1245,11 @@ export class BlobStore implements IBucketStore {
1011
1245
  if (writeDelay > 0 && Date.now() < deadline) {
1012
1246
  let flushAt = Math.min(Date.now() + writeDelay, deadline);
1013
1247
  this.overlay.set(key, { data, t: writeTime, flushAt });
1014
- return key;
1248
+ return;
1015
1249
  }
1016
- this.overlay.delete(key);
1017
- await this.writeToSources(key, data, writeTime);
1018
- return key;
1019
1250
  }
1020
1251
  this.overlay.delete(key);
1021
1252
  await this.writeToSources(key, data, writeTime);
1022
- return key;
1023
1253
  }
1024
1254
 
1025
1255
  private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
@@ -1066,83 +1296,6 @@ export class BlobStore implements IBucketStore {
1066
1296
  }
1067
1297
  }
1068
1298
 
1069
- public async getInfo(key: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined> {
1070
- await this.init();
1071
- let overlayEntry = this.overlay.get(key);
1072
- if (overlayEntry) {
1073
- if (!overlayEntry.data.length && !config?.includeTombstones) return undefined;
1074
- return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
1075
- }
1076
- let entry = await this.getIndexEntry(key);
1077
- if (!entry) return undefined;
1078
- if (!entry.size && !config?.includeTombstones) return undefined;
1079
- return { writeTime: entry.writeTime, size: entry.size };
1080
- }
1081
-
1082
- public async findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
1083
- await this.init();
1084
- await this.waitForRequiredScans();
1085
- let infos = new Map<string, ArchiveFileInfo>();
1086
- for (let [key, entry] of this.mem) {
1087
- if (!key.startsWith(prefix)) continue;
1088
- // Tombstones are missing files, so listings hide them
1089
- if (entry.size === 0) continue;
1090
- infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
1091
- }
1092
- for (let [key, overlayEntry] of this.overlay) {
1093
- if (!key.startsWith(prefix)) continue;
1094
- if (overlayEntry.data.length === 0) {
1095
- infos.delete(key);
1096
- continue;
1097
- }
1098
- infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
1099
- }
1100
- let files = applyFindInfoShape(Array.from(infos.values()), prefix, config);
1101
- sort(files, x => x.path);
1102
- return files;
1103
- }
1104
-
1105
- // All files changed after config.time — fast, straight from the in-memory index. Filters on when WE learned of the change (changedAt), so files synchronized late (with old write times) are still reported. Deletions ARE reported, as size-0 tombstone entries — that's how they propagate to stores syncing from us. config.routes lets a store syncing a partial shard ask for just its slice.
1106
- public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
1107
- await this.init();
1108
- await this.waitForRequiredScans();
1109
- let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
1110
- let files: ArchiveFileInfo[] = [];
1111
- for (let [key, entry] of this.mem) {
1112
- if (entry.changedAt <= config.time) continue;
1113
- if (this.overlay.has(key)) continue;
1114
- if (!inRoutes(key)) continue;
1115
- files.push({ path: key, createTime: entry.writeTime, size: entry.size });
1116
- }
1117
- for (let [key, overlayEntry] of this.overlay) {
1118
- if (overlayEntry.t <= config.time) continue;
1119
- if (!inRoutes(key)) continue;
1120
- files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
1121
- }
1122
- sort(files, x => x.path);
1123
- return files;
1124
- }
1125
-
1126
- public async getSyncStatus(): Promise<ArchivesSyncStatus> {
1127
- await this.init();
1128
- return {
1129
- allScansComplete: this.sourceStates.every(x => x.scanComplete),
1130
- indexSize: this.mem.size,
1131
- sources: this.sources.map((x, i) => ({
1132
- debugName: x.source.getDebugName(),
1133
- validWindow: x.validWindow,
1134
- route: x.route,
1135
- noFullSync: x.noFullSync,
1136
- supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
1137
- initialScanComplete: this.sourceStates[i].scanComplete,
1138
- scannedCount: this.sourceStates[i].scannedCount,
1139
- })).filter((x, i) => this.isLive(i)),
1140
- };
1141
- }
1142
-
1143
- // ── large uploads ──
1144
- // Large uploads stream onto the local disk source directly (they may not fit in memory)
1145
-
1146
1299
  private getDiskSource(): { disk: ArchivesDisk; sourceIndex: number } {
1147
1300
  for (let i = 0; i < this.sources.length; i++) {
1148
1301
  let source = this.sources[i].source;
@@ -1150,26 +1303,6 @@ export class BlobStore implements IBucketStore {
1150
1303
  }
1151
1304
  throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
1152
1305
  }
1153
- public async startLargeUpload(): Promise<string> {
1154
- await this.init();
1155
- return await this.getDiskSource().disk.startLargeUpload();
1156
- }
1157
- public async appendLargeUpload(id: string, data: Buffer): Promise<void> {
1158
- await this.getDiskSource().disk.appendLargeUpload(id, data);
1159
- }
1160
- public async finishLargeUpload(id: string, key: string, lastModified?: number): Promise<void> {
1161
- let { disk, sourceIndex } = this.getDiskSource();
1162
- await disk.finishLargeUpload(id, key, lastModified);
1163
- this.overlay.delete(key);
1164
- // includeTombstones: a zero-byte upload is still a real file whose index entry must be written
1165
- let info = await disk.getInfo(key, { includeTombstones: true });
1166
- if (info) {
1167
- this.setIndexEntry(key, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
1168
- }
1169
- }
1170
- public async cancelLargeUpload(id: string): Promise<void> {
1171
- await this.getDiskSource().disk.cancelLargeUpload(id);
1172
- }
1173
1306
 
1174
1307
  private async flushOverlay(force?: boolean): Promise<void> {
1175
1308
  let now = Date.now();
@@ -1251,4 +1384,6 @@ export class BlobStore implements IBucketStore {
1251
1384
  }
1252
1385
  }
1253
1386
  }
1387
+
1388
+ // #endregion
1254
1389
  }