@pylonsync/sync 0.3.142 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +125 -9
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.142",
6
+ "version": "0.3.146",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1113,6 +1113,19 @@ export class SyncEngine {
1113
1113
  return;
1114
1114
  }
1115
1115
 
1116
+ // Session mutated server-side. Fires for select-org / clear-org
1117
+ // / session revoke — every tab connected as this user gets the
1118
+ // envelope (cross-machine too via the cluster bus). Trigger
1119
+ // a fresh /api/auth/me read which updates the cached session
1120
+ // AND, on tenant flip, resets the replica so stale rows from
1121
+ // the previous tenant disappear. App code calling
1122
+ // /api/auth/select-org via raw fetch no longer needs the
1123
+ // manual `notifySessionChanged()` step.
1124
+ if (msg.type === "session-changed") {
1125
+ void this.refreshResolvedSession();
1126
+ return;
1127
+ }
1128
+
1116
1129
  // Reactive query push: the server-side ReactiveRegistry re-ran
1117
1130
  // a subscribed handler and the result hash changed. Route to
1118
1131
  // the handler registered by `subscribeReactive` so the React
@@ -1315,6 +1328,104 @@ export class SyncEngine {
1315
1328
  return this.storage.get(this.tokenStorageKey());
1316
1329
  }
1317
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
+
1318
1429
  /** Pull changes from the server. */
1319
1430
  async pull(): Promise<void> {
1320
1431
  // Identity change detection. If the token flipped since the last pull
@@ -1679,16 +1790,21 @@ export class SyncEngine {
1679
1790
  }
1680
1791
 
1681
1792
  /**
1682
- * Public alias for `refreshResolvedSession`. Call after anything that
1683
- * mutates the server session (sign-in, sign-out, `/api/auth/select-org`)
1684
- * so the cached session and React subscribers pick up the change without
1685
- * waiting for the next pull.
1793
+ * Public alias for `refreshResolvedSession`. Almost never needed by
1794
+ * app code today — the server pushes a `session-changed` envelope
1795
+ * over WS whenever the session is mutated (select-org, clear-org,
1796
+ * session revoke, even from other tabs / admin tools / server
1797
+ * actions), and the engine's WS handler refreshes automatically.
1798
+ *
1799
+ * Kept as an escape hatch for the rare case where you mutated the
1800
+ * session via a path that doesn't go through the framework's auth
1801
+ * surface (e.g. directly writing to the SessionStore from a Rust
1802
+ * plugin that bypassed `notify_session_changed`).
1686
1803
  *
1687
- * Most apps shouldn't need to call this directly prefer the higher-
1688
- * level helpers below (`selectOrg`, `clearOrg`, `signOut`) which do the
1689
- * fetch + notify in one step, or the React `useSession()` hook which
1690
- * exposes the same helpers bound to the in-scope engine. This is the
1691
- * escape hatch for code that talks to /api/auth/* via its own client.
1804
+ * The `selectOrg` / `clearOrg` / `signOut` helpers below remain as
1805
+ * convenience wrappers that combine the HTTP call with an immediate
1806
+ * local refresh useful when the same tab needs the new state
1807
+ * before the WS round-trip lands.
1692
1808
  */
1693
1809
  notifySessionChanged(): Promise<void> {
1694
1810
  return this.refreshResolvedSession();