sliftutils 1.7.88 → 1.7.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/dist/treeSummary.ts.cache +2 -2
- package/index.d.ts +674 -295
- package/misc/dist/environment.ts.cache +2 -2
- package/misc/dist/getSecret.ts.cache +2 -2
- package/misc/dist/strings.ts.cache +2 -2
- package/misc/dist/zip.ts.cache +2 -2
- package/misc/https/dist/certs.ts.cache +2 -2
- package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
- package/misc/https/dist/dns.ts.cache +2 -2
- package/misc/https/dist/hostServer.ts.cache +49 -21
- package/misc/https/dist/hostsFile.ts.cache +82 -0
- package/misc/https/dist/httpsCerts.ts.cache +2 -2
- package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
- package/misc/https/hostServer.d.ts +6 -2
- package/misc/https/hostServer.ts +52 -19
- package/misc/https/hostsFile.d.ts +7 -0
- package/misc/https/hostsFile.ts +66 -0
- package/package.json +1 -1
- package/render-utils/dist/observer.tsx.cache +2 -2
- package/storage/ArchivesDisk.d.ts +3 -6
- package/storage/ArchivesDisk.ts +26 -2
- package/storage/BulkDatabase2/BulkDatabaseReader.ts +1 -1
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +3 -3
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
- package/storage/IArchives.d.ts +37 -24
- package/storage/IArchives.ts +47 -62
- package/storage/LogMap.d.ts +56 -0
- package/storage/LogMap.ts +220 -0
- package/storage/archiveHelpers.d.ts +36 -0
- package/storage/archiveHelpers.ts +103 -0
- package/storage/backblaze.d.ts +7 -12
- package/storage/backblaze.ts +53 -48
- package/storage/dist/ArchivesDisk.ts.cache +28 -2
- package/storage/dist/FileFolderAPI.tsx.cache +2 -2
- package/storage/dist/IArchives.ts.cache +16 -52
- package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
- package/storage/dist/JSONStorage.ts.cache +2 -2
- package/storage/dist/LogMap.ts.cache +212 -0
- package/storage/dist/PendingManager.tsx.cache +2 -2
- package/storage/dist/TransactionStorage.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +58 -44
- package/storage/dist/fileSystemPointer.ts.cache +2 -2
- package/storage/dist/remoteFileStorage.ts.cache +2 -2
- package/storage/remoteStorage/ArchivesDelayed.d.ts +66 -0
- package/storage/remoteStorage/ArchivesDelayed.ts +237 -0
- package/storage/remoteStorage/ArchivesRemote.d.ts +5 -6
- package/storage/remoteStorage/ArchivesRemote.ts +26 -5
- package/storage/remoteStorage/ArchivesUrl.d.ts +2 -5
- package/storage/remoteStorage/ArchivesUrl.ts +2 -2
- package/storage/remoteStorage/accessPage.tsx +12 -0
- package/storage/remoteStorage/blobStore.d.ts +162 -189
- package/storage/remoteStorage/blobStore.ts +495 -952
- package/storage/remoteStorage/bucketDisk.d.ts +24 -3
- package/storage/remoteStorage/bucketDisk.ts +56 -22
- package/storage/remoteStorage/certTrustModal.d.ts +1 -0
- package/storage/remoteStorage/certTrustModal.tsx +48 -0
- package/storage/remoteStorage/cliArgs.d.ts +2 -0
- package/storage/remoteStorage/cliArgs.ts +5 -0
- package/storage/remoteStorage/createArchives.d.ts +8 -7
- package/storage/remoteStorage/createArchives.ts +65 -26
- package/storage/remoteStorage/deployTakeover.ts +1 -1
- package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +220 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +25 -5
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
- package/storage/remoteStorage/dist/accessPage.tsx.cache +13 -3
- package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
- package/storage/remoteStorage/dist/blobStore.ts.cache +483 -965
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +60 -29
- package/storage/remoteStorage/dist/certTrustModal.tsx.cache +55 -0
- package/storage/remoteStorage/dist/cliArgs.ts.cache +9 -4
- package/storage/remoteStorage/dist/createArchives.ts.cache +63 -26
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +5 -5
- package/storage/remoteStorage/dist/intermediateManagement.ts.cache +286 -0
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +122 -56
- package/storage/remoteStorage/dist/serverConfig.ts.cache +4 -4
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -11
- package/storage/remoteStorage/dist/sourcesList.ts.cache +3 -6
- package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
- package/storage/remoteStorage/dist/storageController.ts.cache +27 -12
- package/storage/remoteStorage/dist/storageServer.ts.cache +27 -7
- package/storage/remoteStorage/dist/storageServerCli.ts.cache +7 -3
- package/storage/remoteStorage/dist/storageServerState.ts.cache +133 -696
- package/storage/remoteStorage/dist/storeConfig.ts.cache +52 -0
- package/storage/remoteStorage/dist/storePlan.ts.cache +110 -81
- package/storage/remoteStorage/dist/storeSources.ts.cache +51 -0
- package/storage/remoteStorage/dist/storeSync.ts.cache +650 -0
- package/storage/remoteStorage/dist/validation.ts.cache +14 -4
- package/storage/remoteStorage/intermediateManagement.d.ts +12 -0
- package/storage/remoteStorage/intermediateManagement.ts +264 -0
- package/storage/remoteStorage/remoteConfig.d.ts +32 -0
- package/storage/remoteStorage/remoteConfig.ts +115 -50
- package/storage/remoteStorage/serverConfig.d.ts +1 -1
- package/storage/remoteStorage/serverConfig.ts +1 -1
- package/storage/remoteStorage/sourceWrapper.ts +4 -8
- package/storage/remoteStorage/sourcesList.d.ts +0 -2
- package/storage/remoteStorage/sourcesList.ts +0 -3
- package/storage/remoteStorage/spec.md +18 -4
- package/storage/remoteStorage/storageController.d.ts +10 -0
- package/storage/remoteStorage/storageController.ts +29 -16
- package/storage/remoteStorage/storageServer.d.ts +2 -0
- package/storage/remoteStorage/storageServer.ts +28 -4
- package/storage/remoteStorage/storageServerCli.ts +5 -1
- package/storage/remoteStorage/storageServerState.d.ts +34 -21
- package/storage/remoteStorage/storageServerState.ts +147 -662
- package/storage/remoteStorage/storeConfig.d.ts +30 -0
- package/storage/remoteStorage/storeConfig.ts +62 -0
- package/storage/remoteStorage/storePlan.d.ts +29 -17
- package/storage/remoteStorage/storePlan.ts +112 -85
- package/storage/remoteStorage/storeSources.d.ts +11 -0
- package/storage/remoteStorage/storeSources.ts +47 -0
- package/storage/remoteStorage/storeSync.d.ts +53 -0
- package/storage/remoteStorage/storeSync.ts +632 -0
- package/storage/remoteStorage/validation.d.ts +2 -0
- package/storage/remoteStorage/validation.ts +10 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { lazy } from "socket-function/src/caching";
|
|
4
|
+
|
|
5
|
+
// A persistent Map that knows about time and about deletions. Every value carries the time it was written, so a write that arrives out of order is simply not applied - the map cannot be made to go backwards. A deleted key keeps its time too, as a tombstone in a second map, so "was this deleted, and when" is as cheap to ask as "does this exist", and expiring old tombstones is a walk of the tombstones alone rather than of everything.
|
|
6
|
+
//
|
|
7
|
+
// On disk it is one append-only log of the sets, deletes and purges that produced both maps, rewritten from them whenever it has grown to several times their size.
|
|
8
|
+
|
|
9
|
+
// The log is rewritten once it holds this many times more records than there are keys (a log of 3 records per surviving key is mostly history)
|
|
10
|
+
const COMPACT_RATIO = 3;
|
|
11
|
+
// ...but never for a small log, where the rewrite would cost more than the history it removes
|
|
12
|
+
const MIN_COMPACT_RECORDS = 1000;
|
|
13
|
+
// Appends are coalesced for this long. A crash loses at most this much - the callers of this class treat their data as reconstructible - and the alternative is a file write per set.
|
|
14
|
+
const FLUSH_DELAY = 500;
|
|
15
|
+
|
|
16
|
+
/** A live value: what was stored, when it was written (the caller's ordering), and when we last changed it (ours). */
|
|
17
|
+
export type LogEntry<T> = { value: T; time: number; changedAt: number };
|
|
18
|
+
/** A deleted key: when it was deleted, and when we learned. The value is gone; the time is the point of a tombstone. */
|
|
19
|
+
export type LogTombstone = { time: number; changedAt: number };
|
|
20
|
+
|
|
21
|
+
// One line of the log. JSON.stringify escapes newlines inside keys and values, so a record can never contain the separator.
|
|
22
|
+
type LogRecord<T> = {
|
|
23
|
+
k: string;
|
|
24
|
+
// The caller's time for this change - what ordering is decided by
|
|
25
|
+
t: number;
|
|
26
|
+
// A set carries the value; a delete carries d; a purge carries p (forget the key entirely, tombstone included)
|
|
27
|
+
v?: T;
|
|
28
|
+
d?: 1;
|
|
29
|
+
p?: 1;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class LogMap<T> {
|
|
33
|
+
constructor(private filePath: string) { }
|
|
34
|
+
|
|
35
|
+
private values = new Map<string, LogEntry<T>>();
|
|
36
|
+
private deleted = new Map<string, LogTombstone>();
|
|
37
|
+
// Records the log holds (written plus pending) - what compaction is decided against, NOT the number of keys
|
|
38
|
+
private logRecords = 0;
|
|
39
|
+
private pending: string[] = [];
|
|
40
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
// Serializes everything that touches the file, so an append can never interleave with a rewrite
|
|
42
|
+
private writeChain: Promise<void> = Promise.resolve();
|
|
43
|
+
|
|
44
|
+
/** Reads the log and replays it into memory. Every other method assumes this has finished. */
|
|
45
|
+
public load = lazy(async () => {
|
|
46
|
+
let data: string;
|
|
47
|
+
try {
|
|
48
|
+
data = await fs.promises.readFile(this.filePath, "utf8");
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if ((e as { code?: string }).code === "ENOENT") return;
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
let applied = 0;
|
|
54
|
+
let damaged = 0;
|
|
55
|
+
for (let line of data.split("\n")) {
|
|
56
|
+
if (!line) continue;
|
|
57
|
+
let record: LogRecord<T> | undefined;
|
|
58
|
+
try {
|
|
59
|
+
record = JSON.parse(line) as LogRecord<T>;
|
|
60
|
+
} catch {
|
|
61
|
+
// A crash mid-append leaves a torn final line; anything earlier means real damage. Either way the rest of the log is still worth having - this is an index, and scanning rebuilds whatever we lose.
|
|
62
|
+
damaged++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
applied++;
|
|
66
|
+
// Replayed through the same ordering rules that produced it, so a log written out of order still lands on the same state
|
|
67
|
+
if (record.p) {
|
|
68
|
+
this.values.delete(record.k);
|
|
69
|
+
this.deleted.delete(record.k);
|
|
70
|
+
} else if (record.d) {
|
|
71
|
+
this.applyDelete(record.k, record.t, record.t);
|
|
72
|
+
} else if (record.v !== undefined) {
|
|
73
|
+
this.applySet(record.k, record.v, record.t, record.t);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.logRecords = applied;
|
|
77
|
+
if (damaged) {
|
|
78
|
+
console.error(`${damaged} damaged record(s) in ${this.filePath} were skipped; ${applied} applied, leaving ${this.values.size} values and ${this.deleted.size} tombstones`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** The live value, or undefined when the key does not exist here (deleted included - a deletion is an absence, see getDeleted for its time). */
|
|
83
|
+
public get(key: string): LogEntry<T> | undefined {
|
|
84
|
+
return this.values.get(key);
|
|
85
|
+
}
|
|
86
|
+
/** When the key was deleted, if it was. Absent both here and in get means we have never heard of it. */
|
|
87
|
+
public getDeleted(key: string): LogTombstone | undefined {
|
|
88
|
+
return this.deleted.get(key);
|
|
89
|
+
}
|
|
90
|
+
/** The time the key last changed either way, or 0 if we have never heard of it - what a new write has to beat. */
|
|
91
|
+
public timeOf(key: string): number {
|
|
92
|
+
let live = this.values.get(key);
|
|
93
|
+
if (live) return live.time;
|
|
94
|
+
return this.deleted.get(key)?.time || 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** O(1), and counts only what exists. */
|
|
98
|
+
public get size(): number {
|
|
99
|
+
return this.values.size;
|
|
100
|
+
}
|
|
101
|
+
public get deletedSize(): number {
|
|
102
|
+
return this.deleted.size;
|
|
103
|
+
}
|
|
104
|
+
/** Live values only. Live, in insertion order - deleting during iteration is safe (JS skips entries removed before they are reached), which is what the passes that walk everything and prune as they go rely on. */
|
|
105
|
+
public entries(): IterableIterator<[string, LogEntry<T>]> {
|
|
106
|
+
return this.values.entries();
|
|
107
|
+
}
|
|
108
|
+
/** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
|
|
109
|
+
public deletedEntries(): IterableIterator<[string, LogTombstone]> {
|
|
110
|
+
return this.deleted.entries();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Stores a value as of `time`. Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
|
|
114
|
+
public set(key: string, value: T, time: number): boolean {
|
|
115
|
+
if (!this.applySet(key, value, time, Date.now())) return false;
|
|
116
|
+
this.append({ k: key, t: time, v: value });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Deletes as of `time`, keeping the tombstone. Returns false when something at least as new is already here. */
|
|
121
|
+
public delete(key: string, time: number): boolean {
|
|
122
|
+
if (!this.applyDelete(key, time, Date.now())) return false;
|
|
123
|
+
this.append({ k: key, t: time, d: 1 });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Forgets the key entirely, tombstone included - for a tombstone old enough that nobody needs to hear about the deletion any more, and for an entry that turned out never to have existed. Not a deletion: it leaves nothing behind to propagate. */
|
|
128
|
+
public purge(key: string): void {
|
|
129
|
+
let had = this.values.delete(key);
|
|
130
|
+
had = this.deleted.delete(key) || had;
|
|
131
|
+
if (!had) return;
|
|
132
|
+
this.append({ k: key, t: Date.now(), p: 1 });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private applySet(key: string, value: T, time: number, changedAt: number): boolean {
|
|
136
|
+
if (time < this.timeOf(key)) return false;
|
|
137
|
+
this.deleted.delete(key);
|
|
138
|
+
this.values.set(key, { value, time, changedAt });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private applyDelete(key: string, time: number, changedAt: number): boolean {
|
|
143
|
+
if (time < this.timeOf(key)) return false;
|
|
144
|
+
this.values.delete(key);
|
|
145
|
+
this.deleted.set(key, { time, changedAt });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private append(record: LogRecord<T>): void {
|
|
150
|
+
this.pending.push(JSON.stringify(record));
|
|
151
|
+
this.logRecords++;
|
|
152
|
+
this.scheduleFlush();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private scheduleFlush(): void {
|
|
156
|
+
if (this.flushTimer !== undefined) return;
|
|
157
|
+
this.flushTimer = setTimeout(() => {
|
|
158
|
+
this.flushTimer = undefined;
|
|
159
|
+
void this.flush().catch((e: Error) => console.error(`Flushing ${this.filePath} failed: ${e.stack ?? e}`));
|
|
160
|
+
}, FLUSH_DELAY);
|
|
161
|
+
(this.flushTimer as { unref?: () => void }).unref?.();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Writes everything pending (rewriting the log first if it has grown too far past what it describes). */
|
|
165
|
+
public async flush(): Promise<void> {
|
|
166
|
+
let result = this.writeChain.then(() => this.write());
|
|
167
|
+
// The chain only exists to serialize writes, so it must not stay rejected: a failure belongs to the caller that asked for this flush, not to every flush after it
|
|
168
|
+
this.writeChain = result.catch(() => { });
|
|
169
|
+
await result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private directory = lazy(async () => {
|
|
173
|
+
await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
private async write(): Promise<void> {
|
|
177
|
+
if (this.logRecords > Math.max(MIN_COMPACT_RECORDS, (this.values.size + this.deleted.size) * COMPACT_RATIO)) {
|
|
178
|
+
await this.compact();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await this.appendPending();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async appendPending(): Promise<void> {
|
|
185
|
+
if (!this.pending.length) return;
|
|
186
|
+
let records = this.pending;
|
|
187
|
+
this.pending = [];
|
|
188
|
+
await this.directory();
|
|
189
|
+
try {
|
|
190
|
+
await fs.promises.appendFile(this.filePath, records.join("\n") + "\n");
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// Put them back rather than lose them: the values are still in memory, so all that is at risk is having to rebuild them by scanning
|
|
193
|
+
this.pending = records.concat(this.pending);
|
|
194
|
+
this.scheduleFlush();
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Rewrites the log as the current contents of both maps. They are the truth - the log only exists to rebuild them - so the snapshot IS the new log.
|
|
200
|
+
private async compact(): Promise<void> {
|
|
201
|
+
let records: string[] = [];
|
|
202
|
+
for (let [key, entry] of this.values) {
|
|
203
|
+
records.push(JSON.stringify({ k: key, t: entry.time, v: entry.value } satisfies LogRecord<T>));
|
|
204
|
+
}
|
|
205
|
+
for (let [key, tombstone] of this.deleted) {
|
|
206
|
+
records.push(JSON.stringify({ k: key, t: tombstone.time, d: 1 } satisfies LogRecord<T>));
|
|
207
|
+
}
|
|
208
|
+
// Cleared with the snapshot: every pending record is already reflected in the maps, so the snapshot contains it. Anything appended from here on is NOT in the snapshot, and lands in the new log after the rename.
|
|
209
|
+
this.pending = [];
|
|
210
|
+
let startedWith = this.logRecords;
|
|
211
|
+
let tempPath = `${this.filePath}.compacting`;
|
|
212
|
+
await this.directory();
|
|
213
|
+
await fs.promises.writeFile(tempPath, records.length && `${records.join("\n")}\n` || "");
|
|
214
|
+
// Replaces the old log in one step, so a crash leaves either the whole old log or the whole new one
|
|
215
|
+
await fs.promises.rename(tempPath, this.filePath);
|
|
216
|
+
this.logRecords = records.length + this.pending.length;
|
|
217
|
+
console.log(`Rewrote ${this.filePath}: ${startedWith} log records for ${this.values.size} values and ${this.deleted.size} tombstones`);
|
|
218
|
+
await this.appendPending();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IArchives } from "./IArchives";
|
|
2
|
+
/** Copies one file between two archives. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, or undefined when the source doesn't have the file. */
|
|
3
|
+
export declare function copyArchiveFile(config: {
|
|
4
|
+
from: IArchives;
|
|
5
|
+
to: IArchives;
|
|
6
|
+
path: string;
|
|
7
|
+
/** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
|
|
8
|
+
toPath?: string;
|
|
9
|
+
size?: number;
|
|
10
|
+
/** Stamps the destination write with this time INSTEAD of now - which makes the copy lose (silently) to anything newer at the destination, tombstones included. That is exactly right for synchronization, which copies the same key between replicas and must preserve its ordering, and exactly wrong for anything else - so omit it unless the destination is another copy of the same key. */
|
|
11
|
+
writeTime?: number;
|
|
12
|
+
forceSetImmutable?: boolean;
|
|
13
|
+
noChecks?: boolean;
|
|
14
|
+
internal?: boolean;
|
|
15
|
+
noFallbacks?: boolean;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
writeTime: number;
|
|
18
|
+
size: number;
|
|
19
|
+
} | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Moves one file - between two archives, or between two paths of one. When from and to are the SAME
|
|
22
|
+
* archives instance and it implements move, the backend moves the file itself (backblaze copies
|
|
23
|
+
* server-side, disk renames, the storage server relocates it node-side) and the bytes never travel
|
|
24
|
+
* through us. Everything else is the safe fallback: copy, then CONFIRM the destination actually
|
|
25
|
+
* reports the file (getInfo), and only then delete the source - the one order in which no failure
|
|
26
|
+
* can lose the file, only at worst leave it in both places. Throws when the source doesn't have the
|
|
27
|
+
* file, or when the copy cannot be confirmed (the source is then left untouched).
|
|
28
|
+
*/
|
|
29
|
+
export declare function moveArchiveFile(config: {
|
|
30
|
+
from: IArchives;
|
|
31
|
+
to: IArchives;
|
|
32
|
+
path: string;
|
|
33
|
+
/** The path at the destination - defaults to path (moving between two archives). Required in practice when from and to are the same archives, where the same path would be a no-op. */
|
|
34
|
+
toPath?: string;
|
|
35
|
+
noFallbacks?: boolean;
|
|
36
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Type-only: IArchives.ts re-exports our functions, so a value import back at it would be a require cycle
|
|
2
|
+
import type { IArchives } from "./IArchives";
|
|
3
|
+
|
|
4
|
+
// Cross-archive file operations built ON TOP of IArchives (copy, move) - helpers over the interface, not part of it, so they live beside it rather than in it.
|
|
5
|
+
|
|
6
|
+
const LARGE_COPY_THRESHOLD = 64 * 1024 * 1024;
|
|
7
|
+
const LARGE_COPY_CHUNK = 32 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Copies one file between two archives. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, or undefined when the source doesn't have the file. */
|
|
10
|
+
export async function copyArchiveFile(config: {
|
|
11
|
+
from: IArchives;
|
|
12
|
+
to: IArchives;
|
|
13
|
+
path: string;
|
|
14
|
+
/** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
|
|
15
|
+
toPath?: string;
|
|
16
|
+
// From the caller's metadata scan; getInfo fills it in when omitted
|
|
17
|
+
size?: number;
|
|
18
|
+
/** Stamps the destination write with this time INSTEAD of now - which makes the copy lose (silently) to anything newer at the destination, tombstones included. That is exactly right for synchronization, which copies the same key between replicas and must preserve its ordering, and exactly wrong for anything else - so omit it unless the destination is another copy of the same key. */
|
|
19
|
+
writeTime?: number;
|
|
20
|
+
forceSetImmutable?: boolean;
|
|
21
|
+
noChecks?: boolean;
|
|
22
|
+
internal?: boolean;
|
|
23
|
+
noFallbacks?: boolean;
|
|
24
|
+
}): Promise<{ writeTime: number; size: number } | undefined> {
|
|
25
|
+
let { from, to, path } = config;
|
|
26
|
+
let toPath = config.toPath || path;
|
|
27
|
+
let size = config.size;
|
|
28
|
+
if (size === undefined) {
|
|
29
|
+
let info = await from.getInfo(path, { noFallbacks: config.noFallbacks });
|
|
30
|
+
if (!info) return undefined;
|
|
31
|
+
size = info.size;
|
|
32
|
+
}
|
|
33
|
+
// Preserving the source's stamp is ONLY for synchronization (copies of the same key between replicas, where the original ordering must survive propagation) - those callers pass writeTime explicitly. A plain copy is a NEW write and is stamped now: stamping it with the source's old time makes it LOSE to any newer write or tombstone at the destination, silently - move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely.
|
|
34
|
+
let writeTime = config.writeTime || Date.now();
|
|
35
|
+
if (size <= LARGE_COPY_THRESHOLD) {
|
|
36
|
+
let result = await from.get2(path, { internal: config.internal, noFallbacks: config.noFallbacks });
|
|
37
|
+
// Empty counts as absent, never as content to copy: an empty file IS a deletion, set refuses empty buffers, and deletions travel through their own path (del / scan tombstones)
|
|
38
|
+
if (!result || !result.data || !result.data.length) return undefined;
|
|
39
|
+
await to.set(toPath, result.data, { lastModified: writeTime, forceSetImmutable: config.forceSetImmutable, noChecks: config.noChecks, internal: config.internal });
|
|
40
|
+
return { writeTime, size: result.data.length };
|
|
41
|
+
}
|
|
42
|
+
// Const so the closure keeps the narrowed type
|
|
43
|
+
const totalSize = size;
|
|
44
|
+
let offset = 0;
|
|
45
|
+
await to.setLargeFile({
|
|
46
|
+
path: toPath,
|
|
47
|
+
lastModified: writeTime,
|
|
48
|
+
forceSetImmutable: config.forceSetImmutable,
|
|
49
|
+
noChecks: config.noChecks,
|
|
50
|
+
internal: config.internal,
|
|
51
|
+
restartStream: () => {
|
|
52
|
+
offset = 0;
|
|
53
|
+
},
|
|
54
|
+
getNextData: async () => {
|
|
55
|
+
if (offset >= totalSize) return undefined;
|
|
56
|
+
let end = Math.min(offset + LARGE_COPY_CHUNK, totalSize);
|
|
57
|
+
let data = await from.get(path, { range: { start: offset, end }, internal: config.internal, noFallbacks: config.noFallbacks });
|
|
58
|
+
if (!data || !data.length) {
|
|
59
|
+
throw new Error(`Ranged read of ${JSON.stringify(path)} from ${from.getDebugName()} returned ${data && data.length || "nothing"} at ${offset}-${end} (expected ${end - offset} bytes of a ${totalSize} byte file - it changed or vanished mid-copy)`);
|
|
60
|
+
}
|
|
61
|
+
offset += data.length;
|
|
62
|
+
return data;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return { writeTime, size: totalSize };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Moves one file - between two archives, or between two paths of one. When from and to are the SAME
|
|
70
|
+
* archives instance and it implements move, the backend moves the file itself (backblaze copies
|
|
71
|
+
* server-side, disk renames, the storage server relocates it node-side) and the bytes never travel
|
|
72
|
+
* through us. Everything else is the safe fallback: copy, then CONFIRM the destination actually
|
|
73
|
+
* reports the file (getInfo), and only then delete the source - the one order in which no failure
|
|
74
|
+
* can lose the file, only at worst leave it in both places. Throws when the source doesn't have the
|
|
75
|
+
* file, or when the copy cannot be confirmed (the source is then left untouched).
|
|
76
|
+
*/
|
|
77
|
+
export async function moveArchiveFile(config: {
|
|
78
|
+
from: IArchives;
|
|
79
|
+
to: IArchives;
|
|
80
|
+
path: string;
|
|
81
|
+
/** The path at the destination - defaults to path (moving between two archives). Required in practice when from and to are the same archives, where the same path would be a no-op. */
|
|
82
|
+
toPath?: string;
|
|
83
|
+
noFallbacks?: boolean;
|
|
84
|
+
}): Promise<void> {
|
|
85
|
+
let { from, to, path } = config;
|
|
86
|
+
let toPath = config.toPath || path;
|
|
87
|
+
if (from === to) {
|
|
88
|
+
if (path === toPath) return;
|
|
89
|
+
if (from.move) {
|
|
90
|
+
await from.move({ fromPath: path, toPath });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let copied = await copyArchiveFile({ from, to, path, toPath, noFallbacks: config.noFallbacks });
|
|
95
|
+
if (!copied) {
|
|
96
|
+
throw new Error(`Cannot move ${JSON.stringify(path)}: ${from.getDebugName()} does not have it`);
|
|
97
|
+
}
|
|
98
|
+
let confirmed = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
|
|
99
|
+
if (!confirmed) {
|
|
100
|
+
throw new Error(`Not deleting ${JSON.stringify(path)} from ${from.getDebugName()} after copying it: ${to.getDebugName()} does not report ${JSON.stringify(toPath)} (the copy claimed to succeed, so something is wrong - the file is left at the source)`);
|
|
101
|
+
}
|
|
102
|
+
await from.del(path);
|
|
103
|
+
}
|
package/storage/backblaze.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
import { IArchives, ArchivesConfig, ChangesAfterConfig, ArchiveFileInfo, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig } from "./IArchives";
|
|
3
|
+
import { IArchives, ArchivesConfig, ChangesAfterConfig, ArchiveFileInfo, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig, SourceConfig } from "./IArchives";
|
|
4
4
|
export declare class ArchivesBackblaze implements IArchives {
|
|
5
5
|
private config;
|
|
6
6
|
constructor(config: {
|
|
@@ -16,6 +16,8 @@ export declare class ArchivesBackblaze implements IArchives {
|
|
|
16
16
|
enableLogging(): void;
|
|
17
17
|
private log;
|
|
18
18
|
getDebugName(): string;
|
|
19
|
+
/** Policy flags changed in the routing config. The bucket-level settings getBucketAPI applied (CORS, cache time) are NOT re-applied - those are one-time bucket setup, and re-running them on every config edit would rewrite the bucket's settings from whichever process noticed first. */
|
|
20
|
+
updateSourceConfig(sourceConfig: SourceConfig): void;
|
|
19
21
|
private getBucketAPI;
|
|
20
22
|
private currentReset;
|
|
21
23
|
private last503Reset;
|
|
@@ -28,13 +30,11 @@ export declare class ArchivesBackblaze implements IArchives {
|
|
|
28
30
|
} | undefined>;
|
|
29
31
|
getConfig(): Promise<ArchivesConfig>;
|
|
30
32
|
hasWriteAccess(): Promise<boolean>;
|
|
33
|
+
/** Whether an existing file already beats this write, so it must not be sent at all. Shared by set and setLargeFile - the size a value happens to have must never change which ordering rules apply to it. */
|
|
34
|
+
private writeIsSuperseded;
|
|
31
35
|
set(fileName: string, data: Buffer, config?: SetConfig): Promise<string>;
|
|
32
36
|
del(fileName: string, config?: DelConfig): Promise<void>;
|
|
33
|
-
setLargeFile(config:
|
|
34
|
-
path: string;
|
|
35
|
-
lastModified?: number;
|
|
36
|
-
getNextData(): Promise<Buffer | undefined>;
|
|
37
|
-
}): Promise<void>;
|
|
37
|
+
setLargeFile(config: SetLargeFileConfig): Promise<void>;
|
|
38
38
|
getInfo(fileName: string, config?: GetInfoConfig): Promise<{
|
|
39
39
|
writeTime: number;
|
|
40
40
|
size: number;
|
|
@@ -47,12 +47,7 @@ export declare class ArchivesBackblaze implements IArchives {
|
|
|
47
47
|
size: number;
|
|
48
48
|
}[]>;
|
|
49
49
|
assertPathValid(path: string): Promise<void>;
|
|
50
|
-
move(config:
|
|
51
|
-
path: string;
|
|
52
|
-
target: IArchives;
|
|
53
|
-
targetPath: string;
|
|
54
|
-
copyInstead?: boolean;
|
|
55
|
-
}): Promise<void>;
|
|
50
|
+
move(config: MoveFileConfig): Promise<void>;
|
|
56
51
|
copy(config: {
|
|
57
52
|
path: string;
|
|
58
53
|
target: IArchives;
|
package/storage/backblaze.ts
CHANGED
|
@@ -9,7 +9,7 @@ import debugbreak from "debugbreak";
|
|
|
9
9
|
import dns from "dns";
|
|
10
10
|
import { getSecret } from "../misc/getSecret";
|
|
11
11
|
import { httpsRequest, HttpsResponseInfo } from "socket-function/src/https";
|
|
12
|
-
import { IArchives, ArchivesConfig, ChangesAfterConfig, ArchiveFileInfo, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, assertValidLastModified, bufferChunkStream, IMMUTABLE_CACHE_TIME } from "./IArchives";
|
|
12
|
+
import { IArchives, ArchivesConfig, ChangesAfterConfig, ArchiveFileInfo, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig, SourceConfig, assertValidLastModified, bufferChunkStream, IMMUTABLE_CACHE_TIME } from "./IArchives";
|
|
13
13
|
import { filterChanges } from "./remoteStorage/remoteConfig";
|
|
14
14
|
|
|
15
15
|
type BackblazeCreds = {
|
|
@@ -441,6 +441,14 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
441
441
|
return "backblaze " + this.config.bucketName;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/** Policy flags changed in the routing config. The bucket-level settings getBucketAPI applied (CORS, cache time) are NOT re-applied - those are one-time bucket setup, and re-running them on every config edit would rewrite the bucket's settings from whichever process noticed first. */
|
|
445
|
+
public updateSourceConfig(sourceConfig: SourceConfig): void {
|
|
446
|
+
if (sourceConfig.type !== "backblaze") return;
|
|
447
|
+
this.config.public = sourceConfig.public;
|
|
448
|
+
this.config.immutable = sourceConfig.immutable;
|
|
449
|
+
this.config.allowedOrigins = sourceConfig.allowedOrigins;
|
|
450
|
+
}
|
|
451
|
+
|
|
444
452
|
private getBucketAPI = lazy(async () => {
|
|
445
453
|
let api = await getAPI();
|
|
446
454
|
|
|
@@ -710,6 +718,19 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
710
718
|
return false;
|
|
711
719
|
}
|
|
712
720
|
}
|
|
721
|
+
/** Whether an existing file already beats this write, so it must not be sent at all. Shared by set and setLargeFile - the size a value happens to have must never change which ordering rules apply to it. */
|
|
722
|
+
private async writeIsSuperseded(fileName: string, config?: SetConfig): Promise<boolean> {
|
|
723
|
+
if (!config?.lastModified) return false;
|
|
724
|
+
assertValidLastModified(config.lastModified);
|
|
725
|
+
// This comparison deliberately IGNORES noChecks: backblaze has no server of ours to enforce only-take-the-latest (unlike hosted targets, where the receiving store re-checks), so this comparison IS the ordering guard. Skipping it lets a stale push land over a newer value or tombstone - and because b2 stamps its own upload times, the stale data then becomes the newest-timestamped copy in the whole system, resurrecting globally through everyone's scans. includeTombstones: a deletion on b2 is a real size-0 file and must win this comparison too.
|
|
726
|
+
let existing = await this.getInfo(fileName, { includeTombstones: true });
|
|
727
|
+
if (!existing) return false;
|
|
728
|
+
// An older write never overwrites a newer one (see IArchives.set). B2 stamps its own upload time, so the exact lastModified is not preserved on the stored file.
|
|
729
|
+
if (config.lastModified < existing.writeTime) return true;
|
|
730
|
+
// Immutability wins: a synchronization push never overwrites an existing path on an immutable bucket (see SetConfig.forceSetImmutable)
|
|
731
|
+
if (config.forceSetImmutable && this.config.immutable) return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
713
734
|
public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
|
|
714
735
|
if (!data.length) {
|
|
715
736
|
throw new Error(`set was called with an empty buffer for ${JSON.stringify(fileName)} on ${this.getDebugName()}: an empty file IS a deletion in this system and would read back as missing - call del instead`);
|
|
@@ -717,18 +738,10 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
717
738
|
if (config?.forceSetImmutable && !config.lastModified) {
|
|
718
739
|
throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${fileName} to ${this.getDebugName()}`);
|
|
719
740
|
}
|
|
720
|
-
if (config
|
|
721
|
-
assertValidLastModified(config.lastModified);
|
|
722
|
-
// This comparison deliberately IGNORES noChecks: backblaze has no server of ours to enforce only-take-the-latest (unlike hosted targets, where the receiving store re-checks), so this comparison IS the ordering guard. Skipping it lets a stale push land over a newer value or tombstone - and because b2 stamps its own upload times, the stale data then becomes the newest-timestamped copy in the whole system, resurrecting globally through everyone's scans. includeTombstones: a deletion on b2 is a real size-0 file and must win this comparison too.
|
|
723
|
-
let existing = await this.getInfo(fileName, { includeTombstones: true });
|
|
724
|
-
// An older write never overwrites a newer one (see IArchives.set). B2 stamps its own upload time, so the exact lastModified is not preserved on the stored file.
|
|
725
|
-
if (existing && config.lastModified < existing.writeTime) return fileName;
|
|
726
|
-
// Immutability wins: a synchronization push never overwrites an existing path on an immutable bucket (see SetConfig.forceSetImmutable)
|
|
727
|
-
if (existing && config.forceSetImmutable && this.config.immutable) return fileName;
|
|
728
|
-
}
|
|
741
|
+
if (await this.writeIsSuperseded(fileName, config)) return fileName;
|
|
729
742
|
// Big buffers stream through the large-file API: a failed part retries alone instead of restarting the whole upload. The threshold MUST stay at two full chunks - below that setLargeFile falls back to plain set, which would recurse right back here.
|
|
730
743
|
if (data.length >= LARGE_FILE_MIN_CHUNK_SIZE * 2) {
|
|
731
|
-
await this.setLargeFile({ path: fileName,
|
|
744
|
+
await this.setLargeFile({ path: fileName, ...config, ...bufferChunkStream(data) });
|
|
732
745
|
return fileName;
|
|
733
746
|
}
|
|
734
747
|
this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
|
|
@@ -775,8 +788,10 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
775
788
|
// NOTE: Deletion SEEMS to work. This DOES break if we delete a file which keeps being recreated, ex, the heartbeat. let existsChecks = 10; while (existsChecks > 0) { let exists = await this.getInfo(fileName); if (!exists) break; await delay(1000); existsChecks--; } if (existsChecks === 0) { let exists = await this.getInfo(fileName); devDebugbreak(); console.warn(`File ${fileName} was deleted, but was still found afterwards`); exists = await this.getInfo(fileName); }
|
|
776
789
|
}
|
|
777
790
|
|
|
778
|
-
// lastModified is accepted but cannot be honored - b2 stamps its own uploadTimestamp, which is what our getInfo/findInfo report as the write time
|
|
779
|
-
public async setLargeFile(config:
|
|
791
|
+
// lastModified is accepted but cannot be honored - b2 stamps its own uploadTimestamp, which is what our getInfo/findInfo report as the write time. fallbacks means nothing here: a single bucket has nowhere to fall back to.
|
|
792
|
+
public async setLargeFile(config: SetLargeFileConfig): Promise<void> {
|
|
793
|
+
// Checked before a single byte moves: an upload that is already superseded must not be started at all (a cancelled large upload still costs the transfer)
|
|
794
|
+
if (await this.writeIsSuperseded(config.path, config)) return;
|
|
780
795
|
|
|
781
796
|
let onError: (() => Promise<void>)[] = [];
|
|
782
797
|
let time = Date.now();
|
|
@@ -805,18 +820,18 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
805
820
|
}
|
|
806
821
|
// Backblaze disallows overly small files
|
|
807
822
|
if (data.length < LARGE_FILE_MIN_CHUNK_SIZE) {
|
|
808
|
-
await this.set(fileName, data);
|
|
823
|
+
await this.set(fileName, data, config);
|
|
809
824
|
return;
|
|
810
825
|
}
|
|
811
826
|
// Backblaze disallows less than 2 chunks
|
|
812
827
|
let secondData = await getNextData();
|
|
813
828
|
if (!secondData?.length) {
|
|
814
|
-
await this.set(fileName, data);
|
|
829
|
+
await this.set(fileName, data, config);
|
|
815
830
|
return;
|
|
816
831
|
}
|
|
817
832
|
// ALSO, if there are two chunks, but one is too small, combine it. This helps allow us never send small chunks.
|
|
818
833
|
if (secondData.length < LARGE_FILE_MIN_CHUNK_SIZE) {
|
|
819
|
-
await this.set(fileName, Buffer.concat([data, secondData]));
|
|
834
|
+
await this.set(fileName, Buffer.concat([data, secondData]), config);
|
|
820
835
|
return;
|
|
821
836
|
}
|
|
822
837
|
this.log(`Uploading large file ${config.path}`);
|
|
@@ -989,54 +1004,44 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
989
1004
|
}
|
|
990
1005
|
}
|
|
991
1006
|
|
|
992
|
-
public async move(config: {
|
|
1007
|
+
public async move(config: MoveFileConfig): Promise<void> {
|
|
1008
|
+
let { fromPath, toPath } = config;
|
|
1009
|
+
if (fromPath === toPath) return;
|
|
1010
|
+
// A b2 copy is a NEW file version, so its uploadTimestamp is stamped at copy time - the fresh write time move requires (see IArchives.move)
|
|
1011
|
+
await this.copy({ path: fromPath, target: this, targetPath: toPath });
|
|
1012
|
+
// The source is only deleted once the destination CONFIRMS the file - a failure here leaves it in both places, never in neither
|
|
1013
|
+
let exists = await this.getInfo(toPath);
|
|
1014
|
+
if (!exists) {
|
|
1015
|
+
throw new Error(`Not deleting ${JSON.stringify(fromPath)} after the move on ${this.getDebugName()}: ${JSON.stringify(toPath)} was not found after the copy claimed to succeed (the file is left at the source)`);
|
|
1016
|
+
}
|
|
1017
|
+
await this.del(fromPath);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
public async copy(config: {
|
|
993
1021
|
path: string;
|
|
994
1022
|
target: IArchives;
|
|
995
1023
|
targetPath: string;
|
|
996
|
-
|
|
997
|
-
}) {
|
|
1024
|
+
}): Promise<void> {
|
|
998
1025
|
let { path, target, targetPath } = config;
|
|
999
|
-
|
|
1000
|
-
if (target === this && path === targetPath) {
|
|
1001
|
-
this.log(`Backblaze move path to itself. Skipping move, as there is no work to do. ${path}`);
|
|
1002
|
-
return;
|
|
1003
|
-
}
|
|
1026
|
+
if (target === this && path === targetPath) return;
|
|
1004
1027
|
if (target instanceof ArchivesBackblaze) {
|
|
1005
1028
|
let targetBucketId = target.bucketId;
|
|
1006
|
-
|
|
1007
|
-
await this.apiRetryLogic(`move ${path} -> ${targetPath}`, async (api) => {
|
|
1029
|
+
await this.apiRetryLogic(`copy ${path} -> ${targetPath}`, async (api) => {
|
|
1008
1030
|
// Ugh... listing the file name sucks, but... I guess it's still better than downloading and re-uploading the entire file.
|
|
1009
1031
|
let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, maxFileCount: 10 });
|
|
1010
1032
|
let file = info.files.find(x => x.fileName === path);
|
|
1011
|
-
if (!file) throw new Error(`File not found to
|
|
1033
|
+
if (!file) throw new Error(`File not found to copy: ${path}`);
|
|
1012
1034
|
await api.copyFile({
|
|
1013
1035
|
sourceFileId: file.fileId,
|
|
1014
1036
|
fileName: targetPath,
|
|
1015
1037
|
destinationBucketId: targetBucketId,
|
|
1016
1038
|
});
|
|
1017
1039
|
});
|
|
1018
|
-
|
|
1019
|
-
let data = await this.get(path);
|
|
1020
|
-
if (!data) throw new Error(`File not found to move: ${path}`);
|
|
1021
|
-
await target.set(targetPath, data);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
if (!config.copyInstead) {
|
|
1025
|
-
let exists = await this.getInfo(targetPath);
|
|
1026
|
-
if (!exists) {
|
|
1027
|
-
console.error(`File not found after move. Leaving BOTH files. ${targetPath} was not found. Being moved from ${path}`);
|
|
1028
|
-
} else {
|
|
1029
|
-
await this.del(path);
|
|
1030
|
-
}
|
|
1040
|
+
return;
|
|
1031
1041
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
path: string;
|
|
1036
|
-
target: IArchives;
|
|
1037
|
-
targetPath: string;
|
|
1038
|
-
}): Promise<void> {
|
|
1039
|
-
return this.move({ ...config, copyInstead: true });
|
|
1042
|
+
let data = await this.get(path);
|
|
1043
|
+
if (!data) throw new Error(`File not found to copy: ${path}`);
|
|
1044
|
+
await target.set(targetPath, data);
|
|
1040
1045
|
}
|
|
1041
1046
|
|
|
1042
1047
|
public async getURL(path: string) {
|