@c9up/ream 0.2.16 → 0.2.18

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 (93) hide show
  1. package/dist/Application.d.ts +54 -8
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +77 -7
  4. package/dist/Application.js.map +1 -1
  5. package/dist/HttpKernel.d.ts +10 -0
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +61 -5
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts.map +1 -1
  10. package/dist/Ignitor.js +76 -18
  11. package/dist/Ignitor.js.map +1 -1
  12. package/dist/commands/ScheduleList.d.ts.map +1 -1
  13. package/dist/commands/ScheduleList.js +29 -0
  14. package/dist/commands/ScheduleList.js.map +1 -1
  15. package/dist/container/Container.d.ts.map +1 -1
  16. package/dist/container/Container.js +174 -45
  17. package/dist/container/Container.js.map +1 -1
  18. package/dist/container/types.d.ts +7 -0
  19. package/dist/container/types.d.ts.map +1 -1
  20. package/dist/dev/hmr.d.ts +37 -0
  21. package/dist/dev/hmr.d.ts.map +1 -0
  22. package/dist/dev/hmr.js +72 -0
  23. package/dist/dev/hmr.js.map +1 -0
  24. package/dist/graphql/GraphQLProvider.d.ts +14 -3
  25. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  26. package/dist/graphql/GraphQLProvider.js +14 -3
  27. package/dist/graphql/GraphQLProvider.js.map +1 -1
  28. package/dist/hot.d.ts +29 -0
  29. package/dist/hot.d.ts.map +1 -0
  30. package/dist/hot.js +135 -0
  31. package/dist/hot.js.map +1 -0
  32. package/dist/http/Exception.d.ts +2 -2
  33. package/dist/http/Exception.d.ts.map +1 -1
  34. package/dist/http/Exception.js +3 -3
  35. package/dist/http/Exception.js.map +1 -1
  36. package/dist/http/Request.d.ts +11 -0
  37. package/dist/http/Request.d.ts.map +1 -1
  38. package/dist/http/Request.js +40 -0
  39. package/dist/http/Request.js.map +1 -1
  40. package/dist/http/RequestValidator.d.ts +80 -0
  41. package/dist/http/RequestValidator.d.ts.map +1 -0
  42. package/dist/http/RequestValidator.js +93 -0
  43. package/dist/http/RequestValidator.js.map +1 -0
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/middleware/Pipeline.d.ts +2 -2
  49. package/dist/middleware/Pipeline.js +2 -2
  50. package/dist/middleware/Pipeline.js.map +1 -1
  51. package/dist/migrations/MigrationRegistry.d.ts +16 -0
  52. package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
  53. package/dist/migrations/MigrationRegistry.js +24 -0
  54. package/dist/migrations/MigrationRegistry.js.map +1 -1
  55. package/dist/openapi/OpenApiGenerator.d.ts +16 -0
  56. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  57. package/dist/openapi/OpenApiGenerator.js +76 -4
  58. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  59. package/dist/openapi/OpenApiProvider.d.ts +16 -5
  60. package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
  61. package/dist/openapi/OpenApiProvider.js +21 -6
  62. package/dist/openapi/OpenApiProvider.js.map +1 -1
  63. package/dist/rpc/RpcProvider.d.ts +13 -2
  64. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  65. package/dist/rpc/RpcProvider.js +13 -2
  66. package/dist/rpc/RpcProvider.js.map +1 -1
  67. package/dist/storage/StaticProvider.d.ts +12 -2
  68. package/dist/storage/StaticProvider.d.ts.map +1 -1
  69. package/dist/storage/StaticProvider.js +12 -2
  70. package/dist/storage/StaticProvider.js.map +1 -1
  71. package/events.win32-x64-msvc.node +0 -0
  72. package/index.win32-x64-msvc.node +0 -0
  73. package/package.json +14 -2
  74. package/scheduler.win32-x64-msvc.node +0 -0
  75. package/src/Application.ts +89 -8
  76. package/src/HttpKernel.ts +87 -5
  77. package/src/Ignitor.ts +82 -19
  78. package/src/commands/ScheduleList.ts +30 -0
  79. package/src/container/Container.ts +178 -48
  80. package/src/container/types.ts +11 -0
  81. package/src/dev/hmr.ts +83 -0
  82. package/src/graphql/GraphQLProvider.ts +14 -3
  83. package/src/hot.ts +165 -0
  84. package/src/http/Exception.ts +4 -4
  85. package/src/http/Request.ts +54 -0
  86. package/src/http/RequestValidator.ts +143 -0
  87. package/src/index.ts +8 -1
  88. package/src/middleware/Pipeline.ts +4 -4
  89. package/src/migrations/MigrationRegistry.ts +23 -0
  90. package/src/openapi/OpenApiGenerator.ts +84 -4
  91. package/src/openapi/OpenApiProvider.ts +21 -6
  92. package/src/rpc/RpcProvider.ts +13 -2
  93. package/src/storage/StaticProvider.ts +12 -2
@@ -9,8 +9,9 @@
9
9
  * const gql = app.container.make<GraphQLEngine>('graphql')
10
10
  * gql.resolver('Query', 'tasks', TaskResolver, 'tasks')
11
11
  *
12
- * - `boot()` — mount the engine at `engine.path` (default `/graphql`) for both
13
- * GET (playground/introspection) and POST (queries).
12
+ * - `start()` — mount the engine at `engine.path` (default `/graphql`) for
13
+ * both GET (playground/introspection) and POST (queries). In `start`, which
14
+ * is the phase upstream documents for routes.
14
15
  *
15
16
  * @implements Story 24.5
16
17
  */
@@ -66,7 +67,17 @@ export class GraphQLProvider extends Provider {
66
67
  this.#registered = true
67
68
  }
68
69
 
69
- override async boot(): Promise<void> {
70
+ /**
71
+ * Routes and server middleware go in `start`, not `boot`.
72
+ *
73
+ * Upstream documents that phase for exactly this, and the order is what makes
74
+ * it matter: providers boot, then providers START, then the preloads run —
75
+ * and the preloads are where an application writes its own routes and kernel.
76
+ * Mounted in `boot`, a framework route landed ahead of every application
77
+ * route, so an overlapping path was answered by the framework rather than by
78
+ * the app that meant to override it.
79
+ */
80
+ override async start(): Promise<void> {
70
81
  const engine = this.#engine
71
82
  if (!engine) return
72
83
  const router = await this.app.container.make<Router>('router')
package/src/hot.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Dev-only entry hook: hot module replacement for the running server.
3
+ *
4
+ * `ream dev` loads this before the application entry:
5
+ *
6
+ * node --import @swc-node/register/esm-register --import @c9up/ream/hot bin/server.ts
7
+ *
8
+ * It is a thin shim over `hot-hook`, which does the actual work: it registers
9
+ * an ESM loader that tracks the import graph, and when a file inside a declared
10
+ * boundary changes it invalidates just that module instead of the process. The
11
+ * `hotHook` key in the application's `package.json` configures it, exactly as
12
+ * upstream reads it.
13
+ *
14
+ * NAMED DEVIATION — why this file exists at all.
15
+ *
16
+ * Upstream's dev server is itself a Node process: it forks the app, so the two
17
+ * talk over Node's IPC channel, and `hot-hook` reports a change it cannot swap
18
+ * by calling `process.send({ type: 'hot-hook:full-reload' })`. Our dev server is
19
+ * a Rust binary. A process it spawns has NO IPC channel, so `process.send` is
20
+ * `undefined` and that message goes nowhere: the server would keep serving the
21
+ * old module and say nothing.
22
+ *
23
+ * `hot.init()` takes an `onFullReloadAsked` callback for exactly this case, and
24
+ * that is the whole of the shim: turn "I cannot hot-swap this" into an exit
25
+ * code the Rust parent understands and restarts on. Everything else is
26
+ * upstream's behaviour, unmodified.
27
+ */
28
+
29
+ import { hotReloadHappened } from './dev/hmr.js'
30
+
31
+ /** Exit code meaning "restart me" — see `EXIT_RESTART` in the CLI's dev module. */
32
+ const FULL_RELOAD_EXIT_CODE = 75
33
+
34
+ interface HotHook {
35
+ init(options: {
36
+ rootDirectory: string
37
+ root?: string
38
+ boundaries?: string[]
39
+ restart?: string[]
40
+ ignore?: string[]
41
+ throwWhenBoundariesAreNotDynamicallyImported?: boolean
42
+ onFullReloadAsked?: () => void
43
+ }): Promise<void>
44
+ }
45
+
46
+ interface HotHookConfig {
47
+ root?: string
48
+ boundaries?: string[]
49
+ restart?: string[]
50
+ ignore?: string[]
51
+ throwWhenBoundariesAreNotDynamicallyImported?: boolean
52
+ }
53
+
54
+ /** Read `hotHook` out of the application's package.json, with no assumptions. */
55
+ function readConfig(pkg: unknown): HotHookConfig {
56
+ if (typeof pkg !== 'object' || pkg === null) return {}
57
+ const raw = Reflect.get(pkg, 'hotHook')
58
+ if (typeof raw !== 'object' || raw === null) return {}
59
+ const stringArray = (key: string): string[] | undefined => {
60
+ const value = Reflect.get(raw, key)
61
+ if (!Array.isArray(value)) return undefined
62
+ return value.filter((entry): entry is string => typeof entry === 'string')
63
+ }
64
+ const root = Reflect.get(raw, 'root')
65
+ const strict = Reflect.get(raw, 'throwWhenBoundariesAreNotDynamicallyImported')
66
+ return {
67
+ root: typeof root === 'string' ? root : undefined,
68
+ boundaries: stringArray('boundaries'),
69
+ restart: stringArray('restart'),
70
+ ignore: stringArray('ignore'),
71
+ throwWhenBoundariesAreNotDynamicallyImported: typeof strict === 'boolean' ? strict : undefined,
72
+ }
73
+ }
74
+
75
+ const { readFile } = await import('node:fs/promises')
76
+ const { dirname, resolve } = await import('node:path')
77
+
78
+ const packageJsonPath = resolve(process.cwd(), 'package.json')
79
+ let config: HotHookConfig = {}
80
+ try {
81
+ config = readConfig(JSON.parse(await readFile(packageJsonPath, 'utf8')))
82
+ } catch {
83
+ // No package.json, or unreadable: hot-hook still works, it just has no
84
+ // boundaries, so every change asks for a restart. That is the old
85
+ // behaviour, not a failure worth stopping dev over.
86
+ }
87
+
88
+ // `hot-hook` is an optional peer, declared by the application because it is a
89
+ // dev dependency there and has no business in a production install. Imported by
90
+ // specifier built at runtime so a production bundle never resolves it.
91
+ function isHotHook(value: unknown): value is HotHook {
92
+ return (
93
+ typeof value === 'object' && value !== null && typeof Reflect.get(value, 'init') === 'function'
94
+ )
95
+ }
96
+
97
+ // `hot-hook` belongs to the APPLICATION: it is a dev dependency there and has
98
+ // no business in a production install of the framework. So it is resolved from
99
+ // the project directory rather than from this file — under pnpm's strict layout
100
+ // a bare `import("hot-hook")` here looks inside @c9up/ream and finds nothing.
101
+ // It is the same reason upstream passes `hot-hook/register` from the app's own
102
+ // cwd instead of re-exporting it from the framework.
103
+ const { createRequire } = await import('node:module')
104
+ const { pathToFileURL } = await import('node:url')
105
+
106
+ let hot: HotHook | undefined
107
+ try {
108
+ const resolved = createRequire(packageJsonPath).resolve('hot-hook')
109
+ const module: unknown = await import(pathToFileURL(resolved).href)
110
+ const candidate =
111
+ typeof module === 'object' && module !== null ? Reflect.get(module, 'hot') : undefined
112
+ if (!isHotHook(candidate)) {
113
+ throw new Error('hot-hook did not expose a `hot` object with init()')
114
+ }
115
+ hot = candidate
116
+ } catch (error) {
117
+ // Loud, and it names the fix. Silence here would start a perfectly healthy
118
+ // server that never picks up an edit, which reads as "HMR is broken" rather
119
+ // than "HMR is not installed".
120
+ //
121
+ // It does NOT throw: a dev server that refuses to start is worse than one
122
+ // without hot reloading, and the CLI only loads this file when it has
123
+ // already seen hot-hook in the project — so reaching here means it went
124
+ // missing under a running session.
125
+ console.error(
126
+ `[ream] hot module replacement is unavailable: ${error instanceof Error ? error.message : String(error)}\n` +
127
+ '[ream] Install it with: pnpm add -D hot-hook, then restart `ream dev`.\n' +
128
+ '[ream] Until then the server runs, but an edit will not be picked up.',
129
+ )
130
+ }
131
+
132
+ // Impersonate the IPC channel upstream's dev server provides.
133
+ //
134
+ // hot-hook reports BOTH of its outcomes through `process.send`: a change it
135
+ // swapped in place (`hot-hook:invalidated`) and one it could not
136
+ // (`hot-hook:full-reload`). Under a Node parent that function exists and both
137
+ // arrive; under our Rust parent it is `undefined` and both are dropped —
138
+ // `onFullReloadAsked` alone recovers only half of that, leaving a hot swap
139
+ // invisible to the very process it happened in, and so to the browser.
140
+ //
141
+ // This is not a trick played on hot-hook: it is exactly the contract it
142
+ // expects, provided by us instead of by Node.
143
+ const previousSend = process.send
144
+ process.send = (message: unknown, ...rest: unknown[]): boolean => {
145
+ const type =
146
+ typeof message === 'object' && message !== null ? Reflect.get(message, 'type') : undefined
147
+ if (type === 'hot-hook:invalidated') {
148
+ hotReloadHappened()
149
+ }
150
+ // If a real channel ever exists (embedded under a Node supervisor) it still
151
+ // gets its message: this observes, it does not intercept.
152
+ if (typeof previousSend === 'function') {
153
+ return Reflect.apply(previousSend, process, [message, ...rest]) === true
154
+ }
155
+ return true
156
+ }
157
+
158
+ await hot?.init({
159
+ ...config,
160
+ rootDirectory: dirname(packageJsonPath),
161
+ root: config.root ? resolve(dirname(packageJsonPath), config.root) : undefined,
162
+ // Exiting on a known code is how the Rust parent learns it must restart —
163
+ // see `EXIT_RESTART` in the CLI's dev module.
164
+ onFullReloadAsked: () => process.exit(FULL_RELOAD_EXIT_CODE),
165
+ })
@@ -156,7 +156,7 @@ export class E_VALIDATION_ERROR extends Exception {
156
156
  static override code = 'E_VALIDATION_ERROR'
157
157
 
158
158
  /**
159
- * Per-field failures. Named `messages` because that is what VineJS, rune and
159
+ * Per-field failures. Named `messages` because that is what rune and
160
160
  * AdonisJS's own validation renderers read — a handler copied from an Adonis
161
161
  * app reaches for `error.messages`.
162
162
  */
@@ -249,7 +249,7 @@ export type HttpError = {
249
249
  code: string
250
250
  stack?: string
251
251
  cause?: unknown
252
- /** Per-field failures, on a validation error (VineJS / rune shape). */
252
+ /** Per-field failures, on a validation error (rune's shape). */
253
253
  messages?: unknown
254
254
  /** Extra detail an exception chose to carry. */
255
255
  errors?: unknown
@@ -487,7 +487,7 @@ export class ExceptionHandler extends Macroable {
487
487
 
488
488
  // Validation failures have per-field detail and their own renderers. Keyed
489
489
  // on the code plus the presence of `messages`, exactly as AdonisJS does, so
490
- // rune's and VineJS's errors both land here.
490
+ // rune's errors land here.
491
491
  if (httpError.code === 'E_VALIDATION_ERROR' && httpError.messages !== undefined) {
492
492
  await this.renderValidationError(httpError, ctx)
493
493
  return
@@ -523,7 +523,7 @@ export class ExceptionHandler extends Macroable {
523
523
  }
524
524
 
525
525
  /**
526
- * The fields a validation message carries (VineJS / rune `RuneErrorNode`).
526
+ * The fields a validation message carries (rune's `RuneErrorNode`).
527
527
  *
528
528
  * Read defensively: the messages come from whichever validator the app wired,
529
529
  * and a renderer must not throw while rendering someone else's error.
@@ -13,6 +13,12 @@ import type { SignedUrl } from '../security/SignedUrl.js'
13
13
  import type { Dict } from '../types/helpers.js'
14
14
  import { Macroable } from '../utils/Macroable.js'
15
15
  import { getPath, omitPaths, pickPaths } from '../utils/objectPath.js'
16
+ import type {
17
+ RequestAwareValidator,
18
+ RequestValidationOptions,
19
+ ValidatableContext,
20
+ } from './RequestValidator.js'
21
+ import { RequestValidator } from './RequestValidator.js'
16
22
  import { unpackCookieValue } from './Response.js'
17
23
 
18
24
  export interface RawRequest {
@@ -789,6 +795,44 @@ export class Request extends Macroable {
789
795
  return { ...this.#merged }
790
796
  }
791
797
 
798
+ /**
799
+ * The context a validation hook is handed.
800
+ *
801
+ * The whole context when this request has one, because that is where a hook
802
+ * finds what it needs — `ctx.i18n` for a translated message. A Request built
803
+ * by hand for a test has none, and stands in for itself.
804
+ */
805
+ #validationContext(): ValidatableContext {
806
+ const ctx = this.ctx
807
+ // Handed back as it is, never spread: a context carries accessors
808
+ // (`ctx.i18n` is one), and a spread would freeze each into a plain value
809
+ // read at this instant instead of when the hook asks for it.
810
+ if (ctx !== undefined && carriesRequest(ctx)) return ctx
811
+ return { request: this }
812
+ }
813
+
814
+ /**
815
+ * Validate this request, throwing `E_VALIDATION_ERROR` on failure.
816
+ *
817
+ * The entry point that lets a schema stay ignorant of the request: the
818
+ * per-request messages provider and error reporter are filled in here, from
819
+ * hooks a package installed at boot. See {@link RequestValidator}.
820
+ */
821
+ validateUsing<T>(
822
+ validator: RequestAwareValidator<T>,
823
+ options?: RequestValidationOptions,
824
+ ): Promise<T> {
825
+ return new RequestValidator(this.#validationContext()).validateUsing(validator, options)
826
+ }
827
+
828
+ /** Validate this request, answering `[error, null]` or `[null, data]`. */
829
+ tryValidateUsing<T>(
830
+ validator: RequestAwareValidator<T>,
831
+ options?: RequestValidationOptions,
832
+ ): Promise<[unknown, null] | [null, T]> {
833
+ return new RequestValidator(this.#validationContext()).tryValidateUsing(validator, options)
834
+ }
835
+
792
836
  /**
793
837
  * The original request input, captured once and never mutated (AdonisJS
794
838
  * `request.original`) — the basis for flash "old input" on validation errors.
@@ -1174,3 +1218,13 @@ function languageMatches(entry: string, offered: string): boolean {
1174
1218
  if (primary && primary === o.split('-')[0]) return true
1175
1219
  return false
1176
1220
  }
1221
+
1222
+ /**
1223
+ * Whether an object is a context wrapped around a real request.
1224
+ *
1225
+ * `Request.ctx` is deliberately typed `object` — the request must not depend
1226
+ * on the context's shape — so this is where that opacity is paid back.
1227
+ */
1228
+ function carriesRequest(value: object): value is ValidatableContext {
1229
+ return 'request' in value && value.request instanceof Request
1230
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Per-request validation.
3
+ *
4
+ * A validator compiled when its module loaded knows nothing about the request
5
+ * about to run through it — yet the two things it most often needs are per
6
+ * request: which language to answer in, and where the errors go. The two
7
+ * static hooks below are that seam. An i18n package installs one at boot:
8
+ *
9
+ * ```ts
10
+ * RequestValidator.messagesProvider = (ctx) => ctx.i18n.createMessagesProvider()
11
+ * ```
12
+ *
13
+ * and every `request.validateUsing(...)` call picks it up, without a single
14
+ * schema mentioning translation. Nothing else in the framework reads them, so
15
+ * a validator called directly keeps whatever provider it was built with.
16
+ *
17
+ * Structural on purpose: ream never imports a validation package. Anything
18
+ * with a `validate(data, options)` fits, which is also what makes this
19
+ * testable without one.
20
+ */
21
+
22
+ import type { HttpContext } from './HttpContext.ts'
23
+
24
+ /** What a validator has to expose to be usable here. */
25
+ export interface RequestAwareValidator<T> {
26
+ validate(data: Record<string, unknown>, options?: RequestValidationOptions): Promise<T>
27
+ }
28
+
29
+ /** Options forwarded to the validator, plus the data override. */
30
+ export interface RequestValidationOptions {
31
+ /** Validate this instead of the request's own data. */
32
+ data?: Record<string, unknown>
33
+ /** Per-call messages provider — beats the static hook. */
34
+ messagesProvider?: unknown
35
+ /** Per-call error reporter factory — beats the static hook. */
36
+ errorReporter?: unknown
37
+ /** Arbitrary metadata a rule can read off the field context. */
38
+ meta?: Record<string, unknown>
39
+ }
40
+
41
+ /**
42
+ * The slice this class reads. A real {@link HttpContext} satisfies it, and so
43
+ * does a bare `{ request }` pair — which is what a Request built by hand, with
44
+ * no context around it, can still offer.
45
+ */
46
+ export interface ValidatableContext {
47
+ request: {
48
+ all(): Record<string, unknown>
49
+ params(): Readonly<Record<string, string | string[]>>
50
+ headers(): Readonly<Record<string, unknown>>
51
+ cookiesList(): Record<string, unknown>
52
+ }
53
+ }
54
+
55
+ /**
56
+ * What a hook is handed. The full context when there is one — an i18n package
57
+ * reads `ctx.i18n` off it — and the narrow shape otherwise, which is why this
58
+ * is a union rather than just `HttpContext`.
59
+ */
60
+ export type ValidationHookContext = HttpContext | ValidatableContext
61
+
62
+ export class RequestValidator {
63
+ readonly #ctx: ValidatableContext
64
+
65
+ constructor(ctx: ValidatableContext) {
66
+ this.#ctx = ctx
67
+ }
68
+
69
+ /**
70
+ * Picks the error reporter for a request. Assigned once, at boot, by
71
+ * whatever package owns error formatting.
72
+ */
73
+ static errorReporter?: (ctx: ValidationHookContext) => unknown
74
+
75
+ /**
76
+ * Picks the messages provider for a request. Assigned once, at boot, by
77
+ * whatever package owns translations.
78
+ */
79
+ static messagesProvider?: (ctx: ValidationHookContext) => unknown
80
+
81
+ /**
82
+ * Everything a rule might address, in one object.
83
+ *
84
+ * `params`, `headers` and `cookies` are nested under their own keys rather
85
+ * than merged: a form field named `params` is ordinary, and flattening them
86
+ * would let a request rename its own route parameters.
87
+ */
88
+ #requestData(): Record<string, unknown> {
89
+ const { request } = this.#ctx
90
+ return {
91
+ ...request.all(),
92
+ params: request.params(),
93
+ headers: request.headers(),
94
+ cookies: request.cookiesList(),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Fill in the request-scoped reporter and provider, without overwriting
100
+ * anything the caller passed — an explicit option is a deliberate choice
101
+ * for this one call and must outrank a process-wide default.
102
+ */
103
+ #processOptions(options?: RequestValidationOptions): RequestValidationOptions {
104
+ const resolved: RequestValidationOptions = { ...options }
105
+ if (RequestValidator.errorReporter && resolved.errorReporter === undefined) {
106
+ const reporter = RequestValidator.errorReporter(this.#ctx)
107
+ // The validator expects a FACTORY, so a fresh reporter is built per run
108
+ // rather than one instance accumulating across requests.
109
+ resolved.errorReporter = () => reporter
110
+ }
111
+ if (RequestValidator.messagesProvider && resolved.messagesProvider === undefined) {
112
+ resolved.messagesProvider = RequestValidator.messagesProvider(this.#ctx)
113
+ }
114
+ return resolved
115
+ }
116
+
117
+ /** Validate the request, throwing `E_VALIDATION_ERROR` on failure. */
118
+ validateUsing<T>(
119
+ validator: RequestAwareValidator<T>,
120
+ options?: RequestValidationOptions,
121
+ ): Promise<T> {
122
+ const resolved = this.#processOptions(options)
123
+ return validator.validate(resolved.data ?? this.#requestData(), resolved)
124
+ }
125
+
126
+ /**
127
+ * Validate the request, returning `[error, null]` or `[null, data]`.
128
+ *
129
+ * The tuple form exists so a handler can answer a failure itself instead of
130
+ * letting the exception reach the error handler — a wizard step that
131
+ * re-renders the form, say.
132
+ */
133
+ async tryValidateUsing<T>(
134
+ validator: RequestAwareValidator<T>,
135
+ options?: RequestValidationOptions,
136
+ ): Promise<[unknown, null] | [null, T]> {
137
+ try {
138
+ return [null, await this.validateUsing(validator, options)]
139
+ } catch (error) {
140
+ return [error, null]
141
+ }
142
+ }
143
+ }
package/src/index.ts CHANGED
@@ -60,6 +60,13 @@ export { HttpContext } from './http/HttpContext.js'
60
60
  export { RedirectBuilder } from './http/RedirectBuilder.js'
61
61
  export type { RawRequest } from './http/Request.js'
62
62
  export { Request } from './http/Request.js'
63
+ export type {
64
+ RequestAwareValidator,
65
+ RequestValidationOptions,
66
+ ValidatableContext,
67
+ ValidationHookContext,
68
+ } from './http/RequestValidator.js'
69
+ export { RequestValidator } from './http/RequestValidator.js'
63
70
  export { Response } from './http/Response.js'
64
71
  export type { SseStreamOptions } from './http/SseStream.js'
65
72
  export { SseStream } from './http/SseStream.js'
@@ -378,4 +385,4 @@ export {
378
385
  StatsTracker,
379
386
  } from './scheduler/index.js'
380
387
 
381
- export const VERSION = '0.2.16'
388
+ export const VERSION = '0.2.18'
@@ -32,7 +32,7 @@ export interface RuntimeValidationResult {
32
32
  * Structural contract for a route validator. ream stays decoupled from the
33
33
  * validation engine — any object exposing a result-returning check works.
34
34
  *
35
- * Three spellings are accepted because `@c9up/rune` follows VineJS: `validate()`
35
+ * Three spellings are accepted because `@c9up/rune` offers all three: `validate()`
36
36
  * is async and throws, `validateResult()` is synchronous and never throws, and
37
37
  * `validateResultAsync()` is the async result-based form.
38
38
  *
@@ -46,7 +46,7 @@ export interface RuntimeValidator {
46
46
  validateResultAsync?(data: unknown): Promise<RuntimeValidationResult>
47
47
  }
48
48
 
49
- /** A thrown validation failure, as raised by rune/VineJS `validate()`. */
49
+ /** A thrown validation failure, as raised by rune's `validate()`. */
50
50
  interface ThrownValidationFailure {
51
51
  messages: unknown[]
52
52
  }
@@ -66,7 +66,7 @@ function isValidationResult(value: unknown): value is RuntimeValidationResult {
66
66
  * result object.
67
67
  *
68
68
  * Order matters: the async result form first (it is the only one that can run
69
- * `unique` / `exists`), then the synchronous one, then VineJS's throwing
69
+ * `unique` / `exists`), then the synchronous one, then the throwing
70
70
  * `validate()` — awaited, and with its `E_VALIDATION_ERROR` translated back
71
71
  * into a result so callers keep a single shape to handle.
72
72
  */
@@ -85,7 +85,7 @@ export async function runValidator(
85
85
  if (typeof validator.validate === 'function') {
86
86
  try {
87
87
  const outcome = await validator.validate(data)
88
- // A result-returning `validate()` (not the VineJS contract) is honoured
88
+ // A result-returning `validate()` (not the throwing contract) is honoured
89
89
  // as-is; the throwing contract resolves to the validated payload.
90
90
  return isValidationResult(outcome) ? outcome : { valid: true, errors: [], data: outcome }
91
91
  } catch (err) {
@@ -27,6 +27,29 @@ export class MigrationRegistry {
27
27
  return this
28
28
  }
29
29
 
30
+ /**
31
+ * Remove a store's runner — only while it is still the one that registered.
32
+ *
33
+ * `register` refuses a duplicate name, deliberately: two providers claiming
34
+ * one name means one of them migrates nothing while the run reports success.
35
+ * But nothing ever removed a source, so a provider that shut down left its
36
+ * name taken and its runner holding a connection it had already closed. A
37
+ * second boot in the same process — a hot reload, a test that restarts the
38
+ * app — then failed on "already registered", and the CLI would have driven a
39
+ * runner pointing at a dead pool.
40
+ *
41
+ * `source` is the ownership guard. Two applications can share a process, and
42
+ * the one shutting down must not unregister the survivor's runner that
43
+ * happens to carry the same name.
44
+ */
45
+ unregister(name: string, source?: RegisteredMigrationSource): boolean {
46
+ const current = this.#sources.get(name)
47
+ if (current === undefined) return false
48
+ if (source !== undefined && current !== source) return false
49
+ this.#sources.delete(name)
50
+ return true
51
+ }
52
+
30
53
  /** Every registered store, in registration order. */
31
54
  all(): RegisteredMigrationSource[] {
32
55
  return [...this.#sources.values()]
@@ -56,6 +56,16 @@ export class OpenApiGenerator {
56
56
  #router: Router
57
57
  #config: OpenApiConfig
58
58
  #runeSchemas: Map<string, Record<string, unknown>>
59
+ /**
60
+ * Schemas a validator produced itself, already in JSON Schema form.
61
+ *
62
+ * Kept apart from {@link #runeSchemas}, which holds rune FIELD MAPS that
63
+ * {@link #runeToJsonSchema} still has to convert. One map for both would
64
+ * mean guessing which kind a value is, and guessing wrong sends a finished
65
+ * schema back through the converter, which reads its `type` and `properties`
66
+ * keys as if they were field names.
67
+ */
68
+ #jsonSchemas = new Map<string, Record<string, unknown>>()
59
69
 
60
70
  constructor(
61
71
  router: Router,
@@ -72,6 +82,30 @@ export class OpenApiGenerator {
72
82
  this.#runeSchemas.set(name, definition)
73
83
  }
74
84
 
85
+ /**
86
+ * Fill the schema map from the validators the routes already name.
87
+ *
88
+ * `route.validate('createUser')` records a container token, and the
89
+ * validator behind it can describe itself. Without this step every one of
90
+ * those routes documented its body as a bare `{ type: 'object' }` unless the
91
+ * app ALSO called `registerSchema` by hand with a definition it had to keep
92
+ * in step with the schema — two descriptions of one payload, and nothing
93
+ * checking they agreed.
94
+ *
95
+ * Resolution happens once, here, because `generate()` is synchronous and the
96
+ * container is not. A validator that cannot be resolved, or that cannot
97
+ * describe itself, is skipped: a missing section of documentation is a
98
+ * smaller problem than a boot that fails over one.
99
+ */
100
+ async hydrateSchemas(resolve: (token: string) => Promise<unknown>): Promise<void> {
101
+ const names = new Set(this.#getRoutes().flatMap((route) => route.validators))
102
+ for (const name of names) {
103
+ if (this.#runeSchemas.has(name) || this.#jsonSchemas.has(name)) continue
104
+ const definition = await describeValidator(resolve, name)
105
+ if (definition !== undefined) this.#jsonSchemas.set(name, definition)
106
+ }
107
+ }
108
+
75
109
  /** Generate the complete OpenAPI 3.1 spec. */
76
110
  generate(): OpenApiSpec {
77
111
  const spec: OpenApiSpec = {
@@ -171,13 +205,13 @@ export class OpenApiGenerator {
171
205
 
172
206
  if (route.validators.length > 0 && ['post', 'put', 'patch'].includes(method)) {
173
207
  const [validatorName] = route.validators
174
- const schema = validatorName === undefined ? undefined : this.#runeSchemas.get(validatorName)
175
- if (schema && validatorName !== undefined) {
208
+ const body = validatorName === undefined ? undefined : this.#bodySchema(validatorName)
209
+ if (body && validatorName !== undefined) {
176
210
  operation.requestBody = {
177
211
  required: true,
178
- content: { 'application/json': { schema: this.#runeToJsonSchema(schema) } },
212
+ content: { 'application/json': { schema: body } },
179
213
  }
180
- schemas[validatorName] = this.#runeToJsonSchema(schema)
214
+ schemas[validatorName] = body
181
215
  } else {
182
216
  operation.requestBody = {
183
217
  required: true,
@@ -211,6 +245,17 @@ export class OpenApiGenerator {
211
245
  return `${route.method} ${route.path}`
212
246
  }
213
247
 
248
+ /**
249
+ * The JSON Schema for a named validator: what it described about itself
250
+ * first, then a hand-registered field map converted here.
251
+ */
252
+ #bodySchema(name: string): Record<string, unknown> | undefined {
253
+ const described = this.#jsonSchemas.get(name)
254
+ if (described !== undefined) return described
255
+ const fields = this.#runeSchemas.get(name)
256
+ return fields === undefined ? undefined : this.#runeToJsonSchema(fields)
257
+ }
258
+
214
259
  /** Convert a Rune schema (Record<string, RuleChain>) to JSON Schema. */
215
260
  #runeToJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
216
261
  const properties: Record<string, unknown> = {}
@@ -254,3 +299,38 @@ export class OpenApiGenerator {
254
299
  return this.#router.getRoutes()
255
300
  }
256
301
  }
302
+
303
+ /** A validator that can describe its own shape as JSON Schema. */
304
+ interface SelfDescribingValidator {
305
+ toJSONSchema(): Record<string, unknown>
306
+ }
307
+
308
+ function describesItself(value: unknown): value is SelfDescribingValidator {
309
+ return (
310
+ typeof value === 'object' &&
311
+ value !== null &&
312
+ 'toJSONSchema' in value &&
313
+ typeof value.toJSONSchema === 'function'
314
+ )
315
+ }
316
+
317
+ /** Resolve one validator and ask it for its schema, or answer undefined. */
318
+ async function describeValidator(
319
+ resolve: (token: string) => Promise<unknown>,
320
+ name: string,
321
+ ): Promise<Record<string, unknown> | undefined> {
322
+ let validator: unknown
323
+ try {
324
+ validator = await resolve(`validator:${name}`)
325
+ } catch {
326
+ // An unregistered validator is already a hard error at request time; the
327
+ // documentation is not the place to raise it a second time.
328
+ return undefined
329
+ }
330
+ if (!describesItself(validator)) return undefined
331
+ try {
332
+ return validator.toJSONSchema()
333
+ } catch {
334
+ return undefined
335
+ }
336
+ }