@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,108 @@
1
+ # references/errors.md — what the checker, the compiler and the runtime tell you
2
+
3
+ ## The checker: refusals and hints
4
+
5
+ `bunx fabrika-check .` prints one finding per line, `<file>:<line>:<col> <rule> <what> (allowed form: <fix>)`.
6
+ Refusals come first and fail the check; hints follow under their own heading and do not. Fix both: a hint is the
7
+ architecture, and a refusal's allowed form is the one fix that passes. There is no suppression syntax.
8
+
9
+ The refusals agents meet most, and their fix:
10
+
11
+ | Rule | Means | Fix |
12
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
13
+ | `literal` | the shell (contract, vocabulary, tables, groups, schemas) holds a computed value or call | literal objects, `z.*` calls and imported literal consts; move logic into `.handle(…)` |
14
+ | `contract` | the contract does not compile (a reserved role, an unknown action, a wrong field) | the message names the field |
15
+ | `package-replacement` | `package.json` names the app under `@fabrika/*`, or a dependency is `workspace:*` | another name; a registry range such as `^0.0.4` |
16
+ | `generated-test-hash` | a generated test is missing, edited or stale | `bunx fabrika db test-scaffold`, and commit what it writes |
17
+ | `endpoint-fixture` | an endpoint's input cannot be sampled, a fixture or seed names no endpoint, or a member, invitation or link field no endpoint that reads, cancels or revokes one | a literal in `src/__tests__/endpoint-access.fixtures.ts` (`references/tests.md`) |
18
+ | `raw-sql` | a driver's `sql` (imported from `bun`, Kysely, `postgres`) | the handles, or `sql: 'read'` and `ctx.sql` |
19
+ | `forbidden-import` | a driver, an S3 client, `jose`, `node:http`, `child_process` | the handles, a file collection, a connector |
20
+ | `forbidden-global` | `Bun.sql`, `Bun.serve`, `Bun.spawn`; `globalThis`, `window`, `self`, `global` or `Bun` in a server module | the handles; state lives in a table, not in a global; `crypto.randomUUID()` for an id |
21
+ | `env-read` | `process.env`, `Bun.env` | `defineDeps(build)`, which receives the application's variables |
22
+ | `client-identity` | an input field `userId`, `ownerId`, `createdBy`, `tenantId`, `workspaceId` | the access form's `from`, `ctx.principal.id`, or `createdBy` in the policy |
23
+ | `type-assertion` | `as T` (other than `as const`), `<T>x` | fix the type; `fromRow` parses a row into a schema's type |
24
+ | `sensitive-log` | a log of an error object, a request, headers or the environment | log a short fixed message |
25
+ | `migration-name` | a migration not named `YYYYMMDDHHMMSS_slug.sql` (UTC) | `bunx fabrika db new <slug>` |
26
+ | `test-only-import` | `@fabrika/app/testing` or `@fabrika/db/testing` outside a test file | import them only from `*.test.ts` or `__tests__/` |
27
+ | `spa-server-import` | the SPA loads a server module | the SPA imports `@fabrika/app/client`, the contract, `src/schemas.ts` and `src/shared/` |
28
+ | `shared-import` | a module under `src/shared/` loads a slice, `src/db/`, `@fabrika/*` or a builtin | a shared module imports `zod/v4` and other shared modules only |
29
+ | `skipped-test` | `.skip`, `.only`, `.todo` | run the test |
30
+
31
+ The hints:
32
+
33
+ | Hint | Means | Fix |
34
+ | ---------------------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
35
+ | `handler-location` | a `.handle(…)` outside a slice's `endpoints.ts` | move the endpoint into `src/<slice>/endpoints.ts` |
36
+ | `data-access-location` | `ctx.db` or `ctx.sql` outside `endpoints.ts` and `queries.ts` | pass the handle into a function of the slice's `queries.ts` |
37
+ | `logic-data-access` | `logic.ts` reaches data | keep `logic.ts` pure; pass it plain values |
38
+ | `cross-slice-queries` | a slice loads another slice's `queries.ts` | move the function, or call that slice's endpoint |
39
+ | `sql-undeclared` | `ctx.sql` in a handler whose endpoint declares no `sql` | add `sql: 'read'` (or `'write'`) to that endpoint |
40
+
41
+ The first run may report only the contract's refusals: the generated-test findings appear once the contract compiles.
42
+
43
+ ## Type errors
44
+
45
+ A kit error lands on the line of the mistake, in one or two lines that name the fix. Read the last line first.
46
+
47
+ | The compiler says | Fix |
48
+ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
49
+ | `add .handle(…) to tasks.get: every endpoint takes its handler beside it` | add the handler; when the handler exists, fix the error inside it first |
50
+ | `the handler returns version, which the output does not declare` | return only the output's fields |
51
+ | `… where the output is camelCase: map it with fromRow(Schema, row)` | `fromRow(Schema, row)` |
52
+ | `update on tasks needs the action 'tasks.write', and this endpoint holds '…'` | the endpoint's action, a column write action, or a separate endpoint |
53
+ | `getForUpdate locks the row, so it runs only in a mutation` | `r.mutation` |
54
+ | `update acts on one object: declare access with on` | access `on`, not `in` or `over` |
55
+ | `tasks is not under in: 'org' (it lies in project): declare …` | the access form the message names |
56
+ | `ctx.sql needs a declaration: add sql: 'read' …` | declare `sql` on the endpoint |
57
+ | `tasks is outside this site's scope: declare unscoped: { tasks: 'read' } on it` | declare it, or use the capability's handles |
58
+ | `a job writes inside ctx.transaction((tx) => …): use tx.db there` | move the write into the transaction |
59
+ | `listing the members of org needs the action '…'; get(principalId) checks one` | `ctx.members.get(id)`, or an endpoint of the directory's `members.list` |
60
+ | `neither task nor a dimension containing it declares members` | declare `members` on the dimension whose people the endpoint picks |
61
+ | `'member.label' is possibly 'null'` | a nullable column or output; only `directory: 'labels'` makes it text |
62
+ | `admin is a role IAM defines itself for every application (reserved: admin)` | another role name |
63
+ | `Property 'id' does not exist on type 'Ref<…>'` | `ctx.target.value` |
64
+ | `Property 'margin' does not exist on type 'Omit<DealsTable, "margin">'` | the column has its own read action, which this capability lacks |
65
+ | `Type 'Date' is not assignable to type 'string'` on a `date` column | a `YYYY-MM-DD` string |
66
+ | `Cannot find module '@fabrika/…'` and implicit `any`s everywhere | `bun install` (at the repository root in a monorepo) |
67
+
68
+ ## Runtime answers
69
+
70
+ A procedure, a form, a webhook and a file collection answer JSON in an envelope: a 200 is `{ result: <output> }` (a
71
+ procedure's output, a form's `{ received: true }`, a file list's `{ items, next }`), any other status
72
+ `{ error: { type, message } }`. The typed client unwraps it; a plain `fetch` (a form, a test) reads `result`. A health
73
+ check (`{ status: 'ok' }`) and the SPA's browser configuration answer without one.
74
+
75
+ | Answer | Comes from |
76
+ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
+ | 400 | the input does not match its schema; `BadRequestError` |
78
+ | 401 | no valid credential. In a test, the caller is missing |
79
+ | 403 | the caller lacks the action, or for `in` the scope: a missing grant, never a bug to code around; `ctx.members.list()` below the org for a caller without the list action on the org itself; a path no endpoint declares |
80
+ | 404 | `on`: the object does not exist or is not the caller's, also when a concurrent delete removed it first; `NotFoundError` |
81
+ | 409 | a stale `expectVersion`; `ConflictError`; a lost `getForUpdate` race |
82
+ | 500 | a throw without a numeric `httpStatus` below 500, a `type` and a `message`, a refused statement, a call of `ctx.sql` the endpoint does not declare (`undeclared_reach`), a `ctx.sql` row the schema refuses (`count(*)` without `::int`) |
83
+ | 503 | IAM cannot be asked, or the member directory has no transport |
84
+
85
+ An undeclared path is 403 at the proxy, also for a signed-in caller who holds every action: the proxy serves only the
86
+ contract's endpoints, picks the most specific one, and never falls through to a broader one. A test that asks for a
87
+ route you did not declare (an edit that does not exist) expects 403, not 404. An unknown procedure that reaches the
88
+ application under an RPC prefix is 404, but the proxy stops it first.
89
+
90
+ A connector call is not an HTTP answer: it resolves to an outcome (`references/webhooks-links-connectors.md`), and the
91
+ handler decides what to answer. Any thrown value with a numeric `httpStatus` below 500, a `type` and a `message` (an
92
+ `HttpError` of `@fabrika/app`, such as `ForbiddenError`, or a plain object of those fields) answers its own status
93
+ with its message; throw the three business errors for the application's own refusals.
94
+
95
+ A 500 answers a fixed message; the server log has one line, `fabrika: <endpoint>: <code>: <reason>: <text>`, with a
96
+ fixed failure code and, for a framework refusal, its reason and the reason's fixed text, never a value or your error's
97
+ message. The codes you meet most:
98
+
99
+ | Code | Means |
100
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | `query_refused` | the data layer refused a statement or an operation: `undeclared_reach`, `sql_read_only`, `unknown_column`, `read_only`, `sql_outside_transaction` …; the reason's text names the rule |
102
+ | `capability_refused` | a capability of another kind, dimension or action than the call takes (`wrong_capability`, `wrong_action`, `out_of_reach`), or one kept past its request (`ended`, `foreign_work`) |
103
+ | `handler_failed` | your handler threw something else; the log has no more, so test the handler |
104
+
105
+ With an Operations DSN the same 500 is a `RequestFailed` issue in Operations. A job attempt names its failure with the
106
+ same codes and reasons: `fabrika job run` prints `job <name> failed after 1 attempt: query_refused: undeclared_reach`,
107
+ its log line adds the text, and `kit.runJob` answers `{ status: 'failed', code, reason }`. A `DatabaseError` carries
108
+ only the SQLSTATE: `23505` is a unique violation, `23503` a foreign key, `23502` a null in a `NOT NULL` column.
@@ -0,0 +1,335 @@
1
+ # references/files.md — file collections
2
+
3
+ A file collection attaches uploaded files to the rows of one table that owns a dimension. It is a section of the
4
+ contract with generated endpoints: no handler, no storage client, no URL of your own.
5
+
6
+ ```ts fabrika.contract.ts
7
+ import { app } from './src/app-kit'
8
+ import { comments } from './src/comments/endpoints'
9
+ import { members } from './src/members/endpoints'
10
+ import { priorities } from './src/priorities/endpoints'
11
+ import { projects } from './src/projects/endpoints'
12
+ import { reports } from './src/reports/endpoints'
13
+ import { digest } from './src/tasks/digest'
14
+ import { tasks } from './src/tasks/endpoints'
15
+ import { erase } from './src/tasks/erase'
16
+
17
+ export const contract = app.contract((c) => ({
18
+ api: c.rpc('/api', {
19
+ projects,
20
+ tasks,
21
+ comments,
22
+ reports,
23
+ members,
24
+ priorities,
25
+ }),
26
+ files: {
27
+ attachments: c.files({
28
+ owner: 'tasks',
29
+ read: 'tasks.read',
30
+ write: 'tasks.write',
31
+ delete: 'tasks.write',
32
+ maxBytes: 5242880,
33
+ types: ['image/png', 'image/jpeg', 'application/pdf', 'text/plain'],
34
+ maxPerOwner: 20,
35
+ }),
36
+ },
37
+ jobs: { digest, erase },
38
+ health: c.health('/healthz'),
39
+ }))
40
+ ```
41
+
42
+ The collection serves, for signed-in people only:
43
+
44
+ | Endpoint | Access | Does |
45
+ | -------------------------------------------- | --------------------------- | ----------------------------------------------------------------------- |
46
+ | `POST /api/files.attachments.upload/:taskId` | `write` on the task | the body is the file, `?name=` its name; answers `{ result: { id } }` |
47
+ | `GET /api/files.attachments/:fileId` | `read` on the file's task | streams the file |
48
+ | `POST /api/files.attachments.list` | `read` on the task | body `{ owner, limit?, after? }`; answers `{ result: { items, next } }` |
49
+ | `POST /api/files.attachments.remove` | `delete` on the file's task | body `{ id }` |
50
+
51
+ A file reaches what its task reaches, now: a moved task moves its files. The list answers the task's files oldest
52
+ first, each `{ id, name, type, size, status, createdAt }`, at most `limit` (1–100, default 50) after the id `after`.
53
+
54
+ The SPA calls them through the typed client, never a hand-written `fetch`: `api.files.attachments` of
55
+ `createClient(contract, 'api')`, and on a link mount's client the `link: true` collections, without the owner.
56
+
57
+ ```ts web/attachments.ts
58
+ import { createClient, RpcError } from '@fabrika/app/client'
59
+ import { contract } from '../fabrika.contract'
60
+
61
+ const api = createClient(contract, 'api')
62
+
63
+ /** Uploads `file` to the task, then lists its files; a file's `type` is the declared media type. */
64
+ export const attach = async (
65
+ taskId: string,
66
+ file: File,
67
+ list: HTMLElement,
68
+ ): Promise<void> => {
69
+ try {
70
+ await api.files.attachments.upload(taskId, file)
71
+ const page = await api.files.attachments.list({ owner: taskId })
72
+ list.replaceChildren(
73
+ ...page.items.map((item) =>
74
+ Object.assign(document.createElement('li'), {
75
+ textContent: `${item.name ?? 'file'} (${item.status})`,
76
+ })
77
+ ),
78
+ )
79
+ } catch (error) {
80
+ list.textContent = error instanceof RpcError
81
+ ? `Refused: ${error.type}`
82
+ : 'Failed'
83
+ }
84
+ }
85
+ ```
86
+
87
+ `download(id)` answers a `File` named as it was uploaded (409 `quarantined` while a scan runs), and `remove({ id })`
88
+ removes one. `ResultOf<typeof api.tasks.get>` and `InputOf<typeof api.tasks.create>` of `@fabrika/app/client` name a
89
+ procedure's answer and input for a view that maps them.
90
+
91
+ ## From a handler or a job: `ctx.files`
92
+
93
+ `ctx.files('<collection>')` reaches one collection of the contract from a handler, a job's `ctx` and its `tx`. Each
94
+ call takes a `Ref` of the owner (a task here), for one of the collection's actions:
95
+
96
+ | Call | Takes the owner's `Ref` for | Answers |
97
+ | --------------------------------- | --------------------------- | -------------------------------------------------------------------------- |
98
+ | `list(owner, { after?, limit? })` | `read` | `{ items, next }`, the files the list endpoint answers; `createdAt` a Date |
99
+ | `open(owner, id, { maxBytes })` | `read` | `{ id, name, type, size, bytes }` of one live file, read whole |
100
+ | `removeAll(owner)` | `delete` | how many files it marked removed |
101
+
102
+ A Ref for another action, or of another dimension, is refused. `open` answers 404 for a file of another owner, a
103
+ removed or a rejected one, 409 `quarantined` while the scan has not called it clean, and 413 for a file over
104
+ `maxBytes`, before any byte is read. Pass the size you hold in memory, at most the collection's `maxBytes`. The name
105
+ is checked when the call runs, not by the types: a name the contract does not declare is a 500.
106
+
107
+ Every call needs the collection's `read`, so a write capability alone sees no file. A mutation that requires a
108
+ document is declared with an action that is the collection's `read` too, or the check runs where the caller holds
109
+ `read`: never mint or widen a capability to list.
110
+
111
+ A job mints the owner's `Ref` with `ctx.ref` and opens each live file, here to attach it to an e-mail of `deps`:
112
+
113
+ ```ts src/tasks/digest.ts
114
+ import { app } from '../app-kit'
115
+ import { mailer } from '../mail'
116
+
117
+ /** The largest report one e-mail carries: the collection's maxBytes. */
118
+ const REPORT_MAX_BYTES = 5242880
119
+
120
+ export const digest = app.cron('0 6 * * *', {
121
+ actions: ['tasks.read'],
122
+ scope: 'app',
123
+ })
124
+ .handle(async ({ ctx }) => {
125
+ const mail = mailer.get()
126
+ const tasks = ctx.db.over(ctx.within('tasks.read', 'project')).tasks
127
+ const done = await tasks.list({ where: [['status', '=', 'done']], limit: 50 })
128
+ for (const task of done.items) {
129
+ const owner = await ctx.ref('tasks.read', 'task', task.id)
130
+ const files = ctx.files('attachments')
131
+ const page = await files.list(owner)
132
+ const reports = []
133
+ for (const file of page.items.filter((item) => item.status === 'live')) {
134
+ reports.push(
135
+ await files.open(owner, file.id, { maxBytes: REPORT_MAX_BYTES }),
136
+ )
137
+ }
138
+ ctx.signal.throwIfAborted()
139
+ await mail.sendReports(task.title, reports, ctx.signal)
140
+ }
141
+ })
142
+ ```
143
+
144
+ ```ts src/mail.ts
145
+ import { defineDeps } from '@fabrika/app/implement'
146
+
147
+ export interface Report {
148
+ readonly name: string | null
149
+ readonly type: string
150
+ readonly bytes: Uint8Array
151
+ }
152
+
153
+ export interface Mailer {
154
+ /** Sends the reports of one task; throws when the provider refuses, and gives up once `signal` aborts. */
155
+ sendReports(
156
+ title: string,
157
+ reports: readonly Report[],
158
+ signal: AbortSignal,
159
+ ): Promise<void>
160
+ }
161
+
162
+ const variable = (env: Readonly<Record<string, unknown>>, name: string) => {
163
+ const value = env[name]
164
+ if (typeof value !== 'string' || value === '') {
165
+ throw new Error(`the application variable ${name} is not set`)
166
+ }
167
+ return value
168
+ }
169
+
170
+ export const mailer = defineDeps((env): Mailer => ({
171
+ async sendReports(title, reports, signal) {
172
+ const response = await fetch(variable(env, 'MAIL_API_URL'), {
173
+ method: 'POST',
174
+ headers: {
175
+ Authorization: `Bearer ${variable(env, 'MAIL_API_KEY')}`,
176
+ 'Content-Type': 'application/json',
177
+ },
178
+ body: JSON.stringify({
179
+ to: variable(env, 'REPORTS_TO'),
180
+ subject: `Reports of ${title}`,
181
+ attachments: reports.map((file) => ({
182
+ filename: file.name ?? 'report',
183
+ contentType: file.type,
184
+ content: Buffer.from(file.bytes).toString('base64'),
185
+ })),
186
+ }),
187
+ signal,
188
+ })
189
+ if (!response.ok) {
190
+ throw new Error(`the mail provider answered ${response.status}`)
191
+ }
192
+ },
193
+ }))
194
+ ```
195
+
196
+ ```ts src/app.ts
197
+ import { serve } from '@fabrika/app/implement'
198
+ import { scoped } from '@fabrika/db'
199
+ import { contract } from '../fabrika.contract'
200
+ import { tables } from './db/tables'
201
+ import { mailer } from './mail'
202
+
203
+ export default serve(contract, { database: scoped(tables), deps: mailer })
204
+ ```
205
+
206
+ `removeAll` runs in a mutation and in a job's `tx.files`, in the same transaction as the handler's writes: an erasure
207
+ that blanks a record and removes its files keeps both or neither. A query and a job's `ctx.files` only read. The
208
+ framework's sweep deletes the removed files' objects within the hour, and so the objects of a deleted owner's files.
209
+
210
+ ```ts src/tasks/erase.ts
211
+ import { app } from '../app-kit'
212
+
213
+ /** Blanks the title of every task closed more than a year ago, and removes its attachments with it. */
214
+ export const erase = app.cron('30 2 * * *', {
215
+ actions: ['tasks.read', 'tasks.write'],
216
+ scope: 'app',
217
+ })
218
+ .handle(async ({ ctx }) => {
219
+ const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(
220
+ 0,
221
+ 10,
222
+ )
223
+ const tasks = ctx.db.over(ctx.within('tasks.read', 'project')).tasks
224
+ const stale = await tasks.list({
225
+ where: [['status', '=', 'done'], ['due_on', '<', yearAgo], [
226
+ 'title',
227
+ '<>',
228
+ '',
229
+ ]],
230
+ limit: 100,
231
+ })
232
+ for (const task of stale.items) {
233
+ const ref = await ctx.ref('tasks.write', 'task', task.id)
234
+ await ctx.transaction(async (tx) => {
235
+ const locked = await tx.db.on(ref).getForUpdate()
236
+ await tx.db.on(ref).update({ title: '' }, { expectVersion: locked.version })
237
+ await tx.files('attachments').removeAll(ref)
238
+ })
239
+ }
240
+ })
241
+ ```
242
+
243
+ - The owner is a table with a dimension scope; an application table owns no collection.
244
+ - There is no limit on the bytes of one owner's files together: `maxPerOwner` × `maxBytes` bounds them.
245
+
246
+ ## Malware scan
247
+
248
+ `scan: true` on a collection holds every upload `quarantined` until ClamAV answers clean; on Zerops the platform
249
+ installs ClamAV and runs the scan job. The download answers 409 `quarantined` until then and 404 once the file is
250
+ `rejected`; the list shows each file's `status`, so show it rather than a broken link. In a test, the scan runs with
251
+ `kit.runJob(FILE_SCAN_JOB)` and a `scanner` passed to `testKit` (`references/tests.md`); the generated tests pass their
252
+ own seeded files.
253
+
254
+ - A scanning collection admits at most 100 MiB per file (`maxBytes`) and not `application/gzip`; an empty upload is
255
+ 400 `empty`.
256
+ - A ZIP or Office document must start with the ZIP header. Any other file that holds a ZIP signature anywhere is
257
+ rejected, even a rare harmless PDF or image; that is the fail-closed cost.
258
+ - A table written by the `fileTableSql` below can already hold the scan states: turning `scan` on needs no migration.
259
+ Only a table created by an earlier two-state `fileTableSql` (its check names `'pending', 'live'` alone) needs
260
+ `fileScanSql('<name>')` of `@fabrika/db` in a new migration, and `fabrika-check` names it. On a current table that
261
+ migration fails: it adds the constraint, columns and index again.
262
+ - ClamAV holds about 1 GB of signatures in memory. On Zerops the deploy service must declare
263
+ `verticalAutoscaling: { minRam: 2 }` (GB), or the build refuses the application. Zerops ignores a changed `verticalAutoscaling` of an existing service on a re-import:
264
+ raise an existing service's RAM through Zerops itself.
265
+
266
+ ```text
267
+ // the deploy service in the services of fabrika.config.ts
268
+ {
269
+ hostname: 'tasksapi',
270
+ type: 'alpine/bun@1.3',
271
+ priority: 10,
272
+ enableSubdomainAccess: false,
273
+ minContainers: 1,
274
+ maxContainers: 2,
275
+ verticalAutoscaling: { minRam: 2 },
276
+ }
277
+ ```
278
+
279
+ ## Its table
280
+
281
+ Each collection has a table `fabrika_files_<name>`, created by an application migration. Write exactly the DDL that
282
+ `fileTableSql` of `@fabrika/db` returns. `key` and each `chain` entry are `{ column, type }`, with `type` `'uuid'` or
283
+ `'text'` as the owner table declares the column, the nearest container first:
284
+
285
+ ```bash
286
+ bun -e "import { fileTableSql } from '@fabrika/db'; console.log(fileTableSql('attachments', { table: 'tasks', key: { column: 'id', type: 'uuid' }, chain: [{ column: 'project_id', type: 'uuid' }, { column: 'org_id', type: 'text' }] }))"
287
+ ```
288
+
289
+ ```sql db/migrations/20261012100000_attachments.sql
290
+ CREATE TABLE fabrika_files_attachments (
291
+ id UUID PRIMARY KEY,
292
+ owner_id UUID,
293
+ project_id UUID,
294
+ org_id TEXT,
295
+ status TEXT NOT NULL CONSTRAINT fabrika_files_attachments_status CHECK (status IN ('pending', 'live', 'quarantined', 'rejected')),
296
+ object_key TEXT NOT NULL UNIQUE,
297
+ size BIGINT,
298
+ content_type TEXT NOT NULL,
299
+ sha256 TEXT,
300
+ original_name TEXT,
301
+ created_by TEXT NOT NULL,
302
+ created_at TIMESTAMPTZ NOT NULL,
303
+ deleted_at TIMESTAMPTZ,
304
+ object_version TEXT,
305
+ scan_attempts INTEGER NOT NULL DEFAULT 0,
306
+ scan_after TIMESTAMPTZ,
307
+ FOREIGN KEY (owner_id, project_id, org_id) REFERENCES tasks (id, project_id, org_id) ON UPDATE CASCADE ON DELETE SET NULL
308
+ );
309
+ CREATE INDEX fabrika_files_attachments_owner ON fabrika_files_attachments (owner_id) WHERE deleted_at IS NULL;
310
+ CREATE INDEX fabrika_files_attachments_removable ON fabrika_files_attachments (created_at) WHERE deleted_at IS NOT NULL OR owner_id IS NULL OR status = 'pending';
311
+ CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_after) WHERE status = 'quarantined';
312
+ ```
313
+
314
+ - The owner table needs `UNIQUE (<key>, <chain columns>)`; `tasks` has `UNIQUE (id, project_id, org_id)`.
315
+ - Codegen leaves the table out, and no policy names it: only the framework reads it.
316
+ - The name is lowercase snake_case, at most 39 characters.
317
+ - `link: true` serves a link mount's holders, for the mount's own object: `POST <prefix>/api/files.<name>.upload`,
318
+ `POST <prefix>/api/files.<name>.list` (no `owner`) and `GET <prefix>/api/files.<name>/:fileId` under every link mount bound to the owner's dimension, which must grant the
319
+ collection's `write` and `read`. The owner is the link's object, never a parameter.
320
+ - `link: ['completion']` serves the named link mounts only. Name them when the owner's dimension has a link meant
321
+ for something else (a one-click answer): a mount the list does not name has no file route and need not grant
322
+ the collection's actions. Its holder gets 403 from the proxy, which finds no endpoint for the path.
323
+
324
+ ## Storage
325
+
326
+ On Zerops, declare one `object-storage` service in `fabrika.config.ts`; the platform writes `FABRIKA_APP_STORAGE_*`
327
+ from it. Never read those variables, build a storage URL or import an S3 client.
328
+
329
+ ```text
330
+ // in the services of fabrika.config.ts
331
+ { hostname: 'tasksfiles', type: 'object-storage', objectStorageSize: 1, priority: 50 }
332
+ ```
333
+
334
+ A test that boots an application with files passes `files: new MemoryFileObjects()` (`@fabrika/platform/testing`) to
335
+ `testKit`.
@@ -0,0 +1,169 @@
1
+ # references/jobs.md — cron jobs
2
+
3
+ Background work is a cron job: `app.cron(schedule, { actions, scope: 'app' })` with its `.handle(…)` in the
4
+ `endpoints.ts` of the slice whose data it works on, mounted under `jobs` in the contract. There are no timers, loops,
5
+ `setInterval` or self-scheduled work, and no `ctx.jobs.enqueue`: work for later is a row that the next run picks up.
6
+
7
+ ```ts src/overdue/endpoints.ts
8
+ import { app } from '../app-kit'
9
+ import { isOverdue } from './logic'
10
+
11
+ /** Marks every open task past its due day, hourly; each is re-read under its lock, so a concurrent change is skipped. */
12
+ export const markOverdue = app.cron('15 * * * *', {
13
+ actions: ['tasks.read', 'tasks.write'],
14
+ scope: 'app',
15
+ timeout: '10m',
16
+ retries: 2,
17
+ })
18
+ .handle(async ({ ctx }) => {
19
+ const today = new Date().toISOString().slice(0, 10)
20
+ const tasks = ctx.db.over(ctx.within('tasks.read', 'project')).tasks
21
+ let after: string | undefined
22
+ do {
23
+ const page = await tasks.list({
24
+ where: [
25
+ ['overdue', '=', false],
26
+ ['status', '<>', 'done'],
27
+ ['due_on', '<', today],
28
+ ],
29
+ limit: 100,
30
+ after,
31
+ })
32
+ for (const task of page.items) {
33
+ const ref = await ctx.ref('tasks.write', 'task', task.id)
34
+ await ctx.transaction(async (tx) => {
35
+ const locked = await tx.db.on(ref).getForUpdate()
36
+ if (isOverdue(locked, today)) {
37
+ await tx.db.on(ref).update({ overdue: true }, {
38
+ expectVersion: locked.version,
39
+ })
40
+ }
41
+ })
42
+ }
43
+ after = page.next ?? undefined
44
+ } while (after !== undefined)
45
+ })
46
+ ```
47
+
48
+ The list's filter and the decision under the lock are one rule, a pure function of `logic.ts`. A task someone closed,
49
+ postponed or cleared the due day of between the list and the lock is left alone:
50
+
51
+ ```ts src/overdue/logic.ts
52
+ /** An open task not yet marked, whose due day (`YYYY-MM-DD`) is before `today`. */
53
+ export const isOverdue = (
54
+ task: {
55
+ readonly status: 'open' | 'doing' | 'done'
56
+ readonly overdue: boolean
57
+ readonly due_on: string | null
58
+ },
59
+ today: string,
60
+ ): boolean =>
61
+ task.status !== 'done' && !task.overdue && task.due_on !== null
62
+ && task.due_on < today
63
+ ```
64
+
65
+ ```ts src/overdue/__tests__/logic.test.ts
66
+ import { expect, test } from 'bun:test'
67
+ import { isOverdue } from '../logic'
68
+
69
+ test('a task is overdue only while open, unmarked and due before today', () => {
70
+ const task = { status: 'open', overdue: false, due_on: '2026-10-01' } as const
71
+ expect(isOverdue(task, '2026-10-12')).toBe(true)
72
+ expect(isOverdue({ ...task, due_on: '2026-10-12' }, '2026-10-12')).toBe(false)
73
+ expect(isOverdue({ ...task, due_on: '2099-01-01' }, '2026-10-12')).toBe(false)
74
+ expect(isOverdue({ ...task, due_on: null }, '2026-10-12')).toBe(false)
75
+ expect(isOverdue({ ...task, status: 'done' }, '2026-10-12')).toBe(false)
76
+ expect(isOverdue({ ...task, overdue: true }, '2026-10-12')).toBe(false)
77
+ })
78
+ ```
79
+
80
+ ```ts fabrika.contract.ts
81
+ import { app } from './src/app-kit'
82
+ import { comments } from './src/comments/endpoints'
83
+ import { members } from './src/members/endpoints'
84
+ import { markOverdue } from './src/overdue/endpoints'
85
+ import { priorities } from './src/priorities/endpoints'
86
+ import { projects } from './src/projects/endpoints'
87
+ import { reports } from './src/reports/endpoints'
88
+ import { tasks } from './src/tasks/endpoints'
89
+
90
+ export const contract = app.contract((c) => ({
91
+ api: c.rpc('/api', {
92
+ projects,
93
+ tasks,
94
+ comments,
95
+ reports,
96
+ members,
97
+ priorities,
98
+ }),
99
+ jobs: { markOverdue },
100
+ health: c.health('/healthz'),
101
+ }))
102
+ ```
103
+
104
+ - The schedule is five cron fields, UTC. `scope: 'app'` is the only scope: a job reaches every value.
105
+ - A job holds exactly its `actions`. `ctx.within(action, dimension)` is a `Within` of every value;
106
+ `ctx.ref(action, dimension, id)` locates one object (404 when it is gone). Bind them with `ctx.db.over(within)`,
107
+ `ctx.db.in(ref)` and `ctx.db.on(ref)`.
108
+ - `ctx.within` names the dimension an endpoint's `over` would: the nearest of the rows' chain (tasks lie in a
109
+ project: `'project'`; `references/endpoint.md`). A job holds every value, so a farther one reaches the same rows;
110
+ the rule keeps a job's capability the same as the list endpoint's. `ctx.ref` names the row's own dimension (`'task'`).
111
+ - "Today" is the UTC day (`new Date().toISOString().slice(0, 10)`), so a task turns overdue at the first run after its
112
+ due day ends in UTC, hours off an org's own midnight. When the product needs the org's day, ask the human: the
113
+ zone is a new column (an IANA name). The decision under the lock then takes the org's day, computed in `logic.ts`
114
+ with `new Intl.DateTimeFormat('en-CA', { timeZone: zone }).format(now)`, and the list filters with the UTC day plus
115
+ one, since no zone is a day further ahead.
116
+ - **Reads run outside the transaction; every write runs inside `ctx.transaction((tx) => …)`**, through `tx.db`, fenced
117
+ by the attempt's lease. A write outside it is a type error that names `tx.db`.
118
+ - An attempt that fails or outlives `timeout` (default 15 minutes) is retried up to `retries` times, so every write is
119
+ idempotent. **Re-read each row under its lock before you write it:** `tx.db.on(ref).getForUpdate()` inside the
120
+ transaction, decide again on what it answers with the list's whole filter (`isOverdue`), and write with its
121
+ `version`. The listed row is already old: someone
122
+ may have closed the task since, and writing with the listed version fails the whole attempt with a 409.
123
+ - Paging over a filter the job's own writes change is safe: `after` is a keyset cursor on the key, so a row that
124
+ leaves the filter (`overdue` is now true) moves no other row to an earlier page.
125
+ - A bulk write declares `sql: 'write'` on the job and runs `tx.sql` inside the transaction:
126
+
127
+ ``ctx.transaction((tx) => tx.sql(Marked)`update tasks set overdue = true, version = version + 1 where (…) returning id`)``
128
+
129
+ The version bump is yours to write. **A job writes no scope fragment**: it is app-wide (`scope: 'app'` is the only
130
+ scope), so `tx.sql.scope` of its `Within` would be every row.
131
+ - `bun test --isolate` runs a job with `kit.runJob('markOverdue')` (`references/tests.md`). The platform runs
132
+ `fabrika job run <name>` from the crontab it generates; never write a crontab or read `FABRIKA_JOB_*`.
133
+ - A job cannot create scopes or manage members, and has no `ctx.members`: a job holds no object chain. It issues share
134
+ links only when it declares `issues: '<link mount>'` and its actions hold the mount's `issuedBy` and every grant:
135
+ `ctx.links.issue(await ctx.ref('<issuedBy>', '<dimension>', id), { mount, days })`, and `ctx.links.revoke(id)` for
136
+ a link it issued. It is a widening for closer review. `references/public-intake.md` is the whole recipe: a form's
137
+ inbox, the record, the link and its e-mail, each step recorded before the next.
138
+ - A job reads a file collection through `ctx.files('<collection>')` with a `Ref` it minted (`list`, `open` to attach a
139
+ file to an e-mail), and removes an owner's files only inside its transaction, `tx.files('<collection>').removeAll(ref)`
140
+ (`references/files.md`). The framework's own `fabrika-file-sweep` then deletes their objects; never write a sweep.
141
+ - `ctx.signal` aborts when the attempt ends (its timeout, a lost lease). Call `ctx.signal.throwIfAborted()` before
142
+ each external effect and pass the signal to `fetch`, so a stale attempt starts nothing after its replacement.
143
+ - A failed attempt names its failure in `kit.runJob`'s answer and the log: `handler_failed`, or a framework refusal
144
+ with its reason (`query_refused: undeclared_reach`, `capability_refused: undeclared_action`; `references/errors.md`).
145
+
146
+ ## An outbox: what a request asks a job to do
147
+
148
+ A request handler calls no outside service (`references/endpoint.md`). It records what to do in a row, in its own
149
+ transaction: a form's inbox row, or a state of its record (`link_state = 'due'`). A job does the work and calls the
150
+ service. `references/public-intake.md` is the whole recipe; its rules:
151
+
152
+ - **Bounded batches.** Each run takes at most a fixed number of records (`order by received_at, id limit 50`), oldest
153
+ first; the next run takes the rest.
154
+ - **Claim a record where you consume it**: read it `for update skip locked` in the transaction that writes it, so a
155
+ row another session holds waits for a later run and blocks nothing behind it.
156
+ - **Each record is its own work.** Run it in its own transactions, inside a `try`: a failure adds one to the record's
157
+ `attempts`, keeps a fixed step code in `last_failure` (never the error's message, which can quote a value), and the
158
+ run goes on with the next record. Rethrow when `ctx.signal` aborted: that failure is the attempt's, and the next
159
+ run meets the record as it is. So give each external call a deadline of its own inside the attempt's
160
+ (`AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)])`): a call that hangs then fails its record, not the run.
161
+ - **A dead state.** A record that failed `MAX_ATTEMPTS` times is dead: the batch skips it, the job logs it once by its
162
+ id (never an address), and an operator fixes or deletes it. Give the record a way back when the cause can go away
163
+ (a resend revives a dead delivery).
164
+ - **A key on every external call**, derived from the record: the e-mail's `Idempotency-Key`, a provider's request id.
165
+ The provider then acts once per key, however often the job hands the call over. A retry sends the same request
166
+ under the same key: keep what it needs in the row until the call is known done (sealed, when it holds a secret).
167
+ - **Each step recorded before the next effect**, so the next run finishes a stopped one.
168
+ - An inbox needs a row key for this: `id UUID NOT NULL DEFAULT gen_random_uuid()`, since a form writes only its input
169
+ fields.