@voltro/cli 0.6.0 → 0.8.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 +41 -0
- package/bin/voltro.mjs +15 -6
- package/dist/apiBuild-CmtI6Ygh.js +2 -0
- package/dist/{apiBuild-BxY44VGj.js → apiBuild-OrDkXGFX.js} +21 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-My_YlUJV.js → commands-DT4PipBz.js} +2489 -2972
- package/dist/dev-Ca1jdwnt.js +2 -0
- package/dist/dev-Ga65K4jc.js +6492 -0
- package/dist/index.js +1 -1
- package/dist/inspectMetrics-EK0CcHyu.js +3929 -0
- package/dist/{serveCommand-C-XQ89NJ.js → serveCommand-CR5xnYZt.js} +209 -208
- package/dist/serveEntry.js +3 -2
- package/dist/start-DuyBQxW3.js +977 -0
- package/dist/startEntry.d.ts +5 -0
- package/dist/startEntry.js +3 -0
- package/package.json +17 -17
- package/templates/AGENTS.core.md +15 -4
- package/templates/AGENTS.md +15 -4
- package/templates/agent-docs/authentication.md +111 -13
- package/templates/agent-docs/cli.md +94 -6
- package/templates/agent-docs/data.md +99 -31
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/reference.md +20 -0
- package/templates/agent-docs/routing.md +158 -24
- package/templates/agent-docs/testing.md +82 -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-storage/package.json +8 -8
- 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-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +8 -8
- 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-blank/package.json +7 -7
- 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-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/dist/apiBuild-Bk4JBt-i.js +0 -2
- package/dist/dev-Bjq-nIvH.js +0 -10186
- package/dist/dev-D6b74iXI.js +0 -2
|
@@ -630,6 +630,26 @@ Available in:
|
|
|
630
630
|
|
|
631
631
|
Returns `null` on pages without a `loader`. The generic narrows the type.
|
|
632
632
|
|
|
633
|
+
Precisely, it returns `LoaderData<T>`. For every ordinary loader that IS `T`. For
|
|
634
|
+
a loader that returned `defer()`, `LoaderData<T>` flattens the two buckets into
|
|
635
|
+
one object — eager fields as values, deferred fields as `Promise<T>` — so the
|
|
636
|
+
compiler tells you which fields have to be rendered through
|
|
637
|
+
[`<Await>`](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await):
|
|
638
|
+
|
|
639
|
+
```tsx
|
|
640
|
+
export const loader = async ({ query }) => defer(
|
|
641
|
+
{ user: await query('users.me') },
|
|
642
|
+
{ report: query('reports.quarterly') },
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
// user: User report: Promise<Report>
|
|
646
|
+
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
If you wrap this hook in your own generic helper, propagate the mapped type
|
|
650
|
+
(`<D,>(): LoaderData<D> => useLoaderData<D>()`) — `LoaderData<D>` is not
|
|
651
|
+
assignable to a bare type parameter `D`.
|
|
652
|
+
|
|
633
653
|
See [Loaders & meta](/docs/routing/loaders-and-meta) for the server-side counterpart.
|
|
634
654
|
|
|
635
655
|
## Compositions
|
|
@@ -537,29 +537,39 @@ Cost: every request triggers a fresh render. For very high-traffic pages, prefer
|
|
|
537
537
|
|
|
538
538
|
### Streaming SSR
|
|
539
539
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
540
|
+
An `ssr` page is **streamed**, on both boot paths (`voltro dev` and `voltro
|
|
541
|
+
start`). The server sends the `<head>` and the page shell as soon as they are
|
|
542
|
+
rendered, then flushes each `<Suspense>` boundary as its data settles, inside
|
|
543
|
+
the same response.
|
|
544
|
+
|
|
545
|
+
For a page with no deferred data this is a time-to-first-byte win and nothing
|
|
546
|
+
else — React's render loop still runs to completion in one pass, so a slow
|
|
547
|
+
render is still a slow render. The lever that matters is `defer()`: it puts a
|
|
548
|
+
real `<Suspense>` boundary in the tree, which is what lets the server return to
|
|
549
|
+
the event loop while a slow value is still pending. See
|
|
550
|
+
[Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await).
|
|
551
|
+
|
|
552
|
+
Measured against a real server rendering a page with one 400ms deferred field:
|
|
553
|
+
first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
|
|
554
|
+
10ms for the duration of that request was served 30 times with a 3ms median and
|
|
555
|
+
a 7ms maximum. The request does not occupy the event loop while it waits.
|
|
556
|
+
|
|
557
|
+
Two consequences worth knowing:
|
|
558
|
+
|
|
559
|
+
- **The entry script is emitted as a bootstrap module.** A plain
|
|
560
|
+
`<script type="module">` is deferred until the document finishes parsing,
|
|
561
|
+
which on a streamed response is *after the last deferred boundary* — the
|
|
562
|
+
framework hands the URL to React instead, so it goes out `async` at the end
|
|
563
|
+
of the shell and hydration starts immediately.
|
|
564
|
+
- **`isr` and `static` are still buffered**, because both produce a stored
|
|
565
|
+
artefact rather than a response. That is also why `defer()` is an error on
|
|
566
|
+
those modes.
|
|
567
|
+
|
|
568
|
+
Apps do not call the renderer directly. If you are building your own server on
|
|
569
|
+
top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
|
|
570
|
+
same options as `renderPageToHtml` plus `bootstrapModules` and the stream
|
|
571
|
+
callbacks, resolves `meta` synchronously so the `<head>` can go out first, and
|
|
572
|
+
returns a Node pipeable stream.
|
|
563
573
|
|
|
564
574
|
## isr (incremental static regeneration)
|
|
565
575
|
|
|
@@ -660,7 +670,7 @@ That last case is how dynamic `static` routes work in dev / when `getStaticPaths
|
|
|
660
670
|
## What doesn't work
|
|
661
671
|
|
|
662
672
|
- **Switching `renderMode` per request.** It's a static module export — one value per build.
|
|
663
|
-
-
|
|
673
|
+
- **Assuming `ssr` is client-only in dev.** It is not: `voltro dev` runs the same SSR path `voltro start` does, streaming included, so cookie-driven gates and `useServerRequest()` behave the same in both. What dev does NOT do is pre-render `static` pages — those fall through to the SPA shell.
|
|
664
674
|
- **`isr` with `cacheInvalidatesOn` against memory cache.** Memory cache is per-process; CDC events fire across processes. Use `SSR_CACHE=postgres`.
|
|
665
675
|
|
|
666
676
|
## Where to read next
|
|
@@ -730,6 +740,130 @@ export default function NotePage(): ReactNode {
|
|
|
730
740
|
|
|
731
741
|
For `static` pages with `getStaticPaths`, the loader runs once per enumerated path.
|
|
732
742
|
|
|
743
|
+
### Server-rendered data reaches the first client render
|
|
744
|
+
|
|
745
|
+
For `static`, `ssr` and `isr` pages the framework inlines the loader's result
|
|
746
|
+
into the HTML document (a `<script type="application/json"
|
|
747
|
+
id="__voltro_state__">` tag) and the browser adopts it before React hydrates.
|
|
748
|
+
Two consequences worth designing around:
|
|
749
|
+
|
|
750
|
+
- **`useLoaderData()` returns real data on the very first client render.** It is
|
|
751
|
+
not `undefined` until an effect has run, so a page can dereference its loader
|
|
752
|
+
data directly (`data.title`) without a guard, and a layout renders its
|
|
753
|
+
loader's value identically on the server and on the client — no hydration
|
|
754
|
+
mismatch, no flash of fallback content.
|
|
755
|
+
- **The loader does NOT re-run on that initial hydration.** It already ran on
|
|
756
|
+
the server; running it again in the browser would just re-fetch what the page
|
|
757
|
+
is already showing. If you need work to happen after mount (refreshing data,
|
|
758
|
+
a side effect), put it in an effect or use `useSubscription` — do not rely on
|
|
759
|
+
the loader firing a second time.
|
|
760
|
+
|
|
761
|
+
Client-side navigation is unchanged: moving to another route runs that route's
|
|
762
|
+
loaders in the browser as usual. A `spa` page has no server render, so its
|
|
763
|
+
loader runs on the client on first mount.
|
|
764
|
+
|
|
765
|
+
Layout loaders are inlined the same way, keyed per layout, so each layout reads
|
|
766
|
+
its OWN data on the first render. The one exception is `voltro build`'s static
|
|
767
|
+
prerender, which runs page loaders but not layout loaders — a `static` page's
|
|
768
|
+
layouts resolve their data on the client after mount. The PAGE keeps its own
|
|
769
|
+
inlined data for the whole of that window, however slow those layout loaders
|
|
770
|
+
are, so the no-guard promise above holds on a prerendered page too: only the
|
|
771
|
+
layout shows its no-data fallback until its loader settles.
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
## Deferring slow data: `defer()` + `<Await>`
|
|
775
|
+
|
|
776
|
+
A loader blocks the whole response. One slow field therefore costs every byte
|
|
777
|
+
of the page — the user stares at nothing while a report query runs. `defer()`
|
|
778
|
+
splits the loader's result into data that blocks the shell and data that
|
|
779
|
+
**streams in after it**, behind a `<Suspense>` boundary the server flushes as
|
|
780
|
+
soon as the promise settles.
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
// src/pages/dashboard.tsx
|
|
784
|
+
import { Await, defer, useLoaderData } from '@voltro/web'
|
|
785
|
+
|
|
786
|
+
export const renderMode = 'ssr' as const // required — see below
|
|
787
|
+
|
|
788
|
+
export const loader = async ({ query }: { query?: <T>(tag: string, input?: Record<string, unknown>) => Promise<T> }) => defer(
|
|
789
|
+
// EAGER — awaited before the shell renders. Keep this fast.
|
|
790
|
+
{ user: await query?.<User>('users.me') },
|
|
791
|
+
// DEFERRED — NOT awaited. Each becomes a promise on useLoaderData().
|
|
792
|
+
{ report: query!<Report>('reports.quarterly') },
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
export default function Dashboard() {
|
|
796
|
+
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
|
|
797
|
+
return (
|
|
798
|
+
<main>
|
|
799
|
+
<h1>Hello {user?.name}</h1>
|
|
800
|
+
|
|
801
|
+
<Await value={report} fallback={<ReportSkeleton />}>
|
|
802
|
+
{(report) => <ReportTable rows={report.rows} />}
|
|
803
|
+
</Await>
|
|
804
|
+
</main>
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
What the browser sees: the full page with `<ReportSkeleton />` in place,
|
|
810
|
+
immediately — then the real table, injected in a later chunk of the same
|
|
811
|
+
response. No second request, no client-side fetch, no loading spinner driven by
|
|
812
|
+
`useEffect`.
|
|
813
|
+
|
|
814
|
+
**Two explicit buckets, not one object.** `defer(eager, deferred)` takes them
|
|
815
|
+
separately rather than treating any promise-valued field as deferred. Deferral
|
|
816
|
+
is then something you wrote down, not something inferred from a value's runtime
|
|
817
|
+
shape — and `useLoaderData()` can type it: eager fields come back as values,
|
|
818
|
+
deferred fields as `Promise<T>`, so the compiler tells you which ones need an
|
|
819
|
+
`<Await>`.
|
|
820
|
+
|
|
821
|
+
### `<Await>`
|
|
822
|
+
|
|
823
|
+
| Prop | Meaning |
|
|
824
|
+
|---|---|
|
|
825
|
+
| `value` | A deferred field off `useLoaderData()`. |
|
|
826
|
+
| `fallback` | Rendered until the value arrives. **This is what ships in the streamed shell** — keep it cheap and layout-stable. |
|
|
827
|
+
| `children` | `(value) => ReactNode` — rendered with the resolved value. |
|
|
828
|
+
| `errorFallback` | Rendered if the deferred promise rejects. Without it, a rejection renders nothing in that subtree. |
|
|
829
|
+
|
|
830
|
+
`<Await>` owns the `<Suspense>` boundary and the hydration handoff for the
|
|
831
|
+
streamed value. Do not hand-roll it with `<Suspense>` + `use()` — the server
|
|
832
|
+
and the client would then have to agree on a wire format that the framework
|
|
833
|
+
otherwise guarantees by construction.
|
|
834
|
+
|
|
835
|
+
A rejected deferred value never takes the page down: it renders
|
|
836
|
+
`errorFallback` in place, on the server and on the client alike.
|
|
837
|
+
|
|
838
|
+
### `defer()` requires `renderMode: 'ssr'` and full interactivity
|
|
839
|
+
|
|
840
|
+
Every other combination is a **hard error at boot or build**, naming the page —
|
|
841
|
+
because each one fails silently otherwise:
|
|
842
|
+
|
|
843
|
+
| Combination | Why it is rejected |
|
|
844
|
+
|---|---|
|
|
845
|
+
| `renderMode: 'static'` | The static prerender uses `renderToString`, which does not support Suspense. It emits an errored boundary and a "switched to client rendering" template with no warning — the artefact would ship a permanent fallback. |
|
|
846
|
+
| `renderMode: 'isr'` | ISR caches a completed HTML string. Filling it in would make `defer()` a silent no-op that still reads like it streams. |
|
|
847
|
+
| `interactive: 'none'` | Revealing a streamed boundary needs React's inline reveal scripts, and this mode ships no JS. The fallback would be permanent. |
|
|
848
|
+
| `interactive: 'islands'` | The page's React root never hydrates, so nothing consumes the streamed value. |
|
|
849
|
+
|
|
850
|
+
In all four cases the fix is the same: put the value in the eager bucket (or
|
|
851
|
+
return it directly) and let the page render as it did before.
|
|
852
|
+
|
|
853
|
+
### Layout loaders can defer too
|
|
854
|
+
|
|
855
|
+
A `layout.tsx` loader may return `defer()` under the same rules. Its deferred
|
|
856
|
+
fields are keyed per layout, so a layout reads its own promises via
|
|
857
|
+
`useLoaderData()` exactly as a page does.
|
|
858
|
+
|
|
859
|
+
### Client-side navigation
|
|
860
|
+
|
|
861
|
+
On a client-side navigation there is no server render, so the loader runs in the
|
|
862
|
+
browser and its deferred fields are ordinary promises. `<Await>` renders the
|
|
863
|
+
fallback and swaps in the content when they settle — the same code, driven by
|
|
864
|
+
React alone.
|
|
865
|
+
|
|
866
|
+
|
|
733
867
|
## Loader arguments
|
|
734
868
|
|
|
735
869
|
```ts
|
|
@@ -116,6 +116,8 @@ test('searchDocs returns matching rows', async () => {
|
|
|
116
116
|
| `ai` | — | Injected AI mock (a `mockAi({...})` value). |
|
|
117
117
|
| `llmResponses` | `[]` | Queued responses for the bundled `ctx.llm` (`MockLLM`). |
|
|
118
118
|
| `env` | ambient `process.env` | Env values sealed into the boot snapshot so handler code reading `getSecret('X')` / `serverEnv.X` resolves under test. Merged over `process.env` (these win). |
|
|
119
|
+
| `relations` | — | `relations()` specs to register for this context — the boot sweep's stand-in. See [Eager loads under test](#eager-loads-under-test). |
|
|
120
|
+
| `rowFilter` | the registered filter | A row filter for this context only, instead of the process-global `setRowFilter(...)`. See [Row-level security under test](#row-level-security-under-test). |
|
|
119
121
|
|
|
120
122
|
The returned `TestContext` carries `{ clock, email, llm, ai?, request, cache, store, withSubject, withTenant }` — read the acting subject at `ctx.request.subject` and the in-memory cache at `ctx.cache`.
|
|
121
123
|
|
|
@@ -137,6 +139,31 @@ const note = await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
|
|
|
137
139
|
await expect(invoke(createNote, createNoteHandler, { title: 42 }, ctx)).rejects.toThrow()
|
|
138
140
|
```
|
|
139
141
|
|
|
142
|
+
### Effect-mode handlers run too
|
|
143
|
+
|
|
144
|
+
A handler may be written `async` **or** as an `Effect` — the framework's contract is "your choice, per handler", and the dispatcher runs both. `invoke` makes the same test, so an `Effect`-returning executor is *executed*, and `invoke` resolves to its success value (typed as that value, not as the `Effect`):
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
export const publishNote = (input: { id: string }, ctx: AppContext) =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const store = yield* EffectStore
|
|
150
|
+
yield* store.update('notes', input.id, { published: true })
|
|
151
|
+
return 'published'
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const out = await invoke(publish, publishNote, { id: 'n1' }, ctx)
|
|
155
|
+
expect(out).toBe('published') // the value — not an un-run Effect
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A failure on the typed error channel rejects with **that error**, exactly as an async handler's `throw` does — so the same assertion works for either mode:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
await expect(invoke(publish, publishNote, { id: 'gone' }, ctx))
|
|
162
|
+
.rejects.toMatchObject({ _tag: 'NoteNotFound' })
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`EffectStore` and `SubjectService` are provided over the context the handler is actually given — inside a mutation that is the *transactional* one, so an Effect handler's writes roll back with everything else. Guards, the input decode, the transaction, the deadlock replay, `afterCommit` and the plugin interceptors all wrap the Effect form identically. An app's own `layers:` and the aggregate registry are **not** provided: those are boot injections the harness has no access to.
|
|
166
|
+
|
|
140
167
|
### Guards are enforced
|
|
141
168
|
|
|
142
169
|
An unauthorized caller is refused with the typed `ScopeError` — the same error a client would receive — before the handler runs:
|
|
@@ -325,6 +352,61 @@ const raw = await ctx.store.select('notes').unscoped().withDeleted().all()
|
|
|
325
352
|
expect(raw[0]?.deletedAt).not.toBeNull() // …but still there, tombstoned
|
|
326
353
|
```
|
|
327
354
|
|
|
355
|
+
## Eager loads under test
|
|
356
|
+
|
|
357
|
+
`relations()` is **pure** — it returns a spec, it does not register one. In production `voltro dev` discovers every `*.relations.ts` and registers what it exports; a unit test runs no boot, so importing the module registers nothing and the first `.with({ … })` fails with *"no relations registered"*. Hand the specs to the context instead:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { teamRelations } from '../db/teams.relations'
|
|
361
|
+
|
|
362
|
+
const ctx = makeTestContext({
|
|
363
|
+
relations: [teamRelations],
|
|
364
|
+
store: mockStore({ teams: [{ id: 't1' }], members: [{ id: 'm1', teamId: 't1' }] }),
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const rows = await ctx.store.select('teams').with({ members: true }).all()
|
|
368
|
+
expect(rows[0].members).toHaveLength(1)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The relations registry is **process-global**, so the option *replaces* it with exactly the specs you pass rather than adding to it. That is what keeps two `makeTestContext({ relations: [...] })` calls in one file independent — additive registration would throw `duplicate relation` on a re-registered spec and would carry the first test's relations into the second. Omitting the option leaves the registry untouched.
|
|
372
|
+
|
|
373
|
+
## Row-level security under test
|
|
374
|
+
|
|
375
|
+
`ctx.store` applies the app's [row filter](/docs/authentication/row-level-security) for the context's subject: registered with `setRowFilter(...)`, resolved once per context, AND-merged into every read. Both read paths are covered (the fluent builders and `store.query(descriptor)`), `.unscoped()` does **not** bypass it — that opts out of tenant isolation, not of authorization — and a `system` subject bypasses it, exactly as at runtime.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const ctx = makeTestContext({ subject: alice, store: mockStore({ tickets: seed }), rowFilter: ownTickets })
|
|
379
|
+
|
|
380
|
+
const rows = await ctx.store.select('tickets').all()
|
|
381
|
+
expect(rows.map((r) => r.id)).not.toContain('bobs-ticket') // the rule, asserted
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Pass `rowFilter:` — as above — to scope a filter to **this context only**. `setRowFilter` is process-global: registered in one test it silently constrains every later test in the same worker, and a forgotten `afterEach` surfaces as a failure in an unrelated file. Either way the resolution is the runtime's own, so the retry schedule, the system bypass and the `onLoadError` policy behave identically: a filter whose `load` fails refuses the read (with `RowFilterUnavailable`, or zero rows under `onLoadError: 'deny'`) rather than quietly returning everything.
|
|
385
|
+
|
|
386
|
+
### Filters over SHARED resources
|
|
387
|
+
|
|
388
|
+
A filter over rows the user *owns* needs no database — the subject carries the id. A filter over rows *shared with* the user must read a membership table, and `load` is `Effect<Ctx, unknown>` with `R = never`, so it cannot `yield*` a store service. Its only route is [`runAsSystem`](/docs/multi-tenancy/edge-cases), and `makeTestContext` wires its seeded store into that, so this resolves under test exactly as it does at runtime:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
const sharedLists: RowFilter<{ listIds: ReadonlyArray<string> }> = {
|
|
392
|
+
load: (subject) =>
|
|
393
|
+
Effect.promise(() =>
|
|
394
|
+
runAsSystem(async (sys) => {
|
|
395
|
+
const rows = await sys.store.select('listMembers').where('userId', String(subject.id)).all()
|
|
396
|
+
return { listIds: rows.map((r) => String(r.listId)) }
|
|
397
|
+
}),
|
|
398
|
+
),
|
|
399
|
+
predicate: (ctx, table) => (table === 'lists' ? inSet('id', ctx.listIds) : undefined),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const bob = makeTestContext({ subject: bobSubject, store: mockStore(seed), rowFilter: sharedLists })
|
|
403
|
+
expect((await bob.store.select('lists').all()).map((r) => r.id)).not.toContain('alices-list')
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`makeTestContext` registers the **raw** data store + schema registry, so nothing is double-wrapped (`runAsSystem` applies the system-subject mixin wrap itself), and it is the outer store rather than a transactional view — matching production, where a `runAsSystem` block inside a mutation reads outside that mutation's transaction.
|
|
407
|
+
|
|
408
|
+
The handle is a process global, and the harness handles that in two layers. Registration is last-wins, so a bare `await runAsSystem(...)` written directly in a test also resolves. On top of that, each context re-points the handle at **its own** store for the span of its `load` and restores the previous value — necessary because two `makeTestContext` calls allocate two separate in-memory stores even from one seed object, so filter resolution must not depend on build order. Nothing leaks across test files (vitest gives each file a fresh module registry). Only the harness registers: an app that registers no handle still gets `runAsSystem: no data store available`, unchanged. If a test in the same file needs that refusal back, call `clearSystemStoreHandle()` from `@voltro/runtime`.
|
|
409
|
+
|
|
328
410
|
## The deterministic mocks
|
|
329
411
|
|
|
330
412
|
### `ctx.clock` — `MockClock`
|
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@effect/platform": "^0.96.1",
|
|
13
13
|
"@effect/rpc": "^0.75.1",
|
|
14
|
-
"@voltro/ai": "0.
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
14
|
+
"@voltro/ai": "0.8.0",
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/protocol": "0.8.0",
|
|
19
|
+
"@voltro/runtime": "0.8.0",
|
|
20
20
|
"effect": "^3.21.2"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@voltro/testing": "0.
|
|
23
|
+
"@voltro/testing": "0.8.0",
|
|
24
24
|
"typescript": "^5.7.0",
|
|
25
25
|
"vitest": "^3.0.0"
|
|
26
26
|
}
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-auth": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
21
|
-
"@voltro/sql-postgres": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-auth": "0.8.0",
|
|
19
|
+
"@voltro/protocol": "0.8.0",
|
|
20
|
+
"@voltro/runtime": "0.8.0",
|
|
21
|
+
"@voltro/sql-postgres": "0.8.0",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.8.0",
|
|
26
26
|
"typescript": "^5.7.0",
|
|
27
27
|
"vitest": "^3.0.0"
|
|
28
28
|
}
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
19
|
+
"@voltro/protocol": "0.8.0",
|
|
20
|
+
"@voltro/runtime": "0.8.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.8.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-deactivation": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-deactivation": "0.8.0",
|
|
19
|
+
"@voltro/protocol": "0.8.0",
|
|
20
|
+
"@voltro/runtime": "0.8.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.8.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@react-email/components": "^1.0.12",
|
|
14
14
|
"@react-email/render": "^1.4.0",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-mail": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-mail": "0.8.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
20
|
+
"@voltro/protocol": "0.8.0",
|
|
21
|
+
"@voltro/runtime": "0.8.0",
|
|
22
22
|
"effect": "^3.21.2",
|
|
23
23
|
"react": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.8.0",
|
|
27
27
|
"typescript": "^5.7.0",
|
|
28
28
|
"vitest": "^3.0.0"
|
|
29
29
|
}
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/plugin-storage": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-mysql": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
19
|
+
"@voltro/plugin-storage": "0.8.0",
|
|
20
|
+
"@voltro/protocol": "0.8.0",
|
|
21
|
+
"@voltro/runtime": "0.8.0",
|
|
22
|
+
"@voltro/sql-mysql": "0.8.0",
|
|
23
23
|
"effect": "^3.21.2"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.8.0",
|
|
27
27
|
"typescript": "^5.7.0",
|
|
28
28
|
"vitest": "^3.0.0"
|
|
29
29
|
}
|
|
@@ -10,17 +10,17 @@
|
|
|
10
10
|
"test": "voltro test"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@voltro/cli": "0.
|
|
14
|
-
"@voltro/database": "0.
|
|
15
|
-
"@voltro/env": "0.
|
|
16
|
-
"@voltro/plugin-multitenancy": "0.
|
|
17
|
-
"@voltro/plugin-storage": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
13
|
+
"@voltro/cli": "0.8.0",
|
|
14
|
+
"@voltro/database": "0.8.0",
|
|
15
|
+
"@voltro/env": "0.8.0",
|
|
16
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
17
|
+
"@voltro/plugin-storage": "0.8.0",
|
|
18
|
+
"@voltro/protocol": "0.8.0",
|
|
19
|
+
"@voltro/runtime": "0.8.0",
|
|
20
20
|
"effect": "^3.21.2"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@voltro/testing": "0.
|
|
23
|
+
"@voltro/testing": "0.8.0",
|
|
24
24
|
"typescript": "^5.7.0",
|
|
25
25
|
"vitest": "^3.0.0"
|
|
26
26
|
}
|
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@effect/platform": "^0.96.1",
|
|
13
13
|
"@effect/rpc": "^0.75.1",
|
|
14
|
-
"@voltro/cli": "0.
|
|
15
|
-
"@voltro/database": "0.
|
|
16
|
-
"@voltro/env": "0.
|
|
17
|
-
"@voltro/plugin-governance": "0.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.
|
|
19
|
-
"@voltro/protocol": "0.
|
|
20
|
-
"@voltro/runtime": "0.
|
|
14
|
+
"@voltro/cli": "0.8.0",
|
|
15
|
+
"@voltro/database": "0.8.0",
|
|
16
|
+
"@voltro/env": "0.8.0",
|
|
17
|
+
"@voltro/plugin-governance": "0.8.0",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
19
|
+
"@voltro/protocol": "0.8.0",
|
|
20
|
+
"@voltro/runtime": "0.8.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.8.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@effect/platform": "^0.96.1",
|
|
13
13
|
"@effect/rpc": "^0.75.1",
|
|
14
|
-
"@voltro/cli": "0.
|
|
15
|
-
"@voltro/database": "0.
|
|
16
|
-
"@voltro/env": "0.
|
|
17
|
-
"@voltro/plugin-multitenancy": "0.
|
|
18
|
-
"@voltro/protocol": "0.
|
|
19
|
-
"@voltro/runtime": "0.
|
|
20
|
-
"@voltro/workflow": "0.
|
|
14
|
+
"@voltro/cli": "0.8.0",
|
|
15
|
+
"@voltro/database": "0.8.0",
|
|
16
|
+
"@voltro/env": "0.8.0",
|
|
17
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
18
|
+
"@voltro/protocol": "0.8.0",
|
|
19
|
+
"@voltro/runtime": "0.8.0",
|
|
20
|
+
"@voltro/workflow": "0.8.0",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.
|
|
24
|
+
"@voltro/testing": "0.8.0",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-flags": "0.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
22
|
-
"@voltro/sql-postgres": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-flags": "0.8.0",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.8.0",
|
|
20
|
+
"@voltro/protocol": "0.8.0",
|
|
21
|
+
"@voltro/runtime": "0.8.0",
|
|
22
|
+
"@voltro/sql-postgres": "0.8.0",
|
|
23
23
|
"effect": "^3.21.2"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.
|
|
26
|
+
"@voltro/testing": "0.8.0",
|
|
27
27
|
"typescript": "^5.7.0",
|
|
28
28
|
"vitest": "^3.0.0"
|
|
29
29
|
}
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/database": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/plugin-audit": "0.
|
|
19
|
-
"@voltro/plugin-governance": "0.
|
|
20
|
-
"@voltro/protocol": "0.
|
|
21
|
-
"@voltro/runtime": "0.
|
|
15
|
+
"@voltro/cli": "0.8.0",
|
|
16
|
+
"@voltro/database": "0.8.0",
|
|
17
|
+
"@voltro/env": "0.8.0",
|
|
18
|
+
"@voltro/plugin-audit": "0.8.0",
|
|
19
|
+
"@voltro/plugin-governance": "0.8.0",
|
|
20
|
+
"@voltro/protocol": "0.8.0",
|
|
21
|
+
"@voltro/runtime": "0.8.0",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.
|
|
25
|
+
"@voltro/testing": "0.8.0",
|
|
26
26
|
"typescript": "^5.7.0",
|
|
27
27
|
"vitest": "^3.0.0"
|
|
28
28
|
}
|