@pylonsync/sync 0.3.144 → 0.3.146
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/package.json +1 -1
- package/src/index.ts +98 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1328,6 +1328,104 @@ export class SyncEngine {
|
|
|
1328
1328
|
return this.storage.get(this.tokenStorageKey());
|
|
1329
1329
|
}
|
|
1330
1330
|
|
|
1331
|
+
/**
|
|
1332
|
+
* Call a Pylon Action / Mutation by name.
|
|
1333
|
+
*
|
|
1334
|
+
* Wraps `POST /api/fn/<name>` with the engine's bearer/cookie auth
|
|
1335
|
+
* AND observes the `X-Pylon-Change-Seq` response header. If the
|
|
1336
|
+
* server reports the action generated change events that the local
|
|
1337
|
+
* replica hasn't seen yet, the engine immediately fires a one-shot
|
|
1338
|
+
* pull — closing the latency window between the HTTP response
|
|
1339
|
+
* landing here and the WS broadcast of the same events arriving.
|
|
1340
|
+
*
|
|
1341
|
+
* App code that uses this method no longer needs the
|
|
1342
|
+
* "after-mutation refetch()" workaround pattern (see pylon-cloud's
|
|
1343
|
+
* domains/page.tsx, pre-2026-05-17, which called refetch() four
|
|
1344
|
+
* times for exactly this reason).
|
|
1345
|
+
*
|
|
1346
|
+
* Throws (`Error & {status, code?}`) on non-2xx with the server's
|
|
1347
|
+
* error envelope. Returns the parsed JSON response.
|
|
1348
|
+
*/
|
|
1349
|
+
async fn<T = unknown>(name: string, args: unknown = {}): Promise<T> {
|
|
1350
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1351
|
+
// Guard against accidentally embedding URL slashes/query in the
|
|
1352
|
+
// function name. The framework validates server-side too, but
|
|
1353
|
+
// failing here is a clearer DX error message.
|
|
1354
|
+
throw new Error(`Invalid function name: ${JSON.stringify(name)}`);
|
|
1355
|
+
}
|
|
1356
|
+
return this.requestWithChangeSync<T>(
|
|
1357
|
+
"POST",
|
|
1358
|
+
`/api/fn/${name}`,
|
|
1359
|
+
args,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Shared by `fn()` and any future entity-mutation wrappers. POSTs
|
|
1364
|
+
* with the engine's auth, parses JSON, observes
|
|
1365
|
+
* `X-Pylon-Change-Seq`, and triggers a one-shot pull when the
|
|
1366
|
+
* server says it produced events past our local cursor. The pull
|
|
1367
|
+
* short-circuits cheaply (`{changes:[]}`) if WS broadcast already
|
|
1368
|
+
* caught us up — so the worst case is one extra in-flight pull
|
|
1369
|
+
* per mutation, never a stale render. */
|
|
1370
|
+
private async requestWithChangeSync<T>(
|
|
1371
|
+
method: string,
|
|
1372
|
+
path: string,
|
|
1373
|
+
body?: unknown,
|
|
1374
|
+
): Promise<T> {
|
|
1375
|
+
const headers: Record<string, string> = {};
|
|
1376
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
1377
|
+
const token =
|
|
1378
|
+
this.config.token ??
|
|
1379
|
+
this.storage.get(this.tokenStorageKey()) ??
|
|
1380
|
+
undefined;
|
|
1381
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
1382
|
+
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
|
1383
|
+
method,
|
|
1384
|
+
headers,
|
|
1385
|
+
credentials: "include",
|
|
1386
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
1387
|
+
});
|
|
1388
|
+
// Read the change-seq header BEFORE consuming the body — some
|
|
1389
|
+
// fetch polyfills consume headers lazily and discard them after
|
|
1390
|
+
// the body stream is drained.
|
|
1391
|
+
const seqHeader = res.headers.get("x-pylon-change-seq");
|
|
1392
|
+
const text = await res.text();
|
|
1393
|
+
let parsed: unknown = null;
|
|
1394
|
+
if (text) {
|
|
1395
|
+
try {
|
|
1396
|
+
parsed = JSON.parse(text);
|
|
1397
|
+
} catch {
|
|
1398
|
+
// Non-JSON body (HTML proxy error, 204, etc.) — fall through;
|
|
1399
|
+
// the !res.ok branch synthesises an Error from the status.
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (!res.ok) {
|
|
1403
|
+
const err = new Error(
|
|
1404
|
+
(parsed as { error?: { message?: string } } | null)?.error?.message ??
|
|
1405
|
+
`${method} ${path} failed: ${res.status}`,
|
|
1406
|
+
) as Error & { status?: number; code?: string };
|
|
1407
|
+
err.status = res.status;
|
|
1408
|
+
const code = (parsed as { error?: { code?: string } } | null)?.error
|
|
1409
|
+
?.code;
|
|
1410
|
+
if (code) err.code = code;
|
|
1411
|
+
throw err;
|
|
1412
|
+
}
|
|
1413
|
+
// Opportunistic pull when the server reports a seq we haven't
|
|
1414
|
+
// applied locally yet. Fire-and-forget — the caller doesn't
|
|
1415
|
+
// block on this; useQuery hooks pick up the new data via the
|
|
1416
|
+
// store notify whenever the pull lands. Skipped when the seq
|
|
1417
|
+
// is already covered (the common case: a write that doesn't
|
|
1418
|
+
// affect the caller's visible set, or one whose WS event
|
|
1419
|
+
// already raced the response).
|
|
1420
|
+
if (seqHeader) {
|
|
1421
|
+
const seq = Number(seqHeader);
|
|
1422
|
+
if (Number.isFinite(seq) && seq > this.cursor.last_seq) {
|
|
1423
|
+
void this.pull();
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return parsed as T;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1331
1429
|
/** Pull changes from the server. */
|
|
1332
1430
|
async pull(): Promise<void> {
|
|
1333
1431
|
// Identity change detection. If the token flipped since the last pull
|