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,656 lines (1,530 loc) • 60.8 kB
text/typescript
import { Query, type Databases, type Models } from "node-appwrite";
import {
attributeSchema,
parseAttribute,
type Attribute,
} 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 type { DatabaseAdapter, CreateAttributeParams, UpdateAttributeParams, DeleteAttributeParams } from "../adapters/DatabaseAdapter.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: Attribute
): attribute is Attribute & { min?: number; max?: number } => {
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: Attribute
): { min?: number; max?: number } => {
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: Attribute): Attribute => {
const normalized: any = { ...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: Databases | DatabaseAdapter,
dbId: string,
collectionId: string,
attribute: Attribute
): Promise<void> => {
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: CreateAttributeParams = {
databaseId: dbId,
tableId: collectionId,
key: attribute.key,
type: attribute.type,
required: attribute.required || false,
array: attribute.array || false,
...((attribute as any).size && { size: (attribute as any).size }),
...((attribute as any).xdefault !== undefined &&
!attribute.required && { default: (attribute as any).xdefault }),
...((attribute as any).encrypt && {
encrypt: (attribute as any).encrypt,
}),
...((attribute as any).min !== undefined && {
min: (attribute as any).min,
}),
...((attribute as any).max !== undefined && {
max: (attribute as any).max,
}),
...((attribute as any).elements && {
elements: (attribute as any).elements,
}),
...((attribute as any).relatedCollection && {
relatedCollection: (attribute as any).relatedCollection,
}),
...((attribute as any).relationType && {
relationType: (attribute as any).relationType,
}),
...((attribute as any).twoWay !== undefined && {
twoWay: (attribute as any).twoWay,
}),
...((attribute as any).onDelete && {
onDelete: (attribute as any).onDelete,
}),
...((attribute as any).twoWayKey && {
twoWayKey: (attribute as any).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: Databases | DatabaseAdapter,
dbId: string,
collectionId: string,
attribute: Attribute
): Promise<void> => {
if (isDatabaseAdapter(db)) {
// Use the adapter's unified updateAttribute method
const params: UpdateAttributeParams = {
databaseId: dbId,
tableId: collectionId,
key: attribute.key,
type: attribute.type,
required: attribute.required || false,
array: attribute.array || false,
size: (attribute as any).size,
min: (attribute as any).min,
max: (attribute as any).max,
encrypt: (attribute as any).encrypt,
elements: (attribute as any).elements,
relatedCollection: (attribute as any).relatedCollection,
relationType: (attribute as any).relationType,
twoWay: (attribute as any).twoWay,
twoWayKey: (attribute as any).twoWayKey,
onDelete: (attribute as any).onDelete
};
if (!attribute.required && (attribute as any).xdefault !== undefined) {
params.default = (attribute as any).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: Databases,
dbId: string,
collectionId: string,
attribute: Attribute
): Promise<void> => {
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 as any).size || 255,
required: attribute.required || false,
defaultValue:
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
array: attribute.array || false,
encrypt: (attribute as any).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 as any).xdefault !== undefined && !attribute.required
? (attribute as any).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 as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "boolean":
await db.createBooleanAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "datetime":
await db.createDatetimeAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "email":
await db.createEmailAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "ip":
await db.createIpAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "url":
await db.createUrlAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "enum":
await db.createEnumAttribute(
dbId,
collectionId,
attribute.key,
(attribute as any).elements || [],
attribute.required || false,
(attribute as any).xdefault !== undefined && !attribute.required
? (attribute as any).xdefault
: undefined,
attribute.array || false
);
break;
case "relationship":
await db.createRelationshipAttribute(
dbId,
collectionId,
(attribute as any).relatedCollection!,
(attribute as any).relationType!,
(attribute as any).twoWay,
attribute.key,
(attribute as any).twoWayKey,
(attribute as any).onDelete
);
break;
default:
const error = new Error(
`Unsupported attribute type: ${(attribute as any).type}`
);
logger.error(
`Unsupported attribute type for '${(attribute as any).key}'`,
{
type: (attribute as any).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: Databases,
dbId: string,
collectionId: string,
attribute: Attribute
): Promise<void> => {
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 as any).xdefault !== undefined
? (attribute as any).xdefault
: null,
attribute.size
);
break;
case "integer":
await db.updateIntegerAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).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 as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "boolean":
await db.updateBooleanAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "datetime":
await db.updateDatetimeAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "email":
await db.updateEmailAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "ip":
await db.updateIpAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "url":
await db.updateUrlAttribute(
dbId,
collectionId,
attribute.key,
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "enum":
await db.updateEnumAttribute(
dbId,
collectionId,
attribute.key,
(attribute as any).elements || [],
attribute.required || false,
!attribute.required && (attribute as any).xdefault !== undefined
? (attribute as any).xdefault
: null
);
break;
case "relationship":
await db.updateRelationshipAttribute(
dbId,
collectionId,
attribute.key,
(attribute as any).onDelete
);
break;
default:
throw new Error(
`Unsupported attribute type for update: ${(attribute as any).type}`
);
}
};
// Interface for attribute with status (fixing the type issue)
interface AttributeWithStatus {
key: string;
type: string;
status: "available" | "processing" | "deleting" | "stuck" | "failed";
error: string;
required: boolean;
array?: boolean;
$createdAt: string;
$updatedAt: string;
[key: string]: any; // For type-specific fields
}
/**
* Wait for attribute to become available, with retry logic for stuck attributes and exponential backoff
*/
const waitForAttributeAvailable = async (
db: Databases | DatabaseAdapter,
dbId: string,
collectionId: string,
attributeKey: string,
maxWaitTime: number = 60000, // 1 minute
retryCount: number = 0,
maxRetries: number = 5
): Promise<boolean> => {
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 as any[]).find(
(attr: AttributeWithStatus) => attr.key === attributeKey
) as AttributeWithStatus | undefined;
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: Databases | DatabaseAdapter,
dbId: string,
collectionId: string,
attributeKeys: string[],
maxWaitTime: number = 60000
): Promise<string[]> => {
MessageFormatter.info(
chalk.blue(
`Waiting for ${attributeKeys.length} attributes to become available...`
)
);
const failedAttributes: string[] = [];
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: Databases | DatabaseAdapter,
dbId: string,
collection: Models.Collection,
retryCount: number
): Promise<Models.Collection | null> => {
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: string): string[] => {
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: Attribute,
configAttribute: Attribute
): boolean => {
// 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 as any).required);
const configRequired = Boolean((normalizedConfigAttr as any).required);
return !(dbRequired || configRequired);
});
const differences: string[] = [];
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 as keyof typeof normalizedDbAttr];
const configValue =
normalizedConfigAttr[attr as keyof typeof normalizedConfigAttr];
// 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 as keyof typeof normalizedDbAttr];
// 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 as keyof typeof normalizedConfigAttr];
// 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: Databases | DatabaseAdapter,
dbId: string,
collection: Models.Collection,
attribute: Attribute,
retryCount: number = 0,
maxRetries: number = 5
): Promise<boolean> => {
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: Databases | DatabaseAdapter,
dbId: string,
collection: Models.Collection,
attribute: Attribute
): Promise<"queued" | "processed" | "error"> => {
let action = "create";
let foundAttribute: Attribute | undefined;
const updateEnabled = true;
let finalAttribute: any = attribute;
try {
const collectionAttr = collection.attributes.find(
(attr: any) => attr.key === attribute.key
) as unknown as any;
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: any) => 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: Models.Collection | undefined;
let relatedCollectionId: string | undefined;
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 as any).tables?.[0]
: (collectionsPulled as any).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 as any).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(
dbId,