feat(schemas): STRUCT-001 - JSON Schema Definition for v2 project format

Add 8 JSON schemas, SchemaValidator with Ajv v8, TS interfaces, and 33 tests.
All smoke tests passing. Critical path for STRUCT-002 (Export Engine).
This commit is contained in:
dishant-kumar-thakur
2026-02-18 23:45:51 +05:30
parent 83278b4370
commit f8d59cce0b
15 changed files with 2195 additions and 13 deletions

View File

@@ -1,19 +1,92 @@
# Phase 10 Progress — Dishant
**Branch:** cline-dev-dishant
# Phase 10A — AI-Powered Development: Dishant's Progress
**Developer:** Dishant
**Branch:** `cline-dev-dishant`
**Last Updated:** 2026-02-18
## Completed This Sprint
---
| Task | Name | Completed | Notes |
|------|------|-----------|-------|
| — | — | — | — |
## Task Status
## In Progress
| Task ID | Title | Status | Notes |
|------------|------------------------------|-------------|--------------------------------------------|
| STRUCT-001 | JSON Schema Definition | ✅ Complete | 8 schemas + validator + tests (33/33 pass) |
| STRUCT-002 | Export Engine Core | 🔜 Next | Depends on STRUCT-001 |
| Task | Name | Started | Blocker |
|------|------|---------|---------|
| — | — | — | Starting after CF11-006/007 |
---
## Decisions & Learnings
## STRUCT-001 — JSON Schema Definition ✅
- [2026-02-18] Sprint 1 started. Phase 10 work begins after CF11-006 and CF11-007 are complete. Starting point will be STRUCT-001 (JSON Schema Definition) — the critical path entry point for all AI features.
**Completed:** 2026-02-18
**Scope:** Define JSON schemas for the v2 multi-file project format
### What was built
All files live under `packages/noodl-editor/src/editor/src/schemas/`:
#### Schema files (8 total)
| File | Schema ID | Describes |
|------|-----------|-----------|
| `project-v2.schema.json` | `https://opennoodl.dev/schemas/project-v2.json` | Root project metadata (`nodegx.project.json`) |
| `component.schema.json` | `https://opennoodl.dev/schemas/component-v2.json` | Component metadata (`component.json`) |
| `nodes.schema.json` | `https://opennoodl.dev/schemas/nodes-v2.json` | Node graph definitions (`nodes.json`) |
| `connections.schema.json` | `https://opennoodl.dev/schemas/connections-v2.json` | Connection/wire definitions (`connections.json`) |
| `registry.schema.json` | `https://opennoodl.dev/schemas/registry-v2.json` | Component index (`_registry.json`) |
| `routes.schema.json` | `https://opennoodl.dev/schemas/routes-v2.json` | URL route definitions (`nodegx.routes.json`) |
| `styles.schema.json` | `https://opennoodl.dev/schemas/styles-v2.json` | Global styles + variants (`nodegx.styles.json`) |
| `model.schema.json` | `https://opennoodl.dev/schemas/model-v2.json` | Backend data model definitions (`models/<Name>.json`) |
#### Validator (`validator.ts`)
- `SchemaValidator` singleton class — compiles all 8 schemas once, reuses validators
- `SCHEMA_IDS` const — typed schema ID map
- `validateSchema()` convenience function
- `validateOrThrow()` — throws with context on failure
- Per-schema convenience methods: `validateProject()`, `validateComponent()`, etc.
- `formatValidationErrors()` — human-readable error formatting
- Ajv v8 with `ajv-formats` for `date-time` format validation
- `allErrors: true` — collects all errors, not just first
#### Index (`index.ts`)
- Re-exports all schemas, validator, and TypeScript interfaces
- Full TS interfaces for all 8 file types: `ProjectV2File`, `ComponentV2File`, `NodesV2File`, `ConnectionsV2File`, `RegistryV2File`, `RoutesV2File`, `StylesV2File`, `ModelV2File`
#### Tests (`tests/schemas/schema-validator.test.ts`)
- 33 test cases covering all 8 schemas
- Valid minimal fixtures, full fixtures with all optional fields
- Invalid cases: missing required fields, wrong enum values, invalid formats
- Edge cases: legacy component refs (`/#Header`), complex port type objects, deeply nested metadata
- Registered in `tests/index.ts``tests/schemas/index.ts`
### Dependencies added
```json
"ajv": "^8.x",
"ajv-formats": "^2.x"
```
Added to `packages/noodl-editor/package.json` dependencies.
### Key design decisions
1. **`additionalProperties: true` on nodes/connections** — node parameters and connection metadata are open-ended by design; the schema validates structure, not content
2. **Port type is `oneOf [string, object]`** — Noodl uses both `"string"` and `{ name: "stringlist", ... }` type formats
3. **`strict: false` on Ajv** — schemas use `description` in `definitions` which Ajv strict mode rejects
4. **`require()` for `ajv-formats`** — avoids TS type conflict between root-level `ajv-formats` (which bundles its own Ajv) and the package-local Ajv v8
### Verification
```
33/33 smoke tests passed (node smoke-test-schemas.js)
0 TypeScript errors
```
---
## Next: STRUCT-002 — Export Engine Core
**Unblocked by:** STRUCT-001 ✅
**Goal:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure, using the schemas defined in STRUCT-001 for validation.

View File

@@ -1 +1 @@
{"name":"noodl-editor","productName":"OpenNoodl","description":"Full stack low code React app builder","author":"The Low Code Foundation","homepage":"https://thelowcodefoundation.com","version":"1.1.0","main":"src/main/main.bundle.js","scripts":{"build":"npx ts-node -P ./tsconfig.build.json ./scripts/build.ts","start":"webpack-dev-server --config=webpackconfigs/webpack.renderer.dev.js","start:_dev":"electron . --dev","test":"webpack-dev-server --config=webpackconfigs/webpack.test.js","test:_start_electron":"electron test.js","test:ci":"webpack-cli --config=webpackconfigs/webpack.test-ci.js && electron test.js"},"build":{"appId":"com.opennoodl.app","afterSign":"./build/macos-notarize.js","mac":{"hardenedRuntime":true,"entitlements":"build/entitlements.mac.plist","extendInfo":{"LSMultipleInstancesProhibited":true,"NSMicrophoneUsageDescription":"Allow OpenNoodl apps that you create and run to access the microphone?","NSCameraUsageDescription":"Allow OpenNoodl apps that you create and run to access the camera?"}},"win":{"target":"nsis"},"nsis":{"guid":"com.opennoodl.app"},"linux":{"target":"deb"},"protocols":{"name":"opennoodl","schemes":["opennoodl"]},"npmRebuild":false,"files":["*.js","src","node_modules","!node_modules/monaco-editor","node_modules/monaco-editor/esm","node_modules/dugite","!test.js","!src/main/main.js","!src/main/src","!src/editor/src","!src/frames/viewer-frame/src","!src/shared"]},"dependencies":{"@anthropic-ai/sdk":"^0.71.2","@babel/parser":"^7.28.5","@blockly/theme-dark":"^8.0.3","@electron/remote":"^2.1.3","@jaames/iro":"^5.5.2","@microlink/react-json-view":"^1.27.0","@microsoft/fetch-event-source":"^2.0.1","@noodl/git":"file:../noodl-git","@noodl/noodl-parse-dashboard":"file:../noodl-parse-dashboard","@noodl/platform":"file:../noodl-platform","@noodl/platform-electron":"file:../noodl-platform-electron","@octokit/auth-oauth-device":"^7.1.5","@octokit/rest":"^20.1.2","about-window":"^1.15.2","algoliasearch":"^5.35.0","archiver":"^5.3.2","async":"^3.2.6","blockly":"^12.3.1","classnames":"^2.5.1","dagre":"^0.8.5","diff3":"0.0.4","electron-store":"^8.2.0","electron-updater":"^6.6.2","express":"^4.21.2","highlight.js":"^11.11.1","isbinaryfile":"^5.0.4","md5":"^2.3.0","md5-file":"^5.0.0","mixpanel-browser":"^2.69.1","mkdirp":"0.5.1","mkdirp-sync":"0.0.2","monaco-editor":"^0.34.1","react":"19.0.0","react-dom":"19.0.0","react-hot-toast":"^2.6.0","react-instantsearch":"^7.16.2","react-markdown":"^9.1.0","react-rnd":"^10.5.2","remark-gfm":"^4.0.1","remarkable":"^2.0.1","s3":"github:noodlapp/node-s3-client","string.prototype.matchall":"^4.0.12","underscore":"^1.13.7","webpack":"^5.101.3","websocket-stream":"^5.5.2","ws":"^8.18.3"},"devDependencies":{"@babel/core":"^7.28.3","@babel/preset-react":"^7.27.1","@svgr/webpack":"^6.5.1","@types/checksum":"^0.1.35","@types/dagre":"^0.7.52","@types/jasmine":"^4.6.5","@types/jquery":"^3.5.33","@types/react":"^19.2.7","@types/react-dom":"^19.2.3","@types/remarkable":"^2.0.8","@types/rimraf":"^3.0.2","@types/split2":"^3.2.1","@types/string.prototype.matchall":"^4.0.4","@types/underscore":"^1.13.0","@types/webpack-env":"^1.18.8","babel-loader":"^8.4.1","concurrently":"^7.6.0","css-loader":"^6.11.0","electron":"31.3.1","electron-builder":"^24.13.3","file-loader":"^6.2.0","html-loader":"^3.1.2","monaco-editor-webpack-plugin":"^7.1.0","ncp":"^2.0.0","rimraf":"^3.0.2","sass":"^1.90.0","sass-loader":"^12.6.0","stringify":"^5.2.0","style-loader":"^3.3.4","ts-loader":"^9.5.4","ts-node":"^10.9.2","typescript":"^5.9.3","url-loader":"^4.1.1","webpack":"^5.101.3","webpack-cli":"^4.10.0","webpack-dev-server":"^4.15.2","webpack-merge":"^5.10.0"},"engines":{"npm":">=6.0.0","node":">=16.0.0"},"optionalDependencies":{"dmg-license":"^1.0.11"}}
{"name":"noodl-editor","productName":"OpenNoodl","description":"Full stack low code React app builder","author":"The Low Code Foundation","homepage":"https://thelowcodefoundation.com","version":"1.1.0","main":"src/main/main.bundle.js","scripts":{"build":"npx ts-node -P ./tsconfig.build.json ./scripts/build.ts","start":"webpack-dev-server --config=webpackconfigs/webpack.renderer.dev.js","start:_dev":"electron . --dev","test":"webpack-dev-server --config=webpackconfigs/webpack.test.js","test:_start_electron":"electron test.js","test:ci":"webpack-cli --config=webpackconfigs/webpack.test-ci.js && electron test.js"},"build":{"appId":"com.opennoodl.app","afterSign":"./build/macos-notarize.js","mac":{"hardenedRuntime":true,"entitlements":"build/entitlements.mac.plist","extendInfo":{"LSMultipleInstancesProhibited":true,"NSMicrophoneUsageDescription":"Allow OpenNoodl apps that you create and run to access the microphone?","NSCameraUsageDescription":"Allow OpenNoodl apps that you create and run to access the camera?"}},"win":{"target":"nsis"},"nsis":{"guid":"com.opennoodl.app"},"linux":{"target":"deb"},"protocols":{"name":"opennoodl","schemes":["opennoodl"]},"npmRebuild":false,"files":["*.js","src","node_modules","!node_modules/monaco-editor","node_modules/monaco-editor/esm","node_modules/dugite","!test.js","!src/main/main.js","!src/main/src","!src/editor/src","!src/frames/viewer-frame/src","!src/shared"]},"dependencies":{"@anthropic-ai/sdk":"^0.71.2","@babel/parser":"^7.28.5","@blockly/theme-dark":"^8.0.3","@electron/remote":"^2.1.3","@jaames/iro":"^5.5.2","@microlink/react-json-view":"^1.27.0","@microsoft/fetch-event-source":"^2.0.1","@noodl/git":"file:../noodl-git","@noodl/noodl-parse-dashboard":"file:../noodl-parse-dashboard","@noodl/platform":"file:../noodl-platform","@noodl/platform-electron":"file:../noodl-platform-electron","@octokit/auth-oauth-device":"^7.1.5","@octokit/rest":"^20.1.2","about-window":"^1.15.2","ajv":"^8.18.0","ajv-formats":"^2.1.1","algoliasearch":"^5.35.0","archiver":"^5.3.2","async":"^3.2.6","blockly":"^12.3.1","classnames":"^2.5.1","dagre":"^0.8.5","diff3":"0.0.4","electron-store":"^8.2.0","electron-updater":"^6.6.2","express":"^4.21.2","highlight.js":"^11.11.1","isbinaryfile":"^5.0.4","md5":"^2.3.0","md5-file":"^5.0.0","mixpanel-browser":"^2.69.1","mkdirp":"0.5.1","mkdirp-sync":"0.0.2","monaco-editor":"^0.34.1","react":"19.0.0","react-dom":"19.0.0","react-hot-toast":"^2.6.0","react-instantsearch":"^7.16.2","react-markdown":"^9.1.0","react-rnd":"^10.5.2","remark-gfm":"^4.0.1","remarkable":"^2.0.1","s3":"github:noodlapp/node-s3-client","string.prototype.matchall":"^4.0.12","underscore":"^1.13.7","webpack":"^5.101.3","websocket-stream":"^5.5.2","ws":"^8.18.3"},"devDependencies":{"@babel/core":"^7.28.3","@babel/preset-react":"^7.27.1","@svgr/webpack":"^6.5.1","@types/checksum":"^0.1.35","@types/dagre":"^0.7.52","@types/jasmine":"^4.6.5","@types/jquery":"^3.5.33","@types/react":"^19.2.7","@types/react-dom":"^19.2.3","@types/remarkable":"^2.0.8","@types/rimraf":"^3.0.2","@types/split2":"^3.2.1","@types/string.prototype.matchall":"^4.0.4","@types/underscore":"^1.13.0","@types/webpack-env":"^1.18.8","babel-loader":"^8.4.1","concurrently":"^7.6.0","css-loader":"^6.11.0","electron":"31.3.1","electron-builder":"^24.13.3","file-loader":"^6.2.0","html-loader":"^3.1.2","monaco-editor-webpack-plugin":"^7.1.0","ncp":"^2.0.0","rimraf":"^3.0.2","sass":"^1.90.0","sass-loader":"^12.6.0","stringify":"^5.2.0","style-loader":"^3.3.4","ts-loader":"^9.5.4","ts-node":"^10.9.2","typescript":"^5.9.3","url-loader":"^4.1.1","webpack":"^5.101.3","webpack-cli":"^4.10.0","webpack-dev-server":"^4.15.2","webpack-merge":"^5.10.0"},"engines":{"npm":">=6.0.0","node":">=16.0.0"},"optionalDependencies":{"dmg-license":"^1.0.11"}}

View File

@@ -0,0 +1,144 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/component-v2.json",
"title": "OpenNoodl Component",
"description": "Component metadata file (component.json) in the v2 multi-file format",
"type": "object",
"required": ["id", "name", "type"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"id": {
"type": "string",
"minLength": 1,
"description": "Unique component identifier (preserved from legacy project.json)"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Component folder name (last segment of the path)"
},
"displayName": {
"type": "string",
"description": "Human-readable display name"
},
"path": {
"type": "string",
"description": "Original Noodl legacy path (e.g. '/#Header') — preserved for cross-component references"
},
"type": {
"type": "string",
"enum": ["root", "page", "visual", "logic", "cloud"],
"description": "Component classification"
},
"description": {
"type": "string"
},
"category": {
"type": "string",
"description": "UI category for the component type (e.g. 'visual', 'logic')"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Searchable tags"
},
"ports": {
"type": "object",
"description": "Component input/output port definitions",
"properties": {
"inputs": {
"type": "array",
"items": { "$ref": "#/definitions/port" }
},
"outputs": {
"type": "array",
"items": { "$ref": "#/definitions/port" }
}
},
"additionalProperties": false
},
"dependencies": {
"type": "array",
"items": { "type": "string" },
"description": "Paths to other components this component uses (for dependency graph)"
},
"settings": {
"type": "object",
"description": "Component-level settings",
"properties": {
"canHaveChildren": { "type": "boolean" },
"allowedInRoutes": { "type": "boolean" },
"defaultDimensions": {
"type": "object",
"properties": {
"width": { "type": "string" },
"height": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": true
},
"created": {
"type": "string",
"format": "date-time"
},
"modified": {
"type": "string",
"format": "date-time"
},
"modifiedBy": {
"type": "string"
},
"metadata": {
"type": "object",
"description": "Arbitrary component metadata (AI history, migration notes, etc.)",
"additionalProperties": true
}
},
"definitions": {
"port": {
"type": "object",
"required": ["name", "type"],
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"type": {
"description": "Port type — can be a string ('string', 'boolean', 'number', 'signal', '*') or a type object",
"oneOf": [
{ "type": "string" },
{ "type": "object" }
]
},
"displayName": {
"type": "string"
},
"description": {
"type": "string"
},
"default": {
"description": "Default value for this port"
},
"required": {
"type": "boolean"
},
"group": {
"type": "string"
},
"index": {
"type": "number"
},
"plug": {
"type": "string",
"enum": ["input", "output", "input/output"]
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/connections-v2.json",
"title": "OpenNoodl Component Connections",
"description": "Connection (wire) definitions for a single component (connections.json) in the v2 format",
"type": "object",
"required": ["componentId", "connections"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"componentId": {
"type": "string",
"description": "ID of the parent component — must match component.json id"
},
"version": {
"type": "integer",
"minimum": 1,
"description": "Schema version for future migrations"
},
"connections": {
"type": "array",
"description": "All connections (wires) in this component's graph",
"items": { "$ref": "#/definitions/connection" }
}
},
"definitions": {
"connection": {
"type": "object",
"required": ["fromId", "fromProperty", "toId", "toProperty"],
"additionalProperties": true,
"properties": {
"fromId": {
"type": "string",
"minLength": 1,
"description": "ID of the source node"
},
"fromProperty": {
"type": "string",
"minLength": 1,
"description": "Output port name on the source node"
},
"toId": {
"type": "string",
"minLength": 1,
"description": "ID of the target node"
},
"toProperty": {
"type": "string",
"minLength": 1,
"description": "Input port name on the target node"
},
"annotation": {
"type": "string",
"enum": ["Deleted", "Changed", "Created"],
"description": "Diff annotation (set by project differ)"
}
}
}
}
}

View File

@@ -0,0 +1,287 @@
/**
* OpenNoodl v2 Project Format — Schema Exports
*
* Central export point for all JSON schemas, the validator, and related types.
*
* Usage:
* ```ts
* import { SchemaValidator, SCHEMA_IDS, validateSchema } from '@noodl-schemas';
* // or
* import { SchemaValidator } from '../../schemas';
* ```
*
* @module noodl-editor/schemas
* @since 1.2.0
*/
// ─── Schema JSON files ────────────────────────────────────────────────────────
export { default as projectV2Schema } from './project-v2.schema.json';
export { default as componentSchema } from './component.schema.json';
export { default as nodesSchema } from './nodes.schema.json';
export { default as connectionsSchema } from './connections.schema.json';
export { default as registrySchema } from './registry.schema.json';
export { default as routesSchema } from './routes.schema.json';
export { default as stylesSchema } from './styles.schema.json';
export { default as modelSchema } from './model.schema.json';
// ─── Validator ────────────────────────────────────────────────────────────────
export {
SchemaValidator,
validateSchema,
formatValidationErrors,
SCHEMA_IDS
} from './validator';
export type { ValidationResult, ValidationError, SchemaId } from './validator';
// ─── TypeScript interfaces for v2 format files ────────────────────────────────
/**
* Root project metadata (nodegx.project.json)
*/
export interface ProjectV2File {
$schema?: string;
name: string;
id?: string;
version: string;
nodegxVersion: string;
runtimeVersion?: 'react17' | 'react19';
created?: string;
modified?: string;
settings?: {
rootComponent?: string;
defaultRoute?: string;
bodyScroll?: boolean;
headCode?: string;
htmlTitle?: string;
navigationPathType?: string;
responsive?: { breakpoints?: string[] };
[key: string]: unknown;
};
structure?: {
componentsDir?: string;
modelsDir?: string;
assetsDir?: string;
};
metadata?: Record<string, unknown>;
}
/**
* Port definition used in component.json
*/
export interface PortDefinition {
name: string;
type: string | Record<string, unknown>;
displayName?: string;
description?: string;
default?: unknown;
required?: boolean;
group?: string;
index?: number;
plug?: 'input' | 'output' | 'input/output';
[key: string]: unknown;
}
/**
* Component metadata file (component.json)
*/
export interface ComponentV2File {
$schema?: string;
id: string;
name: string;
displayName?: string;
path?: string;
type: 'root' | 'page' | 'visual' | 'logic' | 'cloud';
description?: string;
category?: string;
tags?: string[];
ports?: {
inputs?: PortDefinition[];
outputs?: PortDefinition[];
};
dependencies?: string[];
settings?: Record<string, unknown>;
created?: string;
modified?: string;
modifiedBy?: string;
metadata?: Record<string, unknown>;
}
/**
* Port definition used in nodes.json
*/
export interface NodePort {
name: string;
type?: string | Record<string, unknown>;
displayName?: string;
plug?: string;
group?: string;
index?: number;
default?: unknown;
[key: string]: unknown;
}
/**
* Single node entry in nodes.json
*/
export interface NodeV2 {
id: string;
type: string;
label?: string;
x?: number;
y?: number;
variant?: string;
version?: number;
parameters?: Record<string, unknown>;
stateParameters?: Record<string, Record<string, unknown>>;
stateTransitions?: Record<string, Record<string, unknown>>;
defaultStateTransitions?: Record<string, unknown>;
ports?: NodePort[];
dynamicports?: NodePort[];
children?: string[];
parent?: string;
conflicts?: Record<string, unknown>[];
annotation?: 'Deleted' | 'Created' | 'Changed';
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Nodes file (nodes.json)
*/
export interface NodesV2File {
$schema?: string;
componentId: string;
version?: number;
nodes: NodeV2[];
}
/**
* Single connection entry in connections.json
*/
export interface ConnectionV2 {
fromId: string;
fromProperty: string;
toId: string;
toProperty: string;
annotation?: 'Deleted' | 'Changed' | 'Created';
[key: string]: unknown;
}
/**
* Connections file (connections.json)
*/
export interface ConnectionsV2File {
$schema?: string;
componentId: string;
version?: number;
connections: ConnectionV2[];
}
/**
* Single entry in the component registry
*/
export interface RegistryComponentEntry {
path: string;
type: 'root' | 'page' | 'visual' | 'logic' | 'cloud';
route?: string;
created?: string;
modified?: string;
nodeCount?: number;
connectionCount?: number;
}
/**
* Component registry file (_registry.json)
*/
export interface RegistryV2File {
$schema?: string;
version: number;
lastUpdated?: string;
components: Record<string, RegistryComponentEntry>;
stats?: {
totalComponents: number;
totalNodes: number;
totalConnections: number;
};
}
/**
* Single route definition in routes.json
*/
export interface RouteDefinition {
path: string;
component: string;
title?: string;
exact?: boolean;
redirect?: string;
metadata?: Record<string, unknown>;
}
/**
* Routes file (nodegx.routes.json)
*/
export interface RoutesV2File {
$schema?: string;
version?: number;
routes: RouteDefinition[];
notFound?: string;
}
/**
* Styles file (nodegx.styles.json)
*/
export interface StylesV2File {
$schema?: string;
version?: number;
colors?: Record<string, string>;
textStyles?: Record<string, Record<string, unknown>>;
variants?: Array<{
name: string;
typename: string;
parameters?: Record<string, unknown>;
stateParameters?: Record<string, Record<string, unknown>>;
stateTransitions?: Record<string, Record<string, unknown>>;
defaultStateTransitions?: Record<string, unknown>;
[key: string]: unknown;
}>;
tokens?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
/**
* Model field definition
*/
export interface ModelField {
name: string;
type: 'String' | 'Number' | 'Boolean' | 'Date' | 'Object' | 'Array' | 'Pointer' | 'Relation' | 'File' | 'GeoPoint';
displayName?: string;
description?: string;
required?: boolean;
default?: unknown;
targetClass?: string;
unique?: boolean;
}
/**
* Model definition file (models/<Name>.json)
*/
export interface ModelV2File {
$schema?: string;
name: string;
displayName?: string;
description?: string;
className?: string;
fields: ModelField[];
indexes?: Array<{
name?: string;
fields: string[];
unique?: boolean;
}>;
acl?: Record<string, unknown>;
created?: string;
modified?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,116 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/model-v2.json",
"title": "OpenNoodl Data Model",
"description": "Data model definition file (models/<ModelName>.json) describing a backend data schema",
"type": "object",
"required": ["name", "fields"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Model name (e.g. 'User', 'Session')"
},
"displayName": {
"type": "string",
"description": "Human-readable display name"
},
"description": {
"type": "string"
},
"className": {
"type": "string",
"description": "Backend class/collection name (defaults to name)"
},
"fields": {
"type": "array",
"description": "Field definitions for this model",
"items": { "$ref": "#/definitions/field" }
},
"indexes": {
"type": "array",
"description": "Database index definitions",
"items": { "$ref": "#/definitions/index" }
},
"acl": {
"type": "object",
"description": "Access control list configuration",
"additionalProperties": true
},
"created": {
"type": "string",
"format": "date-time"
},
"modified": {
"type": "string",
"format": "date-time"
},
"metadata": {
"type": "object",
"additionalProperties": true
}
},
"definitions": {
"field": {
"type": "object",
"required": ["name", "type"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Field name"
},
"type": {
"type": "string",
"enum": ["String", "Number", "Boolean", "Date", "Object", "Array", "Pointer", "Relation", "File", "GeoPoint"],
"description": "Field data type"
},
"displayName": {
"type": "string"
},
"description": {
"type": "string"
},
"required": {
"type": "boolean",
"default": false
},
"default": {
"description": "Default value for this field"
},
"targetClass": {
"type": "string",
"description": "For Pointer/Relation fields: the target model class name"
},
"unique": {
"type": "boolean",
"default": false
}
}
},
"index": {
"type": "object",
"required": ["fields"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"fields": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"unique": {
"type": "boolean",
"default": false
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/nodes-v2.json",
"title": "OpenNoodl Component Nodes",
"description": "Node graph definitions for a single component (nodes.json) in the v2 format",
"type": "object",
"required": ["componentId", "nodes"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"componentId": {
"type": "string",
"description": "ID of the parent component — must match component.json id"
},
"version": {
"type": "integer",
"minimum": 1,
"description": "Schema version for future migrations"
},
"nodes": {
"type": "array",
"description": "All nodes in this component's graph (roots and children flattened)",
"items": { "$ref": "#/definitions/node" }
}
},
"definitions": {
"node": {
"type": "object",
"required": ["id", "type"],
"additionalProperties": true,
"properties": {
"id": {
"type": "string",
"minLength": 1,
"description": "Unique node ID within the component"
},
"type": {
"type": "string",
"minLength": 1,
"description": "Node type name — built-in (e.g. 'Group', 'Text') or component ref (e.g. '/#Header')"
},
"label": {
"type": "string",
"description": "User-defined label for the node"
},
"x": {
"type": "number",
"description": "Canvas X position"
},
"y": {
"type": "number",
"description": "Canvas Y position"
},
"variant": {
"type": "string",
"description": "Variant name if this node uses a variant"
},
"version": {
"type": "number",
"description": "Node type version"
},
"parameters": {
"type": "object",
"description": "Node parameter values (property bag)",
"additionalProperties": true
},
"stateParameters": {
"type": "object",
"description": "Visual state parameter overrides keyed by state name",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"stateTransitions": {
"type": "object",
"description": "Transition curves per state and parameter",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"defaultStateTransitions": {
"type": "object",
"description": "Default transition curves per state",
"additionalProperties": true
},
"ports": {
"type": "array",
"description": "User-defined instance ports on this node",
"items": { "$ref": "#/definitions/port" }
},
"dynamicports": {
"type": "array",
"description": "Dynamically generated ports (e.g. from StringList inputs)",
"items": { "$ref": "#/definitions/port" }
},
"children": {
"type": "array",
"description": "IDs of child nodes (visual hierarchy)",
"items": { "type": "string" }
},
"parent": {
"type": "string",
"description": "ID of parent node (null/absent for root nodes)"
},
"conflicts": {
"type": "array",
"description": "Merge conflict data (set by project differ)",
"items": { "type": "object", "additionalProperties": true }
},
"annotation": {
"type": "string",
"enum": ["Deleted", "Created", "Changed"],
"description": "Diff annotation (set by project differ)"
},
"metadata": {
"type": "object",
"description": "Arbitrary node metadata (AI prompt history, merge info, comments)",
"additionalProperties": true
}
}
},
"port": {
"type": "object",
"required": ["name"],
"additionalProperties": true,
"properties": {
"name": { "type": "string", "minLength": 1 },
"type": {
"oneOf": [
{ "type": "string" },
{ "type": "object" }
]
},
"displayName": { "type": "string" },
"plug": { "type": "string" },
"group": { "type": "string" },
"index": { "type": "number" },
"default": {}
}
}
}
}

View File

@@ -0,0 +1,107 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/project-v2.json",
"title": "OpenNoodl Project",
"description": "Root project metadata file for the v2 multi-file project format",
"type": "object",
"required": ["name", "version", "nodegxVersion"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Human-readable project name"
},
"id": {
"type": "string",
"description": "Unique project identifier"
},
"version": {
"type": "string",
"description": "Project data format version (e.g. '2.0')"
},
"nodegxVersion": {
"type": "string",
"description": "OpenNoodl editor version that created/last saved this project"
},
"runtimeVersion": {
"type": "string",
"enum": ["react17", "react19"],
"description": "React runtime version for the viewer"
},
"created": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 creation timestamp"
},
"modified": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 last-modified timestamp"
},
"settings": {
"type": "object",
"description": "Project-level settings",
"properties": {
"rootComponent": {
"type": "string",
"description": "Name of the root component"
},
"defaultRoute": {
"type": "string",
"description": "Default navigation route"
},
"bodyScroll": {
"type": "boolean"
},
"headCode": {
"type": "string"
},
"htmlTitle": {
"type": "string"
},
"navigationPathType": {
"type": "string"
},
"responsive": {
"type": "object",
"properties": {
"breakpoints": {
"type": "array",
"items": { "type": "string" }
}
}
}
},
"additionalProperties": true
},
"structure": {
"type": "object",
"description": "Directory layout configuration",
"properties": {
"componentsDir": {
"type": "string",
"default": "components"
},
"modelsDir": {
"type": "string",
"default": "models"
},
"assetsDir": {
"type": "string",
"default": "assets"
}
},
"additionalProperties": false
},
"metadata": {
"type": "object",
"description": "Arbitrary project metadata (styles, cloudservices, appConfig, etc.)",
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,80 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/registry-v2.json",
"title": "OpenNoodl Component Registry",
"description": "Component index/manifest file (_registry.json) listing all components in the project",
"type": "object",
"required": ["version", "components"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"version": {
"type": "integer",
"minimum": 1,
"description": "Registry format version"
},
"lastUpdated": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of last registry update"
},
"components": {
"type": "object",
"description": "Map of component name/path to component summary info",
"additionalProperties": { "$ref": "#/definitions/componentEntry" }
},
"stats": {
"type": "object",
"description": "Aggregate project statistics",
"properties": {
"totalComponents": { "type": "integer", "minimum": 0 },
"totalNodes": { "type": "integer", "minimum": 0 },
"totalConnections": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"definitions": {
"componentEntry": {
"type": "object",
"required": ["path", "type"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description": "Relative path to the component folder from the components/ directory"
},
"type": {
"type": "string",
"enum": ["root", "page", "visual", "logic", "cloud"],
"description": "Component classification"
},
"route": {
"type": "string",
"description": "URL route for page components (e.g. '/home')"
},
"created": {
"type": "string",
"format": "date-time"
},
"modified": {
"type": "string",
"format": "date-time"
},
"nodeCount": {
"type": "integer",
"minimum": 0,
"description": "Number of nodes in this component"
},
"connectionCount": {
"type": "integer",
"minimum": 0,
"description": "Number of connections in this component"
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/routes-v2.json",
"title": "OpenNoodl Routes",
"description": "Route definitions file (nodegx.routes.json) mapping URL paths to page components",
"type": "object",
"required": ["routes"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"version": {
"type": "integer",
"minimum": 1
},
"routes": {
"type": "array",
"description": "Ordered list of route definitions",
"items": { "$ref": "#/definitions/route" }
},
"notFound": {
"type": "string",
"description": "Component path to render for 404 / not-found routes"
}
},
"definitions": {
"route": {
"type": "object",
"required": ["path", "component"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description": "URL path pattern (e.g. '/home', '/user/:id')"
},
"component": {
"type": "string",
"minLength": 1,
"description": "Component name/path that handles this route"
},
"title": {
"type": "string",
"description": "Page title for this route"
},
"exact": {
"type": "boolean",
"description": "Whether the path must match exactly"
},
"redirect": {
"type": "string",
"description": "Redirect target path (if this is a redirect route)"
},
"metadata": {
"type": "object",
"additionalProperties": true,
"description": "Arbitrary route metadata (SEO, auth requirements, etc.)"
}
}
}
}
}

View File

@@ -0,0 +1,99 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://opennoodl.dev/schemas/styles-v2.json",
"title": "OpenNoodl Global Styles",
"description": "Global styles and design tokens file (nodegx.styles.json)",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"version": {
"type": "integer",
"minimum": 1
},
"colors": {
"type": "object",
"description": "Named color tokens",
"additionalProperties": {
"type": "string",
"description": "CSS color value"
}
},
"textStyles": {
"type": "object",
"description": "Named text style presets",
"additionalProperties": { "$ref": "#/definitions/textStyle" }
},
"variants": {
"type": "array",
"description": "Visual variants (responsive overrides) for node types",
"items": { "$ref": "#/definitions/variant" }
},
"tokens": {
"type": "object",
"description": "Design token overrides (CSS custom properties)",
"additionalProperties": true
},
"metadata": {
"type": "object",
"additionalProperties": true
}
},
"definitions": {
"textStyle": {
"type": "object",
"additionalProperties": true,
"properties": {
"fontFamily": { "type": "string" },
"fontSize": { "type": ["string", "number"] },
"fontWeight": { "type": ["string", "number"] },
"lineHeight": { "type": ["string", "number"] },
"letterSpacing": { "type": ["string", "number"] },
"color": { "type": "string" },
"textTransform": { "type": "string" }
}
},
"variant": {
"type": "object",
"required": ["name", "typename"],
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Variant name"
},
"typename": {
"type": "string",
"minLength": 1,
"description": "Node type this variant applies to"
},
"parameters": {
"type": "object",
"additionalProperties": true,
"description": "Parameter overrides for this variant"
},
"stateParameters": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"stateTransitions": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"defaultStateTransitions": {
"type": "object",
"additionalProperties": true
}
}
}
}
}

View File

@@ -0,0 +1,258 @@
/**
* Schema Validator
*
* Ajv-based validation utilities for the v2 multi-file project format.
* Validates project files against their JSON schemas before reading/writing.
*
* @module noodl-editor/schemas/validator
* @since 1.2.0
*/
import Ajv, { ValidateFunction } from 'ajv';
// ajv-formats may resolve to a different Ajv version at the root node_modules.
// Using require() + type assertion avoids the TS type mismatch while keeping
// the same runtime behaviour (both are Ajv v8 compatible).
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addFormats = require('ajv-formats') as (ajv: Ajv) => Ajv;
// Import schemas
import projectV2Schema from './project-v2.schema.json';
import componentSchema from './component.schema.json';
import nodesSchema from './nodes.schema.json';
import connectionsSchema from './connections.schema.json';
import registrySchema from './registry.schema.json';
import routesSchema from './routes.schema.json';
import stylesSchema from './styles.schema.json';
import modelSchema from './model.schema.json';
// ─── Schema IDs ──────────────────────────────────────────────────────────────
export const SCHEMA_IDS = {
PROJECT: 'https://opennoodl.dev/schemas/project-v2.json',
COMPONENT: 'https://opennoodl.dev/schemas/component-v2.json',
NODES: 'https://opennoodl.dev/schemas/nodes-v2.json',
CONNECTIONS: 'https://opennoodl.dev/schemas/connections-v2.json',
REGISTRY: 'https://opennoodl.dev/schemas/registry-v2.json',
ROUTES: 'https://opennoodl.dev/schemas/routes-v2.json',
STYLES: 'https://opennoodl.dev/schemas/styles-v2.json',
MODEL: 'https://opennoodl.dev/schemas/model-v2.json'
} as const;
export type SchemaId = (typeof SCHEMA_IDS)[keyof typeof SCHEMA_IDS];
// ─── Validation Result ────────────────────────────────────────────────────────
export interface ValidationError {
/** JSON path to the failing field (e.g. '/components/Header/type') */
path: string;
/** Human-readable error message */
message: string;
/** Ajv keyword that triggered the error */
keyword: string;
/** Additional error params from Ajv */
params: Record<string, unknown>;
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
// ─── Validator class ──────────────────────────────────────────────────────────
/**
* Singleton validator that compiles all schemas once and reuses them.
*
* @example
* ```ts
* const result = SchemaValidator.instance.validate('component', myComponentJson);
* if (!result.valid) {
* console.error(result.errors);
* }
* ```
*/
export class SchemaValidator {
private static _instance: SchemaValidator | undefined;
public static get instance(): SchemaValidator {
if (!SchemaValidator._instance) {
SchemaValidator._instance = new SchemaValidator();
}
return SchemaValidator._instance;
}
private ajv: Ajv;
private validators: Map<string, ValidateFunction>;
constructor() {
this.ajv = new Ajv({
allErrors: true, // Collect all errors, not just the first
strict: false, // Allow unknown keywords (e.g. 'description' in definitions)
validateFormats: true
});
addFormats(this.ajv);
// Register all schemas
this.ajv.addSchema(projectV2Schema);
this.ajv.addSchema(componentSchema);
this.ajv.addSchema(nodesSchema);
this.ajv.addSchema(connectionsSchema);
this.ajv.addSchema(registrySchema);
this.ajv.addSchema(routesSchema);
this.ajv.addSchema(stylesSchema);
this.ajv.addSchema(modelSchema);
// Pre-compile validators for each schema
this.validators = new Map([
[SCHEMA_IDS.PROJECT, this.ajv.compile(projectV2Schema)],
[SCHEMA_IDS.COMPONENT, this.ajv.compile(componentSchema)],
[SCHEMA_IDS.NODES, this.ajv.compile(nodesSchema)],
[SCHEMA_IDS.CONNECTIONS, this.ajv.compile(connectionsSchema)],
[SCHEMA_IDS.REGISTRY, this.ajv.compile(registrySchema)],
[SCHEMA_IDS.ROUTES, this.ajv.compile(routesSchema)],
[SCHEMA_IDS.STYLES, this.ajv.compile(stylesSchema)],
[SCHEMA_IDS.MODEL, this.ajv.compile(modelSchema)]
]);
}
/**
* Validate data against a named schema.
*
* @param schemaId - One of the SCHEMA_IDS values
* @param data - The parsed JSON object to validate
* @returns ValidationResult with valid flag and any errors
*/
validate(schemaId: SchemaId, data: unknown): ValidationResult {
const validator = this.validators.get(schemaId);
if (!validator) {
return {
valid: false,
errors: [
{
path: '',
message: `Unknown schema ID: ${schemaId}`,
keyword: 'schema',
params: { schemaId }
}
]
};
}
const valid = validator(data) as boolean;
if (valid) {
return { valid: true, errors: [] };
}
const errors: ValidationError[] = (validator.errors ?? []).map((err) => ({
path: err.instancePath || '/',
message: err.message ?? 'Validation error',
keyword: err.keyword,
params: (err.params as Record<string, unknown>) ?? {}
}));
return { valid: false, errors };
}
/**
* Validate and throw if invalid. Useful for strict import paths.
*
* @throws Error with formatted validation messages
*/
validateOrThrow(schemaId: SchemaId, data: unknown, context?: string): void {
const result = this.validate(schemaId, data);
if (!result.valid) {
const prefix = context ? `[${context}] ` : '';
const messages = result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
throw new Error(`${prefix}Schema validation failed for ${schemaId}:\n${messages}`);
}
}
/**
* Validate a project file (nodegx.project.json).
*/
validateProject(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.PROJECT, data);
}
/**
* Validate a component metadata file (component.json).
*/
validateComponent(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.COMPONENT, data);
}
/**
* Validate a nodes file (nodes.json).
*/
validateNodes(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.NODES, data);
}
/**
* Validate a connections file (connections.json).
*/
validateConnections(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.CONNECTIONS, data);
}
/**
* Validate a component registry file (_registry.json).
*/
validateRegistry(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.REGISTRY, data);
}
/**
* Validate a routes file (nodegx.routes.json).
*/
validateRoutes(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.ROUTES, data);
}
/**
* Validate a styles file (nodegx.styles.json).
*/
validateStyles(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.STYLES, data);
}
/**
* Validate a model definition file (models/<Name>.json).
*/
validateModel(data: unknown): ValidationResult {
return this.validate(SCHEMA_IDS.MODEL, data);
}
/**
* Reset the singleton (useful for testing).
*/
static reset(): void {
SchemaValidator._instance = undefined;
}
}
// ─── Convenience functions ────────────────────────────────────────────────────
/**
* Validate data against a schema. Uses the singleton validator.
*
* @example
* ```ts
* const { valid, errors } = validateSchema(SCHEMA_IDS.COMPONENT, data);
* ```
*/
export function validateSchema(schemaId: SchemaId, data: unknown): ValidationResult {
return SchemaValidator.instance.validate(schemaId, data);
}
/**
* Format validation errors into a human-readable string.
*/
export function formatValidationErrors(errors: ValidationError[]): string {
if (errors.length === 0) return 'No errors';
return errors.map((e) => `${e.path || '/'}: ${e.message}`).join('\n');
}

View File

@@ -11,3 +11,4 @@ export * from './project';
export * from './projectmerger';
export * from './projectpatcher';
export * from './utils';
export * from './schemas';

View File

@@ -0,0 +1 @@
export * from './schema-validator.test';

View File

@@ -0,0 +1,745 @@
/**
* Schema Validator Tests — STRUCT-001
*
* Tests for the Ajv-based validation utilities covering all 8 v2 format schemas.
* Each schema is tested with a valid minimal fixture and key invalid cases.
*/
import { SchemaValidator, SCHEMA_IDS, validateSchema, formatValidationErrors } from '../../src/editor/src/schemas/validator';
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const validProject = {
name: 'My Conference App',
version: '2.0',
nodegxVersion: '1.1.0'
};
const validComponent = {
id: 'comp_header_abc123',
name: 'Header',
type: 'visual'
};
const validNodes = {
componentId: 'comp_header_abc123',
version: 1,
nodes: [
{
id: 'node_root_001',
type: 'Group',
label: 'Header Container',
x: 0,
y: 0,
parameters: { layout: 'row' }
}
]
};
const validConnections = {
componentId: 'comp_header_abc123',
version: 1,
connections: [
{
fromId: 'node_root_001',
fromProperty: 'onClick',
toId: 'node_root_002',
toProperty: 'trigger'
}
]
};
const validRegistry = {
version: 1,
lastUpdated: '2026-02-18T00:00:00.000Z',
components: {
Header: {
path: 'Header',
type: 'visual',
nodeCount: 10,
connectionCount: 5
}
},
stats: {
totalComponents: 1,
totalNodes: 10,
totalConnections: 5
}
};
const validRoutes = {
routes: [
{ path: '/home', component: 'pages/HomePage' },
{ path: '/profile', component: 'pages/ProfilePage', title: 'Profile' }
]
};
const validStyles = {
version: 1,
colors: {
primary: '#3B82F6',
background: '#ffffff'
},
textStyles: {
heading: { fontSize: '24px', fontWeight: '700' }
}
};
const validModel = {
name: 'User',
fields: [
{ name: 'username', type: 'String', required: true },
{ name: 'email', type: 'String', unique: true },
{ name: 'age', type: 'Number' }
]
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('SchemaValidator', () => {
beforeEach(() => {
// Reset singleton between tests to ensure clean state
SchemaValidator.reset();
});
describe('singleton', () => {
it('returns the same instance on repeated calls', () => {
const a = SchemaValidator.instance;
const b = SchemaValidator.instance;
expect(a).toBe(b);
});
it('creates a new instance after reset()', () => {
const a = SchemaValidator.instance;
SchemaValidator.reset();
const b = SchemaValidator.instance;
expect(a).not.toBe(b);
});
});
// ─── project-v2.schema.json ─────────────────────────────────────────────────
describe('validateProject', () => {
it('accepts a valid minimal project file', () => {
const result = SchemaValidator.instance.validateProject(validProject);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
});
it('accepts a full project file with all optional fields', () => {
const result = SchemaValidator.instance.validateProject({
...validProject,
id: 'proj_abc123',
runtimeVersion: 'react19',
created: '2026-01-01T00:00:00.000Z',
modified: '2026-02-18T00:00:00.000Z',
settings: { rootComponent: 'App', defaultRoute: '/home' },
structure: { componentsDir: 'components', modelsDir: 'models', assetsDir: 'assets' },
metadata: { styles: {}, cloudservices: {} }
});
expect(result.valid).toBe(true);
});
it('rejects when name is missing', () => {
const result = SchemaValidator.instance.validateProject({ version: '2.0', nodegxVersion: '1.1.0' });
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.message.includes('name'))).toBe(true);
});
it('rejects when version is missing', () => {
const result = SchemaValidator.instance.validateProject({ name: 'Test', nodegxVersion: '1.1.0' });
expect(result.valid).toBe(false);
});
it('rejects when nodegxVersion is missing', () => {
const result = SchemaValidator.instance.validateProject({ name: 'Test', version: '2.0' });
expect(result.valid).toBe(false);
});
it('rejects invalid runtimeVersion enum value', () => {
const result = SchemaValidator.instance.validateProject({
...validProject,
runtimeVersion: 'react18'
});
expect(result.valid).toBe(false);
});
it('rejects invalid date-time format for created', () => {
const result = SchemaValidator.instance.validateProject({
...validProject,
created: 'not-a-date'
});
expect(result.valid).toBe(false);
});
it('rejects non-object input', () => {
expect(SchemaValidator.instance.validateProject(null).valid).toBe(false);
expect(SchemaValidator.instance.validateProject('string').valid).toBe(false);
expect(SchemaValidator.instance.validateProject(42).valid).toBe(false);
});
});
// ─── component.schema.json ──────────────────────────────────────────────────
describe('validateComponent', () => {
it('accepts a valid minimal component file', () => {
const result = SchemaValidator.instance.validateComponent(validComponent);
expect(result.valid).toBe(true);
});
it('accepts a full component with ports and dependencies', () => {
const result = SchemaValidator.instance.validateComponent({
...validComponent,
displayName: 'Site Header',
path: '/#Header',
description: 'Main navigation header',
category: 'layout',
tags: ['navigation', 'header'],
ports: {
inputs: [
{ name: 'userName', type: 'string', displayName: 'User Name' },
{ name: 'isLoggedIn', type: 'boolean', default: false }
],
outputs: [
{ name: 'onLogout', type: 'signal' }
]
},
dependencies: ['shared/Avatar', 'shared/Button'],
created: '2026-01-01T00:00:00.000Z',
modified: '2026-02-18T00:00:00.000Z'
});
expect(result.valid).toBe(true);
});
it('rejects when id is missing', () => {
const result = SchemaValidator.instance.validateComponent({ name: 'Header', type: 'visual' });
expect(result.valid).toBe(false);
});
it('rejects when name is missing', () => {
const result = SchemaValidator.instance.validateComponent({ id: 'comp_abc', type: 'visual' });
expect(result.valid).toBe(false);
});
it('rejects invalid type enum', () => {
const result = SchemaValidator.instance.validateComponent({ ...validComponent, type: 'widget' });
expect(result.valid).toBe(false);
});
it('accepts all valid type enum values', () => {
const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const;
for (const type of types) {
const result = SchemaValidator.instance.validateComponent({ ...validComponent, type });
expect(result.valid).toBe(true);
}
});
it('rejects port with missing name', () => {
const result = SchemaValidator.instance.validateComponent({
...validComponent,
ports: { inputs: [{ type: 'string' }] }
});
expect(result.valid).toBe(false);
});
});
// ─── nodes.schema.json ──────────────────────────────────────────────────────
describe('validateNodes', () => {
it('accepts a valid nodes file', () => {
const result = SchemaValidator.instance.validateNodes(validNodes);
expect(result.valid).toBe(true);
});
it('accepts an empty nodes array', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: []
});
expect(result.valid).toBe(true);
});
it('accepts nodes with all optional fields', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
version: 1,
nodes: [
{
id: 'node_001',
type: 'Group',
label: 'Container',
x: 100,
y: 200,
variant: 'primary',
parameters: { layout: 'row', gap: '12px' },
stateParameters: { hover: { opacity: 0.8 } },
children: ['node_002'],
metadata: { comment: 'Main container' }
},
{
id: 'node_002',
type: '/#Header',
parent: 'node_001',
dynamicports: [{ name: 'item-0-label', type: 'string' }]
}
]
});
expect(result.valid).toBe(true);
});
it('rejects when componentId is missing', () => {
const result = SchemaValidator.instance.validateNodes({ nodes: [] });
expect(result.valid).toBe(false);
});
it('rejects when nodes is missing', () => {
const result = SchemaValidator.instance.validateNodes({ componentId: 'comp_abc' });
expect(result.valid).toBe(false);
});
it('rejects a node with missing id', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ type: 'Group' }]
});
expect(result.valid).toBe(false);
});
it('rejects a node with missing type', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ id: 'node_001' }]
});
expect(result.valid).toBe(false);
});
it('accepts valid annotation values', () => {
const annotations = ['Deleted', 'Created', 'Changed'] as const;
for (const annotation of annotations) {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ id: 'node_001', type: 'Group', annotation }]
});
expect(result.valid).toBe(true);
}
});
it('rejects invalid annotation value', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [{ id: 'node_001', type: 'Group', annotation: 'Modified' }]
});
expect(result.valid).toBe(false);
});
});
// ─── connections.schema.json ────────────────────────────────────────────────
describe('validateConnections', () => {
it('accepts a valid connections file', () => {
const result = SchemaValidator.instance.validateConnections(validConnections);
expect(result.valid).toBe(true);
});
it('accepts an empty connections array', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: []
});
expect(result.valid).toBe(true);
});
it('rejects when componentId is missing', () => {
const result = SchemaValidator.instance.validateConnections({ connections: [] });
expect(result.valid).toBe(false);
});
it('rejects when connections is missing', () => {
const result = SchemaValidator.instance.validateConnections({ componentId: 'comp_abc' });
expect(result.valid).toBe(false);
});
it('rejects a connection missing fromId', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: [{ fromProperty: 'onClick', toId: 'node_002', toProperty: 'trigger' }]
});
expect(result.valid).toBe(false);
});
it('rejects a connection missing toProperty', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: [{ fromId: 'node_001', fromProperty: 'onClick', toId: 'node_002' }]
});
expect(result.valid).toBe(false);
});
it('accepts connection with annotation', () => {
const result = SchemaValidator.instance.validateConnections({
componentId: 'comp_abc',
connections: [
{
fromId: 'node_001',
fromProperty: 'onClick',
toId: 'node_002',
toProperty: 'trigger',
annotation: 'Created'
}
]
});
expect(result.valid).toBe(true);
});
});
// ─── registry.schema.json ───────────────────────────────────────────────────
describe('validateRegistry', () => {
it('accepts a valid registry file', () => {
const result = SchemaValidator.instance.validateRegistry(validRegistry);
expect(result.valid).toBe(true);
});
it('accepts an empty components map', () => {
const result = SchemaValidator.instance.validateRegistry({ version: 1, components: {} });
expect(result.valid).toBe(true);
});
it('rejects when version is missing', () => {
const result = SchemaValidator.instance.validateRegistry({ components: {} });
expect(result.valid).toBe(false);
});
it('rejects when components is missing', () => {
const result = SchemaValidator.instance.validateRegistry({ version: 1 });
expect(result.valid).toBe(false);
});
it('rejects a component entry with invalid type', () => {
const result = SchemaValidator.instance.validateRegistry({
version: 1,
components: {
Header: { path: 'Header', type: 'widget' }
}
});
expect(result.valid).toBe(false);
});
it('rejects a component entry missing path', () => {
const result = SchemaValidator.instance.validateRegistry({
version: 1,
components: {
Header: { type: 'visual' }
}
});
expect(result.valid).toBe(false);
});
it('accepts all valid component type values', () => {
const types = ['root', 'page', 'visual', 'logic', 'cloud'] as const;
for (const type of types) {
const result = SchemaValidator.instance.validateRegistry({
version: 1,
components: { Test: { path: 'Test', type } }
});
expect(result.valid).toBe(true);
}
});
});
// ─── routes.schema.json ─────────────────────────────────────────────────────
describe('validateRoutes', () => {
it('accepts a valid routes file', () => {
const result = SchemaValidator.instance.validateRoutes(validRoutes);
expect(result.valid).toBe(true);
});
it('accepts an empty routes array', () => {
const result = SchemaValidator.instance.validateRoutes({ routes: [] });
expect(result.valid).toBe(true);
});
it('accepts routes with all optional fields', () => {
const result = SchemaValidator.instance.validateRoutes({
version: 1,
routes: [
{
path: '/home',
component: 'pages/HomePage',
title: 'Home',
exact: true,
metadata: { requiresAuth: false }
}
],
notFound: 'pages/NotFoundPage'
});
expect(result.valid).toBe(true);
});
it('rejects when routes is missing', () => {
const result = SchemaValidator.instance.validateRoutes({});
expect(result.valid).toBe(false);
});
it('rejects a route missing path', () => {
const result = SchemaValidator.instance.validateRoutes({
routes: [{ component: 'pages/HomePage' }]
});
expect(result.valid).toBe(false);
});
it('rejects a route missing component', () => {
const result = SchemaValidator.instance.validateRoutes({
routes: [{ path: '/home' }]
});
expect(result.valid).toBe(false);
});
});
// ─── styles.schema.json ─────────────────────────────────────────────────────
describe('validateStyles', () => {
it('accepts a valid styles file', () => {
const result = SchemaValidator.instance.validateStyles(validStyles);
expect(result.valid).toBe(true);
});
it('accepts an empty styles file', () => {
const result = SchemaValidator.instance.validateStyles({});
expect(result.valid).toBe(true);
});
it('accepts styles with variants', () => {
const result = SchemaValidator.instance.validateStyles({
variants: [
{
name: 'primary',
typename: 'Button',
parameters: { backgroundColor: '#3B82F6', color: '#ffffff' },
stateParameters: { hover: { backgroundColor: '#2563EB' } }
}
]
});
expect(result.valid).toBe(true);
});
it('rejects a variant missing name', () => {
const result = SchemaValidator.instance.validateStyles({
variants: [{ typename: 'Button' }]
});
expect(result.valid).toBe(false);
});
it('rejects a variant missing typename', () => {
const result = SchemaValidator.instance.validateStyles({
variants: [{ name: 'primary' }]
});
expect(result.valid).toBe(false);
});
});
// ─── model.schema.json ──────────────────────────────────────────────────────
describe('validateModel', () => {
it('accepts a valid model file', () => {
const result = SchemaValidator.instance.validateModel(validModel);
expect(result.valid).toBe(true);
});
it('accepts a model with all field types', () => {
const result = SchemaValidator.instance.validateModel({
name: 'Session',
fields: [
{ name: 'title', type: 'String' },
{ name: 'capacity', type: 'Number' },
{ name: 'isPublished', type: 'Boolean' },
{ name: 'startTime', type: 'Date' },
{ name: 'metadata', type: 'Object' },
{ name: 'tags', type: 'Array' },
{ name: 'speaker', type: 'Pointer', targetClass: 'User' },
{ name: 'attendees', type: 'Relation', targetClass: 'User' },
{ name: 'thumbnail', type: 'File' },
{ name: 'location', type: 'GeoPoint' }
]
});
expect(result.valid).toBe(true);
});
it('accepts a model with indexes', () => {
const result = SchemaValidator.instance.validateModel({
...validModel,
indexes: [
{ name: 'email_idx', fields: ['email'], unique: true },
{ fields: ['username'] }
]
});
expect(result.valid).toBe(true);
});
it('rejects when name is missing', () => {
const result = SchemaValidator.instance.validateModel({ fields: [] });
expect(result.valid).toBe(false);
});
it('rejects when fields is missing', () => {
const result = SchemaValidator.instance.validateModel({ name: 'User' });
expect(result.valid).toBe(false);
});
it('rejects a field with invalid type', () => {
const result = SchemaValidator.instance.validateModel({
name: 'User',
fields: [{ name: 'username', type: 'Text' }]
});
expect(result.valid).toBe(false);
});
it('rejects a field missing name', () => {
const result = SchemaValidator.instance.validateModel({
name: 'User',
fields: [{ type: 'String' }]
});
expect(result.valid).toBe(false);
});
it('rejects an index with empty fields array', () => {
const result = SchemaValidator.instance.validateModel({
...validModel,
indexes: [{ fields: [] }]
});
expect(result.valid).toBe(false);
});
});
// ─── validateSchema convenience function ────────────────────────────────────
describe('validateSchema (convenience function)', () => {
it('delegates to the singleton validator', () => {
const result = validateSchema(SCHEMA_IDS.COMPONENT, validComponent);
expect(result.valid).toBe(true);
});
it('returns errors for invalid data', () => {
const result = validateSchema(SCHEMA_IDS.PROJECT, {});
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
// ─── validateOrThrow ────────────────────────────────────────────────────────
describe('validateOrThrow', () => {
it('does not throw for valid data', () => {
expect(() => {
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, validComponent);
}).not.toThrow();
});
it('throws for invalid data', () => {
expect(() => {
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, { name: 'Header' });
}).toThrow('Schema validation failed');
});
it('includes context in the error message', () => {
expect(() => {
SchemaValidator.instance.validateOrThrow(SCHEMA_IDS.COMPONENT, {}, 'components/Header/component.json');
}).toThrow('[components/Header/component.json]');
});
});
// ─── formatValidationErrors ─────────────────────────────────────────────────
describe('formatValidationErrors', () => {
it('returns "No errors" for empty array', () => {
const { formatValidationErrors } = require('../../src/editor/src/schemas/validator');
expect(formatValidationErrors([])).toBe('No errors');
});
it('formats errors with path and message', () => {
const { formatValidationErrors } = require('../../src/editor/src/schemas/validator');
const errors = [
{ path: '/name', message: 'must be string', keyword: 'type', params: {} },
{ path: '/type', message: 'must be equal to one of the allowed values', keyword: 'enum', params: {} }
];
const formatted = formatValidationErrors(errors);
expect(formatted).toContain('/name');
expect(formatted).toContain('must be string');
expect(formatted).toContain('/type');
});
});
// ─── SCHEMA_IDS ─────────────────────────────────────────────────────────────
describe('SCHEMA_IDS', () => {
it('has all 8 schema IDs defined', () => {
expect(Object.keys(SCHEMA_IDS).length).toBe(8);
});
it('all IDs are valid opennoodl.dev URLs', () => {
for (const id of Object.values(SCHEMA_IDS)) {
expect(id).toMatch(/^https:\/\/opennoodl\.dev\/schemas\//);
}
});
});
// ─── Edge cases ──────────────────────────────────────────────────────────────
describe('edge cases', () => {
it('collects multiple errors when allErrors is true', () => {
// Missing name, version, AND nodegxVersion — should get 3 errors
const result = SchemaValidator.instance.validateProject({});
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
it('handles deeply nested valid node metadata', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [
{
id: 'node_001',
type: 'JavaScriptNode',
metadata: {
merge: { soureCodePorts: ['script'] },
prompt: {
history: [
{ role: 'user', content: 'Create a function that...' },
{ role: 'assistant', content: 'Here is the code...' }
]
}
}
}
]
});
expect(result.valid).toBe(true);
});
it('handles component references in node type (legacy path format)', () => {
const result = SchemaValidator.instance.validateNodes({
componentId: 'comp_abc',
nodes: [
{ id: 'node_001', type: '/#Header' },
{ id: 'node_002', type: '/#__cloud__/SendGrid/SendEmail' }
]
});
expect(result.valid).toBe(true);
});
it('handles port type as object (complex type definition)', () => {
const result = SchemaValidator.instance.validateComponent({
...validComponent,
ports: {
inputs: [
{
name: 'items',
type: { name: 'stringlist', allowConnectionsOnly: true },
displayName: 'Items'
}
]
}
});
expect(result.valid).toBe(true);
});
});
});