sliftutils 1.7.71 → 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 +101 -62
  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
@@ -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,83 @@ 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
+ let phrase = failures.length === 1 && `source ${failures[0].source.getDebugName()} is unavailable` || `${failures.length} of the ${covering.length} covering sources are unavailable`;
675
+ return { error: new Error(`${operation} failed because ${phrase}: ${failures.map(x => `${x.source.getDebugName()}: ${x.error.stack ?? x.error}`).join(" | ")}`) };
676
+ })();
677
+ if ("values" in outcome) return outcome.values;
678
+ let error = outcome.error;
679
+ if (Date.now() >= deadline) throw error;
680
+ if (config?.fallbacks && failed.size) {
681
+ let substitutable = false;
682
+ try {
683
+ this.selectCoveringSources(state, { fallbacks: true, exclude: failed });
684
+ substitutable = true;
685
+ } catch { }
686
+ if (substitutable) {
687
+ console.error(`${error.message}. Substituting the failed sources' routes with fallback sources.`);
688
+ continue;
689
+ }
690
+ // No substitute covers the failed routes - retry the failed sources themselves after the delay
691
+ failed.clear();
657
692
  }
693
+ console.error(`${error.message}. Retrying in ${COVERING_RETRY_DELAY / 1000}s (giving up at ${new Date(deadline).toISOString()}).`);
694
+ await delay(COVERING_RETRY_DELAY);
695
+ await this.recheckAvailability();
658
696
  }
659
697
  }
660
698
 
661
- public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
699
+ public async find(prefix: string, config?: FindConfig): Promise<string[]> {
662
700
  return (await this.findInfo(prefix, config)).map(x => x.path);
663
701
  }
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));
702
+ public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
703
+ let results = await this.runOnCovering(`The find of ${JSON.stringify(prefix)}`, archives => archives.findInfo(prefix, config), { fallbacks: config?.fallbacks });
666
704
  let byPath = new Map<string, ArchiveFileInfo>();
667
705
  for (let list of results) {
668
706
  for (let file of list) {
@@ -677,7 +715,7 @@ export class ArchivesChain implements IArchives {
677
715
  return merged;
678
716
  }
679
717
  public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
680
- let results = await this.runOnCovering(archives => archives.getChangesAfter2(config));
718
+ let results = await this.runOnCovering(`The changes listing since ${new Date(config.time).toISOString()}`, archives => archives.getChangesAfter2(config));
681
719
  let byPath = new Map<string, ArchiveFileInfo>();
682
720
  for (let list of results) {
683
721
  for (let file of list) {
@@ -692,7 +730,7 @@ export class ArchivesChain implements IArchives {
692
730
  return merged;
693
731
  }
694
732
  public async getSyncStatus(): Promise<ArchivesSyncStatus> {
695
- let statuses = await this.runOnCovering(async archives => {
733
+ let statuses = await this.runOnCovering(`The sync status check`, async archives => {
696
734
  if (!archives.getSyncStatus) {
697
735
  throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
698
736
  }
@@ -734,7 +772,7 @@ export class ArchivesChain implements IArchives {
734
772
  await this.setLargeFile({ path: fileName, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) });
735
773
  return fileName;
736
774
  }
737
- await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: data.length } }, archives => archives.set(fileName, data, config));
775
+ 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
776
  return fileName;
739
777
  }
740
778
 
@@ -763,7 +801,7 @@ export class ArchivesChain implements IArchives {
763
801
  return ROUTING_FILE;
764
802
  }
765
803
  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));
804
+ await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
767
805
  }
768
806
 
769
807
  // 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 +848,7 @@ export class ArchivesChain implements IArchives {
810
848
  let fullKey = materializeShardKey(key, target);
811
849
  try {
812
850
  // 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, () => {
851
+ await this.applySmartTimeout({ uploadBytes: data.length, label: `Variable-shard upload of ${JSON.stringify(fullKey)} (${data.length} bytes)` }, target, () => {
814
852
  if (data.length > LARGE_SET_THRESHOLD) {
815
853
  return target.write(archives => archives.setLargeFile({ path: fullKey, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) }));
816
854
  }
@@ -958,12 +996,12 @@ async function callServer<T>(url: string, run: (controller: typeof RemoteStorage
958
996
  }
959
997
 
960
998
  export async function listServerBuckets(config: { url: string; account: string }): Promise<ServerBucketInfo[]> {
961
- return await callServer(config.url, controller => controller.listBuckets(config.account));
999
+ return await callServer(config.url, controller => controller.listBuckets({ account: config.account }));
962
1000
  }
963
1001
 
964
1002
  /** 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
1003
  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));
1004
+ return await callServer(config.url, controller => controller.getActiveBucket({ account: config.account, bucketName: config.bucketName }));
967
1005
  }
968
1006
 
969
1007
  /** 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 +1011,16 @@ export async function listServerActiveBucketKeys(config: { url: string }): Promi
973
1011
 
974
1012
  /** 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
1013
  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));
1014
+ return await callServer(config.url, controller => controller.activateBucket({ account: config.account, bucketName: config.bucketName }));
977
1015
  }
978
1016
 
979
1017
  /** Zeroes the write statistics listServerBuckets reports, for every bucket in the account. */
980
1018
  export async function clearServerWriteStats(config: { url: string; account: string }): Promise<{ clearedBuckets: number }> {
981
- return await callServer(config.url, controller => controller.clearWriteStats(config.account));
1019
+ return await callServer(config.url, controller => controller.clearWriteStats({ account: config.account }));
982
1020
  }
983
1021
 
984
1022
  export async function getBucketInfo(config: { url: string }): Promise<ArchivesConfig> {
985
- let remote = new ArchivesRemote({ url: config.url, waitForAccess: false });
1023
+ // getConfig is bucket-level (no store selection), so the fabricated sourceConfig never has to match anything
1024
+ let remote = new ArchivesRemote({ url: config.url, waitForAccess: false, sourceConfig: normalizeSource(config.url) });
986
1025
  return await remote.getConfig();
987
1026
  }