@voltro/cli 0.13.0 → 0.15.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 +412 -0
- package/dist/{apiBuild-B6_ehbem.js → apiBuild-CHItlwPO.js} +2 -2
- package/dist/apiBuild-D8juhYsS.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-DYOuGlEC.js → commands-CPQuQRXh.js} +3967 -2831
- package/dist/{dbCommand-DIOJQwsq.js → dbCommand-B8F29zV0.js} +2 -1
- package/dist/dbCommand-Dt7BtdMd.js +2 -0
- package/dist/{dev-xdijqojc.js → dev-BHLM3RPX.js} +1 -1
- package/dist/{dev-Fb9BmavX.js → dev-C2lCUhYI.js} +1478 -1347
- package/dist/fileConventions-Cu0tM-Bl.js +29 -0
- package/dist/{frameworkTableAssembly-CYlrafoK.js → frameworkTableAssembly-BA2bAmYN.js} +164 -163
- package/dist/frameworkTableAssembly-D1zJSgOU.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-DOiiII34.js → inspectMetrics-CYDSO3za.js} +955 -945
- package/dist/{serveCommand-CqYxzBZR.js → serveCommand-DBzQcCuv.js} +410 -396
- package/dist/serveEntry.js +2 -2
- package/dist/{start-CjG_0xhR.js → start-BtgatJN3.js} +246 -246
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +77 -3
- package/templates/AGENTS.md +78 -4
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/authentication.md +34 -2
- package/templates/agent-docs/cli.md +116 -2
- package/templates/agent-docs/data.md +195 -3
- package/templates/agent-docs/database/hosting.md +2 -2
- package/templates/agent-docs/database/migrations.md +77 -3
- package/templates/agent-docs/database/overview.md +11 -0
- package/templates/agent-docs/database/seedsdialects.md +53 -1
- package/templates/agent-docs/internationalization.md +3 -3
- package/templates/agent-docs/introduction.md +133 -2
- package/templates/agent-docs/plugins.md +2 -1
- package/templates/agent-docs/reference.md +4 -4
- package/templates/agent-docs/routing.md +48 -43
- package/templates/agent-docs/schema-driven-ui.md +14 -3
- package/templates/agent-docs/templates/apibackends.md +1 -8
- package/templates/agent-docs/templates/appshells.md +24 -24
- package/templates/agent-docs/testing.md +16 -1
- package/templates/agent-docs/whats-new.md +247 -39
- package/templates/apps/api-ai/database/schema.ts +0 -2
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/database/schema.ts +0 -3
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/database/schema.ts +0 -3
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/database/schema.ts +0 -3
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
- package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/database/schema.ts +0 -3
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/database/schema.ts +0 -1
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/database/schema.ts +0 -1
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/database/schema.ts +0 -3
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/database/schema.ts +0 -2
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/database/schema.ts +0 -1
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/database/schema.ts +0 -1
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/database/schema.ts +0 -2
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/database/schema.ts +0 -3
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/database/schema.ts +0 -1
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/database/schema.ts +0 -1
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/database/schema.ts +0 -1
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/database/schema.ts +0 -1
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
- package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
- package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/dist/apiBuild-Bzpb383i.js +0 -2
- package/dist/dbCommand-ADD4mrTI.js +0 -2
- package/dist/frameworkTableAssembly-XvKO7wQt.js +0 -2
- /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
- /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
|
@@ -383,11 +383,28 @@ Procedure descriptors are intentionally separate from server executors. Descript
|
|
|
383
383
|
|
|
384
384
|
The browser-safe rule is **transitive**, and that is where it usually breaks. The codegen pulls every descriptor (and every workflow descriptor) value-level into `rpcGroup.generated.ts`, which the web client loads — so a descriptor plus *everything it imports* must stay free of server-only code (`node:*`, the `database` handle, `@voltro/ai`, cluster, plugins, `@voltro/protocol/session`). The classic mistake is not a literal `import 'node:crypto'` but a descriptor importing a shared typed-error or helper from a `lib/` file that *also* imports the database — which drags the whole schema graph into the browser bundle. Keep typed errors, Schemas, and pure helpers in files with zero server imports; put DB-backed guards in `.server.ts`. A leak shows up as the web app fetching hundreds of modules / tens of MB on first load, or crashing with `Module "node:crypto" has been externalized for browser compatibility`.
|
|
385
385
|
|
|
386
|
+
### Declaring a shared file browser-safe: `*.client.ts`
|
|
387
|
+
|
|
388
|
+
A shared `lib/` helper can state the rule about itself. Name it `*.client.ts` (or `*.client.tsx`) and it declares: *I, and everything I transitively import, are browser-safe.* `voltro dev` walks its import graph at boot and refuses to start if the claim is false, printing the chain.
|
|
389
|
+
|
|
390
|
+
```text
|
|
391
|
+
lib/orderErrors.client.ts # I and my imports are browser-safe — checked at boot
|
|
392
|
+
lib/orderGuards.server.ts # I may touch the database handle
|
|
393
|
+
lib/orderTypes.ts # unmarked: no claim, the graph decides
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
This is the mirror of `*.server.ts`, and it works for the same reason: both declare a **permission**, which is something an import graph cannot derive. The graph can tell you what a file imports; it can never tell you what a file is *allowed* to import.
|
|
397
|
+
|
|
398
|
+
Without the marker the leak is still caught — by the rpcGroup guard — but only once some descriptor happens to reach the file, and the error is a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.
|
|
399
|
+
|
|
400
|
+
An unmarked file makes no claim, and that is fine: `*.client.ts` is for the shared files where the mistake is expensive, not a label to sprinkle on everything.
|
|
401
|
+
|
|
402
|
+
|
|
386
403
|
## The web side (`apps/*/web/`)
|
|
387
404
|
|
|
388
405
|
| Path | What it is |
|
|
389
406
|
|---|---|
|
|
390
|
-
| `src/pages/*.tsx` | A page. URL is the file path; `[id].tsx` -> `/:id`, `[...slug].tsx` -> catch-all. |
|
|
407
|
+
| `src/pages/*.tsx` | A page. URL is the file path; `[id].page.tsx` -> `/:id`, `[...slug].page.tsx` -> catch-all. |
|
|
391
408
|
| `src/pages/layout.tsx` | Outer layout — wraps every page. |
|
|
392
409
|
| `src/pages/error.tsx` | Error boundary for the whole subtree. |
|
|
393
410
|
| `src/pages/not-found.tsx` | Fallback rendered when no page matches. |
|
|
@@ -398,7 +415,7 @@ The browser-safe rule is **transitive**, and that is where it usually breaks. Th
|
|
|
398
415
|
Each page can opt into a render strategy via two exports:
|
|
399
416
|
|
|
400
417
|
```tsx
|
|
401
|
-
// src/pages/blog/[slug].tsx
|
|
418
|
+
// src/pages/blog/[slug].page.tsx
|
|
402
419
|
export const renderMode = 'isr' as const // 'static' | 'spa' | 'ssr' | 'isr'
|
|
403
420
|
export const interactive = 'islands' as const // 'none' | 'islands' | 'full'
|
|
404
421
|
```
|
|
@@ -436,3 +453,117 @@ apps/acme/api/
|
|
|
436
453
|
- **Don't put two procedure descriptors with the same `name`.** The CLI fails the boot — fix the name collision.
|
|
437
454
|
- **Don't import `.server.ts` files from your web app.** Use the generated client. Importing the server module straight into the browser bundle leaks server-only deps (Postgres driver, secret keys).
|
|
438
455
|
- **Don't move generated files in `.framework/` into the user source tree.** They're disposable; the CLI rewrites them on every boot.
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
<!-- source: en/intro/file-taxonomy.md -->
|
|
462
|
+
## The web file taxonomy
|
|
463
|
+
|
|
464
|
+
_The contract suffixes for web code — component, component.ui, hook, types, internal, fixture, tracking — what each one promises and which rule enforces it._
|
|
465
|
+
|
|
466
|
+
Every suffix on this page is a **contract**, not a label. Something else in the codebase depends on the promise, and `voltro doctor` enforces it. That is the whole admission test, and it is why the list is short:
|
|
467
|
+
|
|
468
|
+
> Does another file's correctness depend on this file keeping its promise?
|
|
469
|
+
|
|
470
|
+
If yes, the promise belongs in the name — you cannot see a contract before you break it otherwise. If no, it is a category, and categories are read out of the file.
|
|
471
|
+
|
|
472
|
+
## The catalogue
|
|
473
|
+
|
|
474
|
+
| Suffix | Promise | Enforced by |
|
|
475
|
+
|---|---|---|
|
|
476
|
+
| `*.component.tsx` | exactly one component (+ types) | `component/one-per-file`, `component/no-hook-export` |
|
|
477
|
+
| `*.component.ui.tsx` | one component, **reads only** | `ui/no-write`, `ui/orphaned`, `ui/unlinked` |
|
|
478
|
+
| `*.hook.ts` | exactly one `use*` hook (+ types) | `hook/one-per-file`, `hook/no-component-export` |
|
|
479
|
+
| `*.types.ts` | zero runtime exports | `types/runtime-export` |
|
|
480
|
+
| `*.internal.ts` | only its own subtree imports it | `internal/foreign-import` |
|
|
481
|
+
| `*.fixture.ts` | no production path reaches it | `fixture/production-import` |
|
|
482
|
+
| `*.tracking.ts` | analytics happens nowhere else | `tracking/outside-tracking-file` |
|
|
483
|
+
| `*.client.ts` | it and its imports are browser-safe | boot-time import walk, `client/not-browser-safe` |
|
|
484
|
+
| `*.store.ts` | exactly one `defineStore`, no server state | `store/one-per-file`, `store/mirrors-server-state` |
|
|
485
|
+
|
|
486
|
+
## `*.component.ui.tsx` — reads, never writes
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
// OrderRow.component.ui.tsx
|
|
490
|
+
import { useCan } from '@voltro/client'
|
|
491
|
+
import { useT } from '@voltro/i18n'
|
|
492
|
+
|
|
493
|
+
export const OrderRow = (props: { order: Order; onCancel: () => void }) => {
|
|
494
|
+
const cancelLabel = useT('orders.cancel')
|
|
495
|
+
const mayCancel = useCan('orders:write')
|
|
496
|
+
return <tr>{/* … */}</tr>
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Reading is allowed on purpose. Threading translations and permissions through props is prop-drilling — it makes every call site worse without making the component more portable.
|
|
501
|
+
|
|
502
|
+
**Writing** is what breaks the contract. A component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading what it does — and that property is exactly what its callers rely on. Lift the mutation into the `*.component.tsx` that owns it and pass a handler down.
|
|
503
|
+
|
|
504
|
+
The same file must also be *reached* from a `*.component.tsx`, another `*.component.ui.tsx`, or a page. An unrendered presentational component is carried, reviewed and refactored forever without ever reaching a user; that is how a design system quietly doubles in size.
|
|
505
|
+
|
|
506
|
+
## `*.internal.ts` — the feature boundary
|
|
507
|
+
|
|
508
|
+
```text
|
|
509
|
+
src/features/orders/
|
|
510
|
+
├── index.ts # the public surface
|
|
511
|
+
├── orderState.internal.ts # only this directory may import it
|
|
512
|
+
└── OrderList.component.tsx
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
`.internal` is the promise that refactoring inside that directory breaks nobody. An import from another feature revokes it — silently, and without a single review comment, which is how a boundary rots.
|
|
516
|
+
|
|
517
|
+
## `*.types.ts` — provably free to import
|
|
518
|
+
|
|
519
|
+
No runtime export at all. That is not tidiness: it is what makes importing the module cost nothing in the bundle **and** makes it impossible for it to participate in a runtime import cycle. In a large codebase the second guarantee is the valuable one — an import cycle is only visible when it finally throws.
|
|
520
|
+
|
|
521
|
+
## `*.tracking.ts` — analytics is confined
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
// checkout.tracking.ts
|
|
525
|
+
import { defineTracking } from '@voltro/client'
|
|
526
|
+
|
|
527
|
+
export const checkoutTracking = defineTracking('CheckoutButton', {
|
|
528
|
+
onMount: (props) => ({ event: 'checkout_started', orderId: props.orderId }),
|
|
529
|
+
onClick: 'checkout.confirmed',
|
|
530
|
+
})
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
A component then wires it up with `useTracking(checkoutTracking, props, sink)` — it names a spec, it does not author one. Event names, property bags and the decisions about which fields leave the building all live in one place.
|
|
534
|
+
|
|
535
|
+
`useTracking` itself is a hook, so it is *not* confined — a rule nobody could satisfy is a rule everybody disables. What is confined is `defineTracking`, the declaration.
|
|
536
|
+
|
|
537
|
+
The payoff is not tidiness. "What do we send to third parties" becomes a file listing instead of an archaeology project — which is the only form in which that question can be answered on demand when someone asks about personal data.
|
|
538
|
+
|
|
539
|
+
## What deliberately has NO suffix
|
|
540
|
+
|
|
541
|
+
A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in — less coverage for more cost. The shape rules above fire only on files that *declared* the contract, because declaring it is what makes the promise mean something.
|
|
542
|
+
|
|
543
|
+
`*.store.ts` was in this section until `defineStore` shipped, and the reason it moved out is the rule itself: **suffixes follow primitives, never the reverse.** While client state was something you brought yourself, a suffix for it would have promised nobody anything. Now `voltro check` reads those files — one store per file, and no store mirroring server state — so the name carries a contract.
|
|
544
|
+
|
|
545
|
+
There is no `*.form.tsx`, and it is the most-requested one. A form is a component; what makes it a form is the schema it validates against, which is already declared and already typed. A suffix would add a rename without adding a checkable promise — "contains a `<form>`" is not something another file's correctness depends on. If forms ever gain a framework primitive that other code binds to, the rule above will produce the suffix on its own.
|
|
546
|
+
|
|
547
|
+
## Code you did not write
|
|
548
|
+
|
|
549
|
+
Our rules are **ours**. A shadcn component arrives via `npx shadcn add`, follows shadcn's conventions (many exports per file, a hook beside the component), and is overwritten by the next `add`. Renaming it would break their convention, be undone on the next generator run, and leave the directory half-migrated the moment one file fails to classify.
|
|
550
|
+
|
|
551
|
+
So the taxonomy applies to what you write, always — and to nothing else. Two signals mark a directory as not-yours:
|
|
552
|
+
|
|
553
|
+
1. **`components.json`** at the app root. Its `aliases.ui` names the directory shadcn owns, so a shadcn project needs no configuration at all. Only `aliases.ui` is honoured — `aliases.components` is where your own components live too, and exempting it would silence the taxonomy across most of a codebase.
|
|
554
|
+
2. **A `.voltro-vendored` file** in any directory, whose first line names the source:
|
|
555
|
+
|
|
556
|
+
```text
|
|
557
|
+
src/vendor/.voltro-vendored # "copied from acme-design-system v3"
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The marker is a file with a reason in it rather than a config list on purpose. A config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. `voltro doctor` prints every exemption it honoured, so the escape hatch is never silent.
|
|
561
|
+
|
|
562
|
+
## Migrating
|
|
563
|
+
|
|
564
|
+
`voltro update` renames what it can decide from the exports alone: one component → `*.component.tsx`, one hook → `*.hook.ts`, no runtime exports → `*.types.ts`. Imports travel with the file.
|
|
565
|
+
|
|
566
|
+
Two things it will NOT do:
|
|
567
|
+
|
|
568
|
+
- A file exporting a component **and** a hook is left alone and reported. That is the file the taxonomy most wants split, and no codemod can decide which half keeps the name.
|
|
569
|
+
- `*.component.ui.tsx` is never inferred. "Presentational" is a promise about what a component *may* do; one that merely happens not to write today has not made it.
|
|
@@ -471,7 +471,8 @@ definePluginRoute({
|
|
|
471
471
|
})
|
|
472
472
|
```
|
|
473
473
|
|
|
474
|
-
`source` must name a table
|
|
474
|
+
`source` must name a table that is reactive — every table is, unless it opted
|
|
475
|
+
out with `.nonReactive()`. Omit `source` for a plain
|
|
475
476
|
poll-only plugin query, a mutation, or an action. This is exactly how
|
|
476
477
|
`@voltro/plugin-presence` makes `presence.list` push-driven — the
|
|
477
478
|
`usePresence` roster updates live with no client polling.
|
|
@@ -482,7 +482,7 @@ className={pathname.startsWith('/dashboard') ? 'active' : ''}
|
|
|
482
482
|
URL params from `[name]` segments. Typed via the generic.
|
|
483
483
|
|
|
484
484
|
```tsx
|
|
485
|
-
// src/pages/users/[id].tsx
|
|
485
|
+
// src/pages/users/[id].page.tsx
|
|
486
486
|
import { useParams } from '@voltro/web'
|
|
487
487
|
|
|
488
488
|
const { id } = useParams<{ id: string }>()
|
|
@@ -491,7 +491,7 @@ const { id } = useParams<{ id: string }>()
|
|
|
491
491
|
For catch-all queries:
|
|
492
492
|
|
|
493
493
|
```tsx
|
|
494
|
-
// src/pages/docs/[...slug].tsx
|
|
494
|
+
// src/pages/docs/[...slug].page.tsx
|
|
495
495
|
const { slug } = useParams<{ slug: string }>()
|
|
496
496
|
// /docs/intro/getting-started → slug = "intro/getting-started"
|
|
497
497
|
```
|
|
@@ -499,7 +499,7 @@ const { slug } = useParams<{ slug: string }>()
|
|
|
499
499
|
For multi-segment dynamic paths:
|
|
500
500
|
|
|
501
501
|
```tsx
|
|
502
|
-
// src/pages/orgs/[orgId]/projects/[projectId].tsx
|
|
502
|
+
// src/pages/orgs/[orgId]/projects/[projectId].page.tsx
|
|
503
503
|
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
504
504
|
```
|
|
505
505
|
|
|
@@ -604,7 +604,7 @@ Idempotent — multiple calls for the same path fire one loader. The prefetched
|
|
|
604
604
|
Page's loader output, typed.
|
|
605
605
|
|
|
606
606
|
```tsx
|
|
607
|
-
// src/pages/notes/[id].tsx
|
|
607
|
+
// src/pages/notes/[id].page.tsx
|
|
608
608
|
import { useLoaderData } from '@voltro/web'
|
|
609
609
|
|
|
610
610
|
interface Note {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
_Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework._
|
|
13
13
|
|
|
14
|
-
The web side of a Voltro app uses **file-based routing**: drop a
|
|
14
|
+
The web side of a Voltro app uses **file-based routing**: drop a `*.page.tsx` file under `src/pages/`, the CLI discovers it on every boot + save, and the file becomes a route. No router config, no manual `<Route>` declarations, no codegen step.
|
|
15
|
+
|
|
16
|
+
The suffix is what makes a file a route — the directory tree only decides *which URL*. Anything else under `src/pages/` (components, hooks, tests) is ordinary code and gets no URL, so it can live beside the page that uses it.
|
|
15
17
|
|
|
16
18
|
This section covers everything about how URLs map to React + how Voltro decides when to render, what to ship to the browser, and how to navigate between pages.
|
|
17
19
|
|
|
@@ -23,18 +25,18 @@ src/pages/
|
|
|
23
25
|
├── error.tsx # error boundary
|
|
24
26
|
├── loading.tsx # pending UI
|
|
25
27
|
├── not-found.tsx # 404 fallback
|
|
26
|
-
├── index.tsx # /
|
|
27
|
-
├── about.tsx # /about
|
|
28
|
+
├── index.page.tsx # /
|
|
29
|
+
├── about.page.tsx # /about
|
|
28
30
|
├── (marketing)/ # route group — no URL segment
|
|
29
31
|
│ ├── layout.tsx # marketing-scoped layout
|
|
30
|
-
│ └── pricing.tsx # /pricing
|
|
32
|
+
│ └── pricing.page.tsx # /pricing
|
|
31
33
|
├── users/
|
|
32
34
|
│ ├── layout.tsx # users-scoped layout
|
|
33
35
|
│ ├── error.tsx # users-scoped error boundary
|
|
34
|
-
│ ├── [id].tsx # /users/:id
|
|
35
|
-
│ └── index.tsx # /users
|
|
36
|
+
│ ├── [id].page.tsx # /users/:id
|
|
37
|
+
│ └── index.page.tsx # /users
|
|
36
38
|
└── docs/
|
|
37
|
-
└── [...slug].tsx # /docs/<anything> (catch-all)
|
|
39
|
+
└── [...slug].page.tsx # /docs/<anything> (catch-all)
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
@@ -50,7 +52,7 @@ That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
|
50
52
|
|
|
51
53
|
## Architecture in one paragraph
|
|
52
54
|
|
|
53
|
-
The framework generates a `.framework/app.tsx` on every boot that imports each `*.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.
|
|
55
|
+
The framework generates a `.framework/app.tsx` on every boot that imports each `*.page.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.
|
|
54
56
|
|
|
55
57
|
You don't write any of this. The CLI regenerates it on every save in dev; the build pipeline freezes it for production.
|
|
56
58
|
|
|
@@ -123,7 +125,7 @@ A **page** is any `*.tsx` file under `src/pages/` that's not a special file (`la
|
|
|
123
125
|
## A static page
|
|
124
126
|
|
|
125
127
|
```tsx
|
|
126
|
-
// src/pages/about.tsx → /about
|
|
128
|
+
// src/pages/about.page.tsx → /about
|
|
127
129
|
import type { ReactNode } from 'react'
|
|
128
130
|
|
|
129
131
|
export default function About(): ReactNode {
|
|
@@ -143,9 +145,9 @@ That's it. Save the file, the CLI's discovery sees it on next save, the page is
|
|
|
143
145
|
`index.tsx` maps to the directory's URL:
|
|
144
146
|
|
|
145
147
|
```text
|
|
146
|
-
src/pages/index.tsx → /
|
|
147
|
-
src/pages/users/index.tsx → /users
|
|
148
|
-
src/pages/admin/index.tsx → /admin
|
|
148
|
+
src/pages/index.page.tsx → /
|
|
149
|
+
src/pages/users/index.page.tsx → /users
|
|
150
|
+
src/pages/admin/index.page.tsx → /admin
|
|
149
151
|
```
|
|
150
152
|
|
|
151
153
|
## Dynamic segments
|
|
@@ -153,7 +155,7 @@ src/pages/admin/index.tsx → /admin
|
|
|
153
155
|
Brackets in the filename are dynamic. The captured value comes through `useParams<T>()`:
|
|
154
156
|
|
|
155
157
|
```tsx
|
|
156
|
-
// src/pages/users/[id].tsx → /users/:id
|
|
158
|
+
// src/pages/users/[id].page.tsx → /users/:id
|
|
157
159
|
import { useParams } from '@voltro/web'
|
|
158
160
|
|
|
159
161
|
export default function User() {
|
|
@@ -165,7 +167,7 @@ export default function User() {
|
|
|
165
167
|
Multiple dynamic segments in one path:
|
|
166
168
|
|
|
167
169
|
```text
|
|
168
|
-
src/pages/orgs/[orgId]/projects/[projectId].tsx
|
|
170
|
+
src/pages/orgs/[orgId]/projects/[projectId].page.tsx
|
|
169
171
|
// → /orgs/:orgId/projects/:projectId
|
|
170
172
|
|
|
171
173
|
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
@@ -176,7 +178,7 @@ const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
|
176
178
|
`[...name]` captures one OR more URL segments as a single param value (joined by `/`):
|
|
177
179
|
|
|
178
180
|
```tsx
|
|
179
|
-
// src/pages/docs/[...slug].tsx → /docs/<anything>
|
|
181
|
+
// src/pages/docs/[...slug].page.tsx → /docs/<anything>
|
|
180
182
|
const { slug } = useParams<{ slug: string }>()
|
|
181
183
|
// /docs/intro/getting-started → slug = "intro/getting-started"
|
|
182
184
|
```
|
|
@@ -184,7 +186,7 @@ const { slug } = useParams<{ slug: string }>()
|
|
|
184
186
|
**Optional** catch-all (matches the base URL too):
|
|
185
187
|
|
|
186
188
|
```tsx
|
|
187
|
-
// src/pages/docs/[[...slug]].tsx
|
|
189
|
+
// src/pages/docs/[[...slug]].page.tsx
|
|
188
190
|
// /docs → slug = ""
|
|
189
191
|
// /docs/foo → slug = "foo"
|
|
190
192
|
// /docs/foo/bar → slug = "foo/bar"
|
|
@@ -199,10 +201,10 @@ When multiple files could match (static, dynamic, catch-all), priority is:
|
|
|
199
201
|
3. Optional catch-all (`[[...slug]]`) wins over required catch-all (`[...slug]`) — the optional form scores as more specific, so it matches first.
|
|
200
202
|
|
|
201
203
|
```text
|
|
202
|
-
src/pages/users/index.tsx # /users → wins for /users
|
|
203
|
-
src/pages/users/[id].tsx # /users/:id → wins for /users/42
|
|
204
|
-
src/pages/users/new.tsx # /users/new → wins (static beats dynamic)
|
|
205
|
-
src/pages/[...rest].tsx # everything else
|
|
204
|
+
src/pages/users/index.page.tsx # /users → wins for /users
|
|
205
|
+
src/pages/users/[id].page.tsx # /users/:id → wins for /users/42
|
|
206
|
+
src/pages/users/new.page.tsx # /users/new → wins (static beats dynamic)
|
|
207
|
+
src/pages/[...rest].page.tsx # everything else
|
|
206
208
|
```
|
|
207
209
|
|
|
208
210
|
## Query strings
|
|
@@ -238,20 +240,23 @@ export default function SearchPage() {
|
|
|
238
240
|
|
|
239
241
|
Parse the query string explicitly via `useServerRequest()` on the server and `window.location.search` on the client, as shown above.
|
|
240
242
|
|
|
241
|
-
##
|
|
243
|
+
## Co-locating components, hooks and tests
|
|
242
244
|
|
|
243
|
-
|
|
245
|
+
Only `*.page.tsx` is a route. Everything else under `src/pages/` is ordinary code and may sit next to the page that uses it:
|
|
244
246
|
|
|
245
247
|
```text
|
|
246
248
|
src/pages/
|
|
247
|
-
├── _components/
|
|
248
|
-
│ └── UserCard.tsx # NOT a route — import from siblings
|
|
249
249
|
├── users/
|
|
250
|
-
│ ├──
|
|
251
|
-
│
|
|
250
|
+
│ ├── index.page.tsx # → /users
|
|
251
|
+
│ ├── index.page.test.tsx # its test
|
|
252
|
+
│ ├── [id].page.tsx # → /users/:id
|
|
253
|
+
│ ├── UserCard.tsx # a component — no URL
|
|
254
|
+
│ └── useFilters.ts # a hook — no URL
|
|
252
255
|
```
|
|
253
256
|
|
|
254
|
-
|
|
257
|
+
No naming trick is needed to keep something out of the router: the absence of the suffix already does it. A `_`-prefixed directory has **no special meaning** — it is neither required nor recognised.
|
|
258
|
+
|
|
259
|
+
> Before this convention, every `.tsx` under `src/pages/` became a route, so a co-located component silently got a URL. That route rendered nothing and nobody visited it in dev; the failure surfaced at the first production build. If you are upgrading, `voltro update` renames your pages for you.
|
|
255
260
|
|
|
256
261
|
## Trailing slashes
|
|
257
262
|
|
|
@@ -314,11 +319,11 @@ Conventions:
|
|
|
314
319
|
```text
|
|
315
320
|
src/pages/
|
|
316
321
|
├── layout.tsx # outer (every page)
|
|
317
|
-
├── about.tsx # /about → wrapped in outer layout
|
|
322
|
+
├── about.page.tsx # /about → wrapped in outer layout
|
|
318
323
|
└── dashboard/
|
|
319
324
|
├── layout.tsx # nested (only /dashboard/*)
|
|
320
|
-
├── index.tsx # /dashboard
|
|
321
|
-
└── settings.tsx # /dashboard/settings
|
|
325
|
+
├── index.page.tsx # /dashboard
|
|
326
|
+
└── settings.page.tsx # /dashboard/settings
|
|
322
327
|
```
|
|
323
328
|
|
|
324
329
|
For `/dashboard/settings`, the React tree is:
|
|
@@ -341,13 +346,13 @@ A directory in `(parentheses)` does NOT contribute a URL segment, but its layout
|
|
|
341
346
|
src/pages/
|
|
342
347
|
├── (marketing)/
|
|
343
348
|
│ ├── layout.tsx # marketing-scoped chrome
|
|
344
|
-
│ ├── index.tsx # /
|
|
345
|
-
│ ├── pricing.tsx # /pricing
|
|
346
|
-
│ └── about.tsx # /about
|
|
349
|
+
│ ├── index.page.tsx # /
|
|
350
|
+
│ ├── pricing.page.tsx # /pricing
|
|
351
|
+
│ └── about.page.tsx # /about
|
|
347
352
|
└── (app)/
|
|
348
353
|
├── layout.tsx # authenticated app chrome
|
|
349
|
-
├── dashboard.tsx # /dashboard
|
|
350
|
-
└── settings.tsx # /settings
|
|
354
|
+
├── dashboard.page.tsx # /dashboard
|
|
355
|
+
└── settings.page.tsx # /settings
|
|
351
356
|
```
|
|
352
357
|
|
|
353
358
|
Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.
|
|
@@ -491,7 +496,7 @@ Those four are the **complete** set. An unrecognised value is a hard error namin
|
|
|
491
496
|
## static (SSG)
|
|
492
497
|
|
|
493
498
|
```tsx
|
|
494
|
-
// src/pages/about.tsx
|
|
499
|
+
// src/pages/about.page.tsx
|
|
495
500
|
export const renderMode = 'static' as const
|
|
496
501
|
```
|
|
497
502
|
|
|
@@ -504,7 +509,7 @@ At `voltro build`:
|
|
|
504
509
|
For dynamic patterns, export `getStaticPaths` to enumerate every URL to pre-render:
|
|
505
510
|
|
|
506
511
|
```tsx
|
|
507
|
-
// src/pages/blog/[slug].tsx
|
|
512
|
+
// src/pages/blog/[slug].page.tsx
|
|
508
513
|
export const renderMode = 'static' as const
|
|
509
514
|
|
|
510
515
|
export const getStaticPaths = async () => [
|
|
@@ -729,7 +734,7 @@ Both are static module exports — the framework discovers them, the build pipel
|
|
|
729
734
|
## A loader
|
|
730
735
|
|
|
731
736
|
```tsx
|
|
732
|
-
// src/pages/notes/[id].tsx
|
|
737
|
+
// src/pages/notes/[id].page.tsx
|
|
733
738
|
import { useLoaderData } from '@voltro/web'
|
|
734
739
|
|
|
735
740
|
interface Note {
|
|
@@ -839,7 +844,7 @@ splits the loader's result into data that blocks the shell and data that
|
|
|
839
844
|
soon as the promise settles.
|
|
840
845
|
|
|
841
846
|
```tsx
|
|
842
|
-
// src/pages/dashboard.tsx
|
|
847
|
+
// src/pages/dashboard.page.tsx
|
|
843
848
|
import { Await, defer, useLoaderData } from '@voltro/web'
|
|
844
849
|
|
|
845
850
|
export const renderMode = 'ssr' as const // required — see below
|
|
@@ -959,7 +964,7 @@ Use `signal` for any fetch that could outlive the request — pass it to `fetch(
|
|
|
959
964
|
Instead of hand-rolling a `fetch(INTERNAL_API/...)`, a server-side loader can call the backend rpc directly through `ctx.query` — the same query tags the client subscribes to, resolved to their initial snapshot:
|
|
960
965
|
|
|
961
966
|
```tsx
|
|
962
|
-
// src/pages/notes/[id].tsx
|
|
967
|
+
// src/pages/notes/[id].page.tsx
|
|
963
968
|
import { useSubscription } from '@voltro/client'
|
|
964
969
|
import { useLoaderData, type PageMeta } from '@voltro/web'
|
|
965
970
|
|
|
@@ -1131,7 +1136,7 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
|
|
|
1131
1136
|
### Authenticated dashboard with cookie-driven loader
|
|
1132
1137
|
|
|
1133
1138
|
```tsx
|
|
1134
|
-
// src/pages/dashboard/index.tsx
|
|
1139
|
+
// src/pages/dashboard/index.page.tsx
|
|
1135
1140
|
import { useLoaderData } from '@voltro/web'
|
|
1136
1141
|
|
|
1137
1142
|
export const renderMode = 'ssr' as const
|
|
@@ -1157,7 +1162,7 @@ export default function Dashboard() {
|
|
|
1157
1162
|
`getStaticPaths` has no framework store — it reads its own content source (a CMS client, the filesystem, an API). The loader runs server-side and fetches via `query` (the backend rpc, resolved to its first snapshot):
|
|
1158
1163
|
|
|
1159
1164
|
```tsx
|
|
1160
|
-
// src/pages/blog/[slug].tsx
|
|
1165
|
+
// src/pages/blog/[slug].page.tsx
|
|
1161
1166
|
import { listPostSlugs, type Post } from '../../content/posts'
|
|
1162
1167
|
|
|
1163
1168
|
export const renderMode = 'static' as const
|
|
@@ -1558,7 +1563,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
|
|
|
1558
1563
|
Use it in a page:
|
|
1559
1564
|
|
|
1560
1565
|
```tsx
|
|
1561
|
-
// src/pages/blog/[slug].tsx
|
|
1566
|
+
// src/pages/blog/[slug].page.tsx
|
|
1562
1567
|
import LikeButton from '../../components/LikeButton.island'
|
|
1563
1568
|
|
|
1564
1569
|
export const renderMode = 'static' as const
|
|
@@ -1017,7 +1017,8 @@ be on. Rather than repeat that check in every `useMutation` call site, each api'
|
|
|
1017
1017
|
runtime carries an error bus; this hook subscribes to it with React lifecycle.
|
|
1018
1018
|
|
|
1019
1019
|
```tsx
|
|
1020
|
-
import { useOnRpcError
|
|
1020
|
+
import { useOnRpcError } from '@voltro/client'
|
|
1021
|
+
import { errorTag } from '@voltro/protocol'
|
|
1021
1022
|
|
|
1022
1023
|
useOnRpcError('app', useCallback((e) => {
|
|
1023
1024
|
if (errorTag(e.error) === 'Unauthenticated') redirectToSignIn()
|
|
@@ -1093,16 +1094,26 @@ readable in a single place.
|
|
|
1093
1094
|
```tsx
|
|
1094
1095
|
import { defineTracking, useTracking } from '@voltro/client'
|
|
1095
1096
|
|
|
1096
|
-
|
|
1097
|
+
interface CheckoutButtonProps {
|
|
1098
|
+
readonly plan: 'free' | 'pro'
|
|
1099
|
+
readonly onClick: () => void
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const spec = defineTracking<CheckoutButtonProps>('CheckoutButton', {
|
|
1097
1103
|
onMount: 'checkout.viewed',
|
|
1098
1104
|
onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
|
|
1099
1105
|
})
|
|
1100
1106
|
|
|
1101
|
-
const CheckoutButton = (props) => {
|
|
1107
|
+
const CheckoutButton = (props: CheckoutButtonProps) => {
|
|
1102
1108
|
const tracked = useTracking(spec, props, sink)
|
|
1103
1109
|
return <button {...tracked}>Checkout</button>
|
|
1104
1110
|
}
|
|
1105
1111
|
```
|
|
1112
|
+
**Name the props.** The type parameter is what lets a payload builder read
|
|
1113
|
+
`props.plan` instead of indexing into `unknown` and casting — and a cast is the
|
|
1114
|
+
last thing that belongs in the one file that states what leaves the browser. It
|
|
1115
|
+
defaults to an untyped bag, so a loose catalogue still compiles; `useTracking`
|
|
1116
|
+
then returns the props type, which is what makes `{...tracked}` typecheck at all.
|
|
1106
1117
|
|
|
1107
1118
|
`useTracking(spec, props, sink)` returns a **copy of your props** with every
|
|
1108
1119
|
callback the map names wrapped, so invoking `onClick` fires its event and then
|
|
@@ -137,7 +137,6 @@ export const notes = table('notes', {
|
|
|
137
137
|
id: id({ prefix: 'note' }), title: text(), body: text(), done: boolean().default(false),
|
|
138
138
|
})
|
|
139
139
|
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
140
|
-
.reactive() // subscriptions get a fresh snapshot/delta on every write to notes
|
|
141
140
|
|
|
142
141
|
export type Note = InferRow<typeof notes>
|
|
143
142
|
export const database = databaseHandle({ actors, tenants, notes })
|
|
@@ -588,13 +587,12 @@ export const orders = table('orders', {
|
|
|
588
587
|
amountCents: integer(),
|
|
589
588
|
})
|
|
590
589
|
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
591
|
-
.reactive() // subscriber + live subscriptions wake on every write to orders
|
|
592
590
|
|
|
593
591
|
export type Order = InferRow<typeof orders>
|
|
594
592
|
export const database = databaseHandle({ actors, tenants, orders })
|
|
595
593
|
```
|
|
596
594
|
|
|
597
|
-
`tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak.
|
|
595
|
+
`tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak. Every table is in the matcher engine by default, so the `orderChanges` subscriber and any live query fire on every mutation write. See [multi-tenancy](/docs/multi-tenancy/overview).
|
|
598
596
|
|
|
599
597
|
## Place — mutation that emits a domain event
|
|
600
598
|
|
|
@@ -1292,7 +1290,6 @@ export const docs = table('docs', {
|
|
|
1292
1290
|
}),
|
|
1293
1291
|
)
|
|
1294
1292
|
// Opt into the matcher engine so subscriptions over `docs` re-fire on write.
|
|
1295
|
-
.reactive()
|
|
1296
1293
|
|
|
1297
1294
|
export type Doc = InferRow<typeof docs>
|
|
1298
1295
|
|
|
@@ -1459,7 +1456,6 @@ export const authors = table('authors', {
|
|
|
1459
1456
|
})
|
|
1460
1457
|
// tenant() pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
1461
1458
|
.with(tenant())
|
|
1462
|
-
.reactive()
|
|
1463
1459
|
```
|
|
1464
1460
|
|
|
1465
1461
|
```ts
|
|
@@ -1588,7 +1584,6 @@ export const books = table('books', {
|
|
|
1588
1584
|
weights: { title: 'A', summary: 'B' },
|
|
1589
1585
|
})
|
|
1590
1586
|
.with(tenant())
|
|
1591
|
-
.reactive()
|
|
1592
1587
|
```
|
|
1593
1588
|
|
|
1594
1589
|
The query executor compiles the FTS predicate via `.matching('bookSearch', q)` — resolving the index's covered columns + config from the declaration on the table. No tenant filter is added by hand; `books` carries `tenant()`, so the runtime AND-merges `eq('tenantId', subject.tenantId)`:
|
|
@@ -2935,7 +2930,6 @@ export const users = table('users', {
|
|
|
2935
2930
|
// adds deactivatedAt + deactivatedBy (→ actors); pulls audit() transitively.
|
|
2936
2931
|
// NO defaultWhere — a deactivated user still shows up in queries.
|
|
2937
2932
|
.with(deactivation())
|
|
2938
|
-
.reactive()
|
|
2939
2933
|
```
|
|
2940
2934
|
|
|
2941
2935
|
Deactivate with a normal update — `deactivatedAt` is just a column:
|
|
@@ -3122,7 +3116,6 @@ export const syncedEvents = table('synced_events', {
|
|
|
3122
3116
|
sequence: integer(),
|
|
3123
3117
|
})
|
|
3124
3118
|
.with(tenant()) // tenantId + audit columns, auto-stamped from the subject
|
|
3125
|
-
.reactive() // events.list subscription wakes on every insert
|
|
3126
3119
|
|
|
3127
3120
|
export type SyncedEvent = InferRow<typeof syncedEvents>
|
|
3128
3121
|
export const database = databaseHandle({ actors, tenants, syncedEvents })
|