UNPKG

@catalystlabs/awm

Version:

Appwrite Migration Tool - Schema management and code generation for Appwrite databases

627 lines (542 loc) 17.9 kB
import crypto from 'crypto'; class AppwriteError extends Error { constructor(message, status, payload) { super(message); this.name = 'AppwriteError'; this.status = status; this.payload = payload; } } export class AppwriteClient { constructor({ endpoint, projectId, apiKey }) { this.endpoint = endpoint?.replace(/\/$/, '') || 'http://localhost/v1'; this.projectId = projectId; this.apiKey = apiKey; } async request({ method = 'GET', path, body, searchParams }) { if (!this.projectId || !this.apiKey) { throw new Error('Appwrite credentials are not configured'); } const url = new URL(`${this.endpoint}${path.startsWith('/') ? '' : '/'}${path}`); if (Array.isArray(searchParams)) { for (const value of searchParams) { url.searchParams.append('queries[]', value); } } else if (searchParams instanceof URLSearchParams) { searchParams.forEach((value, key) => { url.searchParams.append(key, value); }); } const headers = { 'Content-Type': 'application/json', 'X-Appwrite-Project': this.projectId, 'X-Appwrite-Key': this.apiKey }; const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined }); const contentType = response.headers.get('content-type') || ''; const isJson = contentType.includes('application/json'); const raw = await response.text(); const payload = raw && isJson ? JSON.parse(raw) : raw || null; if (!response.ok) { const message = payload?.message || `${method} ${url.pathname} failed (${response.status})`; if (![404, 409].includes(response.status)) { console.error(`[Appwrite] ${method} ${url.toString()} -> ${response.status} ${raw}`); } throw new AppwriteError(message, response.status, payload); } return payload; } async getCollection(databaseId, collectionId) { try { return await this.request({ method: 'GET', path: `/databases/${databaseId}/collections/${collectionId}` }); } catch (error) { if (error.status === 404) return null; throw error; } } async listCollections(databaseId) { const collections = []; let offset = 0; const limit = 100; while (true) { const params = new URLSearchParams(); params.set('limit', limit); params.set('offset', offset); const result = await this.request({ method: 'GET', path: `/databases/${databaseId}/collections`, searchParams: params }); collections.push(...(result.collections || [])); if (!result.total || collections.length >= result.total) break; offset += limit; } return collections; } async createCollection(databaseId, collectionId, name) { return this.request({ method: 'POST', path: `/databases/${databaseId}/collections`, body: { collectionId, name, permissions: [], documentSecurity: false, enabled: true } }); } async ensureStringAttribute(databaseId, collectionId, { key, size = 255, required = false, array = false, defaultValue = null }) { try { await this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/attributes/string`, body: { key, size, required, default: defaultValue, array } }); } catch (error) { if (error.status !== 409) throw error; } } async ensureBooleanAttribute(databaseId, collectionId, { key, required = false, defaultValue = null, array = false }) { try { await this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/attributes/boolean`, body: { key, required, default: defaultValue, array } }); } catch (error) { if (error.status !== 409) throw error; } } async ensureIntegerAttribute(databaseId, collectionId, { key, required = false, defaultValue = null, array = false }) { try { await this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/attributes/integer`, body: { key, required, default: defaultValue, array } }); } catch (error) { if (error.status !== 409) throw error; } } async ensureFloatAttribute(databaseId, collectionId, { key, required = false, defaultValue = null, array = false }) { try { await this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/attributes/float`, body: { key, required, default: defaultValue, array } }); } catch (error) { if (error.status !== 409) throw error; } } async ensureDatetimeAttribute(databaseId, collectionId, { key, required = false, defaultValue = null, array = false }) { try { await this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/attributes/datetime`, body: { key, required, default: defaultValue, array } }); } catch (error) { if (error.status !== 409) throw error; } } async ensureIndex(databaseId, collectionId, { key, type = 'key', attributes = [], orders = [] }) { try { await this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/indexes`, body: { key, type, attributes, orders } }); } catch (error) { if (error.status !== 409) throw error; } } async getDocument(databaseId, collectionId, documentId) { try { return await this.request({ method: 'GET', path: `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}` }); } catch (error) { if (error.status === 404) return null; throw error; } } async listDocuments(databaseId, collectionId, queries = []) { const documents = []; let offset = 0; const limit = 100; while (true) { const params = new URLSearchParams(); params.set('limit', limit); params.set('offset', offset); for (const query of queries.filter(Boolean)) { params.append('queries[]', query); } const result = await this.request({ method: 'GET', path: `/databases/${databaseId}/collections/${collectionId}/documents`, searchParams: params }); documents.push(...(result.documents || [])); if (!result.total || documents.length >= result.total) break; offset += limit; } return documents; } async createDocument(databaseId, collectionId, documentId, data) { return this.request({ method: 'POST', path: `/databases/${databaseId}/collections/${collectionId}/documents`, body: { documentId, data } }); } async updateDocument(databaseId, collectionId, documentId, data) { return this.request({ method: 'PATCH', path: `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}`, body: { data } }); } async deleteDocument(databaseId, collectionId, documentId) { try { await this.request({ method: 'DELETE', path: `/databases/${databaseId}/collections/${collectionId}/documents/${documentId}` }); } catch (error) { if (error.status !== 404) throw error; } } async deleteCollection(databaseId, collectionId) { try { await this.request({ method: 'DELETE', path: `/databases/${databaseId}/collections/${collectionId}` }); } catch (error) { if (error.status !== 404) throw error; } } } export class AppwriteSchemaInspector { constructor({ client, databaseId }) { this.client = client; this.databaseId = databaseId; } async describe() { const collections = await this.client.listCollections(this.databaseId); const map = new Map(); for (const collection of collections) { map.set(collection.$id, { collection, attributes: collection.attributes || [], indexes: collection.indexes || [] }); } return map; } } export class AppwriteStateStore { constructor({ client, databaseId, stateCollectionId = 'awm_state', lockCollectionId = 'awm_locks' }) { this.client = client; this.databaseId = databaseId; this.stateCollectionId = stateCollectionId; this.lockCollectionId = lockCollectionId; } async init() { await this.ensureStateCollection(); await this.ensureLockCollection(); } async ensureStateCollection() { const exists = await this.client.getCollection(this.databaseId, this.stateCollectionId); if (!exists) { await this.client.createCollection(this.databaseId, this.stateCollectionId, 'AWM State'); } await this.client.ensureStringAttribute(this.databaseId, this.stateCollectionId, { key: 'record_type', size: 64, required: true }); await this.client.ensureStringAttribute(this.databaseId, this.stateCollectionId, { key: 'record_id', size: 128, required: true }); await this.client.ensureStringAttribute(this.databaseId, this.stateCollectionId, { key: 'status', size: 50, required: false }); await this.client.ensureStringAttribute(this.databaseId, this.stateCollectionId, { key: 'payload', size: 20000, required: false }); await this.client.ensureDatetimeAttribute(this.databaseId, this.stateCollectionId, { key: 'created_at', required: true, defaultValue: null }); await this.client.ensureDatetimeAttribute(this.databaseId, this.stateCollectionId, { key: 'updated_at', required: false }); await this.client.ensureIndex(this.databaseId, this.stateCollectionId, { key: 'idx_record_type', type: 'key', attributes: ['record_type'] }); await this.client.ensureIndex(this.databaseId, this.stateCollectionId, { key: 'idx_record_type_id', type: 'unique', attributes: ['record_type', 'record_id'], orders: ['ASC', 'ASC'] }); } async ensureLockCollection() { const exists = await this.client.getCollection(this.databaseId, this.lockCollectionId); if (!exists) { await this.client.createCollection(this.databaseId, this.lockCollectionId, 'AWM Locks'); } await this.client.ensureStringAttribute(this.databaseId, this.lockCollectionId, { key: 'lock_id', size: 64, required: true }); await this.client.ensureStringAttribute(this.databaseId, this.lockCollectionId, { key: 'owner', size: 255, required: false }); await this.client.ensureStringAttribute(this.databaseId, this.lockCollectionId, { key: 'status', size: 50, required: false }); await this.client.ensureDatetimeAttribute(this.databaseId, this.lockCollectionId, { key: 'created_at', required: true }); await this.client.ensureDatetimeAttribute(this.databaseId, this.lockCollectionId, { key: 'expires_at', required: false }); await this.client.ensureStringAttribute(this.databaseId, this.lockCollectionId, { key: 'metadata', size: 2000, required: false }); await this.client.ensureIndex(this.databaseId, this.lockCollectionId, { key: 'idx_lock_id', type: 'unique', attributes: ['lock_id'] }); } _docId(type, recordId) { return crypto.createHash('md5').update(`${type}:${recordId}`).digest('hex'); } _serializePayload(payload) { if (payload === undefined || payload === null) return null; return JSON.stringify(payload); } _deserializePayload(payload) { if (!payload) return null; try { return JSON.parse(payload); } catch { return null; } } async upsertRecord(type, recordId, payload, status) { const documentId = this._docId(type, recordId); const existing = await this.client.getDocument(this.databaseId, this.stateCollectionId, documentId); const now = new Date().toISOString(); const data = { record_type: type, record_id: recordId, status: status || existing?.status || null, payload: this._serializePayload(payload), updated_at: now }; if (existing) { await this.client.updateDocument(this.databaseId, this.stateCollectionId, documentId, data); } else { data.created_at = now; await this.client.createDocument(this.databaseId, this.stateCollectionId, documentId, data); } } async deleteRecord(type, recordId) { const documentId = this._docId(type, recordId); await this.client.deleteDocument(this.databaseId, this.stateCollectionId, documentId); } async listRecords(type) { const docs = await this.client.listDocuments(this.databaseId, this.stateCollectionId); return docs .filter(doc => doc.record_type === type) .map(doc => ({ id: doc.record_id, status: doc.status || null, payload: this._deserializePayload(doc.payload), documentId: doc.$id, createdAt: doc.created_at || doc.$createdAt, updatedAt: doc.updated_at || doc.$updatedAt })); } async getRecord(type, recordId) { const documentId = this._docId(type, recordId); const doc = await this.client.getDocument(this.databaseId, this.stateCollectionId, documentId); if (!doc) return null; return { id: doc.record_id, status: doc.status || null, payload: this._deserializePayload(doc.payload), documentId: doc.$id, createdAt: doc.created_at || doc.$createdAt, updatedAt: doc.updated_at || doc.$updatedAt }; } async countRecords(type) { const docs = await this.listRecords(type); return docs.length; } async reset() { const docs = await this.client.listDocuments(this.databaseId, this.stateCollectionId); for (const doc of docs) { await this.client.deleteDocument(this.databaseId, this.stateCollectionId, doc.$id); } } async recordHistory(payload, status = 'applied') { const recordId = crypto.randomUUID().replace(/-/g, ''); const now = new Date().toISOString(); const documentId = this._docId('history', recordId); await this.client.createDocument(this.databaseId, this.stateCollectionId, documentId, { record_type: 'history', record_id: recordId, status, payload: this._serializePayload(payload), created_at: now, updated_at: now }); return recordId; } async updateHistoryStatus(recordId, status) { const documentId = this._docId('history', recordId); await this.client.updateDocument(this.databaseId, this.stateCollectionId, documentId, { status, updated_at: new Date().toISOString() }); } async latestHistory(status = 'applied') { const docs = await this.client.listDocuments(this.databaseId, this.stateCollectionId, [ 'orderDesc("$createdAt")' ]); const doc = docs.find(item => item.record_type === 'history' && (!status || item.status === status)); if (!doc) return null; return { recordId: doc.record_id, status: doc.status || null, payload: this._deserializePayload(doc.payload), documentId: doc.$id, createdAt: doc.$createdAt }; } } export class AppwriteLockManager { constructor({ client, databaseId, collectionId = 'awm_locks' }) { this.client = client; this.databaseId = databaseId; this.collectionId = collectionId; } async init() { // Collection is ensured by state store; nothing to do here currently. } _docId(lockId) { return crypto.createHash('md5').update(`lock:${lockId}`).digest('hex'); } async acquire(lockId, { owner = 'unknown', ttlSeconds = 600, force = false } = {}) { const documentId = this._docId(lockId); const now = new Date(); const expiresAt = ttlSeconds ? new Date(now.getTime() + ttlSeconds * 1000) : null; try { await this.client.createDocument(this.databaseId, this.collectionId, documentId, { lock_id: lockId, owner, status: 'locked', created_at: now.toISOString(), expires_at: expiresAt ? expiresAt.toISOString() : null }); return; } catch (error) { if (error.status !== 409) { throw error; } } const existing = await this.client.getDocument(this.databaseId, this.collectionId, documentId); if (!existing) { return this.acquire(lockId, { owner, ttlSeconds, force }); } const expired = existing.expires_at && new Date(existing.expires_at) < new Date(); const sameOwner = existing.owner === owner; if (!force && !expired && !sameOwner) { throw new Error(`Lock '${lockId}' is held by ${existing.owner || 'unknown'} since ${existing.created_at}`); } await this.client.updateDocument(this.databaseId, this.collectionId, documentId, { owner, status: 'locked', created_at: now.toISOString(), expires_at: expiresAt ? expiresAt.toISOString() : null }); } async release(lockId, owner) { const documentId = this._docId(lockId); const existing = await this.client.getDocument(this.databaseId, this.collectionId, documentId); if (!existing) return; if (owner && existing.owner && existing.owner !== owner) { throw new Error(`Cannot release lock '${lockId}' held by ${existing.owner}`); } await this.client.deleteDocument(this.databaseId, this.collectionId, documentId); } }