sliftutils 1.7.70 → 1.7.72

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 (91) hide show
  1. package/dist/treeSummary.ts.cache +2 -2
  2. package/index.d.ts +505 -257
  3. package/misc/dist/environment.ts.cache +2 -2
  4. package/misc/dist/getSecret.ts.cache +2 -2
  5. package/misc/dist/strings.ts.cache +2 -2
  6. package/misc/dist/zip.ts.cache +2 -2
  7. package/misc/https/dist/certs.ts.cache +2 -2
  8. package/misc/https/dist/hostServer.ts.cache +5 -2
  9. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  10. package/package.json +1 -1
  11. package/render-utils/dist/observer.tsx.cache +2 -2
  12. package/spec.txt +10 -0
  13. package/storage/ArchivesDisk.d.ts +4 -13
  14. package/storage/ArchivesDisk.ts +6 -4
  15. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  16. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  17. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  18. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +2 -2
  19. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  20. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  21. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  22. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  23. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  24. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  25. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  26. package/storage/IArchives.d.ts +14 -10
  27. package/storage/IArchives.ts +16 -4
  28. package/storage/backblaze.d.ts +3 -9
  29. package/storage/backblaze.ts +22 -19
  30. package/storage/dist/ArchivesDisk.ts.cache +6 -3
  31. package/storage/dist/FileFolderAPI.tsx.cache +2 -2
  32. package/storage/dist/IArchives.ts.cache +18 -4
  33. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  34. package/storage/dist/JSONStorage.ts.cache +2 -2
  35. package/storage/dist/PendingManager.tsx.cache +2 -2
  36. package/storage/dist/TransactionStorage.ts.cache +2 -2
  37. package/storage/dist/backblaze.ts.cache +36 -27
  38. package/storage/dist/fileSystemPointer.ts.cache +2 -2
  39. package/storage/dist/remoteFileStorage.ts.cache +2 -2
  40. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -9
  41. package/storage/remoteStorage/ArchivesRemote.ts +25 -20
  42. package/storage/remoteStorage/ArchivesUrl.d.ts +3 -9
  43. package/storage/remoteStorage/ArchivesUrl.ts +7 -5
  44. package/storage/remoteStorage/accessPage.tsx +4 -4
  45. package/storage/remoteStorage/accessStats.d.ts +2 -0
  46. package/storage/remoteStorage/accessStats.ts +32 -0
  47. package/storage/remoteStorage/blobStore.d.ts +193 -88
  48. package/storage/remoteStorage/blobStore.ts +535 -400
  49. package/storage/remoteStorage/bucketDisk.d.ts +18 -0
  50. package/storage/remoteStorage/bucketDisk.ts +66 -0
  51. package/storage/remoteStorage/createArchives.d.ts +4 -10
  52. package/storage/remoteStorage/createArchives.ts +102 -63
  53. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +29 -20
  54. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +8 -5
  55. package/storage/remoteStorage/dist/accessPage.tsx.cache +6 -6
  56. package/storage/remoteStorage/dist/accessStats.ts.cache +39 -4
  57. package/storage/remoteStorage/dist/blobStore.ts.cache +517 -375
  58. package/storage/remoteStorage/dist/bucketDisk.ts.cache +72 -0
  59. package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
  60. package/storage/remoteStorage/dist/createArchives.ts.cache +112 -58
  61. package/storage/remoteStorage/dist/deployTakeover.ts.cache +2 -2
  62. package/storage/remoteStorage/dist/intermediateSources.ts.cache +13 -13
  63. package/storage/remoteStorage/dist/remoteConfig.ts.cache +19 -3
  64. package/storage/remoteStorage/dist/serverConfig.ts.cache +116 -0
  65. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -6
  66. package/storage/remoteStorage/dist/sourcesList.ts.cache +6 -3
  67. package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
  68. package/storage/remoteStorage/dist/storageController.ts.cache +224 -191
  69. package/storage/remoteStorage/dist/storageServer.ts.cache +9 -9
  70. package/storage/remoteStorage/dist/storageServerState.ts.cache +330 -567
  71. package/storage/remoteStorage/dist/storePlan.ts.cache +149 -0
  72. package/storage/remoteStorage/dist/validation.ts.cache +40 -0
  73. package/storage/remoteStorage/grantAccessCli.ts +1 -1
  74. package/storage/remoteStorage/intermediateSources.d.ts +3 -5
  75. package/storage/remoteStorage/intermediateSources.ts +14 -16
  76. package/storage/remoteStorage/remoteConfig.d.ts +2 -2
  77. package/storage/remoteStorage/remoteConfig.ts +19 -3
  78. package/storage/remoteStorage/serverConfig.d.ts +27 -0
  79. package/storage/remoteStorage/serverConfig.ts +109 -0
  80. package/storage/remoteStorage/sourceWrapper.d.ts +3 -3
  81. package/storage/remoteStorage/sourceWrapper.ts +7 -6
  82. package/storage/remoteStorage/sourcesList.ts +3 -0
  83. package/storage/remoteStorage/storageController.d.ts +118 -36
  84. package/storage/remoteStorage/storageController.ts +166 -168
  85. package/storage/remoteStorage/storageServer.ts +1 -1
  86. package/storage/remoteStorage/storageServerState.d.ts +52 -63
  87. package/storage/remoteStorage/storageServerState.ts +340 -584
  88. package/storage/remoteStorage/storePlan.d.ts +34 -0
  89. package/storage/remoteStorage/storePlan.ts +166 -0
  90. package/storage/remoteStorage/validation.d.ts +4 -0
  91. package/storage/remoteStorage/validation.ts +31 -0
@@ -3,228 +3,158 @@ import fs from "fs";
3
3
  import { lazy } from "socket-function/src/caching";
4
4
  import { sort } from "socket-function/src/misc";
5
5
  import { runInfinitePoll } from "socket-function/src/batching";
6
- import { getFileStorageNested2 } from "../FileFolderAPI";
7
- import { TransactionStorage } from "../TransactionStorage";
8
- import { JSONStorage } from "../JSONStorage";
9
6
  import { ArchivesDisk } from "../ArchivesDisk";
10
- import { BlobStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
7
+ import { BlobStore, RawDiskStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
11
8
  import {
12
- RemoteConfig, HostedConfig, BackblazeConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
13
- ArchivesSyncStatus, ChangesAfterConfig, DelConfig, GetConfig, GetInfoConfig, SetConfig,
14
- STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, FULL_ROUTE,
9
+ RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesSource, ArchivesConfig, ArchivesSyncStatus,
10
+ ArchiveFileInfo, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, SyncActivity, FULL_ROUTE,
15
11
  } from "../IArchives";
16
- import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection, normalizeSource } from "./remoteConfig";
12
+ import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, routeIntersection, normalizeSource } from "./remoteConfig";
17
13
  import { injectIntermediateSource, expireIntermediateSources, getIntermediateSources, findSplitUrl, nextIntermediateVersion, INTERMEDIATE_EXPIRE_GRACE } from "./intermediateSources";
18
14
  import { getTakeoverIntermediate } from "./deployTakeover";
19
15
  import { createApiArchives, listServerActiveBucketKeys } from "./createArchives";
20
- import type { IStorage } from "../IStorage";
21
16
  import { broadcastRoutingChanged } from "./storageController";
22
- import type { AccessRequest, TrustRecord } from "./storageController";
23
- import { getArg } from "./cliArgs";
24
-
25
- export type StorageServerConfig = {
26
- domain: string;
27
- port: number;
28
- rootDomain: string;
29
- sshTarget: string;
30
- serverCommand: string;
17
+ import { getStorageServerConfig, assertWritesAllowed } from "./serverConfig";
18
+ import { getBucketFolder, readRoutingFile, readRoutingFromDisk, getRoutingFileResult, getDiskInfo, BucketDiskInfo } from "./bucketDisk";
19
+ import { computeStorePlan, findSelfIndexes, StorePlan, StorePlanStore, SelfSummary } from "./storePlan";
20
+
21
+ // The storage server's bucket lifecycle: the map of loaded buckets (each = its routing config + one BlobStore per route), keeping them current (routing reloads, window-boundary rebuilds, boundary scans), writing/propagating routing configs, deploy-switchover maintenance, and the server-level bucket listings. Store selection (sourceConfig -> store) lives here too, as findBucketStore.
22
+
23
+ export type LoadedStore = {
24
+ // JSON of the route (FULL_ROUTE when absent) - the ONE route this store serves
25
+ routeKey: string;
26
+ route?: [number, number];
27
+ // The exact self entries this store serves (same route, different valid windows). Empty when we are not in the config but still serve our disk data.
28
+ entries: HostedConfig[];
31
29
  folder: string;
30
+ store: IBucketStore;
32
31
  };
33
32
 
34
- let config: StorageServerConfig | undefined;
35
- export function setStorageServerConfig(value: StorageServerConfig): void {
36
- config = value;
37
- }
38
- export function getStorageServerConfig(): StorageServerConfig {
39
- if (!config) {
40
- throw new Error(`Storage server is not initialized (this API only works on the storage server)`);
41
- }
42
- return config;
43
- }
44
- export function getStorageServerConfigOptional(): StorageServerConfig | undefined {
45
- return config;
46
- }
47
-
48
- let writesRejectedReason: string | undefined;
49
- export function setWritesRejectedReason(reason: string | undefined): void {
50
- writesRejectedReason = reason;
51
- }
52
- export function getWritesRejectedReason(): string | undefined {
53
- return writesRejectedReason;
54
- }
55
- export function assertWritesAllowed(): void {
56
- if (writesRejectedReason) throw new Error(writesRejectedReason);
57
- }
33
+ // A bucket loaded on this server: its routing config and its per-route stores. Pure data - the stores themselves do the work.
34
+ export type BucketState = {
35
+ account: string;
36
+ bucketName: string;
37
+ routing: RemoteConfig;
38
+ routingJSON: string;
39
+ selfEntries: HostedConfig[];
40
+ self: SelfSummary | undefined;
41
+ stores: LoadedStore[];
42
+ structureKey: string;
43
+ };
58
44
 
59
- function getStorageFolder(): string {
60
- let config = getStorageServerConfigOptional();
61
- if (config) return config.folder;
62
- let folder = getArg("folder");
63
- if (!folder) {
64
- throw new Error(`Storage server is not initialized and there is no --folder arg, so the storage folder is unknown`);
45
+ /** JSON with sorted object keys and undefined-valued keys dropped, so two configs that mean the same thing compare equal regardless of key order or which side serialized them. */
46
+ function stableStringify(value: unknown): string {
47
+ if (!value || typeof value !== "object") return JSON.stringify(value);
48
+ if (Array.isArray(value)) {
49
+ return `[${value.map(x => stableStringify(x)).join(",")}]`;
65
50
  }
66
- return path.resolve(folder);
51
+ let entries = Object.entries(value as Record<string, unknown>).filter(x => x[1] !== undefined);
52
+ sort(entries, x => x[0]);
53
+ return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`).join(",")}}`;
67
54
  }
68
55
 
69
- const systemStorages = new Map<string, Promise<IStorage<unknown>>>();
70
- function getSystemStorage<T>(name: string): Promise<IStorage<T>> {
71
- let storage = systemStorages.get(name);
72
- if (!storage) {
73
- storage = (async () => {
74
- let root = await getFileStorageNested2(getStorageFolder());
75
- let system = await root.folder.getStorage("system2");
76
- let transactionName = "storage" + name[0].toUpperCase() + name.slice(1);
77
- return new JSONStorage<unknown>(new TransactionStorage(await system.folder.getStorage(name), transactionName));
78
- })();
79
- systemStorages.set(name, storage);
56
+ /** The loaded bucket, loading it (which instantiates its stores and starts their synchronization) if needed. A bucket that does not exist on this server throws - callers never see undefined buckets. */
57
+ export async function requireBucket(account: string, bucketName: string): Promise<BucketState> {
58
+ let bucket = await getLoadedBucket(account, bucketName);
59
+ if (!bucket) {
60
+ throw new Error(`Bucket ${account}/${bucketName} does not exist on this server. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
80
61
  }
81
- return storage as Promise<IStorage<T>>;
82
- }
83
- export function getTrust(): Promise<IStorage<TrustRecord>> {
84
- return getSystemStorage<TrustRecord>("trust");
85
- }
86
- export function getRequests(): Promise<IStorage<AccessRequest[]>> {
87
- return getSystemStorage<AccessRequest[]>("requests");
62
+ return bucket;
88
63
  }
89
64
 
90
- export type BucketWriteStats = {
91
- /** Every set call the bucket accepted */
92
- originalWrites: number;
93
- originalBytes: number;
94
- /** What actually reached the sources. Fast writes coalesce repeated writes to the same key, so this is lower than the original counts (and is what the disk actually did). */
95
- flushedWrites: number;
96
- flushedBytes: number;
97
- };
98
- function emptyWriteStats(): BucketWriteStats {
99
- return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
100
- }
101
-
102
- // In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
103
- const writeStats = new Map<string, BucketWriteStats>();
104
-
105
- function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
106
- let stats = writeStats.get(key);
107
- if (!stats) {
108
- stats = emptyWriteStats();
109
- writeStats.set(key, stats);
65
+ /** The store serving a request: the exact config entry the CLIENT selected, matched by equality (key order ignored) against the bucket's own entries. A match is honored even when its window has passed - the selection never validates, the store's own validation throws instead. Throws when nothing matches, listing what is available. */
66
+ export async function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): Promise<LoadedStore> {
67
+ let bucket = await requireBucket(account, bucketName);
68
+ if (!sourceConfig) {
69
+ throw new Error(`No remote source configuration was provided for bucket ${account}/${bucketName}: every request must say which configured source it selected. Available: ${JSON.stringify(bucket.selfEntries)}`);
110
70
  }
111
- if (kind === "original") {
112
- stats.originalWrites++;
113
- stats.originalBytes += bytes;
114
- } else {
115
- stats.flushedWrites++;
116
- stats.flushedBytes += bytes;
71
+ let wanted = stableStringify(sourceConfig);
72
+ for (let s of bucket.stores) {
73
+ if (s.entries.some(e => stableStringify(e) === wanted)) return s;
117
74
  }
75
+ throw new Error(`No source on this server matches the requested remote configuration for bucket ${account}/${bucketName}. Requested: ${JSON.stringify(sourceConfig)}. Available: ${JSON.stringify(bucket.selfEntries)}`);
118
76
  }
119
77
 
120
- function getBucketWriteStats(key: string): BucketWriteStats {
121
- return writeStats.get(key) || emptyWriteStats();
78
+ /** Internal (store-to-store) reads skip store selection entirely: the caller is another store whose index says this MACHINE holds the bytes - the persisted holder identity is just a URL, which cannot name a store. Whichever store's folder has the newest copy answers. */
79
+ export async function readBucketInternal(account: string, bucketName: string, config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
80
+ let bucket = await requireBucket(account, bucketName);
81
+ let results = await Promise.all(bucket.stores.map(s => s.store.get2({ path: config.path, range: config.range, includeTombstones: config.includeTombstones, internal: true })));
82
+ let best: { data: Buffer; writeTime: number; size: number } | undefined;
83
+ for (let result of results) {
84
+ if (result && (!best || result.writeTime > best.writeTime)) {
85
+ best = result;
86
+ }
87
+ }
88
+ return best;
122
89
  }
123
90
 
124
- /** Zeroes the write statistics of every bucket in the account. */
125
- export function clearAccountWriteStats(account: string): number {
126
- let prefix = `${account}/`;
127
- let cleared = 0;
128
- for (let key of [...writeStats.keys()]) {
129
- if (!key.startsWith(prefix)) continue;
130
- writeStats.delete(key);
131
- cleared++;
91
+ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
92
+ let index = { fileCount: 0, byteCount: 0 };
93
+ let indexSources: { debugName: string; fileCount: number; byteCount: number }[] = [];
94
+ let syncing: SyncActivity[] = [];
95
+ let readerDiskLimit: number | undefined;
96
+ for (let s of bucket.stores) {
97
+ let progress = s.store.getSyncProgress?.();
98
+ if (!progress) continue;
99
+ index.fileCount += progress.index.fileCount;
100
+ index.byteCount += progress.index.byteCount;
101
+ indexSources.push(...progress.sources);
102
+ syncing.push(...progress.syncing);
103
+ readerDiskLimit = readerDiskLimit || progress.readerDiskLimit;
132
104
  }
133
- console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
134
- return cleared;
105
+ return {
106
+ // Native change-feed support = the stores keep an index (rawDisk buckets emulate getChangesAfter2 with a full listing) - clients use this to pick their scan cadence
107
+ supportsChangesAfter: bucket.stores.some(s => s.store instanceof BlobStore),
108
+ remoteConfig: bucket.routing,
109
+ index,
110
+ indexSources,
111
+ readerDiskLimit,
112
+ syncing,
113
+ };
135
114
  }
136
115
 
137
- export async function setTrustedMachines(config: { account: string; machineIds: string[] }): Promise<void> {
138
- let trust = await getTrust();
139
- let prefix = `${config.account}|`;
140
- let desired = new Set(config.machineIds);
141
- for (let key of await trust.getKeys()) {
142
- if (!key.startsWith(prefix)) continue;
143
- let machineId = key.slice(prefix.length);
144
- if (desired.has(machineId)) {
145
- desired.delete(machineId);
146
- continue;
147
- }
148
- console.log(`Removing trust for machine ${machineId} on account ${config.account}`);
149
- await trust.remove(key);
150
- }
151
- for (let machineId of desired) {
152
- console.log(`Adding trust for machine ${machineId} on account ${config.account}`);
153
- await trust.set(`${prefix}${machineId}`, { account: config.account, machineId, ip: "", time: Date.now() });
116
+ export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyncStatus> {
117
+ let supported = bucket.stores.filter(s => s.store.getSyncStatus);
118
+ if (!supported.length) {
119
+ throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
154
120
  }
121
+ let statuses = await Promise.all(supported.map(async s => {
122
+ let getSyncStatus = s.store.getSyncStatus;
123
+ if (!getSyncStatus) {
124
+ throw new Error(`getSyncStatus disappeared from a store for ${bucket.account}/${bucket.bucketName}`);
125
+ }
126
+ return await getSyncStatus.call(s.store);
127
+ }));
128
+ return {
129
+ allScansComplete: statuses.every(x => x.allScansComplete),
130
+ indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
131
+ sources: statuses.flatMap(x => x.sources),
132
+ };
155
133
  }
156
134
 
157
- export type LoadedBucket = {
158
- account: string;
159
- bucketName: string;
160
- routing: RemoteConfig;
161
- routingJSON: string;
162
- selfEntries: HostedConfig[];
163
- self: SelfSummary | undefined;
164
- store: IBucketStore;
165
- structureKey: string;
166
- };
135
+ export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
136
+ let supported = bucket.stores.filter(s => s.store.computeIndexTotals);
137
+ if (!supported.length) return undefined;
138
+ let totals = await Promise.all(supported.map(async s => {
139
+ let computeIndexTotals = s.store.computeIndexTotals;
140
+ if (!computeIndexTotals) {
141
+ throw new Error(`computeIndexTotals disappeared from a store for ${bucket.account}/${bucket.bucketName}`);
142
+ }
143
+ return await computeIndexTotals.call(s.store);
144
+ }));
145
+ return {
146
+ fileCount: totals.reduce((sum, x) => sum + x.fileCount, 0),
147
+ byteCount: totals.reduce((sum, x) => sum + x.byteCount, 0),
148
+ sources: totals.flatMap(x => x.sources),
149
+ };
150
+ }
167
151
 
168
- const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
152
+ const buckets = new Map<string, Promise<BucketState | undefined>>();
169
153
 
170
154
  const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
171
155
  const REBUILD_BOUNDARY_BUFFER = 1000;
172
156
 
173
- function getBucketFolder(account: string, bucketName: string): string {
174
- return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
175
- }
176
-
177
- const extraListenPorts = new Set<number>();
178
- export function addExtraListenPort(port: number): void {
179
- extraListenPorts.add(port);
180
- }
181
- export function removeExtraListenPort(port: number): void {
182
- extraListenPorts.delete(port);
183
- }
184
- /** Whether address:port is this server process. The ONE self test - findSelfIndexes, createApiArchives, and SourceWrapper all consult it, so "is this me" cannot disagree between the routing plan and connection building: a URL that is us on an extra listen port must never become a network client to ourselves, which is how infinite self-request loops form. */
185
- export function isOwnAddress(address: string, port: number): boolean {
186
- let config = getStorageServerConfigOptional();
187
- if (!config) return false;
188
- if (address !== config.domain) return false;
189
- return port === config.port || extraListenPorts.has(port);
190
- }
191
-
192
- function findSelfIndexes(routing: RemoteConfig, account: string, bucketName: string): number[] {
193
- let indexes: number[] = [];
194
- for (let i = 0; i < routing.sources.length; i++) {
195
- let source = routing.sources[i];
196
- if (typeof source === "string" || source.type !== "remote") continue;
197
- let parsed = parseHostedUrl(source.url);
198
- if (parsed.account !== account || parsed.bucketName !== bucketName) continue;
199
- if (isOwnAddress(parsed.address, parsed.port)) {
200
- indexes.push(i);
201
- }
202
- }
203
- return indexes;
204
- }
205
-
206
- function selectEntryAt(entries: HostedConfig[], time: number, route?: number): HostedConfig | undefined {
207
- if (route !== undefined) {
208
- let covering = entries.filter(x => routeContains(x.route, route));
209
- if (covering.length) {
210
- entries = covering;
211
- }
212
- }
213
- let containing = entries.find(x => x.validWindow[0] <= time && time < x.validWindow[1]);
214
- if (containing) return containing;
215
- let best: HostedConfig | undefined;
216
- let bestDistance = Infinity;
217
- for (let entry of entries) {
218
- let distance = Math.min(Math.abs(time - entry.validWindow[0]), Math.abs(time - entry.validWindow[1]));
219
- if (distance < bestDistance) {
220
- bestDistance = distance;
221
- best = entry;
222
- }
223
- }
224
- return best;
225
- }
226
-
227
- function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
157
+ function scheduleWindowBoundaryRebuild(loaded: BucketState): void {
228
158
  let now = Date.now();
229
159
  let boundaries = loaded.selfEntries.flatMap(x => x.validWindow).filter(t => t > now);
230
160
  if (!boundaries.length) return;
@@ -251,7 +181,7 @@ const STARTUP_BOUNDARY_SCAN_DELAY = 30 * 1000;
251
181
  const scheduledBoundaryScans = new Set<string>();
252
182
  const boundaryScansRunning = new Set<string>();
253
183
 
254
- function scheduleBoundaryScans(loaded: LoadedBucket): void {
184
+ function scheduleBoundaryScans(loaded: BucketState): void {
255
185
  let key = `${loaded.account}/${loaded.bucketName}`;
256
186
  let now = Date.now();
257
187
  let starts = new Set<number>();
@@ -305,18 +235,19 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
305
235
  try {
306
236
  let loaded = await buckets.get(bucketKey);
307
237
  if (!loaded) return;
308
- let store = loaded.store;
309
- if (!(store instanceof BlobStore)) return;
310
238
  let effective = loaded.routing;
311
239
  let selfIndexes = findSelfIndexes(effective, loaded.account, loaded.bucketName);
312
240
  let selfIndexSet = new Set(selfIndexes);
313
241
  let selves = selfIndexes.map(i => effective.sources[i] as HostedConfig).filter(x => x.validWindow[0] === windowStart);
314
242
  if (!selves.length) return;
315
243
  let prevTime = windowStart - 1;
316
- let needDiskScan = false;
317
- let remoteOwners = new Map<number, [number, number]>();
244
+ // Everything is per-store: each self entry's route has its own store, and that store's disk/remote boundary pulls only concern its own route slice
245
+ let needDiskScan = new Set<LoadedStore>();
246
+ let remoteOwners = new Map<LoadedStore, Map<number, [number, number]>>();
318
247
  for (let self of selves) {
319
248
  let selfRoute = self.route || FULL_ROUTE;
249
+ let selfStore = loaded.stores.find(x => x.routeKey === JSON.stringify(selfRoute));
250
+ if (!selfStore || !(selfStore.store instanceof BlobStore)) continue;
320
251
  let idx = effective.sources.indexOf(self);
321
252
  let shadowed = false;
322
253
  for (let j = 0; j < idx; j++) {
@@ -353,26 +284,36 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
353
284
  uncovered = remaining;
354
285
  if (!claimed) continue;
355
286
  if (selfIndexSet.has(j)) {
356
- needDiskScan = true;
287
+ needDiskScan.add(selfStore);
357
288
  } else {
358
- let existing = remoteOwners.get(j);
359
- remoteOwners.set(j, existing && [Math.min(existing[0], claimed[0]), Math.max(existing[1], claimed[1])] as [number, number] || claimed);
289
+ let owners = remoteOwners.get(selfStore);
290
+ if (!owners) {
291
+ owners = new Map();
292
+ remoteOwners.set(selfStore, owners);
293
+ }
294
+ let existing = owners.get(j);
295
+ owners.set(j, existing && [Math.min(existing[0], claimed[0]), Math.max(existing[1], claimed[1])] as [number, number] || claimed);
360
296
  }
361
297
  }
362
298
  }
363
- if (!needDiskScan && !remoteOwners.size) return;
364
- console.log(`Boundary scan (${label}): diskScan=${needDiskScan}, remote previous-window owners: ${remoteOwners.size}`);
365
- if (needDiskScan) {
366
- await store.rescanBase();
299
+ if (!needDiskScan.size && !remoteOwners.size) return;
300
+ console.log(`Boundary scan (${label}): diskScans=${needDiskScan.size}, stores with remote previous-window owners: ${remoteOwners.size}`);
301
+ for (let selfStore of needDiskScan) {
302
+ if (selfStore.store instanceof BlobStore) {
303
+ await selfStore.store.rescanBase();
304
+ }
367
305
  }
368
306
  let since = windowStart - BOUNDARY_SCAN_LOOKBACK;
369
- for (let [sourceIndex, route] of remoteOwners) {
370
- let ownerSource = effective.sources[sourceIndex];
371
- if (typeof ownerSource === "string") continue;
372
- try {
373
- await store.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
374
- } catch (e) {
375
- console.error(`Boundary scan (${label}) of previous-window owner (source index ${sourceIndex}) failed: ${(e as Error).stack ?? e}`);
307
+ for (let [selfStore, owners] of remoteOwners) {
308
+ if (!(selfStore.store instanceof BlobStore)) continue;
309
+ for (let [sourceIndex, route] of owners) {
310
+ let ownerSource = effective.sources[sourceIndex];
311
+ if (typeof ownerSource === "string") continue;
312
+ try {
313
+ await selfStore.store.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
314
+ } catch (e) {
315
+ console.error(`Boundary scan (${label}) of previous-window owner (source index ${sourceIndex}) failed: ${(e as Error).stack ?? e}`);
316
+ }
376
317
  }
377
318
  }
378
319
  } finally {
@@ -380,15 +321,6 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
380
321
  }
381
322
  }
382
323
 
383
- type StorePlan = {
384
- selfEntries: HostedConfig[];
385
- self: SelfSummary | undefined;
386
- rawDisk: boolean;
387
- sourceSpecs: { sourceConfig?: HostedConfig | BackblazeConfig; validWindow: [number, number]; route?: [number, number]; noFullSync?: boolean }[];
388
- readerDiskLimit?: number;
389
- structureKey: string;
390
- };
391
-
392
324
  const resolvedSourceArchives = new Map<string, IArchives>();
393
325
  /** A cached IArchives for a persisted source identity: a routing URL (hosted/backblaze) or a disk folder path - the form BlobStore's sources list stores. Configuration (valid windows, routes) decides WHEN a source should be used; for reading bytes the index says a source holds, the URL alone is enough - even for sources no longer in any config. */
394
326
  export function resolveSourceArchives(url: string): IArchives {
@@ -396,6 +328,7 @@ export function resolveSourceArchives(url: string): IArchives {
396
328
  if (existing) return existing;
397
329
  let archives: IArchives;
398
330
  if (url.startsWith("https://")) {
331
+ // The config is fabricated from the bare URL and can never exact-match a server's entries - which is fine, because holder reads are internal reads, and internal reads never select a store (see readBucketInternal)
399
332
  archives = createApiArchives(normalizeSource(url));
400
333
  } else {
401
334
  archives = new ArchivesDisk(url);
@@ -404,176 +337,68 @@ export function resolveSourceArchives(url: string): IArchives {
404
337
  return archives;
405
338
  }
406
339
 
407
- function sourceIdentity(sourceConfig: HostedConfig | BackblazeConfig | undefined): string {
340
+ function sourceIdentity(sourceConfig: SourceConfig | undefined): string {
408
341
  if (!sourceConfig) return "disk";
409
342
  return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
410
343
  }
411
344
 
412
- /** Our role in a bucket's routing config, summarized across ALL currently-valid self entries. Stored instead of a single representative HostedConfig, so nothing can accidentally use one entry's route or flags where the union is required - the standard config has the same URL twice: a routed write-shard entry plus an unrouted read-everything entry. */
413
- export type SelfSummary = {
414
- /** The union of the current entries' routes, with overlapping/adjacent ranges combined - which commonly collapses to a single full range, making matching trivial. */
415
- routes: [number, number][];
416
- public: boolean;
417
- immutable: boolean;
418
- noFullSync: boolean;
419
- rawDisk: boolean;
420
- readerDiskLimit?: number;
421
- };
422
-
423
- function mergeRoutes(routes: ([number, number] | undefined)[]): [number, number][] {
424
- let list = routes.map(x => x || FULL_ROUTE).map(x => [x[0], x[1]] as [number, number]);
425
- sort(list, x => x[0]);
426
- let merged: [number, number][] = [];
427
- for (let route of list) {
428
- let last = merged[merged.length - 1];
429
- if (last && route[0] <= last[1]) {
430
- last[1] = Math.max(last[1], route[1]);
431
- continue;
432
- }
433
- merged.push(route);
434
- }
435
- return merged;
436
- }
437
-
438
- /** anchor is the first currently-valid entry (nearest-window when none contains now, matching selectEntryAt) - the deterministic representative used only for positional things: the sync-topology cut (selfIndex) and the diskWindow seed. Everything behavioral comes from the summary, which spans every entry the anchor's group represents. */
439
- function summarizeSelf(selfEntries: HostedConfig[], now: number): { summary: SelfSummary | undefined; anchor: HostedConfig | undefined } {
440
- let current = selfEntries.filter(x => x.validWindow[0] <= now && now < x.validWindow[1]);
441
- if (!current.length) {
442
- let nearest = selectEntryAt(selfEntries, now);
443
- current = nearest && [nearest] || [];
444
- }
445
- if (!current.length) {
446
- return { summary: undefined, anchor: undefined };
447
- }
448
- let summary: SelfSummary = {
449
- routes: mergeRoutes(current.map(x => x.route)),
450
- public: current.some(x => x.public),
451
- immutable: current.some(x => x.immutable),
452
- noFullSync: current.some(x => x.noFullSync),
453
- rawDisk: current.some(x => x.rawDisk),
454
- readerDiskLimit: current.find(x => x.readerDiskLimit !== undefined)?.readerDiskLimit,
455
- };
456
- return { summary, anchor: current[0] };
457
- }
458
-
459
- function computeStorePlan(account: string, bucketName: string, routing: RemoteConfig): StorePlan {
460
- let selfIndexes = findSelfIndexes(routing, account, bucketName);
461
- let selfEntries = selfIndexes.map(i => routing.sources[i] as HostedConfig);
462
- let { summary: self, anchor } = summarizeSelf(selfEntries, Date.now());
463
- let selfIndex = -1;
464
- if (anchor) {
465
- selfIndex = routing.sources.indexOf(anchor);
466
- }
467
- let diskWindow: [number, number] = [0, 0];
468
- if (anchor) {
469
- let [start, end] = anchor.validWindow;
470
- let merged = true;
471
- while (merged) {
472
- merged = false;
473
- for (let entry of selfEntries) {
474
- let [entryStart, entryEnd] = entry.validWindow;
475
- if (entryStart > end || entryEnd < start) continue;
476
- if (entryStart < start || entryEnd > end) {
477
- start = Math.min(start, entryStart);
478
- end = Math.max(end, entryEnd);
479
- merged = true;
480
- }
481
- }
482
- }
483
- diskWindow = [start, end];
484
- }
485
- let ownIndexes = new Set(selfIndexes);
486
- let sourceSpecs: StorePlan["sourceSpecs"] = [{
487
- validWindow: diskWindow,
488
- }];
489
- if (self && selfIndex !== -1) {
490
- for (let i = selfIndex + 1; i < routing.sources.length; i++) {
491
- let source = routing.sources[i];
492
- if (typeof source === "string" || ownIndexes.has(i)) continue;
493
- // One spec per intersection segment: a peer's route can overlap several of our (disjoint) route slices, and each slice becomes its own sync-source slot (same-URL multi-slot is already the supported shape)
494
- for (let selfRoute of self.routes) {
495
- let sharedRoute = routeIntersection(selfRoute, source.route);
496
- if (!sharedRoute) continue;
497
- sourceSpecs.push({ sourceConfig: source, validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self.noFullSync });
498
- }
499
- }
500
- }
501
- let rawDisk = !!self?.rawDisk;
502
- let structureKey = JSON.stringify({
503
- rawDisk,
504
- readerDiskLimit: self?.readerDiskLimit,
345
+ function buildStore(account: string, bucketName: string, planStore: StorePlanStore): IBucketStore {
346
+ let folder = getBucketFolder(account, bucketName, planStore.route);
347
+ if (planStore.rawDisk) {
348
+ return new RawDiskStore(new ArchivesDisk(folder));
349
+ }
350
+ let sources: ArchivesSource[] = planStore.sourceSpecs.map(spec => ({
351
+ source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
352
+ url: spec.sourceConfig?.url || folder,
353
+ validWindow: spec.validWindow,
354
+ route: spec.route,
355
+ noFullSync: spec.noFullSync,
356
+ intermediate: spec.sourceConfig?.intermediate,
357
+ identity: sourceIdentity(spec.sourceConfig),
358
+ }));
359
+ return new BlobStore(folder, sources, {
360
+ readerDiskLimit: planStore.readerDiskLimit,
361
+ onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
362
+ resolveSourceUrl: resolveSourceArchives,
363
+ entries: planStore.entries,
505
364
  });
506
- return { selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
507
365
  }
508
366
 
509
- function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): LoadedBucket {
510
- let folder = getBucketFolder(account, bucketName);
367
+ function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): BucketState {
511
368
  if (!plan) {
512
369
  plan = computeStorePlan(account, bucketName, routing);
513
370
  }
514
371
  let { selfEntries, self } = plan;
515
- let store: IBucketStore;
516
- if (plan.rawDisk) {
517
- store = new ArchivesDisk(folder);
518
- } else {
519
- if (!self) {
520
- console.log(`This server is not in the routing config for bucket ${account}/${bucketName}: no longer synchronizing, valid window treated as [0, 0] (fast writes flush immediately), disk data kept and served`);
521
- }
522
- let sources: ArchivesSource[] = plan.sourceSpecs.map(spec => ({
523
- source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
524
- url: spec.sourceConfig?.url || folder,
525
- validWindow: spec.validWindow,
526
- route: spec.route,
527
- noFullSync: spec.noFullSync,
528
- intermediate: spec.sourceConfig?.intermediate,
529
- identity: sourceIdentity(spec.sourceConfig),
530
- }));
531
- store = new BlobStore(folder, sources, {
532
- onIndexChanged: key => {
533
- if (key !== ROUTING_FILE) return;
534
- void scheduleRoutingReload(account, bucketName);
535
- },
536
- readerDiskLimit: plan.readerDiskLimit,
537
- onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
538
- resolveSourceUrl: resolveSourceArchives,
539
- });
540
- }
541
- let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
372
+ if (!self) {
373
+ console.log(`This server is not in the routing config for bucket ${account}/${bucketName}: no longer synchronizing, valid window treated as [0, 0] (fast writes flush immediately), disk data kept and served`);
374
+ }
375
+ let stores: LoadedStore[] = plan.stores.map(planStore => ({
376
+ routeKey: planStore.routeKey,
377
+ route: planStore.route,
378
+ entries: planStore.entries,
379
+ folder: getBucketFolder(account, bucketName, planStore.route),
380
+ store: buildStore(account, bucketName, planStore),
381
+ }));
382
+ let loaded: BucketState = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, stores, structureKey: plan.structureKey };
542
383
  scheduleWindowBoundaryRebuild(loaded);
543
384
  scheduleBoundaryScans(loaded);
544
- // A loaded bucket must actually be running: the store's init loads its index and starts its source synchronization, and it is lazy, so without this a bucket nothing has read from or written to sits inert - reporting no data and no syncing while its disk is full of files.
545
- if (store instanceof BlobStore) {
546
- void store.init().catch((e: Error) => console.error(`Initializing the store for bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
385
+ // A loaded bucket must actually be running: each store's init loads its index and starts its source synchronization, and it is lazy, so without this a bucket nothing has read from or written to sits inert - reporting no data and no syncing while its disk is full of files.
386
+ for (let s of stores) {
387
+ if (s.store instanceof BlobStore) {
388
+ let store = s.store;
389
+ void store.init().catch((e: Error) => console.error(`Initializing the store for bucket ${account}/${bucketName} (route ${s.routeKey}) failed: ${e.stack ?? e}`));
390
+ }
547
391
  }
548
392
  return loaded;
549
393
  }
550
394
 
551
- /** The routing file is ours, on our own disk, at a path we know - so it is read directly. Going through an ArchivesDisk would construct a whole store (handle cache sweep loop, uploads-folder cleanup) just to read one file. */
552
- function getRoutingFilePath(folder: string): string {
553
- return path.join(folder, "files", ROUTING_FILE);
554
- }
555
- async function readRoutingFile(folder: string): Promise<Buffer | undefined> {
556
- try {
557
- return await fs.promises.readFile(getRoutingFilePath(folder));
558
- } catch (e) {
559
- if ((e as { code?: string }).code === "ENOENT") return undefined;
560
- throw e;
561
- }
562
- }
563
-
564
- async function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined> {
565
- let data = await readRoutingFile(getBucketFolder(account, bucketName));
566
- if (!data) return undefined;
567
- return parseRoutingData(data);
568
- }
569
-
570
- async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
395
+ async function loadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
571
396
  let routing = await readRoutingFromDisk(account, bucketName);
572
397
  if (!routing) return undefined;
573
398
  return buildBucket(account, bucketName, routing);
574
399
  }
575
400
 
576
- export function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
401
+ export function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
577
402
  let key = `${account}/${bucketName}`;
578
403
  let loaded = buckets.get(key);
579
404
  if (!loaded) {
@@ -592,7 +417,6 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
592
417
  return loaded;
593
418
  }
594
419
 
595
-
596
420
  const routingReloads = new Map<string, Promise<void>>();
597
421
  function scheduleRoutingReload(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
598
422
  let key = `${account}/${bucketName}`;
@@ -612,67 +436,66 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
612
436
  if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
613
437
  let reason = config?.force && (config.reason || "forced") || "routing config changed";
614
438
  let plan = computeStorePlan(account, bucketName, routing);
615
- if (plan.structureKey === loaded.structureKey && loaded.store instanceof BlobStore) {
616
- console.log(`Applying the routing config to the running store for bucket ${key} (${reason})`);
617
- loaded.store.updateSources(plan.sourceSpecs.map(spec => {
618
- const sourceConfig = spec.sourceConfig;
619
- if (!sourceConfig) {
620
- return { identity: sourceIdentity(undefined), url: getBucketFolder(account, bucketName), validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(getBucketFolder(account, bucketName)) };
621
- }
622
- return {
623
- identity: sourceIdentity(sourceConfig),
624
- url: sourceConfig.url,
625
- validWindow: spec.validWindow,
626
- route: spec.route,
627
- noFullSync: spec.noFullSync,
628
- intermediate: sourceConfig.intermediate,
629
- create: () => createApiArchives(sourceConfig),
630
- };
631
- }));
632
- let updated: LoadedBucket = { ...loaded, routing, routingJSON: JSON.stringify(routing), selfEntries: plan.selfEntries, self: plan.self };
633
- buckets.set(key, Promise.resolve(updated));
634
- scheduleWindowBoundaryRebuild(updated);
635
- scheduleBoundaryScans(updated);
439
+ if (plan.structureKey === loaded.structureKey) {
440
+ console.log(`Applying the routing config to the running stores for bucket ${key} (${reason})`);
441
+ for (let planStore of plan.stores) {
442
+ let live = loaded.stores.find(x => x.routeKey === planStore.routeKey);
443
+ // structureKey equality means the store set (routes, rawDisk, limits) is unchanged, so every plan store has its live twin
444
+ if (!live || !(live.store instanceof BlobStore)) continue;
445
+ let storeFolder = live.folder;
446
+ live.store.updateSources(planStore.sourceSpecs.map(spec => {
447
+ const sourceConfig = spec.sourceConfig;
448
+ if (!sourceConfig) {
449
+ return { identity: sourceIdentity(undefined), url: storeFolder, validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(storeFolder) };
450
+ }
451
+ return {
452
+ identity: sourceIdentity(sourceConfig),
453
+ url: sourceConfig.url,
454
+ validWindow: spec.validWindow,
455
+ route: spec.route,
456
+ noFullSync: spec.noFullSync,
457
+ intermediate: sourceConfig.intermediate,
458
+ create: () => createApiArchives(sourceConfig),
459
+ };
460
+ }), planStore.entries);
461
+ live.entries = planStore.entries;
462
+ }
463
+ loaded.routing = routing;
464
+ loaded.routingJSON = JSON.stringify(routing);
465
+ loaded.selfEntries = plan.selfEntries;
466
+ loaded.self = plan.self;
467
+ scheduleWindowBoundaryRebuild(loaded);
468
+ scheduleBoundaryScans(loaded);
636
469
  broadcastRoutingChanged();
637
470
  return;
638
471
  }
639
- console.log(`Rebuilding the store for bucket ${key} (${reason})`);
640
- if (loaded.store instanceof BlobStore) {
641
- await loaded.store.dispose();
472
+ console.log(`Rebuilding the stores for bucket ${key} (${reason}): structure changed from ${loaded.structureKey} to ${plan.structureKey}`);
473
+ for (let s of loaded.stores) {
474
+ if (s.store instanceof BlobStore) {
475
+ await s.store.dispose();
476
+ }
642
477
  }
643
478
  buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
644
479
  broadcastRoutingChanged();
645
480
  }
646
481
 
647
- function getWriteConfig(bucket: LoadedBucket, writeTime: number, route: number): { fast?: boolean; writeDelay?: number } {
648
- let self = selectEntryAt(bucket.selfEntries, writeTime, route);
649
- return { fast: self?.fast, writeDelay: self?.writeDelay };
650
- }
651
-
652
- export async function assertMutable(bucket: LoadedBucket, filePath: string, writeTime: number): Promise<void> {
653
- let self = selectEntryAt(bucket.selfEntries, writeTime, getRoute(filePath));
654
- if (!self?.immutable) return;
655
- if (await bucket.store.getInfo(filePath)) {
656
- throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} is immutable (at write time ${writeTime}) and ${JSON.stringify(filePath)} already exists, so it cannot be written to`);
657
- }
658
- }
482
+ const routingWrites = new Map<string, Promise<void>>();
659
483
 
660
- const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
661
- let lastWrongTargetLog = 0;
662
- function logWrongTargetRejection(message: string): void {
663
- if (Date.now() - lastWrongTargetLog < WRONG_TARGET_LOG_THROTTLE) return;
664
- lastWrongTargetLog = Date.now();
665
- console.log(message);
484
+ /** The routing-config write path - the ONE write that cannot go through a store (it is what CREATES the bucket and its stores). Serialized per bucket: concurrent config writes would race the version check. */
485
+ export async function queueRoutingConfigWrite(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
486
+ assertWritesAllowed();
487
+ let key = `${account}/${bucketName}`;
488
+ let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
489
+ routingWrites.set(key, run.then(() => { }, () => { }));
490
+ return await run;
666
491
  }
667
492
 
668
- const routingWrites = new Map<string, Promise<void>>();
669
-
670
493
  async function writeRoutingConfig(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
671
494
  let key = `${account}/${bucketName}`;
672
495
  let incoming = parseRoutingData(data);
673
496
  let loaded = await getLoadedBucket(account, bucketName);
674
497
  if (!loaded) {
675
- await new ArchivesDisk(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
498
+ await resolveSourceArchives(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
676
499
  buckets.set(key, Promise.resolve(buildBucket(account, bucketName, incoming)));
677
500
  broadcastRoutingChanged();
678
501
  console.log(`Created bucket ${key}`);
@@ -697,78 +520,11 @@ async function writeRoutingConfig(account: string, bucketName: string, data: Buf
697
520
  if (JSON.stringify(stored) !== JSON.stringify(incoming)) {
698
521
  console.log(`Re-injected ${reinjected} in-flight switchover windows into the incoming routing config for bucket ${key} (version ${getConfigVersion(incoming)})`);
699
522
  }
700
- await loaded.store.set(ROUTING_FILE, Buffer.from(serializeRemoteConfig(stored)), { lastModified: config?.lastModified });
523
+ // Written straight into the plain bucket folder: the routing file defines the per-route stores, so it never flows through them
524
+ await resolveSourceArchives(getBucketFolder(account, bucketName)).set(ROUTING_FILE, Buffer.from(serializeRemoteConfig(stored)), { lastModified: config?.lastModified });
701
525
  await scheduleRoutingReload(account, bucketName);
702
526
  }
703
527
 
704
- export async function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: { lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
705
- assertWritesAllowed();
706
- if (filePath === ROUTING_FILE) {
707
- let key = `${account}/${bucketName}`;
708
- let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
709
- routingWrites.set(key, run.then(() => { }, () => { }));
710
- return await run;
711
- }
712
- let loaded = await getLoadedBucket(account, bucketName);
713
- if (!loaded) {
714
- throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
715
- }
716
- let writeTime = config?.lastModified || Date.now();
717
- let route = getRoute(filePath);
718
- if (!config?.lastModified && loaded.selfEntries.length) {
719
- let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
720
- if (!timeValid.length) {
721
- logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))} (a switchover moved the write target)`);
722
- throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This server is not a valid write target at ${writeTime} for bucket ${account}/${bucketName} (our valid windows: ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))}). Re-resolve the currently valid source and retry.`);
723
- }
724
- if (!timeValid.some(x => routeContains(x.route, route))) {
725
- logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: 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)`);
726
- throw new Error(`${STORAGE_WRONG_ROUTE} This server does not handle route ${route} (key ${JSON.stringify(filePath)}) for bucket ${account}/${bucketName} (our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}). Re-resolve the source for this key and retry.`);
727
- }
728
- }
729
- if (config?.forceSetImmutable) {
730
- if (!config.lastModified) {
731
- throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}`);
732
- }
733
- // Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
734
- let self = selectEntryAt(loaded.selfEntries, writeTime, route);
735
- if (self?.immutable && await loaded.store.getInfo(filePath)) return;
736
- } else {
737
- await assertMutable(loaded, filePath, writeTime);
738
- }
739
- if (config?.internal) {
740
- if (!config.lastModified) {
741
- throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), writing ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}`);
742
- }
743
- // See SetConfig.internal: the stamp must land inside SOME window+route this server is configured for (any window, including past ones - synchronization moves old data), so a confused peer can't stuff data onto a node that was never meant to hold it
744
- let covered = loaded.selfEntries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
745
- if (!covered) {
746
- throw new Error(`Internal write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this server is configured for: ${JSON.stringify(loaded.selfEntries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))}`);
747
- }
748
- if (loaded.store.setInternal) {
749
- await loaded.store.setInternal(filePath, data, { lastModified: writeTime });
750
- return;
751
- }
752
- // rawDisk stores: the normal set already IS the disk
753
- await loaded.store.set(filePath, data, { lastModified: writeTime });
754
- return;
755
- }
756
- await loaded.store.set(filePath, data, { ...getWriteConfig(loaded, writeTime, route), lastModified: writeTime });
757
- }
758
-
759
- export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
760
- let progress = bucket.store.getSyncProgress?.();
761
- return {
762
- // Native change-feed support = the store keeps an index (rawDisk buckets emulate getChangesAfter2 with a full listing) - clients use this to pick their scan cadence
763
- supportsChangesAfter: bucket.store instanceof BlobStore,
764
- remoteConfig: bucket.routing,
765
- index: progress?.index,
766
- indexSources: progress?.sources,
767
- readerDiskLimit: progress?.readerDiskLimit,
768
- syncing: progress?.syncing,
769
- };
770
- }
771
-
772
528
  /** Which buckets this process currently has loaded - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
773
529
  export function getActiveBucketKeys(): { account: string; bucketName: string }[] {
774
530
  return [...buckets.keys()].map(key => {
@@ -789,7 +545,7 @@ const INTERMEDIATE_MAINTAIN_INTERVAL = 60 * 1000;
789
545
  // Per bucket, not global: a switchover has to write every bucket's windows at once, and a shared throttle would let only one bucket through per interval
790
546
  const lastOwnConfigWrite = new Map<string, number>();
791
547
 
792
- async function writeOwnRoutingConfig(loaded: LoadedBucket, updated: RemoteConfig, reason: string): Promise<void> {
548
+ async function writeOwnRoutingConfig(loaded: BucketState, updated: RemoteConfig, reason: string): Promise<void> {
793
549
  let key = `${loaded.account}/${loaded.bucketName}`;
794
550
  let sinceLast = Date.now() - (lastOwnConfigWrite.get(key) || 0);
795
551
  if (sinceLast < OWN_CONFIG_WRITE_THROTTLE) {
@@ -801,12 +557,12 @@ async function writeOwnRoutingConfig(loaded: LoadedBucket, updated: RemoteConfig
801
557
  let next: RemoteConfig = { ...updated, version };
802
558
  console.log(`Writing ${ROUTING_FILE} for bucket ${key} (${reason}), version ${getConfigVersion(loaded.routing)} -> ${version}: ${JSON.stringify(next)}`);
803
559
  let data = Buffer.from(serializeRemoteConfig(next));
804
- await writeBucketFile(loaded.account, loaded.bucketName, ROUTING_FILE, data);
560
+ await queueRoutingConfigWrite(loaded.account, loaded.bucketName, data);
805
561
  await propagateRoutingConfig(loaded, next, data);
806
562
  }
807
563
 
808
- async function propagateRoutingConfig(loaded: LoadedBucket, next: RemoteConfig, data: Buffer): Promise<void> {
809
- let targets = new Map<string, HostedConfig | BackblazeConfig>();
564
+ async function propagateRoutingConfig(loaded: BucketState, next: RemoteConfig, data: Buffer): Promise<void> {
565
+ let targets = new Map<string, SourceConfig>();
810
566
  for (let source of [...loaded.routing.sources, ...next.sources]) {
811
567
  if (typeof source === "string" || source.intermediate) continue;
812
568
  if (source.type === "remote") {
@@ -912,11 +668,6 @@ export const startIntermediateMaintenance = lazy(() => {
912
668
  runInfinitePoll(INTERMEDIATE_MAINTAIN_INTERVAL, maintainIntermediates);
913
669
  });
914
670
 
915
- export type BucketDiskInfo = {
916
- totalBytes: number;
917
- freeBytes: number;
918
- usedBytes: number;
919
- };
920
671
  export type ServerBucketInfo = {
921
672
  bucketName: string;
922
673
  active: boolean;
@@ -930,18 +681,6 @@ export type ServerBucketInfo = {
930
681
  error?: string;
931
682
  };
932
683
 
933
- async function getDiskInfo(folder: string): Promise<BucketDiskInfo> {
934
- let stats = await fs.promises.statfs(folder);
935
- let blockSize = Number(stats.bsize);
936
- let totalBytes = Number(stats.blocks) * blockSize;
937
- return {
938
- totalBytes,
939
- // Matches the server's own low-space check: what an unprivileged process can actually use
940
- freeBytes: Number(stats.bavail) * blockSize,
941
- usedBytes: totalBytes - Number(stats.bfree) * blockSize,
942
- };
943
- }
944
-
945
684
  export type ActiveBucketInfo = {
946
685
  folder: string;
947
686
  /** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded */
@@ -952,6 +691,16 @@ export type ActiveBucketInfo = {
952
691
  config: ArchivesConfig;
953
692
  };
954
693
 
694
+ function toActiveBucketInfo(loaded: BucketState): ActiveBucketInfo {
695
+ return {
696
+ folder: getBucketFolder(loaded.account, loaded.bucketName),
697
+ routing: loaded.routing,
698
+ selfEntries: loaded.selfEntries,
699
+ self: loaded.self,
700
+ config: getBucketConfig(loaded),
701
+ };
702
+ }
703
+
955
704
  /** The live in-memory state of ONE bucket, answered without touching the disk (no routing file read, no statfs, no stored write stats). Returns an error string when the bucket is not loaded here, which is the normal state for a bucket nothing has accessed since startup. */
956
705
  export async function getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
957
706
  let key = `${account}/${bucketName}`;
@@ -959,7 +708,7 @@ export async function getActiveBucket(account: string, bucketName: string): Prom
959
708
  if (!loadedPromise) {
960
709
  return `Bucket ${key} is not loaded on this server, so it has no live state (it loads on first access)`;
961
710
  }
962
- let loaded: LoadedBucket | undefined;
711
+ let loaded: BucketState | undefined;
963
712
  try {
964
713
  loaded = await loadedPromise;
965
714
  } catch (e) {
@@ -971,21 +720,11 @@ export async function getActiveBucket(account: string, bucketName: string): Prom
971
720
  return toActiveBucketInfo(loaded);
972
721
  }
973
722
 
974
- function toActiveBucketInfo(loaded: LoadedBucket): ActiveBucketInfo {
975
- return {
976
- folder: getBucketFolder(loaded.account, loaded.bucketName),
977
- routing: loaded.routing,
978
- selfEntries: loaded.selfEntries,
979
- self: loaded.self,
980
- config: getBucketConfig(loaded),
981
- };
982
- }
983
-
984
723
  /** Loads a bucket that exists on this server's disk into memory, which starts its synchronization and window timers, and returns its live state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-loaded buckets just return their state. */
985
724
  export async function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
986
725
  let key = `${account}/${bucketName}`;
987
726
  let wasLoaded = buckets.has(key);
988
- let loaded: LoadedBucket | undefined;
727
+ let loaded: BucketState | undefined;
989
728
  try {
990
729
  loaded = await getLoadedBucket(account, bucketName);
991
730
  } catch (e) {
@@ -994,9 +733,11 @@ export async function activateBucket(account: string, bucketName: string): Promi
994
733
  if (!loaded) {
995
734
  return `Bucket ${key} does not exist on this server (no routing file in ${getBucketFolder(account, bucketName)})`;
996
735
  }
997
- // Wait for the index to load, so the totals we return are the real ones rather than zeroes from a store that has not read its index yet. The source scans keep running in the background.
998
- if (loaded.store instanceof BlobStore) {
999
- await loaded.store.init();
736
+ // Wait for the indexes to load, so the totals we return are the real ones rather than zeroes from stores that have not read their index yet. The source scans keep running in the background.
737
+ for (let s of loaded.stores) {
738
+ if (s.store instanceof BlobStore) {
739
+ await s.store.init();
740
+ }
1000
741
  }
1001
742
  if (!wasLoaded) {
1002
743
  console.log(`Activated bucket ${key} on request: it is now loaded and synchronizing`);
@@ -1022,6 +763,8 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
1022
763
  } catch {
1023
764
  return [];
1024
765
  }
766
+ // Per-route folders (bucketName-route-<start>-<end>) belong to the same bucket as the plain folder - collapse them so one bucket lists once
767
+ names = [...new Set(names.map(x => x.replace(/-route-[\d.]+-[\d.]+$/, "")))];
1025
768
  try {
1026
769
  return await Promise.all(names.map(async bucketName => {
1027
770
  let key = `${account}/${bucketName}`;
@@ -1061,99 +804,121 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
1061
804
  }
1062
805
  }
1063
806
 
1064
- export async function deleteBucketFile(account: string, bucketName: string, filePath: string, config?: { lastModified?: number; internal?: boolean }): Promise<void> {
1065
- assertWritesAllowed();
1066
- if (filePath === ROUTING_FILE) {
1067
- throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
807
+ export type BucketWriteStats = {
808
+ /** Every set call the bucket accepted */
809
+ originalWrites: number;
810
+ originalBytes: number;
811
+ /** What actually reached the sources. Fast writes coalesce repeated writes to the same key, so this is lower than the original counts (and is what the disk actually did). */
812
+ flushedWrites: number;
813
+ flushedBytes: number;
814
+ };
815
+ function emptyWriteStats(): BucketWriteStats {
816
+ return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
817
+ }
818
+
819
+ // In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
820
+ const writeStats = new Map<string, BucketWriteStats>();
821
+
822
+ function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
823
+ let stats = writeStats.get(key);
824
+ if (!stats) {
825
+ stats = emptyWriteStats();
826
+ writeStats.set(key, stats);
1068
827
  }
1069
- let loaded = await getLoadedBucket(account, bucketName);
1070
- if (!loaded) return;
1071
- let writeTime = config?.lastModified || Date.now();
1072
- let route = getRoute(filePath);
1073
- if (config?.internal) {
1074
- if (!config.lastModified) {
1075
- throw new Error(`Internal deletions must carry lastModified (they are synchronization pushes, ordered by their write time), deleting ${JSON.stringify(filePath)} in bucket ${account}/${bucketName}`);
1076
- }
1077
- // Same acceptance rule as internal writes (see SetConfig.internal): the stamp must land inside SOME window+route this server is configured for
1078
- let covered = loaded.selfEntries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
1079
- if (!covered) {
1080
- throw new Error(`Internal deletion of ${JSON.stringify(filePath)} in bucket ${account}/${bucketName} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this server is configured for: ${JSON.stringify(loaded.selfEntries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))}`);
1081
- }
1082
- if (loaded.store.setInternal) {
1083
- // setInternal treats an empty buffer as exactly a deletion: disk removal plus a tombstone index entry, no fan-out
1084
- await loaded.store.setInternal(filePath, Buffer.alloc(0), { lastModified: writeTime });
1085
- return;
1086
- }
1087
- await loaded.store.del(filePath);
1088
- return;
828
+ if (kind === "original") {
829
+ stats.originalWrites++;
830
+ stats.originalBytes += bytes;
831
+ } else {
832
+ stats.flushedWrites++;
833
+ stats.flushedBytes += bytes;
1089
834
  }
1090
- await loaded.store.del(filePath, { ...getWriteConfig(loaded, writeTime, route), lastModified: writeTime });
835
+ }
836
+
837
+ function getBucketWriteStats(key: string): BucketWriteStats {
838
+ return writeStats.get(key) || emptyWriteStats();
839
+ }
840
+
841
+ /** Zeroes the write statistics of every bucket in the account. */
842
+ export function clearAccountWriteStats(account: string): number {
843
+ let prefix = `${account}/`;
844
+ let cleared = 0;
845
+ for (let key of [...writeStats.keys()]) {
846
+ if (!key.startsWith(prefix)) continue;
847
+ writeStats.delete(key);
848
+ cleared++;
849
+ }
850
+ console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
851
+ return cleared;
1091
852
  }
1092
853
 
1093
854
  const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
1094
855
 
1095
- class ArchivesLocalBucket implements IArchives {
1096
- constructor(private account: string, private bucketName: string) { }
856
+ // Wraps a locally hosted bucket in the IArchives interface, so code written against IArchives (the chain, sync sources, holder reads) talks to it directly instead of becoming a network client to ourselves.
857
+ class BucketIArchivesWrapper implements IArchives {
858
+ constructor(private account: string, private bucketName: string, private sourceConfig: SourceConfig) { }
1097
859
 
1098
- private async getBucket(): Promise<LoadedBucket | undefined> {
1099
- return await getLoadedBucket(this.account, this.bucketName);
860
+ private findStore(): Promise<LoadedStore> {
861
+ return findBucketStore(this.account, this.bucketName, this.sourceConfig);
1100
862
  }
1101
863
 
1102
864
  public getDebugName() {
1103
- return `localBucket account ${this.account} bucket ${this.bucketName}`;
865
+ return `localBucket account ${this.account} bucket ${this.bucketName} route ${JSON.stringify(this.sourceConfig.route || FULL_ROUTE)}`;
1104
866
  }
1105
867
  public async get(fileName: string, config?: GetConfig): Promise<Buffer | undefined> {
1106
868
  let result = await this.get2(fileName, config);
1107
869
  return result && result.data || undefined;
1108
870
  }
1109
871
  public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
1110
- let bucket = await this.getBucket();
1111
- if (!bucket) return undefined;
1112
- if (config?.internal && bucket.store.getInternal2) {
1113
- return await bucket.store.getInternal2(fileName, config);
872
+ if (fileName === ROUTING_FILE) {
873
+ return await getRoutingFileResult(this.account, this.bucketName);
1114
874
  }
1115
- return await bucket.store.get2(fileName, config);
875
+ if (config?.internal) {
876
+ return await readBucketInternal(this.account, this.bucketName, { path: fileName, range: config.range, includeTombstones: config.includeTombstones });
877
+ }
878
+ return await (await this.findStore()).store.get2({ path: fileName, ...config });
1116
879
  }
1117
880
  public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
1118
- await writeBucketFile(this.account, this.bucketName, fileName, data, config);
881
+ assertWritesAllowed();
882
+ if (fileName === ROUTING_FILE) {
883
+ await queueRoutingConfigWrite(this.account, this.bucketName, data, config);
884
+ return fileName;
885
+ }
886
+ await (await this.findStore()).store.set({ ...config, path: fileName, data });
1119
887
  return fileName;
1120
888
  }
1121
889
  public async del(fileName: string, config?: DelConfig): Promise<void> {
1122
- await deleteBucketFile(this.account, this.bucketName, fileName, config);
890
+ assertWritesAllowed();
891
+ await (await this.findStore()).store.del({ path: fileName, ...config });
1123
892
  }
1124
893
  public async setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
1125
894
  assertWritesAllowed();
1126
- let bucket = await this.getBucket();
1127
- if (!bucket) {
1128
- throw new Error(`Bucket ${this.account}/${this.bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
1129
- }
1130
- await assertMutable(bucket, config.path, config.lastModified || Date.now());
1131
- let id = await bucket.store.startLargeUpload();
895
+ let store = (await this.findStore()).store;
896
+ let id = await store.startLargeUpload({ path: config.path, lastModified: config.lastModified });
1132
897
  try {
1133
898
  while (true) {
1134
899
  let data = await config.getNextData();
1135
900
  if (!data) break;
1136
901
  for (let offset = 0; offset < data.length; offset += LARGE_FILE_PART_SIZE) {
1137
- await bucket.store.appendLargeUpload(id, data.subarray(offset, offset + LARGE_FILE_PART_SIZE));
902
+ await store.appendLargeUpload({ id, data: data.subarray(offset, offset + LARGE_FILE_PART_SIZE) });
1138
903
  }
1139
904
  }
1140
- await bucket.store.finishLargeUpload(id, config.path, config.lastModified);
905
+ await store.finishLargeUpload({ id, path: config.path, lastModified: config.lastModified });
1141
906
  } catch (e) {
1142
- await bucket.store.cancelLargeUpload(id);
907
+ await store.cancelLargeUpload({ id });
1143
908
  throw e;
1144
909
  }
1145
910
  }
1146
911
  public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined> {
1147
- let bucket = await this.getBucket();
1148
- if (!bucket) return undefined;
1149
- return await bucket.store.getInfo(fileName, config);
912
+ if (fileName === ROUTING_FILE) {
913
+ let result = await getRoutingFileResult(this.account, this.bucketName);
914
+ return result && { writeTime: result.writeTime, size: result.size } || undefined;
915
+ }
916
+ return await (await this.findStore()).store.getInfo({ path: fileName, ...config });
1150
917
  }
1151
- public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
1152
- let bucket = await this.getBucket();
1153
- if (!bucket) return [];
1154
- return await bucket.store.findInfo(prefix, config);
918
+ public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
919
+ return await (await this.findStore()).store.findInfo({ prefix, ...config });
1155
920
  }
1156
- public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
921
+ public async find(prefix: string, config?: FindConfig): Promise<string[]> {
1157
922
  return (await this.findInfo(prefix, config)).map(x => x.path);
1158
923
  }
1159
924
  public async getURL(filePath: string): Promise<string> {
@@ -1161,34 +926,25 @@ class ArchivesLocalBucket implements IArchives {
1161
926
  return buildFileUrl(`https://${domain}:${port}/file/${encodeURIComponent(this.account)}/${encodeURIComponent(this.bucketName)}`, filePath);
1162
927
  }
1163
928
  public async getConfig(): Promise<ArchivesConfig> {
1164
- let bucket = await this.getBucket();
1165
- if (!bucket) return { supportsChangesAfter: true };
1166
- return getBucketConfig(bucket);
929
+ return getBucketConfig(await requireBucket(this.account, this.bucketName));
1167
930
  }
1168
931
  public async hasWriteAccess(): Promise<boolean> {
1169
932
  return true;
1170
933
  }
1171
934
  public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
1172
- let bucket = await this.getBucket();
1173
- if (!bucket) return [];
1174
- return await bucket.store.getChangesAfter2(config);
935
+ return await (await this.findStore()).store.getChangesAfter2(config);
1175
936
  }
1176
937
  public async getSyncStatus(): Promise<ArchivesSyncStatus> {
1177
- let bucket = await this.getBucket();
1178
- if (!bucket) return { allScansComplete: true, indexSize: 0, sources: [] };
1179
- if (!bucket.store.getSyncStatus) {
1180
- throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
1181
- }
1182
- return await bucket.store.getSyncStatus();
938
+ return await bucketSyncStatus(await requireBucket(this.account, this.bucketName));
1183
939
  }
1184
940
  }
1185
941
 
1186
942
  const localArchives = new Map<string, IArchives>();
1187
- export function getLocalArchives(account: string, bucketName: string): IArchives {
1188
- let key = `${account}/${bucketName}`;
943
+ export function getLocalArchives(account: string, bucketName: string, sourceConfig: SourceConfig): IArchives {
944
+ let key = `${account}/${bucketName}|${stableStringify(sourceConfig)}`;
1189
945
  let existing = localArchives.get(key);
1190
946
  if (existing) return existing;
1191
- let archives = new ArchivesLocalBucket(account, bucketName);
947
+ let archives = new BucketIArchivesWrapper(account, bucketName, sourceConfig);
1192
948
  localArchives.set(key, archives);
1193
949
  return archives;
1194
950
  }