@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.
- 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,607 @@
|
|
|
1
|
+
// `defineApp(vocabulary, tables)`: the staged kit. The vocabulary knows nothing, the tables know the vocabulary, the
|
|
2
|
+
// kit knows both, and the groups and the contract know the kit; nothing types itself by `typeof contract`, so the
|
|
3
|
+
// handlers beside their endpoints, in any module, type-check without a cycle. Browser-safe: it builds plain data and
|
|
4
|
+
// holds each handler beside its site (`handled.ts`), never calling one.
|
|
5
|
+
|
|
6
|
+
import type { RpcCaller } from '@fabrika/auth-core'
|
|
7
|
+
import type { AnyDbIndex, DbIndex, Tables, Unavailable, UnscopedDeclaration, UnscopedMode } from '@fabrika/db/tables'
|
|
8
|
+
import type { z } from 'zod/v4'
|
|
9
|
+
import type { Declared, JobKitContext, KitImports, KitTypes, NoTablesIndex, SqlMode, WebhookKitContext } from './context'
|
|
10
|
+
import {
|
|
11
|
+
type AccessDeclaration,
|
|
12
|
+
type CronDeclaration,
|
|
13
|
+
type DeclaredDeclaration,
|
|
14
|
+
declaredOf,
|
|
15
|
+
type ExportAccessDeclaration,
|
|
16
|
+
type ExportDeclaration,
|
|
17
|
+
type ImportDeclaration,
|
|
18
|
+
isProcedure,
|
|
19
|
+
kitUnknownOptions,
|
|
20
|
+
type LinkAccessDeclaration,
|
|
21
|
+
type LinkMountDeclaration,
|
|
22
|
+
type ProcedureDeclaration,
|
|
23
|
+
type ProcedureTreeDeclaration,
|
|
24
|
+
type ProcedureType,
|
|
25
|
+
type RpcMountDeclaration,
|
|
26
|
+
unknownOptions,
|
|
27
|
+
type WebhookMountDeclaration,
|
|
28
|
+
} from './declaration'
|
|
29
|
+
import type { FORM_CHALLENGE_FIELD } from './defaults'
|
|
30
|
+
import {
|
|
31
|
+
type AnyContract,
|
|
32
|
+
type Contract,
|
|
33
|
+
type CreatableDimension,
|
|
34
|
+
type CronJob,
|
|
35
|
+
type Export,
|
|
36
|
+
type FormChallengeOption,
|
|
37
|
+
type FormJobs,
|
|
38
|
+
type FormMount,
|
|
39
|
+
type FormOptions,
|
|
40
|
+
type InputSchema,
|
|
41
|
+
type Kit,
|
|
42
|
+
type LinkMount,
|
|
43
|
+
type NotLoose,
|
|
44
|
+
type Path,
|
|
45
|
+
peopleOf,
|
|
46
|
+
presentationOf,
|
|
47
|
+
type ProcedureTree,
|
|
48
|
+
type RpcMount,
|
|
49
|
+
type Schema,
|
|
50
|
+
type Surface,
|
|
51
|
+
surfaceKit,
|
|
52
|
+
type WebhookMount,
|
|
53
|
+
} from './define'
|
|
54
|
+
import type { DeclaredFields, Endpoint, ExportKit, Group, GroupBuild, GroupInput, GroupKit, LinkGroupKit, ListOf, PendingIn } from './group'
|
|
55
|
+
import { withHandler, withKitOrigin, withSnapshots } from './handled'
|
|
56
|
+
import { ContractError } from './problems'
|
|
57
|
+
import type { AnyAppVocabulary, AppVocabulary } from './vocabulary'
|
|
58
|
+
|
|
59
|
+
/** Fields a builder sets itself: an options object that carries one does not type-check. */
|
|
60
|
+
type Owned<K extends string> = { readonly [P in K]?: never }
|
|
61
|
+
|
|
62
|
+
/** The kit's types, computed once from the vocabulary, the tables' index and the imports. */
|
|
63
|
+
export type KitOf<V, Ix, Imp extends KitImports> = V extends AppVocabulary<infer A, infer D, infer R, infer Dims, infer M> ? {
|
|
64
|
+
readonly action: A
|
|
65
|
+
readonly dimension: D
|
|
66
|
+
readonly creatable: CreatableDimension<Dims>
|
|
67
|
+
readonly role: R
|
|
68
|
+
readonly dimensions: Dims
|
|
69
|
+
readonly members: M
|
|
70
|
+
readonly index: Ix
|
|
71
|
+
readonly tables: Ix extends { readonly tables: infer T } ? keyof T & string : never
|
|
72
|
+
readonly imports: Imp
|
|
73
|
+
}
|
|
74
|
+
: never
|
|
75
|
+
|
|
76
|
+
export type CronOptions<Acts, Sc, S, U, Is, Ix extends AnyDbIndex> =
|
|
77
|
+
& Owned<'kind' | 'schedule'>
|
|
78
|
+
& { readonly actions: Acts; readonly scope: Sc; readonly timeout?: string; readonly retries?: number }
|
|
79
|
+
& DeclaredFields<S, U, never, Is, Ix>
|
|
80
|
+
|
|
81
|
+
/** A cron job of the kit, handled. */
|
|
82
|
+
export interface KitCronJob<Acts extends string, Sc extends string, D> extends CronJob<Acts, Sc> {
|
|
83
|
+
readonly declared: D
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A cron job before `.handle`: the contract's `jobs` refuses it. */
|
|
87
|
+
export interface CronBuilder<K extends KitTypes, Acts extends string, Sc extends string, D extends Declared<SqlMode, unknown, never, string>> {
|
|
88
|
+
handle(handler: (args: { readonly ctx: JobKitContext<K, Acts, D> }) => void | Promise<void>): KitCronJob<Acts, Sc, D>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type WebhookOptions<B, Acts, I, S, U, Ix extends AnyDbIndex> =
|
|
92
|
+
& Owned<'kind' | 'path'>
|
|
93
|
+
& {
|
|
94
|
+
readonly scheme: 'github-sha256'
|
|
95
|
+
readonly boundTo: B
|
|
96
|
+
readonly actions: Acts
|
|
97
|
+
readonly input: I & NotLoose<I>
|
|
98
|
+
readonly maxBytes?: number
|
|
99
|
+
}
|
|
100
|
+
& DeclaredFields<S, U, never, never, Ix>
|
|
101
|
+
|
|
102
|
+
/** A webhook of the kit, handled. */
|
|
103
|
+
export interface KitWebhook<B extends string, Acts extends string, I extends Schema, D> extends WebhookMount<B, Acts, I> {
|
|
104
|
+
readonly declared: D
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A webhook before `.handle`: the contract refuses it as a mount. */
|
|
108
|
+
export interface WebhookBuilder<
|
|
109
|
+
K extends KitTypes,
|
|
110
|
+
B extends string,
|
|
111
|
+
Acts extends string,
|
|
112
|
+
I extends InputSchema,
|
|
113
|
+
D extends Declared<SqlMode, unknown, never, never>,
|
|
114
|
+
> {
|
|
115
|
+
handle(
|
|
116
|
+
handler: (args: { readonly input: z.output<I>; readonly ctx: WebhookKitContext<K, B, Acts, D> }) => void | Promise<void>,
|
|
117
|
+
): KitWebhook<B, Acts, I, D>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A link mount's procedures: handled endpoints whose access names one of the mount's granted actions. */
|
|
121
|
+
export interface LinkTree {
|
|
122
|
+
readonly [name: string]: Endpoint<ProcedureType, InputSchema | null, Schema, LinkAccessDeclaration, unknown>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An export's procedures: handled queries. */
|
|
126
|
+
export interface ExportTree {
|
|
127
|
+
readonly [name: string]: Endpoint<'query', InputSchema | null, Schema, ExportAccessDeclaration, unknown>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What `c.rpc` mounts: one group, or groups by name, nested. */
|
|
131
|
+
export type MountedGroups =
|
|
132
|
+
| Group<readonly [RpcCaller, ...RpcCaller[]], GroupInput>
|
|
133
|
+
| { readonly [name: string]: MountedGroups }
|
|
134
|
+
|
|
135
|
+
type CallersOf<G> = G extends Group<infer C, GroupInput> ? C : { [K in keyof G]: CallersOf<G[K]> }[keyof G]
|
|
136
|
+
type EndpointsOf<G> = G extends Group<readonly [RpcCaller, ...RpcCaller[]], infer T> ? T : { readonly [K in keyof G]: EndpointsOf<G[K]> }
|
|
137
|
+
|
|
138
|
+
type Callers<G> = CallersOf<G> extends infer C extends readonly [RpcCaller, ...RpcCaller[]] ? C : never
|
|
139
|
+
type Endpoints<G> = EndpointsOf<G> extends infer T extends ProcedureTree ? T : never
|
|
140
|
+
|
|
141
|
+
type IsUnion<T, All = T> = T extends unknown ? ([All] extends [T] ? false : true) : never
|
|
142
|
+
|
|
143
|
+
type PendingOf<G, Path extends string = ''> = G extends Group<readonly [RpcCaller, ...RpcCaller[]], infer T> ? PendingIn<T, Path>
|
|
144
|
+
: { [K in keyof G & string]: PendingOf<G[K], `${Path}${K}.`> }[keyof G & string]
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A mount takes its prefix only when it can serve every group: each endpoint took its `.handle(…)` (a group accepts
|
|
148
|
+
* one without, because its own callback cannot be checked before it is typed), and every group names the same callers.
|
|
149
|
+
*/
|
|
150
|
+
type MountPrefix<G> = [PendingOf<G>] extends [never]
|
|
151
|
+
? true extends IsUnion<CallersOf<G>> ? Unavailable<'every group of one RPC mount names the same callers, in the same order'> : Path
|
|
152
|
+
: Unavailable<`add .handle(…) to ${ListOf<PendingOf<G>>}: every endpoint takes its handler beside it`>
|
|
153
|
+
|
|
154
|
+
/** The inbox tables of the kit: each a form's own table (`scope: { inbox, read }`). */
|
|
155
|
+
type InboxTables<Ix extends AnyDbIndex> = {
|
|
156
|
+
[T in keyof Ix['tables'] & string]: Ix['tables'][T]['mode'] extends 'inbox' ? T : never
|
|
157
|
+
}[keyof Ix['tables'] & string]
|
|
158
|
+
|
|
159
|
+
/** `unknown` for one literal name; a widened or union name would let the check below pass for any other. */
|
|
160
|
+
type OneName<N, Message extends string> = string extends N ? Unavailable<Message> : true extends IsUnion<N> ? Unavailable<Message> : unknown
|
|
161
|
+
|
|
162
|
+
type LiteralInbox = "appendTo names the form's inbox table as a literal: appendTo: '<inbox table>'"
|
|
163
|
+
type LiteralHoneypot = "honeypot names the field the page hides as a literal: honeypot: '<field>'"
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `appendTo` names one inbox table, and the form's input fields are its columns, the honeypot `H` and the challenge
|
|
167
|
+
* token `Ch` exempt: the form writes each field to the column of its name. A widened honeypot is refused here too,
|
|
168
|
+
* since it would exempt every field.
|
|
169
|
+
*/
|
|
170
|
+
type InboxTarget<Ix extends AnyDbIndex, T, Fields, H, Ch> = unknown extends OneName<T, LiteralInbox>
|
|
171
|
+
? T extends InboxTables<Ix> & string
|
|
172
|
+
? unknown extends OneName<H, LiteralHoneypot>
|
|
173
|
+
? ExtraFields<T, Exclude<Fields & string, keyof Ix['tables'][T]['row'] | H | Ch>, keyof Ix['tables'][T]['row'] & string>
|
|
174
|
+
: OneName<H, LiteralHoneypot>
|
|
175
|
+
: [InboxTables<Ix>] extends [never]
|
|
176
|
+
? Unavailable<"no table of src/db/tables.ts is a form's inbox: declare one with scope: { inbox: '<form>', read }">
|
|
177
|
+
: Unavailable<`${T & string} is not an inbox table of src/db/tables.ts (inboxes: ${ListOf<InboxTables<Ix> & string>})`>
|
|
178
|
+
: OneName<T, LiteralInbox>
|
|
179
|
+
|
|
180
|
+
type ExtraFields<T extends string, Extra extends string, Columns extends string> = [Extra] extends [never] ? unknown
|
|
181
|
+
: Unavailable<`the inbox ${T} has no column ${ListOf<Extra>}: each input field is named like its column (columns: ${ListOf<Columns>})`>
|
|
182
|
+
|
|
183
|
+
/** The challenge token's field, exempt from the inbox's columns where the form declares a challenge. */
|
|
184
|
+
type ChallengeField<Ch> = [Ch] extends [never] ? never : typeof FORM_CHALLENGE_FIELD
|
|
185
|
+
|
|
186
|
+
/** The builders of the contract's callback: the mounts that take no handler, and `rpc`, which mounts groups. */
|
|
187
|
+
export interface ContractKit<K extends KitTypes> extends Pick<Kit<K['action'], K['dimension'], K['creatable']>, 'spa' | 'health' | 'files'> {
|
|
188
|
+
/** A form that appends each submission to its inbox `appendTo`, one column per input field, and answers a constant. */
|
|
189
|
+
form<
|
|
190
|
+
I extends InputSchema,
|
|
191
|
+
const T extends string,
|
|
192
|
+
const H extends string = never,
|
|
193
|
+
const Ch extends FormChallengeOption = never,
|
|
194
|
+
const Then extends string = never,
|
|
195
|
+
>(
|
|
196
|
+
path: Path,
|
|
197
|
+
options: FormOptions<I, Then> & {
|
|
198
|
+
readonly appendTo: T & NoInfer<InboxTarget<K['index'], T, keyof z.output<I>, H, ChallengeField<Ch>>>
|
|
199
|
+
readonly honeypot?: H & NoInfer<OneName<H, LiteralHoneypot>>
|
|
200
|
+
readonly challenge?: Ch
|
|
201
|
+
},
|
|
202
|
+
): FormMount<I, NoInfer<Then>>
|
|
203
|
+
/** The groups' endpoints at `POST <prefix>/<group>.<endpoint>`, to the callers the groups name. */
|
|
204
|
+
rpc<const G extends MountedGroups>(
|
|
205
|
+
prefix: MountPrefix<G>,
|
|
206
|
+
groups: G,
|
|
207
|
+
): RpcMount<Callers<G>, Endpoints<G>>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The `imports` section the contract carries: the kit's imports, with `usedBy` from the endpoints' `connect`. */
|
|
211
|
+
type ImportsSection<K extends KitTypes> = [keyof K['imports']] extends [never] ? unknown
|
|
212
|
+
: { readonly imports: { readonly [N in keyof K['imports']]: ImportDeclaration } }
|
|
213
|
+
|
|
214
|
+
/** The contract `app.contract` returns: the same `Contract` the client, the compiler and the test kit read. */
|
|
215
|
+
export type KitContract<K extends KitTypes, S extends Surface> = Contract<
|
|
216
|
+
K['action'],
|
|
217
|
+
K['dimension'],
|
|
218
|
+
K['role'],
|
|
219
|
+
K['dimensions'],
|
|
220
|
+
S & ImportsSection<K>
|
|
221
|
+
>
|
|
222
|
+
|
|
223
|
+
/** The kit of one application: every builder is typed by its vocabulary, its tables and its imports. */
|
|
224
|
+
export interface App<K extends KitTypes> {
|
|
225
|
+
/** Endpoints for `callers`, declared and handled together; `fabrika.contract.ts` mounts the group with `c.rpc`. */
|
|
226
|
+
group<const C extends readonly [RpcCaller, ...RpcCaller[]], const T extends GroupBuild>(
|
|
227
|
+
options: { readonly callers: C } & Owned<'kind' | 'endpoints'>,
|
|
228
|
+
build: (r: GroupKit<K, C[number]>) => T,
|
|
229
|
+
): Group<C, T>
|
|
230
|
+
/** A cron job (UTC, five fields) holding `actions` in `scope`; the contract mounts it under `jobs`. */
|
|
231
|
+
cron<
|
|
232
|
+
const Acts extends readonly [K['action'], ...K['action'][]],
|
|
233
|
+
const Sc extends K['dimension'] | 'app',
|
|
234
|
+
const S extends SqlMode = never,
|
|
235
|
+
const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
|
|
236
|
+
const Is extends string = never,
|
|
237
|
+
>(schedule: string, options: CronOptions<Acts, Sc, S, U, Is, K['index']>): CronBuilder<K, Acts[number], Sc, Declared<S, U, never, Is>>
|
|
238
|
+
/** A GitHub webhook bound per value of `boundTo`; the contract mounts it by name. */
|
|
239
|
+
webhook<
|
|
240
|
+
const B extends K['dimension'],
|
|
241
|
+
const Acts extends readonly [K['action'], ...K['action'][]],
|
|
242
|
+
I extends InputSchema,
|
|
243
|
+
const S extends SqlMode = never,
|
|
244
|
+
const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
|
|
245
|
+
>(path: Path, options: WebhookOptions<B, Acts, I, S, U, K['index']>): WebhookBuilder<K, B, Acts[number], I, Declared<S, U, never, never>>
|
|
246
|
+
/** A share SPA under `prefix` and its procedures under `<prefix>/api`, for an anonymous key bound to one object. */
|
|
247
|
+
link<
|
|
248
|
+
const LD extends K['dimension'],
|
|
249
|
+
const G extends readonly [K['action'], ...K['action'][]],
|
|
250
|
+
const IB extends K['action'],
|
|
251
|
+
const T extends LinkTree,
|
|
252
|
+
>(
|
|
253
|
+
prefix: Path,
|
|
254
|
+
options: {
|
|
255
|
+
readonly spa: string
|
|
256
|
+
readonly dimension: LD
|
|
257
|
+
readonly grants: G
|
|
258
|
+
readonly issuedBy: IB
|
|
259
|
+
readonly maxDays: number
|
|
260
|
+
readonly procedures: (l: LinkGroupKit<K, LD, G[number]>) => T
|
|
261
|
+
} & Owned<'kind' | 'prefix'>,
|
|
262
|
+
): LinkMount<LD, G[number], IB, T>
|
|
263
|
+
/** Read-only queries another application's connector calls; the contract mounts it under `exports`. */
|
|
264
|
+
export<const T extends ExportTree>(
|
|
265
|
+
definition: {
|
|
266
|
+
readonly version: number
|
|
267
|
+
readonly consumers: readonly [string, ...string[]]
|
|
268
|
+
readonly audience: 'any consumer user'
|
|
269
|
+
readonly procedures: (e: ExportKit<K>) => T
|
|
270
|
+
} & Owned<'kind'>,
|
|
271
|
+
): Export<T>
|
|
272
|
+
/** The contract: mounts by name and the `files`, `jobs`, `exports` and `csp` sections; `imports` come from the kit. */
|
|
273
|
+
contract<const S extends Surface & Owned<'imports'> & FormJobs<S>>(build: (c: ContractKit<K>) => S): KitContract<K, S>
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The application's kit, built once in `src/app-kit.ts`: `defineApp(vocabulary, tables)`, plus the connectors it
|
|
278
|
+
* imports (`{ imports: { <name>: { snapshot, procedures, timeoutMs? } } }`). The tables' `DbIndex` is computed here,
|
|
279
|
+
* once, so each endpoint's `ctx.db` is a lookup.
|
|
280
|
+
*
|
|
281
|
+
* This signature is the kit's typed face over `RuntimeApp`, which builds plain declarations. The face answers types
|
|
282
|
+
* no implementation can return (`.handle`'s `ExactReturn`, `contract`'s `imports` section by the kit's imports), so
|
|
283
|
+
* the compiler relates the two nowhere else; `kit-runtime.test.ts` holds that each builder returns what its type says.
|
|
284
|
+
*/
|
|
285
|
+
export function defineApp<V extends AnyAppVocabulary>(vocabulary: V): App<KitOf<V, NoTablesIndex, Record<never, never>>>
|
|
286
|
+
export function defineApp<V extends AnyAppVocabulary, DB, P>(
|
|
287
|
+
vocabulary: V,
|
|
288
|
+
tables: Tables<DB, P>,
|
|
289
|
+
): App<KitOf<V, DbIndex<DB, P, V>, Record<never, never>>>
|
|
290
|
+
/** Options that may be absent are no overload: the contract's type would promise an imports section it may not have. */
|
|
291
|
+
export function defineApp<V extends AnyAppVocabulary, DB, P, const Imp extends KitImports>(
|
|
292
|
+
vocabulary: V,
|
|
293
|
+
tables: Tables<DB, P>,
|
|
294
|
+
options: { readonly imports: Imp },
|
|
295
|
+
): App<KitOf<V, DbIndex<DB, P, V>, Imp>>
|
|
296
|
+
export function defineApp(vocabulary: AnyAppVocabulary, tables?: unknown, options?: { readonly imports: KitImports }): object {
|
|
297
|
+
return runtimeApp(vocabulary, options?.imports, tables !== undefined)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── The runtime ─────────────────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/** A site before `.handle`: its declaration as own fields, and `handle` on the prototype, so a spread copies only them. */
|
|
303
|
+
class Pending<T extends object> {
|
|
304
|
+
readonly #declaration: T
|
|
305
|
+
|
|
306
|
+
constructor(declaration: T) {
|
|
307
|
+
this.#declaration = declaration
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A copy of the declaration that carries `handler`; the pending site itself stays without one. */
|
|
311
|
+
handle(handler: unknown): T {
|
|
312
|
+
return withHandler({ ...this.#declaration }, handler)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
type PendingSite<T extends object> = Pending<T> & T
|
|
317
|
+
|
|
318
|
+
const pending = <T extends object>(declaration: T): PendingSite<T> => Object.assign(new Pending(declaration), declaration)
|
|
319
|
+
|
|
320
|
+
interface EndpointOptions<Access> extends DeclaredDeclaration {
|
|
321
|
+
readonly input?: Schema | null
|
|
322
|
+
readonly output: Schema
|
|
323
|
+
readonly access: Access
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function endpoint<Access>(type: ProcedureType, def: EndpointOptions<Access>): PendingSite<ProcedureDeclaration<Schema, Access>> {
|
|
327
|
+
const declaration: ProcedureDeclaration<Schema, Access> = {
|
|
328
|
+
'~kind': 'procedure',
|
|
329
|
+
type,
|
|
330
|
+
input: def.input ?? null,
|
|
331
|
+
output: def.output,
|
|
332
|
+
access: def.access,
|
|
333
|
+
declared: declaredOf(def),
|
|
334
|
+
unknownOptions: kitUnknownOptions(def, 'procedure'),
|
|
335
|
+
}
|
|
336
|
+
return pending(declaration)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface RuntimeEndpointKit<Access> {
|
|
340
|
+
query(def: EndpointOptions<Access>): PendingSite<ProcedureDeclaration<Schema, Access>>
|
|
341
|
+
mutation(def: EndpointOptions<Access>): PendingSite<ProcedureDeclaration<Schema, Access>>
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const endpointKit = <Access>(): RuntimeEndpointKit<Access> => ({
|
|
345
|
+
query: (def) => endpoint('query', def),
|
|
346
|
+
mutation: (def) => endpoint('mutation', def),
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
/** A group as data: its callers and its endpoints, which a mount nests by name. */
|
|
350
|
+
interface GroupDeclaration {
|
|
351
|
+
readonly '~kind': 'group'
|
|
352
|
+
readonly callers: readonly [RpcCaller, ...RpcCaller[]]
|
|
353
|
+
readonly endpoints: ProcedureTreeDeclaration<Schema>
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** What `app.group` returns at runtime; `unknownOptions` are the group's option keys besides `callers`. */
|
|
357
|
+
interface RuntimeGroup extends GroupDeclaration {
|
|
358
|
+
readonly unknownOptions: readonly string[]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
interface CronSiteOptions extends DeclaredDeclaration {
|
|
362
|
+
readonly actions: readonly string[]
|
|
363
|
+
readonly scope: string
|
|
364
|
+
readonly timeout?: string
|
|
365
|
+
readonly retries?: number
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
interface WebhookSiteOptions extends DeclaredDeclaration {
|
|
369
|
+
readonly scheme: 'github-sha256'
|
|
370
|
+
readonly boundTo: string
|
|
371
|
+
readonly actions: readonly string[]
|
|
372
|
+
readonly input: Schema
|
|
373
|
+
readonly maxBytes?: number
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface LinkSiteOptions {
|
|
377
|
+
readonly spa: string
|
|
378
|
+
readonly dimension: string
|
|
379
|
+
readonly grants: readonly string[]
|
|
380
|
+
readonly issuedBy: string
|
|
381
|
+
readonly maxDays: number
|
|
382
|
+
readonly procedures: (l: RuntimeEndpointKit<LinkAccessDeclaration>) => ProcedureTreeDeclaration<Schema, LinkAccessDeclaration>
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface ExportSiteOptions {
|
|
386
|
+
readonly version: number
|
|
387
|
+
readonly consumers: readonly string[]
|
|
388
|
+
readonly audience: string
|
|
389
|
+
readonly procedures: (e: Pick<RuntimeEndpointKit<ExportAccessDeclaration>, 'query'>) => ProcedureTreeDeclaration<Schema, ExportAccessDeclaration>
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
type MountedGroupsValue = GroupDeclaration | { readonly [name: string]: MountedGroupsValue }
|
|
393
|
+
|
|
394
|
+
interface RuntimeContractKit extends Pick<Kit<string, string, string>, 'spa' | 'health' | 'form' | 'files'> {
|
|
395
|
+
rpc(prefix: Path, groups: MountedGroupsValue): RpcMountDeclaration<Schema>
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface RuntimeApp {
|
|
399
|
+
group(
|
|
400
|
+
options: { readonly callers: readonly [RpcCaller, ...RpcCaller[]] },
|
|
401
|
+
build: (r: RuntimeEndpointKit<AccessDeclaration>) => ProcedureTreeDeclaration<Schema>,
|
|
402
|
+
): RuntimeGroup
|
|
403
|
+
cron(schedule: string, options: CronSiteOptions): PendingSite<CronDeclaration>
|
|
404
|
+
webhook(path: Path, options: WebhookSiteOptions): PendingSite<WebhookMountDeclaration<Schema>>
|
|
405
|
+
link(prefix: Path, options: LinkSiteOptions): LinkMountDeclaration<Schema>
|
|
406
|
+
export(definition: ExportSiteOptions): ExportDeclaration<Schema>
|
|
407
|
+
contract(build: (c: RuntimeContractKit) => Surface): AnyContract
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `imports` is what `defineApp`'s options carry; the contract has an imports section exactly when they carry one.
|
|
412
|
+
* `tables` is whether the kit was given tables: a contract of a kit without them is served without a database.
|
|
413
|
+
*/
|
|
414
|
+
function runtimeApp(vocabulary: AnyAppVocabulary, imports: KitImports | undefined, tables: boolean): RuntimeApp {
|
|
415
|
+
return {
|
|
416
|
+
group: (options, build) => ({
|
|
417
|
+
'~kind': 'group',
|
|
418
|
+
callers: options.callers,
|
|
419
|
+
endpoints: build(endpointKit()),
|
|
420
|
+
unknownOptions: unknownOptions(options, 'rpc'),
|
|
421
|
+
}),
|
|
422
|
+
cron: (schedule, options) => {
|
|
423
|
+
const declaration: CronDeclaration = {
|
|
424
|
+
kind: 'cron',
|
|
425
|
+
schedule,
|
|
426
|
+
actions: options.actions,
|
|
427
|
+
scope: options.scope,
|
|
428
|
+
timeout: options.timeout,
|
|
429
|
+
retries: options.retries,
|
|
430
|
+
declared: declaredOf(options),
|
|
431
|
+
unknownOptions: kitUnknownOptions(options, 'cron'),
|
|
432
|
+
}
|
|
433
|
+
return pending(declaration)
|
|
434
|
+
},
|
|
435
|
+
webhook: (path, options) => {
|
|
436
|
+
const declaration: WebhookMountDeclaration<Schema> = {
|
|
437
|
+
kind: 'webhook',
|
|
438
|
+
path,
|
|
439
|
+
scheme: options.scheme,
|
|
440
|
+
boundTo: options.boundTo,
|
|
441
|
+
actions: options.actions,
|
|
442
|
+
input: options.input,
|
|
443
|
+
maxBytes: options.maxBytes,
|
|
444
|
+
declared: declaredOf(options),
|
|
445
|
+
unknownOptions: kitUnknownOptions(options, 'webhook'),
|
|
446
|
+
}
|
|
447
|
+
return pending(declaration)
|
|
448
|
+
},
|
|
449
|
+
link: (prefix, options) => ({
|
|
450
|
+
kind: 'link',
|
|
451
|
+
prefix,
|
|
452
|
+
spa: options.spa,
|
|
453
|
+
dimension: options.dimension,
|
|
454
|
+
grants: options.grants,
|
|
455
|
+
issuedBy: options.issuedBy,
|
|
456
|
+
maxDays: options.maxDays,
|
|
457
|
+
procedures: options.procedures(endpointKit()),
|
|
458
|
+
unknownOptions: unknownOptions(options, 'link'),
|
|
459
|
+
}),
|
|
460
|
+
export: (definition) => ({
|
|
461
|
+
kind: 'export',
|
|
462
|
+
version: definition.version,
|
|
463
|
+
consumers: definition.consumers,
|
|
464
|
+
audience: definition.audience,
|
|
465
|
+
procedures: definition.procedures(endpointKit()),
|
|
466
|
+
unknownOptions: unknownOptions(definition, 'export'),
|
|
467
|
+
}),
|
|
468
|
+
contract: (build) => {
|
|
469
|
+
const shared = surfaceKit()
|
|
470
|
+
const surface = build({ rpc, spa: shared.spa, health: shared.health, form: shared.form, files: shared.files })
|
|
471
|
+
if (surface.imports !== undefined) {
|
|
472
|
+
throw new ContractError(['contract: imports come from defineApp(vocabulary, tables, { imports }); remove the section'])
|
|
473
|
+
}
|
|
474
|
+
const declared = imports === undefined ? surface : { ...surface, imports: kitImportsSection(importDeclarations(imports), surface) }
|
|
475
|
+
const contract: AnyContract = {
|
|
476
|
+
vocabulary: {
|
|
477
|
+
app: vocabulary.app,
|
|
478
|
+
dimensions: vocabulary.dimensions,
|
|
479
|
+
actions: vocabulary.actions,
|
|
480
|
+
roles: vocabulary.roles,
|
|
481
|
+
...presentationOf(vocabulary),
|
|
482
|
+
...peopleOf(vocabulary),
|
|
483
|
+
},
|
|
484
|
+
dimensions: vocabulary.dimensions,
|
|
485
|
+
surface: declared,
|
|
486
|
+
}
|
|
487
|
+
const built = withSnapshots(contract, Object.fromEntries(Object.entries(imports ?? {}).map(([name, imported]) => [name, imported.snapshot])))
|
|
488
|
+
return withKitOrigin(built, tables ? 'tables' : 'tableless')
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `c.rpc(prefix, groups)`: the groups' endpoints under the prefix, nested by the names `groups` gives them. */
|
|
494
|
+
function rpc(prefix: Path, groups: MountedGroupsValue): RpcMountDeclaration<Schema> {
|
|
495
|
+
const mounted = mountedGroups(groups)
|
|
496
|
+
const [first] = mounted
|
|
497
|
+
if (first === undefined) {
|
|
498
|
+
throw new ContractError([`c.rpc('${prefix}'): mounts no group (allowed: c.rpc('${prefix}', { <name>: <group> }))`])
|
|
499
|
+
}
|
|
500
|
+
if (mounted.some((group) => !sameCallers(group.callers, first.callers))) {
|
|
501
|
+
throw new ContractError([`c.rpc('${prefix}'): every group of one RPC mount names the same callers, in the same order`])
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
kind: 'rpc',
|
|
505
|
+
prefix,
|
|
506
|
+
callers: first.callers,
|
|
507
|
+
procedures: mountedProcedures(groups),
|
|
508
|
+
unknownOptions: [...new Set(mounted.flatMap((group) => isRuntimeGroup(group) ? group.unknownOptions : []))],
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isGroup(value: MountedGroupsValue): value is GroupDeclaration {
|
|
513
|
+
return value['~kind'] === 'group'
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isRuntimeGroup(value: GroupDeclaration): value is RuntimeGroup {
|
|
517
|
+
return 'unknownOptions' in value && Array.isArray(value.unknownOptions)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function mountedGroups(value: MountedGroupsValue): GroupDeclaration[] {
|
|
521
|
+
if (typeof value !== 'object' || value === null) {
|
|
522
|
+
throw new ContractError([`c.rpc: ${String(value)} is not a group (allowed: app.group(…), or { <name>: <group> })`])
|
|
523
|
+
}
|
|
524
|
+
if (isGroup(value)) {
|
|
525
|
+
return [value]
|
|
526
|
+
}
|
|
527
|
+
return Object.values(value).flatMap(mountedGroups)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function mountedProcedures(value: MountedGroupsValue): ProcedureTreeDeclaration<Schema> {
|
|
531
|
+
if (isGroup(value)) {
|
|
532
|
+
return value.endpoints
|
|
533
|
+
}
|
|
534
|
+
return Object.fromEntries(Object.entries(value).map(([name, nested]) => [name, mountedProcedures(nested)]))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const sameCallers = (left: readonly string[], right: readonly string[]): boolean =>
|
|
538
|
+
left.length === right.length && left.every((caller, index) => caller === right[index])
|
|
539
|
+
|
|
540
|
+
function importDeclarations(imports: KitImports): Record<string, KitImportDeclaration> {
|
|
541
|
+
return Object.fromEntries(
|
|
542
|
+
Object.entries(imports).map(([name, imported]) => [name, {
|
|
543
|
+
app: imported.snapshot.app,
|
|
544
|
+
export: imported.snapshot.export,
|
|
545
|
+
version: imported.snapshot.version,
|
|
546
|
+
digest: imported.snapshot.digest,
|
|
547
|
+
procedures: imported.procedures,
|
|
548
|
+
timeoutMs: imported.timeoutMs,
|
|
549
|
+
}]),
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** One import of the kit as the contract records it: the snapshot's identity, the procedures called, the deadline. */
|
|
554
|
+
export interface KitImportDeclaration {
|
|
555
|
+
readonly app: string
|
|
556
|
+
readonly export: string
|
|
557
|
+
readonly version: number
|
|
558
|
+
readonly digest: string
|
|
559
|
+
readonly procedures: readonly string[]
|
|
560
|
+
readonly timeoutMs?: number
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The contract's `imports` section: each import of the kit, used by the RPC endpoints that declare `connect` of it,
|
|
565
|
+
* in id order. The static reader builds it with this same function, so both read one `usedBy`.
|
|
566
|
+
*/
|
|
567
|
+
export function kitImportsSection(imports: Readonly<Record<string, KitImportDeclaration>>, surface: Surface): Record<string, ImportDeclaration> {
|
|
568
|
+
const connecting = connectingEndpoints(surface)
|
|
569
|
+
return Object.fromEntries(
|
|
570
|
+
Object.entries(imports).map(([name, declaration]) => [name, {
|
|
571
|
+
app: declaration.app,
|
|
572
|
+
export: declaration.export,
|
|
573
|
+
version: declaration.version,
|
|
574
|
+
digest: declaration.digest,
|
|
575
|
+
procedures: [...declaration.procedures],
|
|
576
|
+
usedBy: connecting.filter((site) => site.connect.includes(name)).map((site) => site.id).sort(),
|
|
577
|
+
...(declaration.timeoutMs === undefined ? {} : { timeoutMs: declaration.timeoutMs }),
|
|
578
|
+
}]),
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Every procedure of an RPC mount that declares a `connect` list, by its id. */
|
|
583
|
+
function connectingEndpoints(surface: Surface): { readonly id: string; readonly connect: readonly unknown[] }[] {
|
|
584
|
+
const found: { readonly id: string; readonly connect: readonly unknown[] }[] = []
|
|
585
|
+
const visit = (tree: ProcedureTreeDeclaration<Schema>, prefix: string): void => {
|
|
586
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
587
|
+
const id = `${prefix}.${name}`
|
|
588
|
+
if (!isProcedure(node)) {
|
|
589
|
+
visit(node, id)
|
|
590
|
+
} else if (isRecord(node.declared) && Array.isArray(node.declared['connect'])) {
|
|
591
|
+
found.push({ id, connect: node.declared['connect'] })
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
for (const [name, mount] of Object.entries(surface)) {
|
|
596
|
+
if (typeof mount === 'object' && isRpcMount(mount)) {
|
|
597
|
+
visit(mount.procedures, name)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return found
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
604
|
+
|
|
605
|
+
function isRpcMount(value: object): value is RpcMountDeclaration<Schema> {
|
|
606
|
+
return 'kind' in value && value.kind === 'rpc' && 'procedures' in value
|
|
607
|
+
}
|