@peerbit/please-lib 2.0.1 → 2.0.3

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/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 { PublicSignKey, sha256Base64Sync, randomBytes } from "@peerbit/crypto";
12
- import { ProgramClient } from "@peerbit/program";
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: number;
50
+ abstract size: bigint;
23
51
  abstract parentId?: string;
24
- abstract getFile<
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
- timeout?: number;
32
- progress?: (progress: number) => any;
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
- ): Promise<Output>;
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: "u32" })
46
- size: number;
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.id = properties.id || sha256Base64Sync(properties.file);
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.parentId = properties.parentId;
217
+ this.hash = properties.hash || sha256Base64Sync(properties.file);
90
218
  }
91
219
 
92
- async getFile<
93
- OutputType extends "chunks" | "joined" = "joined",
94
- Output = OutputType extends "chunks" ? Uint8Array[] : Uint8Array,
95
- >(
220
+ async *streamFile(
96
221
  _files: Files,
97
- properties?: { as: OutputType; progress?: (progress: number) => any }
98
- ): Promise<Output> {
99
- if (sha256Base64Sync(this.file) !== this.id) {
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
- return Promise.resolve(
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; // hash
239
+ id: string;
117
240
 
118
241
  @field({ type: "string" })
119
242
  name: string;
120
243
 
121
- @field({ type: vec("string") })
122
- fileIds: string[];
244
+ @field({ type: "u64" })
245
+ size: bigint;
123
246
 
124
247
  @field({ type: "u32" })
125
- size: number;
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: string;
257
+ id?: string;
129
258
  name: string;
130
- fileIds: string[];
131
- size: number;
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
- static async create(
141
- name: string,
142
- file: Uint8Array,
273
+ get parentId() {
274
+ // Large file can never have a parent
275
+ return undefined;
276
+ }
277
+
278
+ async fetchChunks(
143
279
  files: Files,
144
- progress?: (progress: number) => void
280
+ properties?: {
281
+ timeout?: number;
282
+ }
145
283
  ) {
146
- const segmetSize = TINY_FILE_SIZE_LIMIT / 10; // 10% of the small size limit
147
- const fileIds: string[] = [];
148
- const id = sha256Base64Sync(file);
149
- const fileSize = file.byteLength;
150
- progress?.(0);
151
- const end = Math.ceil(file.byteLength / segmetSize);
152
- for (let i = 0; i < end; i++) {
153
- progress?.((i + 1) / end);
154
- fileIds.push(
155
- await files.add(
156
- name + "/" + i,
157
- file.subarray(
158
- i * segmetSize,
159
- Math.min((i + 1) * segmetSize, file.byteLength)
160
- ),
161
- id
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
- get parentId() {
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 getFile<
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<Output> {
207
- // Get all sub files (SmallFiles) and concatinate them in the right order (the order of this.fileIds)
208
-
209
- properties?.progress?.(0);
210
-
211
- const allChunks = await this.fetchChunks(files);
212
-
213
- const fetchQueue = new PQueue({ concurrency: 10 });
214
- let fetchError: Error | undefined = undefined;
215
- fetchQueue.on("error", (err) => {
216
- fetchError = err;
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
- const chunks: Map<string, Uint8Array | undefined> = new Map();
220
- const expectedIds = new Set(this.fileIds);
221
- if (allChunks.length > 0) {
222
- let c = 0;
223
- for (const r of allChunks) {
224
- if (chunks.has(r.id)) {
225
- // chunk already added;
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
- if (!expectedIds.has(r.id)) {
228
- // chunk is not part of this file
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
- if (fetchError || chunks.size !== expectedIds.size) {
262
- throw new Error(
263
- `Failed to resolve file. Recieved ${chunks.size}/${expectedIds.size} chunks`
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
- const chunkContentResolved: Uint8Array[] = await Promise.all(
268
- this.fileIds.map(async (x) => {
269
- const chunkValue = await chunks.get(x);
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.byteLength <= TINY_FILE_SIZE_LIMIT) {
333
- toPut = new TinyFile({ name, file, parentId });
334
- } else {
335
- if (parentId) {
336
- throw new Error("Unexpected that a LargeFile to have a parent");
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
- toPut = await LargeFile.create(name, file, this, progress);
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
- await this.files.put(toPut);
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 toPut.id;
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
- throwOnMissing: true,
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
  });