diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba02e1..f4219fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [1.6.2] - 2026-07-17 + +### Fixed + +- **`{ type: undefined }` and `{ type: null }` crash**: `normalizeConf` previously accepted config objects where `type` was `undefined` or `null` (because `'type' in conf` is `true`), causing a raw `TypeError` on the downstream `.prototype` access. Now throws a structured `SchemaDefinitionError` with code `SCHEMA_DEFINITION_ERROR`. +- **Null-prototype object crash**: Values created via `Object.create(null)` or exotic deserialization (no `.constructor`) caused a raw `TypeError` in `isOfType()` and the error message formatter. Now throws a structured `TypeValidationError` with message `"...got null-prototype object"`. +- **`Union(Array, String)` silent char-split**: A string value passed to a `Union(Array, String)` field would silently enter the Array branch and iterate characters into `['h','e','l','l','o']`. The structural branch (Array/Set) now checks the runtime value's actual type, not just the schema declaration. +- **`isOfType()` overly permissive `!isNaN` check**: The `(conf.type === Date || conf.type === Number) && !isNaN(value)` fallback allowed strings like `"42"` to pass type-checking for `Number` fields without coercion, producing a string where a number was expected. Removed; values must match the declared type or go through explicit `coerce: true`. +- **Missing `static schema` silent no-op**: A subclass with no `static schema` defined would silently construct empty objects. Now throws `SchemaDefinitionError` with message `"ClassName has no schema defined"`. + +### Added + +- **8 regression tests**: Covering `{ type: undefined }`, `{ type: null }`, null-prototype objects, `Union(Array, String)` with string/array inputs, `Number` strict type rejection, `Number` coercion interop, and missing schema detection. + ## [1.6.0] - 2026-07-12 ### Added diff --git a/README.md b/README.md index cd03298..af536cb 100644 --- a/README.md +++ b/README.md @@ -448,13 +448,13 @@ ModelCore throws typed error classes for every failure mode: | Error class | Trigger | |---|---| | `ValidationError` | Custom `validate` hook throws | -| `TypeValidationError` | Value constructor doesn't match schema type | +| `TypeValidationError` | Value constructor doesn't match schema type, or value has no prototype chain | | `RequiredError` | Required field is missing | | `EnumValueError` | Value not in allowed enum set | | `RangeError` | Value exceeds min/max | | `ImmutableObjectError` | Write to immutable class | | `ImmutablePropertyError` | Write to immutable field | -| `SchemaDefinitionError` | Malformed schema definition | +| `SchemaDefinitionError` | Malformed schema definition, `{ type: undefined }`, `{ type: null }`, or missing `static schema` | | `MissingPropertyError` | Nested required property missing | | `ValueError` | Array method misuse (e.g., `fill()`) | @@ -462,6 +462,21 @@ All errors extend `ModelCoreError`, which carries `source`, `path`, `expected`, --- +## Defensive guarantees + +ModelCore guards against several degenerate inputs that would otherwise cause raw `TypeError` crashes or silent data corruption: + +| Input | Before | After | +|---|---|---| +| `{ type: undefined }` in schema | Raw `TypeError: Cannot read properties of undefined` | `SchemaDefinitionError` with path and code | +| `{ type: null }` in schema | Raw `TypeError` | `SchemaDefinitionError` with path and code | +| `Object.create(null)` as field value | Raw `TypeError: Cannot read properties of undefined (reading 'name')` | `TypeValidationError` with message `"...got null-prototype object"` | +| `Union(Array, String)` with string input | String silently split into char array `['h','e','l','l','o']` | String preserved as-is | +| `"42"` for a `Number` field (no `coerce: true`) | Silently accepted as string | `TypeValidationError` — use `coerce: true` to auto-convert | +| Subclass with no `static schema` | Silent empty object | `SchemaDefinitionError` with class name | + + + ## Performance ModelCore achieves **>380K ops/sec** across all operations on 100K iterations (Node 24) on a regular laptop: diff --git a/base.js b/base.js index 71f5253..e7ee8e9 100644 --- a/base.js +++ b/base.js @@ -78,8 +78,11 @@ export function buildError(errorType, message, source, path, expected, received, function normalizeConf(conf, path) { if (typeof conf === "function") return { type: conf }; - if (conf && typeof conf === "object" && 'type' in conf) + if (conf && typeof conf === "object" && 'type' in conf) { + if (typeof conf.type !== "function") + throw buildError(SchemaDefinitionError, `Schema field '${path}' type must be a constructor function`, Base, path, Function, conf.type, "SCHEMA_DEFINITION_ERROR"); return conf; + } throw buildError(SchemaDefinitionError, `Invalid schema definition for '${path}'`, Base, path, Object, conf, "SCHEMA_DEFINITION_ERROR"); } // ==================== PROXY HANDLER ==================== @@ -345,6 +348,8 @@ export default class Base { json() { return JSON.stringify(this.toObject()); } setProperties(schema, data, isNew = false) { const ctor = this.constructor; + if (!schema || typeof schema !== 'object') + throw buildError(SchemaDefinitionError, `${ctor.name} has no schema defined`, ctor.name, "", Object, schema, "SCHEMA_DEFINITION_ERROR"); if (ctor.immutable && !isNew) throw buildError(ImmutableObjectError, `Cannot update immutable object of type ${ctor.name}`, ctor.name, "", null, data, "IMMUTABLE_CLASS_UPDATE"); for (const key in schema) { @@ -363,14 +368,20 @@ export default class Base { const { conf, value } = this.validateType(confPassed, valuePassed, path); let toReturn; const unionTypes = conf.type === ModelCoreUnion ? new conf.type() : conf.type.prototype instanceof ModelCoreUnion ? new conf.type() : null; - const isArray = conf.type === Array || + const schemaHasArray = conf.type === Array || (unionTypes && unionTypes.some((t) => t === Array || t.prototype instanceof Array)) || (!unionTypes && conf.type.prototype instanceof Array); - const isSet = !isArray && (conf.type === Set || + const schemaHasSet = !schemaHasArray && (conf.type === Set || (unionTypes && unionTypes.some((t) => t === Set || t.prototype instanceof Set)) || (!unionTypes && conf.type.prototype instanceof Set)); - const isObject = !isArray && !isSet && (conf.type === Object || (unionTypes && unionTypes.some((t) => t === Object))); - const isMap = !isArray && !isSet && !isObject && (conf.type === Map || (unionTypes && unionTypes.some((t) => t === Map || t.prototype instanceof Map))); + const schemaHasObject = !schemaHasArray && !schemaHasSet && (conf.type === Object || (unionTypes && unionTypes.some((t) => t === Object))); + const schemaHasMap = !schemaHasArray && !schemaHasSet && !schemaHasObject && (conf.type === Map || (unionTypes && unionTypes.some((t) => t === Map || t.prototype instanceof Map))); + // For unions, structural branches (Array/Set/Object/Map) only apply when the runtime value actually matches that type. + // Without this guard, e.g. Union(Array/Object, String) with a string value would enter the structural branch and crash/fail. + const isArray = schemaHasArray && (Array.isArray(value) || value instanceof Array); + const isSet = schemaHasSet && (value instanceof Set); + const isObject = schemaHasObject && (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map)); + const isMap = schemaHasMap && (value instanceof Map); if (isArray || isSet) { if (!conf.values) throw buildError(SchemaDefinitionError, `Missing array value configuration at ${path}`, this.runValidate, path, Object, value, "SCHEMA_DEFINITION_ERROR"); @@ -464,10 +475,12 @@ export default class Base { return { conf, value }; } } + // Guard against null-prototype objects (e.g. Object.create(null)) which have no .constructor + if (value != null && !value.constructor) + throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got null-prototype object`, this.constructor.name, path, conf.type, value, "INVALID_TYPE"); const isOfType = () => { - return (unionTypes && unionTypes.some((t) => value.constructor === t)) || - value.constructor === conf.type || - ((conf.type === Date || conf.type === Number) && !isNaN(value)); + const ctor = value.constructor; + return (unionTypes && unionTypes.some((t) => ctor === t)) || ctor === conf.type; }; if (!isOfType()) { // Attempt to coerce the value to the correct type if possible. Valuable for date strings from a json for example @@ -485,7 +498,7 @@ export default class Base { } })(); if (!isOfType()) - throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value.constructor.name}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE"); + throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value?.constructor?.name ?? typeof value}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE"); } if ((conf.max !== undefined) && (value.length ? value.length > conf.max : value > conf.max)) throw buildError(RangeError, `Value too large for '${path}', maximum: ${conf.max}`, this.validateType, path, conf.max, value, "VALUE_TOO_LARGE"); diff --git a/base.ts b/base.ts index 04351b7..60e9073 100644 --- a/base.ts +++ b/base.ts @@ -185,7 +185,11 @@ export function buildError( function normalizeConf(conf: FieldConfig | any, path: string): FieldConfig { if (typeof conf === "function") return { type: conf } as FieldConfig; - if (conf && typeof conf === "object" && 'type' in conf) return conf as FieldConfig; + if (conf && typeof conf === "object" && 'type' in conf) { + if (typeof conf.type !== "function") + throw buildError(SchemaDefinitionError, `Schema field '${path}' type must be a constructor function`, Base, path, Function, conf.type, "SCHEMA_DEFINITION_ERROR"); + return conf as FieldConfig; + } throw buildError(SchemaDefinitionError, `Invalid schema definition for '${path}'`, Base, path, Object, conf, "SCHEMA_DEFINITION_ERROR"); } @@ -467,6 +471,8 @@ export default class Base { private setProperties(schema: SchemaDefinition, data: Record, isNew: boolean = false): void { const ctor = this.constructor as typeof Base & BaseConstructor; + if (!schema || typeof schema !== 'object') + throw buildError(SchemaDefinitionError, `${ctor.name} has no schema defined`, ctor.name, "", Object, schema, "SCHEMA_DEFINITION_ERROR"); if (ctor.immutable && !isNew) throw buildError( ImmutableObjectError, `Cannot update immutable object of type ${ctor.name}`, @@ -488,17 +494,24 @@ export default class Base { let toReturn: typeof conf.type; const unionTypes = conf.type === ModelCoreUnion ? new conf.type() : conf.type.prototype instanceof ModelCoreUnion ? new conf.type() : null; - const isArray = conf.type === Array || + const schemaHasArray = conf.type === Array || (unionTypes && unionTypes.some((t: any) => t === Array || t.prototype instanceof Array)) || (!unionTypes && conf.type.prototype instanceof Array) - const isSet = !isArray && (conf.type === Set || + const schemaHasSet = !schemaHasArray && (conf.type === Set || (unionTypes && unionTypes.some((t: any) => t === Set || t.prototype instanceof Set)) || (!unionTypes && conf.type.prototype instanceof Set)) - const isObject = !isArray && !isSet && (conf.type === Object || (unionTypes && unionTypes.some((t: any) => t === Object))); + const schemaHasObject = !schemaHasArray && !schemaHasSet && (conf.type === Object || (unionTypes && unionTypes.some((t: any) => t === Object))); + + const schemaHasMap = !schemaHasArray && !schemaHasSet && !schemaHasObject && (conf.type === Map || (unionTypes && unionTypes.some((t: any) => t === Map || t.prototype instanceof Map))); - const isMap = !isArray && !isSet && !isObject && (conf.type === Map || (unionTypes && unionTypes.some((t: any) => t === Map || t.prototype instanceof Map))) + // For unions, structural branches (Array/Set/Object/Map) only apply when the runtime value actually matches that type. + // Without this guard, e.g. Union(Array/Object, String) with a string value would enter the structural branch and crash/fail. + const isArray = schemaHasArray && (Array.isArray(value) || value instanceof Array); + const isSet = schemaHasSet && (value instanceof Set); + const isObject = schemaHasObject && (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map)); + const isMap = schemaHasMap && (value instanceof Map); if (isArray || isSet) { if (!conf.values) throw buildError( @@ -590,10 +603,13 @@ export default class Base { } } + // Guard against null-prototype objects (e.g. Object.create(null)) which have no .constructor + if (value != null && !value.constructor) + throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got null-prototype object`, this.constructor.name, path, conf.type, value, "INVALID_TYPE"); + const isOfType = () => { - return (unionTypes && unionTypes.some((t: any) => value.constructor === t)) || - value.constructor === conf.type || - ((conf.type === Date || conf.type === Number) && !isNaN(value)) + const ctor = value.constructor; + return (unionTypes && unionTypes.some((t: any) => ctor === t)) || ctor === conf.type }; if (!isOfType()) { @@ -611,7 +627,7 @@ export default class Base { })(); if (!isOfType()) - throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value.constructor.name}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE"); + throw buildError(TypeValidationError, `Invalid type at '${path}', expected ${conf.type.name}, got ${value?.constructor?.name ?? typeof value}`, this.constructor.name, path, conf.type, value, "INVALID_TYPE"); } if ((conf.max !== undefined) && (value.length ? value.length > conf.max : value > conf.max)) throw buildError(RangeError, `Value too large for '${path}', maximum: ${conf.max}`, this.validateType, path, conf.max, value, "VALUE_TOO_LARGE"); diff --git a/package-lock.json b/package-lock.json index a255bda..275e6c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bufferpunk/modelcore", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bufferpunk/modelcore", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "devDependencies": { "typescript": "^5.0.0" diff --git a/package.json b/package.json index e179e11..638118b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bufferpunk/modelcore", - "version": "1.6.1", + "version": "1.6.2", "description": "A blazing fast, lightweight and Reactive Class-Based Object Modeling and data integrity framework", "type": "module", "main": "base.js", diff --git a/test/base.test.js b/test/base.test.js index c7d32f5..8cddd33 100644 --- a/test/base.test.js +++ b/test/base.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import Base, { Union, buildError, ValueError } from "../base.ts"; +import Base, { Union, buildError, ValueError, SchemaDefinitionError, TypeValidationError } from "../base.ts"; /* Tests to verify core functionality. You can add more as needed. Send a PR to be included. */ @@ -836,3 +836,149 @@ test("error.expected contains meaningful values (type, min, max, enum)", () => { assert.ok(e.expected != null); } }); + +// ==================== NULL & EDGE-CASE GUARDS ==================== + +test("schema field with { type: undefined } throws SchemaDefinitionError", () => { + class U extends Base { + static schema = { name: { type: undefined } }; + } + + assert.throws( + () => new U({ name: "hello" }), + (err) => { + assert.ok(err instanceof SchemaDefinitionError); + assert.match(err.message, /must be a constructor function/); + assert.equal(err.code, "SCHEMA_DEFINITION_ERROR"); + return true; + } + ); +}); + +test("schema field with { type: null } throws SchemaDefinitionError", () => { + class U extends Base { + static schema = { name: { type: null } }; + } + + assert.throws( + () => new U({ name: "hello" }), + (err) => { + assert.ok(err instanceof SchemaDefinitionError); + assert.match(err.message, /must be a constructor function/); + return true; + } + ); +}); + +test("schema field with non-function type (e.g. string) throws SchemaDefinitionError", () => { + class U extends Base { + static schema = { name: { type: "string" } }; + } + + assert.throws( + () => new U({ name: "hello" }), + (err) => { + assert.ok(err instanceof SchemaDefinitionError); + assert.match(err.message, /must be a constructor function/); + return true; + } + ); +}); + +test("null-prototype object throws TypeValidationError instead of raw TypeError", () => { + class U extends Base { + static schema = { info: String }; + } + + const nullObj = Object.create(null); + assert.throws( + () => new U({ info: nullObj }), + (err) => { + assert.ok(err instanceof TypeValidationError); + assert.match(err.message, /null-prototype object/); + assert.equal(err.code, "INVALID_TYPE"); + return true; + } + ); +}); + +test("Union(Array, String) with string value preserves string (no char-split)", () => { + class U extends Base { + static schema = { + val: { type: Union(Array, String), values: String } + }; + } + + const u = new U({ val: "hello" }); + assert.equal(typeof u.val, "string"); + assert.equal(u.val, "hello"); + assert.equal(Array.isArray(u.val), false); +}); + +test("Union(Array, String) with array value still validates as array", () => { + class U extends Base { + static schema = { + val: { type: Union(Array, String), values: String } + }; + } + + const u = new U({ val: ["a", "b"] }); + assert.ok(Array.isArray(u.val)); + assert.deepEqual(Array.from(u.val), ["a", "b"]); +}); + +test("Number field rejects string without coerce flag", () => { + class U extends Base { + static schema = { age: { type: Number } }; + } + + assert.throws( + () => new U({ age: "42" }), + (err) => { + assert.ok(err instanceof TypeValidationError); + assert.equal(err.code, "INVALID_TYPE"); + return true; + } + ); +}); + +test("Number field with coerce: true converts string to number", () => { + class U extends Base { + static schema = { age: { type: Number, coerce: true } }; + } + + const u = new U({ age: "42" }); + assert.ok(u.age instanceof Number || typeof u.age === "number"); +}); + +test("subclass with no static schema throws SchemaDefinitionError", () => { + class NoSchema extends Base {} + + assert.throws( + () => new NoSchema({}), + (err) => { + assert.ok(err instanceof SchemaDefinitionError); + assert.match(err.message, /has no schema defined/); + assert.equal(err.code, "SCHEMA_DEFINITION_ERROR"); + return true; + } + ); +}); + +test("Union(Object, String) with string value preserves string (does not run Object keys validations)", () => { + class U extends Base { + static schema = { + val: { + type: Union(Object, String), + keys: { + prop: { type: String, required: true } + } + } + }; + } + + const u = new U({ val: "hello" }); + assert.equal(typeof u.val, "string"); + assert.equal(u.val, "hello"); +}); +