@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/src/upload.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// File upload: POST /api/files/init, PUT the bytes, POST /api/files/confirm.
|
|
2
|
+
//
|
|
3
|
+
// `@pylonsync/react`'s `uploadFile` and the sync engine's offline upload
|
|
4
|
+
// queue both call `uploadBytes`, so the two never send a file differently.
|
|
5
|
+
|
|
6
|
+
import { pylonFetch, pylonFetchRaw, PylonHttpError, type TransportConfig } from "./transport";
|
|
7
|
+
|
|
8
|
+
export interface UploadedFile {
|
|
9
|
+
id: string;
|
|
10
|
+
/** Where to read the file. Store this on your row. */
|
|
11
|
+
url: string;
|
|
12
|
+
size: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Who can read an uploaded file through `url`.
|
|
17
|
+
* - "private" (default): only the uploader.
|
|
18
|
+
* - "public": anyone, signed in or not. Use it for content every visitor
|
|
19
|
+
* sees, such as feed photos or listing images.
|
|
20
|
+
* Writes and deletes stay with the uploader either way. On a CDN-backed
|
|
21
|
+
* store (Stack0, a public bucket) every file is served by its URL, so the
|
|
22
|
+
* setting only changes reads that go through `/api/files/<id>`.
|
|
23
|
+
*/
|
|
24
|
+
export type FileVisibility = "private" | "public";
|
|
25
|
+
|
|
26
|
+
export interface UploadBytesOptions {
|
|
27
|
+
filename?: string;
|
|
28
|
+
contentType?: string;
|
|
29
|
+
visibility?: FileVisibility;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upload a file (File, Blob, or raw bytes) in three steps:
|
|
34
|
+
* 1. POST /api/files/init with the name, type, size, and visibility;
|
|
35
|
+
* 2. PUT the bytes to the returned upload URL;
|
|
36
|
+
* 3. POST /api/files/confirm, which returns `{ id, url, size }`.
|
|
37
|
+
*
|
|
38
|
+
* The upload URL is either pylon's own receiver (local disk), which needs
|
|
39
|
+
* the session like any API call, or a presigned storage URL, whose
|
|
40
|
+
* signature is the credential. The session is never sent to a presigned
|
|
41
|
+
* URL on another origin.
|
|
42
|
+
*
|
|
43
|
+
* Throws `PylonHttpError` (with `status`) when the server answers with an
|
|
44
|
+
* error, and the fetch error when there is no network.
|
|
45
|
+
*/
|
|
46
|
+
export async function uploadBytes(
|
|
47
|
+
transport: TransportConfig,
|
|
48
|
+
input: File | Blob | ArrayBuffer | Uint8Array,
|
|
49
|
+
options: UploadBytesOptions = {},
|
|
50
|
+
): Promise<UploadedFile> {
|
|
51
|
+
let body: BodyInit;
|
|
52
|
+
let size: number;
|
|
53
|
+
let filename = options.filename;
|
|
54
|
+
let contentType = options.contentType;
|
|
55
|
+
|
|
56
|
+
if (typeof File !== "undefined" && input instanceof File) {
|
|
57
|
+
body = input;
|
|
58
|
+
size = input.size;
|
|
59
|
+
filename ??= input.name;
|
|
60
|
+
contentType ??= input.type || "application/octet-stream";
|
|
61
|
+
} else if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
62
|
+
body = input;
|
|
63
|
+
size = input.size;
|
|
64
|
+
contentType ??= input.type || "application/octet-stream";
|
|
65
|
+
} else if (input instanceof ArrayBuffer) {
|
|
66
|
+
body = input;
|
|
67
|
+
size = input.byteLength;
|
|
68
|
+
} else {
|
|
69
|
+
// Newer TS lib types refuse `Uint8Array<ArrayBufferLike>` as BodyInit
|
|
70
|
+
// directly even though every runtime accepts it. Hand fetch the
|
|
71
|
+
// underlying ArrayBuffer slice to sidestep the type narrowing.
|
|
72
|
+
const u8 = input as Uint8Array;
|
|
73
|
+
body = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
|
74
|
+
size = u8.byteLength;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
filename ??= "upload";
|
|
78
|
+
contentType ??= "application/octet-stream";
|
|
79
|
+
|
|
80
|
+
const slot = await pylonFetch<{ uploadUrl: string; assetId: string }>(
|
|
81
|
+
transport,
|
|
82
|
+
"/api/files/init",
|
|
83
|
+
{
|
|
84
|
+
method: "POST",
|
|
85
|
+
json: {
|
|
86
|
+
filename,
|
|
87
|
+
mimeType: contentType,
|
|
88
|
+
size,
|
|
89
|
+
visibility: options.visibility ?? "private",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const base = new URL(transport.baseUrl || "http://localhost");
|
|
95
|
+
const target = new URL(slot.uploadUrl, base);
|
|
96
|
+
const put =
|
|
97
|
+
target.origin === base.origin
|
|
98
|
+
? await pylonFetchRaw(transport, `${target.pathname}${target.search}`, {
|
|
99
|
+
method: "PUT",
|
|
100
|
+
body,
|
|
101
|
+
headers: { "Content-Type": contentType },
|
|
102
|
+
})
|
|
103
|
+
: await fetch(target.toString(), {
|
|
104
|
+
method: "PUT",
|
|
105
|
+
body,
|
|
106
|
+
headers: { "Content-Type": contentType },
|
|
107
|
+
});
|
|
108
|
+
// Read the response body to the end, even an empty 204. Chrome cancels a
|
|
109
|
+
// fetch whose body is never read once the Response is dropped, and
|
|
110
|
+
// DevTools then lists the upload as failed (net::ERR_ABORTED) although
|
|
111
|
+
// the server stored the bytes.
|
|
112
|
+
const putBody = await put.text().catch(() => "");
|
|
113
|
+
if (!put.ok) {
|
|
114
|
+
let message = `Upload of ${filename} failed: ${put.status}`;
|
|
115
|
+
let code: string | undefined;
|
|
116
|
+
try {
|
|
117
|
+
const err = JSON.parse(putBody) as { error?: { code?: string; message?: string } };
|
|
118
|
+
if (err.error?.message) message = err.error.message;
|
|
119
|
+
code = err.error?.code;
|
|
120
|
+
} catch {
|
|
121
|
+
// Not JSON (storage provider XML, proxy page): keep the status text.
|
|
122
|
+
}
|
|
123
|
+
throw new PylonHttpError(message, put.status, code);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return pylonFetch<UploadedFile>(transport, "/api/files/confirm", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
json: { assetId: slot.assetId },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Offline photo uploads (`queueUpload`): the file waits on the device, is
|
|
2
|
+
// uploaded when there is a network, and is attached to its record with an
|
|
3
|
+
// ordinary queued write.
|
|
4
|
+
|
|
5
|
+
import "fake-indexeddb/auto";
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
IndexedDBPersistence,
|
|
10
|
+
IndexedDBUploadStore,
|
|
11
|
+
SyncEngine,
|
|
12
|
+
UploadQueueFullError,
|
|
13
|
+
UploadRejectedError,
|
|
14
|
+
} from "./index";
|
|
15
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
16
|
+
|
|
17
|
+
const photo = (bytes = 1024, name = "hail.jpg") =>
|
|
18
|
+
new File([new Uint8Array(bytes)], name, { type: "image/jpeg" });
|
|
19
|
+
|
|
20
|
+
const target = { entity: "Photo", id: "p1", field: "fileId", urlField: "url" };
|
|
21
|
+
|
|
22
|
+
async function until(cond: () => boolean, ms = 2000) {
|
|
23
|
+
const end = Date.now() + ms;
|
|
24
|
+
while (!cond()) {
|
|
25
|
+
if (Date.now() > end) throw new Error("timed out");
|
|
26
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("queueUpload", () => {
|
|
31
|
+
let env: TestEnv | null = null;
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
if (env) await env.dispose();
|
|
34
|
+
env = null;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function start(opts: { maxQueuedUploadBytes?: number } = {}) {
|
|
38
|
+
env = createTestEnv({ transport: "poll" });
|
|
39
|
+
if (opts.maxQueuedUploadBytes !== undefined) {
|
|
40
|
+
(env.engine as unknown as { uploads: { maxBytes: number } }).uploads.maxBytes =
|
|
41
|
+
opts.maxQueuedUploadBytes;
|
|
42
|
+
}
|
|
43
|
+
env.signIn({ userId: "rep1" });
|
|
44
|
+
env.server.seed("Photo", [{ id: "p1", leadId: "l1", fileId: null, url: null }]);
|
|
45
|
+
await env.start();
|
|
46
|
+
return env;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("the record shows the local copy at once, then the uploaded file", async () => {
|
|
50
|
+
const e = await start();
|
|
51
|
+
const queued = await e.engine.queueUpload(photo(), { attach: target });
|
|
52
|
+
expect(queued.ref).toBe(`local:${queued.localId}`);
|
|
53
|
+
// At once, from the device.
|
|
54
|
+
expect(e.engine.store.get("Photo", "p1")).toMatchObject({ fileId: queued.ref, url: queued.ref });
|
|
55
|
+
|
|
56
|
+
const file = await queued.done;
|
|
57
|
+
expect(file).toMatchObject({ id: "f_1", url: "/api/files/f_1", size: 1024 });
|
|
58
|
+
await until(() => e.engine.pendingCount() === 0);
|
|
59
|
+
expect(e.engine.store.get("Photo", "p1")).toMatchObject({ fileId: "f_1", url: "/api/files/f_1" });
|
|
60
|
+
const [row] = await e.server.listEntityRows("Photo", e.token);
|
|
61
|
+
expect(row).toMatchObject({ fileId: "f_1", url: "/api/files/f_1" });
|
|
62
|
+
expect(e.server.files.stored.get("f_1")).toEqual({ filename: "hail.jpg", size: 1024, owner: "rep1" });
|
|
63
|
+
expect(e.engine.outbox()).toEqual({ pending: [], failed: [] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a rejected upload stays failed and is not retried in a loop", async () => {
|
|
67
|
+
const e = await start();
|
|
68
|
+
e.server.files.rejectInit(415, "FILE_TYPE_NOT_ALLOWED", "image/heic is not allowed");
|
|
69
|
+
const queued = await e.engine.queueUpload(photo(), { attach: target });
|
|
70
|
+
await expect(queued.done).rejects.toBeInstanceOf(UploadRejectedError);
|
|
71
|
+
await expect(queued.done).rejects.toMatchObject({ code: "FILE_TYPE_NOT_ALLOWED" });
|
|
72
|
+
|
|
73
|
+
// Reconnects and later processing do not resend it.
|
|
74
|
+
await e.engine.uploads.process();
|
|
75
|
+
await e.engine.uploads.process();
|
|
76
|
+
expect(e.server.files.initCount).toBe(1);
|
|
77
|
+
|
|
78
|
+
const [failed] = e.engine.outbox().failed;
|
|
79
|
+
expect(failed).toMatchObject({
|
|
80
|
+
id: queued.localId,
|
|
81
|
+
kind: "upload",
|
|
82
|
+
entity: "Photo",
|
|
83
|
+
rowId: "p1",
|
|
84
|
+
error: "image/heic is not allowed",
|
|
85
|
+
errorCode: "FILE_TYPE_NOT_ALLOWED",
|
|
86
|
+
data: { field: "fileId", urlField: "url", filename: "hail.jpg", size: 1024 },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Retry sends it again; dismiss drops it.
|
|
90
|
+
e.server.files.clearRejection();
|
|
91
|
+
await e.engine.retryWrite(queued.localId);
|
|
92
|
+
await until(() => e.engine.outbox().failed.length === 0 && e.engine.outbox().pending.length === 0);
|
|
93
|
+
expect(e.server.files.stored.size).toBe(1);
|
|
94
|
+
|
|
95
|
+
e.server.files.rejectInit(413, "FILE_TOO_LARGE", "too large");
|
|
96
|
+
const second = await e.engine.queueUpload(photo(), { attach: target });
|
|
97
|
+
await second.done.catch(() => {});
|
|
98
|
+
expect(e.engine.dismissWrite(second.localId)).toBe(true);
|
|
99
|
+
expect(e.engine.outbox().failed).toHaveLength(0);
|
|
100
|
+
expect(e.engine.uploadLocalUrl(second.localId)).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a connection dropped mid-PUT keeps the upload pending and it is sent later", async () => {
|
|
104
|
+
const e = await start();
|
|
105
|
+
e.server.files.dropNextPut();
|
|
106
|
+
const queued = await e.engine.queueUpload(photo(), { attach: target });
|
|
107
|
+
await until(() => e.server.files.putCount === 1);
|
|
108
|
+
await e.flush(20);
|
|
109
|
+
expect(e.engine.outbox().pending.map((p) => p.id)).toEqual([queued.localId]);
|
|
110
|
+
expect(e.engine.outbox().failed).toHaveLength(0);
|
|
111
|
+
|
|
112
|
+
// The next attempt (a reconnect, or the backoff timer).
|
|
113
|
+
await e.engine.uploads.process();
|
|
114
|
+
const file = await queued.done;
|
|
115
|
+
expect(file.id).toBe("f_2");
|
|
116
|
+
expect(e.server.files.stored.has("f_2")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("an upload queued as one user is discarded when another signs in", async () => {
|
|
120
|
+
const e = await start();
|
|
121
|
+
// Offline: init fails like no network.
|
|
122
|
+
e.server.files.dropNextPut();
|
|
123
|
+
const queued = await e.engine.queueUpload(photo(), { attach: target });
|
|
124
|
+
await until(() => e.server.files.putCount === 1);
|
|
125
|
+
|
|
126
|
+
e.signIn({ userId: "rep2" });
|
|
127
|
+
await e.engine.refreshResolvedSession();
|
|
128
|
+
await expect(queued.done).rejects.toMatchObject({ code: "DISCARDED" });
|
|
129
|
+
expect(e.engine.outbox().pending.filter((p) => p.kind === "upload")).toHaveLength(0);
|
|
130
|
+
expect(e.server.files.stored.size).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("past the size limit queueUpload refuses with a clear error", async () => {
|
|
134
|
+
const e = await start({ maxQueuedUploadBytes: 3000 });
|
|
135
|
+
e.server.files.dropNextPut();
|
|
136
|
+
await e.engine.queueUpload(photo(2000), { attach: target });
|
|
137
|
+
let caught: unknown;
|
|
138
|
+
try {
|
|
139
|
+
await e.engine.queueUpload(photo(2000), { attach: target });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
caught = err;
|
|
142
|
+
}
|
|
143
|
+
expect(caught).toBeInstanceOf(UploadQueueFullError);
|
|
144
|
+
expect((caught as UploadQueueFullError).code).toBe("UPLOAD_QUEUE_FULL");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("queued uploads survive a reload", () => {
|
|
149
|
+
const realFetch = globalThis.fetch;
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
globalThis.fetch = realFetch;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the file and its owner are kept, and it uploads on the next start", async () => {
|
|
155
|
+
const name = `uploads-${Math.random().toString(36).slice(2)}`;
|
|
156
|
+
// Run 1, offline: queue a photo.
|
|
157
|
+
globalThis.fetch = (async () => {
|
|
158
|
+
throw new TypeError("Failed to fetch");
|
|
159
|
+
}) as unknown as typeof fetch;
|
|
160
|
+
const disk = new IndexedDBPersistence(name);
|
|
161
|
+
await disk.open();
|
|
162
|
+
await disk.saveIdentity("rep1");
|
|
163
|
+
await disk.saveRow("Photo", "p1", { id: "p1", fileId: null, url: null });
|
|
164
|
+
disk.connection?.close();
|
|
165
|
+
|
|
166
|
+
const first = new SyncEngine({
|
|
167
|
+
baseUrl: "http://test.invalid",
|
|
168
|
+
appName: name,
|
|
169
|
+
persistence: new IndexedDBPersistence(name),
|
|
170
|
+
multiTab: false,
|
|
171
|
+
transport: "poll",
|
|
172
|
+
pollInterval: 1e9,
|
|
173
|
+
});
|
|
174
|
+
const started = first.start().catch(() => {});
|
|
175
|
+
await until(() => first.isHydrated());
|
|
176
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
177
|
+
const queued = await first.queueUpload(photo(4096, "roof.jpg"), { attach: target });
|
|
178
|
+
first.stop();
|
|
179
|
+
await started;
|
|
180
|
+
(first as unknown as { persistence: IndexedDBPersistence }).persistence.connection?.close();
|
|
181
|
+
|
|
182
|
+
const store = new IndexedDBUploadStore(name);
|
|
183
|
+
const [saved] = await store.list();
|
|
184
|
+
expect(saved).toMatchObject({
|
|
185
|
+
localId: queued.localId,
|
|
186
|
+
owner: "rep1",
|
|
187
|
+
filename: "roof.jpg",
|
|
188
|
+
size: 4096,
|
|
189
|
+
status: "pending",
|
|
190
|
+
attach: target,
|
|
191
|
+
});
|
|
192
|
+
expect(saved!.blob.size).toBe(4096);
|
|
193
|
+
await store.close();
|
|
194
|
+
|
|
195
|
+
// Run 2, online.
|
|
196
|
+
globalThis.fetch = realFetch;
|
|
197
|
+
const env = createTestEnv({ transport: "poll", appName: name });
|
|
198
|
+
env.signIn({ userId: "rep1" });
|
|
199
|
+
env.server.seed("Photo", [{ id: "p1", fileId: null, url: null }]);
|
|
200
|
+
const second = new SyncEngine({
|
|
201
|
+
baseUrl: "http://test.invalid",
|
|
202
|
+
appName: name,
|
|
203
|
+
persistence: new IndexedDBPersistence(name),
|
|
204
|
+
storage: (env.engine as unknown as { storage: SyncEngine["storage"] }).storage,
|
|
205
|
+
multiTab: false,
|
|
206
|
+
transport: "poll",
|
|
207
|
+
pollInterval: 1e9,
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
await second.start();
|
|
211
|
+
await until(() => env.server.files.stored.size === 1);
|
|
212
|
+
await until(() => second.pendingCount() === 0);
|
|
213
|
+
const [row] = await env.server.listEntityRows("Photo", env.token);
|
|
214
|
+
expect(row).toMatchObject({ fileId: "f_1", url: "/api/files/f_1" });
|
|
215
|
+
expect(await new IndexedDBUploadStore(name).list()).toEqual([]);
|
|
216
|
+
} finally {
|
|
217
|
+
second.stop();
|
|
218
|
+
(second as unknown as { persistence: IndexedDBPersistence }).persistence.connection?.close();
|
|
219
|
+
await env.dispose();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|