@njdamstra/appwrite-utils-cli 1.11.8 → 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) {
@@ -359,26 +371,27 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
359
371
  checkpoint.lastUpdatedAt = new Date().toISOString();
360
372
  saveCheckpoint(checkpoint, checkpointPath);
361
373
  };
362
- // Step 1: Create backup attribute
374
+ // Step 1: Create backup attribute as TARGET type directly
363
375
  if (phaseIndex(cpEntry.phase) < phaseIndex("backup_created")) {
364
- MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
365
- prefix: "Migrate",
366
- });
367
- await createAttributeIfNotExists(adapter, {
376
+ MessageFormatter.info(` Creating backup attribute ${backupKey} as ${targetType}...`, { prefix: "Migrate" });
377
+ const createParams = {
368
378
  databaseId,
369
379
  tableId: collectionId,
370
380
  key: backupKey,
371
- type: "string", // backup keeps original type
372
- size: entry.currentSize,
373
- required: false, // always optional for backup
381
+ type: targetType,
382
+ required: false,
374
383
  array: entry.isArray,
375
- });
384
+ };
385
+ if (targetType === "varchar" && targetSize) {
386
+ createParams.size = targetSize;
387
+ }
388
+ await createAttributeIfNotExists(adapter, createParams);
376
389
  const available = await waitForAttribute(adapter, databaseId, collectionId, backupKey);
377
390
  if (!available)
378
391
  throw new Error(`Backup attribute ${backupKey} stuck`);
379
392
  advance("backup_created");
380
393
  }
381
- // Step 2: Copy data to backup
394
+ // Step 2: Copy data to backup (only data copy needed)
382
395
  if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_to_backup")) {
383
396
  MessageFormatter.info(` Copying data to backup ${backupKey}...`, {
384
397
  prefix: "Migrate",
@@ -391,85 +404,64 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
391
404
  await verifyDataCopy(adapter, databaseId, collectionId, attributeKey, backupKey);
392
405
  advance("data_verified_backup");
393
406
  }
394
- // Step 4: Delete indexes + original attribute
395
- if (phaseIndex(cpEntry.phase) < phaseIndex("original_deleted")) {
396
- // 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)
397
410
  if (entry.indexesAffected.length > 0) {
398
- MessageFormatter.info(` Removing ${entry.indexesAffected.length} affected index(es)...`, {
399
- prefix: "Migrate",
400
- });
411
+ MessageFormatter.info(` Removing ${entry.indexesAffected.length} affected index(es)...`, { prefix: "Migrate" });
401
412
  await saveAndDeleteIndexes(adapter, databaseId, collectionId, entry.indexesAffected, cpEntry);
402
413
  saveCheckpoint(checkpoint, checkpointPath);
403
414
  }
404
- MessageFormatter.info(` Deleting original attribute ${attributeKey}...`, {
405
- prefix: "Migrate",
406
- });
407
- await tryAwaitWithRetry(() => adapter.deleteAttribute({
408
- databaseId,
409
- tableId: collectionId,
410
- key: attributeKey,
411
- }));
412
- await waitForAttributeGone(adapter, databaseId, collectionId, attributeKey);
413
- advance("original_deleted");
414
- }
415
- // Step 5: Create new attribute with target type
416
- if (phaseIndex(cpEntry.phase) < phaseIndex("new_attr_created")) {
417
- MessageFormatter.info(` Creating new attribute ${attributeKey} as ${targetType}...`, { prefix: "Migrate" });
418
- const createParams = {
419
- databaseId,
420
- tableId: collectionId,
421
- key: attributeKey,
422
- type: targetType,
423
- required: false, // create as optional first — data needs to be copied back
424
- array: entry.isArray,
425
- };
426
- if (targetType === "varchar" && targetSize) {
427
- 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);
428
427
  }
429
- if (entry.hasDefault && entry.defaultValue !== undefined) {
430
- 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);
431
437
  }
432
- await createAttributeIfNotExists(adapter, createParams);
433
- const available = await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
434
- if (!available)
435
- throw new Error(`New attribute ${attributeKey} stuck after creation`);
436
- advance("new_attr_created");
437
- }
438
- // Step 6: Copy data back from backup
439
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_back")) {
440
- MessageFormatter.info(` Copying data back from backup...`, {
441
- prefix: "Migrate",
442
- });
443
- await copyAttributeData(adapter, databaseId, collectionId, backupKey, attributeKey, opts.batchSize, opts.batchDelayMs);
444
- advance("data_copied_back");
438
+ advance("original_cleared");
445
439
  }
446
- // Step 7: Verify final data
447
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_verified_final")) {
448
- await verifyDataCopy(adapter, databaseId, collectionId, backupKey, attributeKey);
449
- 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");
450
455
  }
451
- // Step 8: Recreate indexes + delete backup
452
- if (phaseIndex(cpEntry.phase) < phaseIndex("backup_deleted")) {
453
- // Recreate indexes
456
+ // Step 6: Recreate indexes
457
+ if (phaseIndex(cpEntry.phase) < phaseIndex("indexes_recreated")) {
454
458
  if (cpEntry.storedIndexes.length > 0) {
455
459
  MessageFormatter.info(` Recreating ${cpEntry.storedIndexes.length} index(es)...`, { prefix: "Migrate" });
456
460
  await recreateIndexes(adapter, databaseId, collectionId, cpEntry);
457
461
  }
458
- // Delete backup (unless keepBackups)
459
- if (!opts.keepBackups) {
460
- MessageFormatter.info(` Deleting backup attribute ${backupKey}...`, {
461
- prefix: "Migrate",
462
- });
463
- await tryAwaitWithRetry(() => adapter.deleteAttribute({
464
- databaseId,
465
- tableId: collectionId,
466
- key: backupKey,
467
- }));
468
- await waitForAttributeGone(adapter, databaseId, collectionId, backupKey);
469
- }
470
- advance("backup_deleted");
462
+ advance("indexes_recreated");
471
463
  }
472
- // Step 9: Mark completed
464
+ // Mark completed
473
465
  // NOTE: required flag is restored AFTER all attributes in the collection
474
466
  // are migrated, to avoid partial-update validation errors on other attributes.
475
467
  advance("completed");
@@ -493,7 +485,7 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
493
485
  title: ` Copy ${sourceKey} → ${targetKey}`,
494
486
  })
495
487
  : undefined;
496
- const limit = pLimit(5);
488
+ const limit = pLimit(25);
497
489
  while (true) {
498
490
  const queries = [Query.limit(batchSize)];
499
491
  if (lastId)
@@ -516,6 +508,15 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
516
508
  await Promise.all(updatePromises);
517
509
  totalCopied += docs.length;
518
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
+ }
519
520
  progress?.update(totalCopied);
520
521
  if (docs.length < batchSize)
521
522
  break; // last page
@@ -668,6 +669,7 @@ function loadOrCreateCheckpoint(checkpointPath, planFile) {
668
669
  }
669
670
  const now = new Date().toISOString();
670
671
  return {
672
+ version: 2,
671
673
  planFile,
672
674
  startedAt: now,
673
675
  lastUpdatedAt: now,
@@ -708,11 +710,9 @@ const PHASE_ORDER = [
708
710
  "backup_created",
709
711
  "data_copied_to_backup",
710
712
  "data_verified_backup",
711
- "original_deleted",
712
- "new_attr_created",
713
- "data_copied_back",
714
- "data_verified_final",
715
- "backup_deleted",
713
+ "original_cleared",
714
+ "backup_renamed",
715
+ "indexes_recreated",
716
716
  "completed",
717
717
  ];
718
718
  function phaseIndex(phase) {
@@ -724,8 +724,9 @@ function phaseIndex(phase) {
724
724
  // ────────────────────────────────────────────────────────
725
725
  function printDryRunSummary(plan) {
726
726
  console.log("");
727
- console.log(chalk.bold("Dry Run — What Would Happen:"));
727
+ console.log(chalk.bold("Dry Run — What Would Happen (rename-based flow):"));
728
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"));
729
730
  const groups = new Map();
730
731
  for (const entry of plan.entries) {
731
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.8",
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[]>();
@@ -484,7 +504,7 @@ export async function executeMigrationPlan(
484
504
  }
485
505
 
486
506
  // ────────────────────────────────────────────────────────
487
- // Single attribute migration (9 phases)
507
+ // Single attribute migration (rename-based, 6 steps)
488
508
  // ────────────────────────────────────────────────────────
489
509
 
490
510
  interface MigrateOneOptions {
@@ -511,20 +531,24 @@ async function migrateOneAttribute(
511
531
  saveCheckpoint(checkpoint, checkpointPath);
512
532
  };
513
533
 
514
- // Step 1: Create backup attribute
534
+ // Step 1: Create backup attribute as TARGET type directly
515
535
  if (phaseIndex(cpEntry.phase) < phaseIndex("backup_created")) {
516
- MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
517
- prefix: "Migrate",
518
- });
519
- await createAttributeIfNotExists(adapter, {
536
+ MessageFormatter.info(
537
+ ` Creating backup attribute ${backupKey} as ${targetType}...`,
538
+ { prefix: "Migrate" }
539
+ );
540
+ const createParams: Record<string, any> = {
520
541
  databaseId,
521
542
  tableId: collectionId,
522
543
  key: backupKey,
523
- type: "string", // backup keeps original type
524
- size: entry.currentSize,
525
- required: false, // always optional for backup
544
+ type: targetType,
545
+ required: false,
526
546
  array: entry.isArray,
527
- });
547
+ };
548
+ if (targetType === "varchar" && targetSize) {
549
+ createParams.size = targetSize;
550
+ }
551
+ await createAttributeIfNotExists(adapter, createParams as any);
528
552
  const available = await waitForAttribute(
529
553
  adapter,
530
554
  databaseId,
@@ -535,7 +559,7 @@ async function migrateOneAttribute(
535
559
  advance("backup_created");
536
560
  }
537
561
 
538
- // Step 2: Copy data to backup
562
+ // Step 2: Copy data to backup (only data copy needed)
539
563
  if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_to_backup")) {
540
564
  MessageFormatter.info(` Copying data to backup ${backupKey}...`, {
541
565
  prefix: "Migrate",
@@ -564,13 +588,14 @@ async function migrateOneAttribute(
564
588
  advance("data_verified_backup");
565
589
  }
566
590
 
567
- // Step 4: Delete indexes + original attribute
568
- if (phaseIndex(cpEntry.phase) < phaseIndex("original_deleted")) {
569
- // 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)
570
594
  if (entry.indexesAffected.length > 0) {
571
- MessageFormatter.info(` Removing ${entry.indexesAffected.length} affected index(es)...`, {
572
- prefix: "Migrate",
573
- });
595
+ MessageFormatter.info(
596
+ ` Removing ${entry.indexesAffected.length} affected index(es)...`,
597
+ { prefix: "Migrate" }
598
+ );
574
599
  await saveAndDeleteIndexes(
575
600
  adapter,
576
601
  databaseId,
@@ -581,85 +606,70 @@ async function migrateOneAttribute(
581
606
  saveCheckpoint(checkpoint, checkpointPath);
582
607
  }
583
608
 
584
- MessageFormatter.info(` Deleting original attribute ${attributeKey}...`, {
585
- prefix: "Migrate",
586
- });
587
- await tryAwaitWithRetry(() =>
588
- 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,
589
641
  databaseId,
590
- tableId: collectionId,
591
- key: attributeKey,
592
- })
593
- );
594
- await waitForAttributeGone(adapter, databaseId, collectionId, attributeKey);
595
- advance("original_deleted");
642
+ collectionId,
643
+ attributeKey
644
+ );
645
+ }
646
+ advance("original_cleared");
596
647
  }
597
648
 
598
- // Step 5: Create new attribute with target type
599
- if (phaseIndex(cpEntry.phase) < phaseIndex("new_attr_created")) {
649
+ // Step 5: Rename backup to original name
650
+ if (phaseIndex(cpEntry.phase) < phaseIndex("backup_renamed")) {
600
651
  MessageFormatter.info(
601
- ` Creating new attribute ${attributeKey} as ${targetType}...`,
652
+ ` Renaming ${backupKey} ${attributeKey}...`,
602
653
  { prefix: "Migrate" }
603
654
  );
604
- const createParams: Record<string, any> = {
605
- databaseId,
606
- tableId: collectionId,
607
- key: attributeKey,
608
- type: targetType,
609
- required: false, // create as optional first — data needs to be copied back
610
- array: entry.isArray,
611
- };
612
- if (targetType === "varchar" && targetSize) {
613
- createParams.size = targetSize;
614
- }
615
- if (entry.hasDefault && entry.defaultValue !== undefined) {
616
- createParams.default = entry.defaultValue;
617
- }
618
-
619
- await createAttributeIfNotExists(adapter, createParams as any);
620
- const available = await waitForAttribute(
621
- adapter,
622
- databaseId,
623
- collectionId,
624
- attributeKey
625
- );
626
- if (!available)
627
- throw new Error(`New attribute ${attributeKey} stuck after creation`);
628
- advance("new_attr_created");
629
- }
630
-
631
- // Step 6: Copy data back from backup
632
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_copied_back")) {
633
- MessageFormatter.info(` Copying data back from backup...`, {
634
- prefix: "Migrate",
635
- });
636
- await copyAttributeData(
637
- adapter,
638
- databaseId,
639
- collectionId,
640
- backupKey,
641
- attributeKey,
642
- opts.batchSize,
643
- opts.batchDelayMs
644
- );
645
- advance("data_copied_back");
646
- }
647
-
648
- // Step 7: Verify final data
649
- if (phaseIndex(cpEntry.phase) < phaseIndex("data_verified_final")) {
650
- await verifyDataCopy(
651
- adapter,
652
- databaseId,
653
- collectionId,
654
- backupKey,
655
- 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
+ })
656
666
  );
657
- advance("data_verified_final");
667
+ await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
668
+ advance("backup_renamed");
658
669
  }
659
670
 
660
- // Step 8: Recreate indexes + delete backup
661
- if (phaseIndex(cpEntry.phase) < phaseIndex("backup_deleted")) {
662
- // Recreate indexes
671
+ // Step 6: Recreate indexes
672
+ if (phaseIndex(cpEntry.phase) < phaseIndex("indexes_recreated")) {
663
673
  if (cpEntry.storedIndexes.length > 0) {
664
674
  MessageFormatter.info(
665
675
  ` Recreating ${cpEntry.storedIndexes.length} index(es)...`,
@@ -667,30 +677,10 @@ async function migrateOneAttribute(
667
677
  );
668
678
  await recreateIndexes(adapter, databaseId, collectionId, cpEntry);
669
679
  }
670
-
671
- // Delete backup (unless keepBackups)
672
- if (!opts.keepBackups) {
673
- MessageFormatter.info(` Deleting backup attribute ${backupKey}...`, {
674
- prefix: "Migrate",
675
- });
676
- await tryAwaitWithRetry(() =>
677
- adapter.deleteAttribute({
678
- databaseId,
679
- tableId: collectionId,
680
- key: backupKey,
681
- })
682
- );
683
- await waitForAttributeGone(
684
- adapter,
685
- databaseId,
686
- collectionId,
687
- backupKey
688
- );
689
- }
690
- advance("backup_deleted");
680
+ advance("indexes_recreated");
691
681
  }
692
682
 
693
- // Step 9: Mark completed
683
+ // Mark completed
694
684
  // NOTE: required flag is restored AFTER all attributes in the collection
695
685
  // are migrated, to avoid partial-update validation errors on other attributes.
696
686
  advance("completed");
@@ -728,7 +718,7 @@ async function copyAttributeData(
728
718
  })
729
719
  : undefined;
730
720
 
731
- const limit = pLimit(5);
721
+ const limit = pLimit(25);
732
722
 
733
723
  while (true) {
734
724
  const queries: string[] = [Query.limit(batchSize)];
@@ -764,6 +754,16 @@ async function copyAttributeData(
764
754
 
765
755
  totalCopied += docs.length;
766
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
+ }
767
767
  progress?.update(totalCopied);
768
768
 
769
769
  if (docs.length < batchSize) break; // last page
@@ -1000,6 +1000,7 @@ function loadOrCreateCheckpoint(
1000
1000
 
1001
1001
  const now = new Date().toISOString();
1002
1002
  return {
1003
+ version: 2,
1003
1004
  planFile,
1004
1005
  startedAt: now,
1005
1006
  lastUpdatedAt: now,
@@ -1061,11 +1062,9 @@ const PHASE_ORDER: CheckpointPhase[] = [
1061
1062
  "backup_created",
1062
1063
  "data_copied_to_backup",
1063
1064
  "data_verified_backup",
1064
- "original_deleted",
1065
- "new_attr_created",
1066
- "data_copied_back",
1067
- "data_verified_final",
1068
- "backup_deleted",
1065
+ "original_cleared",
1066
+ "backup_renamed",
1067
+ "indexes_recreated",
1069
1068
  "completed",
1070
1069
  ];
1071
1070
 
@@ -1080,8 +1079,13 @@ function phaseIndex(phase: CheckpointPhase): number {
1080
1079
 
1081
1080
  function printDryRunSummary(plan: MigrationPlan): void {
1082
1081
  console.log("");
1083
- console.log(chalk.bold("Dry Run — What Would Happen:"));
1082
+ console.log(chalk.bold("Dry Run — What Would Happen (rename-based flow):"));
1084
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
+ );
1085
1089
 
1086
1090
  const groups = new Map<string, MigrationPlanEntry[]>();
1087
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++) {