@fabrika/app 0.4.8 → 0.5.1

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.
@@ -26,6 +26,7 @@ import {
26
26
  } from '@fabrika/auth-core'
27
27
  import type { Database } from '@fabrika/db'
28
28
  import { refuseInProduction } from '@fabrika/db/production'
29
+ import { MemoryEmailSender } from '@fabrika/email/memory'
29
30
  import type { FileObjectStore } from '@fabrika/platform'
30
31
  import { type IamGateway, REQUEST_ID_HEADER, stripFabrikaCookies } from '@fabrika/proxy-core'
31
32
  import { exportJWK, generateKeyPair, SignJWT } from 'jose'
@@ -44,6 +45,7 @@ import { type ConnectFetch, connectVariables } from '../implement/connect'
44
45
  import type { SpaAssets } from '../implement/dispatch'
45
46
  import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
46
47
  import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
48
+ import type { MailKeyScope } from '../jobs/mail'
47
49
  import type { HandlerErrorSink } from '../jobs/runner'
48
50
  import type { JobRunOptions, JobRunResult } from '../jobs/runner'
49
51
  import {
@@ -106,6 +108,13 @@ export interface TestKitOptions {
106
108
  readonly challengeFetch?: ChallengeFetch
107
109
  /** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
108
110
  readonly revealHandlerErrors?: HandlerErrorSink
111
+ /**
112
+ * The stand-in for every job's mail transport; a new `MemoryEmailSender` by default. Pass one with other capabilities
113
+ * to model another provider: `new MemoryEmailSender({ capabilities: { idempotent: false, confirmation: 'queued' } })`.
114
+ * `'none'` models an installation without a transport: `ctx.mail.available` is false, and every send fails
115
+ * `mail_not_configured`.
116
+ */
117
+ readonly mail?: MemoryEmailSender | 'none'
109
118
  }
110
119
 
111
120
  export interface TestConnection {
@@ -147,11 +156,21 @@ export interface TestKit<C extends AnyContract> {
147
156
  acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
148
157
  /**
149
158
  * Runs the cron job `name` once, as `fabrika job run <name>` does: under its lease, with the actions IAM signs for
150
- * its key, its writes fenced. It first waits for the runs a form's `then` kicked, so it never races them.
159
+ * its key, its writes fenced. It first waits for the runs a form's or a mutation's `then` kicked, so it never races them.
151
160
  * `FILE_SCAN_JOB` runs the scan of `scan: true` collections with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
152
161
  * removed and orphaned files. Needs the application's data layer on Postgres.
153
162
  */
154
163
  runJob(name: JobName<C> | typeof FILE_SCAN_JOB | typeof FILE_SWEEP_JOB, options?: JobRunOptions): Promise<JobRunResult>
164
+ /** Waits for the work requests left running: the runs a form's or a mutation's `then` kicked, a failure's report. */
165
+ drain(): Promise<void>
166
+ /**
167
+ * The stand-in for every job's transport: what reached it (`accepted`, `handoffs`) and its faults (`refuse`,
168
+ * `unavailable`, `unreachable`, `loseAnswers`, `during`, `heal`). Each message carries the transport key,
169
+ * `transportMailKey(kit.mailKeyScope, key)`. Under `mail: 'none'` nothing reaches it.
170
+ */
171
+ readonly mail: MemoryEmailSender
172
+ /** Whose keys this kit's prefix separates: `mailKeyPrefix(kit.mailKeyScope)` is what a job's key gets in front. */
173
+ readonly mailKeyScope: MailKeyScope
155
174
  }
156
175
 
157
176
  /** The names of the contract's cron jobs. */
@@ -308,6 +327,8 @@ export function bootTestKit<C extends AnyContract>(
308
327
  // issues the proxy's delegation grants.
309
328
  const standIn = options.membership === undefined ? kitMembership(artifact, directoryCallers) : null
310
329
  const membership = options.membership ?? standIn?.rpc
330
+ const mail = options.mail === undefined || options.mail === 'none' ? new MemoryEmailSender() : options.mail
331
+ const mailKeyScope: MailKeyScope = Object.freeze({ issuer: TEST_ISSUER, appId })
311
332
  const application: ContractApp = boot(app, {
312
333
  environment: {
313
334
  // The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
@@ -331,6 +352,7 @@ export function bootTestKit<C extends AnyContract>(
331
352
  membership,
332
353
  connectBindings,
333
354
  workloadTokens,
355
+ mailStandIn: options.mail === 'none' ? 'none' : mail,
334
356
  })
335
357
 
336
358
  let issued = 0
@@ -495,7 +517,7 @@ export function bootTestKit<C extends AnyContract>(
495
517
 
496
518
  const proxy = endpointProxy({ artifact, appId, host, iam, issuer: TEST_ISSUER, delegations: membership ?? null })
497
519
 
498
- // What requests leave running: a job a form's `then` kicked, or a failure's report.
520
+ // What requests leave running: a job a form's or a mutation's `then` kicked, or a failure's report.
499
521
  const background = new Set<Promise<void>>()
500
522
  const exec: RequestExecutionContext = {
501
523
  waitUntil(promise) {
@@ -550,10 +572,14 @@ export function bootTestKit<C extends AnyContract>(
550
572
  }
551
573
  }
552
574
 
553
- const runJob = async (name: string, runOptions?: JobRunOptions): Promise<JobRunResult> => {
575
+ const drain = async (): Promise<void> => {
554
576
  while (background.size > 0) {
555
577
  await Promise.all(background)
556
578
  }
579
+ }
580
+
581
+ const runJob = async (name: string, runOptions?: JobRunOptions): Promise<JobRunResult> => {
582
+ await drain()
557
583
  return application.jobs.run(name, runOptions)
558
584
  }
559
585
 
@@ -578,7 +604,21 @@ export function bootTestKit<C extends AnyContract>(
578
604
  return invitee
579
605
  }
580
606
  return {
581
- kit: Object.freeze({ origin, appId, artifact, testCaller, scope, fetch: kitFetch, browser, iam, runJob, acceptInvitation }),
607
+ kit: Object.freeze({
608
+ origin,
609
+ appId,
610
+ artifact,
611
+ testCaller,
612
+ scope,
613
+ fetch: kitFetch,
614
+ browser,
615
+ iam,
616
+ runJob,
617
+ drain,
618
+ acceptInvitation,
619
+ mail,
620
+ mailKeyScope,
621
+ }),
582
622
  standIn,
583
623
  callerOf: (value) => [...memberships.keys()].find((caller) => caller === value),
584
624
  }
@@ -144,6 +144,8 @@ export interface SentInvitation {
144
144
  readonly role: string
145
145
  readonly scope: Scope
146
146
  readonly expiresAt: number
147
+ /** When it was last sent, in seconds: when it was opened, or its latest resend. */
148
+ readonly sentAt: number
147
149
  readonly status: 'open' | 'accepted' | 'cancelled' | 'expired'
148
150
  }
149
151
 
@@ -165,6 +167,7 @@ interface KitInvitation {
165
167
  readonly role: string
166
168
  readonly email: string
167
169
  readonly expiresAt: number
170
+ readonly sentAt: number
168
171
  readonly closed: 'cancelled' | 'accepted' | null
169
172
  /** The principal that opened it, whose authority IAM checks again at acceptance; `null` for a seeded one. */
170
173
  readonly inviter: string | null
@@ -479,11 +482,17 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
479
482
  )
480
483
 
481
484
  const openNew = (scope: Scope, role: string, email: string, inviter: string | null): KitInvitation => {
482
- const invitation = { id: `invitation-${randomId()}`, scope, role, email, expiresAt: now() + INVITATION_TTL_SECONDS, closed: null, inviter }
485
+ const sentAt = now()
486
+ const invitation = { id: `invitation-${randomId()}`, scope, role, email, expiresAt: sentAt + INVITATION_TTL_SECONDS, sentAt, closed: null, inviter }
483
487
  invitations.set(invitation.id, invitation)
484
488
  return invitation
485
489
  }
486
490
 
491
+ const resent = (invitation: KitInvitation): KitInvitation => {
492
+ const sentAt = now()
493
+ return { ...invitation, expiresAt: sentAt + INVITATION_TTL_SECONDS, sentAt }
494
+ }
495
+
487
496
  /** Spends the request's grant before its binding is compared, as IAM does, so each attempt is single use. */
488
497
  const spend = (caller: TestCaller, input: { delegation: string; requestId: string }, purpose: EndpointDelegation, dimension: string) => {
489
498
  const grant = grants.get(input.delegation)
@@ -595,7 +604,7 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
595
604
  const invitation = open === undefined
596
605
  ? openNew(input.scope, input.role, email, caller.id)
597
606
  : input.invitation?.resend === true
598
- ? { ...open, expiresAt: now() + INVITATION_TTL_SECONDS }
607
+ ? resent(open)
599
608
  : open
600
609
  invitations.set(invitation.id, invitation)
601
610
  asked.push({
@@ -751,12 +760,13 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
751
760
  return link === undefined || link.revoked ? undefined : { id: link.id, mount: link.mount, chain: link.chain }
752
761
  }
753
762
  const invitationsSent = (): readonly SentInvitation[] =>
754
- [...invitations.values()].map(({ id, email, role, scope, expiresAt, closed }) => ({
763
+ [...invitations.values()].map(({ id, email, role, scope, expiresAt, sentAt, closed }) => ({
755
764
  id,
756
765
  email,
757
766
  role,
758
767
  scope,
759
768
  expiresAt,
769
+ sentAt,
760
770
  status: closed ?? (expiresAt > now() ? 'open' : 'expired'),
761
771
  }))
762
772
  const accept = (invitationId: string, recipient?: TestCaller): AcceptedInvitation => {