@fabrika/app 0.5.3 → 0.5.5

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.
@@ -8,7 +8,7 @@
8
8
  import type { IamMembership } from '@fabrika/auth'
9
9
  import { type ContractArtifact, JOB_SITE_PREFIX, type JobArtifact, type JobMailer, type PermissionEntry, permits } from '@fabrika/auth-core'
10
10
  import type { CapabilityAuthority } from '@fabrika/db/capability'
11
- import { type DataAccess, type FrameworkTables, type JobLeases, type LeaseFence, LeaseLostError } from '@fabrika/db/framework'
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
14
  import type { FileCollections } from '../files/collections'
@@ -747,29 +747,38 @@ function jobContext(
747
747
  throw new CapabilityRefusedError('undeclared_dimension', `job '${job.name}' named a dimension the contract does not declare`)
748
748
  }
749
749
  }
750
+ /** A located `Ref`, read in `using` (a job's transaction, which sees what it wrote) or a statement of its own. */
751
+ const ref = async <const Act extends string, const Dim extends string>(
752
+ action: Act,
753
+ dimension: Dim,
754
+ id: string,
755
+ using?: Session,
756
+ ): Promise<Ref<Dim, Act>> => {
757
+ check(action, dimension)
758
+ if (typeof id !== 'string' || id === '') {
759
+ throw new CapabilityRefusedError('missing_id', `job '${job.name}' named an object without an id`)
760
+ }
761
+ const database = context.database
762
+ const chain = database === null || !context.ownedDimensions.has(dimension) ? new Map<string, string>() : await database.locate(dimension, id, using)
763
+ if (chain === null) {
764
+ throw new NotFoundError('Not found')
765
+ }
766
+ return context.mint.ref(frame, dimension, action, principal.id, id, chain)
767
+ }
750
768
  return Object.freeze({
751
769
  principal,
752
770
  runId: frame.id,
753
771
  attempt,
754
772
  signal,
755
- ...(job.site === null || context.data === null ? {} : kitJobContext(frame, context.data, context.files, job.name, job.site, principal.id)),
773
+ ...(job.site === null || context.data === null ? {} : kitJobContext(frame, context.data, context.files, job.name, job.site, principal.id, ref)),
756
774
  ...(job.links === null || context.membership === null ? {} : { links: jobLinksOf(job, job.links, context.membership, frame, context) }),
757
775
  ...(job.mailer === null ? {} : { mail: jobMailOf(job, job.mailer, frame, signal, context) }),
758
776
  within<const Act extends string, const Dim extends string>(action: Act, dimension: Dim): Within<Dim, Act> {
759
777
  check(action, dimension)
760
778
  return context.mint.within(frame, dimension, action, principal.id, 'all')
761
779
  },
762
- async ref<const Act extends string, const Dim extends string>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>> {
763
- check(action, dimension)
764
- if (typeof id !== 'string' || id === '') {
765
- throw new CapabilityRefusedError('missing_id', `job '${job.name}' named an object without an id`)
766
- }
767
- const database = context.database
768
- const chain = database === null || !context.ownedDimensions.has(dimension) ? new Map<string, string>() : await database.locate(dimension, id)
769
- if (chain === null) {
770
- throw new NotFoundError('Not found')
771
- }
772
- return context.mint.ref(frame, dimension, action, principal.id, id, chain)
780
+ ref<const Act extends string, const Dim extends string>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>> {
781
+ return ref(action, dimension, id)
773
782
  },
774
783
  })
775
784
  }
@@ -833,6 +842,7 @@ function kitJobContext(
833
842
  name: string,
834
843
  site: SiteDeclaration,
835
844
  principal: string,
845
+ ref: (action: string, dimension: string, id: string, using: Session) => Promise<Ref<string, string>>,
836
846
  ): Readonly<Record<string, unknown>> {
837
847
  const reads = jobHandles(frame, data, site, principal, 'job', async () => data.direct(), () => false)
838
848
  const job = `job '${name}'`
@@ -845,6 +855,7 @@ function kitJobContext(
845
855
  check: boundTo(frame, () => false, 'ctx.files'),
846
856
  session: async () => data.direct(),
847
857
  readOnly: 'a job removes files inside ctx.transaction((tx) => …), as tx.files',
858
+ attaches: false,
848
859
  }),
849
860
  transaction: async <R>(work: (tx: object) => Promise<R>): Promise<R> => {
850
861
  // Outside its attempt there is no lease to fence the transaction with, so it never opens.
@@ -856,8 +867,20 @@ function kitJobContext(
856
867
  const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
857
868
  const removing = files === null
858
869
  ? noFileCollections(job)
859
- : files.site({ where: job, frame, check: boundTo(frame, () => !open, 'tx.files'), session: async () => session, readOnly: null })
860
- return await work(Object.freeze({ db: siteData(writes), sql, files: removing }))
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
+ // Located in this transaction, so an object it wrote is found before the commit.
879
+ const located = (action: string, dimension: string, id: string) => {
880
+ boundTo(frame, () => !open, 'tx.ref')()
881
+ return ref(action, dimension, id, session)
882
+ }
883
+ return await work(Object.freeze({ db: siteData(writes), sql, files: removing, ref: located }))
861
884
  } finally {
862
885
  open = false
863
886
  }
@@ -23,6 +23,9 @@ const BUN_GLOBALS = [
23
23
  'TextEncoder',
24
24
  'TextDecoder',
25
25
  'ReadableStream',
26
+ // happy-dom replaces these with its own, which Bun's streams refuse to pipe through: a form's upload digests through one.
27
+ 'WritableStream',
28
+ 'TransformStream',
26
29
  ] as const
27
30
 
28
31
  export interface InstalledDom {
@@ -774,17 +774,23 @@ export function endpointAccessKit<DB, P>(app: ContractImplementation<AnyContract
774
774
  return { status }
775
775
  },
776
776
  async file(name, operation, caller) {
777
+ if (operation === 'upload') {
778
+ // An upload needs owners only: a seeded file would hold the owner's place, and a collection of
779
+ // `maxPerOwner: 1` could never take the probe's.
780
+ const collection = collectionOf(name)
781
+ const worlds = await postgres.seedWorlds(tables, collection.owner)
782
+ const grants = [{ action: caller === 'reader' ? collection.read : collection.write, scope: { type: worlds.dimension, value: worlds.own.scope } }]
783
+ const file = sampleFile(name, collection.types)
784
+ const path = fileRow(name, 'upload').path.replace(':owner', encodeURIComponent(worlds[caller === 'other' ? 'other' : 'own'].key))
785
+ const fetch = kit.browser(kit.testCaller(grants))
786
+ return { status: (await fetch(path, { method: 'POST', headers: { 'Content-Type': file.type }, body: file.bytes })).status }
787
+ }
777
788
  const { collection, worlds, own, other } = await seedFiles(name)
778
- const operationAction = { upload: collection.write, download: collection.read, remove: collection.delete }[operation]
789
+ const operationAction = operation === 'download' ? collection.read : collection.delete
779
790
  const grants = [{ action: caller === 'reader' ? collection.read : operationAction, scope: { type: worlds.dimension, value: worlds.own.scope } }]
780
791
  const world = caller === 'other' ? 'other' : 'own'
781
792
  const fetch = kit.browser(kit.testCaller(grants))
782
793
  const row = fileRow(name, operation)
783
- if (operation === 'upload') {
784
- const file = sampleFile(name, collection.types)
785
- const path = row.path.replace(':owner', encodeURIComponent(worlds[world].key))
786
- return { status: (await fetch(path, { method: 'POST', headers: { 'Content-Type': file.type }, body: file.bytes })).status }
787
- }
788
794
  const fileId = world === 'own' ? own : other
789
795
  if (operation === 'download') {
790
796
  return { status: (await fetch(row.path.replace(':fileId', encodeURIComponent(fileId)))).status }
@@ -191,7 +191,20 @@ function builtAssets(root: string): SpaAssets {
191
191
  }
192
192
 
193
193
  function toRequest(origin: string, input: RequestInfo | URL, init: RequestInit | undefined): Request {
194
- return input instanceof Request ? new Request(input, init) : new Request(new URL(input, origin), init)
194
+ if (!(input instanceof Request)) {
195
+ return new Request(new URL(input, origin), init)
196
+ }
197
+ // Never `new Request(request, …)`: Bun would buffer the body the application must meet as a stream.
198
+ const base = { method: input.method, headers: input.headers, body: input.body, signal: input.signal, redirect: input.redirect }
199
+ return init === undefined ? input : new Request(input.url, { ...base, ...init })
200
+ }
201
+
202
+ /**
203
+ * `request` with `headers`, its body passed on as the stream it is: `new Request(request, { headers })` would have Bun
204
+ * buffer the body, and the application must meet a body as it streams, as it does behind the proxy.
205
+ */
206
+ function withHeaders(request: Request, headers: Headers): Request {
207
+ return new Request(request.url, { method: request.method, headers, body: request.body, signal: request.signal, redirect: request.redirect })
195
208
  }
196
209
 
197
210
  /** Every header only the proxy may assert, deleted before the decision as Caddy and the proxy Worker delete them. */
@@ -559,7 +572,7 @@ export function bootTestKit<C extends AnyContract>(
559
572
  for (const [header, value] of outcome.asserted) {
560
573
  headers.set(header, value)
561
574
  }
562
- const answer = await application.fetch(new Request(request, { headers }), exec)
575
+ const answer = await application.fetch(withHeaders(request, headers), exec)
563
576
  const decorated = new Headers(answer.headers)
564
577
  outcome.decorate(decorated)
565
578
  return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: decorated })
@@ -584,7 +597,7 @@ export function bootTestKit<C extends AnyContract>(
584
597
  const cookies = headers.get('Cookie')
585
598
  headers.set('Cookie', cookies === null || cookies === '' ? caller.cookie : `${cookies}; ${caller.cookie}`)
586
599
  }
587
- return kitFetch(new Request(request, { headers }))
600
+ return kitFetch(withHeaders(request, headers))
588
601
  }
589
602
  }
590
603