@12-apps/forms-core 1.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/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@12-apps/forms-core",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Pure, framework-agnostic form primitives — field validators, the Result type, and createServerAction. Zero dependencies, consumed from source by @12-apps/ui and apps.",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "clean": "rm -rf node_modules coverage",
11
+ "test": "node ../../scripts/vitest-with-teardown.mjs run",
12
+ "test:watch": "vitest watch",
13
+ "check-types": "tsc --noEmit",
14
+ "typecheck": "tsc --noEmit"
15
+ },
16
+ "devDependencies": {
17
+ "@12-apps/typescript-config": "^1.1.0",
18
+ "typescript": "^5.9.2",
19
+ "vitest": "^3.2.4"
20
+ },
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org",
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/12-apps/shared-packages.git",
28
+ "directory": "packages/forms-core"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "dist",
33
+ "prisma",
34
+ "*.js",
35
+ "*.mjs",
36
+ "*.md"
37
+ ]
38
+ }
package/src/br.spec.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ cep,
5
+ CEP_PATTERN,
6
+ cepDigits,
7
+ cnpj,
8
+ cnpjChars,
9
+ formatCep,
10
+ formatCnpj,
11
+ isUf,
12
+ isValidCep,
13
+ isValidCnpj,
14
+ UF_LIST,
15
+ } from './br';
16
+
17
+ /**
18
+ * `11.222.333/0001-81` is the canonical all-numeric fixture; `12.ABC.345/01DE-35`
19
+ * is Receita Federal's own alphanumeric example (IN 2.229/2024).
20
+ */
21
+ const VALID_NUMERIC = '11.222.333/0001-81';
22
+ const VALID_ALPHANUMERIC = '12.ABC.345/01DE-35';
23
+
24
+ describe('forms-core/br', () => {
25
+ describe('UF_LIST / isUf', () => {
26
+ it('holds the 27 federative units', () => {
27
+ expect(UF_LIST).toHaveLength(27);
28
+ expect(new Set(UF_LIST).size).toBe(27);
29
+ });
30
+
31
+ it('accepts a real UF and rejects anything else', () => {
32
+ expect(isUf('SP')).toBe(true);
33
+ expect(isUf('sp')).toBe(false);
34
+ expect(isUf('XX')).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe('cepDigits', () => {
39
+ it('keeps only digits, capped at eight', () => {
40
+ expect(cepDigits('01310-100')).toBe('01310100');
41
+ expect(cepDigits('01310100999')).toBe('01310100');
42
+ expect(cepDigits('abc')).toBe('');
43
+ });
44
+ });
45
+
46
+ describe('formatCep', () => {
47
+ it('inserts the hyphen once the sixth digit arrives', () => {
48
+ expect(formatCep('01310')).toBe('01310');
49
+ expect(formatCep('013101')).toBe('01310-1');
50
+ expect(formatCep('01310100')).toBe('01310-100');
51
+ });
52
+
53
+ it('is idempotent and forgiving of messy input', () => {
54
+ expect(formatCep(formatCep('01310100'))).toBe('01310-100');
55
+ expect(formatCep('01.310-100')).toBe('01310-100');
56
+ });
57
+
58
+ it('produces a value matching CEP_PATTERN once complete', () => {
59
+ expect(CEP_PATTERN.test(formatCep('01310100'))).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe('isValidCep', () => {
64
+ it('accepts a complete CEP in either shape', () => {
65
+ expect(isValidCep('01310100')).toBe(true);
66
+ expect(isValidCep('01310-100')).toBe(true);
67
+ });
68
+
69
+ it('rejects an incomplete or oversized CEP', () => {
70
+ expect(isValidCep('0131010')).toBe(false);
71
+ expect(isValidCep('')).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('cep validator', () => {
76
+ it('treats empty as valid (the field is optional)', () => {
77
+ expect(cep()('')).toBeUndefined();
78
+ expect(cep()(' ')).toBeUndefined();
79
+ });
80
+
81
+ it('returns a message for a malformed CEP', () => {
82
+ expect(cep()('123')).toBeTruthy();
83
+ expect(cep('Informe o CEP')('123')).toBe('Informe o CEP');
84
+ });
85
+
86
+ it('returns undefined for a well-formed CEP', () => {
87
+ expect(cep()('01310-100')).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe('cnpjChars', () => {
92
+ it('strips punctuation, uppercases and keeps letters', () => {
93
+ expect(cnpjChars(VALID_NUMERIC)).toBe('11222333000181');
94
+ expect(cnpjChars('12.abc.345/01de-35')).toBe('12ABC34501DE35');
95
+ });
96
+
97
+ it('caps at fourteen characters', () => {
98
+ expect(cnpjChars('11222333000181999')).toHaveLength(14);
99
+ });
100
+ });
101
+
102
+ describe('formatCnpj', () => {
103
+ it('masks progressively as characters arrive', () => {
104
+ expect(formatCnpj('11')).toBe('11');
105
+ expect(formatCnpj('11222')).toBe('11.222');
106
+ expect(formatCnpj('11222333')).toBe('11.222.333');
107
+ expect(formatCnpj('112223330001')).toBe('11.222.333/0001');
108
+ expect(formatCnpj('11222333000181')).toBe(VALID_NUMERIC);
109
+ });
110
+
111
+ it('masks an alphanumeric CNPJ', () => {
112
+ expect(formatCnpj('12ABC34501DE35')).toBe(VALID_ALPHANUMERIC);
113
+ expect(formatCnpj('12abc34501de35')).toBe(VALID_ALPHANUMERIC);
114
+ });
115
+
116
+ it('is idempotent', () => {
117
+ expect(formatCnpj(VALID_NUMERIC)).toBe(VALID_NUMERIC);
118
+ expect(formatCnpj(VALID_ALPHANUMERIC)).toBe(VALID_ALPHANUMERIC);
119
+ });
120
+
121
+ it('never lets a letter into the check-digit positions', () => {
122
+ expect(formatCnpj('12ABC34501DEXY')).toBe('12.ABC.345/01DE-');
123
+ });
124
+ });
125
+
126
+ describe('isValidCnpj', () => {
127
+ it('accepts an all-numeric CNPJ, masked or bare', () => {
128
+ expect(isValidCnpj(VALID_NUMERIC)).toBe(true);
129
+ expect(isValidCnpj('11222333000181')).toBe(true);
130
+ });
131
+
132
+ it("accepts Receita Federal's alphanumeric example", () => {
133
+ expect(isValidCnpj(VALID_ALPHANUMERIC)).toBe(true);
134
+ expect(isValidCnpj('12abc34501de35')).toBe(true);
135
+ });
136
+
137
+ it('rejects a wrong check digit', () => {
138
+ expect(isValidCnpj('11222333000182')).toBe(false);
139
+ expect(isValidCnpj('12ABC34501DE36')).toBe(false);
140
+ });
141
+
142
+ it('rejects a repeated-character sequence', () => {
143
+ expect(isValidCnpj('00000000000000')).toBe(false);
144
+ expect(isValidCnpj('11111111111111')).toBe(false);
145
+ });
146
+
147
+ it('rejects the wrong length', () => {
148
+ expect(isValidCnpj('')).toBe(false);
149
+ expect(isValidCnpj('1122233300018')).toBe(false);
150
+ });
151
+
152
+ it('rejects letters in the check-digit positions', () => {
153
+ expect(isValidCnpj('12ABC34501DEAB')).toBe(false);
154
+ });
155
+ });
156
+
157
+ describe('cnpj validator', () => {
158
+ it('treats empty as valid (the field is optional)', () => {
159
+ expect(cnpj()('')).toBeUndefined();
160
+ expect(cnpj()(' ')).toBeUndefined();
161
+ });
162
+
163
+ it('returns a message for an invalid CNPJ', () => {
164
+ expect(cnpj()('11222333000182')).toBeTruthy();
165
+ expect(cnpj('Confira o CNPJ')('123')).toBe('Confira o CNPJ');
166
+ });
167
+
168
+ it('returns undefined for a valid CNPJ', () => {
169
+ expect(cnpj()(VALID_NUMERIC)).toBeUndefined();
170
+ expect(cnpj()(VALID_ALPHANUMERIC)).toBeUndefined();
171
+ });
172
+ });
173
+ });
package/src/br.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Brazilian document + address primitives (FUT-306).
3
+ *
4
+ * Masks, shape checks and check-digit validation for the two fields every
5
+ * pt-BR form asks for — CEP and CNPJ — plus the UF list. Pure and dependency
6
+ * free like the rest of `forms-core`, so the SAME rules run in the browser
7
+ * (input mask + inline validation), in a route handler (before hitting a
8
+ * lookup provider) and in a test.
9
+ *
10
+ * @module forms-core/br
11
+ */
12
+
13
+ import type { Validator } from './validators';
14
+
15
+ /** Brazilian federative units, for UF selects and client-side validation. */
16
+ export const UF_LIST = [
17
+ 'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO',
18
+ 'MA', 'MT', 'MS', 'MG', 'PA', 'PB', 'PR', 'PE', 'PI',
19
+ 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO',
20
+ ] as const;
21
+
22
+ /** A Brazilian federative unit. */
23
+ export type Uf = (typeof UF_LIST)[number];
24
+
25
+ /** True when `value` is one of the 27 {@link UF_LIST} entries (case-sensitive). */
26
+ export function isUf(value: string): value is Uf {
27
+ return (UF_LIST as readonly string[]).includes(value);
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // CEP
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Canonical CEP shape after masking/normalization: `01310-100`. */
35
+ export const CEP_PATTERN = /^\d{5}-\d{3}$/;
36
+
37
+ /** The bare digits of a CEP, capped at the 8 a CEP has. */
38
+ export function cepDigits(raw: string): string {
39
+ return raw.replace(/\D/g, '').slice(0, 8);
40
+ }
41
+
42
+ /**
43
+ * Mask raw CEP input to `#####-###`: strips non-digits, caps at 8 digits and
44
+ * inserts the hyphen once the sixth digit arrives. Pure — safe to run on every
45
+ * keystroke and to normalize before matching {@link CEP_PATTERN}.
46
+ */
47
+ export function formatCep(raw: string): string {
48
+ const digits = cepDigits(raw);
49
+ return digits.length > 5 ? `${digits.slice(0, 5)}-${digits.slice(5)}` : digits;
50
+ }
51
+
52
+ /**
53
+ * True when `raw` holds a complete, well-formed CEP.
54
+ *
55
+ * Shape only: a CEP is a postal RANGE allocated by the Correios, so there is no
56
+ * check digit to verify. Whether it actually EXISTS is answered by a lookup,
57
+ * not by this function.
58
+ */
59
+ export function isValidCep(raw: string): boolean {
60
+ return CEP_PATTERN.test(formatCep(raw));
61
+ }
62
+
63
+ /** Empty ⇒ valid (optional field); otherwise the value must be a full CEP. */
64
+ export const cep =
65
+ (message = 'CEP inválido — use o formato 00000-000.'): Validator =>
66
+ (value) =>
67
+ value.trim() === '' || isValidCep(value) ? undefined : message;
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // CNPJ
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Canonical CNPJ shape after masking: `12.ABC.345/01DE-35`. */
74
+ export const CNPJ_PATTERN = /^[0-9A-Z]{2}\.[0-9A-Z]{3}\.[0-9A-Z]{3}\/[0-9A-Z]{4}-\d{2}$/;
75
+
76
+ /** How many characters a CNPJ has, check digits included. */
77
+ const CNPJ_LENGTH = 14;
78
+
79
+ /**
80
+ * Strip a CNPJ to its 14 significant characters, uppercased.
81
+ *
82
+ * Alphanumeric-aware (Receita Federal IN 2.229/2024, live in production since
83
+ * 2026-07-06): the first 12 positions may be letters OR digits, so only
84
+ * punctuation is removed — never letters.
85
+ */
86
+ export function cnpjChars(raw: string): string {
87
+ return raw.toUpperCase().replace(/[^0-9A-Z]/g, '').slice(0, CNPJ_LENGTH);
88
+ }
89
+
90
+ /**
91
+ * Mask raw CNPJ input to `##.###.###/####-##`, accepting letters in the first
92
+ * twelve positions and digits in the two check positions.
93
+ */
94
+ export function formatCnpj(raw: string): string {
95
+ const chars = cnpjChars(raw);
96
+ const parts = [
97
+ chars.slice(0, 2),
98
+ chars.slice(2, 5),
99
+ chars.slice(5, 8),
100
+ chars.slice(8, 12),
101
+ // The check digits are always numeric, even on an alphanumeric CNPJ.
102
+ chars.slice(12, 14).replace(/\D/g, ''),
103
+ ];
104
+ let masked = parts[0] as string;
105
+ if (chars.length > 2) masked += `.${parts[1]}`;
106
+ if (chars.length > 5) masked += `.${parts[2]}`;
107
+ if (chars.length > 8) masked += `/${parts[3]}`;
108
+ if (chars.length > 12) masked += `-${parts[4]}`;
109
+ return masked;
110
+ }
111
+
112
+ /**
113
+ * The mod-11 weights, right-to-left cycling 2…9. `slice(-n)` takes the tail so
114
+ * the same table serves the 12-char first pass and the 13-char second.
115
+ */
116
+ const CNPJ_WEIGHTS = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] as const;
117
+
118
+ /**
119
+ * A character's numeric contribution: its ASCII code minus 48. Digits `0`–`9`
120
+ * map to 0–9 (identical to the legacy rule, so existing CNPJs keep validating)
121
+ * and letters `A`–`Z` map to 17–42.
122
+ */
123
+ function cnpjValue(char: string): number {
124
+ return char.charCodeAt(0) - 48;
125
+ }
126
+
127
+ /** One mod-11 check digit over the first `base.length` characters. */
128
+ function cnpjCheckDigit(base: string): number {
129
+ const weights = CNPJ_WEIGHTS.slice(-base.length);
130
+ const sum = [...base].reduce((acc, char, i) => acc + cnpjValue(char) * (weights[i] as number), 0);
131
+ const remainder = sum % 11;
132
+ return remainder < 2 ? 0 : 11 - remainder;
133
+ }
134
+
135
+ /**
136
+ * Validate a CNPJ's check digits (mod 11), alphanumeric-aware.
137
+ *
138
+ * Rejects the repeated-character sequences (`00000000000000`, `AAAAAAAAAAAA35`)
139
+ * that satisfy the arithmetic but are never issued. Returns false for anything
140
+ * that is not exactly 14 characters with numeric check digits.
141
+ */
142
+ export function isValidCnpj(raw: string): boolean {
143
+ const chars = cnpjChars(raw);
144
+ if (chars.length !== CNPJ_LENGTH) return false;
145
+ // Only the 12-character root/establishment may be alphanumeric; the two check
146
+ // digits stay numeric.
147
+ if (!/^\d{2}$/.test(chars.slice(12))) return false;
148
+ if (new Set(chars).size === 1) return false;
149
+
150
+ const base = chars.slice(0, 12);
151
+ const first = cnpjCheckDigit(base);
152
+ if (first !== Number(chars[12])) return false;
153
+ return cnpjCheckDigit(`${base}${first}`) === Number(chars[13]);
154
+ }
155
+
156
+ /** Empty ⇒ valid (optional field); otherwise the check digits must match. */
157
+ export const cnpj =
158
+ (message = 'CNPJ inválido — confira os dígitos.'): Validator =>
159
+ (value) =>
160
+ value.trim() === '' || isValidCnpj(value) ? undefined : message;
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Telefone
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Format a Brazilian phone as `(11) 3214-5600` / `(11) 91234-5678`.
168
+ *
169
+ * Both sources that hand us a phone give it as bare concatenated digits — the
170
+ * Receita Federal's `ddd_telefone_1` and an NF-e's `<fone>` — so the same
171
+ * formatting rule serves the CNPJ lookup and the nota importer.
172
+ *
173
+ * Anything that is not a 10- or 11-digit national number is returned trimmed
174
+ * but otherwise untouched: an unrecognized phone (a foreign issuer, an extension
175
+ * appended by the emitter's software) is still better dropped into the field
176
+ * than discarded.
177
+ *
178
+ * @param raw - Digits as the source supplies them, e.g. `1132145600`.
179
+ * @returns The formatted phone, or `null` when there is nothing to format.
180
+ */
181
+ export function formatBrPhone(raw: string | null | undefined): string | null {
182
+ const trimmed = raw?.trim();
183
+ if (!trimmed) return null;
184
+ const digits = trimmed.replace(/\D/g, '');
185
+ if (digits.length !== 10 && digits.length !== 11) return trimmed;
186
+ const ddd = digits.slice(0, 2);
187
+ const subscriber = digits.slice(2);
188
+ const split = subscriber.length - 4;
189
+ return `(${ddd}) ${subscriber.slice(0, split)}-${subscriber.slice(split)}`;
190
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pure form primitives barrel.
3
+ *
4
+ * The field validators, the `Result<T>` type with its `ok`/`err` constructors,
5
+ * and the `createServerAction` wrapper + `ActionError`. Zero dependencies — safe
6
+ * to import from a client component, a server action, or a plain node context
7
+ * alike.
8
+ *
9
+ * @module forms-core
10
+ */
11
+ export * from './validators';
12
+ export * from './result';
13
+ export * from './serverAction';
14
+ export * from './br';
package/src/result.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Discriminated-union result type for operations that can fail.
3
+ *
4
+ * Pure and framework-agnostic. The `ok` discriminant narrows the union so
5
+ * consumers can safely read `data` on success or `error` on failure.
6
+ *
7
+ * @typeParam T - The success payload type.
8
+ * @module forms-core/result
9
+ */
10
+ export type Result<T> =
11
+ | { ok: true; data: T }
12
+ | {
13
+ ok: false;
14
+ error: string;
15
+ /**
16
+ * Per-field error messages, keyed by form field name, when the failure is
17
+ * attributable to specific input(s). Lets a form highlight the exact
18
+ * offending field(s) instead of only showing the summary `error`.
19
+ */
20
+ fieldErrors?: Record<string, string>;
21
+ };
22
+
23
+ /**
24
+ * Builds a successful {@link Result} wrapping `data`.
25
+ *
26
+ * @typeParam T - The success payload type.
27
+ * @param data - The value to wrap.
28
+ * @returns `{ ok: true, data }`.
29
+ */
30
+ export const ok = <T>(data: T): Result<T> => ({ ok: true, data });
31
+
32
+ /**
33
+ * Builds a failed {@link Result} wrapping an error `message`.
34
+ *
35
+ * @param error - A user-safe error message (never an internal/stack detail).
36
+ * @param fieldErrors - Optional per-field messages so a form can highlight the
37
+ * exact offending field(s). Omitted when the failure isn't field-specific.
38
+ * @returns `{ ok: false, error, fieldErrors? }`.
39
+ */
40
+ export const err = (
41
+ error: string,
42
+ fieldErrors?: Record<string, string>,
43
+ ): Result<never> =>
44
+ fieldErrors ? { ok: false, error, fieldErrors } : { ok: false, error };
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ ActionError,
5
+ createServerAction,
6
+ email,
7
+ err,
8
+ ok,
9
+ required,
10
+ } from './index';
11
+
12
+ describe('forms-core/result', () => {
13
+ describe('ok', () => {
14
+ it('returns the success shape wrapping the data', () => {
15
+ expect(ok(42)).toEqual({ ok: true, data: 42 });
16
+ });
17
+ });
18
+
19
+ describe('err', () => {
20
+ it('returns the error shape wrapping the message', () => {
21
+ expect(err('bad')).toEqual({ ok: false, error: 'bad' });
22
+ });
23
+
24
+ it('includes fieldErrors when provided', () => {
25
+ expect(err('bad name', { name: 'bad name' })).toEqual({
26
+ ok: false,
27
+ error: 'bad name',
28
+ fieldErrors: { name: 'bad name' },
29
+ });
30
+ });
31
+ });
32
+ });
33
+
34
+ describe('forms-core/createServerAction', () => {
35
+ const schema = { email: [required(), email()] };
36
+
37
+ it('returns ok and calls the handler exactly once on valid input', async () => {
38
+ const handler = vi.fn(async (input: { email: string }) => input.email);
39
+ const action = createServerAction(schema, handler);
40
+
41
+ const result = await action({ email: 'a@b.com' });
42
+
43
+ expect(result).toEqual({ ok: true, data: 'a@b.com' });
44
+ expect(handler).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it('returns err and never calls the handler on invalid input', async () => {
48
+ const handler = vi.fn(async (input: { email: string }) => input.email);
49
+ const action = createServerAction(schema, handler);
50
+
51
+ const result = await action({ email: '' });
52
+
53
+ expect(result.ok).toBe(false);
54
+ if (!result.ok) {
55
+ expect(typeof result.error).toBe('string');
56
+ expect(result.error).toBeTruthy();
57
+ }
58
+ expect(handler).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('maps a throwing handler to a generic err with no stack trace leak', async () => {
62
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
63
+ const secret = 'INTERNAL_STACK_DETAIL';
64
+ const handler = vi.fn(async () => {
65
+ throw new Error(secret);
66
+ });
67
+ const action = createServerAction(schema, handler);
68
+
69
+ const result = await action({ email: 'a@b.com' });
70
+
71
+ expect(result.ok).toBe(false);
72
+ if (!result.ok) {
73
+ expect(typeof result.error).toBe('string');
74
+ expect(result.error).toBeTruthy();
75
+ expect(result.error).not.toContain(secret);
76
+ expect(result.error).not.toContain('at ');
77
+ }
78
+ // The real cause is logged server-side for diagnosis, never returned.
79
+ expect(errorSpy).toHaveBeenCalled();
80
+ errorSpy.mockRestore();
81
+ });
82
+
83
+ it('passes through the message of a thrown ActionError verbatim', async () => {
84
+ const handler = vi.fn(async () => {
85
+ throw new ActionError('A product with this name already exists.');
86
+ });
87
+ const action = createServerAction(schema, handler);
88
+
89
+ const result = await action({ email: 'a@b.com' });
90
+
91
+ expect(result).toEqual({
92
+ ok: false,
93
+ error: 'A product with this name already exists.',
94
+ });
95
+ });
96
+
97
+ it('attributes a validation failure to the offending field', async () => {
98
+ const handler = vi.fn(async (input: { email: string }) => input.email);
99
+ const action = createServerAction(schema, handler);
100
+
101
+ const result = await action({ email: '' });
102
+
103
+ expect(result.ok).toBe(false);
104
+ if (!result.ok) {
105
+ expect(result.fieldErrors).toEqual({ email: result.error });
106
+ }
107
+ expect(handler).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('surfaces an ActionError field as fieldErrors so the form can highlight it', async () => {
111
+ const handler = vi.fn(async () => {
112
+ throw new ActionError('Já existe um produto com esse nome.', 'name');
113
+ });
114
+ const action = createServerAction(schema, handler);
115
+
116
+ const result = await action({ email: 'a@b.com' });
117
+
118
+ expect(result).toEqual({
119
+ ok: false,
120
+ error: 'Já existe um produto com esse nome.',
121
+ fieldErrors: { name: 'Já existe um produto com esse nome.' },
122
+ });
123
+ });
124
+
125
+ it('passes an ActionError fieldErrors map straight through (multi-field)', async () => {
126
+ const fieldErrors = { 'sku:ABC': 'ABC já usado', 'sku:XYZ': 'XYZ já usado' };
127
+ const handler = vi.fn(async () => {
128
+ throw new ActionError('SKUs duplicados.', undefined, fieldErrors);
129
+ });
130
+ const action = createServerAction(schema, handler);
131
+
132
+ const result = await action({ email: 'a@b.com' });
133
+
134
+ expect(result).toEqual({ ok: false, error: 'SKUs duplicados.', fieldErrors });
135
+ });
136
+ });
@@ -0,0 +1,90 @@
1
+ import { err, ok, type Result } from './result';
2
+ import { validateFields, type ValidatorSchema } from './validators';
3
+
4
+ /** Generic, user-safe message returned when a handler throws. */
5
+ const GENERIC_ERROR = 'Something went wrong. Please try again.';
6
+
7
+ /**
8
+ * An error whose `message` is safe to show to the end user.
9
+ *
10
+ * Handlers throw this to surface a *specific, actionable* failure (e.g. "a
11
+ * product with this name already exists") through {@link createServerAction}
12
+ * instead of collapsing into the {@link GENERIC_ERROR}. Any error that is NOT
13
+ * an `ActionError` is treated as unexpected: it is logged server-side and the
14
+ * caller receives the generic message so internal details never leak.
15
+ */
16
+ export class ActionError extends Error {
17
+ /**
18
+ * The form field this failure is attributable to (e.g. `"name"` for a
19
+ * duplicate-name conflict). When set, {@link createServerAction} surfaces it
20
+ * as `fieldErrors` so the form can highlight that exact field.
21
+ */
22
+ readonly field?: string;
23
+
24
+ /**
25
+ * Per-field messages when a single failure attributes to MORE than one field
26
+ * (e.g. several duplicate SKUs across a product's variation rows). Takes
27
+ * precedence over {@link field}; when set, {@link createServerAction} passes
28
+ * this map straight through as `fieldErrors`.
29
+ */
30
+ readonly fieldErrors?: Record<string, string>;
31
+
32
+ constructor(message: string, field?: string, fieldErrors?: Record<string, string>) {
33
+ super(message);
34
+ this.name = 'ActionError';
35
+ this.field = field;
36
+ this.fieldErrors = fieldErrors;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Wraps a handler-agnostic server action with validate-first semantics.
42
+ *
43
+ * The returned action:
44
+ * 1. Runs {@link validateFields} against `input` using `schema`. On the first
45
+ * field error it returns `err(message)` **without invoking `handler`**.
46
+ * 2. Otherwise invokes `handler(input)` and returns `ok` with its resolved
47
+ * value.
48
+ * 3. Passes through the message of an {@link ActionError} thrown by `handler`
49
+ * (a deliberate, user-safe failure), and maps any *other* exception to a
50
+ * generic `err` message — logging the real cause server-side so production
51
+ * failures stay diagnosable without leaking internals to the client.
52
+ *
53
+ * @typeParam I - The validated input shape (string fields keyed by name).
54
+ * @typeParam O - The handler's success payload type.
55
+ * @param schema - Per-field validators applied before the handler runs.
56
+ * @param handler - The business logic, run only when input is valid.
57
+ * @returns An async action that resolves to a {@link Result}.
58
+ */
59
+ export const createServerAction = <I extends Record<string, string>, O>(
60
+ schema: ValidatorSchema<I>,
61
+ handler: (input: I) => Promise<O> | O,
62
+ ): ((input: I) => Promise<Result<O>>) => {
63
+ return async (input: I): Promise<Result<O>> => {
64
+ const errors = validateFields(input, schema);
65
+ // Surface the first failing field by name so the form highlights it.
66
+ const firstField = (Object.keys(errors) as (keyof I)[]).find(
67
+ (field) => typeof errors[field] === 'string',
68
+ );
69
+ if (firstField !== undefined) {
70
+ const message = errors[firstField] as string;
71
+ return err(message, { [String(firstField)]: message });
72
+ }
73
+
74
+ try {
75
+ return ok(await handler(input));
76
+ } catch (error) {
77
+ if (error instanceof ActionError) {
78
+ // A multi-field map (fieldErrors) wins over the single-field shorthand so
79
+ // one failure can highlight several inputs (e.g. duplicate variation SKUs).
80
+ const fieldErrors =
81
+ error.fieldErrors ?? (error.field ? { [error.field]: error.message } : undefined);
82
+ return err(error.message, fieldErrors);
83
+ }
84
+ // Unexpected: log the real cause server-side (never sent to the client)
85
+ // so prod failures are diagnosable, and return a safe generic message.
86
+ console.error('[serverAction] handler threw an unexpected error:', error);
87
+ return err(GENERIC_ERROR);
88
+ }
89
+ };
90
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { email, min, minLength, required, validateFields } from './validators';
4
+
5
+ describe('forms-core/validators', () => {
6
+ describe('required', () => {
7
+ it('returns an error message for an empty string', () => {
8
+ const result = required()('');
9
+ expect(typeof result).toBe('string');
10
+ expect(result).toBeTruthy();
11
+ });
12
+
13
+ it('returns undefined for a non-empty value', () => {
14
+ expect(required()('hello')).toBeUndefined();
15
+ });
16
+
17
+ it('supports a custom message', () => {
18
+ expect(required('Campo obrigatório')('')).toBe('Campo obrigatório');
19
+ });
20
+ });
21
+
22
+ describe('email', () => {
23
+ it('returns an error message for a malformed address', () => {
24
+ const result = email()('not-an-email');
25
+ expect(typeof result).toBe('string');
26
+ expect(result).toBeTruthy();
27
+ });
28
+
29
+ it('returns undefined for a valid address', () => {
30
+ expect(email()('a@b.com')).toBeUndefined();
31
+ });
32
+
33
+ it('supports a custom message', () => {
34
+ expect(email('E-mail inválido')('bad')).toBe('E-mail inválido');
35
+ });
36
+ });
37
+
38
+ describe('minLength / min', () => {
39
+ it('returns an error message when the value is shorter than the minimum', () => {
40
+ const result = minLength(3)('ab');
41
+ expect(typeof result).toBe('string');
42
+ expect(result).toBeTruthy();
43
+ });
44
+
45
+ it('returns undefined when the value meets the minimum length', () => {
46
+ expect(minLength(3)('abc')).toBeUndefined();
47
+ });
48
+
49
+ it('exposes min as an alias of minLength', () => {
50
+ expect(min).toBe(minLength);
51
+ expect(min(3)('ab')).toBeTruthy();
52
+ expect(min(3)('abc')).toBeUndefined();
53
+ });
54
+
55
+ it('supports a custom message', () => {
56
+ expect(minLength(3, 'Muito curto')('ab')).toBe('Muito curto');
57
+ });
58
+ });
59
+
60
+ describe('validateFields', () => {
61
+ it('returns an entry per failing field using the first failing validator', () => {
62
+ const requiredMsg = required()('') as string;
63
+ const errors = validateFields(
64
+ { email: '' },
65
+ { email: [required(), email()] },
66
+ );
67
+ expect(errors).toEqual({ email: requiredMsg });
68
+ });
69
+
70
+ it('returns an empty object when all fields are valid', () => {
71
+ const errors = validateFields(
72
+ { email: 'a@b.com' },
73
+ { email: [required(), email()] },
74
+ );
75
+ expect(errors).toEqual({});
76
+ });
77
+ });
78
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Field-level form validators
3
+ *
4
+ * Pure, framework-agnostic validators — NO zod, NO react-hook-form. Each
5
+ * validator is curried and returns a `string` error message for invalid input
6
+ * or `undefined` when the value is valid.
7
+ *
8
+ * @module forms-core/validators
9
+ */
10
+
11
+ /** A field validator: returns an error message, or `undefined` when valid. */
12
+ export type Validator = (value: string) => string | undefined;
13
+
14
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
15
+
16
+ /**
17
+ * Requires a non-empty value (after trimming whitespace).
18
+ *
19
+ * @param message - Custom error message for empty input.
20
+ * @returns A validator returning the message for empty input, else `undefined`.
21
+ */
22
+ export const required =
23
+ (message = 'This field is required'): Validator =>
24
+ (value) =>
25
+ value.trim().length === 0 ? message : undefined;
26
+
27
+ /**
28
+ * Validates that the value is a well-formed email address.
29
+ *
30
+ * @param message - Custom error message for malformed input.
31
+ * @returns A validator returning the message for invalid input, else `undefined`.
32
+ */
33
+ export const email =
34
+ (message = 'Enter a valid email address'): Validator =>
35
+ (value) =>
36
+ EMAIL_PATTERN.test(value) ? undefined : message;
37
+
38
+ /**
39
+ * Validates that the value has at least `length` characters.
40
+ *
41
+ * @param length - The minimum number of characters required.
42
+ * @param message - Custom error message for too-short input.
43
+ * @returns A validator returning the message for short input, else `undefined`.
44
+ */
45
+ export const minLength =
46
+ (length: number, message = `Must be at least ${length} characters`): Validator =>
47
+ (value) =>
48
+ value.length < length ? message : undefined;
49
+
50
+ /** Alias of {@link minLength}. */
51
+ export const min = minLength;
52
+
53
+ /** A map of field names to the list of validators applied to that field. */
54
+ export type ValidatorSchema<T> = {
55
+ [K in keyof T]?: Validator[];
56
+ };
57
+
58
+ /** A map of field names to the first error message produced for that field. */
59
+ export type FieldErrors<T> = {
60
+ [K in keyof T]?: string;
61
+ };
62
+
63
+ /**
64
+ * The first error message a field's validators produce, or `undefined` when
65
+ * every validator passes. Short-circuits on the first failure.
66
+ */
67
+ const firstFieldError = (validators: Validator[], value: string): string | undefined => {
68
+ for (const validate of validators) {
69
+ const error = validate(value);
70
+ if (error !== undefined) {
71
+ return error;
72
+ }
73
+ }
74
+ return undefined;
75
+ };
76
+
77
+ /**
78
+ * Runs each field's validators against `values`, short-circuiting on the first
79
+ * failing validator per field.
80
+ *
81
+ * @param values - The field values to validate (keyed by field name).
82
+ * @param schema - Validators to apply per field.
83
+ * @returns A record containing one entry per failing field; `{}` when all valid.
84
+ */
85
+ export const validateFields = <T extends Record<string, string>>(
86
+ values: T,
87
+ schema: ValidatorSchema<T>,
88
+ ): FieldErrors<T> => {
89
+ const errors: FieldErrors<T> = {};
90
+
91
+ for (const field of Object.keys(schema) as (keyof T)[]) {
92
+ const validators = schema[field];
93
+ if (!validators) {
94
+ continue;
95
+ }
96
+ const error = firstFieldError(validators, values[field] ?? '');
97
+ if (error !== undefined) {
98
+ errors[field] = error;
99
+ }
100
+ }
101
+
102
+ return errors;
103
+ };