@pylonsync/functions 0.3.261 → 0.3.263

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.261",
3
+ "version": "0.3.263",
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 {
@@ -38,6 +38,16 @@ type Send = (msg: Record<string, unknown>) => void;
38
38
  interface BundleClientMessage {
39
39
  type: "bundle_client";
40
40
  call_id: string;
41
+ /**
42
+ * Project-relative directory holding the route tree
43
+ * (`<app_dir>/**​/page.tsx`). Defaults to `"app"` when the host
44
+ * doesn't send it (older hosts, or the default single-`app/`
45
+ * layout). The full-stack app that namespaces its frontend under a
46
+ * subdir — e.g. `web/app` via `discoverAppRoutes({appDir:"web/app"})`
47
+ * — sends the same dir here so the client bundler and the SSR
48
+ * manifest agree on where the routes live.
49
+ */
50
+ app_dir?: string;
41
51
  }
42
52
 
43
53
  interface DiscoveredRoute {
@@ -87,19 +97,22 @@ declare const Bun: {
87
97
  };
88
98
 
89
99
  /**
90
- * Synchronously walk `app/` under cwd. Returns one entry per
91
- * discovered page, each carrying its layout chain (root leaf).
92
- * Mirrors the discovery logic in @pylonsync/sdk's
93
- * `discoverAppRoutes` exactly same sort order, same group-strip,
94
- * so the in-browser map keys line up with the manifest's component
95
- * field.
100
+ * Synchronously walk the route dir (`<appDirRel>` under cwd, e.g.
101
+ * `app` or `web/app`) and return one entry per discovered page, each
102
+ * carrying its layout chain (root → leaf). `appDirRel` MUST match the
103
+ * `appDir` the manifest was built with (`discoverAppRoutes({appDir})`)
104
+ * so the component paths `path.relative(cwd, file)` — line up with
105
+ * the manifest's `component` field byte-for-byte. Mirrors the
106
+ * discovery logic in @pylonsync/sdk's `discoverAppRoutes` exactly:
107
+ * same sort order, same group-strip.
96
108
  */
97
109
  function discoverRoutes(
98
110
  fs: any,
99
111
  path: any,
100
112
  cwd: string,
113
+ appDirRel: string,
101
114
  ): DiscoveredRoute[] {
102
- const appDir = path.join(cwd, "app");
115
+ const appDir = path.join(cwd, appDirRel);
103
116
  if (!fs.existsSync(appDir) || !fs.statSync(appDir).isDirectory()) {
104
117
  return [];
105
118
  }
@@ -290,6 +303,15 @@ function makeNoopResponse() {
290
303
  // For a hydrated error boundary (#279), synthesize the reset() the server
291
304
  // rendered as a no-op: re-fetch + re-render the current URL (a transient
292
305
  // error clears to the page; a deterministic one re-shows the boundary).
306
+ // The current route's dynamic params (e.g. { projectId: "p_1" }). Lives here,
307
+ // not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()
308
+ // in a deep client child has a reactive source. A fresh object per nav → stable
309
+ // reference between navs (useSyncExternalStore needs that).
310
+ let currentParams = {};
311
+ function setNavParams(data) {
312
+ currentParams = (data && data.props && data.props.params) || {};
313
+ }
314
+
293
315
  function withClientProps(data) {
294
316
  const props = { ...(data.props || {}) };
295
317
  props.serverData = makeClientServerData(data.ssrData);
@@ -396,6 +418,7 @@ export function hydrate(component, Page, Layouts) {
396
418
  );
397
419
  return;
398
420
  }
421
+ setNavParams(data);
399
422
  const tree = buildTree(Page, Layouts, withClientProps(data));
400
423
  activeRoot = hydrateRoot(document, tree);
401
424
  installNavHandlers();
@@ -472,6 +495,7 @@ async function navigate(href, opts) {
472
495
  }
473
496
  document.title = doc.title || document.title;
474
497
  syncHeadMeta(doc);
498
+ setNavParams(data);
475
499
  const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
476
500
  activeRoot.render(tree);
477
501
  const target = url.pathname + url.search;
@@ -559,7 +583,14 @@ function installNavHandlers() {
559
583
  }
560
584
 
561
585
  // Expose for <Link> component prefetch.
562
- const pylonGlobal = { prefetch, navigate };
586
+ const pylonGlobal = {
587
+ prefetch,
588
+ navigate,
589
+ // Read by useParams(); a getter so it always reflects the latest nav.
590
+ get params() {
591
+ return currentParams;
592
+ },
593
+ };
563
594
  if (typeof window !== "undefined") {
564
595
  window.__pylon = pylonGlobal;
565
596
  }
@@ -666,11 +697,13 @@ let _inflightBuild: Promise<BuildOutput> | null = null;
666
697
  * Used from `handleBundleClient` (protocol RPC path from Rust) AND
667
698
  * from `getManifest` (in-process SSR path).
668
699
  */
669
- export async function buildClientBundle(): Promise<BuildOutput> {
700
+ export async function buildClientBundle(
701
+ appDirRel: string = "app",
702
+ ): Promise<BuildOutput> {
670
703
  if (_inflightBuild) return _inflightBuild;
671
704
  _inflightBuild = (async () => {
672
705
  try {
673
- return await _doBuild();
706
+ return await _doBuild(appDirRel);
674
707
  } finally {
675
708
  _inflightBuild = null;
676
709
  }
@@ -678,7 +711,7 @@ export async function buildClientBundle(): Promise<BuildOutput> {
678
711
  return _inflightBuild;
679
712
  }
680
713
 
681
- async function _doBuild(): Promise<BuildOutput> {
714
+ async function _doBuild(appDirRel: string): Promise<BuildOutput> {
682
715
  // node:* are available in Bun, but `globalThis.require` is
683
716
  // not defined in ESM. Use dynamic import; Bun fast-paths these.
684
717
  const fsMod: any = await import("node:fs");
@@ -686,7 +719,7 @@ async function _doBuild(): Promise<BuildOutput> {
686
719
  const fs = fsMod.default ?? fsMod;
687
720
  const path = pathMod.default ?? pathMod;
688
721
  const cwd = process.cwd();
689
- return _doBuildInner(fs, path, cwd);
722
+ return _doBuildInner(fs, path, cwd, appDirRel);
690
723
  }
691
724
 
692
725
  /**
@@ -701,8 +734,9 @@ async function buildTailwind(
701
734
  path: any,
702
735
  cwd: string,
703
736
  outdir: string,
737
+ appDirRel: string,
704
738
  ): Promise<string | null> {
705
- const globalsPath = path.join(cwd, "app", "globals.css");
739
+ const globalsPath = path.join(cwd, appDirRel, "globals.css");
706
740
  if (!fs.existsSync(globalsPath)) return null;
707
741
  // Resolve @tailwindcss/cli. The package only exports
708
742
  // `./package.json` in its `exports` map (it's a binary, not a
@@ -760,10 +794,17 @@ async function buildTailwind(
760
794
  return stylesName;
761
795
  }
762
796
 
763
- async function _doBuildInner(fs: any, path: any, cwd: string): Promise<BuildOutput> {
764
- const routes = discoverRoutes(fs, path, cwd);
797
+ async function _doBuildInner(
798
+ fs: any,
799
+ path: any,
800
+ cwd: string,
801
+ appDirRel: string,
802
+ ): Promise<BuildOutput> {
803
+ const routes = discoverRoutes(fs, path, cwd, appDirRel);
765
804
  if (routes.length === 0) {
766
- throw new Error("no SSR routes discovered under app/ — nothing to bundle");
805
+ throw new Error(
806
+ `no SSR routes discovered under ${appDirRel}/ — nothing to bundle`,
807
+ );
767
808
  }
768
809
 
769
810
  const stageDir = path.join(cwd, ".pylon");
@@ -958,7 +999,7 @@ async function _doBuildInner(fs: any, path: any, cwd: string): Promise<BuildOutp
958
999
  // `app/globals.css`. Adds the stylesheet to every route's css
959
1000
  // array so SSR head injection emits `<link rel="stylesheet">`.
960
1001
  try {
961
- const styles = await buildTailwind(fs, path, cwd, outdir);
1002
+ const styles = await buildTailwind(fs, path, cwd, outdir, appDirRel);
962
1003
  if (styles) {
963
1004
  for (const r of Object.values(manifest.routes)) {
964
1005
  r.css = [styles];
@@ -1036,7 +1077,9 @@ export async function handleBundleClient(
1036
1077
  send: Send,
1037
1078
  ): Promise<void> {
1038
1079
  try {
1039
- const { manifestPath, outdir } = await buildClientBundle();
1080
+ const appDirRel =
1081
+ msg.app_dir && msg.app_dir.length > 0 ? msg.app_dir : "app";
1082
+ const { manifestPath, outdir } = await buildClientBundle(appDirRel);
1040
1083
  send({
1041
1084
  type: "bundle_client_result",
1042
1085
  call_id: msg.call_id,
@@ -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>;