@pylonsync/sync 0.22.47 → 0.22.50
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/dist/index.d.ts +73 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/server.d.ts +84 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/index.ts +265 -2
- package/src/multi-tab-orchestrator.test.ts +2 -0
- package/src/multi-tab-orchestrator.ts +13 -0
- package/src/mutation-queue.ts +162 -7
- package/src/offline-conflict.test.ts +110 -0
- package/src/outbox.test.ts +333 -0
- package/src/test-harness/server.ts +91 -0
- package/src/test-harness/transport.ts +17 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { LocalStore } from "./local-store";
|
|
2
|
-
import { MutationQueue } from "./mutation-queue";
|
|
2
|
+
import { MutationQueue, type Outbox } from "./mutation-queue";
|
|
3
|
+
import { UploadManager, type QueuedUploadHandle, type UploadAttachTarget } from "./upload-queue";
|
|
4
|
+
import type { FileVisibility } from "./upload";
|
|
3
5
|
import { type RoomError, type RoomMember, type RoomSubscriber } from "./room-subscriptions";
|
|
4
6
|
import { SessionResolver } from "./session-resolver";
|
|
5
7
|
export { RoomSubscriptions, rowRoom, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
|
|
@@ -8,8 +10,13 @@ export type { ReplicaPersistence } from "./persistence";
|
|
|
8
10
|
export { BEARER_REJECTED_HEADER, bearerRejected, buildRequest, clearRejectedToken, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
|
|
9
11
|
export type { PylonRequestInit, TransportConfig } from "./transport";
|
|
10
12
|
export { LocalStore } from "./local-store";
|
|
11
|
-
export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, type PendingMutation, } from "./mutation-queue";
|
|
13
|
+
export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, type Outbox, type OutboxItem, type PendingMutation, } from "./mutation-queue";
|
|
14
|
+
/** The store channel the outbox notifies on. `store.subscribe(cb,
|
|
15
|
+
* OUTBOX_CHANNEL)` hears outbox changes and no entity changes. */
|
|
16
|
+
export declare const OUTBOX_CHANNEL = "$outbox";
|
|
12
17
|
export { generateId } from "./ids";
|
|
18
|
+
export { uploadBytes, type FileVisibility, type UploadBytesOptions, type UploadedFile, } from "./upload";
|
|
19
|
+
export { DEFAULT_MAX_QUEUED_UPLOAD_BYTES, IndexedDBUploadStore, LOCAL_FILE_PREFIX, localUploadId, MemoryUploadStore, UploadQueueFullError, UploadRejectedError, type QueuedUpload, type QueuedUploadHandle, type UploadAttachTarget, type UploadStore, } from "./upload-queue";
|
|
13
20
|
export type { ChangeEvent, ClientChange, PullResponse, PushOpResult, PushResponse, ReactiveMessage, ReactiveSpec, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType, } from "./types";
|
|
14
21
|
export { defaultStorage, createWriteThroughStorage, type Storage, } from "./storage";
|
|
15
22
|
import type { ReactiveMessage, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType } from "./types";
|
|
@@ -105,6 +112,9 @@ export interface SyncEngineConfig {
|
|
|
105
112
|
* its own leader (the pre-multi-tab semantics).
|
|
106
113
|
*/
|
|
107
114
|
multiTab?: boolean;
|
|
115
|
+
/** Cap on the bytes `queueUpload` keeps on the device. Default 200 MB.
|
|
116
|
+
* Past it `queueUpload` throws `UploadQueueFullError`. */
|
|
117
|
+
maxQueuedUploadBytes?: number;
|
|
108
118
|
}
|
|
109
119
|
export declare class SyncEngine {
|
|
110
120
|
private config;
|
|
@@ -212,6 +222,8 @@ export declare class SyncEngine {
|
|
|
212
222
|
private persistDegraded;
|
|
213
223
|
readonly store: LocalStore;
|
|
214
224
|
readonly mutations: MutationQueue;
|
|
225
|
+
/** Files queued with `queueUpload`, sent when there is a network. */
|
|
226
|
+
readonly uploads: UploadManager;
|
|
215
227
|
/**
|
|
216
228
|
* Stable per-client identifier. Minted on first construction, not
|
|
217
229
|
* necessarily persisted (depends on what the host provides).
|
|
@@ -399,6 +411,65 @@ export declare class SyncEngine {
|
|
|
399
411
|
* dev HUD's sync row; also handy for a "saving…" indicator.
|
|
400
412
|
*/
|
|
401
413
|
pendingCount(): number;
|
|
414
|
+
private outboxCache;
|
|
415
|
+
/**
|
|
416
|
+
* Writes made here that the server has not applied yet (`pending`:
|
|
417
|
+
* offline, or waiting for a sign-in), and writes the server rejected
|
|
418
|
+
* (`failed`, with its error). Oldest first. The same object is
|
|
419
|
+
* returned until the queue changes, so it can back
|
|
420
|
+
* `useSyncExternalStore`. Changes notify store subscribers on
|
|
421
|
+
* `OUTBOX_CHANNEL`.
|
|
422
|
+
*/
|
|
423
|
+
outbox(): Outbox;
|
|
424
|
+
/**
|
|
425
|
+
* Send a failed write again, with `data` (when given) merged over the
|
|
426
|
+
* fields it set. The write is applied locally again at once (the
|
|
427
|
+
* rejection rolled it back) and queued under a new op id. Settles like
|
|
428
|
+
* `insert`: resolves with the new op id once the server applies it or
|
|
429
|
+
* it is queued offline, and rejects with a `MutationRejectedError` when
|
|
430
|
+
* the server rejects it again (it is then failed again, with the new
|
|
431
|
+
* error).
|
|
432
|
+
*
|
|
433
|
+
* A write is only ever sent as the user who made it. Throws
|
|
434
|
+
* `MutationRejectedError` with code `OWNER_MISMATCH` when someone else
|
|
435
|
+
* is signed in, and `OWNER_UNKNOWN` when it is not yet known who is
|
|
436
|
+
* signed in (or the write predates owner tracking). Dismiss those.
|
|
437
|
+
*/
|
|
438
|
+
retryWrite(id: string, data?: Row): Promise<string>;
|
|
439
|
+
/** Drop a failed write after the user has seen it. Its local change
|
|
440
|
+
* was already rolled back. Returns false when there is no failed write
|
|
441
|
+
* with that id; a pending write cannot be dismissed. */
|
|
442
|
+
dismissWrite(id: string): boolean;
|
|
443
|
+
/**
|
|
444
|
+
* Keep a file on the device and upload it when there is a network, then
|
|
445
|
+
* set it on the record it belongs to. Returns at once.
|
|
446
|
+
*
|
|
447
|
+
* At once, `attach.entity`/`attach.id` gets `attach.field` (and
|
|
448
|
+
* `attach.urlField`, when given) = `local:<localId>` as a queued write,
|
|
449
|
+
* so the record shows the file from the device (`uploadLocalUrl`). When
|
|
450
|
+
* the upload finishes, a second queued write sets the file's id (and
|
|
451
|
+
* URL). Both are ordinary writes: policies and owner rules apply.
|
|
452
|
+
*
|
|
453
|
+
* The file is sent only as the user who queued it. Throws
|
|
454
|
+
* `UploadQueueFullError` past `maxQueuedUploadBytes`. Pending and failed
|
|
455
|
+
* uploads show in `outbox()` with kind `"upload"`; retry and dismiss them
|
|
456
|
+
* with `retryWrite` / `dismissWrite`.
|
|
457
|
+
*/
|
|
458
|
+
queueUpload(file: Blob, opts: {
|
|
459
|
+
attach: UploadAttachTarget;
|
|
460
|
+
filename?: string;
|
|
461
|
+
contentType?: string;
|
|
462
|
+
visibility?: FileVisibility;
|
|
463
|
+
}): Promise<QueuedUploadHandle>;
|
|
464
|
+
/** An object URL for a queued file (`local:<localId>` without the
|
|
465
|
+
* prefix), or null when no queued upload has that id. */
|
|
466
|
+
uploadLocalUrl(localId: string): string | null;
|
|
467
|
+
/** Apply the queued writes to the replica again after a reload. An
|
|
468
|
+
* optimistic change lives only in memory, so without this a row
|
|
469
|
+
* written offline vanished from the screen on reload (it was still
|
|
470
|
+
* queued and still reached the server later). Oldest first, so an
|
|
471
|
+
* update of a row inserted offline finds the row. */
|
|
472
|
+
private reapplyPendingWrites;
|
|
402
473
|
/**
|
|
403
474
|
* Mutate connection status + notify subscribers. Idempotent — same-
|
|
404
475
|
* status calls are a no-op so the WS onopen → connected transition
|
|
@@ -51,6 +51,10 @@ export interface MultiTabOrchestratorHooks {
|
|
|
51
51
|
/** Leader → followers: the leader's push failed transiently and
|
|
52
52
|
* these op ids stay queued for retry. */
|
|
53
53
|
onMutationsQueued(opIds: string[]): void;
|
|
54
|
+
/** Another tab retried or dismissed these failed writes. */
|
|
55
|
+
onMutationsRemoved(opIds: string[]): void;
|
|
56
|
+
/** Another tab changed the upload queue in storage. */
|
|
57
|
+
onUploadsChanged(): void;
|
|
54
58
|
/** Leader → follower: a binary frame from the WS. Engine routes
|
|
55
59
|
* to its local binary handlers. */
|
|
56
60
|
onBinaryReceived(bytes: Uint8Array): void;
|
package/dist/mutation-queue.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export interface PendingMutation {
|
|
|
29
29
|
error?: string;
|
|
30
30
|
/** Server error code for a failed mutation, when the server sent one. */
|
|
31
31
|
errorCode?: string;
|
|
32
|
+
/** When the write was made, in ms since the epoch. Missing on writes
|
|
33
|
+
* queued by an older client; those sort first. */
|
|
34
|
+
createdAt?: number;
|
|
32
35
|
/** User id the write was made as (`null` = anonymous). Persisted with
|
|
33
36
|
* the queue so a write is only ever pushed as the user who made it.
|
|
34
37
|
* `undefined` = unknown (queued by an older client). */
|
|
@@ -44,6 +47,32 @@ export interface PendingMutation {
|
|
|
44
47
|
* before the mutation; `undefined` = not captured (inserts). */
|
|
45
48
|
prevRow?: Row | null;
|
|
46
49
|
}
|
|
50
|
+
/** One write as the outbox shows it. */
|
|
51
|
+
export interface OutboxItem {
|
|
52
|
+
/** Op id. Pass it to `retryWrite` / `dismissWrite`. */
|
|
53
|
+
id: string;
|
|
54
|
+
entity: string;
|
|
55
|
+
rowId: string;
|
|
56
|
+
/** `"upload"` is a file from `queueUpload`; `entity`/`rowId` is the
|
|
57
|
+
* record it attaches to. */
|
|
58
|
+
kind: "insert" | "update" | "delete" | "upload";
|
|
59
|
+
/** The fields the write sets. Empty for a delete. For an upload:
|
|
60
|
+
* `field`, `urlField`, `filename`, `contentType`, `size`. */
|
|
61
|
+
data: Row;
|
|
62
|
+
/** When the write was made, in ms since the epoch (0 when unknown). */
|
|
63
|
+
createdAt: number;
|
|
64
|
+
/** The server's message, for a failed write. */
|
|
65
|
+
error?: string;
|
|
66
|
+
/** The server's error code (`POLICY_DENIED`, `VALIDATION_FAILED`, ...),
|
|
67
|
+
* for a failed write. */
|
|
68
|
+
errorCode?: string;
|
|
69
|
+
}
|
|
70
|
+
/** Writes not yet on the server, and writes the server rejected. */
|
|
71
|
+
export interface Outbox {
|
|
72
|
+
pending: OutboxItem[];
|
|
73
|
+
failed: OutboxItem[];
|
|
74
|
+
}
|
|
75
|
+
export declare function toOutboxItem(m: PendingMutation): OutboxItem;
|
|
47
76
|
/**
|
|
48
77
|
* Optional persistence backend. The default IndexedDB persistence
|
|
49
78
|
* layer supports incremental writes with `saveChanges`. Custom backends
|
|
@@ -61,7 +90,18 @@ export declare class MutationQueue {
|
|
|
61
90
|
/** Callers waiting for a mutation's outcome, by op id. Memory-only:
|
|
62
91
|
* a promise cannot outlive the page that created it. */
|
|
63
92
|
private waiters;
|
|
93
|
+
/** Called after every change to what the queue holds or to a write's
|
|
94
|
+
* status. The engine turns it into a store notification so the outbox
|
|
95
|
+
* re-renders. */
|
|
96
|
+
private changeListener;
|
|
97
|
+
/** Bumped on every change; lets readers cache a snapshot. */
|
|
98
|
+
private _version;
|
|
64
99
|
constructor(persistence?: MutationQueuePersistence);
|
|
100
|
+
/** Set the one listener called after each queue change. */
|
|
101
|
+
setChangeListener(listener: (() => void) | null): void;
|
|
102
|
+
/** Changes so far. Equal versions mean the same contents. */
|
|
103
|
+
version(): number;
|
|
104
|
+
private changed;
|
|
65
105
|
/**
|
|
66
106
|
* Attach a persistence backend after construction. The SyncEngine
|
|
67
107
|
* swaps in IndexedDB-backed persistence once the DB has opened.
|
|
@@ -79,8 +119,27 @@ export declare class MutationQueue {
|
|
|
79
119
|
* entry with the same op_id is already queued — a follower
|
|
80
120
|
* retrying its forward of the same op shouldn't double-queue on
|
|
81
121
|
* the leader. */
|
|
82
|
-
add(change: ClientChange, prevRow?: Row | null, owner?: string | null, ownerPending?: boolean): string;
|
|
122
|
+
add(change: ClientChange, prevRow?: Row | null, owner?: string | null, ownerPending?: boolean, createdAt?: number): string;
|
|
83
123
|
pending(): PendingMutation[];
|
|
124
|
+
/** Writes the server rejected, oldest first. They stay until the user
|
|
125
|
+
* retries or dismisses them. */
|
|
126
|
+
failed(): PendingMutation[];
|
|
127
|
+
/**
|
|
128
|
+
* Queue a failed write again under a new op id, with `data` (when
|
|
129
|
+
* given) merged over the original change's fields. The failed entry is
|
|
130
|
+
* removed in the same persistence write, so a reload never sees both.
|
|
131
|
+
* The new write keeps the original's owner and `createdAt`. `prevRow`
|
|
132
|
+
* is the row before the caller re-applied the write locally, for the
|
|
133
|
+
* rollback if the server rejects it again. Returns the new op id.
|
|
134
|
+
*
|
|
135
|
+
* The server forgets a rejected op id (it never applied it), so the new
|
|
136
|
+
* id cannot be applied alongside the old one.
|
|
137
|
+
*/
|
|
138
|
+
retry(id: string, data: Row | undefined, prevRow: Row | null | undefined): string;
|
|
139
|
+
/** Drop a failed write after the user has seen it. Returns false when
|
|
140
|
+
* there is no failed write with that id (pending writes cannot be
|
|
141
|
+
* dismissed: they may already be on the server). */
|
|
142
|
+
dismiss(id: string): boolean;
|
|
84
143
|
/** Look up a queued mutation by op id (any status). Used by the
|
|
85
144
|
* follower's mutations-failed handler to reach the captured
|
|
86
145
|
* `prevRow` for rollback. */
|
|
@@ -145,3 +204,5 @@ export declare class MutationQueue {
|
|
|
145
204
|
/** Fire-and-forget persistence write. */
|
|
146
205
|
private flush;
|
|
147
206
|
}
|
|
207
|
+
/** Oldest first; writes without `createdAt` (older clients) first of all. */
|
|
208
|
+
export declare function byCreatedAt(list: PendingMutation[]): PendingMutation[];
|
|
@@ -129,10 +129,16 @@ export declare class TestServer {
|
|
|
129
129
|
/** When set, `/api/sync/push` waits for this promise before it
|
|
130
130
|
* answers. Lets a test queue a write while a push is in flight. */
|
|
131
131
|
pushGate: Promise<void> | null;
|
|
132
|
+
/** The file endpoints (`/api/files/init`, PUT, `/api/files/confirm`). */
|
|
133
|
+
readonly files: TestFileServer;
|
|
132
134
|
/** Entities whose pushed writes are rejected per op, like a write
|
|
133
135
|
* policy denial on the real server (HTTP 200 with a per-op error). */
|
|
134
136
|
private deniedWrites;
|
|
135
137
|
denyWrites(entity: string, code?: string, message?: string): void;
|
|
138
|
+
/** Reject a pushed insert/update of `entity` whose fields match
|
|
139
|
+
* `predicate`, like a write policy that checks a field value. */
|
|
140
|
+
private conditionalDenials;
|
|
141
|
+
rejectWritesWhere(entity: string, predicate: (data: Row) => boolean, code?: string, message?: string): void;
|
|
136
142
|
private nextPushOutcome;
|
|
137
143
|
primeNextPushOutcome(o: {
|
|
138
144
|
kind: "network";
|
|
@@ -223,3 +229,81 @@ export declare class TestServer {
|
|
|
223
229
|
private bumpSeq;
|
|
224
230
|
private appendLog;
|
|
225
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* In-memory stand-in for the runtime's file routes. Records every request
|
|
234
|
+
* so a test can count uploads, and lets a test refuse a file (`rejectInit`)
|
|
235
|
+
* or drop the connection during the next PUT (`dropNextPut`).
|
|
236
|
+
*/
|
|
237
|
+
export declare class TestFileServer {
|
|
238
|
+
private readonly server;
|
|
239
|
+
private nextId;
|
|
240
|
+
private slots;
|
|
241
|
+
readonly stored: Map<string, {
|
|
242
|
+
filename: string;
|
|
243
|
+
size: number;
|
|
244
|
+
owner: string | null;
|
|
245
|
+
}>;
|
|
246
|
+
initCount: number;
|
|
247
|
+
putCount: number;
|
|
248
|
+
private rejection;
|
|
249
|
+
private dropPut;
|
|
250
|
+
constructor(server: TestServer);
|
|
251
|
+
/** Refuse every init with this status, like a type or size check. */
|
|
252
|
+
rejectInit(status: number, code: string, message: string): void;
|
|
253
|
+
clearRejection(): void;
|
|
254
|
+
/** When set, every PUT waits for it before it answers. */
|
|
255
|
+
putGate: Promise<void> | null;
|
|
256
|
+
/** The next PUT fails like a dropped connection (no status). */
|
|
257
|
+
dropNextPut(): void;
|
|
258
|
+
init(token: string | undefined, body: {
|
|
259
|
+
filename?: string;
|
|
260
|
+
size?: number;
|
|
261
|
+
}): {
|
|
262
|
+
status: number;
|
|
263
|
+
body: {
|
|
264
|
+
error: {
|
|
265
|
+
code: string;
|
|
266
|
+
message: string;
|
|
267
|
+
};
|
|
268
|
+
uploadUrl?: undefined;
|
|
269
|
+
assetId?: undefined;
|
|
270
|
+
};
|
|
271
|
+
} | {
|
|
272
|
+
status: number;
|
|
273
|
+
body: {
|
|
274
|
+
uploadUrl: string;
|
|
275
|
+
assetId: string;
|
|
276
|
+
error?: undefined;
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
put(id: string, bytes: unknown): {
|
|
280
|
+
status: number;
|
|
281
|
+
body: {
|
|
282
|
+
error: {
|
|
283
|
+
code: string;
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
} | {
|
|
287
|
+
status: number;
|
|
288
|
+
body: null;
|
|
289
|
+
};
|
|
290
|
+
confirm(token: string | undefined, id: string): {
|
|
291
|
+
status: number;
|
|
292
|
+
body: {
|
|
293
|
+
error: {
|
|
294
|
+
code: string;
|
|
295
|
+
};
|
|
296
|
+
id?: undefined;
|
|
297
|
+
url?: undefined;
|
|
298
|
+
size?: undefined;
|
|
299
|
+
};
|
|
300
|
+
} | {
|
|
301
|
+
status: number;
|
|
302
|
+
body: {
|
|
303
|
+
id: string;
|
|
304
|
+
url: string;
|
|
305
|
+
size: number;
|
|
306
|
+
error?: undefined;
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { OutboxItem } from "./mutation-queue";
|
|
2
|
+
import type { TransportConfig } from "./transport";
|
|
3
|
+
import { type FileVisibility, type UploadedFile } from "./upload";
|
|
4
|
+
/** Where an upload is attached when it finishes: `entity`/`id` gets
|
|
5
|
+
* `field` = the file id, and `urlField` (when set) = the file URL. */
|
|
6
|
+
export interface UploadAttachTarget {
|
|
7
|
+
entity: string;
|
|
8
|
+
id: string;
|
|
9
|
+
field: string;
|
|
10
|
+
urlField?: string;
|
|
11
|
+
}
|
|
12
|
+
/** One queued upload, as stored. */
|
|
13
|
+
export interface QueuedUpload {
|
|
14
|
+
/** `upl_...`. The record's field holds `local:<localId>` until the
|
|
15
|
+
* upload finishes. */
|
|
16
|
+
localId: string;
|
|
17
|
+
blob: Blob;
|
|
18
|
+
filename: string;
|
|
19
|
+
contentType: string;
|
|
20
|
+
size: number;
|
|
21
|
+
visibility: FileVisibility;
|
|
22
|
+
/** User id the upload was made as (`null` = anonymous). */
|
|
23
|
+
owner: string | null;
|
|
24
|
+
/** Made before this run knew who is signed in; stamped by the first
|
|
25
|
+
* resolved session. */
|
|
26
|
+
ownerPending?: boolean;
|
|
27
|
+
attach: UploadAttachTarget;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
status: "pending" | "failed";
|
|
30
|
+
error?: string;
|
|
31
|
+
errorCode?: string;
|
|
32
|
+
/** Set once the server confirmed the file, before the attach update is
|
|
33
|
+
* queued. A restart in between attaches without uploading again. */
|
|
34
|
+
uploaded?: UploadedFile;
|
|
35
|
+
}
|
|
36
|
+
/** Persistence for queued uploads. */
|
|
37
|
+
export interface UploadStore {
|
|
38
|
+
put(record: QueuedUpload): Promise<void>;
|
|
39
|
+
delete(localId: string): Promise<void>;
|
|
40
|
+
list(): Promise<QueuedUpload[]>;
|
|
41
|
+
}
|
|
42
|
+
/** The prefix of a field value that names a queued upload. */
|
|
43
|
+
export declare const LOCAL_FILE_PREFIX = "local:";
|
|
44
|
+
/** `local:<localId>` → `<localId>`; null for any other value. */
|
|
45
|
+
export declare function localUploadId(ref: unknown): string | null;
|
|
46
|
+
/** Default cap on the bytes waiting in the queue: 200 MB. */
|
|
47
|
+
export declare const DEFAULT_MAX_QUEUED_UPLOAD_BYTES: number;
|
|
48
|
+
/** Thrown by `queueUpload` when the file would take the queue past its
|
|
49
|
+
* size limit. Past the limit the browser may evict the app's storage,
|
|
50
|
+
* which would lose every queued file, so the queue refuses instead. */
|
|
51
|
+
export declare class UploadQueueFullError extends Error {
|
|
52
|
+
readonly code = "UPLOAD_QUEUE_FULL";
|
|
53
|
+
readonly limitBytes: number;
|
|
54
|
+
readonly queuedBytes: number;
|
|
55
|
+
constructor(limitBytes: number, queuedBytes: number, fileBytes: number);
|
|
56
|
+
}
|
|
57
|
+
export declare class MemoryUploadStore implements UploadStore {
|
|
58
|
+
private rows;
|
|
59
|
+
put(record: QueuedUpload): Promise<void>;
|
|
60
|
+
delete(localId: string): Promise<void>;
|
|
61
|
+
list(): Promise<QueuedUpload[]>;
|
|
62
|
+
}
|
|
63
|
+
/** Queued uploads in IndexedDB database `pylon_uploads_<appName>`. */
|
|
64
|
+
export declare class IndexedDBUploadStore implements UploadStore {
|
|
65
|
+
private readonly appName;
|
|
66
|
+
private db;
|
|
67
|
+
constructor(appName?: string);
|
|
68
|
+
private open;
|
|
69
|
+
private run;
|
|
70
|
+
put(record: QueuedUpload): Promise<void>;
|
|
71
|
+
delete(localId: string): Promise<void>;
|
|
72
|
+
list(): Promise<QueuedUpload[]>;
|
|
73
|
+
/** Close the connection (tests; a deleteDatabase waits for it). */
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
/** What the queue does with an upload, by its owner and who is signed in. */
|
|
77
|
+
export type UploadOwnerVerdict = "send" | "hold" | "discard";
|
|
78
|
+
/**
|
|
79
|
+
* The owner rule for uploads, the same as for queued writes: send when the
|
|
80
|
+
* upload's owner is signed in (or it was a guest's and that guest signed in
|
|
81
|
+
* to an account), hold while nobody or nobody known is signed in, discard
|
|
82
|
+
* when another user is signed in.
|
|
83
|
+
*/
|
|
84
|
+
export declare function uploadOwnerVerdict(owner: string | null, current: string | null | undefined): UploadOwnerVerdict;
|
|
85
|
+
/** Whether an upload error is worth trying again later: no network, a
|
|
86
|
+
* server error, a timeout, or a rate limit. Anything else (the type or
|
|
87
|
+
* size was refused, a policy) is the server's answer. */
|
|
88
|
+
export declare function isTransientUploadError(err: unknown): boolean;
|
|
89
|
+
/** One queued upload as the outbox shows it. */
|
|
90
|
+
export declare function uploadOutboxItem(r: QueuedUpload): OutboxItem;
|
|
91
|
+
export declare function newUploadId(): string;
|
|
92
|
+
/** What `queueUpload` returns. */
|
|
93
|
+
export interface QueuedUploadHandle {
|
|
94
|
+
localId: string;
|
|
95
|
+
/** `local:<localId>`: the value the record's field holds until the
|
|
96
|
+
* upload finishes. */
|
|
97
|
+
ref: string;
|
|
98
|
+
/** An object URL of the file, to show it at once. Null where the
|
|
99
|
+
* platform has no `URL.createObjectURL`. */
|
|
100
|
+
localUrl: string | null;
|
|
101
|
+
/** Resolves with the uploaded file once it is attached; rejects with an
|
|
102
|
+
* `UploadRejectedError` when the server refuses it or another user is
|
|
103
|
+
* signed in. A page that reloads loses this promise, not the upload. */
|
|
104
|
+
done: Promise<UploadedFile>;
|
|
105
|
+
}
|
|
106
|
+
/** Why a queued upload was not sent. `code` is the server's error code
|
|
107
|
+
* (`FILE_TOO_LARGE`, `POLICY_DENIED`, ...), or `DISCARDED` when another
|
|
108
|
+
* user is signed in. */
|
|
109
|
+
export declare class UploadRejectedError extends Error {
|
|
110
|
+
readonly code: string;
|
|
111
|
+
readonly localId: string;
|
|
112
|
+
constructor(message: string, code: string, localId: string);
|
|
113
|
+
}
|
|
114
|
+
/** What the upload manager needs from the sync engine. */
|
|
115
|
+
export interface UploadManagerHost {
|
|
116
|
+
transport(): TransportConfig;
|
|
117
|
+
/** The signed-in user (`null` = anonymous), or `undefined` while it is
|
|
118
|
+
* not known (no answer from the server yet this run, or a token change
|
|
119
|
+
* in flight). */
|
|
120
|
+
currentOwner(): string | null | undefined;
|
|
121
|
+
/** The user a new upload belongs to; `undefined` = not known yet. */
|
|
122
|
+
ownerForNewUpload(): string | null | undefined;
|
|
123
|
+
/** Only the leader tab uploads. */
|
|
124
|
+
isLeader(): boolean;
|
|
125
|
+
isRunning(): boolean;
|
|
126
|
+
/** Queue an ordinary write that sets fields on a record. */
|
|
127
|
+
update(entity: string, id: string, patch: Record<string, unknown>): void;
|
|
128
|
+
/** The outbox changed. */
|
|
129
|
+
changed(): void;
|
|
130
|
+
/** Tell other tabs to reload the queue. */
|
|
131
|
+
broadcast(): void;
|
|
132
|
+
/** Run `fn` after `ms` unless the engine stops. */
|
|
133
|
+
later(ms: number, fn: () => void): void;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The offline upload queue: storage, the one-at-a-time upload loop, the
|
|
137
|
+
* owner rule, the size limit, and the outbox entries.
|
|
138
|
+
*/
|
|
139
|
+
export declare class UploadManager {
|
|
140
|
+
private readonly host;
|
|
141
|
+
private readonly maxBytes;
|
|
142
|
+
private store;
|
|
143
|
+
private records;
|
|
144
|
+
private objectUrls;
|
|
145
|
+
private waiters;
|
|
146
|
+
private running;
|
|
147
|
+
private failureCount;
|
|
148
|
+
private retryScheduled;
|
|
149
|
+
private _version;
|
|
150
|
+
constructor(host: UploadManagerHost, maxBytes?: number);
|
|
151
|
+
/** Bumped on every change to the queue. */
|
|
152
|
+
version(): number;
|
|
153
|
+
private changed;
|
|
154
|
+
/** Swap in persistent storage and load what it holds. */
|
|
155
|
+
attachStore(store: UploadStore): Promise<void>;
|
|
156
|
+
/** Re-read the queue from storage (another tab changed it). */
|
|
157
|
+
reload(): Promise<void>;
|
|
158
|
+
/** Queued uploads, oldest first. */
|
|
159
|
+
list(): QueuedUpload[];
|
|
160
|
+
get(localId: string): QueuedUpload | undefined;
|
|
161
|
+
/** Bytes waiting on the device. */
|
|
162
|
+
queuedBytes(): number;
|
|
163
|
+
enqueue(blob: Blob, opts: {
|
|
164
|
+
attach: UploadAttachTarget;
|
|
165
|
+
filename?: string;
|
|
166
|
+
contentType?: string;
|
|
167
|
+
visibility?: FileVisibility;
|
|
168
|
+
}): Promise<QueuedUploadHandle>;
|
|
169
|
+
/** An object URL for a queued file, or null. */
|
|
170
|
+
localUrl(localId: string): string | null;
|
|
171
|
+
private revoke;
|
|
172
|
+
private settle;
|
|
173
|
+
private remove;
|
|
174
|
+
/** Stamp uploads made before anyone was known to be signed in. */
|
|
175
|
+
stampPendingOwner(owner: string | null): Promise<void>;
|
|
176
|
+
/**
|
|
177
|
+
* Upload every queued file the signed-in user owns, oldest first, one at
|
|
178
|
+
* a time. Stops at the first error that says the network is down and
|
|
179
|
+
* tries again later (and on the next reconnect). A file the server
|
|
180
|
+
* refuses is marked failed and not tried again until `retry`.
|
|
181
|
+
*/
|
|
182
|
+
process(): Promise<void>;
|
|
183
|
+
private scheduleRetry;
|
|
184
|
+
/** Queue a failed upload again. Throws `UploadRejectedError` with code
|
|
185
|
+
* `OWNER_MISMATCH` / `OWNER_UNKNOWN` as `retryWrite` does. */
|
|
186
|
+
retry(localId: string): Promise<void>;
|
|
187
|
+
/** Drop a failed upload and its bytes. False when there is no failed
|
|
188
|
+
* upload with that id. The record keeps its `local:` value. */
|
|
189
|
+
dismiss(localId: string): boolean;
|
|
190
|
+
}
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type TransportConfig } from "./transport";
|
|
2
|
+
export interface UploadedFile {
|
|
3
|
+
id: string;
|
|
4
|
+
/** Where to read the file. Store this on your row. */
|
|
5
|
+
url: string;
|
|
6
|
+
size: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Who can read an uploaded file through `url`.
|
|
10
|
+
* - "private" (default): only the uploader.
|
|
11
|
+
* - "public": anyone, signed in or not. Use it for content every visitor
|
|
12
|
+
* sees, such as feed photos or listing images.
|
|
13
|
+
* Writes and deletes stay with the uploader either way. On a CDN-backed
|
|
14
|
+
* store (Stack0, a public bucket) every file is served by its URL, so the
|
|
15
|
+
* setting only changes reads that go through `/api/files/<id>`.
|
|
16
|
+
*/
|
|
17
|
+
export type FileVisibility = "private" | "public";
|
|
18
|
+
export interface UploadBytesOptions {
|
|
19
|
+
filename?: string;
|
|
20
|
+
contentType?: string;
|
|
21
|
+
visibility?: FileVisibility;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Upload a file (File, Blob, or raw bytes) in three steps:
|
|
25
|
+
* 1. POST /api/files/init with the name, type, size, and visibility;
|
|
26
|
+
* 2. PUT the bytes to the returned upload URL;
|
|
27
|
+
* 3. POST /api/files/confirm, which returns `{ id, url, size }`.
|
|
28
|
+
*
|
|
29
|
+
* The upload URL is either pylon's own receiver (local disk), which needs
|
|
30
|
+
* the session like any API call, or a presigned storage URL, whose
|
|
31
|
+
* signature is the credential. The session is never sent to a presigned
|
|
32
|
+
* URL on another origin.
|
|
33
|
+
*
|
|
34
|
+
* Throws `PylonHttpError` (with `status`) when the server answers with an
|
|
35
|
+
* error, and the fetch error when there is no network.
|
|
36
|
+
*/
|
|
37
|
+
export declare function uploadBytes(transport: TransportConfig, input: File | Blob | ArrayBuffer | Uint8Array, options?: UploadBytesOptions): Promise<UploadedFile>;
|