sliftutils 1.7.88 → 1.7.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/dist/treeSummary.ts.cache +2 -2
  3. package/index.d.ts +674 -295
  4. package/misc/dist/environment.ts.cache +2 -2
  5. package/misc/dist/getSecret.ts.cache +2 -2
  6. package/misc/dist/strings.ts.cache +2 -2
  7. package/misc/dist/zip.ts.cache +2 -2
  8. package/misc/https/dist/certs.ts.cache +2 -2
  9. package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
  10. package/misc/https/dist/dns.ts.cache +2 -2
  11. package/misc/https/dist/hostServer.ts.cache +49 -21
  12. package/misc/https/dist/hostsFile.ts.cache +82 -0
  13. package/misc/https/dist/httpsCerts.ts.cache +2 -2
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/hostServer.d.ts +6 -2
  16. package/misc/https/hostServer.ts +52 -19
  17. package/misc/https/hostsFile.d.ts +7 -0
  18. package/misc/https/hostsFile.ts +66 -0
  19. package/package.json +1 -1
  20. package/render-utils/dist/observer.tsx.cache +2 -2
  21. package/storage/ArchivesDisk.d.ts +3 -6
  22. package/storage/ArchivesDisk.ts +26 -2
  23. package/storage/BulkDatabase2/BulkDatabaseReader.ts +1 -1
  24. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  25. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  26. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  27. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +3 -3
  28. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  29. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  30. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  31. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  32. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  33. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  34. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  35. package/storage/IArchives.d.ts +37 -24
  36. package/storage/IArchives.ts +47 -62
  37. package/storage/LogMap.d.ts +56 -0
  38. package/storage/LogMap.ts +220 -0
  39. package/storage/archiveHelpers.d.ts +36 -0
  40. package/storage/archiveHelpers.ts +103 -0
  41. package/storage/backblaze.d.ts +7 -12
  42. package/storage/backblaze.ts +53 -48
  43. package/storage/dist/ArchivesDisk.ts.cache +28 -2
  44. package/storage/dist/FileFolderAPI.tsx.cache +2 -2
  45. package/storage/dist/IArchives.ts.cache +16 -52
  46. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  47. package/storage/dist/JSONStorage.ts.cache +2 -2
  48. package/storage/dist/LogMap.ts.cache +212 -0
  49. package/storage/dist/PendingManager.tsx.cache +2 -2
  50. package/storage/dist/TransactionStorage.ts.cache +2 -2
  51. package/storage/dist/backblaze.ts.cache +58 -44
  52. package/storage/dist/fileSystemPointer.ts.cache +2 -2
  53. package/storage/dist/remoteFileStorage.ts.cache +2 -2
  54. package/storage/remoteStorage/ArchivesDelayed.d.ts +66 -0
  55. package/storage/remoteStorage/ArchivesDelayed.ts +237 -0
  56. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -6
  57. package/storage/remoteStorage/ArchivesRemote.ts +26 -5
  58. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -5
  59. package/storage/remoteStorage/ArchivesUrl.ts +2 -2
  60. package/storage/remoteStorage/accessPage.tsx +12 -0
  61. package/storage/remoteStorage/blobStore.d.ts +162 -189
  62. package/storage/remoteStorage/blobStore.ts +495 -952
  63. package/storage/remoteStorage/bucketDisk.d.ts +24 -3
  64. package/storage/remoteStorage/bucketDisk.ts +56 -22
  65. package/storage/remoteStorage/certTrustModal.d.ts +1 -0
  66. package/storage/remoteStorage/certTrustModal.tsx +48 -0
  67. package/storage/remoteStorage/cliArgs.d.ts +2 -0
  68. package/storage/remoteStorage/cliArgs.ts +5 -0
  69. package/storage/remoteStorage/createArchives.d.ts +8 -7
  70. package/storage/remoteStorage/createArchives.ts +65 -26
  71. package/storage/remoteStorage/deployTakeover.ts +1 -1
  72. package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +220 -0
  73. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +25 -5
  74. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
  75. package/storage/remoteStorage/dist/accessPage.tsx.cache +13 -3
  76. package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
  77. package/storage/remoteStorage/dist/blobStore.ts.cache +483 -965
  78. package/storage/remoteStorage/dist/bucketDisk.ts.cache +60 -29
  79. package/storage/remoteStorage/dist/certTrustModal.tsx.cache +55 -0
  80. package/storage/remoteStorage/dist/cliArgs.ts.cache +9 -4
  81. package/storage/remoteStorage/dist/createArchives.ts.cache +63 -26
  82. package/storage/remoteStorage/dist/deployTakeover.ts.cache +5 -5
  83. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +286 -0
  84. package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
  85. package/storage/remoteStorage/dist/remoteConfig.ts.cache +122 -56
  86. package/storage/remoteStorage/dist/serverConfig.ts.cache +4 -4
  87. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -11
  88. package/storage/remoteStorage/dist/sourcesList.ts.cache +3 -6
  89. package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
  90. package/storage/remoteStorage/dist/storageController.ts.cache +27 -12
  91. package/storage/remoteStorage/dist/storageServer.ts.cache +27 -7
  92. package/storage/remoteStorage/dist/storageServerCli.ts.cache +7 -3
  93. package/storage/remoteStorage/dist/storageServerState.ts.cache +133 -696
  94. package/storage/remoteStorage/dist/storeConfig.ts.cache +52 -0
  95. package/storage/remoteStorage/dist/storePlan.ts.cache +110 -81
  96. package/storage/remoteStorage/dist/storeSources.ts.cache +51 -0
  97. package/storage/remoteStorage/dist/storeSync.ts.cache +650 -0
  98. package/storage/remoteStorage/dist/validation.ts.cache +14 -4
  99. package/storage/remoteStorage/intermediateManagement.d.ts +12 -0
  100. package/storage/remoteStorage/intermediateManagement.ts +264 -0
  101. package/storage/remoteStorage/remoteConfig.d.ts +32 -0
  102. package/storage/remoteStorage/remoteConfig.ts +115 -50
  103. package/storage/remoteStorage/serverConfig.d.ts +1 -1
  104. package/storage/remoteStorage/serverConfig.ts +1 -1
  105. package/storage/remoteStorage/sourceWrapper.ts +4 -8
  106. package/storage/remoteStorage/sourcesList.d.ts +0 -2
  107. package/storage/remoteStorage/sourcesList.ts +0 -3
  108. package/storage/remoteStorage/spec.md +18 -4
  109. package/storage/remoteStorage/storageController.d.ts +10 -0
  110. package/storage/remoteStorage/storageController.ts +29 -16
  111. package/storage/remoteStorage/storageServer.d.ts +2 -0
  112. package/storage/remoteStorage/storageServer.ts +28 -4
  113. package/storage/remoteStorage/storageServerCli.ts +5 -1
  114. package/storage/remoteStorage/storageServerState.d.ts +34 -21
  115. package/storage/remoteStorage/storageServerState.ts +147 -662
  116. package/storage/remoteStorage/storeConfig.d.ts +30 -0
  117. package/storage/remoteStorage/storeConfig.ts +62 -0
  118. package/storage/remoteStorage/storePlan.d.ts +29 -17
  119. package/storage/remoteStorage/storePlan.ts +112 -85
  120. package/storage/remoteStorage/storeSources.d.ts +11 -0
  121. package/storage/remoteStorage/storeSources.ts +47 -0
  122. package/storage/remoteStorage/storeSync.d.ts +53 -0
  123. package/storage/remoteStorage/storeSync.ts +632 -0
  124. package/storage/remoteStorage/validation.d.ts +2 -0
  125. package/storage/remoteStorage/validation.ts +10 -0
@@ -1,10 +1,31 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
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>;
4
+ /** A store's folder, from the only three things that identify it. The name is the config entry's name and nothing else: the same name is the same storage, whatever its window or route say, and a different name is different storage even for the same URL. Nothing about a folder changes when the routing does. */
5
+ export declare function getBucketFolder(name: string, account: string, bucketName: string): string;
6
+ export type StoreFolder = {
7
+ account: string;
8
+ name: string;
9
+ bucketName: string;
10
+ folder: string;
11
+ };
12
+ /** Every store this server holds for an account, found by walking the disk. */
13
+ export declare function listAccountStoreFolders(account: string): Promise<StoreFolder[]>;
14
+ /** Every store this server holds for ONE bucket - one per name it has ever been given. */
15
+ export declare function listBucketStoreFolders(account: string, bucketName: string): Promise<StoreFolder[]>;
16
+ export declare function readRoutingFile(folder: string): Promise<{
17
+ data: Buffer;
18
+ writeTime: number;
19
+ } | undefined>;
20
+ /** The bucket's routing config as this server holds it: the newest copy among its stores. Each store keeps its own, and they converge - so when they disagree, the one written most recently is the one that has heard the most. */
21
+ export declare function readNewestRoutingFile(account: string, bucketName: string): Promise<{
22
+ data: Buffer;
23
+ writeTime: number;
24
+ size: number;
25
+ name: string;
26
+ } | undefined>;
6
27
  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). */
28
+ /** What an anonymous URL read of the routing file gets: the same newest copy. */
8
29
  export declare function getRoutingFileResult(account: string, bucketName: string): Promise<{
9
30
  data: Buffer;
10
31
  writeTime: number;
@@ -4,47 +4,81 @@ import { RemoteConfig } from "../IArchives";
4
4
  import { ROUTING_FILE, parseRoutingData } from "./remoteConfig";
5
5
  import { getStorageServerConfig } from "./serverConfig";
6
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.
7
+ // The on-disk layout, and what can be learned from it without opening a store. The layout IS the state: <account>/<name>/<bucket> is a store, and the set of folders is the set of stores this server has - there is no separate record of them to fall out of step.
8
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]}`;
9
+ // Everything this server stores for buckets. "buckets3" because the layout under it changed shape - the previous generations named folders after the route, which meant re-routing renamed the storage.
10
+ const BUCKETS_FOLDER = "buckets4";
11
+
12
+ /** A store's folder, from the only three things that identify it. The name is the config entry's name and nothing else: the same name is the same storage, whatever its window or route say, and a different name is different storage even for the same URL. Nothing about a folder changes when the routing does. */
13
+ export function getBucketFolder(name: string, account: string, bucketName: string): string {
14
+ return path.join(getStorageServerConfig().folder, BUCKETS_FOLDER, account, name, bucketName);
15
+ }
16
+
17
+ async function readDirectory(folder: string): Promise<string[]> {
18
+ try {
19
+ return await fs.promises.readdir(folder);
20
+ } catch (e) {
21
+ if ((e as { code?: string }).code === "ENOENT") return [];
22
+ throw e;
23
+ }
24
+ }
25
+
26
+ export type StoreFolder = { account: string; name: string; bucketName: string; folder: string };
27
+
28
+ /** Every store this server holds for an account, found by walking the disk. */
29
+ export async function listAccountStoreFolders(account: string): Promise<StoreFolder[]> {
30
+ let accountFolder = path.join(getStorageServerConfig().folder, BUCKETS_FOLDER, account);
31
+ let found: StoreFolder[] = [];
32
+ for (let name of await readDirectory(accountFolder)) {
33
+ for (let bucketName of await readDirectory(path.join(accountFolder, name))) {
34
+ found.push({ account, name, bucketName, folder: getBucketFolder(name, account, bucketName) });
35
+ }
36
+ }
37
+ return found;
13
38
  }
14
- export function getBucketFolder(account: string, bucketName: string, route?: [number, number]): string {
15
- return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName + getRouteFolderSuffix(route));
39
+
40
+ /** Every store this server holds for ONE bucket - one per name it has ever been given. */
41
+ export async function listBucketStoreFolders(account: string, bucketName: string): Promise<StoreFolder[]> {
42
+ return (await listAccountStoreFolders(account)).filter(x => x.bucketName === bucketName);
16
43
  }
17
44
 
18
45
  /** 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
46
  function getRoutingFilePath(folder: string): string {
20
47
  return path.join(folder, "files", ROUTING_FILE);
21
48
  }
22
- export async function readRoutingFile(folder: string): Promise<Buffer | undefined> {
49
+ export async function readRoutingFile(folder: string): Promise<{ data: Buffer; writeTime: number } | undefined> {
50
+ let filePath = getRoutingFilePath(folder);
23
51
  try {
24
- return await fs.promises.readFile(getRoutingFilePath(folder));
52
+ let data = await fs.promises.readFile(filePath);
53
+ let stats = await fs.promises.stat(filePath);
54
+ return { data, writeTime: stats.mtimeMs };
25
55
  } catch (e) {
26
56
  if ((e as { code?: string }).code === "ENOENT") return undefined;
27
57
  throw e;
28
58
  }
29
59
  }
30
60
 
61
+ /** The bucket's routing config as this server holds it: the newest copy among its stores. Each store keeps its own, and they converge - so when they disagree, the one written most recently is the one that has heard the most. */
62
+ export async function readNewestRoutingFile(account: string, bucketName: string): Promise<{ data: Buffer; writeTime: number; size: number; name: string } | undefined> {
63
+ let newest: { data: Buffer; writeTime: number; size: number; name: string } | undefined;
64
+ for (let store of await listBucketStoreFolders(account, bucketName)) {
65
+ let file = await readRoutingFile(store.folder);
66
+ if (!file) continue;
67
+ if (newest && file.writeTime <= newest.writeTime) continue;
68
+ newest = { data: file.data, writeTime: file.writeTime, size: file.data.length, name: store.name };
69
+ }
70
+ return newest;
71
+ }
72
+
31
73
  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);
74
+ let newest = await readNewestRoutingFile(account, bucketName);
75
+ if (!newest) return undefined;
76
+ return parseRoutingData(newest.data);
35
77
  }
36
78
 
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). */
79
+ /** What an anonymous URL read of the routing file gets: the same newest copy. */
38
80
  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
- }
81
+ return await readNewestRoutingFile(account, bucketName);
48
82
  }
49
83
 
50
84
  export type BucketDiskInfo = {
@@ -0,0 +1 @@
1
+ export declare function showCertTrustModal(serverUrl: string): void;
@@ -0,0 +1,48 @@
1
+ import preact from "preact";
2
+ import { isNode } from "socket-function/src/misc";
3
+ import { css } from "typesafecss";
4
+
5
+ // Shown in the browser when we can't establish a connection to a storage server - most often because it serves a self-signed certificate (see --selfSigned) that this browser has not accepted yet. Gives the user a link to the server, where the browser lets them accept the certificate (the ?trustCert=1 page, see accessPage.tsx); once accepted, the app's own background connection to that server starts working. A no-op in Node.
6
+
7
+ // One modal per server origin, so repeated connection failures (there are many, on a retry loop) never stack modals
8
+ const shownForOrigin = new Set<string>();
9
+
10
+ export function showCertTrustModal(serverUrl: string): void {
11
+ if (isNode()) return;
12
+ let origin: string;
13
+ try {
14
+ origin = new URL(serverUrl).origin;
15
+ } catch {
16
+ return;
17
+ }
18
+ if (shownForOrigin.has(origin)) return;
19
+ shownForOrigin.add(origin);
20
+
21
+ let container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ let close = () => {
24
+ preact.render(null, container);
25
+ container.remove();
26
+ // Leave it in shownForOrigin: the user dismissed it, so don't re-pop it on the next failure of the same server
27
+ };
28
+ preact.render(<CertTrustModal origin={origin} onClose={close} />, container);
29
+ }
30
+
31
+ function CertTrustModal(props: { origin: string; onClose: () => void }) {
32
+ // Loading this page at all means the browser accepted the certificate; the page then tells the user they can come back
33
+ let trustUrl = props.origin + "/?trustCert=1";
34
+ return <div className={css.fixed.pos(0, 0).fillBoth.zIndex(2147483647)
35
+ .hbox(0).justifyContent("center").alignItems("center").hsla(0, 0, 0, 0.5)
36
+ }>
37
+ <div className={css.vbox(12).pad2(24).maxWidth(480).hsl(0, 0, 100).borderRadius(8)}>
38
+ <div>Can't reach the storage server at <code>{props.origin}</code>.</div>
39
+ <div>
40
+ If this server uses a self-signed certificate, the browser blocks the connection until you accept it. Open the server below, accept the certificate warning, then return to this page - it will connect automatically.
41
+ </div>
42
+ <a href={trustUrl} target="_blank" rel="noreferrer">Open {props.origin} and trust its certificate</a>
43
+ <div className={css.hbox(8).justifyContent("flex-end")}>
44
+ <button onClick={props.onClose}>Close</button>
45
+ </div>
46
+ </div>
47
+ </div>;
48
+ }
@@ -1 +1,3 @@
1
1
  export declare function getArg(name: string): string | undefined;
2
+ /** A valueless boolean flag: true when --name is present (with nothing, or a following flag). */
3
+ export declare function getFlag(name: string): boolean;
@@ -7,3 +7,8 @@ export function getArg(name: string): string | undefined {
7
7
  }
8
8
  return value;
9
9
  }
10
+
11
+ /** A valueless boolean flag: true when --name is present (with nothing, or a following flag). */
12
+ export function getFlag(name: string): boolean {
13
+ return process.argv.includes(`--${name}`);
14
+ }
@@ -1,10 +1,11 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { IArchives, RemoteConfig, RemoteConfigBase, SourceConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig } from "../IArchives";
3
+ import { IArchives, RemoteConfig, RemoteConfigBase, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig } 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: SourceConfig): IArchives;
7
+ /** A client for ONE source - see storeSources.ts. Re-exported here because a chain is built out of them. */
8
+ export { createApiArchives } from "./storeSources";
8
9
  export type ArchivesChainOptions = {
9
10
  /** 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
11
  directConnect?: boolean;
@@ -91,15 +92,15 @@ export declare class ArchivesChain implements IArchives {
91
92
  set(fileName: string, data: Buffer, config?: SetConfig): Promise<string>;
92
93
  private setRoutingConfig;
93
94
  del(fileName: string, config?: DelConfig): Promise<void>;
95
+ /** See IArchives.move. When one node is the write target for BOTH paths, that node moves the file itself - the bytes never come through us - with the same wrong-window/route re-resolution as any write. When the paths route to different shards no single node holds both, so the move degrades to a copy through us plus a delete, CONFIRMED at the destination before the source is touched. No smart timeout on the node-side move: it can be a big file's worth of node-side work, which the upload-sized deadlines would misjudge. */
96
+ move(config: MoveFileConfig): Promise<void>;
94
97
  private getVariableShardTargets;
95
98
  /** The key setVariableShard would materialize for this VARIABLE_SHARD key (a value in the preferred shard's route range), without writing anything. */
96
99
  getShardKey(key: string): Promise<string>;
97
100
  private setVariableShard;
98
- setLargeFile(config: {
99
- path: string;
100
- lastModified?: number;
101
- getNextData(): Promise<Buffer | undefined>;
102
- }): Promise<void>;
101
+ /** A large file is written exactly like a small one - same write node, same wrong-window/route re-resolution, same fallbacks - so a value's SIZE never decides its write semantics (set streams through here past LARGE_SET_THRESHOLD, and a file that grew past it must not suddenly lose the availability its caller asked for). The one difference: every attempt after the first has to rewind the stream, so a config without restartStream gets a single attempt. */
102
+ setLargeFile(config: SetLargeFileConfig): Promise<void>;
103
+ private setLargeFileOnce;
103
104
  getURL(path: string): Promise<string>;
104
105
  /** Every URL that could serve this path: public sources matching both the path's route and the current valid window. The first is the write node's (first matching source in config order, see runPrimary - the one guaranteed current); the rest are ranked fastest-first by measured latency. Empty when none qualify. */
105
106
  getURLs(path: string): Promise<string[]>;
@@ -2,11 +2,12 @@ import { isNode, sort, timeoutToUndefinedSilent, watchSlowPromise } from "socket
2
2
  import { delay } from "socket-function/src/batching";
3
3
  import {
4
4
  IArchives, RemoteConfig, RemoteConfigBase, SourceConfig,
5
- ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, STORAGE_WRONG_VALID_WINDOW,
5
+ ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig, STORAGE_WRONG_VALID_WINDOW,
6
6
  STORAGE_WRONG_ROUTE, FULL_ROUTE, VARIABLE_SHARD, LARGE_SET_THRESHOLD, bufferChunkStream,
7
7
  } from "../IArchives";
8
+ import { copyArchiveFile } from "../archiveHelpers";
8
9
  import {
9
- ROUTING_FILE, getConfigVersion, parseHostedUrl, parseBackblazeUrl,
10
+ ROUTING_FILE, getConfigVersion, parseHostedUrl, parseBackblazeUrl, parseRoutingData, assertValidRemoteConfig,
10
11
  normalizeRemoteConfig, normalizeSource, serializeRemoteConfig,
11
12
  getRoute, routeContains, parseVariableRoute, getBucketBaseUrl, buildFileUrl,
12
13
  } from "./remoteConfig";
@@ -16,8 +17,8 @@ import { SocketFunction } from "socket-function/SocketFunction";
16
17
  import { ArchivesRemote, parseStorageUrl, authenticateStorage } from "./ArchivesRemote";
17
18
  import { onServerRoutingChanged } from "./storageClientController";
18
19
  import { ArchivesBackblaze } from "../backblaze";
19
- import { ServerBucketInfo, ActiveBucketInfo, getLocalArchives } from "./storageServerState";
20
- import { isOwnAddress } from "./serverConfig";
20
+ import { ArchivesDisk } from "../ArchivesDisk";
21
+ import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
21
22
  import { RemoteStorageController, STORAGE_NOT_AUTHENTICATED } from "./storageController";
22
23
  import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
23
24
  import { formatTime } from "socket-function/src/formatting/format";
@@ -50,16 +51,8 @@ type SmartTimeout = {
50
51
  /** 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). */
51
52
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
52
53
 
53
- export function createApiArchives(source: SourceConfig): IArchives {
54
- if (source.type === "backblaze") {
55
- return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable, allowedOrigins: source.allowedOrigins });
56
- }
57
- let parsed = parseHostedUrl(source.url);
58
- if (isNode() && isOwnAddress(parsed.address, parsed.port)) {
59
- return getLocalArchives(parsed.account, parsed.bucketName, source);
60
- }
61
- return new ArchivesRemote({ url: source.url, waitForAccess: false, sourceConfig: source });
62
- }
54
+ /** A client for ONE source - see storeSources.ts. Re-exported here because a chain is built out of them. */
55
+ export { createApiArchives } from "./storeSources";
63
56
 
64
57
  type ChainState = {
65
58
  config: RemoteConfig;
@@ -77,10 +70,6 @@ function configWindowCurrent(config: SourceConfig): boolean {
77
70
  return start <= now && now < end;
78
71
  }
79
72
 
80
- function configAcceptsWrites(config: SourceConfig): boolean {
81
- return configWindowCurrent(config);
82
- }
83
-
84
73
  function materializeShardKey(key: string, target: SourceWrapper): string {
85
74
  let [start, end] = target.config.route || FULL_ROUTE;
86
75
  return key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
@@ -498,7 +487,7 @@ export class ArchivesChain implements IArchives {
498
487
  attempt++;
499
488
  let attemptStart = Date.now();
500
489
  let state = await this.getState();
501
- let target = state.sources.find(x => configAcceptsWrites(x.config) && (config.route === undefined || routeContains(x.config.route, config.route)));
490
+ let target = state.sources.find(x => configWindowCurrent(x.config) && (config.route === undefined || routeContains(x.config.route, config.route)));
502
491
  try {
503
492
  if (!target) {
504
493
  throw new Error(`No source is the ${config.write && "write target" || "primary read source"} for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""} (every source is outside its valid window or outside the key's route)`);
@@ -782,7 +771,7 @@ export class ArchivesChain implements IArchives {
782
771
  public async hasWriteAccess(): Promise<boolean> {
783
772
  let state = await this.getState();
784
773
  for (let source of state.sources) {
785
- if (!configAcceptsWrites(source.config)) continue;
774
+ if (!configWindowCurrent(source.config)) continue;
786
775
  if (!await source.hasWriteAccess()) return false;
787
776
  }
788
777
  return true;
@@ -799,8 +788,8 @@ export class ArchivesChain implements IArchives {
799
788
  return await this.setVariableShard(fileName, data, config);
800
789
  }
801
790
  if (data.length > LARGE_SET_THRESHOLD) {
802
- // Streamed automatically so callers never have to call setLargeFile themselves when they already hold the buffer - one giant message would exceed the wire limit and lag every other client on the connection
803
- await this.setLargeFile({ path: fileName, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) });
791
+ // Streamed automatically so callers never have to call setLargeFile themselves when they already hold the buffer - one giant message would exceed the wire limit and lag every other client on the connection. The whole config goes with it (including fallbacks, which the buffer's restartStream makes possible): a file must not get different write semantics just for being big.
792
+ await this.setLargeFile({ path: fileName, ...config, ...bufferChunkStream(data) });
804
793
  return fileName;
805
794
  }
806
795
  await this.request({ write: true, fallbacks: config?.fallbacks, route: getRoute(fileName), timeout: { uploadBytes: data.length, label: `Upload of ${JSON.stringify(fileName)} (${data.length} bytes)` } }, archives => archives.set(fileName, data, config));
@@ -808,6 +797,8 @@ export class ArchivesChain implements IArchives {
808
797
  }
809
798
 
810
799
  private async setRoutingConfig(data: Buffer, config?: { lastModified?: number }): Promise<string> {
800
+ // Checked before the first node is written to, not just by each node as it arrives: every server would reject it anyway, and finding that out one node at a time is how a config write ends up half-applied
801
+ assertValidRemoteConfig(parseRoutingData(data));
811
802
  let state = await this.getState();
812
803
  let writeTime = config?.lastModified || Date.now();
813
804
  let written: string[] = [];
@@ -835,11 +826,42 @@ export class ArchivesChain implements IArchives {
835
826
  await this.request({ write: true, fallbacks: config?.fallbacks, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
836
827
  }
837
828
 
829
+ /** See IArchives.move. When one node is the write target for BOTH paths, that node moves the file itself - the bytes never come through us - with the same wrong-window/route re-resolution as any write. When the paths route to different shards no single node holds both, so the move degrades to a copy through us plus a delete, CONFIRMED at the destination before the source is touched. No smart timeout on the node-side move: it can be a big file's worth of node-side work, which the upload-sized deadlines would misjudge. */
830
+ public async move(config: MoveFileConfig): Promise<void> {
831
+ if (config.fromPath === config.toPath) return;
832
+ if (config.fromPath === ROUTING_FILE || config.toPath === ROUTING_FILE) {
833
+ throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be moved`);
834
+ }
835
+ let fromRoute = getRoute(config.fromPath);
836
+ let toRoute = getRoute(config.toPath);
837
+ let state = await this.getState();
838
+ let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, fromRoute));
839
+ if (target && routeContains(target.config.route, toRoute)) {
840
+ await this.request({ write: true, route: fromRoute }, async archives => {
841
+ if (!archives.move) {
842
+ throw new Error(`${archives.getDebugName()} does not support move (moving ${JSON.stringify(config.fromPath)} to ${JSON.stringify(config.toPath)})`);
843
+ }
844
+ await archives.move(config);
845
+ });
846
+ return;
847
+ }
848
+ // Inline rather than moveArchiveFile, which - given one archives that implements move - would just call back into this method
849
+ let copied = await copyArchiveFile({ from: this, to: this, path: config.fromPath, toPath: config.toPath });
850
+ if (!copied) {
851
+ throw new Error(`Cannot move ${JSON.stringify(config.fromPath)} to ${JSON.stringify(config.toPath)}: the source file does not exist on ${this.getDebugName()}`);
852
+ }
853
+ let confirmed = await this.getInfo(config.toPath);
854
+ if (!confirmed) {
855
+ throw new Error(`Not deleting ${JSON.stringify(config.fromPath)} after copying it to ${JSON.stringify(config.toPath)}: the destination does not report it (${this.getDebugName()}) - the file is left at the source`);
856
+ }
857
+ await this.del(config.fromPath);
858
+ }
859
+
838
860
  // 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.
839
861
  private getVariableShardTargets(state: ChainState, config: { connectedOnly: boolean }): SourceWrapper[] {
840
862
  let targetsByRoute = new Map<string, SourceWrapper>();
841
863
  for (let source of state.sources) {
842
- if (!configAcceptsWrites(source.config)) continue;
864
+ if (!configWindowCurrent(source.config)) continue;
843
865
  let routeKey = JSON.stringify(source.config.route || FULL_ROUTE);
844
866
  if (!targetsByRoute.has(routeKey)) {
845
867
  targetsByRoute.set(routeKey, source);
@@ -881,7 +903,7 @@ export class ArchivesChain implements IArchives {
881
903
  // 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.
882
904
  await this.applySmartTimeout({ uploadBytes: data.length, label: `Variable-shard upload of ${JSON.stringify(fullKey)} (${data.length} bytes)` }, target, () => {
883
905
  if (data.length > LARGE_SET_THRESHOLD) {
884
- return target.write(archives => archives.setLargeFile({ path: fullKey, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) }));
906
+ return target.write(archives => archives.setLargeFile({ path: fullKey, ...config, ...bufferChunkStream(data) }));
885
907
  }
886
908
  return target.write(archives => archives.set(fullKey, data, config)) as any;
887
909
  });
@@ -909,15 +931,32 @@ export class ArchivesChain implements IArchives {
909
931
  }
910
932
  }
911
933
 
912
- public async setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
934
+ /** A large file is written exactly like a small one - same write node, same wrong-window/route re-resolution, same fallbacks - so a value's SIZE never decides its write semantics (set streams through here past LARGE_SET_THRESHOLD, and a file that grew past it must not suddenly lose the availability its caller asked for). The one difference: every attempt after the first has to rewind the stream, so a config without restartStream gets a single attempt. */
935
+ public async setLargeFile(config: SetLargeFileConfig): Promise<void> {
913
936
  if (config.path.includes(VARIABLE_SHARD) && parseVariableRoute(config.path) === undefined) {
914
937
  throw new Error(`setLargeFile does not support VARIABLE_SHARD keys (there is no way to return the materialized key); write the file with set, or materialize the key yourself. Key: ${JSON.stringify(config.path)}`);
915
938
  }
916
939
  let route = getRoute(config.path);
940
+ const restartStream = config.restartStream;
941
+ if (!restartStream) {
942
+ await this.setLargeFileOnce(config, route);
943
+ return;
944
+ }
945
+ let attempt = 0;
946
+ await this.request({ write: true, fallbacks: config.fallbacks, route }, async archives => {
947
+ attempt++;
948
+ // The previous attempt consumed some (or all) of the stream, and this source needs the file from its first byte
949
+ if (attempt > 1) await restartStream();
950
+ await watchSlowPromise(`setLargeFile|${config.path}`, archives.setLargeFile(config));
951
+ });
952
+ }
953
+
954
+ // A stream that cannot rewind gets exactly ONE attempt at the write node: retrying anywhere (another source, or this one again) would send whatever is left of an already-consumed stream as if it were the whole file.
955
+ private async setLargeFileOnce(config: SetLargeFileConfig, route: number): Promise<void> {
917
956
  let recheckedAvailability = false;
918
957
  while (true) {
919
958
  let state = await this.getState();
920
- let target = state.sources.find(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route));
959
+ let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, route));
921
960
  if (!target) {
922
961
  if (!recheckedAvailability) {
923
962
  recheckedAvailability = true;
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { startIntermediateMaintenance } from "./storageServerState";
3
+ import { startIntermediateMaintenance } from "./intermediateManagement";
4
4
 
5
5
  const PARAMETERS_TIMELINE_FILE_REGEX = /^(\d+)-parameters\.json$/;
6
6
  const DEPLOY_DETECT_RETRY_DELAY = 5 * 1000;