sliftutils 1.7.88 → 1.7.89

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 (125) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/dist/treeSummary.ts.cache +2 -2
  3. package/index.d.ts +674 -295
  4. package/misc/dist/environment.ts.cache +2 -2
  5. package/misc/dist/getSecret.ts.cache +2 -2
  6. package/misc/dist/strings.ts.cache +2 -2
  7. package/misc/dist/zip.ts.cache +2 -2
  8. package/misc/https/dist/certs.ts.cache +2 -2
  9. package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
  10. package/misc/https/dist/dns.ts.cache +2 -2
  11. package/misc/https/dist/hostServer.ts.cache +49 -21
  12. package/misc/https/dist/hostsFile.ts.cache +82 -0
  13. package/misc/https/dist/httpsCerts.ts.cache +2 -2
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/hostServer.d.ts +6 -2
  16. package/misc/https/hostServer.ts +52 -19
  17. package/misc/https/hostsFile.d.ts +7 -0
  18. package/misc/https/hostsFile.ts +66 -0
  19. package/package.json +1 -1
  20. package/render-utils/dist/observer.tsx.cache +2 -2
  21. package/storage/ArchivesDisk.d.ts +3 -6
  22. package/storage/ArchivesDisk.ts +26 -2
  23. package/storage/BulkDatabase2/BulkDatabaseReader.ts +1 -1
  24. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  25. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  26. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  27. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +3 -3
  28. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  29. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  30. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  31. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  32. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  33. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  34. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  35. package/storage/IArchives.d.ts +37 -24
  36. package/storage/IArchives.ts +47 -62
  37. package/storage/LogMap.d.ts +56 -0
  38. package/storage/LogMap.ts +220 -0
  39. package/storage/archiveHelpers.d.ts +36 -0
  40. package/storage/archiveHelpers.ts +103 -0
  41. package/storage/backblaze.d.ts +7 -12
  42. package/storage/backblaze.ts +53 -48
  43. package/storage/dist/ArchivesDisk.ts.cache +28 -2
  44. package/storage/dist/FileFolderAPI.tsx.cache +2 -2
  45. package/storage/dist/IArchives.ts.cache +16 -52
  46. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  47. package/storage/dist/JSONStorage.ts.cache +2 -2
  48. package/storage/dist/LogMap.ts.cache +212 -0
  49. package/storage/dist/PendingManager.tsx.cache +2 -2
  50. package/storage/dist/TransactionStorage.ts.cache +2 -2
  51. package/storage/dist/backblaze.ts.cache +58 -44
  52. package/storage/dist/fileSystemPointer.ts.cache +2 -2
  53. package/storage/dist/remoteFileStorage.ts.cache +2 -2
  54. package/storage/remoteStorage/ArchivesDelayed.d.ts +66 -0
  55. package/storage/remoteStorage/ArchivesDelayed.ts +237 -0
  56. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -6
  57. package/storage/remoteStorage/ArchivesRemote.ts +26 -5
  58. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -5
  59. package/storage/remoteStorage/ArchivesUrl.ts +2 -2
  60. package/storage/remoteStorage/accessPage.tsx +12 -0
  61. package/storage/remoteStorage/blobStore.d.ts +162 -189
  62. package/storage/remoteStorage/blobStore.ts +495 -952
  63. package/storage/remoteStorage/bucketDisk.d.ts +24 -3
  64. package/storage/remoteStorage/bucketDisk.ts +56 -22
  65. package/storage/remoteStorage/certTrustModal.d.ts +1 -0
  66. package/storage/remoteStorage/certTrustModal.tsx +48 -0
  67. package/storage/remoteStorage/cliArgs.d.ts +2 -0
  68. package/storage/remoteStorage/cliArgs.ts +5 -0
  69. package/storage/remoteStorage/createArchives.d.ts +8 -7
  70. package/storage/remoteStorage/createArchives.ts +65 -26
  71. package/storage/remoteStorage/deployTakeover.ts +1 -1
  72. package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +220 -0
  73. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +25 -5
  74. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
  75. package/storage/remoteStorage/dist/accessPage.tsx.cache +13 -3
  76. package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
  77. package/storage/remoteStorage/dist/blobStore.ts.cache +483 -965
  78. package/storage/remoteStorage/dist/bucketDisk.ts.cache +60 -29
  79. package/storage/remoteStorage/dist/certTrustModal.tsx.cache +55 -0
  80. package/storage/remoteStorage/dist/cliArgs.ts.cache +9 -4
  81. package/storage/remoteStorage/dist/createArchives.ts.cache +63 -26
  82. package/storage/remoteStorage/dist/deployTakeover.ts.cache +5 -5
  83. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +286 -0
  84. package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
  85. package/storage/remoteStorage/dist/remoteConfig.ts.cache +122 -56
  86. package/storage/remoteStorage/dist/serverConfig.ts.cache +4 -4
  87. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -11
  88. package/storage/remoteStorage/dist/sourcesList.ts.cache +3 -6
  89. package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
  90. package/storage/remoteStorage/dist/storageController.ts.cache +27 -12
  91. package/storage/remoteStorage/dist/storageServer.ts.cache +27 -7
  92. package/storage/remoteStorage/dist/storageServerCli.ts.cache +7 -3
  93. package/storage/remoteStorage/dist/storageServerState.ts.cache +133 -696
  94. package/storage/remoteStorage/dist/storeConfig.ts.cache +52 -0
  95. package/storage/remoteStorage/dist/storePlan.ts.cache +110 -81
  96. package/storage/remoteStorage/dist/storeSources.ts.cache +51 -0
  97. package/storage/remoteStorage/dist/storeSync.ts.cache +650 -0
  98. package/storage/remoteStorage/dist/validation.ts.cache +14 -4
  99. package/storage/remoteStorage/intermediateManagement.d.ts +12 -0
  100. package/storage/remoteStorage/intermediateManagement.ts +264 -0
  101. package/storage/remoteStorage/remoteConfig.d.ts +32 -0
  102. package/storage/remoteStorage/remoteConfig.ts +115 -50
  103. package/storage/remoteStorage/serverConfig.d.ts +1 -1
  104. package/storage/remoteStorage/serverConfig.ts +1 -1
  105. package/storage/remoteStorage/sourceWrapper.ts +4 -8
  106. package/storage/remoteStorage/sourcesList.d.ts +0 -2
  107. package/storage/remoteStorage/sourcesList.ts +0 -3
  108. package/storage/remoteStorage/spec.md +18 -4
  109. package/storage/remoteStorage/storageController.d.ts +10 -0
  110. package/storage/remoteStorage/storageController.ts +29 -16
  111. package/storage/remoteStorage/storageServer.d.ts +2 -0
  112. package/storage/remoteStorage/storageServer.ts +28 -4
  113. package/storage/remoteStorage/storageServerCli.ts +5 -1
  114. package/storage/remoteStorage/storageServerState.d.ts +34 -21
  115. package/storage/remoteStorage/storageServerState.ts +147 -662
  116. package/storage/remoteStorage/storeConfig.d.ts +30 -0
  117. package/storage/remoteStorage/storeConfig.ts +62 -0
  118. package/storage/remoteStorage/storePlan.d.ts +29 -17
  119. package/storage/remoteStorage/storePlan.ts +112 -85
  120. package/storage/remoteStorage/storeSources.d.ts +11 -0
  121. package/storage/remoteStorage/storeSources.ts +47 -0
  122. package/storage/remoteStorage/storeSync.d.ts +53 -0
  123. package/storage/remoteStorage/storeSync.ts +632 -0
  124. package/storage/remoteStorage/validation.d.ts +2 -0
  125. package/storage/remoteStorage/validation.ts +10 -0
@@ -0,0 +1,632 @@
1
+ import { runInfinitePoll, delay } from "socket-function/src/batching";
2
+ import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
3
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
4
+ import {
5
+ IArchives, ArchiveFileInfo, ArchivesSyncStatus, SyncActivity, FULL_ROUTE,
6
+ windowsAcceptWrites,
7
+ } from "../IArchives";
8
+ import { copyArchiveFile } from "../archiveHelpers";
9
+ import { ArchivesBackblaze } from "../backblaze";
10
+ import { ROUTING_FILE, getRoute, routeContains, parseRoutingData, getConfigVersion } from "./remoteConfig";
11
+ import type { BlobStore, IndexEntry } from "./blobStore";
12
+
13
+ // Everything that keeps a store's index in agreement with its sources, plus the two maintenance loops that follow from holding an index (evicting a bounded disk cache, expiring tombstones). The store owns the sources and decides where writes go; this decides what is scanned, pulled, pushed, evicted, and forgotten - so the write path stays readable without the several hundred lines of synchronization machinery interleaved into it.
14
+
15
+ // Sources with a native (index-backed) change feed are polled this often
16
+ const CHANGES_POLL_INTERVAL = 1000 * 60;
17
+ // The routing config is checked for on every peer this often - far more often than a full rescan, because it is the file that decides what everything else does, and it is one small read per peer
18
+ const CONFIG_POLL_INTERVAL = 1000 * 60 * 5;
19
+ // Full metadata rescans. supportsChangesAfter is the heuristic for "one of our own storage servers": their index-backed listings are cheap, so hourly is fine. Everything else (backblaze, plain disk) pays the full listing cost, so it rescans much less often.
20
+ const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
21
+ const FULL_RESCAN_UNINDEXED_INTERVAL = 1000 * 60 * 60 * 6;
22
+ // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
23
+ const CHANGES_POLL_OVERLAP = timeInMinute;
24
+ const SCAN_RETRY_DELAY = 1000 * 30;
25
+ // Deletes are tombstones (an empty file IS a missing file): the size-0 index entry is what lets a deletion propagate/reconcile like any other write, and it expires after this long
26
+ const TOMBSTONE_EXPIRY = 1000 * 60 * 60 * 24 * 7;
27
+ const TOMBSTONE_CLEANUP_INTERVAL = 1000 * 60 * 60;
28
+ // While a metadata scan or full sync is running, its progress is logged this often
29
+ const SYNC_PROGRESS_LOG_INTERVAL = 1000 * 60;
30
+ const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
31
+ // Full syncs download this many files concurrently (high-latency sources like backblaze would otherwise crawl one round-trip at a time)
32
+ const FULL_SYNC_PARALLEL = 8;
33
+ // A full sync running longer than this is console.errored (and again every interval after), so a sync that will take days is loud instead of a quiet console.log every minute
34
+ const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
35
+ // 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
36
+ const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
37
+ const RECONCILE_ERROR_LOG_LIMIT = 3;
38
+
39
+ type SourceState = {
40
+ supportsChangesAfter: boolean;
41
+ initialScan: ReturnType<typeof promiseObj>;
42
+ scanComplete: boolean;
43
+ // Files seen in this source's scans / change polls so far
44
+ scannedCount: number;
45
+ // Watermark for getChangesAfter2 polls
46
+ changesAfterTime: number;
47
+ // Per-slot stop token: a removed source's loops stop without touching the rest of the store
48
+ stopped: { stop: boolean };
49
+ // A removed source's slot stays in the arrays (they are never spliced, so slot numbers held by running loops stay meaningful), marked dead - never scanned, written, or read. Index entries don't reference slots at all; they persist the sources list's sourcesListIndex.
50
+ dead?: boolean;
51
+ };
52
+
53
+ function newSourceState(): SourceState {
54
+ return {
55
+ supportsChangesAfter: false,
56
+ initialScan: promiseObj(),
57
+ scanComplete: false,
58
+ scannedCount: 0,
59
+ changesAfterTime: 0,
60
+ stopped: { stop: false },
61
+ };
62
+ }
63
+
64
+ // What a scanned listing entry meant when compared against our index
65
+ type ScanOutcome = "filtered" | "new" | "updated" | "tombstone" | "unchanged";
66
+ type ScanTally = Record<ScanOutcome, number>;
67
+ function newScanTally(): ScanTally {
68
+ return { filtered: 0, new: 0, updated: 0, tombstone: 0, unchanged: 0 };
69
+ }
70
+ function formatScanTally(tally: ScanTally, total: number): string {
71
+ let pct = (n: number) => `${Math.round(n / Math.max(total, 1) * 1000) / 10}%`;
72
+ return `${tally.new} new paths (${pct(tally.new)}), ${tally.updated} newer writes (${pct(tally.updated)}), ${tally.tombstone} deletions (${pct(tally.tombstone)}), ${tally.unchanged} unchanged (${pct(tally.unchanged)}), ${tally.filtered} outside route (${pct(tally.filtered)})`;
73
+ }
74
+
75
+ export class StoreSync {
76
+ // Per source slot, in step with the store's sources array. Slots are never spliced out - a removed one is marked dead - so a slot number held by a running loop always means the same source.
77
+ private states: SourceState[];
78
+ // Scans / full syncs in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
79
+ private activities = new Set<SyncActivity>();
80
+ // Guards against a second eviction pass starting while one is still walking the index
81
+ private evicting = false;
82
+ // When each key was last served, for readerDiskLimit's LRU eviction. Never persisted (a restart has no opinion about recency), and only kept for keys that have actually been read - an index entry no one has touched falls back to when we last changed it.
83
+ private lastAccess = new Map<string, number>();
84
+
85
+ constructor(private store: BlobStore) {
86
+ this.states = store.sources.map(() => newSourceState());
87
+ }
88
+
89
+ /** Starts every live source's synchronization, plus the maintenance loops. Called once, by the store's init - the store's index must already be loaded, since scans write straight into it. */
90
+ public start(): void {
91
+ for (let i = 0; i < this.store.sources.length; i++) {
92
+ if (!this.isLive(i)) continue;
93
+ void this.runSourceSync(i);
94
+ }
95
+ runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
96
+ runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
97
+ // Read live: the limit comes from the config, so a store that gains one later starts enforcing it without being restarted
98
+ runInfinitePoll(DISK_LIMIT_CHECK_INTERVAL, () => this.enforceDiskLimit(), this.store.stopped);
99
+ }
100
+
101
+ /**
102
+ * Asks every peer for the routing config, and takes it if it is newer than ours. This is the whole
103
+ * of configuration propagation: it rides beside the scans rather than being part of them, because
104
+ * one small read is worth doing every few minutes while a full listing is not, and because a
105
+ * config we are missing is what stops everything else from being right.
106
+ *
107
+ * The copy is stored the same way a scan stores anything it pulls - an internal write into our own
108
+ * store - and the store notices that file landing and re-configures itself. Nothing here knows what
109
+ * a config means; it only knows this one file is worth asking for often.
110
+ */
111
+ private async pollRoutingConfig(): Promise<void> {
112
+ for (let i = 1; i < this.store.sources.length; i++) {
113
+ if (!this.isLive(i)) continue;
114
+ if (this.store.sources[i].intermediate) continue;
115
+ let { source } = this.store.sources[i];
116
+ try {
117
+ let theirs = await source.get2(ROUTING_FILE, { internal: true });
118
+ if (!theirs || !theirs.data || !theirs.data.length) continue;
119
+ let version = getConfigVersion(parseRoutingData(theirs.data));
120
+ if (version <= this.store.routingVersion()) continue;
121
+ console.log(`Routing config version ${version} found on ${source.getDebugName()} (we have ${this.store.routingVersion()}, store ${this.store.folder}): taking it`);
122
+ // Stamped NOW, not with their write time: configs are ordered by version, and this one is newer by that ordering. Their file can easily be older by write time (ours was rewritten locally more recently), and the store's only-take-the-latest rule would then drop it - leaving us finding the same newer version on every poll and never adopting it.
123
+ await this.store.set({ path: ROUTING_FILE, data: theirs.data, lastModified: Date.now(), internal: true });
124
+ } catch (e) {
125
+ // A peer that cannot answer is the normal case for a source that is down; the next poll asks again
126
+ console.error(`Checking ${source.getDebugName()} for a newer routing config failed (store ${this.store.folder}): ${(e as Error).stack ?? e}`);
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Stops every source's loops (the store's own stop token stops the maintenance loops). */
132
+ public stop(): void {
133
+ for (let state of this.states) {
134
+ state.stopped.stop = true;
135
+ }
136
+ }
137
+
138
+ /** A slot the store just appended to its sources array: it starts from nothing, so it gets a full scan. */
139
+ public addSource(slot: number): void {
140
+ this.states[slot] = newSourceState();
141
+ if (this.store.syncStarted) {
142
+ void this.runSourceSync(slot);
143
+ }
144
+ }
145
+
146
+ /** The slot stays in the store's arrays forever (running loops hold slot numbers); 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). */
147
+ public async removeSource(slot: number): Promise<void> {
148
+ let state = this.states[slot];
149
+ let source = this.store.sources[slot].source;
150
+ state.dead = true;
151
+ state.stopped.stop = true;
152
+ state.scanComplete = true;
153
+ state.initialScan.resolve(undefined);
154
+ let sourcesListIndex = this.store.slotSourcesListIndex(slot);
155
+ // The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
156
+ if (sourcesListIndex !== undefined && this.store.slotForSourcesListIndex(sourcesListIndex) === undefined) {
157
+ let dropped = 0;
158
+ for (let [key, entry] of this.store.indexEntries()) {
159
+ if (entry.sourcesListIndex !== sourcesListIndex) continue;
160
+ // Forgotten, not deleted: the files still exist wherever that source put them, we just no longer have a way to reach them
161
+ this.store.purgeIndexEntry(key);
162
+ dropped++;
163
+ }
164
+ console.log(`Removed sync source ${source.getDebugName()} (store ${this.store.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
165
+ return;
166
+ }
167
+ console.log(`Removed sync source ${source.getDebugName()} (store ${this.store.folder}): its scans are stopped (its URL is still served by another slot, so its index entries stay)`);
168
+ }
169
+
170
+ /** Whether a slot is still configured. Dead slots are never scanned, written, or read. */
171
+ public isLive(slot: number): boolean {
172
+ return !!this.store.sources[slot] && !this.states[slot]?.dead;
173
+ }
174
+
175
+ public getActivities(): SyncActivity[] {
176
+ return [...this.activities];
177
+ }
178
+
179
+ /** A key was just served, so it goes to the back of the eviction queue. */
180
+ public noteAccess(key: string): void {
181
+ this.lastAccess.set(key, Date.now());
182
+ }
183
+
184
+ // Whether the index still holds exactly the entry a pass picked up earlier. Index rows are read out of the database per lookup, so this compares the row, not the object - a write landing mid-pass changes at least one of these three.
185
+ private entryUnchanged(key: string, entry: IndexEntry): boolean {
186
+ let current = this.store.getIndexEntry(key);
187
+ if (!current) return false;
188
+ return current.writeTime === entry.writeTime && current.size === entry.size && current.sourcesListIndex === entry.sourcesListIndex;
189
+ }
190
+
191
+ public getStatus(): ArchivesSyncStatus {
192
+ return {
193
+ allScansComplete: this.states.every(x => x.scanComplete),
194
+ indexSize: this.store.indexSize(),
195
+ sources: this.store.sources.map((x, i) => ({
196
+ debugName: x.source.getDebugName(),
197
+ validWindows: x.validWindows,
198
+ route: x.route,
199
+ noFullSync: x.noFullSync,
200
+ supportsChangesAfter: this.states[i].supportsChangesAfter,
201
+ initialScanComplete: this.states[i].scanComplete,
202
+ scannedCount: this.states[i].scannedCount,
203
+ })).filter((x, i) => this.isLive(i)),
204
+ };
205
+ }
206
+
207
+ /** Listings come straight from the index, so they must wait for our own base source's initial scan (which might lag minutes) before they are trustworthy. The base (local disk) is implicitly required - remote sources are not, they come and go. */
208
+ public async waitForRequiredScans(): Promise<void> {
209
+ await this.states[0].initialScan.promise;
210
+ }
211
+
212
+ /** 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. */
213
+ public async rescanBase(): Promise<void> {
214
+ await this.scanSource(0);
215
+ }
216
+
217
+ /** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes): just its changes since the boundary neighborhood, with matching values pulled onto our own disk. */
218
+ public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
219
+ let scanStart = Date.now();
220
+ console.log(`Boundary scan of ${source.getDebugName()} starting: changes since ${new Date(config.since).toISOString()}, route ${JSON.stringify(config.route || FULL_ROUTE)} (store ${this.store.folder})`);
221
+ let changes = await source.getChangesAfter2({ time: config.since, routes: config.route && [config.route] || undefined });
222
+ let tally = newScanTally();
223
+ for (let file of changes) {
224
+ if (file.path === ROUTING_FILE) {
225
+ tally.filtered++;
226
+ continue;
227
+ }
228
+ let currentTime = this.store.currentWriteTime(file.path);
229
+ if (file.createTime <= currentTime) {
230
+ tally.unchanged++;
231
+ continue;
232
+ }
233
+ if (file.size === 0) {
234
+ // Size 0 in a listing IS a deletion (an empty file is a missing file), so it is taken as one
235
+ this.store.setIndexDeleted(file.path, file.createTime);
236
+ tally.tombstone++;
237
+ continue;
238
+ }
239
+ let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, size: file.size, writeTime: file.createTime, forceSetImmutable: true, noChecks: true, internal: true });
240
+ if (!copied) continue;
241
+ if (copied.size === 0) {
242
+ this.store.setIndexDeleted(file.path, copied.writeTime);
243
+ tally.tombstone++;
244
+ continue;
245
+ }
246
+ this.store.setIndexEntry(file.path, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
247
+ if (currentTime) {
248
+ tally.updated++;
249
+ } else {
250
+ tally.new++;
251
+ }
252
+ }
253
+ console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.store.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
254
+ }
255
+
256
+ // ── per-source loops ──
257
+
258
+ private async runSourceSync(sourceIndex: number): Promise<void> {
259
+ await this.store.registerSlot(sourceIndex);
260
+ let { source } = this.store.sources[sourceIndex];
261
+ let state = this.states[sourceIndex];
262
+ // Read live for every pass, not captured - the store's source list can change while loops run
263
+ let noFullSync = () => this.store.sources[sourceIndex].noFullSync;
264
+ let listing: Map<string, number> | undefined;
265
+ // An intermediate is a deploy switchover's temporary alternate PORT onto a source we already have: the same bucket, reachable another way for a few minutes. Scanning it would list exactly what scanning that source lists, against a port that is about to disappear - so it is never scanned, and the source it was split out of covers it for as long as it exists and after it is gone.
266
+ if (this.store.sources[sourceIndex].intermediate) {
267
+ state.scanComplete = true;
268
+ state.initialScan.resolve(undefined);
269
+ return;
270
+ }
271
+ while (!this.store.stopped.stop && !state.stopped.stop) {
272
+ try {
273
+ let config = await source.getConfig();
274
+ state.supportsChangesAfter = !!config.supportsChangesAfter;
275
+ listing = await this.scanSource(sourceIndex);
276
+ break;
277
+ } catch (e) {
278
+ console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
279
+ await delay(SCAN_RETRY_DELAY);
280
+ }
281
+ }
282
+ state.scanComplete = true;
283
+ state.initialScan.resolve(undefined);
284
+ if (this.store.stopped.stop || state.stopped.stop) return;
285
+ if (listing) {
286
+ await this.reconcileSource(sourceIndex, listing);
287
+ }
288
+ if (!noFullSync()) {
289
+ try {
290
+ await this.copySourceFiles(sourceIndex);
291
+ } catch (e) {
292
+ console.error(`Copying files from sync source ${source.getDebugName()} failed:`, e);
293
+ }
294
+ }
295
+ if (state.supportsChangesAfter) {
296
+ runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
297
+ await this.pollChanges(sourceIndex);
298
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex);
299
+ }, state.stopped);
300
+ // Change polls only show what the source HAS, never what it's missing, so pushes run on the full-rescan cadence (findInfo on an index-backed source is cheap)
301
+ runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
302
+ let files = await source.findInfo("");
303
+ await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
304
+ }, state.stopped);
305
+ } else {
306
+ runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
307
+ let rescan = await this.scanSource(sourceIndex);
308
+ await this.reconcileSource(sourceIndex, rescan);
309
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex);
310
+ }, state.stopped);
311
+ }
312
+ }
313
+
314
+ // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the source's listing (path -> write time), which reconcileSource uses for the push direction.
315
+ private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
316
+ let { source, route } = this.store.sources[sourceIndex];
317
+ let state = this.states[sourceIndex];
318
+ let scanStart = Date.now();
319
+ let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
320
+ this.activities.add(activity);
321
+ console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.store.folder})`);
322
+ let progressTimer = setInterval(() => {
323
+ console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.store.folder})`);
324
+ }, SYNC_PROGRESS_LOG_INTERVAL);
325
+ (progressTimer as { unref?: () => void }).unref?.();
326
+ // The listing request deliberately takes no time or route filters: our slowest sources (backblaze) support neither, so filtering would happen after the full fetch anyway - little benefit, more room for desynchronization. And if a full listing ever becomes too big to send over the network, it is also too big for the receiving process to hold in memory - the fix is more routing shards (each storing and sending less), not filtering.
327
+ let files: ArchiveFileInfo[];
328
+ try {
329
+ files = await source.findInfo("");
330
+ } finally {
331
+ clearInterval(progressTimer);
332
+ this.activities.delete(activity);
333
+ }
334
+ // The source may have been removed while the listing was in flight; its results are dead
335
+ if (state.stopped.stop) return new Map();
336
+ let indexSizeBefore = this.store.indexSize();
337
+ let seen = new Map<string, number>();
338
+ let tally = newScanTally();
339
+ let newPaths = 0;
340
+ for (let file of files) {
341
+ seen.set(file.path, file.createTime);
342
+ if (!this.store.getIndexEntry(file.path)) {
343
+ newPaths++;
344
+ }
345
+ tally[this.updateScanIndex(sourceIndex, file)]++;
346
+ }
347
+ state.scannedCount = files.length;
348
+ // Entries this source was the holder of, but that its listing did not mention, are forgotten - we were wrong about where they are, which is not the same as them having been deleted. Entries changed after the scan started are kept: the listing may simply predate them. Tombstones are not walked here at all, because they are not files a listing could vouch for.
349
+ let removedFromIndex = 0;
350
+ let missingOnSource = 0;
351
+ let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
352
+ for (let [key, entry] of this.store.indexEntries()) {
353
+ if (seen.has(key)) continue;
354
+ if (entry.sourcesListIndex === scannedSourcesListIndex && entry.changedAt < scanStart) {
355
+ this.store.purgeIndexEntry(key);
356
+ removedFromIndex++;
357
+ continue;
358
+ }
359
+ // Counted only when the source SHOULD hold the entry (its route matches) - these are what the reconcile pass pushes to it (which also ignores the valid window: synchronization moves existing values, the window only routes fresh writes)
360
+ if (key === ROUTING_FILE) continue;
361
+ if (!routeContains(route, getRoute(key))) continue;
362
+ missingOnSource++;
363
+ }
364
+ // Percentages are of the union of both sides (our index + their listing), so every count has a stable denominator
365
+ let union = indexSizeBefore + newPaths;
366
+ let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
367
+ console.log(`Metadata scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.store.folder}): ${files.length} listed vs ${indexSizeBefore} indexed (union ${union}): ${formatScanTally(tally, union)}, ${missingOnSource} in index but missing on source (${pct(missingOnSource)}), ${removedFromIndex} removed from index (${pct(removedFromIndex)})`);
368
+ state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
369
+ return seen;
370
+ }
371
+
372
+ // The push direction of synchronization: everything we know that the source is missing (or holds an older copy of) is written to it — including deletions, as tombstone writes. This is what heals a source whose background writes failed (e.g. it was down): the next scan sees what's missing and re-sends it. A failing file is skipped, not fatal (immutable targets are handled by forceSetImmutable, and one unreadable value must not stop the rest of the pass) - only a run of consecutive failures (the source itself is down) aborts until the next scan cycle.
373
+ private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
374
+ let { source, validWindows, route } = this.store.sources[sourceIndex];
375
+ let state = this.states[sourceIndex];
376
+ let acceptsWrites = windowsAcceptWrites(validWindows);
377
+ let targetSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
378
+ let pushed = 0;
379
+ let failed = 0;
380
+ let consecutiveFailures = 0;
381
+ let errors: string[] = [];
382
+ let aborted = false;
383
+ // Files first, then the deletions - two walks now that they are two maps, and the deletions one is the short one
384
+ let pushable: { key: string; writeTime: number; entry?: IndexEntry }[] = [];
385
+ for (let [key, entry] of this.store.indexEntries()) {
386
+ if (entry.sourcesListIndex === targetSourcesListIndex) continue;
387
+ pushable.push({ key, writeTime: entry.writeTime, entry });
388
+ }
389
+ for (let [key, tombstone] of this.store.deletedEntries()) {
390
+ pushable.push({ key, writeTime: tombstone.writeTime });
391
+ }
392
+ for (let { key, writeTime, entry } of pushable) {
393
+ if (this.store.stopped.stop || state.stopped.stop) return;
394
+ // The routing file is not reconciled: it propagates by being asked for, on its own schedule (see pollRoutingConfig)
395
+ if (key === ROUTING_FILE) continue;
396
+ if (!acceptsWrites) continue;
397
+ if (!routeContains(route, getRoute(key))) continue;
398
+ let theirTime = listing.get(key);
399
+ if (theirTime !== undefined && theirTime >= writeTime) continue;
400
+ try {
401
+ if (!entry) {
402
+ // A deletion only needs pushing while the source still holds an older copy. It travels as del (never as an empty set - set rejects empty buffers), with the ORIGINAL deletion time so ordering survives.
403
+ if (theirTime === undefined) continue;
404
+ await source.del(key, { lastModified: writeTime, noChecks: true, internal: true });
405
+ pushed++;
406
+ consecutiveFailures = 0;
407
+ continue;
408
+ }
409
+ let holder = await this.store.getEntryHolder(entry);
410
+ if (!holder) continue;
411
+ let copied = await copyArchiveFile({ from: holder, to: source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
412
+ if (!copied) continue;
413
+ pushed++;
414
+ consecutiveFailures = 0;
415
+ } catch (e) {
416
+ failed++;
417
+ consecutiveFailures++;
418
+ if (errors.length < RECONCILE_ERROR_LOG_LIMIT) {
419
+ errors.push(`${key}: ${(e as Error).stack ?? e}`);
420
+ }
421
+ if (consecutiveFailures >= RECONCILE_MAX_CONSECUTIVE_FAILURES) {
422
+ aborted = true;
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ if (failed) {
428
+ console.error(`Reconciling sync source ${source.getDebugName()} (store ${this.store.folder}): pushed ${pushed} files, ${failed} failed${aborted && ` before aborting the pass (${consecutiveFailures} consecutive failures - the source looks down; the next scan cycle retries)` || ""}. First errors: ${errors.join(" | ")}`);
429
+ } else if (pushed) {
430
+ console.log(`Reconciled sync source ${source.getDebugName()} (store ${this.store.folder}): pushed ${pushed} files it was missing or held older copies of`);
431
+ }
432
+ }
433
+
434
+ private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
435
+ // An in-flight scan can outlive its source's removal; its results are dead
436
+ if (!this.isLive(sourceIndex)) return "filtered";
437
+ if (file.path === ROUTING_FILE) {
438
+ // The routing config is NEVER pulled from other sources - it only ever arrives as an explicit, version-validated write, and is only ever read off our own disk. Route and valid-window filters can't possibly apply to it either: it is the file DEFINING them, so filtering it would mean certain sources could never have their routing config updated, ever.
439
+ if (sourceIndex !== 0) return "filtered";
440
+ } else {
441
+ // The valid window is deliberately NOT applied here: it decides where WRITES route, but a scan is us asking a source what it already holds - existing values synchronize regardless of the window (the same reasoning that lets synchronization ignore the immutable flag). Only the route filters: a partially-overlapping shard's listing legitimately includes keys that aren't ours.
442
+ let { route } = this.store.sources[sourceIndex];
443
+ if (!routeContains(route, getRoute(file.path))) return "filtered";
444
+ }
445
+ let existing = this.store.getIndexEntry(file.path);
446
+ // The highest write time wins across all sources, and a tie keeps what we have - otherwise every rescan of an unchanged file would re-record it, moving its holder and writing a log record for nothing. currentWriteTime counts deletions, so a listing that still shows a file we know was deleted does not resurrect it.
447
+ if (file.createTime <= this.store.currentWriteTime(file.path)) return "unchanged";
448
+ // Size 0 in a listing IS a deletion - an empty file is a missing file - so that is what it is taken as
449
+ if (file.size === 0) {
450
+ this.store.setIndexDeleted(file.path, file.createTime);
451
+ return "tombstone";
452
+ }
453
+ this.store.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(sourceIndex) });
454
+ if (existing) return "updated";
455
+ return "new";
456
+ }
457
+
458
+ private async pollChanges(sourceIndex: number): Promise<void> {
459
+ let { source, route } = this.store.sources[sourceIndex];
460
+ let state = this.states[sourceIndex];
461
+ let pollStart = Date.now();
462
+ let changes = await source.getChangesAfter2({ time: state.changesAfterTime, routes: route && [route] || undefined });
463
+ let tally = newScanTally();
464
+ for (let file of changes) {
465
+ tally[this.updateScanIndex(sourceIndex, file)]++;
466
+ }
467
+ // Polls run constantly, so only the ones that actually changed the index get a line
468
+ if (tally.new || tally.updated || tally.tombstone) {
469
+ console.log(`Changes poll of ${source.getDebugName()} (store ${this.store.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
470
+ }
471
+ state.scannedCount += changes.length;
472
+ state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
473
+ }
474
+
475
+ // Downloads the files a source currently holds onto our own base source (the local disk), preserving their modified times — so a newer local write always wins. Skipped for noFullSync sources (fronting a large database without copying it); reads still down-cache lazily.
476
+ private async copySourceFiles(sourceIndex: number): Promise<void> {
477
+ if (sourceIndex === 0) return;
478
+ let { source } = this.store.sources[sourceIndex];
479
+ let state = this.states[sourceIndex];
480
+ let pending: { key: string; entry: IndexEntry }[] = [];
481
+ let totalBytes = 0;
482
+ let copiedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
483
+ for (let [key, entry] of this.store.indexEntries()) {
484
+ if (entry.sourcesListIndex !== copiedSourcesListIndex) continue;
485
+ pending.push({ key, entry });
486
+ totalBytes += entry.size;
487
+ }
488
+ if (!pending.length) return;
489
+ let activity: SyncActivity = {
490
+ type: "fullSync",
491
+ sourceDebugName: source.getDebugName(),
492
+ startTime: Date.now(),
493
+ doneFiles: 0,
494
+ totalFiles: pending.length,
495
+ doneBytes: 0,
496
+ totalBytes,
497
+ };
498
+ this.activities.add(activity);
499
+ let progressLogged = false;
500
+ let logProgress = () => {
501
+ progressLogged = true;
502
+ console.log(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`);
503
+ };
504
+ let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
505
+ (progressTimer as { unref?: () => void }).unref?.();
506
+ let slowErrorTimer = setInterval(() => {
507
+ let elapsed = Date.now() - activity.startTime;
508
+ let doneFiles = activity.doneFiles || 0;
509
+ let doneBytes = activity.doneBytes || 0;
510
+ let bytesPerSecond = doneBytes / (elapsed / 1000);
511
+ let remainingBytes = totalBytes - doneBytes;
512
+ let etaText = "unknown (no bytes transferred yet)";
513
+ if (bytesPerSecond > 0) {
514
+ let remainingMs = remainingBytes / bytesPerSecond * 1000;
515
+ etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
516
+ }
517
+ console.warn(`Full sync from ${source.getDebugName()} (store ${this.store.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
518
+ }, FULL_SYNC_SLOW_ERROR_INTERVAL);
519
+ (slowErrorTimer as { unref?: () => void }).unref?.();
520
+ try {
521
+ let nextIndex = 0;
522
+ let failed = false;
523
+ let copyWorker = async () => {
524
+ while (!failed && !this.store.stopped.stop && !state.stopped.stop) {
525
+ let index = nextIndex++;
526
+ if (index >= pending.length) return;
527
+ let { key, entry } = pending[index];
528
+ let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
529
+ if (copied) {
530
+ // Only move the entry's source if it wasn't changed while we copied
531
+ if (this.entryUnchanged(key, entry)) {
532
+ this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
533
+ }
534
+ }
535
+ activity.doneFiles = (activity.doneFiles || 0) + 1;
536
+ activity.doneBytes = (activity.doneBytes || 0) + entry.size;
537
+ }
538
+ };
539
+ let workers: Promise<void>[] = [];
540
+ for (let i = 0; i < Math.min(FULL_SYNC_PARALLEL, pending.length); i++) {
541
+ workers.push(copyWorker().catch((e: Error) => {
542
+ // Stop the other workers pulling new files, then surface the error
543
+ failed = true;
544
+ throw e;
545
+ }));
546
+ }
547
+ await Promise.all(workers);
548
+ } finally {
549
+ clearInterval(progressTimer);
550
+ clearInterval(slowErrorTimer);
551
+ this.activities.delete(activity);
552
+ // A sync slow enough to have logged progress also logs its completion
553
+ if (progressLogged) {
554
+ logProgress();
555
+ }
556
+ }
557
+ }
558
+
559
+ // ── maintenance ──
560
+
561
+ // readerDiskLimit: the disk is only a bounded read cache, so once it exceeds the limit, the least recently used files are deleted from it - but ONLY when another source verifiably holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry repoints to that source so reads keep working (re-caching on the next read).
562
+ private async enforceDiskLimit(): Promise<void> {
563
+ let limit = this.store.readerDiskLimit;
564
+ if (!limit || this.evicting) return;
565
+ let totals = this.store.indexTotals();
566
+ // Counted down as we evict, rather than re-read per file: a pass only ever removes bytes from the disk, and anything written during it is picked up by the next pass
567
+ let diskBytes = totals.slots[0].byteCount;
568
+ if (diskBytes <= limit) return;
569
+ this.evicting = true;
570
+ let evictedFiles = 0;
571
+ let evictedBytes = 0;
572
+ try {
573
+ let baseSourcesListIndex = this.store.sourcesListIndexOfSlot(0);
574
+ let candidates: { key: string; entry: IndexEntry }[] = [];
575
+ for (let [key, entry] of this.store.indexEntries()) {
576
+ if (entry.sourcesListIndex !== baseSourcesListIndex || key === ROUTING_FILE) continue;
577
+ candidates.push({ key, entry });
578
+ }
579
+ // Least recently READ first; a key nobody has read since we started falls back to when we last changed it
580
+ sort(candidates, x => this.lastAccess.get(x.key) || x.entry.changedAt);
581
+ for (let { key, entry } of candidates) {
582
+ if (this.store.stopped.stop) return;
583
+ if (diskBytes <= limit) break;
584
+ if (!this.entryUnchanged(key, entry)) continue;
585
+ let holder: number | undefined;
586
+ for (let i = 1; i < this.store.sources.length; i++) {
587
+ if (!this.isLive(i)) continue;
588
+ try {
589
+ let info = await this.store.sources[i].source.getInfo(key);
590
+ if (info && info.writeTime >= entry.writeTime) {
591
+ holder = i;
592
+ break;
593
+ }
594
+ } catch {
595
+ // A down source just can't vouch for this file right now
596
+ }
597
+ }
598
+ if (holder === undefined) continue;
599
+ await this.store.sources[0].source.del(key);
600
+ this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(holder) });
601
+ evictedFiles++;
602
+ evictedBytes += entry.size;
603
+ diskBytes -= entry.size;
604
+ }
605
+ } finally {
606
+ this.evicting = false;
607
+ if (evictedFiles) {
608
+ console.log(`Disk cache over readerDiskLimit (store ${this.store.folder}): evicted ${evictedFiles} least-recently-used files (${formatNumber(evictedBytes)}B), now at ${formatNumber(diskBytes)}B/${formatNumber(limit)}B`);
609
+ }
610
+ }
611
+ }
612
+
613
+ // Tombstones only need to exist long enough for every store to learn of the deletion; expired ones are forgotten entirely. A walk of the tombstones alone, which is why it can run often and cost nothing. The physical empty file is removed only on backblaze sources: remote stores expire their own tombstones (a del there would just mint a fresh one), and our own disk never stored anything for it.
614
+ private async cleanupTombstones(): Promise<void> {
615
+ let cutoff = Date.now() - TOMBSTONE_EXPIRY;
616
+ for (let [key, tombstone] of this.store.deletedEntries()) {
617
+ if (this.store.stopped.stop) return;
618
+ if (tombstone.writeTime > cutoff) continue;
619
+ this.store.purgeIndexEntry(key);
620
+ for (let i = 0; i < this.store.sources.length; i++) {
621
+ if (!this.isLive(i)) continue;
622
+ let sourceEntry = this.store.sources[i];
623
+ if (!windowsAcceptWrites(sourceEntry.validWindows)) continue;
624
+ let source = sourceEntry.source;
625
+ if (!(source instanceof ArchivesBackblaze)) continue;
626
+ void source.del(key).catch((e: Error) => {
627
+ console.error(`Removing expired tombstone ${key} from ${source.getDebugName()} failed: ${e.stack ?? e}`);
628
+ });
629
+ }
630
+ }
631
+ }
632
+ }
@@ -1,4 +1,6 @@
1
1
  export declare function assertValidName(value: string, kind: string): void;
2
+ /** A store's name (see CommonConfig.name), which also allows dots - a name is often a host or a version, and both read wrong without them. It is one path segment of the store's folder, so the two names that would mean a different folder entirely are rejected: everything else containing dots is just a name. */
3
+ export declare function assertValidSourceName(value: string): void;
2
4
  export declare function assertValidPath(path: string): void;
3
5
  /** Method decorator: validates the well-known fields of the method's single config-object argument - account/bucketName as names, path as a path - before the method runs. Fields the config doesn't have are skipped, so it applies to every API method uniformly. prefix is deliberately NOT validated: prefixes may be empty or end with "/", both invalid for paths. */
4
6
  export declare function assertValidArgs(target: unknown, key: string, descriptor: PropertyDescriptor): void;
@@ -4,6 +4,16 @@ export function assertValidName(value: string, kind: string): void {
4
4
  }
5
5
  }
6
6
 
7
+ /** A store's name (see CommonConfig.name), which also allows dots - a name is often a host or a version, and both read wrong without them. It is one path segment of the store's folder, so the two names that would mean a different folder entirely are rejected: everything else containing dots is just a name. */
8
+ export function assertValidSourceName(value: string): void {
9
+ if (!/^[\w.-]{1,64}$/.test(value)) {
10
+ throw new Error(`Invalid source name ${JSON.stringify(value)}, expected 1-64 characters of letters/numbers/underscore/dash/dot`);
11
+ }
12
+ if (/^\.+$/.test(value)) {
13
+ throw new Error(`Invalid source name ${JSON.stringify(value)}: a name of only dots refers to a directory rather than naming one`);
14
+ }
15
+ }
16
+
7
17
  export function assertValidPath(path: string): void {
8
18
  if (Buffer.from(path, "utf8").length > 1000) {
9
19
  throw new Error(`Path too long: ${path.length} characters > 1000. Path: ${path.slice(0, 200)}`);