@catalystlabs/awm
Version:
Appwrite Migration Tool - Schema management and code generation for Appwrite databases
1,263 lines (1,095 loc) • 43.8 kB
JavaScript
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execSync } from 'child_process';
import crypto from 'crypto';
import readline from 'readline';
import dotenv from 'dotenv';
import {
AppwriteClient,
AppwriteStateStore,
AppwriteLockManager,
AppwriteSchemaInspector
} from './lib/appwrite.js';
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
gray: '\x1b[90m'
};
class AWMImproved {
constructor() {
this.root = process.cwd();
this.config = this.loadConfig();
// Try multiple schema file locations
const schemaPaths = [
process.env.AWM_SCHEMA,
this.config.schemaPath,
'schema/appwrite.schema',
'appwrite.schema'
].filter(Boolean);
this.schemaFile = null;
for (const schemaPath of schemaPaths) {
const fullPath = path.resolve(this.root, schemaPath);
if (fs.existsSync(fullPath)) {
this.schemaFile = fullPath;
break;
}
}
// Default to schema/appwrite.schema if none found
if (!this.schemaFile) {
this.schemaFile = path.resolve(this.root, 'schema/appwrite.schema');
}
this.appwriteProject = process.env.APPWRITE_PROJECT_ID || this.config.projectId;
this.appwriteEndpoint = process.env.APPWRITE_ENDPOINT || this.config.endpoint || 'http://localhost/v1';
this.appwriteKey = process.env.APPWRITE_API_KEY || this.config.apiKey;
this.databaseId = process.env.APPWRITE_DATABASE_ID || this.config.databaseId || this.extractDatabaseId();
this.dryRun = false;
this.debug = process.env.AWM_DEBUG === 'true' || this.config.debug;
this.force = false;
this.destructive = false;
this.allowDataLoss = false;
this.lockOwner = process.env.AWM_LOCK_OWNER || os.hostname();
this.appwriteClient = new AppwriteClient({
endpoint: this.appwriteEndpoint,
projectId: this.appwriteProject,
apiKey: this.appwriteKey
});
this.stateStore = new AppwriteStateStore({
client: this.appwriteClient,
databaseId: this.databaseId
});
this.lockManager = new AppwriteLockManager({
client: this.appwriteClient,
databaseId: this.databaseId
});
this.schemaInspector = new AppwriteSchemaInspector({
client: this.appwriteClient,
databaseId: this.databaseId
});
this.ready = this.bootstrap();
const argv = process.argv.slice(2);
this.command = argv[0];
this.args = argv.slice(1);
for (let i = 0; i < this.args.length; i++) {
const arg = this.args[i];
if (arg === '--dry-run') this.dryRun = true;
if (arg === '--force') this.force = true;
if (arg === '--yes') this.nonInteractive = true;
if (arg === '--destructive') this.destructive = true;
if (arg === '--allow-data-loss') this.allowDataLoss = true;
}
}
async bootstrap() {
if (!this.appwriteProject || !this.appwriteKey) {
return;
}
try {
await this.stateStore.init();
await this.lockManager.init();
} catch (error) {
console.error(`${colors.red}Failed to initialise Appwrite state: ${error.message}${colors.reset}`);
}
}
loadConfig() {
const configLocations = [
path.join(this.root, 'awm.config.json'),
path.join(this.root, '.awm.json'),
path.join(this.root, 'config', 'awm.json'),
path.join(this.root, '.config', 'awm.json')
];
for (const configFile of configLocations) {
if (fs.existsSync(configFile)) {
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
}
}
const envFile = path.join(this.root, '.env');
if (fs.existsSync(envFile)) {
dotenv.config({ path: envFile });
}
return {};
}
extractDatabaseId() {
if (!fs.existsSync(this.schemaFile)) return 'database';
const content = fs.readFileSync(this.schemaFile, 'utf8');
const match = content.match(/database\s*{[^}]*id\s*=\s*"([^"]+)"/);
return match ? match[1] : 'database';
}
async run() {
await this.ready;
switch (this.command) {
case 'init':
await this.init();
break;
case 'plan':
await this.plan();
break;
case 'apply':
await this.apply();
break;
case 'relationships':
await this.applyRelationships();
break;
case 'rollback':
await this.rollback();
break;
case 'status':
await this.status();
break;
case 'reset':
await this.reset();
break;
case 'generate-types':
await this.generateTypes(this.firstPositionalArg() || 'types/appwrite.types.ts');
break;
case 'generate-zod':
await this.generateZod(this.firstPositionalArg() || 'types/appwrite.zod.ts');
break;
case 'generate':
await this.generateArtifacts('types/appwrite.types.ts', 'types/appwrite.zod.ts');
break;
case 'studio':
await this.studio();
break;
default:
this.showHelp();
}
}
firstPositionalArg() {
return this.args.find(a => !a.startsWith('--'));
}
async init() {
console.log(`${colors.cyan}Initializing AWM in ${this.root}${colors.reset}`);
const { default: initProject } = await import('./init.js');
await initProject();
console.log(`\n${colors.green}✓ Initialization completed${colors.reset}`);
}
async plan() {
this.ensureConfig();
const schema = this.parseSchema(fs.readFileSync(this.schemaFile, 'utf8'));
const changes = await this.calculateChanges(schema);
const relationships = await this.calculateRelationships(schema);
const { additions, deletions } = changes;
const hasAdditions = additions.collections.length > 0 || additions.attributes.length > 0 || additions.indexes.length > 0;
const hasDeletions = deletions.collections.length > 0 || deletions.attributes.length > 0 || deletions.indexes.length > 0;
if (!hasAdditions && !hasDeletions && relationships.length === 0) {
console.log(`${colors.green}✓${colors.reset} Schema is already in sync.`);
return;
}
console.log(`\n${colors.bright}${colors.cyan}Planned Changes:${colors.reset}\n`);
// Show additions
if (hasAdditions) {
console.log(`${colors.bright}Additions:${colors.reset}`);
if (additions.collections.length) {
console.log(`\n ${colors.bright}Collections:${colors.reset}`);
for (const coll of additions.collections) {
console.log(` ${colors.green}+ ${coll.name}${colors.reset} ${colors.dim}(${coll.id})${colors.reset}`);
}
}
if (additions.attributes.length) {
console.log(`\n ${colors.bright}Attributes:${colors.reset}`);
for (const attr of additions.attributes) {
console.log(` ${colors.green}+ ${attr.collection_id}.${attr.key}${colors.reset} ${colors.dim}(${attr.type}${attr.array ? '[]' : ''})${colors.reset}`);
}
}
if (additions.indexes.length) {
console.log(`\n ${colors.bright}Indexes:${colors.reset}`);
for (const idx of additions.indexes) {
console.log(` ${colors.green}+ ${idx.collection_id}.${idx.key}${colors.reset} ${colors.dim}(${idx.type})${colors.reset}`);
}
}
}
// Show deletions
if (hasDeletions) {
if (hasAdditions) console.log('');
console.log(`${colors.bright}Deletions:${colors.reset} ${colors.red}(requires --destructive flag)${colors.reset}`);
if (deletions.collections.length) {
console.log(`\n ${colors.bright}Collections:${colors.reset}`);
for (const coll of deletions.collections) {
console.log(` ${colors.red}− ${coll.name}${colors.reset} ${colors.dim}(${coll.id})${colors.reset} ${colors.yellow}⚠ DATA LOSS${colors.reset}`);
}
}
if (deletions.attributes.length) {
console.log(`\n ${colors.bright}Attributes:${colors.reset}`);
for (const attr of deletions.attributes) {
console.log(` ${colors.red}− ${attr.collection_id}.${attr.key}${colors.reset} ${colors.dim}(${attr.type})${colors.reset}`);
}
}
if (deletions.indexes.length) {
console.log(`\n ${colors.bright}Indexes:${colors.reset}`);
for (const idx of deletions.indexes) {
console.log(` ${colors.red}− ${idx.collection_id}.${idx.key}${colors.reset} ${colors.dim}(${idx.type})${colors.reset}`);
}
}
}
// Show relationships
if (relationships.length) {
if (hasAdditions || hasDeletions) console.log('');
console.log(`${colors.bright}Relationships:${colors.reset}`);
for (const rel of relationships) {
console.log(` ${colors.green}+ ${rel.collection}.${rel.key}${colors.reset} → ${colors.cyan}${rel.to_collection}${colors.reset} ${colors.dim}(${rel.type})${colors.reset}`);
}
}
// Summary
const totalAdditions = additions.collections.length + additions.attributes.length + additions.indexes.length;
const totalDeletions = deletions.collections.length + deletions.attributes.length + deletions.indexes.length;
console.log(`\n${colors.bright}Summary:${colors.reset}`);
if (totalAdditions > 0) {
console.log(` ${colors.green}+${totalAdditions} addition${totalAdditions !== 1 ? 's' : ''}${colors.reset}`);
}
if (totalDeletions > 0) {
console.log(` ${colors.red}−${totalDeletions} deletion${totalDeletions !== 1 ? 's' : ''}${colors.reset}`);
}
if (relationships.length > 0) {
console.log(` ${colors.cyan}${relationships.length} relationship${relationships.length !== 1 ? 's' : ''}${colors.reset}`);
}
if (hasDeletions && !this.destructive) {
console.log(`\n${colors.yellow}⚠ Use ${colors.bright}--destructive${colors.reset}${colors.yellow} flag with 'apply' to perform deletions${colors.reset}`);
}
if (this.dryRun) {
console.log(`\n${colors.gray}Dry run - no changes will be applied${colors.reset}`);
}
}
async apply() {
this.ensureConfig();
await this.withLock('schema-apply', async () => {
const schema = this.parseSchema(fs.readFileSync(this.schemaFile, 'utf8'));
const changes = await this.calculateChanges(schema);
const { additions, deletions } = changes;
const hasAdditions = additions.collections.length > 0 || additions.attributes.length > 0 || additions.indexes.length > 0;
const hasDeletions = deletions.collections.length > 0 || deletions.attributes.length > 0 || deletions.indexes.length > 0;
if (!hasAdditions && !hasDeletions) {
console.log(`${colors.green}✓${colors.reset} Nothing to apply.`);
return;
}
// Check for deletions without --destructive flag
if (hasDeletions && !this.destructive) {
console.log(`${colors.red}✗ Destructive changes detected but --destructive flag not provided${colors.reset}\n`);
console.log(`${colors.yellow}Deletions require explicit confirmation:${colors.reset}`);
if (deletions.collections.length > 0) {
console.log(` ${colors.red}${deletions.collections.length} collection${deletions.collections.length !== 1 ? 's' : ''} to delete${colors.reset}`);
}
if (deletions.attributes.length > 0) {
console.log(` ${colors.red}${deletions.attributes.length} attribute${deletions.attributes.length !== 1 ? 's' : ''} to delete${colors.reset}`);
}
if (deletions.indexes.length > 0) {
console.log(` ${colors.red}${deletions.indexes.length} index${deletions.indexes.length !== 1 ? 'es' : ''} to delete${colors.reset}`);
}
console.log(`\n${colors.cyan}Run with ${colors.bright}--destructive${colors.reset}${colors.cyan} flag to proceed:${colors.reset}`);
console.log(` ${colors.dim}awm apply --destructive${colors.reset}`);
return;
}
// Show what will be applied
if (hasDeletions) {
console.log(`${colors.yellow}⚠ DESTRUCTIVE CHANGES DETECTED${colors.reset}\n`);
if (deletions.collections.length > 0) {
console.log(`${colors.red}Collections to delete:${colors.reset}`);
for (const coll of deletions.collections) {
console.log(` - ${coll.name} ${colors.yellow}(ALL DATA WILL BE LOST)${colors.reset}`);
}
}
if (deletions.attributes.length > 0 || deletions.indexes.length > 0) {
if (deletions.attributes.length > 0) {
console.log(`\n${colors.red}Attributes to delete: ${deletions.attributes.length}${colors.reset}`);
}
if (deletions.indexes.length > 0) {
console.log(`${colors.red}Indexes to delete: ${deletions.indexes.length}${colors.reset}`);
}
}
// Interactive confirmation unless --yes is provided
if (!this.nonInteractive) {
console.log(`\n${colors.red}${colors.bright}This action CANNOT be undone!${colors.reset}`);
const confirmed = await this.prompt(`${colors.yellow}Type 'DELETE' to confirm: ${colors.reset}`);
if (confirmed !== 'DELETE') {
console.log(`${colors.gray}Cancelled.${colors.reset}`);
return;
}
}
}
if (this.dryRun) {
console.log(`${colors.gray}Dry run - skipping apply${colors.reset}`);
return;
}
console.log(`\n${colors.cyan}Applying schema changes...${colors.reset}\n`);
// Apply deletions first (in correct order: indexes -> attributes -> collections)
if (hasDeletions) {
await this.applyDeletions(deletions);
}
// Then apply additions
if (hasAdditions) {
await this.applyAdditions(additions);
}
// Record in history
await this.stateStore.recordHistory({
type: 'apply',
databaseId: this.databaseId,
additions: this.compactChanges(additions),
deletions: this.compactChanges(deletions),
checksum: this.generateChecksum(changes)
});
console.log(`\n${colors.green}✓ Apply completed${colors.reset}`);
});
}
async applyRelationships() {
this.ensureConfig();
await this.withLock('schema-relationships', async () => {
const schema = this.parseSchema(fs.readFileSync(this.schemaFile, 'utf8'));
const relationships = await this.calculateRelationships(schema);
if (!relationships.length) {
console.log(`${colors.green}✓${colors.reset} No relationship changes needed.`);
return;
}
if (this.dryRun) {
console.log(`${colors.gray}Dry run - skipping relationships${colors.reset}`);
return;
}
console.log(`${colors.cyan}Applying relationship attributes...${colors.reset}\n`);
for (const rel of relationships) {
try {
const relType = (rel.type || 'many-to-one')
.split('-')
.map((part, index) => index === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const twoWay = rel.two_way_key ? '--two-way true' : '';
const twoWayKeyArg = rel.two_way_key ? `--two-way-key "${rel.two_way_key}"` : '';
execSync(`appwrite databases create-relationship-attribute \
--database-id ${this.databaseId} \
--collection-id "${rel.collection}" \
--related-collection-id "${rel.to_collection}" \
--type "${relType}" \
--key "${rel.key}" \
${twoWayKeyArg} \
${twoWay} \
--on-delete "${rel.on_delete || 'restrict'}"`, { stdio: 'pipe' });
console.log(` ${colors.green}✓${colors.reset} Relationship ${rel.collection}.${rel.key}`);
} catch (error) {
if (error.stderr?.toString()?.includes('already exists')) {
console.log(` ${colors.gray}○${colors.reset} Relationship already exists: ${rel.collection}.${rel.key}`);
} else {
throw error;
}
}
}
await this.stateStore.recordHistory({
type: 'relationships',
databaseId: this.databaseId,
relationships: relationships.map(rel => ({
collection: rel.collection,
key: rel.key,
to_collection: rel.to_collection,
type: rel.type,
twoWayKey: rel.two_way_key || null
}))
});
console.log(`\n${colors.green}✓ Relationships applied${colors.reset}`);
});
}
async rollback() {
this.ensureConfig();
await this.withLock('schema-rollback', async () => {
const history = await this.stateStore.latestHistory('applied');
if (!history) {
console.log(`${colors.yellow}No applied migrations found to roll back.${colors.reset}`);
return;
}
const payload = history.payload || {};
if (payload.type !== 'apply') {
console.log(`${colors.yellow}Latest history entry is not an apply run. Nothing to roll back.${colors.reset}`);
return;
}
const changes = payload.changes || {};
const { indexes = [], attributes = [], collections = [] } = changes;
if (this.dryRun) {
console.log(`${colors.gray}Dry run - rollback would remove:${colors.reset}`);
collections.forEach(coll => console.log(` ${colors.red}-${colors.reset} collection ${coll.id}`));
attributes.forEach(attr => console.log(` ${colors.red}-${colors.reset} attribute ${attr.collection_id}.${attr.key}`));
indexes.forEach(idx => console.log(` ${colors.red}-${colors.reset} index ${idx.collection_id}.${idx.key}`));
return;
}
console.log(`${colors.cyan}Rolling back last apply...${colors.reset}`);
for (const idx of indexes) {
try {
execSync(`appwrite databases delete-index \
--database-id ${this.databaseId} \
--collection-id "${idx.collection_id}" \
--key "${idx.key}"`, { stdio: 'pipe' });
console.log(` ${colors.red}-${colors.reset} index ${idx.collection_id}.${idx.key}`);
} catch (error) {
console.log(` ${colors.gray}○${colors.reset} index already removed: ${idx.collection_id}.${idx.key}`);
}
}
for (const attr of attributes) {
try {
execSync(`appwrite databases delete-attribute \
--database-id ${this.databaseId} \
--collection-id "${attr.collection_id}" \
--key "${attr.key}"`, { stdio: 'pipe' });
console.log(` ${colors.red}-${colors.reset} attribute ${attr.collection_id}.${attr.key}`);
} catch (error) {
console.log(` ${colors.gray}○${colors.reset} attribute already removed: ${attr.collection_id}.${attr.key}`);
}
}
for (const coll of collections) {
try {
execSync(`appwrite databases delete-collection \
--database-id ${this.databaseId} \
--collection-id "${coll.id}"`, { stdio: 'pipe' });
console.log(` ${colors.red}-${colors.reset} collection ${coll.id}`);
} catch (error) {
console.log(` ${colors.gray}○${colors.reset} collection already removed: ${coll.id}`);
}
}
await this.stateStore.updateHistoryStatus(history.recordId, 'rolled_back');
console.log(`\n${colors.green}✓ Rollback complete${colors.reset}`);
});
}
async status() {
this.ensureConfig();
const remote = await this.schemaInspector.describe();
const collectionCount = remote.size;
let attributeCount = 0;
let indexCount = 0;
for (const { attributes, indexes } of remote.values()) {
attributeCount += attributes.length;
indexCount += indexes.length;
}
console.log(`${colors.cyan}Database Status${colors.reset}`);
console.log(` Collections: ${collectionCount}`);
console.log(` Attributes: ${attributeCount}`);
console.log(` Indexes: ${indexCount}`);
const histories = await this.stateStore.listRecords('history');
if (histories.length) {
console.log(`\n${colors.bright}Recent history:${colors.reset}`);
histories
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1))
.slice(0, 5)
.forEach(entry => {
const summary = entry.payload?.type || 'unknown';
console.log(` ${entry.createdAt}: ${summary} (${entry.status || 'unknown'})`);
});
}
}
async reset() {
this.ensureConfig();
await this.withLock('schema-reset', async () => {
if (!this.nonInteractive) {
const confirmed = await this.prompt('This will clear migration history. Continue? (y/N): ');
if (!['y', 'yes'].includes((confirmed || '').toLowerCase())) {
console.log('Reset cancelled.');
return;
}
}
await this.stateStore.reset();
console.log(`${colors.green}✓ State cleared${colors.reset}`);
});
}
async generateTypes(outputPath) {
const { TypeGenerator } = await this.loadGenerators();
const generator = new TypeGenerator(this.schemaFile);
await generator.generate(outputPath);
}
async generateZod(outputPath) {
const { ZodGenerator } = await this.loadGenerators();
const generator = new ZodGenerator(this.schemaFile);
await generator.generate(outputPath);
}
async generateArtifacts(typesPath, zodPath) {
console.log('\n🚀 Generating TypeScript types and Zod schemas...\n');
await this.generateTypes(typesPath);
await this.generateZod(zodPath);
console.log('\n✨ All generators completed successfully!');
}
async studio() {
console.log(`${colors.cyan}Launching AWM Studio...${colors.reset}`);
const { spawn } = await import('child_process');
const studioScript = path.join(path.dirname(new URL(import.meta.url).pathname), 'studio-server.js');
const studio = spawn('node', [studioScript], {
stdio: 'inherit',
shell: true
});
studio.on('close', (code) => {
if (code !== 0) {
console.error(`${colors.red}Studio exited with code ${code}${colors.reset}`);
}
});
}
async withLock(lockId, fn) {
if (!this.appwriteProject || !this.appwriteKey) {
throw new Error('Appwrite credentials are required for this operation');
}
await this.lockManager.acquire(lockId, { owner: this.lockOwner, force: this.force });
try {
return await fn();
} finally {
await this.lockManager.release(lockId, this.lockOwner);
}
}
ensureConfig() {
if (!this.appwriteProject) {
throw new Error('APPWRITE_PROJECT_ID is required');
}
if (!this.appwriteKey) {
throw new Error('APPWRITE_API_KEY is required');
}
if (!this.databaseId) {
throw new Error('APPWRITE_DATABASE_ID is required');
}
}
async calculateChanges(schema) {
const remote = await this.schemaInspector.describe();
const additions = {
collections: [],
attributes: [],
indexes: []
};
const deletions = {
collections: [],
attributes: [],
indexes: []
};
// Build map of schema collections for quick lookup
const schemaCollections = new Map();
for (const [name, coll] of Object.entries(schema.collections || {})) {
const collId = this.toKebabCase(name);
schemaCollections.set(collId, coll);
}
// Step 1: Find additions (in schema but not in remote)
for (const [name, coll] of Object.entries(schema.collections || {})) {
const collId = this.toKebabCase(name);
const remoteColl = remote.get(collId);
if (!remoteColl) {
additions.collections.push({
id: collId,
name: coll.name || name,
attributes: coll.attributes,
indexes: coll.indexes
});
continue;
}
const remoteAttributes = new Map();
for (const attr of remoteColl.attributes || []) {
remoteAttributes.set(attr.key, attr);
}
// Find new attributes
for (const [attrName, attr] of Object.entries(coll.attributes || {})) {
if (attr.decorators?.relationship) continue;
if (!remoteAttributes.has(attrName)) {
additions.attributes.push({
collection_id: collId,
key: attrName,
type: attr.type,
array: !!attr.array,
required: !!attr.required,
size: attr.size,
default: attr.default
});
}
}
const remoteIndexes = new Map();
for (const idx of remoteColl.indexes || []) {
remoteIndexes.set(idx.key, idx);
}
// Find new indexes
for (const index of coll.indexes || []) {
const rawKey = index.name || `idx_${(index.attributes || index.fields || []).join('_')}`;
const key = this.sanitizeIndexKey(rawKey);
if (!remoteIndexes.has(key)) {
additions.indexes.push({
collection_id: collId,
key,
type: index.type || 'key',
attributes: index.attributes || index.fields || [],
orders: index.orders || []
});
}
}
}
// Step 2: Find deletions (in remote but not in schema)
for (const [collId, remoteColl] of remote.entries()) {
const schemaColl = schemaCollections.get(collId);
if (!schemaColl) {
// Entire collection should be deleted
deletions.collections.push({
id: collId,
name: remoteColl.name || collId
});
continue;
}
// Build map of schema attributes for this collection
const schemaAttributes = new Set();
for (const [attrName, attr] of Object.entries(schemaColl.attributes || {})) {
if (!attr.decorators?.relationship) {
schemaAttributes.add(attrName);
}
}
// Find deleted attributes
for (const attr of remoteColl.attributes || []) {
if (attr.type === 'relationship') continue;
if (!schemaAttributes.has(attr.key)) {
deletions.attributes.push({
collection_id: collId,
key: attr.key,
type: attr.type
});
}
}
// Build map of schema indexes
const schemaIndexes = new Set();
for (const index of schemaColl.indexes || []) {
const rawKey = index.name || `idx_${(index.attributes || index.fields || []).join('_')}`;
const key = this.sanitizeIndexKey(rawKey);
schemaIndexes.add(key);
}
// Find deleted indexes
for (const idx of remoteColl.indexes || []) {
if (!schemaIndexes.has(idx.key)) {
deletions.indexes.push({
collection_id: collId,
key: idx.key,
type: idx.type
});
}
}
}
return { additions, deletions };
}
compactChanges(changes = {}) {
return {
collections: (changes.collections || []).map(coll => ({
id: coll.id,
name: coll.name
})),
attributes: (changes.attributes || []).map(attr => ({
collection_id: attr.collection_id,
key: attr.key
})),
indexes: (changes.indexes || []).map(idx => ({
collection_id: idx.collection_id,
key: this.sanitizeIndexKey(idx.key)
}))
};
}
async calculateRelationships(schema) {
const remote = await this.schemaInspector.describe();
const pending = [];
for (const [name, coll] of Object.entries(schema.collections || {})) {
const collId = this.toKebabCase(name);
const remoteColl = remote.get(collId);
const remoteRelationshipKeys = new Set(
(remoteColl?.attributes || [])
.filter(attr => attr.type === 'relationship')
.map(attr => attr.key)
);
for (const [attrName, attr] of Object.entries(coll.attributes || {})) {
const rel = attr.decorators?.relationship;
if (!rel) continue;
if (remoteRelationshipKeys.has(attrName)) continue;
pending.push({
collection: collId,
key: attrName,
to_collection: this.toKebabCase(rel.to || attr.type),
type: rel.type || 'many-to-one',
two_way_key: rel.twoWayKey,
on_delete: rel.onDelete || 'restrict'
});
}
}
return pending;
}
async applyDeletions(deletions) {
const dbId = this.databaseId;
// Delete in correct order: indexes -> attributes -> collections
// This ensures no dependency issues
// Step 1: Delete indexes
for (const idx of deletions.indexes || []) {
try {
execSync(`appwrite databases delete-index \
--database-id ${dbId} \
--collection-id "${idx.collection_id}" \
--key "${idx.key}"`, { stdio: 'pipe' });
console.log(` ${colors.red}−${colors.reset} Deleted index ${idx.collection_id}.${idx.key}`);
} catch (error) {
if (error.stderr?.toString()?.includes('not found')) {
console.log(` ${colors.gray}○${colors.reset} Index already removed: ${idx.collection_id}.${idx.key}`);
} else {
console.error(` ${colors.red}✗${colors.reset} Failed to delete index ${idx.collection_id}.${idx.key}: ${error.message}`);
}
}
}
// Step 2: Delete attributes
for (const attr of deletions.attributes || []) {
try {
execSync(`appwrite databases delete-attribute \
--database-id ${dbId} \
--collection-id "${attr.collection_id}" \
--key "${attr.key}"`, { stdio: 'pipe' });
console.log(` ${colors.red}−${colors.reset} Deleted attribute ${attr.collection_id}.${attr.key}`);
} catch (error) {
if (error.stderr?.toString()?.includes('not found')) {
console.log(` ${colors.gray}○${colors.reset} Attribute already removed: ${attr.collection_id}.${attr.key}`);
} else {
console.error(` ${colors.red}✗${colors.reset} Failed to delete attribute ${attr.collection_id}.${attr.key}: ${error.message}`);
}
}
}
// Step 3: Delete collections
for (const coll of deletions.collections || []) {
try {
execSync(`appwrite databases delete-collection \
--database-id ${dbId} \
--collection-id "${coll.id}"`, { stdio: 'pipe' });
console.log(` ${colors.red}−${colors.reset} Deleted collection ${coll.name} ${colors.yellow}(including all data)${colors.reset}`);
} catch (error) {
if (error.stderr?.toString()?.includes('not found')) {
console.log(` ${colors.gray}○${colors.reset} Collection already removed: ${coll.name}`);
} else {
console.error(` ${colors.red}✗${colors.reset} Failed to delete collection ${coll.name}: ${error.message}`);
}
}
}
}
async applyAdditions(additions) {
const dbId = this.databaseId;
for (const coll of additions.collections || []) {
try {
execSync(`appwrite databases create-collection \
--database-id ${dbId} \
--collection-id "${coll.id}" \
--name "${coll.name}" \
--enabled true`, { stdio: 'pipe' });
console.log(` ${colors.green}✓${colors.reset} Created collection ${coll.name}`);
} catch (error) {
if (error.stderr?.toString()?.includes('already exists')) {
console.log(` ${colors.gray}○${colors.reset} Collection already exists: ${coll.name}`);
} else {
throw error;
}
}
}
for (const attr of additions.attributes || []) {
try {
const cmd = this.buildAttributeCommand(dbId, attr);
execSync(cmd, { stdio: 'pipe' });
console.log(` ${colors.green}✓${colors.reset} Created attribute ${attr.collection_id}.${attr.key}`);
} catch (error) {
if (error.stderr?.toString()?.includes('already exists')) {
console.log(` ${colors.gray}○${colors.reset} Attribute already exists: ${attr.collection_id}.${attr.key}`);
} else {
throw error;
}
}
}
for (const idx of additions.indexes || []) {
try {
const indexKey = this.sanitizeIndexKey(idx.key);
if (this.debug) {
console.log(` creating index ${idx.collection_id}.${indexKey} (${idx.type})`);
}
await this.appwriteClient.ensureIndex(dbId, idx.collection_id, {
key: indexKey,
type: idx.type || 'key',
attributes: idx.attributes || [],
orders: (idx.orders || []).filter(Boolean)
});
console.log(` ${colors.green}✓${colors.reset} Created index ${idx.collection_id}.${indexKey}`);
} catch (error) {
if (error.status === 409) {
console.log(` ${colors.gray}○${colors.reset} Index already exists: ${idx.collection_id}.${idx.key}`);
} else {
throw error;
}
}
}
}
buildAttributeCommand(dbId, attr) {
const base = `appwrite databases`;
const common = `--database-id ${dbId} --collection-id "${attr.collection_id}" --key "${attr.key}"`;
const req = attr.required ? '--required true' : '--required false';
const arr = attr.array ? '--array true' : '--array false';
const type = attr.type.toLowerCase();
const size = attr.size || 255;
switch (type) {
case 'string':
return `${base} create-string-attribute ${common} --size ${size} ${req} ${arr}`;
case 'integer':
case 'int':
return `${base} create-integer-attribute ${common} ${req} ${arr}`;
case 'float':
case 'double':
return `${base} create-float-attribute ${common} ${req} ${arr}`;
case 'boolean':
case 'bool':
return `${base} create-boolean-attribute ${common} ${req} ${arr}`;
case 'datetime':
return `${base} create-datetime-attribute ${common} ${req} ${arr}`;
case 'email':
return `${base} create-email-attribute ${common} ${req} ${arr}`;
case 'url':
return `${base} create-url-attribute ${common} ${req} ${arr}`;
default:
throw new Error(`Unsupported attribute type: ${attr.type}`);
}
}
async loadGenerators() {
const [types, zod] = await Promise.all([
import('./type-generator.js'),
import('./zod-generator.js')
]);
return {
TypeGenerator: types.TypeGenerator,
ZodGenerator: zod.ZodGenerator
};
}
parseSchema(content) {
const schema = {
databases: new Map(),
collections: {}
};
const lines = content.split('\n');
let currentBlock = null;
let currentCollection = null;
let currentDatabase = null;
let bracketDepth = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
bracketDepth += (trimmed.match(/{/g) || []).length;
bracketDepth -= (trimmed.match(/}/g) || []).length;
if (trimmed.startsWith('database')) {
currentBlock = 'database';
currentCollection = null;
currentDatabase = {};
continue;
}
const collMatch = trimmed.match(/^collection\s+(\w+)/);
if (collMatch) {
currentBlock = 'collection';
currentCollection = collMatch[1];
schema.collections[currentCollection] = {
name: currentCollection,
attributes: {},
indexes: []
};
continue;
}
if (bracketDepth === 0) {
if (currentBlock === 'database' && currentDatabase?.id) {
schema.databases.set(currentDatabase.id, currentDatabase);
}
currentBlock = null;
currentCollection = null;
currentDatabase = null;
continue;
}
if (currentBlock === 'database') {
const match = trimmed.match(/(\w+)\s*=\s*"([^"]+)"/);
if (match) {
currentDatabase[match[1]] = match[2];
}
continue;
}
if (currentBlock === 'collection' && currentCollection) {
const attrMatch = trimmed.match(/^(\w+)\s+(\w+)(\[\])?\s*(.*)/);
if (attrMatch) {
const [, name, type, isArray, decorators] = attrMatch;
const decoratorData = this.parseDecorators(decorators);
const normalizedDefault = this.normalizeDefaultValue(type, decoratorData.default);
decoratorData.default = normalizedDefault;
schema.collections[currentCollection].attributes[name] = {
type,
array: !!isArray,
required: !!decoratorData.required,
size: decoratorData.size,
default: normalizedDefault,
decorators: decoratorData
};
continue;
}
if (trimmed.startsWith('@@index')) {
const indexMatch = trimmed.match(/@@index\(\[([^\]]+)\](?:,\s*([^)]+))?\)/);
if (indexMatch) {
const { fields, orders } = this.parseIndexFields(indexMatch[1]);
let type = (indexMatch[2] || 'key').trim();
if (['asc', 'desc'].includes(type.toLowerCase())) {
if (fields.length > 0) orders[0] = type.toLowerCase();
type = 'key';
}
schema.collections[currentCollection].indexes.push({
attributes: fields,
orders,
type
});
}
continue;
}
if (trimmed.startsWith('@@unique')) {
const uniqueMatch = trimmed.match(/@@unique\(\[([^\]]+)\]\)/);
if (uniqueMatch) {
const { fields, orders } = this.parseIndexFields(uniqueMatch[1]);
schema.collections[currentCollection].indexes.push({
attributes: fields,
orders,
type: 'unique'
});
}
continue;
}
}
}
return schema;
}
parseDecorators(decoratorString) {
const decorators = {};
const source = decoratorString || '';
const regex = /@(\w+)(?:\(([^)]*)\))?/g;
let match;
while ((match = regex.exec(source)) !== null) {
const [, name, paramsRaw] = match;
const params = paramsRaw || '';
if (name === 'size' && params) {
decorators.size = parseInt(params, 10);
} else if (name === 'required') {
decorators.required = true;
} else if (name === 'unique') {
decorators.unique = true;
} else if (name === 'default') {
decorators.default = params.replace(/['"]/g, '');
} else if (name === 'relationship') {
decorators.relationship = this.parseRelationshipDecorator(params);
} else {
decorators[name] = params || true;
}
}
return decorators;
}
parseRelationshipDecorator(params) {
const rel = {};
if (!params) return rel;
const parts = params.split(',').map(p => p.trim());
for (const part of parts) {
const [key, value] = part.split(':').map(x => x.trim());
rel[key] = value?.replace(/['"]/g, '');
}
return rel;
}
parseIndexFields(fieldString) {
const tokens = fieldString
.split(',')
.map(token => token.trim())
.filter(Boolean);
const fields = [];
const orders = [];
for (const token of tokens) {
const lower = token.toLowerCase();
if ((lower === 'asc' || lower === 'desc') && fields.length) {
orders[fields.length - 1] = lower === 'desc' ? 'DESC' : 'ASC';
} else {
fields.push(token);
orders.push(null);
}
}
return { fields, orders };
}
normalizeDefaultValue(type, value) {
if (value === undefined || value === null || value === '') return undefined;
const lowerType = type.toLowerCase();
if (lowerType === 'boolean' || lowerType === 'bool') {
return ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase());
}
if (lowerType === 'integer' || lowerType === 'int') {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
if (lowerType === 'float' || lowerType === 'double') {
const parsed = parseFloat(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
return String(value);
}
toKebabCase(str) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[\s_]+/g, '-').toLowerCase();
}
sanitizeIndexKey(key) {
const cleaned = (key || '')
.replace(/^[^a-zA-Z0-9]+/, '')
.replace(/[^a-zA-Z0-9._-]/g, '_') || 'idx_hash';
if (cleaned.length <= 36) {
return cleaned;
}
const hash = crypto.createHash('md5').update(cleaned).digest('hex').slice(0, 28);
return `idx_${hash}`;
}
generateChecksum(data) {
return crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
}
prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise(resolve => {
rl.question(question, answer => {
rl.close();
resolve(answer);
});
});
}
showHelp() {
console.log(`
${colors.cyan}${colors.bright}AWM - Appwrite Migration Tool${colors.reset}
${colors.bright}Usage:${colors.reset} awm <command> [options]
${colors.bright}Commands:${colors.reset}
${colors.cyan}init${colors.reset} Initialize AWM in your project
${colors.cyan}plan${colors.reset} Show pending schema changes (additions & deletions)
${colors.cyan}apply${colors.reset} Apply schema changes (additions only by default)
${colors.cyan}relationships${colors.reset} Apply relationship attributes
${colors.cyan}status${colors.reset} Display database summary and migration history
${colors.cyan}rollback${colors.reset} Revert the last apply run
${colors.cyan}reset${colors.reset} Clear migration history
${colors.cyan}generate-types${colors.reset} Generate TypeScript types from schema
${colors.cyan}generate-zod${colors.reset} Generate Zod validation schemas
${colors.cyan}generate${colors.reset} Generate both types and Zod schemas
${colors.cyan}studio${colors.reset} Launch AWM Studio - Beautiful data browser
${colors.bright}Options:${colors.reset}
${colors.yellow}--dry-run${colors.reset} Preview changes without applying them
${colors.yellow}--destructive${colors.reset} ${colors.red}Enable deletion of collections/attributes/indexes${colors.reset}
${colors.yellow}--yes${colors.reset} Skip interactive confirmation prompts
${colors.yellow}--force${colors.reset} Force operation (bypass locks)
${colors.yellow}--debug${colors.reset} Show detailed debug information
${colors.bright}Examples:${colors.reset}
${colors.dim}# Preview all changes${colors.reset}
awm plan
${colors.dim}# Apply additions only${colors.reset}
awm apply
${colors.dim}# Apply ALL changes including deletions${colors.reset}
awm apply --destructive
${colors.dim}# Skip confirmation prompts (CI/CD)${colors.reset}
awm apply --destructive --yes
${colors.yellow}⚠ Warning:${colors.reset} The ${colors.bright}--destructive${colors.reset} flag will delete collections,
attributes, and indexes that are not in your schema file. This CANNOT
be undone and will result in DATA LOSS.
${colors.dim}For more information: https://github.com/ccollier86/awm${colors.reset}
`);
}
}
const main = async () => {
const awm = new AWMImproved();
try {
await awm.run();
} catch (error) {
console.error(`${colors.red}✗ ${error.message}${colors.reset}`);
if (process.env.AWM_DEBUG === 'true' && error.stack) {
console.error(error.stack);
}
process.exit(1);
}
};
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}