appwrite-utils-cli 1.4.1 → 1.5.1

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 (42) hide show
  1. package/README.md +22 -1
  2. package/dist/adapters/TablesDBAdapter.js +7 -4
  3. package/dist/collections/attributes.d.ts +1 -1
  4. package/dist/collections/attributes.js +42 -7
  5. package/dist/collections/indexes.js +13 -3
  6. package/dist/collections/methods.d.ts +9 -0
  7. package/dist/collections/methods.js +268 -0
  8. package/dist/databases/setup.js +6 -2
  9. package/dist/interactiveCLI.js +2 -1
  10. package/dist/migrations/appwriteToX.d.ts +2 -2
  11. package/dist/migrations/comprehensiveTransfer.js +12 -0
  12. package/dist/migrations/dataLoader.d.ts +5 -5
  13. package/dist/migrations/relationships.d.ts +2 -2
  14. package/dist/shared/jsonSchemaGenerator.d.ts +1 -0
  15. package/dist/shared/jsonSchemaGenerator.js +6 -2
  16. package/dist/shared/operationQueue.js +14 -1
  17. package/dist/shared/schemaGenerator.d.ts +2 -1
  18. package/dist/shared/schemaGenerator.js +61 -78
  19. package/dist/storage/schemas.d.ts +8 -8
  20. package/dist/utils/loadConfigs.js +44 -19
  21. package/dist/utils/schemaStrings.d.ts +2 -1
  22. package/dist/utils/schemaStrings.js +61 -78
  23. package/dist/utils/setupFiles.js +19 -1
  24. package/dist/utils/versionDetection.d.ts +6 -0
  25. package/dist/utils/versionDetection.js +30 -0
  26. package/dist/utilsController.js +32 -5
  27. package/package.json +1 -1
  28. package/src/adapters/TablesDBAdapter.ts +20 -17
  29. package/src/collections/attributes.ts +198 -156
  30. package/src/collections/indexes.ts +36 -28
  31. package/src/collections/methods.ts +292 -19
  32. package/src/databases/setup.ts +11 -7
  33. package/src/interactiveCLI.ts +8 -7
  34. package/src/migrations/comprehensiveTransfer.ts +22 -8
  35. package/src/shared/jsonSchemaGenerator.ts +36 -29
  36. package/src/shared/operationQueue.ts +48 -33
  37. package/src/shared/schemaGenerator.ts +128 -134
  38. package/src/utils/loadConfigs.ts +48 -29
  39. package/src/utils/schemaStrings.ts +124 -130
  40. package/src/utils/setupFiles.ts +21 -5
  41. package/src/utils/versionDetection.ts +48 -21
  42. package/src/utilsController.ts +59 -32
package/README.md CHANGED
@@ -325,7 +325,28 @@ Available specifications:
325
325
 
326
326
  This updated CLI ensures that developers have robust tools at their fingertips to manage complex Appwrite projects effectively from the command line, with both interactive and non-interactive modes available for flexibility.
327
327
 
328
- ## Changelog
328
+ ## Changelog
329
+
330
+ ### 1.5.0 - Recursive Zod v4 Schemas + One‑Way Relationship Support
331
+
332
+ Highlights
333
+ - Recursive getters: Generated schemas now use Zod v4 recursive getters (no `z.lazy`, no `BaseSchema` layering). Cleaner types and fully inferred mutual recursion.
334
+ - One‑way relationships: Relationship attributes are now included even when `twoWay: false`. Related imports resolve collection IDs to names.
335
+ - Required semantics: Relationship getters respect `required` and `array`:
336
+ - required scalar → `RelatedSchema`
337
+ - optional scalar → `RelatedSchema.nullish()`
338
+ - required array → `RelatedSchema.array()`
339
+ - optional array → `RelatedSchema.array().nullish()`
340
+ - JSON Schemas: `$ref` definitions use resolved collection names when YAML provides IDs.
341
+
342
+ Validation changes
343
+ - Relationship schema: `twoWayKey` and `side` are now required only when `twoWay` is `true`.
344
+ - Helpful errors: Keeps strong validation but removes false negatives for one‑way relationships.
345
+
346
+ Developer notes
347
+ - Imports: Schema generators import only `...Schema` from related collections (no type imports needed).
348
+ - Example YAML: `Posts.yaml` demonstrates a required `manyToMany` (`categories`) and a one‑way `manyToOne` (`author`).
349
+
329
350
 
330
351
  ### 1.3.0 - Zod v4 Upgrade & Collection Management Fixes
331
352
 
@@ -168,8 +168,9 @@ export class TablesDBAdapter extends BaseAdapter {
168
168
  // Attribute Operations
169
169
  async createAttribute(params) {
170
170
  try {
171
- // TablesDB may have different method names for attribute operations
172
- const result = await this.tablesDB.createAttribute(params);
171
+ // Prefer createColumn if available, fallback to createAttribute
172
+ const fn = this.tablesDB.createColumn || this.tablesDB.createAttribute;
173
+ const result = await fn.call(this.tablesDB, params);
173
174
  return { data: result };
174
175
  }
175
176
  catch (error) {
@@ -178,7 +179,8 @@ export class TablesDBAdapter extends BaseAdapter {
178
179
  }
179
180
  async updateAttribute(params) {
180
181
  try {
181
- const result = await this.tablesDB.updateAttribute(params);
182
+ const fn = this.tablesDB.updateColumn || this.tablesDB.updateAttribute;
183
+ const result = await fn.call(this.tablesDB, params);
182
184
  return { data: result };
183
185
  }
184
186
  catch (error) {
@@ -187,7 +189,8 @@ export class TablesDBAdapter extends BaseAdapter {
187
189
  }
188
190
  async deleteAttribute(params) {
189
191
  try {
190
- const result = await this.tablesDB.deleteAttribute(params);
192
+ const fn = this.tablesDB.deleteColumn || this.tablesDB.deleteAttribute;
193
+ const result = await fn.call(this.tablesDB, params);
191
194
  return { data: result };
192
195
  }
193
196
  catch (error) {
@@ -4,7 +4,7 @@ import { type Attribute } from "appwrite-utils";
4
4
  * Enhanced attribute creation with proper status monitoring and retry logic
5
5
  */
6
6
  export declare const createOrUpdateAttributeWithStatusCheck: (db: Databases, dbId: string, collection: Models.Collection, attribute: Attribute, retryCount?: number, maxRetries?: number) => Promise<boolean>;
7
- export declare const createOrUpdateAttribute: (db: Databases, dbId: string, collection: Models.Collection, attribute: Attribute) => Promise<void>;
7
+ export declare const createOrUpdateAttribute: (db: Databases, dbId: string, collection: Models.Collection, attribute: Attribute) => Promise<"queued" | "processed">;
8
8
  /**
9
9
  * Enhanced collection attribute creation with proper status monitoring
10
10
  */
@@ -131,6 +131,15 @@ const attributesSame = (databaseAttribute, configAttribute) => {
131
131
  (configValue === undefined || configValue === null)) {
132
132
  return true;
133
133
  }
134
+ // Normalize booleans: treat undefined and false as equivalent
135
+ if (typeof dbValue === "boolean" || typeof configValue === "boolean") {
136
+ return Boolean(dbValue) === Boolean(configValue);
137
+ }
138
+ // For numeric comparisons, compare numbers if both are numeric-like
139
+ if ((typeof dbValue === "number" || (typeof dbValue === "string" && dbValue !== "" && !isNaN(Number(dbValue)))) &&
140
+ (typeof configValue === "number" || (typeof configValue === "string" && configValue !== "" && !isNaN(Number(configValue))))) {
141
+ return Number(dbValue) === Number(configValue);
142
+ }
134
143
  return dbValue === configValue;
135
144
  }
136
145
  // If neither has the attribute, consider it the same
@@ -140,10 +149,18 @@ const attributesSame = (databaseAttribute, configAttribute) => {
140
149
  // If one has the attribute and the other doesn't, check if it's undefined or null
141
150
  if (dbHasAttr && !configHasAttr) {
142
151
  const dbValue = databaseAttribute[attr];
152
+ // Consider default-false booleans as equal to missing in config
153
+ if (typeof dbValue === "boolean") {
154
+ return dbValue === false; // missing in config equals false in db
155
+ }
143
156
  return dbValue === undefined || dbValue === null;
144
157
  }
145
158
  if (!dbHasAttr && configHasAttr) {
146
159
  const configValue = configAttribute[attr];
160
+ // Consider default-false booleans as equal to missing in db
161
+ if (typeof configValue === "boolean") {
162
+ return configValue === false; // missing in db equals false in config
163
+ }
147
164
  return configValue === undefined || configValue === null;
148
165
  }
149
166
  // If we reach here, the attributes are different
@@ -157,7 +174,13 @@ export const createOrUpdateAttributeWithStatusCheck = async (db, dbId, collectio
157
174
  console.log(chalk.blue(`Creating/updating attribute '${attribute.key}' (attempt ${retryCount + 1}/${maxRetries + 1})`));
158
175
  try {
159
176
  // First, try to create/update the attribute using existing logic
160
- await createOrUpdateAttribute(db, dbId, collection, attribute);
177
+ const result = await createOrUpdateAttribute(db, dbId, collection, attribute);
178
+ // If the attribute was queued (relationship dependency unresolved),
179
+ // skip status polling and retry logic — the queue will handle it later.
180
+ if (result === "queued") {
181
+ console.log(chalk.yellow(`⏭️ Deferred relationship attribute '${attribute.key}' — queued for later once dependencies are available`));
182
+ return true;
183
+ }
161
184
  // Now wait for the attribute to become available
162
185
  const success = await waitForAttributeAvailable(db, dbId, collection.$id, attribute.key, 60000, // 1 minute timeout
163
186
  retryCount, maxRetries);
@@ -229,7 +252,7 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
229
252
  attributesSame(foundAttribute, attribute) &&
230
253
  updateEnabled) {
231
254
  // No need to do anything, they are the same
232
- return;
255
+ return "processed";
233
256
  }
234
257
  else if (foundAttribute &&
235
258
  !attributesSame(foundAttribute, attribute) &&
@@ -248,7 +271,7 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
248
271
  !attributesSame(foundAttribute, attribute)) {
249
272
  await db.deleteAttribute(dbId, collection.$id, attribute.key);
250
273
  console.log(`Deleted attribute: ${attribute.key} to recreate it because they diff (update disabled temporarily)`);
251
- return;
274
+ return "processed";
252
275
  }
253
276
  // console.log(`${action}-ing attribute: ${finalAttribute.key}`);
254
277
  // Relationship attribute logic with adjustments
@@ -256,7 +279,18 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
256
279
  let relatedCollectionId;
257
280
  if (finalAttribute.type === "relationship" &&
258
281
  finalAttribute.relatedCollection) {
259
- if (nameToIdMapping.has(finalAttribute.relatedCollection)) {
282
+ // First try treating relatedCollection as an ID directly
283
+ try {
284
+ const byIdCollection = await db.getCollection(dbId, finalAttribute.relatedCollection);
285
+ collectionFoundViaRelatedCollection = byIdCollection;
286
+ relatedCollectionId = byIdCollection.$id;
287
+ // Cache by name for subsequent lookups
288
+ nameToIdMapping.set(byIdCollection.name, byIdCollection.$id);
289
+ }
290
+ catch (_) {
291
+ // Not an ID or not found — fall back to name-based resolution below
292
+ }
293
+ if (!collectionFoundViaRelatedCollection && nameToIdMapping.has(finalAttribute.relatedCollection)) {
260
294
  relatedCollectionId = nameToIdMapping.get(finalAttribute.relatedCollection);
261
295
  try {
262
296
  collectionFoundViaRelatedCollection = await db.getCollection(dbId, relatedCollectionId);
@@ -268,7 +302,7 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
268
302
  collectionFoundViaRelatedCollection = undefined;
269
303
  }
270
304
  }
271
- else {
305
+ else if (!collectionFoundViaRelatedCollection) {
272
306
  const collectionsPulled = await db.listCollections(dbId, [
273
307
  Query.equal("name", finalAttribute.relatedCollection),
274
308
  ]);
@@ -288,7 +322,7 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
288
322
  attribute,
289
323
  dependencies: [finalAttribute.relatedCollection],
290
324
  });
291
- return;
325
+ return "queued";
292
326
  }
293
327
  }
294
328
  finalAttribute = parseAttribute(finalAttribute);
@@ -338,7 +372,7 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
338
372
  }
339
373
  const minValue = finalAttribute.min !== undefined && finalAttribute.min !== null
340
374
  ? parseInt(finalAttribute.min)
341
- : 9007199254740991;
375
+ : -9007199254740991;
342
376
  const maxValue = finalAttribute.max !== undefined && finalAttribute.max !== null
343
377
  ? parseInt(finalAttribute.max)
344
378
  : 9007199254740991;
@@ -445,6 +479,7 @@ export const createOrUpdateAttribute = async (db, dbId, collection, attribute) =
445
479
  console.error("Invalid attribute type");
446
480
  break;
447
481
  }
482
+ return "processed";
448
483
  };
449
484
  /**
450
485
  * Enhanced collection attribute creation with proper status monitoring
@@ -20,13 +20,23 @@ retryCount = 0, maxRetries = 5) => {
20
20
  }
21
21
  while (Date.now() - startTime < maxWaitTime) {
22
22
  try {
23
- const indexList = await db.listIndexes(dbId, collectionId);
24
- const index = indexList.indexes.find((idx) => idx.key === indexKey);
23
+ const indexList = await (db instanceof Databases
24
+ ? db.listIndexes(dbId, collectionId)
25
+ : db.listIndexes({ databaseId: dbId, tableId: collectionId }));
26
+ const indexes = (db instanceof Databases)
27
+ ? indexList.indexes
28
+ : (indexList.data || indexList.indexes || []);
29
+ const index = indexes.find((idx) => idx.key === indexKey);
25
30
  if (!index) {
26
31
  console.log(chalk.red(`Index '${indexKey}' not found`));
27
32
  return false;
28
33
  }
29
- console.log(chalk.gray(`Index '${indexKey}' status: ${index.status}`));
34
+ if (db instanceof Databases) {
35
+ console.log(chalk.gray(`Index '${indexKey}' status: ${index.status}`));
36
+ }
37
+ else {
38
+ console.log(chalk.gray(`Index '${indexKey}' detected (TablesDB)`));
39
+ }
30
40
  switch (index.status) {
31
41
  case 'available':
32
42
  console.log(chalk.green(`✅ Index '${indexKey}' is now available`));
@@ -9,11 +9,20 @@ export declare const wipeDatabase: (database: Databases, databaseId: string) =>
9
9
  collectionName: string;
10
10
  }[]>;
11
11
  export declare const wipeCollection: (database: Databases, databaseId: string, collectionId: string) => Promise<void>;
12
+ export declare const wipeAllTables: (adapter: DatabaseAdapter, databaseId: string) => Promise<{
13
+ tableId: string;
14
+ tableName: string;
15
+ }[]>;
16
+ export declare const wipeTableRows: (adapter: DatabaseAdapter, databaseId: string, tableId: string) => Promise<void>;
12
17
  export declare const generateSchemas: (config: AppwriteConfig, appwriteFolderPath: string) => Promise<void>;
13
18
  export declare const createOrUpdateCollections: (database: Databases, databaseId: string, config: AppwriteConfig, deletedCollections?: {
14
19
  collectionId: string;
15
20
  collectionName: string;
16
21
  }[], selectedCollections?: Models.Collection[]) => Promise<void>;
22
+ export declare const createOrUpdateCollectionsViaAdapter: (adapter: DatabaseAdapter, databaseId: string, config: AppwriteConfig, deletedCollections?: {
23
+ collectionId: string;
24
+ collectionName: string;
25
+ }[], selectedCollections?: Models.Collection[]) => Promise<void>;
17
26
  export declare const generateMockData: (database: Databases, databaseId: string, configCollections: any[]) => Promise<void>;
18
27
  export declare const fetchAllCollections: (dbId: string, database: Databases) => Promise<Models.Collection[]>;
19
28
  /**
@@ -198,11 +198,89 @@ export const wipeCollection = async (database, databaseId, collectionId) => {
198
198
  const collection = collections.collections[0];
199
199
  await wipeDocumentsFromCollection(database, databaseId, collection.$id);
200
200
  };
201
+ // TablesDB helpers for wiping
202
+ export const wipeAllTables = async (adapter, databaseId) => {
203
+ MessageFormatter.info(`Wiping tables in database: ${databaseId}`, { prefix: 'Wipe' });
204
+ const res = await adapter.listTables({ databaseId, queries: [Query.limit(500)] });
205
+ const tables = res.tables || [];
206
+ const deleted = [];
207
+ const progress = ProgressManager.create(`wipe-db-${databaseId}`, tables.length, { title: 'Deleting tables' });
208
+ let processed = 0;
209
+ for (const t of tables) {
210
+ try {
211
+ await adapter.deleteTable({ databaseId, tableId: t.$id });
212
+ deleted.push({ tableId: t.$id, tableName: t.name });
213
+ }
214
+ catch (e) {
215
+ MessageFormatter.error(`Failed deleting table ${t.$id}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Wipe' });
216
+ }
217
+ processed++;
218
+ progress.update(processed);
219
+ await delay(100);
220
+ }
221
+ progress.stop();
222
+ return deleted;
223
+ };
224
+ export const wipeTableRows = async (adapter, databaseId, tableId) => {
225
+ try {
226
+ const initial = await adapter.listRows({ databaseId, tableId, queries: [Query.limit(1000)] });
227
+ let rows = initial.rows || [];
228
+ let total = rows.length;
229
+ let cursor = rows.length >= 1000 ? rows[rows.length - 1].$id : undefined;
230
+ while (cursor) {
231
+ const resp = await adapter.listRows({ databaseId, tableId, queries: [Query.limit(1000), ...(cursor ? [Query.cursorAfter(cursor)] : [])] });
232
+ const more = resp.rows || [];
233
+ rows.push(...more);
234
+ total = rows.length;
235
+ cursor = more.length >= 1000 ? more[more.length - 1].$id : undefined;
236
+ if (total % 10000 === 0) {
237
+ MessageFormatter.progress(`Found ${total} rows...`, { prefix: 'Wipe' });
238
+ }
239
+ }
240
+ MessageFormatter.info(`Found ${total} rows to delete`, { prefix: 'Wipe' });
241
+ if (total === 0)
242
+ return;
243
+ const progress = ProgressManager.create(`delete-${tableId}`, total, { title: 'Deleting rows' });
244
+ let processed = 0;
245
+ const maxStackSize = 50;
246
+ const batches = chunk(rows, maxStackSize);
247
+ for (const batch of batches) {
248
+ await Promise.all(batch.map(async (row) => {
249
+ try {
250
+ await adapter.deleteRow({ databaseId, tableId, id: row.$id });
251
+ }
252
+ catch (e) {
253
+ // ignore missing rows
254
+ }
255
+ processed++;
256
+ progress.update(processed);
257
+ }));
258
+ await delay(50);
259
+ }
260
+ progress.stop();
261
+ MessageFormatter.success(`Completed deletion of ${total} rows from table ${tableId}`, { prefix: 'Wipe' });
262
+ }
263
+ catch (error) {
264
+ MessageFormatter.error(`Error wiping rows from table ${tableId}`, error instanceof Error ? error : new Error(String(error)), { prefix: 'Wipe' });
265
+ throw error;
266
+ }
267
+ };
201
268
  export const generateSchemas = async (config, appwriteFolderPath) => {
202
269
  const schemaGenerator = new SchemaGenerator(config, appwriteFolderPath);
203
270
  schemaGenerator.generateSchemas();
204
271
  };
205
272
  export const createOrUpdateCollections = async (database, databaseId, config, deletedCollections, selectedCollections = []) => {
273
+ // If API mode is tablesdb, route to adapter-based implementation
274
+ try {
275
+ const { adapter, apiMode } = await getAdapterFromConfig(config);
276
+ if (apiMode === 'tablesdb') {
277
+ await createOrUpdateCollectionsViaAdapter(adapter, databaseId, config, deletedCollections, selectedCollections);
278
+ return;
279
+ }
280
+ }
281
+ catch {
282
+ // Fallback to legacy path below
283
+ }
206
284
  const collectionsToProcess = selectedCollections.length > 0 ? selectedCollections : config.collections;
207
285
  if (!collectionsToProcess) {
208
286
  return;
@@ -304,6 +382,196 @@ export const createOrUpdateCollections = async (database, databaseId, config, de
304
382
  MessageFormatter.info("No queued operations to process", { prefix: "Collections" });
305
383
  }
306
384
  };
385
+ // New: Adapter-based implementation for TablesDB
386
+ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, config, deletedCollections, selectedCollections = []) => {
387
+ const collectionsToProcess = selectedCollections.length > 0 ? selectedCollections : (config.collections || []);
388
+ if (!collectionsToProcess || collectionsToProcess.length === 0)
389
+ return;
390
+ const usedIds = new Set();
391
+ // Helper: create attributes through adapter
392
+ const createAttr = async (tableId, attr) => {
393
+ const base = {
394
+ databaseId,
395
+ tableId,
396
+ key: attr.key,
397
+ type: attr.type,
398
+ size: attr.size,
399
+ required: !!attr.required,
400
+ default: attr.xdefault,
401
+ array: !!attr.array,
402
+ min: attr.min,
403
+ max: attr.max,
404
+ elements: attr.elements,
405
+ encrypt: attr.encrypted,
406
+ relatedCollection: attr.relatedCollection,
407
+ relationType: attr.relationType,
408
+ twoWay: attr.twoWay,
409
+ twoWayKey: attr.twoWayKey,
410
+ onDelete: attr.onDelete,
411
+ side: attr.side,
412
+ };
413
+ await adapter.createAttribute(base);
414
+ await delay(150);
415
+ };
416
+ // Local queue for unresolved relationships
417
+ const relQueue = [];
418
+ for (const collection of collectionsToProcess) {
419
+ const { attributes, indexes, ...collectionData } = collection;
420
+ // Prepare permissions as strings (reuse Permission helper)
421
+ const permissions = [];
422
+ if (collection.$permissions && collection.$permissions.length > 0) {
423
+ for (const p of collection.$permissions) {
424
+ if (typeof p === 'string')
425
+ permissions.push(p);
426
+ else {
427
+ switch (p.permission) {
428
+ case 'read':
429
+ permissions.push(Permission.read(p.target));
430
+ break;
431
+ case 'create':
432
+ permissions.push(Permission.create(p.target));
433
+ break;
434
+ case 'update':
435
+ permissions.push(Permission.update(p.target));
436
+ break;
437
+ case 'delete':
438
+ permissions.push(Permission.delete(p.target));
439
+ break;
440
+ case 'write':
441
+ permissions.push(Permission.write(p.target));
442
+ break;
443
+ default: break;
444
+ }
445
+ }
446
+ }
447
+ }
448
+ // Find existing table by name
449
+ const list = await adapter.listTables({ databaseId, queries: [Query.equal('name', collectionData.name)] });
450
+ const items = list.tables || [];
451
+ let table = items[0];
452
+ let tableId;
453
+ if (!table) {
454
+ // Determine ID (prefer provided $id or re-use deleted one)
455
+ let foundColl = deletedCollections?.find((coll) => coll.collectionName.toLowerCase().trim().replace(" ", "") === collectionData.name.toLowerCase().trim().replace(" ", ""));
456
+ if (collectionData.$id)
457
+ tableId = collectionData.$id;
458
+ else if (foundColl && !usedIds.has(foundColl.collectionId))
459
+ tableId = foundColl.collectionId;
460
+ else
461
+ tableId = ID.unique();
462
+ usedIds.add(tableId);
463
+ const res = await adapter.createTable({
464
+ databaseId,
465
+ id: tableId,
466
+ name: collectionData.name,
467
+ permissions,
468
+ documentSecurity: !!collectionData.documentSecurity,
469
+ enabled: collectionData.enabled !== false
470
+ });
471
+ table = res.data || res;
472
+ nameToIdMapping.set(collectionData.name, tableId);
473
+ }
474
+ else {
475
+ tableId = table.$id;
476
+ await adapter.updateTable({
477
+ databaseId,
478
+ id: tableId,
479
+ name: collectionData.name,
480
+ permissions,
481
+ documentSecurity: !!collectionData.documentSecurity,
482
+ enabled: collectionData.enabled !== false
483
+ });
484
+ }
485
+ // Add small delay after table create/update
486
+ await delay(250);
487
+ // Create attributes: non-relationship first
488
+ const nonRel = (attributes || []).filter((a) => a.type !== 'relationship');
489
+ for (const attr of nonRel) {
490
+ await createAttr(tableId, attr);
491
+ }
492
+ // Relationship attributes — resolve relatedCollection to ID
493
+ const rels = (attributes || []).filter((a) => a.type === 'relationship');
494
+ for (const attr of rels) {
495
+ const relNameOrId = attr.relatedCollection;
496
+ if (!relNameOrId)
497
+ continue;
498
+ let relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
499
+ // If looks like a name (not ULID) and not in cache, try query by name
500
+ if (!nameToIdMapping.has(relNameOrId)) {
501
+ try {
502
+ const relList = await adapter.listTables({ databaseId, queries: [Query.equal('name', relNameOrId)] });
503
+ const relItems = relList.tables || [];
504
+ if (relItems[0]?.$id) {
505
+ relId = relItems[0].$id;
506
+ nameToIdMapping.set(relNameOrId, relId);
507
+ }
508
+ }
509
+ catch { }
510
+ }
511
+ if (relId && typeof relId === 'string') {
512
+ attr.relatedCollection = relId;
513
+ await createAttr(tableId, attr);
514
+ }
515
+ else {
516
+ // Defer if unresolved
517
+ relQueue.push({ tableId, attr: attr });
518
+ }
519
+ }
520
+ // Indexes
521
+ const idxs = (indexes || []);
522
+ for (const idx of idxs) {
523
+ try {
524
+ await adapter.createIndex({
525
+ databaseId,
526
+ tableId,
527
+ key: idx.key,
528
+ type: idx.type,
529
+ attributes: idx.attributes,
530
+ orders: idx.orders || []
531
+ });
532
+ await delay(150);
533
+ }
534
+ catch (e) {
535
+ MessageFormatter.error(`Failed to create index ${idx.key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
536
+ }
537
+ }
538
+ }
539
+ // Process queued relationships once mapping likely populated
540
+ for (const { tableId, attr } of relQueue) {
541
+ const relNameOrId = attr.relatedCollection;
542
+ if (!relNameOrId)
543
+ continue;
544
+ const relId = nameToIdMapping.get(relNameOrId) || relNameOrId;
545
+ if (relId) {
546
+ attr.relatedCollection = relId;
547
+ try {
548
+ await adapter.createAttribute({
549
+ databaseId,
550
+ tableId,
551
+ key: attr.key,
552
+ type: attr.type,
553
+ size: attr.size,
554
+ required: !!attr.required,
555
+ default: attr.xdefault,
556
+ array: !!attr.array,
557
+ min: attr.min,
558
+ max: attr.max,
559
+ elements: attr.elements,
560
+ relatedCollection: relId,
561
+ relationType: attr.relationType,
562
+ twoWay: attr.twoWay,
563
+ twoWayKey: attr.twoWayKey,
564
+ onDelete: attr.onDelete,
565
+ side: attr.side
566
+ });
567
+ await delay(150);
568
+ }
569
+ catch (e) {
570
+ MessageFormatter.error(`Failed queued relationship ${attr.key}`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Attributes' });
571
+ }
572
+ }
573
+ }
574
+ };
307
575
  export const generateMockData = async (database, databaseId, configCollections) => {
308
576
  for (const { collection, mockFunction } of configCollections) {
309
577
  if (mockFunction) {
@@ -72,7 +72,8 @@ export const ensureDatabasesExist = async (config, databasesToEnsure) => {
72
72
  throw new Error("Appwrite client is not initialized in the config");
73
73
  }
74
74
  const database = new Databases(config.appwriteClient);
75
- const databasesToCreate = databasesToEnsure || config.databases || [];
75
+ // Work on a shallow copy so we don't mutate caller-provided arrays
76
+ const databasesToCreate = [...(databasesToEnsure || config.databases || [])];
76
77
  if (!databasesToCreate.length) {
77
78
  console.log("No databases to create");
78
79
  return;
@@ -81,7 +82,10 @@ export const ensureDatabasesExist = async (config, databasesToEnsure) => {
81
82
  const migrationsDatabase = existingDatabases.databases.find((d) => d.name.toLowerCase().trim().replace(" ", "") === "migrations");
82
83
  if (config.useMigrations && existingDatabases.databases.length !== 0 && migrationsDatabase) {
83
84
  console.log("Creating all databases including migrations");
84
- databasesToCreate.push(migrationsDatabase);
85
+ // Ensure migrations exists, but do not mutate the caller's array
86
+ if (!databasesToCreate.some((d) => d.$id === migrationsDatabase.$id)) {
87
+ databasesToCreate.push(migrationsDatabase);
88
+ }
85
89
  }
86
90
  for (const db of databasesToCreate) {
87
91
  if (!existingDatabases.databases.some((d) => d.name === db.name)) {
@@ -867,7 +867,8 @@ export class InteractiveCLI {
867
867
  console.log(chalk.yellow("No databases selected. Skipping database sync."));
868
868
  return;
869
869
  }
870
- const collections = await this.selectCollections(databases[0], this.controller.database, chalk.blue("Select local collections to push:"), true, true // prefer local
870
+ const collections = await this.selectCollections(databases[0], this.controller.database, chalk.blue("Select local collections to push:"), true, true, // prefer local
871
+ true // filter by selected database
871
872
  );
872
873
  const { syncFunctions } = await inquirer.prompt([
873
874
  {
@@ -90,12 +90,12 @@ export declare class AppwriteToX {
90
90
  relatedCollection: string;
91
91
  relationType: "oneToMany" | "manyToOne" | "oneToOne" | "manyToMany";
92
92
  twoWay: boolean;
93
- twoWayKey: string;
94
93
  onDelete: "setNull" | "cascade" | "restrict";
95
- side: "parent" | "child";
96
94
  error?: string | undefined;
97
95
  required?: boolean | undefined;
98
96
  array?: boolean | undefined;
97
+ twoWayKey?: string | undefined;
98
+ side?: "parent" | "child" | undefined;
99
99
  importMapping?: {
100
100
  originalIdField: string;
101
101
  targetField?: string | undefined;
@@ -2,6 +2,7 @@ import { converterFunctions, tryAwaitWithRetry, parseAttribute, objectNeedsUpdat
2
2
  import { Client, Databases, Storage, Users, Functions, Teams, Query, AppwriteException, } from "node-appwrite";
3
3
  import { InputFile } from "node-appwrite/file";
4
4
  import { MessageFormatter } from "../shared/messageFormatter.js";
5
+ import { processQueue, queuedOperations } from "../shared/operationQueue.js";
5
6
  import { ProgressManager } from "../shared/progressManager.js";
6
7
  import { getClient } from "../utils/getClientFromConfig.js";
7
8
  import { transferDatabaseLocalToLocal, transferDatabaseLocalToRemote, transferStorageLocalToLocal, transferStorageLocalToRemote, transferUsersLocalToRemote, } from "./transfer.js";
@@ -343,6 +344,17 @@ export class ComprehensiveTransfer {
343
344
  MessageFormatter.error(`Error processing collection ${collection.name}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
344
345
  }
345
346
  }
347
+ // After processing all collections' attributes and indexes, process any queued
348
+ // relationship attributes so dependencies are resolved within this phase.
349
+ if (queuedOperations.length > 0) {
350
+ MessageFormatter.info(`Processing ${queuedOperations.length} queued relationship operations`, { prefix: "Transfer" });
351
+ await processQueue(this.targetDatabases, dbId);
352
+ }
353
+ else {
354
+ MessageFormatter.info("No queued relationship operations to process", {
355
+ prefix: "Transfer",
356
+ });
357
+ }
346
358
  }
347
359
  catch (error) {
348
360
  MessageFormatter.error(`Failed to create database structure for ${dbId}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
@@ -100,16 +100,16 @@ export declare const CollectionImportDataSchema: z.ZodObject<{
100
100
  manyToMany: "manyToMany";
101
101
  }>;
102
102
  twoWay: z.ZodBoolean;
103
- twoWayKey: z.ZodString;
103
+ twoWayKey: z.ZodOptional<z.ZodString>;
104
104
  onDelete: z.ZodDefault<z.ZodEnum<{
105
105
  setNull: "setNull";
106
106
  cascade: "cascade";
107
107
  restrict: "restrict";
108
108
  }>>;
109
- side: z.ZodEnum<{
109
+ side: z.ZodOptional<z.ZodEnum<{
110
110
  parent: "parent";
111
111
  child: "child";
112
- }>;
112
+ }>>;
113
113
  importMapping: z.ZodOptional<z.ZodObject<{
114
114
  originalIdField: z.ZodString;
115
115
  targetField: z.ZodOptional<z.ZodString>;
@@ -374,12 +374,12 @@ export declare class DataLoader {
374
374
  relatedCollection: string;
375
375
  relationType: "oneToMany" | "manyToOne" | "oneToOne" | "manyToMany";
376
376
  twoWay: boolean;
377
- twoWayKey: string;
378
377
  onDelete: "setNull" | "cascade" | "restrict";
379
- side: "parent" | "child";
380
378
  error?: string | undefined;
381
379
  required?: boolean | undefined;
382
380
  array?: boolean | undefined;
381
+ twoWayKey?: string | undefined;
382
+ side?: "parent" | "child" | undefined;
383
383
  importMapping?: {
384
384
  originalIdField: string;
385
385
  targetField?: string | undefined;
@@ -9,12 +9,12 @@ export declare const findCollectionsWithRelationships: (config: AppwriteConfig)
9
9
  relatedCollection: string;
10
10
  relationType: "oneToMany" | "manyToOne" | "oneToOne" | "manyToMany";
11
11
  twoWay: boolean;
12
- twoWayKey: string;
13
12
  onDelete: "setNull" | "cascade" | "restrict";
14
- side: "parent" | "child";
15
13
  error?: string | undefined;
16
14
  required?: boolean | undefined;
17
15
  array?: boolean | undefined;
16
+ twoWayKey?: string | undefined;
17
+ side?: "parent" | "child" | undefined;
18
18
  importMapping?: {
19
19
  originalIdField: string;
20
20
  targetField?: string | undefined;