@c9up/rune 0.1.8 → 0.1.10

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/src/Schema.ts CHANGED
@@ -54,9 +54,9 @@ import {
54
54
  readHead,
55
55
  } from "./magic.js";
56
56
  import {
57
+ assertNativeAvailable,
57
58
  isNativeAvailable,
58
59
  validateNative,
59
- warnNativeUnavailableOnce,
60
60
  } from "./native.js";
61
61
 
62
62
  export type ValidationMessageParams = Record<string, string | number | boolean>;
@@ -188,6 +188,18 @@ export interface CreateRuleOptions {
188
188
  isAsync?: boolean;
189
189
  }
190
190
 
191
+ // `isAsync: true` genuinely produces an AsyncCompiledRule — a different
192
+ // discriminant (`__rune: "asyncRule"`) that `.use()` routes to the awaited
193
+ // register. Saying otherwise, as a cast did, told the compiler the opposite of
194
+ // what runs.
195
+ export function createRule(
196
+ validator: AsyncRuleValidator<undefined>,
197
+ options: CreateRuleOptions & { isAsync: true },
198
+ ): () => AsyncCompiledRule;
199
+ export function createRule<Options>(
200
+ validator: AsyncRuleValidator<Options>,
201
+ options: CreateRuleOptions & { isAsync: true },
202
+ ): (options: Options) => AsyncCompiledRule;
191
203
  export function createRule(
192
204
  validator: RuleValidator<undefined>,
193
205
  options?: CreateRuleOptions,
@@ -197,18 +209,18 @@ export function createRule<Options>(
197
209
  options?: CreateRuleOptions,
198
210
  ): (options: Options) => CompiledRule;
199
211
  export function createRule<Options>(
200
- validator: RuleValidator<Options>,
212
+ validator: RuleValidator<Options> | AsyncRuleValidator<Options>,
201
213
  ruleOptions?: CreateRuleOptions,
202
- ): (options: Options) => CompiledRule {
214
+ ): (options: Options) => CompiledRule | AsyncCompiledRule {
203
215
  if (ruleOptions?.isAsync) {
204
216
  // VineJS expresses "async" as an option on createRule, so honour it by
205
217
  // BUILDING the async rule rather than refusing: `.use()` routes an
206
218
  // async-marked rule to the awaited register.
207
- const asyncBuilder = createAsyncRule(
208
- validator as unknown as AsyncRuleValidator<Options>,
209
- { ...ruleOptions, isAsync: undefined },
210
- );
211
- return asyncBuilder as unknown as (options: Options) => CompiledRule;
219
+ const asyncBuilder = createAsyncRule(validator, {
220
+ ...ruleOptions,
221
+ isAsync: undefined,
222
+ });
223
+ return asyncBuilder;
212
224
  }
213
225
  return (options: Options): CompiledRule => ({
214
226
  __rune: "rule",
@@ -816,6 +828,21 @@ export interface ConditionalGroup {
816
828
  }>;
817
829
  }
818
830
 
831
+ /**
832
+ * Called when no union branch matched (VineJS `UnionNoMatchCallback`). Report
833
+ * through the field context; reporting nothing suppresses the generic error.
834
+ */
835
+ export type UnionNoMatchCallback = (
836
+ value: unknown,
837
+ field: FieldContext,
838
+ ) => void;
839
+
840
+ /**
841
+ * Called with a record's keys (VineJS `RecordKeysCallback`). Report through the
842
+ * field context; reporting nothing accepts the key set.
843
+ */
844
+ export type RecordKeysCallback = (keys: string[], field: FieldContext) => void;
845
+
819
846
  /** Per-field introspection returned inside `toJSON().schema`. */
820
847
  export type SchemaIntrospection = Record<
821
848
  string,
@@ -1405,10 +1432,10 @@ export function schema(
1405
1432
  reporterError = nativeReporter?.createError;
1406
1433
  return native;
1407
1434
  }
1408
- // This schema would have used the native engine, but it isn't loaded —
1409
- // surface the platform-dependent TS fallback once instead of diverging
1410
- // silently.
1411
- warnNativeUnavailableOnce();
1435
+ // This schema carries nothing the engine cannot run, so the engine is
1436
+ // what must run it. Falling back to the TypeScript validator here made
1437
+ // the verdict depend on whether a binary loaded.
1438
+ assertNativeAvailable();
1412
1439
  }
1413
1440
 
1414
1441
  const errors: ValidationError[] = [];
@@ -1739,7 +1766,12 @@ export interface RuleDef {
1739
1766
  param?: number;
1740
1767
  /** Interpolation args exposed to i18n/messages providers (e.g. `{ min: 3 }`). */
1741
1768
  args?: Record<string, unknown>;
1742
- validate: (value: unknown) => boolean;
1769
+ /**
1770
+ * `field` is the context VineJS hands a rule — a callback-valued rule
1771
+ * (`in`, `notIn`, `enum`) reads `meta`/`parent` off it to compute its list
1772
+ * per request. Rules that do not need it simply declare one parameter.
1773
+ */
1774
+ validate: (value: unknown, field: FieldContext) => boolean;
1743
1775
  message: string;
1744
1776
  /** Set when `.message()` overrode this rule's default text. */
1745
1777
  hasCustomMessage?: boolean;
@@ -1776,12 +1808,12 @@ const UUID_RE =
1776
1808
  */
1777
1809
  export type AllowedValues =
1778
1810
  | ReadonlyArray<string | number | boolean>
1779
- | (() => ReadonlyArray<string | number | boolean>);
1811
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>);
1780
1812
 
1781
1813
  /** Normalise a static list or a callback into a getter. */
1782
1814
  function allowedValuesResolver(
1783
1815
  values: AllowedValues,
1784
- ): () => ReadonlyArray<string | number | boolean> {
1816
+ ): (field: FieldContext) => ReadonlyArray<string | number | boolean> {
1785
1817
  if (typeof values === "function") return values;
1786
1818
  const snapshot = [...values];
1787
1819
  return () => snapshot;
@@ -1829,8 +1861,14 @@ export class RuleChain<Output = unknown> {
1829
1861
  #camelCaseKeys = false;
1830
1862
  #groups: ConditionalGroup[] = [];
1831
1863
  #recordValueChain: RuleChain | null = null;
1864
+ #enumChoices:
1865
+ | ReadonlyArray<string | number | boolean>
1866
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
1867
+ | null = null;
1868
+ #recordKeysCheck: RecordKeysCallback | null = null;
1832
1869
  #tupleChains: RuleChain[] | null = null;
1833
1870
  #unionChains: ConditionalBranch[] | null = null;
1871
+ #unionNoMatch: UnionNoMatchCallback | null = null;
1834
1872
  #useRules: CompiledRule[] = [];
1835
1873
  #asyncRules: AsyncCompiledRule[] = [];
1836
1874
  /** Last rule added, whichever register it landed in — the `message()` target. */
@@ -1950,8 +1988,11 @@ export class RuleChain<Output = unknown> {
1950
1988
  next.#camelCaseKeys = this.#camelCaseKeys;
1951
1989
  next.#groups = [...this.#groups];
1952
1990
  next.#recordValueChain = this.#recordValueChain;
1991
+ next.#enumChoices = this.#enumChoices;
1992
+ next.#recordKeysCheck = this.#recordKeysCheck;
1953
1993
  next.#tupleChains = this.#tupleChains;
1954
1994
  next.#unionChains = this.#unionChains;
1995
+ next.#unionNoMatch = this.#unionNoMatch;
1955
1996
  next.#ruleMessages = new Map(this.#ruleMessages);
1956
1997
  next.#lastRule = this.#lastRule;
1957
1998
  next.#preTransforms = [...this.#preTransforms];
@@ -2406,6 +2447,18 @@ export class RuleChain<Output = unknown> {
2406
2447
  return this.#retype<Record<string, OutputOf<Item>>>();
2407
2448
  }
2408
2449
 
2450
+ /**
2451
+ * Check the record's KEYS, not its values (VineJS `record().validateKeys()`).
2452
+ *
2453
+ * The callback receives every key at once and reports through the field
2454
+ * context — the set is what matters when keys must be exclusive, exhaustive,
2455
+ * or drawn from a list only known at runtime.
2456
+ */
2457
+ validateKeys(callback: RecordKeysCallback): this {
2458
+ this.#recordKeysCheck = callback;
2459
+ return this;
2460
+ }
2461
+
2409
2462
  /**
2410
2463
  * Fixed-length array with a schema per position (VineJS `tuple`). Extra
2411
2464
  * items are rejected — a tuple that silently ignores a trailing element is
@@ -2436,6 +2489,20 @@ export class RuleChain<Output = unknown> {
2436
2489
  * - bare chains: tried in order, first match wins, and a total miss reports a
2437
2490
  * single `union` error rather than every losing branch's noise.
2438
2491
  */
2492
+ /**
2493
+ * What to do when NO union branch matched (VineJS `union().otherwise()`).
2494
+ *
2495
+ * The callback receives the value and the field, and reports the error it
2496
+ * wants — the point being that "matches nothing" is a useless message when
2497
+ * the caller knows which shapes were on offer. Reporting nothing from the
2498
+ * callback suppresses the generic error entirely, which is how a union
2499
+ * folded into a larger check stays quiet.
2500
+ */
2501
+ otherwise(callback: UnionNoMatchCallback): this {
2502
+ this.#unionNoMatch = callback;
2503
+ return this;
2504
+ }
2505
+
2439
2506
  union(chains: readonly UnionBranch[]): this {
2440
2507
  this.#unionChains = chains.map(toUnionBranch);
2441
2508
  // Marker rule: its name is not in NATIVE_RULES, which is what keeps a
@@ -2669,20 +2736,45 @@ export class RuleChain<Output = unknown> {
2669
2736
  return this;
2670
2737
  }
2671
2738
 
2672
- /** Must equal one of `values` (enum). Narrows the output to the union. */
2739
+ /**
2740
+ * Must equal one of `values` (enum). Narrows the output to the union.
2741
+ *
2742
+ * `values` may be a callback receiving the field, which is how a list that
2743
+ * depends on the request — the roles this tenant allows, the statuses this
2744
+ * user may set — is computed per validation instead of frozen at import.
2745
+ */
2673
2746
  enum<const V extends readonly (string | number | boolean)[]>(
2674
- values: V,
2747
+ values: V | ((field: FieldContext) => V),
2675
2748
  ): RuleChain<V[number]> {
2676
- const allowed = [...values];
2749
+ const lazy = typeof values === "function";
2750
+ const resolve = allowedValuesResolver(values);
2751
+ this.#enumChoices = lazy ? values : [...values];
2677
2752
  this.#pushRule({
2678
2753
  name: "enum",
2679
- args: { values: allowed },
2680
- validate: (v) => allowed.includes(asPrimitive(v)),
2754
+ args: lazy ? {} : { values: [...values] },
2755
+ // A computed list is per-call; the native engine only ever sees a
2756
+ // static array, so it must not run this rule.
2757
+ tsOnly: lazy,
2758
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
2681
2759
  message: "Invalid value",
2682
2760
  });
2683
2761
  return this.#retype<V[number]>();
2684
2762
  }
2685
2763
 
2764
+ /**
2765
+ * The choices this enum was declared with (VineJS `getChoices()`) — the list
2766
+ * itself, or the callback when it is computed per request.
2767
+ *
2768
+ * Reading them back is what lets a form render the same options the
2769
+ * validator will accept, from one declaration instead of two.
2770
+ */
2771
+ getChoices():
2772
+ | ReadonlyArray<string | number | boolean>
2773
+ | ((field: FieldContext) => ReadonlyArray<string | number | boolean>)
2774
+ | undefined {
2775
+ return this.#enumChoices ?? undefined;
2776
+ }
2777
+
2686
2778
  /** Must equal a literal value. */
2687
2779
  literal<V extends string | number | boolean>(value: V): RuleChain<V> {
2688
2780
  this.#pushRule({
@@ -2922,8 +3014,13 @@ export class RuleChain<Output = unknown> {
2922
3014
  }
2923
3015
 
2924
3016
  /**
2925
- * Must be a mobile number in E.164 form. Named deviation from VineJS: rune
2926
- * carries no per-locale numbering plans, so there is no `locale` option.
3017
+ * Must be a mobile number (VineJS/Adonis `mobile()`).
3018
+ *
3019
+ * With no `locale`, the number must be in E.164 form. With one or more,
3020
+ * it must match one of their numbering plans, as in VineJS. rune carries
3021
+ * its own plans rather than validator.js', so it knows fewer locales — an
3022
+ * unknown one raises `UNSUPPORTED_LOCALE` at schema build, naming the ones
3023
+ * it does know, rather than silently accepting anything at request time.
2927
3024
  */
2928
3025
  mobile(options?: { locale?: string | string[]; strictMode?: boolean }): this {
2929
3026
  const locales = options?.locale ? [options.locale].flat() : null;
@@ -3308,7 +3405,7 @@ export class RuleChain<Output = unknown> {
3308
3405
  // A callback list is computed per call — the native engine only ever
3309
3406
  // sees a static array, so it must not run this rule.
3310
3407
  tsOnly: typeof values === "function",
3311
- validate: (v) => resolve().includes(asPrimitive(v)),
3408
+ validate: (v, field) => resolve(field).includes(asPrimitive(v)),
3312
3409
  message: "Invalid value",
3313
3410
  });
3314
3411
  return this;
@@ -3321,7 +3418,7 @@ export class RuleChain<Output = unknown> {
3321
3418
  name: "notIn",
3322
3419
  args: typeof values === "function" ? {} : { values: [...values] },
3323
3420
  tsOnly: typeof values === "function",
3324
- validate: (v) => !resolve().includes(asPrimitive(v)),
3421
+ validate: (v, field) => !resolve(field).includes(asPrimitive(v)),
3325
3422
  message: "Invalid value",
3326
3423
  });
3327
3424
  return this;
@@ -3904,6 +4001,14 @@ export class RuleChain<Output = unknown> {
3904
4001
  }
3905
4002
 
3906
4003
  // 4b. Record values — same shape as the nested-object walk, arbitrary keys.
4004
+ if (this.#recordKeysCheck && isPlainObject(transformed)) {
4005
+ // Keys first: a key-level rule that rejects the shape makes the
4006
+ // per-value errors that would follow noise.
4007
+ this.#recordKeysCheck(
4008
+ Object.keys(transformed),
4009
+ this.#makeFieldContext(field, transformed, ctx, errors, () => {}),
4010
+ );
4011
+ }
3907
4012
  if (this.#recordValueChain && isPlainObject(transformed)) {
3908
4013
  const obj: Record<string, unknown> = { ...transformed };
3909
4014
  transformed = obj;
@@ -3985,19 +4090,30 @@ export class RuleChain<Output = unknown> {
3985
4090
  }
3986
4091
  }
3987
4092
  if (!matched) {
3988
- errors.push({
3989
- field,
3990
- rule: "union",
3991
- message: resolveRuleMessage(
4093
+ if (this.#unionNoMatch) {
4094
+ // The callback owns the reporting: whatever it pushes is the
4095
+ // error, and pushing nothing means it handled the case itself.
4096
+ const reported: ValidationError[] = [];
4097
+ this.#unionNoMatch(
4098
+ transformed,
4099
+ this.#makeFieldContext(field, transformed, ctx, reported, () => {}),
4100
+ );
4101
+ errors.push(...reported);
4102
+ } else {
4103
+ errors.push({
3992
4104
  field,
3993
- {
3994
- name: "union",
3995
- validate: () => false,
3996
- message: "Does not match any allowed shape",
3997
- },
3998
- ctx,
3999
- ),
4000
- });
4105
+ rule: "union",
4106
+ message: resolveRuleMessage(
4107
+ field,
4108
+ {
4109
+ name: "union",
4110
+ validate: () => false,
4111
+ message: "Does not match any allowed shape",
4112
+ },
4113
+ ctx,
4114
+ ),
4115
+ });
4116
+ }
4001
4117
  }
4002
4118
  }
4003
4119
 
@@ -4126,8 +4242,14 @@ export class RuleChain<Output = unknown> {
4126
4242
  value: unknown,
4127
4243
  ctx: RunContext,
4128
4244
  ): ValidationError | null {
4245
+ let context: FieldContext | undefined;
4246
+ const fieldContext = (): FieldContext =>
4247
+ (context ??= this.#makeFieldContext(field, value, ctx, [], () => {}));
4129
4248
  for (const rule of this.#rules) {
4130
- if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
4249
+ if (
4250
+ TYPE_RULE_NAMES.has(rule.name) &&
4251
+ !rule.validate(value, fieldContext())
4252
+ ) {
4131
4253
  return {
4132
4254
  field,
4133
4255
  rule: rule.name,
@@ -4146,10 +4268,21 @@ export class RuleChain<Output = unknown> {
4146
4268
  ctx: RunContext,
4147
4269
  ): ValidationError[] {
4148
4270
  const errors: ValidationError[] = [];
4271
+ // Built once and only if a rule actually reads it: most rules take the
4272
+ // value alone, and a context per rule per field is pure waste.
4273
+ let context: FieldContext | undefined;
4274
+ const fieldContext = (): FieldContext =>
4275
+ (context ??= this.#makeFieldContext(
4276
+ field,
4277
+ transformed,
4278
+ ctx,
4279
+ [],
4280
+ () => {},
4281
+ ));
4149
4282
  for (const rule of this.#rules) {
4150
4283
  if (TYPE_RULE_NAMES.has(rule.name)) continue;
4151
4284
  if (this.#bail && errors.length > 0) break;
4152
- if (!rule.validate(transformed)) {
4285
+ if (!rule.validate(transformed, fieldContext())) {
4153
4286
  errors.push({
4154
4287
  field,
4155
4288
  rule: rule.name,
@@ -4400,7 +4533,10 @@ export const rules = {
4400
4533
  const typeRule = chain.rules.find((rule) =>
4401
4534
  TYPE_RULE_NAMES.has(rule.name),
4402
4535
  );
4403
- return unionIf((value) => typeRule?.validate(value) === true, chain);
4536
+ return unionIf(
4537
+ (value, field) => typeRule?.validate(value, field) === true,
4538
+ chain,
4539
+ );
4404
4540
  }),
4405
4541
  );
4406
4542
  },
@@ -4410,7 +4546,7 @@ export const rules = {
4410
4546
  array: <Item extends RuleChain>(item?: Item): RuleChain<OutputOf<Item>[]> =>
4411
4547
  new RuleChain().array(item),
4412
4548
  enum: <const V extends readonly (string | number | boolean)[]>(
4413
- values: V,
4549
+ values: V | ((field: FieldContext) => V),
4414
4550
  ): RuleChain<V[number]> => new RuleChain().enum(values),
4415
4551
  literal: <V extends string | number | boolean>(value: V): RuleChain<V> =>
4416
4552
  new RuleChain().literal(value),
package/src/formats.ts CHANGED
@@ -157,9 +157,8 @@ export function isPostalCode(v: string, countryCode: string): boolean | null {
157
157
  }
158
158
 
159
159
  /**
160
- * Mobile numbers in E.164 form. Named deviation from VineJS: rune does NOT
161
- * carry per-locale numbering plans, so `locale` is not accepted — a caller that
162
- * needs one writes a `.regex()` or a custom rule.
160
+ * Mobile numbers in E.164 form the locale-less check. Per-locale plans live
161
+ * in `MOBILE_LOCALES`; see {@link isMobileForLocale}.
163
162
  */
164
163
  export const isMobile = (v: string): boolean =>
165
164
  E164_RE.test(v.replace(/[ .-]/g, ""));
@@ -490,12 +489,62 @@ export interface EmailOptions {
490
489
  domain_specific_validation?: boolean;
491
490
  }
492
491
 
492
+ /** Hard bound on anything handed to the email parser (RFC 5322 line limit). */
493
+ const MAX_EMAIL_INPUT = 998;
494
+
493
495
  /** Unquoted local part: dot-separated atoms of RFC 5322 atext. */
494
496
  const ATEXT = "[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+";
495
497
  const DOT_ATOM_RE = new RegExp(`^${ATEXT}(?:\\.${ATEXT})*$`);
496
498
  /** Quoted local part: `"anything but bare quote/backslash, or escaped"`. */
497
499
  const QUOTED_LOCAL_RE = /^"(?:[^"\\]|\\.)*"$/;
498
- const DISPLAY_NAME_RE = /^\s*(?:"(?:[^"\\]|\\.)*"|[^<>@]*?)\s*<(.+)>\s*$/;
500
+ /**
501
+ * Split `Display Name <address@host>` into its address.
502
+ *
503
+ * Parsed rather than matched: the obvious pattern —
504
+ * `^\s*(?:"..."|[^<>@]*?)\s*<(.+)>\s*$` — lets `\s*` and `[^<>@]*?` both
505
+ * claim a space, so an input of N spaces has N ways to be split and the engine
506
+ * tries them all. Measured at O(n³): 8 KB of spaces blocked the event loop for
507
+ * 67 seconds, which turns any route validating an email into a denial of
508
+ * service. This walk is linear and answers the same question.
509
+ *
510
+ * Returns the address, or null when the input is not in display-name form.
511
+ */
512
+ function displayNameAddress(input: string): string | null {
513
+ const trimmed = input.trim();
514
+ if (!trimmed.endsWith(">")) return null;
515
+
516
+ let open: number;
517
+ if (trimmed.startsWith('"')) {
518
+ // A quoted display name may contain anything, `<` included, so the
519
+ // address opens at the first `<` AFTER the closing quote.
520
+ const closingQuote = closingQuoteIndex(trimmed);
521
+ if (closingQuote === -1) return null;
522
+ open = trimmed.indexOf("<", closingQuote + 1);
523
+ if (open === -1) return null;
524
+ if (trimmed.slice(closingQuote + 1, open).trim() !== "") return null;
525
+ } else {
526
+ open = trimmed.indexOf("<");
527
+ if (open === -1) return null;
528
+ // An unquoted display name carries none of `<`, `>` or `@` — the same
529
+ // restriction the pattern expressed.
530
+ if (/[<>@]/.test(trimmed.slice(0, open))) return null;
531
+ }
532
+
533
+ const address = trimmed.slice(open + 1, -1);
534
+ return address.length > 0 ? address : null;
535
+ }
536
+
537
+ /** Index of the quote closing the one at position 0, or -1. */
538
+ function closingQuoteIndex(input: string): number {
539
+ for (let i = 1; i < input.length; i++) {
540
+ if (input[i] === "\\") {
541
+ i++;
542
+ continue;
543
+ }
544
+ if (input[i] === '"') return i;
545
+ }
546
+ return -1;
547
+ }
499
548
 
500
549
  /** A single DNS label: alphanumerics and inner hyphens, 1..63 chars. */
501
550
  function isDnsLabel(label: string): boolean {
@@ -525,9 +574,15 @@ function isIpDomainLiteral(domain: string): boolean {
525
574
  export function isEmail(value: string, options: EmailOptions = {}): boolean {
526
575
  let candidate = value;
527
576
 
577
+ // Bound the input BEFORE any parsing. A caller may opt out of the 254-char
578
+ // address cap, but never out of a bound: an unbounded string reaching the
579
+ // parser is how a validator becomes an outage. RFC 5322 caps a whole line
580
+ // at 998 octets, so a display-name form has no business being longer.
581
+ if (value.length > MAX_EMAIL_INPUT) return false;
582
+
528
583
  if (options.allow_display_name) {
529
- const match = DISPLAY_NAME_RE.exec(candidate);
530
- if (match) candidate = match[1];
584
+ const address = displayNameAddress(candidate);
585
+ if (address !== null) candidate = address;
531
586
  } else if (/[<>]/.test(candidate)) {
532
587
  return false;
533
588
  }
package/src/index.ts CHANGED
@@ -21,6 +21,11 @@ export type {
21
21
  } from "./formats.js";
22
22
  export type { MessagesProviderContract } from "./MessagesProvider.js";
23
23
  export { SimpleMessagesProvider } from "./MessagesProvider.js";
24
+ export {
25
+ assertNativeAvailable,
26
+ isNativeAvailable,
27
+ RuneNativeRequiredError,
28
+ } from "./native.js";
24
29
  export type {
25
30
  AsyncCompiledRule,
26
31
  AsyncRuleValidator,
@@ -31,8 +36,10 @@ export type {
31
36
  DatabaseRuleOptions,
32
37
  FieldContext,
33
38
  Infer,
39
+ RecordKeysCallback,
34
40
  RuleChain,
35
41
  RuleValidator,
42
+ UnionNoMatchCallback,
36
43
  ValidateOptions,
37
44
  ValidationError,
38
45
  ValidationMessageParams,
package/src/native.ts CHANGED
@@ -58,28 +58,43 @@ export function isNativeAvailable(): boolean {
58
58
  return native !== undefined;
59
59
  }
60
60
 
61
- let warnedFallback = false;
61
+ /** Why the engine could not be loaded, phrased for whoever has to fix it. */
62
+ function unavailableReason(): string {
63
+ const target = `${platform}-${arch}`;
64
+ if (loadError !== undefined) {
65
+ return `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`;
66
+ }
67
+ return platformMap[target] !== undefined
68
+ ? "binary not found"
69
+ : `no prebuilt binary for ${target}`;
70
+ }
71
+
72
+ /** Raised when a schema needs the Rust engine and it is not there. */
73
+ export class RuneNativeRequiredError extends Error {
74
+ readonly code = "RUNE_NAPI_REQUIRED" as const;
75
+ constructor() {
76
+ super(
77
+ `[RUNE_NAPI_REQUIRED] The Rust validation engine is required but not loaded — ${unavailableReason()}.\n` +
78
+ "Install the prebuilt binary for this platform, or build it with `pnpm build:napi`.",
79
+ );
80
+ this.name = "RuneNativeRequiredError";
81
+ }
82
+ }
62
83
 
63
84
  /**
64
- * Surface exactly once per process — that the native engine is unavailable so
65
- * the schema is falling back to the TypeScript validator. The two paths can
66
- * differ subtly, so a silent fallback makes validation results depend on whether
67
- * the prebuilt binary loaded (platform-dependent). Callers invoke this only when
68
- * they WOULD have used the native engine (no custom rules / translator).
85
+ * Refuse to validate without the native engine.
86
+ *
87
+ * There is a TypeScript validator, and it used to take over silently with a
88
+ * one-time warning. That made a schema's verdict depend on whether a prebuilt
89
+ * binary happened to load: two deployments of the same code could disagree on
90
+ * whether a payload is valid, and the one that fell back lost the reason to
91
+ * have Rust at all. A schema the engine can run is now run BY the engine or
92
+ * not at all.
93
+ *
94
+ * The TypeScript path stays for what the engine genuinely cannot do — custom
95
+ * rules, a translator, a messages provider — where it is the only
96
+ * implementation, not a second one.
69
97
  */
70
- export function warnNativeUnavailableOnce(): void {
71
- if (warnedFallback) return;
72
- warnedFallback = true;
73
- const target = `${platform}-${arch}`;
74
- const reason =
75
- loadError !== undefined
76
- ? `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`
77
- : platformMap[target] !== undefined
78
- ? "binary not found"
79
- : `no prebuilt binary for ${target}`;
80
- console.warn(
81
- `[rune] native validation engine unavailable — ${reason}. Falling back to the ` +
82
- "TypeScript validator, whose results can differ subtly from the Rust engine — " +
83
- "validation is platform-dependent for this process.",
84
- );
98
+ export function assertNativeAvailable(): void {
99
+ if (native === undefined) throw new RuneNativeRequiredError();
85
100
  }