@abide/abide 0.116.3 → 0.116.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @abide/abide
2
2
 
3
+ ## 0.116.4
4
+
5
+ ### Patch Changes
6
+
7
+ - f4acbfd: a handler whose args schema is derived from its type annotation (no `args` gate) now refuses bad args in process as it does over the wire — a direct call, a call from another handler or `app.run`, and every call under `mount` answer 422 `ValidationError` with the same `Issues` before the body runs, where before the body ran with the bad value
8
+ - be88738: a reader that probed a handler before its first call wakes when the call starts — on a server and under `mount`, `disabled={save.busy()}` read before any call stayed enabled for the whole write, because the unreached answer (0.116.3) subscribed the reader to nothing. It now subscribes the reader to the first call in its scope, and still builds nothing and runs no handler (RULEBOOK 3.3, 13.19)
9
+
3
10
  ## 0.116.3
4
11
 
5
12
  ### Patch Changes
package/docs/RULEBOOK.md CHANGED
@@ -754,7 +754,8 @@ mutation's `declare` built while its write is in flight.
754
754
  where every value it sweeps is.
755
755
 
756
756
  13.19 `busy`, `ready`, `synced`, `refresh` and `invalidate` over an `Rpc` no call has reached in the
757
- current scope MUST answer as over a `Selection` with no entries.
757
+ current scope MUST answer as over a `Selection` with no entries, and reading `busy`, `ready` or
758
+ `synced` there MUST subscribe the reader to the first call that reaches it in that scope.
758
759
 
759
760
  # 14. Tracking
760
761
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.116.3",
3
+ "version": "0.116.4",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@ import { COMPONENT_QUERY } from '#server/MODULE_QUERIES.ts'
11
11
  import { ROUTES_TABLE, SERVER_TABLE } from '#server/TABLES.ts'
12
12
  import { VIRTUAL_DIRECTORY } from '#server/VIRTUAL_DIRECTORY.ts'
13
13
  import { CLIENT_ADDRESS } from '#shared/ADDRESSES.ts'
14
+ import type { JsonValue } from '#shared/JsonValue.ts'
14
15
  import { ROUTING_SPECIFIER, WIRE_SPECIFIER } from '#shared/SPECIFIERS.ts'
15
16
  import {
16
17
  type AppLayout,
@@ -317,6 +318,7 @@ async function emitApp(
317
318
  refuseSharedRoutes(routes, mounts, refusals)
318
319
  refuseAddressCollision(routes, mounts, layout.published, refusals)
319
320
  const standIns = standInsOf(mounts)
321
+ const derived = derivedOf(mounts)
320
322
  // 37.1 — every module a server block or a handler file runs in, for the boot to import before
321
323
  // `onStart`.
322
324
  const servers: string[] = []
@@ -329,6 +331,7 @@ async function emitApp(
329
331
  entry,
330
332
  standIns.get(entry.relative),
331
333
  entry.text,
334
+ derived.get(entry.relative),
332
335
  )
333
336
  } catch (thrown) {
334
337
  refusals.at(entry.path, thrown)
@@ -368,6 +371,7 @@ async function emitApp(
368
371
  handlers[mount.source] = {
369
372
  scope: mount.scope,
370
373
  client: standIns.get(mount.source) ?? '',
374
+ args: mount.args,
371
375
  }
372
376
  if (routes.length > 0) {
373
377
  tables[ROUTES_TABLE] = clientTable(routes, transitions)
@@ -624,6 +628,21 @@ function standInsOf(mounts: Mount[]): Map<string, string> {
624
628
  return standIns
625
629
  }
626
630
 
631
+ /**
632
+ * 19.9 — the schema each private handler's annotation derives, by its binding, by source: what its
633
+ * server module holds it to (15.8). A handler file's is its face's (`HandlerFace`).
634
+ */
635
+ function derivedOf(mounts: Mount[]): Map<string, Record<string, JsonValue>> {
636
+ const derived = new Map<string, Record<string, JsonValue>>()
637
+ for (const mount of mounts) {
638
+ if (!mount.private || mount.args === undefined) continue
639
+ const held = derived.get(mount.source) ?? {}
640
+ held[mount.name] = mount.args
641
+ derived.set(mount.source, held)
642
+ }
643
+ return derived
644
+ }
645
+
627
646
  /**
628
647
  * One handler's wrapper, bound as `binding` writes it.
629
648
  *
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from '#shared/JsonValue.ts'
1
2
  import { SERVER_SPECIFIER } from '#shared/SPECIFIERS.ts'
2
3
  import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
3
4
  import { Cursor, placeTemplate } from './Cursor.ts'
@@ -50,6 +51,8 @@ export function emitComponent(
50
51
  route = '/',
51
52
  /** 38.97 — where given, every chunk the module is written around (`sourceMapOf`). */
52
53
  chunks?: LoweredChunk[],
54
+ /** 19.9 — the schema each private handler's annotation derives, by its binding. */
55
+ derived: Readonly<Record<string, JsonValue>> = {},
53
56
  ): string {
54
57
  // Only what the page actually uses. An emitted module carrying an import nothing calls is a
55
58
  // name the app's own tooling reports as unused, in a file nobody wrote.
@@ -91,7 +94,7 @@ export function emitComponent(
91
94
  : `${lowering.use('fileRoute')}(${JSON.stringify(route)})
92
95
  ${serverSetup.body}
93
96
  ${lowering.use('fileRoute')}(undefined)
94
- ${privateExports(file.servers.join('\n'))}`
97
+ ${heldToAnnotations(lowering, derived)}${privateExports(file.servers.join('\n'))}`
95
98
  const setup = splitImports(lowering.scripts())
96
99
  const componentBody = setup.body
97
100
 
@@ -159,6 +162,21 @@ ${level}${closed}
159
162
  `
160
163
  }
161
164
 
165
+ /**
166
+ * 15.8, 19.9 — each private handler held to the schema its annotation derives, once its server block
167
+ * has built it: the module is what the server mounts and what a component under `mount` calls
168
+ * (38.121), so a call is held to it whoever makes it.
169
+ */
170
+ function heldToAnnotations(
171
+ lowering: Lowering,
172
+ derived: Readonly<Record<string, JsonValue>>,
173
+ ): string {
174
+ let out = ''
175
+ for (const name in derived)
176
+ out += `${lowering.use('derivedArgs')}(${name}, ${JSON.stringify(derived[name])})\n`
177
+ return out
178
+ }
179
+
162
180
  /**
163
181
  * An emitted module's imports: each framework name `lowering` used, from the specifier it ships
164
182
  * under, and the author's own from both script blocks, already pointed where they landed
@@ -1,4 +1,5 @@
1
1
  import { STYLE_QUERY } from '#server/MODULE_QUERIES.ts'
2
+ import type { JsonValue } from '#shared/JsonValue.ts'
2
3
  import type { Placement } from '#server/placement.ts'
3
4
  import type { CheckerAnswer } from './CheckerAnswer.ts'
4
5
  import { emitClient } from './emitClient.ts'
@@ -35,6 +36,8 @@ export function emitSource(
35
36
  standIns = '',
36
37
  /** 38.97 — the `.abide`'s text, where the build wants each module's source map. */
37
38
  text?: string,
39
+ /** 19.9 — the schema each private handler it declares derives off its annotation, by name. */
40
+ derived?: Readonly<Record<string, JsonValue>>,
38
41
  ): {
39
42
  server: string
40
43
  client: string
@@ -109,6 +112,7 @@ export function emitSource(
109
112
  kind !== 'component',
110
113
  source.scope,
111
114
  serverChunks,
115
+ derived,
112
116
  )
113
117
  const name = relative.slice(relative.lastIndexOf('/') + 1)
114
118
  const browser = withStandIns(
@@ -120,7 +120,8 @@ export function serveCompilation(root: string, compilation: Compilation): void {
120
120
  const real = realOrAsGiven(root)
121
121
  const handlerFiles = addCompilation(real, compilation, RUNTIME)
122
122
  installSourceMaps(serverMapsOf(compilation, real), real)
123
- if (handlerFiles.length !== 0) Bun.plugin(handlerFilesPlugin(handlerFiles))
123
+ if (handlerFiles.length !== 0)
124
+ Bun.plugin(handlerFilesPlugin(handlerFiles, RUNTIME.handlers))
124
125
  if (registered) return
125
126
  registered = true
126
127
  Bun.plugin(modulesPlugin(RUNTIME))
@@ -132,7 +133,13 @@ export function serveCompilation(root: string, compilation: Compilation): void {
132
133
  // plugin per compilation because the filter is fixed at registration, and the runtime's `onLoad`
133
134
  // cannot fall through, so the filter names the handler files and nothing else. The face it names
134
135
  // is resolved by `modulesPlugin`, registered once.
135
- function handlerFilesPlugin(paths: string[]): Bun.BunPlugin {
136
+ // 15.8, 19.9 — AND HOLDS ITS HANDLER TO THE SCHEMA ITS ANNOTATION DERIVES, once the face has built it:
137
+ // this module is what every import of the file is answered with — the server's mount, a test's own
138
+ // import, a component's under `mount` — so a call is held to it whoever makes it.
139
+ function handlerFilesPlugin(
140
+ paths: string[],
141
+ faces: ReadonlyMap<string, HandlerFace>,
142
+ ): Bun.BunPlugin {
136
143
  return {
137
144
  name: 'abide-handler-files',
138
145
  setup(build) {
@@ -142,10 +149,18 @@ function handlerFilesPlugin(paths: string[]): Bun.BunPlugin {
142
149
  `^(?:${paths.map(RegExp.escape).join('|')})$`,
143
150
  ),
144
151
  },
145
- (args) => ({
146
- contents: `export { default } from ${JSON.stringify(`${args.path}${HANDLER_SUFFIX}`)}\n`,
147
- loader: 'js',
148
- }),
152
+ (args) => {
153
+ const face = JSON.stringify(`${args.path}${HANDLER_SUFFIX}`)
154
+ const schema = faces.get(args.path)?.args
155
+ return {
156
+ contents:
157
+ `export { default } from ${face}\n` +
158
+ (schema === undefined
159
+ ? ''
160
+ : `import __handler from ${face}\nimport { derivedArgs as __derivedArgs } from '${SERVER_SPECIFIER}'\n__derivedArgs(__handler, ${JSON.stringify(schema)})\n`),
161
+ loader: 'js',
162
+ }
163
+ },
149
164
  )
150
165
  },
151
166
  }
@@ -16,7 +16,7 @@ import type { Gated } from '#shared/reactive/argsContract.ts'
16
16
  import { deadlineOf, heldGenerationOf } from '#shared/reactive/index.ts'
17
17
  import type { Defaults } from '#shared/reactive/parameterDefaults.ts'
18
18
  import { notFound, refuse } from '#shared/refuse.ts'
19
- import type { Method, Rpc } from '#shared/rpc.ts'
19
+ import type { HeldToAnnotation, Method, Rpc } from '#shared/rpc.ts'
20
20
  import { argsInURL } from '#shared/rpcRequest.ts'
21
21
  import { VERSION_HEADER } from '#shared/VERSION_HEADER.ts'
22
22
  import { versionInEtag } from '#shared/versionInEtag.ts'
@@ -118,20 +118,13 @@ export type Mounted = Mount & {
118
118
  * subscription to it both pass through (37.2, 18.26).
119
119
  */
120
120
  rungs: AppRung[]
121
- handler: Rpc<unknown, unknown> & Gated<unknown>
121
+ handler: Rpc<unknown, unknown> & Gated<unknown> & HeldToAnnotation
122
122
  /**
123
123
  * The args' JSON Schema, probed once at mount.
124
124
  *
125
125
  * 19.7, 20.11 — a third-party schema converts itself on every probe.
126
126
  */
127
127
  argsDocument: Document | undefined
128
- /**
129
- * What the wire holds args to where the handler declares no `args` gate of its own.
130
- *
131
- * 19.9 — the annotation's schema. An in-process call already holds the type, so the handler
132
- * never runs it.
133
- */
134
- wireArgs: JsonValue | undefined
135
128
  }
136
129
 
137
130
  /**
@@ -172,7 +165,6 @@ export function mountedOwn(
172
165
  result: undefined,
173
166
  handler: handler as Mounted['handler'],
174
167
  argsDocument: undefined,
175
- wireArgs: undefined,
176
168
  },
177
169
  ]
178
170
  }
@@ -198,7 +190,7 @@ export async function mountHandlers(
198
190
  for (let at = 0; at < table.length; at += 1) {
199
191
  const mount = table[at] as Mount
200
192
  const exported = (modules[at] as Record<string, unknown>)[mount.export]
201
- const handler = exported as Rpc<unknown, unknown> & Gated<unknown>
193
+ const handler = exported as Mounted['handler']
202
194
  const declared = argsDocumentOf(handler)
203
195
  // 16.9, 16.10 — the address `rpc.url` and `rpc.raw` build on, under the mount — and what a
204
196
  // page's render turns a call it made into the browser's request by (41.20).
@@ -210,7 +202,6 @@ export async function mountHandlers(
210
202
  rungs: rungsOver(mount.scope),
211
203
  handler,
212
204
  argsDocument: declared ?? (mount.args as Document | undefined),
213
- wireArgs: declared === undefined ? mount.args : undefined,
214
205
  })
215
206
  }
216
207
  return { handlers }
@@ -2,9 +2,9 @@
2
2
  // off the wire: over http (16.2) and a `GET` followed over the socket mux (18.3). Each lane reads the
3
3
  // args off its own transport and answers a refusal in its own form.
4
4
 
5
+ import { DERIVED_ARGS } from '#shared/DERIVED_ARGS.ts'
5
6
  import { normalized } from '#shared/reactive/argsContract.ts'
6
7
  import { validationError } from '#shared/refuse.ts'
7
- import { validateJson } from '#shared/validateJson.ts'
8
8
  import type { Mounted } from './answerRpc.ts'
9
9
  import { coerceArgs } from './coerceArgs.ts'
10
10
 
@@ -16,11 +16,10 @@ import { coerceArgs } from './coerceArgs.ts'
16
16
  * 19.7 — the text as the types the handler's `args` declares. 11.27 — then the defaults, ahead of
17
17
  * every key taken after it: the seed a render filed and the `global` entry standing were both filed
18
18
  * under them (11.29). 19.9 — THE WIRE HELD TO THE ANNOTATION, where no `args` schema is declared to
19
- * hold it. Args that did not come are held as an empty record: a schema is only derived where the
20
- * handler's args are a required record, so a required member missing is refused, not read off nothing.
19
+ * hold it — the check the handler carries (`derivedArgs`), which its in-process call makes too (15.8).
21
20
  */
22
21
  export function argsFor(
23
- found: Pick<Mounted, 'handler' | 'argsDocument' | 'wireArgs'>,
22
+ found: Pick<Mounted, 'handler' | 'argsDocument'>,
24
23
  sent: unknown,
25
24
  query: string | undefined,
26
25
  ): unknown {
@@ -33,9 +32,7 @@ export function argsFor(
33
32
  query,
34
33
  )
35
34
  const args = normalized(found.handler, coerced)
36
- if (found.wireArgs !== undefined) {
37
- const issues = validateJson(found.wireArgs, args ?? {})
38
- if (issues !== null) throw validationError(issues)
39
- }
35
+ const issues = found.handler[DERIVED_ARGS]?.(args) ?? null
36
+ if (issues !== null) throw validationError(issues)
40
37
  return args
41
38
  }
@@ -0,0 +1,25 @@
1
+ import { DERIVED_ARGS } from '#shared/DERIVED_ARGS.ts'
2
+ import type { JsonValue } from '#shared/JsonValue.ts'
3
+ import type { Gated } from '#shared/reactive/argsContract.ts'
4
+ import type { HeldToAnnotation } from '#shared/rpc.ts'
5
+ import { validateJson } from '#shared/validateJson.ts'
6
+ import { argsDocumentOf } from './coerceArgs.ts'
7
+
8
+ /**
9
+ * Holds `handler`'s args to `schema`, the one the build derived off its annotation, for every call
10
+ * of it from now on: in process and over the wire (15.8, 19.9).
11
+ *
12
+ * What the module a handler is loaded through calls once its handler is built — a handler file's
13
+ * face and a `<script server>`'s module — so the schema is there wherever the handler can be
14
+ * called, whether or not an app mounted it. Not where its `args` gate publishes a document of its
15
+ * own, which holds the args itself (19.7). Args that did not come are held as an empty record: a
16
+ * schema is only derived where the handler's args are a required record, so a required member
17
+ * missing is refused, not read off nothing.
18
+ */
19
+ export function derivedArgs(
20
+ handler: HeldToAnnotation & Gated<unknown>,
21
+ schema: JsonValue,
22
+ ): void {
23
+ if (argsDocumentOf(handler) !== undefined) return
24
+ handler[DERIVED_ARGS] = (args) => validateJson(schema, args ?? {})
25
+ }
@@ -3,6 +3,7 @@
3
3
  // adapter that hands bun's hooks to it, because bun decides which hook asks (an `onResolve` for a
4
4
  // specifier, an `onLoad` for a path), and this decides what the answer is.
5
5
 
6
+ import type { JsonValue } from '#shared/JsonValue.ts'
6
7
  import { HANDLER_SUFFIX, SERVER_QUERY } from './MODULE_QUERIES.ts'
7
8
 
8
9
  // A VIRTUAL FACE'S PATH: its file's, with a suffix that is not a query. Both registries key a module
@@ -45,8 +46,15 @@ export type Asked =
45
46
  | 'abide'
46
47
  | 'handler'
47
48
 
48
- /** One handler file, as `Compilation.handlers` holds it. */
49
- export type HandlerFace = { scope: string; client: string }
49
+ /**
50
+ * One handler file, as `Compilation.handlers` holds it — and 19.9's schema its default's annotation
51
+ * derives, which the module at the file's path holds it to (`derivedArgs`).
52
+ */
53
+ export type HandlerFace = {
54
+ scope: string
55
+ client: string
56
+ args: JsonValue | undefined
57
+ }
50
58
 
51
59
  /**
52
60
  * The face an import of `file` is answered with, asked `asked` from `importer` in `lane` — or
@@ -52,6 +52,9 @@ export { type App, createApp } from './createApp.ts'
52
52
  // 41.28's run-time half: an emitted component reads it on either side of its setup, and one whose
53
53
  // setup made a call answers as a page does. Not an app's name.
54
54
  export { callsInRender } from './deferredBuilds.ts'
55
+ // 19.9's run-time half: what the module a handler is loaded through calls to hold it to the schema
56
+ // its annotation derives (15.8). Not an app's name.
57
+ export { derivedArgs } from './derivedArgs.ts'
55
58
  // 37.1's run-time half: what a compiled server module calls around its `<script server>`, so the
56
59
  // hooks it makes are filed at its route.
57
60
  export {
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The member of an `Rpc` holding its args to the schema its annotation derives (19.9): stamped by the
3
+ * module its handler is loaded through, on the server alone, and read by every call of it — in
4
+ * process and over the wire alike (15.8).
5
+ */
6
+ export const DERIVED_ARGS: unique symbol = Symbol.for('abide.derivedArgs')
@@ -15,9 +15,10 @@
15
15
  import { contractOf, type Gated } from './argsContract.ts'
16
16
  import { CELL } from './CELL.ts'
17
17
  import { KEYED_BRAND } from './KEYED_BRAND.ts'
18
- import { brandedPrototype, MEMBER_KEYS } from './Reactive.ts'
18
+ import { brandedPrototype, MEMBER_KEYS, signalMoved } from './Reactive.ts'
19
19
  import { SELECTION_MEMBERS } from './SELECTION_MEMBERS.ts'
20
20
  import { currentScope, runInScope, SCOPING } from './scoping.ts'
21
+ import { newTracked, TRACKING, type Tracked, trackProbe } from './tracking.ts'
21
22
 
22
23
  const UNBUILT: unique symbol = Symbol('abide.unbuilt')
23
24
 
@@ -36,11 +37,18 @@ class Filed {
36
37
  home: unknown
37
38
  make: () => unknown
38
39
  global: boolean
40
+ /**
41
+ * 3.3, 13.19 — what a probe asked before the value was built here subscribes to, moved by the
42
+ * build: the home's. A scope files its own in `instances` under this one as its key, and drops
43
+ * it with the scope.
44
+ */
45
+ unbuilt: Tracked
39
46
 
40
47
  constructor(make: () => unknown, lazy: boolean, global: boolean) {
41
48
  this.make = make
42
49
  this.home = lazy ? UNBUILT : make()
43
50
  this.global = global
51
+ this.unbuilt = newTracked()
44
52
  }
45
53
  }
46
54
 
@@ -56,14 +64,21 @@ type Forwarder = { [FILED]: Filed }
56
64
  function instanceOf(filed: Filed): Record<PropertyKey, unknown> {
57
65
  const scope = filed.global ? undefined : currentScope()
58
66
  if (scope === undefined) {
59
- if (filed.home === UNBUILT)
67
+ if (filed.home === UNBUILT) {
60
68
  filed.home = runInScope(undefined, () => filed.make(), undefined)
69
+ signalMoved(filed.unbuilt)
70
+ }
61
71
  return filed.home as Record<PropertyKey, unknown>
62
72
  }
63
73
  let instance = scope.instances.get(filed)
64
74
  if (instance === undefined) {
65
75
  instance = filed.make()
66
76
  scope.instances.set(filed, instance)
77
+ const asked = scope.instances.get(filed.unbuilt) as Tracked | undefined
78
+ if (asked !== undefined) {
79
+ scope.instances.delete(filed.unbuilt)
80
+ signalMoved(asked)
81
+ }
67
82
  }
68
83
  return instance as Record<PropertyKey, unknown>
69
84
  }
@@ -152,14 +167,33 @@ export function scoped(
152
167
  /**
153
168
  * Whether the value `value` forwards to is built for the scope that is current — always, for a
154
169
  * value that is no forwarder. Asking builds nothing, where `instanceOf` builds what it finds unbuilt.
170
+ *
171
+ * 3.3 — AND WHERE IT IS NOT, ASKING SUBSCRIBES THE READER TO ITS BUILD: a guard that probed before
172
+ * the first call has no cell to hear the call start from, so `disabled={save.busy()}` stayed
173
+ * enabled through the whole write. One signal per value per scope, never one per entry (13.3).
155
174
  */
156
175
  export function builtHere(value: unknown): boolean {
157
176
  const filed = (value as Partial<Forwarder> | null)?.[FILED]
158
177
  if (filed === undefined) return true
159
178
  const scope = filed.global ? undefined : currentScope()
160
- return scope === undefined
161
- ? filed.home !== UNBUILT
162
- : scope.instances.has(filed)
179
+ if (
180
+ scope === undefined
181
+ ? filed.home !== UNBUILT
182
+ : scope.instances.has(filed)
183
+ )
184
+ return true
185
+ const reader = TRACKING.reader
186
+ if (reader === null) return false
187
+ let asked = filed.unbuilt
188
+ if (scope !== undefined) {
189
+ asked = scope.instances.get(filed.unbuilt) as Tracked
190
+ if (asked === undefined) {
191
+ asked = newTracked()
192
+ scope.instances.set(filed.unbuilt, asked)
193
+ }
194
+ }
195
+ trackProbe(asked, reader)
196
+ return false
163
197
  }
164
198
 
165
199
  /**
package/src/shared/rpc.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  // the call hands back a value whose body runs the onion once per args, per request, over the
14
14
  // handler's own.
15
15
 
16
+ import { DERIVED_ARGS } from './DERIVED_ARGS.ts'
16
17
  import { type Failed, isFailedNamed } from './Failed.ts'
17
18
  import { GLOBAL_GETS } from './GLOBAL_GETS.ts'
18
19
  import { HandlerRoute } from './HandlerRoute.ts'
@@ -52,7 +53,7 @@ import { MUTATION } from './reactive/MUTATION.ts'
52
53
  import { dropAnswered } from './reactive/Reactive.ts'
53
54
  import { SELECTION_MEMBERS } from './reactive/SELECTION_MEMBERS.ts'
54
55
  import { builtHere, isForwarder } from './reactive/scoped.ts'
55
- import { checkArgs } from './refuse.ts'
56
+ import { checkArgs, validationError } from './refuse.ts'
56
57
  import { rpcRequest } from './rpcRequest.ts'
57
58
  import type { Schema } from './Schema.ts'
58
59
 
@@ -162,6 +163,16 @@ type Derived<Member, Whole = Member> = Member extends
162
163
  : never
163
164
  : never
164
165
 
166
+ /**
167
+ * 19.9 — the issues an `Rpc`'s args leave against the schema its annotation derives, `null` where
168
+ * they hold: `undefined` until the module its handler is loaded through stamps it (`derivedArgs`),
169
+ * and so in a browser always. A function rather than the schema, so this seam never carries the
170
+ * validator (19.13).
171
+ */
172
+ export type HeldToAnnotation = {
173
+ [DERIVED_ARGS]: ((args: unknown) => Issues<unknown> | null) | undefined
174
+ }
175
+
165
176
  /** Which generated surfaces carry a handler (20.1). */
166
177
  export type Clients = {
167
178
  ui?: boolean
@@ -440,6 +451,11 @@ export function declare(method: Method): Declare {
440
451
  }
441
452
  const call = (given?: unknown, options?: CallOptions): unknown => {
442
453
  const args = normalized(contract, given)
454
+ // 15.8, 19.9 — AND HELD TO ITS ANNOTATION, where the build derived a schema off it: a
455
+ // type an in-process caller wrote holds nothing at run time (`as never`, a value off
456
+ // JSON), so the call is refused as the wire refuses it rather than handed to the body.
457
+ const issues = rpc[DERIVED_ARGS]?.(args) ?? null
458
+ if (issues !== null) return reactive(() => validationError(issues))
443
459
  const route =
444
460
  rpc.address === undefined ? undefined : routeAt(rpc.address)
445
461
  if (route?.segments !== undefined) {
@@ -469,7 +485,7 @@ export function declare(method: Method): Declare {
469
485
  // 13.19 — AND NOT YET BUILT, NONE OF THEM BUILDS IT: building an unkeyed value starts its body
470
486
  // (1.7), so `save.busy()` over a server's never-called `POST(async () => …)` ran the write and
471
487
  // reported it in flight. Until a call reaches it the handler has no entry, and answers as one
472
- // with no entries does.
488
+ // with no entries does — the asking reader subscribed to the call that builds it (`builtHere`).
473
489
  const built = value as Record<
474
490
  (typeof SELECTION_MEMBERS)[number],
475
491
  (pattern?: unknown) => unknown
@@ -487,6 +503,7 @@ export function declare(method: Method): Declare {
487
503
  maxBodySize: options?.maxBodySize,
488
504
  clients: options?.clients ?? {},
489
505
  guarded: rungs.length > 0,
506
+ [DERIVED_ARGS]: undefined as HeldToAnnotation[typeof DERIVED_ARGS],
490
507
  isError: isFailedNamed,
491
508
  url(args?: Record<string, unknown>): string {
492
509
  return rpcRequest(method, routeAt(addressOf(rpc)), args).url