Remove source-of-truth logic

This commit is contained in:
Konstantin Wohlwend 2026-05-23 01:06:42 -07:00
parent 884f8dfcea
commit f6ef49a3dc
7 changed files with 50 additions and 225 deletions

View File

@ -1,11 +1,8 @@
import { overrideProjectConfigOverride } from "@/lib/config";
import { getPrismaClientForSourceOfTruth, globalPrismaClient } from "@/prisma-client";
import { globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getStackServerApp } from "@/stack";
import { KnownErrors } from "@stackframe/stack-shared";
import { neonAuthorizationHeaderSchema, yupArray, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
export const POST = createSmartRouteHandler({
metadata: {
@ -44,31 +41,8 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.ProjectNotFound(req.query.project_id);
}
const uuidConnectionStrings: Record<string, string> = {};
const store = await getStackServerApp().getDataVaultStore('neon-connection-strings');
const secret = "no client side encryption";
for (const c of req.body.connection_strings) {
const uuid = generateUuid();
await store.setValue(uuid, c.connection_string, { secret });
uuidConnectionStrings[c.branch_id] = uuid;
}
const sourceOfTruthPersisted = {
type: 'neon' as const,
connectionStrings: uuidConnectionStrings,
};
await overrideProjectConfigOverride({
projectId: provisionedProject.projectId,
projectConfigOverrideOverride: {
sourceOfTruth: sourceOfTruthPersisted,
},
});
await Promise.all(req.body.connection_strings.map(({ branch_id, connection_string }) => getPrismaClientForSourceOfTruth({
type: 'neon',
connectionString: undefined,
connectionStrings: { [branch_id]: connection_string },
} as const, branch_id)));
// Connection strings used to configure Neon as source-of-truth. That mode no
// longer exists, but keep accepting this webhook so old integrations do not fail.
return {
statusCode: 200,

View File

@ -1,11 +1,9 @@
import { createApiKeySet } from "@/lib/internal-api-keys";
import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects";
import { getPrismaClientForSourceOfTruth, globalPrismaClient } from "@/prisma-client";
import { globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getStackServerApp } from "@/stack";
import { neonAuthorizationHeaderSchema, projectDisplayNameSchema, yupArray, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
export const POST = createSmartRouteHandler({
metadata: {
@ -33,31 +31,11 @@ export const POST = createSmartRouteHandler({
}),
handler: async (req) => {
const [clientId] = decodeBasicAuthorizationHeader(req.headers.authorization[0])!;
const hasNeonConnections = req.body.connection_strings && req.body.connection_strings.length > 0;
const realConnectionStrings: Record<string, string> = {};
const uuidConnectionStrings: Record<string, string> = {};
if (hasNeonConnections) {
const store = await getStackServerApp().getDataVaultStore('neon-connection-strings');
const secret = "no client side encryption";
for (const c of req.body.connection_strings!) {
const uuid = generateUuid();
await store.setValue(uuid, c.connection_string, { secret });
realConnectionStrings[c.branch_id] = c.connection_string;
uuidConnectionStrings[c.branch_id] = uuid;
}
}
const sourceOfTruthPersisted = hasNeonConnections ? {
type: 'neon' as const,
connectionString: undefined,
connectionStrings: uuidConnectionStrings,
} : { type: 'hosted' as const, connectionString: undefined, connectionStrings: undefined };
// connection_strings used to configure Neon as source-of-truth. That mode no
// longer exists, but keep accepting the field so old integrations do not fail.
const createdProject = await createOrUpdateProjectWithLegacyConfig({
sourceOfTruth: sourceOfTruthPersisted,
sourceOfTruth: { type: 'hosted' },
type: 'create',
data: {
display_name: req.body.display_name,
@ -80,18 +58,6 @@ export const POST = createSmartRouteHandler({
}
});
if (hasNeonConnections) {
// Run migrations using the real connection strings (do not persist them)
const branchIds = Object.keys(realConnectionStrings);
await Promise.all(branchIds.map((branchId) => getPrismaClientForSourceOfTruth({
type: 'neon',
connectionString: undefined,
connectionStrings: realConnectionStrings,
} as const, branchId)));
}
await globalPrismaClient.provisionedProject.create({
data: {
projectId: createdProject.id,

View File

@ -775,7 +775,11 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe
type: 'postgres',
connectionString: 'postgres://user:pass@host:port/db',
},
})).toEqual(Result.ok(null));
})).toEqual(Result.error(deindent`
[WARNING] sourceOfTruth is not matched by any of the provided schemas:
Schema 0:
sourceOfTruth.type must be one of the following values: hosted
`));
expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, {
sourceOfTruth: {
type: 'postgres',
@ -784,10 +788,6 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe
[WARNING] sourceOfTruth is not matched by any of the provided schemas:
Schema 0:
sourceOfTruth.type must be one of the following values: hosted
Schema 1:
sourceOfTruth.connectionStrings must be defined
Schema 2:
sourceOfTruth.connectionString must be defined
`));
// Dot-notation keys that dot into nothing — detected by simulating the rendering pipeline

View File

@ -1,5 +1,4 @@
import { Prisma, PrismaClient } from "@/generated/prisma/client";
import { PrismaNeon } from "@prisma/adapter-neon";
import { PrismaPg } from '@prisma/adapter-pg';
import { readReplicas } from '@prisma/extension-read-replicas';
import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema";
@ -12,11 +11,9 @@ import { concatStacktracesIfRejected, ignoreUnhandledRejection, runAsynchronousl
import { throwingProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import net from "node:net";
import { Pool } from "pg";
import { isPromise } from "util/types";
import { runMigrationNeeded } from "./auto-migrations";
import { registerPgPool } from "./lib/dev-perf-stats";
import { Tenancy } from "./lib/tenancies";
import { ensurePolyfilled } from "./polyfills";
@ -29,45 +26,10 @@ export type PrismaClientTransaction =
| Omit<PrismaClient, "$on"> // $on is not available on extended Prisma clients, so we don't require it here. see: https://www.prisma.io/docs/orm/reference/prisma-client-reference#on
| Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const prismaClientsStore = (globalVar.__stack_prisma_clients as undefined) || {
neon: new Map<string, PrismaClient>(),
postgres: new Map<string, {
client: PrismaClient,
schema: string | null,
}>(),
};
if (getNodeEnvironment().includes('development')) {
globalVar.__stack_prisma_clients = prismaClientsStore; // store globally so fast refresh doesn't recreate too many Prisma clients
}
function getNeonPrismaClient(connectionString: string) {
let neonPrismaClient = prismaClientsStore.neon.get(connectionString);
if (!neonPrismaClient) {
const schema = getSchemaFromConnectionString(connectionString);
const adapter = new PrismaNeon({ connectionString }, { schema });
neonPrismaClient = new PrismaClient({ adapter });
prismaClientsStore.neon.set(connectionString, neonPrismaClient);
}
return neonPrismaClient;
}
function getSchemaFromConnectionString(connectionString: string) {
return (new URL(connectionString)).searchParams.get('schema') ?? "public";
}
async function resolveNeonConnectionString(entry: string): Promise<string> {
if (!isUuid(entry)) {
return entry;
}
const { getStackServerApp } = await import("@/stack");
const store = await getStackServerApp().getDataVaultStore('neon-connection-strings');
const secret = "no client side encryption";
const value = await store.getValue(entry, { secret });
if (!value) throw new Error('No Neon connection string found for UUID');
return value;
}
export async function getPrismaClientForTenancy(tenancy: Tenancy) {
return await getPrismaClientForSourceOfTruth(tenancy.config.sourceOfTruth, tenancy.branchId);
}
@ -110,9 +72,6 @@ if (!getEnvVariable("VERCEL", "") && !globalVar.__stack_prisma_sigterm_registere
for (const [, entry] of postgresPrismaClientsStore) {
await entry.client.$disconnect();
}
for (const [, client] of prismaClientsStore.neon) {
await client.$disconnect();
}
} finally {
clearTimeout(keepAlive);
}
@ -417,49 +376,12 @@ export const { client: globalPrismaClient, schema: globalPrismaSchema }: {
schema: throwingProxy<string>("STACK_DATABASE_CONNECTION_STRING environment variable is not set. Please set it to a valid PostgreSQL connection string, or use a mock Prisma client for testing."),
};
export async function getPrismaClientForSourceOfTruth(sourceOfTruth: CompleteConfig["sourceOfTruth"], branchId: string) {
switch (sourceOfTruth.type) {
case 'neon': {
if (!(branchId in sourceOfTruth.connectionStrings)) {
throw new Error(`No connection string provided for Neon source of truth for branch ${branchId}`);
}
const entry = sourceOfTruth.connectionStrings[branchId];
const connectionString = await resolveNeonConnectionString(entry);
const neonPrismaClient = getNeonPrismaClient(connectionString);
await runMigrationNeeded({ prismaClient: neonPrismaClient, schema: getSchemaFromConnectionString(connectionString), logging: true });
return extendWithFakeReadReplica(neonPrismaClient);
}
case 'postgres': {
const postgresPrismaClient = getPostgresPrismaClient(sourceOfTruth.connectionString);
await runMigrationNeeded({ prismaClient: postgresPrismaClient.client, schema: getSchemaFromConnectionString(sourceOfTruth.connectionString), logging: true });
return extendWithFakeReadReplica(postgresPrismaClient.client);
}
case 'hosted': {
return globalPrismaClient;
}
}
export async function getPrismaClientForSourceOfTruth(_sourceOfTruth: CompleteConfig["sourceOfTruth"], _branchId: string) {
return globalPrismaClient;
}
export async function getPrismaSchemaForSourceOfTruth(sourceOfTruth: CompleteConfig["sourceOfTruth"], branchId: string) {
switch (sourceOfTruth.type) {
case 'postgres': {
return getSchemaFromConnectionString(sourceOfTruth.connectionString);
}
case 'neon': {
if (!(branchId in sourceOfTruth.connectionStrings)) {
throw new Error(`No connection string provided for Neon source of truth for branch ${branchId}`);
}
const entry = sourceOfTruth.connectionStrings[branchId];
if (isUuid(entry)) {
const connectionString = await resolveNeonConnectionString(entry);
return getSchemaFromConnectionString(connectionString);
}
return getSchemaFromConnectionString(entry);
}
case 'hosted': {
return globalPrismaSchema;
}
}
export async function getPrismaSchemaForSourceOfTruth(_sourceOfTruth: CompleteConfig["sourceOfTruth"], _branchId: string) {
return globalPrismaSchema;
}

View File

@ -1,6 +1,5 @@
import { decryptValue, hashKey } from "@stackframe/stack-shared/dist/helpers/vault/client-side";
import { it } from "../../../../../../../helpers";
import { Auth, InternalApiKey, InternalProjectKeys, backendContext, niceBackendFetch } from "../../../../../../backend-helpers";
import { Auth, InternalApiKey, backendContext, niceBackendFetch } from "../../../../../../backend-helpers";
import { provisionProject } from "./provision-helpers";
it("should be able to provision a new project if neon client details are correct", async ({ expect }) => {
@ -224,13 +223,7 @@ it("should validate connection_strings item shape", async ({ expect }) => {
expect(response.headers.get("x-stack-known-error")).toBe("SCHEMA_ERROR");
});
it("can provision with a Neon connection string when provided via env (optional)", async ({ expect }) => {
// this test only runs with a neon connection string set
const neonConnectionString = process.env.STACK_TEST_NEON_CONNECTION_STRING;
if (!neonConnectionString) {
return;
}
it("ignores connection_strings while provisioning with hosted source-of-truth", async ({ expect }) => {
const response = await niceBackendFetch("/api/v1/integrations/neon/projects/provision", {
method: "POST",
body: {
@ -238,7 +231,7 @@ it("can provision with a Neon connection string when provided via env (optional)
connection_strings: [
{
branch_id: "main",
connection_string: neonConnectionString,
connection_string: "postgres://user:password@neon.example.com/database",
},
],
},
@ -263,42 +256,18 @@ it("can provision with a Neon connection string when provided via env (optional)
const sourceOfTruth = JSON.parse(configResponse.body.config_string).sourceOfTruth;
expect(sourceOfTruth).toMatchInlineSnapshot(`
{
"connectionStrings": { "main": "<stripped UUID>" },
"type": "neon",
"type": "hosted",
}
`);
backendContext.set({
projectKeys: InternalProjectKeys,
});
const getConnectionResponse = await niceBackendFetch(`/api/latest/data-vault/stores/neon-connection-strings/get`, {
method: "POST",
accessType: "server",
body: {
hashed_key: await hashKey("no client side encryption", sourceOfTruth.connectionStrings.main),
},
});
expect(getConnectionResponse.status).toBe(200);
const connectionString = await decryptValue(
"no client side encryption",
sourceOfTruth.connectionStrings.main,
getConnectionResponse.body.encrypted_value
);
expect(connectionString).toBe(neonConnectionString);
});
it("can update the connection_strings for an existing provisioned project", async ({ expect }) => {
// this test only runs with a neon connection string set
const neonConnectionString = process.env.STACK_TEST_NEON_CONNECTION_STRING;
if (!neonConnectionString) {
return;
}
it("accepts connection_strings updates without changing hosted source-of-truth", async ({ expect }) => {
const provisionResponse = await provisionProject();
const response = await niceBackendFetch(`/api/v1/integrations/neon/projects/connection?project_id=${provisionResponse.body.project_id}`, {
method: "POST",
body: {
connection_strings: [
{ branch_id: "branch1", connection_string: neonConnectionString },
{ branch_id: "branch1", connection_string: "postgres://user:password@neon.example.com/database" },
],
},
headers: {
@ -327,8 +296,7 @@ it("can update the connection_strings for an existing provisioned project", asyn
const sourceOfTruth = JSON.parse(configResponse.body.config_string).sourceOfTruth;
expect(sourceOfTruth).toMatchInlineSnapshot(`
{
"connectionStrings": { "branch1": "<stripped UUID>" },
"type": "neon",
"type": "hosted",
}
`);
});

View File

@ -12,11 +12,7 @@ type FuzzerConfig<T> = ReadonlyArray<T extends object ? ([T] extends [any[]] ? {
const projectSchemaFuzzerConfig = [{
sourceOfTruth: [{
type: ["hosted", "neon", "postgres"],
connectionString: ["", "postgres://user:password@host:port/database", "THIS IS A STRING LOLOL"],
connectionStrings: [{
"123-some-branch-id": ["", "THIS IS A CONNECTION STRING OR SO"],
}],
type: ["hosted"],
}],
project: [{
requirePublishableClientKey: [true, false],

View File

@ -46,17 +46,6 @@ export const projectConfigSchema = yupObject({
yupObject({
type: yupString().oneOf(['hosted']).defined(),
}),
yupObject({
type: yupString().oneOf(['neon']).defined(),
connectionStrings: yupRecord(
userSpecifiedIdSchema("connectionStringId").defined(),
yupString().defined(),
).defined(),
}),
yupObject({
type: yupString().oneOf(['postgres']).defined(),
connectionString: yupString().defined()
}),
),
project: yupObject({
requirePublishableClientKey: yupBoolean(),
@ -561,10 +550,33 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment"
}
// END
// BEGIN 2026-05-23: external source-of-truth config was never used and is no longer supported.
// Drop the override so legacy Neon/Postgres configs continue to render as the hosted default.
if (type === "project") {
res = removeProperty(res, p => p[0] === "sourceOfTruth");
}
// END
// return the result
return res;
};
import.meta.vitest?.test("migrateConfigOverride removes legacy sourceOfTruth overrides", ({ expect }) => {
expect(migrateConfigOverride("project", {
sourceOfTruth: {
type: "neon",
connectionStrings: {
main: "postgres://user:password@neon.example.com/database",
},
},
})).toEqual({});
expect(migrateConfigOverride("project", {
"sourceOfTruth.type": "postgres",
"sourceOfTruth.connectionString": "postgres://user:password@host:5432/database",
})).toEqual({});
});
function removeProperty(obj: Record<string, any>, pathCond: (path: (string | symbol)[]) => boolean): any {
return mapProperty(obj, pathCond, () => undefined);
}
@ -670,8 +682,6 @@ import.meta.vitest?.test("renameProperty", ({ expect }) => {
const projectConfigDefaults = {
sourceOfTruth: {
type: 'hosted',
connectionStrings: undefined,
connectionString: undefined,
},
project: {
requirePublishableClientKey: false,
@ -1027,23 +1037,12 @@ export function applyOrganizationDefaults(config: OrganizationRenderedConfigBefo
export async function sanitizeProjectConfig<T extends ProjectRenderedConfigBeforeSanitization>(config: T) {
assertNormalized(config);
const oldSourceOfTruth = config.sourceOfTruth;
const sourceOfTruth =
oldSourceOfTruth.type === 'neon' && typeof oldSourceOfTruth.connectionStrings === 'object' ? {
type: 'neon',
connectionStrings: { ...filterUndefined(oldSourceOfTruth.connectionStrings) as Record<string, string> }
} as const
: oldSourceOfTruth.type === 'postgres' && typeof oldSourceOfTruth.connectionString === 'string' ? {
type: 'postgres',
connectionString: oldSourceOfTruth.connectionString,
} as const
: {
type: 'hosted',
} as const;
return {
...config,
sourceOfTruth,
sourceOfTruth: {
type: 'hosted',
} as const,
};
}