@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
774 lines (707 loc) • 24.2 kB
text/typescript
import { SQLiteSchemaInspector } from "./sqlite-schema-inspector";
import {
CollectionTypeDefinition,
CollectionTypeValidationResult,
CollectionTypeIssue,
CollectionTypeAutoFixResult,
CollectionTypeFix,
CollectionTypeField as CollectionFieldType,
CollectionTypeIndex as CollectionIndexType,
CollectionTypeConstraint as CollectionConstraintType,
} from "./types";
/**
* Collections Type Validator
*
* Validates collection type definitions against actual database schema,
* detects mismatches, and provides auto-fixing capabilities.
*
* @class CollectionsTypeValidator
* @example
* const validator = new CollectionsTypeValidator(dbConnection, console);
* const result = await validator.validateCollectionType(typeDefinition, 'users');
*/
export class CollectionsTypeValidator {
private schemaInspector: SQLiteSchemaInspector;
private dbConnection: {
query: (sql: string, params?: unknown[]) => Promise<{ rows?: unknown[] }>;
};
private logger: Console;
constructor(
dbConnection: {
query: (sql: string, params?: unknown[]) => Promise<{ rows?: unknown[] }>;
},
logger: Console = console
) {
this.dbConnection = dbConnection;
this.logger = logger;
this.schemaInspector = new SQLiteSchemaInspector(dbConnection, logger);
}
/**
* Validate a collection type definition against the actual database schema
*/
async validateCollectionType(
typeDefinition: CollectionTypeDefinition,
tableName: string
): Promise<CollectionTypeValidationResult> {
const _startTime = Date.now();
const issues: CollectionTypeIssue[] = [];
const warnings: CollectionTypeIssue[] = [];
const recommendations: string[] = [];
try {
// Check if the table exists
const tableExists = await this.schemaInspector.tableExists(tableName);
if (!tableExists) {
issues.push({
type: "missing_field",
severity: "error",
description: `Table '${tableName}' does not exist in the database`,
auto_fixable: true,
});
} else {
// Get actual database schema
const dbSchema = await this.schemaInspector.getTableSchema(tableName);
// Validate fields
const fieldValidation = await this.validateFields(
typeDefinition.fields,
dbSchema.fields
);
issues.push(...fieldValidation.issues);
warnings.push(...fieldValidation.warnings);
// Validate indexes
const indexValidation = await this.validateIndexes(
typeDefinition.indexes,
dbSchema.indexes
);
issues.push(...indexValidation.issues);
warnings.push(...indexValidation.warnings);
// Validate constraints
const constraintValidation = await this.validateConstraints(
typeDefinition.constraints,
dbSchema.constraints
);
issues.push(...constraintValidation.issues);
warnings.push(...constraintValidation.warnings);
// Check for extra fields in database
const extraFieldIssues = this.checkExtraFields(
typeDefinition.fields,
dbSchema.fields
);
issues.push(...extraFieldIssues);
// Check for extra indexes in database
const extraIndexIssues = this.checkExtraIndexes(
typeDefinition.indexes,
dbSchema.indexes
);
issues.push(...extraIndexIssues);
}
// Generate recommendations
if (issues.length === 0 && warnings.length === 0) {
recommendations.push(
"Collection type definition is valid and matches database schema"
);
} else if (issues.length === 0) {
recommendations.push(
"Collection type definition is valid but has some warnings"
);
} else {
recommendations.push(
"Fix the identified issues to ensure schema consistency"
);
}
// Validation duration tracked for performance monitoring
void (Date.now() - _startTime);
return {
isValid: issues.length === 0,
issues,
warnings,
suggestions: recommendations.map((msg) => ({
type: "suggestion" as const,
severity: "info" as const,
description: msg,
auto_fixable: false,
})),
};
} catch (error) {
this.logger.error(`Validation failed for collection type:`, error);
return {
isValid: false,
issues: [
{
type: "error" as const,
severity: "error" as const,
description: `Validation failed: ${
error instanceof Error ? error.message : "Unknown error"
}`,
auto_fixable: false,
},
],
warnings: [],
suggestions: [
{
type: "suggestion" as const,
severity: "info" as const,
description: "Check database connection and try again",
auto_fixable: false,
},
],
};
}
}
/**
* Validate collection types for a project (batch validation)
*/
async validateCollectionTypes(
schema: CollectionTypeDefinition
): Promise<CollectionTypeValidationResult> {
try {
// For single schema validation, delegate to the main validation method
return await this.validateCollectionType(schema, schema.name);
} catch (error) {
this.logger.error(`Failed to validate collection types:`, error);
return {
isValid: false,
issues: [
{
type: "error" as const,
severity: "error" as const,
description: `Collection type validation failed: ${
error instanceof Error ? error.message : "Unknown error"
}`,
auto_fixable: false,
},
],
warnings: [],
suggestions: [
{
type: "suggestion" as const,
severity: "info" as const,
description: "Check schema definition and database connection",
auto_fixable: false,
},
],
};
}
}
/**
* Validate field definitions against database schema
*/
private async validateFields(
expectedFields: CollectionFieldType[],
actualFields: Array<{
name: string;
type: string;
nullable: boolean;
default?: string;
}>
): Promise<{
issues: CollectionTypeIssue[];
warnings: CollectionTypeIssue[];
}> {
const issues: CollectionTypeIssue[] = [];
const warnings: CollectionTypeIssue[] = [];
for (const expectedField of expectedFields) {
const actualField = actualFields.find(
(f) => f.name === expectedField.name
);
if (!actualField) {
const issue: CollectionTypeIssue = {
type: "missing_field",
severity: "error",
field: expectedField.name,
description: `Field '${expectedField.name}' is missing from database`,
auto_fixable: true,
};
if (expectedField.sqlite_type !== undefined) {
issue.expected = expectedField.sqlite_type;
}
issues.push(issue);
} else {
// Check field type
if (actualField.type !== expectedField.sqlite_type) {
const issue: CollectionTypeIssue = {
type: "wrong_type",
severity: "error",
field: expectedField.name,
actual: actualField.type,
description: `Field '${expectedField.name}' has wrong type: expected ${expectedField.sqlite_type}, got ${actualField.type}`,
auto_fixable: true,
};
if (expectedField.sqlite_type !== undefined) {
issue.expected = expectedField.sqlite_type;
}
issues.push(issue);
}
// Check nullable constraint
if (expectedField.required && actualField.nullable) {
issues.push({
type: "missing_constraint",
severity: "error",
field: expectedField.name,
description: `Field '${expectedField.name}' should be NOT NULL but is nullable in database`,
auto_fixable: true,
});
}
// Check default value
if (
expectedField.default !== undefined &&
actualField.default !== expectedField.default?.toString()
) {
warnings.push({
type: "warning",
severity: "warning",
field: expectedField.name,
description: `Field '${expectedField.name}' has different default value: expected ${expectedField.default}, got ${actualField.default}`,
auto_fixable: false,
});
}
}
}
return { issues, warnings };
}
/**
* Validate index definitions against database schema
*/
private async validateIndexes(
expectedIndexes: CollectionIndexType[],
actualIndexes: Array<{ name: string; fields: string[]; unique: boolean }>
): Promise<{
issues: CollectionTypeIssue[];
warnings: CollectionTypeIssue[];
}> {
const issues: CollectionTypeIssue[] = [];
const warnings: CollectionTypeIssue[] = [];
for (const expectedIndex of expectedIndexes) {
const actualIndex = actualIndexes.find(
(i) => i.name === expectedIndex.name
);
if (!actualIndex) {
issues.push({
type: "missing_index",
severity: "error",
field: expectedIndex.fields.join(", "),
description: `Index '${expectedIndex.name}' is missing from database`,
auto_fixable: true,
});
} else {
// Check index fields
const fieldsMatch = this.arraysEqual(
expectedIndex.fields,
actualIndex.fields
);
if (!fieldsMatch) {
issues.push({
type: "wrong_type",
severity: "error",
field: expectedIndex.fields.join(", "),
expected: expectedIndex.fields.join(", "),
actual: actualIndex.fields.join(", "),
description: `Index '${
expectedIndex.name
}' has wrong fields: expected [${expectedIndex.fields.join(
", "
)}], got [${actualIndex.fields.join(", ")}]`,
auto_fixable: true,
});
}
// Check unique constraint
if (expectedIndex.unique !== actualIndex.unique) {
issues.push({
type: "missing_constraint",
severity: "error",
field: expectedIndex.fields.join(", "),
description: `Index '${expectedIndex.name}' unique constraint mismatch: expected ${expectedIndex.unique}, got ${actualIndex.unique}`,
auto_fixable: true,
});
}
}
}
return { issues, warnings };
}
/**
* Validate constraint definitions against database schema
*/
private async validateConstraints(
expectedConstraints: CollectionConstraintType[],
actualConstraints: Array<{ name: string; type: string; fields: string[] }>
): Promise<{
issues: CollectionTypeIssue[];
warnings: CollectionTypeIssue[];
}> {
const issues: CollectionTypeIssue[] = [];
const warnings: CollectionTypeIssue[] = [];
for (const expectedConstraint of expectedConstraints) {
const actualConstraint = actualConstraints.find(
(c) => c.name === expectedConstraint.name
);
if (!actualConstraint) {
issues.push({
type: "missing_constraint",
severity: "error",
field: expectedConstraint.fields.join(", "),
description: `Constraint '${expectedConstraint.name}' is missing from database`,
auto_fixable: true,
});
} else {
// Check constraint type
if (actualConstraint.type !== expectedConstraint.type) {
issues.push({
type: "wrong_type",
severity: "error",
field: expectedConstraint.fields.join(", "),
expected: expectedConstraint.type,
actual: actualConstraint.type,
description: `Constraint '${expectedConstraint.name}' has wrong type: expected ${expectedConstraint.type}, got ${actualConstraint.type}`,
auto_fixable: true,
});
}
// Check constraint fields
const fieldsMatch = this.arraysEqual(
expectedConstraint.fields,
actualConstraint.fields
);
if (!fieldsMatch) {
issues.push({
type: "wrong_type",
severity: "error",
field: expectedConstraint.fields.join(", "),
expected: expectedConstraint.fields.join(", "),
actual: actualConstraint.fields.join(", "),
description: `Constraint '${
expectedConstraint.name
}' has wrong fields: expected [${expectedConstraint.fields.join(
", "
)}], got [${actualConstraint.fields.join(", ")}]`,
auto_fixable: true,
});
}
}
}
return { issues, warnings };
}
/**
* Check for extra fields in database that are not in the type definition
*/
private checkExtraFields(
expectedFields: CollectionFieldType[],
actualFields: Array<{
name: string;
type: string;
nullable: boolean;
default?: string;
}>
): CollectionTypeIssue[] {
const issues: CollectionTypeIssue[] = [];
const expectedFieldNames = expectedFields.map((f) => f.name);
const systemFields = ["id", "created_at", "updated_at", "project_id"];
for (const actualField of actualFields) {
if (
!expectedFieldNames.includes(actualField.name) &&
!systemFields.includes(actualField.name)
) {
issues.push({
type: "extra_field",
severity: "warning",
field: actualField.name,
actual: actualField.type,
description: `Field '${actualField.name}' exists in database but not in type definition`,
auto_fixable: false,
});
}
}
return issues;
}
/**
* Check for extra indexes in database that are not in the type definition
*/
private checkExtraIndexes(
expectedIndexes: CollectionIndexType[],
actualIndexes: Array<{ name: string; fields: string[]; unique: boolean }>
): CollectionTypeIssue[] {
const issues: CollectionTypeIssue[] = [];
const expectedIndexNames = expectedIndexes.map((i) => i.name);
const systemIndexes = ["_pkey"]; // Primary key index
for (const actualIndex of actualIndexes) {
if (
!expectedIndexNames.includes(actualIndex.name) &&
!systemIndexes.includes(actualIndex.name)
) {
issues.push({
type: "extra_field",
severity: "warning",
field: actualIndex.fields.join(", "),
actual: actualIndex.name,
description: `Index '${actualIndex.name}' exists in database but not in type definition`,
auto_fixable: false,
});
}
}
return issues;
}
/**
* Check for potential performance issues
*/
// @ts-expect-error - Method reserved for future use
private _checkPerformanceIssues(
typeDefinition: CollectionTypeDefinition
): string[] {
const warnings: string[] = [];
// Check for missing indexes on frequently queried fields
const stringFields = typeDefinition.fields.filter(
(f) => f.type === "string"
);
const numberFields = typeDefinition.fields.filter(
(f) => f.type === "number"
);
const dateFields = typeDefinition.fields.filter((f) => f.type === "date");
for (const field of [...stringFields, ...numberFields, ...dateFields]) {
if (
field.indexed &&
!typeDefinition.indexes.some((i) => i.fields.includes(field.name))
) {
warnings.push(
`Field '${field.name}' is marked as indexed but no index exists`
);
}
}
// Check for large text fields without proper indexing
const textFields = typeDefinition.fields.filter((f) => f.type === "text");
for (const field of textFields) {
if (!typeDefinition.indexes.some((i) => i.fields.includes(field.name))) {
warnings.push(
`Large text field '${field.name}' should have an index for better query performance`
);
}
}
// Check for composite indexes
if (typeDefinition.indexes.length > 0) {
const compositeIndexes = typeDefinition.indexes.filter(
(i) => i.fields.length > 1
);
if (compositeIndexes.length === 0) {
warnings.push(
"Consider adding composite indexes for fields that are frequently queried together"
);
}
}
return warnings;
}
/**
* Auto-fix collection type issues
*/
async autoFixCollectionType(
typeDefinition: CollectionTypeDefinition,
tableName: string
): Promise<CollectionTypeAutoFixResult> {
const startTime = Date.now();
const validation = await this.validateCollectionType(
typeDefinition,
tableName
);
if (validation.isValid) {
return {
success: true,
fixes_applied: [],
fixes_failed: [],
total_fixes: 0,
duration: Date.now() - startTime,
details: "Collection type is already valid",
};
}
const appliedFixes: CollectionTypeFix[] = [];
const failedFixes: CollectionTypeFix[] = [];
const details: string[] = [];
try {
for (const issue of validation.issues) {
if (issue.auto_fixable) {
try {
const fix = await this.applyAutoFix(
typeDefinition,
tableName,
issue
);
if (fix.success) {
appliedFixes.push(fix);
details.push(fix.description);
} else {
failedFixes.push(fix);
}
} catch (error) {
const failedFix: CollectionTypeFix = {
type: "modify_field",
...(issue.field !== undefined && { field: issue.field }),
description: `Failed to fix: ${issue.description}`,
sql: "",
success: false,
error: error instanceof Error ? error.message : "Unknown error",
execution_time: 0,
};
failedFixes.push(failedFix);
}
}
}
} catch (error) {
this.logger.error(`Error auto-fixing collection type:`, error);
}
const duration = Date.now() - startTime;
return {
success: appliedFixes.length > 0,
fixes_applied: appliedFixes,
fixes_failed: failedFixes,
total_fixes: appliedFixes.length,
duration,
details: details.join("; "),
};
}
/**
* Apply an auto-fix for a specific issue
*/
private async applyAutoFix(
typeDefinition: CollectionTypeDefinition,
tableName: string,
issue: CollectionTypeIssue
): Promise<CollectionTypeFix> {
const startTime = Date.now();
let sql = "";
let description = "";
try {
switch (issue.type) {
case "missing_field":
if (issue.field) {
const field = typeDefinition.fields.find(
(f) => f.name === issue.field
);
if (field) {
sql = `ALTER TABLE "${tableName}" ADD COLUMN "${field.name}" ${field.sqlite_type}`;
if (!field.required) {
sql += " NULL";
}
if (field.default !== undefined) {
sql += ` DEFAULT ${this.formatDefaultValue(field.default)}`;
}
await this.dbConnection.query(sql);
description = `Added missing field: ${issue.field}`;
}
}
break;
case "wrong_type":
if (issue.field && issue.expected) {
sql = `ALTER TABLE "${tableName}" ALTER COLUMN "${issue.field}" TYPE ${issue.expected}`;
await this.dbConnection.query(sql);
description = `Fixed field type: ${issue.field} -> ${issue.expected}`;
}
break;
case "missing_index":
if (issue.field) {
const index = typeDefinition.indexes.find(
(i) => i.fields.join(", ") === issue.field
);
if (index) {
const unique = index.unique ? "UNIQUE " : "";
const fields = index.fields.map((f) => `"${f}"`).join(", ");
sql = `CREATE ${unique}INDEX "${index.name}" ON "${tableName}" (${fields})`;
await this.dbConnection.query(sql);
description = `Created missing index: ${index.name}`;
}
}
break;
case "missing_constraint":
if (issue.field) {
const constraint = typeDefinition.constraints.find(
(c) => issue.field && c.fields.includes(issue.field)
);
if (constraint) {
sql = this.buildConstraintSQL(tableName, constraint);
await this.dbConnection.query(sql);
description = `Added missing constraint: ${constraint.name}`;
}
}
break;
default:
description = `No auto-fix available for issue type: ${issue.type}`;
break;
}
const executionTime = Date.now() - startTime;
return {
type: "modify_field",
...(issue.field !== undefined && { field: issue.field }),
description: description || `Fixed issue: ${issue.description}`,
sql,
success: true,
execution_time: executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
return {
type: "modify_field",
...(issue.field !== undefined && { field: issue.field }),
description: `Failed to fix: ${issue.description}`,
sql,
success: false,
error: error instanceof Error ? error.message : "Unknown error",
execution_time: executionTime,
};
}
}
/**
* Build SQL for adding a constraint
*/
private buildConstraintSQL(
tableName: string,
constraint: CollectionConstraintType
): string {
switch (constraint.type) {
case "primary_key":
return `ALTER TABLE "${tableName}" ADD CONSTRAINT "${
constraint.name
}" PRIMARY KEY (${constraint.fields.map((f) => `"${f}"`).join(", ")})`;
case "unique":
return `ALTER TABLE "${tableName}" ADD CONSTRAINT "${
constraint.name
}" UNIQUE (${constraint.fields.map((f) => `"${f}"`).join(", ")})`;
case "not_null":
return `ALTER TABLE "${tableName}" ALTER COLUMN "${constraint.fields[0]}" SET NOT NULL`;
case "check":
return `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraint.name}" CHECK (${constraint.expression})`;
case "foreign_key":
if (constraint.reference) {
const onDelete = constraint.reference.onDelete
? ` ON DELETE ${constraint.reference.onDelete}`
: "";
const onUpdate = constraint.reference.onUpdate
? ` ON UPDATE ${constraint.reference.onUpdate}`
: "";
return `ALTER TABLE "${tableName}" ADD CONSTRAINT "${
constraint.name
}" FOREIGN KEY (${constraint.fields
.map((f) => `"${f}"`)
.join(", ")}) REFERENCES "${constraint.reference.table}"("${
constraint.reference.field
}")${onDelete}${onUpdate}`;
}
break;
}
return "";
}
/**
* Format default value for SQL
*/
private formatDefaultValue(value: unknown): string {
if (typeof value === "string") {
return `'${value}'`;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value === null) {
return "NULL";
}
return `'${JSON.stringify(value)}'`;
}
/**
* Check if two arrays are equal
*/
private arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
return a.every((val, index) => val === b[index]);
}
}