@fabrika/app 0.3.3 → 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
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # `@fabrika/app`
2
2
 
3
- The server framework for Fabrika applications: Fetch-based HTTP routing,
4
- middleware, typed RPC, object-level authorization, and a typed browser client.
3
+ The server framework for Fabrika applications: the contract, the runtime that
4
+ serves it, typed RPC, structural errors, and a typed browser client.
5
5
 
6
- Proxy gates remain the application's structural front door. The app receives a
7
- verified Fabrika authorization context and uses `.require(action, scope)` for
8
- checks that depend on application-owned objects.
6
+ An application declares its whole access surface in `fabrika.contract.ts` and
7
+ serves it through `implement`. The platform builds the proxy's endpoint table from
8
+ the same contract, and the proxy is the only enforcement point. The application
9
+ receives a verified Fabrika token and checks application-owned objects through the
10
+ access form of each endpoint.
9
11
 
10
12
  ## Installation
11
13
 
@@ -13,72 +15,79 @@ checks that depend on application-owned objects.
13
15
  bun add @fabrika/app
14
16
  ```
15
17
 
16
- ## Example
18
+ ## Entrypoints
17
19
 
18
- ```ts
19
- import {
20
- type AuthContext,
21
- createRpcClient,
22
- defineApp,
23
- initRpc,
24
- type Middleware,
25
- route,
26
- } from '@fabrika/app'
27
- import { z } from 'zod'
28
-
29
- interface Env {
30
- ASSETS: { fetch(request: Request): Promise<Response> }
31
- }
20
+ | Entrypoint | What it is |
21
+ | ------------------------- | ----------------------------------------------------------------------------------- |
22
+ | `@fabrika/app` | Typed RPC (`initRpc`, `createRpcClient`), structural errors, `FetchService` type. |
23
+ | `@fabrika/app/contract` | `defineContract` and the `c.*` kit; browser-safe. |
24
+ | `@fabrika/app/implement` | `implement`: the handlers of a contract. |
25
+ | `@fabrika/app/client` | `createClient`: the typed browser client of a contract mount. |
26
+ | `@fabrika/app/build` | The generated entrypoint `fabrika app build` writes. |
27
+ | `@fabrika/app/bun` | `createBunHandler`, `serveContract`. |
28
+ | `@fabrika/app/cloudflare` | `createCloudflareWorker`, `createContractWorker`. |
29
+ | `@fabrika/app/service` | The installation's own services only: `defineServiceContract`, `serveService`. |
30
+ | `@fabrika/app/testing` | `testKit`: the application behind the production proxy decision and a stand-in IAM. |
32
31
 
33
- interface Ctx {
34
- env: Env
35
- auth: AuthContext
36
- }
32
+ ## A contract application
37
33
 
38
- const rpc = initRpc<Ctx>()
34
+ ```ts
35
+ // fabrika.contract.ts
36
+ import { defineContract } from '@fabrika/app/contract'
37
+ import { z } from 'zod/v4'
39
38
 
40
- const appRouter = rpc.router({
41
- projects: rpc.router({
42
- rename: rpc.procedure
43
- .input(z.object({ id: z.string(), name: z.string() }))
44
- .require('project:write', (input) => ({
45
- type: 'project',
46
- value: input.id,
47
- }))
48
- .mutation(async ({ ctx, input }) => {
49
- await renameProject(ctx.env, input.id, input.name)
50
- return { ok: true }
51
- }),
52
- }),
39
+ export const NoteList = z.strictObject({
40
+ items: z.array(z.strictObject({ id: z.uuid(), title: z.string() })),
53
41
  })
54
42
 
55
- export type AppRouter = typeof appRouter
56
-
57
- const auth: Middleware<Ctx> = async (_request, ctx, next) => {
58
- ctx.auth = await readFabrikaAuth(ctx.env)
59
- return next()
60
- }
43
+ export const contract = defineContract({
44
+ app: 'notes',
45
+ dimensions: { workspace: {} },
46
+ actions: { 'notes.read': 'Read notes' },
47
+ roles: { reader: { grants: ['notes.read'] } },
48
+ }, (c) => ({
49
+ api: c.rpc('/api', { callers: ['human', 'machine'] }, {
50
+ notes: {
51
+ list: c.query({
52
+ output: NoteList,
53
+ access: { action: 'notes.read', over: 'workspace' },
54
+ }),
55
+ },
56
+ }),
57
+ health: c.health('/healthz'),
58
+ }))
59
+ ```
61
60
 
62
- export const app = defineApp<Env, Ctx>({
63
- context: (env) => ({ env, auth: anonymousAuth() }),
64
- middleware: () => [auth],
65
- routes: [
66
- route.get('/health', () => new Response('ok')),
67
- route.rpc('/api/rpc', appRouter),
68
- ],
69
- assets: (env) => env.ASSETS,
61
+ ```ts
62
+ // src/app.ts
63
+ import { implement } from '@fabrika/app/implement'
64
+ import { contract } from '../fabrika.contract'
65
+ import { database } from './db/database'
66
+ import { notes } from './db/stores/notes'
67
+
68
+ export default implement(contract, {
69
+ database,
70
+ deps: () => ({}),
71
+ handlers: () => ({
72
+ api: {
73
+ notes: {
74
+ list: async ({ ctx }) => ({ items: await notes.list(ctx.within) }),
75
+ },
76
+ },
77
+ }),
70
78
  })
71
-
72
- export const api = createRpcClient<AppRouter>({ baseUrl: '/api/rpc' })
73
79
  ```
74
80
 
75
- Input and output validators may use any Standard Schema implementation.
81
+ An application has no middleware, no raw routes and no custom server.
82
+ `fabrika app build` writes the generated entrypoint, which boots the
83
+ implementation with `serveContract` on Bun or `createContractWorker` on Cloudflare.
84
+ See `examples/zerops-app` and the `fabrika-app` agent skill (`skill/SKILL.md`).
76
85
 
77
86
  ## Portable RPC contracts
78
87
 
79
- Put only DTOs and type-only procedure declarations in a browser-safe contract
80
- package. The browser does not need to import the server router, its validators,
81
- or its context:
88
+ The RPC library serves the `{ method, input }` wire protocol. Put only DTOs and
89
+ type-only procedure declarations in a browser-safe contract package. The browser
90
+ does not need to import the server router, its validators, or its context:
82
91
 
83
92
  ```ts
84
93
  import type { RpcProcedureContract } from '@fabrika/app'
@@ -111,32 +120,31 @@ export const appRouter = rpc.router({
111
120
  export const api = createRpcClient<AppRpcContract>({ baseUrl: '/api/rpc' })
112
121
  ```
113
122
 
123
+ Input and output validators may use any Standard Schema implementation. A row
124
+ handler of an installation service serves such a router with `dispatchRpcRequest`
125
+ from `@fabrika/app/service`.
126
+
114
127
  ## Runtime adapters
115
128
 
116
- `defineApp()` builds the runtime-neutral Fetch request pipeline. Runtime
117
- entrypoints adapt that application explicitly.
129
+ Both adapters serve a `FetchService<Env>`: an object with
130
+ `fetch(request, env, exec)`. `serveService` returns one for a service contract.
118
131
 
119
132
  A Cloudflare Worker module imports `@fabrika/app/cloudflare`:
120
133
 
121
134
  ```ts
122
135
  import { createCloudflareWorker } from '@fabrika/app/cloudflare'
123
- import { app } from './app'
136
+ import { service } from './app'
124
137
 
125
- export default createCloudflareWorker(app)
138
+ export default createCloudflareWorker(service)
126
139
  ```
127
140
 
128
- A long-running Bun process wraps the app with `createBunHandler()`:
141
+ A long-running Bun process wraps the service with `createBunHandler()`:
129
142
 
130
143
  ```ts
131
- import { defineApp, route } from '@fabrika/app'
132
144
  import { createBunHandler } from '@fabrika/app/bun'
145
+ import { service } from './app'
133
146
 
134
- const app = defineApp<Deps, Ctx>({
135
- context: (deps, request, exec) => ({ deps, request, exec }),
136
- routes: [route.get('/healthz', () => Response.json({ status: 'ok' }))],
137
- })
138
-
139
- const handler = createBunHandler(app, deps, {
147
+ const handler = createBunHandler(service, env, {
140
148
  onBackgroundError: () => console.error('background task failed'),
141
149
  })
142
150
 
@@ -152,23 +160,3 @@ const shutdown = async (): Promise<void> => {
152
160
  `drain()` waits for every task registered through the request execution context's
153
161
  `waitUntil()`. Stop accepting requests before draining, then close databases and
154
162
  other process resources.
155
-
156
- ## Runtime conformance
157
-
158
- Use the test-runner-neutral `@fabrika/app/testing` entrypoint to prove that a
159
- portable route behaves the same through direct Fetch dispatch and both runtime
160
- adapters:
161
-
162
- ```ts
163
- import { assertAppRuntimeConformance } from '@fabrika/app/testing'
164
-
165
- const response = await assertAppRuntimeConformance({
166
- app,
167
- createEnv: () => createIsolatedTestEnv(),
168
- createRequest: () => new Request('https://app.test/healthz'),
169
- })
170
- ```
171
-
172
- The helper drains registered background work and compares normalized status,
173
- headers, and text body. Do not use it for routes that exist in only one runtime
174
- composition.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "src",
16
+ "skill",
16
17
  "!src/**/__tests__/**"
17
18
  ],
18
19
  "exports": {
@@ -31,17 +32,78 @@
31
32
  "types": "./src/cloudflare.ts",
32
33
  "default": "./src/cloudflare.ts"
33
34
  },
35
+ "./contract": {
36
+ "bun": "./src/contract/index.ts",
37
+ "types": "./src/contract/index.ts",
38
+ "default": "./src/contract/index.ts"
39
+ },
40
+ "./client": {
41
+ "bun": "./src/client/index.ts",
42
+ "types": "./src/client/index.ts",
43
+ "default": "./src/client/index.ts"
44
+ },
45
+ "./react": {
46
+ "bun": "./src/react/index.ts",
47
+ "types": "./src/react/index.ts",
48
+ "default": "./src/react/index.ts"
49
+ },
50
+ "./implement": {
51
+ "bun": "./src/implement/index.ts",
52
+ "types": "./src/implement/index.ts",
53
+ "default": "./src/implement/index.ts"
54
+ },
55
+ "./service": {
56
+ "bun": "./src/service/index.ts",
57
+ "types": "./src/service/index.ts",
58
+ "default": "./src/service/index.ts"
59
+ },
60
+ "./build": {
61
+ "bun": "./src/build.ts",
62
+ "types": "./src/build.ts",
63
+ "default": "./src/build.ts"
64
+ },
34
65
  "./testing": {
35
- "bun": "./src/testing.ts",
36
- "types": "./src/testing.ts",
37
- "default": "./src/testing.ts"
66
+ "bun": "./src/testing/index.ts",
67
+ "types": "./src/testing/index.ts",
68
+ "default": "./src/testing/index.ts"
69
+ },
70
+ "./testing/isolation": {
71
+ "bun": "./src/testing/isolation.ts",
72
+ "types": "./src/testing/isolation.ts",
73
+ "default": "./src/testing/isolation.ts"
74
+ },
75
+ "./testing/preload": {
76
+ "bun": "./src/testing/preload.ts",
77
+ "types": "./src/testing/preload.ts",
78
+ "default": "./src/testing/preload.ts"
79
+ },
80
+ "./agent-skill": {
81
+ "bun": "./src/agent-skill.ts",
82
+ "types": "./src/agent-skill.ts",
83
+ "default": "./src/agent-skill.ts"
38
84
  }
39
85
  },
40
86
  "scripts": {
41
87
  "typecheck": "tsc --noEmit",
42
- "test": "bun test"
88
+ "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
43
89
  },
44
90
  "dependencies": {
45
- "@fabrika/auth": "^0.3.3"
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",
98
+ "jose": "^5.9.0"
99
+ },
100
+ "peerDependencies": {
101
+ "react": "^19.0.0",
102
+ "zod": "^3.25.76"
103
+ },
104
+ "peerDependenciesMeta": {
105
+ "react": {
106
+ "optional": true
107
+ }
46
108
  }
47
109
  }
package/skill/SKILL.md ADDED
@@ -0,0 +1,319 @@
1
+ ---
2
+ name: fabrika-app
3
+ version: 0.4.0
4
+ description: >
5
+ How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
+ slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
7
+ and the generated tests. Use whenever the repository has a fabrika.contract.ts, or the task adds an endpoint,
8
+ a table, a migration, an action or a role.
9
+ ---
10
+
11
+ # Fabrika application rules
12
+
13
+ The contract is the whole surface of the application: who may call what. The proxy admits only the calls it allows,
14
+ and the data layer scopes every row to the capability that the endpoint's access form minted. Write on the paved road
15
+ below. When it does not reach far enough, declare the step off it. A declared step is allowed, and the approver sees it.
16
+
17
+ ## The files, in import order
18
+
19
+ | File | Holds |
20
+ | -------------------------- | ---------------------------------------------------------------------------------------- |
21
+ | `src/vocabulary.ts` | `defineVocabulary({ app, dimensions, actions, roles })`: written once, typed everywhere |
22
+ | `db/migrations/*.sql` | plain SQL, one new file per change (`bunx fabrika db new <slug>`) |
23
+ | `src/db/tables.ts` | `defineTables(vocabulary, columns)({ … })`: the scope and the actions of each table |
24
+ | `src/app-kit.ts` | `export const app = defineApp(vocabulary, tables)` |
25
+ | `src/schemas.ts` | the `zod/v4` schemas several slices, or a slice and the SPA, share |
26
+ | `src/shared/*.ts` | pure functions the SPA and the slices both run; they import only `zod/v4` and each other |
27
+ | `src/<slice>/endpoints.ts` | `app.group(…)`, a `.handle(…)` beside each endpoint, and the slice's jobs and webhooks |
28
+ | `src/<slice>/logic.ts` | business rules as pure functions: no `ctx`, no data access |
29
+ | `src/<slice>/queries.ts` | data access that the slice's handlers share, over a handle they pass in |
30
+ | `fabrika.contract.ts` | `app.contract((c) => ({ api: c.rpc('/api', { … }), … }))`: mounts the groups |
31
+ | `src/app.ts` | `export default serve(contract, { database: scoped(tables) })` and nothing else |
32
+ | `web/` | the SPA: React over `@fabrika/app/react` and `@fabrika/ui`, never a server module |
33
+
34
+ ## The paved road
35
+
36
+ ```ts src/tasks/endpoints.ts
37
+ import { BadRequestError, ConflictError, fromRow } from '@fabrika/app'
38
+ import { z } from 'zod/v4'
39
+ import { app } from '../app-kit'
40
+ import { ById, Cursor, Saved, TaskStatus, Versioned } from '../schemas'
41
+ import { canClose } from './logic'
42
+
43
+ const Task = z.strictObject({
44
+ id: z.uuid(),
45
+ projectId: z.uuid(),
46
+ title: z.string(),
47
+ status: TaskStatus,
48
+ assignee: z.nullable(z.string()),
49
+ assigneeLabel: z.nullable(z.string()),
50
+ dueOn: z.nullable(z.iso.date()),
51
+ overdue: z.boolean(),
52
+ version: z.int(),
53
+ })
54
+ const TaskPage = z.strictObject({
55
+ items: z.array(Task),
56
+ next: z.nullable(z.string()),
57
+ })
58
+ const ListTasks = z.strictObject({
59
+ projectId: z.uuid(),
60
+ status: TaskStatus.optional(),
61
+ title: z.string().min(1).max(200).optional(),
62
+ after: Cursor.optional(),
63
+ })
64
+ const MyTasks = z.strictObject({ after: Cursor.optional() })
65
+ const NewTask = z.strictObject({
66
+ projectId: z.uuid(),
67
+ title: z.string().min(1).max(200),
68
+ dueOn: z.iso.date().optional(),
69
+ })
70
+ const EditTask = z.strictObject({
71
+ id: z.uuid(),
72
+ expectVersion: z.int().positive(),
73
+ title: z.string().min(1).max(200),
74
+ dueOn: z.nullable(z.iso.date()),
75
+ })
76
+ const AssignTask = z.strictObject({
77
+ id: z.uuid(),
78
+ expectVersion: z.int().positive(),
79
+ assignee: z.nullable(z.string().max(100)),
80
+ })
81
+
82
+ export const tasks = app.group({ callers: ['human'] }, (r) => ({
83
+ list: r.query({
84
+ input: ListTasks,
85
+ output: TaskPage,
86
+ access: { action: 'tasks.read', in: 'project', from: 'projectId' },
87
+ })
88
+ .handle(async ({ input, ctx }) => {
89
+ const page = await ctx.db.target.tasks.list({
90
+ where: [
91
+ ...(input.status === undefined
92
+ ? []
93
+ : [['status', '=', input.status] as const]),
94
+ ...(input.title === undefined
95
+ ? []
96
+ : [['title', 'ilike', `%${input.title}%`] as const]),
97
+ ],
98
+ orderBy: [['title', 'asc']],
99
+ after: input.after,
100
+ })
101
+ return {
102
+ items: page.items.map((row) => fromRow(Task, row)),
103
+ next: page.next,
104
+ }
105
+ }),
106
+ mine: r.query({
107
+ input: MyTasks,
108
+ output: TaskPage,
109
+ access: { action: 'tasks.read', over: 'project' },
110
+ })
111
+ .handle(async ({ input, ctx }) => {
112
+ const page = await ctx.db.within.tasks.list({
113
+ where: [['assignee', '=', ctx.principal.id], ['status', '<>', 'done']],
114
+ orderBy: [['due_on', 'asc']],
115
+ after: input.after,
116
+ })
117
+ return {
118
+ items: page.items.map((row) => fromRow(Task, row)),
119
+ next: page.next,
120
+ }
121
+ }),
122
+ get: r.query({
123
+ input: ById,
124
+ output: Task,
125
+ access: { action: 'tasks.read', on: 'task', from: 'id' },
126
+ })
127
+ .handle(async ({ ctx }) => fromRow(Task, await ctx.db.target.get())),
128
+ create: r.mutation({
129
+ input: NewTask,
130
+ output: Saved,
131
+ access: { action: 'tasks.write', in: 'project', from: 'projectId' },
132
+ })
133
+ .handle(({ input, ctx }) =>
134
+ ctx.db.target.tasks.insert({
135
+ title: input.title,
136
+ due_on: input.dueOn ?? null,
137
+ })
138
+ ),
139
+ edit: r.mutation({
140
+ input: EditTask,
141
+ output: Saved,
142
+ access: { action: 'tasks.write', on: 'task', from: 'id' },
143
+ })
144
+ .handle(({ input, ctx }) =>
145
+ ctx.db.target.update({ title: input.title, due_on: input.dueOn }, {
146
+ expectVersion: input.expectVersion,
147
+ })
148
+ ),
149
+ assign: r.mutation({
150
+ input: AssignTask,
151
+ output: Saved,
152
+ access: { action: 'tasks.assign', on: 'task', from: 'id' },
153
+ })
154
+ .handle(async ({ input, ctx }) => {
155
+ const member = input.assignee === null
156
+ ? null
157
+ : await ctx.members.get(input.assignee)
158
+ if (input.assignee !== null && (member === null || member.disabled)) {
159
+ throw new BadRequestError('the assignee is not a member of the org')
160
+ }
161
+ return ctx.db.target.update({
162
+ assignee: member?.principalId ?? null,
163
+ assignee_label: member?.label ?? null,
164
+ }, { expectVersion: input.expectVersion })
165
+ }),
166
+ start: r.mutation({
167
+ input: Versioned,
168
+ output: Saved,
169
+ access: { action: 'tasks.write', on: 'task', from: 'id' },
170
+ })
171
+ .handle(({ input, ctx }) =>
172
+ ctx.db.target.update({ status: 'doing' }, {
173
+ expectVersion: input.expectVersion,
174
+ })
175
+ ),
176
+ close: r.mutation({
177
+ input: Versioned,
178
+ output: Saved,
179
+ access: { action: 'tasks.write', on: 'task', from: 'id' },
180
+ })
181
+ .handle(async ({ input, ctx }) => {
182
+ const task = await ctx.db.target.getForUpdate()
183
+ if (!canClose(task.status)) {
184
+ throw new ConflictError('only a task in progress closes')
185
+ }
186
+ return ctx.db.target.update({ status: 'done' }, {
187
+ expectVersion: input.expectVersion,
188
+ })
189
+ }),
190
+ }))
191
+ ```
192
+
193
+ - The access form gives the handler its data: `on` one object by id (`ctx.db.target` is its row; another scope's is
194
+ 404), `in` a container (`ctx.db.target.<table>.list` and `.insert`), `over` lists across the caller's scopes
195
+ (`ctx.db.within.<table>`), named by the nearest dimension of the rows' chain (tasks lie in a project).
196
+ `ctx.target.value` is the id; there is no `.id`.
197
+ - A row reaches an output through `fromRow(Schema, row)` (snake_case to camelCase), a list's page through
198
+ `fromPage(Schema, page)`, or comes typed from `ctx.sql(Schema)`. Never return a raw row for a camelCase output.
199
+ - On a versioned table `insert` and `update` answer `Saved` (`{ id, version }`), and `update` and `remove` take
200
+ `{ expectVersion }` (a stale one is 409). `remove` answers nothing, so its endpoint answers `{ id }`. A table without
201
+ `version` answers `{ id }` and takes no version. `update` takes the columns it changes, at least one.
202
+ - A precondition reads with `getForUpdate()` and throws `ConflictError`; the rule (`canClose`) is a pure function
203
+ of `logic.ts`. For a business error throw `ConflictError` (409), `NotFoundError` (404) or `BadRequestError` (400) of
204
+ `@fabrika/app`. Any thrown value with a numeric `httpStatus` below 500, a `type` and a `message` answers that status
205
+ (the framework's other errors are such values); any other throw is a 500.
206
+ - One action per endpoint. A column that another action writes gets its own write action and its own endpoint
207
+ (`assign` above). Never check a second action inside a handler: split the endpoint.
208
+ - Never take a user, owner or tenant id from the input, nor the caller's own address. The scope comes from the access
209
+ form; `ctx.principal.id` is the caller, and `ctx.principal.email` its address. A person someone picks (`assign` above) is checked with `ctx.members.get(id)`, which answers `null` for
210
+ anyone outside the org, and its label comes from that answer, never from the input.
211
+
212
+ ## The SPA
213
+
214
+ The SPA is React: `FabrikaProvider` over `createClient(contract, 'api')`, `useQuery`, `useInfiniteQuery` and
215
+ `useMutation` of `@fabrika/app/react` for every call (typed by the contract, one `FabrikaError` union for every
216
+ refusal), and the screens built from `@fabrika/ui` (`AppShell`, `PageHeader`, `DataTable`, `Form` and `Field`,
217
+ `Dialog`, …) with its router and theme. Never write DOM helpers, a router, a cache or a component kit of your own.
218
+ `useCaller()` and `<Can>` show or hide a control by what the token grants; they are information for the page, never
219
+ the access check. `references/spa.md` shows the stack.
220
+
221
+ ## Off the road: declare it
222
+
223
+ | What the handler needs | Declare on the endpoint | It gets |
224
+ | ------------------------------ | ------------------------------- | -------------------------------------------------- |
225
+ | a join, a report, a bulk write | `sql: 'read'` (or `'write'`) | `` ctx.sql(Row)`…` ``, scoped with `ctx.sql.scope` |
226
+ | rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
227
+ | another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
228
+ | a share link | `issues: '<link mount>'` | `ctx.links.issue` |
229
+ | a trigger, function or view | its migration | — |
230
+
231
+ Scope every scoped table a raw statement reads with `${ctx.sql.scope(<capability>, '<table>', '<alias>')}`, a conjunct
232
+ of its WHERE or JOIN … ON (`where (<filter>) and ${…}`): `ctx.target` for `on` and `in`, `ctx.within` for `over`, a
233
+ job's minted `Ref` or `Within` with `tx.sql.scope`. It binds the capability's whole predicate, chain included, as the
234
+ handles apply it, and on an `update` or `delete` target it needs that operation's action; a reference table needs
235
+ none. A deviation is fine: declare it, and do not contort the code to avoid it. The approver sees every declaration
236
+ marked "needs closer review". Do not hide reach in a helper; the runtime builds `ctx.sql` and `ctx.db.unscoped` only
237
+ where they are declared. A query or an export declares only `'read'`.
238
+
239
+ Which promise the types, the checker, boot, the runtime or the approver keeps, and which declaration lifts each:
240
+ [who enforces each guarantee](https://github.com/contember/fabrika-platform/blob/main/docs/reference/application-runtime.md#who-enforces-each-guarantee).
241
+
242
+ ## Rules
243
+
244
+ - Data work first, external calls after. The invocation's one transaction rolls back on a throw; IAM, object storage
245
+ and connector calls do not. An endpoint the generated tests cover calls no outside service (an e-mail, a provider's
246
+ API): it records an outbox row, and a job sends it (`references/jobs.md`).
247
+ - No `as` (except `as const`), `any`, `@ts-ignore`, `@ts-expect-error`, `process.env` or `Bun.env`.
248
+ - Never log an error object, a request, headers or the environment.
249
+ - Never edit a generated file (`src/db/schema.generated.ts`, the generated tests, `fabrika/entry.generated.ts`,
250
+ `fabrika/connect/`) or a committed migration. Regenerate the file; write a new migration.
251
+
252
+ ## Ask the human first
253
+
254
+ - A 401, a 403 or a failing generated test: never loosen the contract, a role, a policy or a test to pass. Tell the
255
+ human which action or role the caller lacks.
256
+ - When the task does not already ask for it, before you add a role, a grant, an action, a declared deviation, an
257
+ anonymous endpoint (`c.form`, a webhook, a link), an export or an import, or a job, or before you drop or rename a
258
+ column: say in one sentence what it lets someone do, and wait for a yes. A data repair goes to the operator, not
259
+ into a job.
260
+ - A file refused as protected: stop and ask. Do not move the change elsewhere.
261
+
262
+ ## The loop
263
+
264
+ Run from the application's root (setup: `references/app-setup.md`). The application you are handed is green: a tsc or
265
+ checker run before you have written anything proves nothing.
266
+
267
+ 1. Write the slice and its tests. A schema change first adds a migration and runs `migrate` and `codegen`; a
268
+ handler-only change needs neither.
269
+ 2. Run `test-scaffold` when an endpoint, its access or a table policy changed.
270
+ 3. Run `bun test --isolate` until it passes. Bun strips the types and runs the tests without checking them, so this is
271
+ the cheap loop.
272
+ 4. Then run `tsc` and `fabrika-check` once each, in that order, at the end. The checker reads the tests too (a cast, a
273
+ skipped or assertion-free test, a generated test's hash).
274
+ 5. After a finding, fix it and re-run only what that fix needs: the step that found it and any later one. A checker fix
275
+ re-runs `tsc` only when it changed a type (a removed cast does), and the tests only when it changed what code does.
276
+
277
+ ```bash
278
+ bunx fabrika db new <slug> # a new migration file
279
+ export FABRIKA_APP_ID=tasks FABRIKA_APP_DATABASE_URL="$FABRIKA_TEST_POSTGRES_URL"
280
+ bunx fabrika db migrate && bunx fabrika db codegen # writes src/db/schema.generated.ts
281
+ bunx fabrika db test-scaffold # writes the generated tests; commit them
282
+ bun test --isolate # until it passes; needs FABRIKA_TEST_POSTGRES_URL; a skip proves nothing
283
+ bunx tsc --noEmit # once, at the end
284
+ bunx fabrika-check . # once, after tsc; refusals fail; hints print below them
285
+ ```
286
+
287
+ A generated file changes only through its command (`codegen`, `test-scaffold`); running it is the loop, not an edit.
288
+ Format and lint only the files you wrote, with the repository's own tools: in the fabrika-platform repository
289
+ `bunx dprint fmt <files>` and `bunx biome lint <files>`, never `biome check` (its formatter fights dprint), and never a
290
+ generated file. An application outside that repository has neither configuration: use the formatter its own
291
+ repository configures, or none, and never add a configuration unasked; tsc and `fabrika-check` are its gates. `FABRIKA_APP_ID` is lowercase letters, digits and `-`. An applied file is immutable: while you still
292
+ change an uncommitted migration, migrate into a fresh schema with another `FABRIKA_APP_ID`. Read a refusal's
293
+ "allowed form" and use it; fix hints too. `references/errors.md` explains the findings and the type errors.
294
+
295
+ ## Previewing the screens
296
+
297
+ `bunx fabrika app dev` serves the application on a loopback port through its test kit, filled by `dev/seed.ts`
298
+ through the endpoints. Pick a persona at `/__fabrika/dev`; the toolbar on every page switches it and lists the mail IAM
299
+ sent. Write the seed and use it as `references/dev.md` says; it never replaces a test.
300
+
301
+ ## References
302
+
303
+ Read the one you need before you write that part. Together they are one application to copy from: `app-setup.md`
304
+ (`package.json`, `tsconfig.json`, the contract, `fabrika.config.ts`, the SPA's build), `spa.md` (the hooks, forms and
305
+ their errors, the caller, the components, routing, theming, screen tests), `table-and-migration.md`, `endpoint.md`
306
+ (access forms, which `over`, the caller's scopes, handles, lists and aggregates, `fromRow`, `ctx.sql`, audited CSV
307
+ downloads, members and labels), `tests.md` (generated tests, seeds, `testKit`, a reused scope, a caller's label, confirming a file ran),
308
+ `errors.md` (checker findings, type errors, runtime answers), `dev.md` (the seed and the preview), `jobs.md` (which dimension `ctx.within` names, the UTC
309
+ day), `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
310
+ `start.md`, a new application before its first table (its first test, adding the first table, installing and updating
311
+ this skill), and `public-intake.md`, a public page, a form, a job that issues links and e-mails them, a link holder's
312
+ files, HR's review with its history.
313
+
314
+ ## Not built yet
315
+
316
+ - The data layer runs on Bun and Postgres only (Zerops). On Cloudflare an application has no tables, jobs or files.
317
+ - Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A list write is off the road (`sql`).
318
+ - An MCP workspace has no shell, so `codegen` and `test-scaffold` cannot run there. Tell the human; never write a
319
+ generated file by hand.