@voltro/cli 0.27.0 → 0.29.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.
- package/CHANGELOG.md +513 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
- package/dist/apiBuild-C8d74T6g.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BPAtyWs8.js +2420 -0
- package/dist/checkCommand-DBvZvCh1.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
- package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
- package/dist/dev-DeEJj5T4.js +3 -0
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
- package/dist/inspect-DHeaMZak.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
- package/dist/manifestBuild-Ck_90gZy.js +2 -0
- package/dist/sdkgen-CW5NbYF0.js +582 -0
- package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +3 -3
- package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +2 -1
- package/templates/agent-docs/_index.md +2 -1
- package/templates/agent-docs/_manifest.json +10 -1
- package/templates/agent-docs/ai.md +135 -0
- package/templates/agent-docs/cli.md +124 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/deployment.md +56 -0
- package/templates/agent-docs/internationalization.md +161 -1
- package/templates/agent-docs/local-first-mobile.md +414 -0
- package/templates/agent-docs/routing.md +93 -2
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/testing.md +42 -0
- package/templates/agent-docs/whats-new.md +165 -131
- package/templates/agent-docs/workflows.md +11 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/dist/apiBuild-BrjrVJJh.js +0 -2
- package/dist/dbCommand-DrzXimKf.js +0 -2
- package/dist/dev-DNkso403.js +0 -3
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-DfkisVWP.js +0 -1310
|
@@ -51,7 +51,11 @@ Server-side, the active locale is determined by, in priority order:
|
|
|
51
51
|
|
|
52
52
|
The resolved locale is **guaranteed** to be one of the codes in `locales`. Any unsupported value (a cookie pointing at a code you no longer ship, a browser asking for `xx-YY`) falls through to the next signal. RFC 4647 lookup strips subtags one segment at a time — `de-CH-1996` → `de-CH` → `de` — so a `de` catalog serves a `de-CH` browser.
|
|
53
53
|
|
|
54
|
-
The client
|
|
54
|
+
The client **adopts what the server resolved**, reading it from the `<html lang>` attribute the server render sets, then falling back to the cookie and the default. `Accept-Language` is never read in the browser: `navigator.languages` can diverge from what the server saw.
|
|
55
|
+
|
|
56
|
+
> Earlier versions said the client "mirrors cookie and default for hydration safety". That was the opposite of what happened. Dropping the `Accept-Language` signal is not the same as agreeing with the server about it — on a first visit, with no cookie yet, the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app therefore hydrated `de` over an `en` tree and React discarded the entire server render, which is exactly what SSR was enabled to avoid. It stopped as soon as anything wrote the cookie, so one language switch made it un-reproducible for that developer.
|
|
57
|
+
|
|
58
|
+
`<html lang>` carries the same resolved locale — the value the `<I18nProvider>` renders with, on the same request. That matters on its own: it is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses.
|
|
55
59
|
|
|
56
60
|
See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.
|
|
57
61
|
|
|
@@ -697,3 +701,159 @@ For dates, numbers and relative time the hooks are the blessed path; reach for `
|
|
|
697
701
|
- **Don't pin a global `timeZone` / locale for the whole app.** The formatters resolve the active locale from the provider; a pin makes every viewer read the app in one user's settings.
|
|
698
702
|
- **Don't pass a locale-formatted string to a machine consumer.** Formatted output is presentation — send ISO strings and raw numbers to APIs, `dateTime` attributes and sort keys.
|
|
699
703
|
- **Don't format inside a `.map()` by constructing `Intl` objects yourself.** The hooks memoize per locale; a fresh `new Intl.NumberFormat(...)` per row is the slow path.
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
<!-- source: en/i18n/datetime.md -->
|
|
710
|
+
## Dates & timezones
|
|
711
|
+
|
|
712
|
+
_"@voltro/datetime — UTC-instant storage, timezone-aware arithmetic/formatting on TC39 Temporal, and the request-scoped timezone-context seam."_
|
|
713
|
+
|
|
714
|
+
`@voltro/datetime` is the framework's opinion on time: **store UTC instants,
|
|
715
|
+
render in the viewer's timezone, never do zone math by hand.** It is built on the
|
|
716
|
+
[TC39 Temporal](https://tc39.es/proposal-temporal/docs/) standard (via a
|
|
717
|
+
polyfill, so the API is identical on the server and in the browser) and ships
|
|
718
|
+
pure, browser-safe helpers plus a request-scoped timezone seam.
|
|
719
|
+
|
|
720
|
+
The `.` entry is browser-safe — no `effect`, no `node:*` — so a route or
|
|
721
|
+
component can import it directly. The Effect seam lives in a separate subpath,
|
|
722
|
+
`@voltro/datetime/context`, so the pure surface carries no `effect` dependency.
|
|
723
|
+
|
|
724
|
+
> **Phase 1.** This is the storage + timezone + formatting layer. Schema-DSL
|
|
725
|
+
> temporal column types, `interval()`, and `rrule()` are later phases; use
|
|
726
|
+
> `timestamp()` columns and these helpers today.
|
|
727
|
+
|
|
728
|
+
## The storage contract: a `Date` is a UTC instant
|
|
729
|
+
|
|
730
|
+
`timestamp()` stores `TIMESTAMPTZ`, and the driver hands your app code a plain JS
|
|
731
|
+
`Date`. A `Date` is a bare epoch-millisecond count with no zone of its own, so
|
|
732
|
+
the ONLY correct reading of it is "the UTC instant it points at". These helpers
|
|
733
|
+
make that reading explicit and lossless in both directions:
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
import { toInstant, toUTCString, isValidTimeZone } from '@voltro/datetime'
|
|
737
|
+
|
|
738
|
+
// A `Date` from a query IS a UTC instant — read it as one, losslessly.
|
|
739
|
+
const when = toInstant(row.createdAt) // Temporal.Instant
|
|
740
|
+
toUTCString(row.createdAt) // "2026-08-06T12:00:00Z" — the wire/storage form
|
|
741
|
+
|
|
742
|
+
isValidTimeZone('Europe/Berlin') // true
|
|
743
|
+
isValidTimeZone('Mars/Phobos') // false
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
`toInstant` accepts a `Temporal.Instant`, a JS `Date`, or an ISO-8601 string —
|
|
747
|
+
but a string MUST carry an explicit offset or `Z`. A naive `2026-01-01T12:00`
|
|
748
|
+
denotes no instant and throws, by design: the whole point is that there is no
|
|
749
|
+
silent zone-guessing. Bridge back with `toDate(instant)` for storage or interop.
|
|
750
|
+
|
|
751
|
+
## Timezone resolution — the framework convention
|
|
752
|
+
|
|
753
|
+
Which zone should a given request render in? Resolve it in priority order, each
|
|
754
|
+
candidate validated as an IANA name, falling back to `UTC`:
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
import { resolveTimezone } from '@voltro/datetime'
|
|
758
|
+
|
|
759
|
+
const tz = resolveTimezone({
|
|
760
|
+
userTimeZone: user.timezone, // 1. the viewing user's profile
|
|
761
|
+
tenantTimeZone: tenant.timezone, // 2. the tenant / org default
|
|
762
|
+
browserTimeZone: 'Europe/Berlin', // 3. the browser-reported zone
|
|
763
|
+
}) // → a validated IANA name, else 'UTC'
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
This mirrors `@voltro/i18n`'s `resolveLocale` in spirit — pure, framework-agnostic,
|
|
767
|
+
no Effect or RPC — so it runs in any request pipeline. An invalid or absent
|
|
768
|
+
candidate falls through to the next signal; the result is GUARANTEED valid.
|
|
769
|
+
|
|
770
|
+
### Carrying it through a request — the context seam
|
|
771
|
+
|
|
772
|
+
The resolved zone travels through a request via an Effect context tag in
|
|
773
|
+
`@voltro/datetime/context`, so any route, mutation, workflow, or agent can read it:
|
|
774
|
+
|
|
775
|
+
```ts
|
|
776
|
+
import { currentTimezone, withTimezone } from '@voltro/datetime/context'
|
|
777
|
+
import { Effect } from 'effect'
|
|
778
|
+
|
|
779
|
+
const program = Effect.gen(function* () {
|
|
780
|
+
const tz = yield* currentTimezone // the request's zone, or 'UTC' when none set
|
|
781
|
+
return tz
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
// Provide an explicit zone for a sub-computation:
|
|
785
|
+
program.pipe(withTimezone('Europe/Berlin'))
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
`currentTimezone` never fails — an absent context is the documented `UTC`
|
|
789
|
+
default, so call sites don't handle a missing-service error. The runtime provides
|
|
790
|
+
the tag per request (via `resolvedTimezoneLayer`); the web layer's `useTimezone()`
|
|
791
|
+
hook is a thin projection over it.
|
|
792
|
+
|
|
793
|
+
## Arithmetic — the DST split is in the names
|
|
794
|
+
|
|
795
|
+
Every operation that is ambiguous without a zone REQUIRES an IANA `timeZone`
|
|
796
|
+
argument. There is no implicit "system zone", so a call site cannot silently do
|
|
797
|
+
the wrong thing on a differently-configured box. The DST distinction is
|
|
798
|
+
deliberate and encoded in the method names:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
import { addDays, addHours, startOfDay, endOfDay } from '@voltro/datetime'
|
|
802
|
+
|
|
803
|
+
// WALL-CLOCK: same local time, one calendar day later in Berlin.
|
|
804
|
+
// Across a DST boundary the elapsed real time is 23h or 25h. Needs a zone.
|
|
805
|
+
addDays(when, 1, 'Europe/Berlin')
|
|
806
|
+
|
|
807
|
+
// EXACT elapsed time: 24 × 3600 seconds, DST-oblivious. No zone.
|
|
808
|
+
addHours(when, 24)
|
|
809
|
+
|
|
810
|
+
// Zone-relative day boundaries.
|
|
811
|
+
startOfDay(when, 'Europe/Berlin')
|
|
812
|
+
endOfDay(when, 'Europe/Berlin')
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
`addDays` / `addMonths` are wall-clock ("same local time, N days on"); `addHours`
|
|
816
|
+
is exact elapsed time. Comparisons that measure absolute instants (`isAfter`,
|
|
817
|
+
`isBefore`) take no zone; `isSameDay` does, because "same day" is a wall-clock
|
|
818
|
+
question. For a calendar date with no time and no zone — a birthday, a holiday, a
|
|
819
|
+
due date — use `plainDate(year, month, day)` / `parseDate('2026-08-06')`.
|
|
820
|
+
|
|
821
|
+
## Formatting — locale- and timezone-aware
|
|
822
|
+
|
|
823
|
+
Formatting is where the viewer's timezone and locale are APPLIED. Both are
|
|
824
|
+
explicit arguments — this layer carries no ambient locale (the web layer resolves
|
|
825
|
+
`useLocale()` and passes it in):
|
|
826
|
+
|
|
827
|
+
```ts
|
|
828
|
+
import { formatDate, formatDateTime, formatRelativeTime } from '@voltro/datetime'
|
|
829
|
+
|
|
830
|
+
formatDate(row.createdAt, 'Europe/Berlin', { locale: 'de' }) // "6. Aug. 2026"
|
|
831
|
+
formatDateTime(row.createdAt, 'America/New_York', { locale: 'en' })
|
|
832
|
+
formatRelativeTime(row.createdAt, { locale: 'en' }) // "3 hours ago"
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
`FormatOptions` is `{ locale? }` plus any `Intl.DateTimeFormatOptions` override,
|
|
836
|
+
so `formatDate(when, tz, { locale, dateStyle: 'full' })` works. `formatRelativeTime`
|
|
837
|
+
picks the largest unit that fits the signed distance from `now` (default: the
|
|
838
|
+
current instant) and is zone-independent — it measures elapsed real time, not
|
|
839
|
+
wall-clock days.
|
|
840
|
+
|
|
841
|
+
> These helpers are the STORAGE/TIMEZONE layer. `@voltro/i18n`'s
|
|
842
|
+
> [`useFormatDate` / `useRelativeTime`](/docs/i18n/formatting) hooks are the
|
|
843
|
+
> React binding that read the active locale from the provider; reach for those in
|
|
844
|
+
> components, and for `@voltro/datetime` in server code, loaders, and tests.
|
|
845
|
+
|
|
846
|
+
## Temporal directly
|
|
847
|
+
|
|
848
|
+
The exact standard `Temporal` types are re-exported, so you can drop to the full
|
|
849
|
+
API when a helper doesn't cover your case:
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
import { Temporal } from '@voltro/datetime'
|
|
853
|
+
|
|
854
|
+
const noon = Temporal.PlainTime.from('12:00')
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
Import `Temporal` from `@voltro/datetime`, never from the polyfill directly — that
|
|
858
|
+
keeps the eventual switch to the native global (once it is universal in browsers)
|
|
859
|
+
a one-line change for the whole codebase.
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# Local-first & Mobile
|
|
2
|
+
|
|
3
|
+
> "@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath."
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/local-first/overview.md -->
|
|
10
|
+
## Local-first & CRDTs
|
|
11
|
+
|
|
12
|
+
_"@voltro/local-first — CRDT text merge (crdtText/mergeCrdtStates), the offline sync-queue + SyncClient wire, presence/awareness, durable persistence, and the localFirst table mixin. Pure and browser-safe; the React hooks live behind a subpath."_
|
|
13
|
+
|
|
14
|
+
`@voltro/local-first` is the framework's foundation for **offline-capable,
|
|
15
|
+
multiplayer, convergent** apps: edit while disconnected, see other peers' cursors,
|
|
16
|
+
and reconcile without losing work when the network returns. The `.` entry is
|
|
17
|
+
**pure and browser-safe** — no `effect`, no `node:*` — so a route or component
|
|
18
|
+
imports it directly. The React wrappers live behind `@voltro/local-first/react`
|
|
19
|
+
(React is an optional peer, so the pure path never pulls it in).
|
|
20
|
+
|
|
21
|
+
> **What ships today**: the pure CRDT text merge, the offline sync-queue reducer,
|
|
22
|
+
> the connection-lifecycle state machine, the conflict policy, the
|
|
23
|
+
> [`crdtText()` database column](#the-crdttext-database-column) (with its
|
|
24
|
+
> authoritative server-side merge on the write path), the
|
|
25
|
+
> [`SyncClient`](#the-syncclient-bi-directional-wire) that drives the queue over a
|
|
26
|
+
> transport, [presence/awareness](#presence--awareness) via `usePresence`,
|
|
27
|
+
> [durable IndexedDB persistence](#durable-persistence), and the
|
|
28
|
+
> [`localFirst` table mixin](#the-localfirst-table-mixin). What remains is a thin
|
|
29
|
+
> [runtime binding](#whats-shipped-vs-a-runtime-seam) to provisioned infra
|
|
30
|
+
> (a broker at scale, a running app's transport) — not un-built framework code.
|
|
31
|
+
|
|
32
|
+
## CRDT text: `crdtText` + `mergeCrdtStates`
|
|
33
|
+
|
|
34
|
+
A CRDT (Conflict-free Replicated Data Type) text field can be edited by many
|
|
35
|
+
peers offline and always converges to the same result. `crdtText()` builds one
|
|
36
|
+
(Yjs-backed behind the `CrdtBackend` abstraction), and `mergeCrdtStates()` is
|
|
37
|
+
the heart of the package — it converges two encoded states into one,
|
|
38
|
+
deterministically and order-independently.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { crdtText, mergeCrdtStates, decodeCrdtText } from '@voltro/local-first'
|
|
42
|
+
|
|
43
|
+
// A field, edited offline. `insert`/`delete` mutate and return the same handle.
|
|
44
|
+
const doc = crdtText('Hello').insert(5, ', world')
|
|
45
|
+
const state = doc.encode() // the wire/storage form: a CrdtState
|
|
46
|
+
|
|
47
|
+
// Converge two peers' encoded states — order-independent, no lost edits.
|
|
48
|
+
const remote = crdtText('Hello').insert(5, ' there').encode()
|
|
49
|
+
const merged = mergeCrdtStates(state, remote)
|
|
50
|
+
decodeCrdtText(merged) // the plain-string view; both edits survive
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`mergeCrdtStates` is deterministic (`decodeCrdtText(mergeCrdtStates(a, b))`
|
|
54
|
+
equals the same for `(b, a)`), idempotent (re-merging a contained state is a
|
|
55
|
+
no-op), and treats `emptyCrdtState()` as identity. The backend is a parameter on
|
|
56
|
+
every function (defaulting to Yjs), so a later swap to Loro touches no call site.
|
|
57
|
+
|
|
58
|
+
## The `crdtText()` database column
|
|
59
|
+
|
|
60
|
+
`@voltro/database` ships a `crdtText()` **column type** for CRDT-managed fields.
|
|
61
|
+
It needs no special DDL — to the declarative differ it is an ordinary nullable
|
|
62
|
+
`bytes` column (BYTEA / BLOB / LONGBLOB / VARBINARY), so it plans and round-trips
|
|
63
|
+
on every dialect like any other:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { table, id, text, crdtText } from '@voltro/database'
|
|
67
|
+
|
|
68
|
+
export const documents = table('documents', {
|
|
69
|
+
id: id(),
|
|
70
|
+
title: text(),
|
|
71
|
+
body: crdtText(), // CRDT-managed field — stored as the encoded state (bytes)
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The row type is `Uint8Array | null` (the encoded CRDT state); decode it to a
|
|
76
|
+
string with `decodeCrdtText()`, and produce writes with a `crdtText()` handle's
|
|
77
|
+
`.encode()`. The merge is **authoritative and server-side**: the runtime folds an
|
|
78
|
+
incoming update into the stored state with `mergeCrdtStates` on the write path
|
|
79
|
+
before writing, then the reactive engine broadcasts the merged result — which is
|
|
80
|
+
what makes concurrent edits converge without a last-write-wins loser.
|
|
81
|
+
|
|
82
|
+
## The `localFirst` table mixin
|
|
83
|
+
|
|
84
|
+
`localFirst()` **marks a table as local-first** — mirrored to the client, synced
|
|
85
|
+
bi-directionally, and (for its `crdtText()` fields) converged via CRDT merge. It
|
|
86
|
+
adds no column; it is a property the framework reflects on.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { table, id, text, crdtText, localFirst } from '@voltro/database'
|
|
90
|
+
|
|
91
|
+
export const documents = table('documents', {
|
|
92
|
+
id: id(),
|
|
93
|
+
title: text(),
|
|
94
|
+
body: crdtText(),
|
|
95
|
+
}).with(localFirst()) // opt this table into local-first sync + persistence
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Discovery needs no codegen change — a marker mixin rides `.with()` like any
|
|
99
|
+
column type. `isLocalFirst(table)` and `localFirstTables(schema)` are pure
|
|
100
|
+
helpers, and the runtime's schema registry reflects it as `hasLocalFirst(table)`
|
|
101
|
+
(beside `crdtColumns(table)`), which is the signal a client-sync-set builder
|
|
102
|
+
reads. A local-first table may also carry plain columns — those sync
|
|
103
|
+
last-write-wins via the [conflict policy](#conflict-policy-for-non-crdt-fields).
|
|
104
|
+
|
|
105
|
+
## The `SyncClient`: bi-directional wire
|
|
106
|
+
|
|
107
|
+
`createSyncClient({ transport })` maps the offline sync queue onto a transport:
|
|
108
|
+
a local edit merges optimistically and queues; reconnect drains it to the server
|
|
109
|
+
with retry; incoming merged state folds back via the CRDT — and concurrent edits
|
|
110
|
+
converge. It invents **no** transport of its own — the `SyncTransport` is two
|
|
111
|
+
functions an app binds to its **existing** wire:
|
|
112
|
+
|
|
113
|
+
- `push` — deliver a queued CRDT write. Bound to a [`useMutation`](/docs/data/mutations)
|
|
114
|
+
that writes the `crdtText()` column (the server folds it authoritatively).
|
|
115
|
+
- `onRemoteState` — receive merged state. Bound to the reactive
|
|
116
|
+
[`useSubscription`](/docs/data/queries) that already streams the row.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { createSyncClient } from '@voltro/local-first'
|
|
120
|
+
|
|
121
|
+
const sync = createSyncClient({
|
|
122
|
+
transport: {
|
|
123
|
+
kind: 'sync-transport',
|
|
124
|
+
push: (write) => runMutation('documents.setBody', write.payload),
|
|
125
|
+
onRemoteState: (handler) =>
|
|
126
|
+
subscribeRow('documents', (row) =>
|
|
127
|
+
handler({ table: 'documents', id: row.id, column: 'body', state: row.body }),
|
|
128
|
+
),
|
|
129
|
+
},
|
|
130
|
+
adapter: durablePersistence, // optional — survives a reload
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// A local edit: merges locally at once, queues, drains when online.
|
|
134
|
+
sync.enqueue({ table: 'documents', id: 'd1', column: 'body', update: doc.encode() })
|
|
135
|
+
sync.getText({ table: 'documents', id: 'd1', column: 'body' }) // the merged view
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Everything below the two transport functions — the drain loop, retry/attempt
|
|
139
|
+
counting, optimistic local merge, durable persistence — is in the client and
|
|
140
|
+
tested against an in-memory dispatcher that mirrors the server's merge.
|
|
141
|
+
|
|
142
|
+
## Presence & awareness
|
|
143
|
+
|
|
144
|
+
`usePresence(roomId, self, { channel })` publishes this peer's ephemeral state
|
|
145
|
+
(cursor, name, selection) and returns everyone else's — the multiplayer cursors
|
|
146
|
+
of a collaborative editor. Presence is **ephemeral and high-frequency**, so it
|
|
147
|
+
rides a pub/sub channel, never Postgres CDC or a table.
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { usePresence } from '@voltro/local-first/react'
|
|
151
|
+
|
|
152
|
+
function Editor({ documentId, channel }) {
|
|
153
|
+
const { presence, others, setPresence } = usePresence(
|
|
154
|
+
documentId,
|
|
155
|
+
{ cursor: 0, name: 'Ada' },
|
|
156
|
+
{ channel },
|
|
157
|
+
)
|
|
158
|
+
// render `others` as remote cursors; update on selection change:
|
|
159
|
+
const onSelect = (cursor: number) => setPresence({ cursor, name: 'Ada' })
|
|
160
|
+
return <Cursors others={others} />
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The `PresenceChannel` is the **same dumb string-payload pub/sub shape** as the
|
|
165
|
+
framework broker (`@voltro/plugin-broadcast`), so a runtime binding forwards
|
|
166
|
+
straight onto the app's provisioned broker — in-memory locally,
|
|
167
|
+
Redis/NATS at scale (both already shipped). `createInMemoryPresenceChannel()` is
|
|
168
|
+
the local/test transport. Join/leave, announce-back discovery, cursor
|
|
169
|
+
propagation, and TTL expiry live in the pure `createPresenceRoom` the hook wraps.
|
|
170
|
+
|
|
171
|
+
## The offline sync queue
|
|
172
|
+
|
|
173
|
+
`useSyncQueue()` is a reactive view over a **pure, tested reducer**: writes made
|
|
174
|
+
offline are queued, and a transport drains them when connectivity returns. Use it
|
|
175
|
+
directly for fine-grained UI, or let the [`SyncClient`](#the-syncclient-bi-directional-wire)
|
|
176
|
+
drive it for you.
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
import { useSyncQueue, useConnectionStatus } from '@voltro/local-first/react'
|
|
180
|
+
|
|
181
|
+
function SaveIndicator() {
|
|
182
|
+
const queue = useSyncQueue()
|
|
183
|
+
const { status } = useConnectionStatus(queue.outstanding)
|
|
184
|
+
// `outstanding` counts pending + in-flight (0 means synced).
|
|
185
|
+
return status === 'synced' ? null : <span>Saving… ({status})</span>
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Connection status
|
|
190
|
+
|
|
191
|
+
`useConnectionStatus(outstanding)` observes the connection lifecycle for the
|
|
192
|
+
local-first layer — network up/down plus reconnect confirmation — and folds the
|
|
193
|
+
number of unsynced writes into a display `status` of `offline | syncing |
|
|
194
|
+
synced`, so `synced` means online **and** drained. It feeds the machine the two
|
|
195
|
+
signals a browser can observe (`navigator.onLine` + the `online`/`offline`
|
|
196
|
+
events); confirmed round-trips (`confirm()`/`confirmFailed()`) are left to the
|
|
197
|
+
caller, so the hook never invents a server ping.
|
|
198
|
+
|
|
199
|
+
> This is the local-first connection machine, distinct from `@voltro/client`'s
|
|
200
|
+
> RPC-error-derived [`useConnectionStatus`](/docs/ui/client-utilities/use-connection-status) —
|
|
201
|
+
> a different package with a different signal source.
|
|
202
|
+
|
|
203
|
+
## Durable persistence
|
|
204
|
+
|
|
205
|
+
Local CRDT state and the offline queue should survive a reload. Everything above
|
|
206
|
+
storage speaks the `PersistenceAdapter` contract, so the backing swaps freely:
|
|
207
|
+
|
|
208
|
+
- `createInMemoryPersistence()` — ephemeral (lost on reload); the test/default.
|
|
209
|
+
- `createIndexedDbPersistence()` — **durable**, over the browser's own
|
|
210
|
+
IndexedDB. No WASM, no added dependency; the IDB implementation is injectable,
|
|
211
|
+
so it is tested against a fake backend that survives a reopen.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { createIndexedDbPersistence } from '@voltro/local-first'
|
|
215
|
+
|
|
216
|
+
const adapter = await createIndexedDbPersistence({ databaseName: 'my-app' })
|
|
217
|
+
const sync = createSyncClient({ transport, adapter }) // state now survives reload
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Conflict policy for non-CRDT fields
|
|
221
|
+
|
|
222
|
+
CRDT fields resolve themselves — the merge **is** the resolver. A plain scalar
|
|
223
|
+
like `title` needs a policy. `conflictPolicy()` declares one per field; the
|
|
224
|
+
default everywhere is last-write-wins, and any field you do not name falls back
|
|
225
|
+
to it, so a policy never has to enumerate every column.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { conflictPolicy } from '@voltro/local-first'
|
|
229
|
+
|
|
230
|
+
const policy = conflictPolicy({
|
|
231
|
+
title: 'lastWriteWins',
|
|
232
|
+
// A custom resolver MUST converge: both peers pick the same winner.
|
|
233
|
+
tags: (local, remote) => (remote.updatedAt >= local.updatedAt ? remote.value : local.value),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
policy.resolveRecord(
|
|
237
|
+
{ title: { value: 'Draft', updatedAt: 1 } },
|
|
238
|
+
{ title: { value: 'Final', updatedAt: 2 } },
|
|
239
|
+
) // → { title: 'Final' }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The one property that matters is **convergence**: `lastWriteWins` breaks an
|
|
243
|
+
exact `updatedAt` tie on a stable, symmetric key (writer id, then the value's
|
|
244
|
+
string form), so two peers agree regardless of which side each calls "local".
|
|
245
|
+
|
|
246
|
+
## What's shipped vs. a runtime seam
|
|
247
|
+
|
|
248
|
+
The framework code for local-first is built and tested end to end against
|
|
249
|
+
in-memory transports. What remains is not un-built framework — it is the thin
|
|
250
|
+
binding to **provisioned infrastructure**, sitting behind interfaces the tested
|
|
251
|
+
code already speaks:
|
|
252
|
+
|
|
253
|
+
| Runtime seam | What it binds | Why it's a binding, not code |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| **Sync transport → a running app** | The `SyncClient`'s `push`/`onRemoteState` to one app's `useMutation` + `useSubscription`. | The two functions are app-specific glue; the drain loop, merge, retry, and persistence all ship and are tested. |
|
|
256
|
+
| **Presence channel → a broker at scale** | The `PresenceChannel` to a provisioned Redis/NATS broker. | It's a network hop over an already-shipped broker; the awareness logic ships and is tested over the in-memory channel. |
|
|
257
|
+
| **wa-sqlite / Turso adapter** *(optional)* | A SQL durable adapter for cross-tab queries, behind `PersistenceAdapter`. | IndexedDB is the durable default today; a SQL backing is a sibling factory, nothing above it changes. |
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
<!-- source: en/react-native/overview.md -->
|
|
264
|
+
## React Native
|
|
265
|
+
|
|
266
|
+
_"@voltro/react-native — the credential-free mobile plumbing: registerDevice + the _voltro_devices table, defineDeepLink + its matcher, useBackgroundSync, and offline-first client defaults + connection status."_
|
|
267
|
+
|
|
268
|
+
The existing React-client bindings already run in React Native — the runtime has
|
|
269
|
+
no DOM dependency. `@voltro/react-native` adds the mobile-specific plumbing **on
|
|
270
|
+
top** of that, limited to the parts that need **no per-tenant credentials and no
|
|
271
|
+
native runtime**: device registration, background-sync scheduling, offline-first
|
|
272
|
+
defaults, a connection-status surface, and the deep-link declaration shape.
|
|
273
|
+
|
|
274
|
+
The package **root is RN-safe** — no `node:*`, no `@voltro/database`, and React
|
|
275
|
+
is reached only through the hooks (an optional peer). The `_voltro_devices` table
|
|
276
|
+
declaration is server-side and lives at `@voltro/react-native/schema`.
|
|
277
|
+
|
|
278
|
+
## Device registration
|
|
279
|
+
|
|
280
|
+
A device is registered **after** the OS issues its push token (APNs on iOS, FCM
|
|
281
|
+
on Android, Web Push on web). `registerDevice()` normalises a raw input into a
|
|
282
|
+
row and upserts it through whatever transport the app already has — a generated
|
|
283
|
+
mutation caller or a plain `fetch` — so the package stays free of transport
|
|
284
|
+
coupling.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { registerDevice } from '@voltro/react-native'
|
|
288
|
+
|
|
289
|
+
// `userId`/`tenantId` are stamped SERVER-side from the authenticated request —
|
|
290
|
+
// never trusted from the client. `locale`/`timezone` default from the device.
|
|
291
|
+
await registerDevice(
|
|
292
|
+
(row) => api.mutate('registerDevice', row),
|
|
293
|
+
{ deviceToken, platform: 'ios' },
|
|
294
|
+
)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Registration is idempotent: the row is stored in `_voltro_devices` with a unique
|
|
298
|
+
key of `(platform, token)`, so re-registering the same token updates the row in
|
|
299
|
+
place instead of inserting a duplicate. A **rotated** token is a new
|
|
300
|
+
registration; reaping the stale one is the sender adapter's job (a seam), not the
|
|
301
|
+
client's.
|
|
302
|
+
|
|
303
|
+
### The `_voltro_devices` table
|
|
304
|
+
|
|
305
|
+
The table declaration is a server-side entry — it imports the `@voltro/database`
|
|
306
|
+
column DSL, so it is deliberately off the RN-safe root. Contribute it to your
|
|
307
|
+
schema and it migrates like any framework table (the `_voltro_*` prefix rides the
|
|
308
|
+
declarative differ on `voltro dev` / `voltro db apply`, on every dialect):
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// schema/devices.ts — add the framework device table to your app's schema.
|
|
312
|
+
export { devicesTable } from '@voltro/react-native/schema'
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
It carries tenant + user scope, `platform`, `token`, `locale`, `timezone`,
|
|
316
|
+
optional `appVersion`/`metadata`, and a `lastSeenAt` rotation clock. It is unique
|
|
317
|
+
on `(platform, token)` and indexed on `userId` — the hot read path for fanning a
|
|
318
|
+
push out to every device of a user.
|
|
319
|
+
|
|
320
|
+
## Deep links: `defineDeepLink` + the matcher
|
|
321
|
+
|
|
322
|
+
`defineDeepLink({ pattern, handler })` is the descriptor a deep-link file
|
|
323
|
+
declares; its pure matcher turns `/orders/:id` + `/orders/42` into `{ id: '42' }`.
|
|
324
|
+
The params are inferred from the `:name` segments, so `handler` type-checks
|
|
325
|
+
against exactly the params the pattern declares.
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { defineDeepLink } from '@voltro/react-native'
|
|
329
|
+
|
|
330
|
+
export default defineDeepLink({
|
|
331
|
+
pattern: '/orders/:id',
|
|
332
|
+
handler: ({ id }) => navigateTo(`/orders/${id}`),
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The matcher is pure — no navigation, no side effects — and normalises scheme +
|
|
337
|
+
host away, so a universal link, an App Link, and a custom-scheme URL all match
|
|
338
|
+
the same path-only pattern:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { matchDeepLink, matchFirstDeepLink } from '@voltro/react-native'
|
|
342
|
+
import orderLink from './orders.deepLink'
|
|
343
|
+
|
|
344
|
+
matchDeepLink('/orders/:id', '/orders/42') // → { id: '42' }
|
|
345
|
+
matchDeepLink('/orders/:id', '/orders/42/edit') // → null
|
|
346
|
+
|
|
347
|
+
// Until `*.deepLink.ts` file discovery lands, register links by hand —
|
|
348
|
+
// declaration order wins, so list more-specific patterns first.
|
|
349
|
+
const hit = matchFirstDeepLink([orderLink], 'myapp://orders/42')
|
|
350
|
+
hit?.params.id // '42'
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
> **Seam — file discovery.** Wiring `*.deepLink.ts` into codegen (so the router
|
|
354
|
+
> auto-collects every declared link) is one additive file, landing after the
|
|
355
|
+
> current release settles. The descriptor shape above is **final**, so register
|
|
356
|
+
> links via `matchFirstDeepLink()` until then.
|
|
357
|
+
|
|
358
|
+
## Background sync
|
|
359
|
+
|
|
360
|
+
`useBackgroundSync(onSync, options)` owns three triggers — an interval timer, a
|
|
361
|
+
"returned to foreground" subscription, and a manual `sync()` — over a pure
|
|
362
|
+
`shouldSync` policy (single-flight, foreground-gated, interval-gated). The OS
|
|
363
|
+
background-fetch **registration** itself stays the app's; this hook is only the
|
|
364
|
+
interval/foreground state machine.
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
import { useBackgroundSync } from '@voltro/react-native'
|
|
368
|
+
|
|
369
|
+
function SyncIndicator() {
|
|
370
|
+
const { status, lastSyncAt, sync } = useBackgroundSync(
|
|
371
|
+
() => api.refetchAll(),
|
|
372
|
+
{ intervalMs: 60_000, syncOnForeground: true },
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return <button onClick={sync}>Sync ({status})</button>
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`onSync` may be async — a rejection is captured into `status: 'error'` +
|
|
380
|
+
`lastError`, a resolution into `status: 'success'` + `lastSyncAt`.
|
|
381
|
+
|
|
382
|
+
## Offline-first defaults + connection status
|
|
383
|
+
|
|
384
|
+
`offlineFirstDefaults` is the mobile posture as a value you spread into your
|
|
385
|
+
client config: local-first ON, optimistic mutations, sync-on-foreground, a
|
|
386
|
+
5-minute cadence, and a retry backoff schedule. `useMobileConnectionStatus()`
|
|
387
|
+
surfaces a `connected | degraded | offline` status — `offline` from
|
|
388
|
+
`navigator.onLine`, `degraded` from failures the app reports.
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
import { offlineFirstDefaults, useMobileConnectionStatus } from '@voltro/react-native'
|
|
392
|
+
|
|
393
|
+
// Spread the mobile posture into your client config.
|
|
394
|
+
const config = { ...offlineFirstDefaults, url }
|
|
395
|
+
|
|
396
|
+
function ConnectionPill() {
|
|
397
|
+
const { status, reportFailure, reportSuccess } = useMobileConnectionStatus()
|
|
398
|
+
return <span data-status={status}>{status}</span>
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## What's shipped vs. a seam
|
|
403
|
+
|
|
404
|
+
This package ships the credential-free plumbing above. The parts that need
|
|
405
|
+
external credentials or a native runtime are flagged as **deliberate seams** —
|
|
406
|
+
not built here:
|
|
407
|
+
|
|
408
|
+
| Seam | Why it is not in this package |
|
|
409
|
+
|---|---|
|
|
410
|
+
| **APNs / FCM sender adapters** | Need per-tenant Apple Developer / Firebase credentials — genuinely external, managed via provider provisioning. Registration stores the token; sending to it is the seam. |
|
|
411
|
+
| **Native module bindings** (camera, biometrics, secure token storage) | Need a native runtime this TS package cannot provide. |
|
|
412
|
+
| **Swift / Kotlin SDK generators** | An open product decision — not built until demand justifies the maintenance. |
|
|
413
|
+
| **Universal-links / App-Links file automation** (`apple-app-site-association`, `assetlinks.json`) | A deployment-layer concern, not a client primitive. |
|
|
414
|
+
| **`*.deepLink.ts` codegen discovery** | One additive file after the release settles; the descriptor shape is final, so register links via `matchFirstDeepLink()` today. |
|