@pylonsync/react 0.22.47 → 0.22.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { compareForOrder, compileWhere } from "./queryFilter";
3
4
  import { SyncEngine, generateId, pylonFetch, type Row } from "@pylonsync/sync";
4
5
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
6
  import { callFn, clientTransport } from "./index";
@@ -476,7 +477,7 @@ function applyClientFilter(rows: Iterable<Row>, options?: QueryOptions): Row[] {
476
477
  if (!matches || matches(row)) out.push(row);
477
478
  }
478
479
  for (const [field, dir] of order) {
479
- out.sort((a, b) => compare(a[field], b[field], dir));
480
+ out.sort((a, b) => compareForOrder(a[field], b[field], dir));
480
481
  }
481
482
  if (typeof limit === "number") {
482
483
  return out.slice(0, limit);
@@ -484,73 +485,6 @@ function applyClientFilter(rows: Iterable<Row>, options?: QueryOptions): Row[] {
484
485
  return out;
485
486
  }
486
487
 
487
- function compileWhere(where: QueryFilter): (row: Row) => boolean {
488
- const fields = Object.entries(where)
489
- .filter(([key]) => key !== "$order" && key !== "$limit")
490
- .map(([key, val]) => ({
491
- key,
492
- val,
493
- operators: val !== null && typeof val === "object" && !Array.isArray(val)
494
- ? Object.entries(val as Record<string, unknown>).map(([op, opVal]) => ({
495
- op,
496
- opVal,
497
- members: op === "$in" && Array.isArray(opVal) ? new Set(opVal) : undefined,
498
- }))
499
- : undefined,
500
- }));
501
- return (row) => {
502
- for (const { key, val, operators } of fields) {
503
- const rowVal = row[key];
504
- if (operators) {
505
- for (const { op, opVal, members } of operators) {
506
- switch (op) {
507
- case "$not":
508
- if (rowVal === opVal) return false;
509
- break;
510
- case "$gt":
511
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal > opVal))
512
- return false;
513
- break;
514
- case "$gte":
515
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal >= opVal))
516
- return false;
517
- break;
518
- case "$lt":
519
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal < opVal))
520
- return false;
521
- break;
522
- case "$lte":
523
- if (!(typeof rowVal === "number" && typeof opVal === "number" && rowVal <= opVal))
524
- return false;
525
- break;
526
- case "$like":
527
- if (
528
- !(typeof rowVal === "string" && typeof opVal === "string" && rowVal.includes(opVal))
529
- )
530
- return false;
531
- break;
532
- case "$in":
533
- if (!members?.has(rowVal)) return false;
534
- break;
535
- }
536
- }
537
- } else {
538
- if (rowVal !== val) return false;
539
- }
540
- }
541
- return true;
542
- };
543
- }
544
-
545
- function compare(a: unknown, b: unknown, dir: "asc" | "desc"): number {
546
- const mult = dir === "desc" ? -1 : 1;
547
- if (a === b) return 0;
548
- if (a === undefined || a === null) return mult;
549
- if (b === undefined || b === null) return -mult;
550
- if (typeof a === "number" && typeof b === "number") return (a - b) * mult;
551
- return String(a).localeCompare(String(b)) * mult;
552
- }
553
-
554
488
  // ---------------------------------------------------------------------------
555
489
  // useMutation — call a server-side TypeScript function
556
490
  // ---------------------------------------------------------------------------
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
 
@@ -186,6 +190,30 @@ export type { UseSessionReturn, ResolvedSession } from "./useSession";
186
190
 
187
191
  export { useSyncStatus } from "./useSyncStatus";
188
192
  export type { SyncConnectionStatus } from "./useSyncStatus";
193
+ export { useSyncProgress } from "./useSyncProgress";
194
+ export type { SyncProgress } from "./useSyncProgress";
195
+
196
+ // Writes not yet on the server, and writes the server rejected.
197
+ export { useOutbox } from "./useOutbox";
198
+ export type { UseOutboxReturn, Outbox, OutboxItem } from "./useOutbox";
199
+
200
+ // Files kept on the device and uploaded when there is a network.
201
+ export { queueUpload, useFileUrl } from "./offlineUploads";
202
+ export type { QueueUploadOptions, QueuedUploadHandle, UploadAttachTarget } from "./offlineUploads";
203
+ export { UploadQueueFullError, UploadRejectedError } from "@pylonsync/sync";
204
+
205
+ // Offline service worker registration. The SSR client runtime calls it on
206
+ // every page load; apps opt in with `buildManifest({ offline })`.
207
+ export { registerOfflineWorker } from "./offlineWorker";
208
+ export type { OfflineWorkerOptions } from "./offlineWorker";
209
+
210
+ // App updates: a newer build is deployed, load it.
211
+ export { useAppUpdate } from "./useAppUpdate";
212
+ export type { UseAppUpdateReturn } from "./useAppUpdate";
213
+ export { AppUpdateBar } from "./AppUpdateBar";
214
+ export type { AppUpdateBarProps } from "./AppUpdateBar";
215
+ export { createAppUpdate, AppUpdater } from "./appUpdate";
216
+ export type { AppBuild, AppUpdateState, AppUpdateEnv } from "./appUpdate";
189
217
 
190
218
  // One-liner API
191
219
  export { db, init, getSync, setSessionToken } from "./db";
@@ -952,29 +980,10 @@ export async function listFns(): Promise<
952
980
  // File upload
953
981
  // ---------------------------------------------------------------------------
954
982
 
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
- }
983
+ export type { UploadedFile, FileVisibility } from "@pylonsync/sync";
961
984
 
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";
972
-
973
- export interface UploadFileOptions {
974
- filename?: string;
975
- contentType?: string;
985
+ export interface UploadFileOptions extends UploadBytesOptions {
976
986
  token?: string;
977
- visibility?: FileVisibility;
978
987
  }
979
988
 
980
989
  /**
@@ -988,6 +997,9 @@ export interface UploadFileOptions {
988
997
  * signature is the credential. The session is never sent to a presigned
989
998
  * URL on another origin.
990
999
  *
1000
+ * All three steps need the network. For a file taken where there may be
1001
+ * none (a photo in the field), use `queueUpload`.
1002
+ *
991
1003
  * @example
992
1004
  * ```ts
993
1005
  * const photo = await uploadFile(fileFromInput, { visibility: "public" });
@@ -998,87 +1010,8 @@ export async function uploadFile(
998
1010
  input: File | Blob | ArrayBuffer | Uint8Array,
999
1011
  options: UploadFileOptions = {}
1000
1012
  ): 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
- });
1013
+ const { token, ...rest } = options;
1014
+ return uploadBytes(clientTransport(token), input, rest);
1082
1015
  }
1083
1016
 
1084
1017
  /**
@@ -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,110 @@
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
+ });
88
+
89
+ test("passes the page's build to useAppUpdate, in production and in dev", async () => {
90
+ const { appUpdate } = await import("./appUpdate");
91
+ const f = fakeContainer();
92
+ await registerOfflineWorker({
93
+ optedIn: true,
94
+ production: true,
95
+ navigator: { serviceWorker: f.container },
96
+ whenLoaded: loaded,
97
+ build: { version: "abc", builtAt: "2026-10-22T10:00:00.000Z" },
98
+ });
99
+ expect(appUpdate.getSnapshot().version).toBe("abc");
100
+ expect(appUpdate.getSnapshot().builtAt).toBe("2026-10-22T10:00:00.000Z");
101
+
102
+ // Under pylon dev the updater stays empty.
103
+ await registerOfflineWorker({
104
+ optedIn: true,
105
+ production: false,
106
+ navigator: { serviceWorker: f.container },
107
+ build: { version: "abc", builtAt: null },
108
+ });
109
+ expect(appUpdate.getSnapshot().version).toBeNull();
110
+ });
@@ -0,0 +1,103 @@
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
+ import { configureAppUpdate, type AppBuild } from "./appUpdate";
11
+
12
+ const WORKER_URL = "/pylon-sw.js";
13
+
14
+ export interface OfflineWorkerOptions {
15
+ /** The app set `buildManifest({ offline })`. */
16
+ optedIn: boolean;
17
+ /** Not `pylon dev`. */
18
+ production: boolean;
19
+ /** Whether a user is signed in on this page. The worker refetches the
20
+ * pages it keeps when this changes, because each one carries the
21
+ * signed-in bit. */
22
+ signedIn?: boolean;
23
+ /** Defaults to the global `navigator`. */
24
+ navigator?: { serviceWorker?: ServiceWorkerContainerLike };
25
+ /** Resolves when the page has loaded, so the worker's downloads do not
26
+ * compete with the page's own. Defaults to the window `load` event. */
27
+ whenLoaded?: () => Promise<void>;
28
+ /** The build this page runs, from `__PYLON_DATA__`. `useAppUpdate`
29
+ * compares it with the latest build. */
30
+ build?: AppBuild;
31
+ }
32
+
33
+ /** The parts of `ServiceWorkerContainer` this module uses. */
34
+ export interface ServiceWorkerContainerLike {
35
+ register(url: string, options?: { scope?: string }): Promise<unknown>;
36
+ getRegistrations?(): Promise<ReadonlyArray<RegistrationLike>>;
37
+ ready?: Promise<{ active?: { postMessage(message: unknown): void } | null }>;
38
+ }
39
+
40
+ interface RegistrationLike {
41
+ active?: { scriptURL: string } | null;
42
+ waiting?: { scriptURL: string } | null;
43
+ installing?: { scriptURL: string } | null;
44
+ unregister(): Promise<boolean>;
45
+ }
46
+
47
+ function defaultWhenLoaded(): Promise<void> {
48
+ if (typeof window === "undefined" || typeof document === "undefined") {
49
+ return Promise.resolve();
50
+ }
51
+ if (document.readyState === "complete") return Promise.resolve();
52
+ return new Promise((resolve) =>
53
+ window.addEventListener("load", () => resolve(), { once: true }),
54
+ );
55
+ }
56
+
57
+ function isPylonWorker(reg: RegistrationLike): boolean {
58
+ const worker = reg.active ?? reg.waiting ?? reg.installing;
59
+ if (!worker) return false;
60
+ try {
61
+ return new URL(worker.scriptURL).pathname === WORKER_URL;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Register the worker when the app opted in and this is a production page.
69
+ * Otherwise remove a Pylon worker an earlier build left on this origin (an
70
+ * app that turned offline off, or `pylon dev` after `pylon start`). Workers
71
+ * the app registered itself are left alone. Never throws: an app works
72
+ * online without its worker.
73
+ */
74
+ export async function registerOfflineWorker(
75
+ opts: OfflineWorkerOptions,
76
+ ): Promise<unknown | null> {
77
+ // Before the service worker checks: a browser without service workers
78
+ // still learns about new builds from `/_pylon/version`.
79
+ if (opts.build) configureAppUpdate(opts.build, opts.optedIn && opts.production);
80
+ const nav =
81
+ opts.navigator ??
82
+ (typeof navigator !== "undefined" ? (navigator as any) : undefined);
83
+ const container = nav?.serviceWorker as ServiceWorkerContainerLike | undefined;
84
+ if (!container) return null;
85
+ try {
86
+ if (!opts.optedIn || !opts.production) {
87
+ const regs = (await container.getRegistrations?.()) ?? [];
88
+ await Promise.all(regs.filter(isPylonWorker).map((r) => r.unregister()));
89
+ return null;
90
+ }
91
+ await (opts.whenLoaded ?? defaultWhenLoaded)();
92
+ const registration = await container.register(WORKER_URL, { scope: "/" });
93
+ if (typeof opts.signedIn === "boolean" && container.ready) {
94
+ const ready = await container.ready;
95
+ ready.active?.postMessage({ type: "pylon:session", signedIn: opts.signedIn });
96
+ }
97
+ return registration;
98
+ } catch (err) {
99
+ // eslint-disable-next-line no-console
100
+ console.warn("[pylon offline] service worker registration failed:", err);
101
+ return null;
102
+ }
103
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "about": "Filters answered from the local replica must match the server's answer. packages/react/src/queryFilter.test.ts runs these on the client; crates/runtime/tests/query_filter_parity.rs runs them on the server. Rows are stored as the server returns them (datetimes in canonical UTC form).",
3
+ "rows": [
4
+ { "key": "k1", "title": "apple", "occurredAt": "2026-09-30T23:59:59.999Z", "rank": 1 },
5
+ { "key": "k2", "title": "Banana", "occurredAt": "2026-10-01T00:00:00.000Z", "rank": 2 },
6
+ { "key": "k3", "title": "cherry", "occurredAt": "2026-10-01T12:30:00.000Z", "rank": 3 },
7
+ { "key": "k4", "title": "banana", "occurredAt": "2026-10-02T00:00:00.000Z", "rank": 4 },
8
+ { "key": "k5", "title": "", "occurredAt": null, "rank": 5 }
9
+ ],
10
+ "cases": [
11
+ { "where": { "occurredAt": { "$gte": "2026-10-01T00:00:00Z" } }, "keys": ["k2", "k3", "k4"] },
12
+ { "where": { "occurredAt": { "$lt": "2026-10-02T00:00:00.000Z" } }, "keys": ["k1", "k2", "k3"] },
13
+ { "where": { "occurredAt": { "$gte": "2026-09-30T20:00:00-04:00" } }, "keys": ["k2", "k3", "k4"] },
14
+ { "where": { "occurredAt": { "$gte": "2026-10-01T00:00:00.000Z", "$lt": "2026-10-01T23:00:00Z" } }, "keys": ["k2", "k3"] },
15
+ { "where": { "occurredAt": { "$lte": "2026-09-30T23:59:59.999Z" } }, "keys": ["k1"] },
16
+ { "where": { "occurredAt": { "$gt": "2026-10-01T00:00:00.000Z" } }, "keys": ["k3", "k4"] },
17
+ { "where": { "occurredAt": { "$gte": "2026-10-01" } }, "keys": ["k2", "k3", "k4"] },
18
+ { "where": { "title": { "$gte": "b" } }, "keys": ["k3", "k4"] },
19
+ { "where": { "title": { "$lt": "a" } }, "keys": ["k2", "k5"] },
20
+ { "where": { "rank": { "$gt": 2 } }, "keys": ["k3", "k4", "k5"] },
21
+ { "where": { "rank": { "$gte": 2, "$lte": 3 } }, "keys": ["k2", "k3"] }
22
+ ]
23
+ }