@fabrika/app 0.6.0 → 0.8.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 (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -8,25 +8,29 @@ architecture, and a refusal's allowed form is the one fix that passes. There is
8
8
 
9
9
  The refusals agents meet most, and their fix:
10
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`, `@fabrika/app/react/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 |
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 column with `ctx.files`, 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`, `@fabrika/app/react/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
+ | `file-column-migration` | a file column without `REFERENCES fabrika_files (id)` or without an index it leads | both, in a new migration (`references/table-and-migration.md`) |
31
+ | `on-conflict-index` | `insert(…, { onConflict: { columns } })` whose columns are no unique index the migrations create | a `UNIQUE` constraint or a `CREATE UNIQUE INDEX` without `WHERE` on exactly those columns |
32
+ | `cross-app-fetch` | a `fetch` of an absolute URL under no origin a site declares in `egress` | `egress: ['https://…']` on the site that calls it (`references/endpoint.md`) |
33
+ | `url-endpoint` | `ctx.url('<id>')` names no procedure of the contract | the endpoint id `<mount>.<group>.<procedure>` |
30
34
 
31
35
  The hints:
32
36
 
@@ -67,10 +71,9 @@ A kit error lands on the line of the mistake, in one or two lines that name the
67
71
 
68
72
  ## Runtime answers
69
73
 
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
+ A procedure and a webhook answer JSON in an envelope: a 200 is `{ result: <output> }`, any other status
75
+ `{ error: { type, message } }`. The typed client unwraps it; a plain `fetch` (a test) reads `result`. A file answer,
76
+ a health check (`{ status: 'ok' }`) and the SPA's browser configuration answer without one.
74
77
 
75
78
  | Answer | Comes from |
76
79
  | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1,18 +1,21 @@
1
- # references/files.md — file collections
1
+ # references/files.md — file kinds and file columns
2
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.
3
+ A file is a value in a table column. The contract declares a file kind (`c.files`): which types, how large, whether it
4
+ is scanned. A table declares a file column of that kind, and the table's policy is the file's authority: reading the
5
+ column under the table's `read` action is what lets a procedure answer the file, and writing it needs the action that
6
+ writes the column. There are no generated file endpoints: each operation is a procedure of a few lines.
5
7
 
6
8
  ```ts fabrika.contract.ts
7
9
  import { app } from './src/app-kit'
10
+ import { archive } from './src/archiving/endpoints'
11
+ import { attachments } from './src/attachments/endpoints'
8
12
  import { comments } from './src/comments/endpoints'
13
+ import { erase } from './src/erasure/endpoints'
9
14
  import { members } from './src/members/endpoints'
10
15
  import { priorities } from './src/priorities/endpoints'
11
16
  import { projects } from './src/projects/endpoints'
12
17
  import { reports } from './src/reports/endpoints'
13
- import { archive } from './src/tasks/archive'
14
18
  import { tasks } from './src/tasks/endpoints'
15
- import { erase } from './src/tasks/erase'
16
19
 
17
20
  export const contract = app.contract((c) => ({
18
21
  api: c.rpc('/api', {
@@ -22,16 +25,12 @@ export const contract = app.contract((c) => ({
22
25
  reports,
23
26
  members,
24
27
  priorities,
28
+ attachments,
25
29
  }),
26
30
  files: {
27
31
  attachments: c.files({
28
- owner: 'tasks',
29
- read: 'tasks.read',
30
- write: 'tasks.write',
31
- delete: 'tasks.write',
32
- maxBytes: 5242880,
33
32
  types: ['image/png', 'image/jpeg', 'application/pdf', 'text/plain'],
34
- maxPerOwner: 20,
33
+ maxBytes: 5242880,
35
34
  }),
36
35
  },
37
36
  jobs: { archive, erase },
@@ -39,20 +38,131 @@ export const contract = app.contract((c) => ({
39
38
  }))
40
39
  ```
41
40
 
42
- The collection serves, for signed-in people only:
41
+ The table that holds the files is a child of `tasks`, with a file column of the kind `attachments`:
43
42
 
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 }` |
43
+ ```sql db/migrations/20261012100000_attachments.sql
44
+ CREATE TABLE task_attachments (
45
+ id UUID PRIMARY KEY,
46
+ task_id UUID NOT NULL,
47
+ project_id UUID NOT NULL,
48
+ org_id TEXT NOT NULL,
49
+ file UUID NOT NULL REFERENCES fabrika_files (id),
50
+ created_by TEXT NOT NULL,
51
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
52
+ FOREIGN KEY (task_id, project_id, org_id) REFERENCES tasks (id, project_id, org_id) ON UPDATE CASCADE ON DELETE CASCADE
53
+ );
54
+ CREATE INDEX task_attachments_task ON task_attachments (task_id);
55
+ CREATE INDEX task_attachments_file ON task_attachments (file);
56
+ ```
50
57
 
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`.
58
+ Its policy in `src/db/tables.ts` (`references/table-and-migration.md`) names the column and its kind:
53
59
 
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.
60
+ ```text
61
+ task_attachments: {
62
+ scope: { child: 'tasks' },
63
+ actions: { read: 'tasks.read', insert: 'tasks.write', delete: 'tasks.write' },
64
+ insert: ['file'],
65
+ files: { file: 'attachments' },
66
+ createdBy: 'created_by',
67
+ },
68
+ ```
69
+
70
+ - A file column is `UUID REFERENCES fabrika_files (id)` with an index on it; the checker's `file-column-migration`
71
+ verifies both for every column the policy names in `files`. `fabrika_files` is the framework's table: never write
72
+ it.
73
+ - A file kind (`c.files({ types, maxBytes, scan? })`) has no owner, no actions and no endpoint. The table's actions
74
+ are the file's authority, and the approver reads "`tasks.write` writes `task_attachments.file` (attachments)".
75
+ - A file reaches what its row reaches, now: a moved task moves its files. Deleting the row (here with its task, by the
76
+ cascade) or clearing a nullable column removes the reference; the framework's sweep deletes a file no column
77
+ references.
78
+ - A file column takes only a file reference: an input file (`file('<kind>')`), a `ctx.files.put` of this request, or a
79
+ reference this request read from a file column under that table's `read` action. A string id is refused.
80
+
81
+ ```ts src/attachments/endpoints.ts
82
+ import { file } from '@fabrika/app/contract'
83
+ import { z } from 'zod/v4'
84
+ import { app } from '../app-kit'
85
+
86
+ const Upload = z.strictObject({ taskId: z.uuid(), file: file('attachments') })
87
+ const OfTask = z.strictObject({ taskId: z.uuid() })
88
+ const OneFile = z.strictObject({ taskId: z.uuid(), id: z.uuid() })
89
+ const Saved = z.strictObject({ id: z.uuid() })
90
+ const Empty = z.strictObject({})
91
+ const Attachments = z.strictObject({
92
+ items: z.array(z.strictObject({
93
+ id: z.uuid(),
94
+ name: z.nullable(z.string()),
95
+ type: z.string(),
96
+ size: z.int(),
97
+ status: z.enum(['pending', 'quarantined', 'live', 'rejected']),
98
+ })),
99
+ })
100
+
101
+ export const attachments = app.group({ callers: ['human'] }, (r) => ({
102
+ upload: r.mutation({
103
+ input: Upload,
104
+ output: Saved,
105
+ access: { action: 'tasks.write', on: 'task', from: 'taskId' },
106
+ })
107
+ .handle(({ input, ctx }) =>
108
+ ctx.db.target.task_attachments.insert({ file: input.file })
109
+ ),
110
+ list: r.query({
111
+ input: OfTask,
112
+ output: Attachments,
113
+ access: { action: 'tasks.read', on: 'task', from: 'taskId' },
114
+ })
115
+ .handle(async ({ ctx }) => ({
116
+ items: (await ctx.db.target.task_attachments.list()).items.map((row) => ({
117
+ id: row.id,
118
+ name: row.file.name,
119
+ type: row.file.type,
120
+ size: row.file.size,
121
+ status: row.file.status,
122
+ })),
123
+ })),
124
+ download: r.query({
125
+ input: OneFile,
126
+ output: file('attachments'),
127
+ access: { action: 'tasks.read', on: 'task', from: 'taskId' },
128
+ })
129
+ .handle(async ({ input, ctx }) =>
130
+ (await ctx.db.target.task_attachments.get(input.id)).file
131
+ ),
132
+ remove: r.mutation({
133
+ input: OneFile,
134
+ output: Empty,
135
+ access: { action: 'tasks.write', on: 'task', from: 'taskId' },
136
+ })
137
+ .handle(async ({ input, ctx }) => {
138
+ await ctx.db.target.task_attachments.remove(input.id)
139
+ return {}
140
+ }),
141
+ }))
142
+ ```
143
+
144
+ - An input field `file('<kind>')` of a mutation makes the body multipart; the file is bounded by the kind's `types`
145
+ (sniffed, not trusted) and `maxBytes`. `z.optional(file(k))` is an optional file, `z.array(file(k)).max(n)` up to
146
+ `n` files, each a top-level field; one request carries at most 20 files. The body limit grows by each file at its kind's `maxBytes`, up to 48
147
+ MiB (`limits.maxBytes`, `references/endpoint.md`). The JSON `input` part takes up to that whole limit, and the
148
+ input part and the files together never exceed it: a large input beside its files declares a `limits.maxBytes` that
149
+ covers both. The handler writes the reference into the column, in its
150
+ transaction; a rolled-back request keeps no file.
151
+ - A row read through the data layer carries a file reference in each file column, with `id`, `name`, `type`, `size`
152
+ and `status`, so a list shows them without a call per file.
153
+ - A query whose output is `file('<kind>')` returns the reference, and the framework streams the file. It is a `GET`
154
+ with its input in flat query parameters. A mutation with a file output is a `POST` that answers the file after its
155
+ commit, so a download that writes an audit row is a mutation. The handler never returns a `Response`. A `pending`
156
+ or `quarantined` file answers 409 `file_not_ready`, a `rejected` one 409 `file_rejected`, and one the sweep deleted
157
+ 404.
158
+ - A public image (a catalog picture in a page or an e-mail) is a public `GET` query with `http: { cache: 'immutable' }`
159
+ whose input names the file as well as its row (`{ product, file }`), so a new image changes the URL. The handler
160
+ refuses a file id that is not the row's.
161
+
162
+ The SPA calls them through the typed client, never a hand-written `fetch`. `api.attachments.download.url(input)` is
163
+ the URL of a `GET` file procedure for a same-origin page (`<img src>`, `<a download>`), and
164
+ `download(api.attachments.download, input)` of `@fabrika/app/client` fetches it with the caller's credential and
165
+ answers a file (`blob()`, `stream()`, `save()`).
56
166
 
57
167
  ```ts web/attachments.ts
58
168
  import { createClient, RpcError } from '@fabrika/app/client'
@@ -60,18 +170,20 @@ import { contract } from '../fabrika.contract'
60
170
 
61
171
  const api = createClient(contract, 'api')
62
172
 
63
- /** Uploads `file` to the task, then lists its files; a file's `type` is the declared media type. */
173
+ /** Uploads `file` to the task, then lists its files, each a link to its download. */
64
174
  export const attach = async (
65
175
  taskId: string,
66
176
  file: File,
67
177
  list: HTMLElement,
68
178
  ): Promise<void> => {
69
179
  try {
70
- await api.files.attachments.upload(taskId, file)
71
- const page = await api.files.attachments.list({ owner: taskId })
180
+ await api.attachments.upload({ taskId, file })
181
+ const page = await api.attachments.list({ taskId })
72
182
  list.replaceChildren(
73
183
  ...page.items.map((item) =>
74
- Object.assign(document.createElement('li'), {
184
+ Object.assign(document.createElement('a'), {
185
+ href: api.attachments.download.url({ taskId, id: item.id }),
186
+ download: item.name ?? 'file',
75
187
  textContent: `${item.name ?? 'file'} (${item.status})`,
76
188
  })
77
189
  ),
@@ -84,43 +196,35 @@ export const attach = async (
84
196
  }
85
197
  ```
86
198
 
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.
199
+ A link holder's client has a key, which never goes into a URL: a link page uses `download`. `ResultOf<typeof
200
+ api.tasks.get>` and `InputOf<typeof api.tasks.create>` of `@fabrika/app/client` name a procedure's answer and input for
201
+ a view that maps them.
90
202
 
91
203
  ## From a handler or a job: `ctx.files`
92
204
 
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:
205
+ `ctx.files` of a mutation, a webhook, a job and a job's `tx` has two calls; a query's has `open` only:
95
206
 
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 |
207
+ | Call | Does |
208
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
209
+ | `put(kind, bytes, { type, name? })` | stores a new file of the kind and answers its reference; it is pending until a column holds it |
210
+ | `open(ref, { maxBytes? })` | `{ id, name, type, size, bytes }` of a live file, read whole |
101
211
 
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.
212
+ `open` takes a reference the site read from a file column under that table's `read` action. It answers 409
213
+ `file_not_ready` until the scan called the file clean, 409 `file_rejected` for a rejected one, and 413 for a file over
214
+ `maxBytes` (the kind's by default), before any byte is read. Pass the size you hold in memory.
106
215
 
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.
216
+ A job reads each task's rows with a `Ref` it minted and opens each live file, here to hand it to an archive service of
217
+ `deps`. A file can also leave as an e-mail attachment (`attachments` of `ctx.mail.send`, `references/jobs.md`):
110
218
 
111
- A job mints the owner's `Ref` with `ctx.ref` and opens each live file, here to hand it to an archive service of
112
- `deps`. `ctx.mail` carries no attachments, so a file leaves through a service of the application's own:
113
-
114
- ```ts src/tasks/archive.ts
219
+ ```ts src/archiving/endpoints.ts
115
220
  import { app } from '../app-kit'
116
221
  import { archiver } from '../archive'
117
222
 
118
- /** The largest report the archive takes in one call: the collection's maxBytes. */
223
+ /** The largest report the archive takes in one call: the kind's maxBytes. */
119
224
  const REPORT_MAX_BYTES = 5242880
120
225
 
121
226
  export const archive = app.cron('0 6 * * *', {
122
227
  actions: ['tasks.read'],
123
- scope: 'app',
124
228
  })
125
229
  .handle(async ({ ctx }) => {
126
230
  const store = archiver.get()
@@ -128,12 +232,11 @@ export const archive = app.cron('0 6 * * *', {
128
232
  const done = await tasks.list({ where: [['status', '=', 'done']], limit: 50 })
129
233
  for (const task of done.items) {
130
234
  const owner = await ctx.ref('tasks.read', 'task', task.id)
131
- const files = ctx.files('attachments')
132
- const page = await files.list(owner)
235
+ const rows = await ctx.db.on(owner).task_attachments.list()
133
236
  const reports = []
134
- for (const file of page.items.filter((item) => item.status === 'live')) {
237
+ for (const row of rows.items.filter((item) => item.file.status === 'live')) {
135
238
  reports.push(
136
- await files.open(owner, file.id, { maxBytes: REPORT_MAX_BYTES }),
239
+ await ctx.files.open(row.file, { maxBytes: REPORT_MAX_BYTES }),
137
240
  )
138
241
  }
139
242
  ctx.signal.throwIfAborted()
@@ -207,17 +310,15 @@ export default serve(contract, { database: scoped(tables), deps: archiver })
207
310
 
208
311
  The `PUT` under the task's id makes a repeated run replace the same object, so a retried attempt stores nothing twice.
209
312
 
210
- `removeAll` runs in a mutation and in a job's `tx.files`, in the same transaction as the handler's writes: an erasure
211
- that blanks a record and removes its files keeps both or neither. A query and a job's `ctx.files` only read. The
212
- framework's sweep deletes the removed files' objects within the hour, and so the objects of a deleted owner's files.
313
+ An erasure removes the rows that hold the files in the same transaction as its other writes: it keeps both or
314
+ neither. The framework's sweep then deletes each file no column references, within the hour.
213
315
 
214
- ```ts src/tasks/erase.ts
316
+ ```ts src/erasure/endpoints.ts
215
317
  import { app } from '../app-kit'
216
318
 
217
319
  /** Blanks the title of every task closed more than a year ago, and removes its attachments with it. */
218
320
  export const erase = app.cron('30 2 * * *', {
219
321
  actions: ['tasks.read', 'tasks.write'],
220
- scope: 'app',
221
322
  })
222
323
  .handle(async ({ ctx }) => {
223
324
  const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(
@@ -236,33 +337,42 @@ export const erase = app.cron('30 2 * * *', {
236
337
  for (const task of stale.items) {
237
338
  const ref = await ctx.ref('tasks.write', 'task', task.id)
238
339
  await ctx.transaction(async (tx) => {
239
- const locked = await tx.db.on(ref).getForUpdate()
240
- await tx.db.on(ref).update({ title: '' }, { expectVersion: locked.version })
241
- await tx.files('attachments').removeAll(ref)
340
+ const target = tx.db.on(ref)
341
+ const locked = await target.getForUpdate()
342
+ await target.update({ title: '' }, { expectVersion: locked.version })
343
+ let page = await target.task_attachments.list({ limit: 100 })
344
+ while (page.items.length > 0) {
345
+ for (const row of page.items) {
346
+ await target.task_attachments.remove(row.id)
347
+ }
348
+ page = await target.task_attachments.list({ limit: 100 })
349
+ }
242
350
  })
243
351
  }
244
352
  })
245
353
  ```
246
354
 
247
- - The owner is a table with a dimension scope; an application table owns no collection.
248
- - There is no limit on the bytes of one owner's files together: `maxPerOwner` × `maxBytes` bounds them.
355
+ - Never write a sweep or delete an object yourself: a file goes when no column references it.
356
+ - **A handover writes the new reference before it deletes the old one, in one transaction.** Written in two
357
+ transactions, the sweep can delete the file between them. The same covers a file two records share, a versioned
358
+ document (a versions table with a file column) and a thumbnail (a second column of another kind): there is no move or
359
+ copy operation.
360
+ - A file referenced from a dimension-scoped table belongs to that top-level scope: a reference from another one is
361
+ refused.
249
362
 
250
363
  ## Malware scan
251
364
 
252
- `scan: true` on a collection holds every upload `quarantined` until ClamAV answers clean; on Zerops the platform
253
- installs ClamAV and runs the scan job. The download answers 409 `quarantined` until then and 404 once the file is
254
- `rejected`; the list shows each file's `status`, so show it rather than a broken link. In a test, the scan runs with
365
+ `scan: true` on a kind holds every file of it `quarantined` until ClamAV answers clean; on Zerops the platform
366
+ installs ClamAV and runs the scan job. A file answer is 409 `file_not_ready` until then and 409 `file_rejected` once
367
+ the file is `rejected`; a list shows each file's `status`, so show it rather than a broken link. In a test, the scan runs with
255
368
  `kit.runJob(FILE_SCAN_JOB)` and a `scanner` passed to `testKit` (`references/tests.md`); the generated tests pass their
256
369
  own seeded files.
257
370
 
258
- - A scanning collection admits at most 100 MiB per file (`maxBytes`) and not `application/gzip`; an empty upload is
371
+ - A scanning kind admits at most 100 MiB per file (`maxBytes`) and not `application/gzip`; an empty upload is
259
372
  400 `empty`.
260
373
  - A ZIP or Office document must start with the ZIP header. Any other file that holds a ZIP signature anywhere is
261
374
  rejected, even a rare harmless PDF or image; that is the fail-closed cost.
262
- - A table written by the `fileTableSql` below can already hold the scan states: turning `scan` on needs no migration.
263
- Only a table created by an earlier two-state `fileTableSql` (its check names `'pending', 'live'` alone) needs
264
- `fileScanSql('<name>')` of `@fabrika/db` in a new migration, and `fabrika-check` names it. On a current table that
265
- migration fails: it adds the constraint, columns and index again.
375
+ - Turning `scan` on needs no migration: the framework's table holds the scan states.
266
376
  - ClamAV holds about 1 GB of signatures in memory. On Zerops the deploy service must declare
267
377
  `verticalAutoscaling: { minRam: 2 }` (GB), or the build refuses the application. Zerops ignores a changed `verticalAutoscaling` of an existing service on a re-import:
268
378
  raise an existing service's RAM through Zerops itself.
@@ -280,73 +390,13 @@ own seeded files.
280
390
  }
281
391
  ```
282
392
 
283
- ## Its table
284
-
285
- Each collection has a table `fabrika_files_<name>`, created by an application migration. Write exactly the DDL that
286
- `fileTableSql` of `@fabrika/db` returns. `key` and each `chain` entry are `{ column, type }`, with `type` `'uuid'` or
287
- `'text'` as the owner table declares the column, the nearest container first:
288
-
289
- ```bash
290
- 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' }] }))"
291
- ```
292
-
293
- ```sql db/migrations/20261012100000_attachments.sql
294
- CREATE TABLE fabrika_files_attachments (
295
- id UUID PRIMARY KEY,
296
- owner_id UUID,
297
- project_id UUID,
298
- org_id TEXT,
299
- status TEXT NOT NULL CONSTRAINT fabrika_files_attachments_status CHECK (status IN ('pending', 'live', 'quarantined', 'rejected')),
300
- object_key TEXT NOT NULL UNIQUE,
301
- size BIGINT,
302
- content_type TEXT NOT NULL,
303
- sha256 TEXT,
304
- original_name TEXT,
305
- created_by TEXT NOT NULL,
306
- created_at TIMESTAMPTZ NOT NULL,
307
- deleted_at TIMESTAMPTZ,
308
- object_version TEXT,
309
- scan_attempts INTEGER NOT NULL DEFAULT 0,
310
- scan_after TIMESTAMPTZ,
311
- FOREIGN KEY (owner_id, project_id, org_id) REFERENCES tasks (id, project_id, org_id) ON UPDATE CASCADE ON DELETE SET NULL
312
- );
313
- CREATE INDEX fabrika_files_attachments_owner ON fabrika_files_attachments (owner_id) WHERE deleted_at IS NULL;
314
- 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';
315
- CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_after) WHERE status = 'quarantined';
316
- ```
317
-
318
- - The owner table needs `UNIQUE (<key>, <chain columns>)`; `tasks` has `UNIQUE (id, project_id, org_id)`.
319
- - Codegen leaves the table out, and no policy names it: only the framework reads it.
320
- - The name is lowercase snake_case, at most 39 characters.
321
- - `link: true` serves a link mount's holders, for the mount's own object: `POST <prefix>/api/files.<name>.upload`,
322
- `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
323
- collection's `write` and `read`. The owner is the link's object, never a parameter.
324
- - `link: ['completion']` serves the named link mounts only. Name them when the owner's dimension has a link meant
325
- for something else (a one-click answer): a mount the list does not name has no file route and need not grant
326
- the collection's actions. Its holder gets 403 from the proxy, which finds no endpoint for the path.
327
-
328
- ## A file with a public form
329
-
330
- A visitor without an account reaches no collection endpoint. A form takes the file with its submission instead
331
- (`references/public-intake.md` builds it): `files: { photo: { collection: 'photos' } }` on `c.form`, into a collection
332
- with `scan: true`, beside a challenge. The file waits owned by nobody, and nothing reads it; its id lands in the inbox
333
- column of its field (`photo UUID`). The job that drains the inbox creates the record and attaches the file in the same
334
- transaction, with a `Ref` of the new record for the collection's `write` action, which `tx.ref` locates in that
335
- transaction:
336
-
337
- ```text
338
- const [created] = await tx.sql(Id)`insert into candidates (…) values (…) on conflict (email) do nothing returning id`
339
- if (created !== undefined && row.photo !== null) {
340
- await tx.files('photos').attach(await tx.ref('candidates.attach', 'candidate', created.id), row.photo)
341
- }
342
- ```
393
+ ## A file from a public procedure
343
394
 
344
- - Only a job's `tx.files` has `attach`; a mutation's has none. `tx.ref` is `ctx.ref` located in the transaction.
345
- - `attach` answers `false` when no file of the collection waits under the id (none was sent, or its hold of seven days
346
- ended) and `true` again for one the same owner attached before; it never moves another owner's file. Past
347
- `maxPerOwner` it throws 409, and the transaction rolls back with the file still held.
348
- - The attached file is `quarantined` until the scan job's clean verdict, like any upload of a scanning collection.
349
- - A file nobody attached is deleted by the sweep job an hour after its hold of seven days ended.
395
+ A visitor without an account sends a file as a field of a public procedure's input
396
+ (`references/public-intake.md` builds it): `photo: z.optional(file('photos'))`, into a kind with `scan: true`, beside
397
+ a challenge and `admission.rate.bytesPerHour`. The handler writes the reference into a file column of an application
398
+ table (`registrations.photo`), and the job that claims the row writes the same reference into the record's column in
399
+ the transaction that deletes the row. The file is never copied; the sweep deletes it once no column references it.
350
400
 
351
401
  ## Storage
352
402