@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
|
@@ -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
|
|
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
|
|
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(),
|
package/src/contract/build.ts
CHANGED
|
@@ -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 =
|
|
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.#
|
|
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
|
-
#
|
|
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 (
|
|
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) {
|
package/src/contract/context.ts
CHANGED
|
@@ -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 }
|
|
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
|
|
package/src/contract/group.ts
CHANGED
|
@@ -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
|
|
package/src/contract/kit.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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'],
|
package/src/implement/boot.ts
CHANGED
|
@@ -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({
|
|
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(
|
|
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
|
/**
|