@pylonsync/sync 0.22.47 → 0.22.58

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.
@@ -0,0 +1,593 @@
1
+ // Offline upload queue.
2
+ //
3
+ // `SyncEngine.queueUpload(file, { attach })` stores the file's bytes on the
4
+ // device and returns at once. The engine's leader tab uploads queued files
5
+ // one at a time when it has a network (`uploadBytes`: init, PUT, confirm),
6
+ // then sets the file id on the record the upload belongs to with an
7
+ // ordinary queued update, so policies and owner rules apply as for any
8
+ // write.
9
+ //
10
+ // Storage: an IndexedDB database of its own (`pylon_uploads_<app>`), so the
11
+ // bytes never sit in the replica database, whose wipe on an identity change
12
+ // would take them without saying so. Without IndexedDB (tests, React
13
+ // Native) the queue lives in memory.
14
+
15
+ import type { OutboxItem } from "./mutation-queue";
16
+ import type { TransportConfig } from "./transport";
17
+ import { uploadBytes, type FileVisibility, type UploadedFile } from "./upload";
18
+
19
+ /** Where an upload is attached when it finishes: `entity`/`id` gets
20
+ * `field` = the file id, and `urlField` (when set) = the file URL. */
21
+ export interface UploadAttachTarget {
22
+ entity: string;
23
+ id: string;
24
+ field: string;
25
+ urlField?: string;
26
+ }
27
+
28
+ /** One queued upload, as stored. */
29
+ export interface QueuedUpload {
30
+ /** `upl_...`. The record's field holds `local:<localId>` until the
31
+ * upload finishes. */
32
+ localId: string;
33
+ blob: Blob;
34
+ filename: string;
35
+ contentType: string;
36
+ size: number;
37
+ visibility: FileVisibility;
38
+ /** User id the upload was made as (`null` = anonymous). */
39
+ owner: string | null;
40
+ /** Made before this run knew who is signed in; stamped by the first
41
+ * resolved session. */
42
+ ownerPending?: boolean;
43
+ attach: UploadAttachTarget;
44
+ createdAt: number;
45
+ status: "pending" | "failed";
46
+ error?: string;
47
+ errorCode?: string;
48
+ /** Set once the server confirmed the file, before the attach update is
49
+ * queued. A restart in between attaches without uploading again. */
50
+ uploaded?: UploadedFile;
51
+ }
52
+
53
+ /** Persistence for queued uploads. */
54
+ export interface UploadStore {
55
+ put(record: QueuedUpload): Promise<void>;
56
+ delete(localId: string): Promise<void>;
57
+ list(): Promise<QueuedUpload[]>;
58
+ }
59
+
60
+ /** The prefix of a field value that names a queued upload. */
61
+ export const LOCAL_FILE_PREFIX = "local:";
62
+
63
+ /** `local:<localId>` → `<localId>`; null for any other value. */
64
+ export function localUploadId(ref: unknown): string | null {
65
+ return typeof ref === "string" && ref.startsWith(LOCAL_FILE_PREFIX)
66
+ ? ref.slice(LOCAL_FILE_PREFIX.length)
67
+ : null;
68
+ }
69
+
70
+ /** Default cap on the bytes waiting in the queue: 200 MB. */
71
+ export const DEFAULT_MAX_QUEUED_UPLOAD_BYTES = 200 * 1024 * 1024;
72
+
73
+ /** Thrown by `queueUpload` when the file would take the queue past its
74
+ * size limit. Past the limit the browser may evict the app's storage,
75
+ * which would lose every queued file, so the queue refuses instead. */
76
+ export class UploadQueueFullError extends Error {
77
+ readonly code = "UPLOAD_QUEUE_FULL";
78
+ readonly limitBytes: number;
79
+ readonly queuedBytes: number;
80
+ constructor(limitBytes: number, queuedBytes: number, fileBytes: number) {
81
+ super(
82
+ `The upload queue holds ${formatBytes(queuedBytes)}; this file (${formatBytes(fileBytes)}) ` +
83
+ `would pass the ${formatBytes(limitBytes)} limit. Connect to send the queued files first.`,
84
+ );
85
+ this.name = "UploadQueueFullError";
86
+ this.limitBytes = limitBytes;
87
+ this.queuedBytes = queuedBytes;
88
+ }
89
+ }
90
+
91
+ function formatBytes(n: number): string {
92
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
93
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
94
+ return `${n} B`;
95
+ }
96
+
97
+ export class MemoryUploadStore implements UploadStore {
98
+ private rows = new Map<string, QueuedUpload>();
99
+ async put(record: QueuedUpload): Promise<void> {
100
+ this.rows.set(record.localId, { ...record });
101
+ }
102
+ async delete(localId: string): Promise<void> {
103
+ this.rows.delete(localId);
104
+ }
105
+ async list(): Promise<QueuedUpload[]> {
106
+ return Array.from(this.rows.values(), (r) => ({ ...r }));
107
+ }
108
+ }
109
+
110
+ const UPLOAD_STORE = "uploads";
111
+
112
+ /** Queued uploads in IndexedDB database `pylon_uploads_<appName>`. */
113
+ export class IndexedDBUploadStore implements UploadStore {
114
+ private db: Promise<IDBDatabase> | null = null;
115
+ constructor(private readonly appName = "default") {}
116
+
117
+ private open(): Promise<IDBDatabase> {
118
+ if (!this.db) {
119
+ this.db = new Promise((resolve, reject) => {
120
+ const req = indexedDB.open(`pylon_uploads_${this.appName}`, 1);
121
+ req.onupgradeneeded = () => {
122
+ const db = req.result;
123
+ if (!db.objectStoreNames.contains(UPLOAD_STORE)) {
124
+ db.createObjectStore(UPLOAD_STORE, { keyPath: "localId" });
125
+ }
126
+ };
127
+ req.onsuccess = () => {
128
+ const db = req.result;
129
+ // Another tab deleting or upgrading the database: let it.
130
+ db.onversionchange = () => {
131
+ db.close();
132
+ this.db = null;
133
+ };
134
+ resolve(db);
135
+ };
136
+ req.onerror = () => reject(req.error);
137
+ });
138
+ this.db.catch(() => {
139
+ this.db = null;
140
+ });
141
+ }
142
+ return this.db;
143
+ }
144
+
145
+ private async run<T>(
146
+ mode: IDBTransactionMode,
147
+ fn: (store: IDBObjectStore) => IDBRequest<T> | void,
148
+ ): Promise<T | undefined> {
149
+ const db = await this.open();
150
+ return new Promise((resolve, reject) => {
151
+ const tx = db.transaction(UPLOAD_STORE, mode);
152
+ const req = fn(tx.objectStore(UPLOAD_STORE));
153
+ tx.oncomplete = () => resolve(req ? (req.result as T) : undefined);
154
+ tx.onerror = () => reject(tx.error);
155
+ tx.onabort = () => reject(tx.error ?? new Error("upload store transaction aborted"));
156
+ });
157
+ }
158
+
159
+ async put(record: QueuedUpload): Promise<void> {
160
+ await this.run("readwrite", (s) => s.put(record));
161
+ }
162
+ async delete(localId: string): Promise<void> {
163
+ await this.run("readwrite", (s) => s.delete(localId));
164
+ }
165
+ async list(): Promise<QueuedUpload[]> {
166
+ return ((await this.run<QueuedUpload[]>("readonly", (s) => s.getAll())) ?? []).slice();
167
+ }
168
+ /** Close the connection (tests; a deleteDatabase waits for it). */
169
+ async close(): Promise<void> {
170
+ if (!this.db) return;
171
+ const db = await this.db.catch(() => null);
172
+ db?.close();
173
+ this.db = null;
174
+ }
175
+ }
176
+
177
+ /** What the queue does with an upload, by its owner and who is signed in. */
178
+ export type UploadOwnerVerdict = "send" | "hold" | "discard";
179
+
180
+ /**
181
+ * The owner rule for uploads, the same as for queued writes: send when the
182
+ * upload's owner is signed in (or it was a guest's and that guest signed in
183
+ * to an account), hold while nobody or nobody known is signed in, discard
184
+ * when another user is signed in.
185
+ */
186
+ export function uploadOwnerVerdict(
187
+ owner: string | null,
188
+ current: string | null | undefined,
189
+ ): UploadOwnerVerdict {
190
+ if (current === undefined) return "hold";
191
+ if (owner === current) return "send";
192
+ if (current === null) return "hold";
193
+ if (typeof owner === "string" && owner.startsWith("guest_") && !current.startsWith("guest_")) {
194
+ return "send";
195
+ }
196
+ return "discard";
197
+ }
198
+
199
+ /** Whether an upload error is worth trying again later: no network, a
200
+ * server error, a timeout, or a rate limit. Anything else (the type or
201
+ * size was refused, a policy) is the server's answer. */
202
+ export function isTransientUploadError(err: unknown): boolean {
203
+ const status = (err as { status?: unknown })?.status;
204
+ if (typeof status !== "number") return true;
205
+ // 401: the session lapsed; the upload waits for the next sign-in.
206
+ return status >= 500 || status === 401 || status === 408 || status === 429;
207
+ }
208
+
209
+ /** One queued upload as the outbox shows it. */
210
+ export function uploadOutboxItem(r: QueuedUpload): OutboxItem {
211
+ const data: Record<string, unknown> = {
212
+ field: r.attach.field,
213
+ filename: r.filename,
214
+ contentType: r.contentType,
215
+ size: r.size,
216
+ };
217
+ if (r.attach.urlField) data.urlField = r.attach.urlField;
218
+ const item: OutboxItem = {
219
+ id: r.localId,
220
+ entity: r.attach.entity,
221
+ rowId: r.attach.id,
222
+ kind: "upload",
223
+ data,
224
+ createdAt: r.createdAt,
225
+ };
226
+ if (r.status === "failed") {
227
+ if (r.error !== undefined) item.error = r.error;
228
+ if (r.errorCode !== undefined) item.errorCode = r.errorCode;
229
+ }
230
+ return item;
231
+ }
232
+
233
+ export function newUploadId(): string {
234
+ return `upl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
235
+ }
236
+
237
+ /** What `queueUpload` returns. */
238
+ export interface QueuedUploadHandle {
239
+ localId: string;
240
+ /** `local:<localId>`: the value the record's field holds until the
241
+ * upload finishes. */
242
+ ref: string;
243
+ /** An object URL of the file, to show it at once. Null where the
244
+ * platform has no `URL.createObjectURL`. */
245
+ localUrl: string | null;
246
+ /** Resolves with the uploaded file once it is attached; rejects with an
247
+ * `UploadRejectedError` when the server refuses it or another user is
248
+ * signed in. A page that reloads loses this promise, not the upload. */
249
+ done: Promise<UploadedFile>;
250
+ }
251
+
252
+ /** Why a queued upload was not sent. `code` is the server's error code
253
+ * (`FILE_TOO_LARGE`, `POLICY_DENIED`, ...), or `DISCARDED` when another
254
+ * user is signed in. */
255
+ export class UploadRejectedError extends Error {
256
+ readonly code: string;
257
+ readonly localId: string;
258
+ constructor(message: string, code: string, localId: string) {
259
+ super(message);
260
+ this.name = "UploadRejectedError";
261
+ this.code = code;
262
+ this.localId = localId;
263
+ }
264
+ }
265
+
266
+ /** What the upload manager needs from the sync engine. */
267
+ export interface UploadManagerHost {
268
+ transport(): TransportConfig;
269
+ /** The signed-in user (`null` = anonymous), or `undefined` while it is
270
+ * not known (no answer from the server yet this run, or a token change
271
+ * in flight). */
272
+ currentOwner(): string | null | undefined;
273
+ /** The user a new upload belongs to; `undefined` = not known yet. */
274
+ ownerForNewUpload(): string | null | undefined;
275
+ /** Only the leader tab uploads. */
276
+ isLeader(): boolean;
277
+ isRunning(): boolean;
278
+ /** Queue an ordinary write that sets fields on a record. */
279
+ update(entity: string, id: string, patch: Record<string, unknown>): void;
280
+ /** The outbox changed. */
281
+ changed(): void;
282
+ /** Tell other tabs to reload the queue. */
283
+ broadcast(): void;
284
+ /** Run `fn` after `ms` unless the engine stops. */
285
+ later(ms: number, fn: () => void): void;
286
+ }
287
+
288
+ /**
289
+ * The offline upload queue: storage, the one-at-a-time upload loop, the
290
+ * owner rule, the size limit, and the outbox entries.
291
+ */
292
+ export class UploadManager {
293
+ private store: UploadStore = new MemoryUploadStore();
294
+ private records = new Map<string, QueuedUpload>();
295
+ private objectUrls = new Map<string, string>();
296
+ private waiters = new Map<
297
+ string,
298
+ Array<{ resolve: (f: UploadedFile) => void; reject: (e: UploadRejectedError) => void }>
299
+ >();
300
+ private running = false;
301
+ private failureCount = 0;
302
+ private retryScheduled = false;
303
+ private _version = 0;
304
+
305
+ constructor(
306
+ private readonly host: UploadManagerHost,
307
+ private readonly maxBytes: number = DEFAULT_MAX_QUEUED_UPLOAD_BYTES,
308
+ ) {}
309
+
310
+ /** Bumped on every change to the queue. */
311
+ version(): number {
312
+ return this._version;
313
+ }
314
+
315
+ private changed(): void {
316
+ this._version += 1;
317
+ this.host.changed();
318
+ }
319
+
320
+ /** Swap in persistent storage and load what it holds. */
321
+ async attachStore(store: UploadStore): Promise<void> {
322
+ this.store = store;
323
+ await this.reload();
324
+ }
325
+
326
+ /** Re-read the queue from storage (another tab changed it). */
327
+ async reload(): Promise<void> {
328
+ let rows: QueuedUpload[];
329
+ try {
330
+ rows = await this.store.list();
331
+ } catch (err) {
332
+ console.warn("[sync] upload queue load failed:", err);
333
+ return;
334
+ }
335
+ const next = new Map(rows.map((r) => [r.localId, r]));
336
+ for (const id of this.records.keys()) {
337
+ if (!next.has(id)) this.revoke(id);
338
+ }
339
+ this.records = next;
340
+ this.changed();
341
+ }
342
+
343
+ /** Queued uploads, oldest first. */
344
+ list(): QueuedUpload[] {
345
+ return Array.from(this.records.values()).sort((a, b) => a.createdAt - b.createdAt);
346
+ }
347
+
348
+ get(localId: string): QueuedUpload | undefined {
349
+ return this.records.get(localId);
350
+ }
351
+
352
+ /** Bytes waiting on the device. */
353
+ queuedBytes(): number {
354
+ let total = 0;
355
+ for (const r of this.records.values()) total += r.size;
356
+ return total;
357
+ }
358
+
359
+ async enqueue(
360
+ blob: Blob,
361
+ opts: {
362
+ attach: UploadAttachTarget;
363
+ filename?: string;
364
+ contentType?: string;
365
+ visibility?: FileVisibility;
366
+ },
367
+ ): Promise<QueuedUploadHandle> {
368
+ const { attach } = opts;
369
+ if (!attach || !attach.entity || !attach.id || !attach.field) {
370
+ throw new Error("queueUpload: attach needs entity, id and field");
371
+ }
372
+ const queued = this.queuedBytes();
373
+ if (queued + blob.size > this.maxBytes) {
374
+ throw new UploadQueueFullError(this.maxBytes, queued, blob.size);
375
+ }
376
+ const owner = this.host.ownerForNewUpload();
377
+ const localId = newUploadId();
378
+ const name =
379
+ opts.filename ??
380
+ (typeof File !== "undefined" && blob instanceof File ? blob.name : undefined) ??
381
+ "upload";
382
+ const record: QueuedUpload = {
383
+ localId,
384
+ blob,
385
+ filename: name,
386
+ contentType: opts.contentType ?? (blob.type || "application/octet-stream"),
387
+ size: blob.size,
388
+ visibility: opts.visibility ?? "private",
389
+ owner: owner ?? null,
390
+ attach: { ...attach },
391
+ createdAt: Date.now(),
392
+ status: "pending",
393
+ };
394
+ if (owner === undefined) record.ownerPending = true;
395
+ // Stored before the call returns: a page closed right after this keeps
396
+ // the file.
397
+ await this.store.put(record);
398
+ this.records.set(localId, record);
399
+ const done = new Promise<UploadedFile>((resolve, reject) => {
400
+ const list = this.waiters.get(localId) ?? [];
401
+ list.push({ resolve, reject });
402
+ this.waiters.set(localId, list);
403
+ });
404
+ // The caller may not await it; a rejection is still reported through
405
+ // the outbox.
406
+ done.catch(() => {});
407
+ const ref = LOCAL_FILE_PREFIX + localId;
408
+ const patch: Record<string, unknown> = { [attach.field]: ref };
409
+ if (attach.urlField) patch[attach.urlField] = ref;
410
+ this.host.update(attach.entity, attach.id, patch);
411
+ this.changed();
412
+ this.host.broadcast();
413
+ void this.process();
414
+ return { localId, ref, localUrl: this.localUrl(localId), done };
415
+ }
416
+
417
+ /** An object URL for a queued file, or null. */
418
+ localUrl(localId: string): string | null {
419
+ const cached = this.objectUrls.get(localId);
420
+ if (cached) return cached;
421
+ const record = this.records.get(localId);
422
+ if (!record || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
423
+ return null;
424
+ }
425
+ const url = URL.createObjectURL(record.blob);
426
+ this.objectUrls.set(localId, url);
427
+ return url;
428
+ }
429
+
430
+ private revoke(localId: string): void {
431
+ const url = this.objectUrls.get(localId);
432
+ if (!url) return;
433
+ this.objectUrls.delete(localId);
434
+ // A page may still show the local copy until the record's real URL
435
+ // renders; give it time.
436
+ this.host.later(30_000, () => URL.revokeObjectURL(url));
437
+ }
438
+
439
+ private settle(localId: string, result: { file?: UploadedFile; error?: UploadRejectedError }): void {
440
+ const list = this.waiters.get(localId);
441
+ if (!list) return;
442
+ this.waiters.delete(localId);
443
+ for (const w of list) {
444
+ if (result.file) w.resolve(result.file);
445
+ else if (result.error) w.reject(result.error);
446
+ }
447
+ }
448
+
449
+ private async remove(localId: string): Promise<void> {
450
+ this.records.delete(localId);
451
+ this.revoke(localId);
452
+ await this.store.delete(localId);
453
+ }
454
+
455
+ /** Stamp uploads made before anyone was known to be signed in. */
456
+ async stampPendingOwner(owner: string | null): Promise<void> {
457
+ for (const r of this.records.values()) {
458
+ if (!r.ownerPending) continue;
459
+ r.owner = owner;
460
+ delete r.ownerPending;
461
+ await this.store.put(r);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Upload every queued file the signed-in user owns, oldest first, one at
467
+ * a time. Stops at the first error that says the network is down and
468
+ * tries again later (and on the next reconnect). A file the server
469
+ * refuses is marked failed and not tried again until `retry`.
470
+ */
471
+ async process(): Promise<void> {
472
+ if (this.running || !this.host.isLeader() || !this.host.isRunning()) return;
473
+ this.running = true;
474
+ let changed = false;
475
+ try {
476
+ for (const record of this.list()) {
477
+ if (!this.records.has(record.localId) || record.status !== "pending") continue;
478
+ if (record.ownerPending) continue;
479
+ const verdict = uploadOwnerVerdict(record.owner, this.host.currentOwner());
480
+ if (verdict === "hold") continue;
481
+ if (verdict === "discard") {
482
+ await this.remove(record.localId);
483
+ changed = true;
484
+ const error = new UploadRejectedError(
485
+ "The upload was discarded because the signed-in user changed.",
486
+ "DISCARDED",
487
+ record.localId,
488
+ );
489
+ console.warn(`[sync] ${error.message} (${record.filename})`);
490
+ this.settle(record.localId, { error });
491
+ continue;
492
+ }
493
+ if (!record.uploaded) {
494
+ try {
495
+ record.uploaded = await uploadBytes(this.host.transport(), record.blob, {
496
+ filename: record.filename,
497
+ contentType: record.contentType,
498
+ visibility: record.visibility,
499
+ });
500
+ } catch (err) {
501
+ if (isTransientUploadError(err)) {
502
+ this.scheduleRetry();
503
+ break;
504
+ }
505
+ const code = (err as { code?: string }).code ?? "UPLOAD_REJECTED";
506
+ const message = err instanceof Error ? err.message : String(err);
507
+ record.status = "failed";
508
+ record.error = message;
509
+ record.errorCode = code;
510
+ await this.store.put(record);
511
+ changed = true;
512
+ this.settle(record.localId, {
513
+ error: new UploadRejectedError(message, code, record.localId),
514
+ });
515
+ continue;
516
+ }
517
+ this.failureCount = 0;
518
+ // Remember the confirmed file before attaching, so a restart
519
+ // here attaches instead of uploading a second copy.
520
+ await this.store.put(record);
521
+ }
522
+ const file = record.uploaded;
523
+ const patch: Record<string, unknown> = { [record.attach.field]: file.id };
524
+ if (record.attach.urlField) patch[record.attach.urlField] = file.url;
525
+ this.host.update(record.attach.entity, record.attach.id, patch);
526
+ await this.remove(record.localId);
527
+ changed = true;
528
+ this.settle(record.localId, { file });
529
+ }
530
+ } finally {
531
+ this.running = false;
532
+ if (changed) {
533
+ this.changed();
534
+ this.host.broadcast();
535
+ }
536
+ }
537
+ }
538
+
539
+ private scheduleRetry(): void {
540
+ if (this.retryScheduled) return;
541
+ this.retryScheduled = true;
542
+ const attempt = this.failureCount;
543
+ this.failureCount += 1;
544
+ const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
545
+ this.host.later(delay, () => {
546
+ this.retryScheduled = false;
547
+ void this.process();
548
+ });
549
+ }
550
+
551
+ /** Queue a failed upload again. Throws `UploadRejectedError` with code
552
+ * `OWNER_MISMATCH` / `OWNER_UNKNOWN` as `retryWrite` does. */
553
+ async retry(localId: string): Promise<void> {
554
+ const record = this.records.get(localId);
555
+ if (!record || record.status !== "failed") {
556
+ throw new Error(`retryWrite: no failed upload with id ${localId}`);
557
+ }
558
+ const current = this.host.currentOwner();
559
+ if (current === undefined || record.ownerPending) {
560
+ throw new UploadRejectedError(
561
+ "This upload cannot be sent again: it is not known who is signed in.",
562
+ "OWNER_UNKNOWN",
563
+ localId,
564
+ );
565
+ }
566
+ if (uploadOwnerVerdict(record.owner, current) !== "send") {
567
+ throw new UploadRejectedError(
568
+ "This upload was made by another user and cannot be sent as the signed-in user.",
569
+ "OWNER_MISMATCH",
570
+ localId,
571
+ );
572
+ }
573
+ record.status = "pending";
574
+ delete record.error;
575
+ delete record.errorCode;
576
+ await this.store.put(record);
577
+ this.changed();
578
+ this.host.broadcast();
579
+ await this.process();
580
+ }
581
+
582
+ /** Drop a failed upload and its bytes. False when there is no failed
583
+ * upload with that id. The record keeps its `local:` value. */
584
+ dismiss(localId: string): boolean {
585
+ const record = this.records.get(localId);
586
+ if (!record || record.status !== "failed") return false;
587
+ this.remove(localId)
588
+ .catch((err) => console.warn("[sync] upload queue delete failed:", err))
589
+ .finally(() => this.host.broadcast());
590
+ this.changed();
591
+ return true;
592
+ }
593
+ }