diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/services/tenant-isolation.node.test.ts index 55e35c40d..49d433808 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -155,4 +155,56 @@ describe("tenant isolation (HTTP)", () => { expect(result._tag).toBe("Left"); }), ); + + it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () => + Effect.gen(function* () { + const orgA = `org_${crypto.randomUUID()}`; + const orgB = `org_${crypto.randomUUID()}`; + const namespace = `shared_${crypto.randomUUID().replace(/-/g, "_")}`; + + yield* asOrg(orgA, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(orgA) }, + payload: { + spec: MINIMAL_OPENAPI_SPEC, + namespace, + name: "Org A API", + baseUrl: "https://org-a.example.com", + }, + }), + ); + yield* asOrg(orgB, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(orgB) }, + payload: { + spec: MINIMAL_OPENAPI_SPEC, + namespace, + name: "Org B API", + baseUrl: "https://org-b.example.com", + }, + }), + ); + + yield* asOrg(orgA, (client) => + client.openapi.updateSource({ + path: { scopeId: ScopeId.make(orgA), namespace }, + payload: { + name: "Org A Updated API", + baseUrl: "https://org-a-updated.example.com", + }, + }), + ); + + const orgASource = yield* asOrg(orgA, (client) => + client.openapi.getSource({ path: { scopeId: ScopeId.make(orgA), namespace } }), + ); + const orgBSource = yield* asOrg(orgB, (client) => + client.openapi.getSource({ path: { scopeId: ScopeId.make(orgB), namespace } }), + ); + expect(orgASource?.name).toBe("Org A Updated API"); + expect(orgASource?.config.baseUrl).toBe("https://org-a-updated.example.com"); + expect(orgBSource?.name).toBe("Org B API"); + expect(orgBSource?.config.baseUrl).toBe("https://org-b.example.com"); + }), + ); }); diff --git a/packages/core/storage-drizzle/src/adapter.ts b/packages/core/storage-drizzle/src/adapter.ts index 527f481eb..006f62df9 100644 --- a/packages/core/storage-drizzle/src/adapter.ts +++ b/packages/core/storage-drizzle/src/adapter.ts @@ -212,6 +212,17 @@ const compileWhere = ( return andClause ?? orClause; }; +const rowIdentityClause = ( + table: AnyTable, + row: Record, +): SQL => { + const idClause = eq(table.id, row.id); + if (table.scope_id && typeof row.scope_id === "string") { + return and(eq(table.scope_id, row.scope_id), idClause) as SQL; + } + return idClause; +}; + // --------------------------------------------------------------------------- // Join → drizzle `with` clause // @@ -592,7 +603,8 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { if (matched.length === 0) return null; if (matched.length > 1) return null; const target = matched[0]!; - let updQ = db.update(table).set(update).where(eq(table.id, target.id)); + const identity = rowIdentityClause(table, target); + let updQ = db.update(table).set(update).where(identity); if (provider !== "mysql") { const rows = (yield* runPromise( "update returning", @@ -608,7 +620,7 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { ); const reread = (yield* runPromise( "mysql update reread", - () => db.select().from(table).where(eq(table.id, target.id)).limit(1), + () => db.select().from(table).where(identity).limit(1), model, )) as Record[]; return (reread[0] ?? null) as never; @@ -652,18 +664,18 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { const table = getTable(model); const clause = compileWhere(table, where, provider); // Mirror in-memory semantics: delete first matching row only - let findQ = db.select({ id: table.id }).from(table); + let findQ = db.select().from(table); if (clause) findQ = findQ.where(clause); const matched = (yield* runPromise( "delete pre-select", () => findQ.limit(1), model, - )) as { id: unknown }[]; + )) as Record[]; const first = matched[0]; if (!first) return; yield* runPromise( "delete exec", - () => Promise.resolve(db.delete(table).where(eq(table.id, first.id))), + () => Promise.resolve(db.delete(table).where(rowIdentityClause(table, first))), model, ); }).pipe( diff --git a/packages/core/storage-postgres/src/index.test.ts b/packages/core/storage-postgres/src/index.test.ts index 73dbc4c32..36b713f25 100644 --- a/packages/core/storage-postgres/src/index.test.ts +++ b/packages/core/storage-postgres/src/index.test.ts @@ -6,12 +6,14 @@ // pattern apps/cloud uses). Port 5435 so it doesn't clash with the // cloud test DB on 5434. +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { relations } from "drizzle-orm"; import { pgTable, + primaryKey, text, doublePrecision, boolean, @@ -68,6 +70,16 @@ const with_defaults = pgTable("with_defaults", { touchedAt: timestamp("touchedAt"), }); +const scoped_item = pgTable( + "scoped_item", + { + id: text("id").notNull(), + scope_id: text("scope_id").notNull(), + label: text("label"), + }, + (table) => [primaryKey({ columns: [table.scope_id, table.id] })], +); + const sourceRelations = relations(source, ({ many }) => ({ source_tag: many(source_tag), })); @@ -157,3 +169,123 @@ const withAdapter = ( }) as Effect.Effect; runAdapterConformance("postgres", withAdapter); + +const scopedSchema = { + scoped_item: { + fields: { + scope_id: { type: "string", required: true, index: true }, + label: { type: "string", required: true }, + }, + }, +} as const; + +const resetScopedTable = Effect.tryPromise({ + try: async () => { + await sql.unsafe(`DROP TABLE IF EXISTS "scoped_item" CASCADE`); + await sql.unsafe( + `CREATE TABLE "scoped_item" ( + "id" TEXT NOT NULL, + "scope_id" TEXT NOT NULL, + "label" TEXT, + PRIMARY KEY ("scope_id", "id") + )`, + ); + }, + catch: (cause) => + new Error(`failed to reset scoped_item table: ${String(cause)}`), +}); + +const makeScopedAdapter = () => + makePostgresAdapter({ + db: drizzle(sql, { schema: { scoped_item } }), + schema: scopedSchema, + }); + +describe("postgres scoped row identity", () => { + it.effect("update pins composite identity when id is reused across scopes", () => + Effect.gen(function* () { + yield* resetScopedTable; + const adapter = makeScopedAdapter(); + + yield* adapter.create({ + model: "scoped_item", + forceAllowId: true, + data: { id: "shared", scope_id: "scope-a", label: "a" } as never, + }); + yield* adapter.create({ + model: "scoped_item", + forceAllowId: true, + data: { id: "shared", scope_id: "scope-b", label: "b" } as never, + }); + + yield* adapter.update({ + model: "scoped_item", + where: [ + { field: "id", value: "shared" }, + { field: "scope_id", value: "scope-a" }, + ], + update: { label: "a-updated" }, + }); + + const scopeA = yield* adapter.findOne<{ label: string }>({ + model: "scoped_item", + where: [ + { field: "id", value: "shared" }, + { field: "scope_id", value: "scope-a" }, + ], + }); + const scopeB = yield* adapter.findOne<{ label: string }>({ + model: "scoped_item", + where: [ + { field: "id", value: "shared" }, + { field: "scope_id", value: "scope-b" }, + ], + }); + expect(scopeA?.label).toBe("a-updated"); + expect(scopeB?.label).toBe("b"); + }), + ); + + it.effect("delete pins composite identity when id is reused across scopes", () => + Effect.gen(function* () { + yield* resetScopedTable; + const adapter = makeScopedAdapter(); + + yield* adapter.create({ + model: "scoped_item", + forceAllowId: true, + data: { id: "shared", scope_id: "scope-a", label: "a" } as never, + }); + yield* adapter.create({ + model: "scoped_item", + forceAllowId: true, + data: { id: "shared", scope_id: "scope-b", label: "b" } as never, + }); + + yield* adapter.delete({ + model: "scoped_item", + where: [ + { field: "id", value: "shared" }, + { field: "scope_id", value: "scope-a" }, + ], + }); + + const scopeA = yield* adapter.findOne<{ label: string }>({ + model: "scoped_item", + where: [ + { field: "id", value: "shared" }, + { field: "scope_id", value: "scope-a" }, + ], + }); + const scopeB = yield* adapter.findOne<{ label: string }>({ + model: "scoped_item", + where: [ + { field: "id", value: "shared" }, + { field: "scope_id", value: "scope-b" }, + ], + }); + expect(scopeA).toBeNull(); + expect(scopeB?.label).toBe("b"); + }), + ); +});