@pylonsync/functions 0.3.259 → 0.3.262

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.259",
3
+ "version": "0.3.262",
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",
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Regression tests for the ctx.db builder surfaces (runtime.ts).
3
+ *
4
+ * Guards the contract the types now promise (types.ts made `unsafe`
5
+ * REQUIRED on the top-level db and absent on the unsafe surface):
6
+ *
7
+ * 1. `db.unsafe` always exists, with the full op surface.
8
+ * 2. `db.unsafe.unsafe` does NOT exist (no chaining — it would be a
9
+ * runtime undefined that the old optional type silently allowed).
10
+ * 3. Plain ops emit `unsafe_op: false`; `unsafe.*` ops emit
11
+ * `unsafe_op: true` — the flag the Rust policy gate keys on.
12
+ *
13
+ * runtime.ts runs main() on import (it IS the bun runner entrypoint),
14
+ * so the builders are exercised in a child process: we feed it a
15
+ * script over a kept-open stdin pipe and read the NDJSON protocol
16
+ * frames it writes to stdout.
17
+ */
18
+ import { expect, test } from "bun:test";
19
+ import { mkdtempSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
24
+
25
+ const SCRIPT = `
26
+ import { buildDbReader, buildDbWriter } from ${JSON.stringify(RUNTIME)};
27
+
28
+ const reader = buildDbReader("c_r");
29
+ const writer = buildDbWriter("c_w");
30
+
31
+ // Structural facts ride stderr (fenceStdout reserves stdout for frames).
32
+ console.error("STRUCT " + JSON.stringify({
33
+ readerUnsafe: typeof reader.unsafe.get === "function",
34
+ writerUnsafeWrites: typeof (writer.unsafe as any).update === "function",
35
+ noReaderChain: (reader.unsafe as any).unsafe === undefined,
36
+ noWriterChain: (writer.unsafe as any).unsafe === undefined,
37
+ }));
38
+
39
+ // Fire one op per surface — no host replies, so don't await; the
40
+ // emitted frames on stdout are the assertion target.
41
+ reader.get("Todo", "r1").catch(() => {});
42
+ reader.unsafe.get("Todo", "r2").catch(() => {});
43
+ writer.update("Todo", "w1", { a: 1 }).catch(() => {});
44
+ writer.unsafe.update("Todo", "w2", { a: 2 }).catch(() => {});
45
+
46
+ setTimeout(() => process.exit(0), 300);
47
+ `;
48
+
49
+ test("db builders: required unsafe surface, no chaining, unsafe_op flag routing", async () => {
50
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-db-"));
51
+ const scriptPath = join(dir, "probe.ts");
52
+ writeFileSync(scriptPath, SCRIPT);
53
+
54
+ const proc = Bun.spawn(["bun", scriptPath], {
55
+ stdin: "pipe", // keep main()'s readerLoop alive until the probe exits itself
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ });
59
+ const [stdout, stderr] = await Promise.all([
60
+ new Response(proc.stdout).text(),
61
+ new Response(proc.stderr).text(),
62
+ proc.exited,
63
+ ]);
64
+
65
+ // 1+2: structure as reported from inside the child.
66
+ const structLine = stderr.split("\n").find((l) => l.includes("STRUCT "));
67
+ expect(structLine).toBeDefined();
68
+ const struct = JSON.parse(structLine!.slice(structLine!.indexOf("STRUCT ") + 7));
69
+ expect(struct.readerUnsafe).toBe(true);
70
+ expect(struct.writerUnsafeWrites).toBe(true);
71
+ expect(struct.noReaderChain).toBe(true);
72
+ expect(struct.noWriterChain).toBe(true);
73
+
74
+ // 3: every emitted db frame carries the right unsafe_op flag.
75
+ const frames = stdout
76
+ .split("\n")
77
+ .filter((l) => l.trim().startsWith("{"))
78
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
79
+ .filter((f) => f.type === "db");
80
+ const byId = (id: string) => frames.find((f) => f.id === id);
81
+
82
+ expect(byId("r1")).toMatchObject({ op: "get", unsafe_op: false });
83
+ expect(byId("r2")).toMatchObject({ op: "get", unsafe_op: true });
84
+ expect(byId("w1")).toMatchObject({ op: "update", unsafe_op: false });
85
+ expect(byId("w2")).toMatchObject({ op: "update", unsafe_op: true });
86
+ });
package/src/runtime.ts CHANGED
@@ -357,7 +357,11 @@ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
357
357
  // `serverData` read handle that reuses this module's `send` + `pendingRpcs`
358
358
  // + reader loop. The render call_id ("r_<n>") correlates DB replies back
359
359
  // through the shared pendingRpcs map.
360
- export function buildDbReader(callId: string, unsafeOp = false): DbReader {
360
+ export function buildDbReader(callId: string): DbReader {
361
+ return { ...buildReaderOps(callId, false), unsafe: buildReaderOps(callId, true) };
362
+ }
363
+
364
+ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsafe"> {
361
365
  // All DB ops use rpcDb so Promise.all over ctx.db reads can run in
362
366
  // parallel without colliding on the outer call_id key.
363
367
  //
@@ -366,7 +370,7 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
366
370
  // caller-aware policy gate (in Phase 2 — see
367
371
  // pylon-functions/protocol.rs). Plain ctx.db.* leaves the flag
368
372
  // off (the safe default); ctx.db.unsafe.* sets it.
369
- const reader: DbReader = {
373
+ return {
370
374
  async get(entity, id) {
371
375
  return (await rpcDb(callId, {
372
376
  type: "db",
@@ -435,23 +439,26 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
435
439
  })) as any;
436
440
  },
437
441
  };
438
- if (!unsafeOp) {
439
- (reader as DbReader & { unsafe: DbReader }).unsafe = buildDbReader(
440
- callId,
441
- true,
442
- );
443
- }
444
- return reader;
445
442
  }
446
443
 
447
- export function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
448
- // Drop the reader's `unsafe` shortcut before spreading — the
449
- // writer needs its own (which we attach below). Without this
450
- // strip, `writer.unsafe` would be a DbReader and the type
451
- // narrows incorrectly.
452
- const { unsafe: _ignored, ...readerOps } = buildDbReader(callId, unsafeOp);
453
- const writer: DbWriter = {
454
- ...readerOps,
444
+ export function buildDbWriter(callId: string): DbWriter {
445
+ // Top-level `ctx.db` is the safe path. `ctx.db.unsafe` is the
446
+ // escape hatch same surface, every emitted op carries
447
+ // `unsafe_op: true` so the future caller-aware policy gate
448
+ // (PYLON_STRICT_FN_POLICIES) skips enforcement. Use sparingly,
449
+ // with a justifying comment, ideally in code that runs only
450
+ // from server-internal callers (webhooks, cron sweeps, admin
451
+ // tools).
452
+ //
453
+ // The unsafe surface carries no `.unsafe` of its own — chaining
454
+ // is a compile error AND a self-reference would loop on JSON
455
+ // serialization.
456
+ return { ...buildWriterOps(callId, false), unsafe: buildWriterOps(callId, true) };
457
+ }
458
+
459
+ function buildWriterOps(callId: string, unsafeOp: boolean): Omit<DbWriter, "unsafe"> {
460
+ return {
461
+ ...buildReaderOps(callId, unsafeOp),
455
462
  async insert(entity, data) {
456
463
  const r = (await rpcDb(callId, {
457
464
  type: "db",
@@ -518,23 +525,6 @@ export function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
518
525
  });
519
526
  },
520
527
  };
521
- // Top-level `ctx.db` is the safe path. `ctx.db.unsafe` is the
522
- // escape hatch — same surface, every emitted op carries
523
- // `unsafe_op: true` so the future caller-aware policy gate
524
- // (PYLON_STRICT_FN_POLICIES) skips enforcement. Use sparingly,
525
- // with a justifying comment, ideally in code that runs only
526
- // from server-internal callers (webhooks, cron sweeps, admin
527
- // tools).
528
- //
529
- // Self-reference would create an infinite loop on JSON
530
- // serialization; only assign on the writer's `.unsafe` once.
531
- if (!unsafeOp) {
532
- (writer as DbWriter & { unsafe: DbWriter }).unsafe = buildDbWriter(
533
- callId,
534
- true,
535
- );
536
- }
537
- return writer;
538
528
  }
539
529
 
540
530
  function buildStream(callId: string): Stream {
@@ -942,12 +932,12 @@ async function main() {
942
932
  (f) => f.endsWith(".ts") || f.endsWith(".js")
943
933
  );
944
934
  } catch {
945
- send({
946
- type: "ready",
947
- functions: [],
948
- error: `Cannot read functions directory: ${fnDir}`,
949
- });
950
- return;
935
+ // No `functions/` directory. Legitimate for a pure-SSR app (file-based
936
+ // `app/**/page.tsx` routes + entity CRUD, no server functions) — the host
937
+ // still spawns this runner to execute SSR renders. Load zero functions and
938
+ // fall through so we send `ready` AND start the reader loop; returning here
939
+ // would leave the runner unable to serve renders (silent 404s).
940
+ files = [];
951
941
  }
952
942
 
953
943
  for (const file of files) {
@@ -989,7 +979,23 @@ async function main() {
989
979
  }));
990
980
  send({ type: "ready", functions });
991
981
 
982
+ // Belt-and-suspenders against orphaning: if the host dies in a way that
983
+ // somehow leaves our stdin open, we'll have been reparented to init
984
+ // (ppid === 1). Notice and exit. Unref'd so it never keeps us alive on its
985
+ // own.
986
+ const orphanWatch = setInterval(() => {
987
+ if (process.ppid === 1) process.exit(0);
988
+ }, 2000);
989
+ if (typeof orphanWatch.unref === "function") orphanWatch.unref();
990
+
992
991
  await readerLoop();
992
+
993
+ // readerLoop only returns when stdin hits EOF — i.e. the host (the pylon
994
+ // process that spawned us) is gone. Force-exit. We must NOT rely on the
995
+ // event loop draining on its own: the stdout writer, keep-alive sockets
996
+ // from `fetch`, and Bun's own handles keep the process alive, so every
997
+ // killed `pylon dev` would otherwise orphan its whole bun runner pool.
998
+ process.exit(0);
993
999
  }
994
1000
 
995
1001
  main().catch((err) => {
@@ -290,6 +290,15 @@ function makeNoopResponse() {
290
290
  // For a hydrated error boundary (#279), synthesize the reset() the server
291
291
  // rendered as a no-op: re-fetch + re-render the current URL (a transient
292
292
  // error clears to the page; a deterministic one re-shows the boundary).
293
+ // The current route's dynamic params (e.g. { projectId: "p_1" }). Lives here,
294
+ // not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()
295
+ // in a deep client child has a reactive source. A fresh object per nav → stable
296
+ // reference between navs (useSyncExternalStore needs that).
297
+ let currentParams = {};
298
+ function setNavParams(data) {
299
+ currentParams = (data && data.props && data.props.params) || {};
300
+ }
301
+
293
302
  function withClientProps(data) {
294
303
  const props = { ...(data.props || {}) };
295
304
  props.serverData = makeClientServerData(data.ssrData);
@@ -396,6 +405,7 @@ export function hydrate(component, Page, Layouts) {
396
405
  );
397
406
  return;
398
407
  }
408
+ setNavParams(data);
399
409
  const tree = buildTree(Page, Layouts, withClientProps(data));
400
410
  activeRoot = hydrateRoot(document, tree);
401
411
  installNavHandlers();
@@ -472,6 +482,7 @@ async function navigate(href, opts) {
472
482
  }
473
483
  document.title = doc.title || document.title;
474
484
  syncHeadMeta(doc);
485
+ setNavParams(data);
475
486
  const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
476
487
  activeRoot.render(tree);
477
488
  const target = url.pathname + url.search;
@@ -559,7 +570,14 @@ function installNavHandlers() {
559
570
  }
560
571
 
561
572
  // Expose for <Link> component prefetch.
562
- const pylonGlobal = { prefetch, navigate };
573
+ const pylonGlobal = {
574
+ prefetch,
575
+ navigate,
576
+ // Read by useParams(); a getter so it always reflects the latest nav.
577
+ get params() {
578
+ return currentParams;
579
+ },
580
+ };
563
581
  if (typeof window !== "undefined") {
564
582
  window.__pylon = pylonGlobal;
565
583
  }
@@ -11,6 +11,8 @@ import {
11
11
  buildHydrationTail,
12
12
  errorDigest,
13
13
  resolveOrigin,
14
+ asRouteControl,
15
+ PylonRouteControl,
14
16
  } from "./ssr-runtime";
15
17
 
16
18
  describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
@@ -391,3 +393,41 @@ describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () =
391
393
  expect(errorDigest(new Error("other"))).not.toBe(d1);
392
394
  });
393
395
  });
396
+
397
+ describe("asRouteControl — route-control normalization (redirect/notFound)", () => {
398
+ test("passes the framework's own PylonRouteControl straight through", () => {
399
+ const redirect = new PylonRouteControl("redirect");
400
+ redirect.url = "/login";
401
+ redirect.redirectStatus = 302;
402
+ expect(asRouteControl(redirect)).toBe(redirect);
403
+
404
+ const nf = new PylonRouteControl("notFound");
405
+ expect(asRouteControl(nf)).toBe(nf);
406
+ });
407
+
408
+ test("recognizes @pylonsync/react's branded notFound() error by digest", () => {
409
+ // The cross-package contract: `notFound()` from @pylonsync/react throws an
410
+ // error stamped `digest === "PYLON_NOT_FOUND"`. The runtime duck-types on
411
+ // that brand (no import of the React class) and turns it into a notFound
412
+ // control → a real 404 + nearest not-found.tsx. If this regresses, a
413
+ // server page calling notFound() would 500 instead of 404.
414
+ const reactNotFound = Object.assign(new Error("PYLON_NOT_FOUND"), {
415
+ digest: "PYLON_NOT_FOUND",
416
+ });
417
+ const ctrl = asRouteControl(reactNotFound);
418
+ expect(ctrl).not.toBeNull();
419
+ expect(ctrl?.kind).toBe("notFound");
420
+ });
421
+
422
+ test("does NOT swallow ordinary errors as a 404 (fails open is forbidden)", () => {
423
+ // The critical safety property: a real render error must fall through to
424
+ // the error.tsx / 500 path, never be silently masked as a not-found.
425
+ expect(asRouteControl(new Error("boom"))).toBeNull();
426
+ expect(asRouteControl(new TypeError("nope"))).toBeNull();
427
+ expect(asRouteControl({ digest: "SOME_OTHER_DIGEST" })).toBeNull();
428
+ expect(asRouteControl({ digest: 42 })).toBeNull();
429
+ expect(asRouteControl(null)).toBeNull();
430
+ expect(asRouteControl(undefined)).toBeNull();
431
+ expect(asRouteControl("PYLON_NOT_FOUND")).toBeNull(); // a bare string, not an error
432
+ });
433
+ });
@@ -99,6 +99,34 @@ export class PylonRouteControl extends Error {
99
99
  }
100
100
  }
101
101
 
102
+ /**
103
+ * Digest brand that `@pylonsync/react`'s `notFound()` stamps on the error it
104
+ * throws. We recognize it here by string instead of importing the class so the
105
+ * runtime stays decoupled from the React package (which doesn't depend on
106
+ * functions). Keep in sync with `NotFoundError.digest` in
107
+ * `packages/react/src/useRouter.ts`.
108
+ */
109
+ const REACT_NOT_FOUND_DIGEST = "PYLON_NOT_FOUND";
110
+
111
+ /**
112
+ * Normalize a thrown value into a route-control signal: either the framework's
113
+ * own `PylonRouteControl` (`response.redirect()` / `response.notFound()`) or a
114
+ * branded `NotFoundError` thrown by `@pylonsync/react`'s `notFound()` from a
115
+ * page/layout render. Returns `null` for an ordinary error so the caller falls
116
+ * through to its real error-handling path.
117
+ */
118
+ export function asRouteControl(err: unknown): PylonRouteControl | null {
119
+ if (err instanceof PylonRouteControl) return err;
120
+ if (
121
+ err != null &&
122
+ typeof err === "object" &&
123
+ (err as { digest?: unknown }).digest === REACT_NOT_FOUND_DIGEST
124
+ ) {
125
+ return new PylonRouteControl("notFound");
126
+ }
127
+ return null;
128
+ }
129
+
102
130
  export interface SsrCookieOptions {
103
131
  path?: string;
104
132
  domain?: string;
@@ -1481,7 +1509,8 @@ export async function handleRenderRoute(
1481
1509
  // (partial HTML); the dev overlay (#275) only covers failures BEFORE
1482
1510
  // response_start (host-side err channel). Buffered renders surface
1483
1511
  // their error through the catch/boundary path below.
1484
- if (err instanceof PylonRouteControl) {
1512
+ const ctrl = asRouteControl(err);
1513
+ if (ctrl) {
1485
1514
  // A redirect()/notFound() thrown from BELOW a <Suspense> boundary:
1486
1515
  // the shell already committed the head, so React swallowed it and
1487
1516
  // it can't change the response. This is a known limitation on BOTH
@@ -1489,9 +1518,10 @@ export async function handleRenderRoute(
1489
1518
  // synchronous shell). Surface it loudly instead of silently losing.
1490
1519
  // eslint-disable-next-line no-console
1491
1520
  console.error(
1492
- `[ssr] response.${err.kind}() called below a <Suspense> boundary was ignored — ` +
1493
- `the HTTP head was already sent. Call response.redirect()/notFound() in the ` +
1494
- `synchronous shell render, before any await/<Suspense>.`,
1521
+ `[ssr] ${ctrl.kind}() called below a <Suspense> boundary was ignored — ` +
1522
+ `the HTTP head was already sent. Call response.redirect()/notFound() (or ` +
1523
+ `notFound() from @pylonsync/react) in the synchronous shell render, before ` +
1524
+ `any await/<Suspense>.`,
1495
1525
  );
1496
1526
  return;
1497
1527
  }
@@ -1736,16 +1766,20 @@ export async function handleRenderRoute(
1736
1766
 
1737
1767
  send({ type: "render_done", call_id: msg.call_id });
1738
1768
  } catch (err: any) {
1739
- // A page/layout called response.redirect() or response.notFound()
1740
- // during render → short-circuit to a 3xx + Location or a 404 instead
1741
- // of a body. Page-set cookies/headers still ride along.
1742
- if (err instanceof PylonRouteControl) {
1743
- if (err.kind === "redirect") {
1769
+ // A page/layout called response.redirect()/response.notFound(), or
1770
+ // `notFound()` from @pylonsync/react, during render → short-circuit to a
1771
+ // 3xx + Location or a 404 instead of a body. Page-set cookies/headers
1772
+ // still ride along.
1773
+ const ctrl = asRouteControl(err);
1774
+ if (ctrl) {
1775
+ if (ctrl.kind === "redirect") {
1744
1776
  send({
1745
1777
  type: "response_start",
1746
1778
  call_id: msg.call_id,
1747
- status: err.redirectStatus ?? 307,
1748
- headers: finalizeHeaders(responseState, { location: err.url ?? "/" }),
1779
+ status: ctrl.redirectStatus ?? 307,
1780
+ headers: finalizeHeaders(responseState, {
1781
+ location: ctrl.url ?? "/",
1782
+ }),
1749
1783
  });
1750
1784
  send({ type: "render_done", call_id: msg.call_id });
1751
1785
  return;
package/src/types.ts CHANGED
@@ -99,10 +99,11 @@ export interface DbReader {
99
99
  * convention. A future `pylon lint` rule will flag bare
100
100
  * `ctx.db.unsafe.*` without a comment immediately above.
101
101
  *
102
- * Optional on the type because old Pylon runtimes don't ship
103
- * it; new code that targets v0.3.161+ can rely on the field.
102
+ * Required on the type (every runtime since v0.3.161 ships it) —
103
+ * but absent on the unsafe surface itself, so `ctx.db.unsafe.unsafe`
104
+ * is a compile error rather than a runtime undefined.
104
105
  */
105
- unsafe?: DbReader;
106
+ unsafe: Omit<DbReader, "unsafe">;
106
107
 
107
108
  /** Get a single row by ID. Returns null if not found. */
108
109
  get(entity: string, id: string): Promise<Record<string, unknown> | null>;
@@ -201,7 +202,7 @@ export interface DbWriter extends DbReader {
201
202
  * write surface (insert/update/delete/link/unlink/advisoryLock).
202
203
  * Overrides the inherited read-only `unsafe` from DbReader.
203
204
  */
204
- unsafe?: DbWriter;
205
+ unsafe: Omit<DbWriter, "unsafe">;
205
206
 
206
207
  /** Insert a new row. Returns the generated ID. */
207
208
  insert(entity: string, data: Record<string, unknown>): Promise<string>;