@fabrika/app 0.5.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/src/jobs/mail.ts
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
|
-
// `ctx.mail` of a job that declares `mailer`, over the transport `boot` resolved: the installation's (`FABRIKA_EMAIL_
|
|
2
|
-
// the application's own (`serve(contract, { mail })`)
|
|
3
|
-
// says so (`FABRIKA_EMAIL_PROVIDER=none`): its jobs get
|
|
4
|
-
// Each key carries the prefix of the installation and the
|
|
5
|
-
// never share a key. Nothing here logs a message or a key.
|
|
1
|
+
// `ctx.mail` of a job that declares `mailer`, over the transport `boot` resolved: the installation's (`FABRIKA_EMAIL_*`,
|
|
2
|
+
// or the platform's relay under `FABRIKA_EMAIL_PROVIDER=platform`), the application's own (`serve(contract, { mail })`)
|
|
3
|
+
// or the test kit's stand-in. An installation without a transport says so (`FABRIKA_EMAIL_PROVIDER=none`): its jobs get
|
|
4
|
+
// `ctx.mail` that is not available, and each send waits for one. Each key carries the prefix of the installation and the
|
|
5
|
+
// application environment, so senders on one provider account never share a key. Nothing here logs a message or a key.
|
|
6
6
|
|
|
7
|
-
import { type ContractArtifact, JOB_SITE_PREFIX, type JobMailer, sha256Hex } from '@fabrika/auth-core'
|
|
7
|
+
import { type ContractArtifact, JOB_SITE_PREFIX, type JobMailer, platformTransportVariables, sha256Hex } from '@fabrika/auth-core'
|
|
8
8
|
import {
|
|
9
9
|
EMAIL_PROVIDER_VARIABLE,
|
|
10
|
+
type EmailAttachment,
|
|
10
11
|
EmailDeliveryError,
|
|
11
12
|
type EmailDeliveryResult,
|
|
12
13
|
type EmailMessage,
|
|
13
14
|
type EmailProviderRegistry,
|
|
14
15
|
type EmailSender,
|
|
15
16
|
emailSenderFromEnv,
|
|
17
|
+
MAX_EMAIL_ATTACHMENTS,
|
|
16
18
|
mayRetryDelivery,
|
|
17
19
|
NO_EMAIL_PROVIDER,
|
|
18
20
|
pickEmailEnvironment,
|
|
19
21
|
} from '@fabrika/email'
|
|
22
|
+
import { PLATFORM_EMAIL_PROVIDER, PlatformEmailSender } from '@fabrika/email/platform'
|
|
20
23
|
import { z } from 'zod/v4'
|
|
24
|
+
import { HttpError } from '../errors'
|
|
25
|
+
import type { OpenedFile } from '../files/handle'
|
|
26
|
+
import { platformCall } from '../implement/egress'
|
|
21
27
|
import type { ApplicationEnvironment } from '../implement/implement'
|
|
22
28
|
import type { AppMail, AppMailMessage } from './types'
|
|
23
29
|
|
|
@@ -72,10 +78,12 @@ export interface JobMailInput {
|
|
|
72
78
|
readonly signal: AbortSignal
|
|
73
79
|
/** Throws once the attempt has ended. */
|
|
74
80
|
readonly assertLive: () => void
|
|
81
|
+
/** `ctx.files.open` of the attempt: the bytes of a live file whose reference it holds, at most `maxBytes`. */
|
|
82
|
+
readonly open: (file: unknown, options: { readonly maxBytes: number }) => Promise<OpenedFile>
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
export function jobMail(input: JobMailInput): AppMail {
|
|
78
|
-
const { transport: { sender, available }, keyPrefix, signal: attempt, assertLive } = input
|
|
86
|
+
const { transport: { sender, available }, keyPrefix, signal: attempt, assertLive, open } = input
|
|
79
87
|
return Object.freeze({
|
|
80
88
|
available,
|
|
81
89
|
capabilities: sender.capabilities,
|
|
@@ -85,6 +93,8 @@ export function jobMail(input: JobMailInput): AppMail {
|
|
|
85
93
|
if (typeof key !== 'string' || !APP_MAIL_KEY.test(key)) {
|
|
86
94
|
throw new EmailDeliveryError(`Email idempotency key is not 1..${MAX_APP_MAIL_KEY_LENGTH} visible ASCII characters`, { code: 'invalid_message' })
|
|
87
95
|
}
|
|
96
|
+
// Without a transport the send fails `mail_not_configured` below, and no file is read for it.
|
|
97
|
+
const attachments = available ? await readAttachments(message.attachments ?? [], sender.capabilities.maxAttachmentBytes, open) : []
|
|
88
98
|
const transported: EmailMessage = {
|
|
89
99
|
to: message.to,
|
|
90
100
|
subject: message.subject,
|
|
@@ -93,9 +103,12 @@ export function jobMail(input: JobMailInput): AppMail {
|
|
|
93
103
|
idempotencyKey: `${keyPrefix}${key}`,
|
|
94
104
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
95
105
|
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
106
|
+
...(message.unsubscribe === undefined ? {} : { unsubscribeUrl: message.unsubscribe.url }),
|
|
107
|
+
...(attachments.length === 0 ? {} : { attachments }),
|
|
96
108
|
}
|
|
97
109
|
try {
|
|
98
|
-
|
|
110
|
+
// The transport's request is the platform's, which the job's egress does not cover.
|
|
111
|
+
return await platformCall(() => sender.send(transported, signal === undefined ? attempt : AbortSignal.any([attempt, signal])))
|
|
99
112
|
} catch (error) {
|
|
100
113
|
if (error instanceof EmailDeliveryError) {
|
|
101
114
|
throw error
|
|
@@ -108,6 +121,52 @@ export function jobMail(input: JobMailInput): AppMail {
|
|
|
108
121
|
})
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The bytes of each file of `files`, read through `ctx.files.open` (which refuses a file that is not live, or whose
|
|
126
|
+
* reference the attempt does not hold) and bounded together by the transport's limit.
|
|
127
|
+
*/
|
|
128
|
+
async function readAttachments(
|
|
129
|
+
files: readonly unknown[],
|
|
130
|
+
maxBytes: number | undefined,
|
|
131
|
+
open: JobMailInput['open'],
|
|
132
|
+
): Promise<readonly EmailAttachment[]> {
|
|
133
|
+
if (files.length === 0) {
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
if (maxBytes === undefined) {
|
|
137
|
+
throw new EmailDeliveryError('This mail transport sends no attachments', { code: 'invalid_message' })
|
|
138
|
+
}
|
|
139
|
+
if (files.length > MAX_EMAIL_ATTACHMENTS) {
|
|
140
|
+
throw new EmailDeliveryError(`Email has more than ${MAX_EMAIL_ATTACHMENTS} attachments`, { code: 'invalid_message' })
|
|
141
|
+
}
|
|
142
|
+
const attachments: EmailAttachment[] = []
|
|
143
|
+
let remaining = maxBytes
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const opened = await open(file, { maxBytes: remaining }).catch((error: unknown) => {
|
|
146
|
+
if (error instanceof HttpError && error.type === 'too_large') {
|
|
147
|
+
throw new EmailDeliveryError(`Email attachments have more than the ${maxBytes} bytes this transport sends`, { code: 'invalid_message' })
|
|
148
|
+
}
|
|
149
|
+
throw error
|
|
150
|
+
})
|
|
151
|
+
remaining -= opened.size
|
|
152
|
+
attachments.push({ filename: attachmentName(opened), contentType: opened.type, content: opened.bytes })
|
|
153
|
+
}
|
|
154
|
+
return attachments
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const MAX_ATTACHMENT_NAME_LENGTH = 255
|
|
158
|
+
|
|
159
|
+
/** The uploader's name with no path separator or quote, which every transport quotes; `attachment` without one. */
|
|
160
|
+
function attachmentName(file: OpenedFile): string {
|
|
161
|
+
let name = ''
|
|
162
|
+
for (const character of (file.name ?? '').replace(/[/\\"]/g, '_')) {
|
|
163
|
+
if (name.length + character.length > MAX_ATTACHMENT_NAME_LENGTH) break
|
|
164
|
+
name += character
|
|
165
|
+
}
|
|
166
|
+
const trimmed = name.trim()
|
|
167
|
+
return trimmed === '' ? 'attachment' : trimmed
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
export interface MailTransportInput {
|
|
112
171
|
readonly artifact: ContractArtifact
|
|
113
172
|
/** The framework's variables, whose `FABRIKA_EMAIL_*` configure the installation's transport. */
|
|
@@ -166,7 +225,7 @@ function installationTransport(names: readonly string[], input: MailTransportInp
|
|
|
166
225
|
const env = pickEmailEnvironment(input.framework)
|
|
167
226
|
let sender: EmailSender | null
|
|
168
227
|
try {
|
|
169
|
-
sender = emailSenderFromEnv(env, input.registry)
|
|
228
|
+
sender = env[EMAIL_PROVIDER_VARIABLE] === PLATFORM_EMAIL_PROVIDER ? platformSender(input.framework) : emailSenderFromEnv(env, input.registry)
|
|
170
229
|
} catch (error) {
|
|
171
230
|
if (!(error instanceof EmailDeliveryError)) {
|
|
172
231
|
throw error
|
|
@@ -190,6 +249,29 @@ function installationTransport(names: readonly string[], input: MailTransportInp
|
|
|
190
249
|
return null
|
|
191
250
|
}
|
|
192
251
|
|
|
252
|
+
/** The platform's relay, from its receiver's variables; a refusal names them and never their values. */
|
|
253
|
+
function platformSender(framework: Readonly<Record<string, string>>): EmailSender {
|
|
254
|
+
const variables = platformTransportVariables('mail')
|
|
255
|
+
const url = framework[variables.url] ?? ''
|
|
256
|
+
const key = framework[variables.key] ?? ''
|
|
257
|
+
const missing = [variables.url, variables.key].filter((name) => (framework[name] ?? '') === '')
|
|
258
|
+
if (missing.length > 0) {
|
|
259
|
+
throw new EmailDeliveryError(`Email configuration is invalid: ${missing.map((name) => `${name} is missing`).join('; ')}`, {
|
|
260
|
+
code: 'invalid_configuration',
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return new PlatformEmailSender({ url, key })
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (!(error instanceof EmailDeliveryError)) {
|
|
267
|
+
throw error
|
|
268
|
+
}
|
|
269
|
+
throw new EmailDeliveryError(`Email configuration is invalid: ${variables.url} or ${variables.key} is invalid (${error.message})`, {
|
|
270
|
+
code: 'invalid_configuration',
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
193
275
|
function ownTransport(names: readonly string[], input: MailTransportInput, problems: string[]): JobMailTransport | null {
|
|
194
276
|
if (input.own === undefined) {
|
|
195
277
|
return null
|
|
@@ -221,7 +303,13 @@ function ownTransport(names: readonly string[], input: MailTransportInput, probl
|
|
|
221
303
|
return null
|
|
222
304
|
}
|
|
223
305
|
|
|
224
|
-
const SenderCapabilities = z.object({
|
|
306
|
+
const SenderCapabilities = z.object({
|
|
307
|
+
idempotent: z.boolean(),
|
|
308
|
+
confirmation: z.enum(['sync', 'queued']),
|
|
309
|
+
maxBatch: z.int().positive(),
|
|
310
|
+
listUnsubscribe: z.boolean().optional(),
|
|
311
|
+
maxAttachmentBytes: z.int().positive().optional(),
|
|
312
|
+
})
|
|
225
313
|
|
|
226
314
|
/** What `serve`'s `mail` returned: a sender, `null` for none, `undefined` for anything else. */
|
|
227
315
|
function parseSender(value: unknown): EmailSender | null | undefined {
|
package/src/jobs/runner.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The job runner (`fabrika job run <name>`): one run of one `c.cron` job. A run takes the job's lease or does not
|
|
2
2
|
// start; each attempt holds one generation of it with a deadline the database enforces, so a write of an attempt
|
|
3
|
-
// that timed out, failed or lost the lease never commits (`@fabrika/db`'s fence). A
|
|
4
|
-
// background without waiting for any lock; a kick that finds the lease held leaves the shared pending flag, and the run
|
|
3
|
+
// that timed out, failed or lost the lease never commits (`@fabrika/db`'s fence). A site's `ctx.kick` sends a run in
|
|
4
|
+
// the background after its commit, without waiting for any lock; a kick that finds the lease held leaves the shared pending flag, and the run
|
|
5
5
|
// holding it runs once more after it succeeded. Failures, crashed runs and a job that stopped succeeding are reported
|
|
6
6
|
// to Operations, each under a fixed failure code.
|
|
7
7
|
|
|
@@ -11,8 +11,17 @@ import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
|
11
11
|
import { type DataAccess, type FrameworkTables, type JobLeases, type LeaseFence, LeaseLostError, type Session } from '@fabrika/db/framework'
|
|
12
12
|
import { EmailDeliveryError, type EmailSenderCapabilities, mayRetryDelivery } from '@fabrika/email'
|
|
13
13
|
import { NotFoundError } from '../errors'
|
|
14
|
-
import type
|
|
15
|
-
import {
|
|
14
|
+
import { FileWork, type StoredFileKinds, storedFilesHandle } from '../files/kinds'
|
|
15
|
+
import {
|
|
16
|
+
type AppCapability,
|
|
17
|
+
type CapabilityMint,
|
|
18
|
+
CapabilityRefusedError,
|
|
19
|
+
type Ref,
|
|
20
|
+
RequestFrame,
|
|
21
|
+
runInRequest,
|
|
22
|
+
type Within,
|
|
23
|
+
} from '../implement/capability'
|
|
24
|
+
import { runAsSite } from '../implement/egress'
|
|
16
25
|
import { failureCode, type FailureReason, failureReason } from '../implement/failures'
|
|
17
26
|
import { ServerFault } from '../implement/faults'
|
|
18
27
|
import type { ContractDatabase } from '../implement/implement'
|
|
@@ -24,20 +33,20 @@ import {
|
|
|
24
33
|
jobHandles,
|
|
25
34
|
jobSql,
|
|
26
35
|
jobSqlOutsideTransaction,
|
|
27
|
-
|
|
36
|
+
kickRecorder,
|
|
28
37
|
siteData,
|
|
29
38
|
type SiteDeclaration,
|
|
30
39
|
undeclaredSql,
|
|
31
40
|
} from '../implement/invocation'
|
|
32
41
|
import { jobLinkIssuing, type JobLinkMount } from '../implement/membership'
|
|
33
|
-
import type {
|
|
42
|
+
import type { SignedTokens } from '../implement/tokens'
|
|
43
|
+
import { type AppUrl, appUrl } from '../implement/urls'
|
|
44
|
+
import { type WorkloadCredential, workloadSite } from '../implement/workload'
|
|
34
45
|
import { jobMail, type MailTransports } from './mail'
|
|
35
46
|
import type { OperationsReporter } from './report'
|
|
36
47
|
import { type CronSchedule, parseCron, periodAt } from './schedule'
|
|
37
48
|
import type { AppMail, JobContext, JobPrincipal } from './types'
|
|
38
49
|
|
|
39
|
-
/** An attempt of a job without a `timeout` is cancelled after this long. */
|
|
40
|
-
export const DEFAULT_JOB_TIMEOUT_MS = 15 * 60_000
|
|
41
50
|
/**
|
|
42
51
|
* The lease outlives an attempt's deadline by this much: the runner renews or finishes a timed-out attempt's lease,
|
|
43
52
|
* and no other run takes it over meanwhile. The attempt's writes stop at its deadline regardless.
|
|
@@ -47,8 +56,6 @@ export const LEASE_GRACE_MS = 60_000
|
|
|
47
56
|
export const JOB_CHECK_INTERVAL_MS = 60_000
|
|
48
57
|
const BACKOFF_BASE_MS = 1_000
|
|
49
58
|
const BACKOFF_MAX_MS = 5 * 60_000
|
|
50
|
-
const DURATION = /^([1-9][0-9]*)([smh])$/
|
|
51
|
-
const UNIT_MS: Readonly<Record<string, number>> = { s: 1_000, m: 60_000, h: 3_600_000 }
|
|
52
59
|
|
|
53
60
|
/**
|
|
54
61
|
* Why a run failed. Fixed codes only: an application error's name and message may carry a secret, so neither is
|
|
@@ -167,12 +174,17 @@ interface RunnerContext {
|
|
|
167
174
|
readonly reporter: OperationsReporter
|
|
168
175
|
readonly mint: CapabilityMint
|
|
169
176
|
readonly data: DataAccess | null
|
|
170
|
-
readonly
|
|
177
|
+
readonly fileKinds: StoredFileKinds | null
|
|
171
178
|
readonly membership: IamMembership | null
|
|
172
179
|
readonly capabilities: CapabilityAuthority
|
|
173
180
|
readonly containers: ReadonlyMap<string, readonly string[]>
|
|
174
181
|
readonly revealHandlerErrors: HandlerErrorSink | null
|
|
175
182
|
readonly mail: MailTransports
|
|
183
|
+
/** Kicks a contract job in the background: what a job's own `ctx.kick` sends once its run succeeded. */
|
|
184
|
+
readonly kick: (name: string) => Promise<void>
|
|
185
|
+
/** The environment's signed tokens: `ctx.tokens` of a job that declares `signs`. */
|
|
186
|
+
readonly tokens: SignedTokens | null
|
|
187
|
+
readonly url: AppUrl
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
/** Where `fabrika app dev` shows what a handler threw, on the developer's own machine; nothing else sets one. */
|
|
@@ -194,9 +206,9 @@ export interface JobBindingInput {
|
|
|
194
206
|
readonly mint: CapabilityMint
|
|
195
207
|
/** The mini-ORM of the bound `scoped(tables)` layer, behind a staged-kit job's `ctx.db`. */
|
|
196
208
|
readonly data: DataAccess | null
|
|
197
|
-
/** The contract's file
|
|
198
|
-
readonly
|
|
199
|
-
/** The framework's own jobs this contract needs: the scan of its `scan: true` file
|
|
209
|
+
/** The contract's file kinds, behind a staged-kit job's `ctx.files.put` and `.open`; `null` without any. */
|
|
210
|
+
readonly fileKinds?: StoredFileKinds | null
|
|
211
|
+
/** The framework's own jobs this contract needs: the scan of its `scan: true` file kinds, and the file sweep. */
|
|
200
212
|
readonly frameworkJobs?: readonly FrameworkJob[]
|
|
201
213
|
/** IAM's membership operations, which a job that `issues` links asks; `null` when the adapter supplies none. */
|
|
202
214
|
readonly membership: IamMembership | null
|
|
@@ -208,6 +220,10 @@ export interface JobBindingInput {
|
|
|
208
220
|
readonly revealHandlerErrors?: HandlerErrorSink
|
|
209
221
|
/** The transports of the jobs that declare `mailer`, which `boot` resolved. */
|
|
210
222
|
readonly mail: MailTransports
|
|
223
|
+
/** The environment's signed tokens, which a job that declares `signs` signs with. */
|
|
224
|
+
readonly tokens?: SignedTokens
|
|
225
|
+
/** `ctx.url`: absolute links to the application's own procedures; without it every link is refused. */
|
|
226
|
+
readonly url?: AppUrl
|
|
211
227
|
}
|
|
212
228
|
|
|
213
229
|
interface BoundFrameworkJob {
|
|
@@ -221,7 +237,7 @@ export interface BoundJobs {
|
|
|
221
237
|
/** The handler ids the jobs took (`jobs.<name>`), so `boot` finds no undeclared handler among them. */
|
|
222
238
|
readonly declared: readonly string[]
|
|
223
239
|
/**
|
|
224
|
-
* Kicks the contract job `name`, as a
|
|
240
|
+
* Kicks the contract job `name`, as a site's `ctx.kick` does after its commit: a run under the job's own key
|
|
225
241
|
* now, or the shared pending flag for the run holding the lease, which then runs once more after it succeeded. It
|
|
226
242
|
* waits for no lock, settles when that work has ended and never rejects; the schedule stays the recovery path.
|
|
227
243
|
*/
|
|
@@ -237,15 +253,6 @@ function ownChild(node: unknown, key: string): unknown {
|
|
|
237
253
|
return typeof node === 'object' && node !== null && Object.hasOwn(node, key) ? Reflect.get(node, key) : undefined
|
|
238
254
|
}
|
|
239
255
|
|
|
240
|
-
function durationMs(timeout: string | null): number | null {
|
|
241
|
-
if (timeout === null) {
|
|
242
|
-
return DEFAULT_JOB_TIMEOUT_MS
|
|
243
|
-
}
|
|
244
|
-
const match = DURATION.exec(timeout)
|
|
245
|
-
const unit = match?.[2] === undefined ? undefined : UNIT_MS[match[2]]
|
|
246
|
-
return match?.[1] === undefined || unit === undefined ? null : Number.parseInt(match[1], 10) * unit
|
|
247
|
-
}
|
|
248
|
-
|
|
249
256
|
/** Binds every declared job to its handler, or adds why it cannot run to `problems`. */
|
|
250
257
|
export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs {
|
|
251
258
|
const entries = Object.entries(input.artifact.jobs)
|
|
@@ -265,26 +272,22 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
|
|
|
265
272
|
if (schedule === null) {
|
|
266
273
|
problems.push(`${where} has the schedule '${artifact.schedule}', which is not a cron expression (allowed: '0 3 * * *', '*/15 * * * *')`)
|
|
267
274
|
}
|
|
268
|
-
const timeoutMs = durationMs(artifact.timeout)
|
|
269
|
-
if (timeoutMs === null) {
|
|
270
|
-
problems.push(`${where} has the timeout '${artifact.timeout}', which is not a duration (allowed: '30s', '10m', '1h')`)
|
|
271
|
-
}
|
|
272
275
|
const node = ownChild(tree, name)
|
|
273
276
|
const handler = isSiteHandler(node) ? node.handler : node
|
|
274
277
|
const site = isSiteHandler(node) ? approvedDeclaration(where, `${JOB_SITE_PREFIX}${name}`, node.declared, 'job', input.artifact, problems) : null
|
|
275
|
-
const credential = input.credentials.get(
|
|
278
|
+
const credential = input.credentials.get(workloadSite('job', name))
|
|
276
279
|
const links = jobLinks(where, `${JOB_SITE_PREFIX}${name}`, site, input, problems)
|
|
277
280
|
const mailer = input.artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.mailer ?? null
|
|
278
281
|
if (typeof handler !== 'function') {
|
|
279
282
|
problems.push(`${where} has no handler; add it to handlers.jobs`)
|
|
280
283
|
} else if (site !== null && input.data === null) {
|
|
281
284
|
problems.push(`${where} reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
282
|
-
} else if (schedule !== null &&
|
|
285
|
+
} else if (schedule !== null && credential !== undefined) {
|
|
283
286
|
bound.set(name, {
|
|
284
287
|
name,
|
|
285
288
|
artifact,
|
|
286
289
|
schedule,
|
|
287
|
-
timeoutMs,
|
|
290
|
+
timeoutMs: artifact.timeoutMs,
|
|
288
291
|
handler: (args) => Reflect.apply(handler, undefined, [args]),
|
|
289
292
|
credential,
|
|
290
293
|
site,
|
|
@@ -312,12 +315,16 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
|
|
|
312
315
|
reporter: chosen ?? input.reporter,
|
|
313
316
|
mint: input.mint,
|
|
314
317
|
data: input.data,
|
|
315
|
-
|
|
318
|
+
fileKinds: input.fileKinds ?? null,
|
|
316
319
|
membership: input.membership,
|
|
317
320
|
capabilities: input.capabilities,
|
|
318
321
|
containers: input.containers,
|
|
319
322
|
revealHandlerErrors: input.revealHandlerErrors ?? null,
|
|
320
323
|
mail: input.mail,
|
|
324
|
+
tokens: input.tokens ?? null,
|
|
325
|
+
url: input.url ?? appUrl(input.artifact, null),
|
|
326
|
+
// A job's kicks run only after the job is bound, so `kick` below is defined by then.
|
|
327
|
+
kick: (name: string) => kick(name),
|
|
321
328
|
})
|
|
322
329
|
const jobs: ContractJobs = Object.freeze({
|
|
323
330
|
names,
|
|
@@ -534,7 +541,9 @@ async function runLeased(
|
|
|
534
541
|
await pendingTaken(job, context)
|
|
535
542
|
const attempts = job.artifact.retries + 1
|
|
536
543
|
progress.attempts = 1
|
|
537
|
-
|
|
544
|
+
// The jobs the succeeding attempt kicked; a failed attempt's kicks are dropped with its writes.
|
|
545
|
+
const kicked: string[] = []
|
|
546
|
+
let outcome = await attemptOnce(job, fence, progress.attempts, clock, context, kicked, stop)
|
|
538
547
|
while (outcome !== null && outcome.code !== 'stopped' && !outcome.final && progress.attempts < attempts) {
|
|
539
548
|
console.error(`fabrika: job '${job.name}' attempt ${progress.attempts} of ${attempts} failed: ${describeFailure(outcome)}`)
|
|
540
549
|
const delay = backoffMs(progress.attempts, clock.random())
|
|
@@ -546,10 +555,12 @@ async function runLeased(
|
|
|
546
555
|
fence = next
|
|
547
556
|
await clock.wait(delay, stop ?? new AbortController().signal)
|
|
548
557
|
progress.attempts++
|
|
549
|
-
outcome = await attemptOnce(job, fence, progress.attempts, clock, context, stop)
|
|
558
|
+
outcome = await attemptOnce(job, fence, progress.attempts, clock, context, kicked, stop)
|
|
550
559
|
}
|
|
551
560
|
if (outcome === null) {
|
|
552
561
|
if (await leases.finish(fence, 'succeeded')) {
|
|
562
|
+
// After the lease is released, so a job that kicks another never waits on its own lease; a kick never rejects.
|
|
563
|
+
await Promise.all(kicked.map((name) => context.kick(name)))
|
|
553
564
|
return { status: 'succeeded', attempts: progress.attempts }
|
|
554
565
|
}
|
|
555
566
|
return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
|
|
@@ -656,8 +667,10 @@ async function attemptOnce(
|
|
|
656
667
|
attempt: number,
|
|
657
668
|
clock: JobClock,
|
|
658
669
|
context: RunnerContext,
|
|
670
|
+
kicked: string[],
|
|
659
671
|
stop?: AbortSignal,
|
|
660
672
|
): Promise<JobFailure | null> {
|
|
673
|
+
kicked.length = 0
|
|
661
674
|
if (isStopped(stop)) {
|
|
662
675
|
return failure('stopped')
|
|
663
676
|
}
|
|
@@ -673,10 +686,17 @@ async function attemptOnce(
|
|
|
673
686
|
const cancel = new AbortController()
|
|
674
687
|
const timer = new AbortController()
|
|
675
688
|
const signal = stop === undefined ? cancel.signal : AbortSignal.any([cancel.signal, stop])
|
|
676
|
-
const
|
|
689
|
+
const kicks = kickRecorder(`job '${job.name}'`, job.site?.kicks ?? [])
|
|
690
|
+
const ctx = { ...jobContext(job, frame, attempt, signal, context, authority.perms), kick: kicks.kick }
|
|
677
691
|
try {
|
|
678
692
|
const ran = runInRequest(frame, async () => {
|
|
679
|
-
|
|
693
|
+
const site = job.site
|
|
694
|
+
if (site === null) {
|
|
695
|
+
await job.handler({ ctx })
|
|
696
|
+
} else {
|
|
697
|
+
await runAsSite(`job '${job.name}'`, site.egress, async () => await job.handler({ ctx }))
|
|
698
|
+
}
|
|
699
|
+
kicked.push(...kicks.kicked())
|
|
680
700
|
}).then(() => null, (error: unknown) => {
|
|
681
701
|
const failed = failureOf(error, job.mailer === null ? null : (context.mail[job.mailer]?.sender.capabilities ?? null))
|
|
682
702
|
if (failed.code === 'handler_failed') context.revealHandlerErrors?.(`job '${job.name}' attempt ${attempt}`, error)
|
|
@@ -730,7 +750,7 @@ function jobContext(
|
|
|
730
750
|
perms: readonly PermissionEntry[],
|
|
731
751
|
): JobContext<string, string> {
|
|
732
752
|
const principal: JobPrincipal = Object.freeze({ kind: 'job', id: `job:${job.name}`, job: job.name })
|
|
733
|
-
const
|
|
753
|
+
const checkAction = (action: string): void => {
|
|
734
754
|
if (frame.ended) {
|
|
735
755
|
throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it mints no capability`)
|
|
736
756
|
}
|
|
@@ -743,6 +763,9 @@ function jobContext(
|
|
|
743
763
|
if (!permits([...perms], action)) {
|
|
744
764
|
throw new CapabilityRefusedError('undeclared_action', `the token IAM signed for the key of job '${job.name}' does not carry this action`)
|
|
745
765
|
}
|
|
766
|
+
}
|
|
767
|
+
const check = (action: string, dimension: string): void => {
|
|
768
|
+
checkAction(action)
|
|
746
769
|
if (!Object.hasOwn(context.artifact.dimensions, dimension)) {
|
|
747
770
|
throw new CapabilityRefusedError('undeclared_dimension', `job '${job.name}' named a dimension the contract does not declare`)
|
|
748
771
|
}
|
|
@@ -765,14 +788,34 @@ function jobContext(
|
|
|
765
788
|
}
|
|
766
789
|
return context.mint.ref(frame, dimension, action, principal.id, id, chain)
|
|
767
790
|
}
|
|
791
|
+
const app = <const Act extends string>(action: Act): AppCapability<Act> => {
|
|
792
|
+
checkAction(action)
|
|
793
|
+
return context.mint.app(frame, action, principal.id)
|
|
794
|
+
}
|
|
768
795
|
return Object.freeze({
|
|
769
796
|
principal,
|
|
770
797
|
runId: frame.id,
|
|
771
798
|
attempt,
|
|
772
799
|
signal,
|
|
773
|
-
|
|
800
|
+
url: context.url,
|
|
801
|
+
...(job.site === null || context.data === null
|
|
802
|
+
? {}
|
|
803
|
+
: kitJobContext(
|
|
804
|
+
frame,
|
|
805
|
+
context.data,
|
|
806
|
+
context.fileKinds,
|
|
807
|
+
job.name,
|
|
808
|
+
job.site,
|
|
809
|
+
principal.id,
|
|
810
|
+
ref,
|
|
811
|
+
app,
|
|
812
|
+
periodAt(job.schedule, new Date()) ?? undefined,
|
|
813
|
+
)),
|
|
774
814
|
...(job.links === null || context.membership === null ? {} : { links: jobLinksOf(job, job.links, context.membership, frame, context) }),
|
|
775
815
|
...(job.mailer === null ? {} : { mail: jobMailOf(job, job.mailer, frame, signal, context) }),
|
|
816
|
+
...(job.site === null || job.site.signs.length === 0 || context.tokens === null
|
|
817
|
+
? {}
|
|
818
|
+
: { tokens: context.tokens.context(`job '${job.name}'`, job.site.signs) }),
|
|
776
819
|
within<const Act extends string, const Dim extends string>(action: Act, dimension: Dim): Within<Dim, Act> {
|
|
777
820
|
check(action, dimension)
|
|
778
821
|
return context.mint.within(frame, dimension, action, principal.id, 'all')
|
|
@@ -780,6 +823,7 @@ function jobContext(
|
|
|
780
823
|
ref<const Act extends string, const Dim extends string>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>> {
|
|
781
824
|
return ref(action, dimension, id)
|
|
782
825
|
},
|
|
826
|
+
app,
|
|
783
827
|
})
|
|
784
828
|
}
|
|
785
829
|
|
|
@@ -819,16 +863,14 @@ function jobMailOf(job: BoundJob, mailer: JobMailer, frame: RequestFrame, signal
|
|
|
819
863
|
if (transport === null) {
|
|
820
864
|
throw new ServerFault('facility_unbound', `job '${job.name}' declares mailer: '${mailer}', and boot resolved no transport for it`)
|
|
821
865
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
},
|
|
831
|
-
})
|
|
866
|
+
const assertLive = (): void => {
|
|
867
|
+
if (frame.ended) {
|
|
868
|
+
throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it sends no mail`)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// The attempt's holdings belong to its frame, so this work opens what `ctx.files.open` of the attempt opens.
|
|
872
|
+
const files = storedFilesHandle(context.fileKinds, new FileWork(frame, frame.id), { where: `job '${job.name}'`, check: assertLive, writes: false })
|
|
873
|
+
return jobMail({ transport, keyPrefix: context.mail.keyPrefix, signal, assertLive, open: files.open })
|
|
832
874
|
}
|
|
833
875
|
|
|
834
876
|
/**
|
|
@@ -838,49 +880,39 @@ function jobMailOf(job: BoundJob, mailer: JobMailer, frame: RequestFrame, signal
|
|
|
838
880
|
function kitJobContext(
|
|
839
881
|
frame: RequestFrame,
|
|
840
882
|
data: DataAccess,
|
|
841
|
-
|
|
883
|
+
kinds: StoredFileKinds | null,
|
|
842
884
|
name: string,
|
|
843
885
|
site: SiteDeclaration,
|
|
844
886
|
principal: string,
|
|
845
887
|
ref: (action: string, dimension: string, id: string, using: Session) => Promise<Ref<string, string>>,
|
|
888
|
+
app: (action: string) => AppCapability<string>,
|
|
889
|
+
tickMs: number | undefined,
|
|
846
890
|
): Readonly<Record<string, unknown>> {
|
|
847
|
-
|
|
891
|
+
// One attempt's files: what it reads, puts and writes, inside its transactions and outside them, belongs to it.
|
|
892
|
+
const work = new FileWork(frame, frame.id)
|
|
893
|
+
const binding = kinds?.binding(work)
|
|
894
|
+
const reads = jobHandles(frame, data, site, principal, 'job', async () => data.direct(), () => false, tickMs, binding)
|
|
848
895
|
const job = `job '${name}'`
|
|
896
|
+
const putAndOpen = (check: () => void) => storedFilesHandle(kinds, work, { where: job, check, writes: true })
|
|
849
897
|
return {
|
|
850
|
-
db: siteData(reads),
|
|
898
|
+
db: siteData(reads, (action) => reads.app(app(action))),
|
|
851
899
|
sql: site.sql === null ? undeclaredSql(job, 'job') : jobSqlOutsideTransaction(job),
|
|
852
|
-
files:
|
|
853
|
-
where: job,
|
|
854
|
-
frame,
|
|
855
|
-
check: boundTo(frame, () => false, 'ctx.files'),
|
|
856
|
-
session: async () => data.direct(),
|
|
857
|
-
readOnly: 'a job removes files inside ctx.transaction((tx) => …), as tx.files',
|
|
858
|
-
attaches: false,
|
|
859
|
-
}),
|
|
900
|
+
files: putAndOpen(boundTo(frame, () => false, 'ctx.files')),
|
|
860
901
|
transaction: async <R>(work: (tx: object) => Promise<R>): Promise<R> => {
|
|
861
902
|
// Outside its attempt there is no lease to fence the transaction with, so it never opens.
|
|
862
903
|
boundTo(frame, () => false)()
|
|
863
904
|
return data.transaction(async (session) => {
|
|
864
905
|
let open = true
|
|
865
|
-
const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open)
|
|
906
|
+
const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open, undefined, binding)
|
|
866
907
|
try {
|
|
867
908
|
const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
|
|
868
|
-
const removing = files === null
|
|
869
|
-
? noFileCollections(job)
|
|
870
|
-
: files.site({
|
|
871
|
-
where: job,
|
|
872
|
-
frame,
|
|
873
|
-
check: boundTo(frame, () => !open, 'tx.files'),
|
|
874
|
-
session: async () => session,
|
|
875
|
-
readOnly: null,
|
|
876
|
-
attaches: true,
|
|
877
|
-
})
|
|
878
909
|
// Located in this transaction, so an object it wrote is found before the commit.
|
|
879
910
|
const located = (action: string, dimension: string, id: string) => {
|
|
880
911
|
boundTo(frame, () => !open, 'tx.ref')()
|
|
881
912
|
return ref(action, dimension, id, session)
|
|
882
913
|
}
|
|
883
|
-
|
|
914
|
+
const files = putAndOpen(boundTo(frame, () => !open, 'tx.files'))
|
|
915
|
+
return await work(Object.freeze({ db: siteData(writes, (action) => writes.app(app(action))), sql, files, ref: located }))
|
|
884
916
|
} finally {
|
|
885
917
|
open = false
|
|
886
918
|
}
|
package/src/jobs/scrub.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What an error report must not carry (ADR-0059): the values of the application's secrets, and anything shaped like a
|
|
2
2
|
// credential — a JWT, an `Authorization` scheme or header, a URL's userinfo, a sensitive query parameter.
|
|
3
3
|
|
|
4
|
-
import { FABRIKA_CONTRACT_DIGEST, FABRIKA_IAM_APP_ID, FABRIKA_IAM_ISSUER } from '@fabrika/auth-core'
|
|
4
|
+
import { FABRIKA_CONTRACT_DIGEST, FABRIKA_IAM_APP_ID, FABRIKA_IAM_ISSUER, PLATFORM_RECEIVERS, platformTransportVariables } from '@fabrika/auth-core'
|
|
5
5
|
import type { CapturedEnvironment } from '../implement/environment'
|
|
6
6
|
|
|
7
7
|
export const REDACTED = '[redacted]'
|
|
@@ -16,9 +16,12 @@ const PUBLIC_FRAMEWORK_VARIABLES: ReadonlySet<string> = new Set([
|
|
|
16
16
|
'FABRIKA_ENVIRONMENT',
|
|
17
17
|
'FABRIKA_IAM_RPC_URL',
|
|
18
18
|
'FABRIKA_APP_DATABASE_CONNECTIONS',
|
|
19
|
+
// `platform` is eight characters, so a secret scrubber would redact the word wherever a message names it.
|
|
20
|
+
'FABRIKA_EMAIL_PROVIDER',
|
|
19
21
|
FABRIKA_CONTRACT_DIGEST,
|
|
20
22
|
FABRIKA_IAM_APP_ID,
|
|
21
23
|
FABRIKA_IAM_ISSUER,
|
|
24
|
+
...PLATFORM_RECEIVERS.map((receiver) => platformTransportVariables(receiver).url),
|
|
22
25
|
])
|
|
23
26
|
|
|
24
27
|
/**
|
package/src/jobs/types.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import type { EmailDeliveryResult, EmailSenderCapabilities } from '@fabrika/email'
|
|
5
5
|
import type { CronJob, DimensionOf } from '../contract/define'
|
|
6
|
-
import type {
|
|
6
|
+
import type { FileRef } from '../contract/file'
|
|
7
|
+
import type { AppCapability, Ref, Within } from '../implement/capability'
|
|
8
|
+
import type { AppUrl } from '../implement/urls'
|
|
7
9
|
|
|
8
10
|
/** Who a job's writes are stamped with (`createdBy`): the job itself, never a person. */
|
|
9
11
|
export interface JobPrincipal {
|
|
@@ -31,6 +33,10 @@ export interface JobContext<A extends string, D extends string> {
|
|
|
31
33
|
within<const Act extends A, const Dim extends D>(action: Act, dimension: Dim): Within<Dim, Act>
|
|
32
34
|
/** One object of `dimension` for a declared action, located now: what a write takes. A missing object is NotFound. */
|
|
33
35
|
ref<const Act extends A, const Dim extends D>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
|
|
36
|
+
/** A declared action, application-wide: what `ctx.db.app('<action>')` reads and writes the application tables with. */
|
|
37
|
+
app<const Act extends A>(action: Act): AppCapability<Act>
|
|
38
|
+
/** An absolute link to a procedure of this application: `ctx.url('api.mail.unsubscribe', { query: { t } })`. */
|
|
39
|
+
readonly url: AppUrl
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/** One message a job sends. The framework prefixes `idempotencyKey` before the transport sees it. */
|
|
@@ -43,6 +49,17 @@ export interface AppMailMessage {
|
|
|
43
49
|
readonly idempotencyKey: string
|
|
44
50
|
readonly replyTo?: string
|
|
45
51
|
readonly fromName?: string
|
|
52
|
+
/**
|
|
53
|
+
* A one-click unsubscribe (RFC 8058): the transport writes `List-Unsubscribe` and `List-Unsubscribe-Post`. `url` is
|
|
54
|
+
* an `https:` link, usually `ctx.url` of a public procedure that verifies a signed token.
|
|
55
|
+
*/
|
|
56
|
+
readonly unsubscribe?: { readonly url: string }
|
|
57
|
+
/**
|
|
58
|
+
* Files the message carries: references this attempt holds and may read, as `ctx.files.open` takes them. Only a
|
|
59
|
+
* `live` file is sent; the bytes of all of them together stay within the transport's `maxAttachmentBytes`, and a
|
|
60
|
+
* transport without it refuses the message. The idempotency key covers the attachments.
|
|
61
|
+
*/
|
|
62
|
+
readonly attachments?: readonly FileRef[]
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
/** `ctx.mail` of a job that declares `mailer`. */
|
|
@@ -71,6 +88,6 @@ type JobsOf<C> = C extends { readonly surface: { readonly jobs: infer J } } ? J
|
|
|
71
88
|
/** `{ jobs: { <name>: handler } }` for a contract that declares jobs, and nothing for one that declares none. */
|
|
72
89
|
export type JobHandlersOf<C> = [JobsOf<C>] extends [never] ? unknown : {
|
|
73
90
|
readonly jobs: {
|
|
74
|
-
readonly [K in keyof JobsOf<C>]: JobsOf<C>[K] extends CronJob<infer A
|
|
91
|
+
readonly [K in keyof JobsOf<C>]: JobsOf<C>[K] extends CronJob<infer A> ? JobHandler<A, DimensionOf<C>> : never
|
|
75
92
|
}
|
|
76
93
|
}
|
package/src/react/hooks.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
6
6
|
import { z } from 'zod/v4'
|
|
7
|
-
import type { AttachmentProcedure, DownloadInput } from '../client/contract'
|
|
7
|
+
import type { AttachmentProcedure, DownloadedFile, DownloadInput, FileProcedure } from '../client/contract'
|
|
8
8
|
import type { Schema } from '../contract/define'
|
|
9
9
|
import { type FabrikaError, toFabrikaError } from './errors'
|
|
10
10
|
import { parentOf, prefixOf, useFabrika } from './provider'
|
|
@@ -170,7 +170,7 @@ export type DownloadArgument<I> = DownloadInput<I>[0]
|
|
|
170
170
|
* that hands the provider a client over the kit downloads over the kit. `mutate(input)` saves the file in a browser and
|
|
171
171
|
* answers it as `data`; a refusal is the state's `error`, as for `useMutation`. It refetches nothing.
|
|
172
172
|
*/
|
|
173
|
-
export function useDownload<I>(procedure: AttachmentProcedure<I>): MutationState<DownloadArgument<I>,
|
|
173
|
+
export function useDownload<I>(procedure: AttachmentProcedure<I> | FileProcedure<I>): MutationState<DownloadArgument<I>, DownloadedFile> {
|
|
174
174
|
const fabrika = useFabrika('useDownload')
|
|
175
175
|
const run = useMemo(() => fabrika.attachment(procedure, 'useDownload'), [fabrika, procedure])
|
|
176
176
|
return useRun(run, NOTHING_AFTER)
|