@voltro/cli 0.38.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 (170) hide show
  1. package/CHANGELOG.md +224 -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-CvacV4zA.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/{build-_LoTC1s0.js → build-CfF6t0UM.js} +107 -100
  13. package/dist/{capabilitiesCommand-nq_pz5xd.js → capabilitiesCommand-D5gucwYa.js} +8 -8
  14. package/dist/{checkCommand-wibtQx-N.js → checkCommand-BV56Pcc1.js} +1 -1
  15. package/dist/{checkCommand-Pjk5sBl2.js → checkCommand-CBtynBKW.js} +34 -34
  16. package/dist/{cloudCmd-NQSwe_Qk.js → cloudCmd-C42gaO8s.js} +16 -16
  17. package/dist/{clusterCmd-D5wsCmA_.js → clusterCmd-DrVFCzSj.js} +8 -4
  18. package/dist/{codegen-GYYcdpCg.js → codegen-CbpGCWLG.js} +1 -1
  19. package/dist/codegen-i8QGcHsi.js +2 -0
  20. package/dist/codegenCommand-W7SDdiAQ.js +129 -0
  21. package/dist/{codemodRunner-CXgQ-ecJ.js → codemodRunner-D-jTyvWo.js} +622 -551
  22. package/dist/{commands-DaZVi9wi.js → commands-BD9eBRY3.js} +48 -48
  23. package/dist/{connectionConfig-UFlIEiys.js → connectionConfig-Bk9IC7D0.js} +1 -1
  24. package/dist/{dashboardCommand-Dxy_kmoW.js → dashboardCommand-7qGylm0F.js} +3 -3
  25. package/dist/{dataCommand-D6-Ha2lf.js → dataCommand-C_F2DYxe.js} +14 -11
  26. package/dist/{dbCommand-C4UW8cVN.js → dbCommand-DSwGv9wS.js} +50 -49
  27. package/dist/dbCommand-DcqEyxju.js +2 -0
  28. package/dist/dev-alhkKoEX.js +3 -0
  29. package/dist/{dev-E7Eo5GHc.js → dev-gqpnzVhI.js} +2260 -2184
  30. package/dist/{dialectDriver-CgXnDfec.js → dialectDriver-czCHYpeH.js} +2 -1
  31. package/dist/{doctorCommand-gmDyALFL.js → doctorCommand-Bvs-BQrM.js} +242 -238
  32. package/dist/doctorCommand-CVXfRrng.js +2 -0
  33. package/dist/{dormancyCommand-CE6wG8lj.js → dormancyCommand-B-PPHc9Q.js} +1 -1
  34. package/dist/{e2eCmd-BRabZww-.js → e2eCmd-uFHig1hV.js} +8 -2
  35. package/dist/{embeddingsCommand-C3uAuf6N.js → embeddingsCommand-OKY6XjUf.js} +6 -6
  36. package/dist/{envCommand-DzuWh8ya.js → envCommand-B1-Zm85H.js} +19 -19
  37. package/dist/{evolveCommand-DPLqcYb2.js → evolveCommand-C4-NlFbd.js} +6 -8
  38. package/dist/{generateCommand-DbgcUpGw.js → generateCommand-CcyvH2ve.js} +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/{infoCommand-CmczGj_l.js → infoCommand-CTo8Jnhq.js} +7 -7
  41. package/dist/inspect-B7U7Cl_Z.js +1192 -0
  42. package/dist/inspect-DUze25t0.js +2 -0
  43. package/dist/{inspectCmd-EHFZ9yYu.js → inspectCmd-niF97fAq.js} +5 -5
  44. package/dist/inspectGateHint-BjnFubmH.js +7 -0
  45. package/dist/{logFileSink-C_D2wRN1.js → logFileSink-B4uP8pvf.js} +1 -1
  46. package/dist/{logsCmd-D36xK7Zu.js → logsCmd-B6oNsfaZ.js} +10 -7
  47. package/dist/{manifestBuild-hpPLaGxV.js → manifestBuild-BK42hu0k.js} +1 -1
  48. package/dist/manifestBuild-j0n109tt.js +2 -0
  49. package/dist/{metaCommands-CIoQNlaQ.js → metaCommands-DUYR--Ts.js} +1 -1
  50. package/dist/{migrate-RwgUWXfc.js → migrate-BnPw2zC8.js} +7 -4
  51. package/dist/{packageCommand-Cug_3Ogl.js → packageCommand-9SVyvsSo.js} +3 -2
  52. package/dist/{probeCommand-BPEpwT32.js → probeCommand-C9gazU0H.js} +35 -24
  53. package/dist/{projectScaffold-CxgtJvlb.js → projectScaffold-BEhhHjPr.js} +1 -1
  54. package/dist/{projectScaffold-DDgWbNLe.js → projectScaffold-BvhLOrLq.js} +17 -15
  55. package/dist/{runtimeTrace-6jfznSzr.js → runtimeTrace-DzZOCd94.js} +1 -1
  56. package/dist/{scheduleManifestCmd-D2x0CTTY.js → scheduleManifestCmd-kmWzrO_w.js} +6 -9
  57. package/dist/{sdkgen-CEJZZQUA.js → sdkgen-C4roLErM.js} +4 -4
  58. package/dist/{seedRunner-ZmLSqNe2.js → seedRunner-IdHEprqf.js} +1 -4
  59. package/dist/serveCommand-B6TATyCj.js +1770 -0
  60. package/dist/serveCommand-CNR0gI9V.js +2 -0
  61. package/dist/serveEntry.js +2 -2
  62. package/dist/{serverlessCommand-CfJZy6dS.js → serverlessCommand-DbJ7Plg9.js} +3 -3
  63. package/dist/{start-CidwCcjl.js → start-BNuTWdRd.js} +1 -1
  64. package/dist/{start-RCh6qKFe.js → start-ft_KzFTd.js} +257 -254
  65. package/dist/startEntry.js +1 -1
  66. package/dist/{test-DIQ0jlkQ.js → test-CLYXrZ2F.js} +1 -1
  67. package/dist/{tracesCmd-DStmCJPi.js → tracesCmd-DgtgOUdi.js} +7 -4
  68. package/dist/{typecheckCommand-BlsWiCNq.js → typecheckCommand-BuCtT92o.js} +7 -7
  69. package/dist/updateCommand-6FMU2klq.js +2 -0
  70. package/dist/{updateCommand-CmksX7m_.js → updateCommand-CHBmCB17.js} +5 -3
  71. package/dist/{webDev-BKdD7c_a.js → webDev-CTpSY-e_.js} +813 -775
  72. package/dist/webDev-id3I5PvG.js +2 -0
  73. package/dist/{webhookDiscovery-D7VaeMlz.js → webhookDiscovery-CphsQe59.js} +4 -6
  74. package/dist/{webhookDiscovery-CrGAfhIG.js → webhookDiscovery-il9ti-HE.js} +1 -1
  75. package/dist/{webhooksCommand-BHeaF1ZJ.js → webhooksCommand-BVvOtZ1B.js} +1 -1
  76. package/package.json +27 -21
  77. package/templates/AGENTS.md +2 -2
  78. package/templates/agent-docs/_index.md +2 -2
  79. package/templates/agent-docs/_manifest.json +2 -2
  80. package/templates/agent-docs/authentication.md +30 -0
  81. package/templates/agent-docs/cli.md +17 -13
  82. package/templates/agent-docs/data.md +44 -1
  83. package/templates/agent-docs/database/migrations.md +2 -2
  84. package/templates/agent-docs/database/seedsdialects.md +2 -2
  85. package/templates/agent-docs/deployment.md +21 -7
  86. package/templates/agent-docs/internationalization.md +10 -3
  87. package/templates/agent-docs/local-first-mobile.md +141 -20
  88. package/templates/agent-docs/plugins/mail.md +1 -1
  89. package/templates/agent-docs/plugins/ratelimit.md +1 -1
  90. package/templates/agent-docs/plugins.md +3 -1
  91. package/templates/agent-docs/releases.md +130 -1
  92. package/templates/agent-docs/routing.md +34 -2
  93. package/templates/agent-docs/templates/apibackends.md +1 -1
  94. package/templates/agent-docs/templates/mobile.md +1 -1
  95. package/templates/agent-docs/whats-new.md +181 -49
  96. package/templates/apps/api-ai/package.json +7 -7
  97. package/templates/apps/api-auth/package.json +8 -8
  98. package/templates/apps/api-backend/package.json +7 -7
  99. package/templates/apps/api-backend-deactivation/package.json +7 -7
  100. package/templates/apps/api-backend-mail/package.json +8 -8
  101. package/templates/apps/api-backend-mariadb/package.json +9 -9
  102. package/templates/apps/api-backend-sqlite/package.json +8 -8
  103. package/templates/apps/api-backend-storage/package.json +8 -8
  104. package/templates/apps/api-cms/package.json +10 -10
  105. package/templates/apps/api-collab/package.json +8 -8
  106. package/templates/apps/api-data-advanced/package.json +8 -8
  107. package/templates/apps/api-durable/package.json +8 -8
  108. package/templates/apps/api-feature-flags/package.json +9 -9
  109. package/templates/apps/api-governance/package.json +8 -8
  110. package/templates/apps/api-kv/package.json +8 -8
  111. package/templates/apps/api-moderation/package.json +8 -8
  112. package/templates/apps/api-observability/package.json +8 -8
  113. package/templates/apps/api-ratelimit/package.json +8 -8
  114. package/templates/apps/api-rbac/authz.ts +16 -1
  115. package/templates/apps/api-rbac/package.json +8 -8
  116. package/templates/apps/api-rest/package.json +7 -7
  117. package/templates/apps/api-saas/package.json +11 -11
  118. package/templates/apps/api-saas-starter/package.json +10 -10
  119. package/templates/apps/api-search/package.json +8 -8
  120. package/templates/apps/api-status/package.json +8 -8
  121. package/templates/apps/api-versioning/package.json +8 -8
  122. package/templates/apps/api-webhooks/package.json +9 -9
  123. package/templates/apps/changelog/package.json +6 -6
  124. package/templates/apps/edge-functions/package.json +2 -2
  125. package/templates/apps/frontend-admin/package.json +8 -8
  126. package/templates/apps/frontend-app/package.json +9 -9
  127. package/templates/apps/frontend-auth/package.json +8 -8
  128. package/templates/apps/frontend-blank/package.json +7 -7
  129. package/templates/apps/frontend-cms/package.json +9 -9
  130. package/templates/apps/frontend-collab/package.json +10 -10
  131. package/templates/apps/frontend-contact/package.json +7 -7
  132. package/templates/apps/frontend-dashboard/package.json +7 -7
  133. package/templates/apps/frontend-docs/package.json +7 -7
  134. package/templates/apps/frontend-i18n/package.json +6 -6
  135. package/templates/apps/frontend-landing/package.json +7 -7
  136. package/templates/apps/frontend-portal/package.json +8 -8
  137. package/templates/apps/frontend-saas/package.json +8 -8
  138. package/templates/apps/frontend-spa/package.json +7 -7
  139. package/templates/apps/frontend-ssr/package.json +7 -7
  140. package/templates/apps/frontend-ssr-api/package.json +8 -8
  141. package/templates/apps/frontend-static-blog/package.json +6 -6
  142. package/templates/apps/frontend-status/package.json +8 -8
  143. package/templates/apps/mobile-app/README.md +41 -17
  144. package/templates/apps/mobile-app/app.config.ts +1 -1
  145. package/templates/apps/mobile-app/package.json +15 -11
  146. package/templates/apps/mobile-app/src/app/_layout.tsx +44 -13
  147. package/templates/apps/mobile-app/src/app/index.tsx +1 -1
  148. package/templates/apps/mobile-app/src/app/settings.tsx +4 -1
  149. package/templates/apps/mobile-app/src/client.ts +74 -57
  150. package/templates/apps/mobile-app/src/lib/api.ts +4 -4
  151. package/templates/apps/mobile-app/src/lib/deeplinks.ts +6 -6
  152. package/templates/apps/mobile-app/src/persistence.ts +24 -3
  153. package/templates/apps/mobile-app/tsconfig.json +17 -3
  154. package/dist/addCommand-aXSQveak.js +0 -2
  155. package/dist/agentsMd-BTchIZku.js +0 -2
  156. package/dist/apiBuild-ttSDDOGp.js +0 -2
  157. package/dist/baselineCommand-Cfw2Afwm.js +0 -2
  158. package/dist/codegen-uYrrDzQv.js +0 -2
  159. package/dist/codegenCommand-C60m_LCp.js +0 -30
  160. package/dist/dbCommand-CC-qNzpz.js +0 -2
  161. package/dist/dev-CAOoXOkc.js +0 -3
  162. package/dist/doctorCommand-Cjl8Yii8.js +0 -2
  163. package/dist/inspect-CjTYzAs_.js +0 -1190
  164. package/dist/inspect-P4pxoMaV.js +0 -2
  165. package/dist/inspectGateHint-BF6608UT.js +0 -4
  166. package/dist/manifestBuild-COkJoyAr.js +0 -2
  167. package/dist/serveCommand-DNRF2nDI.js +0 -2
  168. package/dist/serveCommand-DnY4VS3D.js +0 -1767
  169. package/dist/updateCommand-B0J_T9Vs.js +0 -2
  170. package/dist/webDev-CcJfdSjl2.js +0 -2
@@ -341,16 +341,88 @@ code already speaks:
341
341
  _"@voltro/react-native — the credential-free mobile plumbing: registerDevice + the _voltro_devices table, defineDeepLink + its matcher, useBackgroundSync, and offline-first client defaults + connection status."_
342
342
 
343
343
  The React-client bindings are **import-safe** in React Native — every DOM touch
344
- in `@voltro/client` is `typeof window`-guarded, so nothing crashes at import.
345
- What is **not done yet** is wiring the client's runtime for RN: building the
346
- `ApiHandle`s (runtime + subscription cache + rpc client) over a native
347
- WebSocket, plus an RN persistence adapter and a NetInfo connection signal. That
348
- is Phase **M0** of the mobile plan until it lands, the reactive loop is
349
- unproven on a device. `@voltro/react-native` ships the mobile-specific plumbing
350
- that works **today**, limited to the parts that need **no per-tenant credentials
351
- and no native runtime**: device registration, background-sync scheduling,
352
- offline-first defaults, a connection-status surface, and the deep-link
353
- declaration shape.
344
+ in `@voltro/client` is `typeof window`-guarded, so nothing crashes at import
345
+ and the **client runtime now builds on RN**: `@voltro/client`'s
346
+ [`buildApiRuntime`](/docs/react-native/overview#building-the-client-on-react-native)
347
+ constructs the `ApiHandle` (runtime + subscription cache + rpc client) over a
348
+ WebSocket **you** inject, so RN passes its own `globalThis.WebSocket` and gets
349
+ the same client stack the web app uses, without pulling in `@voltro/web`.
350
+
351
+ Still open before the loop is proven end-to-end on a device: codegen emitting the
352
+ api's rpc group for a mobile app, an RN persistence adapter, a NetInfo connection
353
+ signal, and a reconnect supervisor. `@voltro/react-native` ships the
354
+ mobile-specific plumbing around that, limited to the parts that need **no
355
+ per-tenant credentials and no native runtime**: device registration,
356
+ background-sync scheduling, offline-first defaults, a connection-status surface,
357
+ and the deep-link declaration shape.
358
+
359
+ ## Building the client on React Native
360
+
361
+ `startMobileApis()` connects every api your app declares and keeps them
362
+ connected — over the **same** supervisor the web client uses, not a mobile copy
363
+ of it: exponential backoff, generation tracking, and the stale-seed gate that
364
+ must never carry one subject's rows into the next one's screens.
365
+
366
+ ```ts
367
+ import { startMobileApis, toApiHandles } from '@voltro/react-native'
368
+ import { mobileApis } from './.framework/mobileApis.generated' // from `voltro codegen`
369
+
370
+ const apis = mobileApis(() => 'ws://192.168.1.20:4000/ws')
371
+
372
+ const supervisor = startMobileApis({
373
+ apis,
374
+ onChange: (clients) => setHandles(toApiHandles(apis, clients)),
375
+ })
376
+ // later: supervisor.dispose() — or supervisor.reconnect() after a sign-in
377
+ ```
378
+
379
+ `buildApiRuntime()` from `@voltro/client` is the layer underneath, if you want
380
+ one connection without supervision:
381
+
382
+ ```ts
383
+ import { buildApiRuntime } from '@voltro/client'
384
+
385
+ const built = await buildApiRuntime({
386
+ name: 'app',
387
+ wsUrl: 'ws://192.168.1.20:4000/ws', // your machine's LAN address
388
+ group: rpcGroup, // from codegen
389
+ // RN provides a global WebSocket; the web client injects a tracked one.
390
+ webSocketConstructor: (url, protocols) => new globalThis.WebSocket(url, protocols as string[]),
391
+ })
392
+ // built = { runtime, cache, client, errorBus } → an ApiHandle for the provider
393
+ ```
394
+
395
+ ### The api binding is generated
396
+
397
+ `voltro codegen` reads the app's `voltro.mobile.ts` and writes
398
+ `.framework/mobileApis.generated.ts` — which apis this app talks to, and where
399
+ each one's rpc group and descriptors come from. The template's `pnpm ios` /
400
+ `pnpm start` scripts run it, so there is no separate step.
401
+
402
+ ```ts
403
+ // voltro.mobile.ts
404
+ export default {
405
+ apis: { app: { package: '@acme/api' } },
406
+ }
407
+ ```
408
+
409
+ Only the BINDING is generated. The procedure types ride the import of the api
410
+ package's own `rpcGroup`, so a schema change needs no regeneration here.
411
+
412
+ ### `localhost` on a phone is the phone
413
+
414
+ The ws URL is a runtime parameter, never baked into the generated file. A device
415
+ pointed at `ws://localhost:4000/ws` connects to itself, times out and retries
416
+ forever — which reads as a broken framework rather than a wrong host.
417
+ `resolveDevWsUrl()` takes the LAN host Expo already knows:
418
+
419
+ ```ts
420
+ import Constants from 'expo-constants'
421
+ import { resolveDevWsUrl } from '@voltro/react-native'
422
+
423
+ const wsUrl = process.env.EXPO_PUBLIC_API_WS_URL
424
+ ?? resolveDevWsUrl(Constants.expoConfig?.hostUri, 4000)
425
+ ```
354
426
 
355
427
  > **Scaffold a mobile app.** `voltro create-project acme --api=api-backend
356
428
  > --mobile` (or `voltro add-app mobile --template mobile-app`) scaffolds an Expo
@@ -362,6 +434,34 @@ The package **root is RN-safe** — no `node:*`, no `@voltro/database`, and Reac
362
434
  is reached only through the hooks (an optional peer). The `_voltro_devices` table
363
435
  declaration is server-side and lives at `@voltro/react-native/schema`.
364
436
 
437
+ ## Persisted stores on a device
438
+
439
+ `defineStore({ persist })` reads during RENDER, and a render cannot await — so a
440
+ device's async storage cannot back it directly. `createAsyncStoragePersistence()`
441
+ hydrates the keys into memory once, then serves reads from memory and writes
442
+ through asynchronously.
443
+
444
+ ```ts
445
+ import AsyncStorage from '@react-native-async-storage/async-storage'
446
+ import { setStoreStorage } from '@voltro/client'
447
+ import { createAsyncStoragePersistence } from '@voltro/react-native'
448
+
449
+ const persistence = createAsyncStoragePersistence({ storage: AsyncStorage })
450
+
451
+ await persistence.hydrate() // BEFORE the first render
452
+ setStoreStorage(persistence.provider)
453
+ ```
454
+
455
+ **`hydrate()` must be awaited before rendering.** An app that renders first shows
456
+ empty state and flickers into the saved state a frame later; the template holds
457
+ the Expo splash screen until it resolves. Writes to one key coalesce per tick, so
458
+ a store written on every keystroke costs one round trip, and a failed write is
459
+ reported through `onError` rather than thrown into the `set()` that caused it.
460
+
461
+ A storage with no `getAllKeys()` and no explicit `keys: [...]` is a REFUSAL, not
462
+ an empty hydration — an empty cache is indistinguishable from a first run, which
463
+ is the hardest persistence bug there is to attribute.
464
+
365
465
  ## Device registration
366
466
 
367
467
  A device is registered **after** the OS issues its push token (APNs on iOS, FCM
@@ -425,7 +525,7 @@ host away, so a universal link, an App Link, and a custom-scheme URL all match
425
525
  the same path-only pattern:
426
526
 
427
527
  ```ts
428
- import { matchDeepLink, matchFirstDeepLink } from '@voltro/react-native'
528
+ import { dispatchDeepLink, matchDeepLink } from '@voltro/react-native'
429
529
  import orderLink from './orders.deepLink'
430
530
 
431
531
  matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' }
@@ -433,14 +533,22 @@ matchDeepLink('/orders/:id', '/orders/42/edit') // → null
433
533
 
434
534
  // Until `*.deepLink.ts` file discovery lands, register links by hand —
435
535
  // declaration order wins, so list more-specific patterns first.
436
- const hit = matchFirstDeepLink([orderLink], 'myapp://orders/42')
437
- hit?.params.id // '42'
536
+ const params = dispatchDeepLink([orderLink], 'myapp://orders/42')
537
+ params?.id // '42' — and the winning handler has already run
438
538
  ```
439
539
 
540
+ **Use `dispatchDeepLink` for a TABLE, `runDeepLink` for one descriptor.**
541
+ `matchFirstDeepLink` also exists and only inspects: because a table is a
542
+ heterogeneous array, the descriptor it returns has an erased pattern, so its
543
+ handler's declared params (`Record<string, never>`) reject the params returned
544
+ alongside it. Matching and invoking in two steps therefore does not typecheck —
545
+ which is why the dispatching version exists rather than being left to every
546
+ caller to cast around.
547
+
440
548
  > **Seam — file discovery.** Wiring `*.deepLink.ts` into codegen (so the router
441
549
  > auto-collects every declared link) is one additive file, landing after the
442
550
  > current release settles. The descriptor shape above is **final**, so register
443
- > links via `matchFirstDeepLink()` until then.
551
+ > links via `dispatchDeepLink()` until then.
444
552
 
445
553
  ## Background sync
446
554
 
@@ -471,21 +579,33 @@ function SyncIndicator() {
471
579
  `offlineFirstDefaults` is the mobile posture as a value you spread into your
472
580
  client config: local-first ON, optimistic mutations, sync-on-foreground, a
473
581
  5-minute cadence, and a retry backoff schedule. `useMobileConnectionStatus()`
474
- surfaces a `connected | degraded | offline` status — `offline` from
475
- `navigator.onLine`, `degraded` from failures the app reports.
582
+ surfaces a `connected | degraded | offline` status.
583
+
584
+ **Where "online" comes from is injected, not detected.** The default source reads
585
+ `navigator.onLine`, which React Native does not have — so on a device it answers
586
+ "online" forever, airplane mode included. Pass `netInfoOnlineSource(NetInfo)`:
476
587
 
477
588
  ```tsx
478
- import { offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'
589
+ import NetInfo from '@react-native-community/netinfo'
590
+ import { netInfoOnlineSource, offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'
591
+
592
+ // Module scope: an inline call is a new object every render, and the hook would
593
+ // resubscribe on each one.
594
+ const onlineSource = netInfoOnlineSource(NetInfo)
479
595
 
480
- // Spread the mobile posture into your client config.
481
596
  const config = { ...offlineFirstDefaults, url }
482
597
 
483
598
  function ConnectionPill() {
484
- const { status, reportFailure, reportSuccess } = useMobileConnectionStatus()
599
+ const { status, reportFailure, reportSuccess } = useMobileConnectionStatus({ onlineSource })
485
600
  return <span data-status={status}>{status}</span>
486
601
  }
487
602
  ```
488
603
 
604
+ `isInternetReachable` is believed only when it is a boolean. NetInfo reports
605
+ `null` while its probe is outstanding, and reading that as `false` flashes
606
+ "offline" on every cold start and every network change — so a `null` falls back
607
+ to the link-layer `isConnected`.
608
+
489
609
  ## What's shipped vs. a seam
490
610
 
491
611
  This package ships the credential-free plumbing above. The parts that need
@@ -498,4 +618,5 @@ not built here:
498
618
  | **Native module bindings** (camera, biometrics, secure token storage) | Need a native runtime this TS package cannot provide. |
499
619
  | **Swift / Kotlin SDK generators** | **Built + golden-tested** — `voltro build api --target swift\|kotlin` emits a native SDK package. What is deferred is *compiling* the emitted package (`swiftc` / Gradle): that is a mobile-CI step, no cross-language toolchain lives in the framework repo. |
500
620
  | **Universal-links / App-Links file automation** (`apple-app-site-association`, `assetlinks.json`) | A deployment-layer concern, not a client primitive. |
501
- | **`*.deepLink.ts` codegen discovery** | One additive file after the release settles; the descriptor shape is final, so register links via `matchFirstDeepLink()` today. |
621
+ | **`*.deepLink.ts` codegen discovery** | One additive file after the release settles; the descriptor shape is final, so register links via `dispatchDeepLink()` today. |
622
+ | **Booting on a device** | Everything above is unit-tested without a simulator, and a simulator is the only thing that can prove the loop runs under Metro. That is an Expo/EAS CI step — the framework repository has no iOS/Android toolchain, and we say so rather than implying coverage we do not have. |
@@ -331,4 +331,4 @@ mailPlugin({ provider: 'resend', from: '…', allowlist: ['me@acme.com'] })
331
331
 
332
332
  - [Webhooks](/docs/plugins/webhooks) — mounting the provider bounce/complaint query
333
333
  - [Rate limiting](/docs/plugins/ratelimit) — the sibling interceptor plugin
334
- - [Multi-tenancy](/docs/multi-tenancy) — where per-tenant suppression scoping comes from
334
+ - [Multi-tenancy](/docs/multi-tenancy/overview) — where per-tenant suppression scoping comes from
@@ -280,4 +280,4 @@ Bring your own backend entirely by passing any object that satisfies the
280
280
  ## See also
281
281
 
282
282
  - [Mutations](/docs/data/mutations) — the interceptor chain rate limiting hooks into
283
- - [Multi-tenancy](/docs/multi-tenancy) — where `subject.tenantId` comes from
283
+ - [Multi-tenancy](/docs/multi-tenancy/overview) — where `subject.tenantId` comes from
@@ -67,6 +67,8 @@ Status legend: ✓ shipped · ◐ partial · — planned.
67
67
  | `@voltro/plugin-rbac` | ✓ | Roles compile to scopes + the `permission()` handler guard + typed `ScopeError` |
68
68
  | `@voltro/plugin-ratelimit` | ✓ | Per-endpoint / per-subject / per-tenant limits; sliding-window / fixed-window / token-bucket; memory / postgres / redis stores |
69
69
  | `@voltro/plugin-billing` | ✓ | Subscriptions, plans, entitlements + usage metering over a pluggable provider (Stripe + mock); seat-based billing; proration, failed-payment retries, tax and the checkout seat stepper are Stripe's, via the official SDK; `requireEntitlement()` guard + `enforce` interceptor; `/billing/webhook` via plugin-webhooks; money as integer minor units |
70
+ | `@voltro/plugin-licensing` | ✓ | Offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version. [→ details](/docs/plugins/licensing) |
71
+ | `@voltro/plugin-ai-flows` | ✓ | Durable multi-step AI pipelines — deterministic or agentic, with human-in-the-loop, chaining and cadence; author flows in code (`defineFlow`) or as data (visual-editor rows), one engine runs both. [→ details](/docs/plugins/ai-flows) |
70
72
  | `@voltro/plugin-mail` | ✓ | Transactional email — Resend / Postmark / SendGrid / SES / Mailgun / SMTP, *.email.tsx templates, per-tenant suppression, send-time scheduling, bulk/batch send, per-send idempotency, durable via workflows |
71
73
  | `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
72
74
  | `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
@@ -432,7 +434,7 @@ Absent `framework` field → no compat check. Suitable for in-tree plugins that
432
434
  - **Mutate other plugins' state.** Plugins don't talk to each other directly. If two plugins need to coordinate, it's via the rpc layer (one plugin's interceptor sees the other's `subject.metadata`, for example).
433
435
  - **Bypass tenant scoping.** Interceptors run AFTER the runtime's tenant predicate merge. A mutation interceptor can't query rows from another tenant by manipulating `subject.tenantId`.
434
436
  - **Access raw secrets directly.** Plugins get config via their own factory function's options object. `lifecycle.env` exposes `process.env` but only the plugin's own factory chooses which env vars to read.
435
- - **Modify the core schema DSL or query builder.** Schema extension happens through schema mixins (the `*.mixin.ts` pattern in `@voltro/database`); not through plugin runtime hooks.
437
+ - **Modify the core schema DSL or query builder.** Schema extension happens through schema mixins (`defineMixin` from `@voltro/database`); not through plugin runtime hooks.
436
438
 
437
439
  These boundaries hold for v1; some may relax for verified plugins once the marketplace ships.
438
440
 
@@ -1,6 +1,135 @@
1
1
  # Releases
2
2
 
3
- > The largest release in the framework's history what breaks your boot, what changes at runtime, and why none of it is rewritten for you.
3
+ > 0.35 through 0.38 in one passthe boot-breaking access declarations, the stricter input handling, and the runtime behaviour that moved underneath you.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/releases/upgrading-to-0-38.md -->
10
+ ## Upgrading to 0.38.0
11
+
12
+ _0.35 through 0.38 in one pass — the boot-breaking access declarations, the stricter input handling, and the runtime behaviour that moved underneath you._
13
+
14
+ 0.35, 0.36, 0.37 and 0.38 landed within two days of each other, so this page covers
15
+ them as one upgrade. It is much smaller than [0.34](/docs/releases/upgrading-to-0-34) —
16
+ but two of the changes will stop your app from booting until you make a decision, and
17
+ one changes how every request is validated.
18
+
19
+ Run the upgrade first, fix the boot, then read what moved at runtime.
20
+
21
+ ## 1. Run `voltro update`
22
+
23
+ ```bash
24
+ voltro update
25
+ ```
26
+
27
+ Every breaking change below either ships a codemod that rewrites your source or prints
28
+ written steps during the update. What a codemod cannot do is make an access decision on
29
+ your behalf — that part is yours.
30
+
31
+ ## 2. What breaks your boot
32
+
33
+ ### A declared event must decide who may listen (0.35)
34
+
35
+ `defineEvent`'s `guards:` was optional, and an empty list was skipped — so under
36
+ `security.defaultDeny` an event with **no access declaration was subscribable by anyone
37
+ who could open the socket**, while the identical shape was already refused for every
38
+ procedure. The boot gate now covers `defineEvent` too.
39
+
40
+ Declare an access decision on each event: a `guards:` list, or an explicit
41
+ `openAccess: '<reason>'` when it genuinely is public.
42
+
43
+ ### Twelve first-party plugin routes now require a scope (0.35)
44
+
45
+ Every first-party plugin rpc route now declares an access decision, and `defaultDeny` is
46
+ enforced in the dispatch spine as defense in depth. Previously-open routes that now need
47
+ a scope:
48
+
49
+ | Route | Scope |
50
+ |---|---|
51
+ | `billing.startCheckout` / `portalUrl` / `previewChange` / `changePlan` / `changeSeats` / `invoices` | `billing:manage` |
52
+ | `billing.reportUsage` | `billing:report` |
53
+ | `governance.export` / `erase` | `admin:full` (was already enforced in-handler, now declared) |
54
+ | `storage.mintUploadUrl` / `ingestUrl` | `storage:manage` |
55
+ | `storage.listRefs` | `storage:browse` |
56
+
57
+ **Migration:** grant each scope to the role or subjects that legitimately hold that
58
+ capability — via an rbac role, `resolveScopes`, or api-key scopes. The codemod lists
59
+ every affected route plus the open-by-design surfaces that did **not** change.
60
+
61
+ ### The framework table set no longer reads a runtime flag (0.35)
62
+
63
+ `CDC`, `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` used to move the *declared* table set,
64
+ which meant the migrate job's env and the pod's env could disagree — a green apply
65
+ followed by a crash loop. `app.config.ts` gained `schema: { traces?, undo? }` to declare
66
+ the two that still need a decision.
67
+
68
+ ### A failing `*.startup.ts` now refuses the boot (0.38)
69
+
70
+ It used to warn and let the server come up. If your startup module is allowed to fail,
71
+ handle the failure inside it — the framework will no longer serve traffic behind a
72
+ startup that did not complete.
73
+
74
+ ## 3. What changed at runtime
75
+
76
+ ### An undeclared input field now rejects the call (0.37)
77
+
78
+ A field a procedure's input schema does not declare **rejects** the request. It used to
79
+ be silently discarded and the call ran with what was left. This is the change most likely
80
+ to surface in a client you did not update in lockstep: an extra property that used to be
81
+ ignored is now an error. Check any hand-built request payloads.
82
+
83
+ ### `IMPERSONATION_METADATA_KEY` moved to `@voltro/protocol` (0.38)
84
+
85
+ It names the one reserved key in `Subject.metadata`, and `Subject` is protocol's type —
86
+ two packages need it (plugin-auth writes the mark, plugin-audit reads it) and a plugin
87
+ must not depend on another plugin. The codemod repoints the import, preserving an alias
88
+ and the type-only form. Everything else stays where it was: `impersonationOf`,
89
+ `isImpersonated`, `ImpersonationMark` and `impersonationAuditRedactor` are still exported
90
+ from `@voltro/plugin-auth`.
91
+
92
+ An audit row now also records when an action was taken through an **impersonated**
93
+ session, on the default settings, and no redactor can remove that mark.
94
+
95
+ ### Upstream 401s report a different code (0.36)
96
+
97
+ A 401 from an upstream now produces `code: 'unauthorized'`, not `code: 'session_expired'`.
98
+ The connection vault's own failure — the case where we *do* know the credential is
99
+ unusable — becomes `code: 'credential_unusable'`. If you branch on these codes, update
100
+ the branch.
101
+
102
+ ### `RunStepStatus` gained `'skipped'` (0.35, plugin-ai-flows)
103
+
104
+ A sixth member means an exhaustive `switch` stops compiling and a status-keyed lookup has
105
+ a hole. A manual codemod fires on any app that names the type or its literals. Everything
106
+ else in that release is additive.
107
+
108
+ ### The analytics CDC mirror versions from the change, not the clock (0.35)
109
+
110
+ Under `changeScope: 'fleet'` (postgres CDC, mysql binlog) the mirror's version is derived
111
+ from the change's own position in the totally-ordered fleet stream instead of each
112
+ replica's clock. N replicas still issue N writes per change — that is the transport — but
113
+ they are now **byte-identical**, so the sinks' existing guards dedupe them for free.
114
+
115
+ This closed a real defect: under clock skew larger than the gap between two changes to one
116
+ row, a peer's duplicate of the *older* image could take the higher version and win in the
117
+ warehouse permanently and silently.
118
+
119
+ **If you wrote a custom sink**, `AnalyticsMirrorImpl.maxVersion` is now **required** — a
120
+ replica joining mid-stream seeds each key from the warehouse's high-water mark. All
121
+ shipped warehouse sinks implement it; the codemod note covers a custom one. New tunable:
122
+ `VOLTRO_ANALYTICS_MIRROR_VERSION_STATE_LIMIT` (default 100000).
123
+
124
+ ## The short checklist
125
+
126
+ 1. `voltro update` — let the codemods run.
127
+ 2. Declare access on every `defineEvent`.
128
+ 3. Grant the twelve plugin-route scopes to the roles that should hold them.
129
+ 4. Move `CDC` / undo / traces decisions into `app.config.ts`'s `schema: { … }`.
130
+ 5. Make sure your `*.startup.ts` cannot fail unintentionally.
131
+ 6. Audit client payloads for fields your input schemas do not declare.
132
+ 7. If you have a custom analytics sink, implement `maxVersion`.
4
133
 
5
134
 
6
135
 
@@ -758,7 +758,18 @@ That last case is how dynamic `static` routes work in dev / when `getStaticPaths
758
758
 
759
759
  _Server-side data fetch via `loader`, page-level `<head>` tags via `meta`, and how the build pipeline runs both._
760
760
 
761
- A **loader** is the page's server-side data hook. It runs before the React render (during SSR, during SSG, or per-request for ISR/SSR), and its result lands in `useLoaderData<T>()`. **Meta** is a sibling export that produces `<title>` + `<meta>` tags.
761
+ A **loader** is the page's data hook. It runs before the React render (during SSR, during SSG, or per-request for ISR/SSR), and its result lands in `useLoaderData<T>()`. **Meta** is a sibling export that produces `<title>` + `<meta>` tags.
762
+
763
+ > **It runs on the server AND again in the browser.** A loader is not a
764
+ > server-only hook: it runs during SSR for the first paint, and runs AGAIN, in
765
+ > the browser, on every in-app navigation to the route. Same function, different
766
+ > environment — so anything server-only in it must be guarded with
767
+ > `ctx.isServer`.
768
+ >
769
+ > This is the single most expensive thing to learn late, because a client-only
770
+ > failure is invisible to every probe that does not NAVIGATE: a fresh page load,
771
+ > a `curl`, any SSR check all take the server path and pass. Only clicking a
772
+ > link inside the running app reaches the other one.
762
773
 
763
774
  Both are static module exports — the framework discovers them, the build pipeline runs them.
764
775
 
@@ -780,7 +791,8 @@ export const loader = async ({ params, headers }: {
780
791
  params: { id: string }
781
792
  headers: Readonly<Record<string, string>>
782
793
  }): Promise<Note> => {
783
- // Server-side fetch runs on the Node side, never in the browser.
794
+ // Runs during SSR *and* again in the browser on in-app navigation
795
+ // guard anything server-only with `ctx.isServer`.
784
796
  const res = await fetch(`${INTERNAL_API}/notes/${params.id}`, {
785
797
  headers: { cookie: headers.cookie ?? '' },
786
798
  })
@@ -1026,6 +1038,7 @@ React alone.
1026
1038
  export const loader = async (ctx: {
1027
1039
  readonly params: Readonly<Record<string, string>> // URL params from [name] segments
1028
1040
  readonly pathname: string // matched path (no query string)
1041
+ readonly isServer: boolean // true during SSR/SSG, false in the browser
1029
1042
  readonly search: string // raw query string incl. `?`, or '' — filled on every path
1030
1043
  readonly signal: AbortSignal // Aborts if the client disconnects mid-render
1031
1044
  readonly headers?: Readonly<Record<string, string>> // Request headers (SSR/ISR only — empty for SSG/client)
@@ -1036,6 +1049,25 @@ export const loader = async (ctx: {
1036
1049
  }) => Promise<unknown>
1037
1050
  ```
1038
1051
 
1052
+ ### Branch on `isServer`, not on what happens to be missing
1053
+
1054
+ `isServer` is the supported way to ask which invocation this is. The two things
1055
+ that look like they answer the same question do not:
1056
+
1057
+ - **`query` is absent in the browser**, so `if (ctx.query)` appears to work. It
1058
+ branches on the ABSENCE OF A FUNCTION, which says nothing about why it is
1059
+ absent and breaks the moment anything else becomes conditional.
1060
+ - **`headers` is `{}` in the browser, not `undefined`** — so `if (ctx.headers)`
1061
+ is TRUE on both paths. A consumer wrote exactly that check and it silently did
1062
+ nothing.
1063
+
1064
+ ```ts
1065
+ export const loader = async (ctx: LoaderContext) => {
1066
+ if (ctx.isServer) seedStore(prefs, await ctx.query!('prefs.get'))
1067
+ return null
1068
+ }
1069
+ ```
1070
+
1039
1071
  The loader context carries `pathname` and `search`, not a `request` object.
1040
1072
 
1041
1073
  `pathname` is deliberately query-free — a loader keyed on `?tab=2` would cache badly. `search` carries the raw query string (with its leading `?`, or `''`), filled identically on client navigation, `voltro dev` SSR and `voltro start` SSR. Parse it with `new URLSearchParams(ctx.search)`.
@@ -1006,7 +1006,7 @@ export default async ({ store, log, onShutdown, id }: StartupContext) => {
1006
1006
  }
1007
1007
  ```
1008
1008
 
1009
- A throw here does NOT block boot — it's logged; the rpc surface stays up. Distinct from `*.seed.ts` (runs once and RETURNS) and `*.cron.tsx` (periodic): a startup HOLDS a resource until shutdown. `StartupContext` is imported from the `@voltro/cli/startup` subpath.
1009
+ A throw here REFUSES the boot, naming the file and the cause a startup is where an app arms things the request path depends on, and "serving without them" is the failure nobody sees. Catch the error inside the function if a failure is genuinely acceptable (a cache warm), so the decision sits where somebody made it. The function must also RETURN: start the work, hand back teardown via `onShutdown`, return — one that never returns refuses the boot after `VOLTRO_STARTUP_TIMEOUT_MS` (default 60 s), while a slow-but-successful startup is simply awaited. Distinct from `*.seed.ts` (runs once and RETURNS) and `*.cron.tsx` (periodic): a startup HOLDS a resource until shutdown. `StartupContext` is imported from the `@voltro/cli/startup` subpath.
1010
1010
 
1011
1011
  ## Try it
1012
1012
 
@@ -51,7 +51,7 @@ Deep links are typed and go through one table: a universal link, a custom-scheme
51
51
 
52
52
  ## Honest status — read before you expect the loop on a device
53
53
 
54
- The **pure logic** (`src/lib/*`) is real and unit-tested (`pnpm test`, no simulator). The **client wiring** building the runtime + subscription cache + rpc client the hooks read is **not yet implemented for React Native**: on the web it lives in `@voltro/web`'s web-coupled boot. Wiring an RN-safe equivalent is Phase **M0** of the framework's mobile plan. Until it lands, `buildApiHandles()` in `src/client.ts` returns an empty map and the screens show their loading state deliberately, not a crash. Its header spells out exactly what M0 must build.
54
+ The **pure logic** (`src/lib/*`) is real and unit-tested (`pnpm test`, no simulator), and the **client wiring now exists**: `src/client.ts` calls `@voltro/client`'s `buildApiRuntime` with RN's `globalThis.WebSocket`, so `buildApiHandle()` returns a live handle. What it still waits on is **codegen emitting this app's rpc group + descriptor map** (on web those come from the generated entry) until that lands, `buildApiHandles()` returns an empty map and the screens show their loading state, deliberately rather than faking one. Its header spells out the remaining step.
55
55
 
56
56
  Booting the app on a device (`expo run:ios`) is your Expo/EAS CI step — there is no iOS/Android toolchain in the framework repo.
57
57