@ftschopp/dynatable-migrations 1.2.5 → 1.3.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +12 -0
  3. package/dist/cli-utils.d.ts +8 -0
  4. package/dist/cli-utils.d.ts.map +1 -0
  5. package/dist/cli-utils.js +21 -0
  6. package/dist/cli-utils.js.map +1 -0
  7. package/dist/cli.js +25 -6
  8. package/dist/cli.js.map +1 -1
  9. package/dist/commands/create.js +1 -1
  10. package/dist/commands/create.js.map +1 -1
  11. package/dist/commands/down.d.ts +1 -1
  12. package/dist/commands/down.d.ts.map +1 -1
  13. package/dist/commands/down.js +61 -1
  14. package/dist/commands/down.js.map +1 -1
  15. package/dist/commands/unlock.d.ts +8 -0
  16. package/dist/commands/unlock.d.ts.map +1 -0
  17. package/dist/commands/unlock.js +87 -0
  18. package/dist/commands/unlock.js.map +1 -0
  19. package/dist/core/config.d.ts.map +1 -1
  20. package/dist/core/config.js +14 -1
  21. package/dist/core/config.js.map +1 -1
  22. package/dist/core/loader.d.ts +7 -1
  23. package/dist/core/loader.d.ts.map +1 -1
  24. package/dist/core/loader.js +35 -6
  25. package/dist/core/loader.js.map +1 -1
  26. package/dist/core/runner.d.ts.map +1 -1
  27. package/dist/core/runner.js +6 -0
  28. package/dist/core/runner.js.map +1 -1
  29. package/dist/core/tracker.d.ts.map +1 -1
  30. package/dist/core/tracker.js +23 -12
  31. package/dist/core/tracker.js.map +1 -1
  32. package/package.json +35 -4
  33. package/.gitkeep +0 -0
  34. package/jest.config.js +0 -8
  35. package/migrations/.gitkeep +0 -0
  36. package/src/cli.ts +0 -111
  37. package/src/commands/create.ts +0 -111
  38. package/src/commands/down.ts +0 -26
  39. package/src/commands/init.ts +0 -37
  40. package/src/commands/status.ts +0 -75
  41. package/src/commands/up.ts +0 -26
  42. package/src/core/client.ts +0 -24
  43. package/src/core/config.ts +0 -140
  44. package/src/core/errors.ts +0 -55
  45. package/src/core/loader.ts +0 -256
  46. package/src/core/lock-heartbeat.test.ts +0 -78
  47. package/src/core/lock-heartbeat.ts +0 -48
  48. package/src/core/runner.ts +0 -303
  49. package/src/core/semver.test.ts +0 -33
  50. package/src/core/semver.ts +0 -26
  51. package/src/core/tracker.test.ts +0 -281
  52. package/src/core/tracker.ts +0 -559
  53. package/src/index.ts +0 -25
  54. package/src/templates/migration.ts +0 -92
  55. package/src/types/index.ts +0 -220
  56. package/tsconfig.json +0 -19
@@ -1,559 +0,0 @@
1
- import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
2
- import {
3
- PutCommand,
4
- GetCommand,
5
- QueryCommand,
6
- UpdateCommand,
7
- DeleteCommand,
8
- TransactWriteCommand,
9
- } from '@aws-sdk/lib-dynamodb';
10
- import { MigrationTracker, MigrationRecord, SchemaChange, MigrationConfig } from '../types';
11
- import { compareSemver } from './semver';
12
- import { MigrationAlreadyAppliedError, MigrationLockLostError } from './errors';
13
-
14
- /** Default lock TTL in seconds. Configurable via MigrationConfig.lockTtlSeconds. */
15
- export const DEFAULT_LOCK_TTL_SECONDS = 300; // 5 minutes
16
-
17
- export class DynamoDBMigrationTracker implements MigrationTracker {
18
- private client: DynamoDBDocumentClient;
19
- private tableName: string;
20
- private trackingPrefix: string;
21
- private gsi1Name: string;
22
- private lockId: string | null = null;
23
- /** TTL for newly-acquired or refreshed locks. */
24
- public readonly lockTtlSeconds: number;
25
-
26
- constructor(client: DynamoDBDocumentClient, config: MigrationConfig) {
27
- this.client = client;
28
- this.tableName = config.tableName;
29
- this.trackingPrefix = config.trackingPrefix || '_SCHEMA#VERSION';
30
- this.gsi1Name = config.gsi1Name || 'GSI1';
31
- this.lockTtlSeconds = config.lockTtlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
32
- }
33
-
34
- private get lockKey() {
35
- return {
36
- PK: `${this.trackingPrefix}#LOCK`,
37
- SK: `${this.trackingPrefix}#LOCK`,
38
- };
39
- }
40
-
41
- /** Throws if there is no active lock. Call before any tracker write. */
42
- private requireLock(): void {
43
- if (!this.lockId) {
44
- throw new Error(
45
- 'Tracker has no active lock; refusing to issue tracker mutations. ' +
46
- 'Call acquireLock() first.'
47
- );
48
- }
49
- }
50
-
51
- /** Builds a TransactWrite ConditionCheck that asserts we still own the lock. */
52
- private lockOwnershipCheck() {
53
- this.requireLock();
54
- return {
55
- ConditionCheck: {
56
- TableName: this.tableName,
57
- Key: this.lockKey,
58
- ConditionExpression: 'lockId = :lockId',
59
- ExpressionAttributeValues: {
60
- ':lockId': this.lockId,
61
- },
62
- },
63
- };
64
- }
65
-
66
- async initialize(): Promise<void> {
67
- // Check if current version pointer exists, if not create it
68
- const current = await this.getCurrentVersion();
69
- if (!current) {
70
- // Create initial version pointer
71
- await this.client.send(
72
- new PutCommand({
73
- TableName: this.tableName,
74
- Item: {
75
- PK: `${this.trackingPrefix}#CURRENT`,
76
- SK: `${this.trackingPrefix}#CURRENT`,
77
- GSI1PK: 'SCHEMA#CURRENT',
78
- GSI1SK: 'v0000',
79
- currentVersion: 'v0000',
80
- updatedAt: new Date().toISOString(),
81
- },
82
- })
83
- );
84
- }
85
- }
86
-
87
- /**
88
- * Acquire a distributed lock to prevent concurrent migrations
89
- */
90
- async acquireLock(): Promise<boolean> {
91
- const now = Date.now();
92
- const expiresAt = now + this.lockTtlSeconds * 1000;
93
- this.lockId = `lock-${now}-${Math.random().toString(36).substring(7)}`;
94
-
95
- try {
96
- await this.client.send(
97
- new PutCommand({
98
- TableName: this.tableName,
99
- Item: {
100
- ...this.lockKey,
101
- lockId: this.lockId,
102
- acquiredAt: new Date().toISOString(),
103
- expiresAt,
104
- },
105
- // Only succeed if lock doesn't exist or has expired. The `<=`
106
- // covers the boundary where two clocks read the exact ms.
107
- ConditionExpression: 'attribute_not_exists(PK) OR expiresAt <= :now',
108
- ExpressionAttributeValues: {
109
- ':now': now,
110
- },
111
- })
112
- );
113
- return true;
114
- } catch (error: any) {
115
- if (error.name === 'ConditionalCheckFailedException') {
116
- this.lockId = null;
117
- return false;
118
- }
119
- throw error;
120
- }
121
- }
122
-
123
- /**
124
- * Extend the lock's expiration by another `lockTtlSeconds`. Throws
125
- * `ConditionalCheckFailedException` if another worker has already taken
126
- * the lock — callers should treat that as "we lost the race; stop
127
- * making writes". Silent no-op if no lock is currently held.
128
- */
129
- async refreshLock(): Promise<void> {
130
- if (!this.lockId) return;
131
-
132
- const newExpiresAt = Date.now() + this.lockTtlSeconds * 1000;
133
-
134
- await this.client.send(
135
- new UpdateCommand({
136
- TableName: this.tableName,
137
- Key: this.lockKey,
138
- UpdateExpression: 'SET expiresAt = :exp',
139
- ConditionExpression: 'lockId = :lockId',
140
- ExpressionAttributeValues: {
141
- ':exp': newExpiresAt,
142
- ':lockId': this.lockId,
143
- },
144
- })
145
- );
146
- }
147
-
148
- /**
149
- * Release the distributed lock
150
- */
151
- async releaseLock(): Promise<void> {
152
- if (!this.lockId) return;
153
-
154
- try {
155
- await this.client.send(
156
- new DeleteCommand({
157
- TableName: this.tableName,
158
- Key: this.lockKey,
159
- // Only delete if we own the lock
160
- ConditionExpression: 'lockId = :lockId',
161
- ExpressionAttributeValues: {
162
- ':lockId': this.lockId,
163
- },
164
- })
165
- );
166
- } catch (error: any) {
167
- // Lock may have expired or been taken by someone else
168
- if (error.name !== 'ConditionalCheckFailedException') {
169
- throw error;
170
- }
171
- } finally {
172
- this.lockId = null;
173
- }
174
- }
175
-
176
- /**
177
- * Get all applied migrations with pagination support
178
- */
179
- async getAppliedMigrations(): Promise<MigrationRecord[]> {
180
- const items: MigrationRecord[] = [];
181
- let lastKey: Record<string, any> | undefined;
182
-
183
- do {
184
- const result = await this.client.send(
185
- new QueryCommand({
186
- TableName: this.tableName,
187
- KeyConditionExpression: 'PK = :pk',
188
- ExpressionAttributeValues: {
189
- ':pk': this.trackingPrefix,
190
- },
191
- ExclusiveStartKey: lastKey,
192
- })
193
- );
194
-
195
- items.push(...((result.Items || []) as MigrationRecord[]));
196
- lastKey = result.LastEvaluatedKey;
197
- } while (lastKey);
198
-
199
- return items;
200
- }
201
-
202
- async getCurrentVersion(): Promise<string | null> {
203
- const result = await this.client.send(
204
- new GetCommand({
205
- TableName: this.tableName,
206
- Key: {
207
- PK: `${this.trackingPrefix}#CURRENT`,
208
- SK: `${this.trackingPrefix}#CURRENT`,
209
- },
210
- })
211
- );
212
-
213
- return result.Item?.currentVersion || null;
214
- }
215
-
216
- /**
217
- * Mark migration as applied using TransactWrite for atomicity
218
- * Handles both new migrations and re-applying rolled back migrations
219
- */
220
- async markAsApplied(
221
- version: string,
222
- name: string,
223
- schemaDefinition?: Record<string, any>,
224
- schemaChanges?: SchemaChange[],
225
- checksum?: string
226
- ): Promise<void> {
227
- this.requireLock();
228
- const now = new Date().toISOString();
229
-
230
- // Check if migration record already exists (e.g., rolled back or failed)
231
- const existing = await this.getMigration(version);
232
-
233
- try {
234
- if (existing) {
235
- // Update existing record (re-applying a rolled back or failed migration)
236
- // Build dynamic update expression - DynamoDB doesn't accept undefined values
237
- const setParts = ['#status = :status', 'appliedAt = :appliedAt', '#name = :name'];
238
- const expressionValues: Record<string, any> = {
239
- ':status': 'applied',
240
- ':appliedAt': now,
241
- ':name': name,
242
- };
243
-
244
- if (schemaDefinition !== undefined) {
245
- setParts.push('schemaDefinition = :schemaDef');
246
- expressionValues[':schemaDef'] = schemaDefinition;
247
- }
248
- if (schemaChanges !== undefined) {
249
- setParts.push('schemaChanges = :schemaChanges');
250
- expressionValues[':schemaChanges'] = schemaChanges;
251
- }
252
- if (checksum !== undefined) {
253
- setParts.push('checksum = :checksum');
254
- expressionValues[':checksum'] = checksum;
255
- }
256
-
257
- await this.client.send(
258
- new TransactWriteCommand({
259
- TransactItems: [
260
- this.lockOwnershipCheck(),
261
- {
262
- Update: {
263
- TableName: this.tableName,
264
- Key: {
265
- PK: this.trackingPrefix,
266
- SK: version,
267
- },
268
- UpdateExpression: `SET ${setParts.join(', ')} REMOVE #error, failedAt, rolledBackAt`,
269
- ExpressionAttributeNames: {
270
- '#status': 'status',
271
- '#name': 'name',
272
- '#error': 'error',
273
- },
274
- ExpressionAttributeValues: expressionValues,
275
- // Only allow re-applying if not currently applied
276
- ConditionExpression: '#status <> :status',
277
- },
278
- },
279
- {
280
- Update: {
281
- TableName: this.tableName,
282
- Key: {
283
- PK: `${this.trackingPrefix}#CURRENT`,
284
- SK: `${this.trackingPrefix}#CURRENT`,
285
- },
286
- UpdateExpression:
287
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
288
- ExpressionAttributeValues: {
289
- ':version': version,
290
- ':updatedAt': now,
291
- ':gsi1sk': version,
292
- },
293
- },
294
- },
295
- ],
296
- })
297
- );
298
- } else {
299
- // Create new migration record
300
- await this.client.send(
301
- new TransactWriteCommand({
302
- TransactItems: [
303
- this.lockOwnershipCheck(),
304
- {
305
- Put: {
306
- TableName: this.tableName,
307
- Item: {
308
- PK: this.trackingPrefix,
309
- SK: version,
310
- version,
311
- name,
312
- timestamp: now,
313
- appliedAt: now,
314
- status: 'applied',
315
- schemaDefinition,
316
- schemaChanges,
317
- checksum,
318
- } as MigrationRecord,
319
- // Ensure migration wasn't already applied (uniqueness on
320
- // SK; PK is shared across all migration rows).
321
- ConditionExpression: 'attribute_not_exists(SK)',
322
- },
323
- },
324
- {
325
- Update: {
326
- TableName: this.tableName,
327
- Key: {
328
- PK: `${this.trackingPrefix}#CURRENT`,
329
- SK: `${this.trackingPrefix}#CURRENT`,
330
- },
331
- UpdateExpression:
332
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
333
- ExpressionAttributeValues: {
334
- ':version': version,
335
- ':updatedAt': now,
336
- ':gsi1sk': version,
337
- },
338
- },
339
- },
340
- ],
341
- })
342
- );
343
- }
344
- } catch (err: unknown) {
345
- await this.handleMarkAsAppliedCancellation(err, version);
346
- }
347
- }
348
-
349
- /**
350
- * Inspect a `TransactionCanceledException` raised by `markAsApplied` and
351
- * either swallow it (idempotent re-apply) or re-throw a typed error.
352
- *
353
- * The TransactWrite items are: [lockOwnershipCheck, migrationRow, currentPointer].
354
- * Each item produces an entry in `CancellationReasons`.
355
- * - reasons[0] failing → the lock was lost → MigrationLockLostError.
356
- * - reasons[1] failing → the migration row already exists in a state the
357
- * write refused. Re-fetch the record:
358
- * * status === 'applied' → idempotent re-apply, return silently.
359
- * * other states → MigrationAlreadyAppliedError with the
360
- * current state, so the caller can decide what to do.
361
- */
362
- private async handleMarkAsAppliedCancellation(
363
- err: unknown,
364
- version: string
365
- ): Promise<void> {
366
- const error = err as { name?: string; CancellationReasons?: Array<{ Code?: string }> } | null;
367
- if (error?.name !== 'TransactionCanceledException') {
368
- throw err;
369
- }
370
- const reasons = error.CancellationReasons ?? [];
371
- const lockReason = reasons[0];
372
- const migrationReason = reasons[1];
373
-
374
- if (lockReason?.Code === 'ConditionalCheckFailed') {
375
- throw new MigrationLockLostError(version);
376
- }
377
- if (migrationReason?.Code === 'ConditionalCheckFailed') {
378
- const current = await this.getMigration(version);
379
- if (current?.status === 'applied') {
380
- // Either we re-tried our own write or another worker applied the same
381
- // version. Either way, the desired post-state is already satisfied.
382
- return;
383
- }
384
- throw new MigrationAlreadyAppliedError(version, current?.status);
385
- }
386
- throw err;
387
- }
388
-
389
- /**
390
- * Mark migration as rolled back using TransactWrite for atomicity
391
- */
392
- async markAsRolledBack(version: string): Promise<void> {
393
- this.requireLock();
394
- const now = new Date().toISOString();
395
-
396
- // Find previous version
397
- const migrations = await this.getAppliedMigrations();
398
- const appliedMigrations = migrations
399
- .filter((m) => m.status === 'applied' && m.version !== version)
400
- .sort((a, b) => compareSemver(b.version, a.version));
401
-
402
- const previousVersion = appliedMigrations[0]?.version || 'v0000';
403
-
404
- // Use TransactWrite to atomically update record and pointer
405
- await this.client.send(
406
- new TransactWriteCommand({
407
- TransactItems: [
408
- this.lockOwnershipCheck(),
409
- {
410
- Update: {
411
- TableName: this.tableName,
412
- Key: {
413
- PK: this.trackingPrefix,
414
- SK: version,
415
- },
416
- UpdateExpression: 'SET #status = :status, rolledBackAt = :timestamp',
417
- ExpressionAttributeNames: {
418
- '#status': 'status',
419
- },
420
- ExpressionAttributeValues: {
421
- ':status': 'rolled_back',
422
- ':timestamp': now,
423
- },
424
- },
425
- },
426
- {
427
- Update: {
428
- TableName: this.tableName,
429
- Key: {
430
- PK: `${this.trackingPrefix}#CURRENT`,
431
- SK: `${this.trackingPrefix}#CURRENT`,
432
- },
433
- UpdateExpression:
434
- 'SET currentVersion = :version, updatedAt = :updatedAt, GSI1SK = :gsi1sk',
435
- ExpressionAttributeValues: {
436
- ':version': previousVersion,
437
- ':updatedAt': now,
438
- ':gsi1sk': previousVersion,
439
- },
440
- },
441
- },
442
- ],
443
- })
444
- );
445
- }
446
-
447
- async markAsFailed(version: string, error: string): Promise<void> {
448
- this.requireLock();
449
- // Check if the migration record exists first
450
- const existing = await this.getMigration(version);
451
- const now = new Date().toISOString();
452
-
453
- if (existing) {
454
- // Update existing record, gated by lock ownership
455
- await this.client.send(
456
- new TransactWriteCommand({
457
- TransactItems: [
458
- this.lockOwnershipCheck(),
459
- {
460
- Update: {
461
- TableName: this.tableName,
462
- Key: {
463
- PK: this.trackingPrefix,
464
- SK: version,
465
- },
466
- UpdateExpression:
467
- 'SET #status = :status, #error = :error, failedAt = :timestamp',
468
- ExpressionAttributeNames: {
469
- '#status': 'status',
470
- '#error': 'error',
471
- },
472
- ExpressionAttributeValues: {
473
- ':status': 'failed',
474
- ':error': error,
475
- ':timestamp': now,
476
- },
477
- },
478
- },
479
- ],
480
- })
481
- );
482
- } else {
483
- // Create new record for failed migration, gated by lock ownership
484
- await this.client.send(
485
- new TransactWriteCommand({
486
- TransactItems: [
487
- this.lockOwnershipCheck(),
488
- {
489
- Put: {
490
- TableName: this.tableName,
491
- Item: {
492
- PK: this.trackingPrefix,
493
- SK: version,
494
- version,
495
- name: 'unknown',
496
- status: 'failed',
497
- error,
498
- failedAt: now,
499
- timestamp: now,
500
- },
501
- },
502
- },
503
- ],
504
- })
505
- );
506
- }
507
- }
508
-
509
- async recordSchemaChange(change: SchemaChange): Promise<void> {
510
- this.requireLock();
511
- const currentVersion = await this.getCurrentVersion();
512
- if (!currentVersion || currentVersion === 'v0000') {
513
- throw new Error('Cannot record schema change: No migration has been applied yet');
514
- }
515
-
516
- // Append to schemaChanges array, gated by lock ownership
517
- await this.client.send(
518
- new TransactWriteCommand({
519
- TransactItems: [
520
- this.lockOwnershipCheck(),
521
- {
522
- Update: {
523
- TableName: this.tableName,
524
- Key: {
525
- PK: this.trackingPrefix,
526
- SK: currentVersion,
527
- },
528
- UpdateExpression:
529
- 'SET schemaChanges = list_append(if_not_exists(schemaChanges, :empty_list), :change)',
530
- ExpressionAttributeValues: {
531
- ':empty_list': [],
532
- ':change': [change],
533
- },
534
- },
535
- },
536
- ],
537
- })
538
- );
539
- }
540
-
541
- async getMigration(version: string): Promise<MigrationRecord | null> {
542
- const result = await this.client.send(
543
- new GetCommand({
544
- TableName: this.tableName,
545
- Key: {
546
- PK: this.trackingPrefix,
547
- SK: version,
548
- },
549
- })
550
- );
551
-
552
- return (result.Item as MigrationRecord) || null;
553
- }
554
-
555
- async isApplied(version: string): Promise<boolean> {
556
- const migration = await this.getMigration(version);
557
- return migration?.status === 'applied';
558
- }
559
- }
package/src/index.ts DELETED
@@ -1,25 +0,0 @@
1
- /**
2
- * @ftschopp/dynatable-migrations
3
- * DynamoDB migration tool for single table design with schema versioning
4
- */
5
-
6
- // Types
7
- export * from './types';
8
-
9
- // Core
10
- export { DynamoDBMigrationTracker } from './core/tracker';
11
- export { MigrationLoader } from './core/loader';
12
- export { MigrationRunner, type RunOptions } from './core/runner';
13
- export { ConfigLoader, loadConfig } from './core/config';
14
- export { createDynamoDBClient } from './core/client';
15
- export { MigrationAlreadyAppliedError, MigrationLockLostError } from './core/errors';
16
-
17
- // Template
18
- export { generateMigrationTemplate } from './templates/migration';
19
-
20
- // Commands (for programmatic use)
21
- export { createMigration } from './commands/create';
22
- export { runMigrations } from './commands/up';
23
- export { rollbackMigrations } from './commands/down';
24
- export { showStatus } from './commands/status';
25
- export { initProject } from './commands/init';
@@ -1,92 +0,0 @@
1
- /**
2
- * Migration template generator
3
- */
4
- export function generateMigrationTemplate(version: string, name: string): string {
5
- return `import { Migration } from "@ftschopp/dynatable-migrations";
6
-
7
- /**
8
- * Migration: ${name}
9
- * Version: ${version}
10
- *
11
- * Description:
12
- * Add description of what this migration does here.
13
- *
14
- * Version type:
15
- * - MAJOR (X.0.0): Breaking changes, incompatible schema changes
16
- * - MINOR (0.X.0): New features, backwards-compatible changes
17
- * - PATCH (0.0.X): Bug fixes, data corrections
18
- */
19
- export const migration: Migration = {
20
- version: "${version}",
21
- name: "${name}",
22
- description: "Add description here",
23
-
24
- /**
25
- * Schema snapshot (optional)
26
- * Document your schema changes here for reference
27
- */
28
- schema: {
29
- // Example:
30
- // User: {
31
- // attributes: {
32
- // username: { type: String, required: true },
33
- // email: { type: String, required: false },
34
- // }
35
- // }
36
- },
37
-
38
- /**
39
- * Apply migration
40
- */
41
- async up({ client, tableName, dynamodb }) {
42
- console.log("Running migration: ${name}");
43
-
44
- // Example: Add a new attribute to existing items
45
- // const { ScanCommand, UpdateCommand } = dynamodb;
46
- //
47
- // const result = await client.send(new ScanCommand({
48
- // TableName: tableName,
49
- // FilterExpression: "begins_with(PK, :pk)",
50
- // ExpressionAttributeValues: { ":pk": "USER#" }
51
- // }));
52
- //
53
- // for (const item of result.Items || []) {
54
- // await client.send(new UpdateCommand({
55
- // TableName: tableName,
56
- // Key: { PK: item.PK, SK: item.SK },
57
- // UpdateExpression: "SET emailVerified = :value",
58
- // ExpressionAttributeValues: { ":value": false }
59
- // }));
60
- // }
61
-
62
- console.log("✅ Migration completed");
63
- },
64
-
65
- /**
66
- * Rollback migration
67
- */
68
- async down({ client, tableName, dynamodb }) {
69
- console.log("Rolling back migration: ${name}");
70
-
71
- // Example: Remove the attribute added in up()
72
- // const { ScanCommand, UpdateCommand } = dynamodb;
73
- //
74
- // const result = await client.send(new ScanCommand({
75
- // TableName: tableName,
76
- // FilterExpression: "begins_with(PK, :pk)",
77
- // ExpressionAttributeValues: { ":pk": "USER#" }
78
- // }));
79
- //
80
- // for (const item of result.Items || []) {
81
- // await client.send(new UpdateCommand({
82
- // TableName: tableName,
83
- // Key: { PK: item.PK, SK: item.SK },
84
- // UpdateExpression: "REMOVE email, emailVerified"
85
- // }));
86
- // }
87
-
88
- console.log("✅ Rollback completed");
89
- },
90
- };
91
- `;
92
- }