From a90fde3a51152bc6960f7f00cf3d470e6ed61944 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:51:36 +0800 Subject: [PATCH] feat(objectql,spec): enforce all declared validation-rule types; trim the unenforceable three (#1475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `validations` union advertised nine rule types but only three (state_machine / cross_field / script) ran on the write path — the other six were accepted by the schema yet silently no-op'd. Close the gap on both sides. objectql: the rule evaluator now also enforces `format` (regex / named email·url·phone·json), `json_schema` (ajv-compiled, memoised), and `conditional` (recursive when → then/otherwise). All three are deterministic, synchronous, side-effect-free predicates over one record. Adds ajv + three error codes; needsPriorRecord recurses into conditionals. spec: removes the `unique`, `async`, and `custom` variants — each needs I/O or a handler model a write-path rule must not carry. Redirected to the layer that already does it correctly (unique index / client form / lifecycle hook). Field-level `unique: true` is unaffected. examples/app-showcase demonstrates and verifies each newly-enforced type. Documented as an ADR-0020 addendum. Closes #1475 Co-Authored-By: Claude Opus 4.8 --- .changeset/enforce-validation-rules-1475.md | 40 ++ ...0020-state-machine-converge-and-enforce.md | 9 + examples/app-showcase/package.json | 1 + .../src/objects/account.object.ts | 67 ++ examples/app-showcase/test/validation.test.ts | 88 +++ packages/objectql/package.json | 1 + .../src/validation/record-validator.ts | 5 +- .../src/validation/rule-validator.test.ts | 208 ++++++- .../objectql/src/validation/rule-validator.ts | 268 +++++++- packages/spec/src/data/validation.test.ts | 580 ++---------------- packages/spec/src/data/validation.zod.ts | 204 ++---- pnpm-lock.yaml | 6 + 12 files changed, 758 insertions(+), 719 deletions(-) create mode 100644 .changeset/enforce-validation-rules-1475.md create mode 100644 examples/app-showcase/test/validation.test.ts diff --git a/.changeset/enforce-validation-rules-1475.md b/.changeset/enforce-validation-rules-1475.md new file mode 100644 index 000000000..e586b73b3 --- /dev/null +++ b/.changeset/enforce-validation-rules-1475.md @@ -0,0 +1,40 @@ +--- +"@objectstack/objectql": minor +"@objectstack/spec": minor +--- + +Enforce every declared validation-rule type on the write path; trim the three that can't be (#1475). + +The `validations` union advertised nine rule types but only three (`state_machine`, +`cross_field`, `script`) ran on insert/update — the other six were accepted by the +schema yet silently did nothing. This closes that gap on both sides: implement the +synchronous types, and trim the ones that don't belong in a write-path rule. + +**`@objectstack/objectql` (additive):** the rule evaluator now enforces three more +types, all deterministic, synchronous, side-effect-free predicates over one record: + +- `format` — a field value against a `regex` and/or a named format + (`email` / `url` / `phone` / `json`). Runs only when the write touches the field + and the value is non-empty; a malformed regex fails open. +- `json_schema` — a JSON field validated against a JSON Schema via `ajv` (compiled + result memoised per schema). Accepts a parsed object or a JSON string; an + unparseable string is itself a violation; an uncompilable schema fails open. +- `conditional` — evaluates `when`, then recurses into `then` / `otherwise`. The + nested rule supplies the message; the outer conditional's `severity` decides + blocking. `needsPriorRecord` now recurses into conditional branches. + +Adds `ajv` as a dependency and three error codes (`invalid_format`, `invalid_json`, +`json_schema_violation`). + +**`@objectstack/spec` (breaking for unused declarations):** removes the +`unique`, `async`, and `custom` validation-rule variants (and the +`UniquenessValidationSchema` / `AsyncValidationSchema` / `CustomValidatorSchema` +exports). They were never enforced and each needs I/O or a handler model a +write-path rule must not carry. Use the layer that already does each correctly: +uniqueness → a unique index (`ObjectSchema.indexes`, `partial` for scope) or +field-level `unique: true`; async/remote → the client form layer; custom code → +a `beforeInsert` / `beforeUpdate` lifecycle hook. Field-level `unique: true` is +unaffected. + +`examples/app-showcase` demonstrates and verifies each newly-enforced type. See the +ADR-0020 addendum for the rationale. diff --git a/docs/adr/0020-state-machine-converge-and-enforce.md b/docs/adr/0020-state-machine-converge-and-enforce.md index 7a9294430..0e3a981ff 100644 --- a/docs/adr/0020-state-machine-converge-and-enforce.md +++ b/docs/adr/0020-state-machine-converge-and-enforce.md @@ -159,3 +159,12 @@ The proposal's intent is fully delivered — converge to the `state_machine` rul - **Make `state_machine` a standalone metadata type / `.state.ts` file.** Rejected: it is intrinsically a per-field constraint on one object (not reusable, not independently versioned), shares `BaseValidationSchema` with eight sibling rule types, and a standalone file just re-creates the top-level type this ADR retires. If a state table grows large, split the *TypeScript* (`export const fooValidations = [...]`), not the metadata model. - **Encode transitions as a Salesforce-style `script` formula instead of a structured `state_machine` rule.** Rejected: a free-form predicate is not introspectable — UI can't grey out illegal actions and an Agent can't read the legal-next set, which is the whole point. The structured table is the upgrade over the Salesforce approach. - **Leave all three shapes, just add enforcement to one.** Rejected: three declaration shapes for one concept is itself the AI-hallucination hazard this ADR exists to remove. + +## Addendum — completing D3 for the whole `validations` union (#1475) + +This ADR enforced `state_machine` / `cross_field` / `script` and left the other six rule types declarative "until a later phase." [#1475](https://github.com/objectstack-ai/framework/issues/1475) finished that phase, applying the same "no advertised-but-unenforced capability" principle to the rest of the union. The "nine types" figure quoted in the problem statement above is therefore now **six**: + +- **Enforced (added by #1475):** `format` (regex / named email·url·phone·json), `json_schema` (ajv-compiled), `conditional` (recursive `when` → `then`/`otherwise`). These join the three this ADR shipped — all are deterministic, synchronous, side-effect-free predicates over one record, the contract that makes them safe on the write path. +- **Removed from the schema:** `unique`, `async`, `custom`. Each needs I/O or a handler model a write-path rule must not carry, so it was trimmed rather than left a silent no-op, and redirected to the layer that already does it correctly: uniqueness → a unique **index** (`ObjectSchema.indexes`, `partial` for scope) or field-level `unique: true`; async/remote → the client form layer; custom code → a `beforeInsert`/`beforeUpdate` lifecycle hook. + +Deviation #3 above ("a conditional transition can be expressed as a sibling `script`/`conditional` rule") now rests on a genuinely enforced `conditional`. See [`validation.zod.ts`](../../packages/spec/src/data/validation.zod.ts) for the trimmed taxonomy and [`rule-validator.ts`](../../packages/objectql/src/validation/rule-validator.ts) for the evaluator; `examples/app-showcase` demonstrates and verifies each enforced type (`test/validation.test.ts`). diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 81bf585e6..b2629426a 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@objectstack/cli": "workspace:*", + "@objectstack/objectql": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.8" } diff --git a/examples/app-showcase/src/objects/account.object.ts b/examples/app-showcase/src/objects/account.object.ts index 52a35c4f8..166927350 100644 --- a/examples/app-showcase/src/objects/account.object.ts +++ b/examples/app-showcase/src/objects/account.object.ts @@ -1,9 +1,16 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { P } from '@objectstack/spec'; /** * Account — a customer org. Lookup target for projects and the field zoo. + * + * Doubles as the validation-rule showcase: besides the re-entrant + * `state_machine` lifecycle below, it demonstrates the other write-path rule + * types — `format` (tax id pattern), `json_schema` (support config shape), and + * `conditional` (churn reason required only when churning). Each is exercised + * by `test/validation.test.ts` against the real evaluator. */ export const Account = ObjectSchema.create({ name: 'showcase_account', @@ -35,6 +42,15 @@ export const Account = ObjectSchema.create({ { label: 'Churned', value: 'churned', color: '#EF4444' }, ], }), + // EIN-style tax id — the `format` rule below enforces the NN-NNNNNNN shape + // with a regex (a deliberately non-trivial pattern, unlike the built-in + // email/url field validators). + tax_id: Field.text({ label: 'Tax ID (EIN)', maxLength: 20 }), + // Free-form integration settings stored as JSON — the `json_schema` rule + // below constrains its shape. + support_config: Field.json({ label: 'Support Config' }), + // Captured only when an account churns; required by the `conditional` rule. + churn_reason: Field.text({ label: 'Churn Reason', maxLength: 500 }), }, // A third `state_machine` example with a different topology than @@ -57,5 +73,56 @@ export const Account = ObjectSchema.create({ churned: ['active'], }, }, + { + // `format` — a regex check on a single field. Runs on insert/update + // whenever the write touches `tax_id` and the value is non-empty. + type: 'format' as const, + name: 'tax_id_format', + label: 'Tax ID Format', + description: 'Tax ID must be a US EIN (NN-NNNNNNN).', + field: 'tax_id', + regex: '^\\d{2}-\\d{7}$', + message: 'Tax ID must look like 12-3456789.', + }, + { + // `json_schema` — validate the JSON `support_config` blob against a + // JSON Schema (compiled by ajv). Accepts a parsed object or a JSON + // string; an unparseable string is itself a violation. + type: 'json_schema' as const, + name: 'support_config_shape', + label: 'Support Config Shape', + description: 'Support config must declare a known tier and a positive seat count.', + field: 'support_config', + message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.', + schema: { + type: 'object', + properties: { + tier: { type: 'string', enum: ['standard', 'premium', 'enterprise'] }, + seats: { type: 'integer', minimum: 1 }, + }, + required: ['tier'], + additionalProperties: false, + }, + }, + { + // `conditional` — only enforce the inner rule when `when` holds. Here: + // an account may only be marked churned if it records why. The nested + // rule supplies the message; this conditional's severity (default + // `error`) decides that it blocks. + type: 'conditional' as const, + name: 'churn_requires_reason', + label: 'Churn Requires a Reason', + description: 'A churned account must record a churn reason.', + when: P`record.status == 'churned'`, + message: 'Churn reason validation.', + then: { + type: 'script' as const, + name: 'churn_reason_present', + message: 'A churn reason is required when an account is marked churned.', + // `has()` guards the absent-key case (a PATCH that never mentions + // churn_reason); the equality checks catch an explicit null / blank. + condition: P`!has(record.churn_reason) || record.churn_reason == null || record.churn_reason == ''`, + }, + }, ], }); diff --git a/examples/app-showcase/test/validation.test.ts b/examples/app-showcase/test/validation.test.ts new file mode 100644 index 000000000..95a89641c --- /dev/null +++ b/examples/app-showcase/test/validation.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { evaluateValidationRules, ValidationError } from '@objectstack/objectql'; + +import { Account } from '../src/objects/account.object.js'; + +/** + * Verifies the Account object's declared validation rules actually enforce on + * the write path — running the real `evaluateValidationRules` evaluator against + * the very rules shipped in `account.object.ts`. This is the "demonstrated AND + * verified" half of the showcase: a rule that parses but no-ops would slip past + * the coverage test, but not this one. + * + * `state_machine` is already covered by objectql's own suite; here we exercise + * the three rule types this object adds: `format`, `json_schema`, `conditional`. + */ +const schema = Account as unknown as { validations?: unknown[] }; + +describe('showcase Account validation rules (write-path enforcement)', () => { + describe('format — tax_id EIN pattern', () => { + it('rejects a malformed tax id', () => { + expect(() => evaluateValidationRules(schema, { tax_id: '123-45' }, 'insert')).toThrow( + ValidationError, + ); + }); + + it('accepts a well-formed EIN', () => { + expect(() => evaluateValidationRules(schema, { tax_id: '12-3456789' }, 'insert')).not.toThrow(); + }); + + it('does not fire when tax_id is absent (requiredness is not its concern)', () => { + expect(() => evaluateValidationRules(schema, { name: 'Acme' }, 'insert')).not.toThrow(); + }); + }); + + describe('json_schema — support_config shape', () => { + it('accepts a conforming config', () => { + expect(() => + evaluateValidationRules(schema, { support_config: { tier: 'premium', seats: 10 } }, 'insert'), + ).not.toThrow(); + }); + + it('rejects an unknown tier / missing required field', () => { + expect(() => + evaluateValidationRules(schema, { support_config: { tier: 'gold' } }, 'insert'), + ).toThrow(ValidationError); + expect(() => + evaluateValidationRules(schema, { support_config: { seats: 3 } }, 'insert'), + ).toThrow(ValidationError); + }); + + it('rejects additional properties (additionalProperties: false)', () => { + expect(() => + evaluateValidationRules(schema, { support_config: { tier: 'standard', extra: 1 } }, 'insert'), + ).toThrow(ValidationError); + }); + }); + + describe('conditional — churn requires a reason', () => { + it('blocks marking an account churned without a reason', () => { + expect(() => + evaluateValidationRules(schema, { status: 'churned' }, 'update', { + previous: { status: 'active' }, + }), + ).toThrow(ValidationError); + }); + + it('allows churning when a reason is supplied', () => { + expect(() => + evaluateValidationRules( + schema, + { status: 'churned', churn_reason: 'Migrated to a competitor' }, + 'update', + { previous: { status: 'active' } }, + ), + ).not.toThrow(); + }); + + it('does not require a reason for non-churned accounts', () => { + expect(() => + evaluateValidationRules(schema, { status: 'active' }, 'update', { + previous: { status: 'prospect' }, + }), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 0cc0f7947..cd090ab34 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -22,6 +22,7 @@ "@objectstack/metadata-core": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", + "ajv": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index cf19d9585..39eb8786b 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -60,7 +60,10 @@ export interface FieldValidationError { | 'invalid_option' // Object-level validation rules (ADR-0020, see rule-validator.ts) | 'invalid_transition' - | 'rule_violation'; + | 'rule_violation' + | 'invalid_format' + | 'invalid_json' + | 'json_schema_violation'; message: string; /** Allowed values for select/multiselect, when applicable. */ options?: string[]; diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index c1ce278f6..51777475f 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -195,8 +195,214 @@ describe('introspection', () => { it('needsPriorRecord detects rules that require prior state', () => { expect(needsPriorRecord(accountSchema)).toBe(true); - expect(needsPriorRecord({ validations: [{ type: 'unique', name: 'u', message: 'm', fields: ['x'] }] })).toBe(false); + // format only inspects the incoming value → no prior fetch needed. + expect(needsPriorRecord({ validations: [{ type: 'format', name: 'f', message: 'm', field: 'x', format: 'email' }] })).toBe(false); expect(needsPriorRecord({ validations: [] })).toBe(false); expect(needsPriorRecord(undefined)).toBe(false); }); + + it('needsPriorRecord recurses into conditional branches', () => { + // conditional wrapping a cross_field → needs prior. + const wrapsPrior = { + validations: [ + { + type: 'conditional' as const, + name: 'c', + message: 'm', + when: { dialect: 'cel', source: 'record.type == "x"' }, + then: { type: 'cross_field', name: 'cf', message: 'm', fields: ['a'], condition: { dialect: 'cel', source: 'record.a < record.b' } }, + }, + ], + }; + expect(needsPriorRecord(wrapsPrior)).toBe(true); + + // conditional wrapping only a format → does not need prior. + const wrapsFormat = { + validations: [ + { + type: 'conditional' as const, + name: 'c', + message: 'm', + when: { dialect: 'cel', source: 'record.type == "x"' }, + then: { type: 'format', name: 'f', message: 'm', field: 'email', format: 'email' }, + }, + ], + }; + expect(needsPriorRecord(wrapsFormat)).toBe(false); + }); +}); + +describe('format enforcement', () => { + const schema = (extra: Record) => ({ + validations: [{ type: 'format' as const, name: 'fmt', message: 'Bad format.', ...extra }], + }); + + it('rejects an invalid named format (email) on insert', () => { + expect(() => + evaluateValidationRules(schema({ field: 'email', format: 'email' }), { email: 'not-an-email' }, 'insert'), + ).toThrow(ValidationError); + }); + + it('accepts a valid named format (email)', () => { + expect(() => + evaluateValidationRules(schema({ field: 'email', format: 'email' }), { email: 'a@b.com' }, 'insert'), + ).not.toThrow(); + }); + + it('validates url / phone / json named formats', () => { + expect(() => evaluateValidationRules(schema({ field: 'site', format: 'url' }), { site: 'nope' }, 'insert')).toThrow(ValidationError); + expect(() => evaluateValidationRules(schema({ field: 'site', format: 'url' }), { site: 'https://x.io' }, 'insert')).not.toThrow(); + expect(() => evaluateValidationRules(schema({ field: 'tel', format: 'phone' }), { tel: 'abc' }, 'insert')).toThrow(ValidationError); + expect(() => evaluateValidationRules(schema({ field: 'tel', format: 'phone' }), { tel: '+1 (415) 555-2020' }, 'insert')).not.toThrow(); + expect(() => evaluateValidationRules(schema({ field: 'blob', format: 'json' }), { blob: '{bad' }, 'insert')).toThrow(ValidationError); + expect(() => evaluateValidationRules(schema({ field: 'blob', format: 'json' }), { blob: '{"ok":1}' }, 'insert')).not.toThrow(); + }); + + it('enforces a regex', () => { + expect(() => evaluateValidationRules(schema({ field: 'zip', regex: '^[0-9]{5}$' }), { zip: '1234' }, 'insert')).toThrow(ValidationError); + expect(() => evaluateValidationRules(schema({ field: 'zip', regex: '^[0-9]{5}$' }), { zip: '94107' }, 'insert')).not.toThrow(); + }); + + it('skips when the field is absent or empty (requiredness is not its job)', () => { + expect(() => evaluateValidationRules(schema({ field: 'email', format: 'email' }), { other: 1 }, 'insert')).not.toThrow(); + expect(() => evaluateValidationRules(schema({ field: 'email', format: 'email' }), { email: '' }, 'insert')).not.toThrow(); + }); + + it('skips on update when the PATCH does not touch the field', () => { + expect(() => + evaluateValidationRules(schema({ field: 'email', format: 'email' }), { name: 'x' }, 'update', { previous: { email: 'still-bad' } }), + ).not.toThrow(); + }); + + it('fails open on an invalid regex', () => { + expect(() => evaluateValidationRules(schema({ field: 'x', regex: '((' }), { x: 'anything' }, 'insert')).not.toThrow(); + }); + + it('surfaces an invalid_format code', () => { + try { + evaluateValidationRules(schema({ field: 'email', format: 'email' }), { email: 'bad' }, 'insert'); + throw new Error('expected throw'); + } catch (e) { + const err = e as ValidationError; + expect(err.fields[0].code).toBe('invalid_format'); + expect(err.fields[0].field).toBe('email'); + } + }); +}); + +describe('json_schema enforcement', () => { + const schema = { + validations: [ + { + type: 'json_schema' as const, + name: 'cfg', + message: 'Config does not match schema.', + field: 'config', + schema: { type: 'object', properties: { port: { type: 'number' } }, required: ['port'], additionalProperties: false }, + }, + ], + }; + + it('accepts a conforming object value', () => { + expect(() => evaluateValidationRules(schema, { config: { port: 8080 } }, 'insert')).not.toThrow(); + }); + + it('rejects a non-conforming object value', () => { + expect(() => evaluateValidationRules(schema, { config: { port: 'nope' } }, 'insert')).toThrow(ValidationError); + expect(() => evaluateValidationRules(schema, { config: {} }, 'insert')).toThrow(ValidationError); + }); + + it('parses and validates a JSON string value', () => { + expect(() => evaluateValidationRules(schema, { config: '{"port":80}' }, 'insert')).not.toThrow(); + expect(() => evaluateValidationRules(schema, { config: '{"port":"x"}' }, 'insert')).toThrow(ValidationError); + }); + + it('treats an unparseable JSON string as invalid_json', () => { + try { + evaluateValidationRules(schema, { config: '{bad' }, 'insert'); + throw new Error('expected throw'); + } catch (e) { + expect((e as ValidationError).fields[0].code).toBe('invalid_json'); + } + }); + + it('skips when the field is absent or null', () => { + expect(() => evaluateValidationRules(schema, { other: 1 }, 'insert')).not.toThrow(); + expect(() => evaluateValidationRules(schema, { config: null }, 'insert')).not.toThrow(); + }); + + it('fails open on an uncompilable schema', () => { + const broken = { + validations: [{ type: 'json_schema' as const, name: 'b', message: 'm', field: 'c', schema: { type: 'not-a-real-type' } }], + }; + expect(() => evaluateValidationRules(broken, { c: { any: 1 } }, 'insert')).not.toThrow(); + }); +}); + +describe('conditional enforcement', () => { + const schema = { + validations: [ + { + type: 'conditional' as const, + name: 'enterprise_requires_approval', + message: 'Conditional failed.', + when: { dialect: 'cel', source: 'record.account_type == "enterprise"' }, + then: { + type: 'script', + name: 'require_approval', + message: 'Enterprise accounts require an approver.', + condition: { dialect: 'cel', source: 'record.approver == null' }, + }, + otherwise: { + type: 'script', + name: 'require_payment', + message: 'A payment method is required.', + condition: { dialect: 'cel', source: 'record.payment == null' }, + }, + }, + ], + }; + + it('runs the then-branch when when is true (and it fails)', () => { + try { + evaluateValidationRules(schema, { account_type: 'enterprise', approver: null }, 'insert'); + throw new Error('expected throw'); + } catch (e) { + const err = e as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.fields[0].message).toBe('Enterprise accounts require an approver.'); + } + }); + + it('passes the then-branch when satisfied', () => { + expect(() => + evaluateValidationRules(schema, { account_type: 'enterprise', approver: 'u1' }, 'insert'), + ).not.toThrow(); + }); + + it('runs the otherwise-branch when when is false', () => { + expect(() => + evaluateValidationRules(schema, { account_type: 'smb', payment: null }, 'insert'), + ).toThrow(ValidationError); + expect(() => + evaluateValidationRules(schema, { account_type: 'smb', payment: 'card' }, 'insert'), + ).not.toThrow(); + }); + + it('is a no-op when when is false and there is no otherwise', () => { + const noElse = { validations: [{ ...schema.validations[0], otherwise: undefined }] }; + expect(() => evaluateValidationRules(noElse, { account_type: 'smb' }, 'insert')).not.toThrow(); + }); + + it('the outer conditional severity governs blocking (warning → non-blocking)', () => { + const advisory = { validations: [{ ...schema.validations[0], severity: 'warning' as const }] }; + expect(() => + evaluateValidationRules(advisory, { account_type: 'enterprise', approver: null }, 'insert'), + ).not.toThrow(); + }); + + it('fails open on an un-evaluable when predicate', () => { + const broken = { validations: [{ ...schema.validations[0], when: { dialect: 'cel', source: 'this is (( not valid' } }] }; + expect(() => evaluateValidationRules(broken, { account_type: 'enterprise', approver: null }, 'insert')).not.toThrow(); + }); }); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index cd20ef94c..68c10d289 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -13,7 +13,7 @@ * said "an account can't jump from churned straight back to prospect" * silently allowed exactly that. This evaluator closes that gap. * - * ## What runs here (Phase 1) + * ## What runs here * * - `state_machine` — the headline guardrail. On update, if the state field * changed and the new value is not in `transitions[oldValue]`, the write @@ -22,10 +22,20 @@ * TRUE the rule is violated. These share the prior-record gap with * `state_machine` (a PATCH carries only changed fields), so they are * evaluated against the *merged* record `{ ...previous, ...patch }`. + * - `format` — a single field's value against a regex and/or a named format + * (`email` / `url` / `phone` / `json`). Only runs when the write touches + * the field and the value is non-empty (emptiness is the field-shape + * validator's job, not the format rule's). + * - `json_schema` — a JSON field validated against a JSON Schema via ajv. + * - `conditional` — evaluates the `when` CEL predicate and then recurses into + * `then` (true) or `otherwise` (false). The nested rule's violation message + * is surfaced; the *outer* conditional's `severity` decides whether it + * blocks (so `when`-gated guards can be advisory as a unit). * - * Other rule variants (`unique`, `format`, `json_schema`, `async`, - * `custom`, `conditional`) are not yet enforced here; they fall through - * untouched and remain declarative until a later phase wires them. + * Every variant declared by `ValidationRuleSchema` is enforced here — the + * schema deliberately excludes anything that would need I/O or a handler model + * (uniqueness → DB index, async → form layer, custom → lifecycle hook), so + * there are no silent no-ops. * * ## Execution-control semantics (from `BaseValidationSchema`) * @@ -56,6 +66,7 @@ import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; +import Ajv, { type ValidateFunction } from 'ajv'; import { ValidationError, type FieldValidationError } from './record-validator.js'; type Mode = 'insert' | 'update'; @@ -82,6 +93,49 @@ interface PredicateRule extends BaseRule { fields?: string[]; } +interface FormatRule extends BaseRule { + type: 'format'; + field: string; + regex?: string; + format?: 'email' | 'url' | 'phone' | 'json'; +} + +interface JsonSchemaRule extends BaseRule { + type: 'json_schema'; + field: string; + schema: Record; +} + +interface ConditionalRule extends BaseRule { + type: 'conditional'; + when: string | Expression; + then: BaseRule; + otherwise?: BaseRule; +} + +/** + * Context threaded through every rule evaluation. `data` is the raw incoming + * write (a PATCH on update); `merged` overlays it on the prior record so a + * predicate referencing an unchanged field still sees its persisted value. + * Field-scoped rules (`state_machine`, `format`, `json_schema`) key off `data` + * to decide whether the write actually touched their field. + */ +interface RuleContext { + data: Record; + merged: Record; + previous: Record | undefined; + mode: Mode; + logger: EvaluateRulesOptions['logger']; +} + +/** + * Shared ajv instance. `strict: false` tolerates author-written JSON Schemas + * that use vendor keywords; `compile` results are memoised per schema object + * (see `jsonSchemaCache`) so we don't recompile on every write. + */ +const ajv = new Ajv({ allErrors: true, strict: false }); +const jsonSchemaCache = new WeakMap(); + export interface EvaluateRulesOptions { /** Prior persisted record (update only). Absent on insert. */ previous?: Record | null; @@ -99,14 +153,28 @@ export function needsPriorRecord( ): boolean { const rules = objectSchema?.validations; if (!Array.isArray(rules)) return false; - return rules.some( - (r) => - r != null && - typeof r === 'object' && - ((r as BaseRule).type === 'state_machine' || - (r as BaseRule).type === 'cross_field' || - (r as BaseRule).type === 'script'), - ); + return rules.some((r) => ruleNeedsPrior(r)); +} + +/** + * A rule needs the prior record if it reasons about the transition or compares + * against unchanged fields (`state_machine` / `cross_field` / `script`), or if + * it is a `conditional` whose branches (or `when`) recursively do. `format` and + * `json_schema` only inspect the incoming value, so they never need it. + */ +function ruleNeedsPrior(r: unknown): boolean { + if (r == null || typeof r !== 'object') return false; + const type = (r as BaseRule).type; + if (type === 'state_machine' || type === 'cross_field' || type === 'script') { + return true; + } + if (type === 'conditional') { + const c = r as ConditionalRule; + // `when` is evaluated against the merged record; the branches may need prior + // state. Be conservative and fetch if either branch does. + return ruleNeedsPrior(c.then) || ruleNeedsPrior(c.otherwise); + } + return false; } /** Normalize an author-time ExpressionInput into the canonical envelope. */ @@ -134,6 +202,7 @@ export function evaluateValidationRules( // Merged view used by predicate rules: prior state overlaid with the PATCH, // so a rule referencing an unchanged field still sees its persisted value. const merged: Record = { ...(previous ?? {}), ...data }; + const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger }; const errors: FieldValidationError[] = []; @@ -149,12 +218,7 @@ export function evaluateValidationRules( for (const rule of ordered) { let violation: FieldValidationError | null = null; try { - if (rule.type === 'state_machine') { - violation = checkStateMachine(rule as StateMachineRule, mode, data, previous); - } else if (rule.type === 'script' || rule.type === 'cross_field') { - violation = checkPredicate(rule as PredicateRule, merged, previous, opts.logger); - } - // Other rule types are not enforced on the write path (yet). + violation = evaluateRule(rule, ctx); } catch (err) { // Defensive: a broken rule must never brick a write. opts.logger?.warn?.(`Validation rule '${rule.name}' threw — skipped`, err); @@ -176,6 +240,31 @@ export function evaluateValidationRules( if (errors.length > 0) throw new ValidationError(errors); } +/** + * Dispatch a single rule to its checker, returning the violation (or null). + * Shared by the top-level loop and by `checkConditional`, which recurses into + * its `then` / `otherwise` branch. Unknown types return null — but the schema + * (`ValidationRuleSchema`) only admits the types handled below, so in practice + * every declared rule is covered. + */ +function evaluateRule(rule: BaseRule, ctx: RuleContext): FieldValidationError | null { + switch (rule.type) { + case 'state_machine': + return checkStateMachine(rule as StateMachineRule, ctx.mode, ctx.data, ctx.previous); + case 'script': + case 'cross_field': + return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger); + case 'format': + return checkFormat(rule as FormatRule, ctx.data, ctx.logger); + case 'json_schema': + return checkJsonSchema(rule as JsonSchemaRule, ctx.data, ctx.logger); + case 'conditional': + return checkConditional(rule as ConditionalRule, ctx); + default: + return null; + } +} + /** * State-machine transition check. * @@ -253,6 +342,149 @@ function checkPredicate( return null; } +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Lenient phone matcher: optional leading +, then 7–20 digits with spaces, +// dashes, dots and parens allowed as separators. Intentionally permissive — +// strict national formats belong in a `regex`. +const PHONE_RE = /^\+?[\d\s().-]{7,20}$/; + +/** + * Format check (`format`). Validates a single field's value against an optional + * `regex` and/or a named `format`. Only runs when the write touches the field + * (mirrors `state_machine`) and the value is non-empty — requiredness and + * type-shape are the field-level validator's job, so an absent/blank value is + * not a *format* violation. A malformed `regex` is a broken rule (logged, + * fail-open), not a violation. + */ +function checkFormat( + rule: FormatRule, + data: Record, + logger: EvaluateRulesOptions['logger'], +): FieldValidationError | null { + if (!(rule.field in data)) return null; + const value = data[rule.field]; + if (value === null || value === undefined || value === '') return null; + const str = String(value); + + if (rule.regex) { + let re: RegExp; + try { + re = new RegExp(rule.regex); + } catch { + logger?.warn?.(`Validation rule '${rule.name}' has an invalid regex — skipped`); + return null; + } + if (!re.test(str)) return formatViolation(rule); + } + + if (rule.format && !matchesNamedFormat(rule.format, str)) { + return formatViolation(rule); + } + return null; +} + +function matchesNamedFormat(format: FormatRule['format'], str: string): boolean { + switch (format) { + case 'email': + return EMAIL_RE.test(str); + case 'phone': + return PHONE_RE.test(str); + case 'url': + try { + // eslint-disable-next-line no-new + new URL(str); + return true; + } catch { + return false; + } + case 'json': + try { + JSON.parse(str); + return true; + } catch { + return false; + } + default: + return true; + } +} + +function formatViolation(rule: FormatRule): FieldValidationError { + return { field: rule.field, code: 'invalid_format', message: rule.message }; +} + +/** + * JSON Schema check (`json_schema`). Validates a JSON field against the rule's + * schema via ajv. The field value may be a parsed object or a JSON string (a + * string that fails to parse is itself a violation). Only runs when the write + * touches the field and the value is non-null. A schema ajv cannot compile is a + * broken rule (logged, fail-open). + */ +function checkJsonSchema( + rule: JsonSchemaRule, + data: Record, + logger: EvaluateRulesOptions['logger'], +): FieldValidationError | null { + if (!(rule.field in data)) return null; + let value = data[rule.field]; + if (value === null || value === undefined) return null; + + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return { field: rule.field, code: 'invalid_json', message: rule.message }; + } + } + + let validate = jsonSchemaCache.get(rule.schema); + if (!validate) { + try { + validate = ajv.compile(rule.schema); + } catch (err) { + logger?.warn?.( + `Validation rule '${rule.name}' has an uncompilable JSON Schema — skipped`, + err, + ); + return null; + } + jsonSchemaCache.set(rule.schema, validate); + } + + if (!validate(value)) { + return { field: rule.field, code: 'json_schema_violation', message: rule.message }; + } + return null; +} + +/** + * Conditional check (`conditional`). Evaluates the `when` predicate against the + * merged record, then recurses into `then` (true) or `otherwise` (false) via + * `evaluateRule`. An un-evaluable `when` is a broken rule (logged, fail-open). + * The nested rule supplies the violation (field/code/message); the *outer* + * conditional's `severity` governs whether it blocks (handled by the caller). + */ +function checkConditional( + rule: ConditionalRule, + ctx: RuleContext, +): FieldValidationError | null { + const result = ExpressionEngine.evaluate(toExpression(rule.when), { + record: ctx.merged, + previous: ctx.previous ?? undefined, + }); + + if (!result.ok) { + ctx.logger?.warn?.( + `Validation rule '${rule.name}' when-predicate failed to evaluate (${result.error.kind}: ${result.error.message}) — skipped`, + ); + return null; + } + + const branch = result.value === true ? rule.then : rule.otherwise; + if (!branch || branch.active === false) return null; + return evaluateRule(branch, ctx); +} + /** * Introspection helper (ADR-0020 D3.3): given an object's schema, a state * field, and a current state, return the legal next states declared by the diff --git a/packages/spec/src/data/validation.test.ts b/packages/spec/src/data/validation.test.ts index 4f3983f3c..9d726c81b 100644 --- a/packages/spec/src/data/validation.test.ts +++ b/packages/spec/src/data/validation.test.ts @@ -2,12 +2,10 @@ import { describe, it, expect } from 'vitest'; import { ValidationRuleSchema, ScriptValidationSchema, - UniquenessValidationSchema, StateMachineValidationSchema, FormatValidationSchema, + JSONValidationSchema, CrossFieldValidationSchema, - AsyncValidationSchema, - CustomValidatorSchema, ConditionalValidationSchema, type ValidationRule, } from './validation.zod'; @@ -79,67 +77,6 @@ describe('ScriptValidationSchema', () => { }); }); -describe('UniquenessValidationSchema', () => { - it('should accept single field uniqueness validation', () => { - const uniqueValidation = { - type: 'unique' as const, - name: 'unique_email', - message: 'Email must be unique', - fields: ['email'], - }; - - expect(() => UniquenessValidationSchema.parse(uniqueValidation)).not.toThrow(); - }); - - it('should accept composite uniqueness validation', () => { - const compositeValidation = { - type: 'unique' as const, - name: 'unique_tenant_email', - message: 'Email must be unique within tenant', - fields: ['tenant_id', 'email'], - }; - - expect(() => UniquenessValidationSchema.parse(compositeValidation)).not.toThrow(); - }); - - it('should accept uniqueness with scope', () => { - const scopedValidation = { - type: 'unique' as const, - name: 'unique_active_email', - message: 'Active emails must be unique', - fields: ['email'], - scope: 'status = "active"', - }; - - expect(() => UniquenessValidationSchema.parse(scopedValidation)).not.toThrow(); - }); - - it('should handle case sensitivity option', () => { - const caseInsensitive = { - type: 'unique' as const, - name: 'unique_username', - message: 'Username must be unique', - fields: ['username'], - caseSensitive: false, - }; - - const result = UniquenessValidationSchema.parse(caseInsensitive); - expect(result.caseSensitive).toBe(false); - }); - - it('should default caseSensitive to true', () => { - const validation = { - type: 'unique' as const, - name: 'unique_code', - message: 'Code must be unique', - fields: ['code'], - }; - - const result = UniquenessValidationSchema.parse(validation); - expect(result.caseSensitive).toBe(true); - }); -}); - describe('StateMachineValidationSchema', () => { it('should accept valid state machine validation', () => { const stateMachine = { @@ -221,6 +158,55 @@ describe('FormatValidationSchema', () => { }); }); +describe('JSONValidationSchema', () => { + it('should accept a valid json_schema rule', () => { + const validation = { + type: 'json_schema' as const, + name: 'config_shape', + message: 'Invalid config', + field: 'config', + schema: { + type: 'object', + properties: { port: { type: 'number' } }, + required: ['port'], + }, + }; + + expect(() => JSONValidationSchema.parse(validation)).not.toThrow(); + }); + + it('should accept a json_schema rule via ValidationRuleSchema', () => { + const validation = { + type: 'json_schema' as const, + name: 'config_shape', + message: 'Invalid config', + field: 'config', + schema: { + type: 'object', + properties: { port: { type: 'number' } }, + required: ['port'], + }, + }; + + expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); + }); + + it('should reject json_schema rule when field is missing', () => { + const validation = { + type: 'json_schema' as const, + name: 'config_shape', + message: 'Invalid config', + schema: { + type: 'object', + properties: { port: { type: 'number' } }, + required: ['port'], + }, + }; + + expect(() => JSONValidationSchema.parse(validation)).toThrow(); + }); +}); + describe('ValidationRuleSchema (Discriminated Union)', () => { it('should accept all validation rule types', () => { const rules: ValidationRule[] = [ @@ -230,12 +216,6 @@ describe('ValidationRuleSchema (Discriminated Union)', () => { message: 'Amount must be positive', condition: 'amount > 0', }, - { - type: 'unique', - name: 'unique_email', - message: 'Email must be unique', - fields: ['email'], - }, { type: 'state_machine', name: 'status_flow', @@ -289,13 +269,6 @@ describe('ValidationRuleSchema (Discriminated Union)', () => { condition: 'close_date < TODAY()', severity: 'warning', }, - { - type: 'unique', - name: 'unique_opportunity_name', - message: 'Opportunity name must be unique per account', - fields: ['account_id', 'name'], - scope: 'is_deleted = false', - }, { type: 'state_machine', name: 'stage_transitions', @@ -532,243 +505,6 @@ describe('ValidationRuleSchema (Discriminated Union)', () => { }); }); - describe('AsyncValidationSchema', () => { - it('should accept async validation with URL', () => { - const asyncValidation = { - type: 'async' as const, - name: 'check_username_available', - message: 'Username is already taken', - field: 'username', - validatorUrl: 'https://api.example.com/validate/username', - timeout: 3000, - debounce: 500, - }; - - expect(() => ValidationRuleSchema.parse(asyncValidation)).not.toThrow(); - }); - - it('should accept async validation with function reference', () => { - const asyncValidation = { - type: 'async' as const, - name: 'verify_vat_number', - message: 'Invalid VAT number', - field: 'vat_number', - validatorFunction: 'validateVatNumber', - params: { country: 'GB' }, - }; - - expect(() => ValidationRuleSchema.parse(asyncValidation)).not.toThrow(); - }); - - it('should apply default timeout', () => { - const validation = { - type: 'async' as const, - name: 'test_async', - message: 'Test', - field: 'email', - validatorUrl: 'https://api.example.com/validate', - }; - - const result = ValidationRuleSchema.parse(validation); - if (result.type === 'async') { - expect(result.timeout).toBe(5000); - } - }); - - // Use Case: Email Uniqueness Check - it('should validate email uniqueness via API', () => { - const validation = { - type: 'async' as const, - name: 'unique_email_check', - message: 'This email address is already registered', - field: 'email', - validatorUrl: '/api/users/check-email', - timeout: 3000, - debounce: 500, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - // Use Case: Username Availability Check - it('should validate username availability with debounce', () => { - const validation = { - type: 'async' as const, - name: 'username_availability', - message: 'This username is not available', - field: 'username', - validatorUrl: '/api/users/check-username', - debounce: 300, - }; - - const result = ValidationRuleSchema.parse(validation); - if (result.type === 'async') { - expect(result.debounce).toBe(300); - expect(result.timeout).toBe(5000); // default - } - }); - - // Use Case: Domain Name Availability - it('should check domain name availability', () => { - const validation = { - type: 'async' as const, - name: 'domain_available', - message: 'This domain is already taken or reserved', - field: 'domain_name', - validatorUrl: '/api/domains/check-availability', - timeout: 2000, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - // Use Case: Tax ID Validation via Government API - it('should validate tax ID via external service', () => { - const validation = { - type: 'async' as const, - name: 'validate_tax_id', - message: 'Invalid Tax ID number', - field: 'tax_id', - validatorFunction: 'validateTaxIdWithIRS', - timeout: 10000, // Government APIs may be slow - params: { country: 'US' }, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - // Use Case: Credit Card Validation with Payment Gateway - it('should validate credit card via payment gateway', () => { - const validation = { - type: 'async' as const, - name: 'validate_card', - message: 'Invalid credit card', - field: 'card_number', - validatorUrl: 'https://api.stripe.com/v1/tokens/validate', - timeout: 5000, - params: { mode: 'validate_only' }, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - // Use Case: Address Validation - it('should validate address via geocoding service', () => { - const validation = { - type: 'async' as const, - name: 'validate_address', - message: 'Unable to verify address', - field: 'street_address', - validatorFunction: 'validateAddressWithGoogleMaps', - timeout: 4000, - params: { - includeFields: ['city', 'state', 'zip'], - strictMode: true - }, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - // Use Case: Coupon Code Validation - it('should validate coupon code availability', () => { - const validation = { - type: 'async' as const, - name: 'check_coupon', - message: 'Invalid or expired coupon code', - field: 'coupon_code', - validatorUrl: '/api/coupons/validate', - timeout: 2000, - params: { checkExpiration: true, checkUsageLimit: true }, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - it('should accept async validation with custom timeout', () => { - const validation = { - type: 'async' as const, - name: 'slow_api_check', - message: 'Validation failed', - field: 'data', - validatorUrl: 'https://slow-api.example.com/validate', - timeout: 15000, - }; - - const result = ValidationRuleSchema.parse(validation); - if (result.type === 'async') { - expect(result.timeout).toBe(15000); - } - }); - - it('should accept async validation with additional params', () => { - const validation = { - type: 'async' as const, - name: 'complex_check', - message: 'Complex validation failed', - field: 'complex_field', - validatorUrl: '/api/validate/complex', - params: { - threshold: 100, - mode: 'strict', - includeMetadata: true, - }, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - it('should enforce required field property', () => { - const invalidValidation = { - type: 'async' as const, - name: 'invalid_async', - message: 'Missing field', - validatorUrl: '/api/validate', - }; - - expect(() => ValidationRuleSchema.parse(invalidValidation)).toThrow(); - }); - }); - - describe('CustomValidatorSchema', () => { - it('should accept custom field validator', () => { - const customValidation = { - type: 'custom' as const, - name: 'custom_business_rule', - message: 'Custom validation failed', - handler: 'validateBusinessRule', - }; - - expect(() => ValidationRuleSchema.parse(customValidation)).not.toThrow(); - }); - - it('should accept custom validator with params', () => { - const validation = { - type: 'custom' as const, - name: 'complex_validation', - message: 'Validation failed', - handler: 'complexValidator', - params: { - threshold: 100, - mode: 'strict', - }, - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - - it('should accept record-level custom validator', () => { - const validation = { - type: 'custom' as const, - name: 'record_level_check', - message: 'Record validation failed', - handler: 'validateEntireRecord', - }; - - expect(() => ValidationRuleSchema.parse(validation)).not.toThrow(); - }); - }); - describe('ConditionalValidationSchema', () => { it('should accept conditional validation with then clause', () => { const conditionalValidation = { @@ -1044,20 +780,6 @@ describe('ValidationRuleSchema (Discriminated Union)', () => { condition: 'end_date > start_date', fields: ['start_date', 'end_date'], }, - { - type: 'async', - name: 'email_available', - message: 'Email is already registered', - field: 'email', - validatorUrl: '/api/validate/email', - debounce: 300, - }, - { - type: 'custom', - name: 'business_logic', - message: 'Business logic validation failed', - handler: 'validateBusinessRules', - }, { type: 'conditional', name: 'type_based_validation', @@ -1096,29 +818,6 @@ describe('ValidationRuleSchema - Edge Cases and Null Handling', () => { expect(result.severity).toBe('error'); }); - it('should handle empty arrays in UniquenessValidation', () => { - expect(() => UniquenessValidationSchema.parse({ - type: 'unique', - name: 'test_unique', - message: 'Must be unique', - fields: [], // Empty array should be valid but probably not useful - })).not.toThrow(); - }); - - it('should handle undefined scope in UniquenessValidation', () => { - const validation = { - type: 'unique' as const, - name: 'unique_email', - message: 'Email must be unique', - fields: ['email'], - scope: undefined, - caseSensitive: undefined, // Should default to true - }; - - const result = UniquenessValidationSchema.parse(validation); - expect(result.caseSensitive).toBe(true); - }); - it('should handle empty state transitions', () => { const validation = { type: 'state_machine' as const, @@ -1175,60 +874,6 @@ describe('ValidationRuleSchema - Edge Cases and Null Handling', () => { expect(() => CrossFieldValidationSchema.parse(validation)).not.toThrow(); }); - it('should handle undefined optional fields in AsyncValidation', () => { - const validation = { - type: 'async' as const, - name: 'async_validation', - message: 'Validation failed', - field: 'email', - validatorUrl: '/api/validate', - validatorFunction: undefined, - debounce: undefined, - params: undefined, - }; - - const result = AsyncValidationSchema.parse(validation); - expect(result.timeout).toBe(5000); // Default timeout - }); - - it('should handle custom timeout in AsyncValidation', () => { - const validation = { - type: 'async' as const, - name: 'async_validation', - message: 'Validation failed', - field: 'email', - validatorUrl: '/api/validate', - timeout: 10000, - }; - - const result = AsyncValidationSchema.parse(validation); - expect(result.timeout).toBe(10000); - }); - - it('should handle undefined field in CustomValidator', () => { - const validation = { - type: 'custom' as const, - name: 'custom_validation', - message: 'Validation failed', - handler: 'validateRecord', - params: undefined, - }; - - expect(() => CustomValidatorSchema.parse(validation)).not.toThrow(); - }); - - it('should handle empty params object', () => { - const validation = { - type: 'custom' as const, - name: 'custom_validation', - message: 'Validation failed', - handler: 'validateRecord', - params: {}, - }; - - expect(() => CustomValidatorSchema.parse(validation)).not.toThrow(); - }); - it('should handle undefined otherwise in ConditionalValidation', () => { const validation = { type: 'conditional' as const, @@ -1294,56 +939,6 @@ describe('ValidationRuleSchema - Type Coercion Edge Cases', () => { }); }); - it('should handle caseSensitive boolean in uniqueness validation', () => { - const validation = { - type: 'unique' as const, - name: 'unique_test', - message: 'Must be unique', - fields: ['field1'], - caseSensitive: false, - }; - - const result = UniquenessValidationSchema.parse(validation); - expect(result.caseSensitive).toBe(false); - }); - - it('should handle distinct boolean in aggregation', () => { - const validation = { - type: 'async' as const, - name: 'async_test', - message: 'Validation failed', - field: 'test', - validatorUrl: '/api/validate', - debounce: 500, - timeout: 3000, - }; - - const result = AsyncValidationSchema.parse(validation); - expect(result.debounce).toBe(500); - expect(result.timeout).toBe(3000); - }); - - it('should handle various param types', () => { - const paramsTests = [ - { params: { key: 'value' } }, - { params: { nested: { key: 'value' } } }, - { params: { array: [1, 2, 3] } }, - { params: { boolean: true, number: 42, string: 'test' } }, - ]; - - paramsTests.forEach(({ params }) => { - const validation = { - type: 'custom' as const, - name: 'custom_test', - message: 'Test', - handler: 'validate', - params, - }; - - expect(() => CustomValidatorSchema.parse(validation)).not.toThrow(); - }); - }); - it('should handle nested conditional validations', () => { const validation = { type: 'conditional' as const, @@ -1410,25 +1005,6 @@ describe('ValidationRuleSchema - Type Coercion Edge Cases', () => { expect(() => CrossFieldValidationSchema.parse(validation)).not.toThrow(); }); - - it('should handle async validation with all optional fields', () => { - const validation = { - type: 'async' as const, - name: 'comprehensive_async', - message: 'Async validation failed', - field: 'email', - validatorUrl: '/api/validate/email', - validatorFunction: 'validateEmail', - timeout: 2000, - debounce: 300, - params: { - checkDomain: true, - allowDisposable: false, - }, - }; - - expect(() => AsyncValidationSchema.parse(validation)).not.toThrow(); - }); }); describe('ValidationRuleSchema - Boundary Conditions', () => { @@ -1456,17 +1032,6 @@ describe('ValidationRuleSchema - Boundary Conditions', () => { expect(() => ScriptValidationSchema.parse(validation)).not.toThrow(); }); - it('should handle large number of fields in uniqueness validation', () => { - const validation = { - type: 'unique' as const, - name: 'composite_unique', - message: 'Combination must be unique', - fields: ['field1', 'field2', 'field3', 'field4', 'field5', 'field6', 'field7', 'field8'], - }; - - expect(() => UniquenessValidationSchema.parse(validation)).not.toThrow(); - }); - it('should handle large number of state transitions', () => { const transitions: Record = {}; for (let i = 0; i < 20; i++) { @@ -1483,51 +1048,6 @@ describe('ValidationRuleSchema - Boundary Conditions', () => { expect(() => StateMachineValidationSchema.parse(validation)).not.toThrow(); }); - - it('should handle extreme timeout values', () => { - const testCases = [ - { timeout: 100 }, // Very short - { timeout: 5000 }, // Default - { timeout: 30000 }, // Long - { timeout: 60000 }, // Very long - ]; - - testCases.forEach(({ timeout }) => { - const validation = { - type: 'async' as const, - name: 'timeout_test', - message: 'Test', - field: 'test', - validatorUrl: '/api/validate', - timeout, - }; - - expect(() => AsyncValidationSchema.parse(validation)).not.toThrow(); - }); - }); - - it('should handle debounce edge cases', () => { - const testCases = [ - { debounce: 0 }, - { debounce: 100 }, - { debounce: 500 }, - { debounce: 1000 }, - { debounce: 5000 }, - ]; - - testCases.forEach(({ debounce }) => { - const validation = { - type: 'async' as const, - name: 'debounce_test', - message: 'Test', - field: 'test', - validatorUrl: '/api/validate', - debounce, - }; - - expect(() => AsyncValidationSchema.parse(validation)).not.toThrow(); - }); - }); }); // ============================================================================ diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index c5cc704f3..0e799ca7d 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -10,18 +10,36 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * type-safe validation system similar to Salesforce's validation rules but with enhanced capabilities. * * ## Overview - * + * * Validation rules are applied at the data layer to ensure data integrity and enforce business logic. - * The system supports multiple validation types: - * - * 1. **Script Validation**: Formula-based validation using expressions - * 2. **Uniqueness Validation**: Enforce unique constraints across fields - * 3. **State Machine Validation**: Control allowed state transitions - * 4. **Format Validation**: Validate field formats (email, URL, regex, etc.) - * 5. **Cross-Field Validation**: Validate relationships between multiple fields - * 6. **Async Validation**: Remote validation via API calls - * 7. **Custom Validation**: User-defined validation functions - * 8. **Conditional Validation**: Apply validations based on conditions + * A validation rule is a **deterministic, synchronous, side-effect-free predicate over a single + * record** — it must be decidable from the incoming write (and, on update, the prior record) with + * no I/O. Everything advertised here runs on the write path (see + * `objectql/src/validation/rule-validator.ts`); nothing is a silent no-op. + * + * The system supports these validation types: + * + * 1. **Script Validation**: Formula-based validation using a CEL predicate + * 2. **State Machine Validation**: Control allowed state transitions + * 3. **Format Validation**: Validate a field's value (email, URL, phone, JSON, regex) + * 4. **Cross-Field Validation**: Validate relationships between multiple fields + * 5. **JSON Schema Validation**: Validate a JSON field against a JSON Schema + * 6. **Conditional Validation**: Apply a nested rule based on a CEL condition + * + * ## Deliberately NOT validation rules + * + * These were once declared here but never enforced. Because the contract above rules them out + * (they need I/O or are client-side concerns), they were removed rather than left as silent + * no-ops. Use the layer that already does each one correctly: + * + * - **Uniqueness** → a unique **index** (`ObjectSchema.indexes`, `{ fields, unique: true }`, + * with `partial` for a scoped/conditional constraint), or field-level `unique: true`. A + * SELECT-then-INSERT "rule" is inherently racy (TOCTOU); a DB unique constraint is not. + * - **Async / remote validation** → a client-form concern (`debounce`/`validatorUrl` only mean + * anything against keystrokes) and an SSRF/latency hazard on the server write path. Keep it in + * the form layer, or enforce the underlying invariant with a `unique` index / lifecycle hook. + * - **Custom handler** → a `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported + * extension point for arbitrary validation code. * * ## Salesforce Comparison * @@ -88,18 +106,7 @@ export const ScriptValidationSchema = lazySchema(() => BaseValidationSchema.exte })); /** - * 2. Uniqueness Validation - * specialized optimized check for unique constraints. - */ -export const UniquenessValidationSchema = lazySchema(() => BaseValidationSchema.extend({ - type: z.literal('unique'), - fields: z.array(z.string()).describe('Fields that must be combined unique'), - scope: ExpressionInputSchema.optional().describe('Predicate (CEL) limiting uniqueness scope. e.g. P`record.active == true`'), - caseSensitive: z.boolean().default(true), -})); - -/** - * 3. State Machine Validation + * 2. State Machine Validation * State transition logic. */ export const StateMachineValidationSchema = lazySchema(() => BaseValidationSchema.extend({ @@ -109,7 +116,7 @@ export const StateMachineValidationSchema = lazySchema(() => BaseValidationSchem })); /** - * 4. Value Format Validation + * 3. Value Format Validation * Regex or specialized formats. */ export const FormatValidationSchema = lazySchema(() => BaseValidationSchema.extend({ @@ -120,7 +127,7 @@ export const FormatValidationSchema = lazySchema(() => BaseValidationSchema.exte })); /** - * 5. Cross-Field Validation + * 4. Cross-Field Validation * Validates relationships between multiple fields. * * ## Use Cases @@ -189,7 +196,7 @@ export const CrossFieldValidationSchema = lazySchema(() => BaseValidationSchema. })); /** - * 6. JSON Structure Validation + * 5. JSON Structure Validation * Validates JSON fields against a JSON Schema. * * ## Use Cases @@ -203,145 +210,10 @@ export const JSONValidationSchema = lazySchema(() => BaseValidationSchema.extend schema: z.record(z.string(), z.unknown()).describe('JSON Schema object definition'), })); -/** - * 7. Async Validation - * Remote validation via API call or database query. - * - * ## Use Cases - * - * ### 1. Email Uniqueness Check - * Check if an email address is already registered in the system. - * ```typescript - * { - * type: 'async', - * name: 'unique_email', - * field: 'email', - * validatorUrl: '/api/users/check-email', - * message: 'This email address is already registered', - * debounce: 500, // Wait 500ms after user stops typing - * timeout: 3000 - * } - * ``` - * - * ### 2. Username Availability - * Verify username is available before form submission. - * ```typescript - * { - * type: 'async', - * name: 'username_available', - * field: 'username', - * validatorUrl: '/api/users/check-username', - * message: 'This username is already taken', - * debounce: 300, - * timeout: 2000 - * } - * ``` - * - * ### 3. Tax ID Validation - * Validate tax ID with government API (e.g., IRS, HMRC). - * ```typescript - * { - * type: 'async', - * name: 'validate_tax_id', - * field: 'tax_id', - * validatorFunction: 'validateTaxIdWithIRS', - * message: 'Invalid Tax ID number', - * timeout: 10000, // Government APIs may be slow - * params: { country: 'US', format: 'EIN' } - * } - * ``` - * - * ### 4. Credit Card Validation - * Verify credit card with payment gateway without charging. - * ```typescript - * { - * type: 'async', - * name: 'validate_card', - * field: 'card_number', - * validatorUrl: 'https://api.stripe.com/v1/tokens/validate', - * message: 'Invalid credit card number', - * timeout: 5000, - * params: { - * mode: 'validate_only', - * checkFunds: false - * } - * } - * ``` - * - * ### 5. Address Validation - * Validate and standardize addresses using geocoding services. - * ```typescript - * { - * type: 'async', - * name: 'validate_address', - * field: 'street_address', - * validatorFunction: 'validateAddressWithGoogleMaps', - * message: 'Unable to verify address', - * timeout: 4000, - * params: { - * includeFields: ['city', 'state', 'zip'], - * strictMode: true, - * country: 'US' - * } - * } - * ``` - * - * ### 6. Domain Name Availability - * Check if domain name is available for registration. - * ```typescript - * { - * type: 'async', - * name: 'domain_available', - * field: 'domain_name', - * validatorUrl: '/api/domains/check-availability', - * message: 'This domain is already taken or reserved', - * debounce: 500, - * timeout: 2000 - * } - * ``` - * - * ### 7. Coupon Code Validation - * Verify coupon code is valid and not expired. - * ```typescript - * { - * type: 'async', - * name: 'validate_coupon', - * field: 'coupon_code', - * validatorUrl: '/api/coupons/validate', - * message: 'Invalid or expired coupon code', - * timeout: 2000, - * params: { - * checkExpiration: true, - * checkUsageLimit: true, - * userId: '{{current_user_id}}' - * } - * } - * ``` - */ -export const AsyncValidationSchema = lazySchema(() => BaseValidationSchema.extend({ - type: z.literal('async'), - field: z.string().describe('Field to validate'), - validatorUrl: z.string().optional().describe('External API endpoint for validation'), - method: z.enum(['GET', 'POST']).default('GET').describe('HTTP method for external call'), - headers: z.record(z.string(), z.string()).optional().describe('Custom headers for the request'), - validatorFunction: z.string().optional().describe('Reference to custom validator function'), - timeout: z.number().optional().default(5000).describe('Timeout in milliseconds'), - debounce: z.number().optional().describe('Debounce delay in milliseconds'), - params: z.record(z.string(), z.unknown()).optional().describe('Additional parameters to pass to validator'), -})); -/** - * 8. Custom Validator Function - * User-defined validation logic with code reference. - */ -export const CustomValidatorSchema = lazySchema(() => BaseValidationSchema.extend({ - type: z.literal('custom'), - handler: z.string().describe('Name of the custom validation function registered in the system'), - params: z.record(z.string(), z.unknown()).optional().describe('Parameters passed to the custom handler'), -})); /** - * 9. Master Validation Rule Schema + * 6. Master Validation Rule Schema */ /** Base type for validation rules - used for z.lazy() recursive type annotation */ export interface BaseValidationRuleShape { @@ -361,19 +233,16 @@ export interface BaseValidationRuleShape { export const ValidationRuleSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('type', [ ScriptValidationSchema, - UniquenessValidationSchema, StateMachineValidationSchema, FormatValidationSchema, CrossFieldValidationSchema, JSONValidationSchema, - AsyncValidationSchema, - CustomValidatorSchema, ConditionalValidationSchema, ]) ); /** - * 8. Conditional Validation + * 7. Conditional Validation * Validation that only applies when a condition is met. * * ## Overview @@ -557,11 +426,8 @@ export const ConditionalValidationSchema = lazySchema(() => BaseValidationSchema export type ValidationRule = z.infer; export type ScriptValidation = z.infer; -export type UniquenessValidation = z.infer; export type StateMachineValidation = z.infer; export type FormatValidation = z.infer; export type CrossFieldValidation = z.infer; export type JSONValidation = z.infer; -export type AsyncValidation = z.infer; -export type CustomValidation = z.infer; export type ConditionalValidation = z.infer; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 689b50f99..8d37b8ecf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@objectstack/cli': specifier: workspace:* version: link:../../packages/cli + '@objectstack/objectql': + specifier: workspace:* + version: link:../../packages/objectql typescript: specifier: ^6.0.3 version: 6.0.3 @@ -937,6 +940,9 @@ importers: '@objectstack/types': specifier: workspace:* version: link:../types + ajv: + specifier: ^8.20.0 + version: 8.20.0 zod: specifier: ^4.4.3 version: 4.4.3