@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.
- package/dist/index.d.ts +171 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +104 -0
- package/dist/types.d.ts +10 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +598 -58
- 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/session-before-pull.test.ts +51 -0
- package/src/session-dedupe.test.ts +77 -0
- package/src/snapshot-catch-up.test.ts +101 -0
- package/src/test-harness/env.ts +3 -0
- package/src/test-harness/server.ts +145 -0
- package/src/test-harness/transport.ts +26 -0
- package/src/types.ts +10 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
|
@@ -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>;
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// A first sync is one snapshot. Measured on the Veritas build: after a
|
|
2
|
+
// 12-page snapshot, the client fetched every entity a second time through
|
|
3
|
+
// /api/entities/<E>/cursor (28 requests, 6.5 MB), because the connect
|
|
4
|
+
// reconcile ran after a later delta pull reset the "started from zero"
|
|
5
|
+
// flag, and empty observed entities each started their own reconcile.
|
|
6
|
+
|
|
7
|
+
import { afterEach, expect, test } from "bun:test";
|
|
8
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
9
|
+
|
|
10
|
+
let env: TestEnv | null = null;
|
|
11
|
+
let restoreFetch: (() => void) | null = null;
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
restoreFetch?.();
|
|
14
|
+
restoreFetch = null;
|
|
15
|
+
if (env) {
|
|
16
|
+
await env.dispose();
|
|
17
|
+
env = null;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function recordUrls(): string[] {
|
|
22
|
+
const urls: string[] = [];
|
|
23
|
+
const inner = globalThis.fetch;
|
|
24
|
+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
25
|
+
urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
26
|
+
return inner(input, init);
|
|
27
|
+
}) as typeof fetch;
|
|
28
|
+
restoreFetch = () => {
|
|
29
|
+
globalThis.fetch = inner;
|
|
30
|
+
};
|
|
31
|
+
return urls;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("a cold start with a websocket makes no cursor requests after the snapshot", async () => {
|
|
35
|
+
env = await createTestEnv({ transport: "websocket" });
|
|
36
|
+
env.server.snapshotMode = true;
|
|
37
|
+
env.signIn({ userId: "u1" });
|
|
38
|
+
env.server.seed("Property", Array.from({ length: 50 }, (_, i) => ({ id: `p${i}` })));
|
|
39
|
+
env.server.seed("Visit", Array.from({ length: 20 }, (_, i) => ({ id: `v${i}` })));
|
|
40
|
+
const urls = recordUrls();
|
|
41
|
+
// Screens observe entities before and during the first sync, including
|
|
42
|
+
// ones with no rows.
|
|
43
|
+
env.engine.observeEntity("Property");
|
|
44
|
+
env.engine.observeEntity("Member");
|
|
45
|
+
await env.engine.start();
|
|
46
|
+
env.engine.observeEntity("Task");
|
|
47
|
+
await env.flush();
|
|
48
|
+
await env.engine.pull();
|
|
49
|
+
await env.flush();
|
|
50
|
+
|
|
51
|
+
expect(env.engine.store.list("Property").length).toBe(50);
|
|
52
|
+
expect(env.engine.store.list("Visit").length).toBe(20);
|
|
53
|
+
expect(urls.filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a reconnect within the interval skips the sweep; after it, the sweep runs", async () => {
|
|
57
|
+
env = await createTestEnv({ transport: "websocket", autoReconcileIntervalMs: 50, reconnectDelay: 5 });
|
|
58
|
+
env.server.snapshotMode = true;
|
|
59
|
+
env.signIn({ userId: "u1" });
|
|
60
|
+
env.server.seed("Property", [{ id: "p1" }]);
|
|
61
|
+
await env.engine.start();
|
|
62
|
+
await env.flush();
|
|
63
|
+
const urls = recordUrls();
|
|
64
|
+
|
|
65
|
+
env.transport.closeLatestWs();
|
|
66
|
+
await env.flush();
|
|
67
|
+
expect(urls.filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
68
|
+
|
|
69
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
70
|
+
env.transport.closeLatestWs();
|
|
71
|
+
await env.flush();
|
|
72
|
+
await env.flush(100);
|
|
73
|
+
expect(urls.some((u) => u.includes("/api/entities/Property/cursor"))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** A minimal `document` so the engine attaches its visibility listener. */
|
|
77
|
+
function installDocument() {
|
|
78
|
+
const target = new EventTarget() as EventTarget & { visibilityState: string };
|
|
79
|
+
target.visibilityState = "visible";
|
|
80
|
+
const g = globalThis as { document?: unknown };
|
|
81
|
+
const previous = g.document;
|
|
82
|
+
g.document = target;
|
|
83
|
+
return {
|
|
84
|
+
foreground() {
|
|
85
|
+
target.dispatchEvent(new Event("visibilitychange"));
|
|
86
|
+
},
|
|
87
|
+
restore() {
|
|
88
|
+
if (previous === undefined) delete g.document;
|
|
89
|
+
else g.document = previous;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("coming to the foreground does not sweep unless the app was offline", async () => {
|
|
95
|
+
const doc = installDocument();
|
|
96
|
+
try {
|
|
97
|
+
env = await createTestEnv({ transport: "websocket", autoReconcileIntervalMs: 20, reconnectDelay: 5 });
|
|
98
|
+
env.server.snapshotMode = true;
|
|
99
|
+
env.signIn({ userId: "u1" });
|
|
100
|
+
env.server.seed("Property", [{ id: "p1" }]);
|
|
101
|
+
await env.engine.start();
|
|
102
|
+
await env.flush();
|
|
103
|
+
const urls = recordUrls();
|
|
104
|
+
|
|
105
|
+
// Long past the interval, but the connection never dropped.
|
|
106
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
107
|
+
doc.foreground();
|
|
108
|
+
await env.flush();
|
|
109
|
+
expect(urls.filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
110
|
+
|
|
111
|
+
// The connection drops (a phone in a pocket), then the app comes back.
|
|
112
|
+
env.transport.closeLatestWs();
|
|
113
|
+
await env.flush(50);
|
|
114
|
+
doc.foreground();
|
|
115
|
+
await env.flush(50);
|
|
116
|
+
expect(urls.some((u) => u.includes("/api/entities/Property/cursor"))).toBe(true);
|
|
117
|
+
|
|
118
|
+
// Swept: the next foreground does not sweep again.
|
|
119
|
+
const swept = urls.length;
|
|
120
|
+
doc.foreground();
|
|
121
|
+
await env.flush();
|
|
122
|
+
expect(urls.slice(swept).filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
123
|
+
} finally {
|
|
124
|
+
doc.restore();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a catch-up snapshot that could not remove deletes is followed by a sweep", async () => {
|
|
129
|
+
env = await createTestEnv({ transport: "websocket", reconnectDelay: 5 });
|
|
130
|
+
env.server.snapshotMode = true;
|
|
131
|
+
env.signIn({ userId: "u1" });
|
|
132
|
+
env.server.seed("Lead", [{ id: "a" }, { id: "b" }]);
|
|
133
|
+
await env.engine.start();
|
|
134
|
+
await env.flush();
|
|
135
|
+
|
|
136
|
+
// An older server: its snapshot does not list the entities it covers.
|
|
137
|
+
env.server.omitSnapshotEntities = true;
|
|
138
|
+
env.server.farBehind = () => true;
|
|
139
|
+
env.server.delete("Lead", "b");
|
|
140
|
+
const urls = recordUrls();
|
|
141
|
+
await env.engine.pull();
|
|
142
|
+
// The snapshot could not drop b.
|
|
143
|
+
env.transport.closeLatestWs();
|
|
144
|
+
await env.flush(50);
|
|
145
|
+
expect(urls.some((u) => u.includes("/api/entities/Lead/cursor"))).toBe(true);
|
|
146
|
+
expect(env.engine.store.list("Lead").map((r) => r.id)).toEqual(["a"]);
|
|
147
|
+
});
|