UNPKG

synthex

Version:

Type-safe LLM response simulation with streaming & error injection

1,289 lines (1,288 loc) 46 kB
import * as fs from "fs"; let yaml = undefined; // NOTE: For YAML support, ensure 'js-yaml' is installed in your project. // Use dynamic import if needed in a Node.js environment. // Example: const yaml = await import('js-yaml'); class SchemaIO { /** * Serialize a schema to JSON string. */ static toJSON(schema) { return JSON.stringify(schema, null, 2); } /** * Deserialize a schema from JSON string. */ static fromJSON(json) { const obj = JSON.parse(json); return new SchemaForm(obj); } /** * Serialize a schema to YAML string (if js-yaml is available). */ static toYAML(schema) { if (!yaml) throw new Error("js-yaml not installed"); return yaml.dump(schema); } /** * Deserialize a schema from YAML string (if js-yaml is available). */ static fromYAML(yamlStr) { if (!yaml) throw new Error("js-yaml not installed"); const obj = yaml.load(yamlStr); return new SchemaForm(obj); } /** * Save a schema to a file (JSON or YAML by extension). */ static saveToFile(schema, filePath) { if (filePath.endsWith(".json")) { fs.writeFileSync(filePath, SchemaIO.toJSON(schema), "utf8"); } else if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) { fs.writeFileSync(filePath, SchemaIO.toYAML(schema), "utf8"); } else { throw new Error("Unsupported file extension"); } } /** * Load a schema from a file (JSON or YAML by extension). */ static loadFromFile(filePath) { const content = fs.readFileSync(filePath, "utf8"); if (filePath.endsWith(".json")) { return SchemaIO.fromJSON(content); } else if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) { return SchemaIO.fromYAML(content); } else { throw new Error("Unsupported file extension"); } } } export { SchemaIO }; /** * Represents a compiled schema for structured data, typically for an object type. * This is what m.object().build() returns. */ class SchemaForm { constructor(params) { this.type = "object"; this._isSchemaForm = true; this.name = params.name; this.version = params.version; this.fields = params.fields; this.required = params.required; this.min = params.min; this.max = params.max; this.items = params.items; this.properties = params.properties; this.enum = params.enum; this.pattern = params.pattern; this.format = params.format; this.unionTypes = params.unionTypes; this.intersectionTypes = params.intersectionTypes; this.reference = params.reference; this.nullableType = params.nullableType; this.probability = params.probability; this.condition = params.condition; this.template = params.template; this.simulateError = params.simulateError; this.errorType = params.errorType; this.generateFn = params.generateFn; } } /** * Error class for schema validation and generation errors. */ class SyntexError extends Error { constructor(message, code) { super(message); this.code = code; this.name = "SyntexError"; } } /** * Utility class for generating random values with constraints. * * */ class RandomGenerator { constructor(seed, randomness = "random") { this.seed = seed || Math.floor(Math.random() * 1000000); this.randomness = randomness; this.rng = this.createSeededRandom(this.seed); } createSeededRandom(seed) { return () => { let x = Math.sin(seed) * 10000; if (this.randomness === "deterministic") { x = Math.sin(x) * 10000; return x - Math.floor(x); } else if (this.randomness === "fuzz") { x = Math.sin(x + Math.random()) * 10000; return x - Math.floor(x); } else { return Math.random(); } }; } random() { return this.rng(); } randomInt(min, max) { return Math.floor(this.random() * (max - min + 1)) + min; } randomChoice(array) { return array[Math.floor(this.random() * array.length)]; } randomWeightedChoice(items) { let totalWeight = 0; const choices = items.map((item) => { if (typeof item === "object" && item !== null && "value" in item) { totalWeight += item.weight; return { value: item.value, weight: item.weight }; } else { totalWeight += 1; return { value: item, weight: 1 }; } }); let randomNum = this.random() * totalWeight; for (const choice of choices) { if (randomNum < choice.weight) { return choice.value; } } return this.randomChoice(choices.map((c) => c.value)); } randomString(length, charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") { let result = ""; for (let i = 0; i < length; i++) { result += charset.charAt(Math.floor(this.random() * charset.length)); } return result; } randomEmail() { const domains = [ "gmail.com", "yahoo.com", "outlook.com", "company.com", "example.org", ]; const names = [ "john", "jane", "alice", "bob", "charlie", "diana", "eve", "frank", ]; const name = this.randomChoice(names); const domain = this.randomChoice(domains); const number = this.randomInt(1, 999); return `${name}${number}@${domain}`; } randomUrl() { const protocols = ["https", "http"]; const domains = ["example.com", "test.org", "sample.net", "demo.co"]; const paths = ["", "/api", "/dashboard", "/users", "/products"]; const protocol = this.randomChoice(protocols); const domain = this.randomChoice(domains); const path = this.randomChoice(paths); return `${protocol}://${domain}${path}`; } randomUuid() { const hex = "0123456789abcdef"; let uuid = ""; for (let i = 0; i < 36; i++) { if (i === 8 || i === 13 || i === 18 || i === 23) { uuid += "-"; } else if (i === 14) { uuid += "4"; } else if (i === 19) { uuid += hex.charAt(this.randomInt(8, 11)); } else { uuid += hex.charAt(this.randomInt(0, 15)); } } return uuid; } randomDate(start, end) { const startTime = start?.getTime() || new Date("2020-01-01").getTime(); const endTime = end?.getTime() || new Date().getTime(); return new Date(startTime + this.random() * (endTime - startTime)); } randomBoolean() { return this.random() < 0.5; } } class MockGenerator { static registerPlugin(plugin) { MockGenerator._plugins.push(plugin); } constructor(options = {}) { this.requestCount = 0; this.lastReset = Date.now(); this.registeredSchemas = {}; this._plugins = []; this.options = options; this.rng = new RandomGenerator(options.seed, options.randomness); this._plugins = [...MockGenerator._plugins]; this._plugins.forEach((p) => p.onInit?.(this)); } /** * Registers a schema to be available for references within other schemas. * @param schema The SchemaForm to register. */ registerSchema(schema) { if (!schema.name) { throw new SyntexError("Cannot register a schema without a name for referencing.", "SCHEMA_NO_NAME"); } this.registeredSchemas[schema.name] = schema; } /** * Simulate streaming tokens for a given string field. * Returns an array of tokens and a finish reason. */ streamTokens(text, maxTokens, stopSequence) { let tokens = text.split(/\s+/); let finishReason = "stop"; if (stopSequence) { const stopIdx = tokens.findIndex((t) => t === stopSequence); if (stopIdx !== -1) { tokens = tokens.slice(0, stopIdx); finishReason = "stop"; } } if (maxTokens && tokens.length > maxTokens) { tokens = tokens.slice(0, maxTokens); finishReason = "length"; } return { tokens, finishReason }; } /** * Generates mock data based on the provided schema form. */ generate(schema) { try { this.validateSchema(schema); const now = Date.now(); this.handleRateLimiting(now); this.handleQuota(); this.handleGlobalErrorSimulation(); const context = this.options.context || {}; const generatedData = this.generateObjectData(schema.fields, context, {}); let tokenCount = 0; try { tokenCount = JSON.stringify(generatedData).length; } catch (jsonError) { throw new SyntexError(`Failed to stringify generated data for token count: ${jsonError}`, "TOKEN_COUNT_ERROR"); } if (this.options.maxTokens && tokenCount > this.options.maxTokens) { throw new SyntexError(`Max token limit exceeded (${tokenCount}/${this.options.maxTokens})`, "MAX_TOKEN_LIMIT"); } let dataWithRoles = generatedData; const roles = this.options.roles || []; if (roles.length > 0) { dataWithRoles = {}; for (const role of roles) { dataWithRoles[role] = { ...generatedData }; } } let tokens; let finishReason; const mainData = roles.includes("assistant") ? dataWithRoles["assistant"] : generatedData; if (mainData && typeof mainData === "object" && Object.keys(mainData).length > 0) { const firstStringFieldKey = Object.keys(mainData).find((k) => typeof mainData[k] === "string"); if (firstStringFieldKey) { const { tokens: tks, finishReason: fr } = this.streamTokens(mainData[firstStringFieldKey], this.options.maxTokens, this.options.metadata?.stopSequence); tokens = tks; finishReason = fr; } } const metadata = { generatedAt: new Date().toISOString(), generator: "Syntex", version: schema.version || "1.0.0", seed: this.options.seed, ...this.options.metadata, tokenUsage: tokenCount, modelInfo: this.options.modelInfo ?? "Syntex-llm", latencyMs: this.options.latencyMs ?? this.rng.randomInt(50, 500), roles: this.options.roles, log: this.options.logTrace ? { requestTime: now, requestId: this.rng.randomUuid() } : undefined, rateLimit: this.options.rateLimit, quota: this.options.quota, quotaUsed: this.options.quotaUsed, finishReason: finishReason, }; return { schema, data: dataWithRoles, metadata, tokens, finishReason, }; } catch (error) { if (error instanceof SyntexError) { throw error; } let errorMessage = "An unexpected error occurred during generation."; if (typeof error === "string") { errorMessage = error; } else if (error instanceof Error) { errorMessage = error.message; } throw new SyntexError(`Failed to generate mock data for schema "${schema.name || "unnamed"}": ${errorMessage}`, "GENERATION_ERROR"); } } /** * Generates multiple mock responses based on the schema. */ generateMultiple(schema, count) { const responses = []; for (let i = 0; i < count; i++) { responses.push(this.generate(schema)); } return responses; } /** * Generates mock data from a schema collection by name. */ generateFromCollection(collection, schemaName) { const schema = collection.schemas.find((s) => s.name === schemaName); if (!schema) { throw new SyntexError(`Schema "${schemaName}" not found in collection "${collection.name}"`, "SCHEMA_NOT_FOUND"); } this.registerSchema(schema); return this.generate(schema); } async *streamGenerate(schema, chunkSize, delayMs) { this.validateSchema(schema); const keys = Object.keys(schema.fields); let i = 0; const fullData = {}; const chunkSz = chunkSize ?? this.options.streamChunkSize ?? 1; const delay = delayMs ?? this.options.streamDelayMs ?? 50; while (i < keys.length) { if (this.options.abortSignal?.aborted) { throw new SyntexError("Stream aborted", "STREAM_ABORTED"); } const chunk = {}; const globalContext = this.options.context || {}; for (let j = 0; j < chunkSz && i < keys.length; j++, i++) { const key = keys[i]; const field = schema.fields[key]; const isRequired = field.required !== false; const probability = field.probability ?? 1.0; const shouldInclude = (isRequired || this.rng.random() < probability) && (!field.condition || field.condition(fullData, globalContext)); if (shouldInclude) { let value = this.generateFieldValue(field, globalContext, fullData); if (this.options.hallucinate && this.rng.random() < (this.options.hallucinationProbability ?? 0.1)) { value = this.simulateHallucination(field); } chunk[key] = value; fullData[key] = value; } } await new Promise((resolve) => setTimeout(resolve, delay)); yield chunk; } } /** * Simulate hallucination for a field (random or nonsense value). */ simulateHallucination(field) { switch (field.type) { case "string": return this.rng.randomString(12, "!@#$%^&*()_+1234567890abcdef"); case "number": return this.rng.randomInt(10000, 99999); case "boolean": return this.rng.random() > 0.5; case "array": return [this.simulateHallucination(field.items)]; case "object": return { hallucinated: true }; case "enum": return "???"; default: return null; } } /** * Simulate an OpenAI function/tool call response. */ simulateFunctionCall(functionName, args = {}) { if (!this.options.simulateFunctionCall) throw new SyntexError("Function call simulation not enabled", "NO_FUNCTION_CALL_SIM"); return { object: "function_call", function: functionName, arguments: args, result: { success: true, data: this.rng.randomString(8) }, finish_reason: "function_call", }; } formatOutput(response) { const format = this.options.outputFormat || "json"; switch (format) { case "json": return JSON.stringify(response.data, null, 2); case "xml": return this.toXML(response.data); case "markdown": return this.toMarkdown(response); default: return JSON.stringify(response.data, null, 2); } } toXML(data, rootName = "root") { function objToXml(obj, indent = " ") { if (obj === null || typeof obj === "undefined") { return ""; } if (Array.isArray(obj)) { return obj .map((item) => `${indent}<item>${objToXml(item, indent + " ")}</item>`) .join(""); } if (typeof obj !== "object") { return obj.toString(); } let xml = ""; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const value = obj[key]; xml += `\n${indent}<${key}>${objToXml(value, indent + " ")}</${key}>`; } } return xml; } return `<${rootName}>${objToXml(data, " ")}\n</${rootName}>`; } toMarkdown(response) { let md = `# Mock Response for ${response.schema.name || "Unnamed Schema"}\n`; md += `\n## Data\n`; md += "```json\n" + JSON.stringify(response.data, null, 2) + "\n```"; if (response.metadata) { md += `\n## Metadata\n`; md += "```json\n" + JSON.stringify(response.metadata, null, 2) + "\n```"; } return md; } validateSchema(schema) { if (!schema.fields || Object.keys(schema.fields).length === 0) { throw new SyntexError("Invalid schema: fields definition is missing or empty.", "INVALID_SCHEMA"); } for (const [fieldName, field] of Object.entries(schema.fields)) { this.validateField(fieldName, field); } } validateField(fieldName, field) { const validTypes = [ "string", "number", "boolean", "array", "object", "enum", "uuid", "email", "url", "date", "union", "intersection", "nullable", "reference", ]; if (!validTypes.includes(field.type)) { throw new SyntexError(`Invalid field type "${field.type}" for field "${fieldName}"`, "INVALID_FIELD_TYPE"); } if (field.type === "array" && !field.items) { throw new SyntexError(`Array field "${fieldName}" must have items definition`, "MISSING_ITEMS"); } if (field.type === "object" && !field.properties) { throw new SyntexError(`Object field "${fieldName}" must have properties definition`, "MISSING_PROPERTIES"); } if (field.type === "enum" && (!field.enum || field.enum.length === 0)) { throw new SyntexError(`Enum field "${fieldName}" must have enum values`, "MISSING_ENUM_VALUES"); } if (field.min !== undefined && field.max !== undefined && field.min > field.max) { throw new SyntexError(`Field "${fieldName}": min (${field.min}) cannot be greater than max (${field.max})`, "INVALID_MIN_MAX"); } } generateObjectData(fields, globalContext = {}, currentData = {}) { const data = currentData; for (const [fieldName, field] of Object.entries(fields)) { if (field.simulateError && this.rng.random() < 0.5) { data[fieldName] = { error: field.errorType ?? "SimulatedFieldError" }; continue; } const isRequired = field.required !== false; const probability = field.probability ?? 1.0; if (!isRequired && probability === 0) { continue; } const shouldInclude = (isRequired || this.rng.random() < probability) && (!field.condition || field.condition(data, globalContext)); if (shouldInclude) { let value = this.generateFieldValue(field, globalContext, data); if (this.options.hallucinate && this.rng.random() < (this.options.hallucinationProbability ?? 0.1)) { value = this.simulateHallucination(field); } data[fieldName] = value; } } return data; } interpolateTemplate(template, globalContext, currentData) { return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => { if (currentData[key] !== undefined) { return String(currentData[key]); } if (globalContext[key] !== undefined) { return String(globalContext[key]); } return ""; }); } generateFieldValue(field, globalContext = {}, currentData = {}) { for (const plugin of this._plugins) { const result = plugin.onGenerateField?.(field, globalContext, currentData, this.rng); if (result !== undefined) return result; } if (field.generateFn) { return field.generateFn(globalContext, currentData, this.rng); } switch (field.type) { case "string": return this.generateString(field, globalContext, currentData); case "number": return this.generateNumber(field); case "boolean": return this.rng.randomBoolean(); case "array": return this.generateArray(field, globalContext, currentData); case "object": return this.generateObjectData(field.properties, globalContext, {}); case "enum": return this.rng.randomWeightedChoice(field.enum); case "uuid": return this.rng.randomUuid(); case "email": return this.rng.randomEmail(); case "url": return this.rng.randomUrl(); case "date": return this.generateDate(field); case "union": return this.generateFieldValue(this.rng.randomChoice(field.unionTypes), globalContext, currentData); case "intersection": return field.intersectionTypes.reduce((acc, t) => { const val = this.generateFieldValue(t, globalContext, currentData); if (typeof val === "object" && val !== null) { return { ...acc, ...val }; } return val; }, {}); case "nullable": return this.rng.randomBoolean() ? this.generateFieldValue(field.nullableType, globalContext, currentData) : null; case "reference": if (!field.reference || !this.registeredSchemas[field.reference]) { throw new SyntexError(`Reference to unregistered schema "${field.reference}"`, "UNREGISTERED_SCHEMA_REFERENCE"); } return this.generateObjectData(this.registeredSchemas[field.reference].fields, globalContext, {}); default: throw new SyntexError(`Unsupported field type: ${field.type}`, "UNSUPPORTED_TYPE"); } } generateString(field, globalContext = {}, currentData = {}) { if (field.template) { return this.interpolateTemplate(field.template, globalContext, currentData); } if (field.pattern) { return this.generatePatternString(field.pattern); } const minLength = field.min || 1; const maxLength = field.max || 50; const length = this.rng.randomInt(minLength, maxLength); const words = [ "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore", "magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud", ]; let result = ""; while (result.length < length) { const word = this.rng.randomChoice(words); if (result.length + word.length + 1 <= length) { result += (result ? " " : "") + word; } else { break; } } return result || this.rng.randomString(length); } generatePatternString(pattern) { if (pattern.includes("[A-Z]")) { return this.rng.randomString(8, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); } if (pattern.includes("[0-9]")) { return this.rng.randomString(6, "0123456789"); } return this.rng.randomString(10); } generateNumber(field) { const min = field.min || 0; const max = field.max || 1000; if (Number.isInteger(min) && Number.isInteger(max)) { return this.rng.randomInt(min, max); } return min + this.rng.random() * (max - min); } generateArray(field, globalContext = {}, currentData = {}) { const minLength = field.min ?? 1; const maxLength = field.max ?? 5; if (minLength === 0 && maxLength === 0) { return []; } const length = this.rng.randomInt(minLength, maxLength); const array = []; for (let i = 0; i < length; i++) { array.push(this.generateFieldValue(field.items, globalContext, currentData)); } return array; } generateDate(field) { const start = this.options.dateRange?.start; const end = this.options.dateRange?.end; const date = this.rng.randomDate(start, end); if (field.format === "date-time") { return date.toISOString(); } return date.toISOString().split("T")[0]; } handleRateLimiting(now) { if (this.options.rateLimit && this.options.rateLimitIntervalMs) { if (now - this.lastReset > this.options.rateLimitIntervalMs) { this.requestCount = 0; this.lastReset = now; } this.requestCount++; if (this.requestCount > this.options.rateLimit) { throw new SyntexError("Rate limit exceeded", "RATE_LIMIT"); } } } handleQuota() { if (this.options.quota !== undefined && this.options.quotaUsed !== undefined) { if (this.options.quotaUsed >= this.options.quota) { throw new SyntexError("Quota exceeded", "QUOTA_EXCEEDED"); } this.options.quotaUsed++; } } handleGlobalErrorSimulation() { if (this.options.simulateError && ((this.options.errorProbability ?? 0.1) === 1 || this.rng.random() < (this.options.errorProbability ?? 0.1))) { const errorTypes = [ "TIMEOUT", "MODEL_UNAVAILABLE", "MALFORMED_REQUEST", "RATE_LIMIT", "INTERNAL_SERVER_ERROR", ]; throw new SyntexError("Simulated global error for testing.", this.rng.randomChoice(errorTypes)); } } } MockGenerator._plugins = []; /** * Base class for all schema field builders. * Handles common modifiers like required, min, max, probability, condition, template, etc. * @template TInfer The inferred TypeScript type of the field. */ class SBase { constructor(type) { this._field = {}; this._type = type; this._field.type = type; } /** Marks the field as required. */ required() { this._field.required = true; return this; } /** Marks the field as optional (default behavior). */ optional() { this._field.required = false; return this; } /** Sets the minimum value for numbers, minimum length for strings/arrays. */ min(val) { this._field.min = val; return this; } /** Sets the maximum value for numbers, maximum length for strings/arrays. */ max(val) { this._field.max = val; return this; } /** Sets a regex pattern for string generation. */ pattern(p) { this._field.pattern = p; return this; } /** Sets a format for date generation (e.g., "date-time" or "date"). */ format(f) { this._field.format = f; return this; } /** Sets the probability (0-1) that this field will be included in the output. */ probability(p) { if (p < 0 || p > 1) { throw new SyntexError("Probability must be between 0 and 1.", "INVALID_PROBABILITY"); } this._field.probability = p; return this; } /** * Defines a condition function for field inclusion. * The field will only be generated if the condition returns true. * @param conditionFn A function that receives the `currentData` (generated fields within the current object) and `globalContext` and returns a boolean. */ condition(conditionFn) { this._field.condition = conditionFn; return this; } /** * A simplified `when` helper for common conditional logic. * Includes the field only if `fieldName` in `currentData` equals `value`. * For more complex logic, use `condition()`. * @param fieldName The name of another field in the same object. * @param value The value the `fieldName` should have for this field to be included. */ when(fieldName, value) { return this.condition((currentData) => currentData[fieldName] === value); } /** Sets a string template for the field's value. Can use `{{key}}` for context values or other fields. */ template(t) { this._field.template = t; return this; } /** Enables simulation of an error for this specific field. */ simulateError(enable = true) { this._field.simulateError = enable; return this; } /** Sets a specific error type for a simulated field error. */ errorType(type) { this._field.errorType = type; return this; } /** * Provides a custom function to generate the field's value. * This overrides all other generation methods for this field. * @param fn A function that receives the `globalContext`, `currentData`, and `rng` instance and returns the value. */ generate(fn) { this._field.generateFn = fn; return this; } /** Returns the compiled SchemaField object. */ build() { return { ...this._field }; } } class SString extends SBase { constructor() { super("string"); } } class SNumber extends SBase { constructor() { super("number"); } } class SBoolean extends SBase { constructor() { super("boolean"); } } class SUUID extends SBase { constructor() { super("uuid"); } } class SEmail extends SBase { constructor() { super("email"); } } class SURL extends SBase { constructor() { super("url"); } } class SDate extends SBase { constructor() { super("date"); } } class SEnum extends SBase { constructor(values) { super("enum"); if (!Array.isArray(values) || values.length === 0) { throw new SyntexError("Enum must have at least one value.", "MISSING_ENUM_VALUES"); } this._field.enum = values; } } class SArray extends SBase { constructor(item) { super("array"); this._field.items = item.build(); } } class SObject extends SBase { constructor(fields) { super("object"); this._fields = fields; this._field.properties = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v.build()])); } /** * Extends the current object schema with additional fields. * @param additionalFields A record of new SBase fields to add. */ extend(additionalFields) { const extendedFields = { ...this._fields, ...additionalFields, }; const newObj = new SObject(extendedFields); return newObj; } /** * Creates a new object schema with only the specified fields. * @param keys An array of keys to pick from the current schema. */ pick(keys) { const pickedFields = {}; for (const key of keys) { if (key in this._fields) { pickedFields[key] = this._fields[key]; } } const newObj = new SObject(pickedFields); return newObj; } /** * Creates a new object schema by omitting the specified fields. * @param keys An array of keys to omit from the current schema. */ omit(keys) { const omittedFields = { ...this._fields }; for (const key of keys) { delete omittedFields[key]; } const newObj = new SObject(omittedFields); return newObj; } /** Returns the compiled SchemaField object. */ build(name, version) { const compiledFields = Object.fromEntries(Object.entries(this._fields).map(([k, v]) => [k, v.build()])); const schema = new SchemaForm({ name, version, fields: compiledFields, properties: compiledFields, }); return schema; } } class SUnion extends SBase { constructor(types) { super("union"); this._field.unionTypes = types.map((t) => t.build()); } build() { return { ...this._field }; } } class SIntersection extends SBase { constructor(types) { super("intersection"); this._field.intersectionTypes = types.map((t) => t.build()); } build() { return { ...this._field }; } } class SNullable extends SBase { constructor(type) { super("nullable"); this._field.nullableType = type.build(); } build() { return { ...this._field }; } } class SReference extends SBase { constructor(ref) { super("reference"); this._field.reference = ref; } } class SnapshotUtils { static toSnapshot(data) { return JSON.stringify(data, null, 2); } static compareSnapshot(data, snapshot) { return SnapshotUtils.toSnapshot(data) === snapshot; } } class DocGenerator { static toMarkdown(schema) { const lines = [ `# ${schema.name || "Unnamed"} Schema`, `\n| Field | Type | Required | Description |`, `|-------|------|----------|-------------|`, ]; for (const [fieldName, field] of Object.entries(schema.fields)) { lines.push(`| ${fieldName} | ${DocGenerator.fieldType(field)} | ${field.required !== false ? "Yes" : "No"} | |`); } return lines.join("\n"); } static fieldType(field) { switch (field.type) { case "string": return "string"; case "number": return "number"; case "boolean": return "boolean"; case "uuid": return "uuid"; case "email": return "email"; case "url": return "url"; case "date": return `date(${field.format || "date"})`; case "enum": const enumValues = field.enum ? field.enum.map((v) => typeof v === "object" && "value" in v ? JSON.stringify(v.value) : JSON.stringify(v)) : []; return `Enum<${enumValues.join(" | ")}>`; case "array": return `Array<${DocGenerator.fieldType(field.items)}>`; case "object": return "object"; case "union": return field.unionTypes ? field.unionTypes.map(DocGenerator.fieldType).join(" | ") : "any"; case "intersection": return field.intersectionTypes ? field.intersectionTypes.map(DocGenerator.fieldType).join(" & ") : "any"; case "nullable": return `${DocGenerator.fieldType(field.nullableType)} | null`; case "reference": return `ref:${field.reference}`; default: return field.type; } } } /** * Creates instances of the MockGenerator with various configurations. */ class SyntexFactory { static createGenerator(options = {}) { return new MockGenerator(options); } static createSeededGenerator(seed) { return new MockGenerator({ seed }); } static createLocalizedGenerator(locale, options = {}) { return new MockGenerator({ ...options, locale }); } } class SchemaUtils { /** * Creates a basic SchemaForm for an object type. * @deprecated Use `m.object({...}).build('SchemaName', '1.0.0')` directly for better type inference. */ static createBasicSchema(name, fields) { return new SchemaForm({ name, version: "1.0.0", fields: fields, properties: fields, }); } static createCollection(name, schemas) { return { name, version: "1.0.0", schemas, }; } /** * Merges two SchemaForms (object types). The fields from `extensionSchema` will override those in `baseSchema`. * @param baseSchema The base SchemaForm. * @param extensionSchema The SchemaForm to extend with. * @returns A new SchemaForm with merged fields. */ static mergeSchemas(baseSchema, extensionSchema) { const mergedFields = { ...baseSchema.fields, ...extensionSchema.fields, }; const newSchema = new SObject(mergedFields).build(`${baseSchema.name || "Unnamed"}Extended`, baseSchema.version || extensionSchema.version); return newSchema; } static validateData(data, schema) { for (const [fieldName, field] of Object.entries(schema.fields)) { if (field.required !== false && !(fieldName in data)) { return false; } if (fieldName in data) { if (!SchemaUtils.validateType(data[fieldName], field)) { return false; } } } return true; } static validateType(value, field) { switch (field.type) { case "string": return typeof value === "string"; case "number": return typeof value === "number"; case "boolean": return typeof value === "boolean"; case "uuid": case "email": case "url": return typeof value === "string"; case "date": return typeof value === "string" && !isNaN(Date.parse(value)); case "enum": if (!Array.isArray(field.enum)) return false; const enumPlainValues = field.enum.map((e) => typeof e === "object" && "value" in e ? e.value : e); return enumPlainValues.includes(value); case "array": return (Array.isArray(value) && value.every((v) => SchemaUtils.validateType(v, field.items))); case "object": return (typeof value === "object" && value !== null && SchemaUtils.validateData(value, new SchemaForm({ fields: field.properties, }))); case "union": return (Array.isArray(field.unionTypes) && field.unionTypes.some((t) => SchemaUtils.validateType(value, t))); case "intersection": return (typeof value === "object" && value !== null && Array.isArray(field.intersectionTypes) && field.intersectionTypes.every((t) => SchemaUtils.validateType(value, t))); case "nullable": return (value === null || SchemaUtils.validateType(value, field.nullableType)); case "reference": return typeof value === "object" && value !== null; default: return false; } } } /** * Utility for generating TypeScript interface strings from schemas. */ class TypeInference { /** * Generates a TypeScript interface string from a compiled SchemaForm. * This is provided for documentation/tooling; for direct type inference, * use `typeof mySchema.infer`. * @param schema The SchemaForm object. * @returns A string representing the TypeScript interface. */ static inferType(schema) { const lines = [ `interface ${schema.name || "GeneratedInterface"} {`, ]; for (const [fieldName, field] of Object.entries(schema.fields)) { const isOptional = field.required === false || (field.probability !== undefined && field.probability < 1); lines.push(` ${fieldName}${isOptional ? "?" : ""}: ${TypeInference.fieldType(field)};`); } lines.push("}"); return lines.join("\n"); } static fieldType(field) { let typeStr; switch (field.type) { case "string": typeStr = "string"; break; case "number": typeStr = "number"; break; case "boolean": typeStr = "boolean"; break; case "uuid": case "email": case "url": case "date": typeStr = "string"; break; case "enum": typeStr = field.enum ? field.enum .map((v) => typeof v === "object" && "value" in v ? JSON.stringify(v.value) : JSON.stringify(v)) .join(" | ") : "any"; break; case "array": typeStr = `${TypeInference.fieldType(field.items)}[]`; break; case "object": const properties = Object.entries(field.properties || {}) .map(([k, v]) => { const isOptional = v.required === false || (v.probability !== undefined && v.probability < 1); return `${k}${isOptional ? "?" : ""}: ${TypeInference.fieldType(v)}`; }) .join("; "); typeStr = `{ ${properties} }`; break; case "union": typeStr = field.unionTypes ? field.unionTypes.map(TypeInference.fieldType).join(" | ") : "any"; break; case "intersection": typeStr = field.intersectionTypes ? field.intersectionTypes.map(TypeInference.fieldType).join(" & ") : "any"; break; case "nullable": typeStr = `${TypeInference.fieldType(field.nullableType)} | null`; break; case "reference": typeStr = field.reference || "any"; break; default: typeStr = "any"; break; } return typeStr; } } /** * The main entry point for building Syntex schemas. */ export const s = { string: () => new SString(), number: () => new SNumber(), boolean: () => new SBoolean(), uuid: () => new SUUID(), email: () => new SEmail(), url: () => new SURL(), date: () => new SDate(), /** * Defines an enum field. Can accept a simple array of values or an array of objects * with `value` and `weight` for probabilistic generation. * @example * m.enum(['red', 'green', 'blue']) * m.enum([{ value: 'admin', weight: 0.8 }, { value: 'user', weight: 0.2 }]) */ enum: (values) => new SEnum(values), array: (item) => new SArray(item), /** * Defines an object field with nested properties. * @example * m.object({ * id: m.uuid().required(), * name: m.string(), * }).build('UserSchema'); */ object: (fields) => new SObject(fields), union: (types) => new SUnion(types), intersection: (types) => new SIntersection(types), nullable: (type) => new SNullable(type), /** * Defines a reference to another named schema within a SchemaCollection. * The referenced schema must be registered with the MockGenerator or included in a collection. * @example * m.object({ * organization: m.reference('OrganizationSchema') * }) */ reference: (ref) => new SReference(ref), }; export { SchemaForm, SyntexError, MockGenerator, SyntexFactory, TypeInference, SnapshotUtils, DocGenerator, SchemaUtils, };