appwrite-utils-cli 1.9.5 → 1.9.6

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.
@@ -1,6 +1,6 @@
1
1
  import { Databases, ID, Permission, Query, } from "node-appwrite";
2
2
  import { getAdapterFromConfig } from "../utils/getClientFromConfig.js";
3
- import { nameToIdMapping, processQueue, queuedOperations, clearProcessingState, isCollectionProcessed, markCollectionProcessed } from "../shared/operationQueue.js";
3
+ import { nameToIdMapping, processQueue, queuedOperations, clearProcessingState, isCollectionProcessed, markCollectionProcessed, enqueueOperation } from "../shared/operationQueue.js";
4
4
  import { logger } from "../shared/logging.js";
5
5
  // Legacy attribute/index helpers removed in favor of unified adapter path
6
6
  import { SchemaGenerator } from "../shared/schemaGenerator.js";
@@ -287,63 +287,106 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
287
287
  }
288
288
  MessageFormatter.info(`Summary → ➕ ${plan.toCreate.length} | 🔧 ${plan.toUpdate.length} | ♻️ ${plan.toRecreate.length} | ⏭️ ${plan.unchanged.length}`, { prefix: 'Attributes' });
289
289
  }
290
- // Relationship attributes — resolve relatedCollection to ID, then diff and create/update
291
- const rels = (attributes || []).filter((a) => a.type === 'relationship');
292
- if (rels.length > 0) {
293
- for (const attr of rels) {
290
+ // Relationship attributes — resolve relatedCollection to ID, then diff and create/update with recreate support
291
+ const relsAll = (attributes || []).filter((a) => a.type === 'relationship');
292
+ if (relsAll.length > 0) {
293
+ const relsResolved = [];
294
+ const relsDeferred = [];
295
+ // Resolve related collections (names -> IDs) using cache or lookup.
296
+ // If not resolvable yet (target table created later in the same push), queue for later.
297
+ for (const attr of relsAll) {
294
298
  const relNameOrId = attr.relatedCollection;
295
299
  if (!relNameOrId)
296
300
  continue;
297
301
  let relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
298
- if (!nameToIdMapping.has(relNameOrId)) {
302
+ let resolved = false;
303
+ if (nameToIdMapping.has(relNameOrId)) {
304
+ resolved = true;
305
+ }
306
+ else {
307
+ // Try resolve by name
299
308
  try {
300
309
  const relList = await adapter.listTables({ databaseId, queries: [Query.equal('name', relNameOrId)] });
301
310
  const relItems = relList.tables || [];
302
311
  if (relItems[0]?.$id) {
303
312
  relId = relItems[0].$id;
304
313
  nameToIdMapping.set(relNameOrId, relId);
314
+ resolved = true;
305
315
  }
306
316
  }
307
317
  catch { }
318
+ // If the relNameOrId looks like an ID but isn't resolved yet, attempt a direct get
319
+ if (!resolved && relNameOrId && relNameOrId.length >= 10) {
320
+ try {
321
+ const probe = await adapter.getTable({ databaseId, tableId: relNameOrId });
322
+ if (probe.data?.$id) {
323
+ nameToIdMapping.set(relNameOrId, relNameOrId);
324
+ relId = relNameOrId;
325
+ resolved = true;
326
+ }
327
+ }
328
+ catch { }
329
+ }
308
330
  }
309
- if (relId && typeof relId === 'string')
331
+ if (resolved && relId && typeof relId === 'string') {
310
332
  attr.relatedCollection = relId;
333
+ relsResolved.push(attr);
334
+ }
335
+ else {
336
+ // Defer until related table exists; queue a surgical operation
337
+ enqueueOperation({
338
+ type: 'attribute',
339
+ collectionId: tableId,
340
+ attribute: attr,
341
+ dependencies: [relNameOrId]
342
+ });
343
+ relsDeferred.push(attr);
344
+ }
311
345
  }
346
+ // Compute a detailed plan for immediately resolvable relationships
312
347
  const tableInfo2 = await adapter.getTable({ databaseId, tableId });
313
348
  const existingCols2 = tableInfo2.data?.columns || tableInfo2.data?.attributes || [];
314
- const { toCreate: relCreate, toUpdate: relUpdate, unchanged: relUnchanged } = diffTableColumns(existingCols2, rels);
315
- // Relationship plan with icons
349
+ const relPlan = diffColumnsDetailed(relsResolved, existingCols2);
350
+ // Relationship plan with icons (includes recreates)
316
351
  {
317
352
  const parts = [];
318
- if (relCreate.length)
319
- parts.push(`➕ ${relCreate.length} (${relCreate.map((a) => a.key).join(', ')})`);
320
- if (relUpdate.length)
321
- parts.push(`🔧 ${relUpdate.length} (${relUpdate.map((a) => a.key).join(', ')})`);
322
- if (relUnchanged.length)
323
- parts.push(`⏭️ ${relUnchanged.length}`);
353
+ if (relPlan.toCreate.length)
354
+ parts.push(`➕ ${relPlan.toCreate.length} (${relPlan.toCreate.map((a) => a.key).join(', ')})`);
355
+ if (relPlan.toUpdate.length)
356
+ parts.push(`🔧 ${relPlan.toUpdate.length} (${relPlan.toUpdate.map((u) => u.attribute?.key ?? u.key).join(', ')})`);
357
+ if (relPlan.toRecreate.length)
358
+ parts.push(`♻️ ${relPlan.toRecreate.length} (${relPlan.toRecreate.map((r) => r.newAttribute?.key ?? r?.key).join(', ')})`);
359
+ if (relPlan.unchanged.length)
360
+ parts.push(`⏭️ ${relPlan.unchanged.length}`);
324
361
  MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Relationships' });
325
362
  }
326
- for (const attr of relUpdate) {
327
- try {
328
- await updateAttr(tableId, attr);
363
+ // Execute plan using the same operation executor to properly handle deletes/recreates
364
+ const relResults = await executeColumnOperations(adapter, databaseId, tableId, relPlan);
365
+ if (relResults.success.length > 0) {
366
+ const totalRelationships = relPlan.toCreate.length + relPlan.toUpdate.length + relPlan.toRecreate.length + relPlan.unchanged.length;
367
+ const activeRelationships = relPlan.toCreate.length + relPlan.toUpdate.length + relPlan.toRecreate.length;
368
+ if (relResults.success.length !== activeRelationships) {
369
+ // Show both counts when they differ (usually due to recreations)
370
+ MessageFormatter.success(`Processed ${relResults.success.length} operations for ${activeRelationships} relationship${activeRelationships === 1 ? '' : 's'}`, { prefix: 'Relationships' });
329
371
  }
330
- catch (e) {
331
- MessageFormatter.error(`Failed to update relationship ${attr.key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Attributes' });
372
+ else {
373
+ MessageFormatter.success(`Processed ${relResults.success.length} relationship${relResults.success.length === 1 ? '' : 's'}`, { prefix: 'Relationships' });
332
374
  }
333
375
  }
334
- for (const attr of relCreate) {
335
- try {
336
- await createAttr(tableId, attr);
337
- }
338
- catch (e) {
339
- MessageFormatter.error(`Failed to create relationship ${attr.key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Attributes' });
376
+ if (relResults.errors.length > 0) {
377
+ MessageFormatter.error(`${relResults.errors.length} relationship operations failed:`, undefined, { prefix: 'Relationships' });
378
+ for (const err of relResults.errors) {
379
+ MessageFormatter.error(` ${err.column}: ${err.error}`, undefined, { prefix: 'Relationships' });
340
380
  }
341
381
  }
382
+ if (relsDeferred.length > 0) {
383
+ MessageFormatter.info(`Deferred ${relsDeferred.length} relationship(s) until related tables become available`, { prefix: 'Relationships' });
384
+ }
342
385
  }
343
386
  // Wait for all attributes to become available before creating indexes
344
387
  const allAttrKeys = [
345
388
  ...nonRel.map((a) => a.key),
346
- ...rels.filter((a) => a.relatedCollection).map((a) => a.key)
389
+ ...relsAll.filter((a) => a.relatedCollection).map((a) => a.key)
347
390
  ];
348
391
  if (allAttrKeys.length > 0) {
349
392
  for (const attrKey of allAttrKeys) {
@@ -647,6 +690,15 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
647
690
  }
648
691
  }
649
692
  }
693
+ // Process any remaining queued operations to complete relationship sync
694
+ try {
695
+ MessageFormatter.info(`🔄 Processing final operation queue for database ${databaseId}`, { prefix: "Tables" });
696
+ await processQueue(adapter, databaseId);
697
+ MessageFormatter.info(`✅ Operation queue processing completed`, { prefix: "Tables" });
698
+ }
699
+ catch (error) {
700
+ MessageFormatter.error(`Failed to process operation queue`, error instanceof Error ? error : new Error(String(error)), { prefix: 'Tables' });
701
+ }
650
702
  };
651
703
  export const generateMockData = async (database, databaseId, configCollections) => {
652
704
  for (const { collection, mockFunction } of configCollections) {
@@ -30,6 +30,7 @@ import { createImportSchemas } from "./migrations/yaml/generateImportSchemas.js"
30
30
  import { validateCollectionsTablesConfig, reportValidationResults, validateWithStrictMode } from "./config/configValidation.js";
31
31
  import { ConfigManager } from "./config/ConfigManager.js";
32
32
  import { ClientFactory } from "./utils/ClientFactory.js";
33
+ import { clearProcessingState, processQueue } from "./shared/operationQueue.js";
33
34
  export class UtilsController {
34
35
  // ──────────────────────────────────────────────────
35
36
  // SINGLETON PATTERN
@@ -458,7 +459,6 @@ export class UtilsController {
458
459
  // Ensure we don't carry state between databases in a multi-db push
459
460
  // This resets processed sets and name->id mapping per database
460
461
  try {
461
- const { clearProcessingState } = await import('./shared/operationQueue.js');
462
462
  clearProcessingState();
463
463
  }
464
464
  catch { }
@@ -475,6 +475,15 @@ export class UtilsController {
475
475
  logger.debug("Adapter unavailable, falling back to legacy Databases path", { prefix: "UtilsController" });
476
476
  await createOrUpdateCollections(this.database, database.$id, this.config, deletedCollections, collections);
477
477
  }
478
+ // Safety net: Process any remaining queued operations to complete relationship sync
479
+ try {
480
+ MessageFormatter.info(`🔄 Processing final operation queue for database ${database.$id}`, { prefix: "UtilsController" });
481
+ await processQueue(this.adapter || this.database, database.$id);
482
+ MessageFormatter.info(`✅ Operation queue processing completed`, { prefix: "UtilsController" });
483
+ }
484
+ catch (error) {
485
+ MessageFormatter.error(`Failed to process operation queue`, error instanceof Error ? error : new Error(String(error)), { prefix: 'UtilsController' });
486
+ }
478
487
  }
479
488
  async generateSchemas() {
480
489
  // Schema generation doesn't need Appwrite connection, just config
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrite-utils-cli",
3
3
  "description": "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.",
4
- "version": "1.9.5",
4
+ "version": "1.9.6",
5
5
  "main": "src/main.ts",
6
6
  "type": "module",
7
7
  "repository": {
@@ -14,10 +14,11 @@ import {
14
14
  queuedOperations,
15
15
  clearProcessingState,
16
16
  isCollectionProcessed,
17
- markCollectionProcessed
17
+ markCollectionProcessed,
18
+ enqueueOperation
18
19
  } from "../shared/operationQueue.js";
19
20
  import { logger } from "../shared/logging.js";
20
- // Legacy attribute/index helpers removed in favor of unified adapter path
21
+ // Legacy attribute/index helpers removed in favor of unified adapter path
21
22
  import { SchemaGenerator } from "../shared/schemaGenerator.js";
22
23
  import {
23
24
  isNull,
@@ -26,11 +27,11 @@ import {
26
27
  isPlainObject,
27
28
  isString,
28
29
  } from "es-toolkit";
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";
30
+ import { delay, tryAwaitWithRetry } from "../utils/helperFunctions.js";
31
+ import { MessageFormatter } from "../shared/messageFormatter.js";
32
+ import { isLegacyDatabases } from "../utils/typeGuards.js";
33
+ import { mapToCreateAttributeParams, mapToUpdateAttributeParams } from "../shared/attributeMapper.js";
34
+ import { diffTableColumns, isIndexEqualToIndex, diffColumnsDetailed, executeColumnOperations } from "./tableOperations.js";
34
35
 
35
36
  // Re-export wipe operations
36
37
  export {
@@ -176,34 +177,34 @@ export const fetchAndCacheCollectionByName = async (
176
177
  }
177
178
  };
178
179
 
179
- export const generateSchemas = async (
180
- config: AppwriteConfig,
181
- appwriteFolderPath: string
182
- ): Promise<void> => {
183
- const schemaGenerator = new SchemaGenerator(config, appwriteFolderPath);
184
- await schemaGenerator.generateSchemas();
185
- };
186
-
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
- };
180
+ export const generateSchemas = async (
181
+ config: AppwriteConfig,
182
+ appwriteFolderPath: string
183
+ ): Promise<void> => {
184
+ const schemaGenerator = new SchemaGenerator(config, appwriteFolderPath);
185
+ await schemaGenerator.generateSchemas();
186
+ };
187
+
188
+ export const createOrUpdateCollections = async (
189
+ database: Databases,
190
+ databaseId: string,
191
+ config: AppwriteConfig,
192
+ deletedCollections?: { collectionId: string; collectionName: string }[],
193
+ selectedCollections: Models.Collection[] = []
194
+ ): Promise<void> => {
195
+ // Clear processing state at the start of a new operation
196
+ clearProcessingState();
197
+
198
+ // Always use adapter path (LegacyAdapter translates when pre-1.8)
199
+ const { adapter } = await getAdapterFromConfig(config);
200
+ await createOrUpdateCollectionsViaAdapter(
201
+ adapter,
202
+ databaseId,
203
+ config,
204
+ deletedCollections,
205
+ selectedCollections
206
+ );
207
+ };
207
208
 
208
209
  // New: Adapter-based implementation for TablesDB with state management
209
210
  export const createOrUpdateCollectionsViaAdapter = async (
@@ -215,22 +216,22 @@ export const createOrUpdateCollectionsViaAdapter = async (
215
216
  ): Promise<void> => {
216
217
  const collectionsToProcess =
217
218
  selectedCollections.length > 0 ? selectedCollections : (config.collections || []);
218
- if (!collectionsToProcess || collectionsToProcess.length === 0) return;
219
+ if (!collectionsToProcess || collectionsToProcess.length === 0) return;
219
220
 
220
221
  const usedIds = new Set<string>();
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
- };
222
+ MessageFormatter.info(`Processing ${collectionsToProcess.length} tables via adapter with intelligent state management`, { prefix: "Tables" });
223
+
224
+ // Helpers for attribute operations through adapter
225
+ const createAttr = async (tableId: string, attr: Attribute) => {
226
+ const params = mapToCreateAttributeParams(attr as any, { databaseId, tableId });
227
+ await adapter.createAttribute(params);
228
+ await delay(150);
229
+ };
230
+ const updateAttr = async (tableId: string, attr: Attribute) => {
231
+ const params = mapToUpdateAttributeParams(attr as any, { databaseId, tableId }) as any;
232
+ await adapter.updateAttribute(params);
233
+ await delay(150);
234
+ };
234
235
 
235
236
  // Local queue for unresolved relationships
236
237
  const relQueue: { tableId: string; attr: Attribute }[] = [];
@@ -238,11 +239,11 @@ export const createOrUpdateCollectionsViaAdapter = async (
238
239
  for (const collection of collectionsToProcess) {
239
240
  const { attributes, indexes, ...collectionData } = collection as any;
240
241
 
241
- // Check if this table has already been processed in this session (per database)
242
- if (collectionData.$id && isCollectionProcessed(collectionData.$id, databaseId)) {
243
- MessageFormatter.info(`Table '${collectionData.name}' already processed, skipping`, { prefix: "Tables" });
244
- continue;
245
- }
242
+ // Check if this table has already been processed in this session (per database)
243
+ if (collectionData.$id && isCollectionProcessed(collectionData.$id, databaseId)) {
244
+ MessageFormatter.info(`Table '${collectionData.name}' already processed, skipping`, { prefix: "Tables" });
245
+ continue;
246
+ }
246
247
 
247
248
  // Prepare permissions as strings (reuse Permission helper)
248
249
  const permissions: string[] = [];
@@ -262,176 +263,236 @@ export const createOrUpdateCollectionsViaAdapter = async (
262
263
  }
263
264
  }
264
265
 
265
- // Find existing table — prefer lookup by ID (if provided), then by name
266
- let table: any | undefined;
267
- let tableId: string;
268
-
269
- // 1) Try by explicit $id first (handles rename scenarios)
270
- if (collectionData.$id) {
271
- try {
272
- const byId = await adapter.getTable({ databaseId, tableId: collectionData.$id });
273
- table = (byId as any).data || (byId as any).tables?.[0];
274
- if (table?.$id) {
275
- MessageFormatter.info(`Found existing table by ID: ${table.$id}`, { prefix: 'Tables' });
276
- }
277
- } catch {
278
- // Not found by ID; fall back to name lookup
279
- }
280
- }
281
-
282
- // 2) If not found by ID, try by name
283
- if (!table) {
284
- const list = await adapter.listTables({ databaseId, queries: [Query.equal('name', collectionData.name)] });
285
- const items: any[] = (list as any).tables || [];
286
- table = items[0];
287
- if (table?.$id) {
288
- // If local has $id that differs from remote, prefer remote (IDs are immutable)
289
- if (collectionData.$id && collectionData.$id !== table.$id) {
290
- MessageFormatter.warning(`Config $id '${collectionData.$id}' differs from existing table ID '${table.$id}'. Using existing table.`, { prefix: 'Tables' });
291
- }
292
- }
293
- }
294
-
295
- if (!table) {
296
- // Determine ID (prefer provided $id or re-use deleted one)
297
- let foundColl = deletedCollections?.find(
298
- (coll) => coll.collectionName.toLowerCase().trim().replace(" ", "") === collectionData.name.toLowerCase().trim().replace(" ", "")
299
- );
300
- if (collectionData.$id) tableId = collectionData.$id;
301
- else if (foundColl && !usedIds.has(foundColl.collectionId)) tableId = foundColl.collectionId;
302
- else tableId = ID.unique();
303
- usedIds.add(tableId);
304
-
305
- const res = await adapter.createTable({
306
- databaseId,
307
- id: tableId,
308
- name: collectionData.name,
309
- permissions,
310
- documentSecurity: !!collectionData.documentSecurity,
311
- enabled: collectionData.enabled !== false
312
- });
313
- table = (res as any).data || res;
314
- nameToIdMapping.set(collectionData.name, tableId);
315
- } else {
316
- tableId = table.$id;
317
- await adapter.updateTable({
318
- databaseId,
319
- id: tableId,
320
- name: collectionData.name,
321
- permissions,
322
- documentSecurity: !!collectionData.documentSecurity,
323
- enabled: collectionData.enabled !== false
324
- });
325
- // Cache the existing table ID
326
- nameToIdMapping.set(collectionData.name, tableId);
327
- }
266
+ // Find existing table — prefer lookup by ID (if provided), then by name
267
+ let table: any | undefined;
268
+ let tableId: string;
269
+
270
+ // 1) Try by explicit $id first (handles rename scenarios)
271
+ if (collectionData.$id) {
272
+ try {
273
+ const byId = await adapter.getTable({ databaseId, tableId: collectionData.$id });
274
+ table = (byId as any).data || (byId as any).tables?.[0];
275
+ if (table?.$id) {
276
+ MessageFormatter.info(`Found existing table by ID: ${table.$id}`, { prefix: 'Tables' });
277
+ }
278
+ } catch {
279
+ // Not found by ID; fall back to name lookup
280
+ }
281
+ }
282
+
283
+ // 2) If not found by ID, try by name
284
+ if (!table) {
285
+ const list = await adapter.listTables({ databaseId, queries: [Query.equal('name', collectionData.name)] });
286
+ const items: any[] = (list as any).tables || [];
287
+ table = items[0];
288
+ if (table?.$id) {
289
+ // If local has $id that differs from remote, prefer remote (IDs are immutable)
290
+ if (collectionData.$id && collectionData.$id !== table.$id) {
291
+ MessageFormatter.warning(`Config $id '${collectionData.$id}' differs from existing table ID '${table.$id}'. Using existing table.`, { prefix: 'Tables' });
292
+ }
293
+ }
294
+ }
295
+
296
+ if (!table) {
297
+ // Determine ID (prefer provided $id or re-use deleted one)
298
+ let foundColl = deletedCollections?.find(
299
+ (coll) => coll.collectionName.toLowerCase().trim().replace(" ", "") === collectionData.name.toLowerCase().trim().replace(" ", "")
300
+ );
301
+ if (collectionData.$id) tableId = collectionData.$id;
302
+ else if (foundColl && !usedIds.has(foundColl.collectionId)) tableId = foundColl.collectionId;
303
+ else tableId = ID.unique();
304
+ usedIds.add(tableId);
305
+
306
+ const res = await adapter.createTable({
307
+ databaseId,
308
+ id: tableId,
309
+ name: collectionData.name,
310
+ permissions,
311
+ documentSecurity: !!collectionData.documentSecurity,
312
+ enabled: collectionData.enabled !== false
313
+ });
314
+ table = (res as any).data || res;
315
+ nameToIdMapping.set(collectionData.name, tableId);
316
+ } else {
317
+ tableId = table.$id;
318
+ await adapter.updateTable({
319
+ databaseId,
320
+ id: tableId,
321
+ name: collectionData.name,
322
+ permissions,
323
+ documentSecurity: !!collectionData.documentSecurity,
324
+ enabled: collectionData.enabled !== false
325
+ });
326
+ // Cache the existing table ID
327
+ nameToIdMapping.set(collectionData.name, tableId);
328
+ }
328
329
 
329
330
  // Add small delay after table create/update
330
331
  await delay(250);
331
332
 
332
- // Create/Update attributes: non-relationship first using enhanced planning
333
- const nonRel = (attributes || []).filter((a: Attribute) => a.type !== 'relationship');
334
- if (nonRel.length > 0) {
335
- // Fetch existing columns once
336
- const tableInfo = await adapter.getTable({ databaseId, tableId });
337
- const existingCols: any[] = (tableInfo as any).data?.columns || (tableInfo as any).data?.attributes || [];
338
-
339
- // Plan with icons
340
- const plan = diffColumnsDetailed(nonRel as any, existingCols);
341
- const plus = plan.toCreate.map((a: any) => a.key);
342
- const plusminus = plan.toUpdate.map((u: any) => (u.attribute as any).key);
343
- const minus = plan.toRecreate.map((r: any) => (r.newAttribute as any).key);
344
- const skip = plan.unchanged;
345
-
346
- // Compute deletions (remote extras not present locally)
347
- const desiredKeysForDelete = new Set((attributes || []).map((a: any) => a.key));
348
- const extraRemoteKeys = (existingCols || [])
349
- .map((c: any) => c?.key)
350
- .filter((k: any): k is string => !!k && !desiredKeysForDelete.has(k));
351
-
352
- const parts: string[] = [];
353
- if (plus.length) parts.push(`➕ ${plus.length} (${plus.join(', ')})`);
354
- if (plusminus.length) parts.push(`🔧 ${plusminus.length} (${plusminus.join(', ')})`);
355
- if (minus.length) parts.push(`♻️ ${minus.length} (${minus.join(', ')})`);
356
- if (skip.length) parts.push(`⏭️ ${skip.length}`);
357
- parts.push(`🗑️ ${extraRemoteKeys.length}${extraRemoteKeys.length ? ` (${extraRemoteKeys.join(', ')})` : ''}`);
358
- MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Attributes' });
359
-
360
- // Execute
361
- const colResults = await executeColumnOperations(adapter, databaseId, tableId, plan);
362
-
363
- if (colResults.success.length > 0) {
364
- MessageFormatter.success(`Processed ${colResults.success.length} ops`, { prefix: 'Attributes' });
365
- }
366
- if (colResults.errors.length > 0) {
367
- MessageFormatter.error(`${colResults.errors.length} attribute operations failed:`, undefined, { prefix: 'Attributes' });
368
- for (const err of colResults.errors) {
369
- MessageFormatter.error(` ${err.column}: ${err.error}`, undefined, { prefix: 'Attributes' });
370
- }
371
- }
372
- MessageFormatter.info(
373
- `Summary → ➕ ${plan.toCreate.length} | 🔧 ${plan.toUpdate.length} | ♻️ ${plan.toRecreate.length} | ⏭️ ${plan.unchanged.length}`,
374
- { prefix: 'Attributes' }
375
- );
376
- }
377
-
378
- // Relationship attributes — resolve relatedCollection to ID, then diff and create/update
379
- const rels = (attributes || []).filter((a: Attribute) => a.type === 'relationship') as any[];
380
- if (rels.length > 0) {
381
- for (const attr of rels) {
382
- const relNameOrId = attr.relatedCollection as string | undefined;
383
- if (!relNameOrId) continue;
384
- let relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
385
- if (!nameToIdMapping.has(relNameOrId)) {
386
- try {
387
- const relList = await adapter.listTables({ databaseId, queries: [Query.equal('name', relNameOrId)] });
388
- const relItems: any[] = (relList as any).tables || [];
389
- if (relItems[0]?.$id) {
390
- relId = relItems[0].$id;
391
- nameToIdMapping.set(relNameOrId, relId);
392
- }
393
- } catch {}
394
- }
395
- if (relId && typeof relId === 'string') attr.relatedCollection = relId;
396
- }
397
- const tableInfo2 = await adapter.getTable({ databaseId, tableId });
398
- const existingCols2: any[] = (tableInfo2 as any).data?.columns || (tableInfo2 as any).data?.attributes || [];
399
- const { toCreate: relCreate, toUpdate: relUpdate, unchanged: relUnchanged } = diffTableColumns(existingCols2, rels as any);
400
-
401
- // Relationship plan with icons
402
- {
403
- const parts: string[] = [];
404
- if (relCreate.length) parts.push(`➕ ${relCreate.length} (${relCreate.map((a:any)=>a.key).join(', ')})`);
405
- if (relUpdate.length) parts.push(`🔧 ${relUpdate.length} (${relUpdate.map((a:any)=>a.key).join(', ')})`);
406
- if (relUnchanged.length) parts.push(`⏭️ ${relUnchanged.length}`);
407
- MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Relationships' });
408
- }
409
- 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' }); } }
410
- 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' }); } }
411
- }
333
+ // Create/Update attributes: non-relationship first using enhanced planning
334
+ const nonRel = (attributes || []).filter((a: Attribute) => a.type !== 'relationship');
335
+ if (nonRel.length > 0) {
336
+ // Fetch existing columns once
337
+ const tableInfo = await adapter.getTable({ databaseId, tableId });
338
+ const existingCols: any[] = (tableInfo as any).data?.columns || (tableInfo as any).data?.attributes || [];
339
+
340
+ // Plan with icons
341
+ const plan = diffColumnsDetailed(nonRel as any, existingCols);
342
+ const plus = plan.toCreate.map((a: any) => a.key);
343
+ const plusminus = plan.toUpdate.map((u: any) => (u.attribute as any).key);
344
+ const minus = plan.toRecreate.map((r: any) => (r.newAttribute as any).key);
345
+ const skip = plan.unchanged;
346
+
347
+ // Compute deletions (remote extras not present locally)
348
+ const desiredKeysForDelete = new Set((attributes || []).map((a: any) => a.key));
349
+ const extraRemoteKeys = (existingCols || [])
350
+ .map((c: any) => c?.key)
351
+ .filter((k: any): k is string => !!k && !desiredKeysForDelete.has(k));
352
+
353
+ const parts: string[] = [];
354
+ if (plus.length) parts.push(`➕ ${plus.length} (${plus.join(', ')})`);
355
+ if (plusminus.length) parts.push(`🔧 ${plusminus.length} (${plusminus.join(', ')})`);
356
+ if (minus.length) parts.push(`♻️ ${minus.length} (${minus.join(', ')})`);
357
+ if (skip.length) parts.push(`⏭️ ${skip.length}`);
358
+ parts.push(`🗑️ ${extraRemoteKeys.length}${extraRemoteKeys.length ? ` (${extraRemoteKeys.join(', ')})` : ''}`);
359
+ MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Attributes' });
360
+
361
+ // Execute
362
+ const colResults = await executeColumnOperations(adapter, databaseId, tableId, plan);
363
+
364
+ if (colResults.success.length > 0) {
365
+ MessageFormatter.success(`Processed ${colResults.success.length} ops`, { prefix: 'Attributes' });
366
+ }
367
+ if (colResults.errors.length > 0) {
368
+ MessageFormatter.error(`${colResults.errors.length} attribute operations failed:`, undefined, { prefix: 'Attributes' });
369
+ for (const err of colResults.errors) {
370
+ MessageFormatter.error(` ${err.column}: ${err.error}`, undefined, { prefix: 'Attributes' });
371
+ }
372
+ }
373
+ MessageFormatter.info(
374
+ `Summary → ➕ ${plan.toCreate.length} | 🔧 ${plan.toUpdate.length} | ♻️ ${plan.toRecreate.length} | ⏭️ ${plan.unchanged.length}`,
375
+ { prefix: 'Attributes' }
376
+ );
377
+ }
378
+
379
+ // Relationship attributes — resolve relatedCollection to ID, then diff and create/update with recreate support
380
+ const relsAll = (attributes || []).filter((a: Attribute) => a.type === 'relationship') as any[];
381
+ if (relsAll.length > 0) {
382
+ const relsResolved: any[] = [];
383
+ const relsDeferred: any[] = [];
384
+
385
+ // Resolve related collections (names -> IDs) using cache or lookup.
386
+ // If not resolvable yet (target table created later in the same push), queue for later.
387
+ for (const attr of relsAll) {
388
+ const relNameOrId = attr.relatedCollection as string | undefined;
389
+ if (!relNameOrId) continue;
390
+ let relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
391
+ let resolved = false;
392
+ if (nameToIdMapping.has(relNameOrId)) {
393
+ resolved = true;
394
+ } else {
395
+ // Try resolve by name
396
+ try {
397
+ const relList = await adapter.listTables({ databaseId, queries: [Query.equal('name', relNameOrId)] });
398
+ const relItems: any[] = (relList as any).tables || [];
399
+ if (relItems[0]?.$id) {
400
+ relId = relItems[0].$id;
401
+ nameToIdMapping.set(relNameOrId, relId);
402
+ resolved = true;
403
+ }
404
+ } catch {}
405
+
406
+ // If the relNameOrId looks like an ID but isn't resolved yet, attempt a direct get
407
+ if (!resolved && relNameOrId && relNameOrId.length >= 10) {
408
+ try {
409
+ const probe = await adapter.getTable({ databaseId, tableId: relNameOrId });
410
+ if ((probe as any).data?.$id) {
411
+ nameToIdMapping.set(relNameOrId, relNameOrId);
412
+ relId = relNameOrId;
413
+ resolved = true;
414
+ }
415
+ } catch {}
416
+ }
417
+ }
418
+
419
+ if (resolved && relId && typeof relId === 'string') {
420
+ attr.relatedCollection = relId;
421
+ relsResolved.push(attr);
422
+ } else {
423
+ // Defer until related table exists; queue a surgical operation
424
+ enqueueOperation({
425
+ type: 'attribute',
426
+ collectionId: tableId,
427
+ attribute: attr,
428
+ dependencies: [relNameOrId]
429
+ });
430
+ relsDeferred.push(attr);
431
+ }
432
+ }
433
+
434
+ // Compute a detailed plan for immediately resolvable relationships
435
+ const tableInfo2 = await adapter.getTable({ databaseId, tableId });
436
+ const existingCols2: any[] = (tableInfo2 as any).data?.columns || (tableInfo2 as any).data?.attributes || [];
437
+ const relPlan = diffColumnsDetailed(relsResolved as any, existingCols2);
438
+
439
+ // Relationship plan with icons (includes recreates)
440
+ {
441
+ const parts: string[] = [];
442
+ if (relPlan.toCreate.length) parts.push(`➕ ${relPlan.toCreate.length} (${relPlan.toCreate.map((a:any)=>a.key).join(', ')})`);
443
+ if (relPlan.toUpdate.length) parts.push(`🔧 ${relPlan.toUpdate.length} (${relPlan.toUpdate.map((u:any)=>u.attribute?.key ?? u.key).join(', ')})`);
444
+ if (relPlan.toRecreate.length) parts.push(`♻️ ${relPlan.toRecreate.length} (${relPlan.toRecreate.map((r:any)=>r.newAttribute?.key ?? r?.key).join(', ')})`);
445
+ if (relPlan.unchanged.length) parts.push(`⏭️ ${relPlan.unchanged.length}`);
446
+ MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Relationships' });
447
+ }
448
+
449
+ // Execute plan using the same operation executor to properly handle deletes/recreates
450
+ const relResults = await executeColumnOperations(adapter, databaseId, tableId, relPlan);
451
+ if (relResults.success.length > 0) {
452
+ const totalRelationships = relPlan.toCreate.length + relPlan.toUpdate.length + relPlan.toRecreate.length + relPlan.unchanged.length;
453
+ const activeRelationships = relPlan.toCreate.length + relPlan.toUpdate.length + relPlan.toRecreate.length;
454
+
455
+ if (relResults.success.length !== activeRelationships) {
456
+ // Show both counts when they differ (usually due to recreations)
457
+ MessageFormatter.success(`Processed ${relResults.success.length} operations for ${activeRelationships} relationship${activeRelationships === 1 ? '' : 's'}`, { prefix: 'Relationships' });
458
+ } else {
459
+ MessageFormatter.success(`Processed ${relResults.success.length} relationship${relResults.success.length === 1 ? '' : 's'}`, { prefix: 'Relationships' });
460
+ }
461
+ }
462
+ if (relResults.errors.length > 0) {
463
+ MessageFormatter.error(`${relResults.errors.length} relationship operations failed:`, undefined, { prefix: 'Relationships' });
464
+ for (const err of relResults.errors) {
465
+ MessageFormatter.error(` ${err.column}: ${err.error}`, undefined, { prefix: 'Relationships' });
466
+ }
467
+ }
468
+
469
+ if (relsDeferred.length > 0) {
470
+ MessageFormatter.info(`Deferred ${relsDeferred.length} relationship(s) until related tables become available`, { prefix: 'Relationships' });
471
+ }
472
+ }
412
473
 
413
474
  // Wait for all attributes to become available before creating indexes
414
475
  const allAttrKeys = [
415
476
  ...nonRel.map((a: any) => a.key),
416
- ...rels.filter((a: any) => a.relatedCollection).map((a: any) => a.key)
477
+ ...relsAll.filter((a: any) => a.relatedCollection).map((a: any) => a.key)
417
478
  ];
418
479
 
419
- if (allAttrKeys.length > 0) {
420
- for (const attrKey of allAttrKeys) {
421
- const maxWait = 60000; // 60 seconds
422
- const startTime = Date.now();
423
- let lastStatus = '';
424
-
425
- while (Date.now() - startTime < maxWait) {
426
- try {
427
- const tableData = await adapter.getTable({ databaseId, tableId });
428
- const attrs = (tableData as any).data?.columns || (tableData as any).data?.attributes || [];
429
- const attr = attrs.find((a: any) => a.key === attrKey);
430
-
431
- if (attr) {
432
- if (attr.status === 'available') {
433
- break; // Attribute is ready
434
- }
480
+ if (allAttrKeys.length > 0) {
481
+ for (const attrKey of allAttrKeys) {
482
+ const maxWait = 60000; // 60 seconds
483
+ const startTime = Date.now();
484
+ let lastStatus = '';
485
+
486
+ while (Date.now() - startTime < maxWait) {
487
+ try {
488
+ const tableData = await adapter.getTable({ databaseId, tableId });
489
+ const attrs = (tableData as any).data?.columns || (tableData as any).data?.attributes || [];
490
+ const attr = attrs.find((a: any) => a.key === attrKey);
491
+
492
+ if (attr) {
493
+ if (attr.status === 'available') {
494
+ break; // Attribute is ready
495
+ }
435
496
  if (attr.status === 'failed' || attr.status === 'stuck') {
436
497
  throw new Error(`Attribute ${attrKey} failed to create: ${attr.error || 'unknown error'}`);
437
498
  }
@@ -461,197 +522,197 @@ export const createOrUpdateCollectionsViaAdapter = async (
461
522
  c => c.name === collectionData.name || c.$id === collectionData.$id
462
523
  );
463
524
  const idxs = (localTableConfig?.indexes ?? indexes ?? []) as any[];
464
- // Compare with existing indexes and create/update accordingly with status checks
465
- try {
466
- const existingIdxRes = await adapter.listIndexes({ databaseId, tableId });
467
- const existingIdx: any[] = (existingIdxRes as any).data || (existingIdxRes as any).indexes || [];
468
- MessageFormatter.debug(`Existing index keys: ${existingIdx.map((i:any)=>i.key).join(', ')}`, undefined, { prefix: 'Indexes' });
469
- // Show a concise plan with icons before executing
470
- const idxPlanPlus: string[] = [];
471
- const idxPlanPlusMinus: string[] = [];
472
- const idxPlanSkip: string[] = [];
473
- for (const idx of idxs) {
474
- const found = existingIdx.find((i: any) => i.key === idx.key);
475
- if (found) {
476
- if (isIndexEqualToIndex(found, idx)) idxPlanSkip.push(idx.key);
477
- else idxPlanPlusMinus.push(idx.key);
478
- } else idxPlanPlus.push(idx.key);
479
- }
480
- const planParts: string[] = [];
481
- if (idxPlanPlus.length) planParts.push(`➕ ${idxPlanPlus.length} (${idxPlanPlus.join(', ')})`);
482
- if (idxPlanPlusMinus.length) planParts.push(`🔧 ${idxPlanPlusMinus.length} (${idxPlanPlusMinus.join(', ')})`);
483
- if (idxPlanSkip.length) planParts.push(`⏭️ ${idxPlanSkip.length}`);
484
- MessageFormatter.info(`Plan → ${planParts.join(' | ') || 'no changes'}`, { prefix: 'Indexes' });
485
- const created: string[] = [];
486
- const updated: string[] = [];
487
- const skipped: string[] = [];
488
- for (const idx of idxs) {
489
- const found = existingIdx.find((i: any) => i.key === idx.key);
490
- if (found) {
491
- if (isIndexEqualToIndex(found, idx)) {
492
- MessageFormatter.info(`Index ${idx.key} unchanged`, { prefix: 'Indexes' });
493
- skipped.push(idx.key);
494
- } else {
495
- try { await adapter.deleteIndex({ databaseId, tableId, key: idx.key }); await delay(100); } catch {}
496
- try {
497
- await adapter.createIndex({ databaseId, tableId, key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders || [] });
498
- updated.push(idx.key);
499
- } catch (e: any) {
500
- const msg = (e?.message || '').toString().toLowerCase();
501
- if (msg.includes('already exists')) {
502
- MessageFormatter.info(`Index ${idx.key} already exists after delete attempt, skipping`, { prefix: 'Indexes' });
503
- skipped.push(idx.key);
504
- } else {
505
- throw e;
506
- }
507
- }
508
- }
509
- } else {
510
- try {
511
- await adapter.createIndex({ databaseId, tableId, key: idx.key, type: idx.type, attributes: idx.attributes, orders: idx.orders || [] });
512
- created.push(idx.key);
513
- } catch (e: any) {
514
- const msg = (e?.message || '').toString().toLowerCase();
515
- if (msg.includes('already exists')) {
516
- MessageFormatter.info(`Index ${idx.key} already exists (create), skipping`, { prefix: 'Indexes' });
517
- skipped.push(idx.key);
518
- } else {
519
- throw e;
520
- }
521
- }
522
- }
523
- // Wait for index availability
524
- const maxWait = 60000; const start = Date.now(); let lastStatus = '';
525
- while (Date.now() - start < maxWait) {
526
- try {
527
- const li = await adapter.listIndexes({ databaseId, tableId });
528
- const list: any[] = (li as any).data || (li as any).indexes || [];
529
- const cur = list.find((i: any) => i.key === idx.key);
530
- if (cur) {
531
- if (cur.status === 'available') break;
532
- if (cur.status === 'failed' || cur.status === 'stuck') { throw new Error(cur.error || `Index ${idx.key} failed`); }
533
- lastStatus = cur.status;
534
- }
535
- await delay(2000);
536
- } catch { await delay(2000); }
537
- }
538
- await delay(150);
539
- }
540
- MessageFormatter.info(`Summary → ➕ ${created.length} | 🔧 ${updated.length} | ⏭️ ${skipped.length}` , { prefix: 'Indexes' });
541
- } catch (e) {
542
- MessageFormatter.error(`Failed to list/create indexes`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
543
- }
544
-
545
- // Deletions for indexes: remove remote indexes not declared in YAML/config
546
- try {
547
- const desiredIndexKeys = new Set((indexes || []).map((i: any) => i.key));
548
- const idxRes = await adapter.listIndexes({ databaseId, tableId });
549
- const existingIdx: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
550
- const extraIdx = existingIdx
551
- .filter((i: any) => i?.key && !desiredIndexKeys.has(i.key))
552
- .map((i: any) => i.key as string);
553
- if (extraIdx.length > 0) {
554
- MessageFormatter.info(`Plan → 🗑️ ${extraIdx.length} indexes (${extraIdx.join(', ')})`, { prefix: 'Indexes' });
555
- const deleted: string[] = [];
556
- const errors: Array<{ key: string; error: string }> = [];
557
- for (const key of extraIdx) {
558
- try {
559
- await adapter.deleteIndex({ databaseId, tableId, key });
560
- // Optionally wait for index to disappear
561
- const start = Date.now();
562
- const maxWait = 30000;
563
- while (Date.now() - start < maxWait) {
564
- try {
565
- const li = await adapter.listIndexes({ databaseId, tableId });
566
- const list: any[] = (li as any).data || (li as any).indexes || [];
567
- if (!list.find((ix: any) => ix.key === key)) break;
568
- } catch {}
569
- await delay(1000);
570
- }
571
- deleted.push(key);
572
- } catch (e: any) {
573
- errors.push({ key, error: e?.message || String(e) });
574
- }
575
- }
576
- if (deleted.length) {
577
- MessageFormatter.success(`Deleted ${deleted.length} indexes: ${deleted.join(', ')}`, { prefix: 'Indexes' });
578
- }
579
- if (errors.length) {
580
- MessageFormatter.error(`${errors.length} index deletions failed`, undefined, { prefix: 'Indexes' });
581
- errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Indexes' }));
582
- }
583
- } else {
584
- MessageFormatter.info(`Plan → 🗑️ 0 indexes`, { prefix: 'Indexes' });
585
- }
586
- } catch (e) {
587
- MessageFormatter.warning(`Could not evaluate index deletions: ${(e as Error)?.message || e}`, { prefix: 'Indexes' });
588
- }
589
-
590
- // Deletions: remove columns/attributes that are present remotely but not in desired config
591
- try {
592
- const desiredKeys = new Set((attributes || []).map((a: any) => a.key));
593
- const tableInfo3 = await adapter.getTable({ databaseId, tableId });
594
- const existingCols3: any[] = (tableInfo3 as any).data?.columns || (tableInfo3 as any).data?.attributes || [];
595
- const toDelete = existingCols3
596
- .filter((col: any) => col?.key && !desiredKeys.has(col.key))
597
- .map((col: any) => col.key as string);
598
-
599
- if (toDelete.length > 0) {
600
- MessageFormatter.info(`Plan → 🗑️ ${toDelete.length} (${toDelete.join(', ')})`, { prefix: 'Attributes' });
601
- const deleted: string[] = [];
602
- const errors: Array<{ key: string; error: string }> = [];
603
- for (const key of toDelete) {
604
- try {
605
- // Drop any indexes that reference this attribute to avoid server errors
606
- try {
607
- const idxRes = await adapter.listIndexes({ databaseId, tableId });
608
- const ilist: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
609
- for (const idx of ilist) {
610
- const attrs: string[] = Array.isArray(idx.attributes) ? idx.attributes : [];
611
- if (attrs.includes(key)) {
612
- MessageFormatter.info(`🗑️ Deleting index '${idx.key}' referencing '${key}'`, { prefix: 'Indexes' });
613
- await adapter.deleteIndex({ databaseId, tableId, key: idx.key });
614
- await delay(500);
615
- }
616
- }
617
- } catch {}
618
-
619
- await adapter.deleteAttribute({ databaseId, tableId, key });
620
- // Wait briefly for deletion to settle
621
- const start = Date.now();
622
- const maxWaitMs = 60000;
623
- while (Date.now() - start < maxWaitMs) {
624
- try {
625
- const tinfo = await adapter.getTable({ databaseId, tableId });
626
- const cols = (tinfo as any).data?.columns || (tinfo as any).data?.attributes || [];
627
- const found = cols.find((c: any) => c.key === key);
628
- if (!found) break;
629
- if (found.status && found.status !== 'deleting') break;
630
- } catch {}
631
- await delay(1000);
632
- }
633
- deleted.push(key);
634
- } catch (e: any) {
635
- errors.push({ key, error: e?.message || String(e) });
636
- }
637
- }
638
- if (deleted.length) {
639
- MessageFormatter.success(`Deleted ${deleted.length} attributes: ${deleted.join(', ')}`, { prefix: 'Attributes' });
640
- }
641
- if (errors.length) {
642
- MessageFormatter.error(`${errors.length} deletions failed`, undefined, { prefix: 'Attributes' });
643
- errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Attributes' }));
644
- }
645
- } else {
646
- MessageFormatter.info(`Plan → 🗑️ 0`, { prefix: 'Attributes' });
647
- }
648
- } catch (e) {
649
- MessageFormatter.warning(`Could not evaluate deletions: ${(e as Error)?.message || e}`, { prefix: 'Attributes' });
650
- }
651
-
652
- // Mark this table as fully processed for this database to prevent re-processing in the same DB only
653
- markCollectionProcessed(tableId, collectionData.name, databaseId);
654
- }
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
+
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
+ }
650
+
651
+ // Deletions: remove columns/attributes that are present remotely but not in desired config
652
+ try {
653
+ const desiredKeys = new Set((attributes || []).map((a: any) => a.key));
654
+ const tableInfo3 = await adapter.getTable({ databaseId, tableId });
655
+ const existingCols3: any[] = (tableInfo3 as any).data?.columns || (tableInfo3 as any).data?.attributes || [];
656
+ const toDelete = existingCols3
657
+ .filter((col: any) => col?.key && !desiredKeys.has(col.key))
658
+ .map((col: any) => col.key as string);
659
+
660
+ if (toDelete.length > 0) {
661
+ MessageFormatter.info(`Plan → 🗑️ ${toDelete.length} (${toDelete.join(', ')})`, { prefix: 'Attributes' });
662
+ const deleted: string[] = [];
663
+ const errors: Array<{ key: string; error: string }> = [];
664
+ for (const key of toDelete) {
665
+ try {
666
+ // Drop any indexes that reference this attribute to avoid server errors
667
+ try {
668
+ const idxRes = await adapter.listIndexes({ databaseId, tableId });
669
+ const ilist: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
670
+ for (const idx of ilist) {
671
+ const attrs: string[] = Array.isArray(idx.attributes) ? idx.attributes : [];
672
+ if (attrs.includes(key)) {
673
+ MessageFormatter.info(`🗑️ Deleting index '${idx.key}' referencing '${key}'`, { prefix: 'Indexes' });
674
+ await adapter.deleteIndex({ databaseId, tableId, key: idx.key });
675
+ await delay(500);
676
+ }
677
+ }
678
+ } catch {}
679
+
680
+ await adapter.deleteAttribute({ databaseId, tableId, key });
681
+ // Wait briefly for deletion to settle
682
+ const start = Date.now();
683
+ const maxWaitMs = 60000;
684
+ while (Date.now() - start < maxWaitMs) {
685
+ try {
686
+ const tinfo = await adapter.getTable({ databaseId, tableId });
687
+ const cols = (tinfo as any).data?.columns || (tinfo as any).data?.attributes || [];
688
+ const found = cols.find((c: any) => c.key === key);
689
+ if (!found) break;
690
+ if (found.status && found.status !== 'deleting') break;
691
+ } catch {}
692
+ await delay(1000);
693
+ }
694
+ deleted.push(key);
695
+ } catch (e: any) {
696
+ errors.push({ key, error: e?.message || String(e) });
697
+ }
698
+ }
699
+ if (deleted.length) {
700
+ MessageFormatter.success(`Deleted ${deleted.length} attributes: ${deleted.join(', ')}`, { prefix: 'Attributes' });
701
+ }
702
+ if (errors.length) {
703
+ MessageFormatter.error(`${errors.length} deletions failed`, undefined, { prefix: 'Attributes' });
704
+ errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Attributes' }));
705
+ }
706
+ } else {
707
+ MessageFormatter.info(`Plan → 🗑️ 0`, { prefix: 'Attributes' });
708
+ }
709
+ } catch (e) {
710
+ MessageFormatter.warning(`Could not evaluate deletions: ${(e as Error)?.message || e}`, { prefix: 'Attributes' });
711
+ }
712
+
713
+ // Mark this table as fully processed for this database to prevent re-processing in the same DB only
714
+ markCollectionProcessed(tableId, collectionData.name, databaseId);
715
+ }
655
716
 
656
717
  // Process queued relationships once mapping likely populated
657
718
  if (relQueue.length > 0) {
@@ -692,6 +753,15 @@ export const createOrUpdateCollectionsViaAdapter = async (
692
753
  }
693
754
  }
694
755
  }
756
+
757
+ // Process any remaining queued operations to complete relationship sync
758
+ try {
759
+ MessageFormatter.info(`🔄 Processing final operation queue for database ${databaseId}`, { prefix: "Tables" });
760
+ await processQueue(adapter, databaseId);
761
+ MessageFormatter.info(`✅ Operation queue processing completed`, { prefix: "Tables" });
762
+ } catch (error) {
763
+ MessageFormatter.error(`Failed to process operation queue`, error instanceof Error ? error : new Error(String(error)), { prefix: 'Tables' });
764
+ }
695
765
  };
696
766
 
697
767
  export const generateMockData = async (
@@ -82,6 +82,7 @@ import {
82
82
  import { ConfigManager } from "./config/ConfigManager.js";
83
83
  import { ClientFactory } from "./utils/ClientFactory.js";
84
84
  import type { DatabaseSelection, BucketSelection } from "./shared/selectionDialogs.js";
85
+ import { clearProcessingState, processQueue } from "./shared/operationQueue.js";
85
86
 
86
87
  export interface SetupOptions {
87
88
  databases?: Models.Database[];
@@ -629,7 +630,6 @@ export class UtilsController {
629
630
  // Ensure we don't carry state between databases in a multi-db push
630
631
  // This resets processed sets and name->id mapping per database
631
632
  try {
632
- const { clearProcessingState } = await import('./shared/operationQueue.js');
633
633
  clearProcessingState();
634
634
  } catch {}
635
635
 
@@ -657,6 +657,15 @@ export class UtilsController {
657
657
  collections
658
658
  );
659
659
  }
660
+
661
+ // Safety net: Process any remaining queued operations to complete relationship sync
662
+ try {
663
+ MessageFormatter.info(`🔄 Processing final operation queue for database ${database.$id}`, { prefix: "UtilsController" });
664
+ await processQueue(this.adapter || this.database!, database.$id);
665
+ MessageFormatter.info(`✅ Operation queue processing completed`, { prefix: "UtilsController" });
666
+ } catch (error) {
667
+ MessageFormatter.error(`Failed to process operation queue`, error instanceof Error ? error : new Error(String(error)), { prefix: 'UtilsController' });
668
+ }
660
669
  }
661
670
 
662
671
  async generateSchemas() {