@fabrika/app 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,627 @@
1
+ # references/endpoint.md — endpoints, their handles and raw SQL
2
+
3
+ An endpoint is declared and handled in one place: `r.query({ input, output, access, … }).handle(async ({ input,
4
+ ctx }) => …)` inside `app.group({ callers }, (r) => ({ … }))` of a slice's `endpoints.ts`. The kit types the handler by
5
+ its endpoint: a field the output does not declare, a handle the access form does not give and a write in a query are
6
+ type errors at their line.
7
+
8
+ ## Schemas
9
+
10
+ ```ts src/schemas.ts
11
+ import { z } from 'zod/v4'
12
+
13
+ export const TaskStatus = z.enum(['open', 'doing', 'done'])
14
+ export const Saved = z.strictObject({ id: z.uuid(), version: z.int() })
15
+ export const ById = z.strictObject({ id: z.uuid() })
16
+ export const Versioned = z.strictObject({
17
+ id: z.uuid(),
18
+ expectVersion: z.int().positive(),
19
+ })
20
+ export const Cursor = z.string().max(500)
21
+ export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
22
+ ```
23
+
24
+ - `z` comes from `zod/v4`. Objects are `z.strictObject`, never `z.object`. No `transform`, `refine`, `pipe`,
25
+ `preprocess`, `passthrough`, `lazy` or `custom`: the shell is read without running it.
26
+ - `src/schemas.ts` holds what several slices, or a slice and the SPA, share, as literal consts only. A slice declares
27
+ its own schemas in its `endpoints.ts` (`Task`, `NewTask` in `SKILL.md`), and may declare other consts there too.
28
+ - A pure function both the server and the SPA run (a template fill, a label) lives in `src/shared/<name>.ts`, once: a
29
+ handler and a `web/` module import the same function, so the preview and the e-mail cannot drift. A shared module
30
+ imports only `zod/v4` and other modules of `src/shared/` (`shared-import`); no slice, no `src/db/`, no `@fabrika/*`,
31
+ no global object, no environment.
32
+ - `z.iso.datetime()` takes UTC (`…Z`) only; `z.iso.date()` is `YYYY-MM-DD`.
33
+ - Optional or nullable: on a create, a field the caller may leave out is `.optional()`, never a required
34
+ `z.nullable(…)`, and the handler writes `?? null` (`NewTask.dueOn`). On an edit, a field the caller must send is required (`EditTask.title`), one the caller may
35
+ clear is `z.nullable(…)` (`EditTask.dueOn`), and a patch that sends only what changed takes `.optional()` fields: an
36
+ update that sets no column writes nothing and answers the row's current `{ id, version }`. The generated cases send
37
+ no optional field and `null` for a nullable one (`references/tests.md`).
38
+ - A query that needs no input declares none: `r.query({ output, access })`, and the SPA calls it with no argument,
39
+ `api.members.orgs()`.
40
+
41
+ ## The access forms and their handles
42
+
43
+ | Access | Use it for | `ctx.db` (query) | adds in a mutation |
44
+ | ---------------------------------------------- | --------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
45
+ | `{ action, on: 'task', from: 'id' }` | one object by id; 404 otherwise | `target.get()`; a child table's `list`, `get` | `getForUpdate()`, `update(v, { expectVersion })`, `remove({ expectVersion })`; child writes |
46
+ | `{ action, in: 'project', from: 'projectId' }` | a container; 403 when not held | `target.get()` (the container's row); `target.<table>.list` | `target.<table>.insert(v)` |
47
+ | `{ action, over: 'org' }` | a list across the caller's scopes | `within.<table>.list`, `.count`, `.aggregate` | — (a list write: `sql` or `unscoped`) |
48
+ | `{ action, creates: 'org' }` | a new top-level scope from IAM | — | `created.<table>.insert(v)` |
49
+ | `{ action, app: true }` | data of the whole application | `app.<table>.list`, `.get(id)`; every row of a scoped table | `app.<table>.insert(v)`, `.update(id, v, …)`, `.remove(id, …)` of an application table |
50
+ | `{ member: true }` | any member, no application data | nothing | nothing |
51
+
52
+ - Every endpoint also has `ctx.db.reference.<table>` (reference tables), `ctx.principal`
53
+ (`{ kind: 'user', id, label, email }` for a `human` caller), `ctx.requestId` and `ctx.signal`, which aborts when
54
+ the client leaves or the request runs past its budget (10 s); its transaction then rolls back. Pass it to every
55
+ outbound call. An `on`, `in` or `over` endpoint also reads `ctx.db.app.<table>`: each application table whose read
56
+ action is the endpoint's action, every row, read-only (below).
57
+ - `ctx.principal.email` is the mailbox IAM recorded for the person, lower-cased, from the token: the address to mail,
58
+ not a proof that the person controls it. Mail "send it to me"
59
+ there and take no recipient from the input (`references/public-intake.md`). A `machine` caller
60
+ (`{ kind: 'service', id, label }`) has no address. `label` is for display: the address as the person spelled it.
61
+ - **An endpoint the generated tests cover calls no outside service.** They boot without your variables, so a handler
62
+ that calls one answers 500 in each case, and a request cannot undo the call when its transaction rolls back. Such an
63
+ endpoint records an outbox row in its transaction, and a job sends it with an attempt count and an idempotency key
64
+ (`references/jobs.md`). A call that is the request's whole purpose, such as a test e-mail to the caller, lives in a
65
+ `{ member: true }` endpoint, which they do not cover (`references/public-intake.md`). The framework's own calls (IAM,
66
+ the file store, `ctx.connect`) are not outside.
67
+ - `ctx.principal.label` is a `string`, never `null`: the label IAM holds for the person (the one its directory
68
+ shows), or the id when the token carries none. When the caller owns what it writes (the creator of a deal, the
69
+ assignee of a task nobody was picked for), store `ctx.principal.id` and `ctx.principal.label`, never a
70
+ `ctx.members.get(ctx.principal.id)` lookup: it adds a directory read the approver sees. A picked person is
71
+ `ctx.members.get` (Members below).
72
+ - `ctx.target` is the capability (a `Ref`); its id is `ctx.target.value`. `ctx.within` is the `over` capability.
73
+ - Two writes with the same `expectVersion` (two tabs, a double click) give one 200 and one 409, also when they arrive
74
+ together: the second one finds the version moved.
75
+ - A delete is the exception: of two deletes of one row with the same `expectVersion`, the second finds the row gone and
76
+ answers 404, not 409. So does any write that loses to a delete. A test of the race expects `[200, 404]`.
77
+ - A capability reads its own target, whatever its action: `tasks.assign` reads the task it assigns. A column with
78
+ its own read action is left out for every other action.
79
+ - Never use `{ member: true }` for application data. `callers` is `['human']`, `['machine']` or both; every group of
80
+ one mount names the same callers.
81
+
82
+ ### Data of the whole application: `app: true`
83
+
84
+ A code list an administrator edits, operational counts, the log of an export of every record: data that belongs to no
85
+ dimension value takes `access: { action, app: true }`.
86
+
87
+ - The proxy admits it only for a caller who holds the action as a global grant (a role granted without a scope); a
88
+ grant in any one scope is 403. So declare it only where every caller is application-wide; a list a scoped member
89
+ also uses stays `over`.
90
+ - `ctx.db.app` holds every application table (`scope: { app: '<why>' }`, `references/table-and-migration.md`), with
91
+ each operation's own action, and every scoped table, read-only and every row: `list`, `count` and `aggregate` where
92
+ the endpoint's action is its read action or one its policy names in `actions.appRead`, with the columns of that
93
+ action (a column whose own read action is another is left out, as for a `Ref`), and only `count({ where })` where it
94
+ is one of `actions.appCount`. `ctx.app` is the capability.
95
+ - So an export, a bulk send or an operations counter runs under an action of its own, not the read action: the table
96
+ names it (`appRead: ['candidates.export']`, `appCount: ['candidates.count']`), and a global holder of only the read
97
+ action gets 403 from that endpoint. An `appCount` action has no `aggregate`: a group, a minimum or a sum is a row's
98
+ value. Count each value of a known set with its own `where` (`count({ where: [['status', '=', status]] })`).
99
+ - An endpoint `on`, `in` or `over` a dimension reads an application table too, read-only and every row, where its
100
+ action is the table's read action: `list`, `count`, `aggregate` and `get(id)` of `ctx.db.app.<table>`, and no write.
101
+ With another action the table is a type error that names its read action, and a refusal at run time. So a code list
102
+ an administrator edits is validated by reading it: an unknown or retired value answers `BadRequestError` (400) that
103
+ names it (`decide` in `references/public-intake.md`), not a foreign key's 500.
104
+ - An audited export of every record is one `app` mutation of its own action, which the records' policy names in
105
+ `appRead`: it lists the records through `ctx.db.app.<table>` and inserts its log row into an application table whose
106
+ insert action is the export's action (`references/public-intake.md`). It needs no `sql` and no `unscoped`.
107
+ - An `app` endpoint has no `ctx.target`, `ctx.within`, `ctx.members` or `ctx.links`. A link procedure and an export
108
+ keep their own forms.
109
+ - The approver sees it as "app-wide: a global grant", and a change of an endpoint to or from `app: true` widens.
110
+
111
+ ### The methods of each handle
112
+
113
+ | Handle | Methods |
114
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `target` (the object of `on`, the container of `in`) | `get()`; under `on`, in a mutation, `getForUpdate()`, `update(values, { expectVersion })`, `remove({ expectVersion })` |
116
+ | `target.<child table>` (`on`) | `list(options)`, `get(id)`; in a mutation `insert(values)`, `update(id, values, { expectVersion })`, `remove(id, { expectVersion })`; no `getForUpdate` |
117
+ | `target.<table>` (`in`), `created.<table>` (`creates`) | `list(options)` of a contained table; in a mutation `insert(values)` |
118
+ | `within.<table>` (`over`) | `list(options)`, `count({ where })`, `aggregate(spec)` |
119
+ | `reference.<table>` | `list(options)`, `count({ where })`: no `get`; look a row up by its key with `where` (`priorities.get` below) |
120
+ | `app.<table>` (`app: true`), an application table | `list(options)`, `count({ where })`, `aggregate(spec)`, `get(id)`; in a mutation `getForUpdate(id)`, `insert(values)`, `update(id, values, { expectVersion })`, `remove(id, { expectVersion })` |
121
+ | `app.<table>` (`app: true`), a scoped table | `list(options)` (read action or `appRead`), `count({ where })` (also `appCount`), `aggregate(spec)` |
122
+ | `app.<table>` (`on`, `in`, `over`) | an application table whose read action is the endpoint's: `list(options)`, `count({ where })`, `aggregate(spec)`, `get(id)`; no write |
123
+ | `unscoped.<table>` (declared) | `list`, `count`, `aggregate`, `get(id)` (not of a reference table or an inbox, which have no key); with `'write'` also `insert`, `update`, `remove` |
124
+
125
+ `{ expectVersion }` is taken only on a table with `version`.
126
+
127
+ ### Which dimension `over` names
128
+
129
+ `over` names the nearest dimension of the listed rows' chain: the dimension the table's rows lie in (tasks lie in a
130
+ project: `over: 'project'`; projects lie in an org: `over: 'org'`), a child table's parent (`task_comments`:
131
+ `over: 'task'`), or a top-level table's own dimension. The caller's grants on that dimension and on every dimension
132
+ containing it count; a grant held only below it does not.
133
+
134
+ - A farther dimension reaches fewer rows: `over: 'org'` on tasks ignores a grant held on one project, which answers
135
+ 403. With roles granted only in the org, both list the same rows today, so the difference shows only later.
136
+ - The dimension is part of the endpoint's requirement, and the approver's diff shows any change of a requirement as a
137
+ widening. Choose it by this rule when you write the endpoint; do not change it to pass a test.
138
+ - `ctx.db.within` reaches every table whose chain holds the dimension, so `over: 'org'` also types tasks two levels
139
+ down. The rule still names `project` for a list of tasks.
140
+
141
+ ### The caller's scopes: `ctx.within.values`
142
+
143
+ `ctx.within.values` is the list of values of the `over` dimension in which the caller holds the action, or `'all'`
144
+ for a global grant (an operator's or an auditor's role granted without a scope), which cannot be listed.
145
+ `ctx.within.containers` holds the grants on dimensions above it. `orgs` below answers "the orgs I work in", for a
146
+ picker of the org a new project goes into; an `'all'` caller types the org instead.
147
+
148
+ ## Lists
149
+
150
+ - `list({ where, orderBy, limit, after })` answers `{ items, next }`: a keyset page of at most `limit` rows (50 by
151
+ default, 500 at most). Pass `next` back as `after`; it is `null` on the last page.
152
+ - `where` is AND of `[column, operator, value]`: `=`, `<>`, `<`, `<=`, `>`, `>=`, `in` and `not in` (a list),
153
+ `like`, `ilike`, `not like`, `not ilike`, `contains`. Optional filters are conditional spreads, each tuple
154
+ `as const` (`list` in `SKILL.md`): `...(input.status === undefined ? [] : [['status', '=', input.status] as const])`.
155
+ There is no `is null` and no OR: write that in `ctx.sql`.
156
+ - `orderBy` is `[[column, 'asc' | 'desc']]`; the key is added last as the tiebreak.
157
+ - `count({ where })` answers a number. `aggregate({ groupBy, count, sum, min, max })` takes lists of column names
158
+ and answers one row per group: the `groupBy` columns, `count` as a string (Postgres `bigint`), and `sum_<column>`,
159
+ `min_<column>`, `max_<column>` per listed column. Over a `deals` table, `{ groupBy: ['stage'], count: true, sum:
160
+ ['value'] }` answers `{ stage, count, sum_value }` per stage.
161
+ `sum_<column>` is typed `string | number | null` for every column: at runtime text for an integer or `numeric`
162
+ column, a number for a floating-point one. It is `null` when no row of the group has a value: over no rows (no
163
+ `groupBy`), or a nullable column that is null in every row of the group. `min_`/`max_` are `null` the same way. A
164
+ caller whose capability reaches no value gets no row at all (`[]`).
165
+ - Convert a sum with a checked conversion, never a bare `Number(…)`: past 2^53 − 1 a number rounds silently.
166
+ `exactInteger` in `logic.ts` below takes what the type gives without `null` and refuses instead; a missing sum is
167
+ a total of zero (`exactInteger(row.sum_value ?? 0)`). A count is far from that limit, so `Number(…)` is fine
168
+ there. Or keep the sum a string in the output (`z.string().regex(/^-?[0-9]+$/)`).
169
+
170
+ ## Business rules: `logic.ts`
171
+
172
+ ```ts src/tasks/logic.ts
173
+ export const canClose = (status: 'open' | 'doing' | 'done'): boolean =>
174
+ status === 'doing'
175
+
176
+ /** An aggregate's sum, text or a number; one past 2^53 - 1 is refused, never rounded. */
177
+ export const exactInteger = (sum: string | number): number => {
178
+ const value = Number(sum)
179
+ if (!Number.isSafeInteger(value)) {
180
+ throw new RangeError('the total is beyond the safe integer range')
181
+ }
182
+ return value
183
+ }
184
+ ```
185
+
186
+ `logic.ts` takes and answers plain values: no `ctx`, no handle, no row it was not given. Test it without a database.
187
+
188
+ ## A slice of its own
189
+
190
+ ```ts src/projects/endpoints.ts
191
+ import { fromPage } from '@fabrika/app'
192
+ import { z } from 'zod/v4'
193
+ import { app } from '../app-kit'
194
+ import { Cursor, Saved } from '../schemas'
195
+
196
+ const Project = z.strictObject({
197
+ id: z.uuid(),
198
+ name: z.string(),
199
+ version: z.int(),
200
+ })
201
+ const ProjectPage = z.strictObject({
202
+ items: z.array(Project),
203
+ next: z.nullable(z.string()),
204
+ })
205
+ const ByOrg = z.strictObject({
206
+ orgId: z.string().min(1).max(100),
207
+ after: Cursor.optional(),
208
+ })
209
+ const NewProject = z.strictObject({
210
+ orgId: z.string().min(1).max(100),
211
+ name: z.string().min(1).max(200),
212
+ })
213
+
214
+ export const projects = app.group({ callers: ['human'] }, (r) => ({
215
+ list: r.query({
216
+ input: ByOrg,
217
+ output: ProjectPage,
218
+ access: { action: 'tasks.read', in: 'org', from: 'orgId' },
219
+ })
220
+ .handle(async ({ input, ctx }) =>
221
+ fromPage(
222
+ Project,
223
+ await ctx.db.target.projects.list({
224
+ orderBy: [['name', 'asc']],
225
+ after: input.after,
226
+ }),
227
+ )
228
+ ),
229
+ create: r.mutation({
230
+ input: NewProject,
231
+ output: Saved,
232
+ access: { action: 'tasks.write', in: 'org', from: 'orgId' },
233
+ })
234
+ .handle(({ input, ctx }) =>
235
+ ctx.db.target.projects.insert({ name: input.name })
236
+ ),
237
+ }))
238
+ ```
239
+
240
+ A child table is reached through its parent's `on` endpoint. A table without `version` answers `{ id }`:
241
+
242
+ ```ts src/comments/endpoints.ts
243
+ import { fromPage } from '@fabrika/app'
244
+ import { z } from 'zod/v4'
245
+ import { app } from '../app-kit'
246
+ import { Cursor } from '../schemas'
247
+
248
+ const Comment = z.strictObject({
249
+ id: z.uuid(),
250
+ body: z.string(),
251
+ createdBy: z.string(),
252
+ createdAt: z.iso.datetime(),
253
+ })
254
+ const CommentPage = z.strictObject({
255
+ items: z.array(Comment),
256
+ next: z.nullable(z.string()),
257
+ })
258
+ const ByTask = z.strictObject({ taskId: z.uuid(), after: Cursor.optional() })
259
+ const NewComment = z.strictObject({
260
+ taskId: z.uuid(),
261
+ body: z.string().min(1).max(2000),
262
+ })
263
+ const Added = z.strictObject({ id: z.uuid() })
264
+
265
+ export const comments = app.group({ callers: ['human'] }, (r) => ({
266
+ list: r.query({
267
+ input: ByTask,
268
+ output: CommentPage,
269
+ access: { action: 'tasks.read', on: 'task', from: 'taskId' },
270
+ })
271
+ .handle(async ({ input, ctx }) =>
272
+ fromPage(
273
+ Comment,
274
+ await ctx.db.target.task_comments.list({
275
+ orderBy: [['created_at', 'asc']],
276
+ after: input.after,
277
+ }),
278
+ )
279
+ ),
280
+ add: r.mutation({
281
+ input: NewComment,
282
+ output: Added,
283
+ access: { action: 'tasks.write', on: 'task', from: 'taskId' },
284
+ })
285
+ .handle(({ input, ctx }) =>
286
+ ctx.db.target.task_comments.insert({ body: input.body })
287
+ ),
288
+ }))
289
+ ```
290
+
291
+ A reference table belongs to no scope, so a `{ member: true }` endpoint reads it. It has no `get`: look a row up by its
292
+ key with a `where`:
293
+
294
+ ```ts src/priorities/endpoints.ts
295
+ import { fromPage, fromRow, NotFoundError } from '@fabrika/app'
296
+ import { z } from 'zod/v4'
297
+ import { app } from '../app-kit'
298
+
299
+ const Priority = z.strictObject({ code: z.string(), label: z.string() })
300
+ const PriorityPage = z.strictObject({
301
+ items: z.array(Priority),
302
+ next: z.nullable(z.string()),
303
+ })
304
+ const ByCode = z.strictObject({ code: z.string().min(1).max(20) })
305
+
306
+ export const priorities = app.group({ callers: ['human'] }, (r) => ({
307
+ list: r.query({ output: PriorityPage, access: { member: true } })
308
+ .handle(async ({ ctx }) =>
309
+ fromPage(
310
+ Priority,
311
+ await ctx.db.reference.priorities.list({ orderBy: [['rank', 'asc']] }),
312
+ )
313
+ ),
314
+ get: r.query({ input: ByCode, output: Priority, access: { member: true } })
315
+ .handle(async ({ input, ctx }) => {
316
+ const page = await ctx.db.reference.priorities.list({
317
+ where: [['code', '=', input.code]],
318
+ limit: 1,
319
+ })
320
+ const row = page.items[0]
321
+ if (row === undefined) throw new NotFoundError('no such priority')
322
+ return fromRow(Priority, row)
323
+ }),
324
+ }))
325
+ ```
326
+
327
+ Mount every group in `fabrika.contract.ts`:
328
+ `api: c.rpc('/api', { projects, tasks, comments, reports, members, priorities })`. A procedure is served at
329
+ `POST /api/<group>.<endpoint>`, and answers `{ result: <output> }` (`references/errors.md`).
330
+
331
+ ## Rows and outputs
332
+
333
+ - `fromRow(Schema, row)` turns snake_case columns into the schema's camelCase fields, a `Date` into ISO text, drops a
334
+ column the schema does not declare, and parses the rest. `fromPage(Schema, page)` does it for every row of a
335
+ `list` answer and keeps its `next`: the whole handler of a list is `fromPage(Item, await ….list({ … }))`.
336
+ - `RowOf<typeof tables, '<table>'>` (`@fabrika/db/tables`) is the row a table's read action reads, for a function that
337
+ takes one: `const toTask = (row: RowOf<typeof tables, 'tasks'>) => fromRow(Task, row)`.
338
+
339
+ ## Shared data access: `queries.ts`
340
+
341
+ A function of the slice's `queries.ts` takes the handle as a parameter typed with `DbHandle` and returns plain values.
342
+ It decides no authorization: the capability already bounds the handle.
343
+
344
+ ```ts src/reports/queries.ts
345
+ import type { DbHandle } from '@fabrika/app/contract'
346
+ import type { app } from '../app-kit'
347
+
348
+ export const statusCounts = async (
349
+ within: DbHandle<typeof app, 'over', 'project', 'tasks.read'>,
350
+ ) => {
351
+ const rows = await within.tasks.aggregate({ groupBy: ['status'], count: true })
352
+ const count = (status: 'open' | 'doing' | 'done') =>
353
+ Number(rows.find((row) => row.status === status)?.count ?? 0)
354
+ return { open: count('open'), doing: count('doing'), done: count('done') }
355
+ }
356
+ ```
357
+
358
+ `DbHandle<typeof app, 'on' | 'in', '<dimension>', '<action>', 'mutation'>` is `ctx.db.target` of that form;
359
+ `DbHandle<typeof app, 'over', …>` is `ctx.db.within`.
360
+
361
+ ## Raw SQL: `sql: 'read'`
362
+
363
+ Raw SQL is legitimate for a join, a report, an `OR` or `IS NULL` filter, or a bulk write. Declare it on the endpoint;
364
+ `ctx.sql` exists only there.
365
+
366
+ ```ts src/reports/endpoints.ts
367
+ import { attachment } from '@fabrika/app/contract'
368
+ import { z } from 'zod/v4'
369
+ import { app } from '../app-kit'
370
+ import { TaskStatus } from '../schemas'
371
+ import { statusCounts } from './queries'
372
+
373
+ const Workload = z.strictObject({
374
+ orgId: z.string().min(1).max(100),
375
+ status: TaskStatus.optional(),
376
+ })
377
+ const ByProject = z.strictObject({ projectId: z.uuid() })
378
+ const WorkloadRow = z.strictObject({
379
+ assignee: z.nullable(z.string()),
380
+ open: z.int(),
381
+ overdue: z.int(),
382
+ })
383
+ const WorkloadReport = z.strictObject({ rows: z.array(WorkloadRow) })
384
+ const Counts = z.strictObject({ open: z.int(), doing: z.int(), done: z.int() })
385
+ const TaskExportRow = z.strictObject({
386
+ title: z.string(),
387
+ status: TaskStatus,
388
+ assignee: z.nullable(z.string()),
389
+ dueOn: z.nullable(z.string()),
390
+ })
391
+ const TaskExport = attachment({
392
+ format: 'csv',
393
+ filename: 'tasks.csv',
394
+ row: TaskExportRow,
395
+ maxBytes: 5_000_000,
396
+ })
397
+
398
+ export const reports = app.group({ callers: ['human'] }, (r) => ({
399
+ workload: r.query({
400
+ input: Workload,
401
+ output: WorkloadReport,
402
+ access: { action: 'tasks.read', in: 'org', from: 'orgId' },
403
+ sql: 'read',
404
+ })
405
+ .handle(async ({ input, ctx }) => {
406
+ const status = input.status ?? null
407
+ const rows = await ctx.sql(WorkloadRow)`
408
+ select t.assignee,
409
+ count(*) filter (where t.status <> 'done')::int as open,
410
+ count(*) filter (where t.status <> 'done' and t.due_on < current_date)::int as overdue
411
+ from tasks t
412
+ where ${ctx.sql.scope(ctx.target, 'tasks', 't')}
413
+ and (${status}::text is null or t.status::text = ${status})
414
+ group by t.assignee
415
+ order by t.assignee`
416
+ return { rows }
417
+ }),
418
+ counts: r.query({
419
+ output: Counts,
420
+ access: { action: 'tasks.read', over: 'project' },
421
+ })
422
+ .handle(({ ctx }) => statusCounts(ctx.db.within)),
423
+ exportProject: r.mutation({
424
+ input: ByProject,
425
+ output: TaskExport,
426
+ access: { action: 'tasks.read', in: 'project', from: 'projectId' },
427
+ sql: 'read',
428
+ })
429
+ .handle(async ({ ctx }) => {
430
+ const rows = await ctx.sql(TaskExportRow)`
431
+ select t.title, t.status, t.assignee, t.due_on::text as due_on
432
+ from tasks t
433
+ where ${ctx.sql.scope(ctx.target, 'tasks', 't')}
434
+ order by t.title
435
+ limit 10000`
436
+ await ctx.db.target.project_exports.insert({ row_count: rows.length })
437
+ return rows
438
+ }),
439
+ }))
440
+ ```
441
+
442
+ - Every `${…}` of a value is a bound parameter, never SQL text. The one exception is `${ctx.sql.scope(…)}`: it is SQL
443
+ (the predicate) around its own bound values, and it may have no value at all (`(true)`, `(false)`). Put no
444
+ interpolation inside quotes, a quoted name, a dollar body or a comment: the tag refuses it.
445
+ - **Scope every scoped table the statement reads with `${ctx.sql.scope(capability, '<table>', '<alias>')}`.**
446
+ `ctx.sql` has no scope predicate of its own. The fragment is the capability's whole predicate on that table: for a
447
+ `Ref` the object's column and every link of its chain, so a project moved to another org is out; for a `Within` its
448
+ values, each container grant on its own column, every row for a global grant and none for an empty one. The
449
+ capability is the access form's: `ctx.target` for `on` and `in`, `ctx.within` for `over`; a job passes the `Ref` or
450
+ `Within` it minted to `tx.sql.scope`. A table the capability does not reach is a type error at the table name. A
451
+ reference table is shared by every scope and needs no fragment, and no statement writes it. A form's inbox needs none either; read it from a site that holds the inbox's
452
+ `read` action (the intake job of `references/public-intake.md`).
453
+ - **A fragment carries its action into the statement.** On the table an `update` or `delete` writes it needs the
454
+ table's own update or delete action (`ctx.within('tasks.write', 'project')` for `update tasks`), so a read action
455
+ never scopes a write; anywhere else it needs an action the handles read the table with. `insert … on conflict do
456
+ update … where ${…}` is an update of the inserted table. The fragment answers for its own query block: a CTE that
457
+ updates and a statement that deletes take one capability each, and another occurrence of the written table (a
458
+ subquery, `from tasks other`) reads with a read capability under its own alias. A refused fragment fails the call
459
+ before anything is sent.
460
+ - **A fragment is a conjunct.** It stands alone, joined by AND, at the top level of a WHERE or JOIN … ON clause whose
461
+ top level holds no OR: `where (<the whole filter>) and ${…}`, `join projects p on p.id = t.project_id and ${…}`.
462
+ The tag refuses `where a or ${…}`, `where a or b and ${…}`, `not ${…}`, a fragment in parentheses or outside such a
463
+ clause. Never scope by hand with `ctx.target.value` alone: it drops the chain. The generated tests check that an `in`
464
+ endpoint with `sql` leaks nothing of another scope.
465
+ - Rows are parsed like `fromRow` (snake_case to camelCase), then by the schema. Postgres sends `bigint` and `numeric`
466
+ as text: cast a count (`count(*)::int`), and a sum to the type its output declares (`::int` for an integer sum that
467
+ fits, `::float8` for a fractional one), or keep it a string. A sum over no rows is `null`: `coalesce(sum(…), 0)`.
468
+ - An optional filter is `(${value}::text is null or <column> = ${value})`.
469
+ - One statement: `select`, `with`, `values`, plus `insert`, `update` and `delete` on a `'write'` site (a mutation, a
470
+ job or a webhook). Refused before anything is sent: DDL, `SET`, `COPY`, transaction control, another schema, any
471
+ `fabrika_*` or `pg_*` name, and calls outside the reviewed built-ins. A function your migrations declare is refused
472
+ too: call it from a view. These name rules are defense in depth; the runtime role (DML only) is the boundary.
473
+ - A query runs in a read-only transaction; a mutation's `ctx.sql` runs in the same transaction as its handles.
474
+
475
+ ## A file download: `attachment`
476
+
477
+ `output: attachment({ format: 'csv', filename, row, maxBytes, maxRows? })` (`@fabrika/app/contract`) makes the endpoint
478
+ answer a file: the handler returns the rows (an array of `row`), and the framework writes the CSV. `exportProject`
479
+ above is one. `format: 'xlsx'` writes a spreadsheet instead, from the same rows.
480
+
481
+ - `row` is a `z.strictObject` of scalar fields (string, number, boolean, enum, literal; nullable or optional); its
482
+ field names are the header line, in their order. `filename` is fixed (`[A-Za-z0-9._-]`, ending in `.csv`, or
483
+ `.xlsx` for an XLSX).
484
+ - The framework writes RFC 4180 CSV in UTF-8 with a byte order mark, and puts `'` before every text cell a spreadsheet
485
+ would run as a formula (one that starts with `=`, `+`, `-`, `@`, a tab or a line break, also after spaces). Never
486
+ write CSV by hand.
487
+ - An XLSX has one sheet with typed cells: a number cell, a boolean cell, and a date cell for a string column whose
488
+ schema is `z.iso.date()` or `z.iso.datetime()` (shown in UTC); every other value is text, which no spreadsheet runs
489
+ as a formula, so it keeps its `=`. Its parts take about 3.5 KB beyond the cells: declare `maxBytes` above that.
490
+ Never write an XLSX by hand or add a library for it.
491
+ - `maxBytes` (at most 16 MiB) and `maxRows` (10 000 unless declared, at most 100 000) bound the file; a cell holds at
492
+ most 32 767 characters. Past a limit the call fails with a 500 and sends no file. Bound the rows in the query too.
493
+ - **An audited export is a mutation** that writes its log row through `ctx.db` and returns the rows: `exportProject`
494
+ inserts a `project_exports` row (who, when, how many rows) in the invocation's one transaction. The file is encoded
495
+ before the transaction commits, so a file over its limit keeps no log row, and no file leaves without its row. The
496
+ log table lies in the scope the export reads (a child of `projects`), so the export's own capability writes it. An
497
+ export of every record is an `app` mutation that writes an application table instead (`app: true` above).
498
+ - A mutation may declare `sql: 'read'`: its statements only read, and its handles still write.
499
+ - The SPA calls `download(api.reports.exportProject, { projectId })` from `@fabrika/app/client`: it saves the file and
500
+ answers it as a `File`. `api.reports.exportProject` is not a function. A refusal is an `RpcError`.
501
+ - An export (`app.export`) answers JSON only; an `attachment()` is for an RPC or a link procedure.
502
+
503
+ ## Rows outside the capability: `unscoped`
504
+
505
+ `unscoped: { tasks: 'read' }` gives `ctx.db.unscoped.tasks` with no scope predicate (every org), still guarded and in
506
+ the invocation's transaction. `'write'` (a mutation, a job, a webhook) also inserts, updates and removes; an insert
507
+ then names the chain columns itself. Use it for an operator's cross-scope view, never to dodge a capability the caller
508
+ should hold. The generated tests skip the other-scope leak case for such an endpoint.
509
+
510
+ ## Members
511
+
512
+ `ctx.members` of an endpoint `on` or `in` a dimension reads the members of the nearest dimension, its own or one
513
+ containing it, that declares `members`: a task endpoint reads its org's. A dimension with no such container has none.
514
+
515
+ - Every such endpoint: `ctx.members.get(principalId)` answers `{ principalId, label, disabled }` when that principal
516
+ is a member of the value the object lies in, and `null` otherwise (another org's member, an unknown id). IAM checks
517
+ the endpoint's own action on the org itself, so `tasks.assign` validates the person it assigns without a second
518
+ action; a caller who holds the action only on a project is refused (403). Use it for every picked person: store
519
+ `member.principalId` and `member.label`, never a label from the input.
520
+ - `disabled` is a person an operator disabled in IAM: still a member, but unable to sign in. Refuse to assign one
521
+ (`assign` in `SKILL.md`), and leave them out of a picker.
522
+ - The approver sees each endpoint that reads a directory ("looks up one member of `org`"): a new one widens.
523
+ - With the directory's `members.list` action (`tasks.read` here) also `ctx.members.list()`, answering
524
+ `{ principalId, label, roles, disabled }` per member, for an assignee picker. IAM checks that action on the org
525
+ itself too.
526
+ - `label` is `null` unless the caller also holds `members.manage` there, or the dimension opts in with
527
+ `directory: 'labels'`, which widens like any other access change: then every endpoint below it reads labels too.
528
+ - Under `directory: 'labels'` IAM answers every member's label, so `label` is a `string` in `get` and `list()` of every
529
+ endpoint below it (an IAM answer without one is a 500). Elsewhere it is `string | null`: store it in a nullable column
530
+ or output, never with `!` or a cast.
531
+ - With the dimension's own `members.manage` action: `list(ref)`, `assign(ref, { email } | { principalId }, role)` and
532
+ `revoke(ref, …)`, one change per request. `list(ref)` adds each member's `email` and `lastSignInAt` (unix seconds of
533
+ its last sign-in to this application, `null` for one that never signed in) and the open `invitations`: show a person
534
+ as invited, active or disabled from these, never from a column of your own.
535
+ - An application whose roles are global grants (staff, no dimension with members) declares its own people in the
536
+ vocabulary: `members: { manage: 'people.manage', list: 'people.list' }`, plus `directory: 'labels'` to name them.
537
+ An `app: true` endpoint of `people.list` reads `ctx.members.list({ after })`: everyone holding a role of the
538
+ application, `{ principalId, label, email, lastSignInAt, roles: [{ role, scope }], disabled, affiliation }`, 100 a page
539
+ with `next`. Name the author of a stored change from it, never from a copied label. A holder of `people.manage`
540
+ grants the roles in IAM's console; build no role management of your own (`references/public-intake.md`).
541
+ - A partner company's people: a role with `external: true` whose `assignableBy` names an app-wide staff role.
542
+ `assign(ref, { email }, 'partner', { label: company.name, message })` mails the invitation with the company's name
543
+ (≤ 200 characters, one line) and a note (≤ 1000 characters); a new address chooses its password from the mail and
544
+ lands in the application. `{ resend: true }` sends an open invitation again with a new link and 7 days;
545
+ `revoke(ref, { invitationId })` cancels one; `revoke(ref, { principalId })` removes a person, whose access ends
546
+ within the 300 s token lifetime. An address the organization names is refused (409 `internal_mailbox`). A company's
547
+ deactivation is the application's own flag: check it where the endpoint loads the company row.
548
+ - Name a picked person's field by its role (`assignee`, `newOwner`), not `ownerId` or `userId`: the checker reads
549
+ those as the caller's own identity (`client-identity`).
550
+
551
+ ```ts src/members/endpoints.ts
552
+ import { z } from 'zod/v4'
553
+ import { app } from '../app-kit'
554
+ import { OrgInput } from '../schemas'
555
+
556
+ const Member = z.strictObject({
557
+ principalId: z.string(),
558
+ label: z.nullable(z.string()),
559
+ })
560
+ const Members = z.strictObject({ items: z.array(Member) })
561
+ const MyOrgs = z.strictObject({ all: z.boolean(), orgs: z.array(z.string()) })
562
+ const Invite = z.strictObject({
563
+ orgId: z.string().min(1).max(100),
564
+ email: z.email(),
565
+ role: z.enum(['owner', 'member']),
566
+ })
567
+ const Invited = z.strictObject({ invitationId: z.string() })
568
+ const Remove = z.strictObject({
569
+ orgId: z.string().min(1).max(100),
570
+ principalId: z.string(),
571
+ })
572
+ const Removed = z.strictObject({ ok: z.boolean() })
573
+
574
+ export const members = app.group({ callers: ['human'] }, (r) => ({
575
+ list: r.query({
576
+ input: OrgInput,
577
+ output: Members,
578
+ access: { action: 'tasks.read', in: 'org', from: 'orgId' },
579
+ })
580
+ .handle(async ({ ctx }) => ({
581
+ items: (await ctx.members.list())
582
+ .filter((member) => !member.disabled)
583
+ .map((member) => ({
584
+ principalId: member.principalId,
585
+ label: member.label,
586
+ })),
587
+ })),
588
+ orgs: r.query({
589
+ output: MyOrgs,
590
+ access: { action: 'tasks.read', over: 'org' },
591
+ })
592
+ .handle(({ ctx }) =>
593
+ ctx.within.values === 'all'
594
+ ? { all: true, orgs: [] }
595
+ : { all: false, orgs: [...ctx.within.values].sort() }
596
+ ),
597
+ invite: r.mutation({
598
+ input: Invite,
599
+ output: Invited,
600
+ access: { action: 'members.manage', in: 'org', from: 'orgId' },
601
+ })
602
+ .handle(async ({ input, ctx }) => {
603
+ const result = await ctx.members.assign(
604
+ ctx.target,
605
+ { email: input.email },
606
+ input.role,
607
+ )
608
+ return {
609
+ invitationId: 'invited' in result
610
+ ? result.invited.invitationId
611
+ : result.assigned.principalId,
612
+ }
613
+ }),
614
+ remove: r.mutation({
615
+ input: Remove,
616
+ output: Removed,
617
+ access: { action: 'members.manage', in: 'org', from: 'orgId' },
618
+ })
619
+ .handle(async ({ input, ctx }) => {
620
+ await ctx.members.revoke(ctx.target, { principalId: input.principalId })
621
+ return { ok: true }
622
+ }),
623
+ }))
624
+ ```
625
+
626
+ IAM answers the directory. A lookup only reads, so it may come before the write that needs its answer (`assign` in
627
+ `SKILL.md`); do every other data work first.