@c9up/rune 0.1.3
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/LICENSE +21 -0
- package/README.md +31 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +48 -0
- package/scripts/copy-napi.mjs +67 -0
- package/src/Schema.ts +553 -0
- package/src/errors.ts +14 -0
- package/src/index.ts +21 -0
- package/src/native.ts +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C9up
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @c9up/rune
|
|
2
|
+
|
|
3
|
+
Validation engine for Node.js. Fluent rules, schema validation, transforms.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { rules, schema } from '@c9up/rune'
|
|
9
|
+
|
|
10
|
+
const CreateOrder = schema({
|
|
11
|
+
total: rules.number().positive(),
|
|
12
|
+
email: rules.string().email(),
|
|
13
|
+
name: rules.string().min(3).max(100).trim(),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const result = CreateOrder.validate({ total: 42, email: 'a@b.com', name: ' Alice ' })
|
|
17
|
+
// result.valid === true, result.data.name === 'Alice'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- `rules.string()`, `rules.number()`, `rules.boolean()`, `rules.any()`
|
|
23
|
+
- `.min()`, `.max()`, `.email()`, `.positive()`, `.trim()`, `.optional()`
|
|
24
|
+
- `.custom(name, fn, message)` for custom rules
|
|
25
|
+
- `.message()` to override error messages
|
|
26
|
+
- Transforms applied before validation
|
|
27
|
+
- Structured error output with field, rule, message
|
|
28
|
+
|
|
29
|
+
## License
|
|
30
|
+
|
|
31
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@c9up/rune",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Rune — Validation engine for the Ream framework",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md",
|
|
12
|
+
"dist",
|
|
13
|
+
"index.*.node",
|
|
14
|
+
"scripts",
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.19.15",
|
|
19
|
+
"typescript": "^6.0.2",
|
|
20
|
+
"vitest": "^4.1.2"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.0.0"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/C9up/rune.git"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"build:rust": "cargo build --release -p rune-engine-napi",
|
|
41
|
+
"build:napi": "pnpm build:rust && node scripts/copy-napi.mjs",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:rust": "cargo test -p rune-engine",
|
|
44
|
+
"lint": "biome check src/",
|
|
45
|
+
"test:coverage": "vitest run --coverage",
|
|
46
|
+
"typecheck": "tsc --noEmit"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { copyFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { arch, env, platform } from 'node:process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const root = join(here, '..')
|
|
8
|
+
const CRATE = 'rune_engine_napi'
|
|
9
|
+
const TAG = '[rune:napi]'
|
|
10
|
+
|
|
11
|
+
// Rust target triple -> { suffix, os }. Set CARGO_BUILD_TARGET to cross-compile
|
|
12
|
+
// (e.g. build the x86_64-apple-darwin binary on an arm64 macOS runner). When
|
|
13
|
+
// unset we fall back to the host platform/arch and the default target/release.
|
|
14
|
+
const tripleMap = {
|
|
15
|
+
'x86_64-unknown-linux-gnu': { suffix: 'linux-x64-gnu', os: 'linux' },
|
|
16
|
+
'aarch64-unknown-linux-gnu': { suffix: 'linux-arm64-gnu', os: 'linux' },
|
|
17
|
+
'x86_64-apple-darwin': { suffix: 'darwin-x64', os: 'darwin' },
|
|
18
|
+
'aarch64-apple-darwin': { suffix: 'darwin-arm64', os: 'darwin' },
|
|
19
|
+
'x86_64-pc-windows-msvc': { suffix: 'win32-x64-msvc', os: 'win32' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hostSuffixMap = {
|
|
23
|
+
'linux-x64': 'linux-x64-gnu',
|
|
24
|
+
'linux-arm64': 'linux-arm64-gnu',
|
|
25
|
+
'darwin-x64': 'darwin-x64',
|
|
26
|
+
'darwin-arm64': 'darwin-arm64',
|
|
27
|
+
'win32-x64': 'win32-x64-msvc',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const triple = env.CARGO_BUILD_TARGET ?? ''
|
|
31
|
+
let suffix
|
|
32
|
+
let os
|
|
33
|
+
let releaseDir
|
|
34
|
+
if (triple) {
|
|
35
|
+
const entry = tripleMap[triple]
|
|
36
|
+
if (!entry) {
|
|
37
|
+
throw new Error(`${TAG} unsupported CARGO_BUILD_TARGET: ${triple}`)
|
|
38
|
+
}
|
|
39
|
+
suffix = entry.suffix
|
|
40
|
+
os = entry.os
|
|
41
|
+
releaseDir = join(root, 'target', triple, 'release')
|
|
42
|
+
} else {
|
|
43
|
+
suffix = hostSuffixMap[`${platform}-${arch}`]
|
|
44
|
+
os = platform
|
|
45
|
+
releaseDir = join(root, 'target', 'release')
|
|
46
|
+
if (!suffix) {
|
|
47
|
+
throw new Error(`${TAG} unsupported platform/arch: ${platform}-${arch}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const candidates =
|
|
52
|
+
os === 'win32'
|
|
53
|
+
? [join(releaseDir, `${CRATE}.dll`), join(releaseDir, `lib${CRATE}.dll`)]
|
|
54
|
+
: os === 'darwin'
|
|
55
|
+
? [join(releaseDir, `lib${CRATE}.dylib`)]
|
|
56
|
+
: [join(releaseDir, `lib${CRATE}.so`)]
|
|
57
|
+
|
|
58
|
+
const source = candidates.find((candidate) => existsSync(candidate))
|
|
59
|
+
if (!source) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${TAG} native library not found. Looked for:\n${candidates.map((p) => `- ${p}`).join('\n')}`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const target = join(root, `index.${suffix}.node`)
|
|
66
|
+
copyFileSync(source, target)
|
|
67
|
+
console.log(`${TAG} copied ${source} -> ${target}`)
|
package/src/Schema.ts
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rune Validation Schema — fluent validation rules.
|
|
3
|
+
*
|
|
4
|
+
* @implements FR38, FR39, FR40, FR41
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RuneError } from "./errors.js";
|
|
8
|
+
import { isNativeAvailable, validateNative } from "./native.js";
|
|
9
|
+
|
|
10
|
+
export type ValidationMessageParams = Record<string, string | number | boolean>;
|
|
11
|
+
export type ValidationTranslator = (
|
|
12
|
+
key: string,
|
|
13
|
+
params?: ValidationMessageParams,
|
|
14
|
+
) => string | undefined;
|
|
15
|
+
|
|
16
|
+
export interface ValidationError {
|
|
17
|
+
field: string;
|
|
18
|
+
rule: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validation result — discriminated union that narrows `data` to the schema's
|
|
24
|
+
* `T` when `valid` is `true`, removing the need for callers to cast or guard
|
|
25
|
+
* `data` separately.
|
|
26
|
+
*/
|
|
27
|
+
export type ValidationResult<T = Record<string, unknown>> =
|
|
28
|
+
| { valid: true; errors: ValidationError[]; data: T }
|
|
29
|
+
| { valid: false; errors: ValidationError[]; data?: undefined };
|
|
30
|
+
|
|
31
|
+
export interface ValidationSchema<T = Record<string, unknown>> {
|
|
32
|
+
fields: Record<string, RuleChain>;
|
|
33
|
+
validate(data: unknown): ValidationResult<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Type guard: narrows `unknown` to a plain object (non-null, non-array, typeof 'object'). */
|
|
37
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Rules the Rust validation engine can handle natively. */
|
|
42
|
+
const STANDARD_RULES: ReadonlySet<string> = new Set([
|
|
43
|
+
"string",
|
|
44
|
+
"number",
|
|
45
|
+
"boolean",
|
|
46
|
+
"min",
|
|
47
|
+
"max",
|
|
48
|
+
"email",
|
|
49
|
+
"positive",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** Default messages for standard rules — used to detect custom-message overrides. */
|
|
53
|
+
const STANDARD_MSGS: Readonly<Record<string, string>> = {
|
|
54
|
+
string: "Must be a string",
|
|
55
|
+
number: "Must be a number",
|
|
56
|
+
boolean: "Must be a boolean",
|
|
57
|
+
min: "Minimum",
|
|
58
|
+
max: "Maximum",
|
|
59
|
+
email: "Must be a valid email",
|
|
60
|
+
positive: "Must be positive",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const TYPE_RULE_NAMES: ReadonlySet<string> = new Set([
|
|
64
|
+
"string",
|
|
65
|
+
"number",
|
|
66
|
+
"boolean",
|
|
67
|
+
"object",
|
|
68
|
+
"array",
|
|
69
|
+
]);
|
|
70
|
+
let validationTranslator: ValidationTranslator | undefined;
|
|
71
|
+
|
|
72
|
+
function hasDefaultMessage(rule: RuleDef): boolean {
|
|
73
|
+
if (rule.name === "min" || rule.name === "max") {
|
|
74
|
+
if (typeof rule.param !== "number") return false;
|
|
75
|
+
const expected = `${rule.name === "min" ? "Minimum" : "Maximum"} ${rule.param}`;
|
|
76
|
+
return rule.message === expected;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const defaultMsg = STANDARD_MSGS[rule.name];
|
|
80
|
+
return defaultMsg !== undefined && rule.message === defaultMsg;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveValidationMessage(
|
|
84
|
+
key: string,
|
|
85
|
+
fallback: string,
|
|
86
|
+
params?: ValidationMessageParams,
|
|
87
|
+
): string {
|
|
88
|
+
const translated = validationTranslator?.(key, params);
|
|
89
|
+
if (typeof translated === "string" && translated.length > 0) {
|
|
90
|
+
return translated;
|
|
91
|
+
}
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveRuleMessage(field: string, rule: RuleDef): string {
|
|
96
|
+
const fallback = rule.message;
|
|
97
|
+
if (!STANDARD_RULES.has(rule.name)) {
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
if (!hasDefaultMessage(rule)) {
|
|
101
|
+
return fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const params: ValidationMessageParams = { field };
|
|
105
|
+
if (rule.name === "min" && typeof rule.param === "number") {
|
|
106
|
+
params.min = rule.param;
|
|
107
|
+
}
|
|
108
|
+
if (rule.name === "max" && typeof rule.param === "number") {
|
|
109
|
+
params.max = rule.param;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return resolveValidationMessage(`validation.${rule.name}`, fallback, params);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Compute once: does any field rule prevent dispatching to Rust? */
|
|
116
|
+
function detectHasCustomRules(fields: Record<string, RuleChain>): boolean {
|
|
117
|
+
return Object.values(fields).some((chain) => {
|
|
118
|
+
return chain.rules.some((r) => {
|
|
119
|
+
if (!STANDARD_RULES.has(r.name)) return true; // custom rule
|
|
120
|
+
if (!hasDefaultMessage(r)) return true; // custom message
|
|
121
|
+
return false;
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a validation schema.
|
|
128
|
+
*
|
|
129
|
+
* Pass `T` explicitly when the caller wants `result.data` typed as a concrete
|
|
130
|
+
* shape after `result.valid === true` narrows the union — runtime validation
|
|
131
|
+
* is unchanged, the generic only types the success branch.
|
|
132
|
+
*
|
|
133
|
+
* const RegisterValidator = schema<{ email: string; password: string }>({
|
|
134
|
+
* email: rules.string().email(),
|
|
135
|
+
* password: rules.string().min(8),
|
|
136
|
+
* });
|
|
137
|
+
*
|
|
138
|
+
* The default `Record<string, unknown>` matches the historical untyped surface
|
|
139
|
+
* so existing call sites that read `result.data` field-by-field with their
|
|
140
|
+
* own narrowing continue to compile.
|
|
141
|
+
*/
|
|
142
|
+
export function schema<T = Record<string, unknown>>(
|
|
143
|
+
fields: Record<string, RuleChain>,
|
|
144
|
+
): ValidationSchema<T> {
|
|
145
|
+
// Computed once at construction time, not per validate() call.
|
|
146
|
+
const hasCustomRules = detectHasCustomRules(fields);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
fields,
|
|
150
|
+
validate(data: unknown): ValidationResult<T> {
|
|
151
|
+
if (!isPlainObject(data)) {
|
|
152
|
+
return {
|
|
153
|
+
valid: false,
|
|
154
|
+
errors: [
|
|
155
|
+
{
|
|
156
|
+
field: "_root",
|
|
157
|
+
rule: "type",
|
|
158
|
+
message: "Input must be an object",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (isNativeAvailable() && !hasCustomRules && !validationTranslator) {
|
|
165
|
+
return validateWithRust<T>(fields, data);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const errors: ValidationError[] = [];
|
|
169
|
+
const validated: Record<string, unknown> = {};
|
|
170
|
+
|
|
171
|
+
for (const [field, chain] of Object.entries(fields)) {
|
|
172
|
+
const value = data[field];
|
|
173
|
+
const result = chain._validateWithTransform(field, value);
|
|
174
|
+
errors.push(...result.errors);
|
|
175
|
+
if (result.errors.length === 0 && value !== undefined) {
|
|
176
|
+
validated[field] = result.transformed;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (errors.length === 0) {
|
|
181
|
+
return { valid: true, errors, data: validated as T };
|
|
182
|
+
}
|
|
183
|
+
return { valid: false, errors };
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function setValidationTranslator(
|
|
189
|
+
translator?: ValidationTranslator,
|
|
190
|
+
): void {
|
|
191
|
+
validationTranslator = translator;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function bindRosetta(rosetta: {
|
|
195
|
+
t(key: string, params?: ValidationMessageParams): string;
|
|
196
|
+
}): void {
|
|
197
|
+
setValidationTranslator((key, params) => rosetta.t(key, params));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Rule definition stored on a chain. */
|
|
201
|
+
export interface RuleDef {
|
|
202
|
+
name: string;
|
|
203
|
+
param?: number;
|
|
204
|
+
validate: (value: unknown) => boolean;
|
|
205
|
+
message: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Rule chain — fluent validation builder. */
|
|
209
|
+
export class RuleChain {
|
|
210
|
+
#rules: RuleDef[] = [];
|
|
211
|
+
#isOptional = false;
|
|
212
|
+
#transforms: Array<{ name: string; fn: (value: unknown) => unknown }> = [];
|
|
213
|
+
#nestedSchema: Record<string, RuleChain> | null = null;
|
|
214
|
+
#arrayItemChain: RuleChain | null = null;
|
|
215
|
+
|
|
216
|
+
/** Public read access to rules (for OpenAPI generation, Rust bridge). */
|
|
217
|
+
get rules(): readonly RuleDef[] {
|
|
218
|
+
return this.#rules;
|
|
219
|
+
}
|
|
220
|
+
get isOptionalField(): boolean {
|
|
221
|
+
return this.#isOptional;
|
|
222
|
+
}
|
|
223
|
+
get transforms(): ReadonlyArray<{
|
|
224
|
+
name: string;
|
|
225
|
+
fn: (value: unknown) => unknown;
|
|
226
|
+
}> {
|
|
227
|
+
return this.#transforms;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Mark field as optional. */
|
|
231
|
+
optional(): this {
|
|
232
|
+
this.#isOptional = true;
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Must be an object matching a nested schema. */
|
|
237
|
+
object(shape: Record<string, RuleChain>): this {
|
|
238
|
+
this.#rules.push({
|
|
239
|
+
name: "object",
|
|
240
|
+
validate: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
|
|
241
|
+
message: "Must be an object",
|
|
242
|
+
});
|
|
243
|
+
this.#nestedSchema = shape;
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Must be an array. Items validated by the provided chain. */
|
|
248
|
+
array(itemChain?: RuleChain): this {
|
|
249
|
+
this.#rules.push({
|
|
250
|
+
name: "array",
|
|
251
|
+
validate: (v) => Array.isArray(v),
|
|
252
|
+
message: "Must be an array",
|
|
253
|
+
});
|
|
254
|
+
this.#arrayItemChain = itemChain ?? null;
|
|
255
|
+
return this;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Must be a string. */
|
|
259
|
+
string(): this {
|
|
260
|
+
this.#rules.push({
|
|
261
|
+
name: "string",
|
|
262
|
+
validate: (v) => typeof v === "string",
|
|
263
|
+
message: "Must be a string",
|
|
264
|
+
});
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Must be a number. */
|
|
269
|
+
number(): this {
|
|
270
|
+
this.#rules.push({
|
|
271
|
+
name: "number",
|
|
272
|
+
validate: (v) =>
|
|
273
|
+
typeof v === "number" && !Number.isNaN(v) && Number.isFinite(v),
|
|
274
|
+
message: "Must be a number",
|
|
275
|
+
});
|
|
276
|
+
return this;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Must be a boolean. */
|
|
280
|
+
boolean(): this {
|
|
281
|
+
this.#rules.push({
|
|
282
|
+
name: "boolean",
|
|
283
|
+
validate: (v) => typeof v === "boolean",
|
|
284
|
+
message: "Must be a boolean",
|
|
285
|
+
});
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Minimum length (string) or minimum value (number). */
|
|
290
|
+
min(n: number): this {
|
|
291
|
+
this.#rules.push({
|
|
292
|
+
name: "min",
|
|
293
|
+
param: n,
|
|
294
|
+
validate: (v) =>
|
|
295
|
+
typeof v === "string"
|
|
296
|
+
? [...v].length >= n
|
|
297
|
+
: typeof v === "number"
|
|
298
|
+
? v >= n
|
|
299
|
+
: false,
|
|
300
|
+
message: `Minimum ${n}`,
|
|
301
|
+
});
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Maximum length (string) or maximum value (number). */
|
|
306
|
+
max(n: number): this {
|
|
307
|
+
this.#rules.push({
|
|
308
|
+
name: "max",
|
|
309
|
+
param: n,
|
|
310
|
+
validate: (v) =>
|
|
311
|
+
typeof v === "string"
|
|
312
|
+
? [...v].length <= n
|
|
313
|
+
: typeof v === "number"
|
|
314
|
+
? v <= n
|
|
315
|
+
: false,
|
|
316
|
+
message: `Maximum ${n}`,
|
|
317
|
+
});
|
|
318
|
+
return this;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Must be a valid email. */
|
|
322
|
+
email(): this {
|
|
323
|
+
this.#rules.push({
|
|
324
|
+
name: "email",
|
|
325
|
+
validate: (v) => {
|
|
326
|
+
if (typeof v !== "string") return false;
|
|
327
|
+
if (/[\r\n]/.test(v)) return false;
|
|
328
|
+
const at = v.indexOf("@");
|
|
329
|
+
if (at <= 0 || at !== v.lastIndexOf("@")) return false;
|
|
330
|
+
const domain = v.slice(at + 1);
|
|
331
|
+
const dot = domain.lastIndexOf(".");
|
|
332
|
+
return dot > 0 && dot < domain.length - 1;
|
|
333
|
+
},
|
|
334
|
+
message: "Must be a valid email",
|
|
335
|
+
});
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Must be positive (> 0) and finite. */
|
|
340
|
+
positive(): this {
|
|
341
|
+
this.#rules.push({
|
|
342
|
+
name: "positive",
|
|
343
|
+
validate: (v) => typeof v === "number" && Number.isFinite(v) && v > 0,
|
|
344
|
+
message: "Must be positive",
|
|
345
|
+
});
|
|
346
|
+
return this;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Trim whitespace (transform). */
|
|
350
|
+
trim(): this {
|
|
351
|
+
this.#transforms.push({
|
|
352
|
+
name: "trim",
|
|
353
|
+
fn: (v) => (typeof v === "string" ? v.trim() : v),
|
|
354
|
+
});
|
|
355
|
+
return this;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Custom validation rule. */
|
|
359
|
+
custom(
|
|
360
|
+
name: string,
|
|
361
|
+
validate: (value: unknown) => boolean,
|
|
362
|
+
message?: string,
|
|
363
|
+
): this {
|
|
364
|
+
this.#rules.push({
|
|
365
|
+
name,
|
|
366
|
+
validate,
|
|
367
|
+
message: message ?? `Failed custom rule: ${name}`,
|
|
368
|
+
});
|
|
369
|
+
return this;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Set custom error message for the last rule. */
|
|
373
|
+
message(msg: string): this {
|
|
374
|
+
if (this.#rules.length === 0) {
|
|
375
|
+
throw new RuneError("NO_RULE", "message() must be called after a rule");
|
|
376
|
+
}
|
|
377
|
+
this.#rules[this.#rules.length - 1].message = msg;
|
|
378
|
+
return this;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Internal: validate a field value and return errors + transformed value. */
|
|
382
|
+
_validateWithTransform(
|
|
383
|
+
field: string,
|
|
384
|
+
value: unknown,
|
|
385
|
+
): { errors: ValidationError[]; transformed: unknown } {
|
|
386
|
+
if (value === undefined || value === null) {
|
|
387
|
+
if (this.#isOptional) return { errors: [], transformed: value };
|
|
388
|
+
return { errors: [this.#requiredError(field)], transformed: value };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 1. Type rules first on the raw value — bail on type mismatch.
|
|
392
|
+
const typeError = this.#runTypeRules(field, value);
|
|
393
|
+
if (typeError) return { errors: [typeError], transformed: value };
|
|
394
|
+
|
|
395
|
+
// 2. Apply transforms (trim, etc.), then run value rules on the result.
|
|
396
|
+
let transformed = this.#applyTransformsTo(value);
|
|
397
|
+
const errors = this.#runValueRules(field, transformed);
|
|
398
|
+
|
|
399
|
+
// 4. Nested object validation (only if type check passed — not arrays)
|
|
400
|
+
if (
|
|
401
|
+
this.#nestedSchema &&
|
|
402
|
+
typeof transformed === "object" &&
|
|
403
|
+
transformed !== null &&
|
|
404
|
+
!Array.isArray(transformed)
|
|
405
|
+
) {
|
|
406
|
+
transformed = { ...(transformed as Record<string, unknown>) };
|
|
407
|
+
for (const [nestedField, chain] of Object.entries(this.#nestedSchema)) {
|
|
408
|
+
const nestedValue = (transformed as Record<string, unknown>)[
|
|
409
|
+
nestedField
|
|
410
|
+
];
|
|
411
|
+
const nestedResult = chain._validateWithTransform(
|
|
412
|
+
`${field}.${nestedField}`,
|
|
413
|
+
nestedValue,
|
|
414
|
+
);
|
|
415
|
+
errors.push(...nestedResult.errors);
|
|
416
|
+
if (nestedResult.transformed !== undefined) {
|
|
417
|
+
(transformed as Record<string, unknown>)[nestedField] =
|
|
418
|
+
nestedResult.transformed;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 5. Array item validation
|
|
424
|
+
if (this.#arrayItemChain && Array.isArray(transformed)) {
|
|
425
|
+
transformed = [...transformed];
|
|
426
|
+
for (let i = 0; i < (transformed as unknown[]).length; i++) {
|
|
427
|
+
const itemResult = this.#arrayItemChain._validateWithTransform(
|
|
428
|
+
`${field}.${i}`,
|
|
429
|
+
(transformed as unknown[])[i],
|
|
430
|
+
);
|
|
431
|
+
errors.push(...itemResult.errors);
|
|
432
|
+
if (itemResult.transformed !== undefined) {
|
|
433
|
+
(transformed as unknown[])[i] = itemResult.transformed;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return { errors, transformed };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#requiredError(field: string): ValidationError {
|
|
442
|
+
return {
|
|
443
|
+
field,
|
|
444
|
+
rule: "required",
|
|
445
|
+
message: resolveValidationMessage(
|
|
446
|
+
"validation.required",
|
|
447
|
+
`${field} is required`,
|
|
448
|
+
{ field },
|
|
449
|
+
),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Run the type rules (string/number/…) on the raw value; first failure bails. */
|
|
454
|
+
#runTypeRules(field: string, value: unknown): ValidationError | null {
|
|
455
|
+
for (const rule of this.#rules) {
|
|
456
|
+
if (TYPE_RULE_NAMES.has(rule.name) && !rule.validate(value)) {
|
|
457
|
+
return {
|
|
458
|
+
field,
|
|
459
|
+
rule: rule.name,
|
|
460
|
+
message: resolveRuleMessage(field, rule),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Run the non-type rules (min/max/email/…) on the transformed value. */
|
|
468
|
+
#runValueRules(field: string, transformed: unknown): ValidationError[] {
|
|
469
|
+
const errors: ValidationError[] = [];
|
|
470
|
+
for (const rule of this.#rules) {
|
|
471
|
+
if (!TYPE_RULE_NAMES.has(rule.name) && !rule.validate(transformed)) {
|
|
472
|
+
errors.push({
|
|
473
|
+
field,
|
|
474
|
+
rule: rule.name,
|
|
475
|
+
message: resolveRuleMessage(field, rule),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return errors;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Internal: validate a field value against all rules. */
|
|
483
|
+
_validate(field: string, value: unknown): ValidationError[] {
|
|
484
|
+
return this._validateWithTransform(field, value).errors;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Internal: apply transforms. */
|
|
488
|
+
_transform(value: unknown): unknown {
|
|
489
|
+
return this.#applyTransformsTo(value);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#applyTransformsTo(value: unknown): unknown {
|
|
493
|
+
let result = value;
|
|
494
|
+
for (const transform of this.#transforms) {
|
|
495
|
+
result = transform.fn(result);
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Entry point for building rules. */
|
|
502
|
+
export const rules = {
|
|
503
|
+
string: () => new RuleChain().string(),
|
|
504
|
+
number: () => new RuleChain().number(),
|
|
505
|
+
boolean: () => new RuleChain().boolean(),
|
|
506
|
+
any: () => new RuleChain(),
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/** Serialize schema + data and validate via Rust NAPI. */
|
|
510
|
+
function validateWithRust<T>(
|
|
511
|
+
fields: Record<string, RuleChain>,
|
|
512
|
+
data: Record<string, unknown>,
|
|
513
|
+
): ValidationResult<T> {
|
|
514
|
+
const schemaDesc: Record<
|
|
515
|
+
string,
|
|
516
|
+
{
|
|
517
|
+
rules: Array<{ name: string; params: unknown }>;
|
|
518
|
+
optional: boolean;
|
|
519
|
+
transforms: string[];
|
|
520
|
+
}
|
|
521
|
+
> = {};
|
|
522
|
+
|
|
523
|
+
for (const [field, chain] of Object.entries(fields)) {
|
|
524
|
+
const rules = chain.rules.map((r) => ({
|
|
525
|
+
name: r.name,
|
|
526
|
+
params:
|
|
527
|
+
r.name === "min"
|
|
528
|
+
? { min: extractParam(chain, "min") }
|
|
529
|
+
: r.name === "max"
|
|
530
|
+
? { max: extractParam(chain, "max") }
|
|
531
|
+
: null,
|
|
532
|
+
}));
|
|
533
|
+
schemaDesc[field] = {
|
|
534
|
+
rules,
|
|
535
|
+
optional: chain.isOptionalField,
|
|
536
|
+
transforms: chain.transforms.map((t) => t.name),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const request = JSON.stringify({ schema: schemaDesc, data });
|
|
541
|
+
const native = validateNative(request);
|
|
542
|
+
if (native.valid && native.data !== undefined) {
|
|
543
|
+
return { valid: true, errors: native.errors, data: native.data as T };
|
|
544
|
+
}
|
|
545
|
+
return { valid: false, errors: native.errors };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function extractParam(chain: RuleChain, ruleName: string): number | undefined {
|
|
549
|
+
const rule = chain.rules.find((r) => r.name === ruleName);
|
|
550
|
+
if (!rule) return undefined;
|
|
551
|
+
// Use stored param directly (no longer parsed from message text)
|
|
552
|
+
return rule.param;
|
|
553
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuneError — structured error for Rune validation.
|
|
3
|
+
*/
|
|
4
|
+
export class RuneError extends Error {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly hint?: string;
|
|
7
|
+
|
|
8
|
+
constructor(code: string, message: string, options?: { hint?: string }) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "RuneError";
|
|
11
|
+
this.code = `RUNE_${code}`;
|
|
12
|
+
this.hint = options?.hint;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @c9up/rune
|
|
3
|
+
* @description Rune — Validation engine for the Ream framework
|
|
4
|
+
* @implements FR38, FR39, FR40, FR41, FR42
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { RuneError } from "./errors.js";
|
|
8
|
+
export type {
|
|
9
|
+
RuleChain,
|
|
10
|
+
ValidationError,
|
|
11
|
+
ValidationMessageParams,
|
|
12
|
+
ValidationResult,
|
|
13
|
+
ValidationSchema,
|
|
14
|
+
ValidationTranslator,
|
|
15
|
+
} from "./Schema.js";
|
|
16
|
+
export {
|
|
17
|
+
bindRosetta,
|
|
18
|
+
rules,
|
|
19
|
+
schema,
|
|
20
|
+
setValidationTranslator,
|
|
21
|
+
} from "./Schema.js";
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native validation loader — loads the Rust NAPI binary.
|
|
3
|
+
*
|
|
4
|
+
* @implements FR40
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { arch, platform } from "node:process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const require2 = createRequire(import.meta.url);
|
|
13
|
+
const __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
const platformMap: Record<string, string> = {
|
|
16
|
+
"linux-x64": "linux-x64-gnu",
|
|
17
|
+
"linux-arm64": "linux-arm64-gnu",
|
|
18
|
+
"darwin-x64": "darwin-x64",
|
|
19
|
+
"darwin-arm64": "darwin-arm64",
|
|
20
|
+
"win32-x64": "win32-x64-msvc",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let native: { validate: (json: string) => string } | undefined;
|
|
24
|
+
let loadError: unknown;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const suffix = platformMap[`${platform}-${arch}`];
|
|
28
|
+
if (suffix) {
|
|
29
|
+
native = require2(join(__dirname2, `../index.${suffix}.node`));
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
loadError = e;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate data via the Rust NAPI engine.
|
|
37
|
+
*/
|
|
38
|
+
export function validateNative(requestJson: string): {
|
|
39
|
+
valid: boolean;
|
|
40
|
+
errors: Array<{ field: string; rule: string; message: string }>;
|
|
41
|
+
data?: Record<string, unknown>;
|
|
42
|
+
} {
|
|
43
|
+
if (!native) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[RUNE_NAPI_NOT_FOUND] Rust validation engine not available: ${loadError ?? "binary not found"}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const result = JSON.parse(native.validate(requestJson));
|
|
49
|
+
if (typeof result.valid !== "boolean" || !Array.isArray(result.errors)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[RUNE_NAPI_INVALID_RESPONSE] Rust engine returned unexpected shape: ${JSON.stringify(result)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isNativeAvailable(): boolean {
|
|
58
|
+
return native !== undefined;
|
|
59
|
+
}
|