@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/dist/AppUpdateBar.d.ts +21 -0
- package/dist/appUpdate.d.ts +166 -0
- package/dist/db.d.ts +4 -0
- package/dist/index.d.ts +21 -21
- package/dist/offlineUploads.d.ts +45 -0
- package/dist/offlineWorker.d.ts +54 -0
- package/dist/queryFilter.d.ts +13 -0
- package/dist/useAppUpdate.d.ts +34 -0
- package/dist/useOutbox.d.ts +45 -0
- package/dist/useSyncProgress.d.ts +21 -0
- package/package.json +4 -4
- package/src/AppUpdateBar.tsx +54 -0
- package/src/Link.tsx +21 -3
- package/src/appUpdate.test.ts +461 -0
- package/src/appUpdate.ts +602 -0
- package/src/db.ts +11 -0
- package/src/hooks.ts +2 -68
- package/src/index.ts +35 -102
- package/src/offlineUploads.test.tsx +66 -0
- package/src/offlineUploads.ts +77 -0
- package/src/offlineWorker.test.ts +110 -0
- package/src/offlineWorker.ts +103 -0
- package/src/query-filter-cases.json +23 -0
- package/src/queryFilter.test.ts +45 -0
- package/src/queryFilter.ts +138 -0
- package/src/useAppUpdate.test.tsx +98 -0
- package/src/useAppUpdate.ts +49 -0
- package/src/useOutbox.test.tsx +82 -0
- package/src/useOutbox.ts +74 -0
- package/src/useSession.ts +4 -2
- package/src/useSyncProgress.test.tsx +37 -0
- package/src/useSyncProgress.ts +46 -0
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
+
}
|