sliftutils 1.7.9 → 1.7.11

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 (92) hide show
  1. package/bin/storageserver.js +4 -0
  2. package/{teststorage → examplestorage}/browser.tsx +18 -22
  3. package/{teststorage/server.ts → examplestorage/exampleserver.ts} +4 -6
  4. package/index.d.ts +770 -84
  5. package/misc/dist/environment.ts.cache +3 -3
  6. package/misc/dist/getSecret.ts.cache +139 -0
  7. package/misc/dist/strings.ts.cache +13 -0
  8. package/misc/dist/zip.ts.cache +9 -0
  9. package/misc/getSecret.ts +38 -16
  10. package/misc/https/cloudflareHelpers.d.ts +19 -0
  11. package/misc/https/cloudflareHelpers.ts +69 -0
  12. package/misc/https/dist/certs.ts.cache +115 -179
  13. package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
  14. package/misc/https/dist/dns.ts.cache +112 -100
  15. package/misc/https/dist/hostServer.ts.cache +130 -0
  16. package/misc/https/dist/httpsCerts.ts.cache +24 -5
  17. package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
  18. package/misc/https/dns.d.ts +41 -8
  19. package/misc/https/dns.ts +104 -106
  20. package/misc/https/hostServer.d.ts +0 -3
  21. package/misc/https/hostServer.ts +12 -8
  22. package/package.json +3 -2
  23. package/render-utils/dist/observer.tsx.cache +3 -2
  24. package/spec.txt +0 -55
  25. package/storage/ArchivesDisk.d.ts +67 -0
  26. package/storage/ArchivesDisk.ts +369 -0
  27. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
  28. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
  29. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
  30. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
  31. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
  32. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
  33. package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
  34. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
  35. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
  36. package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
  37. package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
  38. package/storage/IArchives.d.ts +127 -1
  39. package/storage/IArchives.ts +201 -1
  40. package/storage/backblaze.d.ts +29 -2
  41. package/storage/backblaze.ts +214 -78
  42. package/storage/dist/ArchivesDisk.ts.cache +373 -0
  43. package/storage/dist/FileFolderAPI.tsx.cache +508 -65
  44. package/storage/dist/IArchives.ts.cache +44 -0
  45. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
  46. package/storage/dist/JSONStorage.ts.cache +6 -2
  47. package/storage/dist/PendingManager.tsx.cache +1 -1
  48. package/storage/dist/TransactionStorage.ts.cache +231 -92
  49. package/storage/dist/backblaze.ts.cache +797 -0
  50. package/storage/dist/fileSystemPointer.ts.cache +36 -3
  51. package/storage/dist/remoteFileStorage.ts.cache +495 -0
  52. package/storage/remoteStorage/ArchivesRemote.d.ts +34 -18
  53. package/storage/remoteStorage/ArchivesRemote.ts +85 -65
  54. package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
  55. package/storage/remoteStorage/ArchivesUrl.ts +86 -0
  56. package/storage/remoteStorage/accessPage.tsx +111 -28
  57. package/storage/remoteStorage/blobStore.d.ts +128 -25
  58. package/storage/remoteStorage/blobStore.ts +757 -290
  59. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  60. package/storage/remoteStorage/cliArgs.ts +9 -0
  61. package/storage/remoteStorage/createArchives.d.ts +85 -0
  62. package/storage/remoteStorage/createArchives.ts +612 -0
  63. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
  64. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
  65. package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
  66. package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
  67. package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
  68. package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
  69. package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
  70. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
  71. package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
  72. package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
  73. package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
  74. package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
  75. package/storage/remoteStorage/grantAccess.js +4 -0
  76. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  77. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  78. package/storage/remoteStorage/remoteConfig.d.ts +29 -0
  79. package/storage/remoteStorage/remoteConfig.ts +194 -0
  80. package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
  81. package/storage/remoteStorage/sourceWrapper.ts +223 -0
  82. package/storage/remoteStorage/spec.md +31 -0
  83. package/storage/remoteStorage/storageController.d.ts +30 -27
  84. package/storage/remoteStorage/storageController.ts +297 -159
  85. package/storage/remoteStorage/storageServer.d.ts +6 -0
  86. package/storage/remoteStorage/storageServer.ts +88 -94
  87. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  88. package/storage/remoteStorage/storageServerCli.ts +38 -0
  89. package/storage/remoteStorage/storageServerState.d.ts +43 -0
  90. package/storage/remoteStorage/storageServerState.ts +540 -0
  91. package/testsite/server.ts +0 -2
  92. package/yarn.lock +11 -6
@@ -0,0 +1,612 @@
1
+ module.allowclient = true;
2
+
3
+ import { isNode, sort } from "socket-function/src/misc";
4
+ import {
5
+ IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig,
6
+ ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, WRITE_PAST_WINDOW_GRACE, STORAGE_WRONG_VALID_WINDOW,
7
+ STORAGE_WRONG_ROUTE, FULL_ROUTE, VARIABLE_SHARD,
8
+ } from "../IArchives";
9
+ import {
10
+ ROUTING_FILE, getConfigVersion, parseHostedUrl, parseBackblazeUrl,
11
+ normalizeRemoteConfig, normalizeSource, parseRoutingData, serializeRemoteConfig,
12
+ getRoute, routeContains, parseVariableRoute,
13
+ } from "./remoteConfig";
14
+ import { ArchivesRemote } from "./ArchivesRemote";
15
+ import { ArchivesBackblaze } from "../backblaze";
16
+ import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
17
+ import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
18
+
19
+ // Turns a RemoteConfig into a usable IArchives (createArchives). Initialization is lazy: on the
20
+ // first call we walk the sources IN ORDER and the first one that answers is authoritative (sources
21
+ // are synchronized copies of the same bucket, so we never consult the rest). Its stored routing
22
+ // config wins over the in-memory one - unless ours has a strictly newer version, in which case we
23
+ // write ours (creating the bucket when no routing exists at all). A failed init (all sources down,
24
+ // or the routing write was rejected) stays failed for callers but retries itself in the background
25
+ // with a ramping delay. Once running, we re-read the routing config every CONFIG_POLL_INTERVAL and
26
+ // rebuild the source list when it changed (reusing wrappers for unchanged sources).
27
+
28
+ const CONFIG_POLL_INTERVAL = 5 * 60 * 1000;
29
+
30
+ // The direct API IArchives for one source, with no URL-form fallback and no chaining. Used by the
31
+ // storage server for its synchronization sources. Denied calls throw immediately (registering the
32
+ // access request in the background): the sync loops retry-and-log until access is granted, while
33
+ // explicit writes surface the denial (with grant instructions) to the caller instead of hanging.
34
+ export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
35
+ if (source.type === "backblaze") {
36
+ return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable });
37
+ }
38
+ let parsed = parseHostedUrl(source.url);
39
+ let server = isNode() && getStorageServerConfigOptional() || undefined;
40
+ if (server && parsed.address === server.domain && parsed.port === server.port) {
41
+ // This source is a bucket hosted by our own process - use it directly instead of calling
42
+ // ourselves over HTTPS
43
+ return getLocalArchives(parsed.account, parsed.bucketName);
44
+ }
45
+ return new ArchivesRemote({ url: source.url, accountName: source.accountName, waitForAccess: false });
46
+ }
47
+
48
+ type ChainState = {
49
+ config: RemoteConfig;
50
+ sources: SourceWrapper[];
51
+ };
52
+
53
+ function configWindowCurrent(config: HostedConfig | BackblazeConfig): boolean {
54
+ let now = Date.now();
55
+ let [start, end] = config.validWindow;
56
+ return start - WRITE_PAST_WINDOW_GRACE <= now && now <= end + WRITE_PAST_WINDOW_GRACE;
57
+ }
58
+
59
+ // Client writes target only the CURRENTLY valid source: a future-window source receives its data
60
+ // through server-side downstream propagation/backfill, never directly from clients
61
+ function configAcceptsWrites(config: HostedConfig | BackblazeConfig): boolean {
62
+ return configWindowCurrent(config);
63
+ }
64
+
65
+ export class ArchivesChain implements IArchives {
66
+ private configured: RemoteConfig;
67
+ // The config we actually run on (the authoritative stored one after init)
68
+ private activeConfig: RemoteConfig;
69
+ private statePromise: Promise<ChainState> | undefined;
70
+ private initRetryDelay = RETRY_START_DELAY;
71
+ private initRetryTimer: ReturnType<typeof setTimeout> | undefined;
72
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
73
+ private disposed = false;
74
+
75
+ constructor(config: RemoteConfig | RemoteConfigBase) {
76
+ this.configured = normalizeRemoteConfig(config);
77
+ this.activeConfig = this.configured;
78
+ }
79
+
80
+ public getDebugName() {
81
+ let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
82
+ return `chain/${urls.join(",")}`;
83
+ }
84
+
85
+ // Lazy init that rethrows its error to every caller, while a background timer resets and
86
+ // retries it with a ramping delay - so a chain that couldn't initialize fixes itself once a
87
+ // source comes back, without any caller having to drive it.
88
+ private getState(): Promise<ChainState> {
89
+ if (this.disposed) {
90
+ return Promise.reject(new Error(`ArchivesChain ${this.getDebugName()} has been disposed`));
91
+ }
92
+ if (!this.statePromise) {
93
+ let promise = this.init();
94
+ this.statePromise = promise;
95
+ promise.then(() => {
96
+ this.initRetryDelay = RETRY_START_DELAY;
97
+ }, (e: Error) => {
98
+ if (this.disposed || this.initRetryTimer) return;
99
+ console.error(`Storage init failed for ${this.getDebugName()}, retrying in ${Math.round(this.initRetryDelay / 1000)}s. ${e.stack ?? e}`);
100
+ this.initRetryTimer = setTimeout(() => {
101
+ this.initRetryTimer = undefined;
102
+ if (this.disposed) return;
103
+ if (this.statePromise === promise) {
104
+ this.statePromise = undefined;
105
+ }
106
+ this.getState().catch(() => { });
107
+ }, this.initRetryDelay);
108
+ (this.initRetryTimer as { unref?: () => void }).unref?.();
109
+ this.initRetryDelay = Math.min(RETRY_MAX_DELAY, this.initRetryDelay * RETRY_GROWTH);
110
+ });
111
+ }
112
+ return this.statePromise;
113
+ }
114
+
115
+ private async init(): Promise<ChainState> {
116
+ let configs = this.configured.sources.map(normalizeSource);
117
+ let probeErrors: string[] = [];
118
+ let found: { probe: SourceWrapper; existing: RemoteConfig | undefined } | undefined;
119
+ for (let sourceConfig of configs) {
120
+ let probe = await SourceWrapper.create(sourceConfig, { background: false });
121
+ try {
122
+ let data = await probe.read(archives => archives.get(ROUTING_FILE));
123
+ found = { probe, existing: data && parseRoutingData(data) || undefined };
124
+ break;
125
+ } catch (e) {
126
+ probeErrors.push(`${sourceConfig.url}: ${(e as Error).stack ?? e}`);
127
+ probe.dispose();
128
+ }
129
+ }
130
+ if (!found) {
131
+ throw new Error(`Cannot initialize storage for ${this.getDebugName()}: no source answered. ${probeErrors.join(" | ")}`);
132
+ }
133
+ let active = this.configured;
134
+ let { probe, existing } = found;
135
+ let needsWrite = true;
136
+ if (existing && getConfigVersion(existing) >= getConfigVersion(this.configured)) {
137
+ if (getConfigVersion(existing) === getConfigVersion(this.configured) && JSON.stringify(existing) !== JSON.stringify(this.configured)) {
138
+ console.error(`Archives configuration updated without updating the version, for ${probe.config.url}. Updates will be ignored until you increase the version. Using: ${JSON.stringify(existing)}, ignoring: ${JSON.stringify(this.configured)}`);
139
+ }
140
+ active = existing;
141
+ needsWrite = false;
142
+ }
143
+ let sources = await this.buildSources(active);
144
+ if (needsWrite) {
145
+ // We decided to write (ours is newer than the authoritative first-up source's, or no
146
+ // routing exists yet). The write goes to all sources, so now EVERY reachable source's
147
+ // stored routing is read, and the write is refused unless our version is strictly
148
+ // greater than all of them - a source already at (or past) our version means this
149
+ // version number is taken, and writing anyway would leave sources at the same version
150
+ // with different content, each rejecting the other's pushes forever.
151
+ let best: RemoteConfig | undefined;
152
+ let conflictUrl: string | undefined;
153
+ for (let source of sources) {
154
+ let data: Buffer | undefined;
155
+ try {
156
+ data = await source.read(archives => archives.get(ROUTING_FILE));
157
+ } catch {
158
+ // Down sources are still protected by the server-side version guard when the
159
+ // write eventually reaches them
160
+ continue;
161
+ }
162
+ if (!data) continue;
163
+ let stored = parseRoutingData(data);
164
+ if (!best || getConfigVersion(stored) > getConfigVersion(best)) {
165
+ best = stored;
166
+ }
167
+ if (getConfigVersion(stored) === getConfigVersion(this.configured) && JSON.stringify(stored) !== JSON.stringify(this.configured)) {
168
+ conflictUrl = source.config.url;
169
+ }
170
+ }
171
+ if (best && getConfigVersion(best) >= getConfigVersion(this.configured)) {
172
+ if (conflictUrl && getConfigVersion(best) === getConfigVersion(this.configured)) {
173
+ console.error(`Archives configuration updated without updating the version, for ${conflictUrl}. Updates will be ignored until you increase the version. Using: ${JSON.stringify(best)}, ignoring: ${JSON.stringify(this.configured)}`);
174
+ }
175
+ active = best;
176
+ needsWrite = false;
177
+ for (let source of sources) {
178
+ source.dispose();
179
+ }
180
+ sources = await this.buildSources(active);
181
+ }
182
+ }
183
+ if (needsWrite) {
184
+ // The update only happens when EVERY source would accept it - a partial update would
185
+ // leave the sources out of sync, and a client without access retrying the write forever
186
+ // would never initialize. Without full access we run on the stored config (when there
187
+ // is one) and log the problem instead.
188
+ let missing: string[] = [];
189
+ for (let source of sources) {
190
+ try {
191
+ if (!await source.hasWriteAccess()) {
192
+ missing.push(source.config.url);
193
+ }
194
+ } catch (e) {
195
+ missing.push(`${source.config.url} (check failed: ${(e as Error).stack ?? e})`);
196
+ }
197
+ }
198
+ if (missing.length) {
199
+ console.error(`Not writing the storage routing config for ${this.getDebugName()} (version ${getConfigVersion(this.configured)}): no write access to ${missing.join(", ")}. ${existing && `Running on the stored config (version ${getConfigVersion(existing)}) instead.` || `No stored config exists, so the bucket cannot be created until write access is granted.`}`);
200
+ if (existing) {
201
+ active = existing;
202
+ for (let source of sources) {
203
+ source.dispose();
204
+ }
205
+ sources = await this.buildSources(active);
206
+ }
207
+ } else {
208
+ try {
209
+ // A rejected write fails init, which retries from scratch - re-reading the
210
+ // routing, so losing a create race to another client just adopts their config
211
+ // on the next attempt.
212
+ await probe.write(archives => archives.set(ROUTING_FILE, serializeRemoteConfig(this.configured)));
213
+ } catch (e) {
214
+ for (let source of sources) {
215
+ source.dispose();
216
+ }
217
+ probe.dispose();
218
+ throw e;
219
+ }
220
+ }
221
+ }
222
+ probe.dispose();
223
+ this.activeConfig = active;
224
+ this.startConfigPoll();
225
+ return { config: active, sources };
226
+ }
227
+
228
+ private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
229
+ let sources: SourceWrapper[] = [];
230
+ for (let sourceConfig of config.sources.map(normalizeSource)) {
231
+ let source = await SourceWrapper.create(sourceConfig);
232
+ // Latency (for variable-shard target preference) is tracked from initialization on
233
+ source.startPinging();
234
+ sources.push(source);
235
+ }
236
+ return sources;
237
+ }
238
+
239
+ private startConfigPoll(): void {
240
+ if (this.pollTimer || this.disposed) return;
241
+ this.pollTimer = setInterval(() => {
242
+ void this.checkForNewConfig().catch((e: Error) => {
243
+ console.error(`Checking for a new storage routing config failed for ${this.getDebugName()}: ${e.stack ?? e}`);
244
+ });
245
+ }, CONFIG_POLL_INTERVAL);
246
+ (this.pollTimer as { unref?: () => void }).unref?.();
247
+ }
248
+
249
+ private async checkForNewConfig(): Promise<void> {
250
+ if (this.disposed || !this.statePromise) return;
251
+ let state: ChainState;
252
+ try {
253
+ state = await this.statePromise;
254
+ } catch {
255
+ // Init is failing; its own retry loop handles that
256
+ return;
257
+ }
258
+ let data = await this.run(state, {}, archives => archives.get(ROUTING_FILE));
259
+ if (!data) return;
260
+ let latest = parseRoutingData(data);
261
+ if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
262
+ console.log(`Storage routing config changed for ${this.getDebugName()}, rebuilding sources`);
263
+ let oldByConfig = new Map(state.sources.map(source => [JSON.stringify(source.config), source]));
264
+ let sources: SourceWrapper[] = [];
265
+ for (let sourceConfig of latest.sources.map(normalizeSource)) {
266
+ let key = JSON.stringify(sourceConfig);
267
+ let old = oldByConfig.get(key);
268
+ if (old) {
269
+ oldByConfig.delete(key);
270
+ sources.push(old);
271
+ } else {
272
+ sources.push(await SourceWrapper.create(sourceConfig));
273
+ }
274
+ }
275
+ // In-flight requests still hold the old wrappers and finish fine; dispose just stops any
276
+ // background reconnect loops
277
+ for (let leftover of oldByConfig.values()) {
278
+ leftover.dispose();
279
+ }
280
+ this.activeConfig = latest;
281
+ this.statePromise = Promise.resolve({ config: latest, sources });
282
+ }
283
+
284
+ // Runs a request against the first available source (that covers the key's route, when one is
285
+ // given), moving to the next one ONLY when the source's WebSocket is down (checked directly -
286
+ // never by inspecting the error, which is arbitrary data). An error from a connected source is
287
+ // an application error and throws as-is. A down source gets its background reconnect kicked.
288
+ private async run<T>(state: ChainState, config: { apiOnly?: boolean; write?: boolean; route?: number }, run: (archives: IArchives) => Promise<T>): Promise<T> {
289
+ if (config.write) {
290
+ return await this.runWrite(state, config.route, run);
291
+ }
292
+ let errors: string[] = [];
293
+ for (let source of state.sources) {
294
+ if (config.route !== undefined && !routeContains(source.config.route, config.route)) continue;
295
+ if (!configWindowCurrent(source.config)) continue;
296
+ if (!source.isConnected()) {
297
+ source.noteFailure();
298
+ errors.push(`${source.config.url} is not connected`);
299
+ continue;
300
+ }
301
+ try {
302
+ if (config.apiOnly) {
303
+ let api = source.api;
304
+ if (!api) {
305
+ errors.push(`${source.config.url} has URL-only access, which cannot serve this operation`);
306
+ continue;
307
+ }
308
+ return await run(api);
309
+ }
310
+ return await source.read(run);
311
+ } catch (e) {
312
+ if (source.isConnected()) throw e;
313
+ source.noteFailure();
314
+ errors.push(String((e as Error).stack ?? e));
315
+ }
316
+ }
317
+ throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
318
+ }
319
+
320
+ // Writes are consistent: they go to the first currently-valid source covering the key's route
321
+ // and NEVER fail over to another node - a client wrongly deciding nodes are down must not
322
+ // scatter its writes across the chain. (Reads fail over freely above: sources are synchronized
323
+ // copies, so reading from any of them is safe.) The one retry is for a window boundary passing
324
+ // (or a route disagreement) mid-write, which re-resolves the target rather than falling back.
325
+ private async runWrite<T>(state: ChainState, route: number | undefined, run: (archives: IArchives) => Promise<T>): Promise<T> {
326
+ let retriedWrongWindow = false;
327
+ let retriedWrongRoute = false;
328
+ while (true) {
329
+ let target = state.sources.find(x => configAcceptsWrites(x.config) && (route === undefined || routeContains(x.config.route, route)));
330
+ if (!target) {
331
+ throw new Error(`No source accepts writes for ${this.getDebugName()}${route !== undefined && ` (route ${route})` || ""} (every source is outside its valid window or outside the key's route)`);
332
+ }
333
+ if (!target.isConnected()) {
334
+ target.noteFailure();
335
+ throw new Error(`Cannot write: the write target ${target.config.url} is not connected (writes never fail over to other sources)`);
336
+ }
337
+ try {
338
+ return await target.write(run);
339
+ } catch (e) {
340
+ let message = String((e as Error).stack ?? e);
341
+ if (message.includes(STORAGE_WRONG_VALID_WINDOW) && !retriedWrongWindow) {
342
+ retriedWrongWindow = true;
343
+ continue;
344
+ }
345
+ if (message.includes(STORAGE_WRONG_ROUTE) && !retriedWrongRoute) {
346
+ retriedWrongRoute = true;
347
+ continue;
348
+ }
349
+ if (!target.isConnected()) {
350
+ target.noteFailure();
351
+ }
352
+ throw e;
353
+ }
354
+ }
355
+ }
356
+
357
+ private async request<T>(config: { apiOnly?: boolean; write?: boolean; route?: number }, run: (archives: IArchives) => Promise<T>): Promise<T> {
358
+ let state = await this.getState();
359
+ return await this.run(state, config, run);
360
+ }
361
+
362
+ // The access-page link (plus our machineId/ip, for the approver to match the request) for the
363
+ // first hosted source that hasn't granted us access. undefined when we have access everywhere
364
+ // we can have it. Also registers the access request server-side.
365
+ public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
366
+ let state = await this.getState();
367
+ for (let source of state.sources) {
368
+ if (source.api instanceof ArchivesRemote) {
369
+ let waiting = await source.api.waitingForAccess();
370
+ if (waiting) return waiting;
371
+ }
372
+ }
373
+ return undefined;
374
+ }
375
+
376
+ public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
377
+ let result = await this.get2(fileName, config);
378
+ return result && result.data || undefined;
379
+ }
380
+ public async get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
381
+ return await this.request({ route: getRoute(fileName) }, archives => archives.get2(fileName, config));
382
+ }
383
+ public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
384
+ return await this.request({ route: getRoute(fileName) }, archives => archives.getInfo(fileName));
385
+ }
386
+
387
+ // A minimal set of connected, currently-valid API sources whose routes cover [0, 1) - listing
388
+ // operations must merge every shard. Greedy sweep: repeatedly take the source that extends
389
+ // coverage the furthest. In practice this is one source (unsharded), or one per shard.
390
+ private selectCoveringSources(state: ChainState): SourceWrapper[] {
391
+ let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && x.isConnected());
392
+ let chosen: SourceWrapper[] = [];
393
+ let covered = 0;
394
+ while (covered < 1) {
395
+ let best: SourceWrapper | undefined;
396
+ let bestEnd = covered;
397
+ for (let source of candidates) {
398
+ let [start, end] = source.config.route || FULL_ROUTE;
399
+ if (start > covered) continue;
400
+ if (end > bestEnd) {
401
+ bestEnd = end;
402
+ best = source;
403
+ }
404
+ }
405
+ if (!best) {
406
+ throw new Error(`Cannot cover the full route space for ${this.getDebugName()}: the available sources only cover up to ${covered} (some shards are down or URL-only)`);
407
+ }
408
+ chosen.push(best);
409
+ covered = bestEnd;
410
+ }
411
+ return chosen;
412
+ }
413
+
414
+ private async runOnApi<T>(source: SourceWrapper, run: (archives: IArchives) => Promise<T>): Promise<T> {
415
+ let api = source.api;
416
+ if (!api) {
417
+ throw new Error(`${source.config.url} has URL-only access, which cannot serve this operation`);
418
+ }
419
+ return await run(api);
420
+ }
421
+
422
+ public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
423
+ return (await this.findInfo(prefix, config)).map(x => x.path);
424
+ }
425
+ public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
426
+ let state = await this.getState();
427
+ let covering = this.selectCoveringSources(state);
428
+ let results = await Promise.all(covering.map(source => this.runOnApi(source, archives => archives.findInfo(prefix, config))));
429
+ // Overlapping shards can both report a path; the newest wins
430
+ let byPath = new Map<string, ArchiveFileInfo>();
431
+ for (let list of results) {
432
+ for (let file of list) {
433
+ let existing = byPath.get(file.path);
434
+ if (!existing || file.createTime > existing.createTime) {
435
+ byPath.set(file.path, file);
436
+ }
437
+ }
438
+ }
439
+ let merged = [...byPath.values()];
440
+ sort(merged, x => x.path);
441
+ return merged;
442
+ }
443
+ public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
444
+ let state = await this.getState();
445
+ let covering = this.selectCoveringSources(state);
446
+ let results = await Promise.all(covering.map(source => this.runOnApi(source, async archives => {
447
+ if (!archives.getChangesAfter) {
448
+ throw new Error(`${archives.getDebugName()} does not support getChangesAfter`);
449
+ }
450
+ return await archives.getChangesAfter(time);
451
+ })));
452
+ let byPath = new Map<string, ArchiveFileInfo>();
453
+ for (let list of results) {
454
+ for (let file of list) {
455
+ let existing = byPath.get(file.path);
456
+ if (!existing || file.createTime > existing.createTime) {
457
+ byPath.set(file.path, file);
458
+ }
459
+ }
460
+ }
461
+ let merged = [...byPath.values()];
462
+ sort(merged, x => x.path);
463
+ return merged;
464
+ }
465
+ public async getSyncStatus(): Promise<ArchivesSyncStatus> {
466
+ let state = await this.getState();
467
+ let covering = this.selectCoveringSources(state);
468
+ let statuses = await Promise.all(covering.map(source => this.runOnApi(source, async archives => {
469
+ if (!archives.getSyncStatus) {
470
+ throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
471
+ }
472
+ return await archives.getSyncStatus();
473
+ })));
474
+ return {
475
+ allScansComplete: statuses.every(x => x.allScansComplete),
476
+ indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
477
+ sources: statuses.flatMap(x => x.sources),
478
+ };
479
+ }
480
+ public async getConfig(): Promise<ArchivesConfig> {
481
+ let state = await this.getState();
482
+ if (!state.sources.some(x => x.api)) return { remoteConfig: state.config };
483
+ let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
484
+ return { ...config, remoteConfig: state.config };
485
+ }
486
+ /** True only when EVERY write-receiving source would accept our writes (partial write access
487
+ * desynchronizes sources, so it counts as no access). */
488
+ public async hasWriteAccess(): Promise<boolean> {
489
+ let state = await this.getState();
490
+ for (let source of state.sources) {
491
+ if (!configAcceptsWrites(source.config)) continue;
492
+ if (!await source.hasWriteAccess()) return false;
493
+ }
494
+ return true;
495
+ }
496
+
497
+ private assertNotBareVariableShard(fileName: string): void {
498
+ if (fileName.includes(VARIABLE_SHARD) && parseVariableRoute(fileName) === undefined) {
499
+ throw new Error(`Keys containing VARIABLE_SHARD must be written with setVariableShard, which materializes the shard value and returns the full key. Key: ${JSON.stringify(fileName)}`);
500
+ }
501
+ }
502
+
503
+ public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
504
+ this.assertNotBareVariableShard(fileName);
505
+ await this.request({ write: true, route: getRoute(fileName) }, archives => archives.set(fileName, data, config));
506
+ }
507
+ public async del(fileName: string): Promise<void> {
508
+ await this.request({ write: true, route: getRoute(fileName) }, archives => archives.del(fileName));
509
+ }
510
+
511
+ /** Writes a key containing the VARIABLE_SHARD sentinel: picks the lowest-latency up write
512
+ * shard, materializes the key with a random value inside that shard's route, writes it, and
513
+ * returns the FULL key actually written (the caller needs it to ever read the value back).
514
+ * Unlike normal writes this CAN move to another shard when the preferred one is down (error +
515
+ * socket down, same rule as reads) - each shard receives a different key, so write
516
+ * consistency is preserved. */
517
+ public async setVariableShard(key: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
518
+ if (!key.includes(VARIABLE_SHARD)) {
519
+ throw new Error(`Expected the key to contain the VARIABLE_SHARD sentinel, was ${JSON.stringify(key)}`);
520
+ }
521
+ if (parseVariableRoute(key) !== undefined) {
522
+ throw new Error(`The key already has a materialized shard value; write it with set instead. Key: ${JSON.stringify(key)}`);
523
+ }
524
+ let state = await this.getState();
525
+ // Per-shard write consistency still holds: within a route, only the FIRST source may take
526
+ // writes - latency only picks WHICH shard the key materializes into
527
+ let targetsByRoute = new Map<string, SourceWrapper>();
528
+ for (let source of state.sources) {
529
+ if (!configAcceptsWrites(source.config)) continue;
530
+ let routeKey = JSON.stringify(source.config.route || FULL_ROUTE);
531
+ if (!targetsByRoute.has(routeKey)) {
532
+ targetsByRoute.set(routeKey, source);
533
+ }
534
+ }
535
+ let targets = [...targetsByRoute.values()];
536
+ sort(targets, x => x.getLatency());
537
+ let errors: string[] = [];
538
+ for (let target of targets) {
539
+ if (!target.isConnected()) {
540
+ target.noteFailure();
541
+ errors.push(`${target.config.url} is not connected`);
542
+ continue;
543
+ }
544
+ let [start, end] = target.config.route || FULL_ROUTE;
545
+ let fullKey = key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
546
+ try {
547
+ await target.write(archives => archives.set(fullKey, data, config));
548
+ return fullKey;
549
+ } catch (e) {
550
+ // An error alone doesn't justify moving on (it would be an application error); the
551
+ // socket must also be down
552
+ if (target.isConnected()) throw e;
553
+ target.noteFailure();
554
+ errors.push(String((e as Error).stack ?? e));
555
+ }
556
+ }
557
+ throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
558
+ }
559
+
560
+ public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
561
+ this.assertNotBareVariableShard(config.path);
562
+ let state = await this.getState();
563
+ // Same consistency rule as runWrite: the first currently-valid source for the route, or nothing
564
+ let route = getRoute(config.path);
565
+ let target = state.sources.find(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route));
566
+ if (!target) {
567
+ throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
568
+ }
569
+ if (!target.isConnected()) {
570
+ target.noteFailure();
571
+ throw new Error(`Cannot write: the write target ${target.config.url} is not connected (writes never fail over to other sources)`);
572
+ }
573
+ await target.write(archives => archives.setLargeFile(config));
574
+ }
575
+
576
+ public async getURL(path: string): Promise<string> {
577
+ let state = await this.getState();
578
+ let route = getRoute(path);
579
+ for (let source of state.sources) {
580
+ if (source.config.public === false) continue;
581
+ if (!routeContains(source.config.route, route)) continue;
582
+ if (source.url) return await source.url.getURL(path);
583
+ if (source.api) return await source.api.getURL(path);
584
+ }
585
+ throw new Error(`No public source covering route ${route} to build a URL from for ${this.getDebugName()}`);
586
+ }
587
+
588
+ public dispose(): void {
589
+ this.disposed = true;
590
+ if (this.pollTimer) {
591
+ clearInterval(this.pollTimer);
592
+ }
593
+ if (this.initRetryTimer) {
594
+ clearTimeout(this.initRetryTimer);
595
+ }
596
+ let statePromise = this.statePromise;
597
+ if (statePromise) {
598
+ void statePromise.then(state => {
599
+ for (let source of state.sources) {
600
+ source.dispose();
601
+ }
602
+ }, () => { });
603
+ }
604
+ }
605
+ }
606
+
607
+ // The IArchives for a RemoteConfig (or a single source - a routing URL string works). Fully lazy:
608
+ // nothing is contacted until the first call. Call dispose() when done with it, so its background
609
+ // retry/poll loops stop.
610
+ export function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain {
611
+ return new ArchivesChain(config);
612
+ }