Skip to content

Commit 64de0bc

Browse files
feat(scripts): migrate provider connections (#2389)
* fix(scripts): exit 0 when apply succeeds but report output fails Wrap report serialization and I/O in a nested try/catch so a successful --apply migration is not reported as failure when writing the JSON report to --out or stdout fails. * feat(scripts): migrate provider connections S47: idempotently upsert Sync connections and copy Google refresh tokens into Sync custody. Default dry-run; --apply writes. Never clears source credentials or enqueues Sync jobs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): brand providerAccountId for connection migration Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): skip migrate apply for disconnected Sync connections --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 85274fa commit 64de0bc

8 files changed

Lines changed: 744 additions & 4 deletions

File tree

docs/development/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Primary file:
1818
| `bun run cli migrate pending` | `packages/scripts/src/commands/migrate.ts` | Lists pending migrations. |
1919
| `bun run cli migrate executed` | `packages/scripts/src/commands/migrate.ts` | Lists executed migrations. |
2020
| `bun run cli inventory-legacy-sync [--out path.json]` | `packages/scripts/src/commands/inventory-legacy-sync.ts` | Read-only S46 inventory of legacy Google sync data (users/credentials/calendars/events/cursors/watches). Never writes or calls providers. |
21+
| `bun run cli migrate-connections [--apply] [--out report.json] [--user-id id]...` | `packages/scripts/src/commands/migrate-connections.ts` | S47: idempotently upsert Sync connections + credentials from legacy users. Default dry-run; `--apply` writes. Never clears source tokens or enqueues Sync jobs. |
2122

2223
## Migration Internals
2324

packages/scripts/src/cli.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const requireActual = createRequire(import.meta.url);
77
const mockExitHelpfully = mock();
88
const mockRunMigrator = mock((): Promise<void> => Promise.resolve());
99
const mockRunInventory = mock((): Promise<void> => Promise.resolve());
10+
const mockRunMigrateConnections = mock((): Promise<void> => Promise.resolve());
1011

1112
mock.module("@scripts/cli.validator", () => ({
1213
CliValidator: mock().mockImplementation(() => ({
@@ -24,6 +25,11 @@ mock.module("@scripts/commands/inventory-legacy-sync", () => ({
2425
runInventoryLegacySync: mock(() => mockRunInventory()),
2526
}));
2627

28+
mock.module("@scripts/commands/migrate-connections", () => ({
29+
__esModule: true,
30+
runMigrateConnections: mock(() => mockRunMigrateConnections()),
31+
}));
32+
2733
const { default: CompassCLI } = requireActual(
2834
"@scripts/cli",
2935
) as typeof import("@scripts/cli");
@@ -49,6 +55,14 @@ describe("CompassCLI", () => {
4955
expect(mockRunInventory).toHaveBeenCalled();
5056
});
5157

58+
it("runs migrate-connections command", async () => {
59+
const cli = new CompassCLI(["node", "cli", "migrate-connections"]);
60+
61+
await cli.run();
62+
63+
expect(mockRunMigrateConnections).toHaveBeenCalled();
64+
});
65+
5266
it("calls exitHelpfully for unsupported command", async () => {
5367
const exitSpy = spyOn(process, "exit").mockImplementation(mock() as never);
5468

packages/scripts/src/cli.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CliValidator } from "@scripts/cli.validator";
22
import { runInventoryLegacySync } from "@scripts/commands/inventory-legacy-sync";
33
import { runMigrator } from "@scripts/commands/migrate";
4+
import { runMigrateConnections } from "@scripts/commands/migrate-connections";
45
import { MigratorType } from "@scripts/common/cli.types";
56
import { Command } from "commander";
67

@@ -24,6 +25,9 @@ export default class CompassCLI {
2425
case cmd === "inventory-legacy-sync":
2526
await runInventoryLegacySync();
2627
break;
28+
case cmd === "migrate-connections":
29+
await runMigrateConnections();
30+
break;
2731
default:
2832
this.validator.exitHelpfully(`${cmd as string} is not a supported cmd`);
2933
}
@@ -32,13 +36,17 @@ export default class CompassCLI {
3236
private _createProgram(): Command {
3337
const program = new Command();
3438

39+
program.enablePositionalOptions(true).passThroughOptions(true);
40+
41+
// Register longer `migrate-*` names before `migrate` so Commander does not
42+
// treat them as unknown args to the Umzug migrate command.
3543
program
36-
.enablePositionalOptions(true)
37-
.passThroughOptions(true)
38-
.command("migrate")
44+
.command("migrate-connections")
3945
.helpOption(false)
4046
.allowUnknownOption(true)
41-
.description("run database schema migrations");
47+
.description(
48+
"idempotently copy legacy Google connections into Sync (S47; --apply to write)",
49+
);
4250

4351
program
4452
.command("inventory-legacy-sync")
@@ -48,6 +56,12 @@ export default class CompassCLI {
4856
"read-only inventory of legacy Google sync data (S46; no writes)",
4957
);
5058

59+
program
60+
.command("migrate")
61+
.helpOption(false)
62+
.allowUnknownOption(true)
63+
.description("run database schema migrations");
64+
5165
return program;
5266
}
5367
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { migrateProviderConnections } from "@scripts/commands/migrate-connections/migrate";
2+
import { ObjectId } from "mongodb";
3+
import {
4+
cleanupCollections,
5+
cleanupTestDb,
6+
setupTestDb,
7+
} from "@backend/__tests__/helpers/mock.db.setup";
8+
import mongoService from "@backend/common/services/mongo.service";
9+
import { setupSyncStorage } from "@sync/__tests__/helpers/storage";
10+
import { GOOGLE_SCOPES } from "@sync/providers/google/google.scopes";
11+
import { SYNC_COLLECTIONS } from "@sync/storage/collections";
12+
import { CredentialRepository } from "@sync/storage/repositories/credential.repository";
13+
import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository";
14+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
15+
16+
const NOW = new Date("2026-07-25T04:00:00.000Z");
17+
18+
describe("migrate-connections (db)", () => {
19+
const syncStorage = setupSyncStorage(import.meta.url);
20+
21+
beforeAll(() => setupTestDb(import.meta.url));
22+
afterEach(async () => {
23+
await cleanupCollections();
24+
await mongoService.user.deleteMany({});
25+
});
26+
afterAll(cleanupTestDb);
27+
28+
it("dry-run does not write Sync rows; apply then rerun is idempotent", async () => {
29+
const userId = new ObjectId();
30+
await mongoService.user.insertOne({
31+
_id: userId,
32+
email: "migrate@example.com",
33+
firstName: "Mig",
34+
lastName: "Rate",
35+
name: "Mig Rate",
36+
locale: "en",
37+
google: {
38+
googleId: "google-subject-migrate",
39+
picture: "",
40+
gRefreshToken: "legacy-refresh-token",
41+
},
42+
});
43+
44+
const connections = new ProviderConnectionRepository(syncStorage.db());
45+
const credentials = new CredentialRepository(syncStorage.db());
46+
const users = await mongoService.user.find({}).toArray();
47+
48+
const dry = await migrateProviderConnections(
49+
{ connections, credentials },
50+
users,
51+
{ dryRun: true, now: NOW },
52+
);
53+
expect(dry.counts.wouldCreate).toBe(1);
54+
expect(
55+
await syncStorage
56+
.db()
57+
.collection(SYNC_COLLECTIONS.providerConnections)
58+
.countDocuments(),
59+
).toBe(0);
60+
expect(
61+
await syncStorage
62+
.db()
63+
.collection(SYNC_COLLECTIONS.credentials)
64+
.countDocuments(),
65+
).toBe(0);
66+
67+
// Source credential must remain untouched.
68+
const sourceBefore = await mongoService.user.findOne({ _id: userId });
69+
expect(sourceBefore?.google?.gRefreshToken).toBe("legacy-refresh-token");
70+
71+
const first = await migrateProviderConnections(
72+
{ connections, credentials },
73+
users,
74+
{ dryRun: false, now: NOW },
75+
);
76+
expect(first.counts.created).toBe(1);
77+
expect(first.results[0]?.credentialVerified).toBe(true);
78+
const connectionId = first.results[0]?.connectionId;
79+
expect(connectionId).toMatch(/^[0-9a-f]{24}$/);
80+
81+
const stored = await credentials.findByConnection(connectionId as never);
82+
expect(stored?.refreshToken).toBe("legacy-refresh-token");
83+
expect(stored?.scopes).toEqual([...GOOGLE_SCOPES]);
84+
expect(stored?.accessToken).toBeNull();
85+
86+
const listed = await connections.listByPrincipal(
87+
userId.toHexString() as never,
88+
userId.toHexString() as never,
89+
);
90+
expect(listed).toHaveLength(1);
91+
expect(listed[0]?.state).toBe("importing");
92+
expect(listed[0]?.account.providerAccountId).toBe("google-subject-migrate");
93+
94+
const second = await migrateProviderConnections(
95+
{ connections, credentials },
96+
users,
97+
{ dryRun: false, now: NOW },
98+
);
99+
expect(second.counts.updated).toBe(1);
100+
expect(second.results[0]?.connectionId).toBe(connectionId);
101+
expect(
102+
await syncStorage
103+
.db()
104+
.collection(SYNC_COLLECTIONS.providerConnections)
105+
.countDocuments(),
106+
).toBe(1);
107+
108+
const sourceAfter = await mongoService.user.findOne({ _id: userId });
109+
expect(sourceAfter?.google?.gRefreshToken).toBe("legacy-refresh-token");
110+
});
111+
112+
it("migrates an OAuth user and skips a password-only user", async () => {
113+
const oauthId = new ObjectId();
114+
const passwordId = new ObjectId();
115+
await mongoService.user.insertMany([
116+
{
117+
_id: oauthId,
118+
email: "oauth@example.com",
119+
firstName: "O",
120+
lastName: "Auth",
121+
name: "O Auth",
122+
locale: "en",
123+
google: {
124+
googleId: "google-oauth",
125+
picture: "",
126+
gRefreshToken: "oauth-refresh",
127+
},
128+
},
129+
{
130+
_id: passwordId,
131+
email: "password@example.com",
132+
firstName: "P",
133+
lastName: "Word",
134+
name: "P Word",
135+
locale: "en",
136+
},
137+
]);
138+
139+
const report = await migrateProviderConnections(
140+
{
141+
connections: new ProviderConnectionRepository(syncStorage.db()),
142+
credentials: new CredentialRepository(syncStorage.db()),
143+
},
144+
await mongoService.user.find({}).toArray(),
145+
{ dryRun: false, now: NOW },
146+
);
147+
148+
expect(report.counts.created).toBe(1);
149+
expect(report.counts.skipped).toBe(1);
150+
expect(
151+
report.results.find((r) => r.userId === passwordId.toHexString())
152+
?.skipCategory,
153+
).toBe("no_google_identity");
154+
});
155+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { migrateProviderConnections } from "@scripts/commands/migrate-connections/migrate";
2+
import { loadCompassConfig } from "@core/config/compass.config";
3+
import { Logger } from "@core/logger/winston.logger";
4+
import mongoService from "@backend/common/services/mongo.service";
5+
import { CredentialRepository } from "@sync/storage/repositories/credential.repository";
6+
import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository";
7+
import { SyncMongoService } from "@sync/storage/sync-mongo.service";
8+
import { writeFileSync } from "node:fs";
9+
import { resolve } from "node:path";
10+
11+
const logger = Logger("scripts.commands.migrate-connections");
12+
13+
function syncMongoUri(): string {
14+
const fromEnv = process.env["SYNC_MONGO_URI"]?.trim();
15+
if (fromEnv) return fromEnv;
16+
const uri = loadCompassConfig().sync?.mongoUri?.trim();
17+
if (!uri) {
18+
throw new Error(
19+
"Set SYNC_MONGO_URI or add sync.mongoUri to compass.yaml before migrating connections",
20+
);
21+
}
22+
return uri;
23+
}
24+
25+
function parseArgs(argv: string[]): {
26+
dryRun: boolean;
27+
outPath: string | null;
28+
userIds: Set<string> | undefined;
29+
} {
30+
const apply = argv.includes("--apply");
31+
const dryRun = !apply;
32+
const outFlag = argv.indexOf("--out");
33+
const outPath =
34+
outFlag >= 0 && argv[outFlag + 1] ? resolve(argv[outFlag + 1]!) : null;
35+
36+
const userIds = new Set<string>();
37+
for (let i = 0; i < argv.length; i += 1) {
38+
if (argv[i] === "--user-id" && argv[i + 1]) {
39+
userIds.add(argv[i + 1]!);
40+
i += 1;
41+
}
42+
}
43+
44+
return {
45+
dryRun,
46+
outPath,
47+
userIds: userIds.size > 0 ? userIds : undefined,
48+
};
49+
}
50+
51+
/**
52+
* S47: idempotently copy legacy Google connections + refresh tokens into Sync
53+
* custody. Default is dry-run; pass `--apply` to write. Never clears source
54+
* credentials, never enqueues Sync jobs, never calls Google.
55+
*
56+
* Usage:
57+
* bun run cli migrate-connections [--dry-run|--apply] [--out report.json]
58+
* [--user-id <id>]...
59+
*/
60+
export async function runMigrateConnections(): Promise<void> {
61+
const { dryRun, outPath, userIds } = parseArgs(process.argv.slice(3));
62+
const syncMongo = new SyncMongoService();
63+
64+
try {
65+
await mongoService.start();
66+
await syncMongo.connect({
67+
uri: syncMongoUri(),
68+
enforceLeastPrivilege: false,
69+
forbiddenDatabaseName: "prod_calendar",
70+
});
71+
72+
const users = await mongoService.user.find({}).toArray();
73+
const report = await migrateProviderConnections(
74+
{
75+
connections: new ProviderConnectionRepository(syncMongo.db),
76+
credentials: new CredentialRepository(syncMongo.db),
77+
},
78+
users,
79+
{ dryRun, userIds },
80+
);
81+
82+
try {
83+
const json = `${JSON.stringify(report, null, 2)}\n`;
84+
if (outPath) {
85+
writeFileSync(outPath, json, "utf8");
86+
logger.info(`Wrote connection migration report to ${outPath}`);
87+
} else {
88+
process.stdout.write(json);
89+
}
90+
} catch (outputError) {
91+
logger.error(outputError);
92+
if (dryRun) {
93+
throw outputError;
94+
}
95+
logger.error(
96+
"migrate-connections apply completed but report output failed; database changes were persisted",
97+
);
98+
}
99+
100+
logger.info(
101+
`migrate-connections dryRun=${report.dryRun} scanned=${report.counts.scanned} created=${report.counts.created} updated=${report.counts.updated} skipped=${report.counts.skipped}`,
102+
);
103+
104+
await syncMongo.disconnect();
105+
await mongoService.stop();
106+
process.exit(0);
107+
} catch (error) {
108+
logger.error(error);
109+
try {
110+
await syncMongo.disconnect();
111+
} catch {
112+
// ignore
113+
}
114+
process.exit(1);
115+
}
116+
}

0 commit comments

Comments
 (0)