@fabrika/app 0.3.2 → 0.4.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/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -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 +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -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 +309 -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 +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -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 +808 -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 +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -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 +749 -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 +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -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/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -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,165 @@
|
|
|
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 { type ClientNode, clientNodeOf } 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
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ResolvedProcedure extends StoreTarget {
|
|
65
|
+
readonly node: ClientNode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const FabrikaContext = createContext<Fabrika | null>(null)
|
|
69
|
+
|
|
70
|
+
/** The `{ path, code, message }` issues of a refused input, as the application answers them. */
|
|
71
|
+
function issuesOf(error: z.core.$ZodError): readonly { readonly path: string; readonly code: string; readonly message: string }[] {
|
|
72
|
+
return error.issues.map((issue) => ({ path: issue.path.map(String).join('.'), code: issue.code, message: issue.message }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Checks the input with the procedure's own schema first: a refused input is the application's 400, unsent. */
|
|
76
|
+
function validated(node: ClientNode, input: unknown): unknown {
|
|
77
|
+
const schema = node.procedure?.input ?? null
|
|
78
|
+
if (schema === null) return input
|
|
79
|
+
const parsed = z.safeParse(schema, input ?? {})
|
|
80
|
+
if (parsed.success) return input
|
|
81
|
+
const issues = issuesOf(parsed.error)
|
|
82
|
+
const first = issues[0]
|
|
83
|
+
throw new RpcError({
|
|
84
|
+
type: 'validation',
|
|
85
|
+
message: first === undefined ? 'Invalid input' : `${first.path || 'input'}: ${first.message}`,
|
|
86
|
+
issues,
|
|
87
|
+
httpStatus: 400,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function idOf(node: ClientNode): string {
|
|
92
|
+
return node.path === '' ? node.mount : `${node.mount}.${node.path}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function procedureAt(client: object, path: string): unknown {
|
|
96
|
+
let value: unknown = client
|
|
97
|
+
for (const segment of path.split('.')) {
|
|
98
|
+
value = typeof value === 'object' && value !== null ? Reflect.get(value, segment) : undefined
|
|
99
|
+
}
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fabrika(clients: readonly object[], spa: string, fetch: RpcFetch): Fabrika {
|
|
104
|
+
const byMount = new Map<string, object>()
|
|
105
|
+
for (const client of clients) {
|
|
106
|
+
const node = clientNodeOf(client)
|
|
107
|
+
if (node === null || node.path !== '') throw new Error('FabrikaProvider takes the clients createClient made')
|
|
108
|
+
if (byMount.has(node.mount)) throw new Error(`FabrikaProvider holds two clients of mount '${node.mount}'`)
|
|
109
|
+
byMount.set(node.mount, client)
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
store: new QueryStore(toFabrikaError),
|
|
113
|
+
caller: new CallerSource(joinPath(spa, PAGE_CALLER_FILE), fetch),
|
|
114
|
+
target(procedure, hook) {
|
|
115
|
+
const node = clientNodeOf(procedure)
|
|
116
|
+
if (node?.procedure == null) throw new Error(`${hook} takes a procedure of a client createClient made`)
|
|
117
|
+
const client = byMount.get(node.mount)
|
|
118
|
+
if (client === undefined) throw new Error(`${hook}: the FabrikaProvider holds no client of mount '${node.mount}'`)
|
|
119
|
+
const call = procedureAt(client, node.path)
|
|
120
|
+
if (typeof call !== 'function') throw new Error(`${hook}: '${idOf(node)}' is not a procedure of the provider's client`)
|
|
121
|
+
return {
|
|
122
|
+
id: idOf(node),
|
|
123
|
+
node,
|
|
124
|
+
run: async (input) => {
|
|
125
|
+
const answer: unknown = await Reflect.apply(call, undefined, [validated(node, input)])
|
|
126
|
+
return answer
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface FabrikaProviderProps {
|
|
134
|
+
/** The clients of the mounts the page calls, as `createClient` made them; read at the first render. */
|
|
135
|
+
readonly clients: readonly object[]
|
|
136
|
+
/** The prefix of the SPA mount the page is served from: the caller document is `<spa>/__fabrika-me.json`. */
|
|
137
|
+
readonly spa?: string
|
|
138
|
+
/** What the caller document is fetched with; the global `fetch` by default (a test passes the kit's). */
|
|
139
|
+
readonly fetch?: RpcFetch
|
|
140
|
+
readonly children?: ReactNode
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function FabrikaProvider({ clients, spa = '/', fetch: fetchCaller, children }: FabrikaProviderProps): ReactNode {
|
|
144
|
+
const [value] = useState(() => fabrika(clients, spa, fetchCaller ?? ((input, init) => fetch(input, init))))
|
|
145
|
+
return <FabrikaContext.Provider value={value}>{children}</FabrikaContext.Provider>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useFabrika(hook: string): Fabrika {
|
|
149
|
+
const value = useContext(FabrikaContext)
|
|
150
|
+
if (value === null) throw new Error(`${hook} is used outside a FabrikaProvider`)
|
|
151
|
+
return value
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The prefix a mutation invalidates by default: the group it lies in (`api.deals` for `api.deals.create`). */
|
|
155
|
+
export function parentOf(node: ClientNode): string {
|
|
156
|
+
const segments = node.path.split('.')
|
|
157
|
+
return segments.length <= 1 ? node.mount : `${node.mount}.${segments.slice(0, -1).join('.')}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The prefix of a procedure or a group, for `invalidate`. */
|
|
161
|
+
export function prefixOf(target: unknown): string {
|
|
162
|
+
const node = clientNodeOf(target)
|
|
163
|
+
if (node === null) throw new Error('invalidate takes a procedure or a group of a client createClient made')
|
|
164
|
+
return idOf(node)
|
|
165
|
+
}
|
|
@@ -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,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
|
-
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A service contract: the endpoint table of one of the installation's own services (ADR-0012, ADR-0022 "Installation
|
|
2
|
+
// entries"). Plain data over `@fabrika/auth-core` only, so a table module stays dependency-light.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
APP_ENVIRONMENT_SEPARATOR,
|
|
6
|
+
type BodyAdmission,
|
|
7
|
+
compileEndpointTable,
|
|
8
|
+
EndpointTableError,
|
|
9
|
+
type InstallationCaller,
|
|
10
|
+
type InstallationEndpointMethod,
|
|
11
|
+
type InstallationEndpointRow,
|
|
12
|
+
type InstallationEndpointTable,
|
|
13
|
+
OAUTH_CALLER,
|
|
14
|
+
} from '@fabrika/auth-core'
|
|
15
|
+
|
|
16
|
+
/** A row as the builder writes it; the record key of `endpoints` becomes its id. */
|
|
17
|
+
export type ServiceEndpointSpec = InstallationEndpointRow extends infer Row ? Row extends InstallationEndpointRow ? Omit<Row, 'id'> : never : never
|
|
18
|
+
|
|
19
|
+
/** One installation service's table, with the row ids as a type so its handlers can be checked against them. */
|
|
20
|
+
export interface ServiceArtifact<Id extends string = string> extends InstallationEndpointTable {
|
|
21
|
+
/** The service's IAM app id; never `<app>@<env>`. */
|
|
22
|
+
readonly id: string
|
|
23
|
+
readonly endpoints: readonly (InstallationEndpointRow & { readonly id: Id })[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ServiceEndpointOptions {
|
|
27
|
+
readonly callers: readonly [InstallationCaller, ...InstallationCaller[]]
|
|
28
|
+
/** The request body the row admits; omitted admits none. */
|
|
29
|
+
readonly admission?: BodyAdmission
|
|
30
|
+
/** The scopes an OAuth resource token must carry; required exactly when `callers` holds `oauth`. */
|
|
31
|
+
readonly scopes?: readonly string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServiceContractBuilder {
|
|
35
|
+
/** A liveness endpoint: anonymous, and served without `X-Fabrika-Endpoint`. */
|
|
36
|
+
health(method: InstallationEndpointMethod, path: string): ServiceEndpointSpec
|
|
37
|
+
/** An endpoint the service authenticates itself; the proxy reads no credential and keeps `Authorization`. */
|
|
38
|
+
protocol(method: InstallationEndpointMethod, path: string): ServiceEndpointSpec
|
|
39
|
+
/** A credentialed endpoint: a listed caller kind, and no permission (`authenticated`). */
|
|
40
|
+
service(method: InstallationEndpointMethod, path: string, options: ServiceEndpointOptions): ServiceEndpointSpec
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ServiceContractDeclaration<Id extends string> {
|
|
44
|
+
/** `null` is a row this composition does not serve: the table has no row there, and its handler is never called. */
|
|
45
|
+
readonly endpoints: Readonly<Record<Id, ServiceEndpointSpec | null>>
|
|
46
|
+
/** Literal prefixes a path under which reaches only a row declared under it. */
|
|
47
|
+
readonly reservedPrefixes: readonly string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ServiceContractError extends Error {
|
|
51
|
+
override readonly name = 'ServiceContractError'
|
|
52
|
+
readonly problems: readonly string[]
|
|
53
|
+
|
|
54
|
+
constructor(problems: readonly string[]) {
|
|
55
|
+
super(`the service contract is invalid:\n- ${problems.join('\n- ')}`)
|
|
56
|
+
this.problems = problems
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const builder: ServiceContractBuilder = {
|
|
61
|
+
health: (method, path) => anonymous('health', method, path),
|
|
62
|
+
protocol: (method, path) => anonymous('protocol', method, path),
|
|
63
|
+
service: (method, path, options) => ({
|
|
64
|
+
kind: 'service',
|
|
65
|
+
method,
|
|
66
|
+
path,
|
|
67
|
+
callers: options.callers,
|
|
68
|
+
requirement: { kind: 'authenticated' },
|
|
69
|
+
admission: options.admission ?? null,
|
|
70
|
+
oauth: options.scopes === undefined ? null : { scopes: options.scopes },
|
|
71
|
+
}),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function anonymous(kind: 'health' | 'protocol', method: InstallationEndpointMethod, path: string): ServiceEndpointSpec {
|
|
75
|
+
return { kind, method, path, callers: [], requirement: null, admission: null, oauth: null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Declare one installation service's endpoint table. Validated here, once: an id that is an application environment,
|
|
80
|
+
* an `oauth` caller without its scopes (or scopes without it), and every table the matcher would refuse.
|
|
81
|
+
*/
|
|
82
|
+
export function defineServiceContract<const Id extends string>(
|
|
83
|
+
id: string,
|
|
84
|
+
declare: (s: ServiceContractBuilder) => ServiceContractDeclaration<Id>,
|
|
85
|
+
): ServiceArtifact<Id> {
|
|
86
|
+
const declaration = declare(builder)
|
|
87
|
+
const endpoints = servedEntries(declaration.endpoints).map(([rowId, spec]) => ({ ...spec, id: rowId }))
|
|
88
|
+
const problems: string[] = []
|
|
89
|
+
if (id === '' || id.includes(APP_ENVIRONMENT_SEPARATOR)) {
|
|
90
|
+
problems.push(`'${id}' is no installation service id: it must be non-empty and hold no '${APP_ENVIRONMENT_SEPARATOR}'`)
|
|
91
|
+
}
|
|
92
|
+
for (const row of endpoints) {
|
|
93
|
+
const admitsOAuth = row.callers.some((caller) => caller === OAUTH_CALLER)
|
|
94
|
+
if (admitsOAuth !== (row.oauth !== null)) {
|
|
95
|
+
problems.push(`'${row.id}' must declare scopes exactly when it admits the caller '${OAUTH_CALLER}'`)
|
|
96
|
+
}
|
|
97
|
+
if (row.oauth !== null && row.oauth.scopes.length === 0) {
|
|
98
|
+
problems.push(`'${row.id}' admits '${OAUTH_CALLER}' with no scope`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const table = { endpoints, reservedPrefixes: [...declaration.reservedPrefixes] }
|
|
102
|
+
try {
|
|
103
|
+
compileEndpointTable(table)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!(error instanceof EndpointTableError)) throw error
|
|
106
|
+
problems.push(...error.problems)
|
|
107
|
+
}
|
|
108
|
+
if (problems.length > 0) {
|
|
109
|
+
throw new ServiceContractError(problems)
|
|
110
|
+
}
|
|
111
|
+
return { id, ...table }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function servedEntries<Id extends string, Value>(record: Readonly<Record<Id, Value | null>>): [Id, Value][] {
|
|
115
|
+
const entries: [Id, Value][] = []
|
|
116
|
+
for (const key in record) {
|
|
117
|
+
const value = record[key]
|
|
118
|
+
if (Object.hasOwn(record, key) && value !== null) entries.push([key, value])
|
|
119
|
+
}
|
|
120
|
+
return entries
|
|
121
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @fabrika/app/service — the installation's own services only (IAM, Control, Operations). An application serves its
|
|
2
|
+
// contract through `serve`; `@fabrika/check` refuses this import in one.
|
|
3
|
+
|
|
4
|
+
export { dispatchRpcRequest } from '../rpc/dispatcher.js'
|
|
5
|
+
export {
|
|
6
|
+
defineServiceContract,
|
|
7
|
+
type ServiceArtifact,
|
|
8
|
+
type ServiceContractBuilder,
|
|
9
|
+
type ServiceContractDeclaration,
|
|
10
|
+
ServiceContractError,
|
|
11
|
+
type ServiceEndpointOptions,
|
|
12
|
+
type ServiceEndpointSpec,
|
|
13
|
+
} from './define.js'
|
|
14
|
+
export {
|
|
15
|
+
serveService,
|
|
16
|
+
type ServeServiceOptions,
|
|
17
|
+
type ServiceHandler,
|
|
18
|
+
type ServiceHandlers,
|
|
19
|
+
ServiceHandlersError,
|
|
20
|
+
type ServiceRequest,
|
|
21
|
+
} from './serve.js'
|