@fabrika/app 0.4.8 → 0.5.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.
@@ -284,6 +284,15 @@ if (postgres !== null) {
284
284
  - `kit.runJob('<job>')` runs a cron job once and answers `{ status: 'succeeded', attempts }`. A job's `ctx.links`
285
285
  reaches the kit's stand-in IAM under the job's own key, so a job that declares `issues` issues and revokes links in a
286
286
  plain `testKit` and under `fabrika app dev`, as IAM allows it: for its declared mount only.
287
+ - A form or a mutation that declares `then` starts its job in the background of the call. `kit.drain()` waits for
288
+ those runs, and `kit.runJob` first waits for them; every generated access case drains before it answers. The kicked
289
+ run may start at once, so arm a fault before the call, never between the call and the run.
290
+ - `kit.mail` is the stand-in for every job's mail transport (`MemoryEmailSender` of `@fabrika/app/testing`), so a test
291
+ needs no mail variable. `kit.mail.accepted` is what reached the inbox, once per key, and `kit.mail.handoffs` every
292
+ send with its `answer`. `refuse(code?)`, `unavailable()`, `unreachable()`, `loseAnswers(n)` and `during(hook)`
293
+ inject a failure until `heal()`. A message carries the key the transport got, `transportMailKey(kit.mailKeyScope,
294
+ key)`. Model a transport that does not deduplicate with `testKit(app, { mail: new MemoryEmailSender({ capabilities:
295
+ { idempotent: false, confirmation: 'queued' } }) })`. The test e-mail in `references/public-intake.md` is an example.
287
296
  - An application with files passes `files: new MemoryFileObjects()` (`@fabrika/platform/testing`) to `testKit`;
288
297
  without it the application does not boot. With `scan: true`, an upload stays `quarantined` until
289
298
  `kit.runJob(FILE_SCAN_JOB)` (`@fabrika/app/implement`) runs with a `scanner` passed to `testKit`: a `FileScanner`
@@ -294,7 +303,7 @@ if (postgres !== null) {
294
303
  a stand-in of your own receives the check (`hostname`, `action`) a passing answer must confirm as its second argument.
295
304
  - `testKit` takes no `deps`, and a test may not start a server (`Bun.serve` is `forbidden-global`). Stand in for an
296
305
  outbound dependency through its variables: `env` points it at a test URL, and `spyOn(globalThis, 'fetch')` answers
297
- that URL only and passes every other request on (`src/__tests__/mail-stand-in.ts` in `references/public-intake.md`).
306
+ that URL only and passes every other request on to the real `fetch`.
298
307
  - `defineDeps(build)` runs at every boot, the generated tests' too, which set no application variable: read a variable
299
308
  where it is used, not in `build` (`references/public-intake.md`). A handler that calls an outbound dependency fails its
300
309
  generated cases with 500, so an endpoint they cover writes an outbox row a job sends (`references/endpoint.md`).
package/src/bun.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  import { HttpIamMembership } from '@fabrika/auth'
9
9
  import type { Database, PostgresOptions } from '@fabrika/db'
10
10
  import { isScopedDatabase } from '@fabrika/db/framework'
11
+ import type { EmailProviderRegistry } from '@fabrika/email'
12
+ import { directoryEmailProvider } from '@fabrika/email/directory'
11
13
  import { parseJwks } from '@fabrika/jwks'
12
14
  import type { FileObjectStore } from '@fabrika/platform'
13
15
  import { applicationRuntimeUrl, S3BlobStore } from '@fabrika/platform-node'
@@ -27,6 +29,9 @@ export { clamscanArguments, type ClamscanOptions, clamscanScanner } from './file
27
29
 
28
30
  export { markProductionProcess } from '@fabrika/db/production'
29
31
 
32
+ /** The local stack's capture: boot honours it only under a loopback issuer, and the stack names it with `FABRIKA_EMAIL_PROVIDER`. */
33
+ const LOCAL_EMAIL_PROVIDERS = { directory: directoryEmailProvider } satisfies EmailProviderRegistry
34
+
30
35
  export interface ManagedBunHandlerOptions {
31
36
  /** Required sink for rejected background tasks. Do not log raw errors or secret-bearing values. */
32
37
  onBackgroundError(error: unknown): void
@@ -125,6 +130,23 @@ async function loadDataLayer(): Promise<DataLayerOpeners> {
125
130
  return { postgres: Database.postgres, applicationSchema }
126
131
  }
127
132
 
133
+ /** How long a stopping process waits for the work its requests left running; a kicked run aborts at once. */
134
+ const SHUTDOWN_DRAIN_MS = 10_000
135
+
136
+ /** Waits for `drain`, at most `ms`; a drain the bound cuts short is logged. */
137
+ async function drainWithin(drain: Promise<void>, ms: number): Promise<void> {
138
+ const expired = Promise.withResolvers<void>()
139
+ const timer = setTimeout(() => {
140
+ console.error(`fabrika: the shutdown drain ended after ${ms / 1000} s with background work still running`)
141
+ expired.resolve()
142
+ }, ms)
143
+ try {
144
+ await Promise.race([drain, expired.promise])
145
+ } finally {
146
+ clearTimeout(timer)
147
+ }
148
+ }
149
+
128
150
  /** The provisioning credential the platform writes; only the launcher reads it, and the application's process never holds it. */
129
151
  const PROVISIONING_DATABASE_URL = 'FABRIKA_APP_DATABASE_URL'
130
152
  /** The runtime role's credential, which the launcher derives and hands the application's process in its place. */
@@ -371,9 +393,10 @@ async function startContractServer(
371
393
  membership,
372
394
  workloadTokens,
373
395
  requireProxySecret: true,
396
+ localEmailProviders: LOCAL_EMAIL_PROVIDERS,
374
397
  ...(operationsFetch === undefined ? {} : { operationsFetch }),
375
398
  })
376
- // The work a request leaves running (a failure's report, a job a form kicked) is drained before the database closes.
399
+ // The work a request leaves running (a failure's report, a kicked run) is drained, within a bound, before the database closes.
377
400
  const handler = createBunHandler({ fetch: (request, _env: undefined, exec) => served.fetch(request, exec) }, undefined, {
378
401
  onBackgroundError: () => console.error('fabrika: a background task of a request failed'),
379
402
  })
@@ -386,8 +409,9 @@ async function startContractServer(
386
409
  }
387
410
  const stop = async (): Promise<void> => {
388
411
  clearInterval(checks)
412
+ served.stopKicks()
389
413
  await server.stop(false)
390
- await handler.drain()
414
+ await drainWithin(handler.drain(), SHUTDOWN_DRAIN_MS)
391
415
  await database?.close()
392
416
  }
393
417
  process.once('SIGTERM', () => void stop().then(() => process.exit(0)))
@@ -439,6 +463,7 @@ export async function runContractJob(
439
463
  membership,
440
464
  workloadTokens,
441
465
  requireProxySecret: true,
466
+ localEmailProviders: LOCAL_EMAIL_PROVIDERS,
442
467
  // ClamAV on PATH, as the provider's build installs it for a `scan: true` collection; missing, the scan fails closed.
443
468
  scanner: clamscanScanner(),
444
469
  })
@@ -8,6 +8,7 @@ import {
8
8
  type ContractArtifact,
9
9
  displayNameProblem,
10
10
  type EndpointRow,
11
+ JOB_MAILERS,
11
12
  LOCALES,
12
13
  mailTemplateProblems,
13
14
  type MessageKind,
@@ -294,6 +295,8 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
294
295
  unscoped: record(z.enum(['read', 'write'])),
295
296
  connect: names,
296
297
  issues: nullableName,
298
+ then: name.optional(),
299
+ mailer: z.enum(JOB_MAILERS).optional(),
297
300
  })).optional(),
298
301
  directories: record(z.strictObject({ dimension: name, lists: z.boolean() })).optional(),
299
302
  locale: z.enum(LOCALES).optional(),
@@ -29,8 +29,10 @@ import {
29
29
  type ImportArtifact,
30
30
  isLocale,
31
31
  isMessageKind,
32
+ JOB_MAILERS,
32
33
  JOB_SITE_PREFIX,
33
34
  type JobArtifact,
35
+ type JobMailer,
34
36
  type Locale,
35
37
  LOCALES,
36
38
  type MailTemplate,
@@ -113,7 +115,8 @@ const AUDIENCE = 'any consumer user'
113
115
  const IMPORT_FIELDS = ['app', 'export', 'version', 'digest', 'procedures', 'usedBy', 'timeoutMs']
114
116
  /** Tables the framework keeps in the application's schema; no site reaches them unscoped. */
115
117
  const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
116
- const DECLARED_ALLOWED = "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>'"
118
+ const DECLARED_ALLOWED =
119
+ "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', then: '<cron job>', mailer: 'installation' | 'own'"
117
120
  const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
118
121
  /** An environment variable an application reads; a form's challenge names its secret by one. */
119
122
  const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
@@ -530,7 +533,7 @@ class ArtifactBuilder {
530
533
  path: mount.path,
531
534
  appendTo: mount.appendTo,
532
535
  ...this.#formAdmission(name, where, mount),
533
- ...(mount.then === undefined ? {} : { then: this.#formJob(where, mount.then) }),
536
+ ...(mount.then === undefined ? {} : { then: this.#kickedJob(where, mount.then) }),
534
537
  }
535
538
  case 'webhook': {
536
539
  this.#prefix(where, mount.path)
@@ -555,8 +558,8 @@ class ArtifactBuilder {
555
558
  }
556
559
  }
557
560
 
558
- /** The job a form kicks: a cron job of this contract. */
559
- #formJob(where: string, then: string): string {
561
+ /** The job a form or a mutation kicks: a cron job of this contract. */
562
+ #kickedJob(where: string, then: string): string {
560
563
  const jobs = this.#contract.jobs
561
564
  const job: { readonly kind: string } | undefined = Object.hasOwn(jobs, then) ? jobs[then] : undefined
562
565
  if (job === undefined) {
@@ -1467,6 +1470,8 @@ class ArtifactBuilder {
1467
1470
  this.#problem(where, `'${key}' is not a declared field (allowed: ${DECLARED_ALLOWED})`)
1468
1471
  }
1469
1472
  }
1473
+ const then = this.#then(where, value['then'], place)
1474
+ const mailer = this.#mailer(where, value['mailer'], place)
1470
1475
  const reach: DeclaredReach = {
1471
1476
  sql: this.#sqlMode(where, value['sql'], place.readOnly),
1472
1477
  unscoped: this.#unscoped(where, value['unscoped'], place.readOnly),
@@ -1476,6 +1481,8 @@ class ArtifactBuilder {
1476
1481
  : place.kind === 'job'
1477
1482
  ? this.#jobIssues(where, value['issues'], place.actions)
1478
1483
  : null,
1484
+ ...(then === undefined ? {} : { then }),
1485
+ ...(mailer === undefined ? {} : { mailer }),
1479
1486
  }
1480
1487
  if (place.kind !== 'rpc') {
1481
1488
  this.#rpcOnly(where, 'connect', value['connect'])
@@ -1483,7 +1490,10 @@ class ArtifactBuilder {
1483
1490
  if (place.kind !== 'rpc' && place.kind !== 'job' && value['issues'] !== undefined) {
1484
1491
  this.#problem(where, 'declares issues, which only an endpoint of an RPC mount or a cron job declares')
1485
1492
  }
1486
- if (reach.sql !== null || Object.keys(reach.unscoped).length > 0 || reach.connect.length > 0 || reach.issues !== null) {
1493
+ if (
1494
+ reach.sql !== null || Object.keys(reach.unscoped).length > 0 || reach.connect.length > 0 || reach.issues !== null || reach.then !== undefined
1495
+ || reach.mailer !== undefined
1496
+ ) {
1487
1497
  this.#declared[site] = reach
1488
1498
  }
1489
1499
  }
@@ -1611,6 +1621,42 @@ class ArtifactBuilder {
1611
1621
  return name
1612
1622
  }
1613
1623
 
1624
+ /** The cron job a mutation kicks once its transaction committed; `undefined` when it declares none or after a problem. */
1625
+ #then(where: string, value: unknown, place: DeclaringSite): string | undefined {
1626
+ if (value === undefined) {
1627
+ return undefined
1628
+ }
1629
+ if (place.kind !== 'rpc' && place.kind !== 'link') {
1630
+ this.#problem(where, 'declares then, which only a mutation of an RPC or link mount declares')
1631
+ return undefined
1632
+ }
1633
+ if (place.readOnly) {
1634
+ this.#problem(where, 'declares then, and it runs read-only (allowed: then on a mutation)')
1635
+ return undefined
1636
+ }
1637
+ if (typeof value !== 'string') {
1638
+ this.#problem(where, "then is not a job's name (allowed: then: '<cron job>')")
1639
+ return undefined
1640
+ }
1641
+ return this.#kickedJob(where, value)
1642
+ }
1643
+
1644
+ /** The mail transport a cron job reaches; `undefined` when it declares none or after a problem. */
1645
+ #mailer(where: string, value: unknown, place: DeclaringSite): JobMailer | undefined {
1646
+ if (value === undefined) {
1647
+ return undefined
1648
+ }
1649
+ if (place.kind !== 'job') {
1650
+ this.#problem(where, 'declares mailer, which only a cron job declares')
1651
+ return undefined
1652
+ }
1653
+ const mailer = JOB_MAILERS.find((transport) => transport === value)
1654
+ if (mailer === undefined) {
1655
+ this.#problem(where, `mailer ${JSON.stringify(value)} is not a transport (allowed: mailer: 'installation' or 'own')`)
1656
+ }
1657
+ return mailer
1658
+ }
1659
+
1614
1660
  /** `connect` calls another application on behalf of a person, so only an RPC endpoint declares it. */
1615
1661
  #rpcOnly(where: string, field: 'connect', value: unknown): void {
1616
1662
  if (value !== undefined) {
@@ -2,7 +2,7 @@
2
2
  // is typed by the kit (vocabulary, `DbIndex`, imports) and the site's own declaration, never by the contract value,
3
3
  // so a group in any module types itself without importing `fabrika.contract.ts`.
4
4
 
5
- import type { RpcCaller } from '@fabrika/auth-core'
5
+ import type { JobMailer, RpcCaller } from '@fabrika/auth-core'
6
6
  import type {
7
7
  AnyDbIndex,
8
8
  ApplicationTables,
@@ -24,7 +24,7 @@ import type { ApplicationDirectory, LinkIssuing, MemberDirectory, MemberLookup,
24
24
  import type { AppPrincipal, PrincipalOf, ServicePrincipal } from '../implement/principal'
25
25
  import type { ConnectCall, RequestContext } from '../implement/types'
26
26
  import type { WebhookDelivery, WebhookPrincipal } from '../implement/webhook'
27
- import type { JobContext } from '../jobs/types'
27
+ import type { AppMail, JobContext } from '../jobs/types'
28
28
  import type { ConnectSnapshot } from './connect'
29
29
  import type { MemberAccess, Schema } from './define'
30
30
  import type { App } from './kit'
@@ -86,15 +86,17 @@ export type SqlMode = 'read' | 'write'
86
86
  * The off-road reach a site declares: part of the contract, read by Control, a review signal for the approver. A
87
87
  * field the site does not declare is absent.
88
88
  */
89
- export interface Declared<S extends SqlMode, U, Cn extends readonly string[], Is extends string> {
89
+ export interface Declared<S extends SqlMode, U, Cn extends readonly string[], Is extends string, M extends JobMailer = never> {
90
90
  readonly sql?: S
91
91
  readonly unscoped?: U
92
92
  readonly connect?: Cn
93
93
  readonly issues?: Is
94
+ readonly mailer?: M
94
95
  }
95
96
 
96
97
  /** One declared field, `never` when the site leaves it out. */
97
- type Of<D, F extends keyof Declared<SqlMode, unknown, readonly string[], string>> = D extends { readonly [P in F]?: infer V } ? Exclude<V, undefined>
98
+ type Of<D, F extends keyof Declared<SqlMode, unknown, readonly string[], string, JobMailer>> = D extends { readonly [P in F]?: infer V }
99
+ ? Exclude<V, undefined>
98
100
  : never
99
101
 
100
102
  export type SqlValue = string | number | boolean | bigint | Date | null | readonly (string | number)[]
@@ -347,6 +349,7 @@ export type EndpointContext<
347
349
  & SqlContext<Of<D, 'sql'>, K['index']>
348
350
  & FilesContext<K['index'], Kind>
349
351
  & { readonly db: DataOf<K['index'], EndpointData<K['index'], Kind, Acc, Of<D, 'unscoped'>>> }
352
+ & NoMail
350
353
 
351
354
  /** A link procedure acts on the one object its key is bound to. */
352
355
  export type LinkContext<
@@ -363,6 +366,7 @@ export type LinkContext<
363
366
  & {
364
367
  readonly db: DataOf<K['index'], EndpointData<K['index'], Kind, { readonly action: A; readonly on: LD; readonly from: string }, Of<D, 'unscoped'>>>
365
368
  }
369
+ & NoMail
366
370
 
367
371
  /** An export procedure runs read-only, for a consumer application: its capability and data, nothing that writes. */
368
372
  export type ExportContext<K extends KitTypes, Acc, D extends Declared<'read', unknown, never, never>> =
@@ -370,6 +374,7 @@ export type ExportContext<K extends KitTypes, Acc, D extends Declared<'read', un
370
374
  & CapabilityContext<Acc>
371
375
  & SqlContext<Of<D, 'sql'>, K['index']>
372
376
  & { readonly db: DataOf<K['index'], EndpointData<K['index'], 'query', Acc, Of<D, 'unscoped'>>> }
377
+ & NoMail
373
378
 
374
379
  /**
375
380
  * A capability a job or webhook minted or received, of one of its declared actions `Acts`, bound to the same handles
@@ -387,7 +392,7 @@ type SiteData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string,
387
392
  & UnscopedData<Ix, U, Kind>
388
393
 
389
394
  /** The fenced transaction of a job attempt: writes and `tx.sql` commit only while the attempt holds its lease. */
390
- export type JobTransaction<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string>> =
395
+ export type JobTransaction<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
391
396
  & { readonly db: SiteData<K['index'], 'mutation', A, Of<D, 'unscoped'>> }
392
397
  & SqlContext<Of<D, 'sql'>, K['index']>
393
398
  & FilesContext<K['index'], 'mutation'>
@@ -400,13 +405,22 @@ type JobLinksContext<Dim extends string, A extends string, Is extends string> =
400
405
  ? { readonly links: Unavailable<"declare issues: '<link mount>' on this job to issue its share links"> }
401
406
  : { readonly links: LinkIssuing<Dim, A, Is> }
402
407
 
408
+ /** `ctx.mail` of a cron job that declares the transport it sends through (`mailer: 'installation' | 'own'`). */
409
+ type JobMailContext<M extends JobMailer> = [M] extends [never]
410
+ ? { readonly mail: Unavailable<"declare mailer: 'installation' or 'own' on this job to send mail"> }
411
+ : { readonly mail: AppMail }
412
+
413
+ /** A send is external I/O that never rolls back, so only a job sends, from the outbox row an endpoint wrote. */
414
+ type NoMail = { readonly mail: Unavailable<"an endpoint sends no mail: write an outbox row and kick a job that declares mailer (then: '<job>')"> }
415
+
403
416
  /** What a cron job's handler receives. Its reads run outside the transaction; every write runs in `ctx.transaction`. */
404
- export type JobKitContext<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string>> =
417
+ export type JobKitContext<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
405
418
  & JobContext<A, K['dimension']>
406
419
  & { readonly db: SiteData<K['index'], 'job', A, Of<D, 'unscoped'>> }
407
420
  & { readonly sql: Unavailable<'a job runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql'> }
408
421
  & FilesContext<K['index'], 'job'>
409
422
  & JobLinksContext<K['dimension'], A, Of<D, 'issues'>>
423
+ & JobMailContext<Of<D, 'mailer'>>
410
424
  & { transaction<R>(work: (tx: JobTransaction<K, A, D>) => Promise<R>): Promise<R> }
411
425
 
412
426
  /** What a webhook delivery's handler receives: one transaction per delivery, idempotent by `ctx.delivery.key`. */
@@ -415,6 +429,7 @@ export type WebhookKitContext<K extends KitTypes, B extends string, Acts extends
415
429
  & { readonly refs: { readonly [A in Acts]: Ref<B, A> }; readonly delivery: WebhookDelivery }
416
430
  & SqlContext<Of<D, 'sql'>, K['index']>
417
431
  & { readonly db: SiteData<K['index'], 'mutation', Acts, Of<D, 'unscoped'>> }
432
+ & NoMail
418
433
 
419
434
  type IndexOf<A> = A extends App<infer K> ? K['index'] : never
420
435
 
@@ -69,7 +69,7 @@ export function unknownOptions(options: object, builder: Builder): string[] {
69
69
  * The off-road fields a site of the staged kit declares beside its builder's options. The kit copies them into
70
70
  * `declared`; `buildArtifact` checks which of them the site may carry.
71
71
  */
72
- export const DECLARED_FIELDS = ['sql', 'unscoped', 'connect', 'issues'] as const
72
+ export const DECLARED_FIELDS = ['sql', 'unscoped', 'connect', 'issues', 'then', 'mailer'] as const
73
73
 
74
74
  /** The keys of a kit site's `options` that are neither `builder`'s option fields nor a declared field. */
75
75
  export function kitUnknownOptions(options: object, builder: Builder): string[] {
@@ -77,7 +77,7 @@ export function kitUnknownOptions(options: object, builder: Builder): string[] {
77
77
  }
78
78
 
79
79
  /**
80
- * The reach a site of the staged kit declares, as written (`sql`, `unscoped`, `connect`, `issues`): only the fields
80
+ * The reach a site of the staged kit declares, as written (`sql`, `unscoped`, `connect`, `issues`, `then`, `mailer`): only the fields
81
81
  * its options carry. Unchecked here: the static reader copies what the source holds, and `buildArtifact` parses it.
82
82
  */
83
83
  export interface DeclaredDeclaration {
@@ -85,6 +85,8 @@ export interface DeclaredDeclaration {
85
85
  readonly unscoped?: unknown
86
86
  readonly connect?: unknown
87
87
  readonly issues?: unknown
88
+ readonly then?: unknown
89
+ readonly mailer?: unknown
88
90
  }
89
91
 
90
92
  /** The declared fields of a kit site's options, each one only when the options carry it. */
@@ -94,6 +96,8 @@ export function declaredOf(options: DeclaredDeclaration): DeclaredDeclaration {
94
96
  ...(options.unscoped === undefined ? {} : { unscoped: options.unscoped }),
95
97
  ...(options.connect === undefined ? {} : { connect: options.connect }),
96
98
  ...(options.issues === undefined ? {} : { issues: options.issues }),
99
+ ...(options.then === undefined ? {} : { then: options.then }),
100
+ ...(options.mailer === undefined ? {} : { mailer: options.mailer }),
97
101
  }
98
102
  }
99
103
 
@@ -55,6 +55,22 @@ type OneLiteral<Is> = string extends Is ? Unavailable<"issues names one link mou
55
55
  : true extends IsUnion<Is> ? Unavailable<"issues names one link mount, as a literal: issues: 'shared'">
56
56
  : unknown
57
57
 
58
+ /** `then` names one cron job, as a literal. */
59
+ type OneJob<Th> = string extends Th ? Unavailable<"then names one cron job, as a literal: then: 'mailing'">
60
+ : true extends IsUnion<Th> ? Unavailable<"then names one cron job, as a literal: then: 'mailing'">
61
+ : unknown
62
+
63
+ /** A mutation's `then`, inferred from its optional field and then required, like a declared field. */
64
+ type KickField<Th> =
65
+ & {
66
+ /** The cron job this mutation kicks once its transaction committed; the schedule stays its recovery. */
67
+ readonly then?: Th
68
+ }
69
+ & ([Th] extends [never] ? unknown : { readonly then: Th & NoInfer<OneJob<Th>> })
70
+
71
+ /** A query writes nothing, so it has nothing to kick a job for. */
72
+ type NoKick = { readonly then?: Unavailable<'a query kicks no job: declare then on a mutation'> }
73
+
58
74
  type IsUnion<T, All = T> = T extends unknown ? ([All] extends [T] ? false : true) : never
59
75
 
60
76
  /** An endpoint's declaration: its schemas, its access form and the reach it declares. */
@@ -147,7 +163,7 @@ export interface GroupKit<K extends KitTypes, Caller extends RpcCaller> {
147
163
  const Cn extends readonly ImportOf<K>[] = never,
148
164
  const Is extends string = never,
149
165
  >(
150
- def: EndpointDef<I, O, Acc, S, U, Cn, Is, K['index']>,
166
+ def: EndpointDef<I, O, Acc, S, U, Cn, Is, K['index']> & NoKick,
151
167
  ): EndpointBuilder<
152
168
  'query',
153
169
  I,
@@ -164,8 +180,9 @@ export interface GroupKit<K extends KitTypes, Caller extends RpcCaller> {
164
180
  const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
165
181
  const Cn extends readonly ImportOf<K>[] = never,
166
182
  const Is extends string = never,
183
+ const Th extends string = never,
167
184
  >(
168
- def: EndpointDef<I, O, Acc, S, U, Cn, Is, K['index']>,
185
+ def: EndpointDef<I, O, Acc, S, U, Cn, Is, K['index']> & KickField<Th>,
169
186
  ): EndpointBuilder<
170
187
  'mutation',
171
188
  I,
@@ -226,7 +243,7 @@ export interface LinkGroupKit<K extends KitTypes, LD extends string, G extends s
226
243
  const S extends 'read' = never,
227
244
  const U extends UnscopedDeclaration<K['tables'], 'read'> = never,
228
245
  >(
229
- def: EndpointDef<I, O, Acc, S, U, never, never, K['index']>,
246
+ def: EndpointDef<I, O, Acc, S, U, never, never, K['index']> & NoKick,
230
247
  ): EndpointBuilder<'query', I, O, Acc, Declared<S, U, never, never>, LinkContext<K, 'query', LD, Acc['action'], Declared<S, U, never, never>>>
231
248
  mutation<
232
249
  const Acc extends LinkAccess<G>,
@@ -234,8 +251,9 @@ export interface LinkGroupKit<K extends KitTypes, LD extends string, G extends s
234
251
  I extends InputSchema | null = null,
235
252
  const S extends SqlMode = never,
236
253
  const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
254
+ const Th extends string = never,
237
255
  >(
238
- def: EndpointDef<I, O, Acc, S, U, never, never, K['index']>,
256
+ def: EndpointDef<I, O, Acc, S, U, never, never, K['index']> & KickField<Th>,
239
257
  ): EndpointBuilder<'mutation', I, O, Acc, Declared<S, U, never, never>, LinkContext<K, 'mutation', LD, Acc['action'], Declared<S, U, never, never>>>
240
258
  }
241
259
 
@@ -3,7 +3,7 @@
3
3
  // handlers beside their endpoints, in any module, type-check without a cycle. Browser-safe: it builds plain data and
4
4
  // holds each handler beside its site (`handled.ts`), never calling one.
5
5
 
6
- import type { RpcCaller } from '@fabrika/auth-core'
6
+ import type { JobMailer, RpcCaller } from '@fabrika/auth-core'
7
7
  import type { AnyDbIndex, DbIndex, Tables, Unavailable, UnscopedDeclaration, UnscopedMode } from '@fabrika/db/tables'
8
8
  import type { z } from 'zod/v4'
9
9
  import type { Declared, JobKitContext, KitImports, KitTypes, NoTablesIndex, SqlMode, WebhookKitContext } from './context'
@@ -73,10 +73,19 @@ export type KitOf<V, Ix, Imp extends KitImports> = V extends AppVocabulary<infer
73
73
  }
74
74
  : never
75
75
 
76
- export type CronOptions<Acts, Sc, S, U, Is, Ix extends AnyDbIndex> =
76
+ export type CronOptions<Acts, Sc, S, U, Is, Ix extends AnyDbIndex, M = never> =
77
77
  & Owned<'kind' | 'schedule'>
78
78
  & { readonly actions: Acts; readonly scope: Sc; readonly timeout?: string; readonly retries?: number }
79
79
  & DeclaredFields<S, U, never, Is, Ix>
80
+ & MailerField<M>
81
+
82
+ /** The mail transport a job reaches, inferred from its optional field and then required, like a declared field. */
83
+ type MailerField<M> =
84
+ & {
85
+ /** `'installation'`: the installation's mail transport; `'own'`: the application's own. */
86
+ readonly mailer?: M
87
+ }
88
+ & ([M] extends [never] ? unknown : { readonly mailer: M })
80
89
 
81
90
  /** A cron job of the kit, handled. */
82
91
  export interface KitCronJob<Acts extends string, Sc extends string, D> extends CronJob<Acts, Sc> {
@@ -84,7 +93,12 @@ export interface KitCronJob<Acts extends string, Sc extends string, D> extends C
84
93
  }
85
94
 
86
95
  /** A cron job before `.handle`: the contract's `jobs` refuses it. */
87
- export interface CronBuilder<K extends KitTypes, Acts extends string, Sc extends string, D extends Declared<SqlMode, unknown, never, string>> {
96
+ export interface CronBuilder<
97
+ K extends KitTypes,
98
+ Acts extends string,
99
+ Sc extends string,
100
+ D extends Declared<SqlMode, unknown, never, string, JobMailer>,
101
+ > {
88
102
  handle(handler: (args: { readonly ctx: JobKitContext<K, Acts, D> }) => void | Promise<void>): KitCronJob<Acts, Sc, D>
89
103
  }
90
104
 
@@ -234,7 +248,8 @@ export interface App<K extends KitTypes> {
234
248
  const S extends SqlMode = never,
235
249
  const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
236
250
  const Is extends string = never,
237
- >(schedule: string, options: CronOptions<Acts, Sc, S, U, Is, K['index']>): CronBuilder<K, Acts[number], Sc, Declared<S, U, never, Is>>
251
+ const M extends JobMailer = never,
252
+ >(schedule: string, options: CronOptions<Acts, Sc, S, U, Is, K['index'], M>): CronBuilder<K, Acts[number], Sc, Declared<S, U, never, Is, M>>
238
253
  /** A GitHub webhook bound per value of `boundTo`; the contract mounts it by name. */
239
254
  webhook<
240
255
  const B extends K['dimension'],
@@ -18,6 +18,7 @@ import {
18
18
  FABRIKA_PROXY_SECRET,
19
19
  type FormMountArtifact,
20
20
  type IamMembershipRpc,
21
+ isLoopbackHost,
21
22
  type Jwks,
22
23
  parseAppEnvironmentId,
23
24
  } from '@fabrika/auth-core'
@@ -33,6 +34,8 @@ import {
33
34
  type WebhookTables,
34
35
  } from '@fabrika/db/framework'
35
36
  import type { DataKind } from '@fabrika/db/tables'
37
+ import type { EmailProviderRegistry } from '@fabrika/email'
38
+ import { builtinEmailProviders } from '@fabrika/email/providers'
36
39
  import { TokenVerifier } from '@fabrika/jwks'
37
40
  import type { FileObjectStore } from '@fabrika/platform'
38
41
  import { compileContract } from '../contract/compile'
@@ -44,6 +47,7 @@ import { kitOriginOf } from '../contract/handled'
44
47
  import type { RequestExecutionContext } from '../fetch'
45
48
  import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, fileCollections } from '../files/collections'
46
49
  import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, type FileScanner, SCAN_LIMITS } from '../files/scan'
50
+ import { type MailStandIn, resolveMailTransports } from '../jobs/mail'
47
51
  import { logReporter, type OperationsReporter, operationsReporter, operationsSettings, type ReportSource } from '../jobs/report'
48
52
  import { bindJobs, type ContractJobs, type HandlerErrorSink } from '../jobs/runner'
49
53
  import { environmentScrubber } from '../jobs/scrub'
@@ -127,6 +131,18 @@ export interface BootOptions {
127
131
  * application withholds because it may carry a credential.
128
132
  */
129
133
  readonly revealHandlerErrors?: HandlerErrorSink
134
+ /** The providers `FABRIKA_EMAIL_PROVIDER` may name for the jobs that declare `mailer: 'installation'`; the built-in ones by default. */
135
+ readonly emailProviders?: EmailProviderRegistry
136
+ /**
137
+ * Providers added to `emailProviders` only in a local composition (a loopback `FABRIKA_IAM_ISSUER`); anywhere else
138
+ * `FABRIKA_EMAIL_PROVIDER` naming one is refused like any unknown provider. On a name both hold, `emailProviders` wins.
139
+ */
140
+ readonly localEmailProviders?: EmailProviderRegistry
141
+ /**
142
+ * The test kit's stand-in for every mailer job, or `'none'` for an installation without mail; boot then reads neither
143
+ * `FABRIKA_EMAIL_*` nor `serve`'s `mail`.
144
+ */
145
+ readonly mailStandIn?: MailStandIn
130
146
  }
131
147
 
132
148
  export interface ContractApp {
@@ -134,6 +150,8 @@ export interface ContractApp {
134
150
  fetch(request: Request, exec?: RequestExecutionContext): Promise<Response>
135
151
  /** The contract's `c.cron` jobs; `fabrika job run <name>` runs one. */
136
152
  readonly jobs: ContractJobs
153
+ /** Aborts every kicked run in flight; a later kick starts none; the schedule recovers their work. */
154
+ stopKicks(): void
137
155
  }
138
156
 
139
157
  /** Everything that stops a contract application from starting, with where it is and what to do. */
@@ -628,6 +646,19 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
628
646
  dsn: operations === null ? null : (options.environment.framework['FABRIKA_OPERATIONS_DSN'] ?? '').trim(),
629
647
  release: options.environment.framework['FABRIKA_RELEASE']?.trim() || null,
630
648
  }, problems)
649
+ // A token of a deployed IAM never carries a loopback issuer, so a deployed application is never local.
650
+ const local = variables.issuer !== '' && isLoopbackHost(new URL(variables.issuer).hostname)
651
+ const providers = options.emailProviders ?? builtinEmailProviders
652
+ const mail = resolveMailTransports({
653
+ artifact,
654
+ framework: options.environment.framework,
655
+ application: options.environment.application,
656
+ own: app.mail,
657
+ standIn: options.mailStandIn,
658
+ registry: local ? { ...options.localEmailProviders, ...providers } : providers,
659
+ keyScope: { issuer: variables.issuer, appId: variables.iamAppId },
660
+ local,
661
+ }, problems)
631
662
  if (problems.length > 0) {
632
663
  throw new BootError(problems)
633
664
  }
@@ -682,6 +713,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
682
713
  facilities: siteFacilities(facilitiesOf(artifact, row), bound.declaration),
683
714
  connect: siteConnect(connect.get(id), bound.declaration),
684
715
  site: servedSite(procedure.type, bound),
716
+ ...(bound.declaration?.then == null ? {} : { then: bound.declaration.then }),
685
717
  })
686
718
  }
687
719
  }
@@ -746,6 +778,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
746
778
  capabilities: issuer.authority,
747
779
  containers,
748
780
  ...(options.revealHandlerErrors === undefined ? {} : { revealHandlerErrors: options.revealHandlerErrors }),
781
+ mail,
749
782
  }, problems)
750
783
  for (const id of jobs.declared) {
751
784
  declared.add(id)
@@ -822,5 +855,9 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
822
855
  kick: jobs.kick,
823
856
  actions: Object.freeze(Object.keys(artifact.actions).sort()),
824
857
  })
825
- return Object.freeze({ fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec), jobs: jobs.jobs })
858
+ return Object.freeze({
859
+ fetch: (request: Request, exec?: RequestExecutionContext) => runtime.fetch(request, exec),
860
+ jobs: jobs.jobs,
861
+ stopKicks: jobs.stopKicks,
862
+ })
826
863
  }
@@ -120,6 +120,8 @@ export type ServedEndpoint =
120
120
  readonly connect?: readonly ImportWire[]
121
121
  /** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
122
122
  readonly site?: ServedSite
123
+ /** The cron job this mutation kicks once its transaction committed; absent without `then`. */
124
+ readonly then?: string
123
125
  }
124
126
  /** What an export's consumer vendors, behind the export's own callers. */
125
127
  | { readonly kind: 'description'; readonly row: CredentialedEndpoint; readonly description: ExportDescription }
@@ -302,7 +304,7 @@ export class ContractRuntime {
302
304
  }
303
305
  return await withBootstrap(await this.#asset(served, request), served.prefix)
304
306
  case 'procedure':
305
- return await this.#procedure(served, request)
307
+ return await this.#procedure(served, request, exec)
306
308
  case 'file':
307
309
  return await this.#file(served, request, plan.params)
308
310
  case 'form':
@@ -438,9 +440,14 @@ export class ContractRuntime {
438
440
  /**
439
441
  * A staged-kit site runs in one transaction: its statements commit after the handler returned, the output passed
440
442
  * validation and an attachment was encoded, and roll back on a throw, a refused output, a file over its limit or a
441
- * cancelled request. So an audited export keeps its log row only when its file goes out.
443
+ * cancelled request. So an audited export keeps its log row only when its file goes out. Only after that commit does
444
+ * `then` kick its job, which the answer neither waits for nor depends on.
442
445
  */
443
- async #procedure(served: Extract<ServedEndpoint, { kind: 'procedure' }>, request: Request): Promise<Response> {
446
+ async #procedure(
447
+ served: Extract<ServedEndpoint, { kind: 'procedure' }>,
448
+ request: Request,
449
+ exec: RequestExecutionContext | undefined,
450
+ ): Promise<Response> {
444
451
  const caller = await this.#authorize(served.row, request)
445
452
  const input = await readInput(served, request)
446
453
  const frame = new RequestFrame(crypto.randomUUID(), { readOnly: served.readOnly === true, endpoint: served.row.id })
@@ -476,6 +483,11 @@ export class ContractRuntime {
476
483
  }
477
484
  const answer = this.#answerOf(served, result)
478
485
  await invocation?.commit(signal)
486
+ if (served.then !== undefined) {
487
+ // Without an execution context the kick runs detached; it never rejects.
488
+ const kicked = this.#parts.kick(served.then)
489
+ exec?.waitUntil(kicked)
490
+ }
479
491
  return answer
480
492
  } finally {
481
493
  await invocation?.rollback()
@@ -4,6 +4,7 @@
4
4
  import type { TablePolicyArtifact } from '@fabrika/auth-core'
5
5
  import type { DataLayer } from '@fabrika/db'
6
6
  import type { CapabilityAuthority } from '@fabrika/db/capability'
7
+ import type { EmailSender } from '@fabrika/email'
7
8
  import type { ConnectSnapshot } from '../contract/connect'
8
9
  import type { AnyContract } from '../contract/define'
9
10
  import type { ConnectSnapshots, ExactHandlers, HandlersOf } from './types'
@@ -41,6 +42,8 @@ export interface ContractImplementation<C extends AnyContract = AnyContract> {
41
42
  readonly connect?: Readonly<Record<string, ConnectSnapshot>>
42
43
  /** Runs `deps`, then `handlers` over its result. The tree is checked by the types of `implement`, and again by `boot`. */
43
44
  readonly handlers: (env: ApplicationEnvironment, runtime: DepsRuntime) => unknown
45
+ /** The application's own transport, built once at boot from its own variables; `null` when they configure none. */
46
+ readonly mail?: (env: ApplicationEnvironment) => EmailSender | null
44
47
  }
45
48
 
46
49
  /**