From 93c16590eb21537ded9272ec12ecc683b61de04e Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Thu, 16 Jul 2026 23:01:39 +0900 Subject: [PATCH 1/5] fix(cli): make redeploy of unchanged code a no-op Deploy plans flagged every TailorDB type as an update on each run because remote types deserialized by protobuf-es materialize implicit proto3 fields (e.g. the newly added optional_on_create bool) with zero values that locally built manifests omit. Canonicalize both sides through proto JSON before comparing so implicit defaults drop out symmetrically and future proto field additions stop showing up as drift. Deploys with pending migrations also silently skipped type and gqlPermission changes planned for namespaces without migrations; apply those through the normal flow alongside the migration loop. Add a Deploy Drift Check workflow that deploys example and every create-sdk template to a fresh workspace and fails when an immediate dry run reports any change. --- .changeset/fix-deploy-drift.md | 5 + .github/workflows/deploy-drift-check.yml | 171 +++++++++++ .../src/cli/commands/deploy/compare.test.ts | 27 +- .../sdk/src/cli/commands/deploy/compare.ts | 21 ++ .../commands/deploy/tailordb/index.test.ts | 71 +++++ .../src/cli/commands/deploy/tailordb/index.ts | 56 +++- .../migration-unrelated-namespace.test.ts | 275 ++++++++++++++++++ 7 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-deploy-drift.md create mode 100644 .github/workflows/deploy-drift-check.yml create mode 100644 packages/sdk/src/cli/commands/deploy/tailordb/migration-unrelated-namespace.test.ts diff --git a/.changeset/fix-deploy-drift.md b/.changeset/fix-deploy-drift.md new file mode 100644 index 0000000000..c0f216f24a --- /dev/null +++ b/.changeset/fix-deploy-drift.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix deploy reporting spurious TailorDB type updates on every run when the platform proto gains new fields, and fix deploys with pending migrations silently skipping 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..f59d1e7341 --- /dev/null +++ b/.github/workflows/deploy-drift-check.yml @@ -0,0 +1,171 @@ +name: "Deploy Drift Check" + +# Deploys each app 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. + +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/** + - example/** + - 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 (${{ matrix.target }}) + permissions: + contents: read + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - target: example + dir: example + - target: hello-world + dir: packages/create-sdk/templates/hello-world + - target: tailordb + dir: packages/create-sdk/templates/tailordb + - target: resolver + dir: packages/create-sdk/templates/resolver + - target: executor + dir: packages/create-sdk/templates/executor + - target: workflow + dir: packages/create-sdk/templates/workflow + - target: generators + dir: packages/create-sdk/templates/generators + - target: inventory-management + dir: packages/create-sdk/templates/inventory-management + - target: multi-application + dir: packages/create-sdk/templates/multi-application + config: apps/user/tailor.config.ts,apps/admin/tailor.config.ts + - target: static-web-site + dir: packages/create-sdk/templates/static-web-site + 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: Create workspace + id: workspace + working-directory: ${{ matrix.dir }} + env: + TAILOR_PLATFORM_ORGANIZATION_ID: ${{ secrets.TAILOR_PLATFORM_ORGANIZATION_ID }} + TAILOR_PLATFORM_FOLDER_ID: ${{ secrets.TAILOR_PLATFORM_FOLDER_ID }} + WORKSPACE_NAME: e2e-ws-${{ github.run_id }}-drift-${{ matrix.target }} + run: | + workspace_id=$(pnpm exec tailor-sdk workspace create -n "$WORKSPACE_NAME" -r us-west --json | tail -1 | jq -r '.id') + if [[ -z "$workspace_id" || "$workspace_id" == "null" ]]; then + echo "Failed to create workspace" + exit 1 + fi + echo "id=$workspace_id" >> "$GITHUB_OUTPUT" + + - name: Deploy + working-directory: ${{ matrix.dir }} + env: + # Fresh checkout deploys a throwaway app; per-run id injection is intended here. + TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: "true" + WORKSPACE_ID: ${{ steps.workspace.outputs.id }} + CONFIG: ${{ matrix.config || 'tailor.config.ts' }} + run: pnpm exec tailor-sdk deploy -c "$CONFIG" -w "$WORKSPACE_ID" --yes + + - name: Dry run must report no changes + working-directory: ${{ matrix.dir }} + env: + WORKSPACE_ID: ${{ steps.workspace.outputs.id }} + CONFIG: ${{ matrix.config || 'tailor.config.ts' }} + run: | + summary=$(pnpm exec tailor-sdk deploy -c "$CONFIG" -w "$WORKSPACE_ID" --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 exec tailor-sdk deploy -c "$CONFIG" -w "$WORKSPACE_ID" --dry-run --yes + exit 1 + fi + + - name: Cleanup workspace + if: always() + working-directory: packages/sdk + env: + RUN_ID: ${{ github.run_id }} + TARGET: ${{ matrix.target }} + run: pnpm exec tsx scripts/cleanup-e2e-workspaces.ts --run-id="${RUN_ID}-drift-${TARGET}" + + 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/packages/sdk/src/cli/commands/deploy/compare.test.ts b/packages/sdk/src/cli/commands/deploy/compare.test.ts index 118d760800..6b5df66880 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,22 @@ 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. + 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/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..e757a6e952 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,37 @@ 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. + 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 +700,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 @@ -1726,7 +1769,11 @@ const tailordbCompareKnownDefaults = { } as const; function normalizeComparableTailorDBType(type: unknown) { - const normalized = normalizeProtoConfig(type) as { + const canonical = toComparableProtoJson( + TailorDBTypeSchema, + type as MessageInitShape, + ); + const normalized = normalizeProtoConfig(canonical) as { name?: string; schema?: { description?: string; @@ -1804,7 +1851,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"); + }); +}); From 4097bb37246af4428ea90bf2cee12672e4535efd Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 17 Jul 2026 10:21:47 +0900 Subject: [PATCH 2/5] fix(cli): align IdP userAuthPolicy comparison with platform defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An IdP service deployed without an explicit userAuthPolicy is stored with the platform's default password policy (password_min_length 6, password_max_length 4096) and echoed back with those values, while the comparison fallback filled 0/0 — so such IdP services were flagged as an update on every deploy. Align the comparison fallbacks with the platform defaults; the platform also coerces an explicit 0 to the same defaults, so falsy values map identically on both sides. Also fold the example drift check into the existing Workspace Deploy Test (which already deploys example on every PR) instead of deploying it a second time in the Deploy Drift Check workflow, which now covers only the create-sdk templates. --- .changeset/fix-deploy-drift.md | 2 +- .github/workflows/deploy-drift-check.yml | 13 +++--- .github/workflows/deploy.yml | 16 +++++++ .../src/cli/commands/deploy/idp.plan.test.ts | 43 +++++++++++++++++++ packages/sdk/src/cli/commands/deploy/idp.ts | 7 ++- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/.changeset/fix-deploy-drift.md b/.changeset/fix-deploy-drift.md index c0f216f24a..ea3c37b4c7 100644 --- a/.changeset/fix-deploy-drift.md +++ b/.changeset/fix-deploy-drift.md @@ -2,4 +2,4 @@ "@tailor-platform/sdk": patch --- -Fix deploy reporting spurious TailorDB type updates on every run when the platform proto gains new fields, and fix deploys with pending migrations silently skipping type changes in namespaces that have no migrations +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 index f59d1e7341..7529ef8304 100644 --- a/.github/workflows/deploy-drift-check.yml +++ b/.github/workflows/deploy-drift-check.yml @@ -1,9 +1,11 @@ name: "Deploy Drift Check" -# Deploys each app 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. +# Deploys each create-sdk template 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. on: pull_request: @@ -37,7 +39,6 @@ jobs: - .github/workflows/deploy-drift-check.yml - .github/actions/** - packages/** - - example/** - package.json - tsconfig.json - pnpm-workspace.yaml @@ -55,8 +56,6 @@ jobs: fail-fast: false matrix: include: - - target: example - dir: example - target: hello-world dir: packages/create-sdk/templates/hello-world - target: tailordb 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/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..52c3e4c186 100644 --- a/packages/sdk/src/cli/commands/deploy/idp.ts +++ b/packages/sdk/src/cli/commands/deploy/idp.ts @@ -284,8 +284,11 @@ 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; align the comparison + // fallbacks so an omitted local policy matches the stored defaults. + passwordMinLength: policy?.passwordMinLength || 6, + passwordMaxLength: policy?.passwordMaxLength || 4096, allowedEmailDomains: (policy?.allowedEmailDomains ?? []).toSorted(), allowGoogleOauth: policy?.allowGoogleOauth ?? false, disablePasswordAuth: policy?.disablePasswordAuth ?? false, From aaf875753e57a440d1daf729ef29d3735407d5a6 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 17 Jul 2026 10:36:25 +0900 Subject: [PATCH 3/5] ci: narrow deploy drift check matrix to config-shape-diverse templates All nine templates deployed per PR, but most duplicate resource kinds already covered by the example deploy check. Keep the four whose config shapes example cannot cover: hello-world (minimal), generators (omitted IdP userAuthPolicy + plugins), multi-application (multi-config deploy), and static-web-site (website URL resolution + auth). --- .github/workflows/deploy-drift-check.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-drift-check.yml b/.github/workflows/deploy-drift-check.yml index 7529ef8304..3e3adc5d89 100644 --- a/.github/workflows/deploy-drift-check.yml +++ b/.github/workflows/deploy-drift-check.yml @@ -1,11 +1,16 @@ name: "Deploy Drift Check" -# Deploys each create-sdk template to a fresh workspace and verifies that an +# 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 matrix 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. on: pull_request: @@ -56,23 +61,17 @@ jobs: fail-fast: false matrix: include: + # Minimal config: almost everything omitted - target: hello-world dir: packages/create-sdk/templates/hello-world - - target: tailordb - dir: packages/create-sdk/templates/tailordb - - target: resolver - dir: packages/create-sdk/templates/resolver - - target: executor - dir: packages/create-sdk/templates/executor - - target: workflow - dir: packages/create-sdk/templates/workflow + # IdP without userAuthPolicy + plugins (caught the IdP default-policy drift) - target: generators dir: packages/create-sdk/templates/generators - - target: inventory-management - dir: packages/create-sdk/templates/inventory-management + # Multi-config deploy - target: multi-application dir: packages/create-sdk/templates/multi-application config: apps/user/tailor.config.ts,apps/admin/tailor.config.ts + # Static website URL resolution + auth/oauth2 combination - target: static-web-site dir: packages/create-sdk/templates/static-web-site steps: From 9346a85f56525ba21a62e9d89c521a9332efaa4b Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 17 Jul 2026 10:47:47 +0900 Subject: [PATCH 4/5] ci: run deploy drift check targets serially in one job A matrix spun up four runners and four concurrent ephemeral workspaces for a check that is nowhere near the PR critical path. Loop over the targets in a single job instead: one workspace at a time against the platform, one checkout/install/build setup, and every target still reports before the job fails. --- .github/workflows/deploy-drift-check.yml | 115 ++++++++++++----------- 1 file changed, 61 insertions(+), 54 deletions(-) diff --git a/.github/workflows/deploy-drift-check.yml b/.github/workflows/deploy-drift-check.yml index 3e3adc5d89..0e96d25632 100644 --- a/.github/workflows/deploy-drift-check.yml +++ b/.github/workflows/deploy-drift-check.yml @@ -7,10 +7,17 @@ name: "Deploy Drift Check" # example/ gets the same check inside the existing Workspace Deploy Test # (deploy.yml), which already deploys it on every PR. # -# The matrix 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) +# 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: @@ -53,27 +60,10 @@ jobs: needs: changes if: needs.changes.outputs.should_run == 'true' runs-on: ubuntu-latest - name: Drift check (${{ matrix.target }}) + name: Drift check (templates) permissions: contents: read timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - # Minimal config: almost everything omitted - - target: hello-world - dir: packages/create-sdk/templates/hello-world - # IdP without userAuthPolicy + plugins (caught the IdP default-policy drift) - - target: generators - dir: packages/create-sdk/templates/generators - # Multi-config deploy - - target: multi-application - dir: packages/create-sdk/templates/multi-application - config: apps/user/tailor.config.ts,apps/admin/tailor.config.ts - # Static website URL resolution + auth/oauth2 combination - - target: static-web-site - dir: packages/create-sdk/templates/static-web-site steps: - name: checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -89,52 +79,69 @@ jobs: 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: Create workspace - id: workspace - working-directory: ${{ matrix.dir }} + - 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 }} - WORKSPACE_NAME: e2e-ws-${{ github.run_id }}-drift-${{ matrix.target }} - run: | - workspace_id=$(pnpm exec tailor-sdk workspace create -n "$WORKSPACE_NAME" -r us-west --json | tail -1 | jq -r '.id') - if [[ -z "$workspace_id" || "$workspace_id" == "null" ]]; then - echo "Failed to create workspace" - exit 1 - fi - echo "id=$workspace_id" >> "$GITHUB_OUTPUT" - - - name: Deploy - working-directory: ${{ matrix.dir }} - env: # Fresh checkout deploys a throwaway app; per-run id injection is intended here. TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: "true" - WORKSPACE_ID: ${{ steps.workspace.outputs.id }} - CONFIG: ${{ matrix.config || 'tailor.config.ts' }} - run: pnpm exec tailor-sdk deploy -c "$CONFIG" -w "$WORKSPACE_ID" --yes - - - name: Dry run must report no changes - working-directory: ${{ matrix.dir }} - env: - WORKSPACE_ID: ${{ steps.workspace.outputs.id }} - CONFIG: ${{ matrix.config || 'tailor.config.ts' }} + RUN_ID: ${{ github.run_id }} run: | - summary=$(pnpm exec tailor-sdk deploy -c "$CONFIG" -w "$WORKSPACE_ID" --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 exec tailor-sdk deploy -c "$CONFIG" -w "$WORKSPACE_ID" --dry-run --yes + 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 workspace + - name: Cleanup workspaces if: always() working-directory: packages/sdk env: RUN_ID: ${{ github.run_id }} - TARGET: ${{ matrix.target }} - run: pnpm exec tsx scripts/cleanup-e2e-workspaces.ts --run-id="${RUN_ID}-drift-${TARGET}" + run: pnpm exec tsx scripts/cleanup-e2e-workspaces.ts --run-id="${RUN_ID}-drift" drift-check-result: name: Deploy drift check result From eac00084684200620e28f9f018ef28488b6c00a3 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 17 Jul 2026 12:47:57 +0900 Subject: [PATCH 5/5] refactor(cli): tighten drift-fix types and document non-obvious intent Type normalizeComparableTailorDBType's parameter instead of casting inside, spell out why the IdP password-length fallbacks use || (the platform coerces an explicit 0 to its defaults), and note the ordering intent of the migration-flow step that applies non-migrating namespaces. --- packages/sdk/src/cli/commands/deploy/compare.test.ts | 2 ++ packages/sdk/src/cli/commands/deploy/idp.ts | 5 +++-- packages/sdk/src/cli/commands/deploy/tailordb/index.ts | 9 ++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/cli/commands/deploy/compare.test.ts b/packages/sdk/src/cli/commands/deploy/compare.test.ts index 6b5df66880..300be09bc3 100644 --- a/packages/sdk/src/cli/commands/deploy/compare.test.ts +++ b/packages/sdk/src/cli/commands/deploy/compare.test.ts @@ -30,6 +30,8 @@ describe("compare policy", () => { }; // 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); diff --git a/packages/sdk/src/cli/commands/deploy/idp.ts b/packages/sdk/src/cli/commands/deploy/idp.ts index 52c3e4c186..d6c0da81f2 100644 --- a/packages/sdk/src/cli/commands/deploy/idp.ts +++ b/packages/sdk/src/cli/commands/deploy/idp.ts @@ -285,8 +285,9 @@ function normalizeComparableUserAuthPolicy( passwordRequireNonAlphanumeric: policy?.passwordRequireNonAlphanumeric ?? false, passwordRequireNumeric: policy?.passwordRequireNumeric ?? false, // The platform fills an omitted policy with password_min_length 6 and - // password_max_length 4096 and echoes those back; align the comparison - // fallbacks so an omitted local policy matches the stored defaults. + // 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(), diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index e757a6e952..2537efc36a 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -587,6 +587,8 @@ export async function applyTailorDB( // 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); @@ -1768,11 +1770,8 @@ const tailordbCompareKnownDefaults = { ]), } as const; -function normalizeComparableTailorDBType(type: unknown) { - const canonical = toComparableProtoJson( - TailorDBTypeSchema, - type as MessageInitShape, - ); +function normalizeComparableTailorDBType(type: MessageInitShape) { + const canonical = toComparableProtoJson(TailorDBTypeSchema, type); const normalized = normalizeProtoConfig(canonical) as { name?: string; schema?: {