@voltro/cli 0.37.0 → 0.39.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 (183) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/THIRD-PARTY-NOTICES.md +80 -45
  3. package/dist/{addCommand-BNeoeSxe.js → addCommand-C05L9tZh.js} +2 -2
  4. package/dist/addCommand-C3MdJG-a.js +2 -0
  5. package/dist/{agentsMd-mhQMF1bx.js → agentsMd-7zI2h5l9.js} +3 -3
  6. package/dist/agentsMd-BFCXh2gl.js +2 -0
  7. package/dist/apiBuild-2GvK8CUB.js +2 -0
  8. package/dist/{apiBuild-DGsShRuF.js → apiBuild-DGalUk9v.js} +3 -3
  9. package/dist/baselineCommand-CXv680Dc.js +2 -0
  10. package/dist/{baselineCommand-C6NMt-oa.js → baselineCommand-Ck2Xm8M7.js} +18 -18
  11. package/dist/bin.js +1 -1
  12. package/dist/bootRefusal-pgvDxlrU.js +7 -0
  13. package/dist/{build-CiFA-E19.js → build-CfF6t0UM.js} +88 -81
  14. package/dist/{capabilitiesCommand-nq_pz5xd.js → capabilitiesCommand-D5gucwYa.js} +8 -8
  15. package/dist/{checkCommand-CIPYyNyc.js → checkCommand-BV56Pcc1.js} +1 -1
  16. package/dist/{checkCommand-CLPTzYqM.js → checkCommand-CBtynBKW.js} +34 -34
  17. package/dist/{cloudCmd-NQSwe_Qk.js → cloudCmd-C42gaO8s.js} +16 -16
  18. package/dist/{clusterCmd-D5wsCmA_.js → clusterCmd-DrVFCzSj.js} +8 -4
  19. package/dist/{codegen-GYYcdpCg.js → codegen-CbpGCWLG.js} +1 -1
  20. package/dist/codegen-i8QGcHsi.js +2 -0
  21. package/dist/codegenCommand-W7SDdiAQ.js +129 -0
  22. package/dist/{codemodRunner-cyqu5oxr.js → codemodRunner-D-jTyvWo.js} +679 -495
  23. package/dist/{commands-BwG9A9Vv.js → commands-BD9eBRY3.js} +48 -48
  24. package/dist/{connectionConfig-UFlIEiys.js → connectionConfig-Bk9IC7D0.js} +1 -1
  25. package/dist/{dashboardCommand-C7U146xQ.js → dashboardCommand-7qGylm0F.js} +3 -3
  26. package/dist/{dataCommand-BOwd8_aJ.js → dataCommand-C_F2DYxe.js} +14 -11
  27. package/dist/{dbCommand-C4UW8cVN.js → dbCommand-DSwGv9wS.js} +50 -49
  28. package/dist/dbCommand-DcqEyxju.js +2 -0
  29. package/dist/dev-alhkKoEX.js +3 -0
  30. package/dist/{dev-bwDgSObH.js → dev-gqpnzVhI.js} +2357 -2280
  31. package/dist/{dialectDriver-CgXnDfec.js → dialectDriver-czCHYpeH.js} +2 -1
  32. package/dist/{doctorCommand-BXyrtN2l.js → doctorCommand-Bvs-BQrM.js} +242 -238
  33. package/dist/doctorCommand-CVXfRrng.js +2 -0
  34. package/dist/{dormancyCommand-BDdRRLsa.js → dormancyCommand-B-PPHc9Q.js} +1 -1
  35. package/dist/{e2eCmd-BRabZww-.js → e2eCmd-uFHig1hV.js} +8 -2
  36. package/dist/{embeddingsCommand-CjWGcAE6.js → embeddingsCommand-OKY6XjUf.js} +6 -6
  37. package/dist/{envCommand-B_TeG297.js → envCommand-B1-Zm85H.js} +2 -2
  38. package/dist/{evolveCommand-D6N-WfA4.js → evolveCommand-C4-NlFbd.js} +6 -8
  39. package/dist/{generateCommand-DbgcUpGw.js → generateCommand-CcyvH2ve.js} +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/{infoCommand-BHKTprrx.js → infoCommand-CTo8Jnhq.js} +7 -7
  42. package/dist/inspect-B7U7Cl_Z.js +1192 -0
  43. package/dist/inspect-DUze25t0.js +2 -0
  44. package/dist/{inspectCmd-EHFZ9yYu.js → inspectCmd-niF97fAq.js} +5 -5
  45. package/dist/inspectGateHint-BjnFubmH.js +7 -0
  46. package/dist/{logFileSink-C_D2wRN1.js → logFileSink-B4uP8pvf.js} +1 -1
  47. package/dist/{logsCmd-D36xK7Zu.js → logsCmd-B6oNsfaZ.js} +10 -7
  48. package/dist/{manifestBuild-hpPLaGxV.js → manifestBuild-BK42hu0k.js} +1 -1
  49. package/dist/manifestBuild-j0n109tt.js +2 -0
  50. package/dist/{metaCommands-CIoQNlaQ.js → metaCommands-DUYR--Ts.js} +1 -1
  51. package/dist/{migrate-RwgUWXfc.js → migrate-BnPw2zC8.js} +7 -4
  52. package/dist/{packageCommand-Cug_3Ogl.js → packageCommand-9SVyvsSo.js} +3 -2
  53. package/dist/{probeCommand-BPEpwT32.js → probeCommand-C9gazU0H.js} +35 -24
  54. package/dist/{projectScaffold-CxgtJvlb.js → projectScaffold-BEhhHjPr.js} +1 -1
  55. package/dist/{projectScaffold-DDgWbNLe.js → projectScaffold-BvhLOrLq.js} +17 -15
  56. package/dist/{runtimeTrace-WYV7qYvz.js → runtimeTrace-DzZOCd94.js} +1 -1
  57. package/dist/{scheduleManifestCmd-D2x0CTTY.js → scheduleManifestCmd-kmWzrO_w.js} +6 -9
  58. package/dist/{sdkgen-TM5sSdf7.js → sdkgen-C4roLErM.js} +4 -4
  59. package/dist/{seedRunner-ZmLSqNe2.js → seedRunner-IdHEprqf.js} +1 -4
  60. package/dist/serveCommand-B6TATyCj.js +1770 -0
  61. package/dist/serveCommand-CNR0gI9V.js +2 -0
  62. package/dist/serveEntry.js +2 -2
  63. package/dist/{serverlessCommand-CfJZy6dS.js → serverlessCommand-DbJ7Plg9.js} +3 -3
  64. package/dist/{start-BgN62boB.js → start-BNuTWdRd.js} +1 -1
  65. package/dist/{start-T4VesWiM.js → start-ft_KzFTd.js} +240 -237
  66. package/dist/startEntry.js +1 -1
  67. package/dist/startup.d.ts +40 -1
  68. package/dist/startup.js +2 -2
  69. package/dist/startupRunner-CEqQq7ax.js +108 -0
  70. package/dist/{test-DIQ0jlkQ.js → test-CLYXrZ2F.js} +1 -1
  71. package/dist/{tracesCmd-DStmCJPi.js → tracesCmd-DgtgOUdi.js} +7 -4
  72. package/dist/{typecheckCommand-BlsWiCNq.js → typecheckCommand-BuCtT92o.js} +7 -7
  73. package/dist/updateCommand-6FMU2klq.js +2 -0
  74. package/dist/{updateCommand-9GHHsmDf.js → updateCommand-CHBmCB17.js} +5 -3
  75. package/dist/{webDev-Dybxew86.js → webDev-CTpSY-e_.js} +892 -856
  76. package/dist/webDev-id3I5PvG.js +2 -0
  77. package/dist/{webhookDiscovery-D7VaeMlz.js → webhookDiscovery-CphsQe59.js} +4 -6
  78. package/dist/{webhookDiscovery-CrGAfhIG.js → webhookDiscovery-il9ti-HE.js} +1 -1
  79. package/dist/{webhooksCommand-CHWSPk5z.js → webhooksCommand-BVvOtZ1B.js} +1 -1
  80. package/package.json +39 -21
  81. package/templates/AGENTS.md +2 -2
  82. package/templates/agent-docs/_index.md +2 -2
  83. package/templates/agent-docs/_manifest.json +2 -2
  84. package/templates/agent-docs/authentication.md +30 -0
  85. package/templates/agent-docs/cli.md +17 -13
  86. package/templates/agent-docs/data.md +44 -1
  87. package/templates/agent-docs/database/migrations.md +2 -2
  88. package/templates/agent-docs/database/seedsdialects.md +2 -2
  89. package/templates/agent-docs/deployment.md +21 -7
  90. package/templates/agent-docs/internationalization.md +10 -3
  91. package/templates/agent-docs/local-first-mobile.md +141 -20
  92. package/templates/agent-docs/plugins/audit.md +4 -0
  93. package/templates/agent-docs/plugins/auth.md +4 -6
  94. package/templates/agent-docs/plugins/mail.md +1 -1
  95. package/templates/agent-docs/plugins/ratelimit.md +1 -1
  96. package/templates/agent-docs/plugins.md +3 -1
  97. package/templates/agent-docs/reference.md +3 -2
  98. package/templates/agent-docs/releases.md +130 -1
  99. package/templates/agent-docs/routing.md +34 -2
  100. package/templates/agent-docs/templates/apibackends.md +1 -1
  101. package/templates/agent-docs/templates/mobile.md +1 -1
  102. package/templates/agent-docs/whats-new.md +178 -44
  103. package/templates/apps/api-ai/package.json +7 -7
  104. package/templates/apps/api-auth/package.json +8 -8
  105. package/templates/apps/api-backend/package.json +7 -7
  106. package/templates/apps/api-backend-deactivation/package.json +7 -7
  107. package/templates/apps/api-backend-mail/package.json +8 -8
  108. package/templates/apps/api-backend-mariadb/package.json +9 -9
  109. package/templates/apps/api-backend-sqlite/package.json +8 -8
  110. package/templates/apps/api-backend-storage/package.json +8 -8
  111. package/templates/apps/api-cms/package.json +10 -10
  112. package/templates/apps/api-cms/tests/content.write.test.ts +16 -2
  113. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +11 -5
  114. package/templates/apps/api-collab/package.json +8 -8
  115. package/templates/apps/api-collab/tests/documents.setBody.test.ts +8 -2
  116. package/templates/apps/api-data-advanced/package.json +8 -8
  117. package/templates/apps/api-durable/package.json +8 -8
  118. package/templates/apps/api-feature-flags/package.json +9 -9
  119. package/templates/apps/api-governance/package.json +8 -8
  120. package/templates/apps/api-kv/package.json +8 -8
  121. package/templates/apps/api-moderation/package.json +8 -8
  122. package/templates/apps/api-observability/package.json +8 -8
  123. package/templates/apps/api-ratelimit/package.json +8 -8
  124. package/templates/apps/api-rbac/authz.ts +16 -1
  125. package/templates/apps/api-rbac/package.json +8 -8
  126. package/templates/apps/api-rest/package.json +7 -7
  127. package/templates/apps/api-saas/package.json +11 -11
  128. package/templates/apps/api-saas-starter/package.json +10 -10
  129. package/templates/apps/api-search/package.json +8 -8
  130. package/templates/apps/api-status/package.json +8 -8
  131. package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +7 -5
  132. package/templates/apps/api-versioning/package.json +8 -8
  133. package/templates/apps/api-webhooks/package.json +9 -9
  134. package/templates/apps/changelog/package.json +6 -6
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/package.json +10 -10
  142. package/templates/apps/frontend-contact/package.json +7 -7
  143. package/templates/apps/frontend-dashboard/package.json +7 -7
  144. package/templates/apps/frontend-docs/package.json +7 -7
  145. package/templates/apps/frontend-i18n/package.json +6 -6
  146. package/templates/apps/frontend-landing/package.json +7 -7
  147. package/templates/apps/frontend-portal/package.json +8 -8
  148. package/templates/apps/frontend-saas/package.json +8 -8
  149. package/templates/apps/frontend-spa/package.json +7 -7
  150. package/templates/apps/frontend-ssr/package.json +7 -7
  151. package/templates/apps/frontend-ssr-api/package.json +8 -8
  152. package/templates/apps/frontend-static-blog/package.json +6 -6
  153. package/templates/apps/frontend-status/package.json +8 -8
  154. package/templates/apps/mobile-app/README.md +41 -17
  155. package/templates/apps/mobile-app/app.config.ts +1 -1
  156. package/templates/apps/mobile-app/package.json +15 -11
  157. package/templates/apps/mobile-app/src/app/_layout.tsx +44 -13
  158. package/templates/apps/mobile-app/src/app/index.tsx +1 -1
  159. package/templates/apps/mobile-app/src/app/settings.tsx +4 -1
  160. package/templates/apps/mobile-app/src/client.ts +74 -57
  161. package/templates/apps/mobile-app/src/lib/api.ts +4 -4
  162. package/templates/apps/mobile-app/src/lib/deeplinks.ts +6 -6
  163. package/templates/apps/mobile-app/src/persistence.ts +24 -3
  164. package/templates/apps/mobile-app/tsconfig.json +17 -3
  165. package/templates/patches/@effect__cluster@0.60.0.patch +51 -3
  166. package/dist/addCommand-aXSQveak.js +0 -2
  167. package/dist/agentsMd-BTchIZku.js +0 -2
  168. package/dist/apiBuild-D0f6hYbH.js +0 -2
  169. package/dist/baselineCommand-Cfw2Afwm.js +0 -2
  170. package/dist/codegen-uYrrDzQv.js +0 -2
  171. package/dist/codegenCommand-BxS3sCeJ.js +0 -30
  172. package/dist/dbCommand-CC-qNzpz.js +0 -2
  173. package/dist/dev-BV6XNYKf.js +0 -3
  174. package/dist/doctorCommand-C4L61G_L.js +0 -2
  175. package/dist/inspect-CjTYzAs_.js +0 -1190
  176. package/dist/inspect-P4pxoMaV.js +0 -2
  177. package/dist/inspectGateHint-BF6608UT.js +0 -4
  178. package/dist/manifestBuild-COkJoyAr.js +0 -2
  179. package/dist/serveCommand-CyZsXL1l.js +0 -1766
  180. package/dist/serveCommand-qumm7jdF.js +0 -2
  181. package/dist/startupRunner-DPGFchOa.js +0 -71
  182. package/dist/updateCommand-BN0tkl9-.js +0 -2
  183. package/dist/webDev-BcykISYQ2.js +0 -2
@@ -4,32 +4,36 @@
4
4
  "private": true,
5
5
  "main": "expo-router/entry",
6
6
  "scripts": {
7
- "start": "expo start",
8
- "ios": "expo run:ios",
9
- "android": "expo run:android",
10
- "web": "expo start --web",
7
+ "start": "voltro codegen . && expo start",
8
+ "ios": "voltro codegen . && expo run:ios",
9
+ "android": "voltro codegen . && expo run:android",
10
+ "web": "voltro codegen . && expo start --web",
11
11
  "test": "vitest run",
12
- "typecheck": "tsc --noEmit"
12
+ "typecheck": "tsc --noEmit",
13
+ "codegen": "voltro codegen ."
13
14
  },
14
15
  "dependencies": {
15
- "@voltro/client": "0.37.0",
16
- "@voltro/react-native": "0.37.0",
16
+ "@react-native-async-storage/async-storage": "1.23.1",
17
+ "@react-native-community/netinfo": "11.4.1",
18
+ "@voltro/client": "0.39.0",
19
+ "@voltro/react-native": "0.39.0",
20
+ "@{{projectName}}/api": "0.39.0",
17
21
  "expo": "^52.0.0",
18
22
  "expo-constants": "~17.0.0",
19
23
  "expo-linking": "~7.0.0",
20
24
  "expo-router": "~4.0.0",
21
25
  "expo-secure-store": "~14.0.0",
26
+ "expo-splash-screen": "~0.29.0",
22
27
  "expo-status-bar": "~2.0.0",
23
- "@react-native-async-storage/async-storage": "1.23.1",
24
- "@react-native-community/netinfo": "11.4.1",
25
28
  "react": "^19.0.0",
26
29
  "react-native": "0.76.5"
27
30
  },
28
31
  "devDependencies": {
29
- "@{{projectName}}/api": "0.37.0",
32
+ "@types/node": "^22.0.0",
30
33
  "@types/react": "^19.0.0",
31
- "typescript": "^6.0.3",
32
34
  "@vitest/coverage-v8": "^4.1.10",
35
+ "@voltro/cli": "0.39.0",
36
+ "typescript": "^6.0.3",
33
37
  "vitest": "^4.1.10"
34
38
  }
35
39
  }
@@ -1,21 +1,35 @@
1
- // Root layout — wires the Voltro client provider, deep-link routing, and a
2
- // connection banner around the whole app.
1
+ // Root layout — wires the Voltro client, deep-link routing, and a connection
2
+ // banner around the whole app.
3
3
  //
4
- // NOTE: `buildApiHandles()` is the M0 seam (see ../client.ts). Until the RN boot
5
- // helper lands it returns an empty map, so the hooks render their loading state
6
- // rather than live data — deliberately honest, not a crash.
4
+ // The boot ORDER here is load-bearing, and it is the one thing to preserve if
5
+ // you rewrite this file:
6
+ //
7
+ // 1. hydrate persisted stores, THEN install them as the backing store;
8
+ // 2. only then render the app.
9
+ //
10
+ // A store reads during render and a render cannot await, so rendering first
11
+ // shows empty state and flickers into the saved state a frame later. The splash
12
+ // screen stays up until step 1 finishes — that is what it is for.
13
+ //
14
+ // Connecting is deliberately NOT part of that gate. The app must open offline.
7
15
  import { useEffect, useState } from 'react'
8
16
  import { Stack, useRouter } from 'expo-router'
9
17
  import * as Linking from 'expo-linking'
18
+ import * as SplashScreen from 'expo-splash-screen'
10
19
  import { StatusBar } from 'expo-status-bar'
11
- import { FrameworkRuntimesProvider, type ApiHandle } from '@voltro/client'
20
+ import { FrameworkRuntimesProvider, type ApiHandle, type ResolvedClient } from '@voltro/client'
12
21
  import { useMobileConnectionStatus } from '@voltro/react-native'
13
22
  import { Text, View } from 'react-native'
14
- import { buildApiHandles } from '../client'
23
+ import { connectApis, handlesFor, onlineSource, preparePersistence } from '../client'
15
24
  import { handleDeepLink, makeDeepLinks } from '../lib/deeplinks'
16
25
 
26
+ void SplashScreen.preventAutoHideAsync()
27
+
17
28
  function ConnectionBanner() {
18
- const { status } = useMobileConnectionStatus()
29
+ // Reachability comes from NetInfo. The default source reads `navigator.onLine`,
30
+ // which React Native does not have — so on a device it would answer "online"
31
+ // forever, including in airplane mode.
32
+ const { status } = useMobileConnectionStatus({ onlineSource })
19
33
  if (status === 'connected') return null
20
34
  return (
21
35
  <View style={{ backgroundColor: status === 'offline' ? '#b91c1c' : '#b45309', padding: 8 }}>
@@ -27,16 +41,31 @@ function ConnectionBanner() {
27
41
  }
28
42
 
29
43
  export default function RootLayout() {
30
- // Build the api handle map once on mount (async — it opens the WebSocket via
31
- // @voltro/client's buildApiRuntime). Empty until it resolves; the hooks render
32
- // their loading state meanwhile.
44
+ const [ready, setReady] = useState(false)
33
45
  const [apis, setApis] = useState<ReadonlyMap<string, ApiHandle>>(() => new Map())
46
+ const router = useRouter()
47
+
48
+ // 1. Persisted state, before anything renders.
34
49
  useEffect(() => {
35
50
  let live = true
36
- void buildApiHandles().then((map) => { if (live) setApis(map) })
51
+ void preparePersistence()
52
+ .catch(() => { /* a device with no readable storage still gets an app */ })
53
+ .finally(() => {
54
+ if (!live) return
55
+ setReady(true)
56
+ void SplashScreen.hideAsync()
57
+ })
37
58
  return () => { live = false }
38
59
  }, [])
39
- const router = useRouter()
60
+
61
+ // 2. Connect, and keep connected. The supervisor re-dials with backoff; a
62
+ // dropped socket needs no handling here.
63
+ useEffect(() => {
64
+ const supervisor = connectApis((clients: ReadonlyMap<string, ResolvedClient>) => {
65
+ setApis(handlesFor(clients))
66
+ })
67
+ return () => supervisor.dispose()
68
+ }, [])
40
69
 
41
70
  // Route inbound URLs (universal links, custom scheme, push taps) through the
42
71
  // ONE typed link table — the same `matchFirstDeepLink` path push will use.
@@ -51,6 +80,8 @@ export default function RootLayout() {
51
80
  return () => sub.remove()
52
81
  }, [router])
53
82
 
83
+ if (!ready) return null
84
+
54
85
  return (
55
86
  <FrameworkRuntimesProvider apis={apis}>
56
87
  <StatusBar style="auto" />
@@ -3,7 +3,7 @@
3
3
  // The SAME hooks as the web frontend: `useSubscription('notes.list')` streams
4
4
  // the server-side query; `useMutation('notes.create')` writes it. When the
5
5
  // mutation lands, the server pushes a delta and this list re-renders — no
6
- // refetch, no polling. (Requires the M0 client boot; see ../client.ts.)
6
+ // refetch, no polling.
7
7
  //
8
8
  // Both come from `../lib/api`, which binds them ONCE to the api's generated
9
9
  // procedure map — so the tag autocompletes, a typo is a compile error, and the
@@ -3,9 +3,12 @@ import { useState } from 'react'
3
3
  import { Pressable, Text, View } from 'react-native'
4
4
  import { useMobileConnectionStatus } from '@voltro/react-native'
5
5
  import { buildDeviceRegistration } from '../lib/notifications'
6
+ import { onlineSource } from '../client'
6
7
 
7
8
  export default function Settings() {
8
- const { status, reportSuccess, reportFailure } = useMobileConnectionStatus()
9
+ // Same injected source as the banner in _layout: the default reads
10
+ // `navigator.onLine`, which React Native does not have.
11
+ const { status, reportSuccess, reportFailure } = useMobileConnectionStatus({ onlineSource })
9
12
  const [registered, setRegistered] = useState<string | null>(null)
10
13
 
11
14
  return (
@@ -1,74 +1,91 @@
1
1
  // The Voltro client for React Native.
2
2
  //
3
- // The transport keystone now EXISTS: `@voltro/client`'s `buildApiRuntime` builds
4
- // the runtime + subscription cache + rpc client over a WebSocket, and it takes
5
- // the WebSocket constructor as an INJECTED dependency — so React Native passes
6
- // its own `globalThis.WebSocket` (which RN provides) and gets the SAME client
7
- // stack the web app uses, without pulling in `@voltro/web`. See
8
- // plans/open/mobile/00-overview.md (M0).
3
+ // Three pieces, all of them SHARED with the web client rather than
4
+ // re-implemented for mobile:
9
5
  //
10
- // ┌─ THE ONE REMAINING SEAM ─────────────────────────────────────────────────┐
11
- // `buildApiHandle` below is functional. What it still needs is the api's rpc │
12
- // GROUP + descriptor map on web these come from codegen's generated entry. │
13
- // A `voltro codegen` mobile target that emits them for this app is the next │
14
- // M-step (plans/open/mobile/01). Until it lands, `buildApiHandles()` returns
15
- // an empty map (the screens show their loading state) rather than faking it. │
16
- // └──────────────────────────────────────────────────────────────────────────┘
6
+ // · `mobileApis` the generated binding (`.framework/mobileApis.generated.ts`,
7
+ // written by `voltro codegen` from `voltro.mobile.ts`). It carries the api's
8
+ // rpc group and descriptors, so the hooks are typed end to end from the
9
+ // api's own schema.
10
+ // · `startMobileApis` connect + reconnect, over the SAME supervisor
11
+ // `@voltro/web` uses: exponential backoff, generation tracking, and the
12
+ // stale-seed gate that must not carry one subject's rows into the next.
13
+ // · `createAsyncStoragePersistence` — persisted stores over AsyncStorage,
14
+ // hydrated BEFORE the first render (a render cannot await).
15
+ //
16
+ // The ws URL is resolved at runtime, never baked in. `localhost` on a phone is
17
+ // the PHONE — the single most common reason a mobile dev loop silently never
18
+ // connects — so `resolveDevWsUrl` takes the LAN host Expo already knows.
17
19
 
18
- import { buildApiRuntime, type ApiHandle, type BuildApiRuntimeOptions } from '@voltro/client'
19
- import { offlineFirstDefaults } from '@voltro/react-native'
20
+ import Constants from 'expo-constants'
21
+ import NetInfo from '@react-native-community/netinfo'
22
+ import { setStoreStorage, type ResolvedClient } from '@voltro/client'
23
+ import {
24
+ createAsyncStoragePersistence,
25
+ netInfoOnlineSource,
26
+ offlineFirstDefaults,
27
+ resolveDevWsUrl,
28
+ startMobileApis,
29
+ toApiHandles,
30
+ } from '@voltro/react-native'
31
+ import { mobileApis } from '../.framework/mobileApis.generated'
32
+ import { storeStorage } from './persistence'
20
33
 
21
- /** The api's rpc group type sourced from @voltro/client so the template needs
22
- * no direct @effect/rpc dependency. Codegen produces the concrete value. */
23
- type ApiRpcGroup = BuildApiRuntimeOptions['group']
34
+ /** The api's port, as `voltro dev` allocates it. Override per environment. */
35
+ const API_PORT = Number(process.env.EXPO_PUBLIC_API_PORT ?? 4000)
24
36
 
25
- /** The api's base URL. On a simulator/device this must be your machine's LAN
26
- * address (not `localhost`), e.g. `http://192.168.1.20:4000`. */
27
- export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:4000'
37
+ /**
38
+ * The api's WebSocket URL.
39
+ *
40
+ * In development this is derived from the host running Metro, so a real device
41
+ * on the same wifi reaches your machine. In production set
42
+ * `EXPO_PUBLIC_API_WS_URL` to the deployed api.
43
+ */
44
+ export const apiWsUrl = (): string =>
45
+ process.env.EXPO_PUBLIC_API_WS_URL
46
+ ?? resolveDevWsUrl(Constants.expoConfig?.hostUri, API_PORT)
28
47
 
29
- /** The offline-first posture spread into the app's client config. */
48
+ /** The offline-first posture (local-first on, optimistic writes, sync status
49
+ * surfaced). Spread into a client config, or read for UI decisions. */
30
50
  export const clientPosture = offlineFirstDefaults
31
51
 
32
- /** http(s)://host:port ws(s)://host:port/ws (the api's WebSocket endpoint). */
33
- const toWsUrl = (httpUrl: string): string =>
34
- `${httpUrl.replace(/^http/, 'ws').replace(/\/+$/, '')}/ws`
52
+ /** Reachability, from NetInfo. Module scope on purpose: an inline
53
+ * `netInfoOnlineSource(NetInfo)` is a new object every render, and the hook
54
+ * would resubscribe on each one. */
55
+ export const onlineSource = netInfoOnlineSource(NetInfo)
35
56
 
36
- /** React Native provides a global WebSocket; no tracking/reconnect wrapper here
37
- * yet (the RN reconnect supervisor is a later M-step — plans/open/mobile/03). */
38
- const rnWebSocket = (url: string, protocols?: string | ReadonlyArray<string>): globalThis.WebSocket =>
39
- new globalThis.WebSocket(url, protocols as string | string[] | undefined)
57
+ /** Persisted stores, backed by AsyncStorage NOT the Keychain. `persistence.ts`
58
+ * says why, and where a secret goes instead. */
59
+ export const persistence = createAsyncStoragePersistence({ storage: storeStorage })
40
60
 
41
61
  /**
42
- * Build one api's {@link ApiHandle} for the RN client provider. Fully functional
43
- * — hand it the codegen-produced `group` + `descriptors` for the api and it
44
- * returns a live handle. `descriptors` is `ClientDescriptor`-shaped
45
- * (see @voltro/client); pass the generated map.
62
+ * Load persisted state and install it as the backing store for every
63
+ * `defineStore({ persist })`.
64
+ *
65
+ * MUST be awaited before the first render. A store reads during render and a
66
+ * render cannot await, so an app that renders first shows empty state and then
67
+ * flickers into the saved one. The root layout holds the splash screen until
68
+ * this resolves.
46
69
  */
47
- export const buildApiHandle = async (
48
- name: string,
49
- group: ApiRpcGroup,
50
- descriptors: ApiHandle['descriptors'],
51
- ): Promise<ApiHandle> => {
52
- const wsUrl = toWsUrl(API_URL)
53
- const built = await buildApiRuntime({ name, wsUrl, group, webSocketConstructor: rnWebSocket })
54
- return {
55
- runtime: built.runtime,
56
- cache: built.cache,
57
- client: built.client as ApiHandle['client'],
58
- descriptors,
59
- inspectBaseUrl: API_URL.replace(/\/+$/, ''),
60
- errorBus: built.errorBus,
61
- }
70
+ export const preparePersistence = async (): Promise<void> => {
71
+ await persistence.hydrate()
72
+ setStoreStorage(persistence.provider)
62
73
  }
63
74
 
75
+ /** Every api this app talks to, bound to the resolved ws URL. */
76
+ export const apis = mobileApis(() => apiWsUrl())
77
+
64
78
  /**
65
- * Build the `apis` map `FrameworkRuntimesProvider` needs. Returns an empty map
66
- * until codegen emits this app's rpc group (the seam above) — then map each
67
- * configured api through {@link buildApiHandle}.
79
+ * Connect the apis and keep them connected.
80
+ *
81
+ * `onChange` fires on the first connect, on every reconnect swap, and on
82
+ * teardown. Dispose the returned handle when the tree unmounts; call
83
+ * `reconnect()` after a sign-in so the connection re-resolves who it is.
68
84
  */
69
- export const buildApiHandles = async (): Promise<ReadonlyMap<string, ApiHandle>> => {
70
- // Once codegen targets mobile:
71
- // const { rpcGroup, descriptors } = await import('./rpcGroup.generated')
72
- // return new Map([['app', await buildApiHandle('app', rpcGroup, descriptors)]])
73
- return new Map<string, ApiHandle>()
74
- }
85
+ export const connectApis = (
86
+ onChange: (clients: ReadonlyMap<string, ResolvedClient>, initialized: boolean) => void,
87
+ ) => startMobileApis({ apis, onChange })
88
+
89
+ /** Map the live clients into the `apis` prop the client provider takes. */
90
+ export const handlesFor = (clients: ReadonlyMap<string, ResolvedClient>) =>
91
+ toApiHandles(apis, clients)
@@ -21,10 +21,10 @@
21
21
  // that reason; scaffold the pair (`create-project --api=api-backend --mobile`),
22
22
  // which is what this template's README already assumes.
23
23
  //
24
- // RUNTIME NOTE: the hooks are typed today and live once the mobile codegen
25
- // target lands `buildApiHandles()` in ../client.ts still returns an empty map
26
- // (see the seam documented there), so screens render their loading state until
27
- // then. The TYPES are real either way; they come from the api's own descriptors.
24
+ // RUNTIME: live. `voltro codegen` writes this app's api binding into
25
+ // `.framework/mobileApis.generated.ts` (from `voltro.mobile.ts`), and
26
+ // `../client.ts` connects it through the same supervisor the web client uses.
27
+ // The start scripts run codegen, so `pnpm ios` needs no separate step.
28
28
  import { createHooks } from '@voltro/client'
29
29
  import type { AppProcedures } from '@{{projectName}}/api/rpcGroup'
30
30
 
@@ -6,12 +6,12 @@
6
6
  // come from `@voltro/react-native`; this module is browser/RN-safe (no Metro, no
7
7
  // screens), which is exactly why it is unit-testable without a simulator.
8
8
  //
9
- // Declaration order wins in `matchFirstDeepLink`, so list more-specific patterns
9
+ // Declaration order wins in `dispatchDeepLink`, so list more-specific patterns
10
10
  // FIRST. The `navigate` callback is injected (the screen layer passes
11
11
  // expo-router's `router.push`), keeping this table pure + testable.
12
12
  import {
13
13
  defineDeepLink,
14
- matchFirstDeepLink,
14
+ dispatchDeepLink,
15
15
  type DeepLinkDescriptor,
16
16
  } from '@voltro/react-native'
17
17
 
@@ -32,8 +32,8 @@ export const handleDeepLink = (
32
32
  links: ReadonlyArray<DeepLinkDescriptor>,
33
33
  url: string,
34
34
  ): boolean => {
35
- const hit = matchFirstDeepLink(links, url)
36
- if (hit === null) return false
37
- hit.descriptor.handler(hit.params)
38
- return true
35
+ // `dispatchDeepLink` matches AND runs. Doing it in two steps here does not
36
+ // typecheck: the descriptors are a heterogeneous array, so the handler that
37
+ // comes back declares params the match that produced it cannot satisfy.
38
+ return dispatchDeepLink(links, url) !== null
39
39
  }
@@ -8,9 +8,15 @@
8
8
  // Putting a session token in AsyncStorage is a security bug; secure-store is its
9
9
  // home.
10
10
  //
11
- // This is a generic async KV. M0 adapts it to whichever persistence CONTRACT it
12
- // wires (the client cache and/or `@voltro/local-first`'s `PersistenceAdapter`)
13
- // it does not itself claim to satisfy a framework interface that would drift.
11
+ // Two exports, and the split between them is the point:
12
+ // - `kv` is the APP's own store for things it manages itself, tokens included.
13
+ // - `storeStorage` backs `defineStore({ persist })` and is AsyncStorage ONLY.
14
+ //
15
+ // A persisted store is app STATE — a draft, a filter, a wizard step — and none of
16
+ // that belongs in the Keychain, which cannot be enumerated and which the OS may
17
+ // gate behind a biometric prompt. A secret does not belong in a persisted store
18
+ // either; put it through `kv` with `secretKey()`. Routing store keys by prefix
19
+ // would blur a rule worth keeping sharp.
14
20
  import AsyncStorage from '@react-native-async-storage/async-storage'
15
21
  import * as SecureStore from 'expo-secure-store'
16
22
 
@@ -36,3 +42,18 @@ export const kv = {
36
42
 
37
43
  /** Prefix a key so it lands in secure storage. */
38
44
  export const secretKey = (name: string): string => `${SECRET_PREFIX}${name}`
45
+
46
+ /**
47
+ * The backing store for `defineStore({ persist })`, handed to
48
+ * `createAsyncStoragePersistence()` in `src/client.ts`.
49
+ *
50
+ * Deliberately AsyncStorage alone — see the header. `getAllKeys` is what lets
51
+ * the adapter hydrate without being told which keys exist; SecureStore has no
52
+ * equivalent, which is the second reason secrets do not live here.
53
+ */
54
+ export const storeStorage = {
55
+ getItem: (key: string): Promise<string | null> => AsyncStorage.getItem(key),
56
+ setItem: (key: string, value: string): Promise<void> => AsyncStorage.setItem(key, value),
57
+ removeItem: (key: string): Promise<void> => AsyncStorage.removeItem(key),
58
+ getAllKeys: async (): Promise<ReadonlyArray<string>> => AsyncStorage.getAllKeys(),
59
+ }
@@ -3,8 +3,22 @@
3
3
  "compilerOptions": {
4
4
  "strict": true,
5
5
  "paths": {
6
- "@/*": ["./src/*"]
7
- }
6
+ "@/*": [
7
+ "./src/*"
8
+ ]
9
+ },
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "types": [
13
+ "node",
14
+ "react"
15
+ ]
8
16
  },
9
- "include": ["src", "app.config.ts", "tests", ".expo/types/**/*.ts", "expo-env.d.ts"]
17
+ "include": [
18
+ "src",
19
+ "app.config.ts",
20
+ "tests",
21
+ ".expo/types/**/*.ts",
22
+ "expo-env.d.ts"
23
+ ]
10
24
  }
@@ -1,5 +1,29 @@
1
+ diff --git a/dist/cjs/Sharding.js b/dist/cjs/Sharding.js
2
+ index ffd9f07626d9efbf77b8186ce7a8330bf62566eb..4573677ba76c821d7062295c7c606904ab20e2af 100644
3
+ --- a/dist/cjs/Sharding.js
4
+ +++ b/dist/cjs/Sharding.js
5
+ @@ -125,7 +125,18 @@ const make = /*#__PURE__*/Effect.gen(function* () {
6
+ fiber: "releaseShard",
7
+ runner: selfAddress,
8
+ shardId
9
+ - }))), Effect.eventually, FiberMap.run(releaseShardsMap, shardId, {
10
+ + }))), // PATCHED (voltro): was `Effect.eventually` — retry until success with NO
11
+ + // delay. A shard that cannot be released (storage unreachable, DB down)
12
+ + // therefore spun a fiber at full speed, logging at DEBUG level where nobody
13
+ + // reads it. Same semantics — keep trying until it succeeds — with backoff.
14
+ + //
15
+ + // `union` takes the MINIMUM of the two delays, so this CAPS at 5s rather
16
+ + // than compounding: 100ms, 200ms, 400ms … 5s, 5s, 5s. Both schedules recur
17
+ + // forever, and a union continues while either does, so the retry is still
18
+ + // unbounded in ATTEMPTS — which is what shard release needs. The defect was
19
+ + // the missing delay, not the persistence.
20
+ + Effect.retry(Schedule.exponential(100).pipe(Schedule.union(Schedule.spaced(5000)))),
21
+ + FiberMap.run(releaseShardsMap, shardId, {
22
+ onlyIfMissing: true
23
+ })));
24
+ const releaseShards = Effect.gen(function* () {
1
25
  diff --git a/dist/cjs/SqlMessageStorage.js b/dist/cjs/SqlMessageStorage.js
2
- index 77ebad1d2e940b73a494bf9ab0ad7e008daddf82..5e268da7b7923f454b7293eff2ee79326b3cf588 100644
26
+ index 77ebad1d2e940b73a494bf9ab0ad7e008daddf82..9fa66974a5f7977449778304c48cb7b1b1e72ee9 100644
3
27
  --- a/dist/cjs/SqlMessageStorage.js
4
28
  +++ b/dist/cjs/SqlMessageStorage.js
5
29
  @@ -41,7 +41,11 @@ const make = exports.make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
@@ -84,8 +108,32 @@ index 9c6defd9723112761eb49ca1e189fd6cc4187ae7..e82351709c76d206df68a8aa46c4740b
84
108
  ON target.shard_id = source.shard_id
85
109
  WHEN MATCHED AND (target.address = source.address OR DATEDIFF(SECOND, target.acquired_at, ${sqlNow}) > ${expiresSeconds}) THEN
86
110
  UPDATE SET address = source.address, acquired_at = source.acquired_at
111
+ diff --git a/dist/esm/Sharding.js b/dist/esm/Sharding.js
112
+ index c973ed72595ddc9a90229ede2a14a3f571a54af5..0ff7d7347c63ab270520410375ca07709457ace3 100644
113
+ --- a/dist/esm/Sharding.js
114
+ +++ b/dist/esm/Sharding.js
115
+ @@ -117,7 +117,18 @@ const make = /*#__PURE__*/Effect.gen(function* () {
116
+ fiber: "releaseShard",
117
+ runner: selfAddress,
118
+ shardId
119
+ - }))), Effect.eventually, FiberMap.run(releaseShardsMap, shardId, {
120
+ + }))), // PATCHED (voltro): was `Effect.eventually` — retry until success with NO
121
+ + // delay. A shard that cannot be released (storage unreachable, DB down)
122
+ + // therefore spun a fiber at full speed, logging at DEBUG level where nobody
123
+ + // reads it. Same semantics — keep trying until it succeeds — with backoff.
124
+ + //
125
+ + // `union` takes the MINIMUM of the two delays, so this CAPS at 5s rather
126
+ + // than compounding: 100ms, 200ms, 400ms … 5s, 5s, 5s. Both schedules recur
127
+ + // forever, and a union continues while either does, so the retry is still
128
+ + // unbounded in ATTEMPTS — which is what shard release needs. The defect was
129
+ + // the missing delay, not the persistence.
130
+ + Effect.retry(Schedule.exponential(100).pipe(Schedule.union(Schedule.spaced(5000)))),
131
+ + FiberMap.run(releaseShardsMap, shardId, {
132
+ onlyIfMissing: true
133
+ })));
134
+ const releaseShards = Effect.gen(function* () {
87
135
  diff --git a/dist/esm/SqlMessageStorage.js b/dist/esm/SqlMessageStorage.js
88
- index e41d91033992bb890bebb1a16a5ac9e6657a9c41..5ac3181298a020b4e748fc6433ec6f4aba9540b4 100644
136
+ index e41d91033992bb890bebb1a16a5ac9e6657a9c41..2e0c02d7bda6f2040894d4636f5deea94ae15843 100644
89
137
  --- a/dist/esm/SqlMessageStorage.js
90
138
  +++ b/dist/esm/SqlMessageStorage.js
91
139
  @@ -33,7 +33,11 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
@@ -171,7 +219,7 @@ index d4c94fe72c8ff589c0a8f12ff80bac41614a2096..8f6ee6b12f7892916cbd17d18e66d4be
171
219
  WHEN MATCHED AND (target.address = source.address OR DATEDIFF(SECOND, target.acquired_at, ${sqlNow}) > ${expiresSeconds}) THEN
172
220
  UPDATE SET address = source.address, acquired_at = source.acquired_at
173
221
  diff --git a/src/SqlMessageStorage.ts b/src/SqlMessageStorage.ts
174
- index 4326c3832b425548dac135493a4a6cbec0856f63..430474fe47549170592fa32d5b0dcac61f75c39d 100644
222
+ index 4326c3832b425548dac135493a4a6cbec0856f63..4f83bf02f24e8e8021954353ae28b82c987f8483 100644
175
223
  --- a/src/SqlMessageStorage.ts
176
224
  +++ b/src/SqlMessageStorage.ts
177
225
  @@ -52,8 +52,12 @@ export const make = Effect.fnUntraced(function*(options?: {
@@ -1,2 +0,0 @@
1
- import { r as e } from "./addCommand-BNeoeSxe.js";
2
- export { e as runAddCommand };
@@ -1,2 +0,0 @@
1
- import { a as e } from "./agentsMd-mhQMF1bx.js";
2
- export { e as runAgentsMd };
@@ -1,2 +0,0 @@
1
- import { c as e, l as t } from "./apiBuild-DGsShRuF.js";
2
- export { e as runApiBuild, t as runServeBundleBuild };
@@ -1,2 +0,0 @@
1
- import { o as e } from "./baselineCommand-C6NMt-oa.js";
2
- export { e as runBaselineCommand };
@@ -1,2 +0,0 @@
1
- import { t as e } from "./codegen-GYYcdpCg.js";
2
- export { e as checkRpcGroupFreshness };
@@ -1,30 +0,0 @@
1
- import { l as e, z as t } from "./dev-bwDgSObH.js";
2
- import { r as n } from "./codegen-GYYcdpCg.js";
3
- import { basename as r, relative as i, resolve as a } from "node:path";
4
- //#region src/codegenCommand.ts
5
- var o = async (i) => {
6
- let { config: a, present: o, error: s } = await e(i);
7
- if (o && a === null) return {
8
- kind: "refused",
9
- reason: "app.config.ts exists but could not be loaded" + (s === void 0 ? "" : ` — ${s instanceof Error ? s.message : String(s)}`)
10
- };
11
- let c = a?.plugins ?? [], { pluginContributions: l, pluginErrorImports: u, pluginRpcImports: d } = await t(c, a?.name ?? r(i), i), f = await n({
12
- root: i,
13
- ...l.length > 0 ? { pluginContributions: l } : {},
14
- ...u.length > 0 ? { pluginErrorImports: u } : {},
15
- ...d.length > 0 ? { pluginRpcImports: d } : {},
16
- ...a?.name === void 0 ? {} : { apiName: a.name }
17
- });
18
- return f ? {
19
- kind: "wrote",
20
- file: f,
21
- plugins: c.length,
22
- errorSchemas: u.length,
23
- routes: d.length
24
- } : { kind: "nothing-to-write" };
25
- }, s = async (e) => {
26
- let t = a(e.find((e) => !e.startsWith("-")) ?? process.cwd()), n = await o(t);
27
- return n.kind === "refused" ? (console.error(`voltro codegen: ${n.reason} — refusing to write.`), console.error(" The generated file would omit every plugin's error union and client routes, and would typecheck, so the omission would only surface as a missing error tag at runtime."), 1) : n.kind === "nothing-to-write" ? (console.log("voltro codegen: no rpc primitives found — nothing to write."), 0) : (console.log(`voltro codegen: wrote ${i(t, n.file)} (plugins ${n.plugins}, error schemas ${n.errorSchemas}, plugin routes ${n.routes})`), n.plugins > 0 && n.errorSchemas === 0 && console.log(" note: no plugin contributed a cross-cutting error schema, so the wire error union carries only each procedure's own `error:`. Expected if none of your plugins declare `errorSchemas` — but `guards:` needs @voltro/plugin-rbac for its ScopeError to decode typed."), 0);
28
- };
29
- //#endregion
30
- export { o as regenerateRpcGroup, s as runCodegenCommand };
@@ -1,2 +0,0 @@
1
- import { s as e } from "./dbCommand-C4UW8cVN.js";
2
- export { e as runDbCommand };
@@ -1,3 +0,0 @@
1
- import { c as e, d as t, f as n, i as r, u as i } from "./dev-bwDgSObH.js";
2
- import "./dialectDriver-CgXnDfec.js";
3
- export { r as buildStore, e as loadApiConfig, i as loadDiscovered, t as runDev, n as walk };
@@ -1,2 +0,0 @@
1
- import { v as e, y as t } from "./doctorCommand-BXyrtN2l.js";
2
- export { e as runDoctorCommand, t as runServePreflight };