appwrite-utils-cli 1.7.9 → 1.8.2

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +14 -199
  2. package/README.md +87 -30
  3. package/dist/adapters/AdapterFactory.js +5 -25
  4. package/dist/adapters/DatabaseAdapter.d.ts +17 -2
  5. package/dist/adapters/LegacyAdapter.d.ts +2 -1
  6. package/dist/adapters/LegacyAdapter.js +212 -16
  7. package/dist/adapters/TablesDBAdapter.d.ts +2 -12
  8. package/dist/adapters/TablesDBAdapter.js +261 -57
  9. package/dist/cli/commands/databaseCommands.js +4 -3
  10. package/dist/cli/commands/functionCommands.js +17 -8
  11. package/dist/collections/attributes.js +447 -125
  12. package/dist/collections/methods.js +197 -186
  13. package/dist/collections/tableOperations.d.ts +86 -0
  14. package/dist/collections/tableOperations.js +434 -0
  15. package/dist/collections/transferOperations.d.ts +3 -2
  16. package/dist/collections/transferOperations.js +93 -12
  17. package/dist/config/yamlConfig.d.ts +221 -88
  18. package/dist/examples/yamlTerminologyExample.d.ts +1 -1
  19. package/dist/examples/yamlTerminologyExample.js +6 -3
  20. package/dist/functions/fnConfigDiscovery.d.ts +3 -0
  21. package/dist/functions/fnConfigDiscovery.js +108 -0
  22. package/dist/interactiveCLI.js +18 -15
  23. package/dist/main.js +211 -73
  24. package/dist/migrations/appwriteToX.d.ts +88 -23
  25. package/dist/migrations/comprehensiveTransfer.d.ts +2 -0
  26. package/dist/migrations/comprehensiveTransfer.js +83 -6
  27. package/dist/migrations/dataLoader.d.ts +227 -69
  28. package/dist/migrations/dataLoader.js +3 -3
  29. package/dist/migrations/importController.js +3 -3
  30. package/dist/migrations/relationships.d.ts +8 -2
  31. package/dist/migrations/services/ImportOrchestrator.js +3 -3
  32. package/dist/migrations/transfer.js +159 -37
  33. package/dist/shared/attributeMapper.d.ts +20 -0
  34. package/dist/shared/attributeMapper.js +203 -0
  35. package/dist/shared/selectionDialogs.js +8 -4
  36. package/dist/storage/schemas.d.ts +354 -92
  37. package/dist/utils/configDiscovery.js +4 -3
  38. package/dist/utils/versionDetection.d.ts +0 -4
  39. package/dist/utils/versionDetection.js +41 -173
  40. package/dist/utils/yamlConverter.js +89 -16
  41. package/dist/utils/yamlLoader.d.ts +1 -1
  42. package/dist/utils/yamlLoader.js +6 -2
  43. package/dist/utilsController.js +56 -19
  44. package/package.json +4 -4
  45. package/src/adapters/AdapterFactory.ts +119 -143
  46. package/src/adapters/DatabaseAdapter.ts +18 -3
  47. package/src/adapters/LegacyAdapter.ts +236 -105
  48. package/src/adapters/TablesDBAdapter.ts +773 -643
  49. package/src/cli/commands/databaseCommands.ts +13 -12
  50. package/src/cli/commands/functionCommands.ts +23 -14
  51. package/src/collections/attributes.ts +2054 -1611
  52. package/src/collections/methods.ts +208 -293
  53. package/src/collections/tableOperations.ts +506 -0
  54. package/src/collections/transferOperations.ts +218 -144
  55. package/src/examples/yamlTerminologyExample.ts +10 -5
  56. package/src/functions/fnConfigDiscovery.ts +103 -0
  57. package/src/interactiveCLI.ts +25 -20
  58. package/src/main.ts +549 -194
  59. package/src/migrations/comprehensiveTransfer.ts +126 -50
  60. package/src/migrations/dataLoader.ts +3 -3
  61. package/src/migrations/importController.ts +3 -3
  62. package/src/migrations/services/ImportOrchestrator.ts +3 -3
  63. package/src/migrations/transfer.ts +148 -131
  64. package/src/shared/attributeMapper.ts +229 -0
  65. package/src/shared/selectionDialogs.ts +29 -25
  66. package/src/utils/configDiscovery.ts +9 -3
  67. package/src/utils/versionDetection.ts +74 -228
  68. package/src/utils/yamlConverter.ts +94 -17
  69. package/src/utils/yamlLoader.ts +11 -4
  70. package/src/utilsController.ts +80 -30
@@ -17,8 +17,7 @@ import {
17
17
  markCollectionProcessed
18
18
  } from "../shared/operationQueue.js";
19
19
  import { logger } from "../shared/logging.js";
20
- import { createUpdateCollectionAttributesWithStatusCheck } from "./attributes.js";
21
- import { createOrUpdateIndexesWithStatusCheck } from "./indexes.js";
20
+ // Legacy attribute/index helpers removed in favor of unified adapter path
22
21
  import { SchemaGenerator } from "../shared/schemaGenerator.js";
23
22
  import {
24
23
  isNull,
@@ -27,9 +26,11 @@ import {
27
26
  isPlainObject,
28
27
  isString,
29
28
  } from "es-toolkit";
30
- import { delay, tryAwaitWithRetry } from "../utils/helperFunctions.js";
31
- import { MessageFormatter } from "../shared/messageFormatter.js";
32
- import { isLegacyDatabases } from "../utils/typeGuards.js";
29
+ import { delay, tryAwaitWithRetry } from "../utils/helperFunctions.js";
30
+ import { MessageFormatter } from "../shared/messageFormatter.js";
31
+ import { isLegacyDatabases } from "../utils/typeGuards.js";
32
+ import { mapToCreateAttributeParams, mapToUpdateAttributeParams } from "../shared/attributeMapper.js";
33
+ import { diffTableColumns, isIndexEqualToIndex, diffColumnsDetailed, executeColumnOperations } from "./tableOperations.js";
33
34
 
34
35
  // Re-export wipe operations
35
36
  export {
@@ -183,203 +184,26 @@ export const generateSchemas = async (
183
184
  schemaGenerator.generateSchemas();
184
185
  };
185
186
 
186
- export const createOrUpdateCollections = async (
187
- database: Databases,
188
- databaseId: string,
189
- config: AppwriteConfig,
190
- deletedCollections?: { collectionId: string; collectionName: string }[],
191
- selectedCollections: Models.Collection[] = []
192
- ): Promise<void> => {
193
- // Clear processing state at the start of a new operation
194
- clearProcessingState();
195
-
196
- // If API mode is tablesdb, route to adapter-based implementation
197
- try {
198
- const { adapter, apiMode } = await getAdapterFromConfig(config);
199
- if (apiMode === 'tablesdb') {
200
- await createOrUpdateCollectionsViaAdapter(adapter, databaseId, config, deletedCollections, selectedCollections);
201
- return;
202
- }
203
- } catch {
204
- // Fallback to legacy path below
205
- }
206
- const collectionsToProcess =
207
- selectedCollections.length > 0 ? selectedCollections : config.collections;
208
- if (!collectionsToProcess) {
209
- return;
210
- }
211
- const usedIds = new Set();
212
-
213
- MessageFormatter.info(`Processing ${collectionsToProcess.length} collections with intelligent state management`, { prefix: "Collections" });
214
-
215
- for (const collection of collectionsToProcess) {
216
- const { attributes, indexes, ...collectionData } = collection;
217
-
218
- // Check if this collection has already been processed in this session
219
- if (collectionData.$id && isCollectionProcessed(collectionData.$id)) {
220
- MessageFormatter.info(`Collection '${collectionData.name}' already processed, skipping`, { prefix: "Collections" });
221
- continue;
222
- }
223
-
224
- // Prepare permissions for the collection
225
- const permissions: string[] = [];
226
- if (collection.$permissions && collection.$permissions.length > 0) {
227
- for (const permission of collection.$permissions) {
228
- if (typeof permission === "string") {
229
- permissions.push(permission);
230
- } else {
231
- switch (permission.permission) {
232
- case "read":
233
- permissions.push(Permission.read(permission.target));
234
- break;
235
- case "create":
236
- permissions.push(Permission.create(permission.target));
237
- break;
238
- case "update":
239
- permissions.push(Permission.update(permission.target));
240
- break;
241
- case "delete":
242
- permissions.push(Permission.delete(permission.target));
243
- break;
244
- case "write":
245
- permissions.push(Permission.write(permission.target));
246
- break;
247
- default:
248
- MessageFormatter.warning(`Unknown permission: ${permission.permission}`, { prefix: "Collections" });
249
- break;
250
- }
251
- }
252
- }
253
- }
254
-
255
- // Check if the collection already exists by name
256
- let collectionsFound = await tryAwaitWithRetry(
257
- async () =>
258
- await database.listCollections(databaseId, [
259
- Query.equal("name", collectionData.name),
260
- ])
261
- );
262
-
263
- let collectionToUse =
264
- collectionsFound.total > 0 ? collectionsFound.collections[0] : null;
265
-
266
- // Determine the correct ID for the collection
267
- let collectionId: string;
268
- if (!collectionToUse) {
269
- MessageFormatter.info(`Creating collection: ${collectionData.name}`, { prefix: "Collections" });
270
- let foundColl = deletedCollections?.find(
271
- (coll) =>
272
- coll.collectionName.toLowerCase().trim().replace(" ", "") ===
273
- collectionData.name.toLowerCase().trim().replace(" ", "")
274
- );
275
-
276
- if (collectionData.$id) {
277
- collectionId = collectionData.$id;
278
- } else if (foundColl && !usedIds.has(foundColl.collectionId)) {
279
- collectionId = foundColl.collectionId;
280
- } else {
281
- collectionId = ID.unique();
282
- }
283
-
284
- usedIds.add(collectionId);
285
-
286
- // Create the collection with the determined ID
287
- try {
288
- collectionToUse = await tryAwaitWithRetry(
289
- async () =>
290
- await database.createCollection(
291
- databaseId,
292
- collectionId,
293
- collectionData.name,
294
- permissions,
295
- collectionData.documentSecurity ?? false,
296
- collectionData.enabled ?? true
297
- )
298
- );
299
- collectionData.$id = collectionToUse!.$id;
300
- nameToIdMapping.set(collectionData.name, collectionToUse!.$id);
301
- } catch (error) {
302
- MessageFormatter.error(
303
- `Failed to create collection ${collectionData.name} with ID ${collectionId}`,
304
- error instanceof Error ? error : new Error(String(error)),
305
- { prefix: "Collections" }
306
- );
307
- continue;
308
- }
309
- } else {
310
- MessageFormatter.info(`Collection ${collectionData.name} exists, updating it`, { prefix: "Collections" });
311
- await tryAwaitWithRetry(
312
- async () =>
313
- await database.updateCollection(
314
- databaseId,
315
- collectionToUse!.$id,
316
- collectionData.name,
317
- permissions,
318
- collectionData.documentSecurity ?? false,
319
- collectionData.enabled ?? true
320
- )
321
- );
322
- // Cache the existing collection ID
323
- nameToIdMapping.set(collectionData.name, collectionToUse.$id);
324
- }
325
-
326
- // Add delay after creating/updating collection
327
- await delay(250);
328
-
329
- // Update attributes and indexes for the collection
330
- MessageFormatter.progress("Creating Attributes", { prefix: "Collections" });
331
- await createUpdateCollectionAttributesWithStatusCheck(
332
- database,
333
- databaseId,
334
- collectionToUse!,
335
- // @ts-expect-error
336
- attributes
337
- );
338
-
339
- // Add delay after creating attributes
340
- await delay(250);
341
-
342
- // Prefer local config indexes, but fall back to collection's own indexes if no local config exists
343
- const localCollectionConfig = config.collections?.find(
344
- c => c.name === collectionData.name || c.$id === collectionData.$id
345
- );
346
- const indexesToUse = localCollectionConfig?.indexes ?? indexes ?? [];
347
-
348
- MessageFormatter.progress("Creating Indexes", { prefix: "Collections" });
349
- await createOrUpdateIndexesWithStatusCheck(
350
- databaseId,
351
- database,
352
- collectionToUse!.$id,
353
- collectionToUse!,
354
- indexesToUse as Indexes
355
- );
356
-
357
- // Delete indexes that exist on server but not in local config
358
- const { deleteObsoleteIndexes } = await import('../shared/indexManager.js');
359
- await deleteObsoleteIndexes(
360
- database,
361
- databaseId,
362
- collectionToUse!,
363
- { indexes: indexesToUse } as any,
364
- { verbose: true }
365
- );
366
-
367
- // Mark this collection as fully processed to prevent re-processing
368
- markCollectionProcessed(collectionToUse!.$id, collectionData.name);
369
-
370
- // Add delay after creating indexes
371
- await delay(250);
372
- }
373
-
374
- // Process any remaining relationship attributes in the queue
375
- // This surgical approach only processes specific attributes, not entire collections
376
- if (queuedOperations.length > 0) {
377
- MessageFormatter.info(`🔧 Processing ${queuedOperations.length} queued relationship attributes (surgical approach)`, { prefix: "Collections" });
378
- await processQueue(database, databaseId);
379
- } else {
380
- MessageFormatter.info("✅ No queued relationship attributes to process", { prefix: "Collections" });
381
- }
382
- };
187
+ export const createOrUpdateCollections = async (
188
+ database: Databases,
189
+ databaseId: string,
190
+ config: AppwriteConfig,
191
+ deletedCollections?: { collectionId: string; collectionName: string }[],
192
+ selectedCollections: Models.Collection[] = []
193
+ ): Promise<void> => {
194
+ // Clear processing state at the start of a new operation
195
+ clearProcessingState();
196
+
197
+ // Always use adapter path (LegacyAdapter translates when pre-1.8)
198
+ const { adapter } = await getAdapterFromConfig(config);
199
+ await createOrUpdateCollectionsViaAdapter(
200
+ adapter,
201
+ databaseId,
202
+ config,
203
+ deletedCollections,
204
+ selectedCollections
205
+ );
206
+ };
383
207
 
384
208
  // New: Adapter-based implementation for TablesDB with state management
385
209
  export const createOrUpdateCollectionsViaAdapter = async (
@@ -394,33 +218,19 @@ export const createOrUpdateCollectionsViaAdapter = async (
394
218
  if (!collectionsToProcess || collectionsToProcess.length === 0) return;
395
219
 
396
220
  const usedIds = new Set<string>();
397
- MessageFormatter.info(`Processing ${collectionsToProcess.length} tables via adapter with intelligent state management`, { prefix: "Tables" });
398
-
399
- // Helper: create attributes through adapter
400
- const createAttr = async (tableId: string, attr: Attribute) => {
401
- const base: any = {
402
- databaseId,
403
- tableId,
404
- key: attr.key,
405
- type: (attr as any).type,
406
- size: (attr as any).size,
407
- required: !!(attr as any).required,
408
- default: (attr as any).xdefault,
409
- array: !!(attr as any).array,
410
- min: (attr as any).min,
411
- max: (attr as any).max,
412
- elements: (attr as any).elements,
413
- encrypt: (attr as any).encrypted,
414
- relatedCollection: (attr as any).relatedCollection,
415
- relationType: (attr as any).relationType,
416
- twoWay: (attr as any).twoWay,
417
- twoWayKey: (attr as any).twoWayKey,
418
- onDelete: (attr as any).onDelete,
419
- side: (attr as any).side,
420
- };
421
- await adapter.createAttribute(base);
422
- await delay(150);
423
- };
221
+ MessageFormatter.info(`Processing ${collectionsToProcess.length} tables via adapter with intelligent state management`, { prefix: "Tables" });
222
+
223
+ // Helpers for attribute operations through adapter
224
+ const createAttr = async (tableId: string, attr: Attribute) => {
225
+ const params = mapToCreateAttributeParams(attr as any, { databaseId, tableId });
226
+ await adapter.createAttribute(params);
227
+ await delay(150);
228
+ };
229
+ const updateAttr = async (tableId: string, attr: Attribute) => {
230
+ const params = mapToUpdateAttributeParams(attr as any, { databaseId, tableId }) as any;
231
+ await adapter.updateAttribute(params);
232
+ await delay(150);
233
+ };
424
234
 
425
235
  // Local queue for unresolved relationships
426
236
  const relQueue: { tableId: string; attr: Attribute }[] = [];
@@ -495,39 +305,79 @@ export const createOrUpdateCollectionsViaAdapter = async (
495
305
  // Add small delay after table create/update
496
306
  await delay(250);
497
307
 
498
- // Create attributes: non-relationship first
499
- const nonRel = (attributes || []).filter((a: Attribute) => a.type !== 'relationship');
500
- for (const attr of nonRel) {
501
- await createAttr(tableId, attr as Attribute);
502
- }
503
-
504
- // Relationship attributes — resolve relatedCollection to ID
505
- const rels = (attributes || []).filter((a: Attribute) => a.type === 'relationship');
506
- for (const attr of rels as any[]) {
507
- const relNameOrId = attr.relatedCollection as string | undefined;
508
- if (!relNameOrId) continue;
509
- let relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
510
-
511
- // If looks like a name (not ULID) and not in cache, try query by name
512
- if (!nameToIdMapping.has(relNameOrId)) {
513
- try {
514
- const relList = await adapter.listTables({ databaseId, queries: [Query.equal('name', relNameOrId)] });
515
- const relItems: any[] = (relList as any).tables || [];
516
- if (relItems[0]?.$id) {
517
- relId = relItems[0].$id;
518
- nameToIdMapping.set(relNameOrId, relId);
519
- }
520
- } catch {}
521
- }
522
-
523
- if (relId && typeof relId === 'string') {
524
- attr.relatedCollection = relId;
525
- await createAttr(tableId, attr as Attribute);
526
- } else {
527
- // Defer if unresolved
528
- relQueue.push({ tableId, attr: attr as Attribute });
529
- }
530
- }
308
+ // Create/Update attributes: non-relationship first using enhanced planning
309
+ const nonRel = (attributes || []).filter((a: Attribute) => a.type !== 'relationship');
310
+ if (nonRel.length > 0) {
311
+ // Fetch existing columns once
312
+ const tableInfo = await adapter.getTable({ databaseId, tableId });
313
+ const existingCols: any[] = (tableInfo as any).data?.columns || (tableInfo as any).data?.attributes || [];
314
+
315
+ // Plan with icons
316
+ const plan = diffColumnsDetailed(nonRel as any, existingCols);
317
+ const plus = plan.toCreate.map((a: any) => a.key);
318
+ const plusminus = plan.toUpdate.map((u: any) => (u.attribute as any).key);
319
+ const minus = plan.toRecreate.map((r: any) => (r.newAttribute as any).key);
320
+ const skip = plan.unchanged;
321
+
322
+ const parts: string[] = [];
323
+ if (plus.length) parts.push(`➕ ${plus.length} (${plus.join(', ')})`);
324
+ if (plusminus.length) parts.push(`🔧 ${plusminus.length} (${plusminus.join(', ')})`);
325
+ if (minus.length) parts.push(`♻️ ${minus.length} (${minus.join(', ')})`);
326
+ if (skip.length) parts.push(`⏭️ ${skip.length}`);
327
+ MessageFormatter.info(`Plan ${parts.join(' | ') || 'no changes'}`, { prefix: 'Attributes' });
328
+
329
+ // Execute
330
+ const colResults = await executeColumnOperations(adapter, databaseId, tableId, plan);
331
+
332
+ if (colResults.success.length > 0) {
333
+ MessageFormatter.success(`Processed ${colResults.success.length} ops`, { prefix: 'Attributes' });
334
+ }
335
+ if (colResults.errors.length > 0) {
336
+ MessageFormatter.error(`${colResults.errors.length} attribute operations failed:`, undefined, { prefix: 'Attributes' });
337
+ for (const err of colResults.errors) {
338
+ MessageFormatter.error(` ${err.column}: ${err.error}`, undefined, { prefix: 'Attributes' });
339
+ }
340
+ }
341
+ MessageFormatter.info(
342
+ `Summary → ➕ ${plan.toCreate.length} | 🔧 ${plan.toUpdate.length} | ♻️ ${plan.toRecreate.length} | ⏭️ ${plan.unchanged.length}`,
343
+ { prefix: 'Attributes' }
344
+ );
345
+ }
346
+
347
+ // Relationship attributes — resolve relatedCollection to ID, then diff and create/update
348
+ const rels = (attributes || []).filter((a: Attribute) => a.type === 'relationship') as any[];
349
+ if (rels.length > 0) {
350
+ for (const attr of rels) {
351
+ const relNameOrId = attr.relatedCollection as string | undefined;
352
+ if (!relNameOrId) continue;
353
+ let relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
354
+ if (!nameToIdMapping.has(relNameOrId)) {
355
+ try {
356
+ const relList = await adapter.listTables({ databaseId, queries: [Query.equal('name', relNameOrId)] });
357
+ const relItems: any[] = (relList as any).tables || [];
358
+ if (relItems[0]?.$id) {
359
+ relId = relItems[0].$id;
360
+ nameToIdMapping.set(relNameOrId, relId);
361
+ }
362
+ } catch {}
363
+ }
364
+ if (relId && typeof relId === 'string') attr.relatedCollection = relId;
365
+ }
366
+ const tableInfo2 = await adapter.getTable({ databaseId, tableId });
367
+ const existingCols2: any[] = (tableInfo2 as any).data?.columns || (tableInfo2 as any).data?.attributes || [];
368
+ const { toCreate: relCreate, toUpdate: relUpdate, unchanged: relUnchanged } = diffTableColumns(existingCols2, rels as any);
369
+
370
+ // Relationship plan with icons
371
+ {
372
+ const parts: string[] = [];
373
+ if (relCreate.length) parts.push(`➕ ${relCreate.length} (${relCreate.map((a:any)=>a.key).join(', ')})`);
374
+ if (relUpdate.length) parts.push(`🔧 ${relUpdate.length} (${relUpdate.map((a:any)=>a.key).join(', ')})`);
375
+ if (relUnchanged.length) parts.push(`⏭️ ${relUnchanged.length}`);
376
+ MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Relationships' });
377
+ }
378
+ for (const attr of relUpdate) { try { await updateAttr(tableId, attr as Attribute); } catch (e) { MessageFormatter.error(`Failed to update relationship ${(attr as any).key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Attributes' }); } }
379
+ for (const attr of relCreate) { try { await createAttr(tableId, attr as Attribute); } catch (e) { MessageFormatter.error(`Failed to create relationship ${(attr as any).key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Attributes' }); } }
380
+ }
531
381
 
532
382
  // Wait for all attributes to become available before creating indexes
533
383
  const allAttrKeys = [
@@ -535,22 +385,22 @@ export const createOrUpdateCollectionsViaAdapter = async (
535
385
  ...rels.filter((a: any) => a.relatedCollection).map((a: any) => a.key)
536
386
  ];
537
387
 
538
- if (allAttrKeys.length > 0) {
539
- for (const attrKey of allAttrKeys) {
540
- const maxWait = 60000; // 60 seconds
541
- const startTime = Date.now();
542
- let lastStatus = '';
543
-
544
- while (Date.now() - startTime < maxWait) {
545
- try {
546
- const tableData = await adapter.getTable({ databaseId, tableId });
547
- const attrs = (tableData as any).attributes || [];
548
- const attr = attrs.find((a: any) => a.key === attrKey);
549
-
550
- if (attr) {
551
- if (attr.status === 'available') {
552
- break; // Attribute is ready
553
- }
388
+ if (allAttrKeys.length > 0) {
389
+ for (const attrKey of allAttrKeys) {
390
+ const maxWait = 60000; // 60 seconds
391
+ const startTime = Date.now();
392
+ let lastStatus = '';
393
+
394
+ while (Date.now() - startTime < maxWait) {
395
+ try {
396
+ const tableData = await adapter.getTable({ databaseId, tableId });
397
+ const attrs = (tableData as any).data?.columns || (tableData as any).data?.attributes || [];
398
+ const attr = attrs.find((a: any) => a.key === attrKey);
399
+
400
+ if (attr) {
401
+ if (attr.status === 'available') {
402
+ break; // Attribute is ready
403
+ }
554
404
  if (attr.status === 'failed' || attr.status === 'stuck') {
555
405
  throw new Error(`Attribute ${attrKey} failed to create: ${attr.error || 'unknown error'}`);
556
406
  }
@@ -580,21 +430,86 @@ export const createOrUpdateCollectionsViaAdapter = async (
580
430
  c => c.name === collectionData.name || c.$id === collectionData.$id
581
431
  );
582
432
  const idxs = (localTableConfig?.indexes ?? indexes ?? []) as any[];
583
- for (const idx of idxs) {
584
- try {
585
- await adapter.createIndex({
586
- databaseId,
587
- tableId,
588
- key: idx.key,
589
- type: idx.type,
590
- attributes: idx.attributes,
591
- orders: idx.orders || []
592
- });
593
- await delay(150);
594
- } catch (e) {
595
- MessageFormatter.error(`Failed to create index ${idx.key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
596
- }
597
- }
433
+ // Compare with existing indexes and create/update accordingly with status checks
434
+ try {
435
+ const existingIdxRes = await adapter.listIndexes({ databaseId, tableId });
436
+ const existingIdx: any[] = (existingIdxRes as any).data || (existingIdxRes as any).indexes || [];
437
+ MessageFormatter.debug(`Existing index keys: ${existingIdx.map((i:any)=>i.key).join(', ')}`, undefined, { prefix: 'Indexes' });
438
+ // Show a concise plan with icons before executing
439
+ const idxPlanPlus: string[] = [];
440
+ const idxPlanPlusMinus: string[] = [];
441
+ const idxPlanSkip: string[] = [];
442
+ for (const idx of idxs) {
443
+ const found = existingIdx.find((i: any) => i.key === idx.key);
444
+ if (found) {
445
+ if (isIndexEqualToIndex(found, idx)) idxPlanSkip.push(idx.key);
446
+ else idxPlanPlusMinus.push(idx.key);
447
+ } else idxPlanPlus.push(idx.key);
448
+ }
449
+ const planParts: string[] = [];
450
+ if (idxPlanPlus.length) planParts.push(`➕ ${idxPlanPlus.length} (${idxPlanPlus.join(', ')})`);
451
+ if (idxPlanPlusMinus.length) planParts.push(`🔧 ${idxPlanPlusMinus.length} (${idxPlanPlusMinus.join(', ')})`);
452
+ if (idxPlanSkip.length) planParts.push(`⏭️ ${idxPlanSkip.length}`);
453
+ MessageFormatter.info(`Plan → ${planParts.join(' | ') || 'no changes'}`, { prefix: 'Indexes' });
454
+ const created: string[] = [];
455
+ const updated: string[] = [];
456
+ const skipped: string[] = [];
457
+ for (const idx of idxs) {
458
+ const found = existingIdx.find((i: any) => i.key === idx.key);
459
+ if (found) {
460
+ if (isIndexEqualToIndex(found, idx)) {
461
+ MessageFormatter.info(`Index ${idx.key} unchanged`, { prefix: 'Indexes' });
462
+ skipped.push(idx.key);
463
+ } else {
464
+ try { await adapter.deleteIndex({ databaseId, tableId, key: idx.key }); await delay(100); } catch {}
465
+ try {
466
+ await adapter.createIndex({ databaseId, tableId, key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders || [] });
467
+ updated.push(idx.key);
468
+ } catch (e: any) {
469
+ const msg = (e?.message || '').toString().toLowerCase();
470
+ if (msg.includes('already exists')) {
471
+ MessageFormatter.info(`Index ${idx.key} already exists after delete attempt, skipping`, { prefix: 'Indexes' });
472
+ skipped.push(idx.key);
473
+ } else {
474
+ throw e;
475
+ }
476
+ }
477
+ }
478
+ } else {
479
+ try {
480
+ await adapter.createIndex({ databaseId, tableId, key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders || [] });
481
+ created.push(idx.key);
482
+ } catch (e: any) {
483
+ const msg = (e?.message || '').toString().toLowerCase();
484
+ if (msg.includes('already exists')) {
485
+ MessageFormatter.info(`Index ${idx.key} already exists (create), skipping`, { prefix: 'Indexes' });
486
+ skipped.push(idx.key);
487
+ } else {
488
+ throw e;
489
+ }
490
+ }
491
+ }
492
+ // Wait for index availability
493
+ const maxWait = 60000; const start = Date.now(); let lastStatus = '';
494
+ while (Date.now() - start < maxWait) {
495
+ try {
496
+ const li = await adapter.listIndexes({ databaseId, tableId });
497
+ const list: any[] = (li as any).data || (li as any).indexes || [];
498
+ const cur = list.find((i: any) => i.key === idx.key);
499
+ if (cur) {
500
+ if (cur.status === 'available') break;
501
+ if (cur.status === 'failed' || cur.status === 'stuck') { throw new Error(cur.error || `Index ${idx.key} failed`); }
502
+ lastStatus = cur.status;
503
+ }
504
+ await delay(2000);
505
+ } catch { await delay(2000); }
506
+ }
507
+ await delay(150);
508
+ }
509
+ MessageFormatter.info(`Summary → ➕ ${created.length} | 🔧 ${updated.length} | ⏭️ ${skipped.length}` , { prefix: 'Indexes' });
510
+ } catch (e) {
511
+ MessageFormatter.error(`Failed to list/create indexes`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
512
+ }
598
513
 
599
514
  // Mark this table as fully processed to prevent re-processing
600
515
  markCollectionProcessed(tableId, collectionData.name);