@fabrika/app 0.3.2 → 0.4.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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,364 @@
1
+ # references/app-setup.md — the repository of an application
2
+
3
+ An application is the root of its own repository: Zerops reads `zerops.yaml` there. In the fabrika-platform
4
+ repository an example is a workspace under `examples/<name>/`, laid out the same way. This file shows an application
5
+ with tables and an SPA; a new one starts without either (`references/start.md`), and each part below that belongs to
6
+ the SPA is added with it.
7
+
8
+ ## Packages and compiler
9
+
10
+ ```json package.json
11
+ {
12
+ "name": "tasks",
13
+ "private": true,
14
+ "type": "module",
15
+ "scripts": {
16
+ "build:manifest": "fabrika app build --env=prod",
17
+ "build:web": "bun build-web.ts",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "bun test --isolate"
20
+ },
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",
27
+ "@sentry/browser": "10.69.0",
28
+ "kysely": "0.29.6",
29
+ "react": "^19.0.0",
30
+ "react-dom": "^19.0.0",
31
+ "zod": "^3.25.76"
32
+ },
33
+ "devDependencies": {
34
+ "@fabrika/check": "^0.4.0",
35
+ "@fabrika/cli": "^0.4.0",
36
+ "@fabrika/platform": "^0.4.0",
37
+ "@happy-dom/global-registrator": "^20.11.2",
38
+ "@types/bun": "latest",
39
+ "@types/react": "^19.0.0",
40
+ "@types/react-dom": "^19.0.0",
41
+ "typescript": "^5.9.3"
42
+ }
43
+ }
44
+ ```
45
+
46
+ - The name is not under `@fabrika/*` (that scope is the platform's), and every `@fabrika/*` version is a registry
47
+ range, never `workspace:*`. The checker refuses both (`package-replacement`). Inside the fabrika-platform repository
48
+ Bun still links the workspace copies.
49
+ - `build:web` builds the SPA: add it, `build-web.ts`, `web/`, the contract's `site` mount and the `bun run build:web`
50
+ line of `zerops.yaml` when the application gets an SPA, and not before.
51
+ - `kysely` is there for the generated schema's types. `@sentry/browser` reports the SPA's errors to Operations, from
52
+ the DSN and release the framework serves at `<prefix>/__fabrika-config.json`. The same file carries
53
+ `public: { <VARIABLE>: <value> }`: each form challenge's `siteKey`, and the variables the SPA mount lists in
54
+ `c.spa(prefix, { …, public: ['SIGNUP_URL'] })`. A page reads a per-environment public value (a site key, a link
55
+ target) there, never as a build-time constant; it holds no other variable, and the contract refuses a secret in it.
56
+ `react`, `react-dom` and `@fabrika/ui` (with `@types/react`, `@types/react-dom` and `@happy-dom/global-registrator`
57
+ for the screen tests) are the SPA's: add them with it.
58
+ - After you create a new workspace in a monorepo, run `bun install` at the repository root. Until then every
59
+ `@fabrika/*` import fails and tsc reports a cascade of implicit `any`s that are not your bugs.
60
+ - To try unreleased packages outside the fabrika-platform repository, run `bun scripts/release.ts pack --local
61
+ --artifacts=<dir>` there. It writes the tarballs, `release-manifest.json` and `install.json`. Copy the tarballs into
62
+ the application's `vendor/`, replace every `@fabrika/*` entry of `dependencies` and `devDependencies` with the
63
+ `dependencies` of `install.json`, add its `overrides`, and run `bun install`. Then run the checker as
64
+ `fabrika-check --packed <dir>/release-manifest.json`: without the flag it refuses the tarballs. A deployed
65
+ application never takes this form; the platform build installs from the registry.
66
+
67
+ ```json tsconfig.json
68
+ {
69
+ "compilerOptions": {
70
+ "target": "ES2022",
71
+ "module": "ESNext",
72
+ "moduleResolution": "bundler",
73
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
74
+ "jsx": "react-jsx",
75
+ "strict": true,
76
+ "noUncheckedIndexedAccess": true,
77
+ "noImplicitOverride": true,
78
+ "noPropertyAccessFromIndexSignature": true,
79
+ "forceConsistentCasingInFileNames": true,
80
+ "skipLibCheck": true,
81
+ "isolatedModules": true,
82
+ "noEmit": true,
83
+ "types": ["@types/bun"]
84
+ },
85
+ "include": ["src/**/*", "web/**/*", "dev/**/*", "*.ts"]
86
+ }
87
+ ```
88
+
89
+ The tsconfig stands alone: no `extends` of a file outside the application. `lib` is ES2022, so `toSorted` and other
90
+ newer methods are not there.
91
+
92
+ ## The composition
93
+
94
+ ```ts src/app-kit.ts
95
+ import { defineApp } from '@fabrika/app/contract'
96
+ import { tables } from './db/tables'
97
+ import { vocabulary } from './vocabulary'
98
+
99
+ export const app = defineApp(vocabulary, tables)
100
+ ```
101
+
102
+ ```ts fabrika.contract.ts
103
+ import { app } from './src/app-kit'
104
+ import { comments } from './src/comments/endpoints'
105
+ import { members } from './src/members/endpoints'
106
+ import { priorities } from './src/priorities/endpoints'
107
+ import { projects } from './src/projects/endpoints'
108
+ import { reports } from './src/reports/endpoints'
109
+ import { tasks } from './src/tasks/endpoints'
110
+
111
+ export const contract = app.contract((c) => ({
112
+ api: c.rpc('/api', {
113
+ projects,
114
+ tasks,
115
+ comments,
116
+ reports,
117
+ members,
118
+ priorities,
119
+ }),
120
+ site: c.spa('/', { dir: 'dist/web', access: { member: true } }),
121
+ health: c.health('/healthz'),
122
+ }))
123
+ ```
124
+
125
+ ```ts src/app.ts
126
+ import { serve } from '@fabrika/app/implement'
127
+ import { scoped } from '@fabrika/db'
128
+ import { contract } from '../fabrika.contract'
129
+ import { tables } from './db/tables'
130
+
131
+ export default serve(contract, { database: scoped(tables) })
132
+ ```
133
+
134
+ - Mounts: `c.rpc(prefix, groups)` once per prefix, `c.spa(prefix, { dir, access })`, `c.health('/healthz')` (required
135
+ on Zerops), `c.form(path, { input, appendTo, then? })` (an anonymous form that appends one row and runs no handler), a
136
+ webhook, a link mount. An SPA's `access` is `{ member: true }`, `{ action, over }`, or `{ public: true }` for a page
137
+ anyone opens without signing in (a landing page, a public form): the approver sees it as "for anyone". A public SPA
138
+ at `/` answers every `GET` no more specific endpoint serves; give a member SPA its own prefix (`/app`). Sections:
139
+ `files`, `jobs`, `exports`, `csp` (`connectSrc`, `imgSrc`, `frameAncestors`, `scriptSrc`, `frameSrc`, `styleSrc`,
140
+ `fontSrc`: exact `https://` origins, each with a `purpose`, at most 16 each). A hosted typeface needs both its
141
+ stylesheet origin in `styleSrc` and its font origin in `fontSrc`; font files under the SPA's `dir` need neither. No path under `/__fabrika`.
142
+ - A public form's abuse control, all optional on `c.form`: `rate: { perClient, perForm }` (per minute, shared by
143
+ every instance; 10 and 100 by default; 429 past either), `honeypot: '<field>'` (a `z.optional(z.string())` input
144
+ field the page hides; a filled one gets the same answer and writes nothing) and
145
+ `challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`. A challenge needs the input field
146
+ `challengeToken: z.string()`, which the page fills from the Turnstile widget rendered with `action` set to the
147
+ form's mount name; the framework verifies it on the server (a failed token 400, an unreachable provider 503, no
148
+ row either way) and adds Turnstile's origin to the CSP. `secret` names an application variable the human sets,
149
+ never the value; without it the application does not boot. `siteKey` names the variable with the widget's public
150
+ site key, which every SPA mount serves its pages (below). Neither field reaches the inbox, so neither is a
151
+ column. On Zerops every visitor still shares one client address (the balancer), so `perClient` acts as a second
152
+ per-form limit: raise it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
153
+ - `then: '<job>'` on `c.form` names a cron job of the same contract (`jobs`), which each accepted submission kicks
154
+ once its row committed: a run starts within seconds, under the job's own key, and the answer stays the same constant
155
+ and never waits for it. Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
156
+ submit), so the job still drains its inbox in batches under its own budgets. Keep its schedule: it is the recovery
157
+ path for a kicked run that died. A kick carries nothing; the job reads its inbox. `kit.runJob` first waits for the
158
+ kicked runs.
159
+ - `src/app.ts` composes and does nothing else. A dependency built from the application's own variables is
160
+ `defineDeps(build)`, passed as `serve(contract, { database, deps })` and read in a handler with `get()`. Never read
161
+ `process.env`. `build` runs at every boot, the generated tests' too, which pass no variable: read a variable when
162
+ the dependency is used, and fail there (`src/mail.ts` in `references/public-intake.md`). A handler of an endpoint the
163
+ generated tests cover records an outbox row instead, and a job calls the dependency (`references/endpoint.md`).
164
+
165
+ ## The deploy surface (Zerops)
166
+
167
+ ```ts fabrika.config.ts
168
+ import { compileContract, schemaFromContract } from '@fabrika/app/contract'
169
+ import { defineApp, type ZeropsServiceSpec } from '@fabrika/provider-zerops'
170
+ import { contractProxyTable } from '@fabrika/proxy-core/manifest'
171
+ import { contract } from './fabrika.contract'
172
+ import { tables } from './src/db/tables'
173
+
174
+ const compiled = compileContract(contract, tables)
175
+
176
+ const services = (): ZeropsServiceSpec[] => [
177
+ {
178
+ hostname: 'tasksdb',
179
+ type: 'postgresql:single@18',
180
+ profile: 'oltp-hobby',
181
+ priority: 100,
182
+ },
183
+ {
184
+ hostname: 'tasksapi',
185
+ type: 'alpine/bun@1.3',
186
+ priority: 10,
187
+ enableSubdomainAccess: false,
188
+ minContainers: 1,
189
+ maxContainers: 2,
190
+ },
191
+ ]
192
+
193
+ export default defineApp({
194
+ id: contract.vocabulary.app,
195
+ schema: schemaFromContract(compiled),
196
+ target: {
197
+ platform: 'zerops',
198
+ services,
199
+ contract: compiled,
200
+ proxy: { upstream: 'tasksapi:3000', ...contractProxyTable(compiled) },
201
+ deployService: 'tasksapi',
202
+ zeropsSetup: 'tasksapi',
203
+ },
204
+ })
205
+ ```
206
+
207
+ ```yaml
208
+ # zerops.yaml: write only build.base, build.os and the SPA's buildCommands; the platform generates the rest.
209
+ # Without an SPA, the build holds only base: the platform installs the packages itself.
210
+ zerops:
211
+ - setup: tasksapi
212
+ build:
213
+ base: alpine/bun@1.3
214
+ buildCommands:
215
+ - bun install --frozen-lockfile
216
+ - bun run build:web
217
+ ```
218
+
219
+ The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its start script
220
+ runs `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server: the
221
+ application is not ready until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
222
+
223
+ ## The SPA (added when the application has one)
224
+
225
+ The SPA is React, built from the screens and hooks of the framework: `@fabrika/app/react` runs the typed client's
226
+ calls, and `@fabrika/ui` holds the components. Build a screen from the kit; never write a DOM helper, a router or a
227
+ component set of your own. `references/spa.md` shows every component, the forms, the routes and the tests.
228
+
229
+ ```html web/index.html
230
+ <!doctype html>
231
+ <html lang="en">
232
+ <head>
233
+ <meta charset="utf-8">
234
+ <meta name="viewport" content="width=device-width, initial-scale=1">
235
+ <title>Tasks</title>
236
+ <script type="module" src="./start.tsx"></script>
237
+ </head>
238
+ <body>
239
+ <div id="root"></div>
240
+ </body>
241
+ </html>
242
+ ```
243
+
244
+ ```ts web/api.ts
245
+ import { createClient } from '@fabrika/app/client'
246
+ import { contract } from '../fabrika.contract'
247
+
248
+ export const api = createClient(contract, 'api', { bounceOnAuth: true })
249
+ ```
250
+
251
+ ```tsx web/mine.tsx
252
+ import { useInfiniteQuery } from '@fabrika/app/react'
253
+ import { type Column, DataTable, PageHeader, StatusPill } from '@fabrika/ui'
254
+ import { api } from './api'
255
+
256
+ type Task = {
257
+ readonly id: string
258
+ readonly title: string
259
+ readonly status: string
260
+ }
261
+
262
+ const columns: readonly Column<Task>[] = [
263
+ { key: 'title', header: 'Task', grow: true, cell: (task) => task.title },
264
+ {
265
+ key: 'status',
266
+ header: 'Status',
267
+ cell: (task) => <StatusPill>{task.status}</StatusPill>,
268
+ },
269
+ ]
270
+
271
+ export function MyTasks() {
272
+ const mine = useInfiniteQuery(api.tasks.mine, {})
273
+ return (
274
+ <>
275
+ <PageHeader title="My tasks" lead="Open tasks assigned to you." />
276
+ <DataTable
277
+ caption="My tasks"
278
+ columns={columns}
279
+ rows={mine.items}
280
+ rowKey={(task) => task.id}
281
+ status={mine.status === 'error'
282
+ ? 'error'
283
+ : mine.status === 'success'
284
+ ? 'ready'
285
+ : 'loading'}
286
+ error={mine.error?.message}
287
+ onRetry={() => void mine.retry()}
288
+ loadMore={{
289
+ hasMore: mine.hasMore,
290
+ loading: mine.loadingMore,
291
+ onLoadMore: () => void mine.loadMore(),
292
+ }}
293
+ />
294
+ </>
295
+ )
296
+ }
297
+ ```
298
+
299
+ ```tsx web/start.tsx
300
+ import '@fabrika/ui/styles.css'
301
+ import { FabrikaProvider } from '@fabrika/app/react'
302
+ import { AppShell } from '@fabrika/ui'
303
+ import { StrictMode } from 'react'
304
+ import { createRoot } from 'react-dom/client'
305
+ import { api } from './api'
306
+ import { MyTasks } from './mine'
307
+
308
+ const root = document.getElementById('root')
309
+ if (root !== null) {
310
+ createRoot(root).render(
311
+ <StrictMode>
312
+ <FabrikaProvider clients={[api]}>
313
+ <AppShell
314
+ brand="Tasks"
315
+ nav={[{ label: 'My tasks', href: '/', active: true }]}
316
+ >
317
+ <MyTasks />
318
+ </AppShell>
319
+ </FabrikaProvider>
320
+ </StrictMode>,
321
+ )
322
+ }
323
+ ```
324
+
325
+ ```ts build-web.ts
326
+ import { shellSlicePlugin } from '@fabrika/check/bun-plugin'
327
+ import { join } from 'node:path'
328
+
329
+ const outdir = join(import.meta.dir, 'dist', 'web')
330
+ const built = await Bun.build({
331
+ entrypoints: [join(import.meta.dir, 'web', 'index.html')],
332
+ outdir,
333
+ target: 'browser',
334
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') },
335
+ plugins: [shellSlicePlugin({ root: import.meta.dir })],
336
+ })
337
+ if (!built.success) {
338
+ for (const log of built.logs) console.error(String(log))
339
+ process.exit(1)
340
+ }
341
+ const index = Bun.file(join(outdir, 'index.html'))
342
+ await Bun.write(
343
+ index,
344
+ (await index.text()).replaceAll(/(src|href)="\.\//g, '$1="/'),
345
+ )
346
+ ```
347
+
348
+ - The SPA imports `@fabrika/app/client`, `@fabrika/app/react`, `@fabrika/ui`, React, `fabrika.contract.ts`,
349
+ `src/schemas.ts`, `src/shared/` and its own modules; never a slice, `src/db/` or a Bun or Node module. The plugin loads the
350
+ contract without its handlers. A row type the screen declares (`Task` above) is checked against the answer where
351
+ the rows are passed in.
352
+ - The last step points the page's assets at the SPA's prefix (`/`): the mount answers `index.html` for every path
353
+ under it, so a deep link such as `/tasks/<id>` renders. `define` gives React's production build.
354
+ - Set text through JSX, never `dangerouslySetInnerHTML` or `innerHTML`. Same-origin calls carry the session cookie.
355
+ - **Who is signed in.** Every member SPA serves `<prefix>/__fabrika-me.json` to the person it admitted:
356
+ `{ label, email, actions: { app, scopes } }`. `useCaller()` (`@fabrika/app/react`) answers `{ label, email,
357
+ can(action, scope?) }` from it, and `<Can action="tasks.write">` shows its children only when the token grants the
358
+ action. It comes from the token and asks IAM nothing. Use it to greet the person and to show or hide a control,
359
+ never as the check: every endpoint decides again, so a hidden button is no protection. A public SPA answers it 401,
360
+ because its pages arrive without a token, signed in or not.
361
+
362
+ ## Ignored files
363
+
364
+ `.gitignore`: `node_modules/`, `fabrika.manifest.json`, `/fabrika/entry.generated.ts`, `/dist/`.
@@ -0,0 +1,67 @@
1
+ # references/dev.md — previewing the application with `fabrika app dev`
2
+
3
+ `bunx fabrika app dev` serves the application on `http://127.0.0.1:3000/` through its test kit: the proxy decision,
4
+ the stand-in IAM, a throwaway schema of `FABRIKA_TEST_POSTGRES_URL`, and each SPA from its build (run
5
+ `bun run build:web` first). Use it to look at the screens, never in place of a test.
6
+
7
+ ## The seed
8
+
9
+ `dev/seed.ts` runs once, before the first request. It receives the kit, the API the tests use, writes through the
10
+ endpoints, and answers whom and what the picker offers. `--seed=<module>` names another file.
11
+
12
+ ```ts dev/seed.ts
13
+ import { createClient } from '@fabrika/app/client'
14
+ import type { DevSeed } from '@fabrika/app/testing'
15
+ import { contract } from '../fabrika.contract'
16
+
17
+ const seed: DevSeed<typeof contract> = async (dev) => {
18
+ const acme = dev.scope('org', 'acme')
19
+ const ada = dev.testCaller([{ role: 'owner', scope: acme }], {
20
+ label: 'Ada Lovelace',
21
+ email: 'ada@acme.example',
22
+ })
23
+ const ben = dev.testCaller([{ role: 'member', scope: acme }], {
24
+ label: 'Ben Okafor',
25
+ email: 'ben@acme.example',
26
+ })
27
+ const api = createClient(contract, 'api', { fetch: dev.browser(ada) })
28
+ await api.projects.create({ orgId: acme.value, name: 'Website relaunch' })
29
+ await api.members.invite({
30
+ orgId: acme.value,
31
+ email: 'cleo@acme.example',
32
+ role: 'member',
33
+ })
34
+ return {
35
+ personas: [
36
+ { name: 'Ada', description: 'Owns Acme: invites and assigns', caller: ada },
37
+ { name: 'Ben', description: 'A member who works the tasks', caller: ben },
38
+ ],
39
+ scopes: { org: [{ value: acme.value, label: 'Acme' }] },
40
+ }
41
+ }
42
+
43
+ export default seed
44
+ ```
45
+
46
+ - A persona is a person the seed made with `dev.testCaller` (a session, never `via: 'key'`), with a unique `name` and
47
+ a one-line `description`. The picker shows its roles, the scope values it holds them in, and `external`.
48
+ - `links` lists pages a link holder opens: `{ title, description, url }`, where `url` is what the application's own
49
+ endpoint answered from `ctx.links.issue` (`/s/#<key>`). Never build a link URL by hand.
50
+ - `scopes` names the values the seed made, by dimension, with the label a person knows them by; the picker offers
51
+ them for a membership role.
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.
57
+
58
+ ## In the browser
59
+
60
+ - `/__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`.
62
+ - Every SPA page carries a toolbar in the bottom corner: who you are, the personas to switch to (the page reloads as
63
+ that caller), the picker, the link pages, and the mail. An invitation's link accepts it; the browser is the invited
64
+ person from then on.
65
+ - The choice stays in a cookie the application never sees. The data is gone when the command stops.
66
+ - 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`.