diff --git a/.changeset/tailordb-migration-sync-command.md b/.changeset/tailordb-migration-sync-command.md new file mode 100644 index 000000000..eaa0ff314 --- /dev/null +++ b/.changeset/tailordb-migration-sync-command.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `tailor-sdk tailordb migration sync `. The new subcommand reconstructs the TailorDB schema snapshot at the given migration number (e.g. `0` for the baseline) and brings the remote in line with it without requiring a `git checkout`. Useful for recovering from drift introduced by an unintended `deploy --no-schema-check`. After syncing, run `tailor-sdk deploy` to catch up the remaining migrations from the working tree. diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index 7412af13b..6f961ef13 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -116,6 +116,7 @@ Commands for managing TailorDB tables, data, and schema migrations. | [tailordb migration generate](./cli/tailordb.md#tailordb-migration-generate) | Generate migration files by detecting schema differences between current local types and the previous migration snapshot. | | [tailordb migration set](./cli/tailordb.md#tailordb-migration-set) | Set migration checkpoint to a specific number. | | [tailordb migration status](./cli/tailordb.md#tailordb-migration-status) | Show the current migration status for TailorDB namespaces, including applied and pending migrations. | +| [tailordb migration sync](./cli/tailordb.md#tailordb-migration-sync) | Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift). | | [tailordb erd export](./cli/tailordb.md#tailordb-erd-export) | Export Liam ERD dist from applied TailorDB schema. | | [tailordb erd serve](./cli/tailordb.md#tailordb-erd-serve) | Generate and serve ERD locally (liam build + serve dist). (beta) | | [tailordb erd deploy](./cli/tailordb.md#tailordb-erd-deploy) | Deploy ERD static website for TailorDB namespace(s). | diff --git a/packages/sdk/docs/cli/tailordb.md b/packages/sdk/docs/cli/tailordb.md index 2706e3a7a..935be23b5 100644 --- a/packages/sdk/docs/cli/tailordb.md +++ b/packages/sdk/docs/cli/tailordb.md @@ -41,6 +41,7 @@ tailor-sdk tailordb [command] See [Global Options](../cli-reference.md#global-options) for options available to all commands. + ### tailordb truncate @@ -156,6 +157,7 @@ tailor-sdk tailordb migration [command] | [`tailordb migration generate`](#tailordb-migration-generate) | Generate migration files by detecting schema differences between current local types and the previous migration snapshot. | | [`tailordb migration set`](#tailordb-migration-set) | Set migration checkpoint to a specific number. | | [`tailordb migration status`](#tailordb-migration-status) | Show the current migration status for TailorDB namespaces, including applied and pending migrations. | +| [`tailordb migration sync`](#tailordb-migration-sync) | Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift). | @@ -296,6 +298,58 @@ See [Global Options](../cli-reference.md#global-options) for options available t + + +#### tailordb migration sync + + + + + +Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift). + + + + + +**Usage** + +``` +tailor-sdk tailordb migration sync [options] +``` + + + + + +**Arguments** + +| Argument | Description | Required | +| -------- | --------------------------------------------- | -------- | +| `number` | Migration number to sync to (e.g., 0001 or 1) | Yes | + + + + + +**Options** + +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | + + + + + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + + + **See also:** For migration concepts, configuration, workflow, and troubleshooting, see the [TailorDB Migrations guide](../services/tailordb-migration.md). diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/cli.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/cli.test.ts index bc580b281..65abca184 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/cli.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { generateCommand, migrationCommand, setCommand, statusCommand } from "./index"; +import { generateCommand, migrationCommand, setCommand, statusCommand, syncCommand } from "./index"; describe("migration CLI commands", () => { describe("migrationCommand", () => { @@ -19,6 +19,10 @@ describe("migration CLI commands", () => { it("should have status subcommand", () => { expect(migrationCommand.subCommands).toHaveProperty("status"); }); + + it("should have sync subcommand", () => { + expect(migrationCommand.subCommands).toHaveProperty("sync"); + }); }); describe("generateCommand", () => { @@ -59,4 +63,18 @@ describe("migration CLI commands", () => { expect(shape).toHaveProperty("namespace"); }); }); + + describe("syncCommand", () => { + it("should have correct meta information", () => { + expect(syncCommand.name).toBe("sync"); + expect(syncCommand.description).toContain("migration snapshot"); + }); + + it("should have required args schema", () => { + const shape = syncCommand.args.shape; + expect(shape).toHaveProperty("number"); + expect(shape).toHaveProperty("namespace"); + expect(shape).toHaveProperty("yes"); + }); + }); }); diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/index.ts b/packages/sdk/src/cli/commands/tailordb/migrate/index.ts index 8601ef8a8..93e4a1665 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/index.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/index.ts @@ -5,12 +5,14 @@ * - generate: Generate migration files from schema differences * - set: Set migration checkpoint to a specific number * - status: Show migration status for TailorDB namespaces + * - sync: Sync remote TailorDB schema to a specific migration snapshot */ import { defineCommand } from "politty"; import { generateCommand } from "./generate"; import { setCommand } from "./set"; import { statusCommand } from "./status"; +import { syncCommand } from "./sync"; export const migrationCommand = defineCommand({ name: "migration", @@ -19,6 +21,7 @@ export const migrationCommand = defineCommand({ generate: generateCommand, set: setCommand, status: statusCommand, + sync: syncCommand, }, }); @@ -28,3 +31,5 @@ export { setCommand } from "./set"; export type { SetOptions } from "./set"; export { statusCommand } from "./status"; export type { StatusOptions } from "./status"; +export { syncCommand } from "./sync"; +export type { SyncOptions } from "./sync"; diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts b/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts new file mode 100644 index 000000000..05b538cbf --- /dev/null +++ b/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts @@ -0,0 +1,260 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import * as path from "pathe"; +import { arg } from "politty"; +import { z } from "zod"; +import { trnPrefix } from "@/cli/commands/deploy/label"; +import { confirmationArgs, deploymentArgs } from "@/cli/shared/args"; +import { logBetaWarning } from "@/cli/shared/beta"; +import { fetchAll, initOperatorClient, type OperatorClient } from "@/cli/shared/client"; +import { defineAppCommand } from "@/cli/shared/command"; +import { loadConfig } from "@/cli/shared/config-loader"; +import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; +import { logger, styles } from "@/cli/shared/logger"; +import { prompt } from "@/cli/shared/prompt"; +import { getNamespacesWithMigrations, type NamespaceWithMigrations } from "./config"; +import { + formatMigrationNumber, + isValidMigrationNumber, + reconstructSnapshotFromMigrations, + getLatestMigrationNumber, +} from "./snapshot"; +import { + compareSnapshotWithRemote, + generateAllTypeManifestsFromSnapshot, +} from "./snapshot-manifest"; +import type { TailorDBType as ProtoTailorDBType } from "@tailor-proto/tailor/v1/tailordb_resource_pb"; + +export interface SyncOptions { + configPath?: string; + number: string; + namespace?: string; + yes?: boolean; + workspaceId?: string; + profile?: string; +} + +function parseMigrationNumberArg(numberStr: string): number { + if (isValidMigrationNumber(numberStr)) { + return parseInt(numberStr, 10); + } + const parsed = parseInt(numberStr, 10); + if (isNaN(parsed) || parsed < 0 || String(parsed) !== numberStr.trimStart().replace(/^0+/, "0")) { + throw new Error( + `Invalid migration number format: ${numberStr}. Expected 4-digit format (e.g., 0001) or integer (e.g., 1).`, + ); + } + return parsed; +} + +async function fetchRemoteTypes( + client: OperatorClient, + workspaceId: string, + namespace: string, +): Promise { + return fetchAll(async (pageToken, maxPageSize) => { + try { + const { tailordbTypes, nextPageToken } = await client.listTailorDBTypes({ + workspaceId, + namespaceName: namespace, + pageToken, + pageSize: maxPageSize, + }); + return [tailordbTypes, nextPageToken]; + } catch (error) { + if (error instanceof ConnectError && error.code === Code.NotFound) { + return [[], ""]; + } + throw error; + } + }); +} + +function selectTargetNamespace( + namespacesWithMigrations: NamespaceWithMigrations[], + requested: string | undefined, +): NamespaceWithMigrations { + if (namespacesWithMigrations.length === 0) { + throw new Error("No TailorDB services with migrations configuration found"); + } + if (requested) { + const found = namespacesWithMigrations.find((ns) => ns.namespace === requested); + if (!found) { + throw new Error(`Namespace "${requested}" not found or does not have migrations configured`); + } + return found; + } + if (namespacesWithMigrations.length > 1) { + throw new Error( + `Multiple TailorDB services found. Please specify namespace with --namespace flag: ${namespacesWithMigrations + .map((ns) => ns.namespace) + .join(", ")}`, + ); + } + return namespacesWithMigrations[0]; +} + +/** + * Sync remote TailorDB schema to a specific migration snapshot. + * + * Reconstructs the schema state at `` from `0000/schema.json` + diffs, + * then issues create/update/delete RPCs so the remote matches that snapshot. + * Updates the migration label to `` on success. + * + * Intended for recovering from drift introduced by `deploy --no-schema-check` + * runs against an older revision: instead of having to `git checkout` that + * revision and re-deploy, the operator can sync the remote back to a known + * snapshot version directly. + * @param options - Command options + */ +async function sync(options: SyncOptions): Promise { + logBetaWarning("tailordb migration"); + + const targetVersion = parseMigrationNumberArg(options.number); + + const { config } = await loadConfig(options.configPath); + const configDir = path.dirname(config.path); + const namespacesWithMigrations = getNamespacesWithMigrations(config, configDir); + const target = selectTargetNamespace(namespacesWithMigrations, options.namespace); + + const latest = getLatestMigrationNumber(target.migrationsDir); + if (targetVersion > latest) { + throw new Error( + `Migration ${formatMigrationNumber(targetVersion)} does not exist in working tree (latest is ${formatMigrationNumber(latest)}).`, + ); + } + + const snapshot = reconstructSnapshotFromMigrations(target.migrationsDir, targetVersion); + if (!snapshot) { + throw new Error( + `No initial schema snapshot found in ${target.migrationsDir}. Expected 0000/schema.json.`, + ); + } + + const accessToken = await loadAccessToken({ + useProfile: false, + profile: options.profile, + }); + const client = await initOperatorClient(accessToken); + const workspaceId = await loadWorkspaceId({ + workspaceId: options.workspaceId, + profile: options.profile, + }); + + const remoteTypes = await fetchRemoteTypes(client, workspaceId, target.namespace); + const existingTypeNames = new Set(remoteTypes.map((t) => t.name)); + const { creates, updates, deletes } = compareSnapshotWithRemote(snapshot, existingTypeNames); + + logger.newline(); + logger.info(`Namespace: ${styles.bold(target.namespace)}`); + logger.log(` Target migration: ${styles.bold(formatMigrationNumber(targetVersion))}`); + logger.log(` Types to create: ${styles.bold(String(creates.length))}`); + logger.log(` Types to update: ${styles.bold(String(updates.length))}`); + logger.log(` Types to delete: ${styles.bold(String(deletes.length))}`); + logger.newline(); + + const totalChanges = creates.length + updates.length + deletes.length; + if (totalChanges === 0) { + // Even with no schema changes, the label may be stale, so still update it. + logger.info("Remote schema already matches the target snapshot."); + } else { + logger.warn( + "This operation will overwrite remote TailorDB types to match the selected snapshot.", + ); + logger.warn("Existing data in deleted types will be lost."); + logger.newline(); + } + + if (!options.yes) { + const confirmation = await prompt.confirm({ + message: `Continue and set migration label to ${formatMigrationNumber(targetVersion)}?`, + default: false, + }); + if (!confirmation) { + logger.info("Operation cancelled."); + return; + } + logger.newline(); + } + + const manifests = generateAllTypeManifestsFromSnapshot(snapshot); + + for (const typeName of creates) { + const tailordbType = manifests.get(typeName); + if (!tailordbType) continue; + await client.createTailorDBType({ + workspaceId, + namespaceName: target.namespace, + tailordbType, + }); + } + for (const typeName of updates) { + const tailordbType = manifests.get(typeName); + if (!tailordbType) continue; + await client.updateTailorDBType({ + workspaceId, + namespaceName: target.namespace, + tailordbType, + }); + } + for (const typeName of deletes) { + await client.deleteTailorDBType({ + workspaceId, + namespaceName: target.namespace, + tailordbTypeName: typeName, + }); + } + + const trn = `${trnPrefix(workspaceId)}:tailordb:${target.namespace}`; + const { metadata } = await client.getMetadata({ trn }); + const existingLabels = metadata?.labels ?? {}; + await client.setMetadata({ + trn, + labels: { + ...existingLabels, + "sdk-migration": `m${formatMigrationNumber(targetVersion)}`, + }, + }); + + logger.success( + `Synced namespace ${styles.bold(target.namespace)} to migration ${styles.bold(formatMigrationNumber(targetVersion))}.`, + ); + + if (targetVersion < latest) { + logger.newline(); + logger.info( + `Run 'tailor-sdk deploy' to apply migrations ${formatMigrationNumber( + targetVersion + 1, + )}–${formatMigrationNumber(latest)} from the working tree.`, + ); + } +} + +export const syncCommand = defineAppCommand({ + name: "sync", + description: + "Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift).", + args: z + .object({ + ...deploymentArgs, + ...confirmationArgs, + number: arg(z.string(), { + positional: true, + description: "Migration number to sync to (e.g., 0001 or 1)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }) + .strict(), + run: async (args) => { + await sync({ + configPath: args.config, + number: args.number, + namespace: args.namespace, + yes: args.yes, + workspaceId: args["workspace-id"], + profile: args.profile, + }); + }, +});