added archiving migrations

This commit is contained in:
johnyeo
2026-05-28 15:32:04 +01:00
committed by Charlie Lamb
parent b29fe7a361
commit 25fcd8f6ac
9 changed files with 192 additions and 11 deletions

View File

@@ -1005,6 +1005,7 @@ export class AutumnInt {
operations?: Operations | null;
retry_failed?: boolean;
no_billing_changes?: boolean;
archived?: boolean;
};
}): Promise<Migration> => {
const data = await this.post(`/migrations.update`, params);

View File

@@ -13,6 +13,7 @@ const PatchMigrationBody = z.object({
operations: OperationsSchema.nullable().optional(),
retry_failed: z.boolean().optional(),
no_billing_changes: z.boolean().nullable().optional(),
archived: z.boolean().optional(),
}),
});

View File

@@ -1,4 +1,11 @@
import { type Migration, migrationItemRuns, migrations } from "@autumn/shared";
import {
ErrCode,
type Migration,
MigrationItemKind,
migrationItemRuns,
migrations,
RecaseError,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { RepoContext } from "@/db/repoContext.js";
@@ -10,15 +17,44 @@ export const deleteMigration = async ({
ctx: RepoContext;
id: string;
}): Promise<Migration | null> => {
const [row] = await ctx.db
.delete(migrations)
const [migration] = await ctx.db
.select()
.from(migrations)
.where(
and(
eq(migrations.id, id),
eq(migrations.org_id, ctx.org.id),
eq(migrations.env, ctx.env),
eq(migrations.archived, false),
),
)
.limit(1);
if (!migration) return null;
const [customerRun] = await ctx.db
.select({ id: migrationItemRuns.migration_item_run_id })
.from(migrationItemRuns)
.where(
and(
eq(migrationItemRuns.migration_internal_id, migration.internal_id),
eq(migrationItemRuns.item_kind, MigrationItemKind.Customer),
eq(migrationItemRuns.dry_run, false),
),
)
.limit(1);
if (customerRun) {
throw new RecaseError({
message: `Migration ${id} has customer run history and cannot be deleted. Archive it instead.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const [row] = await ctx.db
.delete(migrations)
.where(eq(migrations.internal_id, migration.internal_id))
.returning();
if (row) {
await ctx.db

View File

@@ -24,6 +24,7 @@ export const findMigration = async ({
and(
eq(m.org_id, ctx.org.id),
eq(m.env, ctx.env),
eq(m.archived, false),
id !== undefined ? eq(m.id, id) : eq(m.internal_id, internalId!),
),
});

View File

@@ -30,6 +30,7 @@ export const insertMigration = async ({
operations: insert.operations ?? null,
no_billing_changes: insert.no_billing_changes ?? null,
retry_failed: false,
archived: false,
created_at: Date.now(),
updated_at: null,
};

View File

@@ -28,6 +28,7 @@ export const updateMigration = async ({
| "prepared_state"
| "retry_failed"
| "no_billing_changes"
| "archived"
>
>;
}): Promise<Migration | null> => {
@@ -39,6 +40,7 @@ export const updateMigration = async ({
eq(migrations.id, id),
eq(migrations.org_id, ctx.org.id),
eq(migrations.env, ctx.env),
eq(migrations.archived, false),
),
)
.returning();

View File

@@ -1,16 +1,31 @@
import { expect, test } from "bun:test";
import {
ErrCode,
MigrationItemKind,
MigrationItemRunStatus,
migrations,
} from "@autumn/shared";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { CusService } from "@/internal/customers/CusService.js";
import { migrationItemRunRepo } from "@/internal/migrations/v2/repos/index.js";
/**
* TDD coverage for migration draft CRUD used by the dashboard.
*
* Red-failure mode: PATCH strips `updates.no_billing_changes`, so the
* saved migration does not match the dashboard toggle.
*
* Green-success criteria: PATCH persists `no_billing_changes` like create.
* Contract under test:
* New fields:
* - migrations.archived: boolean, default false.
* New behaviors:
* - PATCH /migrations.update accepts updates.archived.
* - POST /migrations.delete hard-deletes migrations with no customer runs.
* - POST /migrations.delete rejects migrations with customer run history.
* Side effects:
* - Rejected deletes keep the migration row and run history unchanged.
*/
import { expect, test } from "bun:test";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(
`${chalk.yellowBright("migrations.update: persists no_billing_changes from dashboard PATCH")}`,
async () => {
@@ -32,3 +47,101 @@ test.concurrent(
expect(updated.no_billing_changes).toBe(true);
},
);
test.concurrent(
`${chalk.yellowBright("migrations.delete: hard deletes drafts that have no customer runs")}`,
async () => {
const customerId = "migrations-delete-draft";
const migrationId = `${customerId}-mig`;
const { autumnV2_2 } = await initScenario({
customerId,
setup: [s.customer()],
actions: [],
});
await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId });
const deleted = await autumnV2_2.migrationsV2.delete({ id: migrationId });
const list = await autumnV2_2.migrationsV2.list();
expect(deleted.id).toBe(migrationId);
expect(deleted.archived).toBe(false);
expect(list.list.some((migration) => migration.id === migrationId)).toBe(false);
},
);
test.concurrent(
`${chalk.yellowBright("migrations.delete: rejects migrations that have customer runs")}`,
async () => {
const customerId = `migrations-delete-reject-${Date.now()}`;
const migrationId = `${customerId}-mig`;
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer()],
actions: [],
});
const migration = await autumnV2_2.migrationsV2.deleteAndCreate({
id: migrationId,
});
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
if (!customer) throw new Error(`Expected customer ${customerId}`);
await migrationItemRunRepo.markSucceeded({
ctx,
migrationInternalId: migration.internal_id,
itemKind: MigrationItemKind.Customer,
itemId: customer.internal_id,
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "has customer run history and cannot be deleted",
func: () => autumnV2_2.migrationsV2.delete({ id: migrationId }),
});
const list = await autumnV2_2.migrationsV2.list();
const preserved = list.list.find((candidate) => candidate.id === migrationId);
expect(preserved).toMatchObject({ id: migrationId, archived: false });
expect(
await migrationItemRunRepo.getCustomer({
ctx,
migrationInternalId: migration.internal_id,
internalCustomerId: customer.internal_id,
}),
).toMatchObject({ status: MigrationItemRunStatus.Succeeded });
},
);
test.concurrent(
`${chalk.yellowBright("migrations.update: persists archived from dashboard PATCH")}`,
async () => {
const customerId = `migrations-update-archived-${Date.now()}`;
const migrationId = `${customerId}-mig`;
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer()],
actions: [],
});
await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId });
const updated = await autumnV2_2.migrationsV2.update({
id: migrationId,
updates: { archived: true },
});
const [row] = await ctx.db
.select()
.from(migrations)
.where(eq(migrations.id, migrationId));
expect(updated.archived).toBe(true);
expect(row?.archived).toBe(true);
},
);

View File

@@ -0,0 +1,25 @@
CREATE TABLE "passkey" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"public_key" text NOT NULL,
"user_id" text NOT NULL,
"credential_id" text NOT NULL,
"counter" integer NOT NULL,
"device_type" text NOT NULL,
"backed_up" boolean NOT NULL,
"transports" text,
"created_at" timestamp with time zone,
"aaguid" text,
CONSTRAINT "passkey_credential_id_unique" UNIQUE("credential_id")
);
--> statement-breakpoint
ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint
ALTER TABLE "migrations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint
CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint
CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;

View File

@@ -47,6 +47,7 @@ export const migrations = pgTable(
// `false` → force Stripe path even when inference would say DB-only.
no_billing_changes: boolean(),
retry_failed: boolean().notNull().default(false),
archived: boolean().notNull().default(false),
created_at: numeric({ mode: "number" }).notNull(),
updated_at: numeric({ mode: "number" }),