From ba9cbc220d41afaaf1cbbef5c93e8b409cc0c4ca Mon Sep 17 00:00:00 2001 From: Mykola Bilokonsky Date: Mon, 9 Jun 2025 12:47:37 -0400 Subject: [PATCH] delta validation and error handling done --- __tests__/delta-validation.ts | 359 ++++++++++++++++++++++++++++++++++ src/delta-validation.ts | 188 ++++++++++++++++++ src/delta.ts | 3 + todo.md | 12 +- 4 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 __tests__/delta-validation.ts create mode 100644 src/delta-validation.ts diff --git a/__tests__/delta-validation.ts b/__tests__/delta-validation.ts new file mode 100644 index 0000000..2055ec0 --- /dev/null +++ b/__tests__/delta-validation.ts @@ -0,0 +1,359 @@ +import { DeltaV1, DeltaV2 } from "../src/delta"; +import { + InvalidDeltaFormatError, + MissingRequiredFieldError, + InvalidPointerError, + validateDeltaNetworkImageV1, + validateDeltaNetworkImageV2 +} from "../src/delta-validation"; + +describe("Delta Validation", () => { + describe("Invalid Delta Formats", () => { + describe("DeltaV1 validation", () => { + it("should throw error for non-object delta", () => { + expect(() => validateDeltaNetworkImageV1(null)).toThrow(InvalidDeltaFormatError); + expect(() => validateDeltaNetworkImageV1("string")).toThrow(InvalidDeltaFormatError); + expect(() => validateDeltaNetworkImageV1(123)).toThrow(InvalidDeltaFormatError); + expect(() => validateDeltaNetworkImageV1([])).toThrow(InvalidDeltaFormatError); + }); + + it("should throw error for invalid ID types", () => { + const invalidDeltas = [ + { id: null, timeCreated: 123, host: "host", creator: "creator", pointers: [] }, + { id: 123, timeCreated: 123, host: "host", creator: "creator", pointers: [] }, + { id: "", timeCreated: 123, host: "host", creator: "creator", pointers: [] }, + { id: {}, timeCreated: 123, host: "host", creator: "creator", pointers: [] } + ]; + + invalidDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidDeltaFormatError); + }); + }); + + it("should throw error for invalid timestamp", () => { + const invalidDeltas = [ + { id: "id", timeCreated: "123", host: "host", creator: "creator", pointers: [] }, + { id: "id", timeCreated: -123, host: "host", creator: "creator", pointers: [] }, + { id: "id", timeCreated: 0, host: "host", creator: "creator", pointers: [] }, + { id: "id", timeCreated: null, host: "host", creator: "creator", pointers: [] } + ]; + + invalidDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidDeltaFormatError); + }); + }); + + it("should throw error for invalid host/creator", () => { + const invalidDeltas = [ + { id: "id", timeCreated: 123, host: null, creator: "creator", pointers: [] }, + { id: "id", timeCreated: 123, host: "", creator: "creator", pointers: [] }, + { id: "id", timeCreated: 123, host: 123, creator: "creator", pointers: [] }, + { id: "id", timeCreated: 123, host: "host", creator: null, pointers: [] }, + { id: "id", timeCreated: 123, host: "host", creator: "", pointers: [] }, + { id: "id", timeCreated: 123, host: "host", creator: 123, pointers: [] } + ]; + + invalidDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidDeltaFormatError); + }); + }); + + it("should throw error for non-array pointers", () => { + const invalidDeltas = [ + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: null }, + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: {} }, + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: "pointers" }, + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: 123 } + ]; + + invalidDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidDeltaFormatError); + }); + }); + + it("should throw error for empty pointers array", () => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: [] }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidDeltaFormatError); + }); + + it("should throw error for invalid pointer structure", () => { + const invalidPointers = [ + [null], + ["string"], + [123], + [{ localContext: null, target: "target" }], + [{ localContext: "", target: "target" }], + [{ localContext: 123, target: "target" }], + [{ localContext: "context", target: undefined }], + [{ localContext: "context", target: {} }], + [{ localContext: "context", target: [] }] + ]; + + invalidPointers.forEach(pointers => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidPointerError); + }); + }); + + it("should throw error for invalid targetContext", () => { + const invalidPointers = [ + [{ localContext: "context", target: "target", targetContext: null }], + [{ localContext: "context", target: "target", targetContext: "" }], + [{ localContext: "context", target: "target", targetContext: 123 }], + [{ localContext: "context", target: "target", targetContext: {} }] + ]; + + invalidPointers.forEach(pointers => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidPointerError); + }); + }); + + it("should throw error for pointer consistency violation", () => { + // If targetContext exists, target must be a string (reference) + const pointers = [{ localContext: "context", target: 123, targetContext: "property" }]; + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(InvalidPointerError); + }); + }); + + describe("DeltaV2 validation", () => { + it("should throw error for non-object delta", () => { + expect(() => validateDeltaNetworkImageV2(null)).toThrow(InvalidDeltaFormatError); + expect(() => validateDeltaNetworkImageV2("string")).toThrow(InvalidDeltaFormatError); + expect(() => validateDeltaNetworkImageV2(123)).toThrow(InvalidDeltaFormatError); + expect(() => validateDeltaNetworkImageV2([])).toThrow(InvalidDeltaFormatError); + }); + + it("should throw error for invalid pointers object", () => { + const invalidDeltas = [ + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: null }, + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: [] }, + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: "pointers" }, + { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: 123 } + ]; + + invalidDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(InvalidDeltaFormatError); + }); + }); + + it("should throw error for empty pointers object", () => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers: {} }; + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(InvalidDeltaFormatError); + }); + + it("should throw error for invalid pointer keys", () => { + const invalidPointers = [ + { "": "value" } + ]; + + invalidPointers.forEach(pointers => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers }; + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(InvalidPointerError); + }); + }); + + it("should throw error for invalid pointer values", () => { + const invalidPointers = [ + { key: undefined }, + { key: [] }, + { key: true }, + { key: false } + ]; + + invalidPointers.forEach(pointers => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers }; + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(InvalidPointerError); + }); + }); + + it("should throw error for invalid reference format", () => { + const invalidReferences = [ + { key: {} }, // Empty reference + { key: { ref1: "val1", ref2: "val2" } }, // Multiple keys + { key: { "": "value" } }, // Empty key + { key: { ref: "" } }, // Empty value + { key: { ref: 123 } }, // Non-string value + { key: { ref: null } } // Null value + ]; + + invalidReferences.forEach(pointers => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator", pointers }; + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(InvalidPointerError); + }); + }); + }); + }); + + describe("Missing Required Fields", () => { + describe("DeltaV1", () => { + it("should throw MissingRequiredFieldError for missing id", () => { + const delta = { timeCreated: 123, host: "host", creator: "creator", pointers: [] }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(MissingRequiredFieldError); + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(/id/); + }); + + it("should throw MissingRequiredFieldError for missing timeCreated", () => { + const delta = { id: "id", host: "host", creator: "creator", pointers: [] }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(MissingRequiredFieldError); + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(/timeCreated/); + }); + + it("should throw MissingRequiredFieldError for missing host", () => { + const delta = { id: "id", timeCreated: 123, creator: "creator", pointers: [] }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(MissingRequiredFieldError); + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(/host/); + }); + + it("should throw MissingRequiredFieldError for missing creator", () => { + const delta = { id: "id", timeCreated: 123, host: "host", pointers: [] }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(MissingRequiredFieldError); + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(/creator/); + }); + + it("should throw MissingRequiredFieldError for missing pointers", () => { + const delta = { id: "id", timeCreated: 123, host: "host", creator: "creator" }; + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(MissingRequiredFieldError); + expect(() => validateDeltaNetworkImageV1(delta)).toThrow(/pointers/); + }); + }); + + describe("DeltaV2", () => { + it("should throw MissingRequiredFieldError for all missing fields", () => { + const requiredFields = ["id", "timeCreated", "host", "creator", "pointers"]; + + requiredFields.forEach(field => { + const delta: Record = { + id: "id", + timeCreated: 123, + host: "host", + creator: "creator", + pointers: { key: "value" } + }; + delete delta[field]; + + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(MissingRequiredFieldError); + expect(() => validateDeltaNetworkImageV2(delta)).toThrow(new RegExp(field)); + }); + }); + }); + }); + + describe("Valid Delta Formats", () => { + it("should accept valid DeltaV1", () => { + const validDeltas = [ + { + id: "uuid-123", + timeCreated: 123456789, + host: "host1", + creator: "creator1", + pointers: [{ localContext: "name", target: "Alice" }] + }, + { + id: "uuid-456", + timeCreated: 987654321, + host: "host2", + creator: "creator2", + pointers: [ + { localContext: "name", target: "Bob" }, + { localContext: "age", target: 25 }, + { localContext: "active", target: null } + ] + }, + { + id: "uuid-789", + timeCreated: 111111111, + host: "host3", + creator: "creator3", + pointers: [{ localContext: "friend", target: "user123", targetContext: "friendOf" }] + } + ]; + + validDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV1(delta)).not.toThrow(); + }); + }); + + it("should accept valid DeltaV2", () => { + const validDeltas = [ + { + id: "uuid-123", + timeCreated: 123456789, + host: "host1", + creator: "creator1", + pointers: { name: "Alice" } + }, + { + id: "uuid-456", + timeCreated: 987654321, + host: "host2", + creator: "creator2", + pointers: { + name: "Bob", + age: 25, + active: null + } + }, + { + id: "uuid-789", + timeCreated: 111111111, + host: "host3", + creator: "creator3", + pointers: { friend: { user123: "friendOf" } } + } + ]; + + validDeltas.forEach(delta => { + expect(() => validateDeltaNetworkImageV2(delta)).not.toThrow(); + }); + }); + }); + + describe("Delta class integration", () => { + it("should validate when creating DeltaV1 from network image", () => { + const invalidDelta = { + id: "id", + timeCreated: "not-a-number", + host: "host", + creator: "creator", + pointers: [{ localContext: "name", target: "value" }] + }; + + expect(() => DeltaV1.fromNetworkImage(invalidDelta as never)).toThrow(InvalidDeltaFormatError); + }); + + it("should validate when creating DeltaV2 from network image", () => { + const invalidDelta = { + id: "id", + timeCreated: 123, + host: "", + creator: "creator", + pointers: { name: "value" } + }; + + expect(() => DeltaV2.fromNetworkImage(invalidDelta as never)).toThrow(InvalidDeltaFormatError); + }); + + it("should accept valid network images", () => { + const validV1 = { + id: "uuid-123", + timeCreated: 123456789, + host: "host1", + creator: "creator1", + pointers: [{ localContext: "name", target: "Alice" }] + }; + + const validV2 = { + id: "uuid-456", + timeCreated: 987654321, + host: "host2", + creator: "creator2", + pointers: { name: "Bob" } + }; + + expect(() => DeltaV1.fromNetworkImage(validV1)).not.toThrow(); + expect(() => DeltaV2.fromNetworkImage(validV2)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/src/delta-validation.ts b/src/delta-validation.ts new file mode 100644 index 0000000..dd2fe31 --- /dev/null +++ b/src/delta-validation.ts @@ -0,0 +1,188 @@ +import { DeltaID, PointerTarget, DeltaNetworkImageV1, DeltaNetworkImageV2, PointersV2 } from "./delta"; +import { CreatorID, HostID, Timestamp } from "./types"; + +// Custom error types for delta operations +export class DeltaValidationError extends Error { + constructor(message: string, public field?: string) { + super(message); + this.name = "DeltaValidationError"; + } +} + +export class InvalidDeltaFormatError extends DeltaValidationError { + constructor(message: string, field?: string) { + super(message, field); + this.name = "InvalidDeltaFormatError"; + } +} + +export class MissingRequiredFieldError extends DeltaValidationError { + constructor(field: string) { + super(`Missing required field: ${field}`, field); + this.name = "MissingRequiredFieldError"; + } +} + +export class InvalidPointerError extends DeltaValidationError { + constructor(message: string, pointerIndex?: number) { + super(message, pointerIndex !== undefined ? `pointer[${pointerIndex}]` : undefined); + this.name = "InvalidPointerError"; + } +} + +// Validation functions +export function validateDeltaId(id: unknown): id is DeltaID { + if (typeof id !== "string" || id.length === 0) { + throw new InvalidDeltaFormatError("Delta ID must be a non-empty string", "id"); + } + return true; +} + +export function validateTimestamp(timestamp: unknown, field: string): timestamp is Timestamp { + if (typeof timestamp !== "number" || timestamp <= 0) { + throw new InvalidDeltaFormatError(`${field} must be a positive number`, field); + } + return true; +} + +export function validateHostId(host: unknown): host is HostID { + if (typeof host !== "string" || host.length === 0) { + throw new InvalidDeltaFormatError("Host ID must be a non-empty string", "host"); + } + return true; +} + +export function validateCreatorId(creator: unknown): creator is CreatorID { + if (typeof creator !== "string" || creator.length === 0) { + throw new InvalidDeltaFormatError("Creator ID must be a non-empty string", "creator"); + } + return true; +} + +export function validatePointerTarget(target: unknown): target is PointerTarget { + if (target !== null && typeof target !== "string" && typeof target !== "number") { + throw new InvalidPointerError("Pointer target must be string, number, or null"); + } + return true; +} + +export function validatePointerV1(pointer: unknown, index: number): pointer is { localContext: string; target: PointerTarget; targetContext?: string } { + if (!pointer || typeof pointer !== "object" || Array.isArray(pointer)) { + throw new InvalidPointerError(`Pointer at index ${index} must be an object`, index); + } + + const p = pointer as Record; + + if (typeof p.localContext !== "string" || p.localContext.length === 0) { + throw new InvalidPointerError(`Pointer at index ${index} must have a non-empty localContext`, index); + } + + validatePointerTarget(p.target); + + if (p.targetContext !== undefined && + (typeof p.targetContext !== "string" || p.targetContext.length === 0)) { + throw new InvalidPointerError(`Pointer at index ${index} targetContext must be a non-empty string if present`, index); + } + + // Validate pointer consistency: if targetContext exists, target must be a string (reference) + if (p.targetContext && typeof p.target !== "string") { + throw new InvalidPointerError(`Pointer at index ${index} with targetContext must have string target (reference)`, index); + } + + return true; +} + +export function validatePointersV1(pointers: unknown): pointers is Array<{ localContext: string; target: PointerTarget; targetContext?: string }> { + if (!Array.isArray(pointers)) { + throw new InvalidDeltaFormatError("Pointers must be an array", "pointers"); + } + + if (pointers.length === 0) { + throw new InvalidDeltaFormatError("Delta must have at least one pointer", "pointers"); + } + + (pointers as unknown[]).forEach((pointer, index) => validatePointerV1(pointer, index)); + return true; +} + +export function validatePointersV2(pointers: unknown): pointers is PointersV2 { + if (!pointers || typeof pointers !== "object" || Array.isArray(pointers)) { + throw new InvalidDeltaFormatError("Pointers must be an object", "pointers"); + } + + const keys = Object.keys(pointers); + if (keys.length === 0) { + throw new InvalidDeltaFormatError("Delta must have at least one pointer", "pointers"); + } + + for (const [key, value] of Object.entries(pointers)) { + if (key.length === 0) { + throw new InvalidPointerError("Pointer key must be a non-empty string"); + } + + if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "object") { + throw new InvalidPointerError(`Invalid pointer value for key '${key}'`); + } + + // If value is an object (Reference), validate it + if (value && typeof value === "object") { + const refKeys = Object.keys(value); + if (refKeys.length !== 1) { + throw new InvalidPointerError(`Reference pointer '${key}' must have exactly one key-value pair`); + } + const [refKey, refValue] = Object.entries(value)[0]; + if (typeof refKey !== "string" || refKey.length === 0) { + throw new InvalidPointerError(`Reference key in pointer '${key}' must be a non-empty string`); + } + if (typeof refValue !== "string" || refValue.length === 0) { + throw new InvalidPointerError(`Reference value in pointer '${key}' must be a non-empty string`); + } + } + } + + return true; +} + +export function validateDeltaNetworkImageV1(delta: unknown): delta is DeltaNetworkImageV1 { + if (!delta || typeof delta !== "object" || Array.isArray(delta)) { + throw new InvalidDeltaFormatError("Delta must be an object"); + } + + // Check required fields + if (!("id" in delta)) throw new MissingRequiredFieldError("id"); + if (!("timeCreated" in delta)) throw new MissingRequiredFieldError("timeCreated"); + if (!("host" in delta)) throw new MissingRequiredFieldError("host"); + if (!("creator" in delta)) throw new MissingRequiredFieldError("creator"); + if (!("pointers" in delta)) throw new MissingRequiredFieldError("pointers"); + + // Validate field types + validateDeltaId(delta.id); + validateTimestamp(delta.timeCreated, "timeCreated"); + validateHostId(delta.host); + validateCreatorId(delta.creator); + validatePointersV1(delta.pointers); + + return true; +} + +export function validateDeltaNetworkImageV2(delta: unknown): delta is DeltaNetworkImageV2 { + if (!delta || typeof delta !== "object" || Array.isArray(delta)) { + throw new InvalidDeltaFormatError("Delta must be an object"); + } + + // Check required fields + if (!("id" in delta)) throw new MissingRequiredFieldError("id"); + if (!("timeCreated" in delta)) throw new MissingRequiredFieldError("timeCreated"); + if (!("host" in delta)) throw new MissingRequiredFieldError("host"); + if (!("creator" in delta)) throw new MissingRequiredFieldError("creator"); + if (!("pointers" in delta)) throw new MissingRequiredFieldError("pointers"); + + // Validate field types + validateDeltaId(delta.id); + validateTimestamp(delta.timeCreated, "timeCreated"); + validateHostId(delta.host); + validateCreatorId(delta.creator); + validatePointersV2(delta.pointers); + + return true; +} \ No newline at end of file diff --git a/src/delta.ts b/src/delta.ts index 37166ab..7df1727 100644 --- a/src/delta.ts +++ b/src/delta.ts @@ -3,6 +3,7 @@ import Debug from 'debug'; import microtime from 'microtime'; import {PeerAddress} from "./peers"; import {CreatorID, DomainEntityID, HostID, PropertyID, Timestamp, TransactionID} from "./types"; +import {validateDeltaNetworkImageV1, validateDeltaNetworkImageV2} from "./delta-validation"; const debug = Debug('rz:delta'); export type DeltaID = string; @@ -75,6 +76,7 @@ export class DeltaV1 extends DeltaNetworkImageV1 { } static fromNetworkImage(delta: DeltaNetworkImageV1) { + validateDeltaNetworkImageV1(delta); return new DeltaV1(delta); } } @@ -98,6 +100,7 @@ export class DeltaV2 extends DeltaNetworkImageV2 { } static fromNetworkImage(delta: DeltaNetworkImageV2) { + validateDeltaNetworkImageV2(delta); return new DeltaV2(delta); } diff --git a/todo.md b/todo.md index 4dade32..229fdc6 100644 --- a/todo.md +++ b/todo.md @@ -4,12 +4,12 @@ This document tracks work needed to achieve full specification compliance, organ ## Phase 1: Foundation (Prerequisites) -### 1.1 Delta Validation & Error Handling -- [ ] Implement delta structure validation -- [ ] Add tests for invalid delta formats -- [ ] Add tests for required fields (id, created, pointers) -- [ ] Implement proper error types for delta operations -- [ ] Add validation for pointer consistency +### 1.1 Delta Validation & Error Handling ✅ +- [x] Implement delta structure validation +- [x] Add tests for invalid delta formats +- [x] Add tests for required fields (id, created, pointers) +- [x] Implement proper error types for delta operations +- [x] Add validation for pointer consistency ### 1.2 Complete Transaction Support - [ ] Implement transaction-based filtering in lossless views