sliftutils 1.7.9 → 1.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/bin/storageserver.js +4 -0
  2. package/{teststorage → examplestorage}/browser.tsx +18 -22
  3. package/{teststorage/server.ts → examplestorage/exampleserver.ts} +4 -6
  4. package/index.d.ts +770 -84
  5. package/misc/dist/environment.ts.cache +3 -3
  6. package/misc/dist/getSecret.ts.cache +139 -0
  7. package/misc/dist/strings.ts.cache +13 -0
  8. package/misc/dist/zip.ts.cache +9 -0
  9. package/misc/getSecret.ts +38 -16
  10. package/misc/https/cloudflareHelpers.d.ts +19 -0
  11. package/misc/https/cloudflareHelpers.ts +69 -0
  12. package/misc/https/dist/certs.ts.cache +115 -179
  13. package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
  14. package/misc/https/dist/dns.ts.cache +112 -100
  15. package/misc/https/dist/hostServer.ts.cache +130 -0
  16. package/misc/https/dist/httpsCerts.ts.cache +24 -5
  17. package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
  18. package/misc/https/dns.d.ts +41 -8
  19. package/misc/https/dns.ts +104 -106
  20. package/misc/https/hostServer.d.ts +0 -3
  21. package/misc/https/hostServer.ts +12 -8
  22. package/package.json +3 -2
  23. package/render-utils/dist/observer.tsx.cache +3 -2
  24. package/spec.txt +0 -55
  25. package/storage/ArchivesDisk.d.ts +67 -0
  26. package/storage/ArchivesDisk.ts +369 -0
  27. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
  28. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
  29. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
  30. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
  31. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
  32. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
  33. package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
  34. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
  35. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
  36. package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
  37. package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
  38. package/storage/IArchives.d.ts +127 -1
  39. package/storage/IArchives.ts +201 -1
  40. package/storage/backblaze.d.ts +29 -2
  41. package/storage/backblaze.ts +214 -78
  42. package/storage/dist/ArchivesDisk.ts.cache +373 -0
  43. package/storage/dist/FileFolderAPI.tsx.cache +508 -65
  44. package/storage/dist/IArchives.ts.cache +44 -0
  45. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
  46. package/storage/dist/JSONStorage.ts.cache +6 -2
  47. package/storage/dist/PendingManager.tsx.cache +1 -1
  48. package/storage/dist/TransactionStorage.ts.cache +231 -92
  49. package/storage/dist/backblaze.ts.cache +797 -0
  50. package/storage/dist/fileSystemPointer.ts.cache +36 -3
  51. package/storage/dist/remoteFileStorage.ts.cache +495 -0
  52. package/storage/remoteStorage/ArchivesRemote.d.ts +34 -18
  53. package/storage/remoteStorage/ArchivesRemote.ts +85 -65
  54. package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
  55. package/storage/remoteStorage/ArchivesUrl.ts +86 -0
  56. package/storage/remoteStorage/accessPage.tsx +111 -28
  57. package/storage/remoteStorage/blobStore.d.ts +128 -25
  58. package/storage/remoteStorage/blobStore.ts +757 -290
  59. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  60. package/storage/remoteStorage/cliArgs.ts +9 -0
  61. package/storage/remoteStorage/createArchives.d.ts +85 -0
  62. package/storage/remoteStorage/createArchives.ts +612 -0
  63. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
  64. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
  65. package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
  66. package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
  67. package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
  68. package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
  69. package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
  70. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
  71. package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
  72. package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
  73. package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
  74. package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
  75. package/storage/remoteStorage/grantAccess.js +4 -0
  76. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  77. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  78. package/storage/remoteStorage/remoteConfig.d.ts +29 -0
  79. package/storage/remoteStorage/remoteConfig.ts +194 -0
  80. package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
  81. package/storage/remoteStorage/sourceWrapper.ts +223 -0
  82. package/storage/remoteStorage/spec.md +31 -0
  83. package/storage/remoteStorage/storageController.d.ts +30 -27
  84. package/storage/remoteStorage/storageController.ts +297 -159
  85. package/storage/remoteStorage/storageServer.d.ts +6 -0
  86. package/storage/remoteStorage/storageServer.ts +88 -94
  87. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  88. package/storage/remoteStorage/storageServerCli.ts +38 -0
  89. package/storage/remoteStorage/storageServerState.d.ts +43 -0
  90. package/storage/remoteStorage/storageServerState.ts +540 -0
  91. package/testsite/server.ts +0 -2
  92. package/yarn.lock +11 -6
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ require("typenode");
4
+ require("./grantAccessCli");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { RemoteStorageController } from "./storageController";
3
+ import { authenticateStorage, parseStorageUrl } from "./ArchivesRemote";
4
+ import { getArg } from "./cliArgs";
5
+
6
+ // The grantAccess CLI. Invoked via the sibling grantAccess.js bootstrap (which loads typenode and
7
+ // then requires this file). Grants a specific access request by its requestId; must be run on the
8
+ // storage machine itself (its certs.ts identity is what the server trusts as admin).
9
+
10
+ async function main() {
11
+ let url = getArg("url");
12
+ if (!url) throw new Error(`--url is required (ex: --url https://storage.example.com:4444)`);
13
+ let requestId = getArg("requestId");
14
+ if (!requestId) throw new Error(`--requestId is required (the request id shown on the access page)`);
15
+
16
+ let { address, port } = parseStorageUrl(url);
17
+ let nodeId = SocketFunction.connect({ address, port });
18
+ await authenticateStorage({ address, port, nodeId });
19
+ let record = await RemoteStorageController.nodes[nodeId].adminGrantAccess(requestId);
20
+ console.log(`Granted machine ${record.machineId} access to account ${JSON.stringify(record.account)}`);
21
+ process.exit(0);
22
+ }
23
+
24
+ main().catch(e => {
25
+ console.error(e);
26
+ process.exit(1);
27
+ });
@@ -0,0 +1,29 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "../IArchives";
4
+ export declare const ROUTING_FILE = "storage/storagerouting.json";
5
+ /** The variable-shard route override embedded in the key ("<sentinel>_<value>", see VARIABLE_SHARD), or undefined when the key has no sentinel or the sentinel has no value yet. */
6
+ export declare function parseVariableRoute(key: string): number | undefined;
7
+ /** Where a key routes in [0, 1). A materialized variable-shard suffix completely overrides the hash. */
8
+ export declare function getRoute(key: string): number;
9
+ export declare function routeContains(route: [number, number] | undefined, value: number): boolean;
10
+ export declare function routesOverlap(a: [number, number] | undefined, b: [number, number] | undefined): boolean;
11
+ /** The overlap of two route ranges, or undefined when they don't overlap. */
12
+ export declare function routeIntersection(a: [number, number] | undefined, b: [number, number] | undefined): [number, number] | undefined;
13
+ export declare function getConfigVersion(config: RemoteConfig): number;
14
+ /** Strips the routing-file suffix, leaving the bucket's public base URL (file paths append to it). */
15
+ export declare function getBucketBaseUrl(url: string): string;
16
+ export declare function buildFileUrl(baseUrl: string, filePath: string): string;
17
+ export declare function parseHostedUrl(url: string): {
18
+ address: string;
19
+ port: number;
20
+ account: string;
21
+ bucketName: string;
22
+ };
23
+ export declare function parseBackblazeUrl(url: string): {
24
+ bucketName: string;
25
+ };
26
+ export declare function normalizeSource(source: RemoteConfigBase): HostedConfig | BackblazeConfig;
27
+ export declare function normalizeRemoteConfig(config: RemoteConfig | RemoteConfigBase): RemoteConfig;
28
+ export declare function parseRoutingData(data: Buffer): RemoteConfig;
29
+ export declare function serializeRemoteConfig(config: RemoteConfig): Buffer;
@@ -0,0 +1,194 @@
1
+ module.allowclient = true;
2
+
3
+ import { sort, sha256HashBuffer } from "socket-function/src/misc";
4
+ import { getBufferInt } from "socket-function/src/bits";
5
+ import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, FULL_VALID_WINDOW, FULL_ROUTE, VARIABLE_SHARD } from "../IArchives";
6
+
7
+ // Parsing / normalization of RemoteConfig (see IArchives.ts). Every bucket stores its own
8
+ // configuration (a RemoteConfig) inside itself, at ROUTING_FILE. Writing that file creates the
9
+ // bucket / reconfigures it (see storageServerState.ts); clients reconcile it by version (see
10
+ // createArchives.ts).
11
+
12
+ export const ROUTING_FILE = "storage/storagerouting.json";
13
+ const ROUTING_SUFFIX = "/" + ROUTING_FILE;
14
+
15
+ const ROUTE_PRECISION = 1000 * 1000 * 1000;
16
+
17
+ /** The variable-shard route override embedded in the key ("<sentinel>_<value>", see VARIABLE_SHARD), or undefined when the key has no sentinel or the sentinel has no value yet. */
18
+ export function parseVariableRoute(key: string): number | undefined {
19
+ let index = key.indexOf(VARIABLE_SHARD);
20
+ if (index === -1) return undefined;
21
+ let match = /^_(\d+(?:\.\d+)?)/.exec(key.slice(index + VARIABLE_SHARD.length));
22
+ if (!match) return undefined;
23
+ return +match[1];
24
+ }
25
+
26
+ /** Where a key routes in [0, 1). A materialized variable-shard suffix completely overrides the hash. */
27
+ export function getRoute(key: string): number {
28
+ let override = parseVariableRoute(key);
29
+ if (override !== undefined) return override;
30
+ let hash = getBufferInt(sha256HashBuffer(key));
31
+ return hash % ROUTE_PRECISION / ROUTE_PRECISION;
32
+ }
33
+
34
+ // Route ranges are [start, end) - inclusive start, exclusive end
35
+ export function routeContains(route: [number, number] | undefined, value: number): boolean {
36
+ if (!route) return true;
37
+ return route[0] <= value && value < route[1];
38
+ }
39
+ export function routesOverlap(a: [number, number] | undefined, b: [number, number] | undefined): boolean {
40
+ let [aStart, aEnd] = a || FULL_ROUTE;
41
+ let [bStart, bEnd] = b || FULL_ROUTE;
42
+ return aStart < bEnd && bStart < aEnd;
43
+ }
44
+ /** The overlap of two route ranges, or undefined when they don't overlap. */
45
+ export function routeIntersection(a: [number, number] | undefined, b: [number, number] | undefined): [number, number] | undefined {
46
+ let [aStart, aEnd] = a || FULL_ROUTE;
47
+ let [bStart, bEnd] = b || FULL_ROUTE;
48
+ let start = Math.max(aStart, bStart);
49
+ let end = Math.min(aEnd, bEnd);
50
+ if (start >= end) return undefined;
51
+ return [start, end];
52
+ }
53
+
54
+ // A missing version counts as -1, so any explicitly versioned config beats an unversioned one
55
+ export function getConfigVersion(config: RemoteConfig): number {
56
+ return config.version ?? -1;
57
+ }
58
+
59
+ /** Strips the routing-file suffix, leaving the bucket's public base URL (file paths append to it). */
60
+ export function getBucketBaseUrl(url: string): string {
61
+ if (!url.endsWith(ROUTING_SUFFIX)) {
62
+ throw new Error(`Expected a bucket routing URL ending with ${JSON.stringify(ROUTING_SUFFIX)}, was ${JSON.stringify(url)}`);
63
+ }
64
+ return url.slice(0, -ROUTING_SUFFIX.length);
65
+ }
66
+
67
+ export function buildFileUrl(baseUrl: string, filePath: string): string {
68
+ return baseUrl + "/" + filePath.split("/").map(encodeURIComponent).join("/");
69
+ }
70
+
71
+ // Ex: https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
72
+ export function parseHostedUrl(url: string): { address: string; port: number; account: string; bucketName: string } {
73
+ let base = getBucketBaseUrl(url);
74
+ let u = new URL(base);
75
+ if (u.protocol !== "https:") {
76
+ throw new Error(`Storage URL must use https, got ${JSON.stringify(u.protocol)} in ${JSON.stringify(url)}`);
77
+ }
78
+ let parts = u.pathname.split("/").filter(x => x);
79
+ if (parts.length !== 3 || parts[0] !== "file") {
80
+ throw new Error(`Expected a hosted bucket URL like https://host:port/file/<account>/<bucketName>${ROUTING_SUFFIX}, was ${JSON.stringify(url)}`);
81
+ }
82
+ return { address: u.hostname, port: +u.port || 443, account: decodeURIComponent(parts[1]), bucketName: decodeURIComponent(parts[2]) };
83
+ }
84
+
85
+ // Ex: https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
86
+ export function parseBackblazeUrl(url: string): { bucketName: string } {
87
+ let base = getBucketBaseUrl(url);
88
+ let u = new URL(base);
89
+ let parts = u.pathname.split("/").filter(x => x);
90
+ if (parts.length !== 2 || parts[0] !== "file") {
91
+ throw new Error(`Expected a backblaze bucket URL like https://f002.backblazeb2.com/file/<bucketName>${ROUTING_SUFFIX}, was ${JSON.stringify(url)}`);
92
+ }
93
+ return { bucketName: decodeURIComponent(parts[1]) };
94
+ }
95
+
96
+ export function normalizeSource(source: RemoteConfigBase): HostedConfig | BackblazeConfig {
97
+ if (typeof source !== "string") {
98
+ let window = source.validWindow;
99
+ if (!Array.isArray(window) || window.length !== 2 || !window.every(x => typeof x === "number")) {
100
+ throw new Error(`Object source configs must specify validWindow ([startMs, endMs] of the write times the source is valid for) - configuration changes must be scheduled, not flipped instantly. Use FULL_VALID_WINDOW when the source is always valid. Was ${JSON.stringify(window)} on ${JSON.stringify(source).slice(0, 500)}`);
101
+ }
102
+ let route = source.route;
103
+ if (route !== undefined && (!Array.isArray(route) || route.length !== 2 || !route.every(x => typeof x === "number") || route[0] < 0 || route[1] > 1 || route[0] >= route[1])) {
104
+ throw new Error(`Source route must be a [start, end) fraction range within [0, 1] with start < end, was ${JSON.stringify(route)} on ${JSON.stringify(source).slice(0, 500)}`);
105
+ }
106
+ if (source.readerDiskLimit !== undefined) {
107
+ if (typeof source.readerDiskLimit !== "number" || source.readerDiskLimit <= 0) {
108
+ throw new Error(`readerDiskLimit must be a positive byte count, was ${JSON.stringify(source.readerDiskLimit)} on ${JSON.stringify(source).slice(0, 500)}`);
109
+ }
110
+ if (!source.noFullSync) {
111
+ throw new Error(`readerDiskLimit requires noFullSync: a full copy cannot be bounded, only a read cache can. On ${JSON.stringify(source).slice(0, 500)}`);
112
+ }
113
+ }
114
+ if (source.type === "remote") {
115
+ // Throws if the URL is malformed, so bad configs are rejected before they're stored
116
+ parseHostedUrl(source.url);
117
+ }
118
+ return source;
119
+ }
120
+ let hostname = new URL(source).hostname;
121
+ if (hostname.endsWith(".backblazeb2.com")) {
122
+ // Validates the URL (throws on malformed) before it's stored; the bucket name is read back
123
+ // out of the URL at use sites, never stored on the config.
124
+ parseBackblazeUrl(source);
125
+ return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
126
+ }
127
+ parseHostedUrl(source);
128
+ return { type: "remote", url: source, validWindow: FULL_VALID_WINDOW };
129
+ }
130
+
131
+ export function normalizeRemoteConfig(config: RemoteConfig | RemoteConfigBase): RemoteConfig {
132
+ let result: RemoteConfig;
133
+ if (typeof config !== "string" && "sources" in config) {
134
+ result = { version: config.version, sources: config.sources.map(normalizeSource) };
135
+ } else {
136
+ result = { sources: [normalizeSource(config)] };
137
+ }
138
+ // Mixed immutability makes no sense AMONG sources valid at the same time: a mutable source
139
+ // would accept overwrites that its immutable peers refuse to synchronize, forking their
140
+ // contents. Sources are grouped by transitively overlapping valid windows (absorb everything
141
+ // overlapping the group, extend the group's end, repeat) - a soft check that is correct as
142
+ // long as windows have clean breaks (a group ends exactly where the next begins).
143
+ let sources = result.sources.map(normalizeSource);
144
+ let sorted = [...sources];
145
+ sort(sorted, x => x.validWindow[0]);
146
+ let group: typeof sources = [];
147
+ let groupEnd = 0;
148
+ function checkGroup() {
149
+ let immutableCount = group.filter(x => x.immutable).length;
150
+ if (immutableCount && immutableCount !== group.length) {
151
+ throw new Error(`Sources with overlapping valid windows must agree on immutability: ${immutableCount} of ${group.length} are immutable. Sources: ${JSON.stringify(group.map(x => ({ url: x.url, validWindow: x.validWindow, immutable: !!x.immutable })))}`);
152
+ }
153
+ // The sources valid at any instant must cover the whole key space, or keys routing into a
154
+ // gap could never be read
155
+ let routes = group.map(x => x.route || FULL_ROUTE);
156
+ sort(routes, x => x[0]);
157
+ let covered = 0;
158
+ for (let route of routes) {
159
+ if (route[0] > covered) break;
160
+ covered = Math.max(covered, route[1]);
161
+ }
162
+ if (covered < 1) {
163
+ throw new Error(`Sources with overlapping valid windows must cover the full route space [0, 1); coverage stops at ${covered}. Sources: ${JSON.stringify(group.map(x => ({ url: x.url, validWindow: x.validWindow, route: x.route || FULL_ROUTE })))}`);
164
+ }
165
+ }
166
+ for (let source of sorted) {
167
+ if (group.length && source.validWindow[0] >= groupEnd) {
168
+ checkGroup();
169
+ group = [];
170
+ }
171
+ group.push(source);
172
+ groupEnd = Math.max(groupEnd, source.validWindow[1]);
173
+ }
174
+ checkGroup();
175
+ return result;
176
+ }
177
+
178
+ export function parseRoutingData(data: Buffer): RemoteConfig {
179
+ let text = data.toString();
180
+ let parsed: RemoteConfig;
181
+ try {
182
+ parsed = JSON.parse(text) as RemoteConfig;
183
+ } catch (e) {
184
+ throw new Error(`Routing config is not valid JSON (${String(e)}). Data: ${text.slice(0, 500)}`);
185
+ }
186
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.sources)) {
187
+ throw new Error(`Routing config must be { version?, sources: [...] }, was ${text.slice(0, 500)}`);
188
+ }
189
+ return normalizeRemoteConfig(parsed);
190
+ }
191
+
192
+ export function serializeRemoteConfig(config: RemoteConfig): Buffer {
193
+ return Buffer.from(JSON.stringify(config, undefined, 4));
194
+ }
@@ -0,0 +1,41 @@
1
+ import { IArchives, HostedConfig, BackblazeConfig } from "../IArchives";
2
+ import { ArchivesUrl } from "./ArchivesUrl";
3
+ export declare const RETRY_START_DELAY: number;
4
+ export declare const RETRY_MAX_DELAY: number;
5
+ export declare const RETRY_GROWTH = 1.5;
6
+ export declare class SourceWrapper {
7
+ config: HostedConfig | BackblazeConfig;
8
+ private background;
9
+ api?: IArchives;
10
+ url?: ArchivesUrl;
11
+ writeBlocked?: string;
12
+ private remote?;
13
+ private disposed;
14
+ private reconnectRunning;
15
+ private accessCache?;
16
+ private constructor();
17
+ static create(config: HostedConfig | BackblazeConfig, options?: {
18
+ background?: boolean;
19
+ }): Promise<SourceWrapper>;
20
+ getDebugName(): string;
21
+ isConnected(): boolean;
22
+ /** Call after a request failed while isConnected() was false: starts (if not already running)
23
+ * the background reconnect loop. Never blocks - the failed request still throws. */
24
+ noteFailure(): void;
25
+ private reconnectLoop;
26
+ private checkAccess;
27
+ read<T>(run: (archives: IArchives) => Promise<T>): Promise<T>;
28
+ hasWriteAccess(): Promise<boolean>;
29
+ private pings;
30
+ private pingTimer;
31
+ /** Starts measuring this source's latency (for variable-shard target preference). Only hosted
32
+ * remotes are pinged; our own local server counts as 0, everything else as Infinity. */
33
+ startPinging(): void;
34
+ /** Median of the recent pings. Sources that can't be pinged sort last (Infinity), except our
35
+ * own in-process server, which is the best possible target (0). */
36
+ getLatency(): number;
37
+ /** Writes always go through the API, so a permission error throws to the caller on every write
38
+ * (and access granted in the meantime is picked up automatically). */
39
+ write<T>(run: (archives: IArchives) => Promise<T>): Promise<T>;
40
+ dispose(): void;
41
+ }
@@ -0,0 +1,223 @@
1
+ module.allowclient = true;
2
+
3
+ import { isNode, sort } from "socket-function/src/misc";
4
+ import { delay } from "socket-function/src/batching";
5
+ import { getSecret } from "../../misc/getSecret";
6
+ import { IArchives, HostedConfig, BackblazeConfig } from "../IArchives";
7
+ import { ROUTING_FILE, getBucketBaseUrl, parseHostedUrl, parseBackblazeUrl } from "./remoteConfig";
8
+ import { ArchivesRemote } from "./ArchivesRemote";
9
+ import { ArchivesUrl } from "./ArchivesUrl";
10
+ import { ArchivesBackblaze } from "../backblaze";
11
+ import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
12
+
13
+ export const RETRY_START_DELAY = 2 * 1000;
14
+ export const RETRY_MAX_DELAY = 5 * 60 * 1000;
15
+ export const RETRY_GROWTH = 1.5;
16
+ const ACCESS_RECHECK_INTERVAL = 60 * 1000;
17
+ const PING_INTERVAL = 60 * 1000;
18
+ const PING_HISTORY = 10;
19
+
20
+ async function hasBackblazeCreds(): Promise<boolean> {
21
+ if (!isNode()) return false;
22
+ try {
23
+ await getSecret("backblaze.json.applicationKeyId");
24
+ await getSecret("backblaze.json.applicationKey");
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ // One source of a RemoteConfig, as a usable IArchives. Reads fall back to the public-URL form when
32
+ // we don't have API access; writes always attempt the API (so access granted later is picked up on
33
+ // the next write). When a call fails while the WebSocket is down, noteFailure starts a background
34
+ // reconnect loop with a ramping delay - it never blocks callers, it just keeps trying so the
35
+ // connection eventually comes back. Sources without a WebSocket (backblaze, URL-form, our own
36
+ // local server) report always-connected: they're last-resort sources where throwing is fine.
37
+ export class SourceWrapper {
38
+ public api?: IArchives;
39
+ public url?: ArchivesUrl;
40
+ public writeBlocked?: string;
41
+ private remote?: ArchivesRemote;
42
+ private disposed = false;
43
+ private reconnectRunning = false;
44
+ private accessCache?: { hasAccess: boolean; time: number };
45
+
46
+ private constructor(
47
+ public config: HostedConfig | BackblazeConfig,
48
+ // false for short-lived probe instances, which must never leave a retry loop behind
49
+ private background: boolean,
50
+ ) { }
51
+
52
+ public static async create(config: HostedConfig | BackblazeConfig, options?: { background?: boolean }): Promise<SourceWrapper> {
53
+ let wrapper = new SourceWrapper(config, options?.background !== false);
54
+ if (config.type === "backblaze") {
55
+ if (await hasBackblazeCreds()) {
56
+ wrapper.api = new ArchivesBackblaze({ bucketName: parseBackblazeUrl(config.url).bucketName, public: config.public, immutable: config.immutable });
57
+ } else if (isNode()) {
58
+ wrapper.writeBlocked = `No backblaze credentials for ${config.url}. Provide backblaze.json.applicationKeyId and backblaze.json.applicationKey via getSecret to enable API access.`;
59
+ } else {
60
+ wrapper.writeBlocked = `Browsers cannot write to backblaze (bucket ${config.url})`;
61
+ }
62
+ if (!wrapper.api && config.public !== false) {
63
+ wrapper.url = new ArchivesUrl(getBucketBaseUrl(config.url));
64
+ }
65
+ return wrapper;
66
+ }
67
+ let parsed = parseHostedUrl(config.url);
68
+ let server = isNode() && getStorageServerConfigOptional() || undefined;
69
+ if (server && parsed.address === server.domain && parsed.port === server.port) {
70
+ // A bucket hosted by our own process - use it directly instead of calling ourselves
71
+ wrapper.api = getLocalArchives(parsed.account, parsed.bucketName);
72
+ return wrapper;
73
+ }
74
+ wrapper.remote = new ArchivesRemote({ url: config.url, accountName: config.accountName, waitForAccess: false });
75
+ wrapper.api = wrapper.remote;
76
+ if (config.public !== false) {
77
+ wrapper.url = new ArchivesUrl(getBucketBaseUrl(config.url));
78
+ }
79
+ return wrapper;
80
+ }
81
+
82
+ public getDebugName(): string {
83
+ return `source/${this.config.url}`;
84
+ }
85
+
86
+ public isConnected(): boolean {
87
+ if (!this.remote) return true;
88
+ return this.remote.isConnected();
89
+ }
90
+
91
+ /** Call after a request failed while isConnected() was false: starts (if not already running)
92
+ * the background reconnect loop. Never blocks - the failed request still throws. */
93
+ public noteFailure(): void {
94
+ if (!this.background || this.disposed || this.reconnectRunning) return;
95
+ if (this.isConnected()) return;
96
+ this.reconnectRunning = true;
97
+ void this.reconnectLoop();
98
+ }
99
+
100
+ private async reconnectLoop(): Promise<void> {
101
+ let retryDelay = RETRY_START_DELAY;
102
+ while (true) {
103
+ await delay(retryDelay);
104
+ if (this.disposed) {
105
+ this.reconnectRunning = false;
106
+ return;
107
+ }
108
+ if (this.isConnected()) break;
109
+ try {
110
+ // Any call re-establishes the WebSocket; the result doesn't matter
111
+ await this.remote?.getInfo(ROUTING_FILE);
112
+ break;
113
+ } catch (e) {
114
+ // Even a failing call (e.g. access denied) proves the connection is back
115
+ if (this.isConnected()) break;
116
+ console.warn(`Cannot connect to storage source ${this.config.url}, retrying in ${Math.round(retryDelay / 1000)}s. ${(e as Error).stack ?? e}`);
117
+ }
118
+ retryDelay = Math.min(RETRY_MAX_DELAY, retryDelay * RETRY_GROWTH);
119
+ }
120
+ this.reconnectRunning = false;
121
+ console.log(`Reconnected to storage source ${this.config.url}`);
122
+ }
123
+
124
+ // For hosted sources: cached access check, so reads know whether to use the API or the public
125
+ // URL form. Access, once seen, is assumed to stick; no-access is re-checked periodically (the
126
+ // check also registers our access request server-side, which logs the grant link).
127
+ private async checkAccess(): Promise<boolean> {
128
+ let remote = this.remote;
129
+ if (!remote) return !!this.api;
130
+ let cached = this.accessCache;
131
+ if (cached && (cached.hasAccess || Date.now() - cached.time < ACCESS_RECHECK_INTERVAL)) {
132
+ return cached.hasAccess;
133
+ }
134
+ let waiting = await remote.waitingForAccess();
135
+ this.accessCache = { hasAccess: !waiting, time: Date.now() };
136
+ if (waiting) {
137
+ console.warn(`No access to storage account for ${this.config.url} (our machine ${waiting.machineId}, ip ${waiting.ip}). Reading via the public URL form where possible. Grant access at ${waiting.link}`);
138
+ }
139
+ return !waiting;
140
+ }
141
+
142
+ public async read<T>(run: (archives: IArchives) => Promise<T>): Promise<T> {
143
+ if (this.api && await this.checkAccess()) {
144
+ return await run(this.api);
145
+ }
146
+ if (this.url) {
147
+ return await run(this.url);
148
+ }
149
+ if (this.api) {
150
+ // No public URL form - let the API call throw its own error (which includes the access
151
+ // instructions for hosted sources)
152
+ return await run(this.api);
153
+ }
154
+ throw new Error(`Source ${this.config.url} has no API access and no public URL form (public: false)`);
155
+ }
156
+
157
+ public async hasWriteAccess(): Promise<boolean> {
158
+ if (this.writeBlocked || !this.api) return false;
159
+ return await this.api.hasWriteAccess();
160
+ }
161
+
162
+ private pings: number[] = [];
163
+ private pingTimer: ReturnType<typeof setInterval> | undefined;
164
+
165
+ /** Starts measuring this source's latency (for variable-shard target preference). Only hosted
166
+ * remotes are pinged; our own local server counts as 0, everything else as Infinity. */
167
+ public startPinging(): void {
168
+ let remote = this.remote;
169
+ if (!remote || this.pingTimer || this.disposed) return;
170
+ let measure = async () => {
171
+ let start = Date.now();
172
+ try {
173
+ await remote.ping();
174
+ } catch {
175
+ return;
176
+ }
177
+ this.pings.push(Date.now() - start);
178
+ if (this.pings.length > PING_HISTORY) {
179
+ this.pings.shift();
180
+ }
181
+ };
182
+ void measure();
183
+ this.pingTimer = setInterval(() => {
184
+ void measure();
185
+ }, PING_INTERVAL);
186
+ (this.pingTimer as { unref?: () => void }).unref?.();
187
+ }
188
+
189
+ /** Median of the recent pings. Sources that can't be pinged sort last (Infinity), except our
190
+ * own in-process server, which is the best possible target (0). */
191
+ public getLatency(): number {
192
+ if (!this.remote) {
193
+ if (this.config.type === "remote" && this.api) return 0;
194
+ return Infinity;
195
+ }
196
+ if (!this.pings.length) return Infinity;
197
+ let sorted = [...this.pings];
198
+ sort(sorted, x => x);
199
+ return sorted[Math.floor(sorted.length / 2)];
200
+ }
201
+
202
+ /** Writes always go through the API, so a permission error throws to the caller on every write
203
+ * (and access granted in the meantime is picked up automatically). */
204
+ public async write<T>(run: (archives: IArchives) => Promise<T>): Promise<T> {
205
+ if (this.writeBlocked) {
206
+ throw new Error(this.writeBlocked);
207
+ }
208
+ let api = this.api;
209
+ if (!api) {
210
+ throw new Error(`Source ${this.config.url} has no API access, so it cannot accept writes`);
211
+ }
212
+ let result = await run(api);
213
+ this.accessCache = { hasAccess: true, time: Date.now() };
214
+ return result;
215
+ }
216
+
217
+ public dispose(): void {
218
+ this.disposed = true;
219
+ if (this.pingTimer) {
220
+ clearInterval(this.pingTimer);
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,31 @@
1
+ # Remote storage — core design ideas
2
+
3
+ This is the reasoning behind the remote storage system (createArchives / storageServer / BlobStore). The code documents the mechanics; this documents the ideas that must survive refactors.
4
+
5
+ ## Configuration updates happen only on startup
6
+
7
+ A client adopts a routing config once, during initialization, and then runs on it. Configs never automatically upgrade mid-run: the 5-minute poll picks up whatever the sources currently hold (so a developer's change spreads), but the version-gated *upgrade* — writing a newer configured version over an older stored one — happens only at startup. This is deliberate: config changes are made by a developer running code, and that developer is present at startup. It also prevents reversion loops — if a server briefly held a newer version and then went away, running clients don't cling to that phantom version; they follow what the surviving sources actually hold, and only a fresh startup with an explicitly newer configured version moves things forward again.
8
+
9
+ ## Scanning + tombstones + versions make any two sources mergeable
10
+
11
+ Every store fully rescans its sources' metadata (on startup and periodically), and every write — including deletions — is ordered by last-write time. Deletions are tombstones: an empty file IS a missing file, kept as a size-0 index entry (for a week) so the deletion itself propagates and reconciles like any other write. Because everything is a timestamped write and scans are bidirectional (pull what they have, push what they're missing), any two sources can be merged in any order and converge to the same state: newest write time wins, per file. There is no operation whose loss corrupts the system — a failed background write, a missed delete, a source that was down for a day — the next scan reconciles it.
12
+
13
+ ## The full source list is duplicated into every source
14
+
15
+ Each bucket stores its complete routing config (the full redundancy list) inside itself, at storage/storagerouting.json, on every source. Discovery is therefore trivial: as long as ONE node is up, a client reading it gets the full overview of the intended sources. And because clients re-discover on every startup (and re-read every 5 minutes), a developer can change the configuration in one place and clients rapidly accept it — no redeploy, no coordinated restart of the fleet.
16
+
17
+ ## BulkDatabase2 index + our own disk as just another source
18
+
19
+ Each bucket's store keeps a BulkDatabase2 index of every file (path, write time, size, holding source), served from memory — existence checks and listings are extremely fast, never touching a source. The trick that keeps the index accurate: our own disk is not special-cased, it is simply the first synchronization source. The same scan/reconcile code that synchronizes remote sources also synchronizes the disk with the index, so the index self-heals from the same machinery instead of needing separate consistency logic.
20
+
21
+ ## Metadata first, data second
22
+
23
+ Synchronization starts with metadata: every source's full listing (path, write time, size) is scanned into the index up front. This is fast and small, so a server very quickly knows exactly what exists everywhere — and can act as a consistent authority on the bucket (serving correct reads, listings, and existence checks by fetching bytes from whichever source holds them) long before it has downloaded the actual data. The data then follows: eagerly by default (the full sync), or — when there is simply too much data to copy — in noFullSync mode, where the disk is only a lazy read cache. Either way consistency is unchanged: the index is complete, writes still go to all the servers, and only where the bytes rest differs.
24
+
25
+ ## Client writes are consistent; client reads are redundant
26
+
27
+ Clients always write to the same node — the first source whose valid window is current — and if that node is down, the write FAILS rather than going to another node. A client having a network hiccup and wrongly deciding nodes are down must never scatter its writes across the chain; that would desynchronize the sources based on one client's flaky view of the network. Reads, by contrast, fail over freely across every redundant source: sources are synchronized copies, so reading from any of them is safe. Maximum read uptime, strictly consistent writes. (Write redundancy still exists — it just lives server-side: the receiving node fans writes out downstream and reconciliation heals anything missed, all ordered by the once-stamped write time.)
28
+
29
+ ## Trust instead of API keys
30
+
31
+ Machines authenticate with their certs.ts identity (proving ownership of their machine key with a signed, server-bound token), and access is granted per account to specific machineIds. No API keys are minted, copied into configs, or passed around — granting a machine access is one command on the storage machine, and revoking it is removing the trust record. The only API keys left in the system are the ones third parties force on us: backblaze and cloudflare, both resolved through getSecret.
@@ -1,8 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { ArchiveFileInfo } from "../IArchives";
4
- import type { BlobStore } from "./blobStore";
5
- import type { IStorage } from "../IStorage";
3
+ import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
6
4
  export declare const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
7
5
  export declare const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
8
6
  export declare const STORAGE_NOT_AUTHENTICATED = "REMOTE_STORAGE_NOT_AUTHENTICATED_cf2f7b1e";
@@ -25,34 +23,15 @@ export type TrustRecord = {
25
23
  ip: string;
26
24
  time: number;
27
25
  };
28
- export type BucketConfig = {
29
- public?: boolean;
30
- fast?: boolean;
31
- writeDelay?: number;
32
- };
33
26
  export type AccessState = {
34
27
  machineId: string;
35
28
  ip: string;
36
29
  hasAccess: boolean;
37
- listAccessCommand: string;
38
30
  grantAccessCommand?: string;
39
- machines?: (AccessRequest & {
40
- trusted: boolean;
41
- })[];
42
- };
43
- export type StorageServerState = {
44
- domain: string;
45
- port: number;
46
- rootDomain: string;
47
- sshTarget: string;
48
- serverCommand: string;
49
- blobStore: BlobStore;
50
- trust: IStorage<TrustRecord>;
51
- requests: IStorage<AccessRequest[]>;
52
- buckets: IStorage<BucketConfig>;
31
+ trustedMachines?: TrustRecord[];
53
32
  };
54
- export declare function setStorageServerState(state: StorageServerState): void;
55
33
  export declare const RemoteStorageController: import("socket-function/SocketFunctionTypes").SocketRegistered<{
34
+ ping: () => Promise<void>;
56
35
  authenticate: (token: AuthToken) => Promise<{
57
36
  machineId: string;
58
37
  ip: string;
@@ -64,14 +43,23 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
64
43
  grantAccessCommand: string;
65
44
  }>;
66
45
  getAccessState: (account: string) => Promise<AccessState>;
46
+ listRequestsForIP: (account: string, ip: string) => Promise<AccessRequest[]>;
47
+ grantAccess: (requestId: string) => Promise<TrustRecord>;
67
48
  adminListRequests: (ip: string) => Promise<AccessRequest[]>;
68
49
  adminGrantAccess: (requestId: string) => Promise<TrustRecord>;
69
- ensureBucket: (account: string, bucketName: string, config: BucketConfig) => Promise<void>;
70
50
  get: (account: string, bucketName: string, path: string, range?: {
71
51
  start: number;
72
52
  end: number;
73
53
  }) => Promise<Buffer | undefined>;
74
- set: (account: string, bucketName: string, path: string, data: Buffer) => Promise<void>;
54
+ get2: (account: string, bucketName: string, path: string, range?: {
55
+ start: number;
56
+ end: number;
57
+ }) => Promise<{
58
+ data: Buffer;
59
+ writeTime: number;
60
+ size: number;
61
+ } | undefined>;
62
+ set: (account: string, bucketName: string, path: string, data: Buffer, lastModified?: number) => Promise<void>;
75
63
  del: (account: string, bucketName: string, path: string) => Promise<void>;
76
64
  getInfo: (account: string, bucketName: string, path: string) => Promise<{
77
65
  writeTime: number;
@@ -81,9 +69,24 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
81
69
  shallow?: boolean;
82
70
  type?: "files" | "folders";
83
71
  }) => Promise<ArchiveFileInfo[]>;
72
+ getChangesAfter: (account: string, bucketName: string, time: number) => Promise<ArchiveFileInfo[]>;
73
+ getArchivesConfig: (account: string, bucketName: string) => Promise<ArchivesConfig>;
74
+ getIndexInfo: (account: string, bucketName: string) => Promise<{
75
+ fileCount: number;
76
+ byteCount: number;
77
+ sources: {
78
+ debugName: string;
79
+ fileCount: number;
80
+ byteCount: number;
81
+ }[];
82
+ } | undefined>;
83
+ getSyncStatus: (account: string, bucketName: string) => Promise<ArchivesSyncStatus>;
84
84
  startLargeFile: (account: string, bucketName: string, path: string) => Promise<string>;
85
85
  uploadPart: (uploadId: string, data: Buffer) => Promise<void>;
86
86
  finishLargeFile: (uploadId: string) => Promise<void>;
87
87
  cancelLargeFile: (uploadId: string) => Promise<void>;
88
- getPublicFile: (account: string, bucketName: string, path: string) => Promise<Buffer>;
88
+ httpEntry: (config?: {
89
+ requireCalls?: string[];
90
+ cacheTime?: number;
91
+ }) => Promise<Buffer>;
89
92
  }>;