@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 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 interface UploadedFile {
258
- id: string;
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.47",
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.47",
18
- "@pylonsync/sdk": "0.22.47",
19
- "@pylonsync/sync": "0.22.47"
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 interface UploadedFile {
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
- let body: BodyInit;
1002
- let size: number;
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
+ });
@@ -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
+ }