@pylonsync/functions 0.3.384 → 0.3.385

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/runtime.d.ts CHANGED
@@ -16,6 +16,15 @@
16
16
  */
17
17
  import type { DbReader, DbWriter, Llm, Rooms } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
+ /**
20
+ * Query-function caller for SSR `serverData.fn(name, args)`. Sends the same
21
+ * `run_fn` frame `ctx.runQuery` uses, on the render's call_id — the host's
22
+ * render loop executes the query with the PAGE's auth context (anonymous on
23
+ * public pages) and rejects anything that isn't a query. Legacy rpc() (no
24
+ * op_id) is correct here: concurrent calls queue settle-chained on the one
25
+ * call_id, matching how ctx.runQuery behaves inside a function.
26
+ */
27
+ export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
19
28
  export declare function buildDbWriter(callId: string): DbWriter;
20
29
  /**
21
30
  * Build the LLM client that round-trips through the host runtime.
@@ -394,6 +394,16 @@ export declare function buildHydrationTail(args: {
394
394
  * carrying any stack content. FNV-1a over message+stack, 8 hex chars.
395
395
  */
396
396
  export declare function errorDigest(err: any): string;
397
+ /**
398
+ * Wrap a DbReader so each `serverData.x(...)` call returns a PROMISE CACHED
399
+ * by (method, args) — required for React 19 `use()`, which re-invokes the
400
+ * call on the post-suspense re-render and must get the same (now-resolved)
401
+ * promise instead of a fresh pending one (else it suspends forever). Each
402
+ * resolved value is also recorded into `valueCache` keyed identically, so it
403
+ * can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
404
+ * — keeping hydration free of mismatches.
405
+ */
406
+ export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onFnCall?: () => void): any;
397
407
  /**
398
408
  * #278: does this route STREAM (vs buffer the whole document)? Streaming is
399
409
  * opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.384",
3
+ "version": "0.3.385",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/runtime.ts CHANGED
@@ -509,6 +509,26 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
509
509
  };
510
510
  }
511
511
 
512
+ /**
513
+ * Query-function caller for SSR `serverData.fn(name, args)`. Sends the same
514
+ * `run_fn` frame `ctx.runQuery` uses, on the render's call_id — the host's
515
+ * render loop executes the query with the PAGE's auth context (anonymous on
516
+ * public pages) and rejects anything that isn't a query. Legacy rpc() (no
517
+ * op_id) is correct here: concurrent calls queue settle-chained on the one
518
+ * call_id, matching how ctx.runQuery behaves inside a function.
519
+ */
520
+ export function buildSsrFnCaller(
521
+ callId: string,
522
+ ): (name: string, args?: Record<string, unknown>) => Promise<unknown> {
523
+ return (name, args) =>
524
+ rpc(callId, {
525
+ type: "run_fn",
526
+ fn_name: name,
527
+ fn_type: "query",
528
+ args: args ?? {},
529
+ });
530
+ }
531
+
512
532
  function buildReaderOps(
513
533
  callId: string,
514
534
  unsafeOp: boolean,
@@ -385,6 +385,14 @@ function makeClientServerData(ssrData) {
385
385
  };
386
386
  const sd = wrap("");
387
387
  sd.unsafe = wrap("u:");
388
+ // serverData.fn(name, args) — key MUST match the server's makeServerData
389
+ // ("fn:" + name + ":" + stableStringify(args ?? {})) so hydration replays
390
+ // the query function's SSR'd result without a mismatch.
391
+ sd.fn = (name, args) => {
392
+ const key = "fn:" + name + ":" + stableStringify(args ?? {});
393
+ if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));
394
+ return pc.get(key);
395
+ };
388
396
  return sd;
389
397
  }
390
398
 
@@ -411,6 +419,11 @@ function makePendingServerData() {
411
419
  };
412
420
  const sd = wrap("");
413
421
  sd.unsafe = wrap("u:");
422
+ sd.fn = (name, args) => {
423
+ const key = "fn:" + name + ":" + stableStringify(args ?? {});
424
+ if (!pc.has(key)) pc.set(key, pending());
425
+ return pc.get(key);
426
+ };
414
427
  return sd;
415
428
  }
416
429
 
@@ -2193,7 +2193,19 @@ const SERVER_DATA_METHODS = [
2193
2193
  * can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
2194
2194
  * — keeping hydration free of mismatches.
2195
2195
  */
2196
- function makeServerData(reader: any, valueCache: Record<string, any>): any {
2196
+ export function makeServerData(
2197
+ reader: any,
2198
+ valueCache: Record<string, any>,
2199
+ // `serverData.fn(name, args)` — runs a registered QUERY function with the
2200
+ // page's own auth context (the host's render loop enforces query-only and
2201
+ // executes it exactly like ctx.runQuery). Lets a page SSR data whose safe
2202
+ // public projection lives in a gated query instead of open row policies.
2203
+ callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>,
2204
+ // Fired on every serverData.fn call so the render can veto shared caching
2205
+ // when the call carried a real identity (the fn may read ctx.auth, making
2206
+ // its output identity-specific without the page ever touching props.auth).
2207
+ onFnCall?: () => void,
2208
+ ): any {
2197
2209
  const promiseCache = new Map<string, Promise<any>>();
2198
2210
  const wrap = (r: any, prefix: string): any => {
2199
2211
  const out: any = {};
@@ -2215,6 +2227,23 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
2215
2227
  };
2216
2228
  const sd = wrap(reader, "");
2217
2229
  if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
2230
+ if (callFn) {
2231
+ sd.fn = (name: string, args?: Record<string, unknown>) => {
2232
+ // Key MUST match the client shim's `fn` key so hydration replays
2233
+ // the value from ssrData without a mismatch.
2234
+ const key = "fn:" + name + ":" + stableStringify(args ?? {});
2235
+ let p = promiseCache.get(key);
2236
+ if (!p) {
2237
+ onFnCall?.();
2238
+ p = callFn(name, args).then((value: any) => {
2239
+ valueCache[key] = value;
2240
+ return value;
2241
+ });
2242
+ promiseCache.set(key, p);
2243
+ }
2244
+ return p;
2245
+ };
2246
+ }
2218
2247
  return sd;
2219
2248
  }
2220
2249
 
@@ -2794,7 +2823,11 @@ export async function handleRenderRoute(
2794
2823
  // gate as a query function's ctx.db, and rejects any write. Promise-
2795
2824
  // cached so `use()` doesn't re-suspend forever; resolved values land in
2796
2825
  // `ssrValueCache` for hydration replay.
2797
- const { buildDbReader } = await import("./runtime");
2826
+ const { buildDbReader, buildSsrFnCaller } = await import("./runtime");
2827
+ // Reading auth at all (even for an anon request) opts the render OUT of
2828
+ // caching, because the output could differ by identity. Declared before
2829
+ // serverData so `serverData.fn` can flip it too (below).
2830
+ let authTouched = false;
2798
2831
  // `ssrRead: true` — serverData results are serialized into the
2799
2832
  // client-visible `__PYLON_DATA__` blob, so the host applies the same
2800
2833
  // per-row policy filter + `server_only`/`passwordHash` projection the
@@ -2802,6 +2835,19 @@ export async function handleRenderRoute(
2802
2835
  const serverData = makeServerData(
2803
2836
  buildDbReader(msg.call_id, true),
2804
2837
  ssrValueCache,
2838
+ buildSsrFnCaller(msg.call_id),
2839
+ // A query function runs with the page's auth and may read ctx.auth
2840
+ // internally — identity-specific output the read-tracking proxies
2841
+ // can't see. An identity-carrying render that called serverData.fn
2842
+ // must therefore veto shared caches exactly as if it read props.auth.
2843
+ // Anonymous renders stay cacheable: the anonymous result is uniform
2844
+ // across every anonymous visitor.
2845
+ () => {
2846
+ const a = msg.auth as Record<string, unknown> | undefined;
2847
+ if (a && (a.user_id || a.is_admin || a.tenant_id)) {
2848
+ authTouched = true;
2849
+ }
2850
+ },
2805
2851
  );
2806
2852
 
2807
2853
  // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
@@ -2820,9 +2866,6 @@ export async function handleRenderRoute(
2820
2866
  return proxy;
2821
2867
  };
2822
2868
 
2823
- // Reading auth at all (even for an anon request) opts the render OUT of
2824
- // caching, because the output could differ by identity.
2825
- let authTouched = false;
2826
2869
  const authProxy = track(
2827
2870
  msg.auth as Record<string, unknown> | undefined,
2828
2871
  () => {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `serverData.fn(name, args)` — SSR pages calling query functions with the
3
+ * page's auth context. Pins the pieces that must not drift:
4
+ *
5
+ * - the cache key shape ("fn:" + name + ":" + stableStringify(args ?? {})),
6
+ * which the client hydration shim replays from ssrData — a mismatch means
7
+ * hydration re-suspends forever;
8
+ * - promise caching per (name, args) so React 19 `use()` gets the SAME
9
+ * promise on the post-suspense re-render;
10
+ * - resolved values recorded into the valueCache (the ssrData blob);
11
+ * - the onFnCall notification that lets the render veto shared caching for
12
+ * identity-carrying requests;
13
+ * - the client runtime template carrying the matching `fn` shim.
14
+ */
15
+ import { describe, expect, test } from "bun:test";
16
+ import { makeServerData } from "./ssr-runtime";
17
+
18
+ const noopReader = {};
19
+
20
+ describe("makeServerData.fn", () => {
21
+ test("caches per (name, args) and records into valueCache with the wire key", async () => {
22
+ const valueCache: Record<string, any> = {};
23
+ const calls: Array<[string, any]> = [];
24
+ const sd = makeServerData(noopReader, valueCache, async (name, args) => {
25
+ calls.push([name, args]);
26
+ return { rows: [name, args?.x ?? null] };
27
+ });
28
+
29
+ const p1 = sd.fn("getPublicSchedule", { x: 1 });
30
+ const p2 = sd.fn("getPublicSchedule", { x: 1 });
31
+ expect(p1).toBe(p2); // use() re-render must see the SAME promise
32
+
33
+ const v = await p1;
34
+ expect(v).toEqual({ rows: ["getPublicSchedule", 1] });
35
+ expect(calls.length).toBe(1); // deduped
36
+
37
+ // Distinct args → distinct call, distinct cache entry.
38
+ await sd.fn("getPublicSchedule", { x: 2 });
39
+ expect(calls.length).toBe(2);
40
+
41
+ // No args normalizes to {} in the key.
42
+ await sd.fn("getSpeakers");
43
+ expect(Object.keys(valueCache).sort()).toEqual([
44
+ 'fn:getPublicSchedule:{"x":1}',
45
+ 'fn:getPublicSchedule:{"x":2}',
46
+ "fn:getSpeakers:{}",
47
+ ]);
48
+ });
49
+
50
+ test("onFnCall fires once per unique call (the cache-veto hook)", async () => {
51
+ let touches = 0;
52
+ const sd = makeServerData(
53
+ noopReader,
54
+ {},
55
+ async () => null,
56
+ () => {
57
+ touches++;
58
+ },
59
+ );
60
+ sd.fn("a");
61
+ sd.fn("a"); // cached — no second touch
62
+ sd.fn("b");
63
+ expect(touches).toBe(2);
64
+ });
65
+
66
+ test("no callFn → no fn method (older host pairing)", () => {
67
+ const sd = makeServerData(noopReader, {});
68
+ expect(sd.fn).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe("client runtime shim parity", () => {
73
+ test("CLIENT_RUNTIME_SOURCE replays fn results with the identical key", async () => {
74
+ const src = await Bun.file(
75
+ new URL("./ssr-client-bundler.ts", import.meta.url).pathname,
76
+ ).text();
77
+ // Both client stand-ins (hydration replay + optimistic-nav pending) must
78
+ // build the exact server-side key. String-level guard against drift.
79
+ const keyExpr = '"fn:" + name + ":" + stableStringify(args ?? {})';
80
+ const count = src.split(keyExpr).length - 1;
81
+ expect(count).toBeGreaterThanOrEqual(2);
82
+ });
83
+ });