sliftutils 1.7.8 → 1.7.10

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 (43) hide show
  1. package/bin/storageserver.js +4 -0
  2. package/bundler/bundleEntry.ts +14 -10
  3. package/bundler/bundler.ts +6 -1
  4. package/{teststorage → examplestorage}/browser.tsx +18 -22
  5. package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
  6. package/index.d.ts +528 -78
  7. package/misc/https/dns.d.ts +7 -3
  8. package/misc/https/dns.ts +4 -9
  9. package/misc/https/hostServer.d.ts +6 -3
  10. package/misc/https/hostServer.ts +6 -6
  11. package/package.json +2 -1
  12. package/spec.txt +77 -42
  13. package/storage/ArchivesDisk.d.ts +65 -0
  14. package/storage/ArchivesDisk.ts +365 -0
  15. package/storage/IArchives.d.ts +95 -1
  16. package/storage/IArchives.ts +146 -1
  17. package/storage/backblaze.d.ts +14 -2
  18. package/storage/backblaze.ts +18 -2
  19. package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
  20. package/storage/remoteStorage/ArchivesRemote.ts +63 -63
  21. package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
  22. package/storage/remoteStorage/ArchivesUrl.ts +74 -0
  23. package/storage/remoteStorage/accessPage.tsx +111 -28
  24. package/storage/remoteStorage/blobStore.d.ts +79 -25
  25. package/storage/remoteStorage/blobStore.ts +441 -287
  26. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  27. package/storage/remoteStorage/cliArgs.ts +9 -0
  28. package/storage/remoteStorage/createArchives.d.ts +64 -0
  29. package/storage/remoteStorage/createArchives.ts +395 -0
  30. package/storage/remoteStorage/grantAccess.js +4 -0
  31. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  32. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  33. package/storage/remoteStorage/remoteConfig.d.ts +21 -0
  34. package/storage/remoteStorage/remoteConfig.ts +94 -0
  35. package/storage/remoteStorage/storageController.d.ts +19 -27
  36. package/storage/remoteStorage/storageController.ts +205 -136
  37. package/storage/remoteStorage/storageServer.d.ts +11 -0
  38. package/storage/remoteStorage/storageServer.ts +80 -93
  39. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  40. package/storage/remoteStorage/storageServerCli.ts +41 -0
  41. package/storage/remoteStorage/storageServerState.d.ts +37 -0
  42. package/storage/remoteStorage/storageServerState.ts +358 -0
  43. package/testsite/server.ts +1 -1
@@ -0,0 +1,365 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { lazy } from "socket-function/src/caching";
4
+ import { runInfinitePoll } from "socket-function/src/batching";
5
+ import { sort, binarySearchBasic } from "socket-function/src/misc";
6
+ import { IArchives, ArchiveFileInfo, ArchivesConfig, assertValidLastModified } from "./IArchives";
7
+
8
+ // The base file-system IArchives: storage is one-to-one with the file system, every key is exactly
9
+ // one real file under <folder>/files, so the file system itself is the index. File handles are
10
+ // cached and reused, and closed once idle (see FileHandleCache). All operations on a file run in
11
+ // serial, so they can't collide with each other or with handle closing. Used as the disk
12
+ // synchronization source of BlobStore (see remoteStorage/blobStore.ts).
13
+
14
+ const HANDLE_IDLE_TIMEOUT = 1000 * 60;
15
+ const HANDLE_SWEEP_INTERVAL = 1000 * 15;
16
+
17
+ type HandleEntry = {
18
+ filePath: string;
19
+ handle: fs.promises.FileHandle;
20
+ lastUse: number;
21
+ };
22
+
23
+ // Caches open file handles, closing them once idle for HANDLE_IDLE_TIMEOUT. Instead of one
24
+ // setTimeout per handle, a list sorted by last use is swept periodically (entries are moved via
25
+ // binary search on access, and lastUse values only increase, so touched entries append at the
26
+ // end). Also serializes operations per file: each operation only starts once the previous one on
27
+ // the same file finished, and a handle is never closed while an operation is pending on it.
28
+ class FileHandleCache {
29
+ private entries = new Map<string, HandleEntry>();
30
+ // Sorted by lastUse ascending (least recently used first)
31
+ private lru: HandleEntry[] = [];
32
+ private pending = new Map<string, Promise<void>>();
33
+
34
+ constructor() {
35
+ runInfinitePoll(HANDLE_SWEEP_INTERVAL, () => this.sweep());
36
+ }
37
+
38
+ // Runs fnc after every previously scheduled operation on filePath has finished
39
+ public run<T>(filePath: string, fnc: () => Promise<T>): Promise<T> {
40
+ let prev = this.pending.get(filePath) || Promise.resolve();
41
+ let result = prev.then(fnc);
42
+ // The pending chain must never reject, or one failed operation would poison all later ones
43
+ let last = result.then(() => { }, () => { });
44
+ this.pending.set(filePath, last);
45
+ void last.then(() => {
46
+ // If we're still the last pending operation on this file, clear ourselves
47
+ if (this.pending.get(filePath) === last) {
48
+ this.pending.delete(filePath);
49
+ }
50
+ });
51
+ return result;
52
+ }
53
+
54
+ // Only call inside run() for the same filePath (so opens can't collide with closes)
55
+ public async getHandle(filePath: string, flags: number): Promise<fs.promises.FileHandle> {
56
+ let entry = this.entries.get(filePath);
57
+ if (entry) {
58
+ this.removeFromLRU(entry);
59
+ entry.lastUse = Date.now();
60
+ this.lru.push(entry);
61
+ return entry.handle;
62
+ }
63
+ let handle = await fs.promises.open(filePath, flags);
64
+ entry = { filePath, handle, lastUse: Date.now() };
65
+ this.entries.set(filePath, entry);
66
+ this.lru.push(entry);
67
+ return handle;
68
+ }
69
+
70
+ // Only call inside run() for the same filePath
71
+ public async closeNow(filePath: string): Promise<void> {
72
+ let entry = this.entries.get(filePath);
73
+ if (!entry) return;
74
+ this.entries.delete(filePath);
75
+ this.removeFromLRU(entry);
76
+ await entry.handle.close();
77
+ }
78
+
79
+ private removeFromLRU(entry: HandleEntry) {
80
+ let index = binarySearchBasic(this.lru, x => x.lastUse, entry.lastUse);
81
+ if (index < 0) return;
82
+ // Multiple entries can share a lastUse, so scan for the exact one
83
+ while (index < this.lru.length && this.lru[index].lastUse === entry.lastUse) {
84
+ if (this.lru[index] === entry) {
85
+ this.lru.splice(index, 1);
86
+ return;
87
+ }
88
+ index++;
89
+ }
90
+ }
91
+
92
+ private async sweep(): Promise<void> {
93
+ let cutoff = Date.now() - HANDLE_IDLE_TIMEOUT;
94
+ let index = 0;
95
+ while (index < this.lru.length && this.lru[index].lastUse <= cutoff) {
96
+ let entry = this.lru[index];
97
+ // Can't close a handle with a pending operation; it'll be swept after it finishes
98
+ if (this.pending.has(entry.filePath)) {
99
+ index++;
100
+ continue;
101
+ }
102
+ this.lru.splice(index, 1);
103
+ this.entries.delete(entry.filePath);
104
+ await entry.handle.close();
105
+ }
106
+ }
107
+ }
108
+
109
+ export class ArchivesDisk implements IArchives {
110
+ constructor(private folder: string) { }
111
+
112
+ private filesDir = path.join(this.folder, "files");
113
+ private uploadsDir = path.join(this.folder, "uploads");
114
+ private handles = new FileHandleCache();
115
+ private largeUploads = new Map<string, { tmpPath: string }>();
116
+ private nextLargeUploadId = 1;
117
+
118
+ public init = lazy(async () => {
119
+ await fs.promises.mkdir(this.filesDir, { recursive: true });
120
+ await fs.promises.mkdir(this.uploadsDir, { recursive: true });
121
+ // Uploads don't survive restarts (the uploader streams into them), so old ones are garbage
122
+ for (let file of await fs.promises.readdir(this.uploadsDir)) {
123
+ await fs.promises.unlink(path.join(this.uploadsDir, file));
124
+ }
125
+ });
126
+
127
+ public getDebugName() {
128
+ return `disk/${this.folder}`;
129
+ }
130
+
131
+ public async getConfig(): Promise<ArchivesConfig> {
132
+ return {};
133
+ }
134
+
135
+ private filePath(key: string): string {
136
+ let result = path.join(this.filesDir, key);
137
+ if (!result.startsWith(this.filesDir + path.sep)) {
138
+ throw new Error(`Invalid key ${JSON.stringify(key.slice(0, 200))}, it escapes the store folder`);
139
+ }
140
+ return result;
141
+ }
142
+
143
+ public async set(key: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
144
+ await this.init();
145
+ let lastModified = config?.lastModified;
146
+ if (lastModified) {
147
+ assertValidLastModified(lastModified);
148
+ }
149
+ let filePath = this.filePath(key);
150
+ await this.handles.run(filePath, async () => {
151
+ if (lastModified) {
152
+ let existing = await statOrUndefined(filePath);
153
+ // An older write never overwrites a newer one (see IArchives.set)
154
+ if (existing && lastModified < existing.mtimeMs) return;
155
+ }
156
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
157
+ let handle = await this.handles.getHandle(filePath, fs.constants.O_RDWR | fs.constants.O_CREAT);
158
+ await handle.truncate(0);
159
+ await handle.write(data, 0, data.length, 0);
160
+ if (lastModified) {
161
+ await handle.utimes(new Date(lastModified), new Date(lastModified));
162
+ }
163
+ });
164
+ }
165
+
166
+ public async del(key: string): Promise<void> {
167
+ await this.init();
168
+ let filePath = this.filePath(key);
169
+ await this.handles.run(filePath, async () => {
170
+ await this.handles.closeNow(filePath);
171
+ try {
172
+ await fs.promises.unlink(filePath);
173
+ } catch (e: any) {
174
+ if (e.code !== "ENOENT") throw e;
175
+ }
176
+ });
177
+ }
178
+
179
+ public async get(key: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
180
+ let result = await this.get2(key, config);
181
+ return result && result.data || undefined;
182
+ }
183
+
184
+ public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
185
+ await this.init();
186
+ let range = config?.range;
187
+ let filePath = this.filePath(key);
188
+ return await this.handles.run(filePath, async () => {
189
+ let handle: fs.promises.FileHandle;
190
+ try {
191
+ handle = await this.handles.getHandle(filePath, fs.constants.O_RDWR);
192
+ } catch (e: any) {
193
+ if (e.code === "ENOENT") return undefined;
194
+ throw e;
195
+ }
196
+ let stats = await handle.stat();
197
+ let size = stats.size;
198
+ let start = range && Math.min(range.start, size) || 0;
199
+ let end = range && Math.min(range.end, size) || size;
200
+ if (end <= start) return { data: Buffer.alloc(0), writeTime: stats.mtimeMs };
201
+ let buffer = Buffer.alloc(end - start);
202
+ let { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
203
+ if (bytesRead !== buffer.length) {
204
+ throw new Error(`Expected ${buffer.length} bytes at ${filePath}:${start}, read ${bytesRead}`);
205
+ }
206
+ return { data: buffer, writeTime: stats.mtimeMs };
207
+ });
208
+ }
209
+
210
+ public async getInfo(key: string): Promise<{ writeTime: number; size: number } | undefined> {
211
+ await this.init();
212
+ let filePath = this.filePath(key);
213
+ return await this.handles.run(filePath, async () => {
214
+ let stats = await statOrUndefined(filePath);
215
+ if (!stats || !stats.isFile()) return undefined;
216
+ return { writeTime: stats.mtimeMs, size: stats.size };
217
+ });
218
+ }
219
+
220
+ public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
221
+ return (await this.findInfo(prefix, config)).map(x => x.path);
222
+ }
223
+
224
+ public async findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
225
+ await this.init();
226
+ let infos = new Map<string, ArchiveFileInfo>();
227
+ await this.collectFiles("", prefix, infos);
228
+ let files = Array.from(infos.values());
229
+ files = applyFindInfoShape(files, prefix, config);
230
+ sort(files, x => x.path);
231
+ return files;
232
+ }
233
+
234
+ // relDir is "" or ends with "/". Only descends into directories that can still match the prefix.
235
+ private async collectFiles(relDir: string, prefix: string, infos: Map<string, ArchiveFileInfo>): Promise<void> {
236
+ let entries: fs.Dirent[];
237
+ try {
238
+ entries = await fs.promises.readdir(path.join(this.filesDir, relDir), { withFileTypes: true });
239
+ } catch (e: any) {
240
+ if (e.code === "ENOENT") return;
241
+ throw e;
242
+ }
243
+ for (let entry of entries) {
244
+ let relPath = relDir + entry.name;
245
+ if (entry.isDirectory()) {
246
+ let dirPath = relPath + "/";
247
+ if (dirPath.startsWith(prefix) || prefix.startsWith(dirPath)) {
248
+ await this.collectFiles(dirPath, prefix, infos);
249
+ }
250
+ continue;
251
+ }
252
+ if (!entry.isFile()) continue;
253
+ if (!relPath.startsWith(prefix)) continue;
254
+ let stats = await statOrUndefined(path.join(this.filesDir, relPath));
255
+ // Deleted while we were walking
256
+ if (!stats) continue;
257
+ infos.set(relPath, { path: relPath, createTime: stats.mtimeMs, size: stats.size });
258
+ }
259
+ }
260
+
261
+ public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
262
+ let id = await this.startLargeUpload();
263
+ try {
264
+ while (true) {
265
+ let data = await config.getNextData();
266
+ if (!data) break;
267
+ await this.appendLargeUpload(id, data);
268
+ }
269
+ await this.finishLargeUpload(id, config.path);
270
+ } catch (e) {
271
+ await this.cancelLargeUpload(id);
272
+ throw e;
273
+ }
274
+ }
275
+
276
+ // Large files stream into their own file under uploads/, then move into place on finish. No
277
+ // handle is opened until the first append actually happens.
278
+ public async startLargeUpload(): Promise<string> {
279
+ await this.init();
280
+ let id = `${Date.now()}_${this.nextLargeUploadId++}`;
281
+ this.largeUploads.set(id, { tmpPath: path.join(this.uploadsDir, `upload_${id}.tmp`) });
282
+ return id;
283
+ }
284
+ public async appendLargeUpload(id: string, data: Buffer): Promise<void> {
285
+ let upload = this.largeUploads.get(id);
286
+ if (!upload) throw new Error(`Unknown large upload ${id}`);
287
+ const tmpPath = upload.tmpPath;
288
+ await this.handles.run(tmpPath, async () => {
289
+ let handle = await this.handles.getHandle(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
290
+ await handle.write(data, 0, data.length);
291
+ });
292
+ }
293
+ public async finishLargeUpload(id: string, key: string): Promise<void> {
294
+ let upload = this.largeUploads.get(id);
295
+ if (!upload) throw new Error(`Unknown large upload ${id}`);
296
+ const tmpPath = upload.tmpPath;
297
+ this.largeUploads.delete(id);
298
+ let filePath = this.filePath(key);
299
+ await this.handles.run(tmpPath, () => this.handles.closeNow(tmpPath));
300
+ await this.handles.run(filePath, async () => {
301
+ // Close any cached handle to the file we're replacing, so later reads reopen the new file
302
+ await this.handles.closeNow(filePath);
303
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
304
+ try {
305
+ await fs.promises.rename(tmpPath, filePath);
306
+ } catch (e: any) {
307
+ if (e.code !== "ENOENT") throw e;
308
+ // Nothing was ever appended, so the upload file was never created
309
+ await fs.promises.writeFile(filePath, Buffer.alloc(0));
310
+ }
311
+ });
312
+ }
313
+ public async cancelLargeUpload(id: string): Promise<void> {
314
+ let upload = this.largeUploads.get(id);
315
+ if (!upload) return;
316
+ const tmpPath = upload.tmpPath;
317
+ this.largeUploads.delete(id);
318
+ await this.handles.run(tmpPath, async () => {
319
+ await this.handles.closeNow(tmpPath);
320
+ try {
321
+ await fs.promises.unlink(tmpPath);
322
+ } catch (e: any) {
323
+ if (e.code !== "ENOENT") throw e;
324
+ }
325
+ });
326
+ }
327
+
328
+ public async getURL(path: string): Promise<string> {
329
+ throw new Error(`getURL is not supported for disk archives (${this.getDebugName()}, path ${path})`);
330
+ }
331
+ }
332
+
333
+ async function statOrUndefined(filePath: string): Promise<fs.Stats | undefined> {
334
+ try {
335
+ return await fs.promises.stat(filePath);
336
+ } catch (e: any) {
337
+ if (e.code === "ENOENT") return undefined;
338
+ throw e;
339
+ }
340
+ }
341
+
342
+ // The folders/shallow post-processing shared by findInfo implementations that list flat files
343
+ // (used by ArchivesDisk on the raw disk walk, and by BlobStore on its index).
344
+ export function applyFindInfoShape(files: ArchiveFileInfo[], prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): ArchiveFileInfo[] {
345
+ if (config?.type === "folders") {
346
+ let folders = new Map<string, ArchiveFileInfo>();
347
+ for (let file of files) {
348
+ let rest = file.path.slice(prefix.length);
349
+ let restParts = rest.split("/");
350
+ if (restParts.length < 2) continue;
351
+ let folder: string;
352
+ if (config.shallow) {
353
+ folder = prefix + restParts[0];
354
+ } else {
355
+ folder = file.path.split("/").slice(0, -1).join("/");
356
+ }
357
+ folders.set(folder, { path: folder, createTime: file.createTime, size: file.size });
358
+ }
359
+ return Array.from(folders.values());
360
+ }
361
+ if (config?.shallow) {
362
+ return files.filter(file => !file.path.slice(prefix.length).includes("/"));
363
+ }
364
+ return files;
365
+ }
@@ -1,10 +1,77 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
+ export declare const MAX_LAST_MODIFIED_FUTURE: number;
4
+ export declare function assertValidLastModified(lastModified: number): void;
5
+ export type RemoteConfig = {
6
+ version?: number;
7
+ sources: RemoteConfigBase[];
8
+ };
9
+ /**
10
+ string arguments will be a url, looking like:
11
+ https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
12
+ https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
13
+ - These map to { url }, with the type inferred from the url
14
+ - Hosted urls are /file/<account>/<bucketName>/..., backblaze urls are /file/<bucketName>/...
15
+
16
+ NOTE: If we do not have right access to these, then it becomes a read-only IArchives, where we solely read using the url form (which might throw due to not having access as well). UNLESS Our configuration explicitly has public: false, in which case, we don't even hit the URL and we throw on access.
17
+
18
+ NOTE: If we're in the browser, we should allow downloading the files via the URL form (if it's a public bucket), however, we won't allow writing, because their servers do not allow secure browser writes.
19
+ */
20
+ export type RemoteConfigBase = string | HostedConfig | BackblazeConfig;
21
+ export type CommonConfig = {
22
+ /** The default options for the first config in a list is DEFAULT_BASE_SYNC_OPTIONS. The rest default to DEFAULT_SYNC_OPTIONS. */
23
+ syncOptions?: SyncOptions;
24
+ };
25
+ export type HostedConfig = CommonConfig & {
26
+ type: "remote";
27
+ url: string;
28
+ accountName?: string;
29
+ public?: boolean;
30
+ fast?: boolean;
31
+ writeDelay?: number;
32
+ rawDisk?: boolean;
33
+ immutable?: boolean;
34
+ };
35
+ export type BackblazeConfig = CommonConfig & {
36
+ type: "backblaze";
37
+ url: string;
38
+ bucketName: string;
39
+ public?: boolean;
40
+ immutable?: boolean;
41
+ };
42
+ export declare const DEFAULT_BASE_SYNC_OPTIONS: SyncOptions;
43
+ export declare const DEFAULT_SYNC_OPTIONS: SyncOptions;
3
44
  export type ArchiveFileInfo = {
4
45
  path: string;
5
46
  createTime: number;
6
47
  size: number;
7
48
  };
49
+ export type ArchivesConfig = {
50
+ supportsChangesAfter?: boolean;
51
+ };
52
+ export type SyncOptions = {
53
+ copyFiles?: boolean;
54
+ noWriteBack?: boolean;
55
+ cacheReads?: boolean;
56
+ validWindow: [number, number];
57
+ required?: boolean;
58
+ };
59
+ export type ArchivesSource = {
60
+ source: IArchives;
61
+ options: SyncOptions;
62
+ };
63
+ export type ArchivesSyncSourceStatus = {
64
+ debugName: string;
65
+ options: SyncOptions;
66
+ supportsChangesAfter: boolean;
67
+ initialScanComplete: boolean;
68
+ scannedCount: number;
69
+ };
70
+ export type ArchivesSyncStatus = {
71
+ allScansComplete: boolean;
72
+ indexSize: number;
73
+ sources: ArchivesSyncSourceStatus[];
74
+ };
8
75
  export interface IArchives {
9
76
  getDebugName(): string;
10
77
  get(fileName: string, config?: {
@@ -13,13 +80,31 @@ export interface IArchives {
13
80
  end: number;
14
81
  };
15
82
  }): Promise<Buffer | undefined>;
16
- set(fileName: string, data: Buffer): Promise<void>;
83
+ /** Like get, but also returns the last-write time of the file. get just calls get2. */
84
+ get2(fileName: string, config?: {
85
+ range?: {
86
+ start: number;
87
+ end: number;
88
+ };
89
+ }): Promise<{
90
+ data: Buffer;
91
+ writeTime: number;
92
+ } | undefined>;
93
+ /**
94
+ * lastModified stamps the write with that last-write time instead of now. If it is OLDER than
95
+ * the file's current last-write time the write no-ops (so delayed / synchronized writes can
96
+ * never clobber newer data). Times more than 15 minutes in the future are rejected.
97
+ */
98
+ set(fileName: string, data: Buffer, config?: {
99
+ lastModified?: number;
100
+ }): Promise<void>;
17
101
  del(fileName: string): Promise<void>;
18
102
  /** Streams a file too large to hold in memory. getNextData returns undefined when done. */
19
103
  setLargeFile(config: {
20
104
  path: string;
21
105
  getNextData(): Promise<Buffer | undefined>;
22
106
  }): Promise<void>;
107
+ /** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. */
23
108
  getInfo(fileName: string): Promise<{
24
109
  writeTime: number;
25
110
  size: number;
@@ -34,4 +119,13 @@ export interface IArchives {
34
119
  }): Promise<ArchiveFileInfo[]>;
35
120
  /** Only works for public buckets (private buckets are API-access only). */
36
121
  getURL(path: string): Promise<string>;
122
+ /** The bucket's configuration, which tells whether the optional functions are supported. */
123
+ getConfig(): Promise<ArchivesConfig>;
124
+ /**
125
+ * All files changed after the given time. Only exists when getConfig().supportsChangesAfter;
126
+ * backed by an index, so it is fast (unlike a full findInfo scan). Deletions are not reported.
127
+ */
128
+ getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
129
+ /** Synchronization introspection, for backends that synchronize from sources (see BlobStore). */
130
+ getSyncStatus?(): Promise<ArchivesSyncStatus>;
37
131
  }
@@ -3,18 +3,163 @@ module.allowclient = true;
3
3
  // The important operations of an archive bucket (extracted from ArchivesBackblaze), so other
4
4
  // backends (e.g. our own remote storage server) can be used interchangeably.
5
5
 
6
+ // A write may not be stamped more than this far in the future, or clock skew between machines
7
+ // would let a bad timestamp block writes for a long time.
8
+ export const MAX_LAST_MODIFIED_FUTURE = 15 * 60 * 1000;
9
+ export function assertValidLastModified(lastModified: number): void {
10
+ let max = Date.now() + MAX_LAST_MODIFIED_FUTURE;
11
+ if (lastModified > max) {
12
+ throw new Error(`lastModified is too far in the future: ${lastModified} > ${max} (now + 15 minutes)`);
13
+ }
14
+ }
15
+
16
+
17
+ export type RemoteConfig = {
18
+ // NOTE: Version is used when updating the configuration. The newer version is always taken. A missing version counts as version -1.
19
+ version?: number;
20
+ sources: RemoteConfigBase[];
21
+ };
22
+
23
+ /**
24
+ string arguments will be a url, looking like:
25
+ https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
26
+ https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
27
+ - These map to { url }, with the type inferred from the url
28
+ - Hosted urls are /file/<account>/<bucketName>/..., backblaze urls are /file/<bucketName>/...
29
+
30
+ NOTE: If we do not have right access to these, then it becomes a read-only IArchives, where we solely read using the url form (which might throw due to not having access as well). UNLESS Our configuration explicitly has public: false, in which case, we don't even hit the URL and we throw on access.
31
+
32
+ NOTE: If we're in the browser, we should allow downloading the files via the URL form (if it's a public bucket), however, we won't allow writing, because their servers do not allow secure browser writes.
33
+ */
34
+ export type RemoteConfigBase = string | HostedConfig | BackblazeConfig;
35
+
36
+ export type CommonConfig = {
37
+ /** The default options for the first config in a list is DEFAULT_BASE_SYNC_OPTIONS. The rest default to DEFAULT_SYNC_OPTIONS. */
38
+ syncOptions?: SyncOptions;
39
+ };
40
+
41
+ export type HostedConfig = CommonConfig & {
42
+ type: "remote";
43
+
44
+ // Ex: https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
45
+ // NOTE: The account and bucket name are obtained from the URL.
46
+ url: string;
47
+
48
+ // NOTE: Authentication is handled by cert.ts, via having your machine trusted to access this account.
49
+ accountName?: string;
50
+
51
+ public?: boolean;
52
+ // Fast mode: the server acknowledges writes once they are in memory, flushing to disk after
53
+ // writeDelay (default 5 minutes) and coalescing writes to the same file. A server crash loses
54
+ // writes that haven't flushed yet.
55
+ fast?: boolean;
56
+ writeDelay?: number;
57
+ // The bucket is served straight from the server's disk, with no index — so no fast writes and
58
+ // no getChangesAfter/getSyncStatus.
59
+ rawDisk?: boolean;
60
+ // Writes to paths that already exist are disallowed (deletes still work).
61
+ immutable?: boolean;
62
+ };
63
+
64
+ export type BackblazeConfig = CommonConfig & {
65
+ type: "backblaze";
66
+ // Ex: https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
67
+ // NOTE: The bucket name is obtained from the URL.
68
+ url: string;
69
+ // Public buckets are served over plain HTTPS GETs (getURL). Private buckets are API-access only.
70
+ bucketName: string;
71
+ public?: boolean;
72
+ // NOTE: This isn't enforced on the backblaze level, so this is just a client-side guarantee. This can change how we cache files.
73
+ // - Backblaze does support immutability. However, apparently, once we enable it on a bucket, we can't disable it, which is really bad, as it means if our code could ever enable it and we accidentally enable it on an important bucket, we essentially just bricked that bucket. So we should never write any code that ever tries to use backblaze to make things immutable.
74
+ immutable?: boolean;
75
+
76
+ // NOTE: We will access the api key from getSecret, see backblaze.ts for the specific keys.
77
+ };
78
+
79
+
80
+ export const DEFAULT_BASE_SYNC_OPTIONS: SyncOptions = {
81
+ cacheReads: true,
82
+ required: true,
83
+ validWindow: [0, Number.MAX_SAFE_INTEGER],
84
+ };
85
+ export const DEFAULT_SYNC_OPTIONS: SyncOptions = {
86
+ validWindow: [0, Number.MAX_SAFE_INTEGER],
87
+ };
88
+
89
+
90
+
91
+ // createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as
92
+ // getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date:
93
+ // each write stamps a fresh timestamp on the current version, so both fields are just "when the
94
+ // bytes served by get() were most recently written".
6
95
  export type ArchiveFileInfo = { path: string; createTime: number; size: number };
7
96
 
97
+ export type ArchivesConfig = {
98
+ // Whether getChangesAfter is implemented (fast change polling, instead of full rescans)
99
+ supportsChangesAfter?: boolean;
100
+ };
101
+
102
+ // How a synchronization source behaves (see BlobStore, which synchronizes an index + local cache
103
+ // from a list of { source, options }).
104
+ export type SyncOptions = {
105
+ // After the source's metadata is copied into the index, also copy the file contents over (into
106
+ // the cacheReads sources), preserving their modified times.
107
+ copyFiles?: boolean;
108
+ // Writes are NOT written to this source (by default every source receives writes).
109
+ noWriteBack?: boolean;
110
+ // If a read wasn't served by this source, write the data back to it (using it as a cache), and
111
+ // update the index so it becomes the new source. Set for the local disk source.
112
+ cacheReads?: boolean;
113
+ // Ignore values with write times outside [start, end].
114
+ validWindow: [number, number];
115
+ // If a file isn't in the index and this source hasn't finished its initial scan, check the
116
+ // source directly before declaring the file nonexistent. Set for the disk source.
117
+ required?: boolean;
118
+ };
119
+ export type ArchivesSource = { source: IArchives; options: SyncOptions };
120
+
121
+ export type ArchivesSyncSourceStatus = {
122
+ debugName: string;
123
+ options: SyncOptions;
124
+ supportsChangesAfter: boolean;
125
+ initialScanComplete: boolean;
126
+ // Files seen in this source's scans / change polls so far
127
+ scannedCount: number;
128
+ };
129
+ export type ArchivesSyncStatus = {
130
+ allScansComplete: boolean;
131
+ // Number of files in the index
132
+ indexSize: number;
133
+ sources: ArchivesSyncSourceStatus[];
134
+ };
135
+
8
136
  export interface IArchives {
9
137
  getDebugName(): string;
10
138
  get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined>;
11
- set(fileName: string, data: Buffer): Promise<void>;
139
+ /** Like get, but also returns the last-write time of the file. get just calls get2. */
140
+ get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined>;
141
+ /**
142
+ * lastModified stamps the write with that last-write time instead of now. If it is OLDER than
143
+ * the file's current last-write time the write no-ops (so delayed / synchronized writes can
144
+ * never clobber newer data). Times more than 15 minutes in the future are rejected.
145
+ */
146
+ set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void>;
12
147
  del(fileName: string): Promise<void>;
13
148
  /** Streams a file too large to hold in memory. getNextData returns undefined when done. */
14
149
  setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void>;
150
+ /** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. */
15
151
  getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
16
152
  find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]>;
17
153
  findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
18
154
  /** Only works for public buckets (private buckets are API-access only). */
19
155
  getURL(path: string): Promise<string>;
156
+ /** The bucket's configuration, which tells whether the optional functions are supported. */
157
+ getConfig(): Promise<ArchivesConfig>;
158
+ /**
159
+ * All files changed after the given time. Only exists when getConfig().supportsChangesAfter;
160
+ * backed by an index, so it is fast (unlike a full findInfo scan). Deletions are not reported.
161
+ */
162
+ getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
163
+ /** Synchronization introspection, for backends that synchronize from sources (see BlobStore). */
164
+ getSyncStatus?(): Promise<ArchivesSyncStatus>;
20
165
  }
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { IArchives } from "./IArchives";
3
+ import { IArchives, ArchivesConfig } from "./IArchives";
4
4
  export declare class ArchivesBackblaze implements IArchives {
5
5
  private config;
6
6
  constructor(config: {
@@ -25,7 +25,19 @@ export declare class ArchivesBackblaze implements IArchives {
25
25
  };
26
26
  retryCount?: number;
27
27
  }): Promise<Buffer | undefined>;
28
- set(fileName: string, data: Buffer): Promise<void>;
28
+ get2(fileName: string, config?: {
29
+ range?: {
30
+ start: number;
31
+ end: number;
32
+ };
33
+ }): Promise<{
34
+ data: Buffer;
35
+ writeTime: number;
36
+ } | undefined>;
37
+ getConfig(): Promise<ArchivesConfig>;
38
+ set(fileName: string, data: Buffer, config?: {
39
+ lastModified?: number;
40
+ }): Promise<void>;
29
41
  del(fileName: string): Promise<void>;
30
42
  setLargeFile(config: {
31
43
  path: string;