@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.
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,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
+ }