@fabrika/app 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,6 +47,11 @@
47
47
  "types": "./src/react/index.ts",
48
48
  "default": "./src/react/index.ts"
49
49
  },
50
+ "./react/testing": {
51
+ "bun": "./src/react/testing.ts",
52
+ "types": "./src/react/testing.ts",
53
+ "default": "./src/react/testing.ts"
54
+ },
50
55
  "./implement": {
51
56
  "bun": "./src/implement/index.ts",
52
57
  "types": "./src/implement/index.ts",
@@ -88,20 +93,24 @@
88
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
89
94
  },
90
95
  "dependencies": {
91
- "@fabrika/auth": "^0.4.0",
92
- "@fabrika/auth-core": "^0.4.0",
93
- "@fabrika/db": "^0.4.0",
94
- "@fabrika/jwks": "^0.4.0",
95
- "@fabrika/platform": "^0.4.0",
96
- "@fabrika/platform-node": "^0.4.0",
97
- "@fabrika/proxy-core": "^0.4.0",
96
+ "@fabrika/auth": "^0.4.1",
97
+ "@fabrika/auth-core": "^0.4.1",
98
+ "@fabrika/db": "^0.4.1",
99
+ "@fabrika/jwks": "^0.4.1",
100
+ "@fabrika/platform": "^0.4.1",
101
+ "@fabrika/platform-node": "^0.4.1",
102
+ "@fabrika/proxy-core": "^0.4.1",
98
103
  "jose": "^5.9.0"
99
104
  },
100
105
  "peerDependencies": {
106
+ "@happy-dom/global-registrator": "^20.11.2",
101
107
  "react": "^19.0.0",
102
108
  "zod": "^3.25.76"
103
109
  },
104
110
  "peerDependenciesMeta": {
111
+ "@happy-dom/global-registrator": {
112
+ "optional": true
113
+ },
105
114
  "react": {
106
115
  "optional": true
107
116
  }
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.4.0
3
+ version: 0.4.1
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -19,11 +19,11 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "^0.4.0",
23
- "@fabrika/db": "^0.4.0",
24
- "@fabrika/provider-zerops": "^0.4.0",
25
- "@fabrika/proxy-core": "^0.4.0",
26
- "@fabrika/ui": "^0.4.0",
22
+ "@fabrika/app": "^0.4.1",
23
+ "@fabrika/db": "^0.4.1",
24
+ "@fabrika/provider-zerops": "^0.4.1",
25
+ "@fabrika/proxy-core": "^0.4.1",
26
+ "@fabrika/ui": "^0.4.1",
27
27
  "@sentry/browser": "10.69.0",
28
28
  "kysely": "0.29.6",
29
29
  "react": "^19.0.0",
@@ -31,9 +31,9 @@ the SPA is added with it.
31
31
  "zod": "^3.25.76"
32
32
  },
33
33
  "devDependencies": {
34
- "@fabrika/check": "^0.4.0",
35
- "@fabrika/cli": "^0.4.0",
36
- "@fabrika/platform": "^0.4.0",
34
+ "@fabrika/check": "^0.4.1",
35
+ "@fabrika/cli": "^0.4.1",
36
+ "@fabrika/platform": "^0.4.1",
37
37
  "@happy-dom/global-registrator": "^20.11.2",
38
38
  "@types/bun": "latest",
39
39
  "@types/react": "^19.0.0",
@@ -63,6 +63,12 @@ the SPA is added with it.
63
63
  `dependencies` of `install.json`, add its `overrides`, and run `bun install`. Then run the checker as
64
64
  `fabrika-check --packed <dir>/release-manifest.json`: without the flag it refuses the tarballs. A deployed
65
65
  application never takes this form; the platform build installs from the registry.
66
+ - The local pack is the one way to run a platform checkout's packages in an application. Never `bun link` a
67
+ `@fabrika/*` package: a linked package resolves its dependencies and peers from the checkout, so the application
68
+ runs a second React (every hook fails), a second zod and a second kysely. Installed from `vendor/`, the packages
69
+ resolve `react`, `zod` and `kysely` from the application's own `node_modules`. The pack's version names the
70
+ checkout's commit: after a change in the platform, commit it, pack again, and replace the block with the new
71
+ `install.json`.
66
72
 
67
73
  ```json tsconfig.json
68
74
  {
@@ -50,18 +50,41 @@ export default seed
50
50
  - `scopes` names the values the seed made, by dimension, with the label a person knows them by; the picker offers
51
51
  them for a membership role.
52
52
  - Check every call the seed makes: an endpoint that refuses answers a status, and the seed goes on without its data.
53
- A seed that throws stops `app dev` with `seed_failed` and no message, because the message may carry a credential;
54
- run the same calls in a test to see why.
55
- - Mail the application sends through its own stand-ins is the seed's business. The invitations IAM would e-mail are
56
- listed by `app dev` itself.
53
+ Throw when one does: a seed that throws stops `app dev` and prints its error with the stack.
54
+ - `dev.runJob('<job>')` runs a cron job from the seed (`app dev` runs none on its schedule). A job's `ctx.links` works:
55
+ the stand-in IAM takes the job's own key. What a job's handler throws is printed with its stack, here only.
56
+
57
+ ## Outside the application
58
+
59
+ - `dev/dev.env` holds the application's variables for `app dev`, one `NAME=value` per line (`#` comments, quotes
60
+ optional). They win over the shell's; a `FABRIKA_*` name is refused. Commit it: it holds stand-in values, never a
61
+ real secret.
62
+ - A form's challenge passes in `app dev`: the stand-in answers Turnstile's siteverify for any token. Set the form's
63
+ `secret` variable to any value in `dev/dev.env`, and send a fresh `challengeToken` with each submission, as a test
64
+ does: a token is single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test
65
+ key `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
66
+ submission; a deployed application records it like any other, so there it passes once.
67
+ - Point an outside provider at the dev outbox: `app dev` captures every request the process sends to
68
+ `https://outbox.invalid` (`DEV_OUTBOX_ORIGIN` of `@fabrika/app/testing`) and answers `200 {"id": …}`. The mail view
69
+ lists each one, by the `to` and `subject` of a JSON body, and opens its body; its headers are not kept.
70
+ - The seed's second argument reads the outbox: `outside.outbox()` is every captured request, oldest first
71
+ (`{ id, method, path, contentType, body }`), so the seed can open a link the application mailed.
72
+
73
+ ```text dev/dev.env
74
+ # Stand-in values for fabrika app dev.
75
+ TURNSTILE_SECRET=dev
76
+ TURNSTILE_SITE_KEY=1x00000000000000000000AA
77
+ MAIL_API_URL=https://outbox.invalid/mail
78
+ MAIL_API_KEY=dev
79
+ ```
57
80
 
58
81
  ## In the browser
59
82
 
60
83
  - `/__fabrika/dev` is the picker: the personas, another role of the vocabulary (a membership role with a scope value),
61
- anonymous, the link pages, and the mail IAM sent. It speaks the vocabulary's `locale` and shows its `displayName`.
84
+ anonymous, the link pages, and the mail: what IAM sent and what reached the dev outbox. It speaks the vocabulary's `locale` and shows its `displayName`.
62
85
  - Every SPA page carries a toolbar in the bottom corner: who you are, the personas to switch to (the page reloads as
63
86
  that caller), the picker, the link pages, and the mail. An invitation's link accepts it; the browser is the invited
64
87
  person from then on.
65
88
  - The choice stays in a cookie the application never sees. The data is gone when the command stops.
66
89
  - It serves this machine only (`--host` is a loopback address, `--port=0` picks a free port), answers no request
67
- addressed to another host name, and runs no cron job: test those with `kit.runJob`.
90
+ addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`.
@@ -342,7 +342,9 @@ A function of the slice's `queries.ts` takes the handle as a parameter typed wit
342
342
  It decides no authorization: the capability already bounds the handle.
343
343
 
344
344
  ```ts src/reports/queries.ts
345
- import type { DbHandle } from '@fabrika/app/contract'
345
+ import type { DbHandle, SqlHandle } from '@fabrika/app/contract'
346
+ import type { Ref } from '@fabrika/app/implement'
347
+ import { z } from 'zod/v4'
346
348
  import type { app } from '../app-kit'
347
349
 
348
350
  export const statusCounts = async (
@@ -353,10 +355,34 @@ export const statusCounts = async (
353
355
  Number(rows.find((row) => row.status === status)?.count ?? 0)
354
356
  return { open: count('open'), doing: count('doing'), done: count('done') }
355
357
  }
358
+
359
+ export const WorkloadRow = z.strictObject({
360
+ assignee: z.nullable(z.string()),
361
+ open: z.int(),
362
+ overdue: z.int(),
363
+ })
364
+
365
+ export const workloadRows = (
366
+ sql: SqlHandle<typeof app, 'read'>,
367
+ org: Ref<'org', 'tasks.read'>,
368
+ status: string | null,
369
+ ) =>
370
+ sql(WorkloadRow)`
371
+ select t.assignee,
372
+ count(*) filter (where t.status <> 'done')::int as open,
373
+ count(*) filter (where t.status <> 'done' and t.due_on < current_date)::int as overdue
374
+ from tasks t
375
+ where ${sql.scope(org, 'tasks', 't')}
376
+ and (${status}::text is null or t.status::text = ${status})
377
+ group by t.assignee
378
+ order by t.assignee`
356
379
  ```
357
380
 
358
381
  `DbHandle<typeof app, 'on' | 'in', '<dimension>', '<action>', 'mutation'>` is `ctx.db.target` of that form;
359
- `DbHandle<typeof app, 'over', …>` is `ctx.db.within`.
382
+ `DbHandle<typeof app, 'over', …>` is `ctx.db.within`. `SqlHandle<typeof app, 'read'>` is `ctx.sql` (a `'write'` site's
383
+ `ctx.sql` is one too; `SqlHandle<typeof app, 'write'>` for a helper that writes), and the capability is the endpoint's
384
+ `ctx.target` (`Ref<'<dimension>', '<action>'>`) or `ctx.within` (`Within<…>`), both types of `@fabrika/app/implement`.
385
+ `sql.scope(capability, '<table>')` is checked against the app's tables as it is in the handler.
360
386
 
361
387
  ## Raw SQL: `sql: 'read'`
362
388
 
@@ -368,18 +394,13 @@ import { attachment } from '@fabrika/app/contract'
368
394
  import { z } from 'zod/v4'
369
395
  import { app } from '../app-kit'
370
396
  import { TaskStatus } from '../schemas'
371
- import { statusCounts } from './queries'
397
+ import { statusCounts, WorkloadRow, workloadRows } from './queries'
372
398
 
373
399
  const Workload = z.strictObject({
374
400
  orgId: z.string().min(1).max(100),
375
401
  status: TaskStatus.optional(),
376
402
  })
377
403
  const ByProject = z.strictObject({ projectId: z.uuid() })
378
- const WorkloadRow = z.strictObject({
379
- assignee: z.nullable(z.string()),
380
- open: z.int(),
381
- overdue: z.int(),
382
- })
383
404
  const WorkloadReport = z.strictObject({ rows: z.array(WorkloadRow) })
384
405
  const Counts = z.strictObject({ open: z.int(), doing: z.int(), done: z.int() })
385
406
  const TaskExportRow = z.strictObject({
@@ -402,19 +423,9 @@ export const reports = app.group({ callers: ['human'] }, (r) => ({
402
423
  access: { action: 'tasks.read', in: 'org', from: 'orgId' },
403
424
  sql: 'read',
404
425
  })
405
- .handle(async ({ input, ctx }) => {
406
- const status = input.status ?? null
407
- const rows = await ctx.sql(WorkloadRow)`
408
- select t.assignee,
409
- count(*) filter (where t.status <> 'done')::int as open,
410
- count(*) filter (where t.status <> 'done' and t.due_on < current_date)::int as overdue
411
- from tasks t
412
- where ${ctx.sql.scope(ctx.target, 'tasks', 't')}
413
- and (${status}::text is null or t.status::text = ${status})
414
- group by t.assignee
415
- order by t.assignee`
416
- return { rows }
417
- }),
426
+ .handle(async ({ input, ctx }) => ({
427
+ rows: await workloadRows(ctx.sql, ctx.target, input.status ?? null),
428
+ })),
418
429
  counts: r.query({
419
430
  output: Counts,
420
431
  access: { action: 'tasks.read', over: 'project' },
@@ -452,7 +463,9 @@ export const reports = app.group({ callers: ['human'] }, (r) => ({
452
463
  `read` action (the intake job of `references/public-intake.md`).
453
464
  - **A fragment carries its action into the statement.** On the table an `update` or `delete` writes it needs the
454
465
  table's own update or delete action (`ctx.within('tasks.write', 'project')` for `update tasks`), so a read action
455
- never scopes a write; anywhere else it needs an action the handles read the table with. `insert … on conflict do
466
+ never scopes a write. A column's own write action (`columns: { assignee: { write: 'tasks.assign' } }`) scopes an
467
+ `update` too, when every column its `set` assigns declares that action: a table with only column writes is updated
468
+ this way; anywhere else it needs an action the handles read the table with. `insert … on conflict do
456
469
  update … where ${…}` is an update of the inserted table. The fragment answers for its own query block: a CTE that
457
470
  updates and a statement that deletes take one capability each, and another occurrence of the written table (a
458
471
  subquery, `from tasks other`) reads with a read capability under its own alias. A refused fragment fails the call
@@ -23,7 +23,7 @@ The refusals agents meet most, and their fix:
23
23
  | `type-assertion` | `as T` (other than `as const`), `<T>x` | fix the type; `fromRow` parses a row into a schema's type |
24
24
  | `sensitive-log` | a log of an error object, a request, headers or the environment | log a short fixed message |
25
25
  | `migration-name` | a migration not named `YYYYMMDDHHMMSS_slug.sql` (UTC) | `bunx fabrika db new <slug>` |
26
- | `test-only-import` | `@fabrika/app/testing` or `@fabrika/db/testing` outside a test file | import them only from `*.test.ts` or `__tests__/` |
26
+ | `test-only-import` | `@fabrika/app/testing`, `@fabrika/app/react/testing` or `@fabrika/db/testing` outside a test file | import them only from `*.test.ts` or `__tests__/` |
27
27
  | `spa-server-import` | the SPA loads a server module | the SPA imports `@fabrika/app/client`, the contract, `src/schemas.ts` and `src/shared/` |
28
28
  | `shared-import` | a module under `src/shared/` loads a slice, `src/db/`, `@fabrika/*` or a builtin | a shared module imports `zod/v4` and other shared modules only |
29
29
  | `skipped-test` | `.skip`, `.only`, `.todo` | run the test |
@@ -6,11 +6,12 @@ write a DOM element builder, a router, a cache or a component set of your own: w
6
6
  from the kit's components and the theme's tokens in the application's own stylesheet. `app-setup.md` shows the
7
7
  dependencies, `tsconfig.json` (`"jsx": "react-jsx"`) and `build-web.ts`.
8
8
 
9
- | Module | Holds |
10
- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
11
- | `@fabrika/app/client` | `createClient(contract, 'api')`: one function per procedure, its answer parsed by the output schema |
12
- | `@fabrika/app/react` | `FabrikaProvider`, `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction`, `useInvalidate`, `skip`, `useCaller`, `Can`, `FabrikaError` |
13
- | `@fabrika/ui` | the components below, `createRouter`, and `@fabrika/ui/styles.css` |
9
+ | Module | Holds |
10
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `@fabrika/app/client` | `createClient(contract, 'api')`: one function per procedure, its answer parsed by the output schema |
12
+ | `@fabrika/app/react` | `FabrikaProvider`, `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction`, `useDownload`, `useInvalidate`, `skip`, `useCaller`, `Can`, `FabrikaError` |
13
+ | `@fabrika/app/react/testing` | `installDom`, `type`, `controlByLabel`, `waitForText`: a screen test's DOM and typing |
14
+ | `@fabrika/ui` | the components below, `createRouter`, and `@fabrika/ui/styles.css` |
14
15
 
15
16
  ## The provider and the routes
16
17
 
@@ -94,7 +95,8 @@ if (root !== null) {
94
95
 
95
96
  - `FabrikaProvider` holds the clients of the mounts the page calls. A hook takes a procedure of a client
96
97
  (`api.tasks.get`) as the typed name of the call and runs it on the provider's client of that mount; a test hands the
97
- provider a client over the test kit instead (below). An SPA mounted under a prefix passes it: `spa="/app"`.
98
+ provider a client over the test kit instead (below). An SPA mounted under a prefix passes it: `spa="/app"`. Its bare
99
+ prefix (`/app`) redirects to `/app/`, so a link may name either.
98
100
  - `createRouter` answers typed paths: `href('task', { id })`, `navigate('project', { orgId, projectId })`, `<Link
99
101
  to="task" params={{ id }}>`, and `useRoute()`, the match or `null`. `<Router>` makes every plain `<a href>` under the
100
102
  SPA navigate without a reload: the kit's components render links that way. Pass `{ base: '/app' }` for a prefix.
@@ -248,6 +250,12 @@ export function MyTasks() {
248
250
  - `useInfiniteQuery(procedure, input)` is for a list that answers `{ items, next }` and takes `after`: `items` are the
249
251
  loaded pages, `hasMore` and `loadMore()` the next page. `{ pages: 'all' }` reads every page at once, for a picker's
250
252
  options; use it for small lists only.
253
+ - An offset list that answers `{ items, total }` and takes `limit` and `offset` is `useQuery` with `{ keepPrevious:
254
+ true }`: while the answer of a new `limit` or `offset` loads, `data` stays the previous answer (`status: 'success'`,
255
+ `fetching: true`), so the table keeps its rows. "Load more (n of N)" grows `limit` in the page's state:
256
+ `useQuery(api.tasks.queue, { orgId, limit, offset: 0 }, { keepPrevious: true })` with `loadMore={{ hasMore:
257
+ data.items.length < data.total, loading: queue.fetching, onLoadMore: () => setLimit(limit + 50) }}`; a pager sets
258
+ `offset` instead. A filter change resets `limit` or `offset`.
251
259
 
252
260
  ## Writing: useMutation, forms and their errors
253
261
 
@@ -350,6 +358,9 @@ export function EditTask(
350
358
  - A call that is no procedure — a file collection's `api.files.<name>.upload(owner, file)`, `list`, `download` —
351
359
  runs through `useAction(async (input) => …)`, with the same state and error union; it refetches only what
352
360
  `invalidates` names.
361
+ - An attachment procedure (`output: attachment(…)`) downloads through `useDownload(api.reports.exportProject)`:
362
+ `mutate(input)` saves the file and answers it, with the same state and error union. It runs on the provider's client,
363
+ so a screen test downloads over the kit; never call `download(api.…)` of the module's client from a screen.
353
364
 
354
365
  ## Who is signed in: useCaller and Can
355
366
 
@@ -598,31 +609,31 @@ export function TaskView({ id }: { readonly id: string }) {
598
609
  All are named exports of `@fabrika/ui`, keyboard operable, and labelled for assistive technology. Text they render
599
610
  themselves ("Load more", "Clear selection", "Close", "Retry") is a prop with an English default.
600
611
 
601
- | Component | What it is |
602
- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
603
- | `AppShell({ brand, nav, user, actions })` | sidebar navigation (`{ label, href, active, count }`), top bar with the caller, the main region |
604
- | `PageHeader({ title, lead, back, actions, meta })` | the screen's `h1`, a line of purpose, a back link, its buttons; one `primary` button per screen |
605
- | `Tabs({ label, items, active, onChange })` | tabs with counts; arrow keys, Home and End |
606
- | `FilterBar({ onReset })` + `Field` | a row of filters with a reset |
607
- | `Field({ label, name, hint, error, required })` | label, hint and error around one control; the error comes from the `Form` by `name` |
608
- | `Input`, `Textarea`, `Select({ options, placeholder })` | the controls; native props pass through |
609
- | `DateInput`, `DateRange({ from, to, onChange })` | a day, and a from–to pair of days |
610
- | `Checkbox({ label, checked, onChange, indeterminate })` | one checkbox |
611
- | `RadioGroup({ label, name, options, value, onChange })` | a fieldset of radios |
612
- | `Button({ variant, size, loading })`, `ButtonLink` | `primary`, `secondary` (default), `ghost`, `danger`; `loading` disables and shows a spinner |
613
- | `DataTable({ columns, rows, rowKey, status, selection, selectionActions, loadMore, empty })` | compact rows, loading/error/empty rows, a selection toolbar, a load-more button |
614
- | `useSelection()` | the `selection` a `DataTable` takes, with `clear()` |
615
- | `StatusPill({ tone })` | `neutral`, `info`, `success`, `warning`, `danger`, `accent` |
616
- | `FieldList({ items, columns })` | label/value pairs; an empty value shows a dash |
617
- | `Card({ title, actions, footer })`, `Section({ title, description, actions })` | a framed block, and a titled block without the frame |
618
- | `Columns` + `SidePanel` | the main column and a side column, stacked on a narrow screen |
619
- | `Dialog({ open, title, onClose })`, `ConfirmDialog` | modal: focus moves in and is trapped, Escape closes, focus returns |
620
- | `ToastProvider`, `useToast().show({ tone, message })` | short notices that leave by themselves |
621
- | `Timeline({ items, empty })` | history and notes: `{ id, at, title, body }` |
622
- | `EmptyState`, `ErrorState({ text, onRetry })`, `LoadingState`, `Spinner`, `CountBadge` | the states of a region |
623
- | `Stack({ gap })`, `Inline({ gap, align, justify, wrap })` | layout with the spacing scale |
624
- | `Form({ onSubmit, error })`, `FormActions` | a form that shows a hook's error: the summary and each field's message |
625
- | `createRouter(routes, { base })` | `Router`, `Link`, `useRoute`, `href`, `navigate`, typed by the patterns |
612
+ | Component | What it is |
613
+ | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
614
+ | `AppShell({ brand, nav, user, actions, topBar, navStyle })` | sidebar navigation (`{ label, href, active, count }`), a top bar with the caller (none without `user` or `actions`, or with `topBar={false}`), the main region; `navStyle="filled"` fills the current item |
615
+ | `PageHeader({ title, lead, back, actions, meta })` | the screen's `h1`, a line of purpose, a back link, its buttons; one `primary` button per screen |
616
+ | `Tabs({ label, items, active, onChange, variant })` | tabs with counts; arrow keys, Home and End; `variant="boxed"` frames each tab, its count after a dot |
617
+ | `FilterBar({ onReset })` + `Field` | a row of filters with a reset |
618
+ | `Field({ label, name, hint, error, required })` | label, hint and error around one control; the error comes from the `Form` by `name` |
619
+ | `Input`, `Textarea`, `Select({ options, placeholder })` | the controls; native props pass through |
620
+ | `DateInput`, `DateRange({ from, to, onChange })` | a day, and a from–to pair of days |
621
+ | `Checkbox({ label, checked, onChange, indeterminate })` | one checkbox |
622
+ | `RadioGroup({ label, name, options, value, onChange })` | a fieldset of radios |
623
+ | `Button({ variant, size, loading })`, `ButtonLink` | `primary`, `secondary` (default), `ghost`, `danger`; `loading` disables and shows a spinner |
624
+ | `DataTable({ columns, rows, rowKey, status, selection, selectionActions, loadMore, empty })` | compact rows, loading/error/empty rows, a selection toolbar, a load-more button; a column takes `width` (`'xs'`…`'xl'`) and `wrap` |
625
+ | `useSelection()` | the `selection` a `DataTable` takes, with `clear()` |
626
+ | `StatusPill({ tone, dot })` | `neutral`, `info`, `success`, `warning`, `danger`, `accent`; `dot={false}` leaves the dot out |
627
+ | `FieldList({ items, columns })` | label/value pairs; an empty value shows a dash |
628
+ | `Card({ title, actions, footer })`, `Section({ title, description, actions })` | a framed block, and a titled block without the frame |
629
+ | `Columns` + `SidePanel` | the main column and a side column, stacked on a narrow screen |
630
+ | `Dialog({ open, title, onClose })`, `ConfirmDialog` | modal: focus moves in and is trapped, Escape closes, focus returns |
631
+ | `ToastProvider`, `useToast().show({ tone, message })` | short notices that leave by themselves |
632
+ | `Timeline({ items, empty })` | history and notes: `{ id, at, title, body }` |
633
+ | `EmptyState`, `ErrorState({ text, onRetry })`, `LoadingState`, `Spinner`, `CountBadge` | the states of a region |
634
+ | `Stack({ gap })`, `Inline({ gap, align, justify, wrap })` | layout with the spacing scale |
635
+ | `Form({ onSubmit, error })`, `FormActions` | a form that shows a hook's error: the summary and each field's message |
636
+ | `createRouter(routes, { base })` | `Router`, `Link`, `useRoute`, `href`, `navigate`, typed by the patterns |
626
637
 
627
638
  ## Theming
628
639
 
@@ -646,6 +657,12 @@ body {
646
657
  - The palette is neutral with one accent (`--fui-accent*`), plus tones for status (`--fui-success`, `--fui-warning`,
647
658
  `--fui-danger`, `--fui-info`, each with `-soft` and `-text`). Typography (`--fui-font-family`, `--fui-text-*`),
648
659
  spacing (`--fui-space-1`…`8`), radii, borders and shadows are tokens too.
660
+ - So are the parts a design changes most: the main region's padding (`--fui-main-padding`, `-narrow` below the
661
+ breakpoint), a field's label (`--fui-label-size`, `--fui-label-weight`, `--fui-label-color`), a table's column heads
662
+ (`--fui-table-head-size`, `-weight`, `-color`, `-transform`, `-tracking`: `uppercase` and `0.04em` for small
663
+ capitals) and the column widths (`--fui-column-width-xs`…`xl`). Change the look through tokens and the components'
664
+ props (`navStyle`, `variant`, `dot`, a column's `width` and `wrap`); never restyle a kit class name (`.fui-…`), which
665
+ is no contract and changes with the kit.
649
666
  - Light is the default. `<html data-theme="dark">` switches to the dark token set; the page decides when (a toggle, or
650
667
  `matchMedia('(prefers-color-scheme: dark)')`).
651
668
  - The kit loads nothing from another origin, so it runs under the default CSP. The font token is a system stack; for
@@ -657,30 +674,21 @@ body {
657
674
 
658
675
  A screen test renders the page in happy-dom (`@happy-dom/global-registrator`, a devDependency) over the application
659
676
  itself, behind the test kit's proxy decision: the provider gets a client whose `fetch` is the kit's browser for a
660
- test caller.
677
+ test caller. `installDom` of `@fabrika/app/react/testing` sets the DOM up.
661
678
 
662
679
  ```ts web/__tests__/dom.ts
663
- import { GlobalRegistrator } from '@happy-dom/global-registrator'
680
+ import { installDom } from '@fabrika/app/react/testing'
664
681
  import { afterAll } from 'bun:test'
665
682
 
666
- const runtime = {
667
- fetch: globalThis.fetch,
668
- Request: globalThis.Request,
669
- Response: globalThis.Response,
670
- Headers: globalThis.Headers,
671
- }
672
- GlobalRegistrator.register({ url: 'http://tasks.test/' })
673
- Object.assign(globalThis, runtime)
674
- afterAll(async () => {
675
- await new Promise((resolve) => setTimeout(resolve, 50))
676
- await GlobalRegistrator.unregister()
677
- })
683
+ const dom = installDom({ url: 'http://tasks.test/' })
684
+ afterAll(() => dom.uninstall())
678
685
  ```
679
686
 
680
687
  ```tsx web/__tests__/mine.test.tsx
681
688
  import './dom'
682
689
  import { createClient } from '@fabrika/app/client'
683
690
  import { FabrikaProvider } from '@fabrika/app/react'
691
+ import { waitForText } from '@fabrika/app/react/testing'
684
692
  import { testKit } from '@fabrika/app/testing'
685
693
  import { testDatabase } from '@fabrika/db/testing'
686
694
  import { ToastProvider } from '@fabrika/ui'
@@ -718,14 +726,7 @@ if (postgres !== null) {
718
726
  </ToastProvider>
719
727
  </FabrikaProvider>,
720
728
  )
721
- for (
722
- let tries = 0;
723
- tries < 200
724
- && !document.body.textContent?.includes('Nothing assigned to you.');
725
- tries++
726
- ) {
727
- await new Promise((resolve) => setTimeout(resolve, 10))
728
- }
729
+ await waitForText('Nothing assigned to you.')
729
730
  expect(document.body.textContent).toContain('Nothing assigned to you.')
730
731
  expect(document.querySelector('[role="toolbar"]')).toBeNull()
731
732
  root.unmount()
@@ -733,9 +734,13 @@ if (postgres !== null) {
733
734
  }
734
735
  ```
735
736
 
736
- - `web/__tests__/dom.ts` comes first: imports run in order, and React reads `window` when it loads. Bun's `fetch` is
737
- kept, because the clients and the kit run on it. Run the tests with `bun test --isolate`, which gives each file its
738
- own globals.
737
+ - `web/__tests__/dom.ts` comes first: imports run in order, and React reads `window` when it loads. `installDom` keeps
738
+ Bun's `fetch`, `AbortController`, `URL`, `Blob`, `File` and `FormData`, because the clients, the kit, a form's
739
+ challenge and a download run on them, and turns navigation off, so a download's link click leaves the page for the
740
+ next test. Run the tests with `bun test --isolate`, which gives each file its own globals.
741
+ - `await type('Title', 'report')` types into the control whose label (or `aria-label`) is `Title` as React reads a
742
+ person typing; a select takes the option's value. `controlByLabel(label)` answers the control, and
743
+ `waitForText(text)` waits until the page shows it.
739
744
  - Wait for what the page shows, as a person does; assert on text, roles and ARIA, never on layout (happy-dom lays out
740
745
  nothing). A refused twin is a caller without the role: the button is gone, and the endpoint still answers 403.
741
746
  - Pure mapping (a label, a tone, a field's value) is a function of `web/` with a plain test.
@@ -281,14 +281,17 @@ if (postgres !== null) {
281
281
  - `kit.testCaller(grants, { disabled: true })` is a person an operator disabled: the directory lists and finds them
282
282
  with `disabled: true`, so an assignment of them answers your handler's 400, and their own requests are refused (401 for
283
283
  a session, 403 for a key).
284
- - `kit.runJob('<job>')` runs a cron job once and answers `{ status: 'succeeded', attempts }`.
284
+ - `kit.runJob('<job>')` runs a cron job once and answers `{ status: 'succeeded', attempts }`. A job's `ctx.links`
285
+ reaches the kit's stand-in IAM under the job's own key, so a job that declares `issues` issues and revokes links in a
286
+ plain `testKit` and under `fabrika app dev`, as IAM allows it: for its declared mount only.
285
287
  - An application with files passes `files: new MemoryFileObjects()` (`@fabrika/platform/testing`) to `testKit`;
286
288
  without it the application does not boot. With `scan: true`, an upload stays `quarantined` until
287
289
  `kit.runJob(FILE_SCAN_JOB)` (`@fabrika/app/implement`) runs with a `scanner` passed to `testKit`: a `FileScanner`
288
290
  stand-in, or the real ClamAV one, `clamscanScanner()` of `@fabrika/app/bun`.
289
291
  - A form's challenge token is single use: a second submission with the same token answers 400 `challenge_failed`,
290
292
  also when the test's `challengeFetch` answers success. Send a fresh token with each submission
291
- (`crypto.randomUUID()`).
293
+ (`crypto.randomUUID()`). `passingChallenge` (`@fabrika/app/testing`) is a `challengeFetch` that passes every token;
294
+ a stand-in of your own receives the check (`hostname`, `action`) a passing answer must confirm as its second argument.
292
295
  - `testKit` takes no `deps`, and a test may not start a server (`Bun.serve` is `forbidden-global`). Stand in for an
293
296
  outbound dependency through its variables: `env` points it at a test URL, and `spyOn(globalThis, 'fetch')` answers
294
297
  that URL only and passes every other request on (`src/__tests__/mail-stand-in.ts` in `references/public-intake.md`).
@@ -175,20 +175,34 @@ export interface AttachmentProcedure<I> {
175
175
  readonly [attachmentInput]?: I
176
176
  }
177
177
 
178
- /** The request each attachment procedure sends, kept beside it so the value itself carries no key. */
179
- const downloads = new WeakMap<object, { readonly url: string; readonly options: ClientOptions; readonly bounce: BounceConfig | null }>()
178
+ /** The request each attachment procedure sends and where it lies, kept beside it so the value itself carries no key. */
179
+ interface DownloadRequest {
180
+ readonly url: string
181
+ readonly filename: string
182
+ readonly options: ClientOptions
183
+ readonly bounce: BounceConfig | null
184
+ readonly at: { readonly mount: string; readonly path: string }
185
+ }
186
+
187
+ const downloads = new WeakMap<object, DownloadRequest>()
180
188
 
181
189
  function attachmentProcedure(
182
190
  target: CallTarget,
191
+ at: TreeAt,
183
192
  filename: string,
184
193
  options: ClientOptions,
185
194
  bounce: BounceConfig | null,
186
195
  ): AttachmentProcedure<unknown> {
187
196
  const procedure = Object.freeze({ id: target.id, filename })
188
- downloads.set(procedure, { url: target.url, options, bounce })
197
+ downloads.set(procedure, { url: target.url, filename, options, bounce, at: { mount: at.mount, path: at.path } })
189
198
  return procedure
190
199
  }
191
200
 
201
+ /** The mount and path of an attachment procedure of a client, or `null` for any other value. */
202
+ export function attachmentAt(value: unknown): { readonly mount: string; readonly path: string } | null {
203
+ return typeof value === 'object' && value !== null ? (downloads.get(value)?.at ?? null) : null
204
+ }
205
+
192
206
  /** The input `download` takes: the procedure's, or none. */
193
207
  export type DownloadInput<I> = [I] extends [InputSchema] ? [input: z.input<I>] : [input?: Record<never, never>]
194
208
 
@@ -196,13 +210,21 @@ export type DownloadInput<I> = [I] extends [InputSchema] ? [input: z.input<I>] :
196
210
  * Calls an attachment procedure and saves its file: in a browser the page offers it as a download under the declared
197
211
  * name. Answers the file, also where there is no page to save it from (a script, a test). A refusal is an `RpcError`.
198
212
  */
199
- export async function download<I>(procedure: AttachmentProcedure<I>, ...input: DownloadInput<I>): Promise<File> {
213
+ export function download<I>(procedure: AttachmentProcedure<I>, ...input: DownloadInput<I>): Promise<File> {
214
+ if (!downloads.has(procedure)) {
215
+ return Promise.reject(new Error(`'${procedure.id}' is not an attachment procedure of a client`))
216
+ }
217
+ return downloadFile(procedure, input[0])
218
+ }
219
+
220
+ /** `download` of any attachment procedure of a client, its input unchecked: the provider's `useDownload` runs it. */
221
+ export async function downloadFile(procedure: object, input: unknown): Promise<File> {
200
222
  const request = downloads.get(procedure)
201
223
  if (request === undefined) {
202
- throw new Error(`'${procedure.id}' is not an attachment procedure of a client`)
224
+ throw new Error('download takes an attachment procedure of a client createClient made')
203
225
  }
204
- const { body } = await exchangeFile(request.options.fetch ?? fetch, request.url, requestOf(request.options, input[0]), request.bounce)
205
- const file = new File([body], procedure.filename, { type: body.type })
226
+ const { body } = await exchangeFile(request.options.fetch ?? fetch, request.url, requestOf(request.options, input), request.bounce)
227
+ const file = new File([body], request.filename, { type: body.type })
206
228
  saveFile(file)
207
229
  return file
208
230
  }
@@ -283,7 +305,7 @@ export function createClient<const C extends AnyContract, const M extends Client
283
305
  }
284
306
  const procedures = clientTree(node.procedures, { mount, origin, base, path: '' }, (target, at) => {
285
307
  const mark = attachmentOf(target.output)
286
- return mark === null ? caller(target, at, options, bounce) : attachmentProcedure(target, mark.declaration.filename, options, bounce)
308
+ return mark === null ? caller(target, at, options, bounce) : attachmentProcedure(target, at, mark.declaration.filename, options, bounce)
287
309
  })
288
310
  const transport: FileTransport = { origin, fetch: options.fetch ?? fetch, key: options.key, bounce }
289
311
  const collections = Object.entries(surface.files ?? {}).flatMap(([name, collection]): (readonly [string, FileClient | LinkFileClient])[] => {
@@ -83,6 +83,7 @@ import {
83
83
  joinPath,
84
84
  JSON_ADMISSION,
85
85
  LINK_API_SEGMENT,
86
+ spaPrefixRowId,
86
87
  WEBHOOK_ACCOUNT_PATTERN,
87
88
  WEBHOOK_BINDING_PARAMETER,
88
89
  WEBHOOK_SCOPE_FIELD,
@@ -499,23 +500,18 @@ class ArtifactBuilder {
499
500
  this.#prefix(where, mount.prefix)
500
501
  }
501
502
  this.#nonEmpty(`${where} dir`, mount.dir)
502
- const path = joinPath(mount.prefix, '*')
503
503
  const requirement = this.#spaRequirement(where, mount.access)
504
- if (requirement === 'public') {
505
- this.#anonymous(name, 'public-spa', 'GET', path, null, null)
506
- } else if (requirement !== null) {
507
- this.#row({
508
- id: name,
509
- source: { kind: 'mount', name },
510
- kind: 'spa',
511
- method: 'GET',
512
- path,
513
- callers: ['human'],
514
- requirement,
515
- admission: null,
516
- input: null,
517
- output: null,
518
- })
504
+ // The bare prefix (`/admin`) is the mount's too, under its access: it redirects to `/admin/`.
505
+ const rows = mount.prefix === '/'
506
+ ? [{ id: name, path: joinPath(mount.prefix, '*') }]
507
+ : [{ id: name, path: joinPath(mount.prefix, '*') }, { id: spaPrefixRowId(name), path: mount.prefix }]
508
+ for (const { id, path } of rows) {
509
+ const source = { kind: 'mount' as const, name }
510
+ if (requirement === 'public') {
511
+ this.#row({ id, source, kind: 'public-spa', method: 'GET', path, callers: [], requirement: null, admission: null, input: null, output: null })
512
+ } else if (requirement !== null) {
513
+ this.#row({ id, source, kind: 'spa', method: 'GET', path, callers: ['human'], requirement, admission: null, input: null, output: null })
514
+ }
519
515
  }
520
516
  const publicVariables = this.#spaPublic(where, mount.public)
521
517
  return { kind: 'spa', prefix: mount.prefix, dir: mount.dir, ...(publicVariables.length === 0 ? {} : { public: publicVariables }) }
@@ -435,3 +435,10 @@ export type AppHandle<A, Act extends string, K extends DataKind = 'query'> = App
435
435
 
436
436
  /** The type of `ctx.db.reference`, for a query function that takes it. */
437
437
  export type ReferenceHandle<A> = ReferenceTables<IndexOf<A>>
438
+
439
+ /**
440
+ * The type of `ctx.sql`, for a function of `src/<slice>/queries.ts` that takes it: `SqlHandle<typeof app, 'read'>`.
441
+ * A `'write'` site's `ctx.sql` is assignable to it, and `sql.scope(capability, table)` checks the table against the
442
+ * app's own tables.
443
+ */
444
+ export type SqlHandle<A, M extends SqlMode> = SqlTag<IndexOf<A>, M>