Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/cloud/src/services/tenant-isolation.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
);
});
22 changes: 17 additions & 5 deletions packages/core/storage-drizzle/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,17 @@ const compileWhere = (
return andClause ?? orClause;
};

const rowIdentityClause = (
table: AnyTable,
row: Record<string, unknown>,
): 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
//
Expand Down Expand Up @@ -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",
Expand All @@ -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<string, unknown>[];
return (reread[0] ?? null) as never;
Expand Down Expand Up @@ -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<string, unknown>[];
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(
Expand Down
132 changes: 132 additions & 0 deletions packages/core/storage-postgres/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}));
Expand Down Expand Up @@ -157,3 +169,123 @@ const withAdapter = <A, E>(
}) as Effect.Effect<A, E | Error>;

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");
}),
);
});
Loading