@rudderhq/db 0.6.1 → 0.6.2-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/client.test.js +8 -0
  2. package/dist/client.test.js.map +1 -1
  3. package/dist/mcp-connections-schema.test.js +20 -2
  4. package/dist/mcp-connections-schema.test.js.map +1 -1
  5. package/dist/migrations/0119_nervous_killraven.sql +126 -0
  6. package/dist/migrations/0120_thick_maria_hill.sql +4 -0
  7. package/dist/migrations/0121_pale_wildside.sql +3 -0
  8. package/dist/migrations/0122_jazzy_tyger_tiger.sql +4 -0
  9. package/dist/migrations/managed-mcp-canonicalization.test.ts +342 -0
  10. package/dist/migrations/meta/0119_snapshot.json +22074 -0
  11. package/dist/migrations/meta/0120_snapshot.json +22090 -0
  12. package/dist/migrations/meta/0121_snapshot.json +22108 -0
  13. package/dist/migrations/meta/0122_snapshot.json +22068 -0
  14. package/dist/migrations/meta/_journal.json +28 -0
  15. package/dist/schema/agents.d.ts +0 -17
  16. package/dist/schema/agents.d.ts.map +1 -1
  17. package/dist/schema/agents.js +0 -2
  18. package/dist/schema/agents.js.map +1 -1
  19. package/dist/schema/chat_conversations.d.ts +34 -0
  20. package/dist/schema/chat_conversations.d.ts.map +1 -1
  21. package/dist/schema/chat_conversations.js +2 -0
  22. package/dist/schema/chat_conversations.js.map +1 -1
  23. package/dist/schema/chat_queued_messages.d.ts +17 -0
  24. package/dist/schema/chat_queued_messages.d.ts.map +1 -1
  25. package/dist/schema/chat_queued_messages.js +1 -0
  26. package/dist/schema/chat_queued_messages.js.map +1 -1
  27. package/dist/schema/custom_integrations.d.ts +85 -0
  28. package/dist/schema/custom_integrations.d.ts.map +1 -1
  29. package/dist/schema/custom_integrations.js +10 -1
  30. package/dist/schema/custom_integrations.js.map +1 -1
  31. package/dist/schema/mcp_connections.d.ts +68 -0
  32. package/dist/schema/mcp_connections.d.ts.map +1 -1
  33. package/dist/schema/mcp_connections.js +12 -0
  34. package/dist/schema/mcp_connections.js.map +1 -1
  35. package/dist/schema/plugins.d.ts +2 -2
  36. package/dist/seed.js +0 -1
  37. package/dist/seed.js.map +1 -1
  38. package/package.json +2 -2
@@ -0,0 +1,342 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
3
+ import fs from "node:fs";
4
+ import net from "node:net";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import postgres from "postgres";
8
+ import { describe, expect, it } from "vitest";
9
+ import { applyPendingMigrations, ensurePostgresDatabase } from "../client.js";
10
+ import { createLocalPostgresInstance } from "../local-postgres-provider.js";
11
+
12
+ const migrationsDirectory = path.dirname(new URL(import.meta.url).pathname);
13
+ const migrationFilename = fs.readdirSync(migrationsDirectory)
14
+ .find((filename) => /^0119_.*\.sql$/.test(filename));
15
+ if (!migrationFilename) {
16
+ throw new Error("Expected generated 0119 managed MCP access migration");
17
+ }
18
+ const migrationSql = fs.readFileSync(
19
+ path.join(migrationsDirectory, migrationFilename),
20
+ "utf8",
21
+ );
22
+
23
+ async function availablePort(): Promise<number> {
24
+ return await new Promise((resolve, reject) => {
25
+ const server = net.createServer();
26
+ server.unref();
27
+ server.on("error", reject);
28
+ server.listen(0, "127.0.0.1", () => {
29
+ const address = server.address();
30
+ if (!address || typeof address === "string") {
31
+ server.close(() => reject(new Error("Could not allocate PostgreSQL port")));
32
+ return;
33
+ }
34
+ server.close((error) => error ? reject(error) : resolve(address.port));
35
+ });
36
+ });
37
+ }
38
+
39
+ function migrationsThrough(maxIdx: number, targetRoot: string): string {
40
+ const folder = path.join(targetRoot, `through-${maxIdx}`);
41
+ fs.mkdirSync(path.join(folder, "meta"), { recursive: true });
42
+ const journal = JSON.parse(fs.readFileSync(
43
+ path.join(migrationsDirectory, "meta", "_journal.json"),
44
+ "utf8",
45
+ )) as {
46
+ version: string;
47
+ dialect: string;
48
+ entries: Array<{ idx: number; tag: string }>;
49
+ };
50
+ const entries = journal.entries.filter((entry) => entry.idx <= maxIdx);
51
+ for (const entry of entries) {
52
+ fs.copyFileSync(
53
+ path.join(migrationsDirectory, `${entry.tag}.sql`),
54
+ path.join(folder, `${entry.tag}.sql`),
55
+ );
56
+ }
57
+ fs.writeFileSync(path.join(folder, "meta", "_journal.json"), JSON.stringify({
58
+ version: journal.version,
59
+ dialect: journal.dialect,
60
+ entries,
61
+ }));
62
+ return folder;
63
+ }
64
+
65
+ describe("managed MCP canonicalization migration", () => {
66
+ it("backfills ambiguous DDL and migration tools as destructive", () => {
67
+ const destructiveBranch = migrationSql.match(
68
+ /when[\s\S]*?then\s+'destructive'/i,
69
+ )?.[0];
70
+ expect(destructiveBranch).toMatch(/\bddl\b/i);
71
+ expect(destructiveBranch).toMatch(/\bmigrations\b/i);
72
+ });
73
+
74
+ it("evaluates write capability before read for mixed-action tool names", () => {
75
+ const writeBranch = migrationSql.indexOf("THEN 'normal_write'");
76
+ const readBranch = migrationSql.indexOf("THEN 'read'");
77
+ expect(writeBranch).toBeGreaterThan(-1);
78
+ expect(readBranch).toBeGreaterThan(-1);
79
+ expect(writeBranch).toBeLessThan(readBranch);
80
+ });
81
+
82
+ it("ranks official provider duplicates deterministically and retains every loser", () => {
83
+ expect(migrationSql).toMatch(
84
+ /row_number\(\)\s+over\s*\(\s*partition by\s+(?:"connection"\.)?"org_id",\s*(?:"connection"\.)?"provider"/i,
85
+ );
86
+ expect(migrationSql).toMatch(
87
+ /case[\s\S]*(?:"connection"\.)?"status"\s*=\s*'active'[\s\S]*(?:"connection"\.)?"updated_at"\s+desc[\s\S]*(?:"connection"\.)?"id"\s+asc/i,
88
+ );
89
+ expect(migrationSql).toMatch(/update\s+"mcp_connections"[\s\S]*"canonical_state"\s*=\s*'superseded'/i);
90
+ expect(migrationSql).toMatch(/"superseded_by_connection_id"\s*=/i);
91
+ expect(migrationSql).not.toMatch(/delete\s+from\s+"mcp_connections"/i);
92
+ });
93
+
94
+ it("limits canonical uniqueness to official providers and leaves custom MCP multi-instance", () => {
95
+ expect(migrationSql).toMatch(
96
+ /create unique index\s+"mcp_connections_org_official_canonical_uq"[\s\S]*\("org_id","provider"\)[\s\S]*"provider"\s+in\s*\('supabase',\s*'linear',\s*'notion'\)[\s\S]*"canonical_state"\s*=\s*'canonical'/i,
97
+ );
98
+ expect(migrationSql).not.toMatch(
99
+ /mcp_connections_org_official_canonical_uq[\s\S]*"provider"\s*=\s*'custom'/i,
100
+ );
101
+ });
102
+
103
+ it("uses stable assignments so rerunning canonicalization does not choose a new winner", () => {
104
+ expect(migrationSql).toMatch(
105
+ /where[\s\S]{0,180}(?:"connection"\.)?"canonical_state"\s*=\s*'canonical'/i,
106
+ );
107
+ expect(migrationSql).toMatch(/(?:where|and)\s+(?:"ranked"\.)?"rank"\s*>\s*1/i);
108
+ expect(migrationSql).toMatch(/where\s+"rank"\s*=\s*1/i);
109
+ });
110
+
111
+ it("migrates seeded duplicates without losing bindings, tools, grants, or audit history", async () => {
112
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rudder-mcp-canonicalization-"));
113
+ const port = await availablePort();
114
+ const { instance } = await createLocalPostgresInstance({
115
+ databaseDir: path.join(tempRoot, "postgres"),
116
+ user: "rudder",
117
+ password: "rudder",
118
+ port,
119
+ persistent: true,
120
+ initdbFlags: ["--encoding=UTF8", "--locale=C"],
121
+ onLog: () => {},
122
+ onError: () => {},
123
+ });
124
+ try {
125
+ await instance.initialise();
126
+ await instance.start();
127
+ const adminUrl = `postgres://rudder:rudder@127.0.0.1:${port}/postgres`;
128
+ await ensurePostgresDatabase(adminUrl, "rudder");
129
+ const databaseUrl = `postgres://rudder:rudder@127.0.0.1:${port}/rudder`;
130
+ const sql = postgres(databaseUrl, { max: 1, onnotice: () => {} });
131
+ try {
132
+ await migrate(drizzle(sql), {
133
+ migrationsFolder: migrationsThrough(118, tempRoot),
134
+ });
135
+ await sql.unsafe(`
136
+ INSERT INTO "organizations" ("id", "url_key", "name", "issue_prefix")
137
+ VALUES ('00000000-0000-4000-8000-000000000101', 'mcp-migration', 'MCP Migration', 'MCP');
138
+ INSERT INTO "agents" ("id", "org_id", "name")
139
+ VALUES (
140
+ '00000000-0000-4000-8000-000000000102',
141
+ '00000000-0000-4000-8000-000000000101',
142
+ 'Migration Agent'
143
+ );
144
+ INSERT INTO "organization_secrets" (
145
+ "id", "org_id", "name", "purpose", "external_ref"
146
+ ) VALUES
147
+ (
148
+ '00000000-0000-4000-8000-000000000301',
149
+ '00000000-0000-4000-8000-000000000101',
150
+ 'Active grant', 'managed_mcp_oauth', 'grant-secret'
151
+ ),
152
+ (
153
+ '00000000-0000-4000-8000-000000000302',
154
+ '00000000-0000-4000-8000-000000000101',
155
+ 'Pending session', 'managed_mcp_oauth', 'session-secret'
156
+ ),
157
+ (
158
+ '00000000-0000-4000-8000-000000000303',
159
+ '00000000-0000-4000-8000-000000000101',
160
+ 'Duplicate active grant', 'managed_mcp_oauth', 'duplicate-grant-secret'
161
+ );
162
+ INSERT INTO "organization_secret_versions" (
163
+ "secret_id", "version", "material", "value_sha256"
164
+ ) VALUES
165
+ (
166
+ '00000000-0000-4000-8000-000000000301',
167
+ 1,
168
+ '{"ciphertext":"grant"}'::jsonb,
169
+ 'grant-hash'
170
+ ),
171
+ (
172
+ '00000000-0000-4000-8000-000000000302',
173
+ 1,
174
+ '{"ciphertext":"session"}'::jsonb,
175
+ 'session-hash'
176
+ ),
177
+ (
178
+ '00000000-0000-4000-8000-000000000303',
179
+ 1,
180
+ '{"ciphertext":"duplicate-grant"}'::jsonb,
181
+ 'duplicate-grant-hash'
182
+ );
183
+ INSERT INTO "mcp_connections" (
184
+ "id", "org_id", "name", "display_name", "provider", "transport",
185
+ "access_mode", "status", "enabled", "updated_at"
186
+ ) VALUES
187
+ (
188
+ '00000000-0000-4000-8000-000000000201',
189
+ '00000000-0000-4000-8000-000000000101',
190
+ 'supabase-active', 'Supabase', 'supabase', 'streamable_http',
191
+ 'read_only', 'active', true, now() - interval '2 days'
192
+ ),
193
+ (
194
+ '00000000-0000-4000-8000-000000000202',
195
+ '00000000-0000-4000-8000-000000000101',
196
+ 'supabase-authorizing', 'Supabase', 'supabase', 'streamable_http',
197
+ 'read_only', 'authorizing', true, now()
198
+ ),
199
+ (
200
+ '00000000-0000-4000-8000-000000000203',
201
+ '00000000-0000-4000-8000-000000000101',
202
+ 'supabase-error', 'Supabase', 'supabase', 'streamable_http',
203
+ 'read_only', 'error', true, now() - interval '1 day'
204
+ ),
205
+ (
206
+ '00000000-0000-4000-8000-000000000204',
207
+ '00000000-0000-4000-8000-000000000101',
208
+ 'supabase-active-duplicate', 'Supabase', 'supabase', 'streamable_http',
209
+ 'read_only', 'active', true, now() - interval '3 days'
210
+ );
211
+ INSERT INTO "mcp_oauth_grants" (
212
+ "org_id", "connection_id", "credential_secret_id", "status"
213
+ ) VALUES
214
+ (
215
+ '00000000-0000-4000-8000-000000000101',
216
+ '00000000-0000-4000-8000-000000000201',
217
+ '00000000-0000-4000-8000-000000000301',
218
+ 'active'
219
+ ),
220
+ (
221
+ '00000000-0000-4000-8000-000000000101',
222
+ '00000000-0000-4000-8000-000000000204',
223
+ '00000000-0000-4000-8000-000000000303',
224
+ 'active'
225
+ );
226
+ INSERT INTO "mcp_oauth_sessions" (
227
+ "org_id", "connection_id", "state_hash", "credential_secret_id",
228
+ "redirect_uri", "status", "expires_at"
229
+ ) VALUES (
230
+ '00000000-0000-4000-8000-000000000101',
231
+ '00000000-0000-4000-8000-000000000202',
232
+ 'pending-state',
233
+ '00000000-0000-4000-8000-000000000302',
234
+ 'http://127.0.0.1/callback',
235
+ 'authorizing',
236
+ now() + interval '10 minutes'
237
+ );
238
+ INSERT INTO "custom_integration_tools" (
239
+ "id", "org_id", "connection_id", "external_tool_name", "rudder_tool_name"
240
+ ) VALUES (
241
+ '00000000-0000-4000-8000-000000000401',
242
+ '00000000-0000-4000-8000-000000000101',
243
+ '00000000-0000-4000-8000-000000000202',
244
+ 'execute_sql', 'external.supabase.execute_sql'
245
+ );
246
+ INSERT INTO "agent_custom_integration_bindings" (
247
+ "id", "org_id", "agent_id", "connection_id", "status", "enabled_tool_ids"
248
+ ) VALUES (
249
+ '00000000-0000-4000-8000-000000000501',
250
+ '00000000-0000-4000-8000-000000000101',
251
+ '00000000-0000-4000-8000-000000000102',
252
+ '00000000-0000-4000-8000-000000000202',
253
+ 'active',
254
+ '["00000000-0000-4000-8000-000000000401"]'::jsonb
255
+ );
256
+ INSERT INTO "activity_log" (
257
+ "org_id", "actor_id", "action", "entity_type", "entity_id"
258
+ ) VALUES (
259
+ '00000000-0000-4000-8000-000000000101',
260
+ 'migration-test', 'mcp.test', 'mcp_connection',
261
+ '00000000-0000-4000-8000-000000000202'
262
+ );
263
+ `);
264
+ } finally {
265
+ await sql.end();
266
+ }
267
+
268
+ await expect(applyPendingMigrations(databaseUrl)).resolves.toBeUndefined();
269
+ await expect(applyPendingMigrations(databaseUrl)).resolves.toBeUndefined();
270
+
271
+ const verify = postgres(databaseUrl, { max: 1, onnotice: () => {} });
272
+ try {
273
+ const connections = await verify.unsafe<Array<{
274
+ id: string;
275
+ canonical_state: string;
276
+ superseded_by_connection_id: string | null;
277
+ enabled: boolean;
278
+ }>>(`
279
+ SELECT id, canonical_state, superseded_by_connection_id, enabled
280
+ FROM mcp_connections
281
+ ORDER BY id
282
+ `);
283
+ expect(connections).toHaveLength(4);
284
+ expect(connections[0]).toMatchObject({
285
+ id: "00000000-0000-4000-8000-000000000201",
286
+ canonical_state: "canonical",
287
+ superseded_by_connection_id: null,
288
+ enabled: true,
289
+ });
290
+ expect(connections.slice(1)).toEqual([
291
+ expect.objectContaining({
292
+ canonical_state: "superseded",
293
+ superseded_by_connection_id: "00000000-0000-4000-8000-000000000201",
294
+ enabled: false,
295
+ }),
296
+ expect.objectContaining({
297
+ canonical_state: "superseded",
298
+ superseded_by_connection_id: "00000000-0000-4000-8000-000000000201",
299
+ enabled: false,
300
+ }),
301
+ expect.objectContaining({
302
+ canonical_state: "superseded",
303
+ superseded_by_connection_id: "00000000-0000-4000-8000-000000000201",
304
+ enabled: false,
305
+ }),
306
+ ]);
307
+ const [counts] = await verify.unsafe<Array<Record<string, number>>>(`
308
+ SELECT
309
+ (SELECT count(*)::int FROM custom_integration_tools) AS tools,
310
+ (SELECT count(*)::int FROM agent_custom_integration_bindings) AS bindings,
311
+ (SELECT count(*)::int FROM mcp_oauth_grants) AS grants,
312
+ (SELECT count(*)::int FROM activity_log) AS audit,
313
+ (SELECT count(*)::int FROM organization_secrets) AS secrets
314
+ `);
315
+ expect(counts).toEqual({
316
+ tools: 1,
317
+ bindings: 1,
318
+ grants: 2,
319
+ audit: 1,
320
+ secrets: 2,
321
+ });
322
+ const [expiredSession] = await verify.unsafe<Array<{
323
+ status: string;
324
+ credential_secret_id: string | null;
325
+ }>>(`
326
+ SELECT status, credential_secret_id
327
+ FROM mcp_oauth_sessions
328
+ WHERE connection_id = '00000000-0000-4000-8000-000000000202'
329
+ `);
330
+ expect(expiredSession).toEqual({
331
+ status: "expired",
332
+ credential_secret_id: null,
333
+ });
334
+ } finally {
335
+ await verify.end();
336
+ }
337
+ } finally {
338
+ await instance.stop();
339
+ fs.rmSync(tempRoot, { recursive: true, force: true });
340
+ }
341
+ }, 45_000);
342
+ });