sliftutils 1.7.71 → 1.7.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/index.d.ts +506 -257
  2. package/misc/https/dist/hostServer.ts.cache +5 -2
  3. package/package.json +1 -1
  4. package/spec.txt +10 -0
  5. package/storage/ArchivesDisk.d.ts +4 -13
  6. package/storage/ArchivesDisk.ts +6 -4
  7. package/storage/IArchives.d.ts +15 -10
  8. package/storage/IArchives.ts +19 -6
  9. package/storage/backblaze.d.ts +3 -9
  10. package/storage/backblaze.ts +22 -19
  11. package/storage/dist/ArchivesDisk.ts.cache +5 -2
  12. package/storage/dist/IArchives.ts.cache +17 -3
  13. package/storage/dist/backblaze.ts.cache +35 -26
  14. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -9
  15. package/storage/remoteStorage/ArchivesRemote.ts +25 -20
  16. package/storage/remoteStorage/ArchivesUrl.d.ts +3 -9
  17. package/storage/remoteStorage/ArchivesUrl.ts +7 -5
  18. package/storage/remoteStorage/accessPage.tsx +4 -4
  19. package/storage/remoteStorage/accessStats.d.ts +2 -0
  20. package/storage/remoteStorage/accessStats.ts +32 -0
  21. package/storage/remoteStorage/blobStore.d.ts +193 -88
  22. package/storage/remoteStorage/blobStore.ts +535 -400
  23. package/storage/remoteStorage/bucketDisk.d.ts +18 -0
  24. package/storage/remoteStorage/bucketDisk.ts +66 -0
  25. package/storage/remoteStorage/createArchives.d.ts +4 -10
  26. package/storage/remoteStorage/createArchives.ts +103 -62
  27. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +28 -19
  28. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +7 -4
  29. package/storage/remoteStorage/dist/accessPage.tsx.cache +6 -6
  30. package/storage/remoteStorage/dist/accessStats.ts.cache +38 -3
  31. package/storage/remoteStorage/dist/blobStore.ts.cache +516 -374
  32. package/storage/remoteStorage/dist/bucketDisk.ts.cache +72 -0
  33. package/storage/remoteStorage/dist/createArchives.ts.cache +114 -57
  34. package/storage/remoteStorage/dist/intermediateSources.ts.cache +12 -12
  35. package/storage/remoteStorage/dist/remoteConfig.ts.cache +18 -2
  36. package/storage/remoteStorage/dist/serverConfig.ts.cache +116 -0
  37. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +6 -5
  38. package/storage/remoteStorage/dist/sourcesList.ts.cache +5 -2
  39. package/storage/remoteStorage/dist/storageController.ts.cache +223 -190
  40. package/storage/remoteStorage/dist/storageServer.ts.cache +9 -9
  41. package/storage/remoteStorage/dist/storageServerState.ts.cache +329 -566
  42. package/storage/remoteStorage/dist/storePlan.ts.cache +149 -0
  43. package/storage/remoteStorage/dist/validation.ts.cache +40 -0
  44. package/storage/remoteStorage/grantAccessCli.ts +1 -1
  45. package/storage/remoteStorage/intermediateSources.d.ts +3 -5
  46. package/storage/remoteStorage/intermediateSources.ts +14 -16
  47. package/storage/remoteStorage/remoteConfig.d.ts +2 -2
  48. package/storage/remoteStorage/remoteConfig.ts +19 -3
  49. package/storage/remoteStorage/serverConfig.d.ts +27 -0
  50. package/storage/remoteStorage/serverConfig.ts +109 -0
  51. package/storage/remoteStorage/sourceWrapper.d.ts +3 -3
  52. package/storage/remoteStorage/sourceWrapper.ts +7 -6
  53. package/storage/remoteStorage/sourcesList.ts +3 -0
  54. package/storage/remoteStorage/storageController.d.ts +118 -36
  55. package/storage/remoteStorage/storageController.ts +166 -168
  56. package/storage/remoteStorage/storageServer.ts +1 -1
  57. package/storage/remoteStorage/storageServerState.d.ts +52 -63
  58. package/storage/remoteStorage/storageServerState.ts +340 -584
  59. package/storage/remoteStorage/storePlan.d.ts +34 -0
  60. package/storage/remoteStorage/storePlan.ts +166 -0
  61. package/storage/remoteStorage/validation.d.ts +4 -0
  62. package/storage/remoteStorage/validation.ts +31 -0
@@ -0,0 +1,18 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { RemoteConfig } from "../IArchives";
4
+ export declare function getBucketFolder(account: string, bucketName: string, route?: [number, number]): string;
5
+ export declare function readRoutingFile(folder: string): Promise<Buffer | undefined>;
6
+ export declare function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined>;
7
+ /** The routing file lives ONLY in the plain (routeless) bucket folder - it is what DEFINES the per-route stores, so it cannot live inside any of them. Served directly for reads (the stores never hold it). */
8
+ export declare function getRoutingFileResult(account: string, bucketName: string): Promise<{
9
+ data: Buffer;
10
+ writeTime: number;
11
+ size: number;
12
+ } | undefined>;
13
+ export type BucketDiskInfo = {
14
+ totalBytes: number;
15
+ freeBytes: number;
16
+ usedBytes: number;
17
+ };
18
+ export declare function getDiskInfo(folder: string): Promise<BucketDiskInfo>;
@@ -0,0 +1,66 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { RemoteConfig } from "../IArchives";
4
+ import { ROUTING_FILE, parseRoutingData } from "./remoteConfig";
5
+ import { getStorageServerConfig } from "./serverConfig";
6
+
7
+ // The on-disk layout of buckets, and the direct-disk reads of the files that exist outside every store: the per-route folder naming and the routing file.
8
+
9
+ /** Each ROUTE gets its own folder: the bucket name plus the route range. One store serves exactly one route, so different shards of the same bucket - even across processes on different ports of the same machine - never share a folder and can never mix their data. No route (or the full route) keeps the plain folder, which is also where the routing file always lives. */
10
+ function getRouteFolderSuffix(route: [number, number] | undefined): string {
11
+ if (!route || route[0] === 0 && route[1] === 1) return "";
12
+ return `-route-${route[0]}-${route[1]}`;
13
+ }
14
+ export function getBucketFolder(account: string, bucketName: string, route?: [number, number]): string {
15
+ return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName + getRouteFolderSuffix(route));
16
+ }
17
+
18
+ /** 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. */
19
+ function getRoutingFilePath(folder: string): string {
20
+ return path.join(folder, "files", ROUTING_FILE);
21
+ }
22
+ export async function readRoutingFile(folder: string): Promise<Buffer | undefined> {
23
+ try {
24
+ return await fs.promises.readFile(getRoutingFilePath(folder));
25
+ } catch (e) {
26
+ if ((e as { code?: string }).code === "ENOENT") return undefined;
27
+ throw e;
28
+ }
29
+ }
30
+
31
+ export async function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined> {
32
+ let data = await readRoutingFile(getBucketFolder(account, bucketName));
33
+ if (!data) return undefined;
34
+ return parseRoutingData(data);
35
+ }
36
+
37
+ /** The routing file lives ONLY in the plain (routeless) bucket folder - it is what DEFINES the per-route stores, so it cannot live inside any of them. Served directly for reads (the stores never hold it). */
38
+ export async function getRoutingFileResult(account: string, bucketName: string): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
39
+ let filePath = getRoutingFilePath(getBucketFolder(account, bucketName));
40
+ try {
41
+ let data = await fs.promises.readFile(filePath);
42
+ let stats = await fs.promises.stat(filePath);
43
+ return { data, writeTime: stats.mtimeMs, size: data.length };
44
+ } catch (e) {
45
+ if ((e as { code?: string }).code === "ENOENT") return undefined;
46
+ throw e;
47
+ }
48
+ }
49
+
50
+ export type BucketDiskInfo = {
51
+ totalBytes: number;
52
+ freeBytes: number;
53
+ usedBytes: number;
54
+ };
55
+
56
+ export async function getDiskInfo(folder: string): Promise<BucketDiskInfo> {
57
+ let stats = await fs.promises.statfs(folder);
58
+ let blockSize = Number(stats.bsize);
59
+ let totalBytes = Number(stats.blocks) * blockSize;
60
+ return {
61
+ totalBytes,
62
+ // Matches the server's own low-space check: what an unprivileged process can actually use
63
+ freeBytes: Number(stats.bavail) * blockSize,
64
+ usedBytes: totalBytes - Number(stats.bfree) * blockSize,
65
+ };
66
+ }
@@ -1,10 +1,10 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, GetConfig, GetInfoConfig, SetConfig } from "../IArchives";
3
+ import { IArchives, RemoteConfig, RemoteConfigBase, SourceConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig } from "../IArchives";
4
4
  import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
5
5
  /** The address, port, account, and bucket name a bucket routing URL addresses. Throws when the URL isn't a hosted bucket routing URL (https://host:port/file/<account>/<bucketName>/storage/storagerouting.json). */
6
6
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
7
- export declare function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives;
7
+ export declare function createApiArchives(source: SourceConfig): IArchives;
8
8
  export type ArchivesChainOptions = {
9
9
  /** Outside of node we default to read-only downloads over the public URLs (no API connection) when the config has public sources. Set this to connect to the API anyway - needed for writing, listing, and any other operation the plain URL form cannot serve. */
10
10
  directConnect?: boolean;
@@ -82,14 +82,8 @@ export declare class ArchivesChain implements IArchives {
82
82
  } | undefined>;
83
83
  private selectCoveringSources;
84
84
  private runOnCovering;
85
- find(prefix: string, config?: {
86
- shallow?: boolean;
87
- type: "files" | "folders";
88
- }): Promise<string[]>;
89
- findInfo(prefix: string, config?: {
90
- shallow?: boolean;
91
- type: "files" | "folders";
92
- }): Promise<ArchiveFileInfo[]>;
85
+ find(prefix: string, config?: FindConfig): Promise<string[]>;
86
+ findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]>;
93
87
  getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
94
88
  getSyncStatus(): Promise<ArchivesSyncStatus>;
95
89
  getConfig(): Promise<ArchivesConfig>;
@@ -1,8 +1,8 @@
1
1
  import { isNode, sort, watchSlowPromise } from "socket-function/src/misc";
2
2
  import { delay } from "socket-function/src/batching";
3
3
  import {
4
- IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig,
5
- ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, GetConfig, GetInfoConfig, SetConfig, STORAGE_WRONG_VALID_WINDOW,
4
+ IArchives, RemoteConfig, RemoteConfigBase, SourceConfig,
5
+ ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, STORAGE_WRONG_VALID_WINDOW,
6
6
  STORAGE_WRONG_ROUTE, FULL_ROUTE, VARIABLE_SHARD, LARGE_SET_THRESHOLD, bufferChunkStream,
7
7
  } from "../IArchives";
8
8
  import {
@@ -16,7 +16,8 @@ import { SocketFunction } from "socket-function/SocketFunction";
16
16
  import { ArchivesRemote, parseStorageUrl, authenticateStorage } from "./ArchivesRemote";
17
17
  import { onServerRoutingChanged } from "./storageClientController";
18
18
  import { ArchivesBackblaze } from "../backblaze";
19
- import { getLocalArchives, isOwnAddress, ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
19
+ import { ServerBucketInfo, ActiveBucketInfo, getLocalArchives } from "./storageServerState";
20
+ import { isOwnAddress } from "./serverConfig";
20
21
  import { RemoteStorageController, STORAGE_NOT_AUTHENTICATED } from "./storageController";
21
22
  import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
22
23
 
@@ -27,6 +28,8 @@ const CONFIG_REFRESH_THROTTLE = 30 * 1000;
27
28
  const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
28
29
  const PRIMARY_RETRY_TIMEOUT = 30 * 1000;
29
30
  const PRIMARY_RETRY_DELAY = 2 * 1000;
31
+ const COVERING_RETRY_TIMEOUT = 30 * 1000;
32
+ const COVERING_RETRY_DELAY = 5 * 1000;
30
33
  // Smart timeouts: an attempt gets this long to produce anything before we probe getInfo for the file's size (and the probe itself gets the same window)
31
34
  const SMART_TIMEOUT_PROBE = 10 * 1000;
32
35
  // Very generous assumed transfer rates - the resulting deadline exists to catch stuck sources, not slow ones
@@ -39,20 +42,22 @@ const SMART_TIMEOUT_MARKER = "ARCHIVES_SMART_TIMEOUT_c41a9d";
39
42
  type SmartTimeout = {
40
43
  path?: string;
41
44
  uploadBytes?: number;
45
+ // Names the operation in timeout errors - without it a deletion (a tombstone write, uploadBytes 0) reads as "Upload of 0 bytes", which looks like a bug rather than a delete
46
+ label?: string;
42
47
  };
43
48
 
44
49
  /** The address, port, account, and bucket name a bucket routing URL addresses. Throws when the URL isn't a hosted bucket routing URL (https://host:port/file/<account>/<bucketName>/storage/storagerouting.json). */
45
50
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
46
51
 
47
- export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
52
+ export function createApiArchives(source: SourceConfig): IArchives {
48
53
  if (source.type === "backblaze") {
49
54
  return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable, allowedOrigins: source.allowedOrigins });
50
55
  }
51
56
  let parsed = parseHostedUrl(source.url);
52
57
  if (isNode() && isOwnAddress(parsed.address, parsed.port)) {
53
- return getLocalArchives(parsed.account, parsed.bucketName);
58
+ return getLocalArchives(parsed.account, parsed.bucketName, source);
54
59
  }
55
- return new ArchivesRemote({ url: source.url, waitForAccess: false });
60
+ return new ArchivesRemote({ url: source.url, waitForAccess: false, sourceConfig: source });
56
61
  }
57
62
 
58
63
  type ChainState = {
@@ -65,13 +70,13 @@ export type ArchivesChainOptions = {
65
70
  directConnect?: boolean;
66
71
  };
67
72
 
68
- function configWindowCurrent(config: HostedConfig | BackblazeConfig): boolean {
73
+ function configWindowCurrent(config: SourceConfig): boolean {
69
74
  let now = Date.now();
70
75
  let [start, end] = config.validWindow;
71
76
  return start <= now && now < end;
72
77
  }
73
78
 
74
- function configAcceptsWrites(config: HostedConfig | BackblazeConfig): boolean {
79
+ function configAcceptsWrites(config: SourceConfig): boolean {
75
80
  return configWindowCurrent(config);
76
81
  }
77
82
 
@@ -80,24 +85,16 @@ function materializeShardKey(key: string, target: SourceWrapper): string {
80
85
  return key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
81
86
  }
82
87
 
83
- /** The fewest sources whose routes span the whole key space, or undefined when they leave a gap. */
88
+ /** Covers the whole key space with the AUTHORITATIVE sources: for each uncovered point, the FIRST source in config order whose route contains it - the exact selection every read and write uses, so listings come from the same nodes writes went to (read-your-writes). Never "fewest" or "widest": preferring a wide read replica over the routed write shards would serve listings from a node that only has the data second-hand. Returns undefined when the candidates leave a gap. */
84
89
  function coverRoutes(candidates: SourceWrapper[]): SourceWrapper[] | undefined {
85
90
  let chosen: SourceWrapper[] = [];
86
91
  let covered = 0;
87
92
  while (covered < 1) {
88
- let best: SourceWrapper | undefined;
89
- let bestEnd = covered;
90
- for (let source of candidates) {
91
- let [start, end] = source.config.route || FULL_ROUTE;
92
- if (start > covered) continue;
93
- if (end > bestEnd) {
94
- bestEnd = end;
95
- best = source;
96
- }
97
- }
98
- if (!best) return undefined;
99
- chosen.push(best);
100
- covered = bestEnd;
93
+ let next = candidates.find(x => routeContains(x.config.route, covered));
94
+ if (!next) return undefined;
95
+ chosen.push(next);
96
+ let [, end] = next.config.route || FULL_ROUTE;
97
+ covered = end;
101
98
  }
102
99
  return chosen;
103
100
  }
@@ -129,7 +126,7 @@ export class ArchivesChain implements IArchives {
129
126
  }
130
127
 
131
128
  public getDebugName() {
132
- let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
129
+ let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as SourceConfig).url);
133
130
  return `chain ${urls.join(", ")}`;
134
131
  }
135
132
 
@@ -279,7 +276,7 @@ export class ArchivesChain implements IArchives {
279
276
  return config.sources.map(normalizeSource).some(x => x.public);
280
277
  }
281
278
 
282
- private async createChainSource(sourceConfig: HostedConfig | BackblazeConfig, readOnly: boolean): Promise<SourceWrapper> {
279
+ private async createChainSource(sourceConfig: SourceConfig, readOnly: boolean): Promise<SourceWrapper> {
283
280
  let source = await SourceWrapper.create(sourceConfig, { readOnly });
284
281
  source.startPinging();
285
282
  return source;
@@ -354,7 +351,7 @@ export class ArchivesChain implements IArchives {
354
351
  } else {
355
352
  console.log(`Storage routing config changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
356
353
  }
357
- let strippedKey = (config: HostedConfig | BackblazeConfig) => JSON.stringify({ ...config, validWindow: undefined });
354
+ let strippedKey = (config: SourceConfig) => JSON.stringify({ ...config, validWindow: undefined });
358
355
  let oldByConfig = new Map<string, SourceWrapper[]>();
359
356
  for (let source of state.sources) {
360
357
  let key = strippedKey(source.config);
@@ -533,7 +530,7 @@ export class ArchivesChain implements IArchives {
533
530
  let result = await Promise.race([callPromise.then(value => ({ value })), delay(allowed).then(() => undefined)]);
534
531
  if (result) return result.value;
535
532
  abandon();
536
- throw new Error(`${SMART_TIMEOUT_MARKER} Upload of ${timeout.uploadBytes} bytes to ${source.getDebugName()} timed out after ${Date.now() - start}ms (allowed ${Math.round(allowed)}ms: ${SMART_TIMEOUT_PROBE}ms base plus transfer at an assumed ${SMART_TIMEOUT_UPLOAD_BYTES_PER_SECOND} bytes/s)`);
533
+ throw new Error(`${SMART_TIMEOUT_MARKER} ${timeout.label || `Upload of ${timeout.uploadBytes} bytes`} to ${source.getDebugName()} timed out after ${Date.now() - start}ms (allowed ${Math.round(allowed)}ms: ${SMART_TIMEOUT_PROBE}ms base plus transfer at an assumed ${SMART_TIMEOUT_UPLOAD_BYTES_PER_SECOND} bytes/s)`);
537
534
  }
538
535
  const path = timeout.path;
539
536
  if (path === undefined) return await callPromise;
@@ -606,14 +603,18 @@ export class ArchivesChain implements IArchives {
606
603
  public async getFast(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
607
604
  return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, fast: true, timeout: { path: fileName } }, async (archives, url) => {
608
605
  let result = await archives.get2(fileName, config);
609
- return result && result.data && { data: result.data, writeTime: result.writeTime, size: result.size, url } || { url };
606
+ // Empty data is a tombstone, not content - see get2
607
+ if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url };
608
+ return { data: result.data, writeTime: result.writeTime, size: result.size, url };
610
609
  });
611
610
  }
612
611
  /** Always resolves with a url - the authority that answered. A value that doesn't exist is still an answer FROM a server, so it comes back as { url } with no data (never plain undefined); errors from every source throw instead. */
613
612
  public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
614
613
  return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, timeout: { path: fileName } }, async (archives, url) => {
615
614
  let result = await archives.get2(fileName, config);
616
- return result && result.data && { data: result.data, writeTime: result.writeTime, size: result.size, url } || { url };
615
+ // Empty data is a tombstone, not content (unless the caller asked for tombstones) - a ranged read of a REAL file can legitimately be empty though (range past EOF), which the total size distinguishes
616
+ if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url };
617
+ return { data: result.data, writeTime: result.writeTime, size: result.size, url };
617
618
  });
618
619
  }
619
620
  public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number; url: string } | undefined> {
@@ -623,46 +624,85 @@ export class ArchivesChain implements IArchives {
623
624
  });
624
625
  }
625
626
 
626
- private selectCoveringSources(state: ChainState, excluded: Set<SourceWrapper>): SourceWrapper[] {
627
- let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && !excluded.has(x));
628
- // Unlike the single-source paths, dropping a cooled-down source can leave a route gap - so the cooled-down set only comes back as a whole, when the healthy sources cannot cover everything on their own
629
- let chosen = coverRoutes(candidates.filter(x => !x.isOnCooldown())) || coverRoutes(candidates);
627
+ private selectCoveringSources(state: ChainState, config?: { fallbacks?: boolean; exclude?: Set<SourceWrapper> }): SourceWrapper[] {
628
+ // No cooldown preference by default: listings must come from the AUTHORITATIVE sources (the first matching, like every read and write), or they miss just-written data. With fallbacks the caller chose availability over that: known-down sources move to the back so the next source covering their routes substitutes, and sources that failed THIS call are excluded outright.
629
+ let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && !config?.exclude?.has(x));
630
+ if (config?.fallbacks) {
631
+ let usable = candidates.filter(x => !x.isOnCooldown());
632
+ let chosen = coverRoutes(usable) || coverRoutes(candidates);
633
+ if (chosen) return chosen;
634
+ }
635
+ let chosen = coverRoutes(candidates);
630
636
  if (!chosen) {
631
637
  throw new Error(`Cannot cover the full route space for ${this.getDebugName()}: the available sources leave a gap (some shards are down or URL-only)`);
632
638
  }
633
639
  return chosen;
634
640
  }
635
641
 
636
- private async runOnCovering<T>(run: (archives: IArchives) => Promise<T>): Promise<T[]> {
637
- let state = await this.getState();
638
- let excluded = new Set<SourceWrapper>();
642
+ private async runOnCovering<T>(operation: string, run: (archives: IArchives) => Promise<T>, config?: { fallbacks?: boolean }): Promise<T[]> {
643
+ let deadline = Date.now() + COVERING_RETRY_TIMEOUT;
644
+ // Sources that failed during this call - with fallbacks, the next attempt covers their routes with the next source holding them instead
645
+ let failed = new Set<SourceWrapper>();
639
646
  while (true) {
640
- let covering = this.selectCoveringSources(state, excluded);
641
- let results = await Promise.all(covering.map(async source => {
642
- let api = source.api;
643
- if (!api) {
644
- throw new Error(`${source.config.url} has URL-only access, which cannot serve this operation`);
645
- }
647
+ let state = await this.getState();
648
+ // Errors name the OPERATION and the SPECIFIC sources that failed - "the find failed because source X is unavailable", never an anonymous failure attributed to the whole chain
649
+ let outcome = await (async (): Promise<{ values: T[] } | { error: Error }> => {
650
+ let covering: SourceWrapper[];
646
651
  try {
647
- return { value: await run(api) };
652
+ covering = this.selectCoveringSources(state, { fallbacks: config?.fallbacks, exclude: failed });
648
653
  } catch (e) {
649
- if (source.isConnected()) throw e;
650
- source.noteFailure();
651
- excluded.add(source);
652
- return undefined;
654
+ return { error: new Error(`${operation} cannot run: ${(e as Error).message}`) };
653
655
  }
654
- }));
655
- if (results.every(x => x)) {
656
- return results.map(x => (x as { value: T }).value);
656
+ let values: T[] = [];
657
+ let failures: { source: SourceWrapper; error: Error }[] = [];
658
+ await Promise.all(covering.map(async source => {
659
+ let api = source.api;
660
+ if (!api) {
661
+ failed.add(source);
662
+ failures.push({ source, error: new Error(`URL-only access, which cannot serve ${operation}`) });
663
+ return;
664
+ }
665
+ try {
666
+ values.push(await run(api));
667
+ } catch (e) {
668
+ if (!source.isConnected()) source.noteFailure();
669
+ failed.add(source);
670
+ failures.push({ source, error: e as Error });
671
+ }
672
+ }));
673
+ if (!failures.length) return { values };
674
+ if (failures.length === 1) {
675
+ return { error: new Error(`${operation} failed because source ${failures[0].source.getDebugName()} is unavailable: ${failures[0].error.stack ?? failures[0].error}`) };
676
+ }
677
+ return { error: new Error(`${operation} failed because ${failures.length} of the ${covering.length} covering sources are unavailable: ${failures.map(x => `${x.source.getDebugName()}: ${x.error.stack ?? x.error}`).join(" | ")}`) };
678
+ })();
679
+ if ("values" in outcome) return outcome.values;
680
+ let error = outcome.error;
681
+ if (Date.now() >= deadline) throw error;
682
+ if (config?.fallbacks && failed.size) {
683
+ let substitutable = false;
684
+ try {
685
+ this.selectCoveringSources(state, { fallbacks: true, exclude: failed });
686
+ substitutable = true;
687
+ } catch { }
688
+ if (substitutable) {
689
+ console.error(`${error.message}. Substituting the failed sources' routes with fallback sources.`);
690
+ continue;
691
+ }
692
+ // No substitute covers the failed routes - retry the failed sources themselves after the delay
693
+ failed.clear();
657
694
  }
695
+ console.error(`${error.message}. Retrying in ${COVERING_RETRY_DELAY / 1000}s (giving up at ${new Date(deadline).toISOString()}).`);
696
+ await delay(COVERING_RETRY_DELAY);
697
+ await this.recheckAvailability();
658
698
  }
659
699
  }
660
700
 
661
- public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
701
+ public async find(prefix: string, config?: FindConfig): Promise<string[]> {
662
702
  return (await this.findInfo(prefix, config)).map(x => x.path);
663
703
  }
664
- public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
665
- let results = await this.runOnCovering(archives => archives.findInfo(prefix, config));
704
+ public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
705
+ let results = await this.runOnCovering(`The find of ${JSON.stringify(prefix)}`, archives => archives.findInfo(prefix, config), { fallbacks: config?.fallbacks });
666
706
  let byPath = new Map<string, ArchiveFileInfo>();
667
707
  for (let list of results) {
668
708
  for (let file of list) {
@@ -677,7 +717,7 @@ export class ArchivesChain implements IArchives {
677
717
  return merged;
678
718
  }
679
719
  public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
680
- let results = await this.runOnCovering(archives => archives.getChangesAfter2(config));
720
+ let results = await this.runOnCovering(`The changes listing since ${new Date(config.time).toISOString()}`, archives => archives.getChangesAfter2(config));
681
721
  let byPath = new Map<string, ArchiveFileInfo>();
682
722
  for (let list of results) {
683
723
  for (let file of list) {
@@ -692,7 +732,7 @@ export class ArchivesChain implements IArchives {
692
732
  return merged;
693
733
  }
694
734
  public async getSyncStatus(): Promise<ArchivesSyncStatus> {
695
- let statuses = await this.runOnCovering(async archives => {
735
+ let statuses = await this.runOnCovering(`The sync status check`, async archives => {
696
736
  if (!archives.getSyncStatus) {
697
737
  throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
698
738
  }
@@ -734,7 +774,7 @@ export class ArchivesChain implements IArchives {
734
774
  await this.setLargeFile({ path: fileName, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) });
735
775
  return fileName;
736
776
  }
737
- await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: data.length } }, archives => archives.set(fileName, data, config));
777
+ await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: data.length, label: `Upload of ${JSON.stringify(fileName)} (${data.length} bytes)` } }, archives => archives.set(fileName, data, config));
738
778
  return fileName;
739
779
  }
740
780
 
@@ -763,7 +803,7 @@ export class ArchivesChain implements IArchives {
763
803
  return ROUTING_FILE;
764
804
  }
765
805
  public async del(fileName: string, config?: DelConfig): Promise<void> {
766
- await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: 0 } }, archives => archives.del(fileName, config));
806
+ await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
767
807
  }
768
808
 
769
809
  // One write target per route range, lowest latency first. Within a shard the target is ALWAYS the first source in config order (see runPrimary - writes must stay on the same node): connectivity only decides which SHARD we pick, never which node within it, so a shard whose node is disconnected is dropped entirely when connectedOnly is set.
@@ -810,7 +850,7 @@ export class ArchivesChain implements IArchives {
810
850
  let fullKey = materializeShardKey(key, target);
811
851
  try {
812
852
  // The shard picking already retries across shards, so a stuck shard just costs its timeout and we move on. Large data streams via setLargeFile - the key is already materialized here, so the no-VARIABLE_SHARD restriction on setLargeFile doesn't apply.
813
- await this.applySmartTimeout({ uploadBytes: data.length }, target, () => {
853
+ await this.applySmartTimeout({ uploadBytes: data.length, label: `Variable-shard upload of ${JSON.stringify(fullKey)} (${data.length} bytes)` }, target, () => {
814
854
  if (data.length > LARGE_SET_THRESHOLD) {
815
855
  return target.write(archives => archives.setLargeFile({ path: fullKey, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) }));
816
856
  }
@@ -958,12 +998,12 @@ async function callServer<T>(url: string, run: (controller: typeof RemoteStorage
958
998
  }
959
999
 
960
1000
  export async function listServerBuckets(config: { url: string; account: string }): Promise<ServerBucketInfo[]> {
961
- return await callServer(config.url, controller => controller.listBuckets(config.account));
1001
+ return await callServer(config.url, controller => controller.listBuckets({ account: config.account }));
962
1002
  }
963
1003
 
964
1004
  /** The live, in-memory state of one bucket on a server (routing config included), or a string saying why it is unavailable. Cheap - it never touches the server's disk - but only works while that bucket is loaded there. */
965
1005
  export async function getServerActiveBucket(config: { url: string; account: string; bucketName: string }): Promise<ActiveBucketInfo | string> {
966
- return await callServer(config.url, controller => controller.getActiveBucket(config.account, config.bucketName));
1006
+ return await callServer(config.url, controller => controller.getActiveBucket({ account: config.account, bucketName: config.bucketName }));
967
1007
  }
968
1008
 
969
1009
  /** The buckets a server currently has loaded. Admin only, so in practice this is our own machine's other process - a deploy successor asking its predecessor what is actually in use. */
@@ -973,15 +1013,16 @@ export async function listServerActiveBucketKeys(config: { url: string }): Promi
973
1013
 
974
1014
  /** Tells a server to load one of its buckets into memory (starting its synchronization) and returns its live state, or a string saying why it could not be loaded. Only touches that server - nothing is written and no other source is contacted. */
975
1015
  export async function activateServerBucket(config: { url: string; account: string; bucketName: string }): Promise<ActiveBucketInfo | string> {
976
- return await callServer(config.url, controller => controller.activateBucket(config.account, config.bucketName));
1016
+ return await callServer(config.url, controller => controller.activateBucket({ account: config.account, bucketName: config.bucketName }));
977
1017
  }
978
1018
 
979
1019
  /** Zeroes the write statistics listServerBuckets reports, for every bucket in the account. */
980
1020
  export async function clearServerWriteStats(config: { url: string; account: string }): Promise<{ clearedBuckets: number }> {
981
- return await callServer(config.url, controller => controller.clearWriteStats(config.account));
1021
+ return await callServer(config.url, controller => controller.clearWriteStats({ account: config.account }));
982
1022
  }
983
1023
 
984
1024
  export async function getBucketInfo(config: { url: string }): Promise<ArchivesConfig> {
985
- let remote = new ArchivesRemote({ url: config.url, waitForAccess: false });
1025
+ // getConfig is bucket-level (no store selection), so the fabricated sourceConfig never has to match anything
1026
+ let remote = new ArchivesRemote({ url: config.url, waitForAccess: false, sourceConfig: normalizeSource(config.url) });
986
1027
  return await remote.getConfig();
987
1028
  }