@pylonsync/react 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/db.d.ts +4 -0
- package/dist/index.d.ts +13 -21
- package/dist/offlineUploads.d.ts +45 -0
- package/dist/offlineWorker.d.ts +50 -0
- package/dist/useOutbox.d.ts +45 -0
- package/package.json +4 -4
- package/src/db.ts +11 -0
- package/src/index.ts +25 -102
- package/src/offlineUploads.test.tsx +66 -0
- package/src/offlineUploads.ts +77 -0
- package/src/offlineWorker.test.ts +87 -0
- package/src/offlineWorker.ts +95 -0
- package/src/useOutbox.test.tsx +82 -0
- package/src/useOutbox.ts +74 -0
package/dist/db.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SyncEngine, type Row, type SyncEngineConfig } from "@pylonsync/sync";
|
|
2
|
+
import { type QueuedUploadHandle, type QueueUploadOptions } from "./offlineUploads";
|
|
2
3
|
import { type QueryOptions, type UseQueryReturn, type UseQueryOneReturn, type UseReactiveQueryReturn, type UseMutationReturn, type UseInfiniteQueryReturn, type AggregateSpec, type UseAggregateReturn, type SearchSpec, type UseSearchReturn } from "./hooks";
|
|
3
4
|
import { type UploadedFile, type UploadFileOptions } from "./index";
|
|
4
5
|
/**
|
|
@@ -174,6 +175,9 @@ export declare const db: {
|
|
|
174
175
|
streamFn(name: string, args?: Record<string, unknown>): AsyncGenerator<string, unknown, unknown>;
|
|
175
176
|
/** Upload a file through /api/files/init, a PUT, and /api/files/confirm. */
|
|
176
177
|
uploadFile(input: File | Blob | ArrayBuffer | Uint8Array, options?: UploadFileOptions): Promise<UploadedFile>;
|
|
178
|
+
/** Keep a file on the device and upload it when there is a network,
|
|
179
|
+
* then set it on its record. See `queueUpload`. */
|
|
180
|
+
queueUpload(file: Blob, options: QueueUploadOptions): Promise<QueuedUploadHandle>;
|
|
177
181
|
/** @deprecated Use `uploadFile`; extra fields were never stored. */
|
|
178
182
|
uploadFileMultipart(file: File | Blob, fields?: Record<string, string>): Promise<UploadedFile>;
|
|
179
183
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { ImageResponseOptions, ImageResponseFont, } from "./image-response"
|
|
|
14
14
|
export type { PageProps, PageAuth, ServerData, SsrResponse, SsrCookieOptions, Metadata, GenerateMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, LlmsTxt, LlmsSection, LlmsLink, RouteSegmentConfig, ErrorBoundaryProps, NotFoundProps, FormFields, FormDb, FormRequest, RouteHandler, RawRouteHandler, RawResponse, } from "./ssr";
|
|
15
15
|
export { useRouter, useSearchParams, usePathname, useParams, useRouteSeed, useRouteData, redirect, notFound, NotFoundError, } from "./useRouter";
|
|
16
16
|
export type { PylonRouter } from "./useRouter";
|
|
17
|
-
import { type Storage as PylonStorage, type TransportConfig } from "@pylonsync/sync";
|
|
17
|
+
import { type Storage as PylonStorage, type TransportConfig, type FileVisibility, type UploadBytesOptions, type UploadedFile } from "@pylonsync/sync";
|
|
18
18
|
export { useQuery, useQueryOne, useReactiveQuery, useMutation, useInfiniteQuery, usePaginatedQuery, useEntityMutation, useAction, useQueryRaw, useQueryOneRaw, useLiveList, useLiveRow, useInsert, useUpdate, useDelete, useFn, useAggregate, useSearch, } from "./hooks";
|
|
19
19
|
export type { QueryOptions, QueryFilter, IncludeSpec, UseQueryReturn, UseQueryOneReturn, UseReactiveQueryReturn, UseMutationReturn, UseInfiniteQueryReturn, UsePaginatedQueryReturn, PaginatedQueryStatus, UseFnReturn, AggregateSpec, UseAggregateReturn, SearchSpec, UseSearchReturn, } from "./hooks";
|
|
20
20
|
export { useAgentRun } from "./useAgentRun";
|
|
@@ -32,6 +32,13 @@ export { useSession } from "./useSession";
|
|
|
32
32
|
export type { UseSessionReturn, ResolvedSession } from "./useSession";
|
|
33
33
|
export { useSyncStatus } from "./useSyncStatus";
|
|
34
34
|
export type { SyncConnectionStatus } from "./useSyncStatus";
|
|
35
|
+
export { useOutbox } from "./useOutbox";
|
|
36
|
+
export type { UseOutboxReturn, Outbox, OutboxItem } from "./useOutbox";
|
|
37
|
+
export { queueUpload, useFileUrl } from "./offlineUploads";
|
|
38
|
+
export type { QueueUploadOptions, QueuedUploadHandle, UploadAttachTarget } from "./offlineUploads";
|
|
39
|
+
export { UploadQueueFullError, UploadRejectedError } from "@pylonsync/sync";
|
|
40
|
+
export { registerOfflineWorker } from "./offlineWorker";
|
|
41
|
+
export type { OfflineWorkerOptions } from "./offlineWorker";
|
|
35
42
|
export { db, init, getSync, setSessionToken } from "./db";
|
|
36
43
|
export { createTypedDb } from "./typed";
|
|
37
44
|
export type { TypedDb, AgentDBSchema } from "./typed";
|
|
@@ -254,27 +261,9 @@ export declare function listFns(): Promise<{
|
|
|
254
261
|
name: string;
|
|
255
262
|
fn_type: "query" | "mutation" | "action";
|
|
256
263
|
}[]>;
|
|
257
|
-
export
|
|
258
|
-
|
|
259
|
-
/** Where to read the file. Store this on your row. */
|
|
260
|
-
url: string;
|
|
261
|
-
size: number;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Who can read an uploaded file through `url`.
|
|
265
|
-
* - "private" (default): only the uploader.
|
|
266
|
-
* - "public": anyone, signed in or not. Use it for content every visitor
|
|
267
|
-
* sees, such as feed photos or listing images.
|
|
268
|
-
* Writes and deletes stay with the uploader either way. On a CDN-backed
|
|
269
|
-
* store (Stack0, a public bucket) every file is served by its URL, so the
|
|
270
|
-
* setting only changes reads that go through `/api/files/<id>`.
|
|
271
|
-
*/
|
|
272
|
-
export type FileVisibility = "private" | "public";
|
|
273
|
-
export interface UploadFileOptions {
|
|
274
|
-
filename?: string;
|
|
275
|
-
contentType?: string;
|
|
264
|
+
export type { UploadedFile, FileVisibility } from "@pylonsync/sync";
|
|
265
|
+
export interface UploadFileOptions extends UploadBytesOptions {
|
|
276
266
|
token?: string;
|
|
277
|
-
visibility?: FileVisibility;
|
|
278
267
|
}
|
|
279
268
|
/**
|
|
280
269
|
* Upload a file (File, Blob, or raw bytes) in three steps:
|
|
@@ -287,6 +276,9 @@ export interface UploadFileOptions {
|
|
|
287
276
|
* signature is the credential. The session is never sent to a presigned
|
|
288
277
|
* URL on another origin.
|
|
289
278
|
*
|
|
279
|
+
* All three steps need the network. For a file taken where there may be
|
|
280
|
+
* none (a photo in the field), use `queueUpload`.
|
|
281
|
+
*
|
|
290
282
|
* @example
|
|
291
283
|
* ```ts
|
|
292
284
|
* const photo = await uploadFile(fileFromInput, { visibility: "public" });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type FileVisibility, type QueuedUploadHandle, type SyncEngine, type UploadAttachTarget } from "@pylonsync/sync";
|
|
2
|
+
export type { QueuedUploadHandle, UploadAttachTarget };
|
|
3
|
+
export interface QueueUploadOptions {
|
|
4
|
+
/** The record the file belongs to, and the field that gets its id
|
|
5
|
+
* (and `urlField`, its URL). */
|
|
6
|
+
attach: UploadAttachTarget;
|
|
7
|
+
filename?: string;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
visibility?: FileVisibility;
|
|
10
|
+
/** Defaults to the engine `init()` set up. */
|
|
11
|
+
engine?: SyncEngine;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Keep a file on the device and upload it when there is a network, then
|
|
15
|
+
* set it on its record. Returns at once, also with no network.
|
|
16
|
+
*
|
|
17
|
+
* At once the record's `attach.field` (and `attach.urlField`) holds
|
|
18
|
+
* `local:<localId>`; read it with `useFileUrl` to show the photo from the
|
|
19
|
+
* device. When the upload finishes, the field gets the file id and
|
|
20
|
+
* `urlField` the file URL. Both changes are ordinary queued writes.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const photoId = await db.insert("Photo", { leadId, fileId: null, url: null });
|
|
24
|
+
* await queueUpload(file, {
|
|
25
|
+
* attach: { entity: "Photo", id: photoId, field: "fileId", urlField: "url" },
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Pending and failed uploads are in `useOutbox()` with kind `"upload"`.
|
|
30
|
+
* Throws `UploadQueueFullError` when the queue already holds
|
|
31
|
+
* `maxQueuedUploadBytes` (200 MB by default).
|
|
32
|
+
*/
|
|
33
|
+
export declare function queueUpload(file: Blob, options: QueueUploadOptions): Promise<QueuedUploadHandle>;
|
|
34
|
+
/**
|
|
35
|
+
* The URL to show for a file field: the value itself, or, for a
|
|
36
|
+
* `local:<localId>` value from `queueUpload`, an object URL of the copy on
|
|
37
|
+
* this device. Null for an empty value, and for a `local:` value this
|
|
38
|
+
* device does not hold (another device queued it, or it was dismissed).
|
|
39
|
+
*
|
|
40
|
+
* ```tsx
|
|
41
|
+
* const src = useFileUrl(photo.url);
|
|
42
|
+
* return src ? <img src={src} /> : <Placeholder />;
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function useFileUrl(value: string | null | undefined, engine?: SyncEngine): string | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface OfflineWorkerOptions {
|
|
2
|
+
/** The app set `buildManifest({ offline })`. */
|
|
3
|
+
optedIn: boolean;
|
|
4
|
+
/** Not `pylon dev`. */
|
|
5
|
+
production: boolean;
|
|
6
|
+
/** Whether a user is signed in on this page. The worker refetches the
|
|
7
|
+
* pages it keeps when this changes, because each one carries the
|
|
8
|
+
* signed-in bit. */
|
|
9
|
+
signedIn?: boolean;
|
|
10
|
+
/** Defaults to the global `navigator`. */
|
|
11
|
+
navigator?: {
|
|
12
|
+
serviceWorker?: ServiceWorkerContainerLike;
|
|
13
|
+
};
|
|
14
|
+
/** Resolves when the page has loaded, so the worker's downloads do not
|
|
15
|
+
* compete with the page's own. Defaults to the window `load` event. */
|
|
16
|
+
whenLoaded?: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/** The parts of `ServiceWorkerContainer` this module uses. */
|
|
19
|
+
export interface ServiceWorkerContainerLike {
|
|
20
|
+
register(url: string, options?: {
|
|
21
|
+
scope?: string;
|
|
22
|
+
}): Promise<unknown>;
|
|
23
|
+
getRegistrations?(): Promise<ReadonlyArray<RegistrationLike>>;
|
|
24
|
+
ready?: Promise<{
|
|
25
|
+
active?: {
|
|
26
|
+
postMessage(message: unknown): void;
|
|
27
|
+
} | null;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
interface RegistrationLike {
|
|
31
|
+
active?: {
|
|
32
|
+
scriptURL: string;
|
|
33
|
+
} | null;
|
|
34
|
+
waiting?: {
|
|
35
|
+
scriptURL: string;
|
|
36
|
+
} | null;
|
|
37
|
+
installing?: {
|
|
38
|
+
scriptURL: string;
|
|
39
|
+
} | null;
|
|
40
|
+
unregister(): Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Register the worker when the app opted in and this is a production page.
|
|
44
|
+
* Otherwise remove a Pylon worker an earlier build left on this origin (an
|
|
45
|
+
* app that turned offline off, or `pylon dev` after `pylon start`). Workers
|
|
46
|
+
* the app registered itself are left alone. Never throws: an app works
|
|
47
|
+
* online without its worker.
|
|
48
|
+
*/
|
|
49
|
+
export declare function registerOfflineWorker(opts: OfflineWorkerOptions): Promise<unknown | null>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Outbox, type OutboxItem, type Row, type SyncEngine } from "@pylonsync/sync";
|
|
2
|
+
export type { Outbox, OutboxItem };
|
|
3
|
+
export interface UseOutboxReturn {
|
|
4
|
+
/** Writes the server has not applied yet: made offline, or waiting
|
|
5
|
+
* for a sign-in. Oldest first. */
|
|
6
|
+
pending: OutboxItem[];
|
|
7
|
+
/** Writes the server rejected, with `error` and `errorCode`. Oldest
|
|
8
|
+
* first. Each stays until it is retried or dismissed. */
|
|
9
|
+
failed: OutboxItem[];
|
|
10
|
+
/** Send a failed write again, with `data` merged over its fields.
|
|
11
|
+
* Resolves once the server applies it or it is queued offline;
|
|
12
|
+
* rejects with a `MutationRejectedError` when the server rejects it
|
|
13
|
+
* again or another user is signed in. */
|
|
14
|
+
retry: (id: string, data?: Row) => Promise<string>;
|
|
15
|
+
/** Drop a failed write. */
|
|
16
|
+
dismiss: (id: string) => boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The writes on this device that are not on the server: pending writes
|
|
20
|
+
* (offline, or waiting for a sign-in) and failed writes (the server
|
|
21
|
+
* rejected them). Re-renders when a write is queued, applied, rejected,
|
|
22
|
+
* retried or dismissed.
|
|
23
|
+
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* function Outbox() {
|
|
26
|
+
* const { pending, failed, retry, dismiss } = useOutbox();
|
|
27
|
+
* return (
|
|
28
|
+
* <>
|
|
29
|
+
* {pending.length > 0 && <p>{pending.length} not synced</p>}
|
|
30
|
+
* {failed.map((w) => (
|
|
31
|
+
* <div key={w.id}>
|
|
32
|
+
* {w.entity}: {w.error}
|
|
33
|
+
* <button onClick={() => retry(w.id)}>Retry</button>
|
|
34
|
+
* <button onClick={() => dismiss(w.id)}>Dismiss</button>
|
|
35
|
+
* </div>
|
|
36
|
+
* ))}
|
|
37
|
+
* </>
|
|
38
|
+
* );
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Pass an explicit `engine` if your app uses a non-default sync
|
|
43
|
+
* instance; otherwise the hook reads the singleton wired up by `init()`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function useOutbox(engine?: SyncEngine): UseOutboxReturn;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.22.
|
|
6
|
+
"version": "0.22.50",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/realtime": "0.22.
|
|
18
|
-
"@pylonsync/sdk": "0.22.
|
|
19
|
-
"@pylonsync/sync": "0.22.
|
|
17
|
+
"@pylonsync/realtime": "0.22.50",
|
|
18
|
+
"@pylonsync/sdk": "0.22.50",
|
|
19
|
+
"@pylonsync/sync": "0.22.50"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"react": ">=19.0.0"
|
package/src/db.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { SyncEngine, createSyncEngine, type Row, type SyncEngineConfig } from "@pylonsync/sync";
|
|
4
4
|
import { setActiveEngine } from "./engine-registry";
|
|
5
|
+
import {
|
|
6
|
+
queueUpload,
|
|
7
|
+
type QueuedUploadHandle,
|
|
8
|
+
type QueueUploadOptions,
|
|
9
|
+
} from "./offlineUploads";
|
|
5
10
|
import {
|
|
6
11
|
useQuery as useQueryHook,
|
|
7
12
|
useQueryOne as useQueryOneHook,
|
|
@@ -340,6 +345,12 @@ export const db = {
|
|
|
340
345
|
return uploadFile(input, options);
|
|
341
346
|
},
|
|
342
347
|
|
|
348
|
+
/** Keep a file on the device and upload it when there is a network,
|
|
349
|
+
* then set it on its record. See `queueUpload`. */
|
|
350
|
+
queueUpload(file: Blob, options: QueueUploadOptions): Promise<QueuedUploadHandle> {
|
|
351
|
+
return queueUpload(file, options);
|
|
352
|
+
},
|
|
353
|
+
|
|
343
354
|
/** @deprecated Use `uploadFile`; extra fields were never stored. */
|
|
344
355
|
uploadFileMultipart(
|
|
345
356
|
file: File | Blob,
|
package/src/index.ts
CHANGED
|
@@ -75,8 +75,12 @@ import {
|
|
|
75
75
|
pylonFetch,
|
|
76
76
|
pylonFetchRaw,
|
|
77
77
|
PylonHttpError,
|
|
78
|
+
uploadBytes,
|
|
78
79
|
type Storage as PylonStorage,
|
|
79
80
|
type TransportConfig,
|
|
81
|
+
type FileVisibility,
|
|
82
|
+
type UploadBytesOptions,
|
|
83
|
+
type UploadedFile,
|
|
80
84
|
} from "@pylonsync/sync";
|
|
81
85
|
import { peekActiveEngine } from "./engine-registry";
|
|
82
86
|
|
|
@@ -187,6 +191,20 @@ export type { UseSessionReturn, ResolvedSession } from "./useSession";
|
|
|
187
191
|
export { useSyncStatus } from "./useSyncStatus";
|
|
188
192
|
export type { SyncConnectionStatus } from "./useSyncStatus";
|
|
189
193
|
|
|
194
|
+
// Writes not yet on the server, and writes the server rejected.
|
|
195
|
+
export { useOutbox } from "./useOutbox";
|
|
196
|
+
export type { UseOutboxReturn, Outbox, OutboxItem } from "./useOutbox";
|
|
197
|
+
|
|
198
|
+
// Files kept on the device and uploaded when there is a network.
|
|
199
|
+
export { queueUpload, useFileUrl } from "./offlineUploads";
|
|
200
|
+
export type { QueueUploadOptions, QueuedUploadHandle, UploadAttachTarget } from "./offlineUploads";
|
|
201
|
+
export { UploadQueueFullError, UploadRejectedError } from "@pylonsync/sync";
|
|
202
|
+
|
|
203
|
+
// Offline service worker registration. The SSR client runtime calls it on
|
|
204
|
+
// every page load; apps opt in with `buildManifest({ offline })`.
|
|
205
|
+
export { registerOfflineWorker } from "./offlineWorker";
|
|
206
|
+
export type { OfflineWorkerOptions } from "./offlineWorker";
|
|
207
|
+
|
|
190
208
|
// One-liner API
|
|
191
209
|
export { db, init, getSync, setSessionToken } from "./db";
|
|
192
210
|
|
|
@@ -952,29 +970,10 @@ export async function listFns(): Promise<
|
|
|
952
970
|
// File upload
|
|
953
971
|
// ---------------------------------------------------------------------------
|
|
954
972
|
|
|
955
|
-
export
|
|
956
|
-
id: string;
|
|
957
|
-
/** Where to read the file. Store this on your row. */
|
|
958
|
-
url: string;
|
|
959
|
-
size: number;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/**
|
|
963
|
-
* Who can read an uploaded file through `url`.
|
|
964
|
-
* - "private" (default): only the uploader.
|
|
965
|
-
* - "public": anyone, signed in or not. Use it for content every visitor
|
|
966
|
-
* sees, such as feed photos or listing images.
|
|
967
|
-
* Writes and deletes stay with the uploader either way. On a CDN-backed
|
|
968
|
-
* store (Stack0, a public bucket) every file is served by its URL, so the
|
|
969
|
-
* setting only changes reads that go through `/api/files/<id>`.
|
|
970
|
-
*/
|
|
971
|
-
export type FileVisibility = "private" | "public";
|
|
973
|
+
export type { UploadedFile, FileVisibility } from "@pylonsync/sync";
|
|
972
974
|
|
|
973
|
-
export interface UploadFileOptions {
|
|
974
|
-
filename?: string;
|
|
975
|
-
contentType?: string;
|
|
975
|
+
export interface UploadFileOptions extends UploadBytesOptions {
|
|
976
976
|
token?: string;
|
|
977
|
-
visibility?: FileVisibility;
|
|
978
977
|
}
|
|
979
978
|
|
|
980
979
|
/**
|
|
@@ -988,6 +987,9 @@ export interface UploadFileOptions {
|
|
|
988
987
|
* signature is the credential. The session is never sent to a presigned
|
|
989
988
|
* URL on another origin.
|
|
990
989
|
*
|
|
990
|
+
* All three steps need the network. For a file taken where there may be
|
|
991
|
+
* none (a photo in the field), use `queueUpload`.
|
|
992
|
+
*
|
|
991
993
|
* @example
|
|
992
994
|
* ```ts
|
|
993
995
|
* const photo = await uploadFile(fileFromInput, { visibility: "public" });
|
|
@@ -998,87 +1000,8 @@ export async function uploadFile(
|
|
|
998
1000
|
input: File | Blob | ArrayBuffer | Uint8Array,
|
|
999
1001
|
options: UploadFileOptions = {}
|
|
1000
1002
|
): Promise<UploadedFile> {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
let filename = options.filename;
|
|
1004
|
-
let contentType = options.contentType;
|
|
1005
|
-
|
|
1006
|
-
if (typeof File !== "undefined" && input instanceof File) {
|
|
1007
|
-
body = input;
|
|
1008
|
-
size = input.size;
|
|
1009
|
-
filename ??= input.name;
|
|
1010
|
-
contentType ??= input.type || "application/octet-stream";
|
|
1011
|
-
} else if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
1012
|
-
body = input;
|
|
1013
|
-
size = input.size;
|
|
1014
|
-
contentType ??= input.type || "application/octet-stream";
|
|
1015
|
-
} else if (input instanceof ArrayBuffer) {
|
|
1016
|
-
body = input;
|
|
1017
|
-
size = input.byteLength;
|
|
1018
|
-
} else {
|
|
1019
|
-
// Newer TS lib types refuse `Uint8Array<ArrayBufferLike>` as BodyInit
|
|
1020
|
-
// directly even though every runtime accepts it. Hand fetch the
|
|
1021
|
-
// underlying ArrayBuffer slice to sidestep the type narrowing.
|
|
1022
|
-
const u8 = input as Uint8Array;
|
|
1023
|
-
body = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
|
1024
|
-
size = u8.byteLength;
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
filename ??= "upload";
|
|
1028
|
-
contentType ??= "application/octet-stream";
|
|
1029
|
-
|
|
1030
|
-
const transport = clientTransport(options.token);
|
|
1031
|
-
|
|
1032
|
-
const slot = await pylonFetch<{ uploadUrl: string; assetId: string }>(
|
|
1033
|
-
transport,
|
|
1034
|
-
"/api/files/init",
|
|
1035
|
-
{
|
|
1036
|
-
method: "POST",
|
|
1037
|
-
json: {
|
|
1038
|
-
filename,
|
|
1039
|
-
mimeType: contentType,
|
|
1040
|
-
size,
|
|
1041
|
-
visibility: options.visibility ?? "private",
|
|
1042
|
-
},
|
|
1043
|
-
},
|
|
1044
|
-
);
|
|
1045
|
-
|
|
1046
|
-
const base = new URL(getBaseUrl() || "http://localhost");
|
|
1047
|
-
const target = new URL(slot.uploadUrl, base);
|
|
1048
|
-
const put =
|
|
1049
|
-
target.origin === base.origin
|
|
1050
|
-
? await pylonFetchRaw(transport, `${target.pathname}${target.search}`, {
|
|
1051
|
-
method: "PUT",
|
|
1052
|
-
body,
|
|
1053
|
-
headers: { "Content-Type": contentType },
|
|
1054
|
-
})
|
|
1055
|
-
: await fetch(target.toString(), {
|
|
1056
|
-
method: "PUT",
|
|
1057
|
-
body,
|
|
1058
|
-
headers: { "Content-Type": contentType },
|
|
1059
|
-
});
|
|
1060
|
-
// Read the response body to the end, even an empty 204. Chrome cancels a
|
|
1061
|
-
// fetch whose body is never read once the Response is dropped, and
|
|
1062
|
-
// DevTools then lists the upload as failed (net::ERR_ABORTED) although
|
|
1063
|
-
// the server stored the bytes.
|
|
1064
|
-
const putBody = await put.text().catch(() => "");
|
|
1065
|
-
if (!put.ok) {
|
|
1066
|
-
let message = `Upload of ${filename} failed: ${put.status}`;
|
|
1067
|
-
let code: string | undefined;
|
|
1068
|
-
try {
|
|
1069
|
-
const err = JSON.parse(putBody) as { error?: { code?: string; message?: string } };
|
|
1070
|
-
if (err.error?.message) message = err.error.message;
|
|
1071
|
-
code = err.error?.code;
|
|
1072
|
-
} catch {
|
|
1073
|
-
// Not JSON (storage provider XML, proxy page): keep the status text.
|
|
1074
|
-
}
|
|
1075
|
-
throw new PylonHttpError(message, put.status, code);
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
return pylonFetch<UploadedFile>(transport, "/api/files/confirm", {
|
|
1079
|
-
method: "POST",
|
|
1080
|
-
json: { assetId: slot.assetId },
|
|
1081
|
-
});
|
|
1003
|
+
const { token, ...rest } = options;
|
|
1004
|
+
return uploadBytes(clientTransport(token), input, rest);
|
|
1082
1005
|
}
|
|
1083
1006
|
|
|
1084
1007
|
/**
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// A queued photo shows on its record at once, from the device, and the
|
|
2
|
+
// record shows the uploaded file's URL after the upload runs.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
6
|
+
import type { SyncEngine } from "@pylonsync/sync";
|
|
7
|
+
import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
|
|
8
|
+
|
|
9
|
+
import { useQueryOne } from "./hooks";
|
|
10
|
+
import { queueUpload, useFileUrl } from "./offlineUploads";
|
|
11
|
+
|
|
12
|
+
const asEngine = (e: TestEnv["engine"]): SyncEngine => e as unknown as SyncEngine;
|
|
13
|
+
|
|
14
|
+
let env: TestEnv | null = null;
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
cleanup();
|
|
17
|
+
if (env) {
|
|
18
|
+
await env.dispose();
|
|
19
|
+
env = null;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function PhotoCard({ engine }: { engine: SyncEngine }) {
|
|
24
|
+
const { data } = useQueryOne<{ id: string; url: string | null }>(engine, "Photo", "p1");
|
|
25
|
+
const src = useFileUrl(data?.url, engine);
|
|
26
|
+
return <span data-testid="src">{src ?? "none"}</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("queueUpload + useFileUrl", () => {
|
|
30
|
+
test("the record shows the local photo at once and the real URL after the upload", async () => {
|
|
31
|
+
env = createTestEnv({ transport: "poll" });
|
|
32
|
+
env.signIn({ userId: "rep1" });
|
|
33
|
+
env.server.seed("Photo", [{ id: "p1", url: null, fileId: null }]);
|
|
34
|
+
await env.start();
|
|
35
|
+
const engine = asEngine(env.engine);
|
|
36
|
+
render(<PhotoCard engine={engine} />);
|
|
37
|
+
expect(screen.getByTestId("src").textContent).toBe("none");
|
|
38
|
+
|
|
39
|
+
// Hold the upload so the local stage is observable.
|
|
40
|
+
env.server.files.dropNextPut();
|
|
41
|
+
let queued!: Awaited<ReturnType<typeof queueUpload>>;
|
|
42
|
+
await act(async () => {
|
|
43
|
+
queued = await queueUpload(new File([new Uint8Array(64)], "door.jpg", { type: "image/jpeg" }), {
|
|
44
|
+
attach: { entity: "Photo", id: "p1", field: "fileId", urlField: "url" },
|
|
45
|
+
engine,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
expect(queued.localUrl).toMatch(/^blob:/);
|
|
49
|
+
expect(screen.getByTestId("src").textContent).toBe(queued.localUrl!);
|
|
50
|
+
|
|
51
|
+
await act(async () => {
|
|
52
|
+
await env!.engine.uploads.process();
|
|
53
|
+
await queued.done;
|
|
54
|
+
});
|
|
55
|
+
await waitFor(() => expect(screen.getByTestId("src").textContent).toBe("/api/files/f_2"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a local reference this device does not hold resolves to null", async () => {
|
|
59
|
+
env = createTestEnv({ transport: "poll" });
|
|
60
|
+
env.signIn({ userId: "rep1" });
|
|
61
|
+
env.server.seed("Photo", [{ id: "p1", url: "local:upl_from_another_phone", fileId: null }]);
|
|
62
|
+
await env.start();
|
|
63
|
+
render(<PhotoCard engine={asEngine(env.engine)} />);
|
|
64
|
+
await waitFor(() => expect(screen.getByTestId("src").textContent).toBe("none"));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
localUploadId,
|
|
5
|
+
OUTBOX_CHANNEL,
|
|
6
|
+
type FileVisibility,
|
|
7
|
+
type QueuedUploadHandle,
|
|
8
|
+
type SyncEngine,
|
|
9
|
+
type UploadAttachTarget,
|
|
10
|
+
} from "@pylonsync/sync";
|
|
11
|
+
import { useSyncExternalStore } from "react";
|
|
12
|
+
import { db } from "./db";
|
|
13
|
+
|
|
14
|
+
export type { QueuedUploadHandle, UploadAttachTarget };
|
|
15
|
+
|
|
16
|
+
export interface QueueUploadOptions {
|
|
17
|
+
/** The record the file belongs to, and the field that gets its id
|
|
18
|
+
* (and `urlField`, its URL). */
|
|
19
|
+
attach: UploadAttachTarget;
|
|
20
|
+
filename?: string;
|
|
21
|
+
contentType?: string;
|
|
22
|
+
visibility?: FileVisibility;
|
|
23
|
+
/** Defaults to the engine `init()` set up. */
|
|
24
|
+
engine?: SyncEngine;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keep a file on the device and upload it when there is a network, then
|
|
29
|
+
* set it on its record. Returns at once, also with no network.
|
|
30
|
+
*
|
|
31
|
+
* At once the record's `attach.field` (and `attach.urlField`) holds
|
|
32
|
+
* `local:<localId>`; read it with `useFileUrl` to show the photo from the
|
|
33
|
+
* device. When the upload finishes, the field gets the file id and
|
|
34
|
+
* `urlField` the file URL. Both changes are ordinary queued writes.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* const photoId = await db.insert("Photo", { leadId, fileId: null, url: null });
|
|
38
|
+
* await queueUpload(file, {
|
|
39
|
+
* attach: { entity: "Photo", id: photoId, field: "fileId", urlField: "url" },
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* Pending and failed uploads are in `useOutbox()` with kind `"upload"`.
|
|
44
|
+
* Throws `UploadQueueFullError` when the queue already holds
|
|
45
|
+
* `maxQueuedUploadBytes` (200 MB by default).
|
|
46
|
+
*/
|
|
47
|
+
export function queueUpload(
|
|
48
|
+
file: Blob,
|
|
49
|
+
options: QueueUploadOptions,
|
|
50
|
+
): Promise<QueuedUploadHandle> {
|
|
51
|
+
const { engine, ...rest } = options;
|
|
52
|
+
return (engine ?? db.sync).queueUpload(file, rest);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The URL to show for a file field: the value itself, or, for a
|
|
57
|
+
* `local:<localId>` value from `queueUpload`, an object URL of the copy on
|
|
58
|
+
* this device. Null for an empty value, and for a `local:` value this
|
|
59
|
+
* device does not hold (another device queued it, or it was dismissed).
|
|
60
|
+
*
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const src = useFileUrl(photo.url);
|
|
63
|
+
* return src ? <img src={src} /> : <Placeholder />;
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function useFileUrl(
|
|
67
|
+
value: string | null | undefined,
|
|
68
|
+
engine?: SyncEngine,
|
|
69
|
+
): string | null {
|
|
70
|
+
const sync = engine ?? db.sync;
|
|
71
|
+
const localId = localUploadId(value);
|
|
72
|
+
return useSyncExternalStore(
|
|
73
|
+
(cb) => sync.store.subscribe(cb, OUTBOX_CHANNEL),
|
|
74
|
+
() => (localId ? sync.uploadLocalUrl(localId) : (value ?? null)),
|
|
75
|
+
() => (localId ? null : (value ?? null)),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The SSR client runtime calls registerOfflineWorker on every page load. It
|
|
2
|
+
// must register `/pylon-sw.js` only for a production page of an app that
|
|
3
|
+
// opted in, and otherwise remove a Pylon worker an earlier build left behind
|
|
4
|
+
// without touching the app's own workers.
|
|
5
|
+
|
|
6
|
+
import { expect, test } from "bun:test";
|
|
7
|
+
import { registerOfflineWorker } from "./offlineWorker";
|
|
8
|
+
|
|
9
|
+
function fakeContainer(scripts: string[] = []) {
|
|
10
|
+
const registered: Array<{ url: string; scope?: string }> = [];
|
|
11
|
+
const unregistered: string[] = [];
|
|
12
|
+
const messages: unknown[] = [];
|
|
13
|
+
const container = {
|
|
14
|
+
register: async (url: string, options?: { scope?: string }) => {
|
|
15
|
+
registered.push({ url, scope: options?.scope });
|
|
16
|
+
return { scope: "/" };
|
|
17
|
+
},
|
|
18
|
+
getRegistrations: async () =>
|
|
19
|
+
scripts.map((scriptURL) => ({
|
|
20
|
+
active: { scriptURL },
|
|
21
|
+
unregister: async () => {
|
|
22
|
+
unregistered.push(scriptURL);
|
|
23
|
+
return true;
|
|
24
|
+
},
|
|
25
|
+
})),
|
|
26
|
+
ready: Promise.resolve({ active: { postMessage: (m: unknown) => messages.push(m) } }),
|
|
27
|
+
};
|
|
28
|
+
return { container, registered, unregistered, messages };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const loaded = () => Promise.resolve();
|
|
32
|
+
|
|
33
|
+
test("registers the worker in production when the app opted in", async () => {
|
|
34
|
+
const f = fakeContainer();
|
|
35
|
+
const reg = await registerOfflineWorker({
|
|
36
|
+
optedIn: true,
|
|
37
|
+
production: true,
|
|
38
|
+
signedIn: true,
|
|
39
|
+
navigator: { serviceWorker: f.container },
|
|
40
|
+
whenLoaded: loaded,
|
|
41
|
+
});
|
|
42
|
+
expect(reg).not.toBeNull();
|
|
43
|
+
expect(f.registered).toEqual([{ url: "/pylon-sw.js", scope: "/" }]);
|
|
44
|
+
expect(f.messages).toEqual([{ type: "pylon:session", signedIn: true }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not register under pylon dev", async () => {
|
|
48
|
+
const f = fakeContainer();
|
|
49
|
+
await registerOfflineWorker({
|
|
50
|
+
optedIn: true,
|
|
51
|
+
production: false,
|
|
52
|
+
navigator: { serviceWorker: f.container },
|
|
53
|
+
whenLoaded: loaded,
|
|
54
|
+
});
|
|
55
|
+
expect(f.registered).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("does not register when the app did not opt in", async () => {
|
|
59
|
+
const f = fakeContainer();
|
|
60
|
+
await registerOfflineWorker({
|
|
61
|
+
optedIn: false,
|
|
62
|
+
production: true,
|
|
63
|
+
navigator: { serviceWorker: f.container },
|
|
64
|
+
whenLoaded: loaded,
|
|
65
|
+
});
|
|
66
|
+
expect(f.registered).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("removes a leftover Pylon worker but not the app's own", async () => {
|
|
70
|
+
const f = fakeContainer([
|
|
71
|
+
"https://app.example/pylon-sw.js",
|
|
72
|
+
"https://app.example/push-worker.js",
|
|
73
|
+
]);
|
|
74
|
+
await registerOfflineWorker({
|
|
75
|
+
optedIn: false,
|
|
76
|
+
production: true,
|
|
77
|
+
navigator: { serviceWorker: f.container },
|
|
78
|
+
whenLoaded: loaded,
|
|
79
|
+
});
|
|
80
|
+
expect(f.unregistered).toEqual(["https://app.example/pylon-sw.js"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a browser without service workers is a no-op", async () => {
|
|
84
|
+
expect(
|
|
85
|
+
await registerOfflineWorker({ optedIn: true, production: true, navigator: {} }),
|
|
86
|
+
).toBeNull();
|
|
87
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Registers the offline service worker (`/pylon-sw.js`) for apps that set
|
|
2
|
+
// `buildManifest({ offline })`. The SSR client runtime calls this once per
|
|
3
|
+
// page load; apps do not call it themselves.
|
|
4
|
+
//
|
|
5
|
+
// The worker is served by the Pylon runtime and generated per build (see
|
|
6
|
+
// packages/functions/src/offline-sw.ts). It is never registered under
|
|
7
|
+
// `pylon dev`: a worker serves cached build files first, which would hide
|
|
8
|
+
// every edit until the next install.
|
|
9
|
+
|
|
10
|
+
const WORKER_URL = "/pylon-sw.js";
|
|
11
|
+
|
|
12
|
+
export interface OfflineWorkerOptions {
|
|
13
|
+
/** The app set `buildManifest({ offline })`. */
|
|
14
|
+
optedIn: boolean;
|
|
15
|
+
/** Not `pylon dev`. */
|
|
16
|
+
production: boolean;
|
|
17
|
+
/** Whether a user is signed in on this page. The worker refetches the
|
|
18
|
+
* pages it keeps when this changes, because each one carries the
|
|
19
|
+
* signed-in bit. */
|
|
20
|
+
signedIn?: boolean;
|
|
21
|
+
/** Defaults to the global `navigator`. */
|
|
22
|
+
navigator?: { serviceWorker?: ServiceWorkerContainerLike };
|
|
23
|
+
/** Resolves when the page has loaded, so the worker's downloads do not
|
|
24
|
+
* compete with the page's own. Defaults to the window `load` event. */
|
|
25
|
+
whenLoaded?: () => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The parts of `ServiceWorkerContainer` this module uses. */
|
|
29
|
+
export interface ServiceWorkerContainerLike {
|
|
30
|
+
register(url: string, options?: { scope?: string }): Promise<unknown>;
|
|
31
|
+
getRegistrations?(): Promise<ReadonlyArray<RegistrationLike>>;
|
|
32
|
+
ready?: Promise<{ active?: { postMessage(message: unknown): void } | null }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RegistrationLike {
|
|
36
|
+
active?: { scriptURL: string } | null;
|
|
37
|
+
waiting?: { scriptURL: string } | null;
|
|
38
|
+
installing?: { scriptURL: string } | null;
|
|
39
|
+
unregister(): Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultWhenLoaded(): Promise<void> {
|
|
43
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
}
|
|
46
|
+
if (document.readyState === "complete") return Promise.resolve();
|
|
47
|
+
return new Promise((resolve) =>
|
|
48
|
+
window.addEventListener("load", () => resolve(), { once: true }),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPylonWorker(reg: RegistrationLike): boolean {
|
|
53
|
+
const worker = reg.active ?? reg.waiting ?? reg.installing;
|
|
54
|
+
if (!worker) return false;
|
|
55
|
+
try {
|
|
56
|
+
return new URL(worker.scriptURL).pathname === WORKER_URL;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register the worker when the app opted in and this is a production page.
|
|
64
|
+
* Otherwise remove a Pylon worker an earlier build left on this origin (an
|
|
65
|
+
* app that turned offline off, or `pylon dev` after `pylon start`). Workers
|
|
66
|
+
* the app registered itself are left alone. Never throws: an app works
|
|
67
|
+
* online without its worker.
|
|
68
|
+
*/
|
|
69
|
+
export async function registerOfflineWorker(
|
|
70
|
+
opts: OfflineWorkerOptions,
|
|
71
|
+
): Promise<unknown | null> {
|
|
72
|
+
const nav =
|
|
73
|
+
opts.navigator ??
|
|
74
|
+
(typeof navigator !== "undefined" ? (navigator as any) : undefined);
|
|
75
|
+
const container = nav?.serviceWorker as ServiceWorkerContainerLike | undefined;
|
|
76
|
+
if (!container) return null;
|
|
77
|
+
try {
|
|
78
|
+
if (!opts.optedIn || !opts.production) {
|
|
79
|
+
const regs = (await container.getRegistrations?.()) ?? [];
|
|
80
|
+
await Promise.all(regs.filter(isPylonWorker).map((r) => r.unregister()));
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
await (opts.whenLoaded ?? defaultWhenLoaded)();
|
|
84
|
+
const registration = await container.register(WORKER_URL, { scope: "/" });
|
|
85
|
+
if (typeof opts.signedIn === "boolean" && container.ready) {
|
|
86
|
+
const ready = await container.ready;
|
|
87
|
+
ready.active?.postMessage({ type: "pylon:session", signedIn: opts.signedIn });
|
|
88
|
+
}
|
|
89
|
+
return registration;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn("[pylon offline] service worker registration failed:", err);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `useOutbox` re-renders as writes go pending, reach the server, or fail, and
|
|
2
|
+
// its retry/dismiss act on the failed list.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
6
|
+
import type { SyncEngine } from "@pylonsync/sync";
|
|
7
|
+
import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
|
|
8
|
+
|
|
9
|
+
import { useOutbox } from "./useOutbox";
|
|
10
|
+
|
|
11
|
+
const asEngine = (e: TestEnv["engine"]): SyncEngine => e as unknown as SyncEngine;
|
|
12
|
+
|
|
13
|
+
let env: TestEnv | null = null;
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
cleanup();
|
|
16
|
+
if (env) {
|
|
17
|
+
await env.dispose();
|
|
18
|
+
env = null;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let latest: ReturnType<typeof useOutbox> | null = null;
|
|
23
|
+
function Probe({ engine }: { engine: SyncEngine }) {
|
|
24
|
+
const outbox = useOutbox(engine);
|
|
25
|
+
latest = outbox;
|
|
26
|
+
return (
|
|
27
|
+
<span data-testid="outbox">
|
|
28
|
+
{`${outbox.pending.length} not synced, ${outbox.failed.length} failed` +
|
|
29
|
+
(outbox.failed[0] ? `: ${outbox.failed[0].error}` : "")}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const text = () => screen.getByTestId("outbox").textContent;
|
|
35
|
+
|
|
36
|
+
describe("useOutbox", () => {
|
|
37
|
+
test("re-renders when a write goes pending, then reaches the server", async () => {
|
|
38
|
+
env = createTestEnv({ transport: "poll" });
|
|
39
|
+
env.signIn({ userId: "u1" });
|
|
40
|
+
await env.start();
|
|
41
|
+
render(<Probe engine={asEngine(env.engine)} />);
|
|
42
|
+
expect(text()).toBe("0 not synced, 0 failed");
|
|
43
|
+
|
|
44
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
45
|
+
await act(async () => {
|
|
46
|
+
await env!.engine.insert("Knock", { door: "1" });
|
|
47
|
+
});
|
|
48
|
+
expect(text()).toBe("1 not synced, 0 failed");
|
|
49
|
+
|
|
50
|
+
await act(async () => {
|
|
51
|
+
await env!.engine.push();
|
|
52
|
+
});
|
|
53
|
+
await waitFor(() => expect(text()).toBe("0 not synced, 0 failed"));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("re-renders when a write fails; retry and dismiss work from the hook", async () => {
|
|
57
|
+
env = createTestEnv({ transport: "poll" });
|
|
58
|
+
env.signIn({ userId: "u1" });
|
|
59
|
+
await env.start();
|
|
60
|
+
env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "door number is required");
|
|
61
|
+
render(<Probe engine={asEngine(env.engine)} />);
|
|
62
|
+
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await env!.engine.insert("Knock", { door: "" }).catch(() => {});
|
|
65
|
+
});
|
|
66
|
+
expect(text()).toBe("0 not synced, 1 failed: door number is required");
|
|
67
|
+
|
|
68
|
+
await act(async () => {
|
|
69
|
+
await latest!.retry(latest!.failed[0]!.id, { door: "7" });
|
|
70
|
+
});
|
|
71
|
+
await waitFor(() => expect(text()).toBe("0 not synced, 0 failed"));
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await env!.engine.insert("Knock", { door: "" }).catch(() => {});
|
|
75
|
+
});
|
|
76
|
+
expect(text()).toBe("0 not synced, 1 failed: door number is required");
|
|
77
|
+
act(() => {
|
|
78
|
+
expect(latest!.dismiss(latest!.failed[0]!.id)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
expect(text()).toBe("0 not synced, 0 failed");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/useOutbox.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
OUTBOX_CHANNEL,
|
|
5
|
+
type Outbox,
|
|
6
|
+
type OutboxItem,
|
|
7
|
+
type Row,
|
|
8
|
+
type SyncEngine,
|
|
9
|
+
} from "@pylonsync/sync";
|
|
10
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
11
|
+
import { db } from "./db";
|
|
12
|
+
|
|
13
|
+
export type { Outbox, OutboxItem };
|
|
14
|
+
|
|
15
|
+
export interface UseOutboxReturn {
|
|
16
|
+
/** Writes the server has not applied yet: made offline, or waiting
|
|
17
|
+
* for a sign-in. Oldest first. */
|
|
18
|
+
pending: OutboxItem[];
|
|
19
|
+
/** Writes the server rejected, with `error` and `errorCode`. Oldest
|
|
20
|
+
* first. Each stays until it is retried or dismissed. */
|
|
21
|
+
failed: OutboxItem[];
|
|
22
|
+
/** Send a failed write again, with `data` merged over its fields.
|
|
23
|
+
* Resolves once the server applies it or it is queued offline;
|
|
24
|
+
* rejects with a `MutationRejectedError` when the server rejects it
|
|
25
|
+
* again or another user is signed in. */
|
|
26
|
+
retry: (id: string, data?: Row) => Promise<string>;
|
|
27
|
+
/** Drop a failed write. */
|
|
28
|
+
dismiss: (id: string) => boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EMPTY: Outbox = { pending: [], failed: [] };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The writes on this device that are not on the server: pending writes
|
|
35
|
+
* (offline, or waiting for a sign-in) and failed writes (the server
|
|
36
|
+
* rejected them). Re-renders when a write is queued, applied, rejected,
|
|
37
|
+
* retried or dismissed.
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* function Outbox() {
|
|
41
|
+
* const { pending, failed, retry, dismiss } = useOutbox();
|
|
42
|
+
* return (
|
|
43
|
+
* <>
|
|
44
|
+
* {pending.length > 0 && <p>{pending.length} not synced</p>}
|
|
45
|
+
* {failed.map((w) => (
|
|
46
|
+
* <div key={w.id}>
|
|
47
|
+
* {w.entity}: {w.error}
|
|
48
|
+
* <button onClick={() => retry(w.id)}>Retry</button>
|
|
49
|
+
* <button onClick={() => dismiss(w.id)}>Dismiss</button>
|
|
50
|
+
* </div>
|
|
51
|
+
* ))}
|
|
52
|
+
* </>
|
|
53
|
+
* );
|
|
54
|
+
* }
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* Pass an explicit `engine` if your app uses a non-default sync
|
|
58
|
+
* instance; otherwise the hook reads the singleton wired up by `init()`.
|
|
59
|
+
*/
|
|
60
|
+
export function useOutbox(engine?: SyncEngine): UseOutboxReturn {
|
|
61
|
+
const sync = engine ?? db.sync;
|
|
62
|
+
const outbox = useSyncExternalStore(
|
|
63
|
+
(cb) => sync.store.subscribe(cb, OUTBOX_CHANNEL),
|
|
64
|
+
() => sync.outbox(),
|
|
65
|
+
// The server has no queue.
|
|
66
|
+
() => EMPTY,
|
|
67
|
+
);
|
|
68
|
+
const retry = useCallback(
|
|
69
|
+
(id: string, data?: Row) => sync.retryWrite(id, data),
|
|
70
|
+
[sync],
|
|
71
|
+
);
|
|
72
|
+
const dismiss = useCallback((id: string) => sync.dismissWrite(id), [sync]);
|
|
73
|
+
return { pending: outbox.pending, failed: outbox.failed, retry, dismiss };
|
|
74
|
+
}
|