@fabrika/app 0.4.7 → 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.
@@ -6,9 +6,10 @@
6
6
  // to Operations, each under a fixed failure code.
7
7
 
8
8
  import type { IamMembership } from '@fabrika/auth'
9
- import { type ContractArtifact, JOB_SITE_PREFIX, type JobArtifact, type PermissionEntry, permits } from '@fabrika/auth-core'
9
+ import { type ContractArtifact, JOB_SITE_PREFIX, type JobArtifact, type JobMailer, type PermissionEntry, permits } from '@fabrika/auth-core'
10
10
  import type { CapabilityAuthority } from '@fabrika/db/capability'
11
11
  import { type DataAccess, type FrameworkTables, type JobLeases, type LeaseFence, LeaseLostError } from '@fabrika/db/framework'
12
+ import { EmailDeliveryError, type EmailSenderCapabilities, mayRetryDelivery } from '@fabrika/email'
12
13
  import { NotFoundError } from '../errors'
13
14
  import type { FileCollections } from '../files/collections'
14
15
  import { type CapabilityMint, CapabilityRefusedError, type Ref, RequestFrame, runInRequest, type Within } from '../implement/capability'
@@ -30,9 +31,10 @@ import {
30
31
  } from '../implement/invocation'
31
32
  import { jobLinkIssuing, type JobLinkMount } from '../implement/membership'
32
33
  import type { WorkloadCredential } from '../implement/workload'
34
+ import { jobMail, type MailTransports } from './mail'
33
35
  import type { OperationsReporter } from './report'
34
36
  import { type CronSchedule, parseCron, periodAt } from './schedule'
35
- import type { JobContext, JobPrincipal } from './types'
37
+ import type { AppMail, JobContext, JobPrincipal } from './types'
36
38
 
37
39
  /** An attempt of a job without a `timeout` is cancelled after this long. */
38
40
  export const DEFAULT_JOB_TIMEOUT_MS = 15 * 60_000
@@ -66,6 +68,10 @@ export type JobFailureCode =
66
68
  | 'unauthorized'
67
69
  /** IAM could not be asked for the job's token. */
68
70
  | 'iam_unavailable'
71
+ /** The process stopped a kicked run while shutting down; the schedule runs the job again. */
72
+ | 'stopped'
73
+ /** The transport refused a message the handler did not catch; `reason` is the `EmailDeliveryError` code. */
74
+ | 'mail_failed'
69
75
 
70
76
  export type JobRunResult =
71
77
  | { readonly status: 'succeeded'; readonly attempts: number }
@@ -124,11 +130,16 @@ export const systemClock: JobClock = Object.freeze({
124
130
  resolve()
125
131
  return
126
132
  }
127
- const timer = setTimeout(resolve, ms)
133
+ // The listener goes with the wait: a long-lived signal (a process's stop) would otherwise collect one per wait.
134
+ const done = new AbortController()
135
+ const timer = setTimeout(() => {
136
+ done.abort()
137
+ resolve()
138
+ }, ms)
128
139
  signal.addEventListener('abort', () => {
129
140
  clearTimeout(timer)
130
141
  resolve()
131
- }, { once: true })
142
+ }, { once: true, signal: done.signal })
132
143
  }),
133
144
  random: () => Math.random(),
134
145
  })
@@ -144,6 +155,8 @@ interface BoundJob {
144
155
  readonly site: SiteDeclaration | null
145
156
  /** The link mount the approved artifact says the job `issues`: its `ctx.links`. */
146
157
  readonly links: JobLinkMount | null
158
+ /** The mail transport the approved artifact says the job reaches: its `ctx.mail`. */
159
+ readonly mailer: JobMailer | null
147
160
  }
148
161
 
149
162
  interface RunnerContext {
@@ -159,6 +172,7 @@ interface RunnerContext {
159
172
  readonly capabilities: CapabilityAuthority
160
173
  readonly containers: ReadonlyMap<string, readonly string[]>
161
174
  readonly revealHandlerErrors: HandlerErrorSink | null
175
+ readonly mail: MailTransports
162
176
  }
163
177
 
164
178
  /** Where `fabrika app dev` shows what a handler threw, on the developer's own machine; nothing else sets one. */
@@ -192,6 +206,8 @@ export interface JobBindingInput {
192
206
  readonly containers: ReadonlyMap<string, readonly string[]>
193
207
  /** What a job's handler threw, for `fabrika app dev`; without it the error is only its fixed code. */
194
208
  readonly revealHandlerErrors?: HandlerErrorSink
209
+ /** The transports of the jobs that declare `mailer`, which `boot` resolved. */
210
+ readonly mail: MailTransports
195
211
  }
196
212
 
197
213
  interface BoundFrameworkJob {
@@ -210,6 +226,11 @@ export interface BoundJobs {
210
226
  * waits for no lock, settles when that work has ended and never rejects; the schedule stays the recovery path.
211
227
  */
212
228
  readonly kick: (name: string) => Promise<void>
229
+ /**
230
+ * Aborts every kicked run in flight; a later kick starts none and only sets the pending flag, for a process that is
231
+ * not stopping; the schedule recovers their work.
232
+ */
233
+ readonly stopKicks: () => void
213
234
  }
214
235
 
215
236
  function ownChild(node: unknown, key: string): unknown {
@@ -253,12 +274,23 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
253
274
  const site = isSiteHandler(node) ? approvedDeclaration(where, `${JOB_SITE_PREFIX}${name}`, node.declared, 'job', input.artifact, problems) : null
254
275
  const credential = input.credentials.get(`job:${name}`)
255
276
  const links = jobLinks(where, `${JOB_SITE_PREFIX}${name}`, site, input, problems)
277
+ const mailer = input.artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.mailer ?? null
256
278
  if (typeof handler !== 'function') {
257
279
  problems.push(`${where} has no handler; add it to handlers.jobs`)
258
280
  } else if (site !== null && input.data === null) {
259
281
  problems.push(`${where} reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
260
282
  } else if (schedule !== null && timeoutMs !== null && credential !== undefined) {
261
- bound.set(name, { name, artifact, schedule, timeoutMs, handler: (args) => Reflect.apply(handler, undefined, [args]), credential, site, links })
283
+ bound.set(name, {
284
+ name,
285
+ artifact,
286
+ schedule,
287
+ timeoutMs,
288
+ handler: (args) => Reflect.apply(handler, undefined, [args]),
289
+ credential,
290
+ site,
291
+ links,
292
+ mailer,
293
+ })
262
294
  }
263
295
  }
264
296
  const frameworkJobs = new Map<string, BoundFrameworkJob>()
@@ -285,6 +317,7 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
285
317
  capabilities: input.capabilities,
286
318
  containers: input.containers,
287
319
  revealHandlerErrors: input.revealHandlerErrors ?? null,
320
+ mail: input.mail,
288
321
  })
289
322
  const jobs: ContractJobs = Object.freeze({
290
323
  names,
@@ -320,22 +353,28 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
320
353
  }
321
354
  },
322
355
  })
356
+ const stopping = new AbortController()
323
357
  const kick = async (name: string): Promise<void> => {
324
358
  const job = bound.get(name)
325
359
  if (job !== undefined && framework !== null) {
326
- await kickOnce(job, contextFor(framework.jobs, undefined))
360
+ await kickOnce(job, contextFor(framework.jobs, undefined), stopping.signal)
327
361
  }
328
362
  }
329
- return { jobs, declared, kick }
363
+ return { jobs, declared, kick, stopKicks: () => stopping.abort() }
330
364
  }
331
365
 
332
366
  /**
333
367
  * One kick: a run while the lease is free, else the shared pending flag. Neither waits for a lock, so a kick never
334
368
  * holds a connection requests share while a run writes. After a failed run the flag stays for the schedule.
335
369
  */
336
- async function kickOnce(job: BoundJob, context: RunnerContext): Promise<void> {
337
- if ((await runJob(job, context, systemClock, 'try')).status === 'skipped') {
338
- await handOver(job, context, systemClock)
370
+ async function kickOnce(job: BoundJob, context: RunnerContext, stop: AbortSignal): Promise<void> {
371
+ if (stop.aborted) {
372
+ // The flag waits for no lock, and another process that is not stopping runs the job.
373
+ await markPending(job, context)
374
+ return
375
+ }
376
+ if ((await runJob(job, context, systemClock, 'try', stop)).status === 'skipped') {
377
+ await handOver(job, context, systemClock, stop)
339
378
  }
340
379
  }
341
380
 
@@ -343,11 +382,11 @@ async function kickOnce(job: BoundJob, context: RunnerContext): Promise<void> {
343
382
  * Leaves the flag to the run holding the lease, which takes it after it succeeded. That run may have finished before
344
383
  * the flag was set, so the lease is tried once more; `null` when another run still holds it.
345
384
  */
346
- async function handOver(job: BoundJob, context: RunnerContext, clock: JobClock): Promise<JobRunResult | null> {
385
+ async function handOver(job: BoundJob, context: RunnerContext, clock: JobClock, stop?: AbortSignal): Promise<JobRunResult | null> {
347
386
  if (!(await markPending(job, context))) {
348
387
  return null
349
388
  }
350
- const result = await runJob(job, context, clock, 'try')
389
+ const result = await runJob(job, context, clock, 'try', stop)
351
390
  return result.status === 'skipped' ? null : result
352
391
  }
353
392
 
@@ -428,17 +467,20 @@ interface Progress {
428
467
  */
429
468
  type Acquisition = 'wait' | 'try'
430
469
 
431
- /** A run of the job, then one more while a kick arrived after the last one started and it succeeded. */
432
- async function runJob(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition): Promise<JobRunResult> {
433
- let result = await runOnce(job, context, clock, acquisition)
434
- while (result.status === 'succeeded' && (await pendingTaken(job, context))) {
435
- const next = await runOnce(job, context, clock, 'try')
470
+ /**
471
+ * A run of the job, then one more while a kick arrived after the last one started and it succeeded. Once `stop`
472
+ * aborted, no follow-up run starts: the pending flag stays for the schedule.
473
+ */
474
+ async function runJob(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition, stop?: AbortSignal): Promise<JobRunResult> {
475
+ let result = await runOnce(job, context, clock, acquisition, stop)
476
+ while (result.status === 'succeeded' && stop?.aborted !== true && (await pendingTaken(job, context))) {
477
+ const next = await runOnce(job, context, clock, 'try', stop)
436
478
  if (next.status !== 'skipped') {
437
479
  result = next
438
480
  continue
439
481
  }
440
482
  // A newer run holds the lease and may have read before the kick's row: the flag goes back to it.
441
- const handed = await handOver(job, context, clock)
483
+ const handed = await handOver(job, context, clock, stop)
442
484
  return handed ?? result
443
485
  }
444
486
  return result
@@ -454,10 +496,10 @@ async function pendingTaken(job: BoundJob, context: RunnerContext): Promise<bool
454
496
  }
455
497
  }
456
498
 
457
- async function runOnce(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition): Promise<JobRunResult> {
499
+ async function runOnce(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition, stop?: AbortSignal): Promise<JobRunResult> {
458
500
  const progress: Progress = { attempts: 0 }
459
501
  try {
460
- return await runLeased(job, context, clock, progress, acquisition)
502
+ return await runLeased(job, context, clock, progress, acquisition, stop)
461
503
  } catch {
462
504
  // Only the lease store throws here: the handler's errors stay inside its attempt. Its message may quote the URL.
463
505
  return failed(job, progress.attempts, failure('driver_failed'), context.reporter)
@@ -470,10 +512,14 @@ async function runLeased(
470
512
  clock: JobClock,
471
513
  progress: Progress,
472
514
  acquisition: Acquisition,
515
+ stop?: AbortSignal,
473
516
  ): Promise<JobRunResult> {
474
517
  const { leases } = context
475
518
  await leases.register(job.name)
476
519
  const leaseMs = job.timeoutMs + LEASE_GRACE_MS
520
+ if (stop?.aborted === true) {
521
+ return { status: 'skipped' }
522
+ }
477
523
  let fence: LeaseFence | null
478
524
  if (acquisition === 'wait') {
479
525
  await surveil(job, context)
@@ -488,8 +534,8 @@ async function runLeased(
488
534
  await pendingTaken(job, context)
489
535
  const attempts = job.artifact.retries + 1
490
536
  progress.attempts = 1
491
- let outcome = await attemptOnce(job, fence, progress.attempts, clock, context)
492
- while (outcome !== null && progress.attempts < attempts) {
537
+ let outcome = await attemptOnce(job, fence, progress.attempts, clock, context, stop)
538
+ while (outcome !== null && outcome.code !== 'stopped' && !outcome.final && progress.attempts < attempts) {
493
539
  console.error(`fabrika: job '${job.name}' attempt ${progress.attempts} of ${attempts} failed: ${describeFailure(outcome)}`)
494
540
  const delay = backoffMs(progress.attempts, clock.random())
495
541
  // A new generation at once: nothing the failed attempt still has in flight commits from here on.
@@ -498,9 +544,9 @@ async function runLeased(
498
544
  return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
499
545
  }
500
546
  fence = next
501
- await clock.wait(delay, new AbortController().signal)
547
+ await clock.wait(delay, stop ?? new AbortController().signal)
502
548
  progress.attempts++
503
- outcome = await attemptOnce(job, fence, progress.attempts, clock, context)
549
+ outcome = await attemptOnce(job, fence, progress.attempts, clock, context, stop)
504
550
  }
505
551
  if (outcome === null) {
506
552
  if (await leases.finish(fence, 'succeeded')) {
@@ -509,6 +555,11 @@ async function runLeased(
509
555
  return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
510
556
  }
511
557
  await leases.finish(fence, 'failed')
558
+ if (outcome.code === 'stopped') {
559
+ // Not a failure of the job: the schedule runs it again, so Operations hears nothing.
560
+ console.error(`fabrika: job '${job.name}' stopped: the process is shutting down; its schedule runs it again`)
561
+ return { status: 'failed', attempts: progress.attempts, code: 'stopped' }
562
+ }
512
563
  return failed(job, progress.attempts, outcome, context.reporter)
513
564
  }
514
565
 
@@ -518,9 +569,11 @@ interface JobFailure {
518
569
  readonly why: FailureReason | null
519
570
  /** What the attempt threw, which only the report carries, scrubbed (ADR-0059); never a log line or the result. */
520
571
  readonly thrown?: unknown
572
+ /** Another attempt must not run: it could send a message the transport may already have taken. */
573
+ readonly final: boolean
521
574
  }
522
575
 
523
- const failure = (code: JobFailureCode): JobFailure => ({ code, why: null })
576
+ const failure = (code: JobFailureCode): JobFailure => ({ code, why: null, final: false })
524
577
 
525
578
  /** The code, then a refusal's reason and its fixed text: the same words a request's log line uses. */
526
579
  const describeFailure = ({ code, why }: JobFailure): string => (why === null ? code : `${code}: ${why.reason}: ${why.text}`)
@@ -565,7 +618,7 @@ async function runFrameworkJob(bound: BoundFrameworkJob, context: RunnerContext,
565
618
  const timer = new AbortController()
566
619
  let outcome: JobFailure | null
567
620
  try {
568
- const ran = runInRequest(frame, () => bound.job.run(context.reporter, cancel.signal)).then(() => null, failureOf)
621
+ const ran = runInRequest(frame, () => bound.job.run(context.reporter, cancel.signal)).then(() => null, (error: unknown) => failureOf(error, null))
569
622
  const timeout = clock.wait(bound.job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
570
623
  const result = await Promise.race([ran, timeout])
571
624
  outcome = result === TIMED_OUT ? failure('timeout') : result
@@ -590,6 +643,9 @@ async function runFrameworkJob(bound: BoundFrameworkJob, context: RunnerContext,
590
643
  return failed(bound, 1, outcome, context.reporter)
591
644
  }
592
645
 
646
+ /** Read afresh at each call: the signal may abort while the caller awaits. */
647
+ const isStopped = (stop: AbortSignal | undefined): boolean => stop?.aborted === true
648
+
593
649
  /**
594
650
  * `null` when the handler finished, else the attempt's failure code. Either way the attempt's frame ends and the
595
651
  * statements it still has running are cancelled before the runner touches the lease again.
@@ -600,26 +656,40 @@ async function attemptOnce(
600
656
  attempt: number,
601
657
  clock: JobClock,
602
658
  context: RunnerContext,
659
+ stop?: AbortSignal,
603
660
  ): Promise<JobFailure | null> {
661
+ if (isStopped(stop)) {
662
+ return failure('stopped')
663
+ }
604
664
  // The job's authority is what IAM signs for its key at the start of the attempt; a revoked key stops the next one.
605
665
  const authority = await job.credential.authority()
666
+ if (isStopped(stop)) {
667
+ return failure('stopped')
668
+ }
606
669
  if (!authority.ok) {
607
670
  return failure(authority.reason === 'unavailable' ? 'iam_unavailable' : 'unauthorized')
608
671
  }
609
672
  const frame = new RequestFrame(fence.holder, { fence })
610
673
  const cancel = new AbortController()
611
674
  const timer = new AbortController()
612
- const ctx = jobContext(job, frame, attempt, cancel.signal, context, authority.perms)
675
+ const signal = stop === undefined ? cancel.signal : AbortSignal.any([cancel.signal, stop])
676
+ const ctx = jobContext(job, frame, attempt, signal, context, authority.perms)
613
677
  try {
614
678
  const ran = runInRequest(frame, async () => {
615
679
  await job.handler({ ctx })
616
680
  }).then(() => null, (error: unknown) => {
617
- const failed = failureOf(error)
681
+ const failed = failureOf(error, job.mailer === null ? null : (context.mail[job.mailer]?.sender.capabilities ?? null))
618
682
  if (failed.code === 'handler_failed') context.revealHandlerErrors?.(`job '${job.name}' attempt ${attempt}`, error)
619
683
  return failed
620
684
  })
621
685
  const timeout = clock.wait(job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
622
- const result = await Promise.race([ran, timeout])
686
+ const stopped = new Promise<JobFailure>((resolve) => {
687
+ if (stop?.aborted === true) {
688
+ resolve(failure('stopped'))
689
+ }
690
+ stop?.addEventListener('abort', () => resolve(failure('stopped')), { once: true, signal: timer.signal })
691
+ })
692
+ const result = await Promise.race([ran, timeout, stopped])
623
693
  return result === TIMED_OUT ? failure('timeout') : result
624
694
  } finally {
625
695
  frame.end()
@@ -629,18 +699,26 @@ async function attemptOnce(
629
699
  }
630
700
  }
631
701
 
702
+ /** What a job without a declared transport sends through, if it sends at all: nothing it promises, so nothing is resent. */
703
+ const UNKNOWN_TRANSPORT: EmailSenderCapabilities = Object.freeze({ idempotent: false, confirmation: 'queued', maxBatch: 1 })
704
+
632
705
  /**
633
706
  * By class, never by name: an application error can call itself anything. A refusal is classified as a request's is
634
707
  * (`failureCode`), so both name it alike; every other thrown value is `handler_failed`.
635
708
  */
636
- function failureOf(error: unknown): JobFailure {
709
+ function failureOf(error: unknown, transport: EmailSenderCapabilities | null): JobFailure {
637
710
  if (error instanceof LeaseLostError) {
638
711
  return failure('lease_lost')
639
712
  }
713
+ if (error instanceof EmailDeliveryError) {
714
+ const text = error.outcome === 'unknown' ? 'the transport may have accepted the message' : 'the transport did not send the message'
715
+ // ADR-0019's one retry rule: a repeat over a non-idempotent transport may deliver the message twice.
716
+ return { code: 'mail_failed', why: { reason: error.code, text }, final: !mayRetryDelivery(error, transport ?? UNKNOWN_TRANSPORT) }
717
+ }
640
718
  const code = failureCode(error)
641
719
  return code === 'query_refused' || code === 'capability_refused'
642
- ? { code, why: failureReason(error), thrown: error }
643
- : { code: 'handler_failed', why: null, thrown: error }
720
+ ? { code, why: failureReason(error), thrown: error, final: false }
721
+ : { code: 'handler_failed', why: null, thrown: error, final: false }
644
722
  }
645
723
 
646
724
  function jobContext(
@@ -676,6 +754,7 @@ function jobContext(
676
754
  signal,
677
755
  ...(job.site === null || context.data === null ? {} : kitJobContext(frame, context.data, context.files, job.name, job.site, principal.id)),
678
756
  ...(job.links === null || context.membership === null ? {} : { links: jobLinksOf(job, job.links, context.membership, frame, context) }),
757
+ ...(job.mailer === null ? {} : { mail: jobMailOf(job, job.mailer, frame, signal, context) }),
679
758
  within<const Act extends string, const Dim extends string>(action: Act, dimension: Dim): Within<Dim, Act> {
680
759
  check(action, dimension)
681
760
  return context.mint.within(frame, dimension, action, principal.id, 'all')
@@ -725,6 +804,24 @@ function jobLinksOf(job: BoundJob, mount: JobLinkMount, iam: IamMembership, fram
725
804
  })
726
805
  }
727
806
 
807
+ /** `ctx.mail` of a job that declares `mailer`: the transport `boot` resolved for it, until the attempt ends. */
808
+ function jobMailOf(job: BoundJob, mailer: JobMailer, frame: RequestFrame, signal: AbortSignal, context: RunnerContext): AppMail {
809
+ const transport = context.mail[mailer]
810
+ if (transport === null) {
811
+ throw new ServerFault('facility_unbound', `job '${job.name}' declares mailer: '${mailer}', and boot resolved no transport for it`)
812
+ }
813
+ return jobMail({
814
+ transport,
815
+ keyPrefix: context.mail.keyPrefix,
816
+ signal,
817
+ assertLive: () => {
818
+ if (frame.ended) {
819
+ throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it sends no mail`)
820
+ }
821
+ },
822
+ })
823
+ }
824
+
728
825
  /**
729
826
  * What a staged-kit job adds: `ctx.db`, whose reads run each on its own, and `ctx.transaction(work)`, the one place it
730
827
  * writes and runs `tx.sql` — fenced by the attempt's lease, so an attempt that timed out or lost it commits nothing.
package/src/jobs/scrub.ts CHANGED
@@ -15,6 +15,7 @@ const PUBLIC_FRAMEWORK_VARIABLES: ReadonlySet<string> = new Set([
15
15
  'FABRIKA_APP_ID',
16
16
  'FABRIKA_ENVIRONMENT',
17
17
  'FABRIKA_IAM_RPC_URL',
18
+ 'FABRIKA_APP_DATABASE_CONNECTIONS',
18
19
  FABRIKA_CONTRACT_DIGEST,
19
20
  FABRIKA_IAM_APP_ID,
20
21
  FABRIKA_IAM_ISSUER,
package/src/jobs/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The handler types of a contract's jobs: one handler per `c.cron` job under `handlers.jobs`, each with a context
2
2
  // that reaches only the actions the job declares. A job has no request and no caller, so there is no `input`.
3
3
 
4
+ import type { EmailDeliveryResult, EmailSenderCapabilities } from '@fabrika/email'
4
5
  import type { CronJob, DimensionOf } from '../contract/define'
5
6
  import type { Ref, Within } from '../implement/capability'
6
7
 
@@ -32,6 +33,37 @@ export interface JobContext<A extends string, D extends string> {
32
33
  ref<const Act extends A, const Dim extends D>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
33
34
  }
34
35
 
36
+ /** One message a job sends. The framework prefixes `idempotencyKey` before the transport sees it. */
37
+ export interface AppMailMessage {
38
+ readonly to: string
39
+ readonly subject: string
40
+ readonly text: string
41
+ readonly html?: string
42
+ /** 1..200 visible ASCII characters, unique per message within this application environment. */
43
+ readonly idempotencyKey: string
44
+ readonly replyTo?: string
45
+ readonly fromName?: string
46
+ }
47
+
48
+ /** `ctx.mail` of a job that declares `mailer`. */
49
+ export interface AppMail {
50
+ /**
51
+ * false where the installation has no mail transport (`FABRIKA_EMAIL_PROVIDER=none`, or a local one without any):
52
+ * every `send` fails `mail_not_configured`, outcome `not_sent`. An outbox then leaves its rows queued and spends no
53
+ * attempt; they leave on the first run after the operator adds a transport and the application is deployed again.
54
+ */
55
+ readonly available: boolean
56
+ /** What the transport promises: whether a repeat under one key is deduplicated, sync or queued acceptance. */
57
+ readonly capabilities: EmailSenderCapabilities
58
+ /**
59
+ * Hands one message to the transport. Rejects with `EmailDeliveryError`; its `outcome` says whether the message may
60
+ * have left. `signal` adds to the attempt's own `ctx.signal`, which always aborts the send.
61
+ */
62
+ send(message: AppMailMessage, signal?: AbortSignal): Promise<EmailDeliveryResult>
63
+ /** `mayRetryDelivery` over this transport: true only for an `EmailDeliveryError` that is safe to send again. */
64
+ mayRetry(error: unknown): boolean
65
+ }
66
+
35
67
  export type JobHandler<A extends string, D extends string> = (args: { readonly ctx: JobContext<A, D> }) => void | Promise<void>
36
68
 
37
69
  type JobsOf<C> = C extends { readonly surface: { readonly jobs: infer J } } ? J : never
@@ -1,7 +1,10 @@
1
1
  // The kit `fabrika app dev` previews an application through, and the types of the seed module it runs first.
2
2
 
3
+ import { EMAIL_DELIVERY_ERRORS, type EmailDeliveryErrorCode, type EmailDeliveryOutcome } from '@fabrika/email'
4
+ import type { MemoryHandoff } from '@fabrika/email/memory'
3
5
  import type { AnyContract, DimensionOf } from '../contract/define'
4
6
  import type { ContractImplementation } from '../implement/implement'
7
+ import { mailKeyPrefix } from '../jobs/mail'
5
8
  import type { PersonTestCaller, TestCaller } from './caller'
6
9
  import { passingChallenge, TURNSTILE_TEST_TOKEN } from './challenge'
7
10
  import { bootTestKit, type TestKit, type TestKitOptions } from './kit'
@@ -51,15 +54,40 @@ export interface DevOutboxMessage {
51
54
  readonly body: string
52
55
  }
53
56
 
57
+ /** A message a job handed to the kit's mail stand-in, as `fabrika app dev` shows it. */
58
+ export interface DevMail {
59
+ /** Its position among the handoffs, from 1. */
60
+ readonly id: number
61
+ /** When it was handed off, ISO 8601. */
62
+ readonly at: string
63
+ readonly to: string
64
+ readonly subject: string
65
+ readonly text: string
66
+ readonly html: string | null
67
+ readonly replyTo: string | null
68
+ readonly fromName: string | null
69
+ /** The idempotency key as the job passed it. */
70
+ readonly key: string
71
+ /** The key the transport got: `key` behind the application environment's prefix. */
72
+ readonly transportKey: string
73
+ readonly answer: 'accepted' | EmailDeliveryErrorCode
74
+ readonly outcome: 'accepted' | EmailDeliveryOutcome
75
+ /** Whether this handoff put the message in the inbox; a repeat under its key does not. */
76
+ readonly delivered: boolean
77
+ }
78
+
54
79
  /** What `fabrika app dev` stands in for outside the application, for the seed to read. */
55
80
  export interface DevOutside {
56
81
  /** Every request sent to `DEV_OUTBOX_ORIGIN` so far, oldest first. */
57
82
  outbox(): readonly DevOutboxMessage[]
83
+ /** Every message a job handed to the mail stand-in so far, oldest first. */
84
+ mails(): readonly DevMail[]
58
85
  }
59
86
 
60
87
  /**
61
88
  * The default export of `dev/seed.ts`: it receives the application's test kit, the API its tests use, writes through the
62
- * endpoints, and answers who and what the picker offers. `outside` reads what the application sent to the dev outbox.
89
+ * endpoints, and answers who and what the picker offers. `outside` reads what the application sent to the dev outbox
90
+ * and every message a job handed to the mail stand-in, oldest first.
63
91
  */
64
92
  export type DevSeed<C extends AnyContract> = (dev: TestKit<C>, outside: DevOutside) => Promise<DevSeedResult<C>> | DevSeedResult<C>
65
93
 
@@ -71,6 +99,8 @@ export interface DevKit<C extends AnyContract> {
71
99
  membershipsOf(caller: TestCaller): readonly TestMembership[]
72
100
  /** The invitation e-mails the stand-in IAM sent, in the order it first sent them. */
73
101
  invitations(): readonly SentInvitation[]
102
+ /** Every message the application's jobs handed to the mail stand-in, oldest first; one refused before the handoff is absent. */
103
+ mails(): readonly DevMail[]
74
104
  }
75
105
 
76
106
  const isPerson = (caller: TestCaller): caller is PersonTestCaller => caller.kind === 'human' && caller.email !== null
@@ -95,5 +125,29 @@ export function devKit<C extends AnyContract>(app: ContractImplementation<C>, op
95
125
  },
96
126
  membershipsOf: (caller: TestCaller) => standIn.membershipsOf(caller),
97
127
  invitations: () => standIn.invitations(),
128
+ mails: () => {
129
+ const prefix = mailKeyPrefix(kit.mailKeyScope)
130
+ return kit.mail.handoffs.map((handoff, index) => devMail(handoff, index + 1, prefix))
131
+ },
98
132
  })
99
133
  }
134
+
135
+ function devMail(handoff: MemoryHandoff, id: number, prefix: string): DevMail {
136
+ const { message, answer } = handoff
137
+ const transportKey = message.idempotencyKey
138
+ return {
139
+ id,
140
+ at: new Date(handoff.at).toISOString(),
141
+ to: message.to,
142
+ subject: message.subject,
143
+ text: message.text,
144
+ html: message.html ?? null,
145
+ replyTo: message.replyTo ?? null,
146
+ fromName: message.fromName ?? null,
147
+ key: transportKey.startsWith(prefix) ? transportKey.slice(prefix.length) : transportKey,
148
+ transportKey,
149
+ answer,
150
+ outcome: answer === 'accepted' ? 'accepted' : EMAIL_DELIVERY_ERRORS[answer].outcome,
151
+ delivered: handoff.taken,
152
+ }
153
+ }
@@ -805,5 +805,20 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
805
805
  return status >= 500 || status === 401 || status === 403 ? { answered: false, status } : { answered: true }
806
806
  },
807
807
  }
808
- return Object.freeze(accessKit)
808
+ // Each case waits for the runs its requests kicked, so the suite's teardown never closes the database under one.
809
+ const drained = <A extends readonly unknown[], R>(method: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
810
+ try {
811
+ return await method(...args)
812
+ } finally {
813
+ await kit.drain()
814
+ }
815
+ }
816
+ return Object.freeze({
817
+ denied: drained(accessKit.denied),
818
+ leaked: drained(accessKit.leaked),
819
+ own: drained(accessKit.own),
820
+ file: drained(accessKit.file),
821
+ scoped: drained(accessKit.scoped),
822
+ global: drained(accessKit.global),
823
+ })
809
824
  }
@@ -4,8 +4,10 @@ import { refuseInProduction } from '@fabrika/db/production'
4
4
 
5
5
  refuseInProduction('@fabrika/app/testing')
6
6
  export type { IamMembershipRpc } from '@fabrika/auth-core'
7
+ export { type MemoryEmail, MemoryEmailSender, type MemoryHandoff } from '@fabrika/email/memory'
7
8
  export type { FileScanner } from '../files/scan'
8
9
  export type { ChallengeFetch } from '../implement/challenge'
10
+ export { mailKeyPrefix, type MailKeyScope, transportMailKey } from '../jobs/mail'
9
11
  export type { HandlerErrorSink } from '../jobs/runner'
10
12
  export type {
11
13
  LabelledTestCaller,
@@ -24,6 +26,7 @@ export {
24
26
  type DevKit,
25
27
  devKit,
26
28
  type DevLink,
29
+ type DevMail,
27
30
  type DevOutboxMessage,
28
31
  type DevOutside,
29
32
  type DevPersona,