|
| 1 | +import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { Effect, Schema } from "effect"; |
| 3 | + |
| 4 | +import { collectTables } from "./executor"; |
| 5 | +import { createSqliteTestFumaDb } from "./sqlite-test-db"; |
| 6 | +import { runSqliteConfigBlobMigration } from "./sqlite-config-blob-migration"; |
| 7 | +import { runSqliteDataMigrations, type SqliteDataMigrationClient } from "./sqlite-data-migrations"; |
| 8 | + |
| 9 | +const OPENAPI_OPTIONS = { |
| 10 | + migrationName: "test-spec-to-blob", |
| 11 | + pluginId: "openapi", |
| 12 | + inlineField: "spec", |
| 13 | + hashField: "specHash", |
| 14 | + blobKeyPrefix: "spec", |
| 15 | +} as const; |
| 16 | + |
| 17 | +const decodeConfigJson = Schema.decodeUnknownSync( |
| 18 | + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), |
| 19 | +); |
| 20 | + |
| 21 | +const insertIntegration = async ( |
| 22 | + client: SqliteDataMigrationClient, |
| 23 | + row: { rowId: string; tenant: string; slug: string; pluginId: string; config: unknown }, |
| 24 | +) => { |
| 25 | + await client.execute({ |
| 26 | + sql: `INSERT INTO integration (row_id, tenant, slug, plugin_id, description, config, can_remove, can_refresh, created_at, updated_at) |
| 27 | + VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`, |
| 28 | + args: [ |
| 29 | + row.rowId, |
| 30 | + row.tenant, |
| 31 | + row.slug, |
| 32 | + row.pluginId, |
| 33 | + row.slug, |
| 34 | + JSON.stringify(row.config), |
| 35 | + Date.now(), |
| 36 | + Date.now(), |
| 37 | + ], |
| 38 | + }); |
| 39 | +}; |
| 40 | + |
| 41 | +describe("runSqliteConfigBlobMigration", () => { |
| 42 | + it.effect("moves inline spec text to a blob row and rewrites the config pointer", () => |
| 43 | + Effect.gen(function* () { |
| 44 | + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| 45 | + const spec = JSON.stringify({ openapi: "3.0.0", info: { title: "T", version: "1" } }); |
| 46 | + yield* Effect.promise(() => |
| 47 | + insertIntegration(db.client, { |
| 48 | + rowId: "r1", |
| 49 | + tenant: "t1", |
| 50 | + slug: "legacy_api", |
| 51 | + pluginId: "openapi", |
| 52 | + config: { spec, sourceUrl: "https://example.com/spec.json" }, |
| 53 | + }), |
| 54 | + ); |
| 55 | + |
| 56 | + const moved = yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS); |
| 57 | + expect(moved).toBe(1); |
| 58 | + |
| 59 | + const integration = yield* Effect.promise(() => |
| 60 | + db.client.execute("SELECT config FROM integration WHERE row_id = 'r1'"), |
| 61 | + ); |
| 62 | + const config = decodeConfigJson(String(integration.rows[0]!.config)); |
| 63 | + expect(config.spec).toBeUndefined(); |
| 64 | + expect(typeof config.specHash).toBe("string"); |
| 65 | + // Untouched fields survive the rewrite. |
| 66 | + expect(config.sourceUrl).toBe("https://example.com/spec.json"); |
| 67 | + |
| 68 | + // The blob row uses the runtime's exact naming: namespace |
| 69 | + // `o:<tenant>/<pluginId>`, key `spec/<hash>`, id JSON.stringify pair. |
| 70 | + const blob = yield* Effect.promise(() => |
| 71 | + db.client.execute({ |
| 72 | + sql: "SELECT namespace, key, value FROM blob WHERE id = ?", |
| 73 | + args: [JSON.stringify([`o:t1/openapi`, `spec/${config.specHash}`])], |
| 74 | + }), |
| 75 | + ); |
| 76 | + expect(blob.rows).toHaveLength(1); |
| 77 | + expect(blob.rows[0]!.value).toBe(spec); |
| 78 | + |
| 79 | + yield* Effect.promise(() => db.close()); |
| 80 | + }), |
| 81 | + ); |
| 82 | + |
| 83 | + it.effect("is idempotent and leaves pointer-shaped and foreign rows alone", () => |
| 84 | + Effect.gen(function* () { |
| 85 | + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| 86 | + yield* Effect.promise(() => |
| 87 | + insertIntegration(db.client, { |
| 88 | + rowId: "r1", |
| 89 | + tenant: "t1", |
| 90 | + slug: "already_migrated", |
| 91 | + pluginId: "openapi", |
| 92 | + config: { specHash: "abc123" }, |
| 93 | + }), |
| 94 | + ); |
| 95 | + yield* Effect.promise(() => |
| 96 | + insertIntegration(db.client, { |
| 97 | + rowId: "r2", |
| 98 | + tenant: "t1", |
| 99 | + slug: "mcp_thing", |
| 100 | + pluginId: "mcp", |
| 101 | + config: { endpoint: "https://mcp.example.com" }, |
| 102 | + }), |
| 103 | + ); |
| 104 | + yield* Effect.promise(() => |
| 105 | + insertIntegration(db.client, { |
| 106 | + rowId: "r3", |
| 107 | + tenant: "t1", |
| 108 | + slug: "legacy_api", |
| 109 | + pluginId: "openapi", |
| 110 | + config: { spec: "{}" }, |
| 111 | + }), |
| 112 | + ); |
| 113 | + |
| 114 | + expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(1); |
| 115 | + // Second run: everything is pointer-shaped now. |
| 116 | + expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(0); |
| 117 | + |
| 118 | + const mcpRow = yield* Effect.promise(() => |
| 119 | + db.client.execute("SELECT config FROM integration WHERE row_id = 'r2'"), |
| 120 | + ); |
| 121 | + expect(decodeConfigJson(String(mcpRow.rows[0]!.config))).toEqual({ |
| 122 | + endpoint: "https://mcp.example.com", |
| 123 | + }); |
| 124 | + |
| 125 | + yield* Effect.promise(() => db.close()); |
| 126 | + }), |
| 127 | + ); |
| 128 | + |
| 129 | + it.effect("identical specs across integrations share one blob per tenant", () => |
| 130 | + Effect.gen(function* () { |
| 131 | + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| 132 | + const spec = JSON.stringify({ openapi: "3.0.0", info: { title: "Shared", version: "1" } }); |
| 133 | + for (const [rowId, slug] of [ |
| 134 | + ["r1", "api_a"], |
| 135 | + ["r2", "api_b"], |
| 136 | + ] as const) { |
| 137 | + yield* Effect.promise(() => |
| 138 | + insertIntegration(db.client, { |
| 139 | + rowId, |
| 140 | + tenant: "t1", |
| 141 | + slug, |
| 142 | + pluginId: "openapi", |
| 143 | + config: { spec }, |
| 144 | + }), |
| 145 | + ); |
| 146 | + } |
| 147 | + |
| 148 | + expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(2); |
| 149 | + const blobs = yield* Effect.promise(() => db.client.execute("SELECT id FROM blob")); |
| 150 | + expect(blobs.rows).toHaveLength(1); |
| 151 | + |
| 152 | + yield* Effect.promise(() => db.close()); |
| 153 | + }), |
| 154 | + ); |
| 155 | + |
| 156 | + it.effect("returns 0 when the integration table does not exist yet", () => |
| 157 | + Effect.gen(function* () { |
| 158 | + // The ledger may run against a brand-new database before any schema |
| 159 | + // bring-up; an absent table is "nothing to migrate", not an error. |
| 160 | + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| 161 | + yield* Effect.promise(() => db.client.execute("DROP TABLE integration")); |
| 162 | + expect(yield* runSqliteConfigBlobMigration(db.client, OPENAPI_OPTIONS)).toBe(0); |
| 163 | + yield* Effect.promise(() => db.close()); |
| 164 | + }), |
| 165 | + ); |
| 166 | + |
| 167 | + it.effect("runs once under the ledger and is stamped", () => |
| 168 | + Effect.gen(function* () { |
| 169 | + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); |
| 170 | + yield* Effect.promise(() => |
| 171 | + insertIntegration(db.client, { |
| 172 | + rowId: "r1", |
| 173 | + tenant: "t1", |
| 174 | + slug: "legacy_api", |
| 175 | + pluginId: "openapi", |
| 176 | + config: { spec: "{}" }, |
| 177 | + }), |
| 178 | + ); |
| 179 | + const entry = { |
| 180 | + name: "test-spec-to-blob", |
| 181 | + run: (client: SqliteDataMigrationClient) => |
| 182 | + runSqliteConfigBlobMigration(client, OPENAPI_OPTIONS).pipe(Effect.asVoid), |
| 183 | + }; |
| 184 | + |
| 185 | + expect(yield* runSqliteDataMigrations(db.client, [entry])).toEqual(["test-spec-to-blob"]); |
| 186 | + // Stamped: the second boot doesn't re-run it. |
| 187 | + expect(yield* runSqliteDataMigrations(db.client, [entry])).toEqual([]); |
| 188 | + |
| 189 | + yield* Effect.promise(() => db.close()); |
| 190 | + }), |
| 191 | + ); |
| 192 | +}); |
0 commit comments