mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-12 13:37:19 +02:00
2308 lines
72 KiB
TypeScript
2308 lines
72 KiB
TypeScript
import { Unowned } from "@/AdoptPolicy";
|
|
import { InstanceId } from "@/InstanceId";
|
|
import * as Output from "@/Output";
|
|
import { createPhysicalName } from "@/PhysicalName";
|
|
import * as Provider from "@/Provider";
|
|
import type { App } from "@/Prisma/App";
|
|
import { Branch as PrismaBranch, BranchProvider } from "@/Prisma/Branch";
|
|
import { PrismaApiError, PrismaClient } from "@/Prisma/Client";
|
|
import {
|
|
CustomDomain as PrismaCustomDomain,
|
|
CustomDomainProvider,
|
|
} from "@/Prisma/CustomDomain";
|
|
import {
|
|
Database as PrismaDatabase,
|
|
DatabaseProvider,
|
|
} from "@/Prisma/Database";
|
|
import {
|
|
EnvironmentVariable as PrismaEnvironmentVariable,
|
|
EnvironmentVariableProvider,
|
|
} from "@/Prisma/EnvironmentVariable";
|
|
import { Project as PrismaProject, ProjectProvider } from "@/Prisma/Project";
|
|
import { recoverDatabaseConnectionSecrets } from "@/Prisma/Internal/DatabaseSecrets";
|
|
import {
|
|
SourceRepository as PrismaSourceRepository,
|
|
SourceRepositoryProvider,
|
|
} from "@/Prisma/SourceRepository";
|
|
import * as Test from "@/Test/Alchemy";
|
|
import { expect, it } from "alchemy-test";
|
|
import {
|
|
conflict,
|
|
data,
|
|
dispatchTo,
|
|
failure,
|
|
type FakeManagementApi,
|
|
json,
|
|
makeFakeManagementApi,
|
|
noContent,
|
|
notFound,
|
|
page,
|
|
unhandled,
|
|
} from "./fixtures/FakeManagementApi.ts";
|
|
import { testStackContext } from "./fixtures/StackContext.ts";
|
|
import { PlatformServices } from "@/Util/PlatformServices";
|
|
import * as Effect from "effect/Effect";
|
|
import * as Fiber from "effect/Fiber";
|
|
import * as Layer from "effect/Layer";
|
|
import * as Redacted from "effect/Redacted";
|
|
import * as Result from "effect/Result";
|
|
import * as TestClock from "effect/testing/TestClock";
|
|
import type { PrismaManagementClient } from "@/Prisma/Client";
|
|
import type {
|
|
Branch as ApiBranch,
|
|
CustomDomain as ApiCustomDomain,
|
|
Database as ApiDatabase,
|
|
DatabaseConnectionWithSecrets,
|
|
Project as ApiProject,
|
|
SourceRepository as ApiSourceRepository,
|
|
} from "@/Prisma/Types";
|
|
import { AlchemyContext } from "@/AlchemyContext";
|
|
|
|
const createdAt = "2026-01-01T00:00:00.000Z";
|
|
|
|
const liveProviderContext = Layer.succeed(AlchemyContext, {
|
|
dotAlchemy: ".alchemy-test",
|
|
dev: false,
|
|
adopt: false,
|
|
});
|
|
|
|
class TestPrismaProviders extends Provider.ProviderCollection<TestPrismaProviders>()(
|
|
"Prisma",
|
|
) {}
|
|
|
|
const projectLayer = (fake: FakeManagementApi) =>
|
|
Layer.effect(TestPrismaProviders, Provider.collection([PrismaProject])).pipe(
|
|
Layer.provideMerge(ProjectProvider()),
|
|
Layer.provide(liveProviderContext),
|
|
Layer.provideMerge(fake.layer),
|
|
);
|
|
|
|
/** Wire serializers for the in-memory cloud's Types.ts-shaped records. */
|
|
const toWireProject = (project: ApiProject) => ({
|
|
...project,
|
|
logicalId: null,
|
|
});
|
|
|
|
const toWireDatabase = (database: ApiDatabase) => database;
|
|
|
|
const toWireCreatedDatabase = (database: ApiDatabase) => ({
|
|
...database,
|
|
apiKeys: [],
|
|
connectionString: "postgres://direct",
|
|
directConnection: {
|
|
host: "db.prisma.test",
|
|
user: "prisma",
|
|
pass: "secret",
|
|
},
|
|
});
|
|
|
|
const branchLayer = (fake: FakeManagementApi) =>
|
|
Layer.effect(TestPrismaProviders, Provider.collection([PrismaBranch])).pipe(
|
|
Layer.provideMerge(BranchProvider()),
|
|
Layer.provide(liveProviderContext),
|
|
Layer.provideMerge(fake.layer),
|
|
);
|
|
|
|
const databaseLayer = (fake: FakeManagementApi) =>
|
|
Layer.effect(TestPrismaProviders, Provider.collection([PrismaDatabase])).pipe(
|
|
Layer.provideMerge(DatabaseProvider()),
|
|
Layer.provide(liveProviderContext),
|
|
Layer.provideMerge(fake.layer),
|
|
);
|
|
|
|
/**
|
|
* Serve the Management API from the same hermetic client-shaped handlers this
|
|
* suite declares, for the resources that now call distilled operations.
|
|
* `dispatchTo` maps each handler's result onto the wire (see the fixture).
|
|
*/
|
|
const clientBackedApi = (client: any) =>
|
|
makeFakeManagementApi((request) => {
|
|
// segments[0] is the "v1" prefix.
|
|
const [head, id, tail] = request.pathname
|
|
.split("/")
|
|
.filter((segment) => segment.length > 0)
|
|
.slice(1);
|
|
const body = request.bodyJson as any;
|
|
const query = Object.fromEntries(new URLSearchParams(request.search));
|
|
const { call, callVoid, list } = dispatchTo(request);
|
|
|
|
if (head === "projects") {
|
|
if (id === undefined && request.method === "GET") {
|
|
return call(client.listProjects, [], list);
|
|
}
|
|
if (tail === "branches" && request.method === "GET") {
|
|
return call(client.listBranches, [id, query], list);
|
|
}
|
|
if (tail === "databases" && request.method === "GET") {
|
|
return call(client.listProjectDatabases, [id, query], list);
|
|
}
|
|
}
|
|
if (head === "services" && id === undefined && request.method === "GET") {
|
|
return call(client.listApps, [query], list);
|
|
}
|
|
if (head === "environment-variables") {
|
|
if (id === undefined) {
|
|
return request.method === "GET"
|
|
? call(client.listEnvironmentVariables, [query], list)
|
|
: call(client.createEnvironmentVariable, [body]);
|
|
}
|
|
if (request.method === "GET") {
|
|
return call(client.getEnvironmentVariable, [id]);
|
|
}
|
|
if (request.method === "PATCH") {
|
|
return call(client.updateEnvironmentVariable, [id, body]);
|
|
}
|
|
if (request.method === "DELETE") {
|
|
return callVoid(client.deleteEnvironmentVariable, [id]);
|
|
}
|
|
}
|
|
if (head === "source-repositories") {
|
|
if (id === undefined) {
|
|
return request.method === "GET"
|
|
? call(client.listSourceRepositories, [query], list)
|
|
: call(client.createSourceRepository, [body]);
|
|
}
|
|
if (request.method === "GET") {
|
|
return call(client.getSourceRepository, [id]);
|
|
}
|
|
if (request.method === "DELETE") {
|
|
return callVoid(client.deleteSourceRepository, [id]);
|
|
}
|
|
}
|
|
return unhandled(request);
|
|
});
|
|
|
|
const environmentVariableLayer = (client: PrismaManagementClient) =>
|
|
Layer.effect(
|
|
TestPrismaProviders,
|
|
Provider.collection([PrismaEnvironmentVariable]),
|
|
).pipe(
|
|
Layer.provideMerge(EnvironmentVariableProvider()),
|
|
Layer.provide(liveProviderContext),
|
|
Layer.provideMerge(clientBackedApi(client).layer),
|
|
);
|
|
|
|
const customDomainLayer = (client: PrismaManagementClient) =>
|
|
Layer.effect(
|
|
TestPrismaProviders,
|
|
Provider.collection([PrismaCustomDomain]),
|
|
).pipe(
|
|
Layer.provideMerge(CustomDomainProvider()),
|
|
Layer.provideMerge(Layer.succeed(PrismaClient, client)),
|
|
Layer.provide(liveProviderContext),
|
|
);
|
|
|
|
const sourceRepositoryLayer = (client: PrismaManagementClient) =>
|
|
Layer.effect(
|
|
TestPrismaProviders,
|
|
Provider.collection([PrismaSourceRepository]),
|
|
).pipe(
|
|
Layer.provideMerge(SourceRepositoryProvider()),
|
|
Layer.provide(liveProviderContext),
|
|
Layer.provideMerge(clientBackedApi(client).layer),
|
|
);
|
|
|
|
const apiProject = (
|
|
id: string,
|
|
name: string,
|
|
defaultRegion: string | null = "us-east-1",
|
|
): ApiProject => ({
|
|
id,
|
|
type: "project",
|
|
url: `https://api.prisma.test/v1/projects/${id}`,
|
|
name,
|
|
createdAt,
|
|
defaultRegion,
|
|
workspace: {
|
|
id: "workspace-1",
|
|
url: "https://api.prisma.test/v1/workspaces/workspace-1",
|
|
name: "team",
|
|
},
|
|
});
|
|
|
|
const apiConnection = (
|
|
databaseId: string,
|
|
connectionId = `connection-${databaseId}`,
|
|
): DatabaseConnectionWithSecrets => ({
|
|
id: connectionId,
|
|
type: "connection",
|
|
url: `https://api.prisma.test/v1/connections/${connectionId}`,
|
|
name: "default",
|
|
createdAt,
|
|
kind: "postgres",
|
|
endpoints: {
|
|
direct: {
|
|
host: "db.prisma.test",
|
|
port: 5432,
|
|
connectionString: `postgres://user:password@db.prisma.test/${databaseId}`,
|
|
},
|
|
pooled: {
|
|
host: "pool.prisma.test",
|
|
port: 5432,
|
|
connectionString: `postgres://user:password@pool.prisma.test/${databaseId}`,
|
|
},
|
|
},
|
|
database: {
|
|
id: databaseId,
|
|
url: `https://api.prisma.test/v1/databases/${databaseId}`,
|
|
name: databaseId,
|
|
},
|
|
});
|
|
|
|
it.effect(
|
|
"database credential recovery waits for a ready default connection",
|
|
() => {
|
|
const provisioning: ApiDatabase = {
|
|
...apiDatabase("database-provisioning", {
|
|
projectId: "project-1",
|
|
name: "provisioning",
|
|
}),
|
|
status: "provisioning",
|
|
defaultConnectionId: null,
|
|
connections: [],
|
|
};
|
|
const ready: ApiDatabase = {
|
|
...provisioning,
|
|
status: "ready",
|
|
defaultConnectionId: "connection-provisioning",
|
|
};
|
|
let reads = 0;
|
|
let rotations = 0;
|
|
const fake = makeFakeManagementApi((request) => {
|
|
if (request.pathname.startsWith("/v1/databases/")) {
|
|
return data(toWireDatabase(reads++ === 0 ? provisioning : ready));
|
|
}
|
|
if (request.pathname.endsWith("/rotate")) {
|
|
rotations += 1;
|
|
return data(apiConnection(ready.id, ready.defaultConnectionId!));
|
|
}
|
|
return unhandled(request);
|
|
});
|
|
|
|
return Effect.gen(function* () {
|
|
const fiber = yield* recoverDatabaseConnectionSecrets(
|
|
provisioning,
|
|
{},
|
|
).pipe(Effect.forkChild({ startImmediately: true }));
|
|
yield* Effect.yieldNow;
|
|
yield* TestClock.adjust("1 second");
|
|
const recovered = yield* Fiber.join(fiber);
|
|
expect(recovered.database.status).toBe("ready");
|
|
expect(recovered.database.defaultConnectionId).toBe(
|
|
"connection-provisioning",
|
|
);
|
|
expect(
|
|
Redacted.value(recovered.secrets.directConnectionString!),
|
|
).toContain(ready.id);
|
|
expect(rotations).toBe(1);
|
|
}).pipe(Effect.provide(fake.layer), Effect.provide(TestClock.layer()));
|
|
},
|
|
{ tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
|
|
);
|
|
|
|
it.effect(
|
|
"database credential recovery has a bounded status-rich timeout",
|
|
() => {
|
|
const provisioning: ApiDatabase = {
|
|
...apiDatabase("database-stuck", {
|
|
projectId: "project-1",
|
|
name: "stuck",
|
|
}),
|
|
status: "provisioning",
|
|
defaultConnectionId: null,
|
|
connections: [],
|
|
};
|
|
const fake = makeFakeManagementApi((request) => {
|
|
if (request.pathname.startsWith("/v1/databases/")) {
|
|
return data(toWireDatabase(provisioning));
|
|
}
|
|
if (request.pathname.endsWith("/rotate")) {
|
|
throw new Error("must not rotate while provisioning");
|
|
}
|
|
return unhandled(request);
|
|
});
|
|
|
|
return Effect.gen(function* () {
|
|
const fiber = yield* recoverDatabaseConnectionSecrets(
|
|
provisioning,
|
|
{},
|
|
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }));
|
|
yield* Effect.yieldNow;
|
|
yield* TestClock.adjust("1 minute");
|
|
const result = yield* Fiber.join(fiber);
|
|
expect(Result.isFailure(result)).toBe(true);
|
|
if (Result.isFailure(result)) {
|
|
expect(String(result.failure)).toContain("database-stuck");
|
|
expect(String(result.failure)).toContain("provisioning");
|
|
expect(String(result.failure)).toContain("defaultConnectionId 'null'");
|
|
}
|
|
}).pipe(Effect.provide(fake.layer), Effect.provide(TestClock.layer()));
|
|
},
|
|
{ tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
|
|
);
|
|
|
|
const makeProjectCloud = (initial: ApiProject[] = []) => {
|
|
const projects = new Map(initial.map((project) => [project.id, project]));
|
|
const databases = new Map<string, ApiDatabase>();
|
|
const calls: Array<[string, unknown?]> = [];
|
|
let nextId = initial.length + 1;
|
|
let nextDatabaseId = 1;
|
|
let conflictNextProjectDatabaseCreate = false;
|
|
let staleNextProjectDatabaseObservation = false;
|
|
let staleProjectReads = 0;
|
|
let staleProjectDefaultRegion: string | null = null;
|
|
let staleDatabaseLists = 0;
|
|
let staleDatabases: ApiDatabase[] = [];
|
|
|
|
const currentProject = (project: ApiProject) => {
|
|
const database = Array.from(databases.values()).find(
|
|
(database) => database.project.id === project.id && database.isDefault,
|
|
);
|
|
return {
|
|
...project,
|
|
defaultRegion: database?.region?.id ?? null,
|
|
};
|
|
};
|
|
|
|
const makeDatabase = (
|
|
project: ApiProject,
|
|
input: { region?: string; isDefault?: boolean },
|
|
): ApiDatabase => {
|
|
if (input.isDefault) {
|
|
for (const [id, database] of databases) {
|
|
if (database.project.id === project.id && database.isDefault) {
|
|
databases.set(id, { ...database, isDefault: false });
|
|
}
|
|
}
|
|
}
|
|
const id = `project-database-${nextDatabaseId++}`;
|
|
const database: ApiDatabase = {
|
|
id,
|
|
type: "database",
|
|
url: `https://api.prisma.test/v1/databases/${id}`,
|
|
name: project.name,
|
|
status: "ready",
|
|
createdAt,
|
|
isDefault: input.isDefault ?? false,
|
|
defaultConnectionId: `connection-${id}`,
|
|
connections: [apiConnection(id)],
|
|
project: {
|
|
id: project.id,
|
|
url: project.url,
|
|
name: project.name,
|
|
},
|
|
region: {
|
|
id: input.region ?? "us-east-1",
|
|
name: input.region ?? "us-east-1",
|
|
},
|
|
source: { type: "empty" },
|
|
branchId: null,
|
|
};
|
|
databases.set(id, database);
|
|
return database;
|
|
};
|
|
|
|
// The same in-memory cloud, served over the wire for the resources that
|
|
// call distilled operations instead of the client above.
|
|
const fake = makeFakeManagementApi((request) => {
|
|
const segments = request.pathname.split("/").filter((s) => s.length > 0);
|
|
|
|
if (request.pathname === "/v1/services" && request.method === "GET") {
|
|
calls.push([
|
|
"listApps",
|
|
Object.fromEntries(new URLSearchParams(request.search)),
|
|
]);
|
|
return page([]);
|
|
}
|
|
|
|
if (request.pathname === "/v1/projects" && request.method === "GET") {
|
|
calls.push(["listProjects"]);
|
|
return page(
|
|
Array.from(projects.values()).map(currentProject).map(toWireProject),
|
|
);
|
|
}
|
|
|
|
if (request.pathname === "/v1/projects" && request.method === "POST") {
|
|
const input = request.bodyJson as {
|
|
name?: string;
|
|
region?: string;
|
|
createDatabase?: boolean;
|
|
};
|
|
calls.push(["createProject", input]);
|
|
const id = `project-${nextId++}`;
|
|
const project = apiProject(id, input.name ?? `project-${id}`, null);
|
|
projects.set(id, project);
|
|
const database =
|
|
input.createDatabase === false
|
|
? null
|
|
: makeDatabase(project, { region: input.region, isDefault: true });
|
|
return data(
|
|
{
|
|
...toWireProject(currentProject(project)),
|
|
database: database === null ? null : toWireCreatedDatabase(database),
|
|
},
|
|
{ status: 201 },
|
|
);
|
|
}
|
|
|
|
if (segments.length === 3 && segments[1] === "projects") {
|
|
const id = segments[2]!;
|
|
if (request.method === "GET") {
|
|
calls.push(["getProject", id]);
|
|
const stored = projects.get(id);
|
|
const project = stored
|
|
? staleProjectReads > 0
|
|
? {
|
|
...currentProject(stored),
|
|
defaultRegion: staleProjectDefaultRegion,
|
|
}
|
|
: currentProject(stored)
|
|
: undefined;
|
|
if (staleProjectReads > 0) staleProjectReads -= 1;
|
|
return project === undefined
|
|
? notFound("not found")
|
|
: data(toWireProject(project));
|
|
}
|
|
if (request.method === "PATCH") {
|
|
const input = request.bodyJson as {
|
|
name?: string;
|
|
settings?: Record<string, unknown>;
|
|
};
|
|
calls.push(["updateProject", { id, input }]);
|
|
const project = projects.get(id)!;
|
|
const updated = { ...project, name: input.name ?? project.name };
|
|
projects.set(id, updated);
|
|
return data(toWireProject(currentProject(updated)));
|
|
}
|
|
if (request.method === "DELETE") {
|
|
calls.push(["deleteProject", id]);
|
|
projects.delete(id);
|
|
for (const [databaseId, database] of databases) {
|
|
if (database.project.id === id) databases.delete(databaseId);
|
|
}
|
|
return json(null, { status: 204 });
|
|
}
|
|
}
|
|
|
|
if (
|
|
segments.length === 4 &&
|
|
segments[1] === "projects" &&
|
|
segments[3] === "databases"
|
|
) {
|
|
const projectId = segments[2]!;
|
|
if (request.method === "GET") {
|
|
calls.push(["listProjectDatabases", projectId]);
|
|
if (staleDatabaseLists > 0) {
|
|
staleDatabaseLists -= 1;
|
|
return page(staleDatabases.map(toWireDatabase));
|
|
}
|
|
return page(
|
|
Array.from(databases.values())
|
|
.filter((database) => database.project.id === projectId)
|
|
.map(toWireDatabase),
|
|
);
|
|
}
|
|
if (request.method === "POST") {
|
|
const input = request.bodyJson as {
|
|
region?: string;
|
|
isDefault?: boolean;
|
|
};
|
|
calls.push(["createProjectDatabase", { projectId, input }]);
|
|
if (conflictNextProjectDatabaseCreate) {
|
|
conflictNextProjectDatabaseCreate = false;
|
|
return conflict("default database promotion in progress");
|
|
}
|
|
const previousDefault = Array.from(databases.values()).find(
|
|
(database) => database.project.id === projectId && database.isDefault,
|
|
);
|
|
const created = makeDatabase(projects.get(projectId)!, input);
|
|
if (staleNextProjectDatabaseObservation) {
|
|
staleNextProjectDatabaseObservation = false;
|
|
staleProjectReads = 1;
|
|
staleProjectDefaultRegion = previousDefault?.region?.id ?? null;
|
|
staleDatabaseLists = 1;
|
|
staleDatabases = Array.from(databases.values())
|
|
.filter((database) => database.project.id === projectId)
|
|
.map((database) => ({
|
|
...database,
|
|
isDefault: database.id === previousDefault?.id,
|
|
}));
|
|
}
|
|
return data(toWireCreatedDatabase(created), { status: 201 });
|
|
}
|
|
}
|
|
|
|
if (segments.length === 3 && segments[1] === "databases") {
|
|
const id = segments[2]!;
|
|
if (request.method === "GET") {
|
|
calls.push(["getDatabase", id]);
|
|
const database = databases.get(id);
|
|
return database === undefined
|
|
? notFound("not found")
|
|
: data(toWireDatabase(database));
|
|
}
|
|
if (request.method === "DELETE") {
|
|
calls.push(["deleteDatabase", id]);
|
|
databases.delete(id);
|
|
return json(null, { status: 204 });
|
|
}
|
|
}
|
|
|
|
if (
|
|
segments.length === 4 &&
|
|
segments[1] === "connections" &&
|
|
segments[3] === "rotate" &&
|
|
request.method === "POST"
|
|
) {
|
|
const id = segments[2]!;
|
|
calls.push(["rotateConnection", id]);
|
|
const database = Array.from(databases.values()).find(
|
|
(candidate) => candidate.defaultConnectionId === id,
|
|
)!;
|
|
return data(apiConnection(database.id, id));
|
|
}
|
|
|
|
return unhandled(request);
|
|
});
|
|
|
|
return {
|
|
fake,
|
|
calls,
|
|
databases,
|
|
projects,
|
|
conflictNextProjectDatabaseCreate: () => {
|
|
conflictNextProjectDatabaseCreate = true;
|
|
},
|
|
staleNextProjectDatabaseObservation: () => {
|
|
staleNextProjectDatabaseObservation = true;
|
|
},
|
|
};
|
|
};
|
|
|
|
const foreignProject = apiProject("project-foreign", "app");
|
|
const refusalCloud = makeProjectCloud([foreignProject]);
|
|
const refusal = Test.make({
|
|
providers: projectLayer(refusalCloud.fake),
|
|
});
|
|
|
|
refusal.test.provider(
|
|
"Plan refuses cold adoption of a foreign Prisma project",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
refusalCloud.projects.clear();
|
|
refusalCloud.projects.set(foreignProject.id, foreignProject);
|
|
refusalCloud.calls.length = 0;
|
|
yield* stack.destroy();
|
|
|
|
const result = yield* stack
|
|
.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: false,
|
|
}),
|
|
)
|
|
.pipe(Effect.result);
|
|
|
|
expect(Result.isFailure(result)).toBe(true);
|
|
if (Result.isFailure(result)) {
|
|
expect(String(result.failure)).toContain("Cannot adopt resource");
|
|
}
|
|
expect(refusalCloud.projects.get("project-foreign")?.name).toBe("app");
|
|
expect(refusalCloud.calls.map(([operation]) => operation)).not.toContain(
|
|
"updateProject",
|
|
);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const generatedProjectRecoveryCloud = makeProjectCloud();
|
|
const generatedProjectRecovery = Test.make({
|
|
providers: projectLayer(generatedProjectRecoveryCloud.fake),
|
|
});
|
|
|
|
generatedProjectRecovery.test.provider(
|
|
"generated Project physical names recover as owned for the same instance",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
generatedProjectRecoveryCloud.projects.clear();
|
|
yield* stack.destroy();
|
|
const instanceId = "00000000000000000000000000000000";
|
|
const name = yield* createPhysicalName({ id: "Project" }).pipe(
|
|
Effect.provideService(InstanceId, instanceId),
|
|
);
|
|
generatedProjectRecoveryCloud.projects.set(
|
|
"project-generated",
|
|
apiProject("project-generated", name, null),
|
|
);
|
|
const provider = yield* Provider.findProvider(PrismaProject);
|
|
const observed = yield* provider.read!({
|
|
id: "Project",
|
|
instanceId,
|
|
olds: { createDatabase: false },
|
|
output: undefined,
|
|
} as never).pipe(Effect.provideService(InstanceId, instanceId));
|
|
|
|
expect(observed).toBeDefined();
|
|
expect(Unowned.is(observed!)).toBe(false);
|
|
expect((observed as PrismaProject["Attributes"]).projectName).toBe(name);
|
|
generatedProjectRecoveryCloud.databases.set("database-new-default", {
|
|
...apiDatabase("database-new-default", {
|
|
projectId: "project-generated",
|
|
name: "new-default",
|
|
isDefault: true,
|
|
}),
|
|
connections: [],
|
|
});
|
|
const staleSecret = Redacted.make("postgres://old-project-secret");
|
|
const switched = yield* provider.read!({
|
|
id: "Project",
|
|
instanceId,
|
|
olds: { createDatabase: true },
|
|
output: {
|
|
...(observed as PrismaProject["Attributes"]),
|
|
databaseId: "database-old-default",
|
|
directConnectionString: staleSecret,
|
|
pooledConnectionString: staleSecret,
|
|
accelerateConnectionString: staleSecret,
|
|
password: Redacted.make("old-password"),
|
|
},
|
|
} as never).pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect((switched as PrismaProject["Attributes"]).databaseId).toBe(
|
|
"database-new-default",
|
|
);
|
|
expect(
|
|
(switched as PrismaProject["Attributes"]).directConnectionString,
|
|
).toBeUndefined();
|
|
expect(
|
|
(switched as PrismaProject["Attributes"]).password,
|
|
).toBeUndefined();
|
|
const cannotDropAdoptedDefault = yield* provider
|
|
.reconcile({
|
|
id: "Project",
|
|
instanceId,
|
|
news: { createDatabase: false },
|
|
olds: undefined,
|
|
output: switched,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId), Effect.result);
|
|
expect(Result.isFailure(cannotDropAdoptedDefault)).toBe(true);
|
|
if (Result.isFailure(cannotDropAdoptedDefault)) {
|
|
expect(String(cannotDropAdoptedDefault.failure)).toContain(
|
|
"cannot be removed in place",
|
|
);
|
|
}
|
|
generatedProjectRecoveryCloud.calls.length = 0;
|
|
const recoveredSecrets = yield* provider
|
|
.reconcile({
|
|
id: "Project",
|
|
instanceId,
|
|
news: { createDatabase: true },
|
|
olds: { createDatabase: true },
|
|
output: switched,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect(
|
|
Redacted.value(recoveredSecrets.directConnectionString!),
|
|
).toContain("database-new-default");
|
|
expect(generatedProjectRecoveryCloud.calls).toContainEqual([
|
|
"rotateConnection",
|
|
"connection-database-new-default",
|
|
]);
|
|
generatedProjectRecoveryCloud.projects.clear();
|
|
generatedProjectRecoveryCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const adoptionCloud = makeProjectCloud([foreignProject]);
|
|
const adoption = Test.make({
|
|
providers: projectLayer(adoptionCloud.fake),
|
|
adopt: true,
|
|
});
|
|
|
|
adoption.test.provider(
|
|
"Plan adopts explicitly and applies write-only project settings",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
adoptionCloud.projects.clear();
|
|
adoptionCloud.projects.set(foreignProject.id, foreignProject);
|
|
adoptionCloud.calls.length = 0;
|
|
yield* stack.destroy();
|
|
|
|
const project = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
region: "us-east-1",
|
|
settings: {},
|
|
}),
|
|
);
|
|
|
|
expect(project.projectId).toBe("project-foreign");
|
|
expect(adoptionCloud.calls).toContainEqual([
|
|
"updateProject",
|
|
{ id: "project-foreign", input: { name: "app", settings: {} } },
|
|
]);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const replacementCloud = makeProjectCloud();
|
|
const replacement = Test.make({
|
|
providers: projectLayer(replacementCloud.fake),
|
|
});
|
|
|
|
replacement.test.provider(
|
|
"Apply refuses to replace a data-bearing default database for a region change",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
replacementCloud.projects.clear();
|
|
replacementCloud.calls.length = 0;
|
|
yield* stack.destroy();
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
region: "us-east-1",
|
|
}),
|
|
);
|
|
replacementCloud.calls.length = 0;
|
|
|
|
const result = yield* stack
|
|
.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
region: "us-west-1" as const,
|
|
}),
|
|
)
|
|
.pipe(Effect.result);
|
|
|
|
expect(Result.isFailure(result)).toBe(true);
|
|
if (Result.isFailure(result)) {
|
|
expect(String(result.failure)).toContain("Cannot safely change");
|
|
expect(String(result.failure)).toContain("explicit data migration");
|
|
}
|
|
expect(first.projectId).toBeDefined();
|
|
const operations = replacementCloud.calls.map(([operation]) => operation);
|
|
expect(operations).not.toContain("createProjectDatabase");
|
|
expect(operations).not.toContain("deleteDatabase");
|
|
expect(operations).not.toContain("deleteProject");
|
|
expect(Array.from(replacementCloud.databases.values())).toHaveLength(1);
|
|
expect(
|
|
Array.from(replacementCloud.databases.values())[0]?.region?.id,
|
|
).toBe("us-east-1");
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const eventuallyConsistentRegionCloud = makeProjectCloud();
|
|
const eventuallyConsistentRegion = Test.make({
|
|
providers: projectLayer(eventuallyConsistentRegionCloud.fake),
|
|
});
|
|
|
|
eventuallyConsistentRegion.test.provider(
|
|
"Project default database creation retries one stale observation",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
eventuallyConsistentRegionCloud.projects.clear();
|
|
eventuallyConsistentRegionCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: false,
|
|
}),
|
|
);
|
|
eventuallyConsistentRegionCloud.calls.length = 0;
|
|
eventuallyConsistentRegionCloud.staleNextProjectDatabaseObservation();
|
|
const second = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
region: "us-west-1" as const,
|
|
}),
|
|
);
|
|
|
|
expect(second.projectId).toBe(first.projectId);
|
|
expect(second.defaultRegion).toBe("us-west-1");
|
|
expect(Redacted.value(second.directConnectionString!)).toContain(
|
|
second.databaseId!,
|
|
);
|
|
expect(
|
|
eventuallyConsistentRegionCloud.calls.filter(
|
|
([operation]) => operation === "getProject",
|
|
).length,
|
|
).toBeGreaterThanOrEqual(3);
|
|
expect(
|
|
eventuallyConsistentRegionCloud.calls.filter(
|
|
([operation]) => operation === "listProjectDatabases",
|
|
).length,
|
|
).toBeGreaterThanOrEqual(3);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const conflictingRegionCloud = makeProjectCloud();
|
|
const conflictingRegion = Test.make({
|
|
providers: projectLayer(conflictingRegionCloud.fake),
|
|
});
|
|
|
|
conflictingRegion.test.provider(
|
|
"Project default database creation rejects a conflict without an observed default",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
conflictingRegionCloud.projects.clear();
|
|
conflictingRegionCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: false,
|
|
}),
|
|
);
|
|
conflictingRegionCloud.calls.length = 0;
|
|
conflictingRegionCloud.conflictNextProjectDatabaseCreate();
|
|
|
|
const result = yield* stack
|
|
.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
region: "us-west-1" as const,
|
|
}),
|
|
)
|
|
.pipe(Effect.result);
|
|
|
|
expect(Result.isFailure(result)).toBe(true);
|
|
if (Result.isFailure(result)) {
|
|
expect(String(result.failure)).toContain(
|
|
"does not expose the requested default database",
|
|
);
|
|
}
|
|
expect(first.databaseId).toBeUndefined();
|
|
expect(conflictingRegionCloud.databases.size).toBe(0);
|
|
expect(
|
|
conflictingRegionCloud.calls.map(([operation]) => operation),
|
|
).not.toContain("deleteDatabase");
|
|
|
|
conflictingRegionCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const addDefaultCloud = makeProjectCloud();
|
|
const addDefault = Test.make({
|
|
providers: projectLayer(addDefaultCloud.fake),
|
|
});
|
|
|
|
addDefault.test.provider(
|
|
"Apply adds a missing Project default database without replacing the Project",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
addDefaultCloud.projects.clear();
|
|
addDefaultCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: false,
|
|
}),
|
|
);
|
|
addDefaultCloud.calls.length = 0;
|
|
const second = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
region: "us-west-1" as const,
|
|
}),
|
|
);
|
|
|
|
expect(second.projectId).toBe(first.projectId);
|
|
expect(second.defaultRegion).toBe("us-west-1");
|
|
expect(addDefaultCloud.databases.size).toBe(1);
|
|
expect(addDefaultCloud.calls.map(([operation]) => operation)).toContain(
|
|
"createProjectDatabase",
|
|
);
|
|
expect(
|
|
addDefaultCloud.calls.map(([operation]) => operation),
|
|
).not.toContain("deleteProject");
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const removeDefaultCloud = makeProjectCloud();
|
|
const removeDefault = Test.make({
|
|
providers: projectLayer(removeDefaultCloud.fake),
|
|
});
|
|
|
|
removeDefault.test.provider(
|
|
"Apply replaces a Project when removing its last default database",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
removeDefaultCloud.projects.clear();
|
|
removeDefaultCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: true,
|
|
}),
|
|
);
|
|
removeDefaultCloud.calls.length = 0;
|
|
const second = yield* stack.deploy(
|
|
PrismaProject("Project", {
|
|
name: "app",
|
|
createDatabase: false,
|
|
}),
|
|
);
|
|
|
|
expect(second.projectId).not.toBe(first.projectId);
|
|
expect(second.databaseId).toBeUndefined();
|
|
const operations = removeDefaultCloud.calls.map(
|
|
([operation]) => operation,
|
|
);
|
|
expect(operations.indexOf("deleteProject")).toBeLessThan(
|
|
operations.indexOf("createProject"),
|
|
);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:database",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const apiDatabase = (
|
|
id: string,
|
|
input: {
|
|
projectId: string;
|
|
name?: string;
|
|
region?: string;
|
|
isDefault?: boolean;
|
|
source?: ApiDatabase["source"];
|
|
},
|
|
): ApiDatabase => ({
|
|
id,
|
|
type: "database",
|
|
url: `https://api.prisma.test/v1/databases/${id}`,
|
|
name: input.name ?? `database-${id}`,
|
|
status: "ready",
|
|
createdAt,
|
|
isDefault: input.isDefault ?? false,
|
|
defaultConnectionId: `connection-${id}`,
|
|
connections: [apiConnection(id)],
|
|
project: {
|
|
id: input.projectId,
|
|
url: `https://api.prisma.test/v1/projects/${input.projectId}`,
|
|
name: "app",
|
|
},
|
|
region: { id: input.region ?? "us-east-1", name: "Region" },
|
|
source: input.source ?? { type: "empty" },
|
|
branchId: null,
|
|
});
|
|
|
|
const makeDatabaseCloud = () => {
|
|
const databases = new Map<string, ApiDatabase>();
|
|
const calls: Array<[string, unknown?]> = [];
|
|
let nextId = 1;
|
|
// The same in-memory cloud, served over the wire for the Database resource.
|
|
const fake = makeFakeManagementApi((request) => {
|
|
const segments = request.pathname.split("/").filter((s) => s.length > 0);
|
|
|
|
if (request.pathname === "/v1/databases" && request.method === "GET") {
|
|
return page(Array.from(databases.values()).map(toWireDatabase));
|
|
}
|
|
if (request.pathname === "/v1/databases" && request.method === "POST") {
|
|
const input = request.bodyJson as {
|
|
projectId: string;
|
|
name?: string;
|
|
region?: string;
|
|
isDefault?: boolean;
|
|
source?: ApiDatabase["source"];
|
|
};
|
|
calls.push(["createDatabase", input]);
|
|
if (input.isDefault) {
|
|
for (const [id, database] of databases) {
|
|
if (database.project.id === input.projectId && database.isDefault) {
|
|
databases.set(id, { ...database, isDefault: false });
|
|
}
|
|
}
|
|
}
|
|
const id = `database-${nextId++}`;
|
|
const database = apiDatabase(id, input);
|
|
databases.set(id, database);
|
|
return data(toWireCreatedDatabase(database), { status: 201 });
|
|
}
|
|
|
|
if (
|
|
segments.length === 4 &&
|
|
segments[1] === "projects" &&
|
|
segments[3] === "databases" &&
|
|
request.method === "GET"
|
|
) {
|
|
const projectId = segments[2]!;
|
|
return page(
|
|
Array.from(databases.values())
|
|
.filter((database) => database.project.id === projectId)
|
|
.map(toWireDatabase),
|
|
);
|
|
}
|
|
|
|
if (segments.length === 3 && segments[1] === "databases") {
|
|
const id = segments[2]!;
|
|
if (request.method === "GET") {
|
|
calls.push(["getDatabase", id]);
|
|
const database = databases.get(id);
|
|
return database === undefined
|
|
? notFound("not found")
|
|
: data(toWireDatabase(database));
|
|
}
|
|
if (request.method === "PATCH") {
|
|
const input = request.bodyJson as { name?: string };
|
|
const database = databases.get(id)!;
|
|
const updated = { ...database, name: input.name ?? database.name };
|
|
databases.set(id, updated);
|
|
return data(toWireDatabase(updated));
|
|
}
|
|
if (request.method === "DELETE") {
|
|
calls.push(["deleteDatabase", id]);
|
|
databases.delete(id);
|
|
return json(null, { status: 204 });
|
|
}
|
|
}
|
|
|
|
if (
|
|
segments.length === 4 &&
|
|
segments[1] === "connections" &&
|
|
segments[3] === "rotate" &&
|
|
request.method === "POST"
|
|
) {
|
|
const id = segments[2]!;
|
|
calls.push(["rotateConnection", id]);
|
|
const database = Array.from(databases.values()).find(
|
|
(candidate) => candidate.defaultConnectionId === id,
|
|
)!;
|
|
return data(apiConnection(database.id, id));
|
|
}
|
|
|
|
return unhandled(request);
|
|
});
|
|
|
|
return { fake, calls, databases };
|
|
};
|
|
|
|
const generatedDatabaseRecoveryCloud = makeDatabaseCloud();
|
|
const generatedDatabaseRecovery = Test.make({
|
|
providers: databaseLayer(generatedDatabaseRecoveryCloud.fake),
|
|
});
|
|
|
|
generatedDatabaseRecovery.test.provider(
|
|
"unnamed Database physical names recover exactly without adoption",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
generatedDatabaseRecoveryCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
const instanceId = "00000000000000000000000000000000";
|
|
const name = yield* createPhysicalName({ id: "Database" }).pipe(
|
|
Effect.provideService(InstanceId, instanceId),
|
|
);
|
|
generatedDatabaseRecoveryCloud.databases.set("database-generated", {
|
|
...apiDatabase("database-generated", {
|
|
projectId: "project-1",
|
|
name,
|
|
isDefault: false,
|
|
}),
|
|
connections: [],
|
|
});
|
|
const provider = yield* Provider.findProvider(PrismaDatabase);
|
|
const observed = yield* provider.read!({
|
|
id: "Database",
|
|
instanceId,
|
|
olds: { project: "project-1", isDefault: false },
|
|
output: undefined,
|
|
} as never).pipe(Effect.provideService(InstanceId, instanceId));
|
|
|
|
expect(observed).toBeDefined();
|
|
expect(Unowned.is(observed!)).toBe(false);
|
|
expect((observed as PrismaDatabase["Attributes"]).databaseName).toBe(
|
|
name,
|
|
);
|
|
const localSecret = Redacted.make("postgres://local-dev-secret");
|
|
const fromDev = yield* provider.read!({
|
|
id: "Database",
|
|
instanceId,
|
|
olds: { project: "project-1", isDefault: false },
|
|
output: {
|
|
databaseId: "dev:database:Database",
|
|
databaseName: "local",
|
|
projectId: "dev:project:Project",
|
|
status: "ready",
|
|
region: null,
|
|
isDefault: false,
|
|
branchId: null,
|
|
defaultConnectionId: null,
|
|
createdAt,
|
|
directConnectionString: localSecret,
|
|
pooledConnectionString: localSecret,
|
|
accelerateConnectionString: localSecret,
|
|
host: "127.0.0.1",
|
|
user: "postgres",
|
|
password: Redacted.make("local-password"),
|
|
},
|
|
} as never).pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect((fromDev as PrismaDatabase["Attributes"]).databaseId).toBe(
|
|
"database-generated",
|
|
);
|
|
expect(
|
|
(fromDev as PrismaDatabase["Attributes"]).directConnectionString,
|
|
).toBeUndefined();
|
|
expect(
|
|
(fromDev as PrismaDatabase["Attributes"]).password,
|
|
).toBeUndefined();
|
|
|
|
generatedDatabaseRecoveryCloud.calls.length = 0;
|
|
const recovered = yield* provider
|
|
.reconcile({
|
|
id: "Database",
|
|
instanceId,
|
|
news: { project: "project-1", isDefault: false },
|
|
olds: { project: "project-1", isDefault: false },
|
|
output: observed,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect(Redacted.value(recovered.directConnectionString!)).toContain(
|
|
"database-generated",
|
|
);
|
|
expect(generatedDatabaseRecoveryCloud.calls).toContainEqual([
|
|
"rotateConnection",
|
|
"connection-database-generated",
|
|
]);
|
|
|
|
const explicit = {
|
|
...apiDatabase("database-explicit", {
|
|
projectId: "project-1",
|
|
name: "explicit",
|
|
isDefault: false,
|
|
}),
|
|
connections: [],
|
|
};
|
|
generatedDatabaseRecoveryCloud.databases.set(explicit.id, explicit);
|
|
const adoptionObserved = yield* provider.read!({
|
|
id: "ExplicitDatabase",
|
|
instanceId,
|
|
olds: {
|
|
project: "project-1",
|
|
name: "explicit",
|
|
isDefault: false,
|
|
},
|
|
output: undefined,
|
|
} as never).pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect(Unowned.is(adoptionObserved!)).toBe(true);
|
|
const wrongRegion = yield* provider
|
|
.reconcile({
|
|
id: "ExplicitDatabase",
|
|
instanceId,
|
|
news: {
|
|
project: "project-1",
|
|
name: "explicit",
|
|
region: "us-west-1",
|
|
isDefault: false,
|
|
},
|
|
olds: undefined,
|
|
output: adoptionObserved,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId), Effect.result);
|
|
expect(Result.isFailure(wrongRegion)).toBe(true);
|
|
if (Result.isFailure(wrongRegion)) {
|
|
expect(String(wrongRegion.failure)).toContain("immutable region");
|
|
}
|
|
const cannotPromoteAdopted = yield* provider
|
|
.reconcile({
|
|
id: "ExplicitDatabase",
|
|
instanceId,
|
|
news: {
|
|
project: "project-1",
|
|
name: "explicit",
|
|
isDefault: true,
|
|
},
|
|
olds: undefined,
|
|
output: adoptionObserved,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId), Effect.result);
|
|
expect(Result.isFailure(cannotPromoteAdopted)).toBe(true);
|
|
if (Result.isFailure(cannotPromoteAdopted)) {
|
|
expect(String(cannotPromoteAdopted.failure)).toContain(
|
|
"cannot manage a default database",
|
|
);
|
|
}
|
|
const adopted = yield* provider
|
|
.reconcile({
|
|
id: "ExplicitDatabase",
|
|
instanceId,
|
|
news: {
|
|
project: "project-1",
|
|
name: "explicit",
|
|
isDefault: false,
|
|
},
|
|
olds: undefined,
|
|
output: adoptionObserved,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect(adopted.directConnectionString).toBeUndefined();
|
|
expect(
|
|
generatedDatabaseRecoveryCloud.calls.filter(
|
|
([operation, id]) =>
|
|
operation === "rotateConnection" &&
|
|
id === "connection-database-explicit",
|
|
),
|
|
).toEqual([]);
|
|
|
|
const adoptedWithRotation = yield* provider
|
|
.reconcile({
|
|
id: "ExplicitDatabase",
|
|
instanceId,
|
|
news: {
|
|
project: "project-1",
|
|
name: "explicit",
|
|
isDefault: false,
|
|
rotateCredentialsOnAdopt: true,
|
|
},
|
|
olds: undefined,
|
|
output: adoptionObserved,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.provideService(InstanceId, instanceId));
|
|
expect(
|
|
Redacted.value(adoptedWithRotation.directConnectionString!),
|
|
).toContain("database-explicit");
|
|
expect(generatedDatabaseRecoveryCloud.calls).toContainEqual([
|
|
"rotateConnection",
|
|
"connection-database-explicit",
|
|
]);
|
|
generatedDatabaseRecoveryCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
}),
|
|
{ tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
|
|
);
|
|
|
|
it.effect(
|
|
"refuses an undeletable standalone default database",
|
|
() => {
|
|
let created = false;
|
|
const client = {
|
|
createDatabase: () =>
|
|
Effect.sync(() => {
|
|
created = true;
|
|
throw new Error("must fail before create");
|
|
}),
|
|
} as unknown as PrismaManagementClient;
|
|
|
|
return Effect.gen(function* () {
|
|
const provider = yield* PrismaDatabase.Provider;
|
|
const error = yield* provider
|
|
.reconcile({
|
|
id: "Database",
|
|
instanceId: "00000000000000000000000000000000",
|
|
news: {
|
|
project: "project-1",
|
|
name: "primary",
|
|
region: "us-east-1",
|
|
isDefault: true,
|
|
},
|
|
olds: undefined,
|
|
output: undefined,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.flip);
|
|
|
|
expect(String(error)).toContain("could never be destroyed");
|
|
expect(created).toBe(false);
|
|
}).pipe(
|
|
Effect.provide(DatabaseProvider()),
|
|
Effect.provide(Layer.succeed(PrismaClient, client)),
|
|
Effect.provide(liveProviderContext),
|
|
Effect.provide(testStackContext),
|
|
Effect.provide(makeFakeManagementApi(unhandled).layer),
|
|
Effect.provide(PlatformServices),
|
|
);
|
|
},
|
|
{ tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
|
|
);
|
|
|
|
const inheritedRegionCloud = makeDatabaseCloud();
|
|
const inheritedRegion = Test.make({
|
|
providers: databaseLayer(inheritedRegionCloud.fake),
|
|
});
|
|
|
|
inheritedRegion.test.provider(
|
|
"Database region inherit is stable and follows the project default region",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
inheritedRegionCloud.databases.clear();
|
|
yield* stack.destroy();
|
|
inheritedRegionCloud.databases.set(
|
|
"project-default",
|
|
apiDatabase("project-default", {
|
|
projectId: "project-1",
|
|
name: "project-default",
|
|
region: "us-east-1",
|
|
isDefault: true,
|
|
}),
|
|
);
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaDatabase("Database", {
|
|
project: "project-1",
|
|
name: "inherited",
|
|
region: "inherit",
|
|
}),
|
|
);
|
|
expect(first.region).toBe("us-east-1");
|
|
|
|
inheritedRegionCloud.calls.length = 0;
|
|
const second = yield* stack.deploy(
|
|
PrismaDatabase("Database", {
|
|
project: "project-1",
|
|
name: "inherited",
|
|
region: "inherit",
|
|
}),
|
|
);
|
|
expect(second.databaseId).toBe(first.databaseId);
|
|
expect(
|
|
inheritedRegionCloud.calls.map(([operation]) => operation),
|
|
).not.toContain("createDatabase");
|
|
|
|
inheritedRegionCloud.databases.set("project-default", {
|
|
...inheritedRegionCloud.databases.get("project-default")!,
|
|
region: { id: "us-west-1", name: "US West" },
|
|
});
|
|
inheritedRegionCloud.calls.length = 0;
|
|
const moved = yield* stack.deploy(
|
|
PrismaDatabase("Database", {
|
|
project: "project-1",
|
|
name: "inherited",
|
|
region: "inherit",
|
|
}),
|
|
);
|
|
expect(moved.databaseId).not.toBe(first.databaseId);
|
|
expect(moved.region).toBe("us-west-1");
|
|
expect(
|
|
inheritedRegionCloud.calls.map(([operation]) => operation),
|
|
).toContain("createDatabase");
|
|
|
|
yield* stack.destroy();
|
|
inheritedRegionCloud.databases.clear();
|
|
}),
|
|
{ tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
|
|
);
|
|
|
|
const environmentVariable = {
|
|
id: "env-1",
|
|
type: "environment-variable" as const,
|
|
url: "https://api.prisma.test/v1/environment-variables/env-1",
|
|
projectId: "project-1",
|
|
branchId: null,
|
|
class: "production" as const,
|
|
key: "TOKEN",
|
|
valueKid: "kid-1",
|
|
isManagedBySystem: false,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
};
|
|
const environmentSecrets = new Map([[environmentVariable.id, "foreign"]]);
|
|
const environmentCalls: Array<[string, unknown?]> = [];
|
|
const environmentClient = {
|
|
listEnvironmentVariables: () => Effect.succeed([environmentVariable]),
|
|
getEnvironmentVariable: (id: string) =>
|
|
Effect.succeed({ ...environmentVariable, id }),
|
|
createEnvironmentVariable: () =>
|
|
Effect.fail(
|
|
new PrismaApiError({
|
|
method: "POST",
|
|
path: "/v1/environment-variables",
|
|
status: 409,
|
|
message: "already exists",
|
|
}),
|
|
),
|
|
updateEnvironmentVariable: (id: string, input: { value: string }) =>
|
|
Effect.sync(() => {
|
|
environmentCalls.push(["updateEnvironmentVariable", { id, input }]);
|
|
environmentSecrets.set(id, input.value);
|
|
return { ...environmentVariable, id };
|
|
}),
|
|
deleteEnvironmentVariable: (id: string) =>
|
|
Effect.sync(() => {
|
|
environmentSecrets.delete(id);
|
|
}),
|
|
} as unknown as PrismaManagementClient;
|
|
const environmentAdoption = Test.make({
|
|
providers: environmentVariableLayer(environmentClient),
|
|
adopt: true,
|
|
});
|
|
|
|
environmentAdoption.test.provider(
|
|
"adoption and ordinary deploys always converge write-only environment secrets",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
environmentSecrets.clear();
|
|
environmentSecrets.set(environmentVariable.id, "foreign");
|
|
environmentCalls.length = 0;
|
|
yield* stack.destroy();
|
|
|
|
const deploy = () =>
|
|
stack.deploy(
|
|
PrismaEnvironmentVariable("Token", {
|
|
project: "project-1",
|
|
class: "production",
|
|
key: "TOKEN",
|
|
value: Redacted.make("desired"),
|
|
}),
|
|
);
|
|
|
|
yield* deploy();
|
|
expect(environmentSecrets.get("env-1")).toBe("desired");
|
|
|
|
environmentSecrets.set("env-1", "externally-drifted");
|
|
yield* deploy();
|
|
expect(environmentSecrets.get("env-1")).toBe("desired");
|
|
expect(
|
|
environmentCalls.filter(
|
|
([operation]) => operation === "updateEnvironmentVariable",
|
|
),
|
|
).toHaveLength(2);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:environmentvariable",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const customDomainCalls: Array<[string, unknown?]> = [];
|
|
const customDomainCloud = new Map<string, ApiCustomDomain>();
|
|
let nextCustomDomainId = 1;
|
|
const customDomainClient = {
|
|
listAppDomains: (appId: string) =>
|
|
Effect.sync(() => {
|
|
customDomainCalls.push(["listAppDomains", appId]);
|
|
return Array.from(customDomainCloud.values()).filter(
|
|
(domain) => domain.appId === appId,
|
|
);
|
|
}),
|
|
getCustomDomain: (id: string) =>
|
|
Effect.suspend(() => {
|
|
const domain = customDomainCloud.get(id);
|
|
return domain
|
|
? Effect.succeed(domain)
|
|
: Effect.fail(
|
|
new PrismaApiError({
|
|
method: "GET",
|
|
path: `/v1/domains/${id}`,
|
|
status: 404,
|
|
message: "not found",
|
|
}),
|
|
);
|
|
}),
|
|
getApp: (appId: string) =>
|
|
Effect.sync(() => {
|
|
customDomainCalls.push(["getApp", appId]);
|
|
return {
|
|
id: appId,
|
|
type: "app" as const,
|
|
url: `https://api.prisma.test/v1/services/${appId}`,
|
|
name: "api",
|
|
region: { id: "us-east-1", name: "US East" },
|
|
projectId: "project-1",
|
|
branchId: "branch-1",
|
|
latestDeploymentId: null,
|
|
appEndpointDomain: "api.prisma.build",
|
|
createdAt,
|
|
};
|
|
}),
|
|
getBranch: (branchId: string) =>
|
|
Effect.succeed({
|
|
id: branchId,
|
|
type: "branch" as const,
|
|
url: `https://api.prisma.test/v1/branches/${branchId}`,
|
|
gitName: "main",
|
|
isDefault: true,
|
|
role: "production" as const,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
project: {
|
|
id: "project-1",
|
|
url: "https://api.prisma.test/v1/projects/project-1",
|
|
name: "app",
|
|
},
|
|
}),
|
|
createAppDomain: (appId: string, input: { hostname: string }) =>
|
|
Effect.sync(() => {
|
|
customDomainCalls.push(["createAppDomain", { appId, input }]);
|
|
const id = `domain-${nextCustomDomainId++}`;
|
|
const domain: ApiCustomDomain = {
|
|
id,
|
|
type: "custom-domain" as const,
|
|
url: `https://api.prisma.test/v1/domains/${id}`,
|
|
hostname: input.hostname,
|
|
appId,
|
|
status: "pending_dns" as const,
|
|
foundryStatus: "pending",
|
|
failureReason: null,
|
|
failureCategory: null,
|
|
certExpiresAt: null,
|
|
dnsRecords: [
|
|
{
|
|
type: "CNAME" as const,
|
|
name: input.hostname,
|
|
value: "api.prisma.build",
|
|
ttl: null,
|
|
},
|
|
],
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
};
|
|
customDomainCloud.set(id, domain);
|
|
return { status: 201 as const, domain };
|
|
}),
|
|
retryCustomDomain: (id: string) =>
|
|
Effect.sync(() => {
|
|
customDomainCalls.push(["retryCustomDomain", id]);
|
|
const domain = customDomainCloud.get(id)!;
|
|
const retried: ApiCustomDomain = {
|
|
...domain,
|
|
status: "verifying",
|
|
foundryStatus: "provisioning",
|
|
failureReason: null,
|
|
failureCategory: null,
|
|
updatedAt: createdAt,
|
|
};
|
|
customDomainCloud.set(id, retried);
|
|
return retried;
|
|
}),
|
|
deleteCustomDomain: (id: string) =>
|
|
Effect.sync(() => {
|
|
customDomainCalls.push(["deleteCustomDomain", id]);
|
|
customDomainCloud.delete(id);
|
|
}),
|
|
} as unknown as PrismaManagementClient;
|
|
const customDomains = Test.make({
|
|
providers: customDomainLayer(customDomainClient),
|
|
});
|
|
|
|
customDomains.test.provider(
|
|
"custom domains use the canonical App API and exact status fields",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
customDomainCalls.length = 0;
|
|
customDomainCloud.clear();
|
|
nextCustomDomainId = 1;
|
|
yield* stack.destroy();
|
|
|
|
const domain = yield* stack.deploy(
|
|
PrismaCustomDomain("Domain", {
|
|
app: {
|
|
appId: Output.asOutput("app-1"),
|
|
} as unknown as App,
|
|
hostname: "NEW.EXAMPLE.COM.",
|
|
}),
|
|
);
|
|
|
|
expect(domain.appId).toBe("app-1");
|
|
expect(domain.hostname).toBe("new.example.com");
|
|
expect(domain.foundryStatus).toBe("pending");
|
|
expect(customDomainCalls).toContainEqual(["listAppDomains", "app-1"]);
|
|
expect(customDomainCalls).toContainEqual([
|
|
"createAppDomain",
|
|
{ appId: "app-1", input: { hostname: "new.example.com" } },
|
|
]);
|
|
|
|
const replacement = domain;
|
|
|
|
customDomainCloud.set(replacement.customDomainId, {
|
|
...customDomainCloud.get(replacement.customDomainId)!,
|
|
status: "failed",
|
|
foundryStatus: "failed",
|
|
failureReason: "DNS verification failed",
|
|
failureCategory: "dns",
|
|
});
|
|
const provider = yield* Provider.findProvider(PrismaCustomDomain);
|
|
const failed = yield* provider.read!({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
output: replacement,
|
|
} as never);
|
|
const failedDiff = yield* provider.diff!({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
news: { app: "app-1", hostname: "new.example.com" },
|
|
output: failed,
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never);
|
|
expect(failedDiff).toEqual({ action: "update" });
|
|
const activeDiff = yield* provider.diff!({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
news: { app: "app-1", hostname: "new.example.com" },
|
|
output: { ...replacement, status: "active" },
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never);
|
|
expect(activeDiff).toBeUndefined();
|
|
customDomainCalls.length = 0;
|
|
const retried = yield* provider.reconcile({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
news: { app: "app-1", hostname: "new.example.com" },
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
output: failed,
|
|
bindings: [],
|
|
} as never);
|
|
expect(retried.customDomainId).toBe(replacement.customDomainId);
|
|
expect(retried.status).toBe("verifying");
|
|
expect(customDomainCalls).toContainEqual([
|
|
"retryCustomDomain",
|
|
replacement.customDomainId,
|
|
]);
|
|
expect(
|
|
customDomainCalls.filter(
|
|
([operation]) => operation === "retryCustomDomain",
|
|
),
|
|
).toHaveLength(1);
|
|
const moveDiff = yield* provider.diff!({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
news: { app: "app-2", hostname: "new.example.com" },
|
|
output: retried,
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never).pipe(Effect.result);
|
|
expect(Result.isFailure(moveDiff)).toBe(true);
|
|
if (Result.isFailure(moveDiff)) {
|
|
expect(String(moveDiff.failure)).toContain("cannot atomically replace");
|
|
}
|
|
const hostnameDiff = yield* provider.diff!({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
news: { app: "app-1", hostname: "other.example.com" },
|
|
output: retried,
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never).pipe(Effect.result);
|
|
expect(Result.isFailure(hostnameDiff)).toBe(true);
|
|
|
|
const mismatch = yield* provider
|
|
.reconcile({
|
|
id: "Domain",
|
|
instanceId: "00000000000000000000000000000000",
|
|
news: { app: "app-2", hostname: "new.example.com" },
|
|
olds: { app: "app-1", hostname: "new.example.com" },
|
|
output: retried,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.result);
|
|
expect(Result.isFailure(mismatch)).toBe(true);
|
|
|
|
yield* stack.destroy();
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:app",
|
|
"provider:prisma:customdomain",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const sourceRepositoryCloud = new Map<string, ApiSourceRepository>();
|
|
const sourceRepositoryCalls: Array<[string, unknown?]> = [];
|
|
let nextSourceRepositoryId = 1;
|
|
const sourceRepositoryClient = {
|
|
listProjects: () =>
|
|
Effect.succeed([
|
|
apiProject("project-1", "one", null),
|
|
apiProject("project-2", "two", null),
|
|
]),
|
|
listSourceRepositories: ({ projectId }: { projectId: string }) =>
|
|
Effect.succeed(
|
|
Array.from(sourceRepositoryCloud.values()).filter(
|
|
(repository) =>
|
|
repository.projectId === projectId && repository.status === "active",
|
|
),
|
|
),
|
|
listApps: () => Effect.succeed([]),
|
|
listProjectDatabases: () => Effect.succeed([]),
|
|
listBranches: (projectId: string) =>
|
|
Effect.succeed([
|
|
{
|
|
id: `branch-${projectId}`,
|
|
type: "branch" as const,
|
|
url: `https://api.prisma.test/v1/branches/branch-${projectId}`,
|
|
gitName: "main",
|
|
isDefault: true,
|
|
role: "production" as const,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
project: {
|
|
id: projectId,
|
|
url: `https://api.prisma.test/v1/projects/${projectId}`,
|
|
name: projectId,
|
|
},
|
|
},
|
|
]),
|
|
getSourceRepository: (id: string) =>
|
|
Effect.suspend(() => {
|
|
const repository = sourceRepositoryCloud.get(id);
|
|
return repository?.status === "active"
|
|
? Effect.succeed(repository)
|
|
: Effect.fail(
|
|
new PrismaApiError({
|
|
method: "GET",
|
|
path: `/v1/source-repositories/${id}`,
|
|
status: 404,
|
|
message: "not found",
|
|
}),
|
|
);
|
|
}),
|
|
createSourceRepository: (input: {
|
|
projectId: string;
|
|
provider: "github";
|
|
providerRepositoryId: number;
|
|
installationId?: string;
|
|
}) =>
|
|
Effect.suspend(() => {
|
|
sourceRepositoryCalls.push(["createSourceRepository", input]);
|
|
const conflict = Array.from(sourceRepositoryCloud.values()).some(
|
|
(repository) =>
|
|
repository.status === "active" &&
|
|
(repository.projectId === input.projectId ||
|
|
repository.repoId === input.providerRepositoryId),
|
|
);
|
|
if (conflict) {
|
|
return Effect.fail(
|
|
new PrismaApiError({
|
|
method: "POST",
|
|
path: "/v1/source-repositories",
|
|
status: 409,
|
|
message: "already linked",
|
|
}),
|
|
);
|
|
}
|
|
const id = `source-${nextSourceRepositoryId++}`;
|
|
const repository: ApiSourceRepository = {
|
|
id,
|
|
type: "source-repository",
|
|
url: `https://api.prisma.test/v1/source-repositories/${id}`,
|
|
repoId: input.providerRepositoryId,
|
|
provider: input.provider,
|
|
repoFullName: `owner/repo-${input.providerRepositoryId}`,
|
|
defaultBranch: "main",
|
|
isPrivate: false,
|
|
status: "active",
|
|
projectId: input.projectId,
|
|
installationId: input.installationId ?? "installation-auto",
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
};
|
|
sourceRepositoryCloud.set(id, repository);
|
|
return Effect.succeed(repository);
|
|
}),
|
|
deleteSourceRepository: (id: string) =>
|
|
Effect.sync(() => {
|
|
sourceRepositoryCalls.push(["deleteSourceRepository", id]);
|
|
const repository = sourceRepositoryCloud.get(id);
|
|
if (repository) {
|
|
sourceRepositoryCloud.set(id, {
|
|
...repository,
|
|
status: "archived",
|
|
});
|
|
}
|
|
}),
|
|
} as unknown as PrismaManagementClient;
|
|
const sourceRepositories = Test.make({
|
|
providers: sourceRepositoryLayer(sourceRepositoryClient),
|
|
});
|
|
|
|
sourceRepositories.test.provider(
|
|
"source repository links reject non-atomic relinks without mutating the live link",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
sourceRepositoryCloud.clear();
|
|
sourceRepositoryCalls.length = 0;
|
|
nextSourceRepositoryId = 1;
|
|
yield* stack.destroy();
|
|
|
|
const first = yield* stack.deploy(
|
|
PrismaSourceRepository("Repository", {
|
|
project: "project-1",
|
|
providerRepositoryId: 123,
|
|
}),
|
|
);
|
|
sourceRepositoryCalls.length = 0;
|
|
const relink = yield* stack
|
|
.deploy(
|
|
PrismaSourceRepository("Repository", {
|
|
project: "project-2",
|
|
providerRepositoryId: 456,
|
|
}),
|
|
)
|
|
.pipe(Effect.result);
|
|
expect(Result.isFailure(relink)).toBe(true);
|
|
if (Result.isFailure(relink)) {
|
|
expect(String(relink.failure)).toContain(
|
|
"cannot be replaced atomically",
|
|
);
|
|
}
|
|
expect(sourceRepositoryCalls).toEqual([]);
|
|
expect(sourceRepositoryCloud.get(first.sourceRepositoryId)?.status).toBe(
|
|
"active",
|
|
);
|
|
|
|
const provider = yield* PrismaSourceRepository.Provider;
|
|
const archivedDiff = yield* provider.diff!({
|
|
id: "Repository",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: { project: "project-1", providerRepositoryId: 123 },
|
|
news: { project: "project-1", providerRepositoryId: 123 },
|
|
output: { ...first, status: "archived" as const },
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never).pipe(Effect.result);
|
|
expect(Result.isFailure(archivedDiff)).toBe(true);
|
|
|
|
yield* stack.destroy();
|
|
expect(sourceRepositoryCloud.get(first.sourceRepositoryId)?.status).toBe(
|
|
"archived",
|
|
);
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:project",
|
|
"provider:prisma:sourcerepository",
|
|
"local",
|
|
],
|
|
},
|
|
);
|
|
|
|
const branchCloud = new Map<string, ApiBranch>();
|
|
const branchCalls: Array<[string, unknown?]> = [];
|
|
let nextBranchId = 1;
|
|
const branchClient = {
|
|
listProjects: () => Effect.succeed([apiProject("project-1", "app")]),
|
|
listBranches: (_projectId: string, query?: { gitName?: string }) =>
|
|
Effect.succeed(
|
|
Array.from(branchCloud.values()).filter(
|
|
(branch) =>
|
|
query?.gitName === undefined || branch.gitName === query.gitName,
|
|
),
|
|
),
|
|
getBranch: (id: string) => Effect.succeed(branchCloud.get(id)!),
|
|
createBranch: (
|
|
projectId: string,
|
|
input: { gitName: string; isDefault?: boolean },
|
|
) =>
|
|
Effect.sync(() => {
|
|
branchCalls.push(["createBranch", { projectId, input }]);
|
|
const first = branchCloud.size === 0;
|
|
const makeDefault = first || input.isDefault === true;
|
|
if (makeDefault) {
|
|
for (const [id, branch] of branchCloud) {
|
|
branchCloud.set(id, { ...branch, isDefault: false });
|
|
}
|
|
}
|
|
const id = `branch-${nextBranchId++}`;
|
|
const branch = {
|
|
id,
|
|
type: "branch" as const,
|
|
url: `https://api.prisma.test/v1/branches/${id}`,
|
|
gitName: input.gitName,
|
|
isDefault: makeDefault,
|
|
role: first ? ("production" as const) : ("preview" as const),
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
project: {
|
|
id: projectId,
|
|
url: `https://api.prisma.test/v1/projects/${projectId}`,
|
|
name: "app",
|
|
},
|
|
};
|
|
branchCloud.set(id, branch);
|
|
return branch;
|
|
}),
|
|
updateBranch: (id: string, input: { isDefault?: boolean | null }) =>
|
|
Effect.sync(() => {
|
|
branchCalls.push(["updateBranch", { id, input }]);
|
|
if (input.isDefault !== true) {
|
|
throw new Error("the Management API rejects default demotion");
|
|
}
|
|
for (const [branchId, branch] of branchCloud) {
|
|
branchCloud.set(branchId, {
|
|
...branch,
|
|
isDefault: branchId === id,
|
|
});
|
|
}
|
|
return branchCloud.get(id)!;
|
|
}),
|
|
deleteBranch: (id: string) =>
|
|
Effect.sync(() => {
|
|
branchCalls.push(["deleteBranch", id]);
|
|
branchCloud.delete(id);
|
|
}),
|
|
} as unknown as PrismaManagementClient;
|
|
|
|
// The same in-memory branch cloud, served over the wire.
|
|
const branchFake = makeFakeManagementApi((request) => {
|
|
const segments = request.pathname.split("/").filter((s) => s.length > 0);
|
|
|
|
if (request.pathname === "/v1/projects" && request.method === "GET") {
|
|
return page([toWireProject(apiProject("project-1", "app"))]);
|
|
}
|
|
|
|
if (
|
|
segments.length === 4 &&
|
|
segments[1] === "projects" &&
|
|
segments[3] === "branches"
|
|
) {
|
|
const projectId = segments[2]!;
|
|
if (request.method === "GET") {
|
|
const gitName = request.search.includes("gitName=")
|
|
? new URLSearchParams(request.search).get("gitName")
|
|
: null;
|
|
return page(
|
|
Array.from(branchCloud.values()).filter(
|
|
(branch) => gitName === null || branch.gitName === gitName,
|
|
),
|
|
);
|
|
}
|
|
if (request.method === "POST") {
|
|
const input = request.bodyJson as {
|
|
gitName: string;
|
|
isDefault?: boolean;
|
|
};
|
|
branchCalls.push(["createBranch", { projectId, input }]);
|
|
const first = branchCloud.size === 0;
|
|
const makeDefault = first || input.isDefault === true;
|
|
if (makeDefault) {
|
|
for (const [id, branch] of branchCloud) {
|
|
branchCloud.set(id, { ...branch, isDefault: false });
|
|
}
|
|
}
|
|
const id = `branch-${nextBranchId++}`;
|
|
const branch = {
|
|
id,
|
|
type: "branch" as const,
|
|
url: `https://api.prisma.test/v1/branches/${id}`,
|
|
gitName: input.gitName,
|
|
isDefault: makeDefault,
|
|
role: first ? ("production" as const) : ("preview" as const),
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
project: {
|
|
id: projectId,
|
|
url: `https://api.prisma.test/v1/projects/${projectId}`,
|
|
name: "app",
|
|
},
|
|
};
|
|
branchCloud.set(id, branch);
|
|
return data(branch, { status: 201 });
|
|
}
|
|
}
|
|
|
|
if (segments.length === 3 && segments[1] === "branches") {
|
|
const id = segments[2]!;
|
|
if (request.method === "GET") {
|
|
const branch = branchCloud.get(id);
|
|
return branch === undefined ? notFound("not found") : data(branch);
|
|
}
|
|
if (request.method === "PATCH") {
|
|
const input = request.bodyJson as { isDefault?: boolean | null };
|
|
branchCalls.push(["updateBranch", { id, input }]);
|
|
if (input.isDefault !== true) {
|
|
throw new Error("the Management API rejects default demotion");
|
|
}
|
|
for (const [branchId, branch] of branchCloud) {
|
|
branchCloud.set(branchId, { ...branch, isDefault: branchId === id });
|
|
}
|
|
return data(branchCloud.get(id)!);
|
|
}
|
|
if (request.method === "DELETE") {
|
|
branchCalls.push(["deleteBranch", id]);
|
|
branchCloud.delete(id);
|
|
return json(null, { status: 204 });
|
|
}
|
|
}
|
|
|
|
return unhandled(request);
|
|
});
|
|
|
|
const branches = Test.make({
|
|
providers: branchLayer(branchFake),
|
|
});
|
|
|
|
branches.test.provider(
|
|
"branch promotion persists and restores the displaced default on destroy",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
branchCloud.clear();
|
|
branchCalls.length = 0;
|
|
nextBranchId = 1;
|
|
yield* stack.destroy();
|
|
|
|
const main: ApiBranch = {
|
|
id: "branch-main",
|
|
type: "branch",
|
|
url: "https://api.prisma.test/v1/branches/branch-main",
|
|
gitName: "main",
|
|
isDefault: true,
|
|
role: "production",
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
project: {
|
|
id: "project-1",
|
|
url: "https://api.prisma.test/v1/projects/project-1",
|
|
name: "app",
|
|
},
|
|
};
|
|
branchCloud.set(main.id, main);
|
|
|
|
const preview = yield* stack.deploy(
|
|
PrismaBranch("Preview", {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
}),
|
|
);
|
|
expect(preview.isDefault).toBe(true);
|
|
expect(preview.role).toBe("preview");
|
|
expect(preview.previousDefaultBranchId).toBe(main.id);
|
|
expect(branchCloud.get(main.id)?.isDefault).toBe(false);
|
|
expect(branchCloud.get(main.id)?.role).toBe("production");
|
|
|
|
const provider = yield* Provider.findProvider(PrismaBranch);
|
|
const alreadyDefaultDiff = yield* provider.diff!({
|
|
id: "Preview",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
news: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
output: preview,
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never);
|
|
expect(alreadyDefaultDiff).toBeUndefined();
|
|
|
|
// A different API client can promote another branch, atomically
|
|
// demoting the desired default. The next deploy must observe and heal it
|
|
// even though desired props did not change.
|
|
branchCloud.set(main.id, {
|
|
...branchCloud.get(main.id)!,
|
|
isDefault: true,
|
|
});
|
|
branchCloud.set(preview.branchId, {
|
|
...branchCloud.get(preview.branchId)!,
|
|
isDefault: false,
|
|
});
|
|
branchCalls.length = 0;
|
|
const observed = yield* provider.read!({
|
|
id: "Preview",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
output: preview,
|
|
} as never);
|
|
const driftDiff = yield* provider.diff!({
|
|
id: "Preview",
|
|
instanceId: "00000000000000000000000000000000",
|
|
olds: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
news: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
output: observed,
|
|
oldBindings: [],
|
|
newBindings: [],
|
|
} as never);
|
|
expect(driftDiff).toEqual({ action: "update" });
|
|
yield* provider.reconcile({
|
|
id: "Preview",
|
|
instanceId: "00000000000000000000000000000000",
|
|
news: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
olds: {
|
|
project: "project-1",
|
|
gitName: "preview",
|
|
isDefault: true,
|
|
},
|
|
output: observed,
|
|
bindings: [],
|
|
} as never);
|
|
expect(branchCloud.get(preview.branchId)?.isDefault).toBe(true);
|
|
expect(branchCalls).toContainEqual([
|
|
"updateBranch",
|
|
{ id: preview.branchId, input: { isDefault: true } },
|
|
]);
|
|
expect(yield* provider.list()).toEqual([]);
|
|
branchCalls.length = 0;
|
|
yield* stack.destroy();
|
|
expect(branchCloud.get(main.id)?.isDefault).toBe(true);
|
|
expect(branchCloud.has(preview.branchId)).toBe(false);
|
|
expect(branchCalls).toContainEqual([
|
|
"updateBranch",
|
|
{ id: main.id, input: { isDefault: true } },
|
|
]);
|
|
expect(branchCalls).toContainEqual(["deleteBranch", preview.branchId]);
|
|
|
|
branchCloud.clear();
|
|
branchCalls.length = 0;
|
|
const firstBranch = yield* provider
|
|
.reconcile({
|
|
id: "UnsafeFirst",
|
|
instanceId: "00000000000000000000000000000000",
|
|
news: {
|
|
project: "project-1",
|
|
gitName: "main",
|
|
isDefault: false,
|
|
},
|
|
olds: undefined,
|
|
output: undefined,
|
|
bindings: [],
|
|
} as never)
|
|
.pipe(Effect.result);
|
|
expect(Result.isFailure(firstBranch)).toBe(true);
|
|
if (Result.isFailure(firstBranch)) {
|
|
expect(String(firstBranch.failure)).toContain(
|
|
"undeletable production branch",
|
|
);
|
|
}
|
|
expect(branchCalls.map(([operation]) => operation)).not.toContain(
|
|
"createBranch",
|
|
);
|
|
}),
|
|
{
|
|
tags: [
|
|
"unit",
|
|
"provider:prisma",
|
|
"provider:prisma:branch",
|
|
"provider:prisma:project",
|
|
"local",
|
|
],
|
|
},
|
|
);
|