@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.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- 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:
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
##
|
|
18
|
+
## Entrypoints
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
34
|
-
env: Env
|
|
35
|
-
auth: AuthContext
|
|
36
|
-
}
|
|
32
|
+
## A contract application
|
|
37
33
|
|
|
38
|
-
|
|
34
|
+
```ts
|
|
35
|
+
// fabrika.contract.ts
|
|
36
|
+
import { defineContract } from '@fabrika/app/contract'
|
|
37
|
+
import { z } from 'zod/v4'
|
|
39
38
|
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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 {
|
|
136
|
+
import { service } from './app'
|
|
124
137
|
|
|
125
|
-
export default createCloudflareWorker(
|
|
138
|
+
export default createCloudflareWorker(service)
|
|
126
139
|
```
|
|
127
140
|
|
|
128
|
-
A long-running Bun process wraps the
|
|
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
|
|
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
|
+
"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.
|
|
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.
|