@fabrika/app 0.4.8 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -8
- package/skill/SKILL.md +12 -6
- package/skill/references/app-setup.md +11 -10
- package/skill/references/dev.md +20 -11
- package/skill/references/endpoint.md +6 -4
- package/skill/references/files.md +39 -35
- package/skill/references/jobs.md +70 -12
- package/skill/references/public-intake.md +360 -191
- package/skill/references/tests.md +10 -1
- package/src/bun.ts +27 -2
- package/src/contract/artifact-schema.ts +3 -0
- package/src/contract/build.ts +51 -5
- package/src/contract/context.ts +21 -6
- package/src/contract/declaration.ts +6 -2
- package/src/contract/group.ts +22 -4
- package/src/contract/kit.ts +19 -4
- package/src/implement/boot.ts +38 -1
- package/src/implement/dispatch.ts +15 -3
- package/src/implement/implement.ts +3 -0
- package/src/implement/index.ts +10 -1
- package/src/implement/invocation.ts +38 -6
- package/src/implement/serve.ts +4 -0
- package/src/jobs/mail.ts +261 -0
- package/src/jobs/runner.ts +129 -32
- package/src/jobs/types.ts +32 -0
- package/src/testing/dev.ts +55 -1
- package/src/testing/endpoint-access.ts +16 -1
- package/src/testing/index.ts +3 -0
- package/src/testing/kit.ts +44 -4
- package/src/testing/membership.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -93,13 +93,14 @@
|
|
|
93
93
|
"test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@fabrika/auth": "0.
|
|
97
|
-
"@fabrika/auth-core": "0.
|
|
98
|
-
"@fabrika/db": "0.
|
|
99
|
-
"@fabrika/
|
|
100
|
-
"@fabrika/
|
|
101
|
-
"@fabrika/platform
|
|
102
|
-
"@fabrika/
|
|
96
|
+
"@fabrika/auth": "0.5.1",
|
|
97
|
+
"@fabrika/auth-core": "0.5.1",
|
|
98
|
+
"@fabrika/db": "0.5.1",
|
|
99
|
+
"@fabrika/email": "0.5.1",
|
|
100
|
+
"@fabrika/jwks": "0.5.1",
|
|
101
|
+
"@fabrika/platform": "0.5.1",
|
|
102
|
+
"@fabrika/platform-node": "0.5.1",
|
|
103
|
+
"@fabrika/proxy-core": "0.5.1",
|
|
103
104
|
"jose": "^5.9.0"
|
|
104
105
|
},
|
|
105
106
|
"peerDependencies": {
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fabrika-app
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.1
|
|
4
4
|
description: >
|
|
5
5
|
How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
|
|
6
6
|
slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
|
|
@@ -226,6 +226,7 @@ the access check. `references/spa.md` shows the stack.
|
|
|
226
226
|
| rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
|
|
227
227
|
| another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
|
|
228
228
|
| a share link | `issues: '<link mount>'` | `ctx.links.issue` |
|
|
229
|
+
| e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
|
|
229
230
|
| a trigger, function or view | its migration | — |
|
|
230
231
|
|
|
231
232
|
Scope every scoped table a raw statement reads with `${ctx.sql.scope(<capability>, '<table>', '<alias>')}`, a conjunct
|
|
@@ -242,8 +243,11 @@ Which promise the types, the checker, boot, the runtime or the approver keeps, a
|
|
|
242
243
|
## Rules
|
|
243
244
|
|
|
244
245
|
- Data work first, external calls after. The invocation's one transaction rolls back on a throw; IAM, object storage
|
|
245
|
-
and connector calls do not. An endpoint the generated tests cover calls no outside service (
|
|
246
|
-
|
|
246
|
+
and connector calls do not. An endpoint the generated tests cover calls no outside service (a provider's API): it
|
|
247
|
+
records an outbox row, and a job makes the call (`references/jobs.md`).
|
|
248
|
+
- An endpoint never sends e-mail; it has no `ctx.mail`. It records an outbox row and declares `then: '<job>'`, which
|
|
249
|
+
starts that job once its transaction committed. The job declares `mailer` and sends with `ctx.mail`, under a key
|
|
250
|
+
derived from the row, and sends again only where `ctx.mail.mayRetry(error)` allows it (`references/jobs.md`).
|
|
247
251
|
- No `as` (except `as const`), `any`, `@ts-ignore`, `@ts-expect-error`, `process.env` or `Bun.env`.
|
|
248
252
|
- Never log an error object, a request, headers or the environment.
|
|
249
253
|
- Never edit a generated file (`src/db/schema.generated.ts`, the generated tests, `fabrika/entry.generated.ts`,
|
|
@@ -296,7 +300,7 @@ change an uncommitted migration, migrate into a fresh schema with another `FABRI
|
|
|
296
300
|
|
|
297
301
|
`bunx fabrika app dev` serves the application on a loopback port through its test kit, filled by `dev/seed.ts`
|
|
298
302
|
through the endpoints. Pick a persona at `/__fabrika/dev`; the toolbar on every page switches it and lists the mail IAM
|
|
299
|
-
sent. Write the seed and use it as `references/dev.md` says; it never replaces a test.
|
|
303
|
+
and the application's jobs sent. Write the seed and use it as `references/dev.md` says; it never replaces a test.
|
|
300
304
|
|
|
301
305
|
## References
|
|
302
306
|
|
|
@@ -306,7 +310,7 @@ their errors, the caller, the components, routing, theming, screen tests), `tabl
|
|
|
306
310
|
(access forms, which `over`, the caller's scopes, handles, lists and aggregates, `fromRow`, `ctx.sql`, audited CSV
|
|
307
311
|
downloads, members and labels), `tests.md` (generated tests, seeds, `testKit`, a reused scope, a caller's label, confirming a file ran),
|
|
308
312
|
`errors.md` (checker findings, type errors, runtime answers), `dev.md` (the seed and the preview), `jobs.md` (which dimension `ctx.within` names, the UTC
|
|
309
|
-
day), `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
|
|
313
|
+
day, e-mail through `ctx.mail`, the outbox and `then`), `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
|
|
310
314
|
`start.md`, a new application before its first table (its first test, adding the first table, installing and updating
|
|
311
315
|
this skill), and `public-intake.md`, a public page, a form, a job that issues links and e-mails them, a link holder's
|
|
312
316
|
files, HR's review with its history.
|
|
@@ -314,6 +318,8 @@ files, HR's review with its history.
|
|
|
314
318
|
## Not built yet
|
|
315
319
|
|
|
316
320
|
- The data layer runs on Bun and Postgres only (Zerops). On Cloudflare an application has no tables, jobs or files.
|
|
317
|
-
- Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A
|
|
321
|
+
- Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A mutation's `then` starts a run of its
|
|
322
|
+
job; it carries nothing. A list write is off the road (`sql`).
|
|
323
|
+
- `ctx.mail` sends no attachments.
|
|
318
324
|
- An MCP workspace has no shell, so `codegen` and `test-scaffold` cannot run there. Tell the human; never write a
|
|
319
325
|
generated file by hand.
|
|
@@ -19,20 +19,20 @@ the SPA is added with it.
|
|
|
19
19
|
"test": "bun test --isolate"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@fabrika/app": "0.
|
|
23
|
-
"@fabrika/db": "0.
|
|
24
|
-
"@fabrika/provider-zerops": "0.
|
|
25
|
-
"@fabrika/proxy-core": "0.
|
|
26
|
-
"@fabrika/ui": "0.
|
|
22
|
+
"@fabrika/app": "0.5.1",
|
|
23
|
+
"@fabrika/db": "0.5.1",
|
|
24
|
+
"@fabrika/provider-zerops": "0.5.1",
|
|
25
|
+
"@fabrika/proxy-core": "0.5.1",
|
|
26
|
+
"@fabrika/ui": "0.5.1",
|
|
27
27
|
"kysely": "0.29.6",
|
|
28
28
|
"react": "^19.0.0",
|
|
29
29
|
"react-dom": "^19.0.0",
|
|
30
30
|
"zod": "^3.25.76"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@fabrika/check": "0.
|
|
34
|
-
"@fabrika/cli": "0.
|
|
35
|
-
"@fabrika/platform": "0.
|
|
33
|
+
"@fabrika/check": "0.5.1",
|
|
34
|
+
"@fabrika/cli": "0.5.1",
|
|
35
|
+
"@fabrika/platform": "0.5.1",
|
|
36
36
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
37
37
|
"@types/bun": "latest",
|
|
38
38
|
"@types/react": "^19.0.0",
|
|
@@ -158,15 +158,16 @@ export default serve(contract, { database: scoped(tables) })
|
|
|
158
158
|
per-form limit: raise it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
|
|
159
159
|
- `then: '<job>'` on `c.form` names a cron job of the same contract (`jobs`), which each accepted submission kicks
|
|
160
160
|
once its row committed: a run starts within seconds, under the job's own key, and the answer stays the same constant
|
|
161
|
-
and never waits for it. Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
|
|
161
|
+
and never waits for it. A mutation declares the same `then` for the outbox row it records (`references/jobs.md`). Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
|
|
162
162
|
submit), so the job still drains its inbox in batches under its own budgets. Keep its schedule: it is the recovery
|
|
163
163
|
path for a kicked run that died. A kick carries nothing; the job reads its inbox. `kit.runJob` first waits for the
|
|
164
164
|
kicked runs.
|
|
165
165
|
- `src/app.ts` composes and does nothing else. A dependency built from the application's own variables is
|
|
166
166
|
`defineDeps(build)`, passed as `serve(contract, { database, deps })` and read in a handler with `get()`. Never read
|
|
167
167
|
`process.env`. `build` runs at every boot, the generated tests' too, which pass no variable: read a variable when
|
|
168
|
-
the dependency is used, and fail there (`src/
|
|
168
|
+
the dependency is used, and fail there (`src/links.ts` in `references/public-intake.md`). A handler of an endpoint the
|
|
169
169
|
generated tests cover records an outbox row instead, and a job calls the dependency (`references/endpoint.md`).
|
|
170
|
+
E-mail needs no dependency: a job that declares `mailer` sends through `ctx.mail` (`references/jobs.md`).
|
|
170
171
|
|
|
171
172
|
## The deploy surface (Zerops)
|
|
172
173
|
|
package/skill/references/dev.md
CHANGED
|
@@ -64,27 +64,36 @@ export default seed
|
|
|
64
64
|
does: a token is single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test
|
|
65
65
|
key `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
|
|
66
66
|
submission; a deployed application records it like any other, so there it passes once.
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
- A job's e-mail goes to the kit's mail stand-in (`dev.mail`, as `kit.mail` in a test): nothing is delivered, and no
|
|
68
|
+
mail variable is needed. The mail view lists each message, newest first, beside IAM's invitations: the recipient,
|
|
69
|
+
the subject, the key the job passed, the time and the transport's answer. It opens to every field, the text, and the
|
|
70
|
+
HTML in a sandboxed frame that runs no script and loads nothing. `dev.mail.refuse('provider_rejected')` in the seed
|
|
71
|
+
previews a refused one; `dev.mail.heal()` ends it.
|
|
72
|
+
- The seed's second argument reads what left the application: `outside.mails()` is every message a job handed to the
|
|
73
|
+
stand-in, oldest first (`{ id, at, to, subject, text, html, key, transportKey, answer, outcome, delivered, … }`), so
|
|
74
|
+
the seed can open a link the application mailed. Run the job first (`dev.runJob('<job>')`), or let a mutation's
|
|
75
|
+
`then` kick it and `await dev.drain()`.
|
|
76
|
+
- Point another outside service at the dev outbox: `app dev` captures every request the process sends to
|
|
77
|
+
`https://outbox.invalid` (`DEV_OUTBOX_ORIGIN` of `@fabrika/app/testing`) and answers `200 {"id": …}`. A section of
|
|
78
|
+
its own lists each one, by the `to` and `subject` of a JSON body, and opens its body; its headers are not kept.
|
|
79
|
+
`outside.outbox()` is every captured request, oldest first (`{ id, method, path, contentType, body }`).
|
|
72
80
|
|
|
73
81
|
```text dev/dev.env
|
|
74
82
|
# Stand-in values for fabrika app dev.
|
|
75
83
|
TURNSTILE_SECRET=dev
|
|
76
84
|
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
ARCHIVE_URL=https://outbox.invalid/archive
|
|
86
|
+
ARCHIVE_KEY=dev
|
|
79
87
|
```
|
|
80
88
|
|
|
81
89
|
## In the browser
|
|
82
90
|
|
|
83
91
|
- `/__fabrika/dev` is the picker: the personas, another role of the vocabulary (a membership role with a scope value),
|
|
84
|
-
anonymous, the link pages, and the
|
|
92
|
+
anonymous, the link pages, the mail IAM and the application's jobs sent, and the dev outbox. It speaks the vocabulary's `locale` and shows its `displayName`.
|
|
85
93
|
- Every SPA page carries a toolbar in the bottom corner: who you are, the personas to switch to (the page reloads as
|
|
86
|
-
that caller), the picker, the link pages, and the
|
|
87
|
-
person from then on.
|
|
94
|
+
that caller), the picker, the link pages, the mail and the dev outbox. An invitation's link accepts it; the browser
|
|
95
|
+
is the invited person from then on. A job's message opens at `/__fabrika/dev/mail/<id>`.
|
|
88
96
|
- The choice stays in a cookie the application never sees. The data is gone when the command stops.
|
|
89
97
|
- It serves this machine only (`--host` is a loopback address, `--port=0` picks a free port), answers no request
|
|
90
|
-
addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`.
|
|
98
|
+
addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`. A form's or a
|
|
99
|
+
mutation's `then` still starts its job, as deployed.
|
|
@@ -60,10 +60,12 @@ export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
|
|
|
60
60
|
(`{ kind: 'service', id, label }`) has no address. `label` is for display: the address as the person spelled it.
|
|
61
61
|
- **An endpoint the generated tests cover calls no outside service.** They boot without your variables, so a handler
|
|
62
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
|
|
64
|
-
(`references/jobs.md`). A call that is the request's whole purpose
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
endpoint records an outbox row in its transaction, and a job makes the call with an attempt count and an idempotency
|
|
64
|
+
key (`references/jobs.md`). A call that is the request's whole purpose lives in a `{ member: true }` endpoint, which
|
|
65
|
+
they do not cover. The framework's own calls (IAM, the file store, `ctx.connect`) are not outside.
|
|
66
|
+
- **An endpoint never sends e-mail**, covered or not: it has no `ctx.mail`. It records the message in an outbox row
|
|
67
|
+
and declares `then: '<job>'` on the mutation, so the job that declares `mailer` sends it within seconds of the
|
|
68
|
+
commit (`references/jobs.md`; the test e-mail in `references/public-intake.md`). Only a mutation declares `then`.
|
|
67
69
|
- `ctx.principal.label` is a `string`, never `null`: the label IAM holds for the person (the one its directory
|
|
68
70
|
shows), or the id when the token carries none. When the caller owns what it writes (the creator of a deal, the
|
|
69
71
|
assignee of a task nobody was picked for), store `ctx.principal.id` and `ctx.principal.label`, never a
|
|
@@ -10,7 +10,7 @@ import { members } from './src/members/endpoints'
|
|
|
10
10
|
import { priorities } from './src/priorities/endpoints'
|
|
11
11
|
import { projects } from './src/projects/endpoints'
|
|
12
12
|
import { reports } from './src/reports/endpoints'
|
|
13
|
-
import {
|
|
13
|
+
import { archive } from './src/tasks/archive'
|
|
14
14
|
import { tasks } from './src/tasks/endpoints'
|
|
15
15
|
import { erase } from './src/tasks/erase'
|
|
16
16
|
|
|
@@ -34,7 +34,7 @@ export const contract = app.contract((c) => ({
|
|
|
34
34
|
maxPerOwner: 20,
|
|
35
35
|
}),
|
|
36
36
|
},
|
|
37
|
-
jobs: {
|
|
37
|
+
jobs: { archive, erase },
|
|
38
38
|
health: c.health('/healthz'),
|
|
39
39
|
}))
|
|
40
40
|
```
|
|
@@ -108,21 +108,22 @@ Every call needs the collection's `read`, so a write capability alone sees no fi
|
|
|
108
108
|
document is declared with an action that is the collection's `read` too, or the check runs where the caller holds
|
|
109
109
|
`read`: never mint or widen a capability to list.
|
|
110
110
|
|
|
111
|
-
A job mints the owner's `Ref` with `ctx.ref` and opens each live file, here to
|
|
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:
|
|
112
113
|
|
|
113
|
-
```ts src/tasks/
|
|
114
|
+
```ts src/tasks/archive.ts
|
|
114
115
|
import { app } from '../app-kit'
|
|
115
|
-
import {
|
|
116
|
+
import { archiver } from '../archive'
|
|
116
117
|
|
|
117
|
-
/** The largest report one
|
|
118
|
+
/** The largest report the archive takes in one call: the collection's maxBytes. */
|
|
118
119
|
const REPORT_MAX_BYTES = 5242880
|
|
119
120
|
|
|
120
|
-
export const
|
|
121
|
+
export const archive = app.cron('0 6 * * *', {
|
|
121
122
|
actions: ['tasks.read'],
|
|
122
123
|
scope: 'app',
|
|
123
124
|
})
|
|
124
125
|
.handle(async ({ ctx }) => {
|
|
125
|
-
const
|
|
126
|
+
const store = archiver.get()
|
|
126
127
|
const tasks = ctx.db.over(ctx.within('tasks.read', 'project')).tasks
|
|
127
128
|
const done = await tasks.list({ where: [['status', '=', 'done']], limit: 50 })
|
|
128
129
|
for (const task of done.items) {
|
|
@@ -136,12 +137,12 @@ export const digest = app.cron('0 6 * * *', {
|
|
|
136
137
|
)
|
|
137
138
|
}
|
|
138
139
|
ctx.signal.throwIfAborted()
|
|
139
|
-
await
|
|
140
|
+
await store.putReports(task.id, reports, ctx.signal)
|
|
140
141
|
}
|
|
141
142
|
})
|
|
142
143
|
```
|
|
143
144
|
|
|
144
|
-
```ts src/
|
|
145
|
+
```ts src/archive.ts
|
|
145
146
|
import { defineDeps } from '@fabrika/app/implement'
|
|
146
147
|
|
|
147
148
|
export interface Report {
|
|
@@ -150,10 +151,10 @@ export interface Report {
|
|
|
150
151
|
readonly bytes: Uint8Array
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
export interface
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
export interface Archiver {
|
|
155
|
+
/** Stores the reports of one task under its id; throws when the service refuses, and gives up once `signal` aborts. */
|
|
156
|
+
putReports(
|
|
157
|
+
taskId: string,
|
|
157
158
|
reports: readonly Report[],
|
|
158
159
|
signal: AbortSignal,
|
|
159
160
|
): Promise<void>
|
|
@@ -167,27 +168,28 @@ const variable = (env: Readonly<Record<string, unknown>>, name: string) => {
|
|
|
167
168
|
return value
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
export const
|
|
171
|
-
async
|
|
172
|
-
const response = await fetch(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
171
|
+
export const archiver = defineDeps((env): Archiver => ({
|
|
172
|
+
async putReports(taskId, reports, signal) {
|
|
173
|
+
const response = await fetch(
|
|
174
|
+
`${variable(env, 'ARCHIVE_URL')}/tasks/${taskId}`,
|
|
175
|
+
{
|
|
176
|
+
method: 'PUT',
|
|
177
|
+
headers: {
|
|
178
|
+
Authorization: `Bearer ${variable(env, 'ARCHIVE_KEY')}`,
|
|
179
|
+
'Content-Type': 'application/json',
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
files: reports.map((file) => ({
|
|
183
|
+
filename: file.name ?? 'report',
|
|
184
|
+
contentType: file.type,
|
|
185
|
+
content: Buffer.from(file.bytes).toString('base64'),
|
|
186
|
+
})),
|
|
187
|
+
}),
|
|
188
|
+
signal,
|
|
177
189
|
},
|
|
178
|
-
|
|
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
|
-
})
|
|
190
|
+
)
|
|
189
191
|
if (!response.ok) {
|
|
190
|
-
throw new Error(`the
|
|
192
|
+
throw new Error(`the archive answered ${response.status}`)
|
|
191
193
|
}
|
|
192
194
|
},
|
|
193
195
|
}))
|
|
@@ -197,12 +199,14 @@ export const mailer = defineDeps((env): Mailer => ({
|
|
|
197
199
|
import { serve } from '@fabrika/app/implement'
|
|
198
200
|
import { scoped } from '@fabrika/db'
|
|
199
201
|
import { contract } from '../fabrika.contract'
|
|
202
|
+
import { archiver } from './archive'
|
|
200
203
|
import { tables } from './db/tables'
|
|
201
|
-
import { mailer } from './mail'
|
|
202
204
|
|
|
203
|
-
export default serve(contract, { database: scoped(tables), deps:
|
|
205
|
+
export default serve(contract, { database: scoped(tables), deps: archiver })
|
|
204
206
|
```
|
|
205
207
|
|
|
208
|
+
The `PUT` under the task's id makes a repeated run replace the same object, so a retried attempt stores nothing twice.
|
|
209
|
+
|
|
206
210
|
`removeAll` runs in a mutation and in a job's `tx.files`, in the same transaction as the handler's writes: an erasure
|
|
207
211
|
that blanks a record and removes its files keeps both or neither. A query and a job's `ctx.files` only read. The
|
|
208
212
|
framework's sweep deletes the removed files' objects within the hour, and so the objects of a deleted owner's files.
|
package/skill/references/jobs.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Background work is a cron job: `app.cron(schedule, { actions, scope: 'app' })` with its `.handle(…)` in the
|
|
4
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
|
|
5
|
+
`setInterval` or self-scheduled work, and no `ctx.jobs.enqueue`: work for later is a row that a run picks up. A
|
|
6
|
+
mutation that declares `then: '<job>'` starts a run within seconds of its commit (the outbox below); the schedule
|
|
7
|
+
picks up whatever that run leaves.
|
|
6
8
|
|
|
7
9
|
```ts src/overdue/endpoints.ts
|
|
8
10
|
import { app } from '../app-kit'
|
|
@@ -135,19 +137,62 @@ export const contract = app.contract((c) => ({
|
|
|
135
137
|
`ctx.links.issue(await ctx.ref('<issuedBy>', '<dimension>', id), { mount, days })`, and `ctx.links.revoke(id)` for
|
|
136
138
|
a link it issued. It is a widening for closer review. `references/public-intake.md` is the whole recipe: a form's
|
|
137
139
|
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
|
|
139
|
-
file to
|
|
140
|
+
- A job reads a file collection through `ctx.files('<collection>')` with a `Ref` it minted (`list`, `open` to hand a
|
|
141
|
+
file to another service), and removes an owner's files only inside its transaction, `tx.files('<collection>').removeAll(ref)`
|
|
140
142
|
(`references/files.md`). The framework's own `fabrika-file-sweep` then deletes their objects; never write a sweep.
|
|
141
143
|
- `ctx.signal` aborts when the attempt ends (its timeout, a lost lease). Call `ctx.signal.throwIfAborted()` before
|
|
142
144
|
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`,
|
|
144
|
-
with its reason (`query_refused: undeclared_reach`, `capability_refused: undeclared_action`; `references/errors.md`)
|
|
145
|
+
- A failed attempt names its failure in `kit.runJob`'s answer and the log: `handler_failed`, a framework refusal
|
|
146
|
+
with its reason (`query_refused: undeclared_reach`, `capability_refused: undeclared_action`; `references/errors.md`),
|
|
147
|
+
or `mail_failed` with the transport's code (`mail_failed: provider_rejected`).
|
|
148
|
+
|
|
149
|
+
## E-mail: `mailer` and `ctx.mail`
|
|
150
|
+
|
|
151
|
+
Only a cron job that declares `mailer` sends e-mail, through `ctx.mail`. An endpoint has no `ctx.mail` (a type error
|
|
152
|
+
that says so): it records an outbox row and kicks the job (below). `references/public-intake.md` shows both jobs whole.
|
|
153
|
+
|
|
154
|
+
- `mailer: 'installation'` sends through the installation's transport: the operator configures one provider, and
|
|
155
|
+
Control hands it to each application whose job declares it. The application holds no provider key and writes no
|
|
156
|
+
transport code. An installation without a transport still deploys it, and `ctx.mail.available` is then false.
|
|
157
|
+
- `mailer: 'own'` sends through the application's own transport: `serve(contract, { database, mail: (env) => sender })`,
|
|
158
|
+
where `sender` is an `EmailSender` (`@fabrika/app/implement`) built from the application's own variables, or `null`
|
|
159
|
+
when they configure none. Never name a variable `FABRIKA_EMAIL_*`: those are the installation's.
|
|
160
|
+
- `mailer` is reach the approver sees (`installation` for closer review). Declare it only after the human said yes.
|
|
161
|
+
Without it, `ctx.mail` is a type error that names the declaration. A boot without the declared transport fails and
|
|
162
|
+
says which job needs it; the test kit and `fabrika app dev` stand one in (`kit.mail`, `references/tests.md`).
|
|
163
|
+
- `ctx.mail.send({ to, subject, text, html?, replyTo?, fromName?, idempotencyKey }, signal?)` hands one message to the
|
|
164
|
+
transport and answers `{ status: 'accepted', provider, messageId }`. It always aborts with the attempt's
|
|
165
|
+
`ctx.signal`; pass `AbortSignal.timeout(ms)` for a deadline of the message's own. The sending address is the
|
|
166
|
+
transport's; `fromName` and `replyTo` are the application's, per message. There are no attachments.
|
|
167
|
+
- `idempotencyKey` is 1 to 200 visible ASCII characters, unique per message in the application environment, and
|
|
168
|
+
derived from the outbox row (`intake-link-<link id>`), never random. `ctx.mail` puts a prefix of the installation
|
|
169
|
+
and the environment in front of it, so no other application on the same provider account shares it.
|
|
170
|
+
- `ctx.mail.available` is false where the installation has no transport. Every send then fails `mail_not_configured`
|
|
171
|
+
(`not_sent`). Check it before the batch: when it is false, return and leave every row as it is, so no attempt is
|
|
172
|
+
spent. The rows leave on the first run after the operator adds a transport and redeploys the application.
|
|
173
|
+
- A refused send throws `EmailDeliveryError` (`@fabrika/app/implement`): a fixed `code` and an `outcome`, `not_sent`
|
|
174
|
+
(the message did not leave) or `unknown` (it may have). `ctx.mail.mayRetry(error)` says whether the message may go
|
|
175
|
+
again: false for a refused message or configuration, and for an `unknown` outcome over a transport that does not
|
|
176
|
+
deduplicate a key (`ctx.mail.capabilities.idempotent` is false). Where it is false, never send again: the row is
|
|
177
|
+
dead, for a person.
|
|
178
|
+
- A send error the handler does not catch fails the attempt as `mail_failed`, and the runner retries the attempt only
|
|
179
|
+
where `mayRetry` holds. Catch it per row instead, as the outbox below does.
|
|
180
|
+
- Never log a message, its address or its key: log the row's id and the error's `code`.
|
|
145
181
|
|
|
146
182
|
## An outbox: what a request asks a job to do
|
|
147
183
|
|
|
148
|
-
A request handler calls no outside service (`references/endpoint.md`). It records what to do in a
|
|
149
|
-
transaction: a form's inbox row,
|
|
150
|
-
service. `references/public-intake.md` is the whole recipe
|
|
184
|
+
A request handler calls no outside service and sends no e-mail (`references/endpoint.md`). It records what to do in a
|
|
185
|
+
row, in its own transaction: a form's inbox row, an outbox row (`test_mails`), or a state of its record
|
|
186
|
+
(`link_state = 'due'`). A job does the work and calls the service. `references/public-intake.md` is the whole recipe.
|
|
187
|
+
|
|
188
|
+
**The kick.** A mutation that records such a row declares `then: '<job>'`: once its transaction committed, the job
|
|
189
|
+
runs within seconds, under its own key, and the answer does not wait for it. A form's `then` does the same for its
|
|
190
|
+
inbox. A thrown handler, a refused answer and a failed commit kick nothing. Kicks coalesce: at most one run is active
|
|
191
|
+
and one pending, however many calls arrive. A kick carries nothing; the job reads its rows, and its schedule stays the
|
|
192
|
+
recovery. `then` names one cron job of the contract, as a literal (`r.mutation({ …, then: 'testMails' })`); a query,
|
|
193
|
+
an export, a webhook and a job declare none.
|
|
194
|
+
|
|
195
|
+
The rules of the job:
|
|
151
196
|
|
|
152
197
|
- **Bounded batches.** Each run takes at most a fixed number of records (`order by received_at, id limit 50`), oldest
|
|
153
198
|
first; the next run takes the rest.
|
|
@@ -157,13 +202,26 @@ service. `references/public-intake.md` is the whole recipe; its rules:
|
|
|
157
202
|
`attempts`, keeps a fixed step code in `last_failure` (never the error's message, which can quote a value), and the
|
|
158
203
|
run goes on with the next record. Rethrow when `ctx.signal` aborted: that failure is the attempt's, and the next
|
|
159
204
|
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)])`
|
|
205
|
+
(`AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)])` for a `fetch`; `ctx.mail.send` adds `ctx.signal` itself,
|
|
206
|
+
so it takes only `AbortSignal.timeout(ms)`): a call that hangs then fails its record, not the run.
|
|
207
|
+
- **A backoff per record.** A failure sets the record's `next_attempt_at` one minute ahead, doubled per attempt up to
|
|
208
|
+
an hour (`now() + least(interval '1 minute' * power(2, attempts), interval '1 hour')`), and a run takes only records
|
|
209
|
+
whose time has come (`where next_attempt_at <= now()`). Kicks start runs seconds apart: without the wait, a short
|
|
210
|
+
provider outage spends every attempt of a record at once. Set it, with the attempt, in the transaction that claims
|
|
211
|
+
the record before the call (the test e-mail), and a run that dies during the call waits too. A record a person
|
|
212
|
+
revives (a resend) is due at once again.
|
|
161
213
|
- **A dead state.** A record that failed `MAX_ATTEMPTS` times is dead: the batch skips it, the job logs it once by its
|
|
162
214
|
id (never an address), and an operator fixes or deletes it. Give the record a way back when the cause can go away
|
|
163
215
|
(a resend revives a dead delivery).
|
|
164
|
-
- **A key on every external call**, derived from the record: the e-mail's `
|
|
165
|
-
|
|
166
|
-
under the same key: keep what it needs in the row until the call is known done (sealed, when it holds a
|
|
216
|
+
- **A key on every external call**, derived from the record: the e-mail's `idempotencyKey`, a provider's request id.
|
|
217
|
+
A provider that deduplicates then acts once per key, however often the job hands the call over. A retry sends the
|
|
218
|
+
same request under the same key: keep what it needs in the row until the call is known done (sealed, when it holds a
|
|
219
|
+
secret).
|
|
220
|
+
- **A retry only where the transport allows it.** Mark the record `sending` before the send. A run that finds it still
|
|
221
|
+
`sending` (a crash, a lost answer) sends it again only when `ctx.mail.capabilities.idempotent` holds, and otherwise
|
|
222
|
+
makes it dead: the e-mail may have arrived, and a second one cannot be taken back. A failed send goes again only
|
|
223
|
+
where `ctx.mail.mayRetry(error)` holds, and is dead at once otherwise; one whose `outcome` is `not_sent` returns to
|
|
224
|
+
the state it had before `sending`.
|
|
167
225
|
- **Each step recorded before the next effect**, so the next run finishes a stopped one.
|
|
168
226
|
- An inbox needs a row key for this: `id UUID NOT NULL DEFAULT gen_random_uuid()`, since a form writes only its input
|
|
169
227
|
fields.
|