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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/app-id-rename-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@tailor-platform/sdk": minor
---

Detect app renames via a stable, auto-injected `id` field in `tailor.config.ts`.

The SDK now writes a generated `id: "<uuid>"` field into the
`defineConfig({...})` call on first `deploy`, and stamps every managed
resource with an `sdk-app-id` metadata label. Subsequent deploys identify
ownership by the stable id rather than by the app name, so renaming the
app (or any of its resources) cleanly removes the old resources before
creating the new ones. The id is a plain UUID; the SDK adds the
label-compatible `app-` prefix internally at the metadata boundary.

Deleting the `id` field regenerates a new UUID on the next `deploy` —
typically done after copying `tailor.config.ts` from another project so
the new application does not share the original's id. Existing
resources keep their data and are re-tagged in place; `deploy` shows a
dedicated confirmation prompt for this case ("Application id was
regenerated for ..."), separate from the rename/transfer confirmation.

If your `tailor.config.ts` is a wrapper that re-exports `defineConfig` from
another file, the SDK skips id injection on the wrapper — add the `id`
field manually to the file that contains the actual `defineConfig({...})`
call. Existing deployments without the id continue to work and migrate
transparently on the next `deploy` run.
2 changes: 2 additions & 0 deletions example/tailor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const auth = defineAuth("my-auth", {
});

export default defineConfig({
// SDK-managed app id — do not edit, except when copying this config to a separate app.
id: "d0a3398a-f79c-4c2e-be1e-b81469bb0a43",
Comment thread
toiroakr marked this conversation as resolved.
name: "my-app",
env: {
foo: 1,
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/docs/cli/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t

<!-- politty:command:deploy:global-options-link:end -->

**Config File Modification:**

On first run, `deploy` automatically injects a stable `id: "<uuid>"` field into your `defineConfig({...})` call in `tailor.config.ts`. This UUID is used to track your application across renames so the SDK can recognize ownership across renames. Commit the generated id to version control. See [Configuration](../configuration.md#application-settings) for details.

**Migration Handling:**

When migrations are configured (`db.tailordb.migration` in config), the `deploy` command automatically:
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ For service-specific documentation, see:
import { defineConfig } from "@tailor-platform/sdk";

export default defineConfig({
// SDK-managed app id — do not edit, except when copying this config to a separate app.
// id: "<uuid>" — written here automatically on first run
name: "my-app",
cors: ["https://example.com"],
allowedIpAddresses: ["192.168.1.0/24"],
Expand All @@ -28,6 +30,8 @@ export default defineConfig({

**Name**: Set the application name.

**Id (auto-managed)**: A stable identifier used to recognize resources managed by the SDK across renames. On first `deploy`, the SDK injects an `id: "<uuid>"` field into your `defineConfig({...})` call and commits it to `tailor.config.ts`. Keep it under version control; do not edit it by hand. Delete it only if you want the SDK to assign a new id on the next `deploy` — typically when `tailor.config.ts` was copied from another project and the new application should not share the original's id. If `tailor.config.ts` is just a wrapper that re-exports `defineConfig` from another file, the SDK skips injection on the wrapper — add the `id` field manually to the file that contains the actual `defineConfig({...})` call.

**CORS**: Specify CORS settings as an array. You can also include Static Website URL references (e.g. `website.url`) in this array; see [Static Website](./services/staticwebsite.md).

**Allowed IP Addresses**: Specify IP addresses allowed to access the application in CIDR format.
Expand Down
13 changes: 12 additions & 1 deletion packages/sdk/e2e/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ describe("E2E: Service deletion order", () => {
let client: OperatorClient;
let tempDir: string;
let configCounter = 0;
// Share a single auto-generated app id across all configs created in this
// suite so resources keep being recognized as owned across re-applies, even
// though each apply targets a different config file (a workaround for
// Node.js module caching).
const sharedTestAppId = crypto.randomUUID();

beforeAll(async () => {
// Initialize client (supports both TAILOR_PLATFORM_TOKEN env var and platform config login)
Expand Down Expand Up @@ -86,7 +91,13 @@ describe("E2E: Service deletion order", () => {
function createTestConfig(config: string): string {
configCounter++;
const configPath = path.join(tempDir, `config-${configCounter}.ts`);
fs.writeFileSync(configPath, config);
// Inject the shared id at the top of every defineConfig({...}) call so
// that follow-up applies in the same test still recognize prior resources
// as owned (see sharedTestAppId comment above).
const configWithId = config.includes("id:")
? config
: config.replace(/defineConfig\(\{/, `defineConfig({\n id: "${sharedTestAppId}",`);
fs.writeFileSync(configPath, configWithId);
return configPath;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outDir = process.env.TAILOR_SDK_OUTPUT_DIR ?? path.join(__dirname, "dist");

export default defineConfig({
// SDK-managed app id — do not edit, except when copying this config to a separate app.
id: "47eb65f3-2de9-4279-883f-2db54815ae8a",
name: "test-app",
inlineSourcemap: false,
env: {
Expand Down
129 changes: 121 additions & 8 deletions packages/sdk/src/cli/commands/deploy/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ vi.mock("./label", async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>;
return {
...original,
buildMetaRequest: vi.fn().mockResolvedValue({
trn: "trn:v1:workspace:test-workspace:application:test-app",
labels: {
"sdk-name": "test-app",
"sdk-version": "v1-0-0",
},
}),
buildMetaRequest: vi
.fn()
.mockImplementation(
async ({ trn, appName, appId }: { trn: string; appName: string; appId?: string }) => ({
trn,
labels: {
"sdk-name": appName,
"sdk-version": "v1-0-0",
...(appId ? { "sdk-app-id": `app-${appId}` } : {}),
},
}),
),
};
});

Expand All @@ -40,12 +45,15 @@ const appName = "test-app";

function createMockApplication(
overrides: {
name?: string;
id?: string;
cors?: string[];
staticWebsiteServices?: Array<{ name: string }>;
} = {},
): Application {
return {
name: appName,
name: overrides.name ?? appName,
id: overrides.id,
subgraphs: [
{ Type: "pipeline", Name: "pipeline-a" },
{ Type: "tailordb", Name: "tailordb-a" },
Expand Down Expand Up @@ -79,6 +87,7 @@ function createMockClient(
subgraphs?: Array<{ serviceType: number; serviceNamespace: string }>;
sdkVersion?: string;
label?: string;
sdkAppId?: string;
}>,
): OperatorClient {
return {
Expand All @@ -99,6 +108,7 @@ function createMockClient(
? {
"sdk-name": application.label ?? appName,
"sdk-version": application.sdkVersion ?? "v1-0-0",
...(application.sdkAppId ? { "sdk-app-id": `app-${application.sdkAppId}` } : {}),
}
: {},
},
Expand Down Expand Up @@ -232,6 +242,109 @@ describe("planApplication", () => {
expect(result.unchanged).toHaveLength(0);
});

describe("rename detection via sdk-app-id", () => {
test("creates new app and deletes old when name changed but id matches", async () => {
const appId = "stable-id";
const oldName = "old-app-name";
const client = createMockClient([
{
name: oldName,
authNamespace: "auth-a",
authIdpConfigName: "idp-a",
subgraphs: [
{ serviceType: Subgraph_ServiceType.TAILORDB, serviceNamespace: "tailordb-a" },
{ serviceType: Subgraph_ServiceType.PIPELINE, serviceNamespace: "pipeline-a" },
],
sdkAppId: appId,
},
]);
const application = createMockApplication({ name: appName, id: appId });

const result = await planApplication(createContext(client, application));

expect(result.creates).toHaveLength(1);
expect(result.creates[0].name).toBe(appName);
expect(result.deletes).toHaveLength(1);
expect(result.deletes[0].name).toBe(oldName);
});

test("ignores apps with the same id when name still matches", async () => {
const appId = "stable-id";
const client = createMockClient([
{
name: appName,
authNamespace: "auth-a",
authIdpConfigName: "idp-a",
cors: ["https://a.example.com", "https://b.example.com"],
allowedIpAddresses: ["1.1.1.1", "2.2.2.2"],
disableIntrospection: true,
disabled: false,
subgraphs: [
{ serviceType: Subgraph_ServiceType.TAILORDB, serviceNamespace: "tailordb-a" },
{ serviceType: Subgraph_ServiceType.PIPELINE, serviceNamespace: "pipeline-a" },
],
sdkAppId: appId,
},
]);
const application = createMockApplication({ name: appName, id: appId });

const result = await planApplication(createContext(client, application));

expect(result.unchanged).toHaveLength(1);
expect(result.creates).toHaveLength(0);
expect(result.deletes).toHaveLength(0);
});

test("does not delete unrelated apps when only sdk-name matches a different app", async () => {
const client = createMockClient([
{
name: "other-app",
authNamespace: "auth-a",
authIdpConfigName: "idp-a",
subgraphs: [
{ serviceType: Subgraph_ServiceType.TAILORDB, serviceNamespace: "tailordb-a" },
{ serviceType: Subgraph_ServiceType.PIPELINE, serviceNamespace: "pipeline-a" },
],
label: "other-app",
sdkAppId: "different-id",
},
]);
const application = createMockApplication({ name: appName, id: "stable-id" });

const result = await planApplication(createContext(client, application));

expect(result.creates).toHaveLength(1);
expect(result.deletes).toHaveLength(0);
});

test("forRemoval also deletes id-matched renamed apps", async () => {
const appId = "stable-id";
const oldName = "old-app-name";
const client = createMockClient([
{
name: oldName,
authNamespace: "auth-a",
authIdpConfigName: "idp-a",
subgraphs: [
{ serviceType: Subgraph_ServiceType.TAILORDB, serviceNamespace: "tailordb-a" },
{ serviceType: Subgraph_ServiceType.PIPELINE, serviceNamespace: "pipeline-a" },
],
sdkAppId: appId,
},
]);
const application = createMockApplication({ name: appName, id: appId });

const result = await planApplication({
...createContext(client, application),
forRemoval: true,
});

expect(result.deletes).toHaveLength(1);
expect(result.deletes[0].name).toBe(oldName);
expect(result.creates).toHaveLength(0);
});
});

describe("CORS resolution on first deployment (issue #1030)", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down
77 changes: 68 additions & 9 deletions packages/sdk/src/cli/commands/deploy/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { fetchAll, resolveStaticWebsiteUrls, type OperatorClient } from "@/cli/shared/client";
import { createChangeSet } from "./change-set";
import { areNormalizedEqual } from "./compare";
import { buildMetaRequest, hasMatchingSdkVersion, sdkNameLabelKey } from "./label";
import { buildMetaRequest, hasMatchingSdkVersion, isOwnedByApp } from "./label";
import type { ApplyPhase, PlanContext } from "@/cli/commands/deploy/deploy";
import type { Application } from "@/cli/services/application";
import type {
Expand Down Expand Up @@ -200,12 +200,28 @@ export async function planApplication(context: PlanContext) {
});

if (forRemoval) {
const ownedAppNames = new Set<string>();
if (existingApplications.some((app) => app.name === application.name)) {
ownedAppNames.add(application.name);
}
if (application.id) {
const others = existingApplications.filter((app) => !ownedAppNames.has(app.name));
const owned = await Promise.all(
others.map(async (app) => {
const labels = await fetchAppLabels(client, workspaceId, app.name);
return isOwnedByApp(labels, application.name, application.id) ? app.name : null;
}),
);
for (const name of owned) {
if (name) ownedAppNames.add(name);
}
}
for (const name of ownedAppNames) {
changeSet.deletes.push({
name: application.name,
name,
request: {
workspaceId,
applicationName: application.name,
applicationName: name,
},
});
}
Expand Down Expand Up @@ -250,7 +266,11 @@ export async function planApplication(context: PlanContext) {
authIdpConfigName = idpConfigs[0].name;
}
}
const metaRequest = await buildMetaRequest(trn(workspaceId, application.name), application.name);
const metaRequest = await buildMetaRequest({
trn: trn(workspaceId, application.name),
appName: application.name,
appId: application.id,
});
const expectedLocalWebsites = new Set(
application.staticWebsiteServices.map((website) => website.name),
);
Expand Down Expand Up @@ -279,13 +299,34 @@ export async function planApplication(context: PlanContext) {
};
const existing = existingApplications.find((app) => app.name === application.name);

// Detect renames: other apps owned by our id should be deleted before
// creating/updating the current name (so the old name is freed up).
if (application.id) {
const otherApps = existingApplications.filter((app) => app.name !== application.name);
const renamedAway = await Promise.all(
otherApps.map(async (app) => {
const labels = await fetchAppLabels(client, workspaceId, app.name);
return isOwnedByApp(labels, application.name, application.id) ? app.name : null;
}),
);
for (const name of renamedAway) {
if (name) {
changeSet.deletes.push({
name,
request: {
workspaceId,
applicationName: name,
},
});
}
}
}

if (existing) {
const { metadata } = await client.getMetadata({
trn: trn(workspaceId, application.name),
});
const labels = await fetchAppLabels(client, workspaceId, application.name);
if (
metadata?.labels?.[sdkNameLabelKey] === application.name &&
hasMatchingSdkVersion(metadata?.labels, metaRequest.labels) &&
isOwnedByApp(labels, application.name, application.id) &&
hasMatchingSdkVersion(labels, metaRequest.labels) &&
areApplicationsEqual(existing, desired)
) {
changeSet.unchanged.push({
Expand All @@ -309,6 +350,24 @@ export async function planApplication(context: PlanContext) {
return changeSet;
}

async function fetchAppLabels(
client: OperatorClient,
workspaceId: string,
appName: string,
): Promise<Record<string, string> | undefined> {
try {
const { metadata } = await client.getMetadata({
trn: trn(workspaceId, appName),
});
return metadata?.labels;
} catch (error) {
if (error instanceof ConnectError && error.code === Code.NotFound) {
return undefined;
}
throw error;
}
}

function protoSubgraph(
subgraph: Readonly<{ Type: string; Name: string }>,
): MessageInitShape<typeof SubgraphSchema> {
Expand Down
Loading
Loading