@njdamstra/appwrite-utils-cli 1.11.7 → 1.11.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.
@@ -7,7 +7,7 @@ import chalk from "chalk";
7
7
  import pLimit from "p-limit";
8
8
  import { MessageFormatter, tryAwaitWithRetry, } from "@njdamstra/appwrite-utils-helpers";
9
9
  import { ProgressManager } from "../shared/progressManager.js";
10
- import { MigrationPlanSchema, MigrationCheckpointSchema, suggestTargetType, generateBackupKey, } from "./migrateStringsTypes.js";
10
+ import { MigrationPlanSchema, MigrationCheckpointSchema, suggestTargetType, generateBackupKey, generateArchiveKey, } from "./migrateStringsTypes.js";
11
11
  // ────────────────────────────────────────────────────────
12
12
  // Phase 1: Analyze — queries Appwrite server for real state
13
13
  // ────────────────────────────────────────────────────────
@@ -201,8 +201,20 @@ export async function executeMigrationPlan(adapter, options) {
201
201
  });
202
202
  }
203
203
  const checkpoint = loadOrCreateCheckpoint(checkpointPath, options.planPath);
204
- const batchSize = options.batchSize || 100;
205
- const batchDelayMs = options.batchDelayMs || 50;
204
+ // Detect old-version checkpoints that used 2-copy flow
205
+ if (!checkpoint.version || checkpoint.version < 2) {
206
+ const hasProgress = checkpoint.entries.some((e) => e.phase !== "pending" &&
207
+ e.phase !== "completed" &&
208
+ e.phase !== "failed");
209
+ if (hasProgress) {
210
+ MessageFormatter.warning("Checkpoint from older migration version detected with in-progress entries. " +
211
+ "Use --fresh to restart with optimized rename-based flow.", { prefix: "Checkpoint" });
212
+ }
213
+ checkpoint.version = 2;
214
+ saveCheckpoint(checkpoint, checkpointPath);
215
+ }
216
+ const batchSize = options.batchSize || 500;
217
+ const batchDelayMs = options.batchDelayMs || 0;
206
218
  // Group by database/collection
207
219
  const groups = new Map();
208
220
  for (const entry of migrateEntries) {
@@ -250,6 +262,31 @@ export async function executeMigrationPlan(adapter, options) {
250
262
  skipped += entries.length;
251
263
  continue;
252
264
  }
265
+ // Temporarily set ALL required attributes in this collection to optional.
266
+ // Appwrite rejects partial updateRow calls if a required attribute is missing
267
+ // from the payload — even if the field exists in the document. This affects
268
+ // every data-copy step during migration.
269
+ const schemaRes = await tryAwaitWithRetry(() => adapter.getTable({ databaseId: first.databaseId, tableId: first.collectionId }));
270
+ const allAttrs = schemaRes?.data?.attributes || schemaRes?.data?.columns || [];
271
+ const originallyRequired = allAttrs
272
+ .filter((a) => a.required === true && a.status === "available")
273
+ .map((a) => a.key);
274
+ if (originallyRequired.length > 0) {
275
+ MessageFormatter.info(` Temporarily setting ${originallyRequired.length} required attribute(s) to optional...`, { prefix: "Execute" });
276
+ for (const key of originallyRequired) {
277
+ try {
278
+ await tryAwaitWithRetry(() => adapter.updateAttribute({
279
+ databaseId: first.databaseId,
280
+ tableId: first.collectionId,
281
+ key,
282
+ required: false,
283
+ }));
284
+ }
285
+ catch {
286
+ // Non-fatal — attribute might not support updating required
287
+ }
288
+ }
289
+ }
253
290
  // Migrate each attribute in this collection
254
291
  for (const entry of entries) {
255
292
  const cpEntry = getOrCreateCheckpointEntry(checkpoint, entry);
@@ -275,25 +312,23 @@ export async function executeMigrationPlan(adapter, options) {
275
312
  MessageFormatter.error(` ${entry.attributeKey}: FAILED — ${cpEntry.error}`, undefined, { prefix: "Execute" });
276
313
  }
277
314
  }
278
- // Restore required flags after all attributes in this collection are done.
279
- // This must happen AFTER all migrations to avoid partial-update validation
280
- // errors (Appwrite rejects updateRow if a required attribute is missing
281
- // from the payload, even for partial updates).
282
- const completedRequired = entries.filter((e) => {
283
- const cp = findCheckpointEntry(checkpoint, e);
284
- return cp?.phase === "completed" && e.isRequired;
285
- });
286
- for (const entry of completedRequired) {
287
- try {
288
- await tryAwaitWithRetry(() => adapter.updateAttribute({
289
- databaseId: entry.databaseId,
290
- tableId: entry.collectionId,
291
- key: entry.attributeKey,
292
- required: true,
293
- }));
294
- }
295
- catch {
296
- MessageFormatter.info(` Warning: could not set ${entry.attributeKey} back to required`, { prefix: "Execute" });
315
+ // Restore required flags for ALL originally-required attributes.
316
+ // This covers both migrated attributes (recreated as optional) and
317
+ // non-migrated attributes (temporarily set to optional above).
318
+ if (originallyRequired.length > 0) {
319
+ MessageFormatter.info(` Restoring ${originallyRequired.length} required attribute(s)...`, { prefix: "Execute" });
320
+ for (const key of originallyRequired) {
321
+ try {
322
+ await tryAwaitWithRetry(() => adapter.updateAttribute({
323
+ databaseId: first.databaseId,
324
+ tableId: first.collectionId,
325
+ key,
326
+ required: true,
327
+ }));
328
+ }
329
+ catch {
330
+ MessageFormatter.info(` Warning: could not restore required flag for ${key}`, { prefix: "Execute" });
331
+ }
297
332
  }
298
333
  }
299
334
  // After collection completes, offer to update local YAML
@@ -336,26 +371,27 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
336
371
  checkpoint.lastUpdatedAt = new Date().toISOString();
337
372
  saveCheckpoint(checkpoint, checkpointPath);
338
373
  };
339
- // Step 1: Create backup attribute
374
+ // Step 1: Create backup attribute as TARGET type directly
340
375
  if (phaseIndex(cpEntry.phase) < phaseIndex("backup_created")) {
341
- MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
342
- prefix: "Migrate",
343
- });
344
- await createAttributeIfNotExists(adapter, {
376
+ MessageFormatter.info(` Creating backup attribute ${backupKey} as ${targetType}...`, { prefix: "Migrate" });
377
+ const createParams = {
345
378
  databaseId,
346
379
  tableId: collectionId,
347
380
  key: backupKey,
348
- type: "string", // backup keeps original type
349
- size: entry.currentSize,
350
- required: false, // always optional for backup
381
+ type: targetType,
382
+ required: false,
351
383
  array: entry.isArray,
352
- });
384
+ };
385
+ if (targetType === "varchar" && targetSize) {
386
+ createParams.size = targetSize;
387
+ }
388
+ await createAttributeIfNotExists(adapter, createParams);
353
389
  const available = await waitForAttribute(adapter, databaseId, collectionId, backupKey);
354
390
  if (!available)
355
391
  throw new Error(`Backup attribute ${backupKey} stuck`);
356
392
  advance("backup_created");
357
393
  }
358
- // Step 2: Copy data to backup
394
+ // Step 2: Copy data to backup (only data copy needed)
359
395
  if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_to_backup")) {
360
396
  MessageFormatter.info(` Copying data to backup ${backupKey}...`, {
361
397
  prefix: "Migrate",
@@ -368,85 +404,64 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
368
404
  await verifyDataCopy(adapter, databaseId, collectionId, attributeKey, backupKey);
369
405
  advance("data_verified_backup");
370
406
  }
371
- // Step 4: Delete indexes + original attribute
372
- if (phaseIndex(cpEntry.phase) < phaseIndex("original_deleted")) {
373
- // Save and delete affected indexes
407
+ // Step 4: Clear original attribute name (delete or archive)
408
+ if (phaseIndex(cpEntry.phase) < phaseIndex("original_cleared")) {
409
+ // Delete indexes first (required before delete or rename)
374
410
  if (entry.indexesAffected.length > 0) {
375
- MessageFormatter.info(` Removing ${entry.indexesAffected.length} affected index(es)...`, {
376
- prefix: "Migrate",
377
- });
411
+ MessageFormatter.info(` Removing ${entry.indexesAffected.length} affected index(es)...`, { prefix: "Migrate" });
378
412
  await saveAndDeleteIndexes(adapter, databaseId, collectionId, entry.indexesAffected, cpEntry);
379
413
  saveCheckpoint(checkpoint, checkpointPath);
380
414
  }
381
- MessageFormatter.info(` Deleting original attribute ${attributeKey}...`, {
382
- prefix: "Migrate",
383
- });
384
- await tryAwaitWithRetry(() => adapter.deleteAttribute({
385
- databaseId,
386
- tableId: collectionId,
387
- key: attributeKey,
388
- }));
389
- await waitForAttributeGone(adapter, databaseId, collectionId, attributeKey);
390
- advance("original_deleted");
391
- }
392
- // Step 5: Create new attribute with target type
393
- if (phaseIndex(cpEntry.phase) < phaseIndex("new_attr_created")) {
394
- MessageFormatter.info(` Creating new attribute ${attributeKey} as ${targetType}...`, { prefix: "Migrate" });
395
- const createParams = {
396
- databaseId,
397
- tableId: collectionId,
398
- key: attributeKey,
399
- type: targetType,
400
- required: false, // create as optional first — data needs to be copied back
401
- array: entry.isArray,
402
- };
403
- if (targetType === "varchar" && targetSize) {
404
- createParams.size = targetSize;
415
+ if (opts.keepBackups) {
416
+ // ARCHIVE: rename original → og_X (preserves original data under new name)
417
+ const archiveKey = generateArchiveKey(attributeKey);
418
+ MessageFormatter.info(` Archiving original ${attributeKey} → ${archiveKey}...`, { prefix: "Migrate" });
419
+ await tryAwaitWithRetry(() => adapter.updateAttribute({
420
+ databaseId,
421
+ tableId: collectionId,
422
+ key: attributeKey,
423
+ newKey: archiveKey,
424
+ required: false,
425
+ }));
426
+ await waitForAttribute(adapter, databaseId, collectionId, archiveKey);
405
427
  }
406
- if (entry.hasDefault && entry.defaultValue !== undefined) {
407
- createParams.default = entry.defaultValue;
428
+ else {
429
+ // DELETE: remove original attribute entirely
430
+ MessageFormatter.info(` Deleting original attribute ${attributeKey}...`, { prefix: "Migrate" });
431
+ await tryAwaitWithRetry(() => adapter.deleteAttribute({
432
+ databaseId,
433
+ tableId: collectionId,
434
+ key: attributeKey,
435
+ }));
436
+ await waitForAttributeGone(adapter, databaseId, collectionId, attributeKey);
408
437
  }
409
- await createAttributeIfNotExists(adapter, createParams);
410
- const available = await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
411
- if (!available)
412
- throw new Error(`New attribute ${attributeKey} stuck after creation`);
413
- advance("new_attr_created");
414
- }
415
- // Step 6: Copy data back from backup
416
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_back")) {
417
- MessageFormatter.info(` Copying data back from backup...`, {
418
- prefix: "Migrate",
419
- });
420
- await copyAttributeData(adapter, databaseId, collectionId, backupKey, attributeKey, opts.batchSize, opts.batchDelayMs);
421
- advance("data_copied_back");
438
+ advance("original_cleared");
422
439
  }
423
- // Step 7: Verify final data
424
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_verified_final")) {
425
- await verifyDataCopy(adapter, databaseId, collectionId, backupKey, attributeKey);
426
- advance("data_verified_final");
440
+ // Step 5: Rename backup to original name
441
+ if (phaseIndex(cpEntry.phase) < phaseIndex("backup_renamed")) {
442
+ MessageFormatter.info(` Renaming ${backupKey} ${attributeKey}...`, { prefix: "Migrate" });
443
+ await tryAwaitWithRetry(() => adapter.updateAttribute({
444
+ databaseId,
445
+ tableId: collectionId,
446
+ key: backupKey,
447
+ newKey: attributeKey,
448
+ required: false,
449
+ ...(entry.hasDefault && entry.defaultValue !== undefined
450
+ ? { default: entry.defaultValue }
451
+ : {}),
452
+ }));
453
+ await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
454
+ advance("backup_renamed");
427
455
  }
428
- // Step 8: Recreate indexes + delete backup
429
- if (phaseIndex(cpEntry.phase) < phaseIndex("backup_deleted")) {
430
- // Recreate indexes
456
+ // Step 6: Recreate indexes
457
+ if (phaseIndex(cpEntry.phase) < phaseIndex("indexes_recreated")) {
431
458
  if (cpEntry.storedIndexes.length > 0) {
432
459
  MessageFormatter.info(` Recreating ${cpEntry.storedIndexes.length} index(es)...`, { prefix: "Migrate" });
433
460
  await recreateIndexes(adapter, databaseId, collectionId, cpEntry);
434
461
  }
435
- // Delete backup (unless keepBackups)
436
- if (!opts.keepBackups) {
437
- MessageFormatter.info(` Deleting backup attribute ${backupKey}...`, {
438
- prefix: "Migrate",
439
- });
440
- await tryAwaitWithRetry(() => adapter.deleteAttribute({
441
- databaseId,
442
- tableId: collectionId,
443
- key: backupKey,
444
- }));
445
- await waitForAttributeGone(adapter, databaseId, collectionId, backupKey);
446
- }
447
- advance("backup_deleted");
462
+ advance("indexes_recreated");
448
463
  }
449
- // Step 9: Mark completed
464
+ // Mark completed
450
465
  // NOTE: required flag is restored AFTER all attributes in the collection
451
466
  // are migrated, to avoid partial-update validation errors on other attributes.
452
467
  advance("completed");
@@ -470,7 +485,7 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
470
485
  title: ` Copy ${sourceKey} → ${targetKey}`,
471
486
  })
472
487
  : undefined;
473
- const limit = pLimit(5);
488
+ const limit = pLimit(25);
474
489
  while (true) {
475
490
  const queries = [Query.limit(batchSize)];
476
491
  if (lastId)
@@ -493,6 +508,15 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
493
508
  await Promise.all(updatePromises);
494
509
  totalCopied += docs.length;
495
510
  lastId = docs[docs.length - 1].$id;
511
+ // Appwrite caps result.total at 5000 — adjust progress bar if we exceed it
512
+ if (progress && totalDocs && totalCopied > totalDocs) {
513
+ // Estimate remaining: if we haven't hit the last page, assume at least one more batch
514
+ const estimatedTotal = docs.length < batchSize
515
+ ? totalCopied
516
+ : totalCopied + batchSize;
517
+ progress.setTotal(estimatedTotal);
518
+ totalDocs = estimatedTotal;
519
+ }
496
520
  progress?.update(totalCopied);
497
521
  if (docs.length < batchSize)
498
522
  break; // last page
@@ -645,6 +669,7 @@ function loadOrCreateCheckpoint(checkpointPath, planFile) {
645
669
  }
646
670
  const now = new Date().toISOString();
647
671
  return {
672
+ version: 2,
648
673
  planFile,
649
674
  startedAt: now,
650
675
  lastUpdatedAt: now,
@@ -685,11 +710,9 @@ const PHASE_ORDER = [
685
710
  "backup_created",
686
711
  "data_copied_to_backup",
687
712
  "data_verified_backup",
688
- "original_deleted",
689
- "new_attr_created",
690
- "data_copied_back",
691
- "data_verified_final",
692
- "backup_deleted",
713
+ "original_cleared",
714
+ "backup_renamed",
715
+ "indexes_recreated",
693
716
  "completed",
694
717
  ];
695
718
  function phaseIndex(phase) {
@@ -701,8 +724,9 @@ function phaseIndex(phase) {
701
724
  // ────────────────────────────────────────────────────────
702
725
  function printDryRunSummary(plan) {
703
726
  console.log("");
704
- console.log(chalk.bold("Dry Run — What Would Happen:"));
727
+ console.log(chalk.bold("Dry Run — What Would Happen (rename-based flow):"));
705
728
  console.log(chalk.gray("─".repeat(50)));
729
+ console.log(chalk.dim(" Flow: create mig_X (target type) → copy data → delete/archive original → rename mig_X → X"));
706
730
  const groups = new Map();
707
731
  for (const entry of plan.entries) {
708
732
  if (entry.action !== "migrate")
@@ -99,6 +99,9 @@ export declare const CheckpointPhase: z.ZodEnum<{
99
99
  backup_created: "backup_created";
100
100
  data_copied_to_backup: "data_copied_to_backup";
101
101
  data_verified_backup: "data_verified_backup";
102
+ original_cleared: "original_cleared";
103
+ backup_renamed: "backup_renamed";
104
+ indexes_recreated: "indexes_recreated";
102
105
  original_deleted: "original_deleted";
103
106
  new_attr_created: "new_attr_created";
104
107
  data_copied_back: "data_copied_back";
@@ -118,6 +121,9 @@ export declare const CheckpointEntrySchema: z.ZodObject<{
118
121
  backup_created: "backup_created";
119
122
  data_copied_to_backup: "data_copied_to_backup";
120
123
  data_verified_backup: "data_verified_backup";
124
+ original_cleared: "original_cleared";
125
+ backup_renamed: "backup_renamed";
126
+ indexes_recreated: "indexes_recreated";
121
127
  original_deleted: "original_deleted";
122
128
  new_attr_created: "new_attr_created";
123
129
  data_copied_back: "data_copied_back";
@@ -141,6 +147,7 @@ export declare const CheckpointEntrySchema: z.ZodObject<{
141
147
  }, z.core.$strip>;
142
148
  export type CheckpointEntry = z.infer<typeof CheckpointEntrySchema>;
143
149
  export declare const MigrationCheckpointSchema: z.ZodObject<{
150
+ version: z.ZodDefault<z.ZodNumber>;
144
151
  planFile: z.ZodString;
145
152
  startedAt: z.ZodString;
146
153
  lastUpdatedAt: z.ZodString;
@@ -156,6 +163,9 @@ export declare const MigrationCheckpointSchema: z.ZodObject<{
156
163
  backup_created: "backup_created";
157
164
  data_copied_to_backup: "data_copied_to_backup";
158
165
  data_verified_backup: "data_verified_backup";
166
+ original_cleared: "original_cleared";
167
+ backup_renamed: "backup_renamed";
168
+ indexes_recreated: "indexes_recreated";
159
169
  original_deleted: "original_deleted";
160
170
  new_attr_created: "new_attr_created";
161
171
  data_copied_back: "data_copied_back";
@@ -195,3 +205,4 @@ export interface ExecuteOptions {
195
205
  }
196
206
  export declare function suggestTargetType(size: number, hasIndex: boolean): MigrationTargetType;
197
207
  export declare function generateBackupKey(originalKey: string): string;
208
+ export declare function generateArchiveKey(originalKey: string): string;
@@ -49,13 +49,17 @@ export const CheckpointPhase = z.enum([
49
49
  "backup_created",
50
50
  "data_copied_to_backup",
51
51
  "data_verified_backup",
52
+ "original_cleared",
53
+ "backup_renamed",
54
+ "indexes_recreated",
55
+ "completed",
56
+ "failed",
57
+ // Legacy phases (kept for Zod validation of old v1 checkpoints)
52
58
  "original_deleted",
53
59
  "new_attr_created",
54
60
  "data_copied_back",
55
61
  "data_verified_final",
56
62
  "backup_deleted",
57
- "completed",
58
- "failed",
59
63
  ]);
60
64
  export const CheckpointEntrySchema = z.object({
61
65
  databaseId: z.string(),
@@ -77,6 +81,7 @@ export const CheckpointEntrySchema = z.object({
77
81
  .default([]),
78
82
  });
79
83
  export const MigrationCheckpointSchema = z.object({
84
+ version: z.number().default(1),
80
85
  planFile: z.string(),
81
86
  startedAt: z.string(),
82
87
  lastUpdatedAt: z.string(),
@@ -109,6 +114,17 @@ export function generateBackupKey(originalKey) {
109
114
  const maxOrigLen = MAX_KEY_LENGTH - TRUNC_PREFIX.length - 1 - 4;
110
115
  return `${TRUNC_PREFIX}${originalKey.slice(0, maxOrigLen)}_${hash}`;
111
116
  }
117
+ const ARCHIVE_PREFIX = "og_";
118
+ export function generateArchiveKey(originalKey) {
119
+ const candidate = `${ARCHIVE_PREFIX}${originalKey}`;
120
+ if (candidate.length <= MAX_KEY_LENGTH) {
121
+ return candidate;
122
+ }
123
+ const hash = simpleHash(originalKey);
124
+ const TRUNC_PREFIX = "o_";
125
+ const maxOrigLen = MAX_KEY_LENGTH - TRUNC_PREFIX.length - 1 - 4;
126
+ return `${TRUNC_PREFIX}${originalKey.slice(0, maxOrigLen)}_${hash}`;
127
+ }
112
128
  function simpleHash(str) {
113
129
  let h = 0;
114
130
  for (let i = 0; i < str.length; i++) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@njdamstra/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.11.7",
4
+ "version": "1.11.9",
5
5
  "main": "dist/main.js",
6
6
  "type": "module",
7
7
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "@types/json-schema": "^7.0.15",
41
41
  "@types/yargs": "^17.0.33",
42
42
  "@njdamstra/appwrite-utils": "^1.7.1",
43
- "@njdamstra/appwrite-utils-helpers": "^0.1.2",
43
+ "@njdamstra/appwrite-utils-helpers": "^0.1.3",
44
44
  "chalk": "^5.4.1",
45
45
  "cli-progress": "^3.12.0",
46
46
  "commander": "^12.1.0",
@@ -24,6 +24,7 @@ import {
24
24
  MigrationCheckpointSchema,
25
25
  suggestTargetType,
26
26
  generateBackupKey,
27
+ generateArchiveKey,
27
28
  } from "./migrateStringsTypes.js";
28
29
 
29
30
  // ────────────────────────────────────────────────────────
@@ -273,8 +274,27 @@ export async function executeMigrationPlan(
273
274
  }
274
275
  const checkpoint = loadOrCreateCheckpoint(checkpointPath, options.planPath);
275
276
 
276
- const batchSize = options.batchSize || 100;
277
- const batchDelayMs = options.batchDelayMs || 50;
277
+ // Detect old-version checkpoints that used 2-copy flow
278
+ if (!checkpoint.version || checkpoint.version < 2) {
279
+ const hasProgress = checkpoint.entries.some(
280
+ (e) =>
281
+ e.phase !== "pending" &&
282
+ e.phase !== "completed" &&
283
+ e.phase !== "failed"
284
+ );
285
+ if (hasProgress) {
286
+ MessageFormatter.warning(
287
+ "Checkpoint from older migration version detected with in-progress entries. " +
288
+ "Use --fresh to restart with optimized rename-based flow.",
289
+ { prefix: "Checkpoint" }
290
+ );
291
+ }
292
+ checkpoint.version = 2;
293
+ saveCheckpoint(checkpoint, checkpointPath);
294
+ }
295
+
296
+ const batchSize = options.batchSize || 500;
297
+ const batchDelayMs = options.batchDelayMs || 0;
278
298
 
279
299
  // Group by database/collection
280
300
  const groups = new Map<string, MigrationPlanEntry[]>();
@@ -338,6 +358,40 @@ export async function executeMigrationPlan(
338
358
  continue;
339
359
  }
340
360
 
361
+ // Temporarily set ALL required attributes in this collection to optional.
362
+ // Appwrite rejects partial updateRow calls if a required attribute is missing
363
+ // from the payload — even if the field exists in the document. This affects
364
+ // every data-copy step during migration.
365
+ const schemaRes = await tryAwaitWithRetry(() =>
366
+ adapter.getTable({ databaseId: first.databaseId, tableId: first.collectionId })
367
+ );
368
+ const allAttrs: any[] =
369
+ schemaRes?.data?.attributes || schemaRes?.data?.columns || [];
370
+ const originallyRequired = allAttrs
371
+ .filter((a: any) => a.required === true && a.status === "available")
372
+ .map((a: any) => a.key as string);
373
+
374
+ if (originallyRequired.length > 0) {
375
+ MessageFormatter.info(
376
+ ` Temporarily setting ${originallyRequired.length} required attribute(s) to optional...`,
377
+ { prefix: "Execute" }
378
+ );
379
+ for (const key of originallyRequired) {
380
+ try {
381
+ await tryAwaitWithRetry(() =>
382
+ adapter.updateAttribute({
383
+ databaseId: first.databaseId,
384
+ tableId: first.collectionId,
385
+ key,
386
+ required: false,
387
+ } as any)
388
+ );
389
+ } catch {
390
+ // Non-fatal — attribute might not support updating required
391
+ }
392
+ }
393
+ }
394
+
341
395
  // Migrate each attribute in this collection
342
396
  for (const entry of entries) {
343
397
  const cpEntry = getOrCreateCheckpointEntry(checkpoint, entry);
@@ -382,29 +436,30 @@ export async function executeMigrationPlan(
382
436
  }
383
437
  }
384
438
 
385
- // Restore required flags after all attributes in this collection are done.
386
- // This must happen AFTER all migrations to avoid partial-update validation
387
- // errors (Appwrite rejects updateRow if a required attribute is missing
388
- // from the payload, even for partial updates).
389
- const completedRequired = entries.filter((e) => {
390
- const cp = findCheckpointEntry(checkpoint, e);
391
- return cp?.phase === "completed" && e.isRequired;
392
- });
393
- for (const entry of completedRequired) {
394
- try {
395
- await tryAwaitWithRetry(() =>
396
- adapter.updateAttribute({
397
- databaseId: entry.databaseId,
398
- tableId: entry.collectionId,
399
- key: entry.attributeKey,
400
- required: true,
401
- } as any)
402
- );
403
- } catch {
404
- MessageFormatter.info(
405
- ` Warning: could not set ${entry.attributeKey} back to required`,
406
- { prefix: "Execute" }
407
- );
439
+ // Restore required flags for ALL originally-required attributes.
440
+ // This covers both migrated attributes (recreated as optional) and
441
+ // non-migrated attributes (temporarily set to optional above).
442
+ if (originallyRequired.length > 0) {
443
+ MessageFormatter.info(
444
+ ` Restoring ${originallyRequired.length} required attribute(s)...`,
445
+ { prefix: "Execute" }
446
+ );
447
+ for (const key of originallyRequired) {
448
+ try {
449
+ await tryAwaitWithRetry(() =>
450
+ adapter.updateAttribute({
451
+ databaseId: first.databaseId,
452
+ tableId: first.collectionId,
453
+ key,
454
+ required: true,
455
+ } as any)
456
+ );
457
+ } catch {
458
+ MessageFormatter.info(
459
+ ` Warning: could not restore required flag for ${key}`,
460
+ { prefix: "Execute" }
461
+ );
462
+ }
408
463
  }
409
464
  }
410
465
 
@@ -449,7 +504,7 @@ export async function executeMigrationPlan(
449
504
  }
450
505
 
451
506
  // ────────────────────────────────────────────────────────
452
- // Single attribute migration (9 phases)
507
+ // Single attribute migration (rename-based, 6 steps)
453
508
  // ────────────────────────────────────────────────────────
454
509
 
455
510
  interface MigrateOneOptions {
@@ -476,20 +531,24 @@ async function migrateOneAttribute(
476
531
  saveCheckpoint(checkpoint, checkpointPath);
477
532
  };
478
533
 
479
- // Step 1: Create backup attribute
534
+ // Step 1: Create backup attribute as TARGET type directly
480
535
  if (phaseIndex(cpEntry.phase) < phaseIndex("backup_created")) {
481
- MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
482
- prefix: "Migrate",
483
- });
484
- await createAttributeIfNotExists(adapter, {
536
+ MessageFormatter.info(
537
+ ` Creating backup attribute ${backupKey} as ${targetType}...`,
538
+ { prefix: "Migrate" }
539
+ );
540
+ const createParams: Record<string, any> = {
485
541
  databaseId,
486
542
  tableId: collectionId,
487
543
  key: backupKey,
488
- type: "string", // backup keeps original type
489
- size: entry.currentSize,
490
- required: false, // always optional for backup
544
+ type: targetType,
545
+ required: false,
491
546
  array: entry.isArray,
492
- });
547
+ };
548
+ if (targetType === "varchar" && targetSize) {
549
+ createParams.size = targetSize;
550
+ }
551
+ await createAttributeIfNotExists(adapter, createParams as any);
493
552
  const available = await waitForAttribute(
494
553
  adapter,
495
554
  databaseId,
@@ -500,7 +559,7 @@ async function migrateOneAttribute(
500
559
  advance("backup_created");
501
560
  }
502
561
 
503
- // Step 2: Copy data to backup
562
+ // Step 2: Copy data to backup (only data copy needed)
504
563
  if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_to_backup")) {
505
564
  MessageFormatter.info(` Copying data to backup ${backupKey}...`, {
506
565
  prefix: "Migrate",
@@ -529,13 +588,14 @@ async function migrateOneAttribute(
529
588
  advance("data_verified_backup");
530
589
  }
531
590
 
532
- // Step 4: Delete indexes + original attribute
533
- if (phaseIndex(cpEntry.phase) < phaseIndex("original_deleted")) {
534
- // Save and delete affected indexes
591
+ // Step 4: Clear original attribute name (delete or archive)
592
+ if (phaseIndex(cpEntry.phase) < phaseIndex("original_cleared")) {
593
+ // Delete indexes first (required before delete or rename)
535
594
  if (entry.indexesAffected.length > 0) {
536
- MessageFormatter.info(` Removing ${entry.indexesAffected.length} affected index(es)...`, {
537
- prefix: "Migrate",
538
- });
595
+ MessageFormatter.info(
596
+ ` Removing ${entry.indexesAffected.length} affected index(es)...`,
597
+ { prefix: "Migrate" }
598
+ );
539
599
  await saveAndDeleteIndexes(
540
600
  adapter,
541
601
  databaseId,
@@ -546,85 +606,70 @@ async function migrateOneAttribute(
546
606
  saveCheckpoint(checkpoint, checkpointPath);
547
607
  }
548
608
 
549
- MessageFormatter.info(` Deleting original attribute ${attributeKey}...`, {
550
- prefix: "Migrate",
551
- });
552
- await tryAwaitWithRetry(() =>
553
- adapter.deleteAttribute({
609
+ if (opts.keepBackups) {
610
+ // ARCHIVE: rename original → og_X (preserves original data under new name)
611
+ const archiveKey = generateArchiveKey(attributeKey);
612
+ MessageFormatter.info(
613
+ ` Archiving original ${attributeKey} → ${archiveKey}...`,
614
+ { prefix: "Migrate" }
615
+ );
616
+ await tryAwaitWithRetry(() =>
617
+ adapter.updateAttribute({
618
+ databaseId,
619
+ tableId: collectionId,
620
+ key: attributeKey,
621
+ newKey: archiveKey,
622
+ required: false,
623
+ })
624
+ );
625
+ await waitForAttribute(adapter, databaseId, collectionId, archiveKey);
626
+ } else {
627
+ // DELETE: remove original attribute entirely
628
+ MessageFormatter.info(
629
+ ` Deleting original attribute ${attributeKey}...`,
630
+ { prefix: "Migrate" }
631
+ );
632
+ await tryAwaitWithRetry(() =>
633
+ adapter.deleteAttribute({
634
+ databaseId,
635
+ tableId: collectionId,
636
+ key: attributeKey,
637
+ })
638
+ );
639
+ await waitForAttributeGone(
640
+ adapter,
554
641
  databaseId,
555
- tableId: collectionId,
556
- key: attributeKey,
557
- })
558
- );
559
- await waitForAttributeGone(adapter, databaseId, collectionId, attributeKey);
560
- advance("original_deleted");
642
+ collectionId,
643
+ attributeKey
644
+ );
645
+ }
646
+ advance("original_cleared");
561
647
  }
562
648
 
563
- // Step 5: Create new attribute with target type
564
- if (phaseIndex(cpEntry.phase) < phaseIndex("new_attr_created")) {
649
+ // Step 5: Rename backup to original name
650
+ if (phaseIndex(cpEntry.phase) < phaseIndex("backup_renamed")) {
565
651
  MessageFormatter.info(
566
- ` Creating new attribute ${attributeKey} as ${targetType}...`,
652
+ ` Renaming ${backupKey} ${attributeKey}...`,
567
653
  { prefix: "Migrate" }
568
654
  );
569
- const createParams: Record<string, any> = {
570
- databaseId,
571
- tableId: collectionId,
572
- key: attributeKey,
573
- type: targetType,
574
- required: false, // create as optional first — data needs to be copied back
575
- array: entry.isArray,
576
- };
577
- if (targetType === "varchar" && targetSize) {
578
- createParams.size = targetSize;
579
- }
580
- if (entry.hasDefault && entry.defaultValue !== undefined) {
581
- createParams.default = entry.defaultValue;
582
- }
583
-
584
- await createAttributeIfNotExists(adapter, createParams as any);
585
- const available = await waitForAttribute(
586
- adapter,
587
- databaseId,
588
- collectionId,
589
- attributeKey
590
- );
591
- if (!available)
592
- throw new Error(`New attribute ${attributeKey} stuck after creation`);
593
- advance("new_attr_created");
594
- }
595
-
596
- // Step 6: Copy data back from backup
597
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_back")) {
598
- MessageFormatter.info(` Copying data back from backup...`, {
599
- prefix: "Migrate",
600
- });
601
- await copyAttributeData(
602
- adapter,
603
- databaseId,
604
- collectionId,
605
- backupKey,
606
- attributeKey,
607
- opts.batchSize,
608
- opts.batchDelayMs
609
- );
610
- advance("data_copied_back");
611
- }
612
-
613
- // Step 7: Verify final data
614
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_verified_final")) {
615
- await verifyDataCopy(
616
- adapter,
617
- databaseId,
618
- collectionId,
619
- backupKey,
620
- attributeKey
655
+ await tryAwaitWithRetry(() =>
656
+ adapter.updateAttribute({
657
+ databaseId,
658
+ tableId: collectionId,
659
+ key: backupKey,
660
+ newKey: attributeKey,
661
+ required: false,
662
+ ...(entry.hasDefault && entry.defaultValue !== undefined
663
+ ? { default: entry.defaultValue }
664
+ : {}),
665
+ })
621
666
  );
622
- advance("data_verified_final");
667
+ await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
668
+ advance("backup_renamed");
623
669
  }
624
670
 
625
- // Step 8: Recreate indexes + delete backup
626
- if (phaseIndex(cpEntry.phase) < phaseIndex("backup_deleted")) {
627
- // Recreate indexes
671
+ // Step 6: Recreate indexes
672
+ if (phaseIndex(cpEntry.phase) < phaseIndex("indexes_recreated")) {
628
673
  if (cpEntry.storedIndexes.length > 0) {
629
674
  MessageFormatter.info(
630
675
  ` Recreating ${cpEntry.storedIndexes.length} index(es)...`,
@@ -632,30 +677,10 @@ async function migrateOneAttribute(
632
677
  );
633
678
  await recreateIndexes(adapter, databaseId, collectionId, cpEntry);
634
679
  }
635
-
636
- // Delete backup (unless keepBackups)
637
- if (!opts.keepBackups) {
638
- MessageFormatter.info(` Deleting backup attribute ${backupKey}...`, {
639
- prefix: "Migrate",
640
- });
641
- await tryAwaitWithRetry(() =>
642
- adapter.deleteAttribute({
643
- databaseId,
644
- tableId: collectionId,
645
- key: backupKey,
646
- })
647
- );
648
- await waitForAttributeGone(
649
- adapter,
650
- databaseId,
651
- collectionId,
652
- backupKey
653
- );
654
- }
655
- advance("backup_deleted");
680
+ advance("indexes_recreated");
656
681
  }
657
682
 
658
- // Step 9: Mark completed
683
+ // Mark completed
659
684
  // NOTE: required flag is restored AFTER all attributes in the collection
660
685
  // are migrated, to avoid partial-update validation errors on other attributes.
661
686
  advance("completed");
@@ -693,7 +718,7 @@ async function copyAttributeData(
693
718
  })
694
719
  : undefined;
695
720
 
696
- const limit = pLimit(5);
721
+ const limit = pLimit(25);
697
722
 
698
723
  while (true) {
699
724
  const queries: string[] = [Query.limit(batchSize)];
@@ -729,6 +754,16 @@ async function copyAttributeData(
729
754
 
730
755
  totalCopied += docs.length;
731
756
  lastId = docs[docs.length - 1].$id;
757
+
758
+ // Appwrite caps result.total at 5000 — adjust progress bar if we exceed it
759
+ if (progress && totalDocs && totalCopied > totalDocs) {
760
+ // Estimate remaining: if we haven't hit the last page, assume at least one more batch
761
+ const estimatedTotal = docs.length < batchSize
762
+ ? totalCopied
763
+ : totalCopied + batchSize;
764
+ progress.setTotal(estimatedTotal);
765
+ totalDocs = estimatedTotal;
766
+ }
732
767
  progress?.update(totalCopied);
733
768
 
734
769
  if (docs.length < batchSize) break; // last page
@@ -965,6 +1000,7 @@ function loadOrCreateCheckpoint(
965
1000
 
966
1001
  const now = new Date().toISOString();
967
1002
  return {
1003
+ version: 2,
968
1004
  planFile,
969
1005
  startedAt: now,
970
1006
  lastUpdatedAt: now,
@@ -1026,11 +1062,9 @@ const PHASE_ORDER: CheckpointPhase[] = [
1026
1062
  "backup_created",
1027
1063
  "data_copied_to_backup",
1028
1064
  "data_verified_backup",
1029
- "original_deleted",
1030
- "new_attr_created",
1031
- "data_copied_back",
1032
- "data_verified_final",
1033
- "backup_deleted",
1065
+ "original_cleared",
1066
+ "backup_renamed",
1067
+ "indexes_recreated",
1034
1068
  "completed",
1035
1069
  ];
1036
1070
 
@@ -1045,8 +1079,13 @@ function phaseIndex(phase: CheckpointPhase): number {
1045
1079
 
1046
1080
  function printDryRunSummary(plan: MigrationPlan): void {
1047
1081
  console.log("");
1048
- console.log(chalk.bold("Dry Run — What Would Happen:"));
1082
+ console.log(chalk.bold("Dry Run — What Would Happen (rename-based flow):"));
1049
1083
  console.log(chalk.gray("─".repeat(50)));
1084
+ console.log(
1085
+ chalk.dim(
1086
+ " Flow: create mig_X (target type) → copy data → delete/archive original → rename mig_X → X"
1087
+ )
1088
+ );
1050
1089
 
1051
1090
  const groups = new Map<string, MigrationPlanEntry[]>();
1052
1091
  for (const entry of plan.entries) {
@@ -62,13 +62,17 @@ export const CheckpointPhase = z.enum([
62
62
  "backup_created",
63
63
  "data_copied_to_backup",
64
64
  "data_verified_backup",
65
+ "original_cleared",
66
+ "backup_renamed",
67
+ "indexes_recreated",
68
+ "completed",
69
+ "failed",
70
+ // Legacy phases (kept for Zod validation of old v1 checkpoints)
65
71
  "original_deleted",
66
72
  "new_attr_created",
67
73
  "data_copied_back",
68
74
  "data_verified_final",
69
75
  "backup_deleted",
70
- "completed",
71
- "failed",
72
76
  ]);
73
77
  export type CheckpointPhase = z.infer<typeof CheckpointPhase>;
74
78
 
@@ -96,6 +100,7 @@ export const CheckpointEntrySchema = z.object({
96
100
  export type CheckpointEntry = z.infer<typeof CheckpointEntrySchema>;
97
101
 
98
102
  export const MigrationCheckpointSchema = z.object({
103
+ version: z.number().default(1),
99
104
  planFile: z.string(),
100
105
  startedAt: z.string(),
101
106
  lastUpdatedAt: z.string(),
@@ -152,6 +157,19 @@ export function generateBackupKey(originalKey: string): string {
152
157
  return `${TRUNC_PREFIX}${originalKey.slice(0, maxOrigLen)}_${hash}`;
153
158
  }
154
159
 
160
+ const ARCHIVE_PREFIX = "og_";
161
+
162
+ export function generateArchiveKey(originalKey: string): string {
163
+ const candidate = `${ARCHIVE_PREFIX}${originalKey}`;
164
+ if (candidate.length <= MAX_KEY_LENGTH) {
165
+ return candidate;
166
+ }
167
+ const hash = simpleHash(originalKey);
168
+ const TRUNC_PREFIX = "o_";
169
+ const maxOrigLen = MAX_KEY_LENGTH - TRUNC_PREFIX.length - 1 - 4;
170
+ return `${TRUNC_PREFIX}${originalKey.slice(0, maxOrigLen)}_${hash}`;
171
+ }
172
+
155
173
  function simpleHash(str: string): string {
156
174
  let h = 0;
157
175
  for (let i = 0; i < str.length; i++) {