@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
@@ -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.