@fabrika/app 0.3.3 → 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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. 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'