@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,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
+ }
@@ -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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.47",
6
+ "version": "0.22.58",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -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
+ });