@ultimat3/schema 7.0.0 → 9.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 +8 -3
- package/package.json +1 -1
- package/src/char-count.ts +20 -0
- package/src/describe-value.ts +7 -4
- package/src/errors.ts +8 -1
- package/src/validators.ts +1 -15
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 →
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/describe-value.ts
CHANGED
|
@@ -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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
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
|
-
|
|
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
|