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
JavaScript
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