@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 +17 -8
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +14 -8
- package/skill/references/dev.md +29 -6
- package/skill/references/endpoint.md +35 -22
- package/skill/references/errors.md +1 -1
- package/skill/references/spa.md +61 -56
- package/skill/references/tests.md +5 -2
- package/src/client/contract.ts +30 -8
- package/src/contract/build.ts +12 -16
- package/src/contract/context.ts +7 -0
- package/src/contract/defaults.ts +5 -0
- package/src/implement/boot.ts +18 -2
- package/src/implement/challenge.ts +6 -1
- package/src/implement/dispatch.ts +14 -4
- package/src/jobs/runner.ts +12 -1
- package/src/react/hooks.ts +46 -4
- package/src/react/index.ts +3 -0
- package/src/react/provider.tsx +18 -4
- package/src/react/testing.ts +144 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +30 -4
- package/src/testing/endpoint-access.ts +10 -7
- package/src/testing/index.ts +14 -1
- package/src/testing/kit.ts +42 -9
- package/src/testing/membership.ts +59 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
92
|
-
"@fabrika/auth-core": "^0.4.
|
|
93
|
-
"@fabrika/db": "^0.4.
|
|
94
|
-
"@fabrika/jwks": "^0.4.
|
|
95
|
-
"@fabrika/platform": "^0.4.
|
|
96
|
-
"@fabrika/platform-node": "^0.4.
|
|
97
|
-
"@fabrika/proxy-core": "^0.4.
|
|
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.
|
|
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.
|
|
23
|
-
"@fabrika/db": "^0.4.
|
|
24
|
-
"@fabrika/provider-zerops": "^0.4.
|
|
25
|
-
"@fabrika/proxy-core": "^0.4.
|
|
26
|
-
"@fabrika/ui": "^0.4.
|
|
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.
|
|
35
|
-
"@fabrika/cli": "^0.4.
|
|
36
|
-
"@fabrika/platform": "^0.4.
|
|
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
|
{
|
package/skill/references/dev.md
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
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:
|
|
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
|
-
|
|
407
|
-
|
|
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
|
|
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
|
|
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 |
|
package/skill/references/spa.md
CHANGED
|
@@ -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
|
|
10
|
-
|
|
|
11
|
-
| `@fabrika/app/client`
|
|
12
|
-
| `@fabrika/app/react`
|
|
13
|
-
| `@fabrika/
|
|
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 })`
|
|
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 })`
|
|
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 })`
|
|
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 {
|
|
680
|
+
import { installDom } from '@fabrika/app/react/testing'
|
|
664
681
|
import { afterAll } from 'bun:test'
|
|
665
682
|
|
|
666
|
-
const
|
|
667
|
-
|
|
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
|
-
|
|
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.
|
|
737
|
-
|
|
738
|
-
|
|
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`).
|
package/src/client/contract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
|
205
|
-
const file = new File([body],
|
|
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])[] => {
|
package/src/contract/build.ts
CHANGED
|
@@ -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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
method: 'GET',
|
|
512
|
-
|
|
513
|
-
callers: ['human'],
|
|
514
|
-
|
|
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 }) }
|
package/src/contract/context.ts
CHANGED
|
@@ -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>
|