@ultimat3/schema 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -5,7 +5,7 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
5
5
  | Rule | |
6
6
  |---|---|
7
7
  | Deps | none (`bun-types` only) |
8
- | Errors | `SchemaError` mirrors `UltimateError` field-for-field; keep `Symbol.for('ultimate.error')` |
8
+ | Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`); keep `Symbol.for('ultimate.error')` |
9
9
  | New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
10
10
  | IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
11
11
  | **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
@@ -14,7 +14,11 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
14
14
  | Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it |
15
15
 
16
16
  Module order (no cycles):
17
- `describe-value → node → builder → money-value → validators → discriminated-union → provider → t`.
17
+ `char-count → describe-value → node → builder → money-value → validators → discriminated-union →
18
+ provider → t`. `char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
19
+ `describe-value.ts` (which renders the length in the same message), because they disagreed: the
20
+ rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
21
+ with "at least 3 chars, received a string of 3 characters".
18
22
  `standard.ts` and `errors.ts` depend on nothing but each other. `iso-date.ts` imports nothing and
19
23
  is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
20
24
  the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
@@ -24,7 +28,8 @@ which is returned to the caller AND interpolated into the log line — and core'
24
28
  KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys`
25
29
  did not help: `expected(…, value)` had already written `received "hunter2"` before the logger saw
26
30
  it. Every rejected value goes through `describeValue`, which reports length and type and nothing
27
- else. No dev flag re-enables the echo — one misconfigured environment is the same breach, and a dev
31
+ else, in CHARACTERS (`char-count.ts`) — the unit the rule that rejected it counts in. No dev flag
32
+ re-enables the echo — one misconfigured environment is the same breach, and a dev
28
33
  overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
29
34
 
30
35
  `X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "7.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,20 @@
1
+ // Single responsibility: how many CHARACTERS a string has. One definition, because the rule that
2
+ // rejects a string and the message that describes it must quote the same number — `validators.ts`
3
+ // counts here, `describe-value.ts` renders from here, and they disagreed on any astral value.
4
+
5
+ /**
6
+ * Code points, not UTF-16 code units — the unit `json-schema.ts` publishes `minLength`/`maxLength`
7
+ * in (JSON Schema defines them over code points), the unit the messages have always said ("chars"),
8
+ * and the unit Postgres' `char_length` counts in. `'👍'.length` is 2, so a count in code units
9
+ * refused a value the published schema, a human and the database all call one character — and,
10
+ * once `describeValue` also read `.length`, `t.string.min(3).safeParse('👍a')` answered
11
+ * "at least 3 chars, received a string of 3 characters", an off-by-one an agent cannot debug.
12
+ *
13
+ * Only a surrogate makes the two counts differ, so the string is walked only when one is present:
14
+ * every ASCII value keeps the O(1) read.
15
+ */
16
+ const HAS_SURROGATE = /[\uD800-\uDBFF]/;
17
+
18
+ export function charCount(value: string): number {
19
+ return HAS_SURROGATE.test(value) ? [...value].length : value.length;
20
+ }
@@ -3,6 +3,8 @@
3
3
  // comment — the rejected value's content never appears in an issue message. Its own file so that
4
4
  // rule has one place to live and one test file to enforce it.
5
5
 
6
+ import { charCount } from './char-count';
7
+
6
8
  /**
7
9
  * The shape of a rejected value — **never its content**.
8
10
  *
@@ -25,10 +27,11 @@ export function describeValue(value: unknown): string {
25
27
  if (value === null) return 'null';
26
28
  switch (typeof value) {
27
29
  case 'string':
28
- // Code units, not code points: the length checks in `validators.ts` use `.length` too, so a
29
- // message quoting a different count than the rule that rejected it would send an agent
30
- // chasing an off-by-one that is not there.
31
- return countOf(value.length, 'string', 'character');
30
+ // `charCount`, never `.length`: the message has to quote the same unit as the rule that
31
+ // rejected the value, and `validators.ts` counts code points. Reading `.length` here made
32
+ // `t.string.min(3).safeParse('👍a')` say "at least 3 chars, received a string of
33
+ // 3 characters" — an off-by-one nobody can debug because it is not in the value.
34
+ return countOf(charCount(value), 'string', 'character');
32
35
  case 'number':
33
36
  return describeNumber(value);
34
37
  case 'boolean':
package/src/errors.ts CHANGED
@@ -101,7 +101,14 @@ export class SchemaError extends Error {
101
101
  // failed validation, which is the request body.
102
102
  const code = singleLine(init.code);
103
103
  const title = singleLine(TITLES[init.code] ?? humanize(init.code));
104
- super(`${code}: ${title}`, { cause: singleLine(init.cause) });
104
+ const cause = singleLine(init.cause);
105
+ // The cause is in `message` for the reason `UltimateError`'s constructor gives: `message` is
106
+ // the ONLY field a runtime prints when an error escapes uncaught — a worker log, a CI
107
+ // transcript, a stack trace — and `code: title` alone names which rule fired but not which
108
+ // field, row or value. `format()` still renders the canonical 3 lines from the fields, so the
109
+ // two cannot disagree. Kept identical to core's on purpose; both are tier 0 and neither may
110
+ // import the other.
111
+ super(`${code}: ${title} — ${cause}`, { cause });
105
112
  this.code = code;
106
113
  this.title = title;
107
114
  this.fix = singleLine(init.fix);
package/src/validators.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type ShapeInput,
18
18
  type ShapeOutput,
19
19
  } from './builder';
20
+ import { charCount } from './char-count';
20
21
  import { expected } from './describe-value';
21
22
  import { discriminatedUnionSchema } from './discriminated-union';
22
23
  import { isZonelessDateTime } from './iso-date';
@@ -61,21 +62,6 @@ function describePattern(node: SchemaNode): string {
61
62
  : `/${node.pattern}/${node.patternFlags}`;
62
63
  }
63
64
 
64
- /**
65
- * Characters, not UTF-16 code units — the unit `json-schema.ts` already promises, since JSON
66
- * Schema defines `minLength`/`maxLength` over code points and the message here has always said
67
- * "chars". `'👍'.length` is 2, so `t.string.max(1)` refused a value the published schema, a human
68
- * and Postgres' `char_length` all count as one.
69
- *
70
- * Only a surrogate makes the two counts differ, so the string is walked only when one is present
71
- * — every ASCII value keeps the O(1) read this replaced.
72
- */
73
- const HAS_SURROGATE = /[\uD800-\uDBFF]/;
74
-
75
- function charCount(value: string): number {
76
- return HAS_SURROGATE.test(value) ? [...value].length : value.length;
77
- }
78
-
79
65
  /**
80
66
  * Compiled once per schema, not once per validation. `lastIndex` is reset because a `g` or `y`
81
67
  * pattern carries it between calls — a cached global RegExp answers `false` for the second