@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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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:
|
|
372
|
-
|
|
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:
|
|
395
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
396
|
-
//
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
447
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
452
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
"
|
|
712
|
-
"
|
|
713
|
-
"
|
|
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.
|
|
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.
|
|
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
|
-
|
|
277
|
-
|
|
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 (
|
|
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(
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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:
|
|
524
|
-
|
|
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:
|
|
568
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
569
|
-
//
|
|
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(
|
|
572
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
advance("original_deleted");
|
|
642
|
+
collectionId,
|
|
643
|
+
attributeKey
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
advance("original_cleared");
|
|
596
647
|
}
|
|
597
648
|
|
|
598
|
-
// Step 5:
|
|
599
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
649
|
+
// Step 5: Rename backup to original name
|
|
650
|
+
if (phaseIndex(cpEntry.phase) < phaseIndex("backup_renamed")) {
|
|
600
651
|
MessageFormatter.info(
|
|
601
|
-
`
|
|
652
|
+
` Renaming ${backupKey} → ${attributeKey}...`,
|
|
602
653
|
{ prefix: "Migrate" }
|
|
603
654
|
);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
667
|
+
await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
|
|
668
|
+
advance("backup_renamed");
|
|
658
669
|
}
|
|
659
670
|
|
|
660
|
-
// Step
|
|
661
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
"
|
|
1065
|
-
"
|
|
1066
|
-
"
|
|
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++) {
|