Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tailordb-migration-drift-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
149 changes: 148 additions & 1 deletion packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof planTailorDB>> {
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<string>(),
context: {
workspaceId: "test-workspace",
application: {
name: "test-app",
tailorDBServices: [mockTailorDBService],
} as unknown as Application,
config,
noSchemaCheck: true,
},
} as unknown as Awaited<ReturnType<typeof planTailorDB>>;
}

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" }),
}),
);
});
});
62 changes: 58 additions & 4 deletions packages/sdk/src/cli/commands/deploy/tailordb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -241,22 +242,27 @@ 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
* @param {string} workspaceId - Workspace ID
* @param {ReadonlyMap<string, Record<string, TailorDBType>>} typesByNamespace - Types by namespace
* @param {LoadedConfig} config - Loaded application config (includes path)
* @param {boolean} noSchemaCheck - Whether to skip schema diff check
* @returns {Promise<PendingMigration[]>} List of pending migrations
* @returns {Promise<ValidateAndDetectResult>} Pending migrations and namespaces that have migration directories configured
*/
async function validateAndDetectMigrations(
client: OperatorClient,
workspaceId: string,
typesByNamespace: ReadonlyMap<string, Record<string, TailorDBType>>,
config: LoadedConfig,
noSchemaCheck: boolean,
): Promise<PendingMigration[]> {
): Promise<ValidateAndDetectResult> {
const configDir = path.dirname(config.path);
const namespacesWithMigrations = getNamespacesWithMigrations(config, configDir);
let pendingMigrations: PendingMigration[] = [];
Expand Down Expand Up @@ -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<void> {
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 ? "<unset>" : formatMigrationNumber(currentVersion);
logger.info(
`Migration label for namespace ${namespace} reconciled: ${from} → ${formatMigrationNumber(targetVersion)}.`,
);
}
}
}

/**
Expand Down Expand Up @@ -401,7 +442,7 @@ export async function applyTailorDB(
}
}

const pendingMigrations = await validateAndDetectMigrations(
const { pendingMigrations, namespacesWithMigrations } = await validateAndDetectMigrations(
client,
migrationContext.workspaceId,
typesByNamespace,
Expand Down Expand Up @@ -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(
Expand Down
Loading