@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.
- package/package.json +9 -8
- package/skill/SKILL.md +12 -6
- package/skill/references/app-setup.md +11 -10
- package/skill/references/dev.md +20 -11
- package/skill/references/endpoint.md +6 -4
- package/skill/references/files.md +39 -35
- package/skill/references/jobs.md +70 -12
- package/skill/references/public-intake.md +360 -191
- package/skill/references/tests.md +10 -1
- package/src/bun.ts +27 -2
- package/src/contract/artifact-schema.ts +3 -0
- package/src/contract/build.ts +51 -5
- package/src/contract/context.ts +21 -6
- package/src/contract/declaration.ts +6 -2
- package/src/contract/group.ts +22 -4
- package/src/contract/kit.ts +19 -4
- package/src/implement/boot.ts +38 -1
- package/src/implement/dispatch.ts +15 -3
- package/src/implement/implement.ts +3 -0
- package/src/implement/index.ts +10 -1
- package/src/implement/invocation.ts +38 -6
- package/src/implement/serve.ts +4 -0
- package/src/jobs/mail.ts +261 -0
- package/src/jobs/runner.ts +129 -32
- package/src/jobs/types.ts +32 -0
- package/src/testing/dev.ts +55 -1
- package/src/testing/endpoint-access.ts +16 -1
- package/src/testing/index.ts +3 -0
- package/src/testing/kit.ts +44 -4
- package/src/testing/membership.ts +13 -3
package/src/jobs/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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 (
|
|
338
|
-
|
|
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
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|
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/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
|
package/src/testing/dev.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/testing/index.ts
CHANGED
|
@@ -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,
|