diff --git a/.changeset/fix-deploy-drift.md b/.changeset/fix-deploy-drift.md new file mode 100644 index 0000000000..ea3c37b4c7 --- /dev/null +++ b/.changeset/fix-deploy-drift.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix deploy reporting spurious updates on every run: TailorDB types no longer drift when the platform proto gains new fields, IdP services without an explicit userAuthPolicy no longer drift against the platform's default password policy, and deploys with pending migrations no longer silently skip type changes in namespaces that have no migrations diff --git a/.github/workflows/deploy-drift-check.yml b/.github/workflows/deploy-drift-check.yml new file mode 100644 index 0000000000..0e96d25632 --- /dev/null +++ b/.github/workflows/deploy-drift-check.yml @@ -0,0 +1,176 @@ +name: "Deploy Drift Check" + +# Deploys create-sdk templates to a fresh workspace and verifies that an +# immediate dry run reports "Plan: 0 to create, 0 to update, 0 to delete" — a +# redeploy of unchanged code must be a no-op. Guards against comparison drift +# such as newly added proto fields being reported as updates on every deploy. +# example/ gets the same check inside the existing Workspace Deploy Test +# (deploy.yml), which already deploys it on every PR. +# +# The target list intentionally covers config-shape diversity rather than +# every template: example configures everything explicitly, so drift caused by +# the platform filling defaults for OMITTED sections (e.g. IdP userAuthPolicy) +# only reproduces on minimal configs like these. +# +# Targets run as a serial loop in a single job, not a matrix: it keeps the +# platform load at one ephemeral workspace at a time and pays the +# checkout/install/build setup only once. The job is nowhere near the PR +# critical path (Workspace Deploy Test and the e2e suites take far longer), +# so the extra wall-clock over a parallel matrix costs nothing. A failing +# target does not stop the loop — every target reports before the job fails. + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +jobs: + changes: + name: Detect changes + # Skip re-runs triggered by tailor-pr-trigger bot (changeset/generate commits) + if: github.event.action != 'synchronize' || github.event.sender.login != 'tailor-pr-trigger[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + outputs: + should_run: ${{ steps.filter.outputs.changes }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: "false" + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + id: filter + with: + filters: | + changes: + - .github/workflows/deploy-drift-check.yml + - .github/actions/** + - packages/** + - package.json + - tsconfig.json + - pnpm-workspace.yaml + - pnpm-lock.yaml + + drift-check: + needs: changes + if: needs.changes.outputs.should_run == 'true' + runs-on: ubuntu-latest + name: Drift check (templates) + permissions: + contents: read + timeout-minutes: 30 + steps: + - name: checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: "false" + + - name: Install deps + uses: ./.github/actions/install-deps + + - name: Login as machine user + run: pnpm tailor-sdk login --machineuser + env: + TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} + TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} + + - name: Deploy each template and verify a dry run reports no changes + env: + TAILOR_PLATFORM_ORGANIZATION_ID: ${{ secrets.TAILOR_PLATFORM_ORGANIZATION_ID }} + TAILOR_PLATFORM_FOLDER_ID: ${{ secrets.TAILOR_PLATFORM_FOLDER_ID }} + # Fresh checkout deploys a throwaway app; per-run id injection is intended here. + TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: "true" + RUN_ID: ${{ github.run_id }} + run: | + failed=() + + check_target() { + local target="$1" dir="$2" config="$3" + local workspace_id summary + + workspace_id=$(cd "$dir" && pnpm exec tailor-sdk workspace create -n "e2e-ws-${RUN_ID}-drift-${target}" -r us-west --json | tail -1 | jq -r '.id') || workspace_id="" + if [[ -z "$workspace_id" || "$workspace_id" == "null" ]]; then + echo "::error::${target}: failed to create workspace" + return 1 + fi + + local result=0 + if ! (cd "$dir" && pnpm exec tailor-sdk deploy -c "$config" -w "$workspace_id" --yes); then + echo "::error::${target}: deploy failed" + result=1 + else + summary=$(cd "$dir" && pnpm exec tailor-sdk deploy -c "$config" -w "$workspace_id" --dry-run --yes --json | tail -1 | jq -c '.summary') || summary="" + echo "${target} plan summary: $summary" + if ! echo "$summary" | jq -e '.create == 0 and .update == 0 and .delete == 0 and .replace == 0' > /dev/null; then + echo "::error::${target}: dry run after deploy reported drift: $summary" + echo "Re-running without --json for a readable diff:" + (cd "$dir" && pnpm exec tailor-sdk deploy -c "$config" -w "$workspace_id" --dry-run --yes) || true + result=1 + fi + fi + + pnpm exec tailor-sdk workspace delete -w "$workspace_id" -y \ + || echo "::warning::${target}: workspace delete failed (the cleanup step sweeps it)" + return "$result" + } + + while IFS='|' read -r target dir config; do + echo "::group::${target}" + check_target "$target" "$dir" "$config" || failed+=("$target") + echo "::endgroup::" + done <<'TARGETS' + hello-world|packages/create-sdk/templates/hello-world|tailor.config.ts + generators|packages/create-sdk/templates/generators|tailor.config.ts + multi-application|packages/create-sdk/templates/multi-application|apps/user/tailor.config.ts,apps/admin/tailor.config.ts + static-web-site|packages/create-sdk/templates/static-web-site|tailor.config.ts + TARGETS + + if (( ${#failed[@]} > 0 )); then + echo "::error::Deploy drift check failed for: ${failed[*]}" + exit 1 + fi + echo "All templates redeploy as a no-op" + + - name: Cleanup workspaces + if: always() + working-directory: packages/sdk + env: + RUN_ID: ${{ github.run_id }} + run: pnpm exec tsx scripts/cleanup-e2e-workspaces.ts --run-id="${RUN_ID}-drift" + + drift-check-result: + name: Deploy drift check result + needs: [changes, drift-check] + if: >- + always() && + (github.event.action != 'synchronize' || github.event.sender.login != 'tailor-pr-trigger[bot]') + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 5 + steps: + - name: Check result + env: + CHANGES_RESULT: ${{ needs.changes.result }} + SHOULD_RUN: ${{ needs.changes.outputs.should_run }} + DRIFT_CHECK_RESULT: ${{ needs.drift-check.result }} + run: | + # Fail closed: if the paths-filter job itself failed, should_run is + # empty and must not be mistaken for "no relevant changes". + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Changes job did not succeed (result=$CHANGES_RESULT)" + exit 1 + fi + if [[ "$SHOULD_RUN" != "true" ]]; then + echo "Skipped - no relevant changes" + exit 0 + fi + if [[ "$DRIFT_CHECK_RESULT" != "success" ]]; then + echo "Drift check job failed" + exit 1 + fi + echo "Deploy drift check succeeded" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 47a9624341..d58af31f94 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -122,6 +122,22 @@ jobs: echo "✅ All migrations applied successfully" + # A redeploy of unchanged code must be a no-op; anything else is + # comparison drift (e.g. new proto fields read as updates on every run). + - name: Dry run must report no changes + if: runner.os != 'Windows' + working-directory: example + shell: bash + run: | + summary=$(pnpm --silent tailor-sdk deploy -c tailor.config.ts --dry-run --yes --json | tail -1 | jq -c '.summary') + echo "Plan summary: $summary" + if ! echo "$summary" | jq -e '.create == 0 and .update == 0 and .delete == 0 and .replace == 0' > /dev/null; then + echo "::error::Dry run after deploy reported drift: $summary" + echo "Re-running without --json for a readable diff:" + pnpm tailor-sdk deploy -c tailor.config.ts --dry-run --yes + exit 1 + fi + - name: Validate seed data if: runner.os == 'Windows' working-directory: example diff --git a/packages/sdk/src/cli/commands/deploy/compare.test.ts b/packages/sdk/src/cli/commands/deploy/compare.test.ts index 118d760800..300be09bc3 100644 --- a/packages/sdk/src/cli/commands/deploy/compare.test.ts +++ b/packages/sdk/src/cli/commands/deploy/compare.test.ts @@ -1,5 +1,12 @@ +import { create } from "@bufbuild/protobuf"; +import { TailorDBTypeSchema } from "@tailor-platform/tailor-proto/tailordb_resource_pb"; import { describe, expect, test } from "vitest"; -import { areNormalizedEqual, normalizeProtoConfig, stableStringify } from "./compare"; +import { + areNormalizedEqual, + normalizeProtoConfig, + stableStringify, + toComparableProtoJson, +} from "./compare"; describe("compare policy", () => { // Generic compare preserves type distinctions. @@ -15,4 +22,24 @@ describe("compare policy", () => { expect(normalizeProtoConfig({ seconds: 1n })).toEqual({ seconds: "1" }); expect(normalizeProtoConfig({ seconds: 1 })).toEqual({ seconds: 1 }); }); + + test("toComparableProtoJson equates an init shape with its materialized message", () => { + const init = { + name: "Invoice", + schema: { fields: { code: { type: "string", required: true } } }, + }; + // Deserialized messages materialize implicit proto3 fields (e.g. bools + // added to the proto later) with zero values that the init shape omits. + // optionalOnCreate is deliberately named as the canary: it is a field the + // SDK never sets, so it proves materialization happens. + const materialized = create(TailorDBTypeSchema, init); + expect(materialized.schema?.fields.code?.optionalOnCreate).toBe(false); + + expect( + areNormalizedEqual( + toComparableProtoJson(TailorDBTypeSchema, init), + toComparableProtoJson(TailorDBTypeSchema, materialized), + ), + ).toBe(true); + }); }); diff --git a/packages/sdk/src/cli/commands/deploy/compare.ts b/packages/sdk/src/cli/commands/deploy/compare.ts index 9213a03a30..7c43e37cfb 100644 --- a/packages/sdk/src/cli/commands/deploy/compare.ts +++ b/packages/sdk/src/cli/commands/deploy/compare.ts @@ -1,3 +1,24 @@ +import { create, toJson, type DescMessage, type MessageInitShape } from "@bufbuild/protobuf"; + +/** + * Canonicalize a proto message (or init shape) into proto JSON for comparison. + * + * Deserialized messages materialize implicit proto3 fields with their zero + * values while locally built init shapes omit them, so comparing the raw + * objects reports a diff for every field the SDK does not set — including + * fields newly added to the proto. Proto JSON omits implicit zero values on + * both sides, which matches wire semantics (unset == zero value). + * @param schema - Message schema describing the value + * @param value - Message or init shape to canonicalize + * @returns Proto JSON representation of the value + */ +export function toComparableProtoJson( + schema: Desc, + value: MessageInitShape, +): unknown { + return toJson(schema, create(schema, value)); +} + /** * Stable JSON-like serialization that sorts object keys and ignores proto runtime metadata. * @param value - Value to serialize diff --git a/packages/sdk/src/cli/commands/deploy/idp.plan.test.ts b/packages/sdk/src/cli/commands/deploy/idp.plan.test.ts index 4bce1b3249..ee3208f9b2 100644 --- a/packages/sdk/src/cli/commands/deploy/idp.plan.test.ts +++ b/packages/sdk/src/cli/commands/deploy/idp.plan.test.ts @@ -53,6 +53,7 @@ type MockIdpServiceOpts = { clients?: string[]; publishUserEvents?: boolean | undefined; gqlOperations?: Record; + omitUserAuthPolicy?: boolean; }; function createMockApplication(opts?: { @@ -97,6 +98,9 @@ function createMockApplication(opts?: { } else { result.publishUserEvents = true; } + if (service.omitUserAuthPolicy) { + delete result.userAuthPolicy; + } return result; }), } as unknown as Application; @@ -218,6 +222,45 @@ describe("planIdP", () => { expect(result.changeSet.service.unchanged).toHaveLength(0); }); + test("marks idp service unchanged when userAuthPolicy is omitted and remote returns server defaults", async () => { + // The platform fills an omitted userAuthPolicy with its own defaults + // (password_min_length 6 / password_max_length 4096, everything else zero) + // and echoes them back; that must not read as drift. + const client = createMockClient({ + services: [ + createMatchingRemoteService({ + userAuthPolicy: { + useNonEmailIdentifier: false, + allowSelfPasswordReset: false, + passwordRequireUppercase: false, + passwordRequireLowercase: false, + passwordRequireNonAlphanumeric: false, + passwordRequireNumeric: false, + passwordMinLength: 6, + passwordMaxLength: 4096, + allowedEmailDomains: [], + allowGoogleOauth: false, + disablePasswordAuth: false, + allowMicrosoftOauth: false, + enableMfa: false, + requireMfa: false, + allowedReturnOrigins: [], + mfaIssuer: "", + }, + }), + ], + clients: defaultIdpClientSecret, + }); + + const result = await planIdP({ + ...createContext(client), + application: createMockApplication({ idpServices: [{ omitUserAuthPolicy: true }] }), + }); + + expect(result.changeSet.service.updates).toHaveLength(0); + expect(result.changeSet.service.unchanged).toHaveLength(1); + }); + test("marks idp service updated when config matches but ownership metadata is missing", async () => { const client = createMockClient({ services: [createMatchingRemoteService({ label: undefined })], diff --git a/packages/sdk/src/cli/commands/deploy/idp.ts b/packages/sdk/src/cli/commands/deploy/idp.ts index 67427ee83b..d6c0da81f2 100644 --- a/packages/sdk/src/cli/commands/deploy/idp.ts +++ b/packages/sdk/src/cli/commands/deploy/idp.ts @@ -284,8 +284,12 @@ function normalizeComparableUserAuthPolicy( passwordRequireLowercase: policy?.passwordRequireLowercase ?? false, passwordRequireNonAlphanumeric: policy?.passwordRequireNonAlphanumeric ?? false, passwordRequireNumeric: policy?.passwordRequireNumeric ?? false, - passwordMinLength: policy?.passwordMinLength ?? 0, - passwordMaxLength: policy?.passwordMaxLength ?? 0, + // The platform fills an omitted policy with password_min_length 6 and + // password_max_length 4096 and echoes those back; it also coerces an + // explicit 0 to the same defaults, which is why these use || (not ??) — + // every falsy local value must compare equal to the stored defaults. + passwordMinLength: policy?.passwordMinLength || 6, + passwordMaxLength: policy?.passwordMaxLength || 4096, allowedEmailDomains: (policy?.allowedEmailDomains ?? []).toSorted(), allowGoogleOauth: policy?.allowGoogleOauth ?? false, disablePasswordAuth: policy?.disablePasswordAuth ?? false, 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 8c630a4083..f9c602a716 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; +import { create } from "@bufbuild/protobuf"; +import { TailorDBTypeSchema } from "@tailor-platform/tailor-proto/tailordb_resource_pb"; import * as path from "pathe"; import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; import { applyPreMigrationFieldAdjustments } from "#/cli/commands/tailordb/migrate/pre-migration-schema"; @@ -607,6 +609,75 @@ describe("planTailorDB (service level)", () => { expect(result.changeSet.type.unchanged).toHaveLength(0); }); + test("treats a redeploy of the exact type previously sent as unchanged when the platform echoes it as a proto message", async () => { + // Simulates the real client path: the platform stores what the SDK sent + // and returns it as a protobuf-es message, which materializes implicit + // proto3 fields (e.g. a newly added bool like `optionalOnCreate`) with + // their zero values even though the local manifest never sets them. + const tailordbType: TailorDBType = { + name: "Invoice", + pluralForm: "Invoices", + description: "Invoice type", + fields: { + code: { + name: "code", + config: { + type: "string", + required: true, + }, + }, + }, + forwardRelationships: {}, + backwardRelationships: {}, + settings: {}, + permissions: {}, + files: {}, + }; + + const makeCtx = (remoteTypes: unknown[]): PlanContext => { + const tailorDBService = createMockTailorDBService("test-tailordb"); + Object.defineProperty(tailorDBService, "types", { + value: { [tailordbType.name]: tailordbType }, + }); + const client = { + listTailorDBServices: vi.fn().mockResolvedValue({ + tailordbServices: [{ namespace: { name: "test-tailordb" } }], + nextPageToken: "", + }), + listTailorDBTypes: vi.fn().mockResolvedValue({ + tailordbTypes: remoteTypes, + nextPageToken: "", + }), + getMetadata: vi.fn().mockResolvedValue({ + metadata: { labels: { [sdkNameLabelKey]: appName, "sdk-version": "v1-0-0" } }, + }), + listTailorDBGQLPermissions: vi.fn().mockResolvedValue({ + permissions: [], + nextPageToken: "", + }), + } as unknown as OperatorClient; + return { + client, + workspaceId, + application: createMockApplication([tailorDBService]), + forRemoval: false, + config: mockConfig, + noSchemaCheck: true, + }; + }; + + // First plan against an empty workspace captures the exact manifest the + // SDK deploys; the second plan sees it echoed back as a proto message. + const firstPlan = await planTailorDB(makeCtx([])); + const deployedManifest = firstPlan.changeSet.type.creates[0]!.request.tailordbType!; + const remoteMessage = create(TailorDBTypeSchema, deployedManifest); + + const result = await planTailorDB(makeCtx([remoteMessage])); + + expect(result.changeSet.type.updates).toHaveLength(0); + expect(result.changeSet.type.unchanged).toEqual([{ name: "Invoice" }]); + }); + test("treats an omitted remote field description as unchanged against the local empty-string manifest", async () => { const tailordbType: TailorDBType = { name: "Event", diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index 3e6399205e..2537efc36a 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -11,7 +11,7 @@ import { } from "@tailor-platform/tailor-proto/tailordb_pb"; import { type TailorDBType as ProtoTailorDBType, - type TailorDBTypeSchema, + TailorDBTypeSchema, } from "@tailor-platform/tailor-proto/tailordb_resource_pb"; import * as path from "pathe"; import { @@ -56,7 +56,7 @@ import { fetchAllTolerant, type OperatorClient } from "#/cli/shared/client"; import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { createChangeSet, type HasName, type ChangeSet } from "../change-set"; -import { areNormalizedEqual, normalizeProtoConfig } from "../compare"; +import { areNormalizedEqual, normalizeProtoConfig, toComparableProtoJson } from "../compare"; import { ACTION_SYMBOLS, type DisplayAction, type GroupedDisplayEntry } from "../grouped-display"; import { buildMetaRequest, hasMatchingSdkVersion, resourceTrn } from "../label"; import { @@ -584,6 +584,39 @@ export async function applyTailorDB( // Step 1: Create/update services once at the beginning (services don't need per-migration handling) await executeServicesCreation(client, changeSet); + // Step 1.5: The migration loop below only touches the namespaces of the + // pending migrations; changes planned for every other namespace must go + // through the normal flow or they would be silently dropped. + // Creates/updates run before the loop so migration scripts see the + // complete world; deletes are irreversible and stay last (Step 5). + const migratingNamespaces = new Set(pendingMigrations.map((m) => m.namespace)); + const isOutsideMigrations = (namespaceName: string | undefined) => + namespaceName !== undefined && !migratingNamespaces.has(namespaceName); + + try { + for (const create of changeSet.type.creates) { + if (!isOutsideMigrations(create.request.namespaceName)) continue; + await client.createTailorDBType(create.request); + } + for (const update of changeSet.type.updates) { + if (!isOutsideMigrations(update.request.namespaceName)) continue; + await client.updateTailorDBType(update.request); + } + } catch (error) { + handleOptionalToRequiredError(error, [ + "Run 'tailor-sdk tailordb migration generate' to create migration files.", + "Migration scripts allow you to handle existing data before applying the schema change.", + ]); + } + await Promise.all([ + ...changeSet.gqlPermission.creates + .filter((create) => isOutsideMigrations(create.request.namespaceName)) + .map((create) => client.createTailorDBGQLPermission(create.request)), + ...changeSet.gqlPermission.updates + .filter((update) => isOutsideMigrations(update.request.namespaceName)) + .map((update) => client.updateTailorDBGQLPermission(update.request)), + ]); + const migrationsRequiringScripts = pendingMigrations.filter((m) => m.hasScript); // Step 2: Build migration context for script execution (if any migrations require scripts) @@ -669,6 +702,18 @@ export async function applyTailorDB( ), ); } + + // Step 5: Delete types outside the migrating namespaces (their GQL + // permissions were just removed above; migration postPhases never see them) + await Promise.all( + changeSet.type.deletes + .filter( + (del) => + isOutsideMigrations(del.request.namespaceName) && + !deletedResources.types.has(del.name), + ) + .map((del) => client.deleteTailorDBType(del.request)), + ); } else { // Normal create-update flow without migrations // Services @@ -1725,8 +1770,9 @@ const tailordbCompareKnownDefaults = { ]), } as const; -function normalizeComparableTailorDBType(type: unknown) { - const normalized = normalizeProtoConfig(type) as { +function normalizeComparableTailorDBType(type: MessageInitShape) { + const canonical = toComparableProtoJson(TailorDBTypeSchema, type); + const normalized = normalizeProtoConfig(canonical) as { name?: string; schema?: { description?: string; @@ -1804,7 +1850,8 @@ function normalizeTailorDBCompareValue( if ( path.at(-1) === "disableGqlOperations" && - areNormalizedEqual(normalizedObject, tailordbCompareKnownDefaults.disableGqlOperations) + (Object.keys(normalizedObject).length === 0 || + areNormalizedEqual(normalizedObject, tailordbCompareKnownDefaults.disableGqlOperations)) ) { return undefined; } diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/migration-unrelated-namespace.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/migration-unrelated-namespace.test.ts new file mode 100644 index 0000000000..0b871ee526 --- /dev/null +++ b/packages/sdk/src/cli/commands/deploy/tailordb/migration-unrelated-namespace.test.ts @@ -0,0 +1,275 @@ +/** + * When some namespace has pending migrations, the whole apply takes the + * migration flow — but that flow must not drop changes planned for OTHER + * namespaces that have no pending migration (e.g. a fresh deploy where the + * main namespace runs its baseline migration while a second namespace has + * plain types to create). + */ + +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), + lines: () => [], + }), + }; +}); + +// 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", + }, + ]), +})); + +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().mockReturnValue({ + version: 1, + namespace: "test-ns", + createdAt: "2026-01-01T00:00:00.000Z", + types: { + User: { + name: "User", + pluralForm: "users", + fields: { name: { type: "string", required: true } }, + }, + }, + }), + }; +}); + +import * as migrationModule from "./migration"; + +const mockConfig = { path: "/test/tailor.config.ts" } as LoadedConfig; + +describe("migration flow: namespaces without pending migrations", () => { + 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 typeRequest(namespaceName: string, typeName: string) { + return { + workspaceId: "test-workspace", + namespaceName, + tailordbType: { + name: typeName, + schema: { + fields: { name: { type: "string", required: true } }, + }, + }, + }; + } + + function createMockPlanResult() { + const migratedService = { + namespace: "test-ns", + loadTypes: vi.fn().mockResolvedValue({}), + types: {}, + } as unknown as TailorDBService; + const plainService = { + namespace: "analytics-ns", + loadTypes: vi.fn().mockResolvedValue({}), + types: {}, + } as unknown as TailorDBService; + + return { + changeSet: { + service: { + creates: [], + updates: [], + deletes: [], + title: "TailorDB Services", + isEmpty: () => true, + lines: () => [], + }, + type: { + creates: [ + { + name: "Event", + request: typeRequest("analytics-ns", "Event"), + }, + ], + updates: [ + { + name: "Session", + request: typeRequest("analytics-ns", "Session"), + }, + ], + deletes: [ + { + name: "Legacy", + request: { + workspaceId: "test-workspace", + namespaceName: "analytics-ns", + tailordbTypeName: "Legacy", + }, + }, + ], + title: "TailorDB Types", + isEmpty: () => false, + lines: () => [], + }, + gqlPermission: { + creates: [ + { + name: "Event", + request: { + workspaceId: "test-workspace", + namespaceName: "analytics-ns", + typeName: "Event", + permission: {}, + }, + }, + ], + updates: [], + deletes: [], + title: "TailorDB GQL Permissions", + isEmpty: () => false, + lines: () => [], + }, + }, + conflicts: [], + unmanaged: [], + resourceOwners: new Set(), + context: { + workspaceId: "test-workspace", + application: { + name: "test-app", + tailorDBServices: [migratedService, plainService], + 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 mkPendingMigration(): PendingMigration { + return { + number: 0, + scriptPath: "/test/migrations/0000/migrate.ts", + diffPath: "/test/migrations/0000/diff.json", + namespace: "test-ns", + migrationsDir: "/test/migrations", + hasScript: false, + diff: { + version: 1, + namespace: "test-ns", + createdAt: "2026-01-01T00:00:00.000Z", + changes: [ + { + kind: "type_added", + typeName: "User", + }, + ], + hasBreakingChanges: false, + breakingChanges: [], + requiresMigrationScript: false, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("applies type and gqlPermission changes of a namespace without pending migrations", async () => { + const client = createMockClient(); + const planResult = createMockPlanResult(); + + vi.mocked(migrationModule.detectPendingMigrations).mockResolvedValue([mkPendingMigration()]); + + await applyTailorDB(client, planResult, "create-update"); + + const createdTypes = vi.mocked(client.createTailorDBType).mock.calls.map((call) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const request = call[0] as any; + return `${request.namespaceName}/${request.tailordbType?.name}`; + }); + expect(createdTypes).toContain("analytics-ns/Event"); + + const updatedTypes = vi.mocked(client.updateTailorDBType).mock.calls.map((call) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const request = call[0] as any; + return `${request.namespaceName}/${request.tailordbType?.name}`; + }); + expect(updatedTypes).toContain("analytics-ns/Session"); + + const createdPermissions = vi + .mocked(client.createTailorDBGQLPermission) + .mock.calls.map((call) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const request = call[0] as any; + return `${request.namespaceName}/${request.typeName}`; + }); + expect(createdPermissions).toContain("analytics-ns/Event"); + + const deletedTypes = vi.mocked(client.deleteTailorDBType).mock.calls.map((call) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const request = call[0] as any; + return `${request.namespaceName}/${request.tailordbTypeName}`; + }); + expect(deletedTypes).toContain("analytics-ns/Legacy"); + }); +});