@pylonsync/create-pylon 0.9.0 → 0.9.1

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/create-pylon",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,7 +10,7 @@
10
10
  "create-pylon": "./bin/create-pylon.js"
11
11
  },
12
12
  "scripts": {
13
- "test": "node --test"
13
+ "test": "node --test \"test/*.test.mjs\""
14
14
  },
15
15
  "files": [
16
16
  "bin",
@@ -22,6 +22,7 @@ Package.resolved
22
22
 
23
23
  # Expo / RN
24
24
  .expo/
25
+ .expo-export/
25
26
  .expo-shared/
26
27
  ios/Pods/
27
28
  ios/build/
@@ -6,6 +6,7 @@ data API, realtime sync, auth, and the RevenueCat webhook.
6
6
  ```
7
7
  app.ts User + Note + RcEntitlement (from @pylonsync/revenuecat)
8
8
  functions/createNote server-enforced free-tier cap, then insert
9
+ functions/deleteMyData the user's rows, run by DELETE /api/auth/account
9
10
  functions/revenuecatWebhook, syncEntitlements, _pylonRcUpsertEntitlement
10
11
  lib/purchases.ts the RevenueCat plugin instance + FREE_NOTE_LIMIT
11
12
  ```
@@ -79,7 +79,11 @@ const manifest = buildManifest({
79
79
  // the app's ids on the server: PYLON_APPLE_NATIVE_CLIENT_IDS (the iOS
80
80
  // bundle id) and PYLON_GOOGLE_NATIVE_CLIENT_IDS (the iOS + Android OAuth
81
81
  // client ids). See .env.example.
82
- auth: auth(),
82
+ //
83
+ // `onDeleteAccount`: DELETE /api/auth/account runs this function before
84
+ // it removes the User row. Pylon deletes its own auth rows; the app's
85
+ // rows (Note, RcEntitlement) are deleted in functions/deleteMyData.ts.
86
+ auth: auth({ onDeleteAccount: "deleteMyData" }),
83
87
  });
84
88
 
85
89
  // The CLI runs `bun run app.ts` and reads this as the manifest.
@@ -9,15 +9,19 @@ import { FREE_NOTE_LIMIT, PRO_ENTITLEMENT } from "../lib/purchases";
9
9
  *
10
10
  * Returns `LIMIT_REACHED` when a free account is at the cap; the app opens
11
11
  * the paywall on that code.
12
+ *
13
+ * `auth: "guest"`: the app starts every user as a guest, and the default
14
+ * (`"user"`) rejects guest sessions with 401 before the handler runs.
12
15
  */
13
16
  export default mutation({
17
+ auth: "guest",
14
18
  args: {
15
19
  title: v.string(),
16
20
  body: v.optional(v.string()),
17
21
  },
18
22
  async handler(ctx, args: { title: string; body?: string }) {
19
23
  const userId = ctx.auth.userId;
20
- if (!userId) throw ctx.error("UNAUTHENTICATED", "sign in first");
24
+ if (!userId) throw ctx.error("UNAUTHENTICATED", "start a guest session or sign in first");
21
25
  const title = args.title.trim();
22
26
  if (!title) throw ctx.error("INVALID_ARGS", "title is required");
23
27
 
@@ -0,0 +1,38 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { ENTITLEMENT_ENTITY } from "../lib/purchases";
3
+
4
+ /**
5
+ * Deletes the caller's rows before Pylon deletes the account. Wired
6
+ * through `auth({ onDeleteAccount: "deleteMyData" })` in app.ts; the
7
+ * runtime calls it as the user with `{ userId }` and aborts the deletion
8
+ * if it throws. `internal: true` keeps it off the HTTP surface.
9
+ *
10
+ * Add every entity that stores user data here. Pylon removes the User
11
+ * row, sessions, API keys, linked accounts, and trusted devices; nothing
12
+ * else.
13
+ */
14
+ export default mutation({
15
+ internal: true,
16
+ auth: "guest",
17
+ args: { userId: v.string() },
18
+ async handler(ctx, args: { userId: string }) {
19
+ if (ctx.auth.userId !== args.userId) {
20
+ throw ctx.error("FORBIDDEN", "a user can only delete their own data");
21
+ }
22
+
23
+ const notes = await ctx.db.query("Note", { ownerId: args.userId });
24
+ for (const note of notes) {
25
+ await ctx.db.delete("Note", String(note.id));
26
+ }
27
+
28
+ // The entitlement policy denies every client delete. This is server
29
+ // code running as part of account deletion, so the unsafe surface
30
+ // (policy bypass) is the right tool.
31
+ const entitlements = await ctx.db.query(ENTITLEMENT_ENTITY, { userId: args.userId });
32
+ for (const row of entitlements) {
33
+ await ctx.db.unsafe.delete(ENTITLEMENT_ENTITY, String(row.id));
34
+ }
35
+
36
+ return { notes: notes.length, entitlements: entitlements.length };
37
+ },
38
+ });
@@ -16,7 +16,15 @@ export const FREE_NOTE_LIMIT = 10;
16
16
  // Point the dashboard webhook at https://<your-app>/api/fn/revenuecatWebhook.
17
17
  export const purchases = revenuecat({
18
18
  entitlements: [PRO_ENTITLEMENT],
19
+ // Guests can buy before they sign in (the plugin default). The server
20
+ // reads RevenueCat by the caller's own id, so a guest cannot claim an
21
+ // entitlement it did not pay for. Set `syncAuth: "user"` to require an
22
+ // account first.
23
+ syncAuth: "guest",
19
24
  });
20
25
 
26
+ /** The synced entitlement entity; `deleteMyData` purges it on account deletion. */
27
+ export const ENTITLEMENT_ENTITY = purchases.manifest.entities[0].name;
28
+
21
29
  export const { revenuecatWebhook, syncEntitlements } = purchases.handlers;
22
30
  export const { _pylonRcUpsertEntitlement } = purchases.internals;
@@ -26,8 +26,32 @@ bun run dev # Expo Go: everything except native sign-in and purchases
26
26
  eas build --profile development --platform ios && bun run dev # dev build: everything
27
27
  ```
28
28
 
29
- The backend must be running (`cd ../api && bun run dev`) or deployed
30
- (`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`).
29
+ The backend must be running (`cd ../api && bun run dev`, port 4321) or
30
+ deployed (`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`). An Android emulator
31
+ reaches the host machine at `http://10.0.2.2:4321`; `src/pylon.ts` uses
32
+ that when the variable is unset.
33
+
34
+ `bun run check` typechecks. `bun run check:bundle` exports the iOS and
35
+ Android bundles without a device; run it after changing dependencies.
36
+
37
+ Adding an Expo native module (a package with `ios/` or `android/`) needs a
38
+ new development build (`eas build --profile development`). The Metro
39
+ server can keep running.
40
+
41
+ ## Replace the demo
42
+
43
+ The Notes screens are placeholders. When you replace them, these files
44
+ also carry Notes-specific copy or logic:
45
+
46
+ - `app/(onboarding)/welcome.tsx`: the three slides.
47
+ - `app/(auth)/sign-in.tsx`: the line under the title.
48
+ - `app/paywall.tsx`: `BENEFITS` and the reason text.
49
+ - `app/(tabs)/settings.tsx`: the delete-account confirmation.
50
+ - `app/(tabs)/index.tsx`: the list, `FREE_LIMIT`, and the sign-in nudge.
51
+ - `app.config.ts`: `name`, `slug`, `scheme`, and the icons in `assets/`.
52
+ - `apps/api/functions/deleteMyData.ts`: delete every entity that stores
53
+ user data, or account deletion leaves rows behind.
54
+ - `apps/api/functions/createNote.ts`: the free-tier cap.
31
55
 
32
56
  ## Ship
33
57
 
@@ -11,12 +11,15 @@ pylon secrets set PYLON_APPLE_NATIVE_CLIENT_IDS=<bundle id> REVENUECAT_WEBHOOK_A
11
11
  ```
12
12
 
13
13
  Put the deployed URL in `apps/expo/eas.json` (`EXPO_PUBLIC_PYLON_BASE_URL`
14
- under `preview` and `production`).
14
+ under `preview` and `production`). `app.config.ts` refuses a preview or
15
+ production build until that value, `APP_BUNDLE_ID`, and `EAS_PROJECT_ID`
16
+ are set.
15
17
 
16
18
  ## 2. Identifiers
17
19
 
18
20
  - Pick a bundle id (`com.yourco.app`) and set `APP_BUNDLE_ID` in `eas.json`
19
- env for every profile, or export it before building.
21
+ env for `preview` and `production`, or export it before building. The
22
+ `development` profile appends `.dev` to the `com.example` placeholder.
20
23
  - `eas init` prints the project id; set `EAS_PROJECT_ID` the same way.
21
24
  - Apple: an App ID with the Sign in with Apple capability. EAS creates it on
22
25
  the first `eas build` when you let it manage credentials.
@@ -70,8 +73,25 @@ eas submit --platform ios --latest
70
73
  eas submit --platform android --latest
71
74
  ```
72
75
 
73
- Fill `submit.production.ios.ascAppId` in `eas.json` with the App Store
74
- Connect app id first.
76
+ For iOS, add the App Store Connect app id to `eas.json` first:
77
+
78
+ ```json
79
+ "submit": { "production": { "ios": { "ascAppId": "1234567890" } } }
80
+ ```
81
+
82
+ Without it `eas submit` asks for the app interactively.
83
+
84
+ ## Before the first store build
85
+
86
+ - Products: the `pro` entitlement and a `default` offering exist in
87
+ RevenueCat, and the iOS and Android public keys are in `apps/expo/.env`.
88
+ - Sign-in: `PYLON_APPLE_NATIVE_CLIENT_IDS` (the bundle id) and
89
+ `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.
92
+ - Deletion: `apps/api/functions/deleteMyData.ts` removes every entity that
93
+ stores user data. Test it with a throwaway account.
94
+ - Copy: the "Replace the demo" list in `README.md` is done.
75
95
 
76
96
  ## After launch
77
97
 
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import { Alert, Platform, Text, View } from "react-native";
3
3
  import { useLocalSearchParams, useRouter } from "expo-router";
4
- import { nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
4
+ import { db, nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
5
5
  import { track } from "@/analytics";
6
6
  import { useAppSession } from "@/session";
7
7
  import { space, useTheme } from "@/theme";
@@ -59,12 +59,23 @@ export default function SignIn() {
59
59
  });
60
60
  }, [apple, google]);
61
61
 
62
- function done() {
62
+ async function done() {
63
63
  track("sign_in_completed");
64
+ // Wait for the server to confirm the new session so the route guard
65
+ // sees `ready` before the navigation, not after it.
66
+ await db.sync.notifySessionChanged();
64
67
  if (params.next) router.replace(params.next as never);
65
68
  else router.replace("/(tabs)");
66
69
  }
67
70
 
71
+ // "Not now" is reachable from a cold start (no history) as well as from
72
+ // Settings (history). Fall back to the app when there is nothing to go
73
+ // back to.
74
+ function dismiss() {
75
+ if (router.canGoBack()) router.back();
76
+ else router.replace("/(tabs)");
77
+ }
78
+
68
79
  async function withApple() {
69
80
  if (!apple) return;
70
81
  setBusy("apple");
@@ -81,7 +92,7 @@ export default function SignIn() {
81
92
  .filter(Boolean)
82
93
  .join(" ");
83
94
  await nativeSignIn("apple", credential.identityToken, name || undefined);
84
- done();
95
+ await done();
85
96
  } catch (e) {
86
97
  if ((e as { code?: string })?.code !== "ERR_REQUEST_CANCELED") {
87
98
  Alert.alert("Sign in failed", messageOf(e));
@@ -101,7 +112,7 @@ export default function SignIn() {
101
112
  const idToken = result.type === "success" ? result.data.idToken : null;
102
113
  if (!idToken) return;
103
114
  await nativeSignIn("google", idToken, result.type === "success" ? result.data.user.name : undefined);
104
- done();
115
+ await done();
105
116
  } catch (e) {
106
117
  Alert.alert("Sign in failed", messageOf(e));
107
118
  } finally {
@@ -132,7 +143,7 @@ export default function SignIn() {
132
143
  <Spacer h={space.xxl} />
133
144
  <Title>Sign in</Title>
134
145
  <Body muted style={{ marginTop: space.sm }}>
135
- Keep your notes on every device. Anything you made already comes with you.
146
+ Keep everything on every device. Anything you made already comes with you.
136
147
  </Body>
137
148
  <Spacer h={space.xxl} />
138
149
  <View style={{ gap: space.md }}>
@@ -179,7 +190,7 @@ export default function SignIn() {
179
190
  onPress={() => void continueAsGuest().then(() => router.replace("/(tabs)"))}
180
191
  />
181
192
  ) : (
182
- <Button title="Not now" variant="ghost" onPress={() => router.back()} />
193
+ <Button title="Not now" variant="ghost" onPress={dismiss} />
183
194
  )}
184
195
  <Spacer />
185
196
  <Text style={{ color: t.muted, fontSize: 12, textAlign: "center", lineHeight: 18 }}>
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
2
  import { Alert, TextInput } from "react-native";
3
3
  import { useLocalSearchParams, useRouter } from "expo-router";
4
- import { sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
4
+ import { db, sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
5
5
  import { track } from "@/analytics";
6
6
  import { space } from "@/theme";
7
7
  import { Body, Button, Field, Screen, Spacer, Title } from "@/ui";
@@ -26,6 +26,8 @@ export default function Verify() {
26
26
  try {
27
27
  await verifyEmailCode(email, value);
28
28
  track("sign_in_completed", { method: "email" });
29
+ // See sign-in.tsx: let the route guard observe the session first.
30
+ await db.sync.notifySessionChanged();
29
31
  if (next) router.replace(next as never);
30
32
  else router.replace("/(tabs)");
31
33
  } catch (e) {
@@ -15,6 +15,11 @@ import { Caption, Row, Screen, Spacer, Title } from "@/ui";
15
15
  * Account, subscription, legal, and support. App Review requires an
16
16
  * in-app account deletion path when the app offers account creation, and
17
17
  * both stores require the privacy policy and terms links.
18
+ *
19
+ * Delete account: `deleteAccount()` calls `DELETE /api/auth/account`. The
20
+ * backend runs `deleteMyData` first (see `auth({ onDeleteAccount })` in
21
+ * apps/api/app.ts), so the user's notes and entitlement rows go with the
22
+ * account. Add every new entity that stores user data to that function.
18
23
  */
19
24
  export default function Settings() {
20
25
  const router = useRouter();
@@ -40,7 +45,7 @@ export default function Settings() {
40
45
  function confirmDelete() {
41
46
  Alert.alert(
42
47
  "Delete account?",
43
- "This removes your account and every note on our servers. Subscriptions are managed by the store and must be cancelled there.",
48
+ "This deletes your account and every note on our servers. A subscription is managed by the store; cancel it there.",
44
49
  [
45
50
  { text: "Cancel", style: "cancel" },
46
51
  {
@@ -4,10 +4,11 @@ import type { ConfigContext, ExpoConfig } from "expo/config";
4
4
  * App config. The values that change per environment come from env vars
5
5
  * so one file serves development builds, TestFlight, and the store:
6
6
  *
7
- * APP_BUNDLE_ID reverse-DNS id (iOS bundle id + Android package)
8
- * APP_SCHEME deep-link scheme, e.g. "__APP_NAME_KEBAB__"
9
- * EAS_PROJECT_ID from `eas init` (printed once, then stable)
10
- * APP_VARIANT "development" | "preview" | "production" (set by eas.json)
7
+ * APP_BUNDLE_ID reverse-DNS id (iOS bundle id + Android package)
8
+ * APP_SCHEME deep-link scheme, e.g. "__APP_NAME_KEBAB__"
9
+ * EAS_PROJECT_ID from `eas init` (printed once, then stable)
10
+ * EXPO_PUBLIC_PYLON_BASE_URL the backend; https:// outside development
11
+ * APP_VARIANT "development" | "preview" | "production" (set by eas.json)
11
12
  *
12
13
  * A development build gets a ".dev" suffix so it installs alongside the
13
14
  * store app on the same phone.
@@ -17,6 +18,27 @@ const baseBundleId = process.env.APP_BUNDLE_ID ?? "com.example.__APP_NAME_SNAKE_
17
18
  const bundleId = variant === "development" ? `${baseBundleId}.dev` : baseBundleId;
18
19
  const name = variant === "production" ? "__APP_NAME__" : `__APP_NAME__ (${variant})`;
19
20
 
21
+ // A preview or store build with placeholder values would install and
22
+ // then fail at first launch. Refuse to build instead.
23
+ if (variant !== "development") {
24
+ const problems: string[] = [];
25
+ if (!process.env.APP_BUNDLE_ID) {
26
+ problems.push("APP_BUNDLE_ID is unset; the com.example placeholder cannot ship");
27
+ }
28
+ if (!process.env.EAS_PROJECT_ID) {
29
+ problems.push("EAS_PROJECT_ID is unset; run `eas init` and copy the id");
30
+ }
31
+ if (!/^https:\/\//.test(process.env.EXPO_PUBLIC_PYLON_BASE_URL ?? "")) {
32
+ problems.push("EXPO_PUBLIC_PYLON_BASE_URL must be the deployed https:// backend");
33
+ }
34
+ if (problems.length > 0) {
35
+ throw new Error(
36
+ `The ${variant} build is not configured:\n - ${problems.join("\n - ")}\n` +
37
+ `Set these under build.${variant}.env in eas.json, or export them before building.`,
38
+ );
39
+ }
40
+ }
41
+
20
42
  export default ({ config }: ConfigContext): ExpoConfig => ({
21
43
  ...config,
22
44
  name,
@@ -56,7 +78,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
56
78
  [
57
79
  "expo-build-properties",
58
80
  {
59
- ios: { deploymentTarget: "15.1" },
81
+ // The floors for Expo SDK 57 / React Native 0.86. expo-build-properties
82
+ // rejects a lower iOS target; Android below 24 does not build.
83
+ ios: { deploymentTarget: "16.4" },
60
84
  // Cleartext lets a dev/preview build reach http://localhost:4321.
61
85
  android: { minSdkVersion: 24, usesCleartextTraffic: variant !== "production" },
62
86
  },
@@ -1,8 +1,8 @@
1
1
  module.exports = function (api) {
2
2
  api.cache(true);
3
3
  return {
4
+ // babel-preset-expo 57 registers the react-native-worklets plugin
5
+ // (Reanimated 4) itself; adding it here again applies it twice.
4
6
  presets: ["babel-preset-expo"],
5
- // Reanimated's plugin must be last.
6
- plugins: ["react-native-reanimated/plugin"],
7
7
  };
8
8
  };
@@ -21,22 +21,23 @@
21
21
  "distribution": "internal",
22
22
  "env": {
23
23
  "APP_VARIANT": "preview",
24
- "EXPO_PUBLIC_PYLON_BASE_URL": "https://__APP_NAME_KEBAB__.pylonapp.com"
24
+ "APP_BUNDLE_ID": "",
25
+ "EAS_PROJECT_ID": "",
26
+ "EXPO_PUBLIC_PYLON_BASE_URL": ""
25
27
  }
26
28
  },
27
29
  "production": {
28
30
  "autoIncrement": true,
29
31
  "env": {
30
32
  "APP_VARIANT": "production",
31
- "EXPO_PUBLIC_PYLON_BASE_URL": "https://__APP_NAME_KEBAB__.pylonapp.com"
33
+ "APP_BUNDLE_ID": "",
34
+ "EAS_PROJECT_ID": "",
35
+ "EXPO_PUBLIC_PYLON_BASE_URL": ""
32
36
  }
33
37
  }
34
38
  },
35
39
  "submit": {
36
40
  "production": {
37
- "ios": {
38
- "ascAppId": "REPLACE_WITH_APP_STORE_CONNECT_APP_ID"
39
- },
40
41
  "android": {
41
42
  "track": "internal"
42
43
  }
@@ -14,11 +14,13 @@
14
14
  "build:preview": "eas build --profile preview --platform all",
15
15
  "build:prod": "eas build --profile production --platform all",
16
16
  "submit:ios": "eas submit --platform ios --latest",
17
- "submit:android": "eas submit --platform android --latest"
17
+ "submit:android": "eas submit --platform android --latest",
18
+ "check:bundle": "expo export --platform ios --platform android --output-dir .expo-export"
18
19
  },
19
20
  "dependencies": {
20
21
  "@pylonsync/react": "^__PYLON_VERSION__",
21
22
  "@pylonsync/react-native": "^__PYLON_VERSION__",
23
+ "@pylonsync/revenuecat": "^__PYLON_VERSION__",
22
24
  "@pylonsync/sdk": "^__PYLON_VERSION__",
23
25
  "@pylonsync/sync": "^__PYLON_VERSION__",
24
26
  "@react-native-async-storage/async-storage": "2.2.0",
@@ -42,11 +44,13 @@
42
44
  "react-native-reanimated": "4.5.1",
43
45
  "react-native-safe-area-context": "~5.7.0",
44
46
  "react-native-screens": "~4.26.0",
45
- "react-native-web": "~0.21.0"
47
+ "react-native-web": "~0.21.0",
48
+ "react-native-worklets": "0.10.1"
46
49
  },
47
50
  "devDependencies": {
48
- "@babel/core": "^7.25.0",
49
- "@types/react": "~19.2.0",
50
- "typescript": "~5.9.0"
51
+ "@babel/core": "^7.29.0",
52
+ "@types/react": "~19.2.4",
53
+ "babel-preset-expo": "~57.0.10",
54
+ "typescript": "~6.0.3"
51
55
  }
52
56
  }
@@ -1,19 +1,14 @@
1
1
  import { db } from "@pylonsync/react-native";
2
- import { hasEntitlement } from "@pylonsync/revenuecat";
2
+ import { hasEntitlement, type RcEntitlementRow } from "@pylonsync/revenuecat/client";
3
3
 
4
4
  export const PRO = "pro";
5
5
 
6
- interface RcEntitlementRow {
7
- id: string;
8
- userId: string;
9
- entitlement: string;
10
- status: string;
11
- expiresAt?: string | null;
12
- }
13
-
14
6
  /**
15
7
  * Live Pro status from the synced RcEntitlement rows. Updates the moment
16
8
  * the server writes the row after a purchase, on every device.
9
+ *
10
+ * `@pylonsync/revenuecat/client` is the browser/React Native entry of the
11
+ * plugin. The package root is server code and must not be imported here.
17
12
  */
18
13
  export function usePro(): { pro: boolean; loading: boolean } {
19
14
  const { data, loading } = db.useQuery<RcEntitlementRow>("RcEntitlement", {});
@@ -1,9 +1,14 @@
1
1
  import { Platform } from "react-native";
2
2
  import { init } from "@pylonsync/react-native";
3
3
 
4
- /** The backend URL. Android emulators reach the host machine at 10.0.2.2. */
4
+ /**
5
+ * The backend URL. `pylon dev` in apps/api listens on 4321. An Android
6
+ * emulator reaches the host machine at 10.0.2.2. Outside development
7
+ * app.config.ts refuses to build without an https:// value.
8
+ */
9
+ const configured = process.env.EXPO_PUBLIC_PYLON_BASE_URL?.trim();
5
10
  export const PYLON_BASE_URL =
6
- process.env.EXPO_PUBLIC_PYLON_BASE_URL ??
11
+ configured ||
7
12
  (Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
8
13
 
9
14
  export const APP_NAME = "__APP_NAME_SNAKE__";
@@ -1,8 +1,15 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
- import { db, guestSession, signOut as pylonSignOut, useSession } from "@pylonsync/react-native";
2
+ import {
3
+ db,
4
+ getReactStorage,
5
+ guestSession,
6
+ signOut as pylonSignOut,
7
+ storageKey,
8
+ useSession,
9
+ } from "@pylonsync/react-native";
3
10
  import { ensurePylon } from "./pylon";
4
11
  import { getOnboardingDone, setOnboardingDone as persistOnboardingDone } from "./flags";
5
- import { configure as configurePurchases } from "./purchases";
12
+ import { configure as configurePurchases, syncEntitlements } from "./purchases";
6
13
 
7
14
  /**
8
15
  * What the router needs to know:
@@ -10,7 +17,7 @@ import { configure as configurePurchases } from "./purchases";
10
17
  * booting the engine and the on-device flags are still loading
11
18
  * onboarding first launch: show the welcome slides
12
19
  * ready a session exists (guest or account) and the tabs can render
13
- * signedOut the user signed out explicitly; offer sign-in or a fresh guest
20
+ * signedOut no session; offer sign-in or a fresh guest
14
21
  */
15
22
  export type SessionState = "booting" | "onboarding" | "ready" | "signedOut";
16
23
 
@@ -26,37 +33,73 @@ interface SessionValue {
26
33
 
27
34
  const Ctx = createContext<SessionValue | null>(null);
28
35
 
36
+ async function notBooted(): Promise<void> {
37
+ throw new Error("Pylon is still booting");
38
+ }
39
+
40
+ const BOOTING: SessionValue = {
41
+ state: "booting",
42
+ userId: null,
43
+ isGuest: false,
44
+ completeOnboarding: notBooted,
45
+ continueAsGuest: notBooted,
46
+ signOut: notBooted,
47
+ };
48
+
49
+ /**
50
+ * Boots Pylon, then mounts the session reader. The order matters: before
51
+ * `init()` has run, `db.sync` would create a default engine with the
52
+ * wrong base URL and no AsyncStorage, and the reader would stay bound to
53
+ * it. Children render during boot, so the native splash stays up (see
54
+ * app/_layout.tsx) instead of a blank frame.
55
+ */
29
56
  export function SessionProvider({ children }: { children: React.ReactNode }) {
30
- const [booted, setBooted] = useState(false);
31
- const [onboardingDone, setOnboardingDoneState] = useState(false);
32
- const [signedOut, setSignedOut] = useState(false);
33
- const session = useSession(db.sync);
34
- const userId = session.userId;
57
+ const [boot, setBoot] = useState<{ onboardingDone: boolean } | null>(null);
35
58
 
36
59
  useEffect(() => {
37
60
  let alive = true;
38
61
  void (async () => {
39
62
  await ensurePylon();
40
- const done = await getOnboardingDone();
41
- if (!alive) return;
42
- setOnboardingDoneState(done);
43
- setBooted(true);
63
+ const onboardingDone = await getOnboardingDone();
64
+ if (alive) setBoot({ onboardingDone });
44
65
  })();
45
66
  return () => {
46
67
  alive = false;
47
68
  };
48
69
  }, []);
49
70
 
71
+ if (!boot) return <Ctx.Provider value={BOOTING}>{children}</Ctx.Provider>;
72
+ return <BootedSession initialOnboardingDone={boot.onboardingDone}>{children}</BootedSession>;
73
+ }
74
+
75
+ /** A token in AsyncStorage: the last session (guest or account) to restore. */
76
+ function hasStoredToken(): boolean {
77
+ return Boolean(getReactStorage().get(storageKey("token")));
78
+ }
79
+
80
+ function BootedSession({
81
+ initialOnboardingDone,
82
+ children,
83
+ }: {
84
+ initialOnboardingDone: boolean;
85
+ children: React.ReactNode;
86
+ }) {
87
+ const [onboardingDone, setOnboardingDoneState] = useState(initialOnboardingDone);
88
+ const session = useSession(db.sync);
89
+ const userId = session.userId;
90
+
50
91
  // Identify the purchase SDK with the Pylon user id so RevenueCat's
51
- // app_user_id is our id and webhook events map to our rows.
92
+ // app_user_id is our id and webhook events map to our rows. After a
93
+ // guest signs in, RevenueCat aliases the ids; re-reading the
94
+ // entitlements moves the guest's purchase onto the account.
52
95
  useEffect(() => {
53
- if (userId) void configurePurchases(userId);
96
+ if (!userId) return;
97
+ void configurePurchases(userId).then((ok) => (ok ? syncEntitlements() : undefined));
54
98
  }, [userId]);
55
99
 
56
100
  const continueAsGuest = useCallback(async () => {
57
101
  await guestSession();
58
102
  await db.sync.notifySessionChanged();
59
- setSignedOut(false);
60
103
  }, []);
61
104
 
62
105
  const completeOnboarding = useCallback(async () => {
@@ -69,16 +112,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
69
112
 
70
113
  const signOut = useCallback(async () => {
71
114
  await pylonSignOut();
72
- setSignedOut(true);
115
+ // Wait for /api/auth/me so `state` is signedOut before the caller
116
+ // navigates, not one render later.
117
+ await db.sync.notifySessionChanged();
73
118
  }, []);
74
119
 
75
- const state: SessionState = !booted
76
- ? "booting"
77
- : !onboardingDone
78
- ? "onboarding"
79
- : userId && !signedOut
80
- ? "ready"
81
- : "signedOut";
120
+ // Until /api/auth/me answers, `session.userId` is null because the
121
+ // answer has not arrived, not because the user is signed out. A stored
122
+ // token means the previous session is still the one to show: the
123
+ // replica renders from AsyncStorage and the server confirms or rejects
124
+ // the token on the first pull. An expired token resolves to no user and
125
+ // the state moves to signedOut then.
126
+ const hasSession = session.resolved ? userId != null : hasStoredToken();
127
+ const state: SessionState = !onboardingDone ? "onboarding" : hasSession ? "ready" : "signedOut";
82
128
 
83
129
  const value = useMemo<SessionValue>(
84
130
  () => ({