appwrite-utils-cli 1.8.7 → 1.8.9

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.
package/CHANGELOG.md CHANGED
@@ -17,3 +17,19 @@ All notable changes to this project will be documented in this file.
17
17
  - Output directory selectable during interactive flow (absolute paths respected)
18
18
  - Constants generator now supports selecting which categories to include (databases, collections/tables, buckets, functions).
19
19
  - Bucket management: interactive create/delete commands with YAML persistence when active; selective push now diffs and updates bucket settings (name, permissions, security, limits).
20
+
21
+ ## 1.8.8 - 2025-10-29
22
+
23
+ - Fix: Selective push processed state is tracked per database. Previously, pushing the same table to multiple databases could skip the second database with “already processed”; now each DB is handled independently.
24
+ - Fix: Clear in-memory processing state between databases during multi-DB pushes to prevent cross-database leakage of IDs and processed flags.
25
+ - Enhancement: Interactive selection adds “Use same selection as before” when choosing tables per database, speeding up multi-DB workflows.
26
+ - Behavior: Non-interactive `--push` with both `--dbIds` and `--collectionIds` is fully non-interactive. The provided IDs are applied as-is to every selected database and the confirmation summary is skipped.
27
+ - Improvement: Selective push reloads local config from disk before pushing to ensure the latest YAML/TS changes are used.
28
+ - Fix: Sync-from-Appwrite now captures function `scopes` and writes them to config; deployments use `scopes` from either central config or `.fnconfig.yaml`.
29
+ - Change: Removed global prompt to choose between central `config.yaml` and per-function `.fnconfig.yaml` during function deployments. The tool now prompts per function only when both sources exist for that function (with an option to merge where `.fnconfig` overrides).
30
+
31
+ ## 1.8.9 - 2025-10-29
32
+
33
+ - Fix: Functions sync now fetches full details via `getFunction` per function to reliably capture `scopes` (prevents empty scopes in some environments).
34
+ - Confirmed schema: YAML `appwrite-config.schema.json` supports `functions[].dirPath`; writers preserve `dirPath` when present.
35
+ - Polish: Per-function config source selection is now applied at deploy time when both central and `.fnconfig.yaml` are present.
@@ -111,38 +111,14 @@ export const functionCommands = {
111
111
  MessageFormatter.error("Failed to initialize controller or load config", undefined, { prefix: "Functions" });
112
112
  return;
113
113
  }
114
- // Offer choice of function config sources: central YAML, .fnconfig.yaml, or both
115
- let sourceChoice = 'both';
114
+ // Discover per-function .fnconfig.yaml definitions and merge with central list for selection
115
+ // No global prompt; we'll handle conflicts per-function if both exist.
116
+ let discovered = [];
117
+ let central = cli.controller.config.functions || [];
116
118
  try {
117
- const answer = await inquirer.prompt([
118
- {
119
- type: 'list',
120
- name: 'source',
121
- message: 'Select function config source:',
122
- choices: [
123
- { name: 'config.yaml functions (central)', value: 'central' },
124
- { name: '.fnconfig.yaml (discovered per-function)', value: 'fnconfig' },
125
- { name: 'Both (merge; .fnconfig overrides)', value: 'both' },
126
- ],
127
- default: 'both'
128
- }
129
- ]);
130
- sourceChoice = answer.source;
131
- }
132
- catch { }
133
- try {
134
- const discovered = discoverFnConfigs(cli.currentDir);
135
- const central = cli.controller.config.functions || [];
136
- if (sourceChoice === 'central') {
137
- cli.controller.config.functions = central;
138
- }
139
- else if (sourceChoice === 'fnconfig') {
140
- cli.controller.config.functions = discovered;
141
- }
142
- else {
143
- const merged = mergeDiscoveredFunctions(central, discovered);
144
- cli.controller.config.functions = merged;
145
- }
119
+ discovered = discoverFnConfigs(cli.currentDir);
120
+ const merged = mergeDiscoveredFunctions(central, discovered);
121
+ cli.controller.config.functions = merged;
146
122
  }
147
123
  catch { }
148
124
  const functions = await cli.selectFunctions("Select function(s) to deploy:", true, true);
@@ -155,22 +131,51 @@ export const functionCommands = {
155
131
  MessageFormatter.error("Invalid function configuration", undefined, { prefix: "Functions" });
156
132
  return;
157
133
  }
134
+ // Resolve effective config for this function (prefer per-function choice if both sources exist)
135
+ const byIdOrName = (arr) => arr.find((f) => f?.$id === functionConfig.$id || f?.name === functionConfig.name);
136
+ const centralDef = byIdOrName(central);
137
+ const discoveredDef = byIdOrName(discovered);
138
+ let effectiveConfig = functionConfig;
139
+ if (centralDef && discoveredDef) {
140
+ try {
141
+ const answer = await inquirer.prompt([
142
+ {
143
+ type: 'list',
144
+ name: 'cfgChoice',
145
+ message: `Multiple configs found for '${functionConfig.name}'. Which to use?`,
146
+ choices: [
147
+ { name: 'config.yaml (central)', value: 'central' },
148
+ { name: '.fnconfig.yaml (local file)', value: 'fnconfig' },
149
+ { name: 'Merge (.fnconfig overrides central)', value: 'merge' },
150
+ ],
151
+ default: 'fnconfig'
152
+ }
153
+ ]);
154
+ if (answer.cfgChoice === 'central')
155
+ effectiveConfig = centralDef;
156
+ else if (answer.cfgChoice === 'fnconfig')
157
+ effectiveConfig = discoveredDef;
158
+ else
159
+ effectiveConfig = { ...centralDef, ...discoveredDef };
160
+ }
161
+ catch { }
162
+ }
158
163
  // Ensure functions array exists
159
164
  if (!cli.controller.config.functions) {
160
165
  cli.controller.config.functions = [];
161
166
  }
162
- const functionNameLower = functionConfig.name
167
+ const functionNameLower = effectiveConfig.name
163
168
  .toLowerCase()
164
169
  .replace(/\s+/g, "-");
165
170
  // Debug logging
166
171
  MessageFormatter.info(`🔍 Function deployment debug:`, { prefix: "Functions" });
167
- MessageFormatter.info(` Function name: ${functionConfig.name}`, { prefix: "Functions" });
168
- MessageFormatter.info(` Function ID: ${functionConfig.$id}`, { prefix: "Functions" });
169
- MessageFormatter.info(` Config dirPath: ${functionConfig.dirPath || 'undefined'}`, { prefix: "Functions" });
170
- if (functionConfig.dirPath) {
171
- const expandedPath = functionConfig.dirPath.startsWith('~/')
172
- ? functionConfig.dirPath.replace('~', os.homedir())
173
- : functionConfig.dirPath;
172
+ MessageFormatter.info(` Function name: ${effectiveConfig.name}`, { prefix: "Functions" });
173
+ MessageFormatter.info(` Function ID: ${effectiveConfig.$id}`, { prefix: "Functions" });
174
+ MessageFormatter.info(` Config dirPath: ${effectiveConfig.dirPath || 'undefined'}`, { prefix: "Functions" });
175
+ if (effectiveConfig.dirPath) {
176
+ const expandedPath = effectiveConfig.dirPath.startsWith('~/')
177
+ ? effectiveConfig.dirPath.replace('~', os.homedir())
178
+ : effectiveConfig.dirPath;
174
179
  MessageFormatter.info(` Expanded dirPath: ${expandedPath}`, { prefix: "Functions" });
175
180
  }
176
181
  MessageFormatter.info(` Appwrite folder: ${cli.controller.getAppwriteFolderPath()}`, { prefix: "Functions" });
@@ -182,10 +187,10 @@ export const functionCommands = {
182
187
  // Check locations in priority order:
183
188
  const priorityLocations = [
184
189
  // 1. Config dirPath if specified (with tilde expansion)
185
- functionConfig.dirPath
186
- ? (require('node:path').isAbsolute(expandTildePath(functionConfig.dirPath))
187
- ? expandTildePath(functionConfig.dirPath)
188
- : require('node:path').resolve(yamlBaseDir, expandTildePath(functionConfig.dirPath)))
190
+ effectiveConfig.dirPath
191
+ ? (require('node:path').isAbsolute(expandTildePath(effectiveConfig.dirPath))
192
+ ? expandTildePath(effectiveConfig.dirPath)
193
+ : require('node:path').resolve(yamlBaseDir, expandTildePath(effectiveConfig.dirPath)))
189
194
  : undefined,
190
195
  // 2. Appwrite config folder/functions/name
191
196
  join(cli.controller.getAppwriteFolderPath(), "functions", functionNameLower),
@@ -226,10 +231,10 @@ export const functionCommands = {
226
231
  if (shouldDownload) {
227
232
  try {
228
233
  MessageFormatter.progress("Downloading latest deployment...", { prefix: "Functions" });
229
- const { path: downloadedPath, function: remoteFunction } = await downloadLatestFunctionDeployment(cli.controller.appwriteServer, functionConfig.$id, join(cli.controller.getAppwriteFolderPath(), "functions"));
234
+ const { path: downloadedPath, function: remoteFunction } = await downloadLatestFunctionDeployment(cli.controller.appwriteServer, effectiveConfig.$id, join(cli.controller.getAppwriteFolderPath(), "functions"));
230
235
  MessageFormatter.success(`✨ Function downloaded to ${downloadedPath}`, { prefix: "Functions" });
231
236
  functionPath = downloadedPath;
232
- functionConfig.dirPath = downloadedPath;
237
+ effectiveConfig.dirPath = downloadedPath;
233
238
  const existingIndex = cli.controller.config.functions.findIndex((f) => f?.$id === remoteFunction.$id);
234
239
  if (existingIndex >= 0) {
235
240
  cli.controller.config.functions[existingIndex].dirPath =
@@ -243,7 +248,7 @@ export const functionCommands = {
243
248
  }
244
249
  }
245
250
  else {
246
- MessageFormatter.error(`Function ${functionConfig.name} not found locally. Cannot deploy.`, undefined, { prefix: "Functions" });
251
+ MessageFormatter.error(`Function ${effectiveConfig.name} not found locally. Cannot deploy.`, undefined, { prefix: "Functions" });
247
252
  return;
248
253
  }
249
254
  }
@@ -252,8 +257,8 @@ export const functionCommands = {
252
257
  return;
253
258
  }
254
259
  try {
255
- await deployLocalFunction(cli.controller.appwriteServer, functionConfig.name, {
256
- ...functionConfig,
260
+ await deployLocalFunction(cli.controller.appwriteServer, effectiveConfig.name, {
261
+ ...effectiveConfig,
257
262
  dirPath: functionPath,
258
263
  }, functionPath);
259
264
  MessageFormatter.success("Function deployed successfully!", { prefix: "Functions" });
@@ -1222,7 +1222,7 @@ export const createUpdateCollectionAttributesWithStatusCheck = async (db, dbId,
1222
1222
  // Filter to only attributes that need processing (new, changed, or not yet processed)
1223
1223
  const attributesToProcess = attributes.filter((attribute) => {
1224
1224
  // Skip if already processed in this session
1225
- if (isAttributeProcessed(currentCollection.$id, attribute.key)) {
1225
+ if (isAttributeProcessed(dbId, currentCollection.$id, attribute.key)) {
1226
1226
  return false;
1227
1227
  }
1228
1228
  const existing = existingAttributesMap.get(attribute.key);
@@ -1253,7 +1253,7 @@ export const createUpdateCollectionAttributesWithStatusCheck = async (db, dbId,
1253
1253
  const success = await createOrUpdateAttributeWithStatusCheck(db, dbId, currentCollection, attribute);
1254
1254
  if (success) {
1255
1255
  // Mark this specific attribute as processed
1256
- markAttributeProcessed(currentCollection.$id, attribute.key);
1256
+ markAttributeProcessed(dbId, currentCollection.$id, attribute.key);
1257
1257
  // Get updated collection data for next iteration
1258
1258
  try {
1259
1259
  currentCollection = isDatabaseAdapter(db)
@@ -149,8 +149,8 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
149
149
  const relQueue = [];
150
150
  for (const collection of collectionsToProcess) {
151
151
  const { attributes, indexes, ...collectionData } = collection;
152
- // Check if this table has already been processed in this session
153
- if (collectionData.$id && isCollectionProcessed(collectionData.$id)) {
152
+ // Check if this table has already been processed in this session (per database)
153
+ if (collectionData.$id && isCollectionProcessed(collectionData.$id, databaseId)) {
154
154
  MessageFormatter.info(`Table '${collectionData.name}' already processed, skipping`, { prefix: "Tables" });
155
155
  continue;
156
156
  }
@@ -602,8 +602,8 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
602
602
  catch (e) {
603
603
  MessageFormatter.warning(`Could not evaluate deletions: ${e?.message || e}`, { prefix: 'Attributes' });
604
604
  }
605
- // Mark this table as fully processed to prevent re-processing
606
- markCollectionProcessed(tableId, collectionData.name);
605
+ // Mark this table as fully processed for this database to prevent re-processing in the same DB only
606
+ markCollectionProcessed(tableId, collectionData.name, databaseId);
607
607
  }
608
608
  // Process queued relationships once mapping likely populated
609
609
  if (relQueue.length > 0) {
@@ -2,6 +2,7 @@ export declare class InteractiveCLI {
2
2
  private currentDir;
3
3
  private controller;
4
4
  private isUsingTypeScriptConfig;
5
+ private lastSelectedCollectionIds;
5
6
  constructor(currentDir: string);
6
7
  run(): Promise<void>;
7
8
  private initControllerIfNeeded;
@@ -52,6 +52,7 @@ export class InteractiveCLI {
52
52
  currentDir;
53
53
  controller;
54
54
  isUsingTypeScriptConfig = false;
55
+ lastSelectedCollectionIds = [];
55
56
  constructor(currentDir) {
56
57
  this.currentDir = currentDir;
57
58
  }
@@ -382,25 +383,41 @@ export class InteractiveCLI {
382
383
  }
383
384
  // Show current database context clearly before view mode selection
384
385
  MessageFormatter.info(`DB: ${database.name}`, { prefix: "Collections" });
385
- // Ask user if they want to filter by database or show all
386
+ // Ask user if they want to filter by database, show all, or reuse previous selection
387
+ const choices = [
388
+ {
389
+ name: `Show all available collections/tables (${totalCount} total) - You can push any collection to any database`,
390
+ value: "all"
391
+ },
392
+ {
393
+ name: `Filter by database "${database.name}" - Show only related collections/tables`,
394
+ value: "filter"
395
+ }
396
+ ];
397
+ if (this.lastSelectedCollectionIds && this.lastSelectedCollectionIds.length > 0) {
398
+ choices.unshift({
399
+ name: `Use same selection as before (${this.lastSelectedCollectionIds.length} items)`,
400
+ value: "same"
401
+ });
402
+ }
386
403
  const { filterChoice } = await inquirer.prompt([
387
404
  {
388
405
  type: "list",
389
406
  name: "filterChoice",
390
407
  message: chalk.blue("How would you like to view collections/tables?"),
391
- choices: [
392
- {
393
- name: `Show all available collections/tables (${totalCount} total) - You can push any collection to any database`,
394
- value: "all"
395
- },
396
- {
397
- name: `Filter by database "${database.name}" - Show only related collections/tables`,
398
- value: "filter"
399
- }
400
- ],
401
- default: "all"
408
+ choices,
409
+ default: choices[0]?.value || "all"
402
410
  }
403
411
  ]);
412
+ // If user wants to reuse the previous selection, map IDs to current config and return
413
+ if (filterChoice === "same") {
414
+ const map = new Map(this.getLocalCollections().map((c) => [c.$id || c.id, c]));
415
+ const selected = this.lastSelectedCollectionIds
416
+ .map((id) => map.get(id))
417
+ .filter((c) => !!c);
418
+ MessageFormatter.info(`Using same selection as previous: ${selected.length} item(s)`, { prefix: "Collections" });
419
+ return selected;
420
+ }
404
421
  // User's choice overrides the parameter
405
422
  const userWantsFiltering = filterChoice === "filter";
406
423
  // Show appropriate informational message
@@ -416,7 +433,10 @@ export class InteractiveCLI {
416
433
  else {
417
434
  MessageFormatter.info(`ℹ️ Showing all available collections/tables - you can push any collection to any database\n`, { prefix: "Collections" });
418
435
  }
419
- return this.selectCollections(database, databasesClient, message, multiSelect, preferLocal, userWantsFiltering);
436
+ const result = await this.selectCollections(database, databasesClient, message, multiSelect, preferLocal, userWantsFiltering);
437
+ // Remember this selection for subsequent databases
438
+ this.lastSelectedCollectionIds = (result || []).map((c) => c.$id || c.id);
439
+ return result;
420
440
  }
421
441
  getTemplateDefaults(template) {
422
442
  const defaults = {
package/dist/main.js CHANGED
@@ -964,6 +964,7 @@ async function main() {
964
964
  // Build DatabaseSelection[] with tableIds per DB
965
965
  const databaseSelections = [];
966
966
  const allConfigItems = controller.config.collections || controller.config.tables || [];
967
+ let lastSelectedTableIds = null;
967
968
  for (const dbId of selectedDbIds) {
968
969
  const db = availableDatabases.find(d => d.$id === dbId);
969
970
  if (!db)
@@ -983,13 +984,39 @@ async function main() {
983
984
  // Determine selected table IDs
984
985
  let selectedTableIds = [];
985
986
  if (parsedArgv.collectionIds) {
986
- const ids = parsedArgv.collectionIds.split(/[,\s]+/).filter(Boolean);
987
- // Only allow IDs that are in eligible config items
988
- const eligibleIds = new Set(eligibleConfigItems.map((c) => c.$id || c.id));
989
- selectedTableIds = ids.filter(id => eligibleIds.has(id));
987
+ // Non-interactive: respect provided table IDs as-is (apply to each selected DB)
988
+ selectedTableIds = parsedArgv.collectionIds.split(/[\,\s]+/).filter(Boolean);
990
989
  }
991
990
  else {
992
- selectedTableIds = await SelectionDialogs.selectTablesForDatabase(dbId, db.name, availableTables, eligibleConfigItems, { showSelectAll: false, allowNewOnly: true, defaultSelected: [] });
991
+ // If we have a previous selection, offer to reuse it
992
+ if (lastSelectedTableIds && lastSelectedTableIds.length > 0) {
993
+ const inquirer = (await import("inquirer")).default;
994
+ const { reuseMode } = await inquirer.prompt([
995
+ {
996
+ type: "list",
997
+ name: "reuseMode",
998
+ message: `How do you want to select tables for ${db.name}?`,
999
+ choices: [
1000
+ { name: `Use same selection as previous (${lastSelectedTableIds.length} items)`, value: "same" },
1001
+ { name: `Filter by this database (manual select)`, value: "filter" },
1002
+ { name: `Show all available in this database (manual select)`, value: "all" }
1003
+ ],
1004
+ default: "same"
1005
+ }
1006
+ ]);
1007
+ if (reuseMode === "same") {
1008
+ selectedTableIds = [...lastSelectedTableIds];
1009
+ }
1010
+ else if (reuseMode === "all") {
1011
+ selectedTableIds = await SelectionDialogs.selectTablesForDatabase(dbId, db.name, availableTables, allConfigItems, { showSelectAll: false, allowNewOnly: false, defaultSelected: lastSelectedTableIds });
1012
+ }
1013
+ else {
1014
+ selectedTableIds = await SelectionDialogs.selectTablesForDatabase(dbId, db.name, availableTables, eligibleConfigItems, { showSelectAll: false, allowNewOnly: true, defaultSelected: lastSelectedTableIds });
1015
+ }
1016
+ }
1017
+ else {
1018
+ selectedTableIds = await SelectionDialogs.selectTablesForDatabase(dbId, db.name, availableTables, eligibleConfigItems, { showSelectAll: false, allowNewOnly: true, defaultSelected: [] });
1019
+ }
993
1020
  }
994
1021
  databaseSelections.push({
995
1022
  databaseId: db.$id,
@@ -998,6 +1025,9 @@ async function main() {
998
1025
  tableNames: [],
999
1026
  isNew: false,
1000
1027
  });
1028
+ if (!parsedArgv.collectionIds) {
1029
+ lastSelectedTableIds = selectedTableIds;
1030
+ }
1001
1031
  }
1002
1032
  if (databaseSelections.every(sel => sel.tableIds.length === 0)) {
1003
1033
  MessageFormatter.warning("No tables/collections selected for push", { prefix: "Push" });
@@ -1008,10 +1038,13 @@ async function main() {
1008
1038
  collections: databaseSelections.reduce((sum, s) => sum + s.tableIds.length, 0),
1009
1039
  details: databaseSelections.map(s => `${s.databaseId}: ${s.tableIds.length} items`),
1010
1040
  };
1011
- const confirmed = await ConfirmationDialogs.showOperationSummary('Push', pushSummary, { confirmationRequired: true });
1012
- if (!confirmed) {
1013
- MessageFormatter.info("Push operation cancelled", { prefix: "Push" });
1014
- return;
1041
+ // Skip confirmation if both dbIds and collectionIds are provided (non-interactive)
1042
+ if (!(parsedArgv.dbIds && parsedArgv.collectionIds)) {
1043
+ const confirmed = await ConfirmationDialogs.showOperationSummary('Push', pushSummary, { confirmationRequired: true });
1044
+ if (!confirmed) {
1045
+ MessageFormatter.info("Push operation cancelled", { prefix: "Push" });
1046
+ return;
1047
+ }
1015
1048
  }
1016
1049
  await controller.selectivePush(databaseSelections, []);
1017
1050
  operationStats.pushedDatabases = databaseSelections.length;
@@ -7,7 +7,7 @@ import { CollectionSchema, attributeSchema, AppwriteConfigSchema, permissionsSch
7
7
  import { getDatabaseFromConfig } from "./afterImportActions.js";
8
8
  import { getAdapterFromConfig } from "../utils/getClientFromConfig.js";
9
9
  import { listBuckets } from "../storage/methods.js";
10
- import { listFunctions, listFunctionDeployments } from "../functions/methods.js";
10
+ import { listFunctions, listFunctionDeployments, getFunction } from "../functions/methods.js";
11
11
  import { MessageFormatter } from "../shared/messageFormatter.js";
12
12
  import { isLegacyDatabases } from "../utils/typeGuards.js";
13
13
  /**
@@ -420,7 +420,18 @@ export class AppwriteToX {
420
420
  const remoteFunctions = await listFunctions(this.config.appwriteClient, [
421
421
  Query.limit(1000),
422
422
  ]);
423
- this.updatedConfig.functions = remoteFunctions.functions.map((func) => ({
423
+ // Fetch full details per function to ensure 'scopes' and other fields are present
424
+ const detailedFunctions = [];
425
+ for (const f of remoteFunctions.functions) {
426
+ try {
427
+ const full = await getFunction(this.config.appwriteClient, f.$id);
428
+ detailedFunctions.push(full);
429
+ }
430
+ catch {
431
+ detailedFunctions.push(f);
432
+ }
433
+ }
434
+ this.updatedConfig.functions = detailedFunctions.map((func) => ({
424
435
  $id: func.$id,
425
436
  name: func.name,
426
437
  runtime: func.runtime,
@@ -432,6 +443,7 @@ export class AppwriteToX {
432
443
  logging: func.logging !== false,
433
444
  entrypoint: func.entrypoint || "src/index.ts",
434
445
  commands: func.commands || "npm install",
446
+ scopes: Array.isArray(func.scopes) ? func.scopes : [],
435
447
  dirPath: `functions/${func.name}`,
436
448
  specification: func.specification,
437
449
  }));
@@ -20,19 +20,19 @@ export declare const clearProcessingState: () => void;
20
20
  /**
21
21
  * Check if a collection has already been fully processed
22
22
  */
23
- export declare const isCollectionProcessed: (collectionId: string) => boolean;
23
+ export declare const isCollectionProcessed: (collectionId: string, databaseId: string) => boolean;
24
24
  /**
25
25
  * Mark a collection as fully processed
26
26
  */
27
- export declare const markCollectionProcessed: (collectionId: string, collectionName?: string) => void;
27
+ export declare const markCollectionProcessed: (collectionId: string, collectionName: string | undefined, databaseId: string) => void;
28
28
  /**
29
29
  * Check if a specific attribute has been processed
30
30
  */
31
- export declare const isAttributeProcessed: (collectionId: string, attributeKey: string) => boolean;
31
+ export declare const isAttributeProcessed: (databaseId: string, collectionId: string, attributeKey: string) => boolean;
32
32
  /**
33
33
  * Mark a specific attribute as processed
34
34
  */
35
- export declare const markAttributeProcessed: (collectionId: string, attributeKey: string) => void;
35
+ export declare const markAttributeProcessed: (databaseId: string, collectionId: string, attributeKey: string) => void;
36
36
  /**
37
37
  * Process only specific attributes in the queue, not entire collections
38
38
  * This prevents triggering full collection re-processing cycles
@@ -7,8 +7,14 @@ import { MessageFormatter } from "../shared/messageFormatter.js";
7
7
  // Global state management
8
8
  export const queuedOperations = [];
9
9
  export const nameToIdMapping = new Map();
10
+ // Keys are scoped per database to avoid cross-database collisions
11
+ // Collections key format: `${databaseId}::${collectionId}`
12
+ // Attributes key format: `${databaseId}::${collectionId}::${attributeKey}`
10
13
  export const processedCollections = new Set();
11
- export const processedAttributes = new Set(); // format: "collectionId:attributeKey"
14
+ export const processedAttributes = new Set();
15
+ // Helpers to build scoped keys
16
+ const collectionKey = (databaseId, collectionId) => `${databaseId}::${collectionId}`;
17
+ const attributeKeyScoped = (databaseId, collectionId, key) => `${databaseId}::${collectionId}::${key}`;
12
18
  export const enqueueOperation = (operation) => {
13
19
  // Avoid duplicate queue entries for same attribute
14
20
  const attributeKey = operation.attribute?.key;
@@ -64,38 +70,40 @@ export const clearProcessingState = () => {
64
70
  /**
65
71
  * Check if a collection has already been fully processed
66
72
  */
67
- export const isCollectionProcessed = (collectionId) => {
68
- return processedCollections.has(collectionId);
73
+ export const isCollectionProcessed = (collectionId, databaseId) => {
74
+ return processedCollections.has(collectionKey(databaseId, collectionId));
69
75
  };
70
76
  /**
71
77
  * Mark a collection as fully processed
72
78
  */
73
- export const markCollectionProcessed = (collectionId, collectionName) => {
74
- processedCollections.add(collectionId);
79
+ export const markCollectionProcessed = (collectionId, collectionName, databaseId) => {
80
+ processedCollections.add(collectionKey(databaseId, collectionId));
75
81
  const logData = {
82
+ databaseId,
76
83
  collectionId,
77
84
  collectionName,
78
85
  totalProcessedCollections: processedCollections.size,
79
86
  operation: 'markCollectionProcessed'
80
87
  };
81
88
  if (collectionName) {
82
- MessageFormatter.success(`Marked collection '${collectionName}' (${collectionId}) as processed`);
89
+ MessageFormatter.success(`Marked collection '${collectionName}' (${collectionId}) as processed`, { prefix: 'Tables' });
83
90
  }
84
91
  logger.info('Collection marked as processed', logData);
85
92
  };
86
93
  /**
87
94
  * Check if a specific attribute has been processed
88
95
  */
89
- export const isAttributeProcessed = (collectionId, attributeKey) => {
90
- return processedAttributes.has(`${collectionId}:${attributeKey}`);
96
+ export const isAttributeProcessed = (databaseId, collectionId, attributeKey) => {
97
+ return processedAttributes.has(attributeKeyScoped(databaseId, collectionId, attributeKey));
91
98
  };
92
99
  /**
93
100
  * Mark a specific attribute as processed
94
101
  */
95
- export const markAttributeProcessed = (collectionId, attributeKey) => {
96
- const identifier = `${collectionId}:${attributeKey}`;
102
+ export const markAttributeProcessed = (databaseId, collectionId, attributeKey) => {
103
+ const identifier = attributeKeyScoped(databaseId, collectionId, attributeKey);
97
104
  processedAttributes.add(identifier);
98
105
  logger.debug('Attribute marked as processed', {
106
+ databaseId,
99
107
  collectionId,
100
108
  attributeKey,
101
109
  identifier,
@@ -152,8 +160,8 @@ export const processQueue = async (db, dbId) => {
152
160
  }
153
161
  const attributeKey = operation.attribute.key;
154
162
  const collectionId = operation.collectionId;
155
- // Skip if this specific attribute was already processed
156
- if (isAttributeProcessed(collectionId, attributeKey)) {
163
+ // Skip if this specific attribute was already processed (per database)
164
+ if (isAttributeProcessed(dbId, collectionId, attributeKey)) {
157
165
  MessageFormatter.debug(`Attribute '${attributeKey}' already processed, removing from queue`);
158
166
  logger.debug('Removing already processed attribute from queue', {
159
167
  attributeKey,
@@ -268,7 +276,7 @@ export const processQueue = async (db, dbId) => {
268
276
  targetCollectionName: targetCollection.name,
269
277
  operation: 'processQueue'
270
278
  });
271
- markAttributeProcessed(collectionId, attributeKey);
279
+ markAttributeProcessed(dbId, collectionId, attributeKey);
272
280
  queuedOperations.splice(i, 1);
273
281
  progress = true;
274
282
  }
@@ -454,6 +454,13 @@ export class UtilsController {
454
454
  this.config.apiMode = this.adapter.getApiMode();
455
455
  logger.debug(`Updated config.apiMode from adapter: ${this.config.apiMode}`, { prefix: "UtilsController" });
456
456
  }
457
+ // Ensure we don't carry state between databases in a multi-db push
458
+ // This resets processed sets and name->id mapping per database
459
+ try {
460
+ const { clearProcessingState } = await import('./shared/operationQueue.js');
461
+ clearProcessingState();
462
+ }
463
+ catch { }
457
464
  // Always prefer adapter path for unified behavior. LegacyAdapter internally translates when needed.
458
465
  if (this.adapter) {
459
466
  logger.debug("Using adapter for createOrUpdateCollections (unified path)", {
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.8.7",
4
+ "version": "1.8.9",
5
5
  "main": "src/main.ts",
6
6
  "type": "module",
7
7
  "repository": {
@@ -138,36 +138,14 @@ export const functionCommands = {
138
138
  return;
139
139
  }
140
140
 
141
- // Offer choice of function config sources: central YAML, .fnconfig.yaml, or both
142
- let sourceChoice: 'central' | 'fnconfig' | 'both' = 'both';
141
+ // Discover per-function .fnconfig.yaml definitions and merge with central list for selection
142
+ // No global prompt; we'll handle conflicts per-function if both exist.
143
+ let discovered: any[] = [];
144
+ let central: any[] = (cli as any).controller!.config!.functions || [];
143
145
  try {
144
- const answer = await inquirer.prompt([
145
- {
146
- type: 'list',
147
- name: 'source',
148
- message: 'Select function config source:',
149
- choices: [
150
- { name: 'config.yaml functions (central)', value: 'central' },
151
- { name: '.fnconfig.yaml (discovered per-function)', value: 'fnconfig' },
152
- { name: 'Both (merge; .fnconfig overrides)', value: 'both' },
153
- ],
154
- default: 'both'
155
- }
156
- ]);
157
- sourceChoice = answer.source;
158
- } catch {}
159
-
160
- try {
161
- const discovered = discoverFnConfigs((cli as any).currentDir);
162
- const central = (cli as any).controller!.config!.functions || [];
163
- if (sourceChoice === 'central') {
164
- (cli as any).controller!.config!.functions = central as any;
165
- } else if (sourceChoice === 'fnconfig') {
166
- (cli as any).controller!.config!.functions = discovered as any;
167
- } else {
168
- const merged = mergeDiscoveredFunctions(central, discovered);
169
- (cli as any).controller!.config!.functions = merged as any;
170
- }
146
+ discovered = discoverFnConfigs((cli as any).currentDir) as any[];
147
+ const merged = mergeDiscoveredFunctions(central as any, discovered as any);
148
+ (cli as any).controller!.config!.functions = merged as any;
171
149
  } catch {}
172
150
 
173
151
  const functions = await (cli as any).selectFunctions(
@@ -181,32 +159,59 @@ export const functionCommands = {
181
159
  return;
182
160
  }
183
161
 
184
- for (const functionConfig of functions) {
162
+ for (const functionConfig of functions) {
185
163
  if (!functionConfig) {
186
164
  MessageFormatter.error("Invalid function configuration", undefined, { prefix: "Functions" });
187
165
  return;
188
166
  }
189
167
 
190
- // Ensure functions array exists
191
- if (!(cli as any).controller.config.functions) {
192
- (cli as any).controller.config.functions = [];
193
- }
168
+ // Resolve effective config for this function (prefer per-function choice if both sources exist)
169
+ const byIdOrName = (arr: any[]) => arr.find((f:any) => f?.$id === functionConfig.$id || f?.name === functionConfig.name);
170
+ const centralDef = byIdOrName(central as any[]);
171
+ const discoveredDef = byIdOrName(discovered as any[]);
172
+
173
+ let effectiveConfig = functionConfig;
174
+ if (centralDef && discoveredDef) {
175
+ try {
176
+ const answer = await inquirer.prompt([
177
+ {
178
+ type: 'list',
179
+ name: 'cfgChoice',
180
+ message: `Multiple configs found for '${functionConfig.name}'. Which to use?`,
181
+ choices: [
182
+ { name: 'config.yaml (central)', value: 'central' },
183
+ { name: '.fnconfig.yaml (local file)', value: 'fnconfig' },
184
+ { name: 'Merge (.fnconfig overrides central)', value: 'merge' },
185
+ ],
186
+ default: 'fnconfig'
187
+ }
188
+ ]);
189
+ if (answer.cfgChoice === 'central') effectiveConfig = centralDef;
190
+ else if (answer.cfgChoice === 'fnconfig') effectiveConfig = discoveredDef;
191
+ else effectiveConfig = { ...centralDef, ...discoveredDef };
192
+ } catch {}
193
+ }
194
+
195
+ // Ensure functions array exists
196
+ if (!(cli as any).controller.config.functions) {
197
+ (cli as any).controller.config.functions = [];
198
+ }
194
199
 
195
- const functionNameLower = functionConfig.name
196
- .toLowerCase()
197
- .replace(/\s+/g, "-");
200
+ const functionNameLower = effectiveConfig.name
201
+ .toLowerCase()
202
+ .replace(/\s+/g, "-");
198
203
 
199
204
  // Debug logging
200
205
  MessageFormatter.info(`🔍 Function deployment debug:`, { prefix: "Functions" });
201
- MessageFormatter.info(` Function name: ${functionConfig.name}`, { prefix: "Functions" });
202
- MessageFormatter.info(` Function ID: ${functionConfig.$id}`, { prefix: "Functions" });
203
- MessageFormatter.info(` Config dirPath: ${functionConfig.dirPath || 'undefined'}`, { prefix: "Functions" });
204
- if (functionConfig.dirPath) {
205
- const expandedPath = functionConfig.dirPath.startsWith('~/')
206
- ? functionConfig.dirPath.replace('~', os.homedir())
207
- : functionConfig.dirPath;
208
- MessageFormatter.info(` Expanded dirPath: ${expandedPath}`, { prefix: "Functions" });
209
- }
206
+ MessageFormatter.info(` Function name: ${effectiveConfig.name}`, { prefix: "Functions" });
207
+ MessageFormatter.info(` Function ID: ${effectiveConfig.$id}`, { prefix: "Functions" });
208
+ MessageFormatter.info(` Config dirPath: ${effectiveConfig.dirPath || 'undefined'}`, { prefix: "Functions" });
209
+ if (effectiveConfig.dirPath) {
210
+ const expandedPath = effectiveConfig.dirPath.startsWith('~/')
211
+ ? effectiveConfig.dirPath.replace('~', os.homedir())
212
+ : effectiveConfig.dirPath;
213
+ MessageFormatter.info(` Expanded dirPath: ${expandedPath}`, { prefix: "Functions" });
214
+ }
210
215
  MessageFormatter.info(` Appwrite folder: ${(cli as any).controller.getAppwriteFolderPath()}`, { prefix: "Functions" });
211
216
  MessageFormatter.info(` Current working dir: ${process.cwd()}`, { prefix: "Functions" });
212
217
 
@@ -218,10 +223,10 @@ export const functionCommands = {
218
223
  // Check locations in priority order:
219
224
  const priorityLocations = [
220
225
  // 1. Config dirPath if specified (with tilde expansion)
221
- functionConfig.dirPath
222
- ? (require('node:path').isAbsolute(expandTildePath(functionConfig.dirPath))
223
- ? expandTildePath(functionConfig.dirPath)
224
- : require('node:path').resolve(yamlBaseDir, expandTildePath(functionConfig.dirPath)))
226
+ effectiveConfig.dirPath
227
+ ? (require('node:path').isAbsolute(expandTildePath(effectiveConfig.dirPath))
228
+ ? expandTildePath(effectiveConfig.dirPath)
229
+ : require('node:path').resolve(yamlBaseDir, expandTildePath(effectiveConfig.dirPath)))
225
230
  : undefined,
226
231
  // 2. Appwrite config folder/functions/name
227
232
  join(
@@ -240,7 +245,7 @@ export const functionCommands = {
240
245
  MessageFormatter.info(` ${i + 1}. ${loc}`, { prefix: "Functions" });
241
246
  });
242
247
 
243
- let functionPath: string | null = null;
248
+ let functionPath: string | null = null;
244
249
 
245
250
  // Check each priority location
246
251
  for (const location of priorityLocations) {
@@ -280,21 +285,21 @@ export const functionCommands = {
280
285
  const { path: downloadedPath, function: remoteFunction } =
281
286
  await downloadLatestFunctionDeployment(
282
287
  (cli as any).controller.appwriteServer!,
283
- functionConfig.$id,
284
- join((cli as any).controller.getAppwriteFolderPath()!, "functions")
285
- );
288
+ effectiveConfig.$id,
289
+ join((cli as any).controller.getAppwriteFolderPath()!, "functions")
290
+ );
286
291
  MessageFormatter.success(`✨ Function downloaded to ${downloadedPath}`, { prefix: "Functions" });
287
292
 
288
293
  functionPath = downloadedPath;
289
- functionConfig.dirPath = downloadedPath;
294
+ effectiveConfig.dirPath = downloadedPath;
290
295
 
291
296
  const existingIndex = (cli as any).controller.config.functions.findIndex(
292
297
  (f: any) => f?.$id === remoteFunction.$id
293
298
  );
294
299
 
295
300
  if (existingIndex >= 0) {
296
- (cli as any).controller.config.functions[existingIndex].dirPath =
297
- downloadedPath;
301
+ (cli as any).controller.config.functions[existingIndex].dirPath =
302
+ downloadedPath;
298
303
  }
299
304
 
300
305
  await (cli as any).reloadConfigWithSessionPreservation();
@@ -303,9 +308,9 @@ export const functionCommands = {
303
308
  return;
304
309
  }
305
310
  } else {
306
- MessageFormatter.error(`Function ${functionConfig.name} not found locally. Cannot deploy.`, undefined, { prefix: "Functions" });
307
- return;
308
- }
311
+ MessageFormatter.error(`Function ${effectiveConfig.name} not found locally. Cannot deploy.`, undefined, { prefix: "Functions" });
312
+ return;
313
+ }
309
314
  }
310
315
 
311
316
  if (!(cli as any).controller.appwriteServer) {
@@ -316,13 +321,13 @@ export const functionCommands = {
316
321
  try {
317
322
  await deployLocalFunction(
318
323
  (cli as any).controller.appwriteServer,
319
- functionConfig.name,
320
- {
321
- ...functionConfig,
322
- dirPath: functionPath,
323
- },
324
- functionPath
325
- );
324
+ effectiveConfig.name,
325
+ {
326
+ ...effectiveConfig,
327
+ dirPath: functionPath,
328
+ },
329
+ functionPath
330
+ );
326
331
  MessageFormatter.success("Function deployed successfully!", { prefix: "Functions" });
327
332
  } catch (error) {
328
333
  MessageFormatter.error("Failed to deploy function", error instanceof Error ? error : new Error(String(error)), { prefix: "Functions" });
@@ -1835,7 +1835,7 @@ export const createUpdateCollectionAttributesWithStatusCheck = async (
1835
1835
  // Filter to only attributes that need processing (new, changed, or not yet processed)
1836
1836
  const attributesToProcess = attributes.filter((attribute) => {
1837
1837
  // Skip if already processed in this session
1838
- if (isAttributeProcessed(currentCollection.$id, attribute.key)) {
1838
+ if (isAttributeProcessed(dbId, currentCollection.$id, attribute.key)) {
1839
1839
  return false;
1840
1840
  }
1841
1841
 
@@ -1879,7 +1879,7 @@ export const createUpdateCollectionAttributesWithStatusCheck = async (
1879
1879
 
1880
1880
  if (success) {
1881
1881
  // Mark this specific attribute as processed
1882
- markAttributeProcessed(currentCollection.$id, attribute.key);
1882
+ markAttributeProcessed(dbId, currentCollection.$id, attribute.key);
1883
1883
 
1884
1884
  // Get updated collection data for next iteration
1885
1885
  try {
@@ -215,7 +215,7 @@ export const createOrUpdateCollectionsViaAdapter = async (
215
215
  ): Promise<void> => {
216
216
  const collectionsToProcess =
217
217
  selectedCollections.length > 0 ? selectedCollections : (config.collections || []);
218
- if (!collectionsToProcess || collectionsToProcess.length === 0) return;
218
+ if (!collectionsToProcess || collectionsToProcess.length === 0) return;
219
219
 
220
220
  const usedIds = new Set<string>();
221
221
  MessageFormatter.info(`Processing ${collectionsToProcess.length} tables via adapter with intelligent state management`, { prefix: "Tables" });
@@ -238,11 +238,11 @@ export const createOrUpdateCollectionsViaAdapter = async (
238
238
  for (const collection of collectionsToProcess) {
239
239
  const { attributes, indexes, ...collectionData } = collection as any;
240
240
 
241
- // Check if this table has already been processed in this session
242
- if (collectionData.$id && isCollectionProcessed(collectionData.$id)) {
243
- MessageFormatter.info(`Table '${collectionData.name}' already processed, skipping`, { prefix: "Tables" });
244
- continue;
245
- }
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
+ }
246
246
 
247
247
  // Prepare permissions as strings (reuse Permission helper)
248
248
  const permissions: string[] = [];
@@ -649,8 +649,8 @@ export const createOrUpdateCollectionsViaAdapter = async (
649
649
  MessageFormatter.warning(`Could not evaluate deletions: ${(e as Error)?.message || e}`, { prefix: 'Attributes' });
650
650
  }
651
651
 
652
- // Mark this table as fully processed to prevent re-processing
653
- markCollectionProcessed(tableId, collectionData.name);
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
654
  }
655
655
 
656
656
  // Process queued relationships once mapping likely populated
@@ -74,8 +74,9 @@ enum CHOICES {
74
74
  }
75
75
 
76
76
  export class InteractiveCLI {
77
- private controller: UtilsController | undefined;
78
- private isUsingTypeScriptConfig: boolean = false;
77
+ private controller: UtilsController | undefined;
78
+ private isUsingTypeScriptConfig: boolean = false;
79
+ private lastSelectedCollectionIds: string[] = [];
79
80
 
80
81
  constructor(private currentDir: string) {}
81
82
 
@@ -454,14 +455,14 @@ export class InteractiveCLI {
454
455
  /**
455
456
  * Enhanced collection/table selection with better guidance for mixed scenarios
456
457
  */
457
- private async selectCollectionsAndTables(
458
- database: Models.Database,
459
- databasesClient: Databases,
460
- message: string,
461
- multiSelect = true,
462
- preferLocal = false,
463
- shouldFilterByDatabase = false
464
- ): Promise<Models.Collection[]> {
458
+ private async selectCollectionsAndTables(
459
+ database: Models.Database,
460
+ databasesClient: Databases,
461
+ message: string,
462
+ multiSelect = true,
463
+ preferLocal = false,
464
+ shouldFilterByDatabase = false
465
+ ): Promise<Models.Collection[]> {
465
466
  const configCollections = this.getLocalCollections();
466
467
  const collectionsCount = configCollections.filter(c => !c._isFromTablesDir).length;
467
468
  const tablesCount = configCollections.filter(c => c._isFromTablesDir).length;
@@ -481,28 +482,48 @@ export class InteractiveCLI {
481
482
  // Show current database context clearly before view mode selection
482
483
  MessageFormatter.info(`DB: ${database.name}`, { prefix: "Collections" });
483
484
 
484
- // Ask user if they want to filter by database or show all
485
+ // Ask user if they want to filter by database, show all, or reuse previous selection
486
+ const choices: { name: string; value: string }[] = [
487
+ {
488
+ name: `Show all available collections/tables (${totalCount} total) - You can push any collection to any database`,
489
+ value: "all"
490
+ },
491
+ {
492
+ name: `Filter by database "${database.name}" - Show only related collections/tables`,
493
+ value: "filter"
494
+ }
495
+ ];
496
+ if (this.lastSelectedCollectionIds && this.lastSelectedCollectionIds.length > 0) {
497
+ choices.unshift({
498
+ name: `Use same selection as before (${this.lastSelectedCollectionIds.length} items)`,
499
+ value: "same"
500
+ });
501
+ }
502
+
485
503
  const { filterChoice } = await inquirer.prompt([
486
504
  {
487
505
  type: "list",
488
506
  name: "filterChoice",
489
507
  message: chalk.blue("How would you like to view collections/tables?"),
490
- choices: [
491
- {
492
- name: `Show all available collections/tables (${totalCount} total) - You can push any collection to any database`,
493
- value: "all"
494
- },
495
- {
496
- name: `Filter by database "${database.name}" - Show only related collections/tables`,
497
- value: "filter"
498
- }
499
- ],
500
- default: "all"
501
- }
502
- ]);
503
-
504
- // User's choice overrides the parameter
505
- const userWantsFiltering = filterChoice === "filter";
508
+ choices,
509
+ default: choices[0]?.value || "all"
510
+ }
511
+ ]);
512
+
513
+ // If user wants to reuse the previous selection, map IDs to current config and return
514
+ if (filterChoice === "same") {
515
+ const map = new Map<string, Models.Collection>(
516
+ this.getLocalCollections().map((c: any) => [c.$id || c.id, c as Models.Collection])
517
+ );
518
+ const selected = this.lastSelectedCollectionIds
519
+ .map((id) => map.get(id))
520
+ .filter((c): c is Models.Collection => !!c);
521
+ MessageFormatter.info(`Using same selection as previous: ${selected.length} item(s)`, { prefix: "Collections" });
522
+ return selected;
523
+ }
524
+
525
+ // User's choice overrides the parameter
526
+ const userWantsFiltering = filterChoice === "filter";
506
527
 
507
528
  // Show appropriate informational message
508
529
  if (userWantsFiltering) {
@@ -519,8 +540,18 @@ export class InteractiveCLI {
519
540
  MessageFormatter.info(`ℹ️ Showing all available collections/tables - you can push any collection to any database\n`, { prefix: "Collections" });
520
541
  }
521
542
 
522
- return this.selectCollections(database, databasesClient, message, multiSelect, preferLocal, userWantsFiltering);
523
- }
543
+ const result = await this.selectCollections(
544
+ database,
545
+ databasesClient,
546
+ message,
547
+ multiSelect,
548
+ preferLocal,
549
+ userWantsFiltering
550
+ );
551
+ // Remember this selection for subsequent databases
552
+ this.lastSelectedCollectionIds = (result || []).map((c: any) => c.$id || c.id);
553
+ return result;
554
+ }
524
555
 
525
556
  private getTemplateDefaults(template: string) {
526
557
  const defaults = {
package/src/main.ts CHANGED
@@ -1398,6 +1398,7 @@ async function main() {
1398
1398
  // Build DatabaseSelection[] with tableIds per DB
1399
1399
  const databaseSelections: DatabaseSelection[] = [];
1400
1400
  const allConfigItems = controller.config.collections || controller.config.tables || [];
1401
+ let lastSelectedTableIds: string[] | null = null;
1401
1402
 
1402
1403
  for (const dbId of selectedDbIds) {
1403
1404
  const db = availableDatabases.find(d => d.$id === dbId);
@@ -1418,18 +1419,54 @@ async function main() {
1418
1419
  // Determine selected table IDs
1419
1420
  let selectedTableIds: string[] = [];
1420
1421
  if (parsedArgv.collectionIds) {
1421
- const ids = parsedArgv.collectionIds.split(/[,\s]+/).filter(Boolean);
1422
- // Only allow IDs that are in eligible config items
1423
- const eligibleIds = new Set(eligibleConfigItems.map((c: any) => c.$id || c.id));
1424
- selectedTableIds = ids.filter(id => eligibleIds.has(id));
1422
+ // Non-interactive: respect provided table IDs as-is (apply to each selected DB)
1423
+ selectedTableIds = parsedArgv.collectionIds.split(/[\,\s]+/).filter(Boolean);
1425
1424
  } else {
1426
- selectedTableIds = await SelectionDialogs.selectTablesForDatabase(
1427
- dbId,
1428
- db.name,
1429
- availableTables,
1430
- eligibleConfigItems,
1431
- { showSelectAll: false, allowNewOnly: true, defaultSelected: [] }
1432
- );
1425
+ // If we have a previous selection, offer to reuse it
1426
+ if (lastSelectedTableIds && lastSelectedTableIds.length > 0) {
1427
+ const inquirer = (await import("inquirer")).default;
1428
+ const { reuseMode } = await inquirer.prompt([
1429
+ {
1430
+ type: "list",
1431
+ name: "reuseMode",
1432
+ message: `How do you want to select tables for ${db.name}?`,
1433
+ choices: [
1434
+ { name: `Use same selection as previous (${lastSelectedTableIds.length} items)`, value: "same" },
1435
+ { name: `Filter by this database (manual select)`, value: "filter" },
1436
+ { name: `Show all available in this database (manual select)`, value: "all" }
1437
+ ],
1438
+ default: "same"
1439
+ }
1440
+ ]);
1441
+
1442
+ if (reuseMode === "same") {
1443
+ selectedTableIds = [...lastSelectedTableIds];
1444
+ } else if (reuseMode === "all") {
1445
+ selectedTableIds = await SelectionDialogs.selectTablesForDatabase(
1446
+ dbId,
1447
+ db.name,
1448
+ availableTables,
1449
+ allConfigItems as any[],
1450
+ { showSelectAll: false, allowNewOnly: false, defaultSelected: lastSelectedTableIds }
1451
+ );
1452
+ } else {
1453
+ selectedTableIds = await SelectionDialogs.selectTablesForDatabase(
1454
+ dbId,
1455
+ db.name,
1456
+ availableTables,
1457
+ eligibleConfigItems,
1458
+ { showSelectAll: false, allowNewOnly: true, defaultSelected: lastSelectedTableIds }
1459
+ );
1460
+ }
1461
+ } else {
1462
+ selectedTableIds = await SelectionDialogs.selectTablesForDatabase(
1463
+ dbId,
1464
+ db.name,
1465
+ availableTables,
1466
+ eligibleConfigItems,
1467
+ { showSelectAll: false, allowNewOnly: true, defaultSelected: [] }
1468
+ );
1469
+ }
1433
1470
  }
1434
1471
 
1435
1472
  databaseSelections.push({
@@ -1439,6 +1476,9 @@ async function main() {
1439
1476
  tableNames: [],
1440
1477
  isNew: false,
1441
1478
  });
1479
+ if (!parsedArgv.collectionIds) {
1480
+ lastSelectedTableIds = selectedTableIds;
1481
+ }
1442
1482
  }
1443
1483
 
1444
1484
  if (databaseSelections.every(sel => sel.tableIds.length === 0)) {
@@ -1451,10 +1491,13 @@ async function main() {
1451
1491
  collections: databaseSelections.reduce((sum, s) => sum + s.tableIds.length, 0),
1452
1492
  details: databaseSelections.map(s => `${s.databaseId}: ${s.tableIds.length} items`),
1453
1493
  };
1454
- const confirmed = await ConfirmationDialogs.showOperationSummary('Push', pushSummary, { confirmationRequired: true });
1455
- if (!confirmed) {
1456
- MessageFormatter.info("Push operation cancelled", { prefix: "Push" });
1457
- return;
1494
+ // Skip confirmation if both dbIds and collectionIds are provided (non-interactive)
1495
+ if (!(parsedArgv.dbIds && parsedArgv.collectionIds)) {
1496
+ const confirmed = await ConfirmationDialogs.showOperationSummary('Push', pushSummary, { confirmationRequired: true });
1497
+ if (!confirmed) {
1498
+ MessageFormatter.info("Push operation cancelled", { prefix: "Push" });
1499
+ return;
1500
+ }
1458
1501
  }
1459
1502
 
1460
1503
  await controller.selectivePush(databaseSelections, []);
@@ -28,7 +28,7 @@ import {
28
28
  import { getDatabaseFromConfig } from "./afterImportActions.js";
29
29
  import { getAdapterFromConfig } from "../utils/getClientFromConfig.js";
30
30
  import { listBuckets } from "../storage/methods.js";
31
- import { listFunctions, listFunctionDeployments } from "../functions/methods.js";
31
+ import { listFunctions, listFunctionDeployments, getFunction } from "../functions/methods.js";
32
32
  import { MessageFormatter } from "../shared/messageFormatter.js";
33
33
  import { isLegacyDatabases } from "../utils/typeGuards.js";
34
34
  import type { DatabaseAdapter } from "../adapters/DatabaseAdapter.js";
@@ -570,27 +570,39 @@ export class AppwriteToX {
570
570
  antivirus: bucket.antivirus,
571
571
  }));
572
572
 
573
- const remoteFunctions = await listFunctions(this.config.appwriteClient!, [
574
- Query.limit(1000),
575
- ]);
576
-
577
- this.updatedConfig.functions = remoteFunctions.functions.map(
578
- (func) => ({
579
- $id: func.$id,
580
- name: func.name,
581
- runtime: func.runtime as Runtime,
582
- execute: func.execute,
583
- events: func.events || [],
584
- schedule: func.schedule || "",
585
- timeout: func.timeout || 15,
586
- enabled: func.enabled !== false,
587
- logging: func.logging !== false,
588
- entrypoint: func.entrypoint || "src/index.ts",
589
- commands: func.commands || "npm install",
590
- dirPath: `functions/${func.name}`,
591
- specification: func.specification as Specification,
592
- })
593
- );
573
+ const remoteFunctions = await listFunctions(this.config.appwriteClient!, [
574
+ Query.limit(1000),
575
+ ]);
576
+
577
+ // Fetch full details per function to ensure 'scopes' and other fields are present
578
+ const detailedFunctions: any[] = [];
579
+ for (const f of remoteFunctions.functions) {
580
+ try {
581
+ const full = await getFunction(this.config.appwriteClient!, f.$id);
582
+ detailedFunctions.push(full);
583
+ } catch {
584
+ detailedFunctions.push(f);
585
+ }
586
+ }
587
+
588
+ this.updatedConfig.functions = detailedFunctions.map(
589
+ (func: any) => ({
590
+ $id: func.$id,
591
+ name: func.name,
592
+ runtime: func.runtime as Runtime,
593
+ execute: func.execute,
594
+ events: func.events || [],
595
+ schedule: func.schedule || "",
596
+ timeout: func.timeout || 15,
597
+ enabled: func.enabled !== false,
598
+ logging: func.logging !== false,
599
+ entrypoint: func.entrypoint || "src/index.ts",
600
+ commands: func.commands || "npm install",
601
+ scopes: Array.isArray(func.scopes) ? func.scopes : [],
602
+ dirPath: `functions/${func.name}`,
603
+ specification: func.specification as Specification,
604
+ })
605
+ );
594
606
 
595
607
  // Make sure to update the config with all changes including databases
596
608
  updatedConfig.functions = this.updatedConfig.functions;
@@ -16,10 +16,17 @@ export interface QueuedOperation {
16
16
  }
17
17
 
18
18
  // Global state management
19
- export const queuedOperations: QueuedOperation[] = [];
20
- export const nameToIdMapping: Map<string, string> = new Map();
21
- export const processedCollections: Set<string> = new Set();
22
- export const processedAttributes: Set<string> = new Set(); // format: "collectionId:attributeKey"
19
+ export const queuedOperations: QueuedOperation[] = [];
20
+ export const nameToIdMapping: Map<string, string> = new Map();
21
+ // Keys are scoped per database to avoid cross-database collisions
22
+ // Collections key format: `${databaseId}::${collectionId}`
23
+ // Attributes key format: `${databaseId}::${collectionId}::${attributeKey}`
24
+ export const processedCollections: Set<string> = new Set();
25
+ export const processedAttributes: Set<string> = new Set();
26
+
27
+ // Helpers to build scoped keys
28
+ const collectionKey = (databaseId: string, collectionId: string) => `${databaseId}::${collectionId}`;
29
+ const attributeKeyScoped = (databaseId: string, collectionId: string, key: string) => `${databaseId}::${collectionId}::${key}`;
23
30
 
24
31
  export const enqueueOperation = (operation: QueuedOperation) => {
25
32
  // Avoid duplicate queue entries for same attribute
@@ -86,52 +93,54 @@ export const clearProcessingState = () => {
86
93
  /**
87
94
  * Check if a collection has already been fully processed
88
95
  */
89
- export const isCollectionProcessed = (collectionId: string): boolean => {
90
- return processedCollections.has(collectionId);
91
- };
96
+ export const isCollectionProcessed = (collectionId: string, databaseId: string): boolean => {
97
+ return processedCollections.has(collectionKey(databaseId, collectionId));
98
+ };
92
99
 
93
100
  /**
94
101
  * Mark a collection as fully processed
95
102
  */
96
- export const markCollectionProcessed = (collectionId: string, collectionName?: string) => {
97
- processedCollections.add(collectionId);
98
-
99
- const logData = {
100
- collectionId,
101
- collectionName,
102
- totalProcessedCollections: processedCollections.size,
103
- operation: 'markCollectionProcessed'
104
- };
105
-
106
- if (collectionName) {
107
- MessageFormatter.success(`Marked collection '${collectionName}' (${collectionId}) as processed`);
108
- }
109
-
110
- logger.info('Collection marked as processed', logData);
111
- };
103
+ export const markCollectionProcessed = (collectionId: string, collectionName: string | undefined, databaseId: string) => {
104
+ processedCollections.add(collectionKey(databaseId, collectionId));
105
+
106
+ const logData = {
107
+ databaseId,
108
+ collectionId,
109
+ collectionName,
110
+ totalProcessedCollections: processedCollections.size,
111
+ operation: 'markCollectionProcessed'
112
+ };
113
+
114
+ if (collectionName) {
115
+ MessageFormatter.success(`Marked collection '${collectionName}' (${collectionId}) as processed`, { prefix: 'Tables' });
116
+ }
117
+
118
+ logger.info('Collection marked as processed', logData);
119
+ };
112
120
 
113
121
  /**
114
122
  * Check if a specific attribute has been processed
115
123
  */
116
- export const isAttributeProcessed = (collectionId: string, attributeKey: string): boolean => {
117
- return processedAttributes.has(`${collectionId}:${attributeKey}`);
118
- };
124
+ export const isAttributeProcessed = (databaseId: string, collectionId: string, attributeKey: string): boolean => {
125
+ return processedAttributes.has(attributeKeyScoped(databaseId, collectionId, attributeKey));
126
+ };
119
127
 
120
128
  /**
121
129
  * Mark a specific attribute as processed
122
130
  */
123
- export const markAttributeProcessed = (collectionId: string, attributeKey: string) => {
124
- const identifier = `${collectionId}:${attributeKey}`;
125
- processedAttributes.add(identifier);
126
-
127
- logger.debug('Attribute marked as processed', {
128
- collectionId,
129
- attributeKey,
130
- identifier,
131
- totalProcessedAttributes: processedAttributes.size,
132
- operation: 'markAttributeProcessed'
133
- });
134
- };
131
+ export const markAttributeProcessed = (databaseId: string, collectionId: string, attributeKey: string) => {
132
+ const identifier = attributeKeyScoped(databaseId, collectionId, attributeKey);
133
+ processedAttributes.add(identifier);
134
+
135
+ logger.debug('Attribute marked as processed', {
136
+ databaseId,
137
+ collectionId,
138
+ attributeKey,
139
+ identifier,
140
+ totalProcessedAttributes: processedAttributes.size,
141
+ operation: 'markAttributeProcessed'
142
+ });
143
+ };
135
144
 
136
145
  /**
137
146
  * Process only specific attributes in the queue, not entire collections
@@ -193,8 +202,8 @@ export const processQueue = async (db: Databases | DatabaseAdapter, dbId: string
193
202
  const attributeKey = operation.attribute.key;
194
203
  const collectionId = operation.collectionId;
195
204
 
196
- // Skip if this specific attribute was already processed
197
- if (isAttributeProcessed(collectionId, attributeKey)) {
205
+ // Skip if this specific attribute was already processed (per database)
206
+ if (isAttributeProcessed(dbId, collectionId, attributeKey)) {
198
207
  MessageFormatter.debug(`Attribute '${attributeKey}' already processed, removing from queue`);
199
208
  logger.debug('Removing already processed attribute from queue', {
200
209
  attributeKey,
@@ -327,7 +336,7 @@ export const processQueue = async (db: Databases | DatabaseAdapter, dbId: string
327
336
  targetCollectionName: targetCollection.name,
328
337
  operation: 'processQueue'
329
338
  });
330
- markAttributeProcessed(collectionId, attributeKey);
339
+ markAttributeProcessed(dbId, collectionId, attributeKey);
331
340
  queuedOperations.splice(i, 1);
332
341
  progress = true;
333
342
  } else {
@@ -615,9 +615,9 @@ export class UtilsController {
615
615
  deletedCollections?: { collectionId: string; collectionName: string }[],
616
616
  collections: Models.Collection[] = []
617
617
  ) {
618
- await this.init();
619
- if (!this.database || !this.config)
620
- throw new Error("Database or config not initialized");
618
+ await this.init();
619
+ if (!this.database || !this.config)
620
+ throw new Error("Database or config not initialized");
621
621
 
622
622
  // Ensure apiMode is properly set from adapter
623
623
  if (this.adapter && (!this.config.apiMode || this.config.apiMode === 'auto')) {
@@ -625,6 +625,13 @@ export class UtilsController {
625
625
  logger.debug(`Updated config.apiMode from adapter: ${this.config.apiMode}`, { prefix: "UtilsController" });
626
626
  }
627
627
 
628
+ // Ensure we don't carry state between databases in a multi-db push
629
+ // This resets processed sets and name->id mapping per database
630
+ try {
631
+ const { clearProcessingState } = await import('./shared/operationQueue.js');
632
+ clearProcessingState();
633
+ } catch {}
634
+
628
635
  // Always prefer adapter path for unified behavior. LegacyAdapter internally translates when needed.
629
636
  if (this.adapter) {
630
637
  logger.debug("Using adapter for createOrUpdateCollections (unified path)", {