@pylonsync/create-pylon 0.9.1 → 0.10.0

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 (48) hide show
  1. package/package.json +1 -1
  2. package/templates/_root/AGENTS.md +11 -0
  3. package/templates/agency/AGENTS.md +11 -0
  4. package/templates/ai-chat/AGENTS.md +11 -0
  5. package/templates/ai-studio/AGENTS.md +11 -0
  6. package/templates/backend/mobile/apps/api/README.md +20 -1
  7. package/templates/backend/mobile/apps/api/app/(site)/layout.tsx +103 -0
  8. package/templates/backend/mobile/apps/api/app/(site)/not-found.tsx +23 -0
  9. package/templates/backend/mobile/apps/api/app/(site)/page.tsx +221 -0
  10. package/templates/backend/mobile/apps/api/app/(site)/privacy/page.tsx +131 -0
  11. package/templates/backend/mobile/apps/api/app/(site)/support/page.tsx +78 -0
  12. package/templates/backend/mobile/apps/api/app/(site)/terms/page.tsx +144 -0
  13. package/templates/backend/mobile/apps/api/app/error.tsx +27 -0
  14. package/templates/backend/mobile/apps/api/app/globals.css +87 -0
  15. package/templates/backend/mobile/apps/api/app/layout.tsx +27 -0
  16. package/templates/backend/mobile/apps/api/app/robots.ts +13 -0
  17. package/templates/backend/mobile/apps/api/app/sitemap.ts +16 -0
  18. package/templates/backend/mobile/apps/api/app.ts +6 -2
  19. package/templates/backend/mobile/apps/api/components/legal.tsx +64 -0
  20. package/templates/backend/mobile/apps/api/lib/site.ts +94 -0
  21. package/templates/backend/mobile/apps/api/package.json +8 -1
  22. package/templates/backend/mobile/apps/api/tsconfig.json +6 -2
  23. package/templates/barebones/AGENTS.md +11 -0
  24. package/templates/chat/AGENTS.md +11 -0
  25. package/templates/consumer/AGENTS.md +11 -0
  26. package/templates/creator/AGENTS.md +11 -0
  27. package/templates/crm/AGENTS.md +11 -0
  28. package/templates/directory/AGENTS.md +11 -0
  29. package/templates/expo/mobile/apps/expo/.env.example +13 -4
  30. package/templates/expo/mobile/apps/expo/README.md +9 -0
  31. package/templates/expo/mobile/apps/expo/STORE.md +9 -5
  32. package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +9 -6
  33. package/templates/expo/mobile/apps/expo/app/paywall.tsx +9 -13
  34. package/templates/expo/mobile/apps/expo/src/links.ts +29 -0
  35. package/templates/helpdesk/AGENTS.md +11 -0
  36. package/templates/inventory/AGENTS.md +11 -0
  37. package/templates/invoices/AGENTS.md +11 -0
  38. package/templates/local-service/AGENTS.md +11 -0
  39. package/templates/marketplace/AGENTS.md +11 -0
  40. package/templates/projects/AGENTS.md +11 -0
  41. package/templates/restaurant/AGENTS.md +11 -0
  42. package/templates/saas/AGENTS.md +11 -0
  43. package/templates/saas/app/dashboard/layout.tsx +9 -2
  44. package/templates/saas/app/dashboard/page.tsx +37 -14
  45. package/templates/saas/app/dashboard/settings/page.tsx +11 -4
  46. package/templates/shop/AGENTS.md +11 -0
  47. package/templates/todo/AGENTS.md +11 -0
  48. package/templates/waitlist/AGENTS.md +11 -0
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -13,7 +13,16 @@ EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=
13
13
  EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=
14
14
  EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=
15
15
 
16
- # Shown in Settings and required by both stores.
17
- EXPO_PUBLIC_PRIVACY_URL=https://example.com/privacy
18
- EXPO_PUBLIC_TERMS_URL=https://example.com/terms
19
- EXPO_PUBLIC_SUPPORT_EMAIL=support@example.com
16
+ # The privacy policy, terms, and help pages are served by the backend in
17
+ # apps/api, so they default to EXPO_PUBLIC_PYLON_BASE_URL + /privacy, /terms,
18
+ # and /support. Both stores require a reachable privacy policy URL.
19
+ #
20
+ # Set these only if the marketing site lives somewhere else, e.g. your own
21
+ # domain. EXPO_PUBLIC_SITE_URL moves all three at once.
22
+ # EXPO_PUBLIC_SITE_URL=https://yourapp.com
23
+ # EXPO_PUBLIC_PRIVACY_URL=
24
+ # EXPO_PUBLIC_TERMS_URL=
25
+ # EXPO_PUBLIC_SUPPORT_URL=
26
+
27
+ # Optional. Adds a "Contact support" row in Settings.
28
+ EXPO_PUBLIC_SUPPORT_EMAIL=
@@ -15,9 +15,16 @@ src/session.tsx boot + state machine
15
15
  src/purchases.ts RevenueCat wrapper, safe in Expo Go
16
16
  src/entitlements.ts usePro() from the synced RcEntitlement rows
17
17
  src/analytics.ts funnel events; wire to your SDK in one place
18
+ src/links.ts privacy / terms / support URLs, served by apps/api
18
19
  STORE.md the submission checklist
19
20
  ```
20
21
 
22
+ The public website (landing page, `/privacy`, `/terms`, `/support`) is served
23
+ by the backend in `apps/api`, on the same host as the API. The links in
24
+ Settings and under the paywall point there with no configuration, which is
25
+ what makes the store requirement satisfiable from a fresh scaffold. Edit its
26
+ copy in `apps/api/lib/site.ts`.
27
+
21
28
  ## Run
22
29
 
23
30
  ```bash
@@ -49,6 +56,8 @@ also carry Notes-specific copy or logic:
49
56
  - `app/(tabs)/settings.tsx`: the delete-account confirmation.
50
57
  - `app/(tabs)/index.tsx`: the list, `FREE_LIMIT`, and the sign-in nudge.
51
58
  - `app.config.ts`: `name`, `slug`, `scheme`, and the icons in `assets/`.
59
+ - `apps/api/lib/site.ts`: the website copy, store links, and the company
60
+ details the privacy policy and terms need.
52
61
  - `apps/api/functions/deleteMyData.ts`: delete every entity that stores
53
62
  user data, or account deletion leaves rows behind.
54
63
  - `apps/api/functions/createNote.ts`: the free-tier cap.
@@ -3,7 +3,7 @@
3
3
  The order below is the shortest path that App Review and Google Play accept
4
4
  on the first try. Each step is one command or one dashboard page.
5
5
 
6
- ## 1. Backend live
6
+ ## 1. Backend and website live
7
7
 
8
8
  ```bash
9
9
  cd apps/api && pylon deploy
@@ -52,8 +52,12 @@ Test the purchase on TestFlight with a sandbox Apple ID before submitting.
52
52
 
53
53
  Both stores require, and reviewers check:
54
54
 
55
- - Privacy policy URL and terms URL (`EXPO_PUBLIC_PRIVACY_URL`, `EXPO_PUBLIC_TERMS_URL`).
56
- The app shows them in Settings and under the paywall.
55
+ - Privacy policy URL and terms URL. The backend serves them at
56
+ `<your-backend>/privacy` and `<your-backend>/terms`, and the app links to
57
+ them from Settings and under the paywall with no configuration. Fill in
58
+ `apps/api/lib/site.ts` first: the text ships as a draft and a banner stays
59
+ on the page until you do.
60
+ - A support URL for App Store Connect: `<your-backend>/support`.
57
61
  - Account deletion inside the app (Settings → Delete account). Present.
58
62
  - Sign in with Apple when any other third-party sign-in is offered. Present.
59
63
  - Subscription terms next to the purchase button (price, period, renewal). Present.
@@ -87,8 +91,8 @@ Without it `eas submit` asks for the app interactively.
87
91
  RevenueCat, and the iOS and Android public keys are in `apps/expo/.env`.
88
92
  - Sign-in: `PYLON_APPLE_NATIVE_CLIENT_IDS` (the bundle id) and
89
93
  `PYLON_GOOGLE_NATIVE_CLIENT_IDS` are set on the backend.
90
- - Legal: `EXPO_PUBLIC_PRIVACY_URL`, `EXPO_PUBLIC_TERMS_URL`, and
91
- `EXPO_PUBLIC_SUPPORT_EMAIL` point at real pages and a monitored inbox.
94
+ - Legal: `apps/api/lib/site.ts` is filled in, the draft banner is gone from
95
+ `/privacy` and `/terms`, and a lawyer has read both.
92
96
  - Deletion: `apps/api/functions/deleteMyData.ts` removes every entity that
93
97
  stores user data. Test it with a throwaway account.
94
98
  - Copy: the "Replace the demo" list in `README.md` is done.
@@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
4
4
  import Constants from "expo-constants";
5
5
  import { deleteAccount } from "@pylonsync/react-native";
6
6
  import { track } from "@/analytics";
7
+ import { PRIVACY_URL, SUPPORT_EMAIL, SUPPORT_URL, TERMS_URL } from "@/links";
7
8
  import { usePro } from "@/entitlements";
8
9
  import { resetFlags } from "@/flags";
9
10
  import { manageSubscriptionUrl, restore } from "@/purchases";
@@ -27,9 +28,8 @@ export default function Settings() {
27
28
  const { pro } = usePro();
28
29
  const [busy, setBusy] = useState<string | null>(null);
29
30
 
30
- const privacy = process.env.EXPO_PUBLIC_PRIVACY_URL;
31
- const terms = process.env.EXPO_PUBLIC_TERMS_URL;
32
- const support = process.env.EXPO_PUBLIC_SUPPORT_EMAIL;
31
+ // These resolve to the site the backend serves (apps/api), so the rows
32
+ // always render and always lead somewhere real. See src/links.ts.
33
33
  const version = Constants.expoConfig?.version ?? "dev";
34
34
 
35
35
  async function restorePurchases() {
@@ -99,9 +99,12 @@ export default function Settings() {
99
99
  <Spacer />
100
100
 
101
101
  <Caption>About</Caption>
102
- {privacy ? <Row label="Privacy policy" onPress={() => void Linking.openURL(privacy)} /> : null}
103
- {terms ? <Row label="Terms of service" onPress={() => void Linking.openURL(terms)} /> : null}
104
- {support ? <Row label="Contact support" onPress={() => void Linking.openURL(`mailto:${support}`)} /> : null}
102
+ <Row label="Help" onPress={() => void Linking.openURL(SUPPORT_URL)} />
103
+ <Row label="Privacy policy" onPress={() => void Linking.openURL(PRIVACY_URL)} />
104
+ <Row label="Terms of service" onPress={() => void Linking.openURL(TERMS_URL)} />
105
+ {SUPPORT_EMAIL ? (
106
+ <Row label="Contact support" onPress={() => void Linking.openURL(`mailto:${SUPPORT_EMAIL}`)} />
107
+ ) : null}
105
108
  <Row label="Version" value={version} />
106
109
  <Spacer />
107
110
 
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
2
2
  import { Alert, Linking, Pressable, Text, View } from "react-native";
3
3
  import { useLocalSearchParams, useRouter } from "expo-router";
4
4
  import { track } from "@/analytics";
5
+ import { PRIVACY_URL, TERMS_URL } from "@/links";
5
6
  import { usePro } from "@/entitlements";
6
7
  import { available, offers, purchase, restore, type Offer } from "@/purchases";
7
8
  import { radius, space, useTheme } from "@/theme";
@@ -45,8 +46,7 @@ export default function Paywall() {
45
46
  if (pro) router.back();
46
47
  }, [pro, router]);
47
48
 
48
- const privacy = process.env.EXPO_PUBLIC_PRIVACY_URL;
49
- const terms = process.env.EXPO_PUBLIC_TERMS_URL;
49
+
50
50
 
51
51
  async function buy() {
52
52
  const offer = list?.find((o) => o.id === selected);
@@ -160,17 +160,13 @@ export default function Paywall() {
160
160
  <Button title="Restore purchases" variant="ghost" loading={busy === "restore"} onPress={() => void restorePurchases()} />
161
161
  <Text style={{ color: t.muted, fontSize: 11, textAlign: "center", lineHeight: 16, marginTop: space.sm }}>
162
162
  Renews automatically until cancelled. Manage in your store account settings.{" "}
163
- {terms ? (
164
- <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(terms)}>
165
- Terms
166
- </Text>
167
- ) : null}
168
- {terms && privacy ? " · " : ""}
169
- {privacy ? (
170
- <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(privacy)}>
171
- Privacy
172
- </Text>
173
- ) : null}
163
+ <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(TERMS_URL)}>
164
+ Terms
165
+ </Text>
166
+ {" · "}
167
+ <Text style={{ textDecorationLine: "underline" }} onPress={() => void Linking.openURL(PRIVACY_URL)}>
168
+ Privacy
169
+ </Text>
174
170
  </Text>
175
171
  </Screen>
176
172
  );
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where the app sends people for legal text and help.
3
+ *
4
+ * The backend in `apps/api` serves a website as well as the API, so these
5
+ * pages already exist on whatever host `EXPO_PUBLIC_PYLON_BASE_URL` points
6
+ * at. That is what makes the App Store and Play Store requirements
7
+ * satisfiable straight from a scaffold: both stores refuse a submission
8
+ * without a reachable privacy policy URL.
9
+ *
10
+ * Set the EXPO_PUBLIC_* values only if you host the marketing site somewhere
11
+ * else, on your own domain for example.
12
+ */
13
+ import { PYLON_BASE_URL } from "./pylon";
14
+
15
+ const siteOrigin = (
16
+ process.env.EXPO_PUBLIC_SITE_URL || PYLON_BASE_URL
17
+ ).replace(/\/+$/, "");
18
+
19
+ export const PRIVACY_URL =
20
+ process.env.EXPO_PUBLIC_PRIVACY_URL || `${siteOrigin}/privacy`;
21
+
22
+ export const TERMS_URL =
23
+ process.env.EXPO_PUBLIC_TERMS_URL || `${siteOrigin}/terms`;
24
+
25
+ export const SUPPORT_URL =
26
+ process.env.EXPO_PUBLIC_SUPPORT_URL || `${siteOrigin}/support`;
27
+
28
+ /** Optional. Settings shows a "Contact support" row when it is set. */
29
+ export const SUPPORT_EMAIL = process.env.EXPO_PUBLIC_SUPPORT_EMAIL;
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
@@ -36,8 +36,15 @@ export default function DashboardLayout({
36
36
  if (!auth.tenant_id) {
37
37
  return <>{children}</>;
38
38
  }
39
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
40
- const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
39
+ // Every read is started before the first use(): calling them one at a time
40
+ // makes each wait for the last, since use() suspends on the first pending
41
+ // thenable. Never Promise.all them — a new pending promise each render means
42
+ // the page never returns (React error #482).
43
+ const mePromise = serverData.get<{ email?: string }>("User", auth.user_id);
44
+ const orgPromise = serverData.get<{ name?: string }>("Org", auth.tenant_id);
45
+
46
+ const me = use(mePromise);
47
+ const org = use(orgPromise);
41
48
  // Longest matching NAV href wins so /dashboard/projects highlights
42
49
  // Projects, not Overview.
43
50
  const path = (url ?? "").split("?")[0];
@@ -27,13 +27,35 @@ export default function DashboardPage({ auth, response, serverData }: PageProps)
27
27
  if (!auth.tenant_id) {
28
28
  return <ProvisionWorkspace />;
29
29
  }
30
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id!));
31
- const org = use(
32
- serverData.get<{ name?: string; onboardedAt?: string | null; setupDismissedAt?: string | null }>(
33
- "Org",
34
- auth.tenant_id,
35
- ),
36
- );
30
+ // Every read is STARTED here, before the first use(). Each serverData call
31
+ // returns a thenable the handle caches by key, and use() suspends on the
32
+ // first one still pending — so reading them one at a time makes each wait
33
+ // for the last. Six reads that way is six round trips; issued together it
34
+ // is one, and the replayed render finds each already cached.
35
+ //
36
+ // Do NOT reach for Promise.all. It builds a new, pending, uncached promise
37
+ // on every render, so use() suspends, re-renders, builds another, and the
38
+ // page never returns — React reports it as an async Client Component
39
+ // (minified error #482).
40
+ //
41
+ // The onboarding redirect below only needs `org`, so on that path the other
42
+ // reads are issued and thrown away. A redirect is the rare case; paying one
43
+ // round trip on every normal load to save four on a rare one is the wrong
44
+ // way round.
45
+ const mePromise = serverData.get<{ email?: string }>("User", auth.user_id!);
46
+ const orgPromise = serverData.get<{
47
+ name?: string;
48
+ onboardedAt?: string | null;
49
+ setupDismissedAt?: string | null;
50
+ }>("Org", auth.tenant_id);
51
+ const projectsPromise = serverData.list<Project>("Project");
52
+ const membersPromise = serverData.list<OrgMemberRow>("OrgMember");
53
+ const subsPromise = serverData.list<Subscription>("StripeSubscription");
54
+ const invitesPromise =
55
+ serverData.list<{ orgId: string; acceptedAt?: string | null }>("OrgInvite");
56
+
57
+ const me = use(mePromise);
58
+ const org = use(orgPromise);
37
59
  // A workspace created outside the wizard (or one that abandoned it) gets
38
60
  // sent through it once. Owners/admins only; a member who lands here just
39
61
  // sees the dashboard.
@@ -43,21 +65,22 @@ export default function DashboardPage({ auth, response, serverData }: PageProps)
43
65
  response.redirect("/onboarding");
44
66
  return null;
45
67
  }
46
- const projects = use(serverData.list<Project>("Project"));
47
- const members = use(serverData.list<OrgMemberRow>("OrgMember"));
68
+ const projects = use(projectsPromise);
48
69
  // The OrgMember read policy returns this user's memberships across every org,
49
70
  // so scope the count to the active workspace.
50
- const memberCount = members.filter((m) => m.orgId === auth.tenant_id).length;
71
+ const memberCount = use(membersPromise).filter(
72
+ (m) => m.orgId === auth.tenant_id,
73
+ ).length;
51
74
  // Active-plan badge from the workspace's Stripe subscription (Free until one
52
75
  // exists). Scoped to the active tenant by the plugin's read policy.
53
- const subs = use(serverData.list<Subscription>("StripeSubscription"));
54
- const active = subs.find((s) =>
76
+ const active = use(subsPromise).find((s) =>
55
77
  ["active", "trialing", "past_due"].includes(s.status),
56
78
  );
57
79
  const plan = active ? active.plan : "free";
58
80
  // Getting-started checklist, derived from real rows so it ticks itself off.
59
- const invites = use(serverData.list<{ orgId: string; acceptedAt?: string | null }>("OrgInvite"));
60
- const pendingInvites = invites.filter((i) => i.orgId === auth.tenant_id && !i.acceptedAt).length;
81
+ const pendingInvites = use(invitesPromise).filter(
82
+ (i) => i.orgId === auth.tenant_id && !i.acceptedAt,
83
+ ).length;
61
84
  const setup: SetupState | null = org?.setupDismissedAt
62
85
  ? null
63
86
  : {
@@ -21,10 +21,17 @@ export default function SettingsPage({ auth, response, serverData }: PageProps)
21
21
  response.redirect("/dashboard");
22
22
  return null;
23
23
  }
24
- const org = use(serverData.get<OrgInfo>("Org", auth.tenant_id));
25
- const me = use(serverData.get<AccountInfo>("User", auth.user_id!));
26
- const members = use(serverData.list<OrgMemberRow>("OrgMember"));
27
- const memberCount = members.filter(
24
+ // Every read is started before the first use(): calling them one at a time
25
+ // makes each wait for the last, since use() suspends on the first pending
26
+ // thenable. Never Promise.all them — a new pending promise each render means
27
+ // the page never returns (React error #482).
28
+ const orgPromise = serverData.get<OrgInfo>("Org", auth.tenant_id);
29
+ const mePromise = serverData.get<AccountInfo>("User", auth.user_id!);
30
+ const membersPromise = serverData.list<OrgMemberRow>("OrgMember");
31
+
32
+ const org = use(orgPromise);
33
+ const me = use(mePromise);
34
+ const memberCount = use(membersPromise).filter(
28
35
  (m) => m.orgId === auth.tenant_id,
29
36
  ).length;
30
37
  return (
@@ -48,6 +48,17 @@ this pass before the user has to ask.
48
48
  - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`); `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
49
49
  - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive; during SSR they return defaults (empty params / "/"); for server-side URL values read the `pathname` / `searchParams` page props (`pathname` is the PATH only — the query is already parsed into `searchParams`, so never try to read a query parameter back out of it; the older `url` prop is the same value and is deprecated).
50
50
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
51
+ - **Never wrap `serverData` calls in `Promise.all`.** Each method returns a thenable the handle CACHES by key — on the client it is already fulfilled, so `use()` returns synchronously. `Promise.all` builds a new, pending, uncached promise on every render, so `use()` suspends, re-renders, builds another, and the page never returns; React reports it as an async Client Component (minified error #482) and the error boundary shows a broken page. To read several things in parallel, START every call before the first `use()`, then `use()` each handle — the reads overlap and the replayed render finds each one cached:
52
+
53
+ ```tsx
54
+ const orgPromise = serverData.get<Org>("Org", auth.tenant_id);
55
+ const projectsPromise = serverData.list<Project>("Project");
56
+ const org = use(orgPromise);
57
+ const projects = use(projectsPromise);
58
+ ```
59
+
60
+ Reading them one at a time (`use(serverData.a()); use(serverData.b());`) is correct but serial: each read waits for the one above it.
61
+
51
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
52
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
53
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.