@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,129 @@
|
|
|
1
|
+
// The five-field cron expressions of `c.cron`, read in UTC: which minutes a job is due, so the runner can tell how
|
|
2
|
+
// long ago it should last have succeeded. The crontab, not this module, triggers a run.
|
|
3
|
+
|
|
4
|
+
interface Field {
|
|
5
|
+
readonly min: number
|
|
6
|
+
readonly max: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MINUTE: Field = { min: 0, max: 59 }
|
|
10
|
+
const HOUR: Field = { min: 0, max: 23 }
|
|
11
|
+
const DAY_OF_MONTH: Field = { min: 1, max: 31 }
|
|
12
|
+
const MONTH: Field = { min: 1, max: 12 }
|
|
13
|
+
// 7 is Sunday too, as in Vixie cron; it is folded into 0 below.
|
|
14
|
+
const DAY_OF_WEEK: Field = { min: 0, max: 7 }
|
|
15
|
+
|
|
16
|
+
const NUMBER = /^\d+$/
|
|
17
|
+
const MINUTE_MS = 60_000
|
|
18
|
+
/** How far back a due minute is looked for: a schedule that matches nothing in that window has no period. */
|
|
19
|
+
const SEARCH_DAYS = 366 * 5
|
|
20
|
+
|
|
21
|
+
export interface CronSchedule {
|
|
22
|
+
readonly minutes: ReadonlySet<number>
|
|
23
|
+
readonly hours: ReadonlySet<number>
|
|
24
|
+
readonly daysOfMonth: ReadonlySet<number>
|
|
25
|
+
readonly months: ReadonlySet<number>
|
|
26
|
+
readonly daysOfWeek: ReadonlySet<number>
|
|
27
|
+
/** Both day fields name days: a day is due when either matches (Vixie cron). */
|
|
28
|
+
readonly eitherDay: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function number(text: string, field: Field): number | null {
|
|
32
|
+
if (!NUMBER.test(text)) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
const value = Number.parseInt(text, 10)
|
|
36
|
+
return value >= field.min && value <= field.max ? value : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `*`, `n`, `a-b`, each optionally `/step`, separated by commas; `null` for anything else. */
|
|
40
|
+
function parseField(text: string, field: Field): Set<number> | null {
|
|
41
|
+
const values = new Set<number>()
|
|
42
|
+
for (const item of text.split(',')) {
|
|
43
|
+
const [range = '', step, extra] = item.split('/')
|
|
44
|
+
if (extra !== undefined) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
const every = step === undefined ? 1 : number(step, { min: 1, max: field.max + 1 })
|
|
48
|
+
if (every === null) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
let from: number | null = field.min
|
|
52
|
+
let to: number | null = field.max
|
|
53
|
+
if (range !== '*') {
|
|
54
|
+
const [low = '', high, more] = range.split('-')
|
|
55
|
+
if (more !== undefined) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
from = number(low, field)
|
|
59
|
+
to = high === undefined ? (step === undefined ? from : field.max) : number(high, field)
|
|
60
|
+
}
|
|
61
|
+
if (from === null || to === null || from > to) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
for (let value = from; value <= to; value += every) {
|
|
65
|
+
values.add(value)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return values
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The schedule of a five-field expression, or `null` when it is not one this runner reads. */
|
|
72
|
+
export function parseCron(expression: string): CronSchedule | null {
|
|
73
|
+
const fields = expression.split(' ')
|
|
74
|
+
if (fields.length !== 5) {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
const [minute = '', hour = '', dayOfMonth = '', month = '', dayOfWeek = ''] = fields
|
|
78
|
+
const minutes = parseField(minute, MINUTE)
|
|
79
|
+
const hours = parseField(hour, HOUR)
|
|
80
|
+
const daysOfMonth = parseField(dayOfMonth, DAY_OF_MONTH)
|
|
81
|
+
const months = parseField(month, MONTH)
|
|
82
|
+
const weekdays = parseField(dayOfWeek, DAY_OF_WEEK)
|
|
83
|
+
if (minutes === null || hours === null || daysOfMonth === null || months === null || weekdays === null) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const daysOfWeek = new Set([...weekdays].map((day) => day % 7))
|
|
87
|
+
return { minutes, hours, daysOfMonth, months, daysOfWeek, eitherDay: !dayOfMonth.startsWith('*') && !dayOfWeek.startsWith('*') }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dayMatches(schedule: CronSchedule, day: Date): boolean {
|
|
91
|
+
if (!schedule.months.has(day.getUTCMonth() + 1)) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
const byMonth = schedule.daysOfMonth.has(day.getUTCDate())
|
|
95
|
+
const byWeek = schedule.daysOfWeek.has(day.getUTCDay())
|
|
96
|
+
return schedule.eitherDay ? byMonth || byWeek : byMonth && byWeek
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const descending = (values: ReadonlySet<number>): number[] => [...values].sort((left, right) => right - left)
|
|
100
|
+
|
|
101
|
+
/** The latest `count` due minutes at or before `at`, newest first; fewer when the schedule is that rare. */
|
|
102
|
+
export function previousDue(schedule: CronSchedule, at: Date, count: number): Date[] {
|
|
103
|
+
const due: Date[] = []
|
|
104
|
+
const hours = descending(schedule.hours)
|
|
105
|
+
const minutes = descending(schedule.minutes)
|
|
106
|
+
const limit = Math.floor(at.getTime() / MINUTE_MS) * MINUTE_MS
|
|
107
|
+
const today = Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
|
|
108
|
+
for (let back = 0; back <= SEARCH_DAYS && due.length < count; back++) {
|
|
109
|
+
const day = new Date(today - back * 24 * 60 * MINUTE_MS)
|
|
110
|
+
if (!dayMatches(schedule, day)) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
for (const hour of hours) {
|
|
114
|
+
for (const minute of minutes) {
|
|
115
|
+
const time = day.getTime() + (hour * 60 + minute) * MINUTE_MS
|
|
116
|
+
if (time <= limit && due.length < count) {
|
|
117
|
+
due.push(new Date(time))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return due
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The time between the two latest due minutes at or before `at`, or `null` when there are not two in the window. */
|
|
126
|
+
export function periodAt(schedule: CronSchedule, at: Date): number | null {
|
|
127
|
+
const [latest, before] = previousDue(schedule, at, 2)
|
|
128
|
+
return latest === undefined || before === undefined ? null : latest.getTime() - before.getTime()
|
|
129
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The handler types of a contract's jobs: one handler per `c.cron` job under `handlers.jobs`, each with a context
|
|
2
|
+
// that reaches only the actions the job declares. A job has no request and no caller, so there is no `input`.
|
|
3
|
+
|
|
4
|
+
import type { CronJob, DimensionOf } from '../contract/define'
|
|
5
|
+
import type { Ref, Within } from '../implement/capability'
|
|
6
|
+
|
|
7
|
+
/** Who a job's writes are stamped with (`createdBy`): the job itself, never a person. */
|
|
8
|
+
export interface JobPrincipal {
|
|
9
|
+
readonly kind: 'job'
|
|
10
|
+
/** `job:<name>`. */
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly job: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a job handler gets. A capability is minted for a declared action only when the token IAM signed for the job's
|
|
17
|
+
* key at the start of the attempt carries it, with the job's `scope: 'app'`, and only while this attempt holds the
|
|
18
|
+
* job's lease: a capability of another attempt is refused, and so is every write after the attempt timed out or lost
|
|
19
|
+
* the lease.
|
|
20
|
+
*/
|
|
21
|
+
export interface JobContext<A extends string, D extends string> {
|
|
22
|
+
readonly principal: JobPrincipal
|
|
23
|
+
/** This attempt's id; it is also the holder of the job's lease. */
|
|
24
|
+
readonly runId: string
|
|
25
|
+
/** 1 for the first attempt, one more for each retry. */
|
|
26
|
+
readonly attempt: number
|
|
27
|
+
/** Aborted when the attempt times out or loses its lease. */
|
|
28
|
+
readonly signal: AbortSignal
|
|
29
|
+
/** Every value of `dimension`, for a declared action: what a list or an aggregate takes. */
|
|
30
|
+
within<const Act extends A, const Dim extends D>(action: Act, dimension: Dim): Within<Dim, Act>
|
|
31
|
+
/** One object of `dimension` for a declared action, located now: what a write takes. A missing object is NotFound. */
|
|
32
|
+
ref<const Act extends A, const Dim extends D>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type JobHandler<A extends string, D extends string> = (args: { readonly ctx: JobContext<A, D> }) => void | Promise<void>
|
|
36
|
+
|
|
37
|
+
type JobsOf<C> = C extends { readonly surface: { readonly jobs: infer J } } ? J : never
|
|
38
|
+
|
|
39
|
+
/** `{ jobs: { <name>: handler } }` for a contract that declares jobs, and nothing for one that declares none. */
|
|
40
|
+
export type JobHandlersOf<C> = [JobsOf<C>] extends [never] ? unknown : {
|
|
41
|
+
readonly jobs: {
|
|
42
|
+
readonly [K in keyof JobsOf<C>]: JobsOf<C>[K] extends CronJob<infer A, string> ? JobHandler<A, DimensionOf<C>> : never
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Who the page's person is, from the caller document the SPA mount serves: their label, mailbox and what the token
|
|
2
|
+
// grants. A page greets by it and shows or hides a control by it. It is information for the page only and never a
|
|
3
|
+
// check: every endpoint decides again, so a hidden button protects nothing.
|
|
4
|
+
|
|
5
|
+
import { type ReactNode, useMemo, useSyncExternalStore } from 'react'
|
|
6
|
+
import type { PageCaller } from '../client/caller'
|
|
7
|
+
import { useFabrika } from './provider'
|
|
8
|
+
|
|
9
|
+
/** A scope value as the token names it: `{ type: 'org', value: 'acme' }`. */
|
|
10
|
+
export interface CallerScope {
|
|
11
|
+
readonly type: string
|
|
12
|
+
readonly value: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Caller {
|
|
16
|
+
readonly label: string
|
|
17
|
+
readonly email: string
|
|
18
|
+
/**
|
|
19
|
+
* Whether the token grants `action`: without `scope`, app-wide or in any scope value; with `scope`, app-wide or in
|
|
20
|
+
* that value itself. A grant on a container is not seen from a value inside it (the page knows no chain): ask at
|
|
21
|
+
* the dimension the person holds the role in.
|
|
22
|
+
*/
|
|
23
|
+
can(action: string, scope?: CallerScope): boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The caller of a parsed document; exported for a page that reads the document itself. */
|
|
27
|
+
export function callerOf(document: PageCaller): Caller {
|
|
28
|
+
const app = new Set(document.actions.app)
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
label: document.label,
|
|
31
|
+
email: document.email,
|
|
32
|
+
can(action: string, scope?: CallerScope): boolean {
|
|
33
|
+
if (app.has(action)) return true
|
|
34
|
+
return document.actions.scopes.some((held) =>
|
|
35
|
+
held.actions.includes(action) && (scope === undefined || (held.type === scope.type && held.value === scope.value))
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The page's caller, or `null` while the document loads and when it could not be read (a public SPA answers 401). */
|
|
42
|
+
export function useCaller(): Caller | null {
|
|
43
|
+
const source = useFabrika('useCaller').caller
|
|
44
|
+
const state = useSyncExternalStore((listener) => source.subscribe(listener), () => source.state, () => source.state)
|
|
45
|
+
return useMemo(() => (state.status === 'ready' ? callerOf(state.caller) : null), [state])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CanProps {
|
|
49
|
+
readonly action: string
|
|
50
|
+
readonly scope?: CallerScope
|
|
51
|
+
/** Shown instead, also while the caller document loads; nothing by default. */
|
|
52
|
+
readonly fallback?: ReactNode
|
|
53
|
+
readonly children?: ReactNode
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Renders its children when the caller's token grants `action` (see `Caller.can`). Never the access check. */
|
|
57
|
+
export function Can({ action, scope, fallback = null, children }: CanProps): ReactNode {
|
|
58
|
+
const caller = useCaller()
|
|
59
|
+
return caller?.can(action, scope) === true ? children : fallback
|
|
60
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Every refusal a page meets, as one union it can switch on: the proxy's 401 with the login URL, a 403, a stale
|
|
2
|
+
// `expectVersion` (409), a refused input with its field paths, a 404, and everything else.
|
|
3
|
+
|
|
4
|
+
export type FabrikaError =
|
|
5
|
+
| {
|
|
6
|
+
readonly kind: 'unauthenticated'
|
|
7
|
+
readonly message: string
|
|
8
|
+
/** Where the proxy sends the person to sign in; `null` when the answer carried none. */
|
|
9
|
+
readonly loginUrl: string | null
|
|
10
|
+
}
|
|
11
|
+
| { readonly kind: 'forbidden'; readonly message: string }
|
|
12
|
+
| { readonly kind: 'conflict'; readonly message: string }
|
|
13
|
+
| {
|
|
14
|
+
readonly kind: 'validation'
|
|
15
|
+
readonly message: string
|
|
16
|
+
/** The first message for each refused field, by its dotted input path (`title`, `lines.0.amount`). */
|
|
17
|
+
readonly fields: Readonly<Record<string, string>>
|
|
18
|
+
}
|
|
19
|
+
| { readonly kind: 'not-found'; readonly message: string }
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: 'other'
|
|
22
|
+
readonly message: string
|
|
23
|
+
/** The HTTP status; `0` when the request never got an answer. */
|
|
24
|
+
readonly status: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FabrikaErrorKind = FabrikaError['kind']
|
|
28
|
+
|
|
29
|
+
interface Issue {
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly message: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
35
|
+
return typeof value === 'object' && value !== null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `{ path, message }` issues of a 400; anything else in `issues` is ignored. */
|
|
39
|
+
function issuesOf(value: unknown): readonly Issue[] {
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
return value.flatMap((issue: unknown) => {
|
|
44
|
+
if (!isRecord(issue)) return []
|
|
45
|
+
const path = issue['path']
|
|
46
|
+
const message = issue['message']
|
|
47
|
+
return typeof path === 'string' && typeof message === 'string' ? [{ path, message }] : []
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The first message per path: a field shows one line. */
|
|
52
|
+
export function fieldsOf(issues: readonly Issue[]): Readonly<Record<string, string>> {
|
|
53
|
+
const fields: Record<string, string> = {}
|
|
54
|
+
for (const issue of issues) {
|
|
55
|
+
fields[issue.path] ??= issue.message
|
|
56
|
+
}
|
|
57
|
+
return Object.freeze(fields)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A refused call as the union, read structurally off the client's `RpcError` (`httpStatus`, `type`, `issues`,
|
|
62
|
+
* `loginUrl`); any other thrown value (a bug in the page) is `other` with status `0`.
|
|
63
|
+
*/
|
|
64
|
+
export function toFabrikaError(error: unknown): FabrikaError {
|
|
65
|
+
const message = error instanceof Error ? error.message : 'Something went wrong'
|
|
66
|
+
const status = isRecord(error) && typeof error['httpStatus'] === 'number' ? error['httpStatus'] : 0
|
|
67
|
+
const type = isRecord(error) ? error['type'] : undefined
|
|
68
|
+
if (status === 401 || type === 'auth') {
|
|
69
|
+
const loginUrl = isRecord(error) ? error['loginUrl'] : undefined
|
|
70
|
+
return { kind: 'unauthenticated', message, loginUrl: typeof loginUrl === 'string' ? loginUrl : null }
|
|
71
|
+
}
|
|
72
|
+
if (status === 403) return { kind: 'forbidden', message }
|
|
73
|
+
if (status === 404) return { kind: 'not-found', message }
|
|
74
|
+
if (status === 409) return { kind: 'conflict', message }
|
|
75
|
+
if (status === 400) return { kind: 'validation', message, fields: fieldsOf(issuesOf(isRecord(error) ? error['issues'] : undefined)) }
|
|
76
|
+
return { kind: 'other', message, status }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The message for one field of a validation error, or `undefined`: what a form shows under that field. */
|
|
80
|
+
export function fieldError(error: FabrikaError | null, path: string): string | undefined {
|
|
81
|
+
return error?.kind === 'validation' ? error.fields[path] : undefined
|
|
82
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The data hooks over the typed client: `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction` and
|
|
2
|
+
// `useInvalidate`. Each takes a client procedure (`api.deals.get`) as the typed name of the call; the answer comes back
|
|
3
|
+
// through the procedure's own output schema, so its type is the contract's.
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
6
|
+
import { z } from 'zod/v4'
|
|
7
|
+
import type { Schema } from '../contract/define'
|
|
8
|
+
import { type FabrikaError, toFabrikaError } from './errors'
|
|
9
|
+
import { parentOf, prefixOf, useFabrika } from './provider'
|
|
10
|
+
import type { PagesEntry, QueryEntry } from './store'
|
|
11
|
+
import type { InputArgument, InvalidateTarget, PagedProcedure, PageInput, TypedProcedure } from './types'
|
|
12
|
+
|
|
13
|
+
/** Pass as the input to wait: the query sends nothing until its input is known. */
|
|
14
|
+
export const skip: unique symbol = Symbol('fabrika.skip')
|
|
15
|
+
|
|
16
|
+
export type QueryState<T> =
|
|
17
|
+
& { readonly fetching: boolean; refetch(): Promise<void> }
|
|
18
|
+
& (
|
|
19
|
+
| { readonly status: 'idle'; readonly data: undefined; readonly error: null }
|
|
20
|
+
| { readonly status: 'loading'; readonly data: undefined; readonly error: null }
|
|
21
|
+
| { readonly status: 'success'; readonly data: T; readonly error: null }
|
|
22
|
+
/** `data` keeps the last answer when a refetch failed. */
|
|
23
|
+
| { readonly status: 'error'; readonly data: T | undefined; readonly error: FabrikaError }
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const IDLE = Object.freeze({ status: 'idle', data: undefined, error: null, fetching: false, refetch: () => Promise.resolve() } as const)
|
|
27
|
+
|
|
28
|
+
const NO_ENTRY = { subscribe: () => () => {}, snapshot: null }
|
|
29
|
+
|
|
30
|
+
/** An answer the cache holds, read back through the output schema; a value it refuses is an error, never a cast. */
|
|
31
|
+
function readBack<O extends Schema>(schema: O, value: unknown): { readonly ok: true; readonly data: z.output<O> } | { readonly ok: false } {
|
|
32
|
+
const parsed = z.safeParse(schema, value)
|
|
33
|
+
return parsed.success ? { ok: true, data: parsed.data } : { ok: false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const UNREADABLE: FabrikaError = { kind: 'other', message: 'the cached answer does not match the output schema', status: 0 }
|
|
37
|
+
|
|
38
|
+
/** Starts the entry's first load once a view shows it, and again after it failed. */
|
|
39
|
+
function useLoad(entry: QueryEntry | PagesEntry | null): void {
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (entry !== null && entry.snapshot.status !== 'success' && !entry.snapshot.fetching) void entry.load()
|
|
42
|
+
}, [entry])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The answer of a query, cached by procedure and input: every view of one call shares one request, and a mutation
|
|
47
|
+
* that invalidates it refetches it. Pass `skip` as the input to wait for a value the input needs.
|
|
48
|
+
*/
|
|
49
|
+
export function useQuery<O extends Schema>(
|
|
50
|
+
procedure: TypedProcedure<Record<never, never> | undefined, O>,
|
|
51
|
+
input?: typeof skip,
|
|
52
|
+
): QueryState<z.output<O>>
|
|
53
|
+
export function useQuery<I, O extends Schema>(procedure: TypedProcedure<I, O>, input: I | typeof skip): QueryState<z.output<O>>
|
|
54
|
+
export function useQuery<I, O extends Schema>(procedure: TypedProcedure<I, O>, input?: I | typeof skip): QueryState<z.output<O>> {
|
|
55
|
+
const fabrika = useFabrika('useQuery')
|
|
56
|
+
const target = useMemo(() => fabrika.target(procedure, 'useQuery'), [fabrika, procedure])
|
|
57
|
+
if (target.node.procedure?.type === 'mutation') throw new Error(`useQuery: '${target.id}' is a mutation; use useMutation`)
|
|
58
|
+
const waiting = input === skip
|
|
59
|
+
const key = waiting ? null : JSON.stringify(input ?? null)
|
|
60
|
+
// The entry follows the input's value, not its identity: a fresh object literal each render is the same query.
|
|
61
|
+
const entry = useMemo(() => (waiting ? null : fabrika.store.query(target, input ?? undefined)), [fabrika, target, key])
|
|
62
|
+
const source = entry ?? NO_ENTRY
|
|
63
|
+
const snapshot = useSyncExternalStore((listener) => source.subscribe(listener), () => source.snapshot, () => source.snapshot)
|
|
64
|
+
useLoad(entry)
|
|
65
|
+
const refetch = useCallback(() => entry?.load() ?? Promise.resolve(), [entry])
|
|
66
|
+
return useMemo((): QueryState<z.output<O>> => {
|
|
67
|
+
if (snapshot === null) return IDLE
|
|
68
|
+
const fetching = snapshot.fetching
|
|
69
|
+
if (snapshot.status === 'loading') return { status: 'loading', data: undefined, error: null, fetching, refetch }
|
|
70
|
+
const read = snapshot.data === undefined ? null : readBack(procedure.output, snapshot.data)
|
|
71
|
+
if (read !== null && !read.ok) return { status: 'error', data: undefined, error: UNREADABLE, fetching, refetch }
|
|
72
|
+
const data = read?.data
|
|
73
|
+
if (snapshot.status === 'error' && snapshot.error !== null) return { status: 'error', data, error: snapshot.error, fetching, refetch }
|
|
74
|
+
return data === undefined
|
|
75
|
+
? { status: 'loading', data: undefined, error: null, fetching, refetch }
|
|
76
|
+
: { status: 'success', data, error: null, fetching, refetch }
|
|
77
|
+
}, [snapshot, procedure, refetch])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface InfiniteQueryOptions {
|
|
81
|
+
/** How many pages the first load reads: `1` (the default) or every page (`'all'`, for a picker's options). */
|
|
82
|
+
readonly pages?: number | 'all'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface InfiniteQueryState<Item> {
|
|
86
|
+
readonly status: 'idle' | 'loading' | 'success' | 'error'
|
|
87
|
+
/** Every item of the loaded pages, in order. */
|
|
88
|
+
readonly items: readonly Item[]
|
|
89
|
+
readonly error: FabrikaError | null
|
|
90
|
+
readonly hasMore: boolean
|
|
91
|
+
readonly fetching: boolean
|
|
92
|
+
readonly loadingMore: boolean
|
|
93
|
+
loadMore(): Promise<void>
|
|
94
|
+
refetch(): Promise<void>
|
|
95
|
+
/** After a failed later page, asks for that page again; after a failed first load, loads again. */
|
|
96
|
+
retry(): Promise<void>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A cursor list (`{ items, next }`, input `after`): the loaded pages as one list and `loadMore` for the next page. A
|
|
101
|
+
* refetch reads as many pages as are loaded again, from the first cursor.
|
|
102
|
+
*/
|
|
103
|
+
export function useInfiniteQuery<I extends { readonly after?: string } | undefined, Item>(
|
|
104
|
+
procedure: PagedProcedure<I, Item>,
|
|
105
|
+
input: PageInput<I> | typeof skip,
|
|
106
|
+
options: InfiniteQueryOptions = {},
|
|
107
|
+
): InfiniteQueryState<Item> {
|
|
108
|
+
const fabrika = useFabrika('useInfiniteQuery')
|
|
109
|
+
const target = useMemo(() => fabrika.target(procedure, 'useInfiniteQuery'), [fabrika, procedure])
|
|
110
|
+
const waiting = input === skip
|
|
111
|
+
const pages = options.pages === 'all' ? Number.POSITIVE_INFINITY : Math.max(1, options.pages ?? 1)
|
|
112
|
+
const key = waiting ? null : JSON.stringify(input ?? null)
|
|
113
|
+
const entry = useMemo(() => (waiting ? null : fabrika.store.pages(target, input ?? undefined, pages)), [fabrika, target, key, pages])
|
|
114
|
+
const source = entry ?? NO_ENTRY
|
|
115
|
+
const snapshot = useSyncExternalStore((listener) => source.subscribe(listener), () => source.snapshot, () => source.snapshot)
|
|
116
|
+
useLoad(entry)
|
|
117
|
+
const loadMore = useCallback(() => entry?.loadMore() ?? Promise.resolve(), [entry])
|
|
118
|
+
const refetch = useCallback(() => entry?.load() ?? Promise.resolve(), [entry])
|
|
119
|
+
return useMemo((): InfiniteQueryState<Item> => {
|
|
120
|
+
const retry = () => (snapshot !== null && snapshot.items.length > 0 && snapshot.next !== null ? loadMore() : refetch())
|
|
121
|
+
if (snapshot === null) {
|
|
122
|
+
return { status: 'idle', items: [], error: null, hasMore: false, fetching: false, loadingMore: false, loadMore, refetch, retry }
|
|
123
|
+
}
|
|
124
|
+
const read = readBack(procedure.output, { items: snapshot.items, next: snapshot.next })
|
|
125
|
+
return {
|
|
126
|
+
status: read.ok ? snapshot.status : 'error',
|
|
127
|
+
items: read.ok ? read.data.items : [],
|
|
128
|
+
error: read.ok ? snapshot.error : UNREADABLE,
|
|
129
|
+
hasMore: snapshot.next !== null,
|
|
130
|
+
fetching: snapshot.fetching,
|
|
131
|
+
loadingMore: snapshot.loadingMore,
|
|
132
|
+
loadMore,
|
|
133
|
+
refetch,
|
|
134
|
+
retry,
|
|
135
|
+
}
|
|
136
|
+
}, [snapshot, procedure, loadMore, refetch])
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type MutationResult<T> = { readonly ok: true; readonly data: T } | { readonly ok: false; readonly error: FabrikaError }
|
|
140
|
+
|
|
141
|
+
export interface MutationState<I, T> {
|
|
142
|
+
readonly status: 'idle' | 'pending' | 'success' | 'error'
|
|
143
|
+
readonly pending: boolean
|
|
144
|
+
readonly data: T | undefined
|
|
145
|
+
readonly error: FabrikaError | null
|
|
146
|
+
/** Runs the call; never throws: a refusal is `{ ok: false, error }` and the state's `error`. */
|
|
147
|
+
mutate(input: I): Promise<MutationResult<T>>
|
|
148
|
+
reset(): void
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface MutationOptions<T> {
|
|
152
|
+
/**
|
|
153
|
+
* What to refetch after a success or a `conflict`: procedures and groups of the client. Without it the procedure's own group
|
|
154
|
+
* (`api.deals` for `api.deals.create`); `[]` refetches nothing.
|
|
155
|
+
*/
|
|
156
|
+
readonly invalidates?: readonly InvalidateTarget[]
|
|
157
|
+
readonly onSuccess?: (data: T) => void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type RunState<T> = Pick<MutationState<unknown, T>, 'status' | 'data' | 'error'>
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The state machine of a call a person starts: pending, then its answer or its refusal, then what it invalidates. When
|
|
164
|
+
* calls overlap, only the latest one's outcome becomes the state, and `pending` holds while any of them runs.
|
|
165
|
+
*/
|
|
166
|
+
function useRun<I, T>(run: (input: I) => Promise<T>, after: () => Promise<void>, onSuccess?: (data: T) => void): MutationState<I, T> {
|
|
167
|
+
const [state, setState] = useState<RunState<T>>({ status: 'idle', data: undefined, error: null })
|
|
168
|
+
const [running, setRunning] = useState(0)
|
|
169
|
+
const latest = useRef(0)
|
|
170
|
+
const mutate = useCallback(async (input: I): Promise<MutationResult<T>> => {
|
|
171
|
+
const call = ++latest.current
|
|
172
|
+
setRunning((count) => count + 1)
|
|
173
|
+
setState({ status: 'pending', data: undefined, error: null })
|
|
174
|
+
try {
|
|
175
|
+
let data: T
|
|
176
|
+
try {
|
|
177
|
+
data = await run(input)
|
|
178
|
+
} catch (refusal) {
|
|
179
|
+
const error = toFabrikaError(refusal)
|
|
180
|
+
// A conflict means the rows changed under the page: refetch them, so a form reseeds from the current version.
|
|
181
|
+
if (error.kind === 'conflict') await after()
|
|
182
|
+
if (call === latest.current) setState({ status: 'error', data: undefined, error })
|
|
183
|
+
return { ok: false, error }
|
|
184
|
+
}
|
|
185
|
+
await after()
|
|
186
|
+
if (call === latest.current) setState({ status: 'success', data, error: null })
|
|
187
|
+
onSuccess?.(data)
|
|
188
|
+
return { ok: true, data }
|
|
189
|
+
} finally {
|
|
190
|
+
setRunning((count) => count - 1)
|
|
191
|
+
}
|
|
192
|
+
}, [run, after, onSuccess])
|
|
193
|
+
const reset = useCallback(() => {
|
|
194
|
+
latest.current++
|
|
195
|
+
setState({ status: 'idle', data: undefined, error: null })
|
|
196
|
+
}, [])
|
|
197
|
+
const status = running > 0 ? 'pending' : state.status
|
|
198
|
+
return { ...state, status, pending: running > 0, mutate, reset }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A mutation of the client: `mutate(input)` checks the input with the procedure's schema (a refused input is a
|
|
203
|
+
* `validation` error and sends nothing), calls it, and after a success refetches what `invalidates` names.
|
|
204
|
+
*/
|
|
205
|
+
export function useMutation<I, O extends Schema>(
|
|
206
|
+
procedure: TypedProcedure<I, O>,
|
|
207
|
+
options: MutationOptions<z.output<O>> = {},
|
|
208
|
+
): MutationState<InputArgument<I>, z.output<O>> {
|
|
209
|
+
const fabrika = useFabrika('useMutation')
|
|
210
|
+
const target = useMemo(() => fabrika.target(procedure, 'useMutation'), [fabrika, procedure])
|
|
211
|
+
const run = useCallback(async (input: InputArgument<I>): Promise<z.output<O>> => {
|
|
212
|
+
const read = readBack(procedure.output, await target.run(input ?? undefined))
|
|
213
|
+
if (!read.ok) throw new Error(`'${target.id}' answered a value its output schema refuses`)
|
|
214
|
+
return read.data
|
|
215
|
+
}, [procedure, target])
|
|
216
|
+
const prefixes = useMemo(() => (options.invalidates === undefined ? [parentOf(target.node)] : options.invalidates.map(prefixOf)), [
|
|
217
|
+
target,
|
|
218
|
+
options.invalidates,
|
|
219
|
+
])
|
|
220
|
+
const after = useCallback(() => fabrika.store.invalidate(prefixes), [fabrika, prefixes])
|
|
221
|
+
return useRun(run, after, options.onSuccess)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A call that is no client procedure yet (a file upload until the client covers file collections), with the same
|
|
226
|
+
* state and error union as a mutation. It refetches only what `invalidates` names.
|
|
227
|
+
*/
|
|
228
|
+
export function useAction<I, T>(run: (input: I) => Promise<T>, options: MutationOptions<T> = {}): MutationState<I, T> {
|
|
229
|
+
const fabrika = useFabrika('useAction')
|
|
230
|
+
const prefixes = useMemo(() => (options.invalidates ?? []).map(prefixOf), [options.invalidates])
|
|
231
|
+
const after = useCallback(() => fabrika.store.invalidate(prefixes), [fabrika, prefixes])
|
|
232
|
+
return useRun(run, after, options.onSuccess)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Refetches every shown query under the procedures or groups given, and forgets the cached rest. */
|
|
236
|
+
export function useInvalidate(): (...targets: readonly InvalidateTarget[]) => Promise<void> {
|
|
237
|
+
const fabrika = useFabrika('useInvalidate')
|
|
238
|
+
return useCallback((...targets) => fabrika.store.invalidate(targets.map(prefixOf)), [fabrika])
|
|
239
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @fabrika/app/react — the paved road's data layer of an SPA: a provider over the typed clients, query and mutation
|
|
2
|
+
// hooks typed from the contract, one error union, and the caller document. Browser-safe; React is a peer dependency.
|
|
3
|
+
export { type Caller, callerOf, type CallerScope, Can, type CanProps, useCaller } from './caller'
|
|
4
|
+
export { type FabrikaError, type FabrikaErrorKind, fieldError, toFabrikaError } from './errors'
|
|
5
|
+
export {
|
|
6
|
+
type InfiniteQueryOptions,
|
|
7
|
+
type InfiniteQueryState,
|
|
8
|
+
type MutationOptions,
|
|
9
|
+
type MutationResult,
|
|
10
|
+
type MutationState,
|
|
11
|
+
type QueryState,
|
|
12
|
+
skip,
|
|
13
|
+
useAction,
|
|
14
|
+
useInfiniteQuery,
|
|
15
|
+
useInvalidate,
|
|
16
|
+
useMutation,
|
|
17
|
+
useQuery,
|
|
18
|
+
} from './hooks'
|
|
19
|
+
export { FabrikaProvider, type FabrikaProviderProps } from './provider'
|
|
20
|
+
export type {
|
|
21
|
+
ClientGroup,
|
|
22
|
+
ClientProcedure,
|
|
23
|
+
InputArgument,
|
|
24
|
+
InvalidateTarget,
|
|
25
|
+
PageAnswer,
|
|
26
|
+
PagedProcedure,
|
|
27
|
+
PageInput,
|
|
28
|
+
ProcedureInput,
|
|
29
|
+
ProcedureResult,
|
|
30
|
+
TypedProcedure,
|
|
31
|
+
} from './types'
|