UNPKG

appwrite-utils-cli

Version:

Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.

1,097 lines 61.1 kB
import { Query } from "node-appwrite"; import { attributeSchema, parseAttribute, } from "appwrite-utils"; import { nameToIdMapping, enqueueOperation, markAttributeProcessed, isAttributeProcessed, } from "../shared/operationQueue.js"; import { delay, tryAwaitWithRetry, calculateExponentialBackoff, } from "../utils/helperFunctions.js"; import chalk from "chalk"; import { Decimal } from "decimal.js"; import { logger } from "../shared/logging.js"; import { MessageFormatter } from "../shared/messageFormatter.js"; import { isDatabaseAdapter } from "../utils/typeGuards.js"; // Extreme values that Appwrite may return, which should be treated as undefined const EXTREME_MIN_INTEGER = -9223372036854776000; const EXTREME_MAX_INTEGER = 9223372036854776000; const EXTREME_MIN_FLOAT = -1.7976931348623157e308; const EXTREME_MAX_FLOAT = 1.7976931348623157e308; /** * Type guard to check if an attribute has min/max properties */ const hasMinMaxProperties = (attribute) => { return (attribute.type === "integer" || attribute.type === "double" || attribute.type === "float"); }; /** * Normalizes min/max values for integer and float attributes using Decimal.js for precision * Validates that min < max and handles extreme database values */ const normalizeMinMaxValues = (attribute) => { if (!hasMinMaxProperties(attribute)) { logger.debug(`Attribute '${attribute.key}' does not have min/max properties`, { type: attribute.type, operation: "normalizeMinMaxValues", }); return {}; } const { type, min, max } = attribute; let normalizedMin = min; let normalizedMax = max; logger.debug(`Normalizing min/max values for attribute '${attribute.key}'`, { type, originalMin: min, originalMax: max, operation: "normalizeMinMaxValues", }); // Handle min value - only filter out extreme database values if (normalizedMin !== undefined && normalizedMin !== null) { const minValue = Number(normalizedMin); const originalMin = normalizedMin; // Check if it's an extreme database value (but don't filter out large numbers) if (type === 'integer') { if (minValue === EXTREME_MIN_INTEGER) { logger.debug(`Min value normalized to undefined for attribute '${attribute.key}'`, { type, originalValue: originalMin, numericValue: minValue, reason: 'extreme_database_value', extremeValue: EXTREME_MIN_INTEGER, operation: 'normalizeMinMaxValues' }); normalizedMin = undefined; } } else { // float/double if (minValue === EXTREME_MIN_FLOAT) { logger.debug(`Min value normalized to undefined for attribute '${attribute.key}'`, { type, originalValue: originalMin, numericValue: minValue, reason: 'extreme_database_value', extremeValue: EXTREME_MIN_FLOAT, operation: 'normalizeMinMaxValues' }); normalizedMin = undefined; } } } // Handle max value - only filter out extreme database values if (normalizedMax !== undefined && normalizedMax !== null) { const maxValue = Number(normalizedMax); const originalMax = normalizedMax; // Check if it's an extreme database value (but don't filter out large numbers) if (type === 'integer') { if (maxValue === EXTREME_MAX_INTEGER) { logger.debug(`Max value normalized to undefined for attribute '${attribute.key}'`, { type, originalValue: originalMax, numericValue: maxValue, reason: 'extreme_database_value', extremeValue: EXTREME_MAX_INTEGER, operation: 'normalizeMinMaxValues' }); normalizedMax = undefined; } } else { // float/double if (maxValue === EXTREME_MAX_FLOAT) { logger.debug(`Max value normalized to undefined for attribute '${attribute.key}'`, { type, originalValue: originalMax, numericValue: maxValue, reason: 'extreme_database_value', extremeValue: EXTREME_MAX_FLOAT, operation: 'normalizeMinMaxValues' }); normalizedMax = undefined; } } } // Validate that min < max using multiple comparison methods for reliability if (normalizedMin !== undefined && normalizedMax !== undefined && normalizedMin !== null && normalizedMax !== null) { logger.debug(`Validating min/max values for attribute '${attribute.key}'`, { type, normalizedMin, normalizedMax, normalizedMinType: typeof normalizedMin, normalizedMaxType: typeof normalizedMax, operation: 'normalizeMinMaxValues' }); // Use multiple validation approaches to ensure reliability let needsSwap = false; let comparisonMethod = ''; try { // Method 1: Direct number comparison (most reliable for normal numbers) const minNum = Number(normalizedMin); const maxNum = Number(normalizedMax); if (!isNaN(minNum) && !isNaN(maxNum)) { needsSwap = minNum >= maxNum; comparisonMethod = 'direct_number_comparison'; logger.debug(`Direct number comparison: ${minNum} >= ${maxNum} = ${needsSwap}`, { operation: 'normalizeMinMaxValues' }); } // Method 2: Fallback to string comparison for very large numbers if (!needsSwap && (isNaN(minNum) || isNaN(maxNum) || Math.abs(minNum) > Number.MAX_SAFE_INTEGER || Math.abs(maxNum) > Number.MAX_SAFE_INTEGER)) { const minStr = normalizedMin.toString(); const maxStr = normalizedMax.toString(); // Simple string length and lexicographical comparison for very large numbers if (minStr.length !== maxStr.length) { needsSwap = minStr.length > maxStr.length; } else { needsSwap = minStr >= maxStr; } comparisonMethod = 'string_comparison_fallback'; logger.debug(`String comparison fallback: '${minStr}' >= '${maxStr}' = ${needsSwap}`, { operation: 'normalizeMinMaxValues' }); } // Method 3: Final validation using Decimal.js as last resort if (!needsSwap && (typeof normalizedMin === 'string' || typeof normalizedMax === 'string')) { try { const minDecimal = new Decimal(normalizedMin.toString()); const maxDecimal = new Decimal(normalizedMax.toString()); needsSwap = minDecimal.greaterThanOrEqualTo(maxDecimal); comparisonMethod = 'decimal_js_fallback'; logger.debug(`Decimal.js fallback: ${normalizedMin} >= ${normalizedMax} = ${needsSwap}`, { operation: 'normalizeMinMaxValues' }); } catch (decimalError) { logger.warn(`Decimal.js comparison failed for attribute '${attribute.key}': ${decimalError instanceof Error ? decimalError.message : String(decimalError)}`, { operation: 'normalizeMinMaxValues' }); } } // Log final validation result if (needsSwap) { logger.error(`Invalid min/max values detected for attribute '${attribute.key}': min (${normalizedMin}) must be less than max (${normalizedMax})`, { type, min: normalizedMin, max: normalizedMax, comparisonMethod, operation: 'normalizeMinMaxValues' }); // Swap values to ensure min < max (graceful handling) logger.warn(`Swapping min/max values for attribute '${attribute.key}' to fix validation`, { type, originalMin: normalizedMin, originalMax: normalizedMax, newMin: normalizedMax, newMax: normalizedMin, comparisonMethod, operation: 'normalizeMinMaxValues' }); const temp = normalizedMin; normalizedMin = normalizedMax; normalizedMax = temp; } else { logger.debug(`Min/max validation passed for attribute '${attribute.key}'`, { type, min: normalizedMin, max: normalizedMax, comparisonMethod, operation: 'normalizeMinMaxValues' }); } } catch (error) { logger.error(`Critical error during min/max validation for attribute '${attribute.key}'`, { type, min: normalizedMin, max: normalizedMax, error: error instanceof Error ? error.message : String(error), operation: 'normalizeMinMaxValues' }); // If all comparison methods fail, set both to undefined to avoid API errors normalizedMin = undefined; normalizedMax = undefined; } } const result = { min: normalizedMin, max: normalizedMax }; logger.debug(`Min/max normalization complete for attribute '${attribute.key}'`, { type, result, operation: "normalizeMinMaxValues", }); return result; }; /** * Normalizes an attribute for comparison by handling extreme database values * This is used when comparing database attributes with config attributes */ const normalizeAttributeForComparison = (attribute) => { const normalized = { ...attribute }; // Ignore defaults on required attributes to prevent false positives if (normalized.required === true && "xdefault" in normalized) { delete normalized.xdefault; } // Normalize min/max for numeric types if (hasMinMaxProperties(attribute)) { const { min, max } = normalizeMinMaxValues(attribute); normalized.min = min; normalized.max = max; } // Remove xdefault if null/undefined to ensure consistent comparison // Appwrite sets xdefault: null for required attributes, but config files omit it if ("xdefault" in normalized && (normalized.xdefault === null || normalized.xdefault === undefined)) { delete normalized.xdefault; } return normalized; }; /** * Helper function to create an attribute using either the adapter or legacy API */ const createAttributeViaAdapter = async (db, dbId, collectionId, attribute) => { const startTime = Date.now(); const adapterType = isDatabaseAdapter(db) ? "adapter" : "legacy"; logger.info(`Creating attribute '${attribute.key}' via ${adapterType}`, { type: attribute.type, dbId, collectionId, adapterType, operation: "createAttributeViaAdapter", }); if (isDatabaseAdapter(db)) { // Use the adapter's unified createAttribute method const params = { databaseId: dbId, tableId: collectionId, key: attribute.key, type: attribute.type, required: attribute.required || false, array: attribute.array || false, ...(attribute.size && { size: attribute.size }), ...(attribute.xdefault !== undefined && !attribute.required && { default: attribute.xdefault }), ...(attribute.encrypt && { encrypt: attribute.encrypt, }), ...(attribute.min !== undefined && { min: attribute.min, }), ...(attribute.max !== undefined && { max: attribute.max, }), ...(attribute.elements && { elements: attribute.elements, }), ...(attribute.relatedCollection && { relatedCollection: attribute.relatedCollection, }), ...(attribute.relationType && { relationType: attribute.relationType, }), ...(attribute.twoWay !== undefined && { twoWay: attribute.twoWay, }), ...(attribute.onDelete && { onDelete: attribute.onDelete, }), ...(attribute.twoWayKey && { twoWayKey: attribute.twoWayKey, }), }; logger.debug(`Adapter create parameters for '${attribute.key}'`, { params, operation: "createAttributeViaAdapter", }); await db.createAttribute(params); const duration = Date.now() - startTime; logger.info(`Successfully created attribute '${attribute.key}' via adapter`, { duration, operation: "createAttributeViaAdapter", }); } else { // Use legacy type-specific methods logger.debug(`Using legacy creation for attribute '${attribute.key}'`, { operation: "createAttributeViaAdapter", }); await createLegacyAttribute(db, dbId, collectionId, attribute); const duration = Date.now() - startTime; logger.info(`Successfully created attribute '${attribute.key}' via legacy`, { duration, operation: "createAttributeViaAdapter", }); } }; /** * Helper function to update an attribute using either the adapter or legacy API */ const updateAttributeViaAdapter = async (db, dbId, collectionId, attribute) => { if (isDatabaseAdapter(db)) { // Use the adapter's unified updateAttribute method const params = { databaseId: dbId, tableId: collectionId, key: attribute.key, type: attribute.type, required: attribute.required || false, array: attribute.array || false, size: attribute.size, min: attribute.min, max: attribute.max, encrypt: attribute.encrypt, elements: attribute.elements, relatedCollection: attribute.relatedCollection, relationType: attribute.relationType, twoWay: attribute.twoWay, twoWayKey: attribute.twoWayKey, onDelete: attribute.onDelete }; if (!attribute.required && attribute.xdefault !== undefined) { params.default = attribute.xdefault; } await db.updateAttribute(params); } else { // Use legacy type-specific methods await updateLegacyAttribute(db, dbId, collectionId, attribute); } }; /** * Legacy attribute creation using type-specific methods */ const createLegacyAttribute = async (db, dbId, collectionId, attribute) => { const startTime = Date.now(); const { min: normalizedMin, max: normalizedMax } = normalizeMinMaxValues(attribute); logger.info(`Creating legacy attribute '${attribute.key}'`, { type: attribute.type, dbId, collectionId, normalizedMin, normalizedMax, operation: "createLegacyAttribute", }); switch (attribute.type) { case "string": const stringParams = { size: attribute.size || 255, required: attribute.required || false, defaultValue: attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, array: attribute.array || false, encrypt: attribute.encrypt, }; logger.debug(`Creating string attribute '${attribute.key}'`, { ...stringParams, operation: "createLegacyAttribute", }); await db.createStringAttribute(dbId, collectionId, attribute.key, stringParams.size, stringParams.required, stringParams.defaultValue, stringParams.array, stringParams.encrypt); break; case "integer": const integerParams = { required: attribute.required || false, min: normalizedMin !== undefined ? parseInt(String(normalizedMin)) : undefined, max: normalizedMax !== undefined ? parseInt(String(normalizedMax)) : undefined, defaultValue: attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, array: attribute.array || false, }; logger.debug(`Creating integer attribute '${attribute.key}'`, { ...integerParams, operation: "createLegacyAttribute", }); await db.createIntegerAttribute(dbId, collectionId, attribute.key, integerParams.required, integerParams.min, integerParams.max, integerParams.defaultValue, integerParams.array); break; case "double": case "float": await db.createFloatAttribute(dbId, collectionId, attribute.key, attribute.required || false, normalizedMin !== undefined ? Number(normalizedMin) : undefined, normalizedMax !== undefined ? Number(normalizedMax) : undefined, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "boolean": await db.createBooleanAttribute(dbId, collectionId, attribute.key, attribute.required || false, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "datetime": await db.createDatetimeAttribute(dbId, collectionId, attribute.key, attribute.required || false, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "email": await db.createEmailAttribute(dbId, collectionId, attribute.key, attribute.required || false, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "ip": await db.createIpAttribute(dbId, collectionId, attribute.key, attribute.required || false, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "url": await db.createUrlAttribute(dbId, collectionId, attribute.key, attribute.required || false, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "enum": await db.createEnumAttribute(dbId, collectionId, attribute.key, attribute.elements || [], attribute.required || false, attribute.xdefault !== undefined && !attribute.required ? attribute.xdefault : undefined, attribute.array || false); break; case "relationship": await db.createRelationshipAttribute(dbId, collectionId, attribute.relatedCollection, attribute.relationType, attribute.twoWay, attribute.key, attribute.twoWayKey, attribute.onDelete); break; default: const error = new Error(`Unsupported attribute type: ${attribute.type}`); logger.error(`Unsupported attribute type for '${attribute.key}'`, { type: attribute.type, supportedTypes: [ "string", "integer", "double", "float", "boolean", "datetime", "email", "ip", "url", "enum", "relationship", ], operation: "createLegacyAttribute", }); throw error; } const duration = Date.now() - startTime; logger.info(`Successfully created legacy attribute '${attribute.key}'`, { type: attribute.type, duration, operation: "createLegacyAttribute", }); }; /** * Legacy attribute update using type-specific methods */ const updateLegacyAttribute = async (db, dbId, collectionId, attribute) => { const { min: normalizedMin, max: normalizedMax } = normalizeMinMaxValues(attribute); switch (attribute.type) { case "string": await db.updateStringAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null, attribute.size); break; case "integer": await db.updateIntegerAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null, normalizedMin !== undefined ? parseInt(String(normalizedMin)) : undefined, normalizedMax !== undefined ? parseInt(String(normalizedMax)) : undefined); break; case "double": case "float": const minParam = normalizedMin !== undefined ? Number(normalizedMin) : undefined; const maxParam = normalizedMax !== undefined ? Number(normalizedMax) : undefined; await db.updateFloatAttribute(dbId, collectionId, attribute.key, attribute.required || false, minParam, maxParam, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "boolean": await db.updateBooleanAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "datetime": await db.updateDatetimeAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "email": await db.updateEmailAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "ip": await db.updateIpAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "url": await db.updateUrlAttribute(dbId, collectionId, attribute.key, attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "enum": await db.updateEnumAttribute(dbId, collectionId, attribute.key, attribute.elements || [], attribute.required || false, !attribute.required && attribute.xdefault !== undefined ? attribute.xdefault : null); break; case "relationship": await db.updateRelationshipAttribute(dbId, collectionId, attribute.key, attribute.onDelete); break; default: throw new Error(`Unsupported attribute type for update: ${attribute.type}`); } }; /** * Wait for attribute to become available, with retry logic for stuck attributes and exponential backoff */ const waitForAttributeAvailable = async (db, dbId, collectionId, attributeKey, maxWaitTime = 60000, // 1 minute retryCount = 0, maxRetries = 5) => { const startTime = Date.now(); let checkInterval = 2000; // Start with 2 seconds logger.info(`Waiting for attribute '${attributeKey}' to become available`, { dbId, collectionId, maxWaitTime, retryCount, maxRetries, operation: "waitForAttributeAvailable", }); // Calculate exponential backoff: 2s, 4s, 8s, 16s, 30s (capped at 30s) if (retryCount > 0) { const exponentialDelay = calculateExponentialBackoff(retryCount); await delay(exponentialDelay); } while (Date.now() - startTime < maxWaitTime) { try { const collection = isDatabaseAdapter(db) ? (await db.getTable({ databaseId: dbId, tableId: collectionId })).data : await db.getCollection(dbId, collectionId); const attribute = collection.attributes.find((attr) => attr.key === attributeKey); if (!attribute) { MessageFormatter.error(`Attribute '${attributeKey}' not found`); return false; } const statusInfo = { attributeKey, status: attribute.status, error: attribute.error, dbId, collectionId, waitTime: Date.now() - startTime, operation: "waitForAttributeAvailable", }; switch (attribute.status) { case "available": logger.info(`Attribute '${attributeKey}' became available`, statusInfo); return true; case "failed": logger.error(`Attribute '${attributeKey}' failed`, statusInfo); return false; case "stuck": logger.warn(`Attribute '${attributeKey}' is stuck`, statusInfo); return false; case "processing": // Continue waiting logger.debug(`Attribute '${attributeKey}' still processing`, statusInfo); break; case "deleting": MessageFormatter.info(chalk.yellow(`Attribute '${attributeKey}' is being deleted`)); logger.warn(`Attribute '${attributeKey}' is being deleted`, statusInfo); break; default: MessageFormatter.info(chalk.yellow(`Unknown status '${attribute.status}' for attribute '${attributeKey}'`)); logger.warn(`Unknown status for attribute '${attributeKey}'`, statusInfo); break; } await delay(checkInterval); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); MessageFormatter.error(`Error checking attribute status: ${errorMessage}`); logger.error("Error checking attribute status", { attributeKey, dbId, collectionId, error: errorMessage, waitTime: Date.now() - startTime, operation: "waitForAttributeAvailable", }); return false; } } // Timeout reached MessageFormatter.info(chalk.yellow(`⏰ Timeout waiting for attribute '${attributeKey}' (${maxWaitTime}ms)`)); // If we have retries left and this isn't the last retry, try recreating if (retryCount < maxRetries) { MessageFormatter.info(chalk.yellow(`🔄 Retrying attribute creation (attempt ${retryCount + 1}/${maxRetries})`)); return false; // Signal that we need to retry } return false; }; /** * Wait for all attributes in a collection to become available */ const waitForAllAttributesAvailable = async (db, dbId, collectionId, attributeKeys, maxWaitTime = 60000) => { MessageFormatter.info(chalk.blue(`Waiting for ${attributeKeys.length} attributes to become available...`)); const failedAttributes = []; for (const attributeKey of attributeKeys) { const success = await waitForAttributeAvailable(db, dbId, collectionId, attributeKey, maxWaitTime); if (!success) { failedAttributes.push(attributeKey); } } return failedAttributes; }; /** * Delete collection and recreate with retry logic */ const deleteAndRecreateCollection = async (db, dbId, collection, retryCount) => { try { MessageFormatter.info(chalk.yellow(`🗑️ Deleting collection '${collection.name}' for retry ${retryCount}`)); // Delete the collection if (isDatabaseAdapter(db)) { await db.deleteTable({ databaseId: dbId, tableId: collection.$id }); } else { await db.deleteCollection(dbId, collection.$id); } MessageFormatter.warning(`Deleted collection '${collection.name}'`); // Wait a bit before recreating await delay(2000); // Recreate the collection MessageFormatter.info(`🔄 Recreating collection '${collection.name}'`); const newCollection = isDatabaseAdapter(db) ? (await db.createTable({ databaseId: dbId, id: collection.$id, name: collection.name, permissions: collection.$permissions, documentSecurity: collection.documentSecurity, enabled: collection.enabled, })).data : await db.createCollection(dbId, collection.$id, collection.name, collection.$permissions, collection.documentSecurity, collection.enabled); MessageFormatter.success(`✅ Recreated collection '${collection.name}'`); return newCollection; } catch (error) { MessageFormatter.info(chalk.red(`Failed to delete/recreate collection '${collection.name}': ${error}`)); return null; } }; /** * Get the fields that should be compared for a specific attribute type * Only returns fields that are valid for the given type to avoid false positives */ const getComparableFields = (type) => { const baseFields = ["key", "type", "array", "required", "xdefault"]; switch (type) { case "string": return [...baseFields, "size", "encrypt"]; case "integer": case "double": case "float": return [...baseFields, "min", "max"]; case "enum": return [...baseFields, "elements"]; case "relationship": return [ ...baseFields, "relationType", "twoWay", "twoWayKey", "onDelete", "relatedCollection", ]; case "boolean": case "datetime": case "email": case "ip": case "url": return baseFields; default: // Fallback to all fields for unknown types return [ "key", "type", "array", "encrypt", "required", "size", "min", "max", "xdefault", "elements", "relationType", "twoWay", "twoWayKey", "onDelete", "relatedCollection", ]; } }; const attributesSame = (databaseAttribute, configAttribute) => { // Normalize both attributes for comparison (handle extreme database values) const normalizedDbAttr = normalizeAttributeForComparison(databaseAttribute); const normalizedConfigAttr = normalizeAttributeForComparison(configAttribute); // Use type-specific field list to avoid false positives from irrelevant fields const attributesToCheck = getComparableFields(normalizedConfigAttr.type); const fieldsToCheck = attributesToCheck.filter((attr) => { if (attr !== "xdefault") { return true; } const dbRequired = Boolean(normalizedDbAttr.required); const configRequired = Boolean(normalizedConfigAttr.required); return !(dbRequired || configRequired); }); const differences = []; const result = fieldsToCheck.every((attr) => { // Check if both objects have the attribute const dbHasAttr = attr in normalizedDbAttr; const configHasAttr = attr in normalizedConfigAttr; // If both have the attribute, compare values if (dbHasAttr && configHasAttr) { const dbValue = normalizedDbAttr[attr]; const configValue = normalizedConfigAttr[attr]; // Consider undefined and null as equivalent if ((dbValue === undefined || dbValue === null) && (configValue === undefined || configValue === null)) { return true; } // Normalize booleans: treat undefined and false as equivalent if (typeof dbValue === "boolean" || typeof configValue === "boolean") { const boolMatch = Boolean(dbValue) === Boolean(configValue); if (!boolMatch) { differences.push(`${attr}: db=${dbValue} config=${configValue}`); } return boolMatch; } // For numeric comparisons, compare numbers if both are numeric-like if ((typeof dbValue === "number" || (typeof dbValue === "string" && dbValue !== "" && !isNaN(Number(dbValue)))) && (typeof configValue === "number" || (typeof configValue === "string" && configValue !== "" && !isNaN(Number(configValue))))) { const numMatch = Number(dbValue) === Number(configValue); if (!numMatch) { differences.push(`${attr}: db=${dbValue} config=${configValue}`); } return numMatch; } // For array comparisons (e.g., enum elements), use order-independent equality if (Array.isArray(dbValue) && Array.isArray(configValue)) { const arrayMatch = dbValue.length === configValue.length && dbValue.every((val) => configValue.includes(val)); if (!arrayMatch) { differences.push(`${attr}: db=${JSON.stringify(dbValue)} config=${JSON.stringify(configValue)}`); } return arrayMatch; } const match = dbValue === configValue; if (!match) { differences.push(`${attr}: db=${JSON.stringify(dbValue)} config=${JSON.stringify(configValue)}`); } return match; } // If neither has the attribute, consider it the same if (!dbHasAttr && !configHasAttr) { return true; } // If one has the attribute and the other doesn't, check if it's undefined or null if (dbHasAttr && !configHasAttr) { const dbValue = normalizedDbAttr[attr]; // Consider default-false booleans as equal to missing in config if (typeof dbValue === "boolean") { const match = dbValue === false; // missing in config equals false in db if (!match) { differences.push(`${attr}: db=${dbValue} config=<missing>`); } return match; } const match = dbValue === undefined || dbValue === null; if (!match) { differences.push(`${attr}: db=${JSON.stringify(dbValue)} config=<missing>`); } return match; } if (!dbHasAttr && configHasAttr) { const configValue = normalizedConfigAttr[attr]; // Consider default-false booleans as equal to missing in db if (typeof configValue === "boolean") { const match = configValue === false; // missing in db equals false in config if (!match) { differences.push(`${attr}: db=<missing> config=${configValue}`); } return match; } const match = configValue === undefined || configValue === null; if (!match) { differences.push(`${attr}: db=<missing> config=${JSON.stringify(configValue)}`); } return match; } // If we reach here, the attributes are different differences.push(`${attr}: unexpected comparison state`); return false; }); if (!result && differences.length > 0) { logger.debug(`Attribute mismatch detected for '${normalizedConfigAttr.key}'`, { differences, dbAttribute: normalizedDbAttr, configAttribute: normalizedConfigAttr, operation: "attributesSame", }); } return result; }; /** * Enhanced attribute creation with proper status monitoring and retry logic */ export const createOrUpdateAttributeWithStatusCheck = async (db, dbId, collection, attribute, retryCount = 0, maxRetries = 5) => { try { // First, try to create/update the attribute using existing logic const result = await createOrUpdateAttribute(db, dbId, collection, attribute); // If the attribute was queued (relationship dependency unresolved), // skip status polling and retry logic — the queue will handle it later. if (result === "queued") { MessageFormatter.info(chalk.yellow(`⏭️ Deferred relationship attribute '${attribute.key}' — queued for later once dependencies are available`)); return true; } // If collection creation failed, return false to indicate failure if (result === "error") { MessageFormatter.error(`Failed to create collection for attribute '${attribute.key}'`); return false; } // Now wait for the attribute to become available const success = await waitForAttributeAvailable(db, dbId, collection.$id, attribute.key, 60000, // 1 minute timeout retryCount, maxRetries); if (success) { return true; } // If not successful and we have retries left, delete specific attribute and try again if (retryCount < maxRetries) { MessageFormatter.info(chalk.yellow(`Attribute '${attribute.key}' failed/stuck, deleting and retrying...`)); // Try to delete the specific stuck attribute instead of the entire collection try { if (isDatabaseAdapter(db)) { await db.deleteAttribute({ databaseId: dbId, tableId: collection.$id, key: attribute.key, }); } else { await db.deleteAttribute(dbId, collection.$id, attribute.key); } MessageFormatter.info(chalk.yellow(`Deleted stuck attribute '${attribute.key}', will retry creation`)); // Wait a bit before retry await delay(3000); // Get fresh collection data const freshCollection = isDatabaseAdapter(db) ? (await db.getTable({ databaseId: dbId, tableId: collection.$id })) .data : await db.getCollection(dbId, collection.$id); // Retry with the same collection (attribute should be gone now) return await createOrUpdateAttributeWithStatusCheck(db, dbId, freshCollection, attribute, retryCount + 1, maxRetries); } catch (deleteError) { MessageFormatter.info(chalk.red(`Failed to delete stuck attribute '${attribute.key}': ${deleteError}`)); // If attribute deletion fails, only then try collection recreation as last resort if (retryCount >= maxRetries - 1) { MessageFormatter.info(chalk.yellow(`Last resort: Recreating collection for attribute '${attribute.key}'`)); // Get fresh collection data const freshCollection = isDatabaseAdapter(db) ? (await db.getTable({ databaseId: dbId, tableId: collection.$id })) .data : await db.getCollection(dbId, collection.$id); // Delete and recreate collection const newCollection = await deleteAndRecreateCollection(db, dbId, freshCollection, retryCount + 1); if (newCollection) { // Retry with the new collection return await createOrUpdateAttributeWithStatusCheck(db, dbId, newCollection, attribute, retryCount + 1, maxRetries); } } else { // Continue to next retry without collection recreation return await createOrUpdateAttributeWithStatusCheck(db, dbId, collection, attribute, retryCount + 1, maxRetries); } } } MessageFormatter.info(chalk.red(`❌ Failed to create attribute '${attribute.key}' after ${maxRetries + 1} attempts`)); return false; } catch (error) { MessageFormatter.info(chalk.red(`Error creating attribute '${attribute.key}': ${error}`)); if (retryCount < maxRetries) { MessageFormatter.info(chalk.yellow(`Retrying attribute '${attribute.key}' due to error...`)); // Wait a bit before retry await delay(2000); return await createOrUpdateAttributeWithStatusCheck(db, dbId, collection, attribute, retryCount + 1, maxRetries); } return false; } }; export const createOrUpdateAttribute = async (db, dbId, collection, attribute) => { let action = "create"; let foundAttribute; const updateEnabled = true; let finalAttribute = attribute; try { const collectionAttr = collection.attributes.find((attr) => attr.key === attribute.key); foundAttribute = parseAttribute(collectionAttr); } catch (error) { foundAttribute = undefined; } // If attribute exists but type changed, delete it so we can recreate with new type if (foundAttribute && foundAttribute.type !== attribute.type) { MessageFormatter.info(chalk.yellow(`Attribute '${attribute.key}' type changed from '${foundAttribute.type}' to '${attribute.type}'. Recreating attribute.`)); try { if (isDatabaseAdapter(db)) { await db.deleteAttribute({ databaseId: dbId, tableId: collection.$id, key: attribute.key }); } else { await db.deleteAttribute(dbId, collection.$id, attribute.key); } // Remove from local collection metadata so downstream logic treats it as new collection.attributes = collection.attributes.filter((attr) => attr.key !== attribute.key); foundAttribute = undefined; } catch (deleteError) { MessageFormatter.error(`Failed to delete attribute '${attribute.key}' before recreation: ${deleteError}`); return "error"; } } if (foundAttribute && attributesSame(foundAttribute, attribute) && updateEnabled) { // No need to do anything, they are the same return "processed"; } else if (foundAttribute && !attributesSame(foundAttribute, attribute) && updateEnabled) { // MessageFormatter.info( // `Updating attribute with same key ${attribute.key} but different values` // ); finalAttribute = { ...foundAttribute, ...attribute, }; action = "update"; } else if (!updateEnabled && foundAttribute && !attributesSame(foundAttribute, attribute)) { if (isDatabaseAdapter(db)) { await db.deleteAttribute({ databaseId: dbId, tableId: collection.$id, key: attribute.key, }); } else { await db.deleteAttribute(dbId, collection.$id, attribute.key); } MessageFormatter.info(`Deleted attribute: ${attribute.key} to recreate it because they diff (update disabled temporarily)`); return "processed"; } // Relationship attribute logic with adjustments let collectionFoundViaRelatedCollection; let relatedCollectionId; if (finalAttribute.type === "relationship" && finalAttribute.relatedCollection) { // First try treating relatedCollection as an ID directly try { const byIdCollection = isDatabaseAdapter(db) ? (await db.getTable({ databaseId: dbId, tableId: finalAttribute.relatedCollection, })).data : await db.getCollection(dbId, finalAttribute.relatedCollection); collectionFoundViaRelatedCollection = byIdCollection; relatedCollectionId = byIdCollection.$id; // Cache by name for subsequent lookups nameToIdMapping.set(byIdCollection.name, byIdCollection.$id); } catch (_) { // Not an ID or not found — fall back to name-based resolution below } if (!collectionFoundViaRelatedCollection && nameToIdMapping.has(finalAttribute.relatedCollection)) { relatedCollectionId = nameToIdMapping.get(finalAttribute.relatedCollection); try { collectionFoundViaRelatedCollection = isDatabaseAdapter(db) ? (await db.getTable({ databaseId: dbId, tableId: relatedCollectionId, })).data : await db.getCollection(dbId, relatedCollectionId); } catch (e) { // MessageFormatter.info( // `Collection not found: ${finalAttribute.relatedCollection} when nameToIdMapping was set` // ); collectionFoundViaRelatedCollection = undefined; } } else if (!collectionFoundViaRelatedCollection) { const collectionsPulled = isDatabaseAdapter(db) ? await db.listTables({ databaseId: dbId, queries: [Query.equal("name", finalAttribute.relatedCollection)], }) : await db.listCollections(dbId, [ Query.equal("name", finalAttribute.relatedCollection), ]); if (collectionsPulled.total && collectionsPulled.total > 0) { collectionFoundViaRelatedCollection = isDatabaseAdapter(db) ? collectionsPulled.tables?.[0] : collectionsPulled.collections?.[0]; relatedCollectionId = collectionFoundViaRelatedCollection?.$id; if (relatedCollectionId) { nameToIdMapping.set(finalAttribute.relatedCollection, relatedCollectionId); } } } // ONLY queue relationship attributes that have actual unresolved dependencies if (!(relatedCollectionId && collectionFoundViaRelatedCollection)) { MessageFormatter.info(chalk.yellow(`⏳ Queueing relationship attribute '${finalAttribute.key}' - related collection '${finalAttribute.relatedCollection}' not found yet`)); enqueueOperation({ type: "attribute", collectionId: collection.$id, collection: collection, attribute, dependencies: [finalAttribute.relatedCollection], }); return "queued"; } } finalAttribute = parseAttribute(finalAttribute); // Ensure collection/table exists - create it if it doesn't try { await (isDatabaseAdapter(db) ? db.getTable({ databaseId: dbId, tableId: collection.$id }) : db.getCollection(dbId, collection.$id)); } catch (error) { // Collection doesn't exist - create it if (error.code === 404 || (error instanceof Error && (error.message.includes("collection_not_found") || error.message.includes("Collection with the requested ID could not be found")))) { MessageFormatter.info(`Collection '${collection.name}' doesn't exist, creating it first...`); try { if (isDatabaseAdapter(db)) { await db.createTable({ databaseId: dbId, id: collection.$id, name: collection.name, permissions: collection.$permissions || [], documentSecurity: collection.documentSecurity ?? false, enabled: collection.enabled ?? true, }); } else { await db.createCollection