#!/usr/bin/env node /** * Idempotent Directus schema bootstrap for the Swansea airfield project. * * Usage (containerized): * docker run --rm --network webdev_default \ * -v "$PWD:/app" -w /app \ * -e DIRECTUS_BASE_URL=http://directus:8055 \ * -e DIRECTUS_EMAIL=admin@example.com \ * -e DIRECTUS_PASSWORD=change-me \ * node:22-alpine node scripts/bootstrap-directus.mjs */ const baseUrl = (process.env.DIRECTUS_URL || process.env.DIRECTUS_BASE_URL || 'http://localhost:8055').replace(/\/$/, ''); const email = process.env.DIRECTUS_ADMIN_EMAIL || process.env.DIRECTUS_EMAIL || 'admin@example.com'; const password = process.env.DIRECTUS_ADMIN_PASSWORD || process.env.DIRECTUS_PASSWORD || 'change-me'; // Note: We intentionally ignore pre-set DIRECTUS_TOKEN because it often lacks proper // permissions. Email/password login to get a fresh admin token is more reliable. const collections = [ { collection: 'tags', icon: 'sell' }, { collection: 'news', icon: 'article' }, { collection: 'events', icon: 'event' }, { collection: 'notices', icon: 'campaign' }, { collection: 'fuel_prices', icon: 'local_gas_station' }, { collection: 'documents', icon: 'description' }, { collection: 'contacts', icon: 'contacts' }, { collection: 'news_tags', icon: 'link', hidden: true }, { collection: 'events_tags', icon: 'link', hidden: true }, ]; const fieldsByCollection = { tags: [ { field: 'name', type: 'string', required: true }, { field: 'slug', type: 'string', required: true }, ], news: [ { field: 'title', type: 'string', required: true }, { field: 'slug', type: 'string', required: true }, { field: 'body', type: 'text' }, { field: 'summary', type: 'text' }, { field: 'featured_image', type: 'uuid' }, { field: 'status', type: 'string' }, { field: 'publish_date', type: 'dateTime' }, { field: 'author', type: 'string' }, ], events: [ { field: 'title', type: 'string', required: true }, { field: 'slug', type: 'string', required: true }, { field: 'description', type: 'text' }, { field: 'start_datetime', type: 'dateTime', required: true }, { field: 'end_datetime', type: 'dateTime' }, { field: 'location_text', type: 'string' }, { field: 'image', type: 'uuid' }, { field: 'registration_link', type: 'string' }, { field: 'status', type: 'string' }, { field: 'is_featured', type: 'boolean', default: false }, ], notices: [ { field: 'title', type: 'string', required: true }, { field: 'message', type: 'text', required: true }, { field: 'severity', type: 'string', default: 'info' }, { field: 'start_date', type: 'date' }, { field: 'end_date', type: 'date' }, { field: 'active', type: 'boolean', default: true }, { field: 'priority', type: 'integer', default: 0 }, ], fuel_prices: [ { field: 'fuel_type', type: 'string', required: true }, { field: 'price_per_litre', type: 'decimal', required: true }, { field: 'currency', type: 'string', default: 'GBP' }, { field: 'last_updated', type: 'dateTime' }, { field: 'notes', type: 'text' }, ], documents: [ { field: 'title', type: 'string', required: true }, { field: 'file', type: 'uuid' }, { field: 'category', type: 'string' }, { field: 'description', type: 'text' }, { field: 'uploaded_at', type: 'dateTime' }, ], contacts: [ { field: 'name', type: 'string', required: true }, { field: 'role', type: 'string' }, { field: 'email', type: 'string', required: true }, { field: 'phone', type: 'string' }, { field: 'is_public', type: 'boolean', default: true }, { field: 'order', type: 'integer', default: 0 }, ], news_tags: [ { field: 'news_id', type: 'integer', required: true }, { field: 'tag_id', type: 'integer', required: true }, ], events_tags: [ { field: 'event_id', type: 'integer', required: true }, { field: 'tag_id', type: 'integer', required: true }, ], }; const typeMap = { string: { data_type: 'varchar', max_length: 255 }, text: { data_type: 'text' }, integer: { data_type: 'integer' }, decimal: { data_type: 'decimal', numeric_precision: 10, numeric_scale: 2 }, boolean: { data_type: 'boolean' }, date: { data_type: 'date' }, dateTime: { data_type: 'timestamp with time zone' }, uuid: { data_type: 'uuid' }, }; async function main() { try { // Wait for Directus to be ready console.log('========================================'); console.log('Directus Schema Bootstrap'); console.log('========================================'); console.log(''); console.log('âŗ Waiting for Directus to be ready...'); let ready = false; let retries = 0; const maxRetries = 30; while (!ready && retries < maxRetries) { try { const health = await fetch(`${baseUrl}/server/health`); if (health.ok) { ready = true; console.log('✓ Directus is ready'); } } catch (e) { // Continue retrying } if (!ready) { retries++; if (retries % 5 === 0) { process.stdout.write('.'); } await new Promise(r => setTimeout(r, 1000)); } } if (!ready) { throw new Error(`Directus did not become ready after ${maxRetries} seconds`); } console.log(''); console.log('🔐 Authenticating with email/password login...'); const token = await login(email, password); console.log('✓ Authentication successful'); console.log(''); console.log('📋 Creating schema...'); console.log(''); // Create all collections with their fields in one call // This ensures both metadata and tables are created const created = []; const existing = []; for (const collection of collections) { const collectionName = collection.collection; const fields = fieldsByCollection[collectionName] || []; const result = await ensureCollection(token, collection, fields); if (result.created) { created.push(collectionName); } else { existing.push(collectionName); } } console.log(''); if (created.length > 0) { console.log('✅ Schema bootstrap completed successfully!'); console.log(''); console.log(`Created ${created.length} collections:`); created.forEach(name => console.log(` - ${name}`)); if (existing.length > 0) { console.log(''); console.log(`${existing.length} collections already exist.`); } } else { console.log('â„šī¸ All collections already exist. Schema is up to date.'); } console.log(''); } catch (error) { console.error(''); console.error('❌ Bootstrap failed:', error.message); console.error(''); if (error.details) { console.error('Details:', error.details); } process.exit(1); } } async function login(userEmail, userPassword) { const response = await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: userEmail, password: userPassword }), }); if (!response.ok) { throw new Error(`Login failed with status ${response.status}`); } const payload = await response.json(); const token = payload?.data?.access_token; if (!token) { throw new Error('No access token returned from Directus login.'); } return token; } async function ensureCollection(token, collection, fields = []) { const collectionName = collection.collection; // Build field payloads - convert to Directus field format const fieldPayloads = fields.map(field => { const typeConfig = typeMap[field.type]; if (!typeConfig) { throw new Error(`Unsupported field type: ${field.type}`); } return { field: field.field, type: field.type, meta: { required: Boolean(field.required), interface: defaultInterfaceForType(field.type), }, schema: { ...typeConfig, is_nullable: !field.required, default_value: field.default || null, }, }; }); try { const result = await fetchJson(`${baseUrl}/collections`, token, 'POST', { collection: collectionName, meta: { icon: collection.icon, hidden: Boolean(collection.hidden), singleton: false, note: `Managed by scripts/bootstrap-directus.mjs`, }, // Include schema to tell Directus to create the table schema: { name: collectionName, }, // Include fields to create them in one call fields: fieldPayloads, }); console.log(`Created collection: ${collectionName}`); return { created: true }; } catch (error) { // Check if it's a "collection already exists" error if (error.message && error.message.includes('already exists')) { console.log(`Collection exists: ${collectionName}`); return { created: false }; } // Check if response indicates duplicate if (error.statusCode === 409 || error.statusCode === 400) { console.log(`Collection exists: ${collectionName}`); return { created: false }; } throw error; } } function defaultInterfaceForType(type) { switch (type) { case 'text': return 'input-multiline'; case 'boolean': return 'boolean'; case 'date': return 'datetime'; case 'dateTime': return 'datetime'; case 'uuid': return 'select-dropdown-m2o'; case 'decimal': case 'integer': return 'input'; default: return 'input'; } } async function ensureRelation(token, relation) { const result = await fetchJson(`${baseUrl}/relations`, token, 'POST', { collection: relation.collection, field: relation.field, related_collection: relation.related_collection, schema: { on_delete: 'CASCADE', }, meta: { many_collection: relation.collection, many_field: relation.field, one_collection: relation.one_collection, one_field: relation.one_field, one_deselect_action: 'nullify', }, }, { allowAlreadyExists: true }); if (result?.alreadyExists) { console.log(`Relation exists: ${relation.collection}.${relation.field}`); return; } console.log(`Created relation: ${relation.collection}.${relation.field} -> ${relation.related_collection}`); } async function fetchJson(url, token, method, body, options = {}) { const { allowNotFound = false, allowAlreadyExists = false } = options; const response = await fetch(url, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, }); if (allowNotFound && response.status === 404) { return null; } if (!response.ok) { const text = await response.text(); if (allowAlreadyExists && isAlreadyExistsError(response.status, text)) { return { alreadyExists: true }; } if (response.status === 403) { throw new Error( `${method} ${url} failed (403): ${text}\n` + 'Bootstrap authentication is valid but lacks admin privileges. ' + 'If this database was initialized earlier, your current DIRECTUS_ADMIN_EMAIL/PASSWORD may no longer match the original admin user. ' + 'Use a stable DIRECTUS_ADMIN_TOKEN, or reset dev volumes with "docker compose down -v".' ); } throw new Error(`${method} ${url} failed (${response.status}): ${text}`); } if (response.status === 204) { return null; } return response.json(); } function isAlreadyExistsError(status, text) { if (status !== 400 && status !== 409) { return false; } const normalized = text.toLowerCase(); return ( normalized.includes('already exists') || normalized.includes('duplicate') || normalized.includes('not unique') || normalized.includes('record_not_unique') ); } main().catch((error) => { console.error(error.message || error); process.exit(1); });