@valfuse-node/form 0.2.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/README.md ADDED
@@ -0,0 +1,578 @@
1
+ # @valfuse-node/form
2
+
3
+ > Framework-agnostic form domain: schema definition, rule-based validation, value transformation, error normalization, and pure state-management primitives. Zero React/Vue dependencies — usable in Node.js, server actions, CLI tools, or any framework.
4
+
5
+ ```bash
6
+ npm install @valfuse-node/form
7
+ ```
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Why `@valfuse-node/form`?](#why-valfuse-nodeform)
14
+ - [Quick Start](#quick-start)
15
+ - [Schema Definition](#schema-definition)
16
+ - [Built-in Rules](#built-in-rules)
17
+ - [Value Transformation](#value-transformation)
18
+ - [Validation](#validation)
19
+ - [Error Handling](#error-handling)
20
+ - [Framework-Agnostic State](#framework-agnostic-state)
21
+ - [Type Reference](#type-reference)
22
+ - [Development Usage](#development-usage)
23
+ - [License](#license)
24
+
25
+ ---
26
+
27
+ ## Why `@valfuse-node/form`?
28
+
29
+ The form package is the **shared contract** for the entire `@valfuse-node` ecosystem:
30
+
31
+ - **Use directly in Node.js / server actions** — no React or Vue required.
32
+ - **Use as the validation engine** for `useReactValfuseForm` / `useVueValfuseForm` — the adapters read the same `ValfuseSchema` and `ValfuseFormState` interfaces.
33
+ - **Type-safe end-to-end** — `defaultValues` infers the value type; `setErrors` is typed by your field names; `formState.errors` is a `Record<keyof TFieldValues, ValfuseFieldError>`.
34
+
35
+ ```ts
36
+ import { createSchema, validateSchema, transformValues, t } from "@valfuse-node/form";
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ```ts
44
+ import {
45
+ createSchema,
46
+ validateSchema,
47
+ transformValues,
48
+ t,
49
+ } from "@valfuse-node/form";
50
+
51
+ const schema = createSchema({
52
+ email: {
53
+ type: "string",
54
+ transform: t.pipe(t.trim, t.toLowerCase),
55
+ rules: [
56
+ { name: "required", error: { message: "Email is required", code: "email.required" } },
57
+ { name: "email", error: { message: "Invalid email", code: "email.invalid" } },
58
+ ],
59
+ },
60
+ age: {
61
+ type: "number",
62
+ transform: t.toInteger,
63
+ rules: [
64
+ { name: "required", error: { message: "Required", code: "age.required" } },
65
+ { name: "min", value: 18, error: { message: "Must be 18+", code: "age.min" } },
66
+ ],
67
+ },
68
+ });
69
+
70
+ // 1. Coerce raw input (e.g. strings from <input>) to typed values
71
+ const typed = transformValues(schema, { email: " ALICE@EXAMPLE.COM ", age: "25" });
72
+ // → { email: "alice@example.com", age: 25 }
73
+
74
+ // 2. Validate the typed values
75
+ const errors = validateSchema(schema, typed);
76
+ // → {} (passes)
77
+
78
+ // 3. Inject server errors if any
79
+ // form.setErrors({ email: { message: "Already registered", code: "auth.duplicate" } });
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Schema Definition
85
+
86
+ `createSchema(definition)` is an identity function whose only job is to give you **autocomplete and type inference** for the schema.
87
+
88
+ ```ts
89
+ import { createSchema } from "@valfuse-node/form";
90
+
91
+ const schema = createSchema({
92
+ // ─── string fields ──────────────────────────────────────────────────────────
93
+ name: {
94
+ type: "string",
95
+ rules: [
96
+ { name: "required", error: { message: "Required" } },
97
+ { name: "minLength", value: 2, error: { message: "Min 2 chars" } },
98
+ { name: "maxLength", value: 50, error: { message: "Max 50 chars" } },
99
+ ],
100
+ },
101
+
102
+ // ─── number fields ──────────────────────────────────────────────────────────
103
+ age: {
104
+ type: "number",
105
+ transform: t.toInteger, // optional pre-validation coercion
106
+ rules: [
107
+ { name: "min", value: 0, error: { message: "Must be ≥ 0" } },
108
+ { name: "max", value: 120, error: { message: "Must be ≤ 120" } },
109
+ { name: "int", error: { message: "Whole numbers only" } },
110
+ ],
111
+ },
112
+
113
+ // ─── boolean fields ─────────────────────────────────────────────────────────
114
+ agreed: {
115
+ type: "boolean",
116
+ rules: [
117
+ { name: "accepted", error: { message: "You must accept the terms" } },
118
+ ],
119
+ },
120
+
121
+ // ─── array fields ───────────────────────────────────────────────────────────
122
+ tags: {
123
+ type: "array",
124
+ rules: [
125
+ { name: "minItems", value: 1, error: { message: "Add at least 1 tag" } },
126
+ { name: "maxItems", value: 10, error: { message: "Max 10 tags" } },
127
+ ],
128
+ },
129
+
130
+ // ─── object fields (nested) ─────────────────────────────────────────────────
131
+ address: {
132
+ type: "object",
133
+ rules: [
134
+ { name: "required", error: { message: "Address is required" } },
135
+ ],
136
+ },
137
+ });
138
+ ```
139
+
140
+ ### Supported field types
141
+
142
+ | Type | Description |
143
+ |---|---|
144
+ | `"string"` | Free text, validated by string rules |
145
+ | `"number"` | Coerced numeric value (use `transform: t.toNumber` to coerce raw strings) |
146
+ | `"boolean"` | `true` / `false` |
147
+ | `"array"` | Any array (length-based rules only — element validation is a `custom` rule) |
148
+ | `"object"` | Any object (shape and presence rules only) |
149
+
150
+ ---
151
+
152
+ ## Built-in Rules
153
+
154
+ ### String rules
155
+
156
+ | Rule | Value | Example |
157
+ |---|---|---|
158
+ | `required` | — | `{ name: "required", error: { message: "Required" } }` |
159
+ | `min` | `number` (length) | `{ name: "min", value: 3, error: … }` |
160
+ | `max` | `number` (length) | `{ name: "max", value: 100, error: … }` |
161
+ | `length` | `number` (exact length) | `{ name: "length", value: 10, error: … }` |
162
+ | `email` | — | `{ name: "email", error: … }` |
163
+ | `url` | — | `{ name: "url", error: … }` |
164
+ | `uuid` | — | `{ name: "uuid", error: … }` |
165
+ | `regex` | `RegExp` or `{ pattern, flags }` | `{ name: "regex", value: /^[a-z]+$/, error: … }` |
166
+ | `includes` | `string` | `{ name: "includes", value: "@", error: … }` |
167
+ | `startsWith` | `string` | `{ name: "startsWith", value: "https://", error: … }` |
168
+ | `endsWith` | `string` | `{ name: "endsWith", value: ".com", error: … }` |
169
+
170
+ ### Number rules
171
+
172
+ | Rule | Value | Notes |
173
+ |---|---|---|
174
+ | `required` | — | Rejects `null`, `undefined`, `NaN` |
175
+ | `min` | `number` | Inclusive lower bound |
176
+ | `max` | `number` | Inclusive upper bound |
177
+ | `gt` | `number` | Strictly greater than |
178
+ | `gte` | `number` | Greater than or equal |
179
+ | `lt` | `number` | Strictly less than |
180
+ | `lte` | `number` | Less than or equal |
181
+ | `int` | — | Rejects non-integers |
182
+ | `positive` | — | `> 0` |
183
+ | `nonnegative` | — | `≥ 0` |
184
+ | `negative` | — | `< 0` |
185
+ | `nonpositive` | — | `≤ 0` |
186
+ | `multipleOf` | `number` | `value % multipleOf === 0` |
187
+
188
+ ### Boolean rules
189
+
190
+ | Rule | Value | Notes |
191
+ |---|---|---|
192
+ | `required` | — | Rejects `null`, `undefined`, `false` |
193
+ | `literal` | `boolean` | Must match exactly |
194
+ | `accepted` | — | Sugar for `literal: true` (terms-of-service pattern) |
195
+
196
+ ### Array rules
197
+
198
+ | Rule | Value |
199
+ |---|---|
200
+ | `required` | — |
201
+ | `min` | `number` (min length) |
202
+ | `max` | `number` (max length) |
203
+ | `length` | `number` (exact length) |
204
+ | `nonempty` | — (length ≥ 1) |
205
+
206
+ ### Object rules
207
+
208
+ | Rule | Value |
209
+ |---|---|
210
+ | `required` | — (rejects `null` / `undefined`) |
211
+ | `shape` | `Record<string, unknown>` (key set must match) |
212
+
213
+ ### Generic (all types)
214
+
215
+ | Rule | Shape | Use |
216
+ |---|---|---|
217
+ | `custom` | `{ name: "custom", validate: (v, all) => boolean, error }` | Ad-hoc validator with access to all values |
218
+ | `refine` | Same as `custom` | Alias — same implementation, different intent name |
219
+ | `matchField` | `{ name: "matchField", value: "<other-field-name>", error }` | Cross-field equality (e.g. password confirmation) |
220
+ | `oneOf` | `{ name: "oneOf", value: unknown[], error }` | Value must be in the list |
221
+ | `notOneOf` | `{ name: "notOneOf", value: unknown[], error }` | Value must NOT be in the list |
222
+
223
+ **Example — cross-field password match:**
224
+
225
+ ```ts
226
+ const schema = createSchema({
227
+ password: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
228
+ confirmPassword: { type: "string", rules: [{ name: "matchField", value: "password", error: { message: "Passwords do not match" } }] },
229
+ });
230
+ ```
231
+
232
+ **Example — custom rule with access to sibling values:**
233
+
234
+ ```ts
235
+ const schema = createSchema({
236
+ startDate: { type: "string", rules: [{ name: "required", error: { message: "Required" } }] },
237
+ endDate: {
238
+ type: "string",
239
+ rules: [
240
+ {
241
+ name: "custom",
242
+ validate: (value, all) => new Date(value as string) > new Date(all.startDate as string),
243
+ error: { message: "End date must be after start date" },
244
+ },
245
+ ],
246
+ },
247
+ });
248
+ ```
249
+
250
+ ---
251
+
252
+ ## Value Transformation
253
+
254
+ `transform` runs **before validation and before submission**. It is a single function or a `t.pipe(...)` composition.
255
+
256
+ ### Built-in transformers (`t`)
257
+
258
+ ```ts
259
+ import { t } from "@valfuse-node/form";
260
+
261
+ // ─── String transformers ──────────────────────────────────────────────────────
262
+ t.trim // " hi " → "hi"
263
+ t.trimStart // " hi " → "hi "
264
+ t.trimEnd // " hi " → " hi"
265
+ t.toLowerCase // "Hi" → "hi"
266
+ t.toUpperCase // "hi" → "HI"
267
+ t.toTitleCase // "hello world" → "Hello World"
268
+ t.toSentenceCase // "HELLO" → "Hello"
269
+ t.collapseSpaces // "a b" → "a b"
270
+
271
+ // ─── Coercion transformers ───────────────────────────────────────────────────
272
+ t.toNumber // "42" → 42 (returns original if NaN)
273
+ t.toInteger // "42.7" → 42
274
+ t.toFloat // "3.14" → 3.14
275
+ t.toBoolean // "true"/"1"/1/true → true; everything else → false
276
+
277
+ // ─── Composition ──────────────────────────────────────────────────────────────
278
+ t.pipe(t.trim, t.toLowerCase) // compose left-to-right
279
+ ```
280
+
281
+ ### Custom transformers
282
+
283
+ Any function `(value: unknown) => unknown` is a valid transformer:
284
+
285
+ ```ts
286
+ const slugify = (v: unknown) =>
287
+ typeof v === "string" ? v.toLowerCase().replace(/\s+/g, "-") : v;
288
+
289
+ const schema = createSchema({
290
+ slug: {
291
+ type: "string",
292
+ transform: slugify,
293
+ rules: [{ name: "required", error: { message: "Required" } }],
294
+ },
295
+ });
296
+ ```
297
+
298
+ ### `transformValues(schema, values)`
299
+
300
+ Apply all per-field transforms in one call — the canonical pre-submit pipeline:
301
+
302
+ ```ts
303
+ import { transformValues } from "@valfuse-node/form";
304
+
305
+ const typed = transformValues(schema, { email: " Alice@Example.com ", age: "25" });
306
+ // → { email: "alice@example.com", age: 25 }
307
+ ```
308
+
309
+ Fields without a `transform` are passed through unchanged. The original `values` object is **never mutated**.
310
+
311
+ ---
312
+
313
+ ## Validation
314
+
315
+ ```ts
316
+ import { validateSchema } from "@valfuse-node/form";
317
+
318
+ const errors = validateSchema(loginSchema, {
319
+ email: "bad",
320
+ password: "123",
321
+ });
322
+ // → {
323
+ // email: { message: "Invalid email format", type: "validation", code: "email.invalid" },
324
+ // password: { message: "Min 8 chars", type: "validation", code: "password.min" },
325
+ // }
326
+ ```
327
+
328
+ **Returns:** `Record<string, ValfuseError>`. Empty object `{}` means valid.
329
+
330
+ **Error shape:**
331
+
332
+ ```ts
333
+ interface ValfuseError {
334
+ message: string; // user-facing message
335
+ type?: "validation" | "server" | "manual" | "custom";
336
+ code?: string; // semantic code (e.g. "email.required")
337
+ metadata?: Record<string, unknown>; // extra context
338
+ }
339
+ ```
340
+
341
+ > **Note:** `validateSchema` returns the first error per field (rules are evaluated in order, and validation short-circuits on the first error). Order your rules from cheapest → most expensive.
342
+
343
+ ---
344
+
345
+ ## Error Handling
346
+
347
+ ### `normalizeError(error)`
348
+
349
+ ```ts
350
+ import { normalizeError } from "@valfuse-node/form";
351
+
352
+ normalizeError("Something went wrong");
353
+ // → { message: "Something went wrong" }
354
+
355
+ normalizeError({ message: "Boom", code: "boom.explode" });
356
+ // → { message: "Boom", code: "boom.explode" }
357
+ ```
358
+
359
+ Useful when you need to merge API errors (which may be strings or objects) into the same shape your form expects.
360
+
361
+ ### `ValfuseFieldError` (the shape used by adapters)
362
+
363
+ ```ts
364
+ interface ValfuseFieldError {
365
+ message: string;
366
+ type?: string; // "validation" | "server" | "manual" | "custom"
367
+ code?: string; // e.g. "email.required", "auth.not_found"
368
+ metadata?: Record<string, unknown>;
369
+ }
370
+ ```
371
+
372
+ ### Error types by origin
373
+
374
+ | `type` | Origin | Typical use |
375
+ |---|---|---|
376
+ | `"validation"` | A schema rule failed | Automatic — emitted by `validateSchema` |
377
+ | `"server"` | Injected via `form.setErrors` after a failed API call | Manual |
378
+ | `"manual"` | Injected via `form.setErrors` for client-only logic | Manual |
379
+ | `"custom"` | Returned by a `custom` / `refine` rule | Automatic — but tagged "custom" so consumers can distinguish |
380
+
381
+ ---
382
+
383
+ ## Framework-Agnostic State
384
+
385
+ If you want the same form-state primitives the React/Vue adapters use internally, you can import them directly. **Most consumers will not need this** — use `useReactValfuseForm` or `useVueValfuseForm` instead. This is exposed for adapter authors and for non-framework usage (e.g. CLI tools, server actions).
386
+
387
+ ### Values
388
+
389
+ ```ts
390
+ import { createValuesState, updateValue, resetValues, computeIsDirty, computeDirtyFields } from "@valfuse-node/form";
391
+
392
+ const state = createValuesState({ email: "", age: 0 });
393
+ updateValue(state, "email", "alice@example.com");
394
+ const isDirty = computeIsDirty(state, { email: "", age: 0 });
395
+ // → true
396
+ const dirty = computeDirtyFields(state, { email: "", age: 0 });
397
+ // → { email: true }
398
+ resetValues(state, { email: "", age: 0 });
399
+ ```
400
+
401
+ ### Touched
402
+
403
+ ```ts
404
+ import { createTouchedState, markTouched, isTouched, toTouchedFieldsRecord } from "@valfuse-node/form";
405
+
406
+ const touched = createTouchedState();
407
+ markTouched(touched, "email");
408
+ isTouched(touched, "email"); // true
409
+ toTouchedFieldsRecord(touched); // { email: true }
410
+ ```
411
+
412
+ ### Errors
413
+
414
+ ```ts
415
+ import { createErrorsState, setFieldError, clearFieldErrors, hasErrors, getFieldError, toFormErrors } from "@valfuse-node/form";
416
+
417
+ const errors = createErrorsState();
418
+ setFieldError(errors, "email", { message: "Taken", code: "auth.duplicate" });
419
+ hasErrors(errors); // true
420
+ getFieldError(errors, "email"); // { message: "Taken", code: "auth.duplicate" }
421
+ clearFieldErrors(errors);
422
+ toFormErrors(errors); // {} (object form)
423
+ ```
424
+
425
+ ### Submission
426
+
427
+ ```ts
428
+ import { createSubmissionState, startSubmit, endSubmitSuccess, endSubmitFailure, resetSubmission } from "@valfuse-node/form";
429
+
430
+ const sub = createSubmissionState();
431
+ startSubmit(sub);
432
+ try {
433
+ await api.call();
434
+ endSubmitSuccess(sub);
435
+ } catch (err) {
436
+ endSubmitFailure(sub);
437
+ } finally {
438
+ // sub.isSubmitting === false
439
+ }
440
+ ```
441
+
442
+ ---
443
+
444
+ ## Type Reference
445
+
446
+ ### `ValfuseSchema`
447
+
448
+ ```ts
449
+ type ValfuseSchema = Record<string, ValfuseFieldSchema>;
450
+
451
+ type ValfuseFieldSchema =
452
+ | ValfuseStringFieldSchema
453
+ | ValfuseNumberFieldSchema
454
+ | ValfuseBooleanFieldSchema
455
+ | ValfuseArrayFieldSchema
456
+ | ValfuseObjectFieldSchema;
457
+
458
+ interface ValfuseStringFieldSchema {
459
+ type: "string";
460
+ rules: (ValfuseStringRule | ValfuseGenericRule)[];
461
+ transform?: ValfuseTransformer;
462
+ }
463
+ // (same shape for the other four types)
464
+ ```
465
+
466
+ ### Rule types
467
+
468
+ Every rule is a discriminated union member with a discriminator field. The TypeScript type for each field's `rules` array is the union of type-specific rules plus the generic ones.
469
+
470
+ ```ts
471
+ // Generic (work on any field type)
472
+ type ValfuseGenericRule =
473
+ | { name: "custom"; validate: (v, all) => boolean; error: ValfuseRuleError }
474
+ | { name: "refine"; validate: (v, all) => boolean; error: ValfuseRuleError }
475
+ | { name: "matchField"; value: string; error: ValfuseRuleError }
476
+ | { name: "oneOf"; value: unknown[]; error: ValfuseRuleError }
477
+ | { name: "notOneOf"; value: unknown[]; error: ValfuseRuleError };
478
+ ```
479
+
480
+ ### Error types
481
+
482
+ ```ts
483
+ type ValfuseErrorType = "validation" | "server" | "manual" | "custom";
484
+
485
+ interface ValfuseError {
486
+ message: string;
487
+ type?: ValfuseErrorType | string;
488
+ code?: string;
489
+ metadata?: Record<string, unknown>;
490
+ }
491
+
492
+ type ValfuseFieldErrors<TFieldName extends string = string> = Partial<
493
+ Record<TFieldName, string | ValfuseError>
494
+ >;
495
+ ```
496
+
497
+ ---
498
+
499
+ ## Development Usage
500
+
501
+ ### Use it in a Node.js script
502
+
503
+ ```ts
504
+ // scripts/validate-signup.ts
505
+ import { createSchema, validateSchema, transformValues } from "@valfuse-node/form";
506
+
507
+ const schema = createSchema({
508
+ email: { type: "string", transform: (v) => String(v).toLowerCase(), rules: [{ name: "required", error: { message: "Required" } }] },
509
+ });
510
+
511
+ const input = process.argv[2] ?? "";
512
+ const errors = validateSchema(schema, transformValues(schema, { email: input }));
513
+
514
+ if (Object.keys(errors).length) {
515
+ console.error("Invalid:", errors);
516
+ process.exit(1);
517
+ }
518
+ console.log("OK");
519
+ ```
520
+
521
+ ```bash
522
+ npx tsx scripts/validate-signup.ts "alice@example.com"
523
+ ```
524
+
525
+ ### Use it in a server action
526
+
527
+ ```ts
528
+ // app/actions/signup.ts
529
+ "use server";
530
+ import { createSchema, validateSchema, transformValues, normalizeError } from "@valfuse-node/form";
531
+
532
+ const schema = createSchema({
533
+ email: { type: "string", rules: [{ name: "required", error: { message: "Email required" } }, { name: "email", error: { message: "Invalid" } }] },
534
+ password: { type: "string", rules: [{ name: "required", error: { message: "Password required" } }, { name: "minLength", value: 8, error: { message: "Min 8" } }] },
535
+ });
536
+
537
+ export async function signupAction(formData: FormData) {
538
+ const typed = transformValues(schema, {
539
+ email: String(formData.get("email") ?? ""),
540
+ password: String(formData.get("password") ?? ""),
541
+ });
542
+ const errors = validateSchema(schema, typed);
543
+
544
+ if (Object.keys(errors).length) {
545
+ return { ok: false, errors };
546
+ }
547
+ // … DB insert
548
+ return { ok: true };
549
+ }
550
+ ```
551
+
552
+ ### Use it as the source-of-truth schema for React/Vue adapters
553
+
554
+ ```ts
555
+ // schemas/user.ts (shared by web + mobile)
556
+ import { createSchema } from "@valfuse-node/form";
557
+ export const userSchema = createSchema({ /* … */ });
558
+ ```
559
+
560
+ ```tsx
561
+ // web (React)
562
+ import { useReactValfuseForm } from "@valfuse-node/core";
563
+ const form = useReactValfuseForm({ schema: userSchema, defaultValues: { … } });
564
+ ```
565
+
566
+ ```vue
567
+ <!-- mobile (Vue) -->
568
+ <script setup>
569
+ import { useVueValfuseForm } from "@valfuse-node/core";
570
+ const form = useVueValfuseForm({ schema: userSchema, defaultValues: { … } });
571
+ </script>
572
+ ```
573
+
574
+ ---
575
+
576
+ ## License
577
+
578
+ [MIT](../../LICENSE)