@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.
- 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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# references/start.md — a new application, before its first table
|
|
2
|
+
|
|
3
|
+
A new application starts without tables: a vocabulary, a contract, one endpoint and its first test. It needs no
|
|
4
|
+
database, no migration and no generated schema until it stores something. This file is an application of its own; its
|
|
5
|
+
`package.json` and `tsconfig.json` are those of `app-setup.md`, without the `build:web` script until it has an SPA.
|
|
6
|
+
|
|
7
|
+
## The files
|
|
8
|
+
|
|
9
|
+
```ts src/vocabulary.ts
|
|
10
|
+
import { defineVocabulary } from '@fabrika/app/contract'
|
|
11
|
+
|
|
12
|
+
export const vocabulary = defineVocabulary({
|
|
13
|
+
app: 'tasks',
|
|
14
|
+
dimensions: {
|
|
15
|
+
org: { members: { manage: 'members.manage', list: 'tasks.read' } },
|
|
16
|
+
},
|
|
17
|
+
actions: {
|
|
18
|
+
'tasks.read': 'Read the tasks of an org',
|
|
19
|
+
'members.manage': 'Invite and remove members',
|
|
20
|
+
},
|
|
21
|
+
roles: {
|
|
22
|
+
owner: {
|
|
23
|
+
grants: ['tasks.read', 'members.manage'],
|
|
24
|
+
member: { scope: 'org', assignableBy: ['owner'] },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts src/app-kit.ts
|
|
31
|
+
import { defineApp } from '@fabrika/app/contract'
|
|
32
|
+
import { vocabulary } from './vocabulary'
|
|
33
|
+
|
|
34
|
+
export const app = defineApp(vocabulary)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts src/orgs/endpoints.ts
|
|
38
|
+
import { z } from 'zod/v4'
|
|
39
|
+
import { app } from '../app-kit'
|
|
40
|
+
|
|
41
|
+
const MyOrgs = z.strictObject({ all: z.boolean(), orgs: z.array(z.string()) })
|
|
42
|
+
|
|
43
|
+
export const orgs = app.group({ callers: ['human'] }, (r) => ({
|
|
44
|
+
mine: r.query({
|
|
45
|
+
output: MyOrgs,
|
|
46
|
+
access: { action: 'tasks.read', over: 'org' },
|
|
47
|
+
})
|
|
48
|
+
.handle(({ ctx }) =>
|
|
49
|
+
ctx.within.values === 'all'
|
|
50
|
+
? { all: true, orgs: [] }
|
|
51
|
+
: { all: false, orgs: [...ctx.within.values].sort() }
|
|
52
|
+
),
|
|
53
|
+
}))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```ts fabrika.contract.ts
|
|
57
|
+
import { app } from './src/app-kit'
|
|
58
|
+
import { orgs } from './src/orgs/endpoints'
|
|
59
|
+
|
|
60
|
+
export const contract = app.contract((c) => ({
|
|
61
|
+
api: c.rpc('/api', { orgs }),
|
|
62
|
+
health: c.health('/healthz'),
|
|
63
|
+
}))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts src/app.ts
|
|
67
|
+
import { serve } from '@fabrika/app/implement'
|
|
68
|
+
import { contract } from '../fabrika.contract'
|
|
69
|
+
|
|
70
|
+
export default serve(contract, { database: null })
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `defineApp(vocabulary)` declares no tables, so `serve` takes `database: null`, and only then. A handler has no
|
|
74
|
+
`ctx.db`, `ctx.sql` or `unscoped` (a type error that names `defineApp(vocabulary, tables)`); it answers from the
|
|
75
|
+
caller's grants (`ctx.within.values`, `ctx.principal`), the member directory, or a connector.
|
|
76
|
+
- Forms, jobs and file collections keep their rows in tables, so an application gets them with its first table.
|
|
77
|
+
|
|
78
|
+
```ts fabrika.config.ts
|
|
79
|
+
import { compileContract, schemaFromContract } from '@fabrika/app/contract'
|
|
80
|
+
import { defineApp, type ZeropsServiceSpec } from '@fabrika/provider-zerops'
|
|
81
|
+
import { contractProxyTable } from '@fabrika/proxy-core/manifest'
|
|
82
|
+
import { contract } from './fabrika.contract'
|
|
83
|
+
|
|
84
|
+
const compiled = compileContract(contract, {})
|
|
85
|
+
|
|
86
|
+
const services = (): ZeropsServiceSpec[] => [
|
|
87
|
+
{
|
|
88
|
+
hostname: 'tasksapi',
|
|
89
|
+
type: 'alpine/bun@1.3',
|
|
90
|
+
priority: 10,
|
|
91
|
+
enableSubdomainAccess: false,
|
|
92
|
+
minContainers: 1,
|
|
93
|
+
maxContainers: 2,
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
export default defineApp({
|
|
98
|
+
id: contract.vocabulary.app,
|
|
99
|
+
schema: schemaFromContract(compiled),
|
|
100
|
+
target: {
|
|
101
|
+
platform: 'zerops',
|
|
102
|
+
services,
|
|
103
|
+
contract: compiled,
|
|
104
|
+
proxy: { upstream: 'tasksapi:3000', ...contractProxyTable(compiled) },
|
|
105
|
+
deployService: 'tasksapi',
|
|
106
|
+
zeropsSetup: 'tasksapi',
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Without a PostgreSQL service the platform runs no `fabrika db migrate`, no codegen check and no generated test at
|
|
112
|
+
start. `zerops.yaml` holds only `build.base` until the application has an SPA to build.
|
|
113
|
+
|
|
114
|
+
## The first test
|
|
115
|
+
|
|
116
|
+
`bunx fabrika db test-scaffold` writes nothing for this application and says so: the generated tests start with the
|
|
117
|
+
first table (its policy test, then the endpoint access test over its rows). `bun test` fails on an application with no test file, and the platform's
|
|
118
|
+
build runs it, so write the first test yourself:
|
|
119
|
+
|
|
120
|
+
```ts src/__tests__/orgs.test.ts
|
|
121
|
+
import { createClient } from '@fabrika/app/client'
|
|
122
|
+
import { testKit } from '@fabrika/app/testing'
|
|
123
|
+
import { describe, expect, test } from 'bun:test'
|
|
124
|
+
import { contract } from '../../fabrika.contract'
|
|
125
|
+
import app from '../app'
|
|
126
|
+
|
|
127
|
+
const kit = testKit(app)
|
|
128
|
+
|
|
129
|
+
describe('a new application', () => {
|
|
130
|
+
test('answers its health check', async () => {
|
|
131
|
+
expect((await kit.fetch('/healthz')).status).toBe(200)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('lists the orgs a member works in, and refuses a caller of none', async () => {
|
|
135
|
+
const owner = kit.testCaller([
|
|
136
|
+
{ role: 'owner', scope: { type: 'org', value: 'acme' } },
|
|
137
|
+
])
|
|
138
|
+
const api = createClient(contract, 'api', { fetch: kit.browser(owner) })
|
|
139
|
+
expect(await api.orgs.mine()).toEqual({ all: false, orgs: ['acme'] })
|
|
140
|
+
const stranger = kit.browser(kit.testCaller([]))
|
|
141
|
+
const refused = await stranger('/api/orgs.mine', {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: { 'Content-Type': 'application/json' },
|
|
144
|
+
body: '{}',
|
|
145
|
+
})
|
|
146
|
+
expect(refused.status).toBe(403)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The loop of `SKILL.md` without its database lines: `bun test --isolate` until it passes, then `bunx tsc --noEmit` and
|
|
152
|
+
`bunx fabrika-check .` once each.
|
|
153
|
+
|
|
154
|
+
## The first table
|
|
155
|
+
|
|
156
|
+
When the application first stores something, in this order:
|
|
157
|
+
|
|
158
|
+
1. Add the PostgreSQL service to `fabrika.config.ts` (`app-setup.md`). The platform then migrates at every start.
|
|
159
|
+
2. `bunx fabrika db new <slug>` and the table's SQL (`table-and-migration.md`), then `bunx fabrika db migrate` and
|
|
160
|
+
`bunx fabrika db codegen`, which writes `src/db/schema.generated.ts`.
|
|
161
|
+
3. `src/db/tables.ts` with the table's policy, over the generated `columns`.
|
|
162
|
+
4. `defineApp(vocabulary, tables)` in `src/app-kit.ts`, `serve(contract, { database: scoped(tables) })` in
|
|
163
|
+
`src/app.ts`, and `compileContract(contract, tables)` in `fabrika.config.ts`.
|
|
164
|
+
5. `bunx fabrika db test-scaffold`: it now writes the generated tests. Commit them.
|
|
165
|
+
|
|
166
|
+
## The skill itself
|
|
167
|
+
|
|
168
|
+
These rules ship in `@fabrika/app` (`node_modules/@fabrika/app/skill/`), at the version the application installed:
|
|
169
|
+
read them there, or install them where an agent looks for skills.
|
|
170
|
+
|
|
171
|
+
- `bunx fabrika builder init --profile zerops-app --no-daemon --no-open --agent=claude` (or `codex`, or `all`)
|
|
172
|
+
writes them under `.claude/skills/fabrika-app/` (`.agents/skills/fabrika-app/` for Codex) from the application's own
|
|
173
|
+
`@fabrika/app`, beside the builder's `fabrika` skill. It also creates the builder's `.fabrika/` and an empty
|
|
174
|
+
`tests/`. Run `bun install` first, or it installs the builder's copy and says so.
|
|
175
|
+
- After you update `@fabrika/app`, run it again: it replaces the files it installed and keeps a file you edited.
|
|
176
|
+
- An agent that reads `AGENTS.md` gets the same text, rendered by `renderAgentsFile` of `@fabrika/app/agent-skill`:
|
|
177
|
+
`bun -e "import { installedAppAgentSkill, renderAgentsFile } from '@fabrika/app/agent-skill'; const skill =
|
|
178
|
+
installedAppAgentSkill('.'); if (skill) await Bun.write('AGENTS.md', renderAgentsFile(skill))"`. Regenerate it
|
|
179
|
+
after each update; never edit it.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# references/table-and-migration.md — the vocabulary, migrations and table policies
|
|
2
|
+
|
|
3
|
+
The order is fixed: the vocabulary, then the migration, then `fabrika db migrate` and `fabrika db codegen`, then the
|
|
4
|
+
table policies, which take the generated `columns`.
|
|
5
|
+
|
|
6
|
+
## The vocabulary
|
|
7
|
+
|
|
8
|
+
```ts src/vocabulary.ts
|
|
9
|
+
import { defineVocabulary } from '@fabrika/app/contract'
|
|
10
|
+
|
|
11
|
+
export const vocabulary = defineVocabulary({
|
|
12
|
+
app: 'tasks',
|
|
13
|
+
dimensions: {
|
|
14
|
+
org: { members: { manage: 'members.manage', list: 'tasks.read' } },
|
|
15
|
+
project: { in: 'org' },
|
|
16
|
+
task: { in: 'project' },
|
|
17
|
+
},
|
|
18
|
+
actions: {
|
|
19
|
+
'tasks.read': 'Read projects, tasks and comments',
|
|
20
|
+
'tasks.write': 'Create and edit projects, tasks and comments',
|
|
21
|
+
'tasks.assign': 'Assign a task to a member',
|
|
22
|
+
'members.manage': 'Invite and remove members',
|
|
23
|
+
},
|
|
24
|
+
roles: {
|
|
25
|
+
owner: {
|
|
26
|
+
grants: ['tasks.read', 'tasks.write', 'tasks.assign', 'members.manage'],
|
|
27
|
+
member: { scope: 'org', assignableBy: ['owner'] },
|
|
28
|
+
},
|
|
29
|
+
member: {
|
|
30
|
+
grants: ['tasks.read', 'tasks.write'],
|
|
31
|
+
member: { scope: 'org', assignableBy: ['owner'] },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- A **dimension** is a kind of scope value. `in` is its container: every task lies in one project, every project in
|
|
38
|
+
one org. A grant on a container reaches everything inside it. A top-level dimension (`org`) is the value IAM grants
|
|
39
|
+
roles in; no table needs to own it.
|
|
40
|
+
- An **action** is an exact name; a role lists actions and there is no `*`. Give an operation that only some roles
|
|
41
|
+
may do its own action (`tasks.assign`).
|
|
42
|
+
- A **membership role** (`member: { scope, assignableBy }`) is granted in one value of `scope`. `assignableBy` names
|
|
43
|
+
who may give it there: a membership role of the same `scope`, held in that value, or a role with no `member`, which
|
|
44
|
+
IAM grants app-wide (`staff`), held globally. Holding the role is not enough: IAM also requires `members.manage` and
|
|
45
|
+
every action of the role given in that value, which a global grant covers. `external: true` on
|
|
46
|
+
one lets a person from outside the organization (a partner's account) hold it; IAM gives such a person no other
|
|
47
|
+
role. `{ member: true }` then admits them too, so a staff-only endpoint names an action only internal roles grant.
|
|
48
|
+
`ctx.members` answers each member's `affiliation`.
|
|
49
|
+
`members: { manage, list }` on a dimension gives `ctx.members` to the endpoints on, in or below it: a lookup of one
|
|
50
|
+
member everywhere, the list with `list`, management with `manage` (`references/endpoint.md`).
|
|
51
|
+
- `admin` is reserved: IAM defines it for every application. `defineVocabulary` refuses it.
|
|
52
|
+
- `locale: 'cs'` (or `'en'`) and `displayName: 'Tasks'` beside `app` set the language and the name IAM uses for the
|
|
53
|
+
application on its sign-in, password and invitation pages and e-mails. Without `locale` IAM speaks its default
|
|
54
|
+
language, `en`. Without `displayName` the sign-in and password pages and mails name no application, and an invitation
|
|
55
|
+
names it by its id (`tasks@prod`). The approver sees each change.
|
|
56
|
+
- `mail` beside them words IAM's e-mails in the application's own way: per message (`invitation`, `enrollment`,
|
|
57
|
+
`reset`) and language, a one-line `subject` and a plain `text` with `{{name}}` placeholders, for example
|
|
58
|
+
`mail: { reset: { cs: { subject: 'Nové heslo do {{displayName}}', text: 'Heslo si nastavíte zde:\n{{actionUrl}}' } } }`.
|
|
59
|
+
The text must hold `{{actionUrl}}`, the one-time link IAM builds, and the subject must not. Without `html` IAM writes
|
|
60
|
+
the HTML part itself. Every message fills
|
|
61
|
+
`{{displayName}}`, `{{recipient}}`, `{{actionUrl}}`, `{{appUrl}}` and `{{expiry}}`; an invitation also `{{inviterLabel}}`,
|
|
62
|
+
`{{companyLabel}}` and `{{message}}`. Another placeholder is refused. A message or language without a template is IAM's
|
|
63
|
+
own wording.
|
|
64
|
+
- An `html` beside `subject` and `text` is the message's own HTML body, in a closed subset: `<p>`, `<br>`, `<strong>`,
|
|
65
|
+
`<b>`, `<em>`, `<i>`, `<u>`, `<span>`, `<div>`, `<h1>`–`<h3>`, `<ul>`, `<ol>`, `<li>`, `<blockquote>`, `<hr>` and
|
|
66
|
+
`<a>`, lower case and balanced, with no attribute but a link's double-quoted `href`, which is exactly
|
|
67
|
+
`{{actionUrl}}`, `{{appUrl}}` or a `mailto:` address. It holds at least one `<a href="{{actionUrl}}">`. No style,
|
|
68
|
+
image, script, form, frame, comment or other origin: the checker and IAM's reconcile refuse each, and IAM escapes every
|
|
69
|
+
value it fills in. `{{appUrl}}` is the application's first registered return origin; until one is registered, a
|
|
70
|
+
template that names it is sent in IAM's own wording.
|
|
71
|
+
- `creator: '<role>'` on a top-level dimension lets an endpoint with `creates: '<dimension>'` make a new value and
|
|
72
|
+
grant the caller that role there.
|
|
73
|
+
|
|
74
|
+
## The migration
|
|
75
|
+
|
|
76
|
+
```sql db/migrations/20261012090000_tasks.sql
|
|
77
|
+
-- An org is the scope IAM grants roles in; no table owns it. Every contained table repeats its container's chain and
|
|
78
|
+
-- references UNIQUE (id, <chain>) of its container, so a moved project moves its tasks.
|
|
79
|
+
CREATE TABLE projects (
|
|
80
|
+
id UUID PRIMARY KEY,
|
|
81
|
+
org_id TEXT NOT NULL,
|
|
82
|
+
name TEXT NOT NULL,
|
|
83
|
+
created_by TEXT NOT NULL,
|
|
84
|
+
version INTEGER NOT NULL,
|
|
85
|
+
UNIQUE (id, org_id)
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX projects_org ON projects (org_id);
|
|
88
|
+
|
|
89
|
+
CREATE TYPE task_status AS ENUM ('open', 'doing', 'done');
|
|
90
|
+
|
|
91
|
+
CREATE TABLE tasks (
|
|
92
|
+
id UUID PRIMARY KEY,
|
|
93
|
+
project_id UUID NOT NULL,
|
|
94
|
+
org_id TEXT NOT NULL,
|
|
95
|
+
title TEXT NOT NULL,
|
|
96
|
+
status task_status NOT NULL DEFAULT 'open',
|
|
97
|
+
assignee TEXT,
|
|
98
|
+
assignee_label TEXT,
|
|
99
|
+
due_on DATE,
|
|
100
|
+
overdue BOOLEAN NOT NULL DEFAULT false,
|
|
101
|
+
created_by TEXT NOT NULL,
|
|
102
|
+
version INTEGER NOT NULL,
|
|
103
|
+
UNIQUE (id, project_id, org_id),
|
|
104
|
+
FOREIGN KEY (project_id, org_id) REFERENCES projects (id, org_id) ON UPDATE CASCADE
|
|
105
|
+
);
|
|
106
|
+
CREATE INDEX tasks_project ON tasks (project_id);
|
|
107
|
+
|
|
108
|
+
CREATE TABLE task_comments (
|
|
109
|
+
id UUID PRIMARY KEY,
|
|
110
|
+
task_id UUID NOT NULL,
|
|
111
|
+
project_id UUID NOT NULL,
|
|
112
|
+
org_id TEXT NOT NULL,
|
|
113
|
+
body TEXT NOT NULL,
|
|
114
|
+
created_by TEXT NOT NULL,
|
|
115
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
116
|
+
FOREIGN KEY (task_id, project_id, org_id) REFERENCES tasks (id, project_id, org_id) ON UPDATE CASCADE ON DELETE CASCADE
|
|
117
|
+
);
|
|
118
|
+
CREATE INDEX task_comments_task ON task_comments (task_id);
|
|
119
|
+
|
|
120
|
+
CREATE TABLE project_exports (
|
|
121
|
+
id UUID PRIMARY KEY,
|
|
122
|
+
project_id UUID NOT NULL,
|
|
123
|
+
org_id TEXT NOT NULL,
|
|
124
|
+
row_count INTEGER NOT NULL,
|
|
125
|
+
created_by TEXT NOT NULL,
|
|
126
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
127
|
+
FOREIGN KEY (project_id, org_id) REFERENCES projects (id, org_id) ON UPDATE CASCADE ON DELETE CASCADE
|
|
128
|
+
);
|
|
129
|
+
CREATE INDEX project_exports_project ON project_exports (project_id);
|
|
130
|
+
|
|
131
|
+
-- A code list: shared rows that every endpoint reads, and only a migration writes.
|
|
132
|
+
CREATE TABLE priorities (
|
|
133
|
+
code TEXT PRIMARY KEY,
|
|
134
|
+
label TEXT NOT NULL,
|
|
135
|
+
rank INTEGER NOT NULL
|
|
136
|
+
);
|
|
137
|
+
INSERT INTO priorities (code, label, rank) VALUES ('low', 'Low', 1), ('normal', 'Normal', 2), ('high', 'High', 3);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- `bunx fabrika db new <slug>` writes `db/migrations/<UTC YYYYMMDDHHMMSS>_<slug>.sql`. Without a shell, use that name.
|
|
141
|
+
- The key is `id`. A contained table has `<container>_id` and every column of its container's chain (`project_id`,
|
|
142
|
+
`org_id`), `UNIQUE (id, <chain>)`, and a composite foreign key `(<container>_id, <chain>) → <container
|
|
143
|
+
table> (id, <chain>)`. A child table (`task_comments`) does the same with its parent. Without the composite key the
|
|
144
|
+
table policy is refused, and an `over` list cannot reach the table from an outer dimension.
|
|
145
|
+
- `version INTEGER NOT NULL` makes `expectVersion` and the 409 work; `created_by TEXT NOT NULL` with `createdBy` in
|
|
146
|
+
the policy records the caller. The data layer generates `id` and writes `version` and `created_by`.
|
|
147
|
+
- A column with a fixed set of values is an `ENUM` type, not a `CHECK` on text: the generated tests fill text columns
|
|
148
|
+
with markers, which a `CHECK` refuses.
|
|
149
|
+
- Codegen types: `date` is a `YYYY-MM-DD` string; `timestamptz` reads a `Date` and takes a `Date` or a strict ISO 8601 string with `Z` or an offset;
|
|
150
|
+
`int8` and `numeric` are strings; an enum is its literal union. JSON, `bytea`, arrays and domains are refused.
|
|
151
|
+
- Plain SQL of the application's own schema only. Refused: another schema (`public.`, `app_…`), any `fabrika_*`
|
|
152
|
+
object (except a file collection's table, `references/files.md`), `GRANT`, roles, `CREATE EXTENSION`, `COPY`,
|
|
153
|
+
`SET`, transaction control, `SECURITY DEFINER`, a domain, `serial` and sequences (use `GENERATED ALWAYS AS IDENTITY`),
|
|
154
|
+
a function overload, and calls outside the reviewed built-ins.
|
|
155
|
+
- Never edit, rename or delete a committed migration; add a new one. Change a schema by expand, switch, contract, and
|
|
156
|
+
ask before you drop or rename anything.
|
|
157
|
+
|
|
158
|
+
## Database code is a declared deviation
|
|
159
|
+
|
|
160
|
+
A function, trigger, view, rule, policy or `DO` block in a migration is allowed. The migration file declares it, and
|
|
161
|
+
the approver sees it marked "needs closer review". Prefer a handler when the rule is the application's; use database
|
|
162
|
+
code when it must hold for every writer.
|
|
163
|
+
|
|
164
|
+
```sql db/migrations/20261012110000_done_clears_overdue.sql
|
|
165
|
+
CREATE FUNCTION tasks_done_clears_overdue() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
166
|
+
BEGIN
|
|
167
|
+
IF NEW.status = 'done' THEN
|
|
168
|
+
NEW.overdue := false;
|
|
169
|
+
END IF;
|
|
170
|
+
RETURN NEW;
|
|
171
|
+
END
|
|
172
|
+
$$;
|
|
173
|
+
|
|
174
|
+
CREATE TRIGGER tasks_done_clears_overdue BEFORE UPDATE ON tasks
|
|
175
|
+
FOR EACH ROW EXECUTE FUNCTION tasks_done_clears_overdue();
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The platform runs `fabrika db codegen --verify` at every start; it fails when the schema holds database code that no
|
|
179
|
+
migration declares.
|
|
180
|
+
|
|
181
|
+
## The table policies
|
|
182
|
+
|
|
183
|
+
```ts src/db/tables.ts
|
|
184
|
+
import { defineTables } from '@fabrika/db/tables'
|
|
185
|
+
import { vocabulary } from '../vocabulary'
|
|
186
|
+
import { columns } from './schema.generated'
|
|
187
|
+
|
|
188
|
+
export const tables = defineTables(vocabulary, columns)({
|
|
189
|
+
projects: {
|
|
190
|
+
scope: { dimension: 'project' },
|
|
191
|
+
actions: { read: 'tasks.read', insert: 'tasks.write', update: 'tasks.write' },
|
|
192
|
+
insert: ['name'],
|
|
193
|
+
update: ['name'],
|
|
194
|
+
version: 'version',
|
|
195
|
+
createdBy: 'created_by',
|
|
196
|
+
},
|
|
197
|
+
tasks: {
|
|
198
|
+
scope: { dimension: 'task' },
|
|
199
|
+
actions: {
|
|
200
|
+
read: 'tasks.read',
|
|
201
|
+
insert: 'tasks.write',
|
|
202
|
+
update: 'tasks.write',
|
|
203
|
+
delete: 'tasks.write',
|
|
204
|
+
},
|
|
205
|
+
insert: ['title', 'due_on'],
|
|
206
|
+
update: ['title', 'status', 'due_on', 'overdue'],
|
|
207
|
+
version: 'version',
|
|
208
|
+
createdBy: 'created_by',
|
|
209
|
+
columns: {
|
|
210
|
+
assignee: { write: 'tasks.assign' },
|
|
211
|
+
assignee_label: { write: 'tasks.assign' },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
task_comments: {
|
|
215
|
+
scope: { child: 'tasks' },
|
|
216
|
+
actions: { read: 'tasks.read', insert: 'tasks.write' },
|
|
217
|
+
insert: ['body'],
|
|
218
|
+
createdBy: 'created_by',
|
|
219
|
+
},
|
|
220
|
+
project_exports: {
|
|
221
|
+
scope: { child: 'projects' },
|
|
222
|
+
actions: { read: 'tasks.read', insert: 'tasks.read' },
|
|
223
|
+
insert: ['row_count'],
|
|
224
|
+
createdBy: 'created_by',
|
|
225
|
+
},
|
|
226
|
+
priorities: {
|
|
227
|
+
scope: { reference: 'the priority codes; a migration changes them' },
|
|
228
|
+
actions: { read: 'tasks.read' },
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- Every table has a policy. Its scope is one of: `{ dimension: '<d>' }` (the table owns the dimension's values),
|
|
234
|
+
`{ child: '<table>' }` (rows that belong to one row of the parent), `{ app: '<why>' }` (rows of the whole
|
|
235
|
+
application, such as a code list an administrator edits) or `{ reference: '<why>' }` (shared rows every endpoint
|
|
236
|
+
reads and none writes; a migration changes them).
|
|
237
|
+
- An application table takes `actions`, `insert`, `update`, `version`, `createdBy` and `columns` like a dimension
|
|
238
|
+
table, and no container. An `app: true` endpoint reads and writes it (`ctx.db.app.<table>`, `references/endpoint.md`),
|
|
239
|
+
for a caller who holds the operation's action as a global grant. An `on`, `in` or `over` endpoint whose action is
|
|
240
|
+
its read action reads it, every row and read-only, so its read action decides who reads it.
|
|
241
|
+
- A reference table (`priorities`) takes `actions: { read: '<action>' }` and, at most, `columns` with read actions: no
|
|
242
|
+
`insert`, `update`, `version` or `createdBy`. Its rows change by a migration only. No handle writes it, and
|
|
243
|
+
`unscoped: { priorities: 'write' }` is refused by tsc and the checker.
|
|
244
|
+
- The key (`id`), the container column (`<container>_id`), a child's column (`<parent dimension>_id`) and the chain
|
|
245
|
+
are derived. State `in: { column }` or `column` only off that convention.
|
|
246
|
+
- `actions` maps `read`, `insert`, `update` and `delete` to vocabulary actions. `insert` and `update` list the columns
|
|
247
|
+
that those actions write; nothing else is writable.
|
|
248
|
+
- A dimension or child table may also list `appRead: ['<action>', …]`, actions besides `read` with which an `app: true`
|
|
249
|
+
endpoint lists every row, read-only (an export, a bulk send), with the columns of that action, and
|
|
250
|
+
`appCount: ['<action>', …]`, actions with which it only counts rows (`count({ where })`) and reads none, no aggregate
|
|
251
|
+
(an operations counter). Each is a widening the approver sees.
|
|
252
|
+
- `columns: { <column>: { read: '<action>' } }` hides a column from every capability of another action, in the data
|
|
253
|
+
and in the types. `columns: { <column>: { write: '<action>' } }` lets only that action update the column (and that
|
|
254
|
+
action updates nothing else); the column is then not in `update`.
|
|
255
|
+
- A column has one write action. A state that two actions change is two columns: in `references/public-intake.md` the
|
|
256
|
+
link holder's `candidates.verify` sets `verified_at`, HR's `candidates.review` sets `status`, and `logic.ts` derives
|
|
257
|
+
the stage HR sees from both (`stageOf`).
|
|
258
|
+
- An audit or history table is an ordinary child table that a handler writes in its own transaction:
|
|
259
|
+
`project_exports` logs each export of a project's tasks, so its insert action is the export's read action
|
|
260
|
+
(`references/endpoint.md`). The log of an export across every record is an application table instead
|
|
261
|
+
(`references/public-intake.md`). There is no framework audit API.
|
|
262
|
+
- IAM already audits sign-ins (its auth log, refusals included) and every grant and revocation (its admin events
|
|
263
|
+
`iam.grant.create` and `iam.grant.revoke`): do not rebuild either in the application. An operator's access to the
|
|
264
|
+
database or to the provider's console is outside Fabrika.
|