@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
1,659 lines (1,537 loc) • 75.3 kB
text/typescript
import { CollectionsSchemaManager } from "./collections-schema-manager";
import { KrapiError } from "./core/krapi-error";
import { SQLiteSchemaInspector } from "./sqlite-schema-inspector";
import {
Collection,
CollectionField,
FieldType,
FieldValidation,
RelationConfig,
FieldDefinition,
IndexDefinition,
CollectionSettings,
} from "./types";
import { normalizeError } from "./utils/error-handler";
import { logServiceOperationError } from "./utils/error-logger";
export interface Document {
id: string;
collection_id: string;
project_id: string;
data: Record<string, unknown>;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
version: number;
is_deleted: boolean;
deleted_at?: string;
deleted_by?: string;
}
export interface DocumentFilter {
field_filters?: Record<string, unknown>;
search?: string;
created_after?: string;
created_before?: string;
updated_after?: string;
updated_before?: string;
created_by?: string;
updated_by?: string;
include_deleted?: boolean;
}
export interface DocumentQueryOptions {
limit?: number;
offset?: number;
sort_by?: string;
sort_order?: "asc" | "desc";
select_fields?: string[];
}
export interface CreateDocumentRequest {
data: Record<string, unknown>;
created_by?: string;
}
export interface CollectionStatistics {
total_documents: number;
total_size_bytes: number;
average_document_size: number;
field_statistics: Record<
string,
{
null_count: number;
unique_values: number;
most_common_values?: Array<{ value: unknown; count: number }>;
}
>;
index_usage: Record<
string,
{
size_bytes: number;
scans: number;
last_used?: string;
}
>;
}
export interface UpdateDocumentRequest {
data: Record<string, unknown>;
updated_by?: string;
}
export interface DatabaseConnection {
query: (
sql: string,
params?: unknown[]
) => Promise<{ rows: unknown[]; rowCount: number }>;
}
/**
* Collections Service
*
* High-level service for managing dynamic collections with schema validation,
* auto-fixing, and TypeScript interface generation.
*
* @class CollectionsService
* @example
* const collectionsService = new CollectionsService(dbConnection, logger);
* const collection = await collectionsService.createCollection({
* name: 'users',
* fields: [{ name: 'email', type: FieldType.string, required: true }]
* });
*/
export class CollectionsService {
private schemaManager: CollectionsSchemaManager;
private schemaInspector: SQLiteSchemaInspector;
private db: DatabaseConnection;
/**
* Create a new CollectionsService instance
*
* @param {DatabaseConnection} databaseConnection - Database connection
* @param {Console} [logger=console] - Logger instance
*/
constructor(
databaseConnection: DatabaseConnection,
private logger: Console = console
) {
this.db = databaseConnection;
this.schemaManager = new CollectionsSchemaManager(
databaseConnection,
logger
);
this.schemaInspector = new SQLiteSchemaInspector(
databaseConnection,
logger
);
}
/**
* Map database row to Document interface
*/
private mapDocument(row: Record<string, unknown>): Document {
// Parse data from JSON string (SQLite stores JSON as TEXT)
let parsedData: Record<string, unknown> = {};
if (typeof row.data === "string") {
try {
parsedData = JSON.parse(row.data);
} catch (error) {
this.logger.error("Error parsing document data JSON:", error);
parsedData = {};
}
} else if (typeof row.data === "object" && row.data !== null) {
parsedData = row.data as Record<string, unknown>;
}
const document: Document = {
id: row.id as string,
collection_id: row.collection_id as string,
project_id: (row.project_id as string) || "",
data: parsedData,
version: (row.version as number | undefined) || 1,
is_deleted: (row.is_deleted as boolean | undefined) || false,
created_at: row.created_at as string,
updated_at: (row.updated_at as string) || row.created_at as string,
created_by: (row.created_by as string | undefined) || "system",
updated_by: (row.updated_by as string | undefined) || (row.created_by as string | undefined) || "system",
};
if (row.deleted_at !== undefined && row.deleted_at !== null) {
(document as { deleted_at?: string }).deleted_at = row.deleted_at as string;
}
if (row.deleted_by !== undefined && row.deleted_by !== null) {
(document as { deleted_by?: string }).deleted_by = row.deleted_by as string;
}
return document;
}
/**
* Create a new collection with custom schema
* Example: Create an "articles" collection with title, content, author fields
*/
/**
* Create a new collection
*
* Creates a new collection with the specified schema and fields.
* Automatically creates the database table and indexes.
*
* @param {Object} collectionData - Collection creation data
* @param {string} collectionData.project_id - Project ID
* @param {string} collectionData.name - Collection name (required)
* @param {string} [collectionData.description] - Collection description
* @param {CollectionField[]} collectionData.fields - Collection field definitions
* @param {CollectionSettings} [collectionData.settings] - Collection settings
* @returns {Promise<Collection>} Created collection
* @throws {Error} If creation fails or collection name already exists
*
* @example
* const collection = await collectionsService.createCollection({
* project_id: 'project-id',
* name: 'users',
* description: 'User collection',
* fields: [
* { name: 'email', type: FieldType.string, required: true },
* { name: 'name', type: FieldType.string, required: true }
* ]
* });
*/
async createCollection(collectionData: {
name: string;
description?: string;
fields: Array<{
name: string;
type: FieldType;
required?: boolean;
unique?: boolean;
indexed?: boolean;
default?: unknown;
description?: string;
validation?: FieldValidation;
relation?: RelationConfig;
}>;
indexes?: Array<{
name: string;
fields: string[];
unique?: boolean;
}>;
}): Promise<Collection> {
// Validate collection name
if (!this.isValidCollectionName(collectionData.name)) {
throw KrapiError.validationError(
"Invalid collection name. Use only letters, numbers, and underscores.",
"name",
collectionData.name
);
}
// Check if collection already exists
const existingCollection = await this.getCollectionByName(
collectionData.name
);
if (existingCollection) {
throw KrapiError.conflict(
`Collection "${collectionData.name}" already exists`,
{
resource: "collections",
operation: "createCollection",
collectionName: collectionData.name
}
);
}
// Validate fields
this.validateCollectionFields(collectionData.fields);
// Create the collection
const collection = await this.schemaManager.createCollection(
collectionData
);
this.logger.info(
`Created collection "${collection.name}" with ${collection.schema.fields.length} fields`
);
return collection;
}
/**
* Create a collection from a predefined template
*
* Creates a collection using a template (e.g., 'users', 'posts', 'products')
* with optional customizations.
*
* @param {string} templateName - Template name
* @param {Object} [customizations] - Optional customizations
* @param {string} [customizations.name] - Custom collection name
* @param {string} [customizations.description] - Custom description
* @param {Array} [customizations.additionalFields] - Additional fields to add
* @returns {Promise<Collection>} Created collection
* @throws {Error} If template not found or creation fails
*
* @example
* const collection = await collectionsService.createCollectionFromTemplate('users', {
* name: 'customers',
* additionalFields: [{ name: 'phone', type: FieldType.string }]
* });
*/
async createCollectionFromTemplate(
templateName: string,
customizations?: {
name?: string;
description?: string;
additionalFields?: Array<{
name: string;
type: FieldType;
required?: boolean;
unique?: boolean;
indexed?: boolean;
description?: string;
}>;
}
): Promise<Collection> {
const template = this.getCollectionTemplate(templateName);
if (!template) {
throw KrapiError.notFound(
`Collection template '${templateName}' not found`,
{
templateName,
operation: "createCollectionFromTemplate"
}
);
}
const collectionData = {
name: customizations?.name || template.name,
description: customizations?.description || template.description,
fields: [...template.fields, ...(customizations?.additionalFields || [])],
indexes: template.indexes,
};
return this.createCollection(collectionData);
}
/**
* Update collection schema
*
* Updates a collection's schema by adding, removing, or modifying fields.
* Automatically updates the database table structure.
*
* @param {string} collectionId - Collection ID
* @param {Object} updates - Schema updates
* @param {Array} [updates.addFields] - Fields to add
* @param {string[]} [updates.removeFields] - Field names to remove
* @param {Array} [updates.modifyFields] - Fields to modify
* @returns {Promise<Collection>} Updated collection
* @throws {Error} If update fails or collection not found
*
* @example
* const updated = await collectionsService.updateCollectionSchema('collection-id', {
* addFields: [{ name: 'age', type: FieldType.number }],
* removeFields: ['old_field']
* });
*/
async updateCollectionSchema(
collectionId: string,
updates: {
addFields?: Array<{
name: string;
type: FieldType;
required?: boolean;
unique?: boolean;
indexed?: boolean;
default?: unknown;
description?: string;
validation?: FieldValidation;
relation?: RelationConfig;
}>;
removeFields?: string[];
modifyFields?: Array<{
name: string;
type?: FieldType;
required?: boolean;
unique?: boolean;
indexed?: boolean;
default?: unknown;
description?: string;
validation?: FieldValidation;
relation?: RelationConfig;
}>;
addIndexes?: Array<{
name: string;
fields: string[];
unique?: boolean;
}>;
removeIndexes?: string[];
}
): Promise<Collection> {
const collection = await this.schemaManager.getCollection(collectionId);
if (!collection) {
throw KrapiError.notFound(`Collection ${collectionId} not found`, {
collectionId,
});
}
// Apply updates
const updatedFields = [...(collection.fields || [])];
const updatedIndexes = [...(collection.indexes || [])];
// Add new fields
if (updates.addFields) {
for (const field of updates.addFields) {
if (updatedFields.find((f) => f.name === field.name)) {
throw KrapiError.conflict(`Field "${field.name}" already exists`, {
fieldName: field.name,
collectionId,
});
}
const fieldToAdd: CollectionField = {
name: field.name,
type: field.type,
required: field.required ?? false,
unique: field.unique ?? false,
indexed: field.indexed ?? false,
description: field.description || "",
};
if (field.default !== undefined) {
fieldToAdd.default = field.default;
}
if (field.validation !== undefined) {
fieldToAdd.validation = field.validation;
}
if (field.relation !== undefined) {
fieldToAdd.relation = field.relation as unknown as Record<
string,
unknown
>;
}
updatedFields.push(fieldToAdd);
}
}
// Remove fields
if (updates.removeFields) {
for (const fieldName of updates.removeFields) {
const index = updatedFields.findIndex((f) => f.name === fieldName);
if (index === -1) {
throw KrapiError.notFound(`Field "${fieldName}" not found`, {
fieldName,
collectionId,
});
}
updatedFields.splice(index, 1);
}
}
// Modify fields
if (updates.modifyFields) {
for (const modification of updates.modifyFields) {
const field = updatedFields.find((f) => f.name === modification.name);
if (!field) {
throw KrapiError.notFound(`Field "${modification.name}" not found`, {
fieldName: modification.name,
collectionId,
});
}
Object.assign(field, modification);
if (modification.description !== undefined) {
field.description = modification.description || "";
}
}
}
// Add indexes
if (updates.addIndexes) {
for (const index of updates.addIndexes) {
if (updatedIndexes.find((i) => i.name === index.name)) {
throw KrapiError.conflict(`Index "${index.name}" already exists`, {
indexName: index.name,
collectionId,
});
}
updatedIndexes.push(index);
}
}
// Remove indexes
if (updates.removeIndexes) {
for (const indexName of updates.removeIndexes) {
const index = updatedIndexes.findIndex((i) => i.name === indexName);
if (index === -1) {
throw KrapiError.notFound(`Index "${indexName}" not found`, {
indexName,
collectionId,
});
}
updatedIndexes.splice(index, 1);
}
}
// Update the collection
const updatedCollection = await this.schemaManager.updateCollection(
collectionId,
{
fields: updatedFields,
indexes: updatedIndexes,
updated_at: new Date().toISOString(),
}
);
this.logger.info(`Updated collection "${updatedCollection.name}" schema`);
return updatedCollection;
}
/**
* Validate collection schema against database
*/
async validateCollection(collectionId: string): Promise<{
isValid: boolean;
issues: Array<{
type:
| "missing_field"
| "wrong_type"
| "missing_index"
| "missing_constraint"
| "extra_field";
field?: string;
expected?: string;
actual?: string;
description: string;
severity: "error" | "warning" | "info";
}>;
recommendations: string[];
}> {
const collection = await this.schemaManager.getCollection(collectionId);
if (!collection) {
throw KrapiError.notFound(`Collection ${collectionId} not found`, {
collectionId,
});
}
const validation = await this.schemaManager.validateCollectionSchema(
collectionId
);
// Add severity levels to issues
const issuesWithSeverity = validation.issues.map((issue) => ({
...issue,
severity: this.getIssueSeverity(issue.type) as
| "error"
| "warning"
| "info",
}));
// Generate recommendations
const recommendations = this.generateRecommendations(
validation.issues,
collection
);
return {
isValid: validation.isValid,
issues: issuesWithSeverity,
recommendations,
};
}
/**
* Auto-fix collection schema issues
*/
async autoFixCollection(collectionId: string): Promise<{
success: boolean;
fixesApplied: number;
details: string[];
remainingIssues: number;
}> {
const result = await this.schemaManager.autoFixCollectionSchema(
collectionId
);
// Check if there are remaining issues after auto-fix
const remainingValidation = await this.validateCollection(collectionId);
return {
...result,
remainingIssues: remainingValidation.issues.length,
};
}
/**
* Generate TypeScript interface for a collection
*/
async generateTypeScriptInterface(collectionId: string): Promise<string> {
const collection = await this.schemaManager.getCollection(collectionId);
if (!collection) {
throw KrapiError.notFound(`Collection ${collectionId} not found`, {
collectionId,
});
}
return this.schemaManager.generateTypeScriptInterface(collection);
}
/**
* Generate all TypeScript interfaces
*/
async generateAllTypeScriptInterfaces(): Promise<string> {
return this.schemaManager.generateAllTypeScriptInterfaces();
}
/**
* Get collection health status
*/
async getCollectionHealth(collectionId: string): Promise<{
status: "healthy" | "degraded" | "unhealthy";
schemaValid: boolean;
dataIntegrity: {
hasNullViolations: boolean;
hasUniqueViolations: boolean;
hasForeignKeyViolations: boolean;
issues: string[];
};
tableStats: {
rowCount: number;
sizeBytes: number;
indexSizeBytes: number;
};
lastValidated: string;
}> {
const collection = await this.schemaManager.getCollection(collectionId);
if (!collection) {
throw KrapiError.notFound(`Collection ${collectionId} not found`, {
collectionId,
});
}
const [validation, dataIntegrity, tableStats] = await Promise.all([
this.validateCollection(collectionId),
this.schemaInspector.checkTableIntegrity(collection.name),
this.schemaInspector.getTableStats(collection.name),
]);
const status = this.determineHealthStatus(
validation.isValid,
dataIntegrity
);
return {
status,
schemaValid: validation.isValid,
dataIntegrity,
tableStats,
lastValidated: new Date().toISOString(),
};
}
/**
* Get all collections with health status
*/
async getAllCollectionsWithHealth(): Promise<
Array<
Collection & {
health: {
status: "healthy" | "degraded" | "unhealthy";
issues: number;
};
}
>
> {
const collections = await this.schemaManager.getCollections();
const collectionsWithHealth = [];
for (const collection of collections) {
try {
const health = await this.getCollectionHealth(collection.id);
collectionsWithHealth.push({
...collection,
health: {
status: health.status,
issues:
health.dataIntegrity.issues.length + (health.schemaValid ? 0 : 1),
},
});
} catch (error) {
this.logger.error(
`Error getting health for collection ${collection.name}:`,
error
);
collectionsWithHealth.push({
...collection,
health: {
status: "unhealthy" as const,
issues: 1,
},
});
}
}
return collectionsWithHealth;
}
/**
* Get all collections for a project
*
* Retrieves all collections associated with a specific project.
*
* @param {string} projectId - Project ID
* @returns {Promise<Collection[]>} Array of collections
* @throws {Error} If query fails
*
* @example
* const collections = await collectionsService.getCollectionsByProject('project-id');
*/
async getCollectionsByProject(projectId: string): Promise<Collection[]> {
try {
const collections = await this.schemaManager.getCollections();
return collections.filter(
(collection) => collection.project_id === projectId
);
} catch (error) {
this.logger.error(
`Error getting collections for project ${projectId}:`,
error
);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getCollectionsByProject",
projectId,
});
}
}
/**
* Get collections by project ID
*
* Alias for getCollectionsByProject. Retrieves all collections for a project.
*
* @param {string} projectId - Project ID
* @returns {Promise<Collection[]>} Array of collections
* @throws {Error} If query fails
*
* @example
* const collections = await collectionsService.getProjectCollections('project-id');
*/
async getProjectCollections(projectId: string): Promise<Collection[]> {
return this.getCollectionsByProject(projectId);
}
/**
* Get a collection by ID or name
*
* Retrieves a single collection by its ID (UUID) or name (case-insensitive) within a project.
* Supports both UUID lookup and case-insensitive name lookup.
*
* @param {string} projectId - Project ID
* @param {string} collectionId - Collection ID (UUID) or name
* @returns {Promise<Collection | null>} Collection or null if not found
* @throws {Error} If query fails
*
* @example
* const collection = await collectionsService.getCollection('project-id', 'collection-id');
* const collectionByName = await collectionsService.getCollection('project-id', 'test_collection');
*/
async getCollection(
projectId: string,
collectionId: string
): Promise<Collection | null> {
try {
// Check if collectionId is a UUID
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(collectionId);
let result;
if (isUUID) {
// Lookup by UUID (id field)
result = await this.db.query(
`SELECT * FROM collections WHERE id = $1 AND project_id = $2`,
[collectionId, projectId]
);
} else {
// Lookup by name (case-insensitive using LOWER for SQLite compatibility)
result = await this.db.query(
`SELECT * FROM collections WHERE LOWER(name) = LOWER($1) AND project_id = $2`,
[collectionId, projectId]
);
}
if (result.rows.length === 0) {
this.logger.warn(
`No collection found with ${isUUID ? 'ID' : 'name'} "${collectionId}" in project "${projectId}"`
);
return null;
}
const dbCollection = result.rows[0] as Record<string, unknown>;
this.logger.info(`Found collection:`, dbCollection);
// Parse JSON fields and indexes from database
let fields: FieldDefinition[] = [];
let indexes: IndexDefinition[] = [];
try {
if (typeof dbCollection.fields === 'string') {
fields = JSON.parse(dbCollection.fields as string) as FieldDefinition[];
} else if (Array.isArray(dbCollection.fields)) {
fields = dbCollection.fields as FieldDefinition[];
}
} catch (error) {
this.logger.warn('Failed to parse fields:', error);
}
try {
if (typeof dbCollection.indexes === 'string') {
indexes = JSON.parse(dbCollection.indexes as string) as IndexDefinition[];
} else if (Array.isArray(dbCollection.indexes)) {
indexes = dbCollection.indexes as IndexDefinition[];
}
} catch (error) {
this.logger.warn('Failed to parse indexes:', error);
}
// Convert database collection to Collection interface
return {
id: dbCollection.id as string,
name: dbCollection.name as string,
description: dbCollection.description as string,
project_id: dbCollection.project_id as string,
fields,
indexes,
schema: {
fields,
indexes,
},
settings: (dbCollection.settings as unknown as CollectionSettings) || {
read_permissions: [],
write_permissions: [],
delete_permissions: [],
enable_audit_log: false,
enable_soft_delete: false,
enable_versioning: false,
},
created_at: dbCollection.created_at as string,
updated_at: dbCollection.updated_at as string,
};
} catch (error) {
this.logger.error("Failed to get collection", {
error,
projectId,
collectionId,
});
throw error;
}
}
/**
* Update a collection
*
* Updates collection metadata (name, description, settings) without changing schema.
*
* @param {string} projectId - Project ID
* @param {string} collectionId - Collection ID
* @param {Partial<Collection>} updates - Collection updates
* @returns {Promise<Collection>} Updated collection
* @throws {Error} If update fails or collection not found
*
* @example
* const updated = await collectionsService.updateCollection('project-id', 'collection-id', {
* description: 'Updated description'
* });
*/
async updateCollection(
projectId: string,
collectionId: string,
updates: Partial<Collection>
): Promise<Collection> {
try {
const collection = await this.getCollection(projectId, collectionId);
if (!collection) {
throw KrapiError.notFound("Collection not found", {
projectId,
collectionId,
});
}
return await this.schemaManager.updateCollection(collectionId, updates);
} catch (error) {
this.logger.error("Failed to update collection", {
error,
projectId,
collectionId,
updates,
});
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "updateCollection",
projectId,
collectionId,
});
}
}
/**
* Delete a collection
*
* Permanently deletes a collection and all its documents.
* This action cannot be undone.
*
* @param {string} projectId - Project ID
* @param {string} collectionId - Collection ID
* @returns {Promise<boolean>} True if deletion successful
* @throws {Error} If deletion fails or collection not found
*
* @example
* const deleted = await collectionsService.deleteCollection('project-id', 'collection-id');
*/
async deleteCollection(
projectId: string,
collectionId: string
): Promise<boolean> {
try {
const collection = await this.getCollection(projectId, collectionId);
if (!collection) {
throw KrapiError.notFound("Collection not found", {
projectId,
collectionId,
});
}
return await this.schemaManager.deleteCollection(collectionId);
} catch (error) {
this.logger.error("Failed to delete collection", {
error,
projectId,
collectionId,
});
throw error;
}
}
// Private helper methods
private async getCollectionByName(name: string): Promise<Collection | null> {
const collections = await this.schemaManager.getCollections();
return collections.find((c) => c.name === name) || null;
}
private async getCollectionByNameInProject(
projectId: string,
name: string
): Promise<Collection | null> {
try {
this.logger.info(
`Looking for collection "${name}" in project "${projectId}"`
);
// Use case-insensitive matching with LOWER for SQLite compatibility
const result = await this.db.query(
`SELECT * FROM collections WHERE LOWER(name) = LOWER($1) AND project_id = $2`,
[name, projectId]
);
this.logger.info(
`Database query result: ${result.rows.length} rows found`
);
if (result.rows.length === 0) {
this.logger.warn(
`No collection found with name "${name}" in project "${projectId}"`
);
return null;
}
const dbCollection = result.rows[0] as Record<string, unknown>;
this.logger.info(`Found collection:`, dbCollection);
// Parse JSON fields and indexes from database
let fields: FieldDefinition[] = [];
let indexes: IndexDefinition[] = [];
try {
if (typeof dbCollection.fields === 'string') {
fields = JSON.parse(dbCollection.fields as string) as FieldDefinition[];
} else if (Array.isArray(dbCollection.fields)) {
fields = dbCollection.fields as FieldDefinition[];
}
} catch (error) {
this.logger.warn('Failed to parse fields:', error);
}
try {
if (typeof dbCollection.indexes === 'string') {
indexes = JSON.parse(dbCollection.indexes as string) as IndexDefinition[];
} else if (Array.isArray(dbCollection.indexes)) {
indexes = dbCollection.indexes as IndexDefinition[];
}
} catch (error) {
this.logger.warn('Failed to parse indexes:', error);
}
// Convert database collection to Collection interface
return {
id: dbCollection.id as string,
name: dbCollection.name as string,
description: dbCollection.description as string,
project_id: dbCollection.project_id as string,
fields,
indexes,
schema: {
fields,
indexes,
},
settings: (dbCollection.settings as unknown as CollectionSettings) || {
read_permissions: [],
write_permissions: [],
delete_permissions: [],
enable_audit_log: false,
enable_soft_delete: false,
enable_versioning: false,
},
created_at: dbCollection.created_at as string,
updated_at: dbCollection.updated_at as string,
};
} catch (error) {
this.logger.error("Error getting collection by name in project:", error);
return null;
}
}
private isValidCollectionName(name: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name);
}
private validateCollectionFields(
fields: Array<{ name: string; type: FieldType }>
): void {
if (fields.length === 0) {
throw KrapiError.validationError(
"Collection must have at least one field",
"fields"
);
}
const fieldNames = new Set<string>();
for (const field of fields) {
if (!this.isValidFieldName(field.name)) {
throw KrapiError.validationError(
`Invalid field name: "${field.name}". Use only letters, numbers, and underscores.`,
field.name
);
}
if (fieldNames.has(field.name)) {
throw KrapiError.validationError(
`Duplicate field name: "${field.name}"`,
field.name
);
}
fieldNames.add(field.name);
}
}
private isValidFieldName(name: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name);
}
private getIssueSeverity(issueType: string): "error" | "warning" | "info" {
switch (issueType) {
case "missing_field":
case "wrong_type":
return "error";
case "missing_index":
case "missing_constraint":
return "warning";
case "extra_field":
return "info";
default:
return "warning";
}
}
private generateRecommendations(
issues: Array<{
type: string;
field?: string;
description: string;
severity?: string;
}>,
_collection: Collection
): string[] {
const recommendations: string[] = [];
if (issues.some((i) => i.type === "missing_field")) {
recommendations.push(
"Run auto-fix to add missing fields to the database"
);
}
if (issues.some((i) => i.type === "wrong_type")) {
recommendations.push(
"Field type mismatches detected. Consider running auto-fix or manually updating field types"
);
}
if (issues.some((i) => i.type === "missing_index")) {
recommendations.push(
"Missing indexes detected. Run auto-fix to create them for better performance"
);
}
if (issues.some((i) => i.type === "extra_field")) {
recommendations.push(
"Extra fields found in database. Review if they should be removed or added to the schema"
);
}
if (recommendations.length === 0) {
recommendations.push("Collection schema is healthy. No action needed.");
}
return recommendations;
}
private determineHealthStatus(
schemaValid: boolean,
dataIntegrity: {
hasNullViolations: boolean;
hasUniqueViolations: boolean;
hasForeignKeyViolations: boolean;
}
): "healthy" | "degraded" | "unhealthy" {
if (!schemaValid) {
return "unhealthy";
}
if (
dataIntegrity.hasNullViolations ||
dataIntegrity.hasUniqueViolations ||
dataIntegrity.hasForeignKeyViolations
) {
return "degraded";
}
return "healthy";
}
private getCollectionTemplate(templateName: string): {
name: string;
description: string;
fields: Array<{
name: string;
type: FieldType;
required?: boolean;
unique?: boolean;
indexed?: boolean;
description?: string;
}>;
indexes: Array<{
name: string;
fields: string[];
unique?: boolean;
}>;
} | null {
const templates: Record<
string,
{
name: string;
description: string;
fields: Array<{
name: string;
type: FieldType;
required?: boolean;
unique?: boolean;
indexed?: boolean;
description?: string;
}>;
indexes: Array<{
name: string;
fields: string[];
unique?: boolean;
}>;
}
> = {
articles: {
name: "articles",
description: "Blog articles or news posts",
fields: [
{
name: "title",
type: "string" as FieldType,
required: true,
indexed: true,
description: "Article title",
},
{
name: "content",
type: "text" as FieldType,
required: true,
description: "Article content",
},
{
name: "author",
type: "string" as FieldType,
required: true,
indexed: true,
description: "Author name",
},
{
name: "published_at",
type: "date" as FieldType,
description: "Publication date",
},
{
name: "tags",
type: "array" as FieldType,
description: "Article tags",
},
],
indexes: [
{ name: "idx_articles_title", fields: ["title"], unique: false },
{ name: "idx_articles_author", fields: ["author"], unique: false },
{
name: "idx_articles_published",
fields: ["published_at"],
unique: false,
},
],
},
products: {
name: "products",
description: "E-commerce products",
fields: [
{
name: "name",
type: "string" as FieldType,
required: true,
indexed: true,
description: "Product name",
},
{
name: "description",
type: "text" as FieldType,
description: "Product description",
},
{
name: "price",
type: "number" as FieldType,
required: true,
description: "Product price",
},
{
name: "category",
type: "string" as FieldType,
required: true,
indexed: true,
description: "Product category",
},
{
name: "in_stock",
type: "boolean" as FieldType,
required: true,
description: "Stock availability",
},
{
name: "images",
type: "array" as FieldType,
description: "Product images",
},
],
indexes: [
{ name: "idx_products_name", fields: ["name"], unique: false },
{
name: "idx_products_category",
fields: ["category"],
unique: false,
},
{ name: "idx_products_price", fields: ["price"], unique: false },
],
},
users: {
name: "users",
description: "User accounts",
fields: [
{
name: "email",
type: "string" as FieldType,
required: true,
unique: true,
indexed: true,
description: "User email",
},
{
name: "username",
type: "string" as FieldType,
required: true,
unique: true,
indexed: true,
description: "Username",
},
{
name: "first_name",
type: "string" as FieldType,
description: "First name",
},
{
name: "last_name",
type: "string" as FieldType,
description: "Last name",
},
{
name: "is_active",
type: "boolean" as FieldType,
required: true,
description: "Account status",
},
{
name: "profile_data",
type: "json" as FieldType,
description: "Additional profile information",
},
],
indexes: [
{ name: "idx_users_email", fields: ["email"], unique: true },
{ name: "idx_users_username", fields: ["username"], unique: true },
],
},
};
return templates[templateName] || null;
}
// ===== DOCUMENT CRUD OPERATIONS =====
/**
* Get all documents from a collection
*
* Retrieves documents from a collection with optional filtering, sorting, and pagination.
*
* @param {string} projectId - Project ID
* @param {string} collectionName - Collection name
* @param {DocumentFilter} [filter] - Document filters
* @param {DocumentQueryOptions} [options] - Query options (limit, offset, sort)
* @returns {Promise<Document[]>} Array of documents
* @throws {Error} If query fails or collection not found
*
* @example
* const documents = await collectionsService.getDocuments('project-id', 'users', {
* field_filters: { active: true },
* search: 'john'
* }, { limit: 10, sort_by: 'created_at', sort_order: 'desc' });
*/
async getDocuments(
projectId: string,
collectionName: string,
filter?: DocumentFilter,
options?: DocumentQueryOptions
): Promise<Document[]> {
try {
const collection = await this.getCollectionByNameInProject(
projectId,
collectionName
);
if (!collection) {
throw KrapiError.notFound(
`Collection "${collectionName}" not found in project ${projectId}`,
{ collectionName, projectId }
);
}
// Include project_id in query for proper database routing
let query = `SELECT * FROM documents WHERE collection_id = $1 AND project_id = $2`;
const params: unknown[] = [collection.id, projectId];
let paramCount = 2;
// Apply filters
if (filter) {
// Note: Soft delete not implemented in current database schema
// if (!filter.include_deleted) {
// query += ` AND is_deleted = false`;
// }
if (filter.field_filters) {
for (const [field, value] of Object.entries(filter.field_filters)) {
paramCount++;
query += ` AND data->>'${field}' = $${paramCount}`;
params.push(value);
}
}
if (filter.search) {
paramCount++;
// SQLite uses LIKE with lower() for case-insensitive search
query += ` AND lower(data) LIKE lower($${paramCount})`;
params.push(`%${filter.search}%`);
}
if (filter.created_after) {
paramCount++;
query += ` AND created_at >= $${paramCount}`;
params.push(filter.created_after);
}
if (filter.created_before) {
paramCount++;
query += ` AND created_at <= $${paramCount}`;
params.push(filter.created_before);
}
if (filter.updated_after) {
paramCount++;
query += ` AND updated_at >= $${paramCount}`;
params.push(filter.updated_after);
}
if (filter.updated_before) {
paramCount++;
query += ` AND updated_at <= $${paramCount}`;
params.push(filter.updated_before);
}
if (filter.created_by) {
paramCount++;
query += ` AND created_by = $${paramCount}`;
params.push(filter.created_by);
}
if (filter.updated_by) {
paramCount++;
query += ` AND updated_by = $${paramCount}`;
params.push(filter.updated_by);
}
}
// Apply sorting
if (options?.sort_by) {
const sortOrder = options.sort_order || "asc";
if (
options.sort_by === "created_at" ||
options.sort_by === "updated_at"
) {
query += ` ORDER BY ${options.sort_by} ${sortOrder.toUpperCase()}`;
} else {
// For numeric fields, cast to appropriate type for proper sorting
const field = options.sort_by;
if (
field === "priority" ||
field === "id" ||
field.includes("count") ||
field.includes("total")
) {
// Use CAST for type conversion
query += ` ORDER BY CAST(json_extract(data, '$.${field}') AS INTEGER) ${sortOrder.toUpperCase()}`;
} else if (
field === "is_active" ||
field.includes("enabled") ||
field.includes("active")
) {
// SQLite: Boolean fields are stored as 0/1, cast to integer for sorting
query += ` ORDER BY CAST(json_extract(data, '$.${field}') AS INTEGER) ${sortOrder.toUpperCase()}`;
} else {
query += ` ORDER BY data->>'${field}' ${sortOrder.toUpperCase()}`;
}
}
} else {
query += ` ORDER BY created_at DESC`;
}
// Apply pagination
if (options?.limit) {
paramCount++;
query += ` LIMIT $${paramCount}`;
params.push(options.limit);
}
if (options?.offset) {
paramCount++;
query += ` OFFSET $${paramCount}`;
params.push(options.offset);
}
const result = await this.db.query(query, params);
return result.rows as Document[];
} catch (error) {
this.logger.error("Failed to get documents:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getDocuments",
projectId,
collectionName,
});
}
}
/**
* Get a single document by ID
*
* Retrieves a document by its ID from a collection.
*
* @param {string} projectId - Project ID
* @param {string} collectionName - Collection name
* @param {string} documentId - Document ID
* @returns {Promise<Document | null>} Document or null if not found
* @throws {Error} If query fails or collection not found
*
* @example
* const document = await collectionsService.getDocumentById('project-id', 'users', 'doc-id');
*/
async getDocumentById(
projectId: string,
collectionName: string,
documentId: string
): Promise<Document | null> {
try {
const collection = await this.getCollectionByNameInProject(
projectId,
collectionName
);
if (!collection) {
throw KrapiError.notFound(
`Collection "${collectionName}" not found in project ${projectId}`,
{ collectionName, projectId }
);
}
// Include project_id in query for proper database routing
const result = await this.db.query(
`SELECT * FROM documents WHERE collection_id = $1 AND id = $2 AND project_id = $3`,
[collection.id, documentId, projectId]
);
if (result.rows.length === 0) {
return null;
}
// Use mapDocument to parse JSON data and ensure proper formatting
const document = this.mapDocument(result.rows[0] as Record<string, unknown>);
// Ensure project_id is set from collection
document.project_id = collection.project_id || projectId;
return document;
} catch (error) {
this.logger.error("Failed to get document by ID:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getDocumentById",
projectId,
collectionName,
documentId,
});
}
}
/**
* Create a new document in a collection
*
* Creates a new document with validation against the collection schema.
*
* @param {string} projectId - Project ID
* @param {string} collectionName - Collection name
* @param {CreateDocumentRequest} documentData - Document data
* @param {Record<string, unknown>} documentData.data - Document data object
* @param {string} [documentData.created_by] - User ID who created the document
* @returns {Promise<Document>} Created document
* @throws {Error} If creation fails, validation fails, or collection not found
*
* @example
* const document = await collectionsService.createDocument('project-id', 'users', {
* data: { email: 'user@example.com', name: 'John Doe' },
* created_by: 'user-id'
* });
*/
async createDocument(
projectId: string,
collectionName: string,
documentData: CreateDocumentRequest
): Promise<Document> {
try {
const collection = await this.getCollectionByNameInProject(
projectId,
collectionName
);
if (!collection) {
throw KrapiError.notFound(
`Collection "${collectionName}" not found in project ${projectId}`,
{ collectionName, projectId }
);
}
// Validate document data against collection schema
// Handle the case where data is double-nested due to frontend/backend mismatch
const actualDocumentData = (documentData.data?.data ||
documentData.data) as Record<string, unknown>;
await this.validateDocumentData(collection, actualDocumentData);
// Generate document ID (SQLite doesn't support RETURNING *)
const documentId = typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
// SQLite-compatible INSERT (no RETURNING *)
// Include project_id and updated_by in INSERT for proper database routing
const createdBy = documentData.created_by || documentData.data?.created_by || "system";
await this.db.query(
`INSERT INTO documents (id, collection_id, project_id, data, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
documentId,
collection.id,
projectId,
JSON.stringify(actualDocumentData),
createdBy,
createdBy, // updated_by defaults to created_by for new documents
]
);
// Query back the inserted row (SQLite doesn't support RETURNING *)
// Use collection_id and project_id for proper database routing
const result = await this.db.query(
`SELECT * FROM documents WHERE id = $1 AND collection_id = $2 AND project_id = $3`,
[documentId, collection.id, projectId]
);
this.logger.info(`Created document in collection "${collectionName}"`);
return this.mapDocument(result.rows[0] as Record<string, unknown>);
} catch (error) {
// Comprehensive error logging with full context
// Include input data (what was received)
const inputData = {
projectId,
collectionName,
documentData: {
// Include document data structure but not full content (may be large)
hasData: !!documentData.data,
dataKeys: documentData.data ? Object.keys(documentData.data) : [],
created_by: documentData.created_by,
},
};
logServiceOperationError(
this.logger,
error,
"CollectionsService",
"createDocument",
inputData,
{ projectId, collectionName }
);
// Preserve validation error messages for proper error handling
if (error instanceof Error) {
if (
error.message.includes("Required field") ||
error.message.includes("Invalid type") ||
error.message.includes("validation") ||
error.message.includes("missing")
) {
// Re-throw validation errors with their original message
throw error;
}
}
// For other errors, throw generic message
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "createDocument",
projectId,
collectionName,
});
}
}
/**
* Update an existing document
*
* Updates a document's data with validation against the collection schema.
*
* @param {string} projectId - Project ID
* @param {string} collectionName - Collection name
* @param {string} documentId - Document ID
* @param {UpdateDocumentRequest} updateData - Update data
* @param {Record<string, unknown>} updateData.data - Updated data (merged with existing)
* @param {string} [updateData.updated_by] - User ID who updated the document
* @returns {Promise<Document | null>} Updated document or null if not found
* @throws {Error} If update fails, validation fails, or document not found
*
* @example
* const updated = await collectionsService.updateDocument('project-id', 'users', 'doc-id', {
* data: { name: 'Jane Doe' },
* updated_by: 'user-id'
* });
*/
async updateDocument(
projectId: string,
collectionName: string,
documentId: string,
updateData: UpdateDocumentRequest
): Promise