@fabrika/app 0.3.3 → 0.4.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.
- package/README.md +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +331 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +824 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +760 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `FabrikaProvider`: the page's clients, the cache of their answers, and the caller document of the SPA mount. The
|
|
2
|
+
// hooks take a procedure of a client as the typed name of a call; the provider runs it on the client it holds for
|
|
3
|
+
// that mount, so a test hands in a client over the test kit and the page's own module-level client stays as it is.
|
|
4
|
+
|
|
5
|
+
import { createContext, type ReactNode, useContext, useState } from 'react'
|
|
6
|
+
import { z } from 'zod/v4'
|
|
7
|
+
import { type PageCaller, parsePageCaller } from '../client/caller'
|
|
8
|
+
import { attachmentAt, type ClientNode, clientNodeOf, downloadFile } from '../client/contract'
|
|
9
|
+
import { RpcError, type RpcFetch } from '../client/transport'
|
|
10
|
+
import { joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
|
|
11
|
+
import { type FabrikaError, toFabrikaError } from './errors'
|
|
12
|
+
import { QueryStore, type StoreTarget } from './store'
|
|
13
|
+
|
|
14
|
+
export type CallerState =
|
|
15
|
+
| { readonly status: 'loading' }
|
|
16
|
+
| { readonly status: 'ready'; readonly caller: PageCaller }
|
|
17
|
+
| { readonly status: 'error'; readonly error: FabrikaError }
|
|
18
|
+
|
|
19
|
+
/** The caller document, read once per provider when a view first asks for it. */
|
|
20
|
+
export class CallerSource {
|
|
21
|
+
state: CallerState = { status: 'loading' }
|
|
22
|
+
#started = false
|
|
23
|
+
readonly #listeners = new Set<() => void>()
|
|
24
|
+
|
|
25
|
+
constructor(private readonly url: string, private readonly fetch: RpcFetch) {}
|
|
26
|
+
|
|
27
|
+
subscribe(listener: () => void): () => void {
|
|
28
|
+
this.#listeners.add(listener)
|
|
29
|
+
if (!this.#started) {
|
|
30
|
+
this.#started = true
|
|
31
|
+
void this.#load()
|
|
32
|
+
}
|
|
33
|
+
return () => this.#listeners.delete(listener)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async #load(): Promise<void> {
|
|
37
|
+
this.state = await this.#read()
|
|
38
|
+
for (const listener of [...this.#listeners]) listener()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async #read(): Promise<CallerState> {
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.fetch(this.url, { credentials: 'same-origin' })
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'error',
|
|
47
|
+
error: toFabrikaError(new RpcError({ type: 'caller', message: `HTTP ${response.status}`, httpStatus: response.status })),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { status: 'ready', caller: parsePageCaller(await response.json()) }
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { status: 'error', error: toFabrikaError(error) }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Fabrika {
|
|
58
|
+
readonly store: QueryStore
|
|
59
|
+
readonly caller: CallerSource
|
|
60
|
+
/** The call of `procedure` on the provider's client of its mount. */
|
|
61
|
+
target(procedure: unknown, hook: string): ResolvedProcedure
|
|
62
|
+
/** The download of attachment `procedure` on the provider's client of its mount. */
|
|
63
|
+
attachment(procedure: unknown, hook: string): (input: unknown) => Promise<File>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedProcedure extends StoreTarget {
|
|
67
|
+
readonly node: ClientNode
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const FabrikaContext = createContext<Fabrika | null>(null)
|
|
71
|
+
|
|
72
|
+
/** The `{ path, code, message }` issues of a refused input, as the application answers them. */
|
|
73
|
+
function issuesOf(error: z.core.$ZodError): readonly { readonly path: string; readonly code: string; readonly message: string }[] {
|
|
74
|
+
return error.issues.map((issue) => ({ path: issue.path.map(String).join('.'), code: issue.code, message: issue.message }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Checks the input with the procedure's own schema first: a refused input is the application's 400, unsent. */
|
|
78
|
+
function validated(node: ClientNode, input: unknown): unknown {
|
|
79
|
+
const schema = node.procedure?.input ?? null
|
|
80
|
+
if (schema === null) return input
|
|
81
|
+
const parsed = z.safeParse(schema, input ?? {})
|
|
82
|
+
if (parsed.success) return input
|
|
83
|
+
const issues = issuesOf(parsed.error)
|
|
84
|
+
const first = issues[0]
|
|
85
|
+
throw new RpcError({
|
|
86
|
+
type: 'validation',
|
|
87
|
+
message: first === undefined ? 'Invalid input' : `${first.path || 'input'}: ${first.message}`,
|
|
88
|
+
issues,
|
|
89
|
+
httpStatus: 400,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function idOf(node: ClientNode): string {
|
|
94
|
+
return node.path === '' ? node.mount : `${node.mount}.${node.path}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function procedureAt(client: object, path: string): unknown {
|
|
98
|
+
let value: unknown = client
|
|
99
|
+
for (const segment of path.split('.')) {
|
|
100
|
+
value = typeof value === 'object' && value !== null ? Reflect.get(value, segment) : undefined
|
|
101
|
+
}
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fabrika(clients: readonly object[], spa: string, fetch: RpcFetch): Fabrika {
|
|
106
|
+
const byMount = new Map<string, object>()
|
|
107
|
+
for (const client of clients) {
|
|
108
|
+
const node = clientNodeOf(client)
|
|
109
|
+
if (node === null || node.path !== '') throw new Error('FabrikaProvider takes the clients createClient made')
|
|
110
|
+
if (byMount.has(node.mount)) throw new Error(`FabrikaProvider holds two clients of mount '${node.mount}'`)
|
|
111
|
+
byMount.set(node.mount, client)
|
|
112
|
+
}
|
|
113
|
+
const clientOf = (mount: string, hook: string): object => {
|
|
114
|
+
const client = byMount.get(mount)
|
|
115
|
+
if (client === undefined) throw new Error(`${hook}: the FabrikaProvider holds no client of mount '${mount}'`)
|
|
116
|
+
return client
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
store: new QueryStore(toFabrikaError),
|
|
120
|
+
caller: new CallerSource(joinPath(spa, PAGE_CALLER_FILE), fetch),
|
|
121
|
+
attachment(procedure, hook) {
|
|
122
|
+
const at = attachmentAt(procedure)
|
|
123
|
+
if (at === null) throw new Error(`${hook} takes an attachment procedure of a client createClient made`)
|
|
124
|
+
const own = procedureAt(clientOf(at.mount, hook), at.path)
|
|
125
|
+
if (attachmentAt(own) === null || typeof own !== 'object' || own === null) {
|
|
126
|
+
throw new Error(`${hook}: '${at.mount}.${at.path}' is not an attachment procedure of the provider's client`)
|
|
127
|
+
}
|
|
128
|
+
return (input) => downloadFile(own, input)
|
|
129
|
+
},
|
|
130
|
+
target(procedure, hook) {
|
|
131
|
+
const node = clientNodeOf(procedure)
|
|
132
|
+
if (node?.procedure == null) throw new Error(`${hook} takes a procedure of a client createClient made`)
|
|
133
|
+
const call = procedureAt(clientOf(node.mount, hook), node.path)
|
|
134
|
+
if (typeof call !== 'function') throw new Error(`${hook}: '${idOf(node)}' is not a procedure of the provider's client`)
|
|
135
|
+
return {
|
|
136
|
+
id: idOf(node),
|
|
137
|
+
node,
|
|
138
|
+
run: async (input) => {
|
|
139
|
+
const answer: unknown = await Reflect.apply(call, undefined, [validated(node, input)])
|
|
140
|
+
return answer
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface FabrikaProviderProps {
|
|
148
|
+
/** The clients of the mounts the page calls, as `createClient` made them; read at the first render. */
|
|
149
|
+
readonly clients: readonly object[]
|
|
150
|
+
/** The prefix of the SPA mount the page is served from: the caller document is `<spa>/__fabrika-me.json`. */
|
|
151
|
+
readonly spa?: string
|
|
152
|
+
/** What the caller document is fetched with; the global `fetch` by default (a test passes the kit's). */
|
|
153
|
+
readonly fetch?: RpcFetch
|
|
154
|
+
readonly children?: ReactNode
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function FabrikaProvider({ clients, spa = '/', fetch: fetchCaller, children }: FabrikaProviderProps): ReactNode {
|
|
158
|
+
const [value] = useState(() => fabrika(clients, spa, fetchCaller ?? ((input, init) => fetch(input, init))))
|
|
159
|
+
return <FabrikaContext.Provider value={value}>{children}</FabrikaContext.Provider>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function useFabrika(hook: string): Fabrika {
|
|
163
|
+
const value = useContext(FabrikaContext)
|
|
164
|
+
if (value === null) throw new Error(`${hook} is used outside a FabrikaProvider`)
|
|
165
|
+
return value
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The prefix a mutation invalidates by default: the group it lies in (`api.deals` for `api.deals.create`). */
|
|
169
|
+
export function parentOf(node: ClientNode): string {
|
|
170
|
+
const segments = node.path.split('.')
|
|
171
|
+
return segments.length <= 1 ? node.mount : `${node.mount}.${segments.slice(0, -1).join('.')}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The prefix of a procedure or a group, for `invalidate`. */
|
|
175
|
+
export function prefixOf(target: unknown): string {
|
|
176
|
+
const node = clientNodeOf(target)
|
|
177
|
+
if (node === null) throw new Error('invalidate takes a procedure or a group of a client createClient made')
|
|
178
|
+
return idOf(node)
|
|
179
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The provider's cache, without React: one entry per procedure and input (and one per paged list), each with the
|
|
2
|
+
// pages it holds, a snapshot the hooks subscribe to, and a sequence number so a slower, older answer never overwrites
|
|
3
|
+
// a newer one. `invalidate` refetches the entries a view shows and drops the others.
|
|
4
|
+
|
|
5
|
+
import type { FabrikaError } from './errors'
|
|
6
|
+
|
|
7
|
+
/** A procedure as the store calls it: its id (`<mount>.<path>`) and the call of the provider's client. */
|
|
8
|
+
export interface StoreTarget {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly run: (input: unknown) => Promise<unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type QueryStatus = 'loading' | 'success' | 'error'
|
|
14
|
+
|
|
15
|
+
export interface QuerySnapshot {
|
|
16
|
+
readonly status: QueryStatus
|
|
17
|
+
readonly data: unknown
|
|
18
|
+
readonly error: FabrikaError | null
|
|
19
|
+
/** A request of this entry is in flight: the first load, a refetch or the next page. */
|
|
20
|
+
readonly fetching: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PagesSnapshot {
|
|
24
|
+
readonly status: QueryStatus
|
|
25
|
+
readonly items: readonly unknown[]
|
|
26
|
+
readonly next: string | null
|
|
27
|
+
readonly error: FabrikaError | null
|
|
28
|
+
readonly fetching: boolean
|
|
29
|
+
readonly loadingMore: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One page of a cursor list, as every list procedure of the paved road answers it. */
|
|
33
|
+
export interface Page {
|
|
34
|
+
readonly items: readonly unknown[]
|
|
35
|
+
readonly next: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Listener = () => void
|
|
39
|
+
|
|
40
|
+
/** JSON with sorted keys, so two inputs that are the same value are the same key. */
|
|
41
|
+
export function stableKey(value: unknown): string {
|
|
42
|
+
return JSON.stringify(value, (_key, field: unknown) => {
|
|
43
|
+
if (typeof field !== 'object' || field === null || Array.isArray(field)) return field
|
|
44
|
+
return Object.fromEntries(Object.entries(field).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
|
|
45
|
+
}) ?? 'null'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `<mount>.<path>` lies under `prefix` when it is the prefix itself or one of its descendants. */
|
|
49
|
+
export function under(id: string, prefix: string): boolean {
|
|
50
|
+
return id === prefix || id.startsWith(`${prefix}.`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
abstract class Entry<S> {
|
|
54
|
+
snapshot: S
|
|
55
|
+
protected sequence = 0
|
|
56
|
+
readonly #listeners = new Set<Listener>()
|
|
57
|
+
|
|
58
|
+
constructor(readonly target: StoreTarget, readonly input: unknown, initial: S) {
|
|
59
|
+
this.snapshot = initial
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get watched(): boolean {
|
|
63
|
+
return this.#listeners.size > 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
subscribe(listener: Listener): () => void {
|
|
67
|
+
this.#listeners.add(listener)
|
|
68
|
+
return () => this.#listeners.delete(listener)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected publish(snapshot: S): void {
|
|
72
|
+
this.snapshot = snapshot
|
|
73
|
+
for (const listener of [...this.#listeners]) listener()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
abstract load(): Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class QueryEntry extends Entry<QuerySnapshot> {
|
|
80
|
+
constructor(target: StoreTarget, input: unknown, private readonly mapError: (error: unknown) => FabrikaError) {
|
|
81
|
+
super(target, input, { status: 'loading', data: undefined, error: null, fetching: false })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async load(): Promise<void> {
|
|
85
|
+
const sequence = ++this.sequence
|
|
86
|
+
this.publish({ ...this.snapshot, fetching: true })
|
|
87
|
+
try {
|
|
88
|
+
const data = await this.target.run(this.input)
|
|
89
|
+
if (sequence !== this.sequence) return
|
|
90
|
+
this.publish({ status: 'success', data, error: null, fetching: false })
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (sequence !== this.sequence) return
|
|
93
|
+
this.publish({ ...this.snapshot, status: 'error', error: this.mapError(error), fetching: false })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `null` when the answer is no page: a hook over a procedure that does not page reports it instead of looping. */
|
|
99
|
+
function pageOf(value: unknown): Page | null {
|
|
100
|
+
if (typeof value !== 'object' || value === null) return null
|
|
101
|
+
const items: unknown = Reflect.get(value, 'items')
|
|
102
|
+
const next: unknown = Reflect.get(value, 'next')
|
|
103
|
+
return Array.isArray(items) && (typeof next === 'string' || next === null) ? { items, next } : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const NOT_A_PAGE: FabrikaError = { kind: 'other', message: 'the procedure answered no { items, next } page', status: 0 }
|
|
107
|
+
|
|
108
|
+
export class PagesEntry extends Entry<PagesSnapshot> {
|
|
109
|
+
/** How many pages the view has asked for; a refetch loads as many again. `Infinity` follows every cursor. */
|
|
110
|
+
#pages: number
|
|
111
|
+
|
|
112
|
+
constructor(target: StoreTarget, input: unknown, pages: number, private readonly mapError: (error: unknown) => FabrikaError) {
|
|
113
|
+
super(target, input, { status: 'loading', items: [], next: null, error: null, fetching: false, loadingMore: false })
|
|
114
|
+
this.#pages = pages
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#request(after: string | null): Promise<unknown> {
|
|
118
|
+
const input = typeof this.input === 'object' && this.input !== null ? this.input : {}
|
|
119
|
+
return this.target.run(after === null ? input : { ...input, after })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Loads the first `#pages` pages again, from the first cursor. */
|
|
123
|
+
async load(): Promise<void> {
|
|
124
|
+
const sequence = ++this.sequence
|
|
125
|
+
this.publish({ ...this.snapshot, fetching: true })
|
|
126
|
+
try {
|
|
127
|
+
const items: unknown[] = []
|
|
128
|
+
let next: string | null = null
|
|
129
|
+
let loaded = 0
|
|
130
|
+
do {
|
|
131
|
+
const page = pageOf(await this.#request(next))
|
|
132
|
+
if (sequence !== this.sequence) return
|
|
133
|
+
if (page === null) {
|
|
134
|
+
this.#refuse(NOT_A_PAGE)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
items.push(...page.items)
|
|
138
|
+
next = page.next
|
|
139
|
+
loaded++
|
|
140
|
+
} while (next !== null && loaded < this.#pages)
|
|
141
|
+
this.publish({ status: 'success', items, next, error: null, fetching: false, loadingMore: false })
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (sequence !== this.sequence) return
|
|
144
|
+
this.#refuse(this.mapError(error))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#refuse(error: FabrikaError): void {
|
|
149
|
+
this.publish({ ...this.snapshot, status: 'error', error, fetching: false, loadingMore: false })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Appends the next page; nothing while a request is in flight or when the list has no more. */
|
|
153
|
+
async loadMore(): Promise<void> {
|
|
154
|
+
const after = this.snapshot.next
|
|
155
|
+
if (after === null || this.snapshot.fetching) return
|
|
156
|
+
const sequence = ++this.sequence
|
|
157
|
+
this.publish({ ...this.snapshot, fetching: true, loadingMore: true })
|
|
158
|
+
try {
|
|
159
|
+
const page = pageOf(await this.#request(after))
|
|
160
|
+
if (sequence !== this.sequence) return
|
|
161
|
+
if (page === null) {
|
|
162
|
+
this.#refuse(NOT_A_PAGE)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
this.#pages++
|
|
166
|
+
this.publish({
|
|
167
|
+
status: 'success',
|
|
168
|
+
items: [...this.snapshot.items, ...page.items],
|
|
169
|
+
next: page.next,
|
|
170
|
+
error: null,
|
|
171
|
+
fetching: false,
|
|
172
|
+
loadingMore: false,
|
|
173
|
+
})
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (sequence !== this.sequence) return
|
|
176
|
+
// The loaded rows stay; the next `loadMore` asks for the same cursor again.
|
|
177
|
+
this.publish({ ...this.snapshot, status: 'error', error: this.mapError(error), fetching: false, loadingMore: false })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class QueryStore {
|
|
183
|
+
readonly #entries = new Map<string, QueryEntry | PagesEntry>()
|
|
184
|
+
|
|
185
|
+
constructor(private readonly mapError: (error: unknown) => FabrikaError) {}
|
|
186
|
+
|
|
187
|
+
query(target: StoreTarget, input: unknown): QueryEntry {
|
|
188
|
+
const key = `query\u0000${target.id}\u0000${stableKey(input)}`
|
|
189
|
+
const found = this.#entries.get(key)
|
|
190
|
+
if (found instanceof QueryEntry) return found
|
|
191
|
+
const entry = new QueryEntry(target, input, this.mapError)
|
|
192
|
+
this.#entries.set(key, entry)
|
|
193
|
+
return entry
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pages(target: StoreTarget, input: unknown, pages: number): PagesEntry {
|
|
197
|
+
const key = `pages\u0000${target.id}\u0000${stableKey(input)}\u0000${pages}`
|
|
198
|
+
const found = this.#entries.get(key)
|
|
199
|
+
if (found instanceof PagesEntry) return found
|
|
200
|
+
const entry = new PagesEntry(target, input, pages, this.mapError)
|
|
201
|
+
this.#entries.set(key, entry)
|
|
202
|
+
return entry
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Refetches every entry under one of `prefixes` that a view shows, and forgets the rest, so their next view loads. */
|
|
206
|
+
async invalidate(prefixes: readonly string[]): Promise<void> {
|
|
207
|
+
const loads: Promise<void>[] = []
|
|
208
|
+
for (const [key, entry] of this.#entries) {
|
|
209
|
+
if (!prefixes.some((prefix) => under(entry.target.id, prefix))) continue
|
|
210
|
+
if (entry.watched) loads.push(entry.load())
|
|
211
|
+
else this.#entries.delete(key)
|
|
212
|
+
}
|
|
213
|
+
await Promise.all(loads)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @fabrika/app/react/testing — what a screen test of an SPA needs: happy-dom's document over Bun's own web platform,
|
|
2
|
+
// and typing that React reads. No test-runner dependency; happy-dom's registrator is an optional peer dependency.
|
|
3
|
+
|
|
4
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator'
|
|
5
|
+
import { act } from 'react'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The classes and functions of Bun's web platform a screen test keeps: the client, the test kit, a form's challenge and
|
|
9
|
+
* a download run on them, and happy-dom's own differ (a happy-dom `File` of a Bun `Blob` holds `[object Blob]`).
|
|
10
|
+
*/
|
|
11
|
+
const BUN_GLOBALS = [
|
|
12
|
+
'fetch',
|
|
13
|
+
'Request',
|
|
14
|
+
'Response',
|
|
15
|
+
'Headers',
|
|
16
|
+
'AbortController',
|
|
17
|
+
'AbortSignal',
|
|
18
|
+
'URL',
|
|
19
|
+
'URLSearchParams',
|
|
20
|
+
'Blob',
|
|
21
|
+
'File',
|
|
22
|
+
'FormData',
|
|
23
|
+
'TextEncoder',
|
|
24
|
+
'TextDecoder',
|
|
25
|
+
'ReadableStream',
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
export interface InstalledDom {
|
|
29
|
+
/** Closes the window and removes its globals; call it from the test file's `afterAll`. */
|
|
30
|
+
uninstall(): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DomOptions {
|
|
34
|
+
/** The page's address; `http://app.test/` by default. A router needs a real origin. */
|
|
35
|
+
readonly url?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Installs happy-dom's window as the globals, keeping Bun's fetch, abort, URL, blob, file and form classes, with
|
|
40
|
+
* navigation off: a download's link click leaves the page where it is, so a later test of the file still has it. Call
|
|
41
|
+
* it in a module the test file imports first: React reads `window` when it loads.
|
|
42
|
+
*/
|
|
43
|
+
export function installDom(options: DomOptions = {}): InstalledDom {
|
|
44
|
+
const kept = BUN_GLOBALS.map((name) => [name, Reflect.get(globalThis, name)] as const)
|
|
45
|
+
GlobalRegistrator.register({
|
|
46
|
+
url: options.url ?? 'http://app.test/',
|
|
47
|
+
settings: {
|
|
48
|
+
navigation: {
|
|
49
|
+
disableMainFrameNavigation: true,
|
|
50
|
+
disableChildFrameNavigation: true,
|
|
51
|
+
disableChildPageNavigation: true,
|
|
52
|
+
disableFallbackToSetURL: true,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
for (const [name, value] of kept) Reflect.set(globalThis, name, value)
|
|
57
|
+
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
|
|
58
|
+
return {
|
|
59
|
+
async uninstall() {
|
|
60
|
+
// A request a screen started may still settle: let it, before its window goes.
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
62
|
+
await GlobalRegistrator.unregister()
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Control = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The text a label gives its control: what is hidden from assistive technology (a required marker) left out, and so
|
|
71
|
+
* is a control the label wraps.
|
|
72
|
+
*/
|
|
73
|
+
function readText(element: Element): string {
|
|
74
|
+
const copy = element.cloneNode(true)
|
|
75
|
+
if (copy instanceof Element) {
|
|
76
|
+
for (const left of copy.querySelectorAll('[aria-hidden="true"], input, textarea, select')) left.remove()
|
|
77
|
+
}
|
|
78
|
+
return (copy.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A control's accessible name: its `aria-label`, the elements `aria-labelledby` names, or its `<label>`. */
|
|
82
|
+
function nameOf(control: Control): string {
|
|
83
|
+
const aria = control.getAttribute('aria-label')
|
|
84
|
+
if (aria !== null) return aria.trim()
|
|
85
|
+
const labelledBy = control.getAttribute('aria-labelledby')
|
|
86
|
+
if (labelledBy !== null) {
|
|
87
|
+
return labelledBy.split(/\s+/).map((id) => {
|
|
88
|
+
const label = control.ownerDocument.getElementById(id)
|
|
89
|
+
return label === null ? '' : readText(label)
|
|
90
|
+
}).join(' ').trim()
|
|
91
|
+
}
|
|
92
|
+
const labels = control.labels === null ? [] : [...control.labels]
|
|
93
|
+
return labels.map(readText).join(' ').trim()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isControl = (element: Element): element is Control =>
|
|
97
|
+
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement
|
|
98
|
+
|
|
99
|
+
/** The one input, textarea or select whose accessible name is `label`; throws when there is none or more than one. */
|
|
100
|
+
export function controlByLabel(label: string, within: ParentNode = document.body): Control {
|
|
101
|
+
const found = [...within.querySelectorAll('input, textarea, select')].filter(isControl).filter((control) => nameOf(control) === label)
|
|
102
|
+
const [control] = found
|
|
103
|
+
if (control === undefined || found.length > 1) {
|
|
104
|
+
throw new Error(`${found.length === 0 ? 'no' : found.length} control${found.length > 1 ? 's' : ''} labelled '${label}'`)
|
|
105
|
+
}
|
|
106
|
+
return control
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The prototype whose `value` setter React's own value tracking does not see, so the next event reads as a change. */
|
|
110
|
+
function valueSetter(control: Control): ((value: string) => void) | undefined {
|
|
111
|
+
const prototype = control instanceof HTMLTextAreaElement
|
|
112
|
+
? HTMLTextAreaElement.prototype
|
|
113
|
+
: control instanceof HTMLSelectElement
|
|
114
|
+
? HTMLSelectElement.prototype
|
|
115
|
+
: HTMLInputElement.prototype
|
|
116
|
+
return Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Types `value` into the control labelled `label`, as React reads a person typing: it focuses the control, sets the
|
|
121
|
+
* value past React's tracking, and sends `input`, `change` and `keyup`. A select takes the option's value.
|
|
122
|
+
*/
|
|
123
|
+
export async function type(label: string, value: string, within?: ParentNode): Promise<void> {
|
|
124
|
+
const control = controlByLabel(label, within)
|
|
125
|
+
await act(async () => {
|
|
126
|
+
control.focus()
|
|
127
|
+
valueSetter(control)?.call(control, value)
|
|
128
|
+
control.dispatchEvent(new Event('input', { bubbles: true }))
|
|
129
|
+
control.dispatchEvent(new Event('change', { bubbles: true }))
|
|
130
|
+
control.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }))
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Waits, letting React render, until the text of `within` holds `text`; throws after `timeoutMs` (2 s by default). */
|
|
135
|
+
export async function waitForText(text: string, options: { readonly within?: Element; readonly timeoutMs?: number } = {}): Promise<void> {
|
|
136
|
+
const within = options.within ?? document.body
|
|
137
|
+
const deadline = Date.now() + (options.timeoutMs ?? 2000)
|
|
138
|
+
while (!(within.textContent ?? '').includes(text)) {
|
|
139
|
+
if (Date.now() > deadline) throw new Error(`the page did not show '${text}' in time`)
|
|
140
|
+
await act(async () => {
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The types the hooks read off a client procedure: its input as the caller writes it, and its answer as the output
|
|
2
|
+
// schema parses it.
|
|
3
|
+
|
|
4
|
+
import type { z } from 'zod/v4'
|
|
5
|
+
import type { Schema } from '../contract/define'
|
|
6
|
+
|
|
7
|
+
/** A query or mutation of a client that `createClient` built. */
|
|
8
|
+
export type ClientProcedure = ((input: never) => Promise<unknown>) & { readonly output: Schema }
|
|
9
|
+
|
|
10
|
+
/** A group of a client tree (`api.deals`), or the client itself: every procedure under it. */
|
|
11
|
+
export interface ClientGroup {
|
|
12
|
+
readonly [name: string]: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What `invalidate` takes: one procedure, or a group for every procedure under it. */
|
|
16
|
+
export type InvalidateTarget = ClientProcedure | ClientGroup
|
|
17
|
+
|
|
18
|
+
/** The input as the caller writes it; `undefined` admitted for a procedure that takes none. */
|
|
19
|
+
export type ProcedureInput<F extends ClientProcedure> = F extends (input: infer I) => Promise<unknown> ? I : never
|
|
20
|
+
|
|
21
|
+
/** A procedure by its input `I` and output schema `O`: what the hooks take, so both are inferred from the call. */
|
|
22
|
+
export type TypedProcedure<I, O extends Schema> = ((input: I) => Promise<unknown>) & { readonly output: O }
|
|
23
|
+
|
|
24
|
+
/** An input argument that may be left out when the procedure takes no input. */
|
|
25
|
+
export type InputArgument<I> = undefined extends I ? I | void : I
|
|
26
|
+
|
|
27
|
+
/** The answer, as the procedure's output schema parses it. */
|
|
28
|
+
export type ProcedureResult<F extends ClientProcedure> = z.output<F['output']>
|
|
29
|
+
|
|
30
|
+
/** The answer of a cursor list: `{ items, next }`. */
|
|
31
|
+
export interface PageAnswer<Item> {
|
|
32
|
+
readonly items: readonly Item[]
|
|
33
|
+
readonly next: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A procedure that pages: input `after`, answer `{ items, next }`. */
|
|
37
|
+
export type PagedProcedure<I, Item> = ((input: I) => Promise<unknown>) & { readonly output: z.core.$ZodType<PageAnswer<Item>> }
|
|
38
|
+
|
|
39
|
+
/** The input of a paged procedure without its cursor: the hook sends `after` itself. */
|
|
40
|
+
export type PageInput<I> = undefined extends I ? Omit<NonNullable<I>, 'after'> | void : Omit<I, 'after'>
|
package/src/rpc/dispatcher.ts
CHANGED
|
@@ -114,9 +114,9 @@ async function dispatchOne(router: AnyRouter, ctx: unknown, call: SingleCall): P
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* Dispatch an RPC POST. The `ctx` is already built (
|
|
118
|
-
*
|
|
119
|
-
* `{ method, input }
|
|
117
|
+
* Dispatch an RPC POST. The `ctx` is already built (with its `ctx.auth`, for example)
|
|
118
|
+
* by the server before this is called. One request is one call,
|
|
119
|
+
* `{ method, input }`.
|
|
120
120
|
*/
|
|
121
121
|
export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown; request: Request }): Promise<Response> {
|
|
122
122
|
let rawBody: unknown
|
|
@@ -126,25 +126,10 @@ export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown
|
|
|
126
126
|
return jsonResponse({ error: { type: 'validation', message: 'Invalid JSON body' } }, 400)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
if (isRecord(rawBody) && Array.isArray(rawBody['batch'])) {
|
|
130
|
-
const results: SingleResult[] = []
|
|
131
|
-
for (const call of rawBody['batch']) {
|
|
132
|
-
results.push((await dispatchOne(args.router, args.ctx, normalizeCall(call))).body)
|
|
133
|
-
}
|
|
134
|
-
return jsonResponse({ batch: results }, 200)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
129
|
if (!isRecord(rawBody) || typeof rawBody['method'] !== 'string') {
|
|
138
|
-
return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input }
|
|
130
|
+
return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input }' } }, 400)
|
|
139
131
|
}
|
|
140
132
|
|
|
141
133
|
const single = await dispatchOne(args.router, args.ctx, { method: rawBody['method'], input: rawBody['input'] })
|
|
142
134
|
return jsonResponse(single.body, single.status)
|
|
143
135
|
}
|
|
144
|
-
|
|
145
|
-
function normalizeCall(call: unknown): SingleCall {
|
|
146
|
-
if (isRecord(call) && typeof call['method'] === 'string') {
|
|
147
|
-
return { method: call['method'], input: call['input'] }
|
|
148
|
-
}
|
|
149
|
-
return { method: '', input: undefined }
|
|
150
|
-
}
|