/** * Schema Cache Manager * Maintains an offline cache of Directus collections and fields schema * Updates cache on every create/update/delete operation */ const fs = require('fs').promises; const path = require('path'); const { readCollections, readFields, readRelations } = require('@directus/sdk'); class SchemaCache { constructor(cacheDir = null) { this.cacheDir = cacheDir || path.join(process.cwd(), '.directus-cache'); this.cacheFile = path.join(this.cacheDir, 'schema.json'); this.lockFile = path.join(this.cacheDir, 'schema.lock'); this.cache = null; this.isDirty = false; } /** * Initialize cache directory and files */ async init() { // Skip if already initialized if (this.cache !== null) { return true; } try { // Create cache directory if it doesn't exist await fs.mkdir(this.cacheDir, { recursive: true }); // Try to load existing cache await this.load(); return true; } catch (error) { console.error('❌ Failed to initialize cache:', error.message); return false; } } /** * Load cache from disk */ async load() { try { const data = await fs.readFile(this.cacheFile, 'utf-8'); this.cache = JSON.parse(data); return this.cache; } catch (error) { // If file doesn't exist, initialize empty cache if (error.code === 'ENOENT') { this.cache = { version: '1.0.0', lastSync: null, collections: {}, relations: [], metadata: { totalCollections: 0, totalFields: 0, totalRelations: 0 } }; await this.save(); } else { throw error; } } return this.cache; } /** * Save cache to disk */ async save() { try { // Add timestamp this.cache.lastModified = new Date().toISOString(); // Update metadata this.updateMetadata(); // Write with pretty formatting for readability const data = JSON.stringify(this.cache, null, 2); await fs.writeFile(this.cacheFile, data, 'utf-8'); this.isDirty = false; return true; } catch (error) { console.error('❌ Failed to save cache:', error.message); return false; } } /** * Update cache metadata */ updateMetadata() { if (!this.cache) return; const collections = Object.keys(this.cache.collections); let totalFields = 0; collections.forEach(collection => { if (this.cache.collections[collection].fields) { totalFields += Object.keys(this.cache.collections[collection].fields).length; } }); this.cache.metadata = { totalCollections: collections.length, totalFields: totalFields, totalRelations: this.cache.relations ? this.cache.relations.length : 0 }; } /** * Sync cache with Directus instance */ async sync(client) { try { console.log('🔄 Syncing schema cache with Directus...'); // Fetch all collections const collections = await client.request(readCollections()); // Reset cache this.cache = { version: '1.0.0', lastSync: new Date().toISOString(), collections: {}, relations: [], metadata: {} }; // Process each collection for (const collection of collections) { // Skip system collections unless specified if (collection.collection.startsWith('directus_') && !collection.meta?.system) { continue; } // Store collection info this.cache.collections[collection.collection] = { name: collection.collection, meta: collection.meta, schema: collection.schema, fields: {}, created: new Date().toISOString() }; // Fetch fields for this collection try { const fields = await client.request(readFields(collection.collection)); // Store field info fields.forEach(field => { this.cache.collections[collection.collection].fields[field.field] = { field: field.field, type: field.type, meta: field.meta, schema: field.schema }; }); } catch (fieldError) { console.warn(` ⚠️ Could not fetch fields for ${collection.collection}:`, fieldError.message); } } // Fetch all relations try { const relations = await client.request(readRelations()); this.cache.relations = relations; } catch (relError) { console.warn(' ⚠️ Could not fetch relations:', relError.message); } // Save to disk await this.save(); console.log(`✅ Synced ${Object.keys(this.cache.collections).length} collections`); console.log(`📊 Cache statistics:`, this.cache.metadata); return this.cache; } catch (error) { console.error('❌ Failed to sync cache:', error.message); throw error; } } /** * Check if a collection exists in cache */ hasCollection(collectionName) { return this.cache && this.cache.collections && this.cache.collections[collectionName] !== undefined; } /** * Check if a field exists in cache */ hasField(collectionName, fieldName) { return this.hasCollection(collectionName) && this.cache.collections[collectionName].fields && this.cache.collections[collectionName].fields[fieldName] !== undefined; } /** * Get collection info from cache */ getCollection(collectionName) { if (!this.hasCollection(collectionName)) { return null; } return this.cache.collections[collectionName]; } /** * Get field info from cache */ getField(collectionName, fieldName) { if (!this.hasField(collectionName, fieldName)) { return null; } return this.cache.collections[collectionName].fields[fieldName]; } /** * Add or update collection in cache */ async addCollection(collectionName, collectionData = {}) { if (!this.cache) await this.load(); this.cache.collections[collectionName] = { name: collectionName, meta: collectionData.meta || {}, schema: collectionData.schema || {}, fields: collectionData.fields || {}, created: collectionData.created || new Date().toISOString(), modified: new Date().toISOString() }; this.isDirty = true; await this.save(); return this.cache.collections[collectionName]; } /** * Add or update field in cache */ async addField(collectionName, fieldName, fieldData = {}) { if (!this.cache) await this.load(); // Ensure collection exists if (!this.hasCollection(collectionName)) { await this.addCollection(collectionName); } // Ensure fields object exists if (!this.cache.collections[collectionName].fields) { this.cache.collections[collectionName].fields = {}; } // Add or update field this.cache.collections[collectionName].fields[fieldName] = { field: fieldName, type: fieldData.type, meta: fieldData.meta || {}, schema: fieldData.schema || {}, created: fieldData.created || new Date().toISOString(), modified: new Date().toISOString() }; this.isDirty = true; await this.save(); return this.cache.collections[collectionName].fields[fieldName]; } /** * Remove collection from cache */ async removeCollection(collectionName) { if (!this.cache) await this.load(); if (this.hasCollection(collectionName)) { delete this.cache.collections[collectionName]; this.isDirty = true; await this.save(); return true; } return false; } /** * Remove field from cache */ async removeField(collectionName, fieldName) { if (!this.cache) await this.load(); if (this.hasField(collectionName, fieldName)) { delete this.cache.collections[collectionName].fields[fieldName]; this.isDirty = true; await this.save(); return true; } return false; } /** * Rename field in cache */ async renameField(collectionName, oldFieldName, newFieldName) { if (!this.cache) await this.load(); if (this.hasField(collectionName, oldFieldName)) { const fieldData = this.cache.collections[collectionName].fields[oldFieldName]; fieldData.field = newFieldName; fieldData.modified = new Date().toISOString(); fieldData.previousName = oldFieldName; // Add new field name this.cache.collections[collectionName].fields[newFieldName] = fieldData; // Remove old field name delete this.cache.collections[collectionName].fields[oldFieldName]; this.isDirty = true; await this.save(); return true; } return false; } /** * Add or update relation in cache */ async addRelation(relationData) { if (!this.cache) await this.load(); if (!this.cache.relations) { this.cache.relations = []; } // Check if relation already exists const existingIndex = this.cache.relations.findIndex(r => r.collection === relationData.collection && r.field === relationData.field ); if (existingIndex >= 0) { // Update existing relation this.cache.relations[existingIndex] = { ...relationData, modified: new Date().toISOString() }; } else { // Add new relation this.cache.relations.push({ ...relationData, created: new Date().toISOString() }); } this.isDirty = true; await this.save(); return true; } /** * Get cache statistics */ getStats() { if (!this.cache) { return { initialized: false }; } const collections = Object.keys(this.cache.collections); let totalFields = 0; let systemCollections = 0; let customCollections = 0; collections.forEach(collection => { if (collection.startsWith('directus_')) { systemCollections++; } else { customCollections++; } if (this.cache.collections[collection].fields) { totalFields += Object.keys(this.cache.collections[collection].fields).length; } }); return { initialized: true, lastSync: this.cache.lastSync, lastModified: this.cache.lastModified, totalCollections: collections.length, systemCollections, customCollections, totalFields, totalRelations: this.cache.relations ? this.cache.relations.length : 0, cacheFile: this.cacheFile }; } /** * Clear the cache */ async clear() { this.cache = { version: '1.0.0', lastSync: null, collections: {}, relations: [], metadata: {} }; await this.save(); console.log('🗑️ Cache cleared'); return true; } /** * Export cache for debugging */ async export(outputFile = null) { if (!this.cache) await this.load(); const exportFile = outputFile || path.join(this.cacheDir, `schema-export-${Date.now()}.json`); const data = JSON.stringify(this.cache, null, 2); await fs.writeFile(exportFile, data, 'utf-8'); console.log(`📤 Cache exported to: ${exportFile}`); return exportFile; } } // Singleton instance let cacheInstance = null; /** * Get or create cache instance */ function getCache(cacheDir = null) { if (!cacheInstance) { cacheInstance = new SchemaCache(cacheDir); } return cacheInstance; } module.exports = { SchemaCache, getCache };