@moatamed-sce/form-schema
v0.1.5
Published
Angular-free JSON contract for SCE form definitions.
Maintainers
Readme
@moatamed-sce/form-schema
The JSON contract for a form on the SCE Platform. A form here is data, not TypeScript: a versioned document that can be stored, diffed, returned by an API and — eventually — written by a designer, because nothing in it is a function.
This package holds the contract, the version-migration chain, and the parser that decides whether an untrusted document may be trusted. It does not build a FormGroup and it does not render anything; those are @moatamed-sce/form-engine and the sce-dynamic-form organism in @moatamed-sce/ui-components.
Nothing in it imports @angular/*. That is the point rather than an accident: the same document has to be validatable on a server, previewable in a designer, and renderable in the app.
Status — not published
This package is not currently on npmjs.org. It exists in the SCE monorepo and in the build output, but no version has been pushed to the public registry — and neither has @moatamed-sce/form-engine, which declares this package as a peer. Inside the monorepo both resolve through TypeScript path aliases (@moatamed-sce/form-schema and @libs/form-schema both point at libs/form-schema/src/index.ts).
What's in it
The contract — FormDefinition, and the node tree beneath it: FormField, FormGroupNode, FormArrayNode, FormPage, FormStep, plus FieldLayout, FieldPii, FormChrome and DataSourceRef. Narrowing helpers (isFormField, isFormGroupNode, isFormArrayNode) and flattenFormFields come with it.
Bilingual copy — I18nText is { ar, en, key? } rather than a bare string. A Saudi government form renders in two languages, and a schema carrying one label: string pushes that problem onto every consumer.
Named validators — NamedValidator is a name (saudiId, iban, saudiMobile05, min, pattern, …) that the engine resolves against libs/core/src/lib/validation/. A ValidatorFn is not serializable, so the moment one appears on a document the document stops being storable. SerializedPattern splits a regex into { source, flags } for the same reason.
Rules — a closed operator set (eq, neq, in, truthy, falsy, and, or) driving seven actions (show, hide, enable, disable, require, unrequire, readonly). Never a string expression: an expression language accepted from an API is an injection surface, and there is no eval or new Function anywhere in the platform.
Versioning — FORM_DEFINITION_VERSION, the FORM_DEFINITION_MIGRATIONS chain and migrateFormDefinition. A version field with no migrator is a version field nobody can ever bump.
The trust boundary — parseFormDefinition / isFormDefinition. See below.
Legacy conversion — fromLegacyConfig turns an existing DynamicFormConfig into a FormDefinition, reporting anything it could not represent instead of dropping it silently.
Reference fixtures — REFERENCE_DEFINITIONS, and the four documents in it, each mirroring a real screen.
The trust boundary
Everything downstream — buildFormGroup, the rule evaluator, the renderer — is written against FormDefinition and trusts it completely. That trust is earned exactly once, in parseFormDefinition, or it is not earned at all.
import { parseFormDefinition } from '@moatamed-sce/form-schema';
const { definition, problems, fromVersion, migrations } = parseFormDefinition(await response.text());
if (!definition) {
// Nothing throws. Every problem is located by a JSON path into the document:
// pages[0].nodes[2].validators[1].name: "sqlInject" is not a named validator …
return renderError(problems);
}It accepts a JSON string or an already-parsed value, migrates the document forward first, and checks:
| Check | What it stops |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Forbidden keys and path segments | __proto__ / constructor / prototype as an object key, or as a segment of any path the engine walks — a rule's field, a rule's target, a data source's dependsOn |
| Round-trip safety | Functions, Dates, RegExps, NaN, undefined inside an array — anything JSON.stringify would destroy or change |
| Closed vocabularies | Field types, validator names, rule actions, condition operators, PII categories, data-source types — each against a Record<Union, true> table the compiler forces to stay complete |
| Cross-references | A rule targeting a field that exists, a step naming a page that exists, a field naming a declared data source, a dependsOn naming a real control |
| lookupKey is a key, never a URL | A document that could name its own endpoint would make the app fetch from anywhere |
| Bounds | PATTERN_SOURCE_MAX_LENGTH (ReDoS), ARRAY_ITEMS_MAX (minItems is an allocation the document chooses), COL_SPAN_MAX, PAGE_COLUMNS_MAX, nesting depth |
| Undeclared members | validatorz: [...] is valid JSON that silently does nothing — the renderer reads validators, finds nothing, and shows an unvalidated field. Every object is checked against a Record<keyof Interface, true> table, so the compiler keeps the tables complete. props is exempt: it is the renderer's open bag |
| Spacing is a token | chrome.gap must be var(--…). --space-N is N pixels in this repository, so a raw "12px" reads as deliberate while meaning something else |
Nothing throws: a hostile or malformed document yields definition: null plus a list of problems, because the caller has an error state to render and an exception in a resolver just blanks the screen.
Peer dependencies
None. The package depends on tslib and nothing else — no Angular, no RxJS, no NgRx.
Quick start
Author a definition as data:
import { FORM_DEFINITION_VERSION, type FormDefinition } from '@moatamed-sce/form-schema';
const definition: FormDefinition = {
id: 'account-user-verify',
version: FORM_DEFINITION_VERSION,
title: { ar: 'تحقق', en: 'Verify' },
localeDefaults: { primary: 'ar', fallback: 'en' },
pages: [
{
id: 'main',
columns: 2,
nodes: [
{
kind: 'field',
id: 'fld-national-id',
key: 'nationalId',
type: 'text',
label: { ar: 'رقم الهوية', en: 'National ID', key: 'dashboard.accountUsers.dialog.nationalId' },
validators: [{ name: 'required' }, { name: 'saudiId' }],
pii: { category: 'personal' },
},
],
},
],
chrome: { showSubmit: true, gap: 'var(--space-12)' },
};Reveal a field with a rule rather than a flag, so one condition can drive several targets:
const rules = [
{
id: 'reveal-cr',
when: { op: 'and', clauses: [{ op: 'eq', field: 'entityType', value: 'company' }] },
then: [{ action: 'show', target: 'crNumber' }],
},
];
// and the field itself carries `hiddenByDefault: true` — otherwise a first render
// with no data shows every conditional branch at once.Convert an existing DynamicFormConfig instead of rewriting it:
import { fromLegacyConfig } from '@moatamed-sce/form-schema';
const { definition, issues } = fromLegacyConfig(legacyConfig, 'lookup-create');
// A non-empty `issues` must fail your test rather than be ignored: it means a custom
// ValidatorFn could not be identified, or a value fell outside what the schema allows.
expect(issues).toEqual([]);Conventions worth knowing
idandkeyare different.keyis theFormControlname and the API field name — the contract with the backend.idis the node's stable identity for a designer's move/undo history, which has to survive renaming the key.- Key uniqueness is per scope, not global:
applicant.nameand apartners[]row'snameare different controls. Pages share one namespace, becausebuildFormGroupmerges them into a single flat group. hiddenByDefaultinverts the legacyshowWhen. A conditional field starts absent and a rule reveals it.propsis the renderer's open bag of widget options (rows,calendarSystem,accept). Its contents are deliberately unchecked — but it must be an object, since it is read by key.
What this package deliberately does not do
- Build a
FormGroup, resolve a validator name, or evaluate a rule — that is@moatamed-sce/form-engine. - Render — that is
sce-dynamic-form. - Fetch anything. A
DataSourceRefnames alookupKey; the host resolves it. - Enforce PDPL policy.
FieldPiiis a place to record sensitivity so an inventory has something to read. Recording it is not a compliance claim. - Generate Angular, HTML or SCSS. The definition is the artefact; there is no codegen.
FormStep is declared and validated here. sce-dynamic-form exposes step / stepPlan; the form designer preview hosts sce-stepper when the definition declares steps.
Docs
- ADR 0002 — a form is a versioned JSON definition
- Form definition platform — full plan
- Consuming the packages in an external app
License
MIT
