@voltro/cli 0.28.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 +278 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
- 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-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
- package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
- package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
- 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/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +2 -2
- 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/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/whats-new.md +162 -181
- 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-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- 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-ZTn-dPFa.js +0 -1425
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# What's new in 0.
|
|
1
|
+
# What's new in 0.29.0
|
|
2
2
|
|
|
3
3
|
Read this FIRST when a task touches an area you have not worked in recently.
|
|
4
4
|
It is the cheapest way to notice that the framework grew the thing you were
|
|
@@ -7,295 +7,276 @@ workaround for something that shipped two versions ago.
|
|
|
7
7
|
|
|
8
8
|
BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
9
9
|
|
|
10
|
-
###
|
|
11
|
-
|
|
12
|
-
- **@voltro/plugin-audit, @voltro/plugin-versioning** — `scope` — the app's own scoping dimension on `_voltro_audit_log` **and** `_voltro_row_history`, supplied by a `resolveScope` option on each plugin.
|
|
10
|
+
### ⚠ BREAKING
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
- **@voltro/web** — **`useLoaderData()` throws where no `loader` is declared, and `useOptionalLoaderData()` is the way to read where one may be absent.**
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
`useLoaderData()` was `useContext(LoaderDataContext) as LoaderData<T>` — a cast over a context whose default was `undefined`. At a level with no `loader`, `const { project } = useLoaderData<Data>()` died at `Cannot destructure property 'project' of undefined`: a message naming the property rather than the mistake, and under `renderMode: 'ssr'` a throw that fails the entire server render instead of degrading. Reported by a consumer who spent a cycle on it.
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
**What this is NOT: a `| undefined` return type.** That was the obvious fix and it is wrong. The router never renders a page that declares a `loader` without its data — a settled loader commits its data and the displayed route together, a pending one renders the `Pending` skeleton (or keeps the previous page), and one that threw renders the error subtree; three separate branches. Widening the type would have taxed every correct call site to model a state the router already prevents.
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
codemod: none
|
|
23
|
-
- **@voltro/plugin-notifications** — `notificationsPlugin({ resolveSubjectId })` — the app names its own addressing unit.
|
|
18
|
+
So the return type is unchanged and the absence becomes loud instead:
|
|
24
19
|
|
|
25
|
-
|
|
20
|
+
- a level with **no `loader`** throws, naming the cause and pointing at `useOptionalLoaderData()`; - **`useOptionalLoaderData()`** returns `undefined` there — for the one legitimate case, a component genuinely mounted both under routes that declare a loader and routes that do not; - a loader that legitimately resolves to `undefined` does **not** throw. Declaring a loader and returning nothing is a choice; having no loader is not.
|
|
26
21
|
|
|
27
|
-
|
|
22
|
+
**An empty RESULT is not an absence.** A loader returning `{ items: [] }` returns exactly that through both hooks. `undefined` never means "the query found nothing" — only "there is no loader at this level". The wire already made this distinction (`pageLoaderRan`, documented as "not derivable from loaderData"); the hooks now honour it too.
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
**`useEvent` already returns what the same report asked for.** `{ status, missed, lastMiss }`, where `status === 'live'` is the connected flag — the ask was for discoverability, not an API, so the docs now name the case that motivated it: a wall display nobody is standing at keeps rendering the last thing it received, and from across the room stale and current look identical.
|
|
32
|
-
|
|
33
|
-
codemod: none
|
|
34
|
-
- **@voltro/plugin-presence** — The presence tracker gets a perf suite — the last of the four realtime surfaces without pinned numbers — and the four are now documented side by side.
|
|
24
|
+
Internally the provider carries a `NO_LOADER_DATA` marker instead of `undefined`. It is still a provider, deliberately: dropping it for loader-less levels would let a page fall through to its layout's data and silently render a neighbour's value — worse than the crash it replaces, because nothing would report it. Server and client set the marker from the same fact (`descriptor.loader` / `segment.loader`), so a component cannot render on one side and throw on the other.
|
|
35
25
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Measured across all four, each asserted by a test:
|
|
39
|
-
|
|
40
|
-
| primitive | operation | cost | scales with | | --- | --- | --- | --- | | Events | `ctx.events.publish` | 4.3 µs (~232 k/s) | nothing | | Events | delivery to a subscriber | 0.027 µs | subscribers, cheaply | | Presence | a heartbeat | 0.16 µs | nothing | | Presence | a roster read, 10 k members | 547 µs | the ROOM | | Records | a live-query re-diff, 5 000 rows | 3 062 µs | the RESULT SET | | Broadcast | cross-replica over real Redis | p50 1.1 ms · p99 11.2 ms | the network |
|
|
41
|
-
|
|
42
|
-
**The comparison is what was missing, not the numbers.** Publishing an event costs about a thousandth of re-diffing a large live query, and that ratio is what should decide between them — a 60 Hz value belongs in an event, because the same value written to a table wakes every subscriber of every query reading it and each pays the full walk.
|
|
43
|
-
|
|
44
|
-
Two of the four are flat and two are not. That is the property the tests assert: the per-member cost of a roster read must not grow with the room, and the per-row cost of a diff must not grow with the result set. Either one growing is the difference between expensive and unusable.
|
|
45
|
-
|
|
46
|
-
codemod: none
|
|
47
|
-
- **@voltro/cli** — A capability matrix for the realtime surface — fifteen things people build, each mapped to the primitive that carries it, each asserted by a test.
|
|
26
|
+
Covered by `loaderDataAbsence.test.tsx` (both render paths, the shield, the empty-result and legitimately-undefined cases), red-verified by removing the marker.
|
|
48
27
|
|
|
49
|
-
|
|
28
|
+
**`voltro update` carries you across this** — codemod `0.29.0/01_loader-data-absence`.
|
|
50
29
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
It asserts EXPORTS rather than behaviour on purpose. Behaviour is what the other suites are for, and duplicating them here would make this a slower copy of them. What it catches is the gap between "we support that" and "the thing that supports it still exists".
|
|
54
|
-
|
|
55
|
-
The same table is in the docs, with the three capabilities people usually reach for wrongly called out: a value changing many times a second is an EVENT and not a row (writing it to a table wakes every subscriber of every query reading that table, each paying a full re-diff); "who is online" is presence rather than a table; and "did anything get lost" has a computed answer in `missed`, so nobody needs to build a heartbeat of their own to find out.
|
|
56
|
-
|
|
57
|
-
codemod: none
|
|
58
|
-
- **@voltro/cli** — The ten hard questions a realtime system is judged on, with this framework's answer and — enforced by a test — the proof behind each.
|
|
59
|
-
|
|
60
|
-
`realtimeProperties.test.ts` fails if a row's proof disappears: a property may not be CLAIMED without something in the repository that demonstrates it. Red-verified by re-pointing one row at a test that does not exist.
|
|
61
|
-
|
|
62
|
-
The questions, because they are the deliverable rather than the mechanism: is a missed delivery reported or silently dropped; can a late arrival tell "nothing happened" from "I was not listening"; is a SUBSCRIPTION authorized or only the connection; does a subscription outlive its credential; does the link heal itself after an outage; does a degraded network lose messages or only slow them; does fan-out cost grow with subscribers; are channels typed or strings; is a declared event nobody publishes reported; is cross-replica traffic separated per app by default.
|
|
30
|
+
### Added
|
|
63
31
|
|
|
64
|
-
|
|
32
|
+
- **@voltro/runtime, @voltro/cli** — aggregate-derivation (sharpened): the mutation/action interceptor `meta` now carries the descriptor's DECLARED write-target table names (`meta.target`) for an audit/derivation consumer — purely additive, omitted when no target is declared. And `voltro doctor` gains a junction-FK finding: it flags a link/junction table whose FK wiring is suspect (composite-PK member unwired, an unwired id beside a real `reference()` sibling, pure link-only table), reading real declared column types (not regex) with a tight non-FK-id exclusion so a lone foreign id on a normal entity stays silent. The FK-walk DSL + `voltro audit map` are deferred (single-consumer). apiSurface compatible — additive exports + type-alias renumbering only. codemod: none.
|
|
33
|
+
- **@voltro/plugin-billing** — Stripe Checkout now collects a customer's VAT / tax ID (`tax_id_collection`), and reuses an existing customer's captured name + address so the tax ID attaches correctly. Additive; `automatic_tax` was already on.
|
|
34
|
+
- **@voltro/database, @voltro/protocol, @voltro/runtime** — New `rule()` primitive: `table(...).rule(name, predicate, { severity? })` declares a cross-table transactional invariant evaluated INSIDE the mutation transaction (predicate reads share the write's MVCC snapshot via the dialect-neutral query layer — correct on all four dialects, no per-dialect code). A violation rolls the transaction back and fails with the typed, wire-preserved `BusinessRuleViolation` (auto-merged into every mutation's error union at `mutationToRpc`, like `ScopeError`); `severity: 'warning'` logs + commits instead. codemod: none.
|
|
35
|
+
- **@voltro/cli** — Three CLI commands: `voltro typecheck` (runs `tsc --noEmit` using the APP's own TypeScript, resolved from the app's node_modules), `voltro info` (CLI/node/pm/dialect + every installed `@voltro/*` version, flags lockstep skew with a non-zero exit), and `voltro new <query|mutation|action|workflow|page> <name>` (scaffolds the correct file convention incl. the descriptor/executor split; refuses overwrite without `--force`). codemod: none.
|
|
36
|
+
- **@voltro/cli** — DNS-rebinding Host guard on the `voltro dev` inspect surface (the API dev server binds 0.0.0.0 and had no Host validation, unlike the web dev server's vite `allowedHosts`). Allows loopback names + any IP literal (an IP can't be DNS-rebound, so phone-on-LAN testing keeps working) + an operator `VOLTRO_INSPECT_ALLOWED_HOSTS` allowlist; any other Host domain → 403. Dev-only by design (prod is token-gated + served on a public hostname). codemod: none.
|
|
37
|
+
- **@voltro/datetime** — New `@voltro/datetime` package (Phase 1): UTC-storage + timezone-aware helpers on the TC39 Temporal standard, plus a request-scoped timezone-context seam (`@voltro/datetime/context`). The `.` entry is browser-safe (no `effect`, no `node:*`); `effect` is an optional peer for the context seam. `interval()`/`rrule()`/schema DSL types are deferred to later phases.
|
|
38
|
+
- **@voltro/runtime** — `defineExpectation()` — reactive data-quality contracts as standing reactions. An expectation over a table (`freshness`/`nullRate`/`rowCount`/`valueBounds`) is maintained incrementally from `store.onChange` CDC deltas by the same IVM engine that backs `defineAggregate({ incremental })` (O(1) per write, no re-query); it tips `holding`↔`violated` with the provenance (traceId/subject/procedure) of the write that caused it, observable via `ExpectationRegistry`. `freshness` also re-compares its IVM-maintained max against the moving clock (so "writes stopped" is detectable — a metric re-comparison, not a data poller). `*.expectation.ts` file discovery wires in the CLI. codemod: none.
|
|
65
39
|
|
|
66
|
-
|
|
67
|
-
- **@voltro/
|
|
40
|
+
(apiSurface: compatible — the runtime golden churn this session is additions plus api-extractor renumbering its internal `Row_N` dedup alias; no public symbol was removed or resignatured, so no consumer breaks.)
|
|
41
|
+
- **@voltro/runtime** — `defineExperiment()` — online A/B / holdout experiments as live IVM aggregates. Per-variant metrics (count/sum/avg/conversionRate) are maintained incrementally from a table's CDC by grouping rows onto a synthetic variant column through one `AggregateMaintainer` (real-time lift/diff vs a baseline, no batch pipeline); assignment is a salted FNV-1a hash → `[0,1)` (client-reproducible, no `node:crypto`), holdout carved off the top so treatments don't perturb it. Observable via `ExperimentRegistry`/`useExperiment`. Correctness pinned by a brute-force oracle (incremental == full recompute) over insert/update/delete for all four metrics. `*.experiment.ts` file discovery wires in the CLI. codemod: none.
|
|
42
|
+
- **@voltro/cli** — `voltro doctor` gains two findings over declared events. **Delivery-semantics visibility** (informational): it now NAMES each declared event's delivery mode (`each` vs `latest`) — the mode decides what a MISSING message means (`each` counts a drop as a loss and tells the subscriber; `latest` supersedes and says nothing), and it was invisible after the fact everywhere except the devtools panel. **A scale WARN** for two declared-but-won't-scale shapes: a routing key with 3+ fields (every field fragments the subscriber set — distinct routes are the product of the fields' value spaces, so a payload discriminator smuggled into the key multiplies routes for nothing), and a `webhook:` block on a per-frame-looking event (`player.moved`, `cursor.moved`, `*.frameRendered` — every publish becomes N HTTP deliveries per second per target, and the plugin DEFERS the excess as pending rows rather than failing, so the symptom is a growing table). Both read the REAL declared descriptors — the routing-key field count comes from the same top-level schema-property reader the runtime validation uses, so it cannot disagree with the key the event routes on. Advisory, never blocking (same ladder as the orphan audit); surfaced in `--json` as `eventDelivery`. codemod: none.
|
|
43
|
+
- **@voltro/runtime, @voltro/cli** — Reactive-finops now EMITS cost events on real reactive work — the deferred second half of `attachFinops`. The Dispatcher gained a `recordCost?: (e: CostEvent) => void` sink (sibling to `recordDelivery`); each time a change re-runs an affected subscription and pushes it a delta, it records one `{ unit: 'recompute', amount: 1 }` event attributed to the subscription's tenant + traceId (both the row-set and computed-query delivery paths). Both boot paths (`voltro dev`, `voltro serve`) thread the finops runner's `record` into the dispatcher as `recordCost` — ONLY when `*.budget.ts` cost budgets were discovered, so an app with none allocates no `CostEvent` on the reactive hot path. Live attribution is observable via `CostRegistry`. codemod: none.
|
|
68
44
|
|
|
69
|
-
|
|
45
|
+
(apiSurface: compatible — one additive optional field on `DispatcherDependencies`; no public symbol removed or resignatured.)
|
|
46
|
+
- **@voltro/database, @voltro/runtime, @voltro/local-first** — local-first (deepened toward a working vertical): `crdtText()` is now a real database column type (`@voltro/database`). It stores an encoded CRDT state as `bytes` + a pure `crdtManaged` marker, so the declarative differ treats it as an ordinary nullable `bytes` column — no special DDL, and it round-trips through a plan on every dialect with zero churn (`crdtColumn.test.ts`). The authoritative server-side merge is wired into the runtime write path: the MutationStore folds an incoming encoded update into the stored state with `mergeCrdtStates` (`@voltro/local-first`) before writing, so two concurrent clients converge, and the reactive engine broadcasts the merged result (`crdtMerge.test.ts`, in-memory store, order-independent convergence). `@voltro/local-first` also gains a client persistence CONTRACT — `PersistenceAdapter` + `createInMemoryPersistence()` — and `loadPersistedSyncQueue`, which drains the offline sync queue into it so writes survive a reload. The browser-safe merge primitives stay separate from any `database` handle (the column type is server-side in `@voltro/database`; the merge core is the pure `@voltro/local-first` `.` entry).
|
|
70
47
|
|
|
71
|
-
|
|
48
|
+
Still seamed (documented, not built): the durable persistence backing (WASM-SQLite / Turso), the bi-directional sync WIRE transport, the presence channel (Redis/NATS), and the higher-level `localFirst` table mixin + client codegen discovery. codemod: none (purely additive — no user-authored code is affected; a `crdtText()` column is opt-in).
|
|
49
|
+
- **@voltro/local-first** — New opt-in `@voltro/local-first` package (Phase 4, first slice): CRDT + local-first primitives. `crdtText()` is a Yjs-backed CRDT text field behind our own `CrdtBackend` abstraction (the swap point for Loro later — nothing above the backend file imports `yjs`), with the deterministic merge primitive `mergeCrdtStates(a, b)` at its core (concurrent inserts converge order-independently, idempotent re-merge, empty-state identity). Ships the offline sync-queue as a pure reducer (`syncQueueReducer` — enqueue offline, FIFO drain on reconnect, requeue-on-fail with attempt counts), the connection-lifecycle state machine (`connectionReducer` + `deriveSyncStatus`), and `conflictPolicy()` / `lastWriteWins` for non-CRDT fields (deterministic, convergent tiebreak). React wrappers `useSyncQueue()` / `useConnectionStatus()` live under the `./react` subpath (`react` is an optional peer, kept off the pure `.` path). The `.` entry is browser-safe (no `node:*`, no `effect`). Client SQLite persistence (Turso/WASM), the bi-directional sync wire, the presence channel, and the `crdtText()` schema-DSL / `localFirst` mixin codegen wiring are declared as type-level seams (`./seams`) — deferred, not faked.
|
|
50
|
+
- **@voltro/local-first, @voltro/database, @voltro/runtime** — local-first (the vertical, integrated with existing framework infra): three of the four seams from the first slice are now BUILT against real, tested wiring, and the `localFirst` table mixin ships.
|
|
72
51
|
|
|
73
|
-
|
|
52
|
+
**Bi-directional sync wire (`@voltro/local-first`).** `createSyncClient({ transport })` maps the pure sync-queue reducer onto a `SyncTransport` (two functions an app binds to its EXISTING primitives — `push` to the client's mutation caller writing the `crdtText()` column, `onRemoteState` to the reactive subscription streaming the row). A local edit merges optimistically + queues; reconnect drains to `push` with retry/attempt-bump; incoming merged state folds back via the CRDT. Tested against an in-memory dispatcher that mirrors the runtime's authoritative merge — offline edit drains on reconnect, a remote edit arrives and merges, two concurrent offline edits converge (`syncClient.test.ts`).
|
|
74
53
|
|
|
75
|
-
|
|
54
|
+
**Presence / awareness (`@voltro/local-first` + `./react`).** `usePresence(roomId, { cursor, name }, { channel })` returns `{ presence, others, setPresence }` over a `PresenceChannel` — the SAME dumb string-payload pub/sub shape as the framework's `BroadcastProvider`, so a runtime binding forwards straight onto the app's broker (in-memory locally; Redis/NATS at scale, already shipped). Join/leave, announce-back discovery, cursor propagation, and TTL expiry live in the pure `createPresenceRoom`; `createInMemoryPresenceChannel` is the test/local transport. Tested pure (`room.test.ts`) and in a real DOM (`usePresence.test.tsx`): two peers see each other, updates propagate, a leaver drops, a silent peer expires.
|
|
76
55
|
|
|
77
|
-
**
|
|
56
|
+
**Durable persistence (`@voltro/local-first`).** `createIndexedDbPersistence()` is a durable `PersistenceAdapter` over IndexedDB — no WASM, no added dependency. The IDB implementation is injected, so it is tested against a fake backend that survives a reopen — the durability the in-memory adapter lacks (`indexedDb.test.ts`).
|
|
78
57
|
|
|
79
|
-
|
|
58
|
+
**`localFirst()` table mixin (`@voltro/database` + `@voltro/runtime`).** A marker mixin (adds no column) that opts a table into local-first sync + persistence; `isLocalFirst()` / `localFirstTables()` are pure discovery helpers, and the runtime SchemaRegistry reflects it as `hasLocalFirst(table)` (beside the existing `crdtColumns(table)`) — the discovery surface, with NO codegen change (a marker mixin rides `.with()` like any column type). The registry id is re-declared and pinned to the mixin by `localFirstMixinId.test.ts`, exactly like tenant/expires.
|
|
80
59
|
|
|
81
|
-
|
|
60
|
+
Browser/server boundary preserved: the sync client, presence, and persistence are browser-safe (no `node:*`, no `@voltro/database`, no runtime); the authoritative CRDT merge stays server-side in `@voltro/runtime`. codemod: none — purely additive, all opt-in.
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
- **@voltro/
|
|
62
|
+
What GENUINELY remains a runtime seam (infra + a thin app binding, not un-built framework code): a `SyncTransport` bound to a specific running app's mutation/subscription, a `PresenceChannel` bound to a provisioned Redis/NATS broker at scale, and (optional) a wa-sqlite/Turso durable adapter for cross-tab SQL. All three sit behind interfaces the tested code already speaks.
|
|
63
|
+
- **@voltro/cli** — Native mobile SDK generators: `voltro build api --target swift` emits a Swift Package and `voltro build api --target kotlin` a Kotlin Multiplatform module, generated FROM the app's capability manifest (the same procedure descriptors + JSON Schemas the TypeScript client codegen reads — no source is re-parsed). Each package ships type-safe models (Codable structs / `@Serializable` data classes + enums), a one-shot HTTP client (query/mutation/action), a WebSocket subscription client (streams), an auth/tenant-context helper, and a push-registration stub. Faithful type mapping (string/number/boolean/array/nested-object/enum, optional → Swift `Optional` / Kotlin nullable). Flags: `--target`, `--out`, `--name`, `--kotlin-package`; default output `<appDir>/sdk/<target>`. The generated SOURCE is golden-string tested; cross-language COMPILE (swiftc / Gradle) and the native runtime (native modules, the APNs/FCM push sender, OTA build pipeline) are out of scope. codemod: none.
|
|
64
|
+
- **@voltro/cli, @voltro/web** — Page `export const preload` convention: a page declares `ReadonlyArray<string | { tag; input?(params) }>` and the SSR render (dev + start; inert for SSG) runs each subscription server-side and seeds it, so a `usePreloadedSubscription` on that page renders with data on first paint instead of re-fetching on mount. Read directly from the page module in the render loops (a purely server-side directive; the client never needs it). codemod: none.
|
|
65
|
+
- **@voltro/client, @voltro/web** — SSR-preloaded subscriptions: `usePreloadedSubscription(api, tag, input)` (`@voltro/client`) — `useSubscription` that reads its FIRST value from the SSR hydration payload instead of flashing an empty state and re-fetching on mount, then upgrades to the live WebSocket stream. The value is seeded server-side during a render (a loader, a layout loader) with `seedPreloadedSubscription(api, tag, input, value)`, keyed by the SAME `stableKey([tag, input])` the SubscriptionCache uses, and carried into the hydration payload alongside the store seeds (mirroring their request-scoped-bag + resolver inversion; the `node:async_hooks` scoping stays in `@voltro/web/ssr`). Because the value flows through `useSubscription`'s `initialSnapshot` render branch — read identically on the server and the client hydration render — there is no hydration mismatch. When no seed exists for the key (a client-side SPA navigation the server never rendered), it behaves exactly like `useSubscription`. codemod: none.
|
|
66
|
+
- **@voltro/react-native** — **New package `@voltro/react-native` — the credential-free mobile primitives.** The React client already runs in React Native (the runtime has no DOM dependency); this adds the mobile plumbing on top of it that needs no per-tenant Apple/Firebase credentials and no native runtime.
|
|
85
67
|
|
|
86
|
-
`
|
|
68
|
+
- **Device registration** — a `_voltro_devices` table (`@voltro/react-native/schema`: tenant + user scope, platform/token/locale/timezone, `(platform, token)` unique upsert target, per-user fan-out index) plus a `registerDevice(upsert, input)` client function. `resolveDeviceRegistration` normalises locale/timezone (input → env → ambient → `en`/`UTC` floor) into the row; `userId`/`tenantId` are the server's to stamp, never trusted from the client. - **`useBackgroundSync()`** — the interval / foreground-trigger state machine. The OS background-fetch registration stays the app's; the hook is a thin wrapper over a pure reducer (`backgroundSyncReducer` + `shouldSync`: single-flight, foreground-gated, interval-gated, forced triggers bypass only the interval). - **Offline-first defaults** (`offlineFirstDefaults`: local-first opt-out on mobile, sync on foreground + interval, status surfaced) and a standalone `useMobileConnectionStatus` (`connected | degraded | offline`) — deliberately not coupled to a transport or the in-flight local-first package. - **`defineDeepLink({ pattern, handler })`** descriptor + a pure matcher (`matchDeepLink('/orders/:id', '/orders/42')` → `{ id: '42' }`; segment-exact, scheme/host/query/trailing-slash normalised; params typed from the pattern literal).
|
|
87
69
|
|
|
88
|
-
|
|
70
|
+
The root export is RN-safe (no `node:*`, no `@voltro/database`; React is an optional peer reached only through the hooks). The `_voltro_devices` declaration is the server-side `@voltro/react-native/schema` subpath.
|
|
89
71
|
|
|
90
|
-
**
|
|
72
|
+
**Deferred as documented seams** (flagged in the package, not built): APNs/FCM **sender** adapters (need per-tenant Apple Developer / Firebase credentials); native module bindings (camera, biometrics, secure storage — need a native runtime); Swift/Kotlin SDK generators (open product decision); universal-links / App-Links file automation; and the `*.deepLink.ts` codegen discovery wiring (one additive file, landed after the current release — the descriptor shape is final, so until then links register via `matchFirstDeepLink`).
|
|
73
|
+
- **@voltro/runtime** — `defineCostBudget()` + `attachFinops()` — reactive FinOps: per-tenant / per-subscription compute-cost attribution + budgets. A `CostAccountant` folds each `CostEvent` (`{ tenantId, subscriptionId?, unit, amount, … }`) in O(1) into a standing per-tenant attribution accumulator (`total` + `byUnit` + `bySubscription` — the chargeback/showback answer) and every budget that watches its unit. A budget is a POLICY holding EVERY tenant to the same ceiling independently (mirroring `requireAiBudget`); its per-`(budget,tenant)` windowed counter crosses `ok`→`warn`→`exceeded` with the provenance of the causing event, recovers on a tumbling-window rollover (event- AND clock-driven) or an explicit `reset(tenantId)`, and is observable via `CostRegistry` (same shape as `ExpectationRegistry`: snapshot / get / breaches / subscribe). The engine is store-free + unit-testable; the descriptor + registry Tag are browser-safe. `*.budget.ts` file discovery, the `attachFinops` call in both boot paths, and the dispatcher/query cost-event taps are the forthcoming CLI wiring. codemod: none.
|
|
74
|
+
- **@voltro/cache, @voltro/ai** — Reactive semantic cache: `SemanticCache` (`@voltro/cache/semantic`) — an embedding-keyed LLM cache with a cosine-similarity vector index over the existing `CacheStore`, dependency-set capture as tags (`rowDep`/`tableDep`), and eviction by source change (`onSourceChange`/`onTableChange`, insert evicts table-coarse only). `@voltro/ai/semanticCache` wraps it: `semanticGenerateText`/`semanticGenerateObject` embed→lookup→hit-returns-cached (zero tokens) / miss-generates-and-stores under the captured deps, best-effort (a cache outage degrades to always-generate). Firing eviction on live writes is a one-line CLI-facade sink (documented; no runtime change). codemod: none.
|
|
75
|
+
- **@voltro/database, @voltro/runtime, @voltro/cli** — Field-level read permissions: a new `.readableBy(...scopes)` column modifier (Part B of the column-wire-visibility seam). A column marked `.readableBy('billing:read')` is stripped from query + subscription wire OUTPUT for any subject that holds NONE of the listed scopes, and present for one holding ANY of them — checked against the subject's EFFECTIVE scope set (raw subject scopes ∪ rbac role-derived scopes), with the `admin:full` bypass seeing every such column. It is the graded middle of the wire-exposure axis between a plain column (visible to everyone) and `.serverOnly()` (hidden from every client); the two compose (`.serverOnly()` still wins — hidden from everyone including admins). Enforced at the SAME Dispatcher read chokepoint as `.serverOnly()` (initial snapshot + every reactive delta) and at the `publicApi` one-shot REST GET in both boot paths (`voltro dev`, `voltro serve`). Subject-independent — and therefore memo-sharing — for any table that declares no `.readableBy(...)` column. Server-internal reads (`ctx.store.query`) still see the value; the strip is a wire concern only. Declaration rejects `.readableBy()` with no scope (that is `.serverOnly()`) and a blank scope string. codemod: none.
|
|
91
76
|
|
|
92
|
-
`
|
|
77
|
+
apiSurface note: the one changed golden line is `OneShotQueryRunnerDeps.queryRows`, which gained a second `context: ServeRequestContext` parameter so the one-shot runner can apply the subject-aware strip. It is a callback the CONSUMER supplies, so an existing `(descriptor) => …` still satisfies the wider `(descriptor, context) => …` type — the change cannot turn compiling code into non-compiling code. Everything else is a pure addition (`readableBy`, `readableByColumns`, `ReadableByColumn`, `forbiddenColumnsForSubject`, `stripForbiddenForWire`).
|
|
78
|
+
- **@voltro/cli** — `voltro evolve` — schema-evolution copilot for changing EXISTING schema safely. Given a change (`rename-column`/`retype-column`/`split-column`/`drop-column`/`rename-table`) it reads the OBSERVED graph (`app.graph.observed.generated.json`) + the app manifest to enumerate the real blast radius (handlers that actually touch the table; declared-but-unexercised ones flagged UNKNOWN, never assumed safe), then proposes a reviewable plan: a codemod (rename-column gets a real transform that renames the `*.entity.ts` field AND chains `.renamedFrom('old')` so the differ plans a catalog RENAME not a lossy drop+create, and annotates the handler sites the blast radius found; reshaping kinds get a `manual` codemod with generated steps) + a branch-verified backfill plan (per-kind SQL tied to `planBranchProvision`, snapshotting the exact tables the affected handlers touch) + a `voltro check` verify step. Dry-run by default; `--write` applies via the existing `runCodemods` toolkit; `--json` for CI. codemod: none.
|
|
79
|
+
- **@voltro/env** — Live secret rotation: `refreshEnvValue(key, value, { previous, graceMs })` installs a re-resolved env value a running process serves immediately while holding the OLD value for a grace window (lazy prune-on-read, no timer); `rotateSecretLive(key, { graceMs })` (`@voltro/env/server`) re-resolves through the backend + does the cutover, and `getSecretWithOverlap(key)` returns `{ current, previous }` — the current/previous verifier pattern for app env. Bounds (documented): this updates what code reading a secret PER USE sees (outbound keys, webhook-signing, field-encryption); it does not reconnect a live DB pool built with the old credential. codemod: none.
|
|
80
|
+
- **@voltro/cache, @voltro/cli** — The reactive semantic cache (`SemanticCache`) is now wireable as a framework-managed opt-in. Set `cacheSemantic: true` in `app.config.ts` and both boot paths build a `SemanticCache` over the SAME `CacheStore` the query `Cache` uses (`CacheLayer.storeLayer` is now exported so one store instance is shared by both), provide it as a `yield*`-able handler service, AND wire row-granular eviction off the runtime's existing `store.onChange` — a live DB write to a source row drops every semantic entry that depended on it (`onSourceChange`). Gated end-to-end: an app that leaves `cacheSemantic` off builds no vector index, no service, and no eviction sink. codemod: none.
|
|
93
81
|
|
|
94
|
-
|
|
82
|
+
(apiSurface: compatible — `CacheLayer.storeLayer` is a new export; nothing removed or resignatured.)
|
|
83
|
+
- **@voltro/web, @voltro/cli, @voltro/ui** — Framework SEO + a11y primitives: `seoAlternates()` (reciprocal absolute canonical + hreflang alternates + x-default, browser-safe), `PageMeta.noIndex` (emits robots noindex on every render path + excludes the route from the sitemap), build-time `dist/sitemap.xml` + `dist/robots.txt` generation (per-locale alternates, `WebAppConfig.seo.siteUrl`, `VOLTRO_SEO_NOINDEX` staging override, never overwrites a user `public/` copy), a dev-server disallow-all robots. Accessible `<Field>` defaults filled: Schema-`description` hints wired via `aria-describedby`, the RadioWidget error now associated, required marker `aria-hidden` + `aria-required`.
|
|
84
|
+
- **@voltro/cli** — File-convention discovery + boot attach for the standing primitives: `*.expectation.ts` (defineExpectation), `*.budget.ts` (defineCostBudget), `*.experiment.ts` (defineExperiment) are now discovered like `*.aggregate.ts` and attached in BOTH `voltro dev` and `voltro serve` (parity), each providing its registry (ExpectationRegistry/CostRegistry/ExperimentRegistry) as a handler Layer + a `GET /_voltro/inspect/{expectations,budgets,experiments}` snapshot. This is what makes the three primitives user-reachable via file convention. (Cost-EVENT emission — the dispatcher recordCost tap — remains the deferred secondary half, so budgets are declarable+observable but attribution stays 0 until it lands.) codemod: none.
|
|
85
|
+
- **@voltro/ai, @voltro/cli** — `voltro eval` — replay real recorded agent/AI runs and gate the deploy on the result. `defineEval({ name, cases, assert?, judge? })` (`@voltro/ai`) declares golden cases from recorded runs; `voltro eval` discovers `*.eval.ts`, replays each case against the CURRENT model, judges with HARD assertions (`contains`/`matches`/`equals`/`nonEmpty`/`maxLatencyMs`) plus an optional LLM judge (`generateObject`-backed, schema-constrained verdict), and exits 1 on any regression — a deploy-gate signal, `--json` feedable into CI. Reuses run-recording (`runAndPersist`/`threads`), the data-branch identity machinery (`branchNamespaceName`, `--branch`), and mirrors `voltro check`'s gate shape. The runner (`runEval`/`scoreCase`/`evaluateAssertions`) is pure over an injected replay + judge, so it is fully unit-testable without a provider. `*.eval.ts` is read only by `voltro eval` — it is deliberately not a boot/browser file convention. codemod: none.
|
|
86
|
+
- **@voltro/workflow, @voltro/cli** — Workflow **resume-from-step** — rewind a terminally-`failed` run to an operator-chosen step and re-execute from there, past the point it actually died. The generalisation of `redrive` (which only re-runs the failed step): resume resets the target step **and every step after it** (succeeded ones included), so a step that completed cleanly but on stale/wrong external state re-runs too, while the steps *before* the target replay from the durable journal. For the dead-letter case where the failure point is not the right recovery point.
|
|
95
87
|
|
|
96
|
-
|
|
88
|
+
- Engine adapter `resumeRunFromStep` on `@voltro/workflow/cluster` (sibling to `redriveFailedRun`, sharing the one `@effect/cluster`-coupling core — a live-cluster contract test asserts the step before the target REPLAYS while the target + downstream RE-RUN). - `voltro workflows resume-from-step <runId> <stepName>` + the inspect action `POST /_voltro/inspect/workflows/runs/:id/resume-from-step` `{ step }`, wired into **both** `voltro dev` and `voltro serve`. Refuses a non-`failed`/discarded run and an unknown step; declines cleanly (no journal / still running / already succeeded).
|
|
97
89
|
|
|
98
90
|
codemod: none
|
|
99
91
|
|
|
100
92
|
### Fixed
|
|
101
93
|
|
|
102
|
-
- **@voltro/
|
|
94
|
+
- **@voltro/client** — **`useAction(...).run` forked against the boot-window stub instead of waiting for the api.**
|
|
103
95
|
|
|
104
|
-
|
|
96
|
+
`useSubscription` survives that window by design — it reads through the loading cache, reports no data, and delivers when the real client arrives. `run` had no such backstop: called from a mount effect it threw *"rpc / cache calls are not invokable on a not-yet-resolved api"*.
|
|
105
97
|
|
|
106
|
-
|
|
98
|
+
A component that fetches once on mount therefore had a race it could not see. It usually lost on a cold load and won on an HMR reload, so the page "worked when you looked at it".
|
|
107
99
|
|
|
108
|
-
|
|
100
|
+
**And the message REPLACED the real one**, which is the expensive half: one app reported api resolution while its upstream was answering `403`, and the `403` was invisible because the call never left the browser. SSR sharpened it — seeding a subscription via `initialSnapshot` makes `isAuthenticated` true on the very first render, so guards that gated mount effects behind "we have a user" stopped gating anything.
|
|
109
101
|
|
|
110
|
-
|
|
102
|
+
`run` now waits for the api, which is what a caller expects and what the sibling primitive already does. The wait is **bounded** (15s) and the timeout says what happened: an api that never resolves is a real condition — a name matching no configured api, a supervisor that gave up — and hanging forever would trade a confusing error for no error at all.
|
|
111
103
|
|
|
112
|
-
The
|
|
104
|
+
The callback also stays stable across the window now: it reads the handle through a ref at call time rather than being recreated the moment the api resolves.
|
|
113
105
|
|
|
114
|
-
|
|
115
|
-
- **@voltro/plugin-audit, @voltro/cli, @voltro/runtime** — **`@voltro/plugin-audit` could not boot — a release blocker, reported within a day.** 0.26.0 attached `interceptAction` and `interceptQuery` and declared neither scope, so the boot permission audit (`level: fatal`) refused to start EVERY app carrying the plugin, whether or not it had opted into query auditing. The audit inspects the presence of a hook, not what it does, so the identity passthrough counted.
|
|
106
|
+
`isUnresolvedApi(useFrameworkApi(name))` still composes for a caller who wants the readiness bit itself.
|
|
116
107
|
|
|
117
|
-
|
|
108
|
+
**Measured in a real browser against a real api process**, not only unit-covered — `node scripts/browser-action-boot-window.mjs` (chromium, `e2e-fixtures/web-action-boot` → `memory-api`), with the pre-fix shape restored as a negative control:
|
|
118
109
|
|
|
119
|
-
|
|
110
|
+
| | on a natural cold load | with a 3s `authHeaders` resolver | |---|---|---| | before | `ERR: rpc / cache calls are not invokable on a not-yet-resolved api` after **17 ms** | same error after **3 ms** | | after | `OK: {"ok":true}` after **34 ms** | `OK: {"ok":true}` after **3039 ms** |
|
|
120
111
|
|
|
121
|
-
|
|
112
|
+
The window really is only ~15–30 ms wide on a warm machine, so the page takes a negative control in the same instant the call is issued — invoking `handle.client` directly, which still throws. Without it, "run succeeded" would be indistinguishable from "the api had already resolved". The bound is exercised too: a resolver that outlasts the budget settles at 15006 ms with the timeout message, rather than never.
|
|
122
113
|
|
|
123
|
-
|
|
114
|
+
One thing this does NOT claim, because an earlier draft did and was wrong: `run` waits for the api to RESOLVE, not to be REACHABLE. With the api process killed the supervisor still hands over a client in ~20 ms (an rpc client is built from a layer; nothing there needs a live socket), so the call goes out and fails with a genuine `Error in socket` — which is the point of the fix, a real transport error instead of a stub message that displaced it.
|
|
115
|
+
- **@voltro/cli** — **The atlassian credential codemod now tells you to grep for your own key, not just for `credentialsResolver`.**
|
|
124
116
|
|
|
125
|
-
|
|
117
|
+
A team doing this migration found **four** call sites reading the PAT off the Subject and only one of them was the resolver: a session strategy stamping it into `metadata`, two delegation helpers building synthetic Subjects that carried it, and an avatar fetch. Fixing the resolver alone leaves the credential on the identity and the leak intact — which is the entire point of the change.
|
|
126
118
|
|
|
127
|
-
|
|
128
|
-
- **@voltro/
|
|
119
|
+
The note said as much in passing and was easy to read past. It now says it first, and names the reason: the resolver is where the credential is READ, not where it got onto the Subject. It also passes on what the reporter did afterwards — an invariant test that fails if anything puts a token-shaped key into a metadata bag again, mutation-tested by restoring the old line.
|
|
120
|
+
- **@voltro/workflow** — First-deploy cluster convergence: N runners started simultaneously against a fresh database (no `@effect/cluster` schema yet) no longer silently fail to converge. Root cause was `@effect/cluster`'s first-boot storage migration racing the pg catalog (its Migrator creates the tracking table without `IF NOT EXISTS`, and its `LOCK TABLE` guard only exists AFTER that table does). A new `clusterMigrationGateLayer` serializes the FIRST migration behind a cross-dialect advisory lock pinned to a single reserved connection (so acquire+release share a backend and auto-release on crash — the pooled `withMigrationLock` leaks here because storage build checks out several connections), building the storages sequentially. Warm boots skip it. New knob `VOLTRO_CLUSTER_MIGRATION_LOCK_TIMEOUT_MS` (default 60s). codemod: none.
|
|
121
|
+
- **@voltro/workflow** — **The cluster first-boot migration gate deadlocked on sqlite — an app on `store: 'sqlite'` with a workflow would never finish booting.**
|
|
129
122
|
|
|
130
|
-
|
|
123
|
+
The gate serializes `@effect/cluster`'s first schema migration behind an advisory lock pinned to a *reserved* connection, because acquire and release must land on the same backend. On sqlite the lock is a no-op on both sides — single-writer, single-process, no sibling to serialize against — but the reservation around it was not: `sql.reserve` takes the ONE connection an in-process sqlite client has, and the locked work is the library's storage build, which then asks the pool for another and waits on a connection its own caller is holding.
|
|
131
124
|
|
|
132
|
-
|
|
125
|
+
It presents as a boot that never finishes, not as an error. Nothing logs.
|
|
133
126
|
|
|
134
|
-
|
|
127
|
+
Sqlite now runs the migration without reserving. Every other dialect is unchanged — the reservation is load-bearing there, and removing it would leak a session lock onto an idle pooled connection that every late runner then blocks on.
|
|
135
128
|
|
|
136
|
-
|
|
137
|
-
- **@voltro/plugin-broadcast, @voltro/cli** — **`broadcastPlugin()` with `REDIS_URL` set no longer stays silently on `memory`.** `REDIS_URL` counted for RESOLUTION but not for INFERENCE — it took an explicit `connection` option to be considered — so the plugin fell through to the in-process bus while the branch that would have read the variable sat directly below. Two doc strings promised the fallback ("inferred from … `REDIS_URL`", "falls back to `REDIS_URL`").
|
|
129
|
+
**Never released** (it landed after 0.28.0), but worth reading for how it was found. The webhook delivery suite is the only place we build the cluster engine against `:memory:` sqlite; five of its tests sat at their 30 s timeout while the *same* tests on postgres, mysql, mariadb and mssql passed, because those pools hand out a second connection. So the one configuration with no infrastructure — the likeliest first thing a new user runs — was also the only one nothing else covered.
|
|
138
130
|
|
|
139
|
-
|
|
131
|
+
`clusterMigrationGate.test.ts` pins the connection count, not the outcome: a test asserting only "the work ran" passes on the broken code as long as its fake pool is willing to hand out a second connection, which is exactly the assumption the real sqlite client does not satisfy.
|
|
132
|
+
- **@voltro/cli** — **The credential-purge query in two 0.28.0 codemods was postgres-only, and its MySQL/MariaDB translation silently under-reported.**
|
|
140
133
|
|
|
141
|
-
|
|
134
|
+
Codemods `03_atlassian-credentials-context` and `04_audit-redacts-subject-metadata` both told you to check your existing rows with `subject::text ILIKE '%token%'`. `::text` and `ILIKE` do not run on MySQL/MariaDB, so the natural translation is a bare `LIKE` — which is case-**sensitive** against the `utf8mb4_bin` collation our own migrator emits for a `json()` column. `'%token%'` therefore does not match `jiraToken`, and a credential key is almost always camelCase.
|
|
142
135
|
|
|
143
|
-
**
|
|
136
|
+
A team ran the translated query against 141 rows, got **0**, and nearly reported themselves clean. 117 of those rows held a working credential; they caught it only because the count looked implausible and they printed a sample row.
|
|
144
137
|
|
|
145
|
-
|
|
138
|
+
Both notes now use `LOWER(subject) LIKE '%token%'`, which is correct on every dialect we ship.
|
|
146
139
|
|
|
147
|
-
|
|
140
|
+
**Why this is worse than a syntax error, which is the part worth keeping:** a query that fails to run gets fixed. A query that runs and returns good news when the answer is wrong is read as an all-clear — in the security-relevant half of a security-relevant codemod.
|
|
148
141
|
|
|
149
|
-
|
|
142
|
+
`codemodSqlPortability.test.ts` now scans every codemod note for postgres-only spellings (`::text`, `ILIKE`, `table_schema = 'public'`). It distinguishes SQL a user would copy from prose ABOUT sql by the backtick, because the first version fired on the very sentence warning against the construct — and it carries a selftest, since a scan that silently stopped matching reads exactly like a clean tree.
|
|
143
|
+
- **@voltro/cli** — **Outgoing webhooks never delivered on the cluster engine — i.e. in every deployment.**
|
|
150
144
|
|
|
151
|
-
|
|
152
|
-
- **@voltro/plugin-broadcast** — `broadcastPlugin` refuses a request it cannot honour instead of downgrading it silently.
|
|
153
|
-
|
|
154
|
-
Probed the way the events, records and presence surfaces were: five plausible mistakes, **five accepted**, and every one produced the same outcome — the in-process memory bus with a successful boot.
|
|
155
|
-
|
|
156
|
-
| written | got | said | | --- | --- | --- | | `provider: 'redes'` (typo) | memory | nothing | | `url: 'http://x'` | memory | nothing | | `url: ''` | memory | nothing | | `provider: 'redis'`, no url anywhere | memory | nothing |
|
|
157
|
-
|
|
158
|
-
On one replica each of these is indistinguishable from working. They appear on the second, as "some screens miss some events" — which is the report that led here, and it cost a consumer a deployment.
|
|
159
|
-
|
|
160
|
-
The asymmetry that decides it: **an app that configures nothing has taken a default, and memory is the honest answer. An app that writes `provider: 'redis'` has stated a requirement**, and answering a requirement with a downgrade is the shape removed everywhere else in this codebase.
|
|
161
|
-
|
|
162
|
-
So configuring nothing still takes memory, an explicit `provider: 'memory'` is still honoured — saying it out loud must not be worse than saying nothing — and a bare redis url still resolves without naming the provider. What throws is only the case where the request cannot be met: an unknown name (listing the valid ones, so the fix does not need the docs), a url whose scheme names no provider, and a named provider with no url anywhere (naming the variables that would satisfy it).
|
|
163
|
-
|
|
164
|
-
Red-verified: with the refusal removed, the two tests that assert it go red.
|
|
165
|
-
|
|
166
|
-
codemod: none
|
|
167
|
-
- **@voltro/cli** — Cross-replica delivery is now tested across a broker OUTAGE, not only a healthy or a degraded link.
|
|
168
|
-
|
|
169
|
-
The suites here proved delivery on a working link, and one proved it on a throttled one. None broke the link. That is the failure an operator actually meets — a redis restart, a failover, a partition that heals — and it was the last untested shape in the realtime stack.
|
|
170
|
-
|
|
171
|
-
**The property asserted is recovery, not delivery.** A broker that is down cannot carry messages, and claiming otherwise would be exactly the sort of guarantee this repo keeps removing. What must hold is that the link heals BY ITSELF: after the outage, delivery resumes with no process restart, no app-side retry and no resubscribe. A subscriber that silently stays dead after a blip is the worst realtime failure there is, because the screen keeps rendering and nothing reports it.
|
|
172
|
-
|
|
173
|
-
The test proves the link worked BEFORE it breaks it, so a zero at the end cannot be blamed on a link that never worked. Red-verified: leaving the proxy disabled gives 0 recovered deliveries instead of 10.
|
|
145
|
+
`voltro.deliverWebhook` was provided per-emit: `execute(input).pipe(Effect.provide(deliverWebhookWorkflow.toLayer(…)))`, built fresh inside the emit callback. The in-memory engine tolerates that, because there the layer IS the registry. The **cluster** engine does not: a workflow must be registered as an entity type while the runtime is constructed, and an emit happens long afterwards. So every delivery died with
|
|
174
146
|
|
|
175
|
-
|
|
147
|
+
```
|
|
148
|
+
Entity type 'Workflow/voltro.deliverWebhook' not registered
|
|
149
|
+
```
|
|
176
150
|
|
|
177
|
-
|
|
178
|
-
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **The credential bound covered one auth shape and the sentence did not say so.**
|
|
179
|
-
|
|
180
|
-
We wrote that "an event subscription can no longer outlive the credential that authorized it" and, a release later, that "the bound now covers EVERY realtime primitive". Both were true only for the `voltro:session` cookie: `sessionExpiryFromHeaders` read that cookie and nothing else, so for an app authenticating with Bearer JWTs the bound was always `undefined` — a no-op that reads as a guarantee.
|
|
151
|
+
**after** the mutation had already returned `200`. Zero deliveries, zero rows in `_voltro_webhook_deliveries`, nothing in the calling service's logs. Reported by a consumer on MariaDB + cluster-sql for whom the feature had never once delivered in any environment.
|
|
181
152
|
|
|
182
|
-
|
|
153
|
+
The layer is now built at boot and registered in `allWorkflowLayers` alongside the app's own workflows, in both boot paths; the emit closure runs on that runtime. An app with outgoing webhooks and no workflows of its own now builds the workflow runtime too — otherwise the fix becomes a different silent failure.
|
|
183
154
|
|
|
184
|
-
|
|
155
|
+
**The axis is the part worth keeping.** Both boot paths carried the *identical* construction, so no dev/serve parity check could see it — those compare the two paths to each other, and here they agreed. The difference was IN-MEMORY vs CLUSTER, and it looked like dev-vs-serve only because `voltro dev` defaults to the in-memory engine while a deployment uses the cluster one. **A difference between two configurations of ONE path is invisible to every guard that compares paths.**
|
|
185
156
|
|
|
186
|
-
|
|
157
|
+
`deliverWebhookRegistration.test.ts` pins the boot registration across both paths (red-verified by removing it from one). It is the source half; the behavioural half needs a real SQL cluster engine and is not something a fake engine could stand in for.
|
|
158
|
+
- **@voltro/cli** — **`voltro serve` warned that framework-provided tables "are not a declared table".**
|
|
187
159
|
|
|
188
|
-
The
|
|
160
|
+
The stale-`source` audit is called by both boot paths. `voltro dev` passed `allRegisteredTables()` — the process registry, which includes framework- and plugin-provided tables. `voltro serve` passed `discovered.tables`, which is only what the APP declares. So a query naming `_voltro_agent_messages` (or the audit trail, or the notification inbox) was reported as naming a table that does not exist — about a table that does.
|
|
189
161
|
|
|
190
|
-
|
|
162
|
+
Same codebase, same version, two boots: dev silent, serve warning. It is the false positive fixed for dev in 0.27.0, still live on the serve path — now only where nobody is watching a terminal.
|
|
191
163
|
|
|
192
|
-
|
|
193
|
-
- **@voltro/cli** — **Records and presence are now PROVEN cross-replica, not asserted.**
|
|
164
|
+
**Why no existing guard saw it.** It is not a ctx field and not a missing call, so neither the derived boot-path audit nor its ctx-key axis applies: both paths call the *same* function and hand it *different sets*. That is the same variant as the schedule-subject divergence — each call site internally consistent, the difference visible only by comparing them. Reported by a consumer who noticed the two boots disagreeing on identical source.
|
|
194
165
|
|
|
195
|
-
|
|
166
|
+
Both paths read the process registry now, pinned by a guard that fails if either reverts to the app-declared set.
|
|
167
|
+
- **@voltro/cli** — `.serverOnly()` columns are now stripped from a `publicApi` query's buffered REST GET response. The runtime dispatcher already strips every WS / `POST /rpc` snapshot (which is what the SSR web-router loaders and `usePreloadedSubscription` seeds fetch through, so the `__voltro_state__` hydration payload was already safe), but a query projected to a public REST endpoint has no subscription to drive it — it read the store directly and shipped the raw row, including any `.serverOnly()` credential column (e.g. `keyHash`), to the caller. Both boot paths (`voltro dev`, `voltro serve`) now route the one-shot public read through the same `stripServerOnlyForWire` choke point. codemod: none.
|
|
168
|
+
- **@voltro/runtime** — `.serverOnly()` columns are now stripped from ALL query + subscription OUTPUT at the Dispatcher's read boundary (initial snapshot + every reactive delta), not just `crud.*` echoes + the boot audit — so a hand-written query/subscription returning a raw row no longer leaks a server-only column to the wire. Server-internal reads (`ctx.store.query`) still see the column; the strip is wire-only and subject-independent, so it shares the read memo. codemod: none.
|
|
169
|
+
- **@voltro/cli, @voltro/i18n** — **`voltro dev` server-renders WITHOUT `<I18nProvider>`, so SSR could not be developed at all for a translated app.**
|
|
196
170
|
|
|
197
|
-
|
|
171
|
+
There are three server renderers and each arranged the i18n wrapper for itself: `voltro build`'s prerender picked a wrap per locale, `voltro start` called the `i18n.resolve` baked into the generated `ssrEntry.ts`, and `voltro dev` — which loads `@voltro/web/ssr` directly and therefore has no generated entry to call — passed **no `outerWrap` at all**. Any component calling `useT()` / `<T>` rendered fine under `voltro start` and threw on the server under `voltro dev`:
|
|
198
172
|
|
|
199
|
-
|
|
173
|
+
```
|
|
174
|
+
Error: [React Intl] Could not find required `intl` object.
|
|
175
|
+
<IntlProvider> needs to exist in the component ancestry.
|
|
176
|
+
```
|
|
200
177
|
|
|
201
|
-
|
|
178
|
+
Reported by a consumer whose 51 `renderMode: 'ssr'` pages were every one of them serving a spinner — and two further defects sat behind this one, because nobody could get a page far enough to see them.
|
|
202
179
|
|
|
203
|
-
|
|
180
|
+
`@voltro/i18n/server` gains **`makeSsrI18nResolver`** (cookie `voltro:lang` > `Accept-Language` > default → the matching wrapper), and both the generated entry and the dev server now call it. The dev copy and the generated copy were going to be two hand-written versions of the same five lines, which is how they diverged in the first place. It stays React-free so the CLI takes no React dependency; dev loads the React half through Vite's SSR loader, as the prerender already did.
|
|
204
181
|
|
|
205
|
-
|
|
182
|
+
Both dev render branches are covered — the page, and `prepareSpaLayoutShell`, which builds its own `renderInput` and matters because a translated ROOT LAYOUT above a client-only page hits `useT()` on the server exactly as a page does.
|
|
206
183
|
|
|
207
|
-
|
|
208
|
-
- **@voltro/cli** — The `no-consumer` half of the event audit sees sibling apps.
|
|
184
|
+
**And the dev SSR failure path no longer hands the raw error to the logger.** A React SSR error carries the element/props graph; formatting it through `util.inspect` can exceed V8's ~512 MB string cap, at which point `RangeError: Invalid string length` from `inspect` *becomes* the reported error and the real message is gone. The consumer had to monkey-patch `console.error` from application code to recover a one-line i18n error. `boundedErrorText` reads `stack`/`message` only, caps the result, names the truncation, and includes `cause` / `AggregateError` children.
|
|
209
185
|
|
|
210
|
-
|
|
186
|
+
Verified against a real `voltro dev` process rendering a fixture with two locales: the marker appears in the SERVER body, the `voltro:lang` cookie selects the German catalog (so the wrap is per-request, not a fixed default), and both branches were red-verified by removing their spread.
|
|
211
187
|
|
|
212
|
-
|
|
188
|
+
codemod: none — no user-authored code changes shape; a page that was crashing now renders.
|
|
189
|
+
- **@voltro/cli** — **Every first visit to an SSR page hydration-mismatched, and `<html lang>` was a constant.**
|
|
213
190
|
|
|
214
|
-
|
|
191
|
+
Two defects on one surface, and the second is why the obvious fix for the first did not work.
|
|
215
192
|
|
|
216
|
-
|
|
217
|
-
- **@voltro/protocol** — `defineEvent` refuses four authoring mistakes it used to accept.
|
|
193
|
+
**1. The halves disagreed on the no-cookie case.** The generated client entry resolved the locale from the cookie only — correctly refusing `navigator.languages`, which can diverge from what the server saw. But *dropping* the `Accept-Language` signal is not the same as *agreeing* with the server about it. With no `voltro:lang` cookie yet — every first visit — the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app hydrated `de` over an `en` tree, so React discarded the whole server render: exactly what SSR was enabled to buy. It stopped the moment anything wrote the cookie, which is why one language switch made it un-reproducible for that developer.
|
|
218
194
|
|
|
219
|
-
|
|
195
|
+
The client now **adopts what the server resolved**, from `<html lang>`, before falling back to the cookie and the default. `navigator.languages` is still never read.
|
|
220
196
|
|
|
221
|
-
**
|
|
197
|
+
**2. `<html lang>` never carried the resolved locale.** `voltro dev` read a `voltro:locale` cookie. Nothing writes that name — `resolveLocale`, the generated entry, `@voltro/ui-shadcn`'s ProfileMenu and the docs all use `voltro:lang` — so the lookup always missed and the attribute was the literal `"en"` on every page of a German-default app. Measured by a consumer with three different `Accept-Language` values against `/login`: `<html lang="en">` all three times.
|
|
222
198
|
|
|
223
|
-
|
|
199
|
+
That is wrong on its own terms: `<html lang>` is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses. It is now the locale THIS request resolved — the same value the `<I18nProvider>` renders with — falling back to `voltro:lang`, then the app's `defaultLocale`, never a hardcoded `'en'`.
|
|
224
200
|
|
|
225
|
-
|
|
201
|
+
**It also cost the reporter a wrong fix**, which is the part worth keeping: they shipped "adopt `<html lang>` as the client fallback" with green tests, because the tests asserted their belief about what the attribute contained. A `curl` is what caught it. Both halves are now asserted against a running `voltro dev`, red-verified by restoring the old cookie name.
|
|
226
202
|
|
|
227
|
-
|
|
203
|
+
The docs said the client "mirrors cookie and default for hydration safety" — a sentence that reads as a guarantee and described the opposite of what happened. Corrected in both languages.
|
|
228
204
|
|
|
229
|
-
|
|
230
|
-
- **@voltro/
|
|
205
|
+
Still open, narrower: `voltro start`'s `<html lang>` prefers the resolved locale but falls back to `'en'` rather than `defaultLocale` when an app configures a locale whose catalog file is missing.
|
|
206
|
+
- **@voltro/runtime** — **An undeclared throw reached the client as a ~2 KB decode tree instead of its message.**
|
|
231
207
|
|
|
232
|
-
|
|
208
|
+
An executor threw a plain `TypeError`. The server logged it correctly. What the client got was the entire `ExitEncoded<…>` transformation — every member of the descriptor's `error:` union, the full type, and the actual cause on the *last* line. One consumer's account page rendered that verbatim where a reason belonged, and every app otherwise has to condense it heuristically to avoid putting a schema on screen.
|
|
233
209
|
|
|
234
|
-
|
|
210
|
+
**The channel is the part worth keeping, and only a deployed process settled it.** The first attempt guarded the DEFECT channel — reasonable, and inert: an executor that throws is settled as a FAILURE by the async wrapper, so the encoded cause reads `_tag: "Fail"` and a defect-channel catch never fires. The existing `isInfraError` guard missed it too, because a plain `TypeError` has no `_tag`.
|
|
235
211
|
|
|
236
|
-
|
|
212
|
+
So the rule is on the failure channel and is not a heuristic: **every error an app DECLARES carries a `_tag`** — that is the wire contract the client pattern-matches on — so an `Error` without one is exactly the set the descriptor's `error:` union cannot contain. A tagged error passes through untouched.
|
|
237
213
|
|
|
238
|
-
|
|
214
|
+
An undeclared defect now collapses to the same small tagged `InternalError` the infra path already produced, carrying the message the server just logged. `message` only: no stack, no `cause` chain, no own fields — the same reasoning as `wireErrorFromCause`, where a nested object can hold a DSN or a token. Bounded at 500 chars with the truncation marked.
|
|
239
215
|
|
|
240
|
-
|
|
216
|
+
**Measured against `voltro serve`**, an action doing `undefined.runWithEager()`, from the published fixture bundle:
|
|
241
217
|
|
|
242
|
-
|
|
218
|
+
| | response | |---|---| | before | **543 bytes** of `ExitEncoded<…>` decode tree | | after | **185 bytes** — `{"_tag":"InternalError","message":"Cannot read properties of undefined (reading 'runWithEager')","traceId":…}` |
|
|
243
219
|
|
|
244
|
-
|
|
245
|
-
- **@voltro/plugin-presence** — `presencePlugin` refuses a `timeoutMs` that expires members between heartbeats.
|
|
220
|
+
The server log line and the client message are now identical, which was the ask. The unit test was green through BOTH states, because it exercised the pure function and not the channel it hangs on.
|
|
246
221
|
|
|
247
|
-
The
|
|
222
|
+
**The asymmetry with `isInfraError` is deliberate.** A `SqlError` still collapses to the generic `'internal server error'`, because its message names internal `table.column` detail. An arbitrary app defect has no such known shape, and withholding its text too would leave the app exactly where it started — with a reason it cannot show.
|
|
223
|
+
- **@voltro/plugin-webhooks** — **The scope lookups read one page of the target table and answered confidently from it.**
|
|
248
224
|
|
|
249
|
-
`
|
|
225
|
+
`scope` is an app-defined JSON blob, so matching it cannot be a SQL predicate — a JSON comparison is dialect-divergent, and on MariaDB a `json()` column carries `utf8mb4_bin`, which has already produced a case-sensitive `LIKE` that reported a clean `0` over 141 dirty rows. Filtering in JS is the right call. Reading only the first 1000 rows to filter was not.
|
|
250
226
|
|
|
251
|
-
|
|
227
|
+
Past that many target rows, both callers returned a wrong answer rather than an error:
|
|
252
228
|
|
|
253
|
-
|
|
229
|
+
- **`subscribe` minted a fresh secret for a LIVE endpoint.** Growing an endpoint inherits its secret precisely because the receiver verifies one signature for one URL. Not seeing the endpoint's rows meant inventing a new key, so half its rows then sign with a key the receiver does not hold — and the "these are not one endpoint" refusal never fires, because it only inspects what was fetched. - **`resolveTargets` threw `no target matches scope … the operation would have silently done nothing`** for a scope that does match. That message sits three lines under a comment about exactly this failure shape.
|
|
254
230
|
|
|
255
|
-
|
|
256
|
-
- **@voltro/protocol** — `defineQuery` refuses three contradictions it used to accept — the same probe that found four on `defineEvent`, run against the records surface.
|
|
231
|
+
Both now page until the table is exhausted, ordered by `id` (unique — `OFFSET` over a non-unique order can repeat or skip rows between pages, and mssql refuses `OFFSET` without an `ORDER BY`). Past 100k rows the scan THROWS: a scan that gives up quietly is the thing being fixed.
|
|
257
232
|
|
|
258
|
-
|
|
233
|
+
`emit`'s fan-out pages too. Its cap was per-event (it has a real `event` predicate) and carried the comment *"sane bound — 1000 targets per event is plenty"* — but a bound whose overflow is a silent non-delivery is not a bound, it is a data-loss ceiling nobody is told about. Paging costs nothing in the normal case: one page, one round trip.
|
|
259
234
|
|
|
260
|
-
|
|
235
|
+
**Why nothing caught it.** Every test harness in the package returns its whole row array from `query()` and ignores `take`/`skip` — which is precisely what a paging bug looks like from the inside. `targetScan.test.ts` uses a harness that honours them, and that is the only reason its assertions mean anything.
|
|
261
236
|
|
|
262
|
-
|
|
237
|
+
### Internal (no consumer-facing effect)
|
|
263
238
|
|
|
264
|
-
|
|
265
|
-
- **@voltro/cli** — The one multi-replica scenario with no test: a replica that goes away, misses traffic, and comes back. The existing suites prove two replicas REACH each other, not what happens when one stops being able to.
|
|
239
|
+
- **@voltro/plugin-ai-flows** — **`FlowStep` / `RunStep` are declared interfaces, so the api report stops churning.**
|
|
266
240
|
|
|
267
|
-
|
|
241
|
+
`type FlowStep = typeof FlowStep.Type` is an alias to a mapped type, and TypeScript's declaration emit expands such an alias structurally rather than printing its name. Both types are reached from an exported table (`aiFlows.steps: json<ReadonlyArray<FlowStep>>()`), so a ~60-line expansion sat inline in `etc/plugin-ai-flows.api.md` — and its member order depends on which other packages were built in the same turbo run. Measured: a full-monorepo build and a single-package `--force` build emit `params` and `schema` (structurally identical, both `Schema.optional(Json)`) in different positions, so the pre-push drift gate rejected whichever order was committed. CI does not build the scope a dev machine does.
|
|
268
242
|
|
|
269
|
-
|
|
243
|
+
An `interface` is a real declaration TypeScript prints by name. Both are pinned to their schemas by an `Equals` check that fails to compile on divergence, so the hand-written shape cannot drift from the runtime one.
|
|
270
244
|
|
|
271
|
-
|
|
245
|
+
**`apiSurface: compatible`, and the reason matters more than the label:** the 36 removed golden lines are the collapsed expansion, not a removed capability. The type is structurally identical — the `Equals` pin proves exactness in both directions — so no consumer expression changes meaning. What changed is how the report SPELLS the same type.
|
|
272
246
|
|
|
273
|
-
|
|
247
|
+
Verified byte-identical across exactly the two build scopes that disagreed before.
|
|
248
|
+
- **@voltro/cli** — boot-validate (sharpened): a `pnpm boot-validate:sqlite` lane (driver-gated, degraded-boot: probes the sqlite driver chain, DEGRADED+exit-0 when absent, else a real embedded-sqlite durable-CRUD round-trip incl. reopen) with a `--self-test`; plus completing the internal `ApiAppConfig.store` union with `'sqlite'` (the resolver already supported it). The Tier-B service lanes (mysql/mssql/clickhouse/redis + a boot-validate compose) are the remainder. Ships with a new `api-backend-sqlite` template.
|
|
249
|
+
- **@voltro/plugin-webhooks, @voltro/workflow** — **The delivery workflow is now covered against a REAL SQL cluster engine, not only the in-memory one.**
|
|
274
250
|
|
|
275
|
-
|
|
276
|
-
- **@voltro/runtime** — A resume replay could be **overtaken** by live traffic, delivering serials out of order.
|
|
251
|
+
`deliverWorkflow.integration.test.ts` runs the delivery workflow against every dialect with the workflow ENGINE in `memory` — a documented, defensible trade (a cluster cold start made it slow and flaky under CI contention). It is also why a cluster-only defect shipped: a suite named "deliverWorkflow end-to-end per dialect" reads like coverage and was structurally blind to entity registration.
|
|
277
252
|
|
|
278
|
-
|
|
253
|
+
`deliverWorkflowTwoRunners.integration.test.ts` stands up a real SQL-backed cluster engine on MariaDB and asserts the workflow resolves AND its body runs. `purgeClusterState` is exported from `@voltro/workflow/cluster-suite` so a suite outside the dialect packages can use it. It is ONE file on purpose: two suites purging the same `cluster_*` tables wipe each other's runners mid-run, which fails as something that looks nothing like shared state.
|
|
279
254
|
|
|
280
|
-
|
|
255
|
+
**Two things measured on the way, both worth more than the test itself:**
|
|
281
256
|
|
|
282
|
-
|
|
257
|
+
- The old per-emit shape is what the shared cluster suite itself uses (`Effect.provide(handler.pipe(Layer.provideMerge(engine)))`) and it works *there* — because there is exactly one runner. In a deployment the boot runner owns the shard, and an ad-hoc participant registering the entity does not change where the message routes. That is the mechanism behind `Entity type 'Workflow/voltro.deliverWebhook' not registered`, and it is why no single-runner test could have caught it. - **A cluster test that hangs is usually not the cluster.** A stub handler returning `void` against a `success` schema of `{ finalStatus, attempts }` cannot be encoded, so the message is redelivered forever and `execute` never resolves — a 120s timeout with the row still in `cluster_messages`. Accumulated cluster state was blamed first, the purge added, and it still hung; counting the rows settled it. Look at the handler's return type before the cluster.
|
|
283
258
|
|
|
284
|
-
|
|
259
|
+
**And the two-runner case IS covered now** — `deliverWorkflowTwoRunners.integration.test.ts` stands up a boot runner and a dispatcher against one live MariaDB and asserts BOTH directions:
|
|
285
260
|
|
|
286
|
-
|
|
261
|
+
| boot runner A | dispatch | result | |---|---|---| | built WITHOUT the workflow's layer | B provides it at dispatch (the shipped shape) | **`not registered`**, body never ran | | built WITH it (what both boot paths do now) | same B | run completes, body executed |
|
|
287
262
|
|
|
288
|
-
|
|
289
|
-
- **@voltro/cli** —
|
|
263
|
+
The negative control is the point: a test that can only pass cannot tell a registered entity from an unregistered one, and every single-runner test in this repo passes on the broken code. The defect case costs ~60s — the ENGINE retries an unroutable message before the failure surfaces — against ~3s for the fix. That is the price of having a reproduction at all; do not lower the timeout to tidy it.
|
|
264
|
+
- **@voltro/cli** — dev.ts refactor (dev-ts-decomposition Step 1+3): the SSE subscription-snapshot push now reuses the shared `buildSubscriptionsInspect` builder instead of an inlined byte-identical copy; the `_voltro_workflow_runs` refetch-and-emit shared by redrive + resume-from-step is one helper; and a live-span-leak GATE test asserts the inspect door gate sits above every ungated live-data branch. Pure refactor, zero behaviour change.
|
|
265
|
+
- **@voltro/cli** — **The message-API check called a real chainable member non-existent.**
|
|
290
266
|
|
|
291
|
-
|
|
267
|
+
`.index()` is a genuine member of the table builder with two overloads. api-extractor prints an overloaded member as a call-signature *object*:
|
|
292
268
|
|
|
293
|
-
|
|
269
|
+
```
|
|
270
|
+
index: {
|
|
271
|
+
<const F extends readonly [...]>(fields: F, options?: …): Table<…>;
|
|
272
|
+
<const IxName extends string, …>(name: IxName, …): Table<…>;
|
|
273
|
+
}
|
|
274
|
+
```
|
|
294
275
|
|
|
295
|
-
|
|
276
|
+
which matches neither of the check's line-shaped rules (`foo(` / `foo: (…) =>`). So a correct comment naming it was reported as naming something that does not exist, and the static check went red on `main`.
|
|
296
277
|
|
|
297
|
-
**
|
|
278
|
+
**A false alarm is not the harmless direction here.** This check exists to be believed — its own failure text says "fix the message, or build the thing it promises". One that cries wolf gets its finding argued with instead of read.
|
|
298
279
|
|
|
299
|
-
|
|
280
|
+
The lookahead is the part worth recording: matching the generic precisely does **not** work, because `<const F extends … Array<…>>` nests `>`, so a `<[^>]*>` character class stops inside it. The first version of the branch therefore matched nothing and looked like a fix. A call signature simply *starts* with `<` or `(` once trimmed; a data member starts with an identifier or `readonly` — which is what keeps `index?: { readonly where: string }` out, and with it the data-property bug the original rules exist to reject.
|
|
300
281
|
|
|
301
|
-
|
|
282
|
+
Both directions are now selftest cases, since a rule that quietly stops matching prints exactly like a clean tree.
|