sliftutils 1.7.48 → 1.7.50
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.
- package/index.d.ts +99 -38
- package/package.json +1 -1
- package/storage/ArchivesDisk.d.ts +5 -5
- package/storage/ArchivesDisk.ts +19 -5
- package/storage/IArchives.d.ts +36 -7
- package/storage/IArchives.ts +76 -8
- package/storage/backblaze.d.ts +4 -4
- package/storage/backblaze.ts +89 -36
- package/storage/dist/ArchivesDisk.ts.cache +8 -2
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +86 -36
- package/storage/remoteStorage/ArchivesRemote.d.ts +4 -5
- package/storage/remoteStorage/ArchivesRemote.ts +7 -7
- package/storage/remoteStorage/ArchivesUrl.d.ts +2 -1
- package/storage/remoteStorage/ArchivesUrl.ts +13 -3
- package/storage/remoteStorage/blobStore.d.ts +15 -6
- package/storage/remoteStorage/blobStore.ts +198 -115
- package/storage/remoteStorage/createArchives.d.ts +4 -5
- package/storage/remoteStorage/createArchives.ts +6 -11
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +15 -4
- package/storage/remoteStorage/dist/blobStore.ts.cache +194 -108
- package/storage/remoteStorage/dist/createArchives.ts.cache +4 -9
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +15 -3
- package/storage/remoteStorage/dist/sourcesList.ts.cache +79 -0
- package/storage/remoteStorage/dist/storageController.ts.cache +7 -10
- package/storage/remoteStorage/dist/storageServerState.ts.cache +50 -18
- package/storage/remoteStorage/remoteConfig.d.ts +3 -1
- package/storage/remoteStorage/remoteConfig.ts +11 -1
- package/storage/remoteStorage/sourcesList.d.ts +15 -0
- package/storage/remoteStorage/sourcesList.ts +69 -0
- package/storage/remoteStorage/storageController.d.ts +4 -4
- package/storage/remoteStorage/storageController.ts +11 -14
- package/storage/remoteStorage/storageServerState.d.ts +3 -0
- package/storage/remoteStorage/storageServerState.ts +40 -15
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "../IArchives";
|
|
3
|
+
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, ArchiveFileInfo, ChangesAfterConfig } from "../IArchives";
|
|
4
4
|
export declare const ROUTING_FILE = "storage/storagerouting.json";
|
|
5
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
6
|
export declare function parseVariableRoute(key: string): number | undefined;
|
|
7
7
|
/** Where a key routes in [0, 1). A materialized variable-shard suffix completely overrides the hash. */
|
|
8
8
|
export declare function getRoute(key: string): number;
|
|
9
|
+
/** The in-memory getChangesAfter2 emulation, for backends without a native change feed: a full listing filtered down to files written after config.time whose keys route into config.routes. */
|
|
10
|
+
export declare function filterChanges(files: ArchiveFileInfo[], config: ChangesAfterConfig): ArchiveFileInfo[];
|
|
9
11
|
export declare function routeContains(route: [number, number] | undefined, value: number): boolean;
|
|
10
12
|
export declare function routesOverlap(a: [number, number] | undefined, b: [number, number] | undefined): boolean;
|
|
11
13
|
/** The overlap of two route ranges, or undefined when they don't overlap. */
|
|
@@ -2,7 +2,7 @@ import { sort } from "socket-function/src/misc";
|
|
|
2
2
|
import { getBufferInt } from "socket-function/src/bits";
|
|
3
3
|
import { setFlag } from "socket-function/require/compileFlags";
|
|
4
4
|
import jsSha256 from "js-sha256";
|
|
5
|
-
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, FULL_VALID_WINDOW, FULL_ROUTE, VARIABLE_SHARD } from "../IArchives";
|
|
5
|
+
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, ArchiveFileInfo, ChangesAfterConfig, FULL_VALID_WINDOW, FULL_ROUTE, VARIABLE_SHARD } from "../IArchives";
|
|
6
6
|
|
|
7
7
|
setFlag(require, "js-sha256", "allowclient", true);
|
|
8
8
|
|
|
@@ -31,6 +31,16 @@ export function getRoute(key: string): number {
|
|
|
31
31
|
return hash % ROUTE_PRECISION / ROUTE_PRECISION;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The in-memory getChangesAfter2 emulation, for backends without a native change feed: a full listing filtered down to files written after config.time whose keys route into config.routes. */
|
|
35
|
+
export function filterChanges(files: ArchiveFileInfo[], config: ChangesAfterConfig): ArchiveFileInfo[] {
|
|
36
|
+
return files.filter(file => {
|
|
37
|
+
if (file.createTime <= config.time) return false;
|
|
38
|
+
let routes = config.routes;
|
|
39
|
+
if (routes && !routes.some(route => routeContains(route, getRoute(file.path)))) return false;
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
// Route ranges are [start, end) - inclusive start, exclusive end
|
|
35
45
|
export function routeContains(route: [number, number] | undefined, value: number): boolean {
|
|
36
46
|
if (!route) return true;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class SourcesList {
|
|
2
|
+
private filePath;
|
|
3
|
+
constructor(filePath: string);
|
|
4
|
+
private urls;
|
|
5
|
+
private indexes;
|
|
6
|
+
private endsClean;
|
|
7
|
+
private lastReload;
|
|
8
|
+
private appendQueue;
|
|
9
|
+
private load;
|
|
10
|
+
getUrl(sourcesListIndex: number): string | undefined;
|
|
11
|
+
/** For a sourcesListIndex beyond our in-memory list (another process appended since we read): re-reads the file, at most once per RELOAD_THROTTLE. Returning undefined can therefore mean "throttled", not "definitely absent" - never treat it as proof the index is bogus. */
|
|
12
|
+
getUrlReloading(sourcesListIndex: number): Promise<string | undefined>;
|
|
13
|
+
/** The sourcesListIndex of the url, appending it if it is new. Appends are serialized within this process; before each append the file is re-read, so appends by other processes are picked up instead of duplicated. */
|
|
14
|
+
ensure(url: string): Promise<number>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
|
|
3
|
+
const RELOAD_THROTTLE = 5 * 1000;
|
|
4
|
+
|
|
5
|
+
// The persistent identity behind IndexEntry.sourcesListIndex: an append-only file of source URLs / disk folder paths, one per line, where a URL's 0-based line number IS the sourcesListIndex the index persists. Because the file is only ever appended to, a line number means the same URL forever - across restarts and across processes sharing the folder. A torn append (crash mid-write) leaves an unterminated partial line; the next writer terminates it before appending, so the garbage line permanently occupies its index (harmless - it never matches a real URL) and every reader agrees on the numbering.
|
|
6
|
+
export class SourcesList {
|
|
7
|
+
constructor(private filePath: string) { }
|
|
8
|
+
private urls: string[] = [];
|
|
9
|
+
private indexes = new Map<string, number>();
|
|
10
|
+
private endsClean = true;
|
|
11
|
+
private lastReload = 0;
|
|
12
|
+
private appendQueue = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
private async load(): Promise<void> {
|
|
15
|
+
let content = "";
|
|
16
|
+
try {
|
|
17
|
+
content = await fs.promises.readFile(this.filePath, "utf8");
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if ((e as { code?: string }).code !== "ENOENT") throw e;
|
|
20
|
+
}
|
|
21
|
+
this.endsClean = !content || content.endsWith("\n");
|
|
22
|
+
let lines = content.split("\n");
|
|
23
|
+
if (lines[lines.length - 1] === "") {
|
|
24
|
+
lines.pop();
|
|
25
|
+
}
|
|
26
|
+
this.urls = lines;
|
|
27
|
+
this.indexes.clear();
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
if (!this.indexes.has(lines[i])) {
|
|
30
|
+
this.indexes.set(lines[i], i);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public getUrl(sourcesListIndex: number): string | undefined {
|
|
36
|
+
return this.urls[sourcesListIndex];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** For a sourcesListIndex beyond our in-memory list (another process appended since we read): re-reads the file, at most once per RELOAD_THROTTLE. Returning undefined can therefore mean "throttled", not "definitely absent" - never treat it as proof the index is bogus. */
|
|
40
|
+
public async getUrlReloading(sourcesListIndex: number): Promise<string | undefined> {
|
|
41
|
+
if (sourcesListIndex < this.urls.length) return this.urls[sourcesListIndex];
|
|
42
|
+
if (Date.now() - this.lastReload < RELOAD_THROTTLE) return undefined;
|
|
43
|
+
this.lastReload = Date.now();
|
|
44
|
+
await this.load();
|
|
45
|
+
return this.urls[sourcesListIndex];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The sourcesListIndex of the url, appending it if it is new. Appends are serialized within this process; before each append the file is re-read, so appends by other processes are picked up instead of duplicated. */
|
|
49
|
+
public ensure(url: string): Promise<number> {
|
|
50
|
+
if (url.includes("\n")) {
|
|
51
|
+
throw new Error(`Source URLs cannot contain newlines (they are stored one per line): ${JSON.stringify(url)}`);
|
|
52
|
+
}
|
|
53
|
+
let result = this.appendQueue.then(async () => {
|
|
54
|
+
await this.load();
|
|
55
|
+
let existing = this.indexes.get(url);
|
|
56
|
+
if (existing !== undefined) return existing;
|
|
57
|
+
let prefix = this.endsClean && "" || "\n";
|
|
58
|
+
await fs.promises.appendFile(this.filePath, prefix + url + "\n");
|
|
59
|
+
this.endsClean = true;
|
|
60
|
+
let index = this.urls.length;
|
|
61
|
+
this.urls.push(url);
|
|
62
|
+
this.indexes.set(url, index);
|
|
63
|
+
console.log(`Registered new source ${JSON.stringify(url)} as sourcesListIndex ${index} in ${this.filePath}`);
|
|
64
|
+
return index;
|
|
65
|
+
});
|
|
66
|
+
this.appendQueue = result.then(() => { }, () => { });
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
|
|
3
|
+
import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig } from "../IArchives";
|
|
4
4
|
import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
|
|
5
5
|
export declare const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
|
|
6
6
|
export declare const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
|
|
@@ -70,7 +70,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
70
70
|
writeTime: number;
|
|
71
71
|
size: number;
|
|
72
72
|
} | undefined>;
|
|
73
|
-
set: (account: string, bucketName: string, path: string, data: Buffer, lastModified?: number) => Promise<void>;
|
|
73
|
+
set: (account: string, bucketName: string, path: string, data: Buffer, lastModified?: number, forceSetImmutable?: boolean) => Promise<void>;
|
|
74
74
|
del: (account: string, bucketName: string, path: string) => Promise<void>;
|
|
75
75
|
getInfo: (account: string, bucketName: string, path: string) => Promise<{
|
|
76
76
|
writeTime: number;
|
|
@@ -80,7 +80,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
80
80
|
shallow?: boolean;
|
|
81
81
|
type?: "files" | "folders";
|
|
82
82
|
}) => Promise<ArchiveFileInfo[]>;
|
|
83
|
-
|
|
83
|
+
getChangesAfter2: (account: string, bucketName: string, config: ChangesAfterConfig) => Promise<ArchiveFileInfo[]>;
|
|
84
84
|
getArchivesConfig: (account: string, bucketName: string) => Promise<ArchivesConfig>;
|
|
85
85
|
listBuckets: (account: string) => Promise<ServerBucketInfo[]>;
|
|
86
86
|
getActiveBucket: (account: string, bucketName: string) => Promise<ActiveBucketInfo | string>;
|
|
@@ -98,7 +98,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
98
98
|
}[];
|
|
99
99
|
} | undefined>;
|
|
100
100
|
getSyncStatus: (account: string, bucketName: string) => Promise<ArchivesSyncStatus>;
|
|
101
|
-
startLargeFile: (account: string, bucketName: string, path: string) => Promise<string>;
|
|
101
|
+
startLargeFile: (account: string, bucketName: string, path: string, lastModified?: number) => Promise<string>;
|
|
102
102
|
uploadPart: (uploadId: string, data: Buffer) => Promise<void>;
|
|
103
103
|
finishLargeFile: (uploadId: string) => Promise<void>;
|
|
104
104
|
cancelLargeFile: (uploadId: string) => Promise<void>;
|
|
@@ -6,7 +6,7 @@ import { performLocalCall } from "socket-function/src/callManager";
|
|
|
6
6
|
import { RequireController } from "socket-function/require/RequireController";
|
|
7
7
|
import { timeInMinute } from "socket-function/src/misc";
|
|
8
8
|
import { getCommonName, getPublicIdentifier, getOwnMachineId, verify, verifyMachineIdForPublicKey } from "../../misc/https/certs";
|
|
9
|
-
import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, IMMUTABLE_CACHE_TIME } from "../IArchives";
|
|
9
|
+
import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, IMMUTABLE_CACHE_TIME } from "../IArchives";
|
|
10
10
|
import { ROUTING_FILE } from "./remoteConfig";
|
|
11
11
|
import {
|
|
12
12
|
getStorageServerConfig, getTrust, getRequests, getLoadedBucket, writeBucketFile,
|
|
@@ -294,10 +294,10 @@ class RemoteStorageControllerBase {
|
|
|
294
294
|
if (!bucket) return undefined;
|
|
295
295
|
return await bucket.store.get2(path, { range });
|
|
296
296
|
}
|
|
297
|
-
async set(account: string, bucketName: string, path: string, data: Buffer, lastModified?: number): Promise<void> {
|
|
297
|
+
async set(account: string, bucketName: string, path: string, data: Buffer, lastModified?: number, forceSetImmutable?: boolean): Promise<void> {
|
|
298
298
|
assertValidName(bucketName, "bucket name");
|
|
299
299
|
assertValidPath(path);
|
|
300
|
-
await writeBucketFile(account, bucketName, path, Buffer.from(data), { lastModified });
|
|
300
|
+
await writeBucketFile(account, bucketName, path, Buffer.from(data), { lastModified, forceSetImmutable });
|
|
301
301
|
}
|
|
302
302
|
async del(account: string, bucketName: string, path: string): Promise<void> {
|
|
303
303
|
assertValidName(bucketName, "bucket name");
|
|
@@ -315,13 +315,10 @@ class RemoteStorageControllerBase {
|
|
|
315
315
|
if (!bucket) return [];
|
|
316
316
|
return await bucket.store.findInfo(prefix, config);
|
|
317
317
|
}
|
|
318
|
-
async
|
|
318
|
+
async getChangesAfter2(account: string, bucketName: string, config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
319
319
|
let bucket = await getBucket(account, bucketName);
|
|
320
320
|
if (!bucket) return [];
|
|
321
|
-
|
|
322
|
-
throw new Error(`Bucket ${account}/${bucketName} does not support getChangesAfter (rawDisk buckets have no index)`);
|
|
323
|
-
}
|
|
324
|
-
return await bucket.store.getChangesAfter(time);
|
|
321
|
+
return await bucket.store.getChangesAfter2(config);
|
|
325
322
|
}
|
|
326
323
|
async getArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
|
|
327
324
|
let bucket = await getBucket(account, bucketName);
|
|
@@ -366,16 +363,16 @@ class RemoteStorageControllerBase {
|
|
|
366
363
|
return await bucket.store.getSyncStatus();
|
|
367
364
|
}
|
|
368
365
|
|
|
369
|
-
async startLargeFile(account: string, bucketName: string, path: string): Promise<string> {
|
|
366
|
+
async startLargeFile(account: string, bucketName: string, path: string, lastModified?: number): Promise<string> {
|
|
370
367
|
assertWritesAllowed();
|
|
371
368
|
assertValidPath(path);
|
|
372
369
|
let bucket = await getBucket(account, bucketName);
|
|
373
370
|
if (!bucket) {
|
|
374
371
|
throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
375
372
|
}
|
|
376
|
-
await assertMutable(bucket, path, Date.now());
|
|
373
|
+
await assertMutable(bucket, path, lastModified || Date.now());
|
|
377
374
|
let id = await bucket.store.startLargeUpload();
|
|
378
|
-
largeUploadInfo.set(id, { account, bucketName, path });
|
|
375
|
+
largeUploadInfo.set(id, { account, bucketName, path, lastModified });
|
|
379
376
|
return id;
|
|
380
377
|
}
|
|
381
378
|
async uploadPart(uploadId: string, data: Buffer): Promise<void> {
|
|
@@ -393,7 +390,7 @@ class RemoteStorageControllerBase {
|
|
|
393
390
|
largeUploadInfo.delete(uploadId);
|
|
394
391
|
let bucket = await getBucket(info.account, info.bucketName);
|
|
395
392
|
if (!bucket) throw new Error(`Bucket ${info.account}/${info.bucketName} no longer exists`);
|
|
396
|
-
await bucket.store.finishLargeUpload(uploadId, info.path);
|
|
393
|
+
await bucket.store.finishLargeUpload(uploadId, info.path, info.lastModified);
|
|
397
394
|
}
|
|
398
395
|
async cancelLargeFile(uploadId: string): Promise<void> {
|
|
399
396
|
let info = largeUploadInfo.get(uploadId);
|
|
@@ -485,7 +482,7 @@ class RemoteStorageControllerBase {
|
|
|
485
482
|
}
|
|
486
483
|
}
|
|
487
484
|
|
|
488
|
-
const largeUploadInfo = new Map<string, { account: string; bucketName: string; path: string }>();
|
|
485
|
+
const largeUploadInfo = new Map<string, { account: string; bucketName: string; path: string; lastModified?: number }>();
|
|
489
486
|
|
|
490
487
|
const accountAccess: SocketFunctionHook = async (context) => {
|
|
491
488
|
let start = Date.now();
|
|
@@ -523,7 +520,7 @@ export const RemoteStorageController = SocketFunction.register(
|
|
|
523
520
|
del: { hooks: [accountAccess] },
|
|
524
521
|
getInfo: { hooks: [accountAccess] },
|
|
525
522
|
findInfo: { hooks: [accountAccess] },
|
|
526
|
-
|
|
523
|
+
getChangesAfter2: { hooks: [accountAccess] },
|
|
527
524
|
getArchivesConfig: { hooks: [accountAccess] },
|
|
528
525
|
getIndexInfo: { hooks: [accountAccess] },
|
|
529
526
|
listBuckets: { hooks: [accountAccess] },
|
|
@@ -46,10 +46,13 @@ export type LoadedBucket = {
|
|
|
46
46
|
};
|
|
47
47
|
export declare function addExtraListenPort(port: number): void;
|
|
48
48
|
export declare function removeExtraListenPort(port: number): void;
|
|
49
|
+
/** A cached IArchives for a persisted source identity: a routing URL (hosted/backblaze) or a disk folder path - the form BlobStore's sources list stores. Configuration (valid windows, routes) decides WHEN a source should be used; for reading bytes the index says a source holds, the URL alone is enough - even for sources no longer in any config. */
|
|
50
|
+
export declare function resolveSourceArchives(url: string): IArchives;
|
|
49
51
|
export declare function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined>;
|
|
50
52
|
export declare function assertMutable(bucket: LoadedBucket, filePath: string, writeTime: number): Promise<void>;
|
|
51
53
|
export declare function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: {
|
|
52
54
|
lastModified?: number;
|
|
55
|
+
forceSetImmutable?: boolean;
|
|
53
56
|
}): Promise<void>;
|
|
54
57
|
export declare function getBucketConfig(bucket: LoadedBucket): ArchivesConfig;
|
|
55
58
|
/** Which buckets this process currently has loaded - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
|
|
@@ -9,10 +9,10 @@ import { ArchivesDisk } from "../ArchivesDisk";
|
|
|
9
9
|
import { BlobStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
|
|
10
10
|
import {
|
|
11
11
|
RemoteConfig, HostedConfig, BackblazeConfig, IArchives, ArchivesSource, ArchiveFileInfo, ArchivesConfig,
|
|
12
|
-
ArchivesSyncStatus,
|
|
12
|
+
ArchivesSyncStatus, ChangesAfterConfig, SetConfig,
|
|
13
13
|
STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, FULL_ROUTE,
|
|
14
14
|
} from "../IArchives";
|
|
15
|
-
import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection } from "./remoteConfig";
|
|
15
|
+
import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection, normalizeSource } from "./remoteConfig";
|
|
16
16
|
import { injectIntermediateSource, expireIntermediateSources, getIntermediateSources, findSplitUrl, nextIntermediateVersion, INTERMEDIATE_EXPIRE_GRACE } from "./intermediateSources";
|
|
17
17
|
import { getTakeoverIntermediate } from "./deployTakeover";
|
|
18
18
|
import { createApiArchives, listServerActiveBucketKeys } from "./createArchives";
|
|
@@ -424,6 +424,21 @@ type StorePlan = {
|
|
|
424
424
|
structureKey: string;
|
|
425
425
|
};
|
|
426
426
|
|
|
427
|
+
const resolvedSourceArchives = new Map<string, IArchives>();
|
|
428
|
+
/** A cached IArchives for a persisted source identity: a routing URL (hosted/backblaze) or a disk folder path - the form BlobStore's sources list stores. Configuration (valid windows, routes) decides WHEN a source should be used; for reading bytes the index says a source holds, the URL alone is enough - even for sources no longer in any config. */
|
|
429
|
+
export function resolveSourceArchives(url: string): IArchives {
|
|
430
|
+
let existing = resolvedSourceArchives.get(url);
|
|
431
|
+
if (existing) return existing;
|
|
432
|
+
let archives: IArchives;
|
|
433
|
+
if (url.startsWith("https://")) {
|
|
434
|
+
archives = createApiArchives(normalizeSource(url));
|
|
435
|
+
} else {
|
|
436
|
+
archives = new ArchivesDisk(url);
|
|
437
|
+
}
|
|
438
|
+
resolvedSourceArchives.set(url, archives);
|
|
439
|
+
return archives;
|
|
440
|
+
}
|
|
441
|
+
|
|
427
442
|
function sourceIdentity(sourceConfig: HostedConfig | BackblazeConfig | undefined): string {
|
|
428
443
|
if (!sourceConfig) return "disk";
|
|
429
444
|
return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
|
|
@@ -491,6 +506,7 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig,
|
|
|
491
506
|
}
|
|
492
507
|
let sources: ArchivesSource[] = plan.sourceSpecs.map(spec => ({
|
|
493
508
|
source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
|
|
509
|
+
url: spec.sourceConfig?.url || folder,
|
|
494
510
|
validWindow: spec.validWindow,
|
|
495
511
|
route: spec.route,
|
|
496
512
|
noFullSync: spec.noFullSync,
|
|
@@ -503,6 +519,7 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig,
|
|
|
503
519
|
},
|
|
504
520
|
readerDiskLimit: plan.readerDiskLimit,
|
|
505
521
|
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
522
|
+
resolveSourceUrl: resolveSourceArchives,
|
|
506
523
|
});
|
|
507
524
|
}
|
|
508
525
|
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
|
|
@@ -584,10 +601,11 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
584
601
|
loaded.store.updateSources(plan.sourceSpecs.map(spec => {
|
|
585
602
|
const sourceConfig = spec.sourceConfig;
|
|
586
603
|
if (!sourceConfig) {
|
|
587
|
-
return { identity: sourceIdentity(undefined), validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(getBucketFolder(account, bucketName)) };
|
|
604
|
+
return { identity: sourceIdentity(undefined), url: getBucketFolder(account, bucketName), validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(getBucketFolder(account, bucketName)) };
|
|
588
605
|
}
|
|
589
606
|
return {
|
|
590
607
|
identity: sourceIdentity(sourceConfig),
|
|
608
|
+
url: sourceConfig.url,
|
|
591
609
|
validWindow: spec.validWindow,
|
|
592
610
|
route: spec.route,
|
|
593
611
|
noFullSync: spec.noFullSync,
|
|
@@ -666,7 +684,7 @@ async function writeRoutingConfig(account: string, bucketName: string, data: Buf
|
|
|
666
684
|
await scheduleRoutingReload(account, bucketName);
|
|
667
685
|
}
|
|
668
686
|
|
|
669
|
-
export async function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
687
|
+
export async function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: { lastModified?: number; forceSetImmutable?: boolean }): Promise<void> {
|
|
670
688
|
assertWritesAllowed();
|
|
671
689
|
if (filePath === ROUTING_FILE) {
|
|
672
690
|
let key = `${account}/${bucketName}`;
|
|
@@ -691,14 +709,24 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
691
709
|
throw new Error(`${STORAGE_WRONG_ROUTE} This server does not handle route ${route} (key ${JSON.stringify(filePath)}) for bucket ${account}/${bucketName} (our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}). Re-resolve the source for this key and retry.`);
|
|
692
710
|
}
|
|
693
711
|
}
|
|
694
|
-
|
|
712
|
+
if (config?.forceSetImmutable) {
|
|
713
|
+
if (!config.lastModified) {
|
|
714
|
+
throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}`);
|
|
715
|
+
}
|
|
716
|
+
// Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
|
|
717
|
+
let self = selectEntryAt(loaded.selfEntries, writeTime, route);
|
|
718
|
+
if (self?.immutable && await loaded.store.getInfo(filePath)) return;
|
|
719
|
+
} else {
|
|
720
|
+
await assertMutable(loaded, filePath, writeTime);
|
|
721
|
+
}
|
|
695
722
|
await loaded.store.set(filePath, data, { ...getWriteConfig(loaded, writeTime, route), lastModified: writeTime });
|
|
696
723
|
}
|
|
697
724
|
|
|
698
725
|
export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
|
|
699
726
|
let progress = bucket.store.getSyncProgress?.();
|
|
700
727
|
return {
|
|
701
|
-
|
|
728
|
+
// Native change-feed support = the store keeps an index (rawDisk buckets emulate getChangesAfter2 with a full listing) - clients use this to pick their scan cadence
|
|
729
|
+
supportsChangesAfter: bucket.store instanceof BlobStore,
|
|
702
730
|
remoteConfig: bucket.routing,
|
|
703
731
|
index: progress?.index,
|
|
704
732
|
indexSources: progress?.sources,
|
|
@@ -1033,20 +1061,20 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
1033
1061
|
if (!bucket) return undefined;
|
|
1034
1062
|
return await bucket.store.get2(fileName, config);
|
|
1035
1063
|
}
|
|
1036
|
-
public async set(fileName: string, data: Buffer, config?:
|
|
1064
|
+
public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
|
|
1037
1065
|
await writeBucketFile(this.account, this.bucketName, fileName, data, config);
|
|
1038
1066
|
return fileName;
|
|
1039
1067
|
}
|
|
1040
1068
|
public async del(fileName: string): Promise<void> {
|
|
1041
1069
|
await deleteBucketFile(this.account, this.bucketName, fileName);
|
|
1042
1070
|
}
|
|
1043
|
-
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
1071
|
+
public async setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
1044
1072
|
assertWritesAllowed();
|
|
1045
1073
|
let bucket = await this.getBucket();
|
|
1046
1074
|
if (!bucket) {
|
|
1047
1075
|
throw new Error(`Bucket ${this.account}/${this.bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
1048
1076
|
}
|
|
1049
|
-
await assertMutable(bucket, config.path, Date.now());
|
|
1077
|
+
await assertMutable(bucket, config.path, config.lastModified || Date.now());
|
|
1050
1078
|
let id = await bucket.store.startLargeUpload();
|
|
1051
1079
|
try {
|
|
1052
1080
|
while (true) {
|
|
@@ -1056,7 +1084,7 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
1056
1084
|
await bucket.store.appendLargeUpload(id, data.subarray(offset, offset + LARGE_FILE_PART_SIZE));
|
|
1057
1085
|
}
|
|
1058
1086
|
}
|
|
1059
|
-
await bucket.store.finishLargeUpload(id, config.path);
|
|
1087
|
+
await bucket.store.finishLargeUpload(id, config.path, config.lastModified);
|
|
1060
1088
|
} catch (e) {
|
|
1061
1089
|
await bucket.store.cancelLargeUpload(id);
|
|
1062
1090
|
throw e;
|
|
@@ -1087,13 +1115,10 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
1087
1115
|
public async hasWriteAccess(): Promise<boolean> {
|
|
1088
1116
|
return true;
|
|
1089
1117
|
}
|
|
1090
|
-
public async
|
|
1118
|
+
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
1091
1119
|
let bucket = await this.getBucket();
|
|
1092
1120
|
if (!bucket) return [];
|
|
1093
|
-
|
|
1094
|
-
throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getChangesAfter (rawDisk buckets have no index)`);
|
|
1095
|
-
}
|
|
1096
|
-
return await bucket.store.getChangesAfter(time);
|
|
1121
|
+
return await bucket.store.getChangesAfter2(config);
|
|
1097
1122
|
}
|
|
1098
1123
|
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
1099
1124
|
let bucket = await this.getBucket();
|