@peerbit/please-lib 2.0.1 → 2.0.2
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/lib/esm/index.d.ts +53 -24
- package/lib/esm/index.js +409 -120
- package/lib/esm/index.js.map +1 -1
- package/package.json +15 -6
- package/src/index.ts +566 -162
package/src/index.ts
CHANGED
|
@@ -5,36 +5,99 @@ import {
|
|
|
5
5
|
SearchRequest,
|
|
6
6
|
StringMatch,
|
|
7
7
|
StringMatchMethod,
|
|
8
|
-
Or,
|
|
9
8
|
IsNull,
|
|
10
9
|
} from "@peerbit/document";
|
|
11
|
-
import {
|
|
12
|
-
|
|
10
|
+
import {
|
|
11
|
+
PublicSignKey,
|
|
12
|
+
sha256Base64Sync,
|
|
13
|
+
randomBytes,
|
|
14
|
+
toBase64,
|
|
15
|
+
toBase64URL,
|
|
16
|
+
} from "@peerbit/crypto";
|
|
13
17
|
import { concat } from "uint8arrays";
|
|
14
18
|
import { sha256Sync } from "@peerbit/crypto";
|
|
15
19
|
import { TrustedNetwork } from "@peerbit/trusted-network";
|
|
16
|
-
import PQueue from "p-queue";
|
|
17
20
|
import { ReplicationOptions } from "@peerbit/shared-log";
|
|
21
|
+
import { SHA256 } from "@stablelib/sha256";
|
|
22
|
+
|
|
23
|
+
const sleep = (ms: number) =>
|
|
24
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
|
|
26
|
+
const isRetryableChunkLookupError = (error: unknown) =>
|
|
27
|
+
error instanceof Error &&
|
|
28
|
+
(error.name === "AbortError" ||
|
|
29
|
+
error.message.includes("fanout channel closed"));
|
|
30
|
+
|
|
31
|
+
type FileReadOptions = {
|
|
32
|
+
timeout?: number;
|
|
33
|
+
progress?: (progress: number) => any;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface ReReadableChunkSource {
|
|
37
|
+
size: bigint;
|
|
38
|
+
readChunks(chunkSize: number): AsyncIterable<Uint8Array>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ChunkWritable {
|
|
42
|
+
write(chunk: Uint8Array): Promise<void> | void;
|
|
43
|
+
close?(): Promise<void> | void;
|
|
44
|
+
abort?(reason?: unknown): Promise<void> | void;
|
|
45
|
+
}
|
|
18
46
|
|
|
19
47
|
export abstract class AbstractFile {
|
|
20
48
|
abstract id: string;
|
|
21
49
|
abstract name: string;
|
|
22
|
-
abstract size:
|
|
50
|
+
abstract size: bigint;
|
|
23
51
|
abstract parentId?: string;
|
|
24
|
-
abstract
|
|
52
|
+
abstract streamFile(
|
|
53
|
+
files: Files,
|
|
54
|
+
properties?: FileReadOptions
|
|
55
|
+
): AsyncIterable<Uint8Array>;
|
|
56
|
+
|
|
57
|
+
async getFile<
|
|
25
58
|
OutputType extends "chunks" | "joined" = "joined",
|
|
26
59
|
Output = OutputType extends "chunks" ? Uint8Array[] : Uint8Array,
|
|
27
60
|
>(
|
|
28
61
|
files: Files,
|
|
29
62
|
properties?: {
|
|
30
63
|
as: OutputType;
|
|
31
|
-
|
|
32
|
-
|
|
64
|
+
} & FileReadOptions
|
|
65
|
+
): Promise<Output> {
|
|
66
|
+
const chunks: Uint8Array[] = [];
|
|
67
|
+
for await (const chunk of this.streamFile(files, properties)) {
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
properties?.as === "chunks" ? chunks : concat(chunks)
|
|
72
|
+
) as Output;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async writeFile(
|
|
76
|
+
files: Files,
|
|
77
|
+
writable: ChunkWritable,
|
|
78
|
+
properties?: FileReadOptions
|
|
79
|
+
) {
|
|
80
|
+
try {
|
|
81
|
+
for await (const chunk of this.streamFile(files, properties)) {
|
|
82
|
+
await writable.write(chunk);
|
|
83
|
+
}
|
|
84
|
+
await writable.close?.();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (writable.abort) {
|
|
87
|
+
try {
|
|
88
|
+
await writable.abort(error);
|
|
89
|
+
} catch {
|
|
90
|
+
// Ignore writable cleanup failures.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
33
94
|
}
|
|
34
|
-
|
|
95
|
+
}
|
|
96
|
+
|
|
35
97
|
abstract delete(files: Files): Promise<void>;
|
|
36
98
|
}
|
|
37
99
|
|
|
100
|
+
@variant("files_indexable_file")
|
|
38
101
|
export class IndexableFile {
|
|
39
102
|
@field({ type: "string" })
|
|
40
103
|
id: string;
|
|
@@ -42,8 +105,8 @@ export class IndexableFile {
|
|
|
42
105
|
@field({ type: "string" })
|
|
43
106
|
name: string;
|
|
44
107
|
|
|
45
|
-
@field({ type: "
|
|
46
|
-
size:
|
|
108
|
+
@field({ type: "u64" })
|
|
109
|
+
size: bigint;
|
|
47
110
|
|
|
48
111
|
@field({ type: option("string") })
|
|
49
112
|
parentId?: string;
|
|
@@ -57,6 +120,57 @@ export class IndexableFile {
|
|
|
57
120
|
}
|
|
58
121
|
|
|
59
122
|
const TINY_FILE_SIZE_LIMIT = 5 * 1e6; // 6mb
|
|
123
|
+
const LARGE_FILE_SEGMENT_SIZE = TINY_FILE_SIZE_LIMIT / 10;
|
|
124
|
+
const LARGE_FILE_TARGET_CHUNK_COUNT = 1024;
|
|
125
|
+
const CHUNK_SIZE_GRANULARITY = 64 * 1024;
|
|
126
|
+
const MAX_LARGE_FILE_SEGMENT_SIZE = TINY_FILE_SIZE_LIMIT - 256 * 1024;
|
|
127
|
+
const LARGE_FILE_CHUNK_LOOKUP_TIMEOUT_MS = 5 * 60 * 1000;
|
|
128
|
+
const TINY_FILE_SIZE_LIMIT_BIGINT = BigInt(TINY_FILE_SIZE_LIMIT);
|
|
129
|
+
|
|
130
|
+
const roundUpTo = (value: number, multiple: number) =>
|
|
131
|
+
Math.ceil(value / multiple) * multiple;
|
|
132
|
+
const getLargeFileSegmentSize = (size: number | bigint) =>
|
|
133
|
+
Math.min(
|
|
134
|
+
MAX_LARGE_FILE_SEGMENT_SIZE,
|
|
135
|
+
roundUpTo(
|
|
136
|
+
Math.max(
|
|
137
|
+
LARGE_FILE_SEGMENT_SIZE,
|
|
138
|
+
Math.ceil(Number(size) / LARGE_FILE_TARGET_CHUNK_COUNT)
|
|
139
|
+
),
|
|
140
|
+
CHUNK_SIZE_GRANULARITY
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
const getChunkStart = (index: number, chunkSize = LARGE_FILE_SEGMENT_SIZE) =>
|
|
144
|
+
index * chunkSize;
|
|
145
|
+
const getChunkEnd = (
|
|
146
|
+
index: number,
|
|
147
|
+
size: number | bigint,
|
|
148
|
+
chunkSize = LARGE_FILE_SEGMENT_SIZE
|
|
149
|
+
) => Math.min((index + 1) * chunkSize, Number(size));
|
|
150
|
+
const getChunkCount = (
|
|
151
|
+
size: number | bigint,
|
|
152
|
+
chunkSize = LARGE_FILE_SEGMENT_SIZE
|
|
153
|
+
) => Math.ceil(Number(size) / chunkSize);
|
|
154
|
+
const getChunkId = (parentId: string, index: number) => `${parentId}:${index}`;
|
|
155
|
+
const createUploadId = () => toBase64URL(randomBytes(16));
|
|
156
|
+
const isBlobLike = (value: Uint8Array | Blob): value is Blob =>
|
|
157
|
+
typeof Blob !== "undefined" && value instanceof Blob;
|
|
158
|
+
const readBlobChunk = async (blob: Blob, index: number, chunkSize: number) =>
|
|
159
|
+
new Uint8Array(
|
|
160
|
+
await blob
|
|
161
|
+
.slice(
|
|
162
|
+
getChunkStart(index, chunkSize),
|
|
163
|
+
getChunkEnd(index, blob.size, chunkSize)
|
|
164
|
+
)
|
|
165
|
+
.arrayBuffer()
|
|
166
|
+
);
|
|
167
|
+
const ensureSourceSize = (actual: bigint, expected: bigint) => {
|
|
168
|
+
if (actual !== expected) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Source size changed during upload. Expected ${expected} bytes, got ${actual}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
60
174
|
|
|
61
175
|
@variant(0) // for versioning purposes
|
|
62
176
|
export class TinyFile extends AbstractFile {
|
|
@@ -69,40 +183,49 @@ export class TinyFile extends AbstractFile {
|
|
|
69
183
|
@field({ type: Uint8Array })
|
|
70
184
|
file: Uint8Array; // 10 mb imit
|
|
71
185
|
|
|
186
|
+
@field({ type: "string" })
|
|
187
|
+
hash: string;
|
|
188
|
+
|
|
72
189
|
@field({ type: option("string") })
|
|
73
190
|
parentId?: string;
|
|
74
191
|
|
|
192
|
+
@field({ type: option("u32") })
|
|
193
|
+
index?: number;
|
|
194
|
+
|
|
75
195
|
get size() {
|
|
76
|
-
return this.file.byteLength;
|
|
196
|
+
return BigInt(this.file.byteLength);
|
|
77
197
|
}
|
|
78
198
|
|
|
79
199
|
constructor(properties: {
|
|
80
200
|
id?: string;
|
|
81
201
|
name: string;
|
|
82
202
|
file: Uint8Array;
|
|
203
|
+
hash?: string;
|
|
83
204
|
parentId?: string;
|
|
205
|
+
index?: number;
|
|
84
206
|
}) {
|
|
85
207
|
super();
|
|
86
|
-
this.
|
|
208
|
+
this.parentId = properties.parentId;
|
|
209
|
+
this.index = properties.index;
|
|
210
|
+
this.id =
|
|
211
|
+
properties.id ||
|
|
212
|
+
(properties.parentId != null && properties.index != null
|
|
213
|
+
? `${properties.parentId}:${properties.index}`
|
|
214
|
+
: sha256Base64Sync(properties.file));
|
|
87
215
|
this.name = properties.name;
|
|
88
216
|
this.file = properties.file;
|
|
89
|
-
this.
|
|
217
|
+
this.hash = properties.hash || sha256Base64Sync(properties.file);
|
|
90
218
|
}
|
|
91
219
|
|
|
92
|
-
async
|
|
93
|
-
OutputType extends "chunks" | "joined" = "joined",
|
|
94
|
-
Output = OutputType extends "chunks" ? Uint8Array[] : Uint8Array,
|
|
95
|
-
>(
|
|
220
|
+
async *streamFile(
|
|
96
221
|
_files: Files,
|
|
97
|
-
properties?:
|
|
98
|
-
):
|
|
99
|
-
if (sha256Base64Sync(this.file) !== this.
|
|
222
|
+
properties?: FileReadOptions
|
|
223
|
+
): AsyncIterable<Uint8Array> {
|
|
224
|
+
if (sha256Base64Sync(this.file) !== this.hash) {
|
|
100
225
|
throw new Error("Hash does not match the file content");
|
|
101
226
|
}
|
|
102
227
|
properties?.progress?.(1);
|
|
103
|
-
|
|
104
|
-
properties?.as == "chunks" ? [this.file] : this.file
|
|
105
|
-
) as Output;
|
|
228
|
+
yield this.file;
|
|
106
229
|
}
|
|
107
230
|
|
|
108
231
|
async delete(): Promise<void> {
|
|
@@ -113,171 +236,192 @@ export class TinyFile extends AbstractFile {
|
|
|
113
236
|
@variant(1) // for versioning purposes
|
|
114
237
|
export class LargeFile extends AbstractFile {
|
|
115
238
|
@field({ type: "string" })
|
|
116
|
-
id: string;
|
|
239
|
+
id: string;
|
|
117
240
|
|
|
118
241
|
@field({ type: "string" })
|
|
119
242
|
name: string;
|
|
120
243
|
|
|
121
|
-
@field({ type:
|
|
122
|
-
|
|
244
|
+
@field({ type: "u64" })
|
|
245
|
+
size: bigint;
|
|
123
246
|
|
|
124
247
|
@field({ type: "u32" })
|
|
125
|
-
|
|
248
|
+
chunkCount: number;
|
|
249
|
+
|
|
250
|
+
@field({ type: "bool" })
|
|
251
|
+
ready: boolean;
|
|
252
|
+
|
|
253
|
+
@field({ type: option("string") })
|
|
254
|
+
finalHash?: string;
|
|
126
255
|
|
|
127
256
|
constructor(properties: {
|
|
128
|
-
id
|
|
257
|
+
id?: string;
|
|
129
258
|
name: string;
|
|
130
|
-
|
|
131
|
-
|
|
259
|
+
size: bigint;
|
|
260
|
+
chunkCount: number;
|
|
261
|
+
ready?: boolean;
|
|
262
|
+
finalHash?: string;
|
|
132
263
|
}) {
|
|
133
264
|
super();
|
|
134
|
-
this.id = properties.id;
|
|
265
|
+
this.id = properties.id || createUploadId();
|
|
135
266
|
this.name = properties.name;
|
|
136
|
-
this.fileIds = properties.fileIds;
|
|
137
267
|
this.size = properties.size;
|
|
268
|
+
this.chunkCount = properties.chunkCount;
|
|
269
|
+
this.ready = properties.ready ?? false;
|
|
270
|
+
this.finalHash = properties.finalHash;
|
|
138
271
|
}
|
|
139
272
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
273
|
+
get parentId() {
|
|
274
|
+
// Large file can never have a parent
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async fetchChunks(
|
|
143
279
|
files: Files,
|
|
144
|
-
|
|
280
|
+
properties?: {
|
|
281
|
+
timeout?: number;
|
|
282
|
+
}
|
|
145
283
|
) {
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
284
|
+
const chunks = new Map<number, TinyFile>();
|
|
285
|
+
const totalTimeout = properties?.timeout ?? 30_000;
|
|
286
|
+
const deadline = Date.now() + totalTimeout;
|
|
287
|
+
const queryTimeout = Math.min(totalTimeout, 5_000);
|
|
288
|
+
const searchOptions = {
|
|
289
|
+
local: true,
|
|
290
|
+
remote: {
|
|
291
|
+
timeout: queryTimeout,
|
|
292
|
+
throwOnMissing: false,
|
|
293
|
+
// Chunk queries return the full TinyFile document including its
|
|
294
|
+
// bytes. Observer reads can stream that result directly, while
|
|
295
|
+
// actual replicators should still persist downloaded chunks.
|
|
296
|
+
replicate: files.persistChunkReads,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
const recordChunks = (results: AbstractFile[]) => {
|
|
300
|
+
for (const chunk of results) {
|
|
301
|
+
if (
|
|
302
|
+
chunk instanceof TinyFile &&
|
|
303
|
+
chunk.parentId === this.id &&
|
|
304
|
+
chunk.index != null &&
|
|
305
|
+
!chunks.has(chunk.index)
|
|
306
|
+
) {
|
|
307
|
+
chunks.set(chunk.index, chunk);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
while (chunks.size < this.chunkCount && Date.now() < deadline) {
|
|
313
|
+
const before = chunks.size;
|
|
314
|
+
recordChunks(
|
|
315
|
+
await files.files.index.search(
|
|
316
|
+
new SearchRequest({
|
|
317
|
+
query: new StringMatch({ key: "parentId", value: this.id }),
|
|
318
|
+
fetch: 0xffffffff,
|
|
319
|
+
}),
|
|
320
|
+
searchOptions
|
|
162
321
|
)
|
|
163
322
|
);
|
|
323
|
+
|
|
324
|
+
if (chunks.size === before && chunks.size < this.chunkCount) {
|
|
325
|
+
await sleep(250);
|
|
326
|
+
}
|
|
164
327
|
}
|
|
165
|
-
progress?.(1);
|
|
166
|
-
return new LargeFile({ id, name, fileIds: fileIds, size: fileSize });
|
|
167
|
-
}
|
|
168
328
|
|
|
169
|
-
|
|
170
|
-
// Large file can never have a parent
|
|
171
|
-
return undefined;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async fetchChunks(files: Files) {
|
|
175
|
-
const expectedIds = new Set(this.fileIds);
|
|
176
|
-
const allFiles = await files.files.index.search(
|
|
177
|
-
new SearchRequest({
|
|
178
|
-
query: [
|
|
179
|
-
new Or(
|
|
180
|
-
[...expectedIds].map(
|
|
181
|
-
(x) => new StringMatch({ key: "id", value: x })
|
|
182
|
-
)
|
|
183
|
-
),
|
|
184
|
-
],
|
|
185
|
-
fetch: 0xffffffff,
|
|
186
|
-
})
|
|
187
|
-
);
|
|
188
|
-
return allFiles;
|
|
329
|
+
return [...chunks.values()].sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
189
330
|
}
|
|
190
331
|
async delete(files: Files) {
|
|
191
|
-
await Promise.all(
|
|
192
|
-
(await this.fetchChunks(files)).map((x) => x.delete(files))
|
|
193
|
-
);
|
|
332
|
+
await Promise.all((await this.fetchChunks(files)).map((x) => files.files.del(x.id)));
|
|
194
333
|
}
|
|
195
334
|
|
|
196
|
-
async
|
|
197
|
-
OutputType extends "chunks" | "joined" = "joined",
|
|
198
|
-
Output = OutputType extends "chunks" ? Uint8Array[] : Uint8Array,
|
|
199
|
-
>(
|
|
335
|
+
private async resolveChunk(
|
|
200
336
|
files: Files,
|
|
337
|
+
index: number,
|
|
338
|
+
knownChunks: Map<number, TinyFile>,
|
|
201
339
|
properties?: {
|
|
202
|
-
as: OutputType;
|
|
203
340
|
timeout?: number;
|
|
204
|
-
progress?: (progress: number) => any;
|
|
205
341
|
}
|
|
206
|
-
): Promise<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
342
|
+
): Promise<TinyFile> {
|
|
343
|
+
const totalTimeout =
|
|
344
|
+
properties?.timeout ?? LARGE_FILE_CHUNK_LOOKUP_TIMEOUT_MS;
|
|
345
|
+
const deadline = Date.now() + totalTimeout;
|
|
346
|
+
const attemptTimeout = Math.min(totalTimeout, 5_000);
|
|
347
|
+
const chunkId = getChunkId(this.id, index);
|
|
348
|
+
|
|
349
|
+
while (Date.now() < deadline) {
|
|
350
|
+
const cached = knownChunks.get(index);
|
|
351
|
+
if (cached) {
|
|
352
|
+
return cached;
|
|
353
|
+
}
|
|
218
354
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
355
|
+
try {
|
|
356
|
+
const chunk = await files.files.index.get(chunkId, {
|
|
357
|
+
local: true,
|
|
358
|
+
waitFor: attemptTimeout,
|
|
359
|
+
remote: {
|
|
360
|
+
timeout: attemptTimeout,
|
|
361
|
+
wait: {
|
|
362
|
+
timeout: attemptTimeout,
|
|
363
|
+
behavior: "keep-open",
|
|
364
|
+
},
|
|
365
|
+
throwOnMissing: false,
|
|
366
|
+
retryMissingResponses: true,
|
|
367
|
+
replicate: files.persistChunkReads,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (
|
|
372
|
+
chunk instanceof TinyFile &&
|
|
373
|
+
chunk.parentId === this.id &&
|
|
374
|
+
chunk.index === index
|
|
375
|
+
) {
|
|
376
|
+
knownChunks.set(index, chunk);
|
|
377
|
+
return chunk;
|
|
226
378
|
}
|
|
227
|
-
|
|
228
|
-
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (!isRetryableChunkLookupError(error)) {
|
|
381
|
+
throw error;
|
|
229
382
|
}
|
|
230
|
-
fetchQueue
|
|
231
|
-
.add(async () => {
|
|
232
|
-
let lastError: Error | undefined = undefined;
|
|
233
|
-
for (let i = 0; i < 3; i++) {
|
|
234
|
-
try {
|
|
235
|
-
const chunk = await r.getFile(files, {
|
|
236
|
-
as: "joined",
|
|
237
|
-
timeout: properties?.timeout,
|
|
238
|
-
});
|
|
239
|
-
if (!chunk) {
|
|
240
|
-
throw new Error("Failed to fetch chunk");
|
|
241
|
-
}
|
|
242
|
-
chunks.set(r.id, chunk);
|
|
243
|
-
c++;
|
|
244
|
-
properties?.progress?.(c / allChunks.length);
|
|
245
|
-
return;
|
|
246
|
-
} catch (error: any) {
|
|
247
|
-
// try 3 times
|
|
248
|
-
|
|
249
|
-
lastError = error;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
throw lastError;
|
|
253
|
-
})
|
|
254
|
-
.catch(() => {
|
|
255
|
-
fetchQueue.clear(); // Dont do anything more since we failed to fetch one block
|
|
256
|
-
});
|
|
257
383
|
}
|
|
384
|
+
|
|
385
|
+
await sleep(250);
|
|
258
386
|
}
|
|
259
|
-
await fetchQueue.onIdle();
|
|
260
387
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
388
|
+
throw new Error(
|
|
389
|
+
`Failed to resolve chunk ${index + 1}/${this.chunkCount} for file ${this.id}`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async *streamFile(
|
|
394
|
+
files: Files,
|
|
395
|
+
properties?: FileReadOptions
|
|
396
|
+
): AsyncIterable<Uint8Array> {
|
|
397
|
+
if (!this.ready) {
|
|
398
|
+
throw new Error("File is still uploading");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
properties?.progress?.(0);
|
|
402
|
+
|
|
403
|
+
let processed = 0;
|
|
404
|
+
const hasher = this.finalHash ? new SHA256() : undefined;
|
|
405
|
+
const knownChunks = new Map<number, TinyFile>();
|
|
406
|
+
for (let index = 0; index < this.chunkCount; index++) {
|
|
407
|
+
const chunkFile = await this.resolveChunk(files, index, knownChunks, {
|
|
408
|
+
timeout: properties?.timeout,
|
|
409
|
+
});
|
|
410
|
+
const chunk = await chunkFile.getFile(files, {
|
|
411
|
+
as: "joined",
|
|
412
|
+
timeout: properties?.timeout,
|
|
413
|
+
});
|
|
414
|
+
hasher?.update(chunk);
|
|
415
|
+
processed += chunk.byteLength;
|
|
416
|
+
properties?.progress?.(
|
|
417
|
+
processed / Math.max(Number(this.size), 1)
|
|
264
418
|
);
|
|
419
|
+
yield chunk;
|
|
265
420
|
}
|
|
266
421
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (!chunkValue) {
|
|
271
|
-
throw new Error("Failed to retrieve chunk with id: " + x);
|
|
272
|
-
}
|
|
273
|
-
return chunkValue;
|
|
274
|
-
})
|
|
275
|
-
);
|
|
276
|
-
return (
|
|
277
|
-
properties?.as == "chunks"
|
|
278
|
-
? chunkContentResolved
|
|
279
|
-
: concat(chunkContentResolved)
|
|
280
|
-
) as Output;
|
|
422
|
+
if (hasher && toBase64(hasher.digest()) !== this.finalHash) {
|
|
423
|
+
throw new Error("File hash does not match the expected content");
|
|
424
|
+
}
|
|
281
425
|
}
|
|
282
426
|
}
|
|
283
427
|
|
|
@@ -297,6 +441,8 @@ export class Files extends Program<Args> {
|
|
|
297
441
|
@field({ type: Documents })
|
|
298
442
|
files: Documents<AbstractFile, IndexableFile>;
|
|
299
443
|
|
|
444
|
+
persistChunkReads: boolean;
|
|
445
|
+
|
|
300
446
|
constructor(
|
|
301
447
|
properties: {
|
|
302
448
|
id?: Uint8Array;
|
|
@@ -319,27 +465,225 @@ export class Files extends Program<Args> {
|
|
|
319
465
|
])
|
|
320
466
|
),
|
|
321
467
|
});
|
|
468
|
+
this.persistChunkReads = true;
|
|
322
469
|
}
|
|
323
470
|
|
|
324
471
|
async add(
|
|
325
472
|
name: string,
|
|
326
|
-
file: Uint8Array,
|
|
473
|
+
file: Uint8Array | Blob,
|
|
474
|
+
parentId?: string,
|
|
475
|
+
progress?: (progress: number) => void
|
|
476
|
+
) {
|
|
477
|
+
if (isBlobLike(file)) {
|
|
478
|
+
return this.addBlob(name, file, parentId, progress);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
progress?.(0);
|
|
482
|
+
if (BigInt(file.byteLength) <= TINY_FILE_SIZE_LIMIT_BIGINT) {
|
|
483
|
+
const tinyFile = new TinyFile({ name, file, parentId });
|
|
484
|
+
await this.files.put(tinyFile);
|
|
485
|
+
progress?.(1);
|
|
486
|
+
return tinyFile.id;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const chunkSize = getLargeFileSegmentSize(file.byteLength);
|
|
490
|
+
return this.addChunkedFile(
|
|
491
|
+
name,
|
|
492
|
+
BigInt(file.byteLength),
|
|
493
|
+
(index) =>
|
|
494
|
+
Promise.resolve(
|
|
495
|
+
file.subarray(
|
|
496
|
+
getChunkStart(index, chunkSize),
|
|
497
|
+
getChunkEnd(index, file.byteLength, chunkSize)
|
|
498
|
+
)
|
|
499
|
+
),
|
|
500
|
+
chunkSize,
|
|
501
|
+
parentId,
|
|
502
|
+
progress
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async addBlob(
|
|
507
|
+
name: string,
|
|
508
|
+
file: Blob,
|
|
327
509
|
parentId?: string,
|
|
328
510
|
progress?: (progress: number) => void
|
|
329
511
|
) {
|
|
330
|
-
let toPut: AbstractFile;
|
|
331
512
|
progress?.(0);
|
|
332
|
-
if (file.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
513
|
+
if (BigInt(file.size) <= TINY_FILE_SIZE_LIMIT_BIGINT) {
|
|
514
|
+
const tinyFile = new TinyFile({
|
|
515
|
+
name,
|
|
516
|
+
file: new Uint8Array(await file.arrayBuffer()),
|
|
517
|
+
parentId,
|
|
518
|
+
});
|
|
519
|
+
await this.files.put(tinyFile);
|
|
520
|
+
progress?.(1);
|
|
521
|
+
return tinyFile.id;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const chunkSize = getLargeFileSegmentSize(file.size);
|
|
525
|
+
return this.addChunkedFile(
|
|
526
|
+
name,
|
|
527
|
+
BigInt(file.size),
|
|
528
|
+
(index) => readBlobChunk(file, index, chunkSize),
|
|
529
|
+
chunkSize,
|
|
530
|
+
parentId,
|
|
531
|
+
progress
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async addSource(
|
|
536
|
+
name: string,
|
|
537
|
+
source: ReReadableChunkSource,
|
|
538
|
+
parentId?: string,
|
|
539
|
+
progress?: (progress: number) => void
|
|
540
|
+
) {
|
|
541
|
+
progress?.(0);
|
|
542
|
+
if (source.size <= TINY_FILE_SIZE_LIMIT_BIGINT) {
|
|
543
|
+
const chunks: Uint8Array[] = [];
|
|
544
|
+
let processed = 0n;
|
|
545
|
+
for await (const chunk of source.readChunks(TINY_FILE_SIZE_LIMIT)) {
|
|
546
|
+
chunks.push(chunk);
|
|
547
|
+
processed += BigInt(chunk.byteLength);
|
|
337
548
|
}
|
|
338
|
-
|
|
549
|
+
ensureSourceSize(processed, source.size);
|
|
550
|
+
const tinyFile = new TinyFile({
|
|
551
|
+
name,
|
|
552
|
+
file: chunks.length === 0 ? new Uint8Array(0) : concat(chunks),
|
|
553
|
+
parentId,
|
|
554
|
+
});
|
|
555
|
+
await this.files.put(tinyFile);
|
|
556
|
+
progress?.(1);
|
|
557
|
+
return tinyFile.id;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (parentId) {
|
|
561
|
+
throw new Error("Unexpected that a LargeFile to have a parent");
|
|
339
562
|
}
|
|
340
|
-
|
|
563
|
+
|
|
564
|
+
const size = source.size;
|
|
565
|
+
const chunkSize = getLargeFileSegmentSize(size);
|
|
566
|
+
const uploadId = createUploadId();
|
|
567
|
+
const manifest = new LargeFile({
|
|
568
|
+
id: uploadId,
|
|
569
|
+
name,
|
|
570
|
+
size,
|
|
571
|
+
chunkCount: getChunkCount(size, chunkSize),
|
|
572
|
+
ready: false,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
await this.files.put(manifest);
|
|
576
|
+
const hasher = new SHA256();
|
|
577
|
+
try {
|
|
578
|
+
let uploadedBytes = 0n;
|
|
579
|
+
let chunkCount = 0;
|
|
580
|
+
for await (const chunkBytes of source.readChunks(chunkSize)) {
|
|
581
|
+
hasher.update(chunkBytes);
|
|
582
|
+
await this.files.put(
|
|
583
|
+
new TinyFile({
|
|
584
|
+
name: name + "/" + chunkCount,
|
|
585
|
+
file: chunkBytes,
|
|
586
|
+
parentId: uploadId,
|
|
587
|
+
index: chunkCount,
|
|
588
|
+
})
|
|
589
|
+
);
|
|
590
|
+
uploadedBytes += BigInt(chunkBytes.byteLength);
|
|
591
|
+
chunkCount++;
|
|
592
|
+
progress?.(Number(uploadedBytes) / Math.max(Number(size), 1));
|
|
593
|
+
}
|
|
594
|
+
ensureSourceSize(uploadedBytes, source.size);
|
|
595
|
+
await this.files.put(
|
|
596
|
+
new LargeFile({
|
|
597
|
+
id: uploadId,
|
|
598
|
+
name,
|
|
599
|
+
size,
|
|
600
|
+
chunkCount,
|
|
601
|
+
ready: true,
|
|
602
|
+
finalHash: toBase64(hasher.digest()),
|
|
603
|
+
})
|
|
604
|
+
);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
await this.cleanupChunkedUpload(uploadId).catch(() => {});
|
|
607
|
+
await this.files.del(uploadId).catch(() => {});
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
|
|
341
611
|
progress?.(1);
|
|
342
|
-
return
|
|
612
|
+
return uploadId;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private async cleanupChunkedUpload(uploadId: string) {
|
|
616
|
+
const chunks = await this.files.index.search(
|
|
617
|
+
new SearchRequest({
|
|
618
|
+
query: new StringMatch({
|
|
619
|
+
key: "parentId",
|
|
620
|
+
value: uploadId,
|
|
621
|
+
}),
|
|
622
|
+
fetch: 0xffffffff,
|
|
623
|
+
}),
|
|
624
|
+
{ local: true }
|
|
625
|
+
);
|
|
626
|
+
await Promise.all(chunks.map((chunk) => this.files.del(chunk.id)));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async addChunkedFile(
|
|
630
|
+
name: string,
|
|
631
|
+
size: bigint,
|
|
632
|
+
getChunk: (index: number) => Promise<Uint8Array>,
|
|
633
|
+
chunkSize: number,
|
|
634
|
+
parentId?: string,
|
|
635
|
+
progress?: (progress: number) => void
|
|
636
|
+
) {
|
|
637
|
+
if (parentId) {
|
|
638
|
+
throw new Error("Unexpected that a LargeFile to have a parent");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const uploadId = createUploadId();
|
|
642
|
+
const chunkCount = getChunkCount(size, chunkSize);
|
|
643
|
+
const manifest = new LargeFile({
|
|
644
|
+
id: uploadId,
|
|
645
|
+
name,
|
|
646
|
+
size,
|
|
647
|
+
chunkCount,
|
|
648
|
+
ready: false,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
await this.files.put(manifest);
|
|
652
|
+
const hasher = new SHA256();
|
|
653
|
+
try {
|
|
654
|
+
let uploadedBytes = 0;
|
|
655
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
656
|
+
const chunkBytes = await getChunk(i);
|
|
657
|
+
hasher.update(chunkBytes);
|
|
658
|
+
await this.files.put(
|
|
659
|
+
new TinyFile({
|
|
660
|
+
name: name + "/" + i,
|
|
661
|
+
file: chunkBytes,
|
|
662
|
+
parentId: uploadId,
|
|
663
|
+
index: i,
|
|
664
|
+
})
|
|
665
|
+
);
|
|
666
|
+
uploadedBytes += chunkBytes.byteLength;
|
|
667
|
+
progress?.(uploadedBytes / Math.max(Number(size), 1));
|
|
668
|
+
}
|
|
669
|
+
await this.files.put(
|
|
670
|
+
new LargeFile({
|
|
671
|
+
id: uploadId,
|
|
672
|
+
name,
|
|
673
|
+
size,
|
|
674
|
+
chunkCount,
|
|
675
|
+
ready: true,
|
|
676
|
+
finalHash: toBase64(hasher.digest()),
|
|
677
|
+
})
|
|
678
|
+
);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
await this.cleanupChunkedUpload(uploadId).catch(() => {});
|
|
681
|
+
await this.files.del(uploadId).catch(() => {});
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
progress?.(1);
|
|
686
|
+
return uploadId;
|
|
343
687
|
}
|
|
344
688
|
|
|
345
689
|
async removeById(id: string) {
|
|
@@ -378,7 +722,10 @@ export class Files extends Program<Args> {
|
|
|
378
722
|
{
|
|
379
723
|
local: true,
|
|
380
724
|
remote: {
|
|
381
|
-
|
|
725
|
+
// Allow partial results while the network is still forming. If we
|
|
726
|
+
// throw on missing shards here, the UI can appear "empty" until
|
|
727
|
+
// all shard roots respond, which feels broken during joins/churn.
|
|
728
|
+
throwOnMissing: false,
|
|
382
729
|
replicate: true, // sync here because this, because we might want to access it offline, even though we are not replicators
|
|
383
730
|
},
|
|
384
731
|
}
|
|
@@ -396,6 +743,62 @@ export class Files extends Program<Args> {
|
|
|
396
743
|
return count;
|
|
397
744
|
}
|
|
398
745
|
|
|
746
|
+
async resolveById(
|
|
747
|
+
id: string,
|
|
748
|
+
properties?: {
|
|
749
|
+
timeout?: number;
|
|
750
|
+
replicate?: boolean;
|
|
751
|
+
}
|
|
752
|
+
): Promise<AbstractFile | undefined> {
|
|
753
|
+
return this.files.index.get(id, {
|
|
754
|
+
local: true,
|
|
755
|
+
waitFor: properties?.timeout,
|
|
756
|
+
remote: {
|
|
757
|
+
timeout: properties?.timeout ?? 10 * 1000,
|
|
758
|
+
wait: properties?.timeout
|
|
759
|
+
? {
|
|
760
|
+
timeout: properties.timeout,
|
|
761
|
+
behavior: "keep-open",
|
|
762
|
+
}
|
|
763
|
+
: undefined,
|
|
764
|
+
throwOnMissing: false,
|
|
765
|
+
retryMissingResponses: true,
|
|
766
|
+
replicate: properties?.replicate,
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async resolveByName(
|
|
772
|
+
name: string,
|
|
773
|
+
properties?: {
|
|
774
|
+
timeout?: number;
|
|
775
|
+
replicate?: boolean;
|
|
776
|
+
}
|
|
777
|
+
): Promise<AbstractFile | undefined> {
|
|
778
|
+
const results = await this.files.index.search(
|
|
779
|
+
new SearchRequest({
|
|
780
|
+
query: [
|
|
781
|
+
new StringMatch({
|
|
782
|
+
key: "name",
|
|
783
|
+
value: name,
|
|
784
|
+
caseInsensitive: false,
|
|
785
|
+
method: StringMatchMethod.exact,
|
|
786
|
+
}),
|
|
787
|
+
],
|
|
788
|
+
fetch: 1,
|
|
789
|
+
}),
|
|
790
|
+
{
|
|
791
|
+
local: true,
|
|
792
|
+
remote: {
|
|
793
|
+
timeout: properties?.timeout ?? 10 * 1000,
|
|
794
|
+
throwOnMissing: false,
|
|
795
|
+
replicate: properties?.replicate,
|
|
796
|
+
},
|
|
797
|
+
}
|
|
798
|
+
);
|
|
799
|
+
return results[0];
|
|
800
|
+
}
|
|
801
|
+
|
|
399
802
|
/**
|
|
400
803
|
* Get by name
|
|
401
804
|
* @param id
|
|
@@ -472,6 +875,7 @@ export class Files extends Program<Args> {
|
|
|
472
875
|
|
|
473
876
|
// Setup lifecycle, will be invoked on 'open'
|
|
474
877
|
async open(args?: Args): Promise<void> {
|
|
878
|
+
this.persistChunkReads = args?.replicate !== false;
|
|
475
879
|
await this.trustGraph?.open({
|
|
476
880
|
replicate: args?.replicate,
|
|
477
881
|
});
|