appwrite-utils-cli 1.9.6 → 1.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ import {
33
33
  type AdapterMetadata,
34
34
  AdapterError
35
35
  } from './DatabaseAdapter.js';
36
+ import { type Column } from 'appwrite-utils';
36
37
  import { TablesDB, Client } from "node-appwrite";
37
38
 
38
39
  /**
@@ -256,8 +257,14 @@ export class TablesDBAdapter extends BaseAdapter {
256
257
  async listIndexes(params: ListIndexesParams): Promise<ApiResponse> {
257
258
  try {
258
259
  const result = await this.tablesDB.listIndexes(params);
260
+ // Normalize TablesDB response to expose a consistent 'attributes' array
261
+ const normalized = (result.indexes || []).map((idx: any) => ({
262
+ ...idx,
263
+ attributes: Array.isArray(idx?.attributes) ? idx.attributes : (Array.isArray(idx?.columns) ? idx.columns : []),
264
+ orders: Array.isArray(idx?.orders) ? idx.orders : (Array.isArray(idx?.directions) ? idx.directions : []),
265
+ }));
259
266
  return {
260
- data: result.indexes,
267
+ data: normalized,
261
268
  total: result.total
262
269
  };
263
270
  } catch (error) {
@@ -271,14 +278,15 @@ export class TablesDBAdapter extends BaseAdapter {
271
278
 
272
279
  async createIndex(params: CreateIndexParams): Promise<ApiResponse> {
273
280
  try {
274
- const result = await this.tablesDB.createIndex(
275
- params.databaseId,
276
- params.tableId,
277
- params.key,
278
- params.type as IndexType,
279
- params.attributes,
280
- params.orders || []
281
- );
281
+ const result = await this.tablesDB.createIndex({
282
+ databaseId: params.databaseId,
283
+ tableId: params.tableId,
284
+ key: params.key,
285
+ type: params.type as IndexType,
286
+ columns: params.attributes,
287
+ orders: params.orders || [],
288
+ lengths: params.lengths || [],
289
+ });
282
290
  return { data: result };
283
291
  } catch (error) {
284
292
  throw new AdapterError(
@@ -494,7 +502,7 @@ export class TablesDBAdapter extends BaseAdapter {
494
502
 
495
503
  // Use the appropriate updateXColumn method based on the column type
496
504
  // Cast column to proper Models type to access its specific properties
497
- const columnType = (column as any).type;
505
+ const columnType = (column.type === 'string' && !('format' in column)) || column.type !== 'string' ? column.type : 'enum';
498
506
 
499
507
  switch (columnType) {
500
508
  case 'string':
@@ -651,13 +651,6 @@ const updateLegacyAttribute = async (
651
651
  collectionId: string,
652
652
  attribute: Attribute
653
653
  ): Promise<void> => {
654
- console.log(`DEBUG updateLegacyAttribute before normalizeMinMaxValues:`, {
655
- key: attribute.key,
656
- type: attribute.type,
657
- min: (attribute as any).min,
658
- max: (attribute as any).max
659
- });
660
-
661
654
  const { min: normalizedMin, max: normalizedMax } =
662
655
  normalizeMinMaxValues(attribute);
663
656
 
@@ -1515,37 +1508,10 @@ export const createOrUpdateAttribute = async (
1515
1508
  // `Updating attribute with same key ${attribute.key} but different values`
1516
1509
  // );
1517
1510
 
1518
- // DEBUG: Log before object merge to detect corruption
1519
- if ((attribute.key === 'conversationType' || attribute.key === 'messageStreakCount')) {
1520
- console.log(`[DEBUG] MERGE - key="${attribute.key}"`, {
1521
- found: {
1522
- elements: (foundAttribute as any)?.elements,
1523
- min: (foundAttribute as any)?.min,
1524
- max: (foundAttribute as any)?.max
1525
- },
1526
- desired: {
1527
- elements: (attribute as any)?.elements,
1528
- min: (attribute as any)?.min,
1529
- max: (attribute as any)?.max
1530
- }
1531
- });
1532
- }
1533
-
1534
1511
  finalAttribute = {
1535
1512
  ...foundAttribute,
1536
1513
  ...attribute,
1537
1514
  };
1538
-
1539
- // DEBUG: Log after object merge to detect corruption
1540
- if ((finalAttribute.key === 'conversationType' || finalAttribute.key === 'messageStreakCount')) {
1541
- console.log(`[DEBUG] AFTER_MERGE - key="${finalAttribute.key}"`, {
1542
- merged: {
1543
- elements: finalAttribute?.elements,
1544
- min: (finalAttribute as any)?.min,
1545
- max: (finalAttribute as any)?.max
1546
- }
1547
- });
1548
- }
1549
1515
  action = "update";
1550
1516
  } else if (
1551
1517
  !updateEnabled &&
@@ -32,6 +32,7 @@ import { MessageFormatter } from "../shared/messageFormatter.js";
32
32
  import { isLegacyDatabases } from "../utils/typeGuards.js";
33
33
  import { mapToCreateAttributeParams, mapToUpdateAttributeParams } from "../shared/attributeMapper.js";
34
34
  import { diffTableColumns, isIndexEqualToIndex, diffColumnsDetailed, executeColumnOperations } from "./tableOperations.js";
35
+ import { createOrUpdateIndexesViaAdapter, deleteObsoleteIndexesViaAdapter } from "../tables/indexManager.js";
35
36
 
36
37
  // Re-export wipe operations
37
38
  export {
@@ -517,136 +518,18 @@ export const createOrUpdateCollectionsViaAdapter = async (
517
518
  }
518
519
  }
519
520
 
520
- // Prefer local config indexes, but fall back to collection's own indexes if no local config exists (TablesDB path)
521
+ // Index management: create/update indexes using clean adapter-based system
521
522
  const localTableConfig = config.collections?.find(
522
523
  c => c.name === collectionData.name || c.$id === collectionData.$id
523
524
  );
524
525
  const idxs = (localTableConfig?.indexes ?? indexes ?? []) as any[];
525
- // Compare with existing indexes and create/update accordingly with status checks
526
- try {
527
- const existingIdxRes = await adapter.listIndexes({ databaseId, tableId });
528
- const existingIdx: any[] = (existingIdxRes as any).data || (existingIdxRes as any).indexes || [];
529
- MessageFormatter.debug(`Existing index keys: ${existingIdx.map((i:any)=>i.key).join(', ')}`, undefined, { prefix: 'Indexes' });
530
- // Show a concise plan with icons before executing
531
- const idxPlanPlus: string[] = [];
532
- const idxPlanPlusMinus: string[] = [];
533
- const idxPlanSkip: string[] = [];
534
- for (const idx of idxs) {
535
- const found = existingIdx.find((i: any) => i.key === idx.key);
536
- if (found) {
537
- if (isIndexEqualToIndex(found, idx)) idxPlanSkip.push(idx.key);
538
- else idxPlanPlusMinus.push(idx.key);
539
- } else idxPlanPlus.push(idx.key);
540
- }
541
- const planParts: string[] = [];
542
- if (idxPlanPlus.length) planParts.push(`➕ ${idxPlanPlus.length} (${idxPlanPlus.join(', ')})`);
543
- if (idxPlanPlusMinus.length) planParts.push(`🔧 ${idxPlanPlusMinus.length} (${idxPlanPlusMinus.join(', ')})`);
544
- if (idxPlanSkip.length) planParts.push(`⏭️ ${idxPlanSkip.length}`);
545
- MessageFormatter.info(`Plan → ${planParts.join(' | ') || 'no changes'}`, { prefix: 'Indexes' });
546
- const created: string[] = [];
547
- const updated: string[] = [];
548
- const skipped: string[] = [];
549
- for (const idx of idxs) {
550
- const found = existingIdx.find((i: any) => i.key === idx.key);
551
- if (found) {
552
- if (isIndexEqualToIndex(found, idx)) {
553
- MessageFormatter.info(`Index ${idx.key} unchanged`, { prefix: 'Indexes' });
554
- skipped.push(idx.key);
555
- } else {
556
- try { await adapter.deleteIndex({ databaseId, tableId, key: idx.key }); await delay(100); } catch {}
557
- try {
558
- await adapter.createIndex({ databaseId, tableId, key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders || [] });
559
- updated.push(idx.key);
560
- } catch (e: any) {
561
- const msg = (e?.message || '').toString().toLowerCase();
562
- if (msg.includes('already exists')) {
563
- MessageFormatter.info(`Index ${idx.key} already exists after delete attempt, skipping`, { prefix: 'Indexes' });
564
- skipped.push(idx.key);
565
- } else {
566
- throw e;
567
- }
568
- }
569
- }
570
- } else {
571
- try {
572
- await adapter.createIndex({ databaseId, tableId, key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders || [] });
573
- created.push(idx.key);
574
- } catch (e: any) {
575
- const msg = (e?.message || '').toString().toLowerCase();
576
- if (msg.includes('already exists')) {
577
- MessageFormatter.info(`Index ${idx.key} already exists (create), skipping`, { prefix: 'Indexes' });
578
- skipped.push(idx.key);
579
- } else {
580
- throw e;
581
- }
582
- }
583
- }
584
- // Wait for index availability
585
- const maxWait = 60000; const start = Date.now(); let lastStatus = '';
586
- while (Date.now() - start < maxWait) {
587
- try {
588
- const li = await adapter.listIndexes({ databaseId, tableId });
589
- const list: any[] = (li as any).data || (li as any).indexes || [];
590
- const cur = list.find((i: any) => i.key === idx.key);
591
- if (cur) {
592
- if (cur.status === 'available') break;
593
- if (cur.status === 'failed' || cur.status === 'stuck') { throw new Error(cur.error || `Index ${idx.key} failed`); }
594
- lastStatus = cur.status;
595
- }
596
- await delay(2000);
597
- } catch { await delay(2000); }
598
- }
599
- await delay(150);
600
- }
601
- MessageFormatter.info(`Summary → ➕ ${created.length} | 🔧 ${updated.length} | ⏭️ ${skipped.length}` , { prefix: 'Indexes' });
602
- } catch (e) {
603
- MessageFormatter.error(`Failed to list/create indexes`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
604
- }
605
526
 
606
- // Deletions for indexes: remove remote indexes not declared in YAML/config
607
- try {
608
- const desiredIndexKeys = new Set((indexes || []).map((i: any) => i.key));
609
- const idxRes = await adapter.listIndexes({ databaseId, tableId });
610
- const existingIdx: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
611
- const extraIdx = existingIdx
612
- .filter((i: any) => i?.key && !desiredIndexKeys.has(i.key))
613
- .map((i: any) => i.key as string);
614
- if (extraIdx.length > 0) {
615
- MessageFormatter.info(`Plan → 🗑️ ${extraIdx.length} indexes (${extraIdx.join(', ')})`, { prefix: 'Indexes' });
616
- const deleted: string[] = [];
617
- const errors: Array<{ key: string; error: string }> = [];
618
- for (const key of extraIdx) {
619
- try {
620
- await adapter.deleteIndex({ databaseId, tableId, key });
621
- // Optionally wait for index to disappear
622
- const start = Date.now();
623
- const maxWait = 30000;
624
- while (Date.now() - start < maxWait) {
625
- try {
626
- const li = await adapter.listIndexes({ databaseId, tableId });
627
- const list: any[] = (li as any).data || (li as any).indexes || [];
628
- if (!list.find((ix: any) => ix.key === key)) break;
629
- } catch {}
630
- await delay(1000);
631
- }
632
- deleted.push(key);
633
- } catch (e: any) {
634
- errors.push({ key, error: e?.message || String(e) });
635
- }
636
- }
637
- if (deleted.length) {
638
- MessageFormatter.success(`Deleted ${deleted.length} indexes: ${deleted.join(', ')}`, { prefix: 'Indexes' });
639
- }
640
- if (errors.length) {
641
- MessageFormatter.error(`${errors.length} index deletions failed`, undefined, { prefix: 'Indexes' });
642
- errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Indexes' }));
643
- }
644
- } else {
645
- MessageFormatter.info(`Plan → 🗑️ 0 indexes`, { prefix: 'Indexes' });
646
- }
647
- } catch (e) {
648
- MessageFormatter.warning(`Could not evaluate index deletions: ${(e as Error)?.message || e}`, { prefix: 'Indexes' });
649
- }
527
+ // Create/update indexes with proper planning and execution
528
+ await createOrUpdateIndexesViaAdapter(adapter, databaseId, tableId, idxs, indexes);
529
+
530
+ // Handle obsolete index deletions
531
+ const desiredIndexKeys: Set<string> = new Set((indexes || []).map((i: any) => i.key as string));
532
+ await deleteObsoleteIndexesViaAdapter(adapter, databaseId, tableId, desiredIndexKeys);
650
533
 
651
534
  // Deletions: remove columns/attributes that are present remotely but not in desired config
652
535
  try {
@@ -668,7 +551,9 @@ export const createOrUpdateCollectionsViaAdapter = async (
668
551
  const idxRes = await adapter.listIndexes({ databaseId, tableId });
669
552
  const ilist: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
670
553
  for (const idx of ilist) {
671
- const attrs: string[] = Array.isArray(idx.attributes) ? idx.attributes : [];
554
+ const attrs: string[] = Array.isArray(idx.attributes)
555
+ ? idx.attributes
556
+ : (Array.isArray((idx as any).columns) ? (idx as any).columns : []);
672
557
  if (attrs.includes(key)) {
673
558
  MessageFormatter.info(`🗑️ Deleting index '${idx.key}' referencing '${key}'`, { prefix: 'Indexes' });
674
559
  await adapter.deleteIndex({ databaseId, tableId, key: idx.key });
@@ -143,10 +143,13 @@ export function normalizeAttributeToComparable(attr: Attribute): ComparableColum
143
143
  }
144
144
 
145
145
  export function normalizeColumnToComparable(col: any): ComparableColumn {
146
- // Detect enum surfaced as string+elements from server and normalize to enum for comparison
146
+ // Detect enum surfaced as string+elements or string+format:enum from server and normalize to enum for comparison
147
147
  let t = String((col?.type ?? col?.columnType ?? '')).toLowerCase();
148
148
  const hasElements = Array.isArray(col?.elements) && (col.elements as any[]).length > 0;
149
- if (t === 'string' && hasElements) t = 'enum';
149
+ const hasEnumFormat = (col?.format === 'enum');
150
+ if (t === 'string' && (hasElements || hasEnumFormat)) {
151
+ t = 'enum';
152
+ }
150
153
  const base: ComparableColumn = {
151
154
  key: col?.key,
152
155
  type: t,
@@ -227,21 +230,29 @@ export function isIndexEqualToIndex(a: any, b: any): boolean {
227
230
  if (String(a.type).toLowerCase() !== String(b.type).toLowerCase()) return false;
228
231
 
229
232
  // Compare attributes as sets (order-insensitive)
230
- const attrsA = Array.isArray(a.attributes) ? [...a.attributes].sort() : [];
231
- const attrsB = Array.isArray(b.attributes) ? [...b.attributes].sort() : [];
233
+ // Support TablesDB which returns 'columns' instead of 'attributes'
234
+ const attrsAraw = Array.isArray(a.attributes)
235
+ ? a.attributes
236
+ : (Array.isArray((a as any).columns) ? (a as any).columns : []);
237
+ const attrsA = [...attrsAraw].sort();
238
+ const attrsB = Array.isArray(b.attributes)
239
+ ? [...b.attributes].sort()
240
+ : (Array.isArray((b as any).columns) ? [...(b as any).columns].sort() : []);
232
241
  if (attrsA.length !== attrsB.length) return false;
233
242
  for (let i = 0; i < attrsA.length; i++) if (attrsA[i] !== attrsB[i]) return false;
234
243
 
235
- // Orders are only considered if BOTH have orders defined
236
- const hasOrdersA = Array.isArray(a.orders) && a.orders.length > 0;
237
- const hasOrdersB = Array.isArray(b.orders) && b.orders.length > 0;
238
- if (hasOrdersA && hasOrdersB) {
239
- const ordersA = [...a.orders].sort();
244
+ // Orders are only considered if CONFIG (b) has orders defined
245
+ // This prevents false positives when Appwrite returns orders but user didn't specify them
246
+ const hasConfigOrders = Array.isArray(b.orders) && b.orders.length > 0;
247
+ if (hasConfigOrders) {
248
+ // Some APIs may expose 'directions' instead of 'orders'
249
+ const ordersA = Array.isArray(a.orders)
250
+ ? [...a.orders].sort()
251
+ : (Array.isArray((a as any).directions) ? [...(a as any).directions].sort() : []);
240
252
  const ordersB = [...b.orders].sort();
241
253
  if (ordersA.length !== ordersB.length) return false;
242
254
  for (let i = 0; i < ordersA.length; i++) if (ordersA[i] !== ordersB[i]) return false;
243
255
  }
244
- // If only one side has orders, treat as equal (orders unspecified by user)
245
256
  return true;
246
257
  }
247
258
 
@@ -255,6 +266,8 @@ function compareColumnProperties(
255
266
  ): ColumnPropertyChange[] {
256
267
  const changes: ColumnPropertyChange[] = [];
257
268
  const t = String(columnType || (newAttribute as any).type || '').toLowerCase();
269
+ const key = newAttribute?.key || 'unknown';
270
+
258
271
  const mutableProps = (MUTABLE_PROPERTIES as any)[t] || [];
259
272
  const immutableProps = (IMMUTABLE_PROPERTIES as any)[t] || [];
260
273
 
@@ -274,7 +287,9 @@ function compareColumnProperties(
274
287
  let newValue = getNewVal(prop);
275
288
  // Special-case: enum elements empty/missing should not trigger updates
276
289
  if (t === 'enum' && prop === 'elements') {
277
- if (!Array.isArray(newValue) || newValue.length === 0) newValue = oldValue;
290
+ if (!Array.isArray(newValue) || newValue.length === 0) {
291
+ newValue = oldValue;
292
+ }
278
293
  }
279
294
  if (Array.isArray(oldValue) && Array.isArray(newValue)) {
280
295
  if (oldValue.length !== newValue.length || oldValue.some((v: any, i: number) => v !== newValue[i])) {
@@ -300,11 +315,11 @@ function compareColumnProperties(
300
315
  // Type change requires recreate (normalize string+elements to enum on old side)
301
316
  const oldTypeRaw = String(oldColumn?.type || oldColumn?.columnType || '').toLowerCase();
302
317
  const oldHasElements = Array.isArray(oldColumn?.elements) && (oldColumn.elements as any[]).length > 0;
303
- const oldType = oldTypeRaw === 'string' && oldHasElements ? 'enum' : oldTypeRaw;
318
+ const oldHasEnumFormat = (oldColumn?.format === 'enum');
319
+ const oldType = oldTypeRaw === 'string' && (oldHasElements || oldHasEnumFormat) ? 'enum' : oldTypeRaw;
304
320
  if (oldType && t && oldType !== t && TYPE_CHANGE_REQUIRES_RECREATE.includes(oldType)) {
305
321
  changes.push({ property: 'type', oldValue: oldType, newValue: t, requiresRecreate: true });
306
322
  }
307
-
308
323
  return changes;
309
324
  }
310
325
 
@@ -14,6 +14,7 @@ import {
14
14
  } from "./services/index.js";
15
15
  import { MessageFormatter } from "../shared/messageFormatter.js";
16
16
  import { logger } from "../shared/logging.js";
17
+ import { detectAppwriteVersionCached, type ApiMode } from "../utils/versionDetection.js";
17
18
 
18
19
  /**
19
20
  * Database type from AppwriteConfig
@@ -297,6 +298,37 @@ export class ConfigManager {
297
298
  }
298
299
  }
299
300
 
301
+ // 8. Run version detection and set apiMode if not explicitly configured
302
+ if (!config.apiMode || config.apiMode === 'auto') {
303
+ try {
304
+ logger.debug('Running version detection for API mode detection', {
305
+ prefix: "ConfigManager",
306
+ endpoint: config.appwriteEndpoint
307
+ });
308
+
309
+ const versionResult = await detectAppwriteVersionCached(
310
+ config.appwriteEndpoint,
311
+ config.appwriteProject,
312
+ config.appwriteKey
313
+ );
314
+
315
+ config.apiMode = versionResult.apiMode;
316
+ logger.info(`API mode detected: ${config.apiMode}`, {
317
+ prefix: "ConfigManager",
318
+ method: versionResult.detectionMethod,
319
+ confidence: versionResult.confidence,
320
+ serverVersion: versionResult.serverVersion,
321
+ });
322
+
323
+ } catch (error) {
324
+ logger.warn('Version detection failed, defaulting to legacy mode', {
325
+ prefix: "ConfigManager",
326
+ error: error instanceof Error ? error.message : 'Unknown error',
327
+ });
328
+ config.apiMode = 'legacy';
329
+ }
330
+ }
331
+
300
332
  // 8. Cache the config
301
333
  this.cachedConfig = config;
302
334
  this.cachedConfigPath = configPath;
@@ -161,7 +161,7 @@ export function mapToUpdateAttributeParams(
161
161
  attr: Attribute,
162
162
  base: { databaseId: string; tableId: string }
163
163
  ): UpdateAttributeParams {
164
- const type = String((attr as any).type || "").toLowerCase();
164
+ const type = String((attr.type == 'string' && attr.format !== 'enum') || attr.type !== 'string' ? (attr as any).type : 'enum').toLowerCase();
165
165
  const params: UpdateAttributeParams = {
166
166
  databaseId: base.databaseId,
167
167
  tableId: base.tableId,