@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
|
-
|
|
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) {
|
|
@@ -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
|
|
279
|
-
// This
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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:
|
|
349
|
-
|
|
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:
|
|
372
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
373
|
-
//
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
429
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
"
|
|
689
|
-
"
|
|
690
|
-
"
|
|
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.
|
|
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[]>();
|
|
@@ -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
|
|
386
|
-
// This
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 (
|
|
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(
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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:
|
|
489
|
-
|
|
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:
|
|
533
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
534
|
-
//
|
|
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(
|
|
537
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
advance("original_deleted");
|
|
642
|
+
collectionId,
|
|
643
|
+
attributeKey
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
advance("original_cleared");
|
|
561
647
|
}
|
|
562
648
|
|
|
563
|
-
// Step 5:
|
|
564
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
649
|
+
// Step 5: Rename backup to original name
|
|
650
|
+
if (phaseIndex(cpEntry.phase) < phaseIndex("backup_renamed")) {
|
|
565
651
|
MessageFormatter.info(
|
|
566
|
-
`
|
|
652
|
+
` Renaming ${backupKey} → ${attributeKey}...`,
|
|
567
653
|
{ prefix: "Migrate" }
|
|
568
654
|
);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
667
|
+
await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
|
|
668
|
+
advance("backup_renamed");
|
|
623
669
|
}
|
|
624
670
|
|
|
625
|
-
// Step
|
|
626
|
-
if (phaseIndex(cpEntry.phase) < phaseIndex("
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
"
|
|
1030
|
-
"
|
|
1031
|
-
"
|
|
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++) {
|