@fabrika/app 0.5.4 → 0.6.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 +11 -10
- package/skill/references/app-setup.md +8 -8
- package/skill/references/files.md +23 -0
- package/skill/references/public-intake.md +98 -16
- package/skill/references/spa.md +5 -3
- package/src/contract/artifact-schema.ts +2 -1
- package/src/contract/build.ts +109 -16
- package/src/contract/context.ts +14 -5
- package/src/contract/declaration.ts +8 -3
- package/src/contract/defaults.ts +27 -0
- package/src/contract/define.ts +15 -1
- package/src/contract/kit.ts +8 -2
- package/src/files/collections.ts +195 -16
- package/src/files/handle.ts +21 -2
- package/src/implement/anonymous.ts +15 -2
- package/src/implement/boot.ts +31 -6
- package/src/implement/dispatch.ts +194 -5
- package/src/implement/implement.ts +6 -2
- package/src/implement/invocation.ts +1 -0
- package/src/implement/multipart.ts +332 -0
- package/src/jobs/runner.ts +38 -15
- package/src/react/testing.ts +3 -0
- package/src/testing/endpoint-access.ts +12 -6
- package/src/testing/kit.ts +16 -3
package/src/jobs/runner.ts
CHANGED
|
@@ -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
|
-
|
|
763
|
-
|
|
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({
|
|
860
|
-
|
|
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
|
}
|
package/src/react/testing.ts
CHANGED
|
@@ -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 =
|
|
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 }
|
package/src/testing/kit.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
600
|
+
return kitFetch(withHeaders(request, headers))
|
|
588
601
|
}
|
|
589
602
|
}
|
|
590
603
|
|