@@ -0,0 +1,389 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user