added archiving migrations
This commit is contained in:
1
server/src/external/autumn/autumnCli.ts
vendored
1
server/src/external/autumn/autumnCli.ts
vendored
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
25
shared/drizzle/0001_talented_thor.sql
Normal file
25
shared/drizzle/0001_talented_thor.sql
Normal 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;
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user