@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.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -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 +387 -236
  10. package/skill/references/spa.md +5 -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 +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  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 +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -1,10 +1,11 @@
1
1
  # references/jobs.md — cron jobs
2
2
 
3
- Background work is a cron job: `app.cron(schedule, { actions, scope: 'app' })` with its `.handle(…)` in the
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. 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.
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
- scope: 'app',
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. `scope: 'app'` is the only scope: a job reaches every value.
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 `timeout` (default 15 minutes) is retried up to `retries` times, so every write is
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 app-wide (`scope: 'app'` is the only
132
- scope), so `tx.sql.scope` of its `Within` would be every row.
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 form's
139
- inbox, the record, the link and its e-mail, each step recorded before the next.
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)`
142
- (`references/files.md`). The framework's own `fabrika-file-sweep` then deletes their objects; never write a sweep.
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
- 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.
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. There are no attachments.
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 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.
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 form's inbox row, an outbox row (`test_mails`), or a state of its record
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 `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.
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**: read it `for update skip locked` in the transaction that writes it, so a
200
- row another session holds waits for a later run and blocks nothing behind it.
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.** 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
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.