@fabrika/app 0.6.0 → 0.7.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.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/skill/references/jobs.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# references/jobs.md — cron jobs
|
|
2
2
|
|
|
3
|
-
Background work is a cron job: `app.cron(schedule, { actions
|
|
3
|
+
Background work is a cron job: `app.cron(schedule, { actions })` 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 a run picks up
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
`setInterval` or self-scheduled work, and no `ctx.jobs.enqueue`: work for later is a row that a run picks up, usually
|
|
6
|
+
from a queue table with `claim` (below). A mutation, a webhook or another job that declares `kicks: ['<job>']` and
|
|
7
|
+
calls `ctx.kick('<job>')` starts a run within seconds of its commit (the outbox below); the schedule picks up whatever
|
|
8
|
+
that run leaves.
|
|
8
9
|
|
|
9
10
|
```ts src/overdue/endpoints.ts
|
|
10
11
|
import { app } from '../app-kit'
|
|
@@ -13,8 +14,7 @@ import { isOverdue } from './logic'
|
|
|
13
14
|
/** Marks every open task past its due day, hourly; each is re-read under its lock, so a concurrent change is skipped. */
|
|
14
15
|
export const markOverdue = app.cron('15 * * * *', {
|
|
15
16
|
actions: ['tasks.read', 'tasks.write'],
|
|
16
|
-
|
|
17
|
-
timeout: '10m',
|
|
17
|
+
limits: { timeoutMs: 600000 },
|
|
18
18
|
retries: 2,
|
|
19
19
|
})
|
|
20
20
|
.handle(async ({ ctx }) => {
|
|
@@ -103,10 +103,11 @@ export const contract = app.contract((c) => ({
|
|
|
103
103
|
}))
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
- The schedule is five cron fields, UTC.
|
|
106
|
+
- The schedule is five cron fields, UTC. A job is application-wide: it reaches every value.
|
|
107
107
|
- A job holds exactly its `actions`. `ctx.within(action, dimension)` is a `Within` of every value;
|
|
108
108
|
`ctx.ref(action, dimension, id)` locates one object (404 when it is gone). Bind them with `ctx.db.over(within)`,
|
|
109
|
-
`ctx.db.in(ref)` and `ctx.db.on(ref)`.
|
|
109
|
+
`ctx.db.in(ref)` and `ctx.db.on(ref)`. `ctx.db.app('<action>')` reads the application tables with one of its
|
|
110
|
+
actions, and `tx.db.app('<action>')` writes them; `ctx.app('<action>')` is that capability.
|
|
110
111
|
- `ctx.within` names the dimension an endpoint's `over` would: the nearest of the rows' chain (tasks lie in a
|
|
111
112
|
project: `'project'`; `references/endpoint.md`). A job holds every value, so a farther one reaches the same rows;
|
|
112
113
|
the rule keeps a job's capability the same as the list endpoint's. `ctx.ref` names the row's own dimension (`'task'`).
|
|
@@ -117,7 +118,7 @@ export const contract = app.contract((c) => ({
|
|
|
117
118
|
one, since no zone is a day further ahead.
|
|
118
119
|
- **Reads run outside the transaction; every write runs inside `ctx.transaction((tx) => …)`**, through `tx.db`, fenced
|
|
119
120
|
by the attempt's lease. A write outside it is a type error that names `tx.db`.
|
|
120
|
-
- An attempt that fails or outlives `
|
|
121
|
+
- An attempt that fails or outlives `limits.timeoutMs` (default 15 minutes, at most a day) is retried up to `retries` times, so every write is
|
|
121
122
|
idempotent. **Re-read each row under its lock before you write it:** `tx.db.on(ref).getForUpdate()` inside the
|
|
122
123
|
transaction, decide again on what it answers with the list's whole filter (`isOverdue`), and write with its
|
|
123
124
|
`version`. The listed row is already old: someone
|
|
@@ -128,18 +129,21 @@ export const contract = app.contract((c) => ({
|
|
|
128
129
|
|
|
129
130
|
``ctx.transaction((tx) => tx.sql(Marked)`update tasks set overdue = true, version = version + 1 where (…) returning id`)``
|
|
130
131
|
|
|
131
|
-
The version bump is yours to write. **A job writes no scope fragment**: it is
|
|
132
|
-
|
|
132
|
+
The version bump is yours to write. **A job writes no scope fragment**: it is application-wide, so `tx.sql.scope` of
|
|
133
|
+
its `Within` would be every row.
|
|
133
134
|
- `bun test --isolate` runs a job with `kit.runJob('markOverdue')` (`references/tests.md`). The platform runs
|
|
134
135
|
`fabrika job run <name>` from the crontab it generates; never write a crontab or read `FABRIKA_JOB_*`.
|
|
135
136
|
- A job cannot create scopes or manage members, and has no `ctx.members`: a job holds no object chain. It issues share
|
|
136
137
|
links only when it declares `issues: '<link mount>'` and its actions hold the mount's `issuedBy` and every grant:
|
|
137
138
|
`ctx.links.issue(await ctx.ref('<issuedBy>', '<dimension>', id), { mount, days })`, and `ctx.links.revoke(id)` for
|
|
138
|
-
a link it issued. It is a widening for closer review. `references/public-intake.md` is the whole recipe: a
|
|
139
|
-
|
|
140
|
-
- A job reads a file
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
a link it issued. It is a widening for closer review. `references/public-intake.md` is the whole recipe: a public
|
|
140
|
+
procedure's row, the record, the link and its e-mail, each step recorded before the next.
|
|
141
|
+
- A job reads a file column through a capability it minted and opens a file with `ctx.files.open(ref)`, to hand it to
|
|
142
|
+
another service; it removes files by deleting the rows that hold them, inside its transaction (`references/files.md`).
|
|
143
|
+
The framework's own `fabrika-file-sweep` then deletes a file no column references; never write a sweep.
|
|
144
|
+
- A job may also declare `kicks` (another job; the edges never form a cycle), `signs` (`ctx.tokens.sign`),
|
|
145
|
+
`egress` (the HTTPS origins its `fetch` calls) and `issues` (`references/endpoint.md`). `ctx.url('<endpoint id>', …)`
|
|
146
|
+
builds an absolute link to one of the application's procedures, for an e-mail.
|
|
143
147
|
- `ctx.signal` aborts when the attempt ends (its timeout, a lost lease). Call `ctx.signal.throwIfAborted()` before
|
|
144
148
|
each external effect and pass the signal to `fetch`, so a stale attempt starts nothing after its replacement.
|
|
145
149
|
- A failed attempt names its failure in `kit.runJob`'s answer and the log: `handler_failed`, a framework refusal
|
|
@@ -152,8 +156,9 @@ Only a cron job that declares `mailer` sends e-mail, through `ctx.mail`. An endp
|
|
|
152
156
|
that says so): it records an outbox row and kicks the job (below). `references/public-intake.md` shows both jobs whole.
|
|
153
157
|
|
|
154
158
|
- `mailer: 'installation'` sends through the installation's transport: the operator configures one provider, and
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
each message goes to the platform's mail relay, which records it and sends it with that provider. The application
|
|
160
|
+
holds no provider key and writes no transport code. An accepted send means the platform took the message
|
|
161
|
+
(`ctx.mail.capabilities.confirmation` is `'queued'`): an operator may hold it, or drop it by a rule, before it leaves.
|
|
157
162
|
- `mailer: 'own'` sends through the application's own transport: `serve(contract, { database, mail: (env) => sender })`,
|
|
158
163
|
where `sender` is an `EmailSender` (`@fabrika/app/implement`) built from the application's own variables, or `null`
|
|
159
164
|
when they configure none. Never name a variable `FABRIKA_EMAIL_*`: those are the installation's.
|
|
@@ -163,13 +168,23 @@ that says so): it records an outbox row and kicks the job (below). `references/p
|
|
|
163
168
|
- `ctx.mail.send({ to, subject, text, html?, replyTo?, fromName?, idempotencyKey }, signal?)` hands one message to the
|
|
164
169
|
transport and answers `{ status: 'accepted', provider, messageId }`. It always aborts with the attempt's
|
|
165
170
|
`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.
|
|
171
|
+
transport's; `fromName` and `replyTo` are the application's, per message.
|
|
172
|
+
- `attachments: [ref]` attaches files the job read from a file column (at most 20). Only a live file is sent: a
|
|
173
|
+
pending or quarantined one fails the send with 409 `file_not_ready`, a rejected one with `file_rejected`. The
|
|
174
|
+
transport bounds the bytes of all attachments together (`maxAttachmentBytes`), and a transport without attachments
|
|
175
|
+
refuses the message as `invalid_message`.
|
|
176
|
+
- `unsubscribe: { url }` writes `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
|
|
177
|
+
(RFC 8058), so a mailbox offers one-click unsubscribe. The URL is a public procedure of your own with a signed token
|
|
178
|
+
(`ctx.url('api.mail.unsubscribe', { query: { t: ctx.tokens.sign('unsubscribe', claims, { days: 400 }) } })`,
|
|
179
|
+
`references/endpoint.md`). There are no free headers. A transport without the header refuses the message.
|
|
180
|
+
- The idempotency key covers the attachments and the unsubscribe URL: a repeated key with other content is refused.
|
|
167
181
|
- `idempotencyKey` is 1 to 200 visible ASCII characters, unique per message in the application environment, and
|
|
168
182
|
derived from the outbox row (`intake-link-<link id>`), never random. `ctx.mail` puts a prefix of the installation
|
|
169
183
|
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
|
|
171
|
-
(`not_sent`). Check it before the batch:
|
|
172
|
-
|
|
184
|
+
- `ctx.mail.available` is false where no transport reaches the job: a local composition that configures none, or a
|
|
185
|
+
test kit with `mail: 'none'`. Every send then fails `mail_not_configured` (`not_sent`). Check it before the batch:
|
|
186
|
+
when it is false, return and leave every row as it is, so no attempt is spent. The rows leave on the first run after
|
|
187
|
+
a transport is configured.
|
|
173
188
|
- A refused send throws `EmailDeliveryError` (`@fabrika/app/implement`): a fixed `code` and an `outcome`, `not_sent`
|
|
174
189
|
(the message did not leave) or `unknown` (it may have). `ctx.mail.mayRetry(error)` says whether the message may go
|
|
175
190
|
again: false for a refused message or configuration, and for an `unknown` outcome over a transport that does not
|
|
@@ -179,33 +194,67 @@ that says so): it records an outbox row and kicks the job (below). `references/p
|
|
|
179
194
|
where `mayRetry` holds. Catch it per row instead, as the outbox below does.
|
|
180
195
|
- Never log a message, its address or its key: log the row's id and the error's `code`.
|
|
181
196
|
|
|
197
|
+
## Queue tables: `queue` and `claim`
|
|
198
|
+
|
|
199
|
+
A table whose rows are work declares `queue: { attempts: '<column>', due: '<column>', maxAttempts? }` and a `version`
|
|
200
|
+
column (`references/table-and-migration.md`). The migration gives `<due>` a default of `now()` and allows `null`. A
|
|
201
|
+
row is waiting while `<due>` is set, and done when it is `null`.
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
for (const row of await ctx.db.app('mail.send').outbox.claim({ limit: 50 })) {
|
|
205
|
+
await send(ctx, row)
|
|
206
|
+
await ctx.transaction((tx) =>
|
|
207
|
+
tx.db.on(row.ref).update({}, { expectVersion: row.version, done: true })
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- `claim({ limit, where? })` (`limit` at most 500) leases due rows in a short transaction of its own, before the work: rows whose `<due>`
|
|
213
|
+
has come and whose attempts are below `maxAttempts` (10 by default), `for update skip locked`. It adds one attempt
|
|
214
|
+
and moves `<due>` by the backoff: the job's schedule period (60 s without one), doubled per attempt, at most 30
|
|
215
|
+
days. Each row answers its columns, its new `version` and `ref`.
|
|
216
|
+
- Claim through the capability you read the table with: `ctx.db.app('<action>').<table>` for an application table,
|
|
217
|
+
`ctx.db.over(ctx.within('<action>', '<dimension>')).<table>` for a scoped one. The action is the table's `update`
|
|
218
|
+
action. `claim` runs only in a job and never inside `ctx.transaction`.
|
|
219
|
+
- Finish each row in its own transaction through `tx.db.on(row.ref)`: `.update(values, { expectVersion, done: true })`
|
|
220
|
+
writes and clears `<due>`; `.remove({ expectVersion })` deletes the row (the table's delete action);
|
|
221
|
+
`.requeue({ at? })` makes it due again at `at` (now by default) with no attempts, for a dead row or a reminder moved
|
|
222
|
+
later.
|
|
223
|
+
- A failure or a crash leaves the row as the claim left it, due again after the backoff; nothing retries at once.
|
|
224
|
+
Attempts count claims, so claim only what the run will reach.
|
|
225
|
+
- A row past `maxAttempts` stays in the table, no longer due, for the application to show or requeue.
|
|
226
|
+
- One recipe covers an outbox, a per-record reminder (`<due>` is the reminder time) and asynchronous work: a mutation
|
|
227
|
+
inserts a request row and kicks; the job claims it, renders, puts the result into a file column with
|
|
228
|
+
`ctx.files.put` and marks it done; the page polls the row and downloads the file.
|
|
229
|
+
|
|
182
230
|
## An outbox: what a request asks a job to do
|
|
183
231
|
|
|
184
232
|
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
|
|
233
|
+
row, in its own transaction: a public procedure's row (`registrations`), an outbox row (`test_mails`), or a state of its record
|
|
186
234
|
(`link_state = 'due'`). A job does the work and calls the service. `references/public-intake.md` is the whole recipe.
|
|
187
235
|
|
|
188
|
-
**The kick.** A mutation that records such a row declares `
|
|
189
|
-
runs within seconds, under its own key, and the answer does not wait for it. A
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
236
|
+
**The kick.** A mutation that records such a row declares `kicks: ['<job>']` and calls `ctx.kick('<job>')`: once its
|
|
237
|
+
transaction committed, the job runs within seconds, under its own key, and the answer does not wait for it. A thrown
|
|
238
|
+
handler, a refused answer and a failed commit kick nothing. Kicks coalesce: at most one run is active and one pending,
|
|
239
|
+
however many calls arrive. A kick carries nothing; the job reads its rows, and its schedule stays the recovery.
|
|
240
|
+
`kicks` names cron jobs of the contract, as literals (`r.mutation({ …, kicks: ['testMails'] })`); a mutation, a
|
|
241
|
+
webhook and a job declare it, and the kick edges never form a cycle.
|
|
194
242
|
|
|
195
243
|
The rules of the job:
|
|
196
244
|
|
|
197
245
|
- **Bounded batches.** Each run takes at most a fixed number of records (`order by received_at, id limit 50`), oldest
|
|
198
246
|
first; the next run takes the rest.
|
|
199
|
-
- **Claim a record where you consume it**:
|
|
200
|
-
row another session holds waits for a later run and blocks nothing
|
|
247
|
+
- **Claim a record where you consume it**: a queue table's `claim` (above), or a read `for update skip locked` in the
|
|
248
|
+
transaction that writes it (`sql: 'write'`), so a row another session holds waits for a later run and blocks nothing
|
|
249
|
+
behind it.
|
|
201
250
|
- **Each record is its own work.** Run it in its own transactions, inside a `try`: a failure adds one to the record's
|
|
202
251
|
`attempts`, keeps a fixed step code in `last_failure` (never the error's message, which can quote a value), and the
|
|
203
252
|
run goes on with the next record. Rethrow when `ctx.signal` aborted: that failure is the attempt's, and the next
|
|
204
253
|
run meets the record as it is. So give each external call a deadline of its own inside the attempt's
|
|
205
254
|
(`AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)])` for a `fetch`; `ctx.mail.send` adds `ctx.signal` itself,
|
|
206
255
|
so it takes only `AbortSignal.timeout(ms)`): a call that hangs then fails its record, not the run.
|
|
207
|
-
- **A backoff per record.**
|
|
208
|
-
an hour (`now() + least(interval '1 minute' * power(2, attempts), interval '1 hour')`), and a run takes only records
|
|
256
|
+
- **A backoff per record.** `claim` sets it for you. Without a queue table, a failure sets the record's
|
|
257
|
+
`next_attempt_at` one minute ahead, doubled per attempt up to an hour (`now() + least(interval '1 minute' * power(2, attempts), interval '1 hour')`), and a run takes only records
|
|
209
258
|
whose time has come (`where next_attempt_at <= now()`). Kicks start runs seconds apart: without the wait, a short
|
|
210
259
|
provider outage spends every attempt of a record at once. Set it, with the attempt, in the transaction that claims
|
|
211
260
|
the record before the call (the test e-mail), and a run that dies during the call waits too. A record a person
|
|
@@ -223,5 +272,3 @@ The rules of the job:
|
|
|
223
272
|
where `ctx.mail.mayRetry(error)` holds, and is dead at once otherwise; one whose `outcome` is `not_sent` returns to
|
|
224
273
|
the state it had before `sending`.
|
|
225
274
|
- **Each step recorded before the next effect**, so the next run finishes a stopped one.
|
|
226
|
-
- An inbox needs a row key for this: `id UUID NOT NULL DEFAULT gen_random_uuid()`, since a form writes only its input
|
|
227
|
-
fields.
|