From f72ffe1aed824eb9af5e7520529c3ebde029b5a6 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 15 May 2026 13:56:23 +0900 Subject: [PATCH] fix(sdk): reconcile TailorDB migration label after deploy --no-schema-check Previously, running `tailor-sdk deploy --no-schema-check` from a revision whose working tree was older than the remote left the remote migration label stale. The next `deploy` then reconstructed a snapshot at a label that no longer existed in the working tree and aborted with a false "Remote schema drift detected" error. Force the migration label to the working tree's latest migration number when `--no-schema-check` is in effect, restoring the invariant that the label is always reachable from the working tree. --- .../tailordb-migration-drift-recovery.md | 5 + .../commands/deploy/tailordb/index.test.ts | 149 +++++++++++++++++- .../src/cli/commands/deploy/tailordb/index.ts | 62 +++++++- 3 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 .changeset/tailordb-migration-drift-recovery.md diff --git a/.changeset/tailordb-migration-drift-recovery.md b/.changeset/tailordb-migration-drift-recovery.md new file mode 100644 index 000000000..5059ea30f --- /dev/null +++ b/.changeset/tailordb-migration-drift-recovery.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix `tailor-sdk deploy --no-schema-check` to reconcile the TailorDB migration label to the working tree's latest migration number when it completes. Previously, running `deploy --no-schema-check` from a revision whose working tree is older than the remote left the remote migration label stale; the next `deploy` then reconstructed a snapshot at a label that no longer existed in the working tree and aborted with a false "Remote schema drift detected" error. diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts index ee282b4db..6b4563285 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -1,4 +1,7 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; import { sdkNameLabelKey } from "../label"; import { applyTailorDB, formatTailorDBResourceChangeEntries, planTailorDB } from "."; import type { PlanContext } from "../deploy"; @@ -820,3 +823,147 @@ describe("applyTailorDB phase separation", () => { expect(client.deleteTailorDBService).not.toHaveBeenCalled(); }); }); + +describe("applyTailorDB migration label reconciliation (--no-schema-check)", () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "applyTailorDB-reconcile-")); + configPath = path.join(tmpDir, "tailor.config.ts"); + // Working tree latest migration = 0 (only baseline schema.json under 0000/) + const baselineDir = path.join(tmpDir, "0000"); + fs.mkdirSync(baselineDir, { recursive: true }); + fs.writeFileSync( + path.join(baselineDir, "schema.json"), + JSON.stringify({ + version: 1, + namespace: "test-tailordb", + createdAt: new Date().toISOString(), + types: {}, + }), + ); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + function makePlanResult(): Awaited> { + const mockTailorDBService = { + namespace: "test-tailordb", + loadTypes: vi.fn().mockResolvedValue({}), + types: {}, + } as unknown as TailorDBService; + + const config = { + path: configPath, + name: "test-app", + db: { + "test-tailordb": { + files: [], + migration: { directory: "." }, + }, + }, + } as unknown as LoadedConfig; + + return { + changeSet: { + service: { + creates: [], + updates: [], + deletes: [], + title: "TailorDB Services", + isEmpty: () => true, + print: () => {}, + }, + type: { + creates: [], + updates: [], + deletes: [], + title: "TailorDB Types", + isEmpty: () => true, + 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: [mockTailorDBService], + } as unknown as Application, + config, + noSchemaCheck: true, + }, + } as unknown as Awaited>; + } + + test("forces migration label to working_tree_max when label is ahead of working tree", async () => { + // Remote label is m0002 but the working tree only has migration 0000. + // Without reconciliation, the next deploy would reconstruct a snapshot at + // m0002 (which does not exist) and trigger a false drift error. + const getMetadata = vi.fn().mockResolvedValue({ + metadata: { labels: { "sdk-migration": "m0002" } }, + }); + const setMetadata = vi.fn().mockResolvedValue({}); + const client = { + getMetadata, + setMetadata, + createTailorDBService: 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({}), + } as unknown as OperatorClient; + + await applyTailorDB(client, makePlanResult(), "create-update"); + + expect(setMetadata).toHaveBeenCalledTimes(1); + expect(setMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + labels: expect.objectContaining({ "sdk-migration": "m0000" }), + }), + ); + }); + + test("forces migration label even when remote has no prior label", async () => { + const getMetadata = vi.fn().mockResolvedValue({ metadata: { labels: {} } }); + const setMetadata = vi.fn().mockResolvedValue({}); + const client = { + getMetadata, + setMetadata, + createTailorDBService: 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({}), + } as unknown as OperatorClient; + + await applyTailorDB(client, makePlanResult(), "create-update"); + + expect(setMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + labels: expect.objectContaining({ "sdk-migration": "m0000" }), + }), + ); + }); +}); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index 5f9649565..6a1b21a09 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -53,6 +53,7 @@ import { formatMigrationNumber, compareRemoteWithSnapshot, formatSchemaDrifts, + getLatestMigrationNumber, } from "@/cli/commands/tailordb/migrate/snapshot"; import { type TailorDBService } from "@/cli/services/tailordb/service"; import { fetchAll, type OperatorClient } from "@/cli/shared/client"; @@ -241,6 +242,11 @@ function formatRemoteVerificationResults(results: RemoteSchemaVerificationResult // Migration Validation // ============================================================================ +type ValidateAndDetectResult = { + pendingMigrations: PendingMigration[]; + namespacesWithMigrations: NamespaceWithMigrations[]; +}; + /** * Validate migration files and detect pending migrations * @param {OperatorClient} client - Operator client instance @@ -248,7 +254,7 @@ function formatRemoteVerificationResults(results: RemoteSchemaVerificationResult * @param {ReadonlyMap>} typesByNamespace - Types by namespace * @param {LoadedConfig} config - Loaded application config (includes path) * @param {boolean} noSchemaCheck - Whether to skip schema diff check - * @returns {Promise} List of pending migrations + * @returns {Promise} Pending migrations and namespaces that have migration directories configured */ async function validateAndDetectMigrations( client: OperatorClient, @@ -256,7 +262,7 @@ async function validateAndDetectMigrations( typesByNamespace: ReadonlyMap>, config: LoadedConfig, noSchemaCheck: boolean, -): Promise { +): Promise { const configDir = path.dirname(config.path); const namespacesWithMigrations = getNamespacesWithMigrations(config, configDir); let pendingMigrations: PendingMigration[] = []; @@ -337,7 +343,42 @@ async function validateAndDetectMigrations( } } - return pendingMigrations; + return { pendingMigrations, namespacesWithMigrations }; +} + +/** + * Reconcile the on-remote migration label with the working tree's latest + * migration number for each namespace. + * + * Used after a `--no-schema-check` apply: that flag skips the local/remote + * snapshot drift checks, but if it also leaves the label untouched the remote + * label can drift past the working tree's latest migration (e.g. when + * checking out an older revision and re-deploying). A subsequent run would + * then reconstruct the expected snapshot at a label that no longer exists in + * the working tree, triggering a false drift error. + * + * Always force `label = working_tree_max` regardless of the previous label so + * the invariant `label <= working_tree_max` is preserved. + * @param client - Operator client instance + * @param workspaceId - Workspace ID + * @param namespacesWithMigrations - Namespaces that have migration directories configured + */ +async function reconcileMigrationLabels( + client: OperatorClient, + workspaceId: string, + namespacesWithMigrations: NamespaceWithMigrations[], +): Promise { + for (const { namespace, migrationsDir } of namespacesWithMigrations) { + const targetVersion = getLatestMigrationNumber(migrationsDir); + const currentVersion = await getRemoteMigrationNumber(client, workspaceId, namespace); + await updateMigrationLabel(client, workspaceId, namespace, targetVersion); + if (currentVersion !== targetVersion) { + const from = currentVersion === null ? "" : formatMigrationNumber(currentVersion); + logger.info( + `Migration label for namespace ${namespace} reconciled: ${from} → ${formatMigrationNumber(targetVersion)}.`, + ); + } + } } /** @@ -401,7 +442,7 @@ export async function applyTailorDB( } } - const pendingMigrations = await validateAndDetectMigrations( + const { pendingMigrations, namespacesWithMigrations } = await validateAndDetectMigrations( client, migrationContext.workspaceId, typesByNamespace, @@ -519,6 +560,19 @@ export async function applyTailorDB( changeSet.type.deletes.map((del) => client.deleteTailorDBType(del.request)), ); } + + // When schema checks are skipped, force-reconcile the migration label so + // that the invariant `label <= working_tree_max` always holds. Without + // this, a `--no-schema-check` deploy from an older revision can leave a + // stale label that breaks the next snapshot reconstruction (see + // verifyRemoteSchema). + if (migrationContext.noSchemaCheck && namespacesWithMigrations.length > 0) { + await reconcileMigrationLabels( + client, + migrationContext.workspaceId, + namespacesWithMigrations, + ); + } } else if (phase === "delete-resources") { // Delete GQL permissions first, then types await Promise.all(