Skip to content

Commit 1492de3

Browse files
authored
fix patch release data upgrades (#405)
1 parent 28ea43c commit 1492de3

6 files changed

Lines changed: 166 additions & 16 deletions

File tree

.changeset/data-upgrade-paths.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Fix local data upgrade paths for legacy OAuth connection backfills and OpenAPI inline credential rewrites.

.skills/cli-release/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ Does **not** ship in the CLI:
2626

2727
- Prior convention in this repo uses **`patch`** bumps for feature-heavy releases (see `.changeset/executor-1.4.6-beta.md` for precedent). Don't push back on patch unless there are genuine SemVer-breaking API changes to a library consumer surface.
2828
- Breaking CLI UX changes (removed flags, changed argv shape) have historically still been `patch` bumps. Follow the owner's call — ask, don't assume `minor`.
29-
- Only `apps/cli/package.json` version should move during Version Packages PRs. `@executor-js/*` library packages have their own publish path.
29+
- Normal release/patch PRs must add a `.changeset/*.md` file with frontmatter like `"executor": patch`. Do **not** directly bump `apps/cli/package.json` or `bun.lock` in a feature/fix PR.
30+
- Only the Changesets-generated `Version Packages` PR should move `apps/cli/package.json`. If a normal PR directly changes that version, merging it to `main` can make `.github/workflows/release.yml` tag the commit and dispatch `publish-executor-package.yml`, causing an immediate CLI publish.
31+
- `@executor-js/*` library packages have their own publish path.
3032

3133
## Release notes: curated, not auto-generated
3234

apps/cli/release-notes/next.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Tool dispatch, plugins, storage, schema, and transport are now fully instrumente
5050
- Per-scope blob and secret lookups now use a single `IN` query instead of N per-scope round-trips.
5151

5252
## Fixes
53+
- Upgrade: preserve legacy OAuth connection backfills after the `connection.kind` column is removed.
54+
- OpenAPI: refreshing or editing sources with legacy inline secret/OAuth config now materializes the new source binding rows instead of dropping credentials.
5355
- Keychain: skip provider registration when the OS backend is unreachable (no more startup failure when running headless on Linux without a keyring).
5456
- Local server: return 404 for missing static assets instead of serving HTML.
5557
- Tests: Windows compatibility across the suite.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
2+
import { Database } from "bun:sqlite";
3+
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
4+
import { drizzle } from "drizzle-orm/bun-sqlite";
5+
import { mkdtempSync, rmSync } from "node:fs";
6+
import { tmpdir } from "node:os";
7+
import { join } from "node:path";
8+
9+
import { migrateLegacyConnections } from "./migrate-connections";
10+
11+
let workDir: string;
12+
13+
beforeEach(() => {
14+
workDir = mkdtempSync(join(tmpdir(), "executor-migrate-connections-"));
15+
});
16+
17+
afterEach(() => {
18+
rmSync(workDir, { recursive: true, force: true });
19+
});
20+
21+
const columnNames = (db: Database, table: string): ReadonlyArray<string> =>
22+
(
23+
db.prepare(`PRAGMA table_info('${table}')`).all() as ReadonlyArray<{
24+
readonly name: string;
25+
}>
26+
).map((column) => column.name);
27+
28+
describe("migrateLegacyConnections", () => {
29+
it("backfills legacy MCP OAuth rows after connection.kind has been dropped", async () => {
30+
const db = new Database(join(workDir, "data.db"));
31+
migrate(drizzle(db), {
32+
migrationsFolder: join(import.meta.dirname, "../../drizzle"),
33+
});
34+
35+
expect(columnNames(db, "connection")).not.toContain("kind");
36+
37+
const now = Date.now();
38+
db.prepare(
39+
"INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)",
40+
).run("scope-1", "access-token", "Access token", "keychain", now);
41+
db.prepare(
42+
"INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)",
43+
).run("scope-1", "refresh-token", "Refresh token", "keychain", now);
44+
db.prepare(
45+
"INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)",
46+
).run(
47+
"scope-1",
48+
"remote-mcp",
49+
"Remote MCP",
50+
JSON.stringify({
51+
transport: "remote",
52+
endpoint: "https://example.com/mcp",
53+
auth: {
54+
kind: "oauth2",
55+
accessTokenSecretId: "access-token",
56+
refreshTokenSecretId: "refresh-token",
57+
tokenType: "Bearer",
58+
expiresAt: null,
59+
scope: "read",
60+
clientInformation: null,
61+
authorizationServerUrl: null,
62+
resourceMetadataUrl: null,
63+
},
64+
}),
65+
now,
66+
);
67+
68+
await migrateLegacyConnections(db);
69+
70+
const connection = db
71+
.prepare(
72+
"SELECT id, provider, access_token_secret_id, refresh_token_secret_id FROM connection WHERE scope_id = ?",
73+
)
74+
.get("scope-1") as
75+
| {
76+
readonly id: string;
77+
readonly provider: string;
78+
readonly access_token_secret_id: string;
79+
readonly refresh_token_secret_id: string | null;
80+
}
81+
| undefined;
82+
expect(connection).toEqual({
83+
id: "mcp-oauth2-remote-mcp",
84+
provider: "mcp:oauth2",
85+
access_token_secret_id: "access-token",
86+
refresh_token_secret_id: "refresh-token",
87+
});
88+
89+
const source = db
90+
.prepare("SELECT config FROM mcp_source WHERE scope_id = ? AND id = ?")
91+
.get("scope-1", "remote-mcp") as { readonly config: string };
92+
expect(JSON.parse(source.config).auth).toEqual({
93+
kind: "oauth2",
94+
connectionId: "mcp-oauth2-remote-mcp",
95+
});
96+
97+
const ownedSecrets = db
98+
.prepare(
99+
"SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? ORDER BY id",
100+
)
101+
.all("scope-1");
102+
expect(ownedSecrets).toEqual([
103+
{
104+
id: "access-token",
105+
owned_by_connection_id: "mcp-oauth2-remote-mcp",
106+
},
107+
{
108+
id: "refresh-token",
109+
owned_by_connection_id: "mcp-oauth2-remote-mcp",
110+
},
111+
]);
112+
113+
db.close();
114+
});
115+
});

apps/local/src/server/migrate-connections.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ const tableExists = (sqlite: Database, name: string): boolean => {
5353
return row !== null && row !== undefined;
5454
};
5555

56+
const columnExists = (sqlite: Database, table: string, column: string): boolean => {
57+
const columns = sqlite
58+
.prepare(`PRAGMA table_info('${table.replaceAll("'", "''")}')`)
59+
.all() as ReadonlyArray<{ readonly name: string }>;
60+
return columns.some((c) => c.name === column);
61+
};
62+
5663
type SecretRow = { id: string; owned_by_connection_id: string | null };
5764

5865
/** Shared: re-parent the pointed-to secret ids to the new connection,
@@ -133,20 +140,30 @@ const insertConnectionRow = (
133140
providerState: unknown;
134141
},
135142
): void => {
136-
const stmt = sqlite.prepare(
137-
`INSERT INTO connection (
138-
id, scope_id, provider, kind, identity_label,
139-
access_token_secret_id, refresh_token_secret_id,
140-
expires_at, scope, provider_state,
141-
created_at, updated_at
142-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
143-
);
143+
const hasKind = columnExists(sqlite, "connection", "kind");
144+
const stmt = hasKind
145+
? sqlite.prepare(
146+
`INSERT INTO connection (
147+
id, scope_id, provider, kind, identity_label,
148+
access_token_secret_id, refresh_token_secret_id,
149+
expires_at, scope, provider_state,
150+
created_at, updated_at
151+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
152+
)
153+
: sqlite.prepare(
154+
`INSERT INTO connection (
155+
id, scope_id, provider, identity_label,
156+
access_token_secret_id, refresh_token_secret_id,
157+
expires_at, scope, provider_state,
158+
created_at, updated_at
159+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
160+
);
144161
const now = Date.now();
145-
stmt.run(
162+
const values = [
146163
params.id,
147164
params.scopeId,
148165
params.provider,
149-
"user",
166+
...(hasKind ? ["user"] : []),
150167
params.identityLabel,
151168
params.accessTokenSecretId,
152169
params.refreshTokenSecretId,
@@ -155,7 +172,8 @@ const insertConnectionRow = (
155172
JSON.stringify(params.providerState),
156173
now,
157174
now,
158-
);
175+
];
176+
stmt.run(...values);
159177
};
160178

161179
// ---------------------------------------------------------------------------

packages/plugins/openapi/src/sdk/plugin.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -795,8 +795,8 @@ export const openApiPlugin = definePlugin(
795795
name: existing.name,
796796
baseUrl: resolvedConfig.baseUrl,
797797
namespace: existing.namespace,
798-
headers: existing.config.headers,
799-
oauth2: existing.config.oauth2,
798+
headers: existing.legacy?.headers ?? existing.config.headers,
799+
oauth2: existing.legacy?.oauth2 ?? existing.config.oauth2,
800800
});
801801
});
802802

@@ -872,9 +872,17 @@ export const openApiPlugin = definePlugin(
872872
const existing = yield* ctx.storage.getSource(namespace, scope);
873873
if (!existing) return;
874874
const canonicalHeaders =
875-
input.headers !== undefined ? canonicalizeHeaders(input.headers) : null;
875+
input.headers !== undefined
876+
? canonicalizeHeaders(input.headers)
877+
: existing.legacy?.headers
878+
? canonicalizeHeaders(existing.legacy.headers)
879+
: null;
876880
const canonicalOAuth2 =
877-
input.oauth2 !== undefined ? canonicalizeOAuth2(input.oauth2) : null;
881+
input.oauth2 !== undefined
882+
? canonicalizeOAuth2(input.oauth2)
883+
: existing.legacy?.oauth2
884+
? canonicalizeOAuth2(existing.legacy.oauth2)
885+
: null;
878886
yield* ctx.storage.updateSourceMeta(namespace, scope, {
879887
name: input.name?.trim() || undefined,
880888
baseUrl: input.baseUrl,

0 commit comments

Comments
 (0)