diff --git a/.changeset/fix-migration-prephase-snapshot.md b/.changeset/fix-migration-prephase-snapshot.md new file mode 100644 index 000000000..dab5fd6f9 --- /dev/null +++ b/.changeset/fix-migration-prephase-snapshot.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix `tailor deploy` so an intermediate migration's data script can still read fields that a later migration removes. Each migration's pre/post phase now submits the schema state reconstructed up to that migration (initial baseline + diffs through N), instead of the FINAL post-all-migrations schema. Previously, removals declared in later migrations leaked into earlier migrations' pre-phase and caused `field 'X' not found` failures at script execution time. diff --git a/packages/sdk/src/cli/commands/deploy/deploy.test.ts b/packages/sdk/src/cli/commands/deploy/deploy.test.ts index 4d3e625e9..0a30ecf14 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.test.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.test.ts @@ -48,6 +48,7 @@ function emptyResults(): PlanResults { workspaceId: "ws", application: {} as PlanResults["tailorDB"]["context"]["application"], tailorDBInputs: [], + executorUsedTypes: new Set(), config: {} as PlanResults["tailorDB"]["context"]["config"], noSchemaCheck: false, }, diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index a641a07d1..17db9a399 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -59,6 +59,7 @@ import { createSnapshotType, getLatestMigrationNumber, isSnapshotFieldRefOperand, + type SchemaSnapshot, type SnapshotFieldConfig, type TailorDBSnapshotType, type SnapshotRecordPermission, @@ -95,7 +96,6 @@ import type { RemoteSchemaVerificationResult, } from "@/cli/commands/tailordb/migrate/types"; import type { LoadedConfig } from "@/cli/shared/config-loader"; -import type { Executor } from "@/types/executor.generated"; import type { GqlOperations, TailorDBServiceConfig } from "@/types/tailordb.generated"; import type { SetMetadataRequestSchema } from "@tailor-proto/tailor/v1/metadata_pb"; @@ -457,6 +457,7 @@ export async function applyTailorDB( // Reset tracking state for this migration run processedTypes.reset(); deletedResources.reset(); + migrationSnapshotCache.reset(); // Step 1: Create/update services once at the beginning (services don't need per-migration handling) await executeServicesCreation(client, changeSet); @@ -477,7 +478,13 @@ export async function applyTailorDB( for (const migration of pendingMigrations) { // Pre-migration phase: Create/update types with breaking fields as optional - await executeSingleMigrationPrePhase(client, changeSet, migration); + await executeSingleMigrationPrePhase( + client, + changeSet, + migration, + migrationContext.tailorDBInputs, + migrationContext.executorUsedTypes, + ); // Script execution (only if migrate.ts exists for this migration) if (migration.hasScript && migrationCtx) { @@ -485,7 +492,13 @@ export async function applyTailorDB( } // Post-migration phase: Apply final types (required: true) and deletions - await executeSingleMigrationPostPhase(client, changeSet, migration); + await executeSingleMigrationPostPhase( + client, + changeSet, + migration, + migrationContext.tailorDBInputs, + migrationContext.executorUsedTypes, + ); // Update migration label only after all phases complete successfully await updateMigrationLabel( @@ -678,17 +691,75 @@ const processedTypes = { }, }; +/** + * Snapshot cache for per-migration schema lookups during a single apply run. + * + * Only the initial baseline `0000/schema.json` is stored on disk; later migrations + * ship `diff.json` only. To get the schema state AFTER migration N we replay the + * initial snapshot through all diffs up to N via `reconstructSnapshotFromMigrations`. + * Results are memoized per (namespace, migration number) for the apply run. + */ +const migrationSnapshotCache = { + cache: new Map(), + reset() { + this.cache.clear(); + }, + load(migration: PendingMigration): SchemaSnapshot { + const key = `${migration.namespace}/${migration.number}`; + let snapshot = this.cache.get(key); + if (!snapshot) { + const reconstructed = reconstructSnapshotFromMigrations( + migration.migrationsDir, + migration.number, + ); + if (!reconstructed) { + throw new Error( + `Cannot reconstruct snapshot for ${migration.namespace} migration ${migration.number}: no migrations found in ${migration.migrationsDir}`, + ); + } + snapshot = reconstructed; + this.cache.set(key, snapshot); + } + return snapshot; + }, +}; + +/** + * Build the TailorDBType manifest for `typeName` from migration N's snapshot. + * @param migration - The pending migration whose snapshot to consult + * @param typeName - The type name to look up in the snapshot + * @param tailorDBInputs - Deploy inputs, used to resolve namespace gqlOperations + * @param executorUsedTypes - Types used by executors (drives publishRecordEvents default) + * @returns The manifest, or undefined if `typeName` is not in that snapshot. + */ +function buildSnapshotTypeManifest( + migration: PendingMigration, + typeName: string, + tailorDBInputs: ReadonlyArray, + executorUsedTypes: ReadonlySet, +): MessageInitShape | undefined { + const snapshot = migrationSnapshotCache.load(migration); + const snapshotType = snapshot.types[typeName]; + if (!snapshotType) return undefined; + const input = tailorDBInputs.find((i) => i.namespace === migration.namespace); + return generateTailorDBTypeManifest(snapshotType, executorUsedTypes, input?.config.gqlOperations); +} + /** * Execute pre-migration phase for a single migration * @param {OperatorClient} client - Operator client instance * @param {TailorDBChangeSet} changeSet - TailorDB change set * @param {PendingMigration} migration - Single pending migration + * @param tailorDBInputs - Deploy inputs, used to resolve namespace gqlOperations for the snapshot + * @param executorUsedTypes - Types used by executors (drives publishRecordEvents default) * @returns {Promise} Promise that resolves when pre-migration phase completes */ async function executeSingleMigrationPrePhase( client: OperatorClient, changeSet: TailorDBChangeSet, migration: PendingMigration, + tailorDBInputs: ReadonlyArray, + executorUsedTypes: ReadonlySet, ): Promise { // Build pre-migration changes map for this single migration. Includes both // breaking changes (required-add, unique-add, enum value removal) and the @@ -697,9 +768,7 @@ async function executeSingleMigrationPrePhase( const affectedTypes = getAffectedTypeNames(migration); const createdBeforeMigration = new Set(processedTypes.created); - // Types - create/update only types affected by this migration await Promise.all([ - // Create types that are affected by this migration and haven't been created yet ...changeSet.type.creates .filter((create) => { const typeName = create.request.tailordbType?.name; @@ -707,17 +776,17 @@ async function executeSingleMigrationPrePhase( }) .map((create) => { const typeName = create.request.tailordbType?.name; + const snapshotType = typeName + ? buildSnapshotTypeManifest(migration, typeName, tailorDBInputs, executorUsedTypes) + : undefined; + if (!snapshotType) return undefined; if (typeName) processedTypes.created.add(typeName); - const typeChanges = typeName ? preMigrationChanges.get(typeName) : undefined; - - if (!typeChanges || typeChanges.size === 0) { - return client.createTailorDBType(create.request); - } - - // Clone request to avoid modifying the original changeSet const clonedRequest = structuredClone(create.request); - if (clonedRequest.tailordbType?.schema?.fields) { + clonedRequest.tailordbType = snapshotType; + + const typeChanges = typeName ? preMigrationChanges.get(typeName) : undefined; + if (typeChanges && typeChanges.size > 0 && clonedRequest.tailordbType?.schema?.fields) { applyPreMigrationFieldAdjustments(clonedRequest.tailordbType.schema.fields, typeChanges); } @@ -731,27 +800,22 @@ async function executeSingleMigrationPrePhase( }) .map((create) => { const typeName = create.request.tailordbType?.name; + const snapshotType = typeName + ? buildSnapshotTypeManifest(migration, typeName, tailorDBInputs, executorUsedTypes) + : undefined; + if (!snapshotType) return undefined; if (typeName) processedTypes.updated.add(typeName); + const clonedTypeRequest = structuredClone(snapshotType); const typeChanges = typeName ? preMigrationChanges.get(typeName) : undefined; - - if (!typeChanges || typeChanges.size === 0) { - return client.updateTailorDBType({ - workspaceId: create.request.workspaceId, - namespaceName: create.request.namespaceName, - tailordbType: create.request.tailordbType, - }); - } - - const clonedRequest = structuredClone(create.request); - if (clonedRequest.tailordbType?.schema?.fields) { - applyPreMigrationFieldAdjustments(clonedRequest.tailordbType.schema.fields, typeChanges); + if (typeChanges && typeChanges.size > 0 && clonedTypeRequest.schema?.fields) { + applyPreMigrationFieldAdjustments(clonedTypeRequest.schema.fields, typeChanges); } return client.updateTailorDBType({ workspaceId: create.request.workspaceId, namespaceName: create.request.namespaceName, - tailordbType: clonedRequest.tailordbType, + tailordbType: clonedTypeRequest, }); }), // Update types that are affected by this migration @@ -762,17 +826,17 @@ async function executeSingleMigrationPrePhase( }) .map((update) => { const typeName = update.request.tailordbType?.name; + const snapshotType = typeName + ? buildSnapshotTypeManifest(migration, typeName, tailorDBInputs, executorUsedTypes) + : undefined; + if (!snapshotType) return undefined; if (typeName) processedTypes.updated.add(typeName); - const typeChanges = typeName ? preMigrationChanges.get(typeName) : undefined; - - if (!typeChanges || typeChanges.size === 0) { - return client.updateTailorDBType(update.request); - } - - // Clone request to avoid modifying the original changeSet const clonedRequest = structuredClone(update.request); - if (clonedRequest.tailordbType?.schema?.fields) { + clonedRequest.tailordbType = snapshotType; + + const typeChanges = typeName ? preMigrationChanges.get(typeName) : undefined; + if (typeChanges && typeChanges.size > 0 && clonedRequest.tailordbType?.schema?.fields) { applyPreMigrationFieldAdjustments(clonedRequest.tailordbType.schema.fields, typeChanges); } @@ -839,12 +903,16 @@ const deletedResources = { * @param {OperatorClient} client - Operator client instance * @param {TailorDBChangeSet} changeSet - TailorDB change set * @param {PendingMigration} migration - Single pending migration + * @param tailorDBInputs - Deploy inputs, used to resolve namespace gqlOperations for the snapshot + * @param executorUsedTypes - Types used by executors (drives publishRecordEvents default) * @returns {Promise} Promise that resolves when post-migration phase completes */ async function executeSingleMigrationPostPhase( client: OperatorClient, changeSet: TailorDBChangeSet, migration: PendingMigration, + tailorDBInputs: ReadonlyArray, + executorUsedTypes: ReadonlySet, ): Promise { // Re-use the pre-migration changes map to know which types were touched in // this migration (so we send the post-phase final-schema update for them). @@ -852,30 +920,48 @@ async function executeSingleMigrationPostPhase( const affectedTypes = getAffectedTypeNames(migration); const deletedTypeNames = getDeletedTypeNames(migration); - // Types - apply final schema values for types affected by this migration - // Pre-migration used cloned requests, so the original changeSet still has correct values + // Types - apply schema as of migration N (= snapshot[N]) with all breaking + // changes enforced. The prePhase sent the same schema with breaking fields + // relaxed; here we send it again without relaxation so required/unique/etc. + // take effect after the data script has reconciled records. try { await Promise.all([ - // For newly created types that had pre-migration adjustments in this migration, send update with final values + // For newly created types that had pre-migration adjustments in this migration, send update with snapshot[N] values ...changeSet.type.creates .filter((create) => { const typeName = create.request.tailordbType?.name; return typeName && affectedTypes.has(typeName) && preMigrationChanges.has(typeName); }) - .map((create) => - client.updateTailorDBType({ + .map((create) => { + const typeName = create.request.tailordbType?.name; + const snapshotType = typeName + ? buildSnapshotTypeManifest(migration, typeName, tailorDBInputs, executorUsedTypes) + : undefined; + if (!snapshotType) return undefined; + return client.updateTailorDBType({ workspaceId: create.request.workspaceId, namespaceName: create.request.namespaceName, - tailordbType: create.request.tailordbType, - }), - ), - // For updated types affected by this migration, send update with final values + tailordbType: snapshotType, + }); + }), + // For updated types affected by this migration, send update with snapshot[N] values ...changeSet.type.updates .filter((update) => { const typeName = update.request.tailordbType?.name; return typeName && affectedTypes.has(typeName) && preMigrationChanges.has(typeName); }) - .map((update) => client.updateTailorDBType(update.request)), + .map((update) => { + const typeName = update.request.tailordbType?.name; + const snapshotType = typeName + ? buildSnapshotTypeManifest(migration, typeName, tailorDBInputs, executorUsedTypes) + : undefined; + if (!snapshotType) return undefined; + return client.updateTailorDBType({ + workspaceId: update.request.workspaceId, + namespaceName: update.request.namespaceName, + tailordbType: snapshotType, + }); + }), ]); } catch (error) { handleOptionalToRequiredError(error, [ @@ -973,6 +1059,12 @@ export async function planTailorDB(context: PlanContext) { const executors = forRemoval ? [] : Object.values((await application.executorService?.loadExecutors()) ?? {}); + const executorUsedTypes = new Set(); + for (const executor of executors) { + if (executor.trigger.kind === "tailordb") { + executorUsedTypes.add(executor.trigger.typeName); + } + } const { changeSet: serviceChangeSet, @@ -982,7 +1074,15 @@ export async function planTailorDB(context: PlanContext) { } = await planServices(client, workspaceId, application.name, application.id, tailordbs); const deletedServices = serviceChangeSet.deletes.map((del) => del.name); const [typeChangeSet, gqlPermissionChangeSet] = await Promise.all([ - planTypes(client, workspaceId, tailordbs, executors, deletedServices, undefined, forceApplyAll), + planTypes( + client, + workspaceId, + tailordbs, + executorUsedTypes, + deletedServices, + undefined, + forceApplyAll, + ), planGqlPermissions(client, workspaceId, tailordbs, deletedServices, forceApplyAll), ]); @@ -999,6 +1099,7 @@ export async function planTailorDB(context: PlanContext) { workspaceId, application, tailorDBInputs: tailordbs, + executorUsedTypes, config, noSchemaCheck: noSchemaCheck ?? false, }, @@ -1266,7 +1367,7 @@ async function planTypes( client: OperatorClient, workspaceId: string, tailordbs: ReadonlyArray, - executors: ReadonlyArray, + executorUsedTypes: ReadonlySet, deletedServices: ReadonlyArray, filteredTypesByNamespace?: Map>, forceApplyAll = false, @@ -1292,13 +1393,6 @@ async function planTypes( }); }; - const executorUsedTypes = new Set(); - for (const executor of executors) { - if (executor.trigger.kind === "tailordb") { - executorUsedTypes.add(executor.trigger.typeName); - } - } - // Validate that types used by executors don't have publishEvents explicitly set to false for (const tailordb of tailordbs) { const types = filteredTypesByNamespace?.get(tailordb.namespace) ?? tailordb.types; diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/migration-bulk-schema-bug.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/migration-bulk-schema-bug.test.ts new file mode 100644 index 000000000..55043406f --- /dev/null +++ b/packages/sdk/src/cli/commands/deploy/tailordb/migration-bulk-schema-bug.test.ts @@ -0,0 +1,326 @@ +/** + * Per-migration prePhase must submit the schema state as of that migration, + * not the FINAL (post-all-migrations) schema. Removals declared in migration + * M must not leak into the prePhase of any earlier migration N (N < M); + * deletions are owned by M's postPhase only. + * + * See `services/tailordb-migration.md` ยง"Per-migration phases". + */ + +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { applyTailorDB } from "./index"; +import type { PendingMigration } from "@/cli/commands/tailordb/migrate/types"; +import type { Application } from "@/cli/services/application"; +import type { TailorDBService } from "@/cli/services/tailordb/service"; +import type { OperatorClient } from "@/cli/shared/client"; +import type { LoadedConfig } from "@/cli/shared/config-loader"; + +// Mock label.ts to suppress real metadata building +vi.mock("../label", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await importOriginal()) as typeof import("../label"); + return { + ...original, + buildMetaRequest: vi.fn().mockResolvedValue({ + trn: "trn:v1:workspace:test-workspace:tailordb:test-ns", + labels: {}, + }), + }; +}); + +// Mock createChangeSet to suppress output in tests +vi.mock("../change-set", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await importOriginal()) as typeof import("../change-set"); + return { + ...original, + createChangeSet: (title: string) => ({ + ...original.createChangeSet(title), + print: () => {}, + }), + }; +}); + +// Mock the migration helpers so applyTailorDB enters the migration flow without +// touching the filesystem or the remote workspace. +vi.mock("./migration", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await importOriginal()) as typeof import("./migration"); + return { + ...original, + detectPendingMigrations: vi.fn(), + executeMigrations: vi.fn().mockResolvedValue(undefined), + updateMigrationLabel: vi.fn().mockResolvedValue(undefined), + }; +}); + +// Mock migration config / snapshot helpers (called inside validateAndDetectMigrations) +vi.mock("@/cli/commands/tailordb/migrate/config", () => ({ + getNamespacesWithMigrations: vi.fn().mockReturnValue([ + { + namespace: "test-ns", + migrationsDir: "/test/migrations", + }, + ]), +})); + +const snapshotFixtures = vi.hoisted(() => { + const buildUser = ( + fields: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => ({ + name: "User", + pluralForm: "users", + fields, + }); + + const userAfterMigration1 = buildUser({ + name: { type: "string", required: true }, + permissions: { type: "string", required: false, array: true }, + roles: { type: "string", required: true, array: true }, + }); + + const userAfterMigration5 = buildUser({ + name: { type: "string", required: true }, + permissions: { type: "string", required: false, array: true }, + }); + + const baseSnapshot = + (typesByMigration: Record) => (migrationsDir: string, maxVersion?: number) => { + void migrationsDir; + const number = maxVersion ?? 0; + const types = typesByMigration[number]; + if (!types) { + throw new Error(`No snapshot fixture configured for migration number: ${number}`); + } + return { + version: 1 as const, + namespace: "test-ns", + createdAt: new Date().toISOString(), + types, + }; + }; + + return { + reconstructSnapshotFromMigrations: baseSnapshot({ + 1: { User: userAfterMigration1 }, + 5: { User: userAfterMigration5 }, + }), + }; +}); + +vi.mock("@/cli/commands/tailordb/migrate/snapshot", async (importOriginal) => { + const original = + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + (await importOriginal()) as typeof import("@/cli/commands/tailordb/migrate/snapshot"); + return { + ...original, + assertValidMigrationFiles: vi.fn(), + reconstructSnapshotFromMigrations: vi.fn(snapshotFixtures.reconstructSnapshotFromMigrations), + }; +}); + +import * as migrationModule from "./migration"; + +const mockConfig = { path: "/test/tailor.config.ts" } as LoadedConfig; + +describe("per-migration prePhase: schema is scoped to migration[N]", () => { + function createMockClient() { + return { + createTailorDBService: vi.fn().mockResolvedValue({}), + setMetadata: vi.fn().mockResolvedValue({}), + createTailorDBType: vi.fn().mockResolvedValue({}), + updateTailorDBType: vi.fn().mockResolvedValue({}), + createTailorDBGQLPermission: vi.fn().mockResolvedValue({}), + updateTailorDBGQLPermission: vi.fn().mockResolvedValue({}), + deleteTailorDBGQLPermission: vi.fn().mockResolvedValue({}), + deleteTailorDBType: vi.fn().mockResolvedValue({}), + deleteTailorDBService: vi.fn().mockResolvedValue({}), + } as unknown as OperatorClient; + } + + function createMockPlanResult() { + const mockService = { + namespace: "test-ns", + loadTypes: vi.fn().mockResolvedValue({}), + types: {}, + } as unknown as TailorDBService; + + const finalUserTypeRequest = { + workspaceId: "test-workspace", + namespaceName: "test-ns", + tailordbType: { + name: "User", + schema: { + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "name", type: "string", required: true }, + { name: "permissions", type: "string", required: false, array: true }, + ], + }, + }, + }; + + return { + changeSet: { + service: { + creates: [], + updates: [], + deletes: [], + title: "TailorDB Services", + isEmpty: () => true, + print: () => {}, + }, + type: { + creates: [], + updates: [ + { + name: "User", + request: finalUserTypeRequest, + }, + ], + deletes: [], + title: "TailorDB Types", + isEmpty: () => false, + print: () => {}, + }, + gqlPermission: { + creates: [], + updates: [], + deletes: [], + title: "TailorDB GQL Permissions", + isEmpty: () => true, + print: () => {}, + }, + }, + conflicts: [], + unmanaged: [], + resourceOwners: new Set(), + context: { + workspaceId: "test-workspace", + application: { + name: "test-app", + tailorDBServices: [mockService], + authService: undefined, + } as unknown as Application, + tailorDBInputs: [], + executorUsedTypes: new Set(), + config: mockConfig, + noSchemaCheck: true, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + function mkAddFieldMigration( + number: number, + typeName: string, + fieldName: string, + ): PendingMigration { + return { + number, + scriptPath: `/test/migrations/${String(number).padStart(4, "0")}/migrate.ts`, + diffPath: `/test/migrations/${String(number).padStart(4, "0")}/diff.json`, + namespace: "test-ns", + migrationsDir: "/test/migrations", + diff: { + version: 1, + namespace: "test-ns", + createdAt: new Date().toISOString(), + changes: [ + { + kind: "field_added", + typeName, + fieldName, + after: { type: "string", required: false, array: true }, + }, + ], + hasBreakingChanges: false, + breakingChanges: [], + requiresMigrationScript: false, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + function mkRemoveFieldMigration( + number: number, + typeName: string, + fieldName: string, + ): PendingMigration { + return { + number, + scriptPath: `/test/migrations/${String(number).padStart(4, "0")}/migrate.ts`, + diffPath: `/test/migrations/${String(number).padStart(4, "0")}/diff.json`, + namespace: "test-ns", + migrationsDir: "/test/migrations", + diff: { + version: 1, + namespace: "test-ns", + createdAt: new Date().toISOString(), + changes: [ + { + kind: "field_removed", + typeName, + fieldName, + before: { type: "string", required: true, array: true }, + }, + ], + hasBreakingChanges: false, + breakingChanges: [], + requiresMigrationScript: false, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("per-migration semantics: migration #1 prePhase must NOT apply removals declared in later migration #5", async () => { + const client = createMockClient(); + const planResult = createMockPlanResult(); + + vi.mocked(migrationModule.detectPendingMigrations).mockResolvedValue([ + mkAddFieldMigration(1, "User", "permissions"), + mkRemoveFieldMigration(5, "User", "roles"), + ]); + + await applyTailorDB(client, planResult, "create-update"); + + const updateCalls = vi.mocked(client.updateTailorDBType).mock.calls; + expect(updateCalls.length).toBeGreaterThanOrEqual(1); + + const firstCall = updateCalls[0]; + expect(firstCall).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sentSchema = (firstCall![0] as any)?.tailordbType?.schema; + expect(sentSchema).toBeDefined(); + + // `fields` is a Record keyed by field name (id is implicit and excluded). + const fieldNames = Object.keys(sentSchema.fields ?? {}); + + expect(fieldNames).toContain("permissions"); + expect(fieldNames).toContain("name"); + expect(fieldNames).toContain("roles"); + }); + + test("verification: only User-affecting migrations trigger updateTailorDBType for User", async () => { + const client = createMockClient(); + const planResult = createMockPlanResult(); + + vi.mocked(migrationModule.detectPendingMigrations).mockResolvedValue([ + mkAddFieldMigration(1, "SomeOtherType", "foo"), + ]); + + await applyTailorDB(client, planResult, "create-update"); + + const updateCalls = vi.mocked(client.updateTailorDBType).mock.calls; + const userUpdates = updateCalls.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c) => (c[0] as any)?.tailordbType?.name === "User", + ); + expect(userUpdates).toHaveLength(0); + }); +});