1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Redacted from "effect/Redacted";
4import * as Binding from "../Binding.ts";
5import * as Output from "../Output.ts";
6import { defaultProviderMode } from "../ProviderMode.ts";
7import type { ResourceLike } from "../Resource.ts";
8import {
9 CurrentRuntimeContext,
10 type RuntimeContext,
11} from "../RuntimeContext.ts";
12import type { Branch } from "./Branch.ts";
13import { resolveBranchScope } from "./BranchScope.ts";
14import type { Function } from "./Function.ts";
15import type { Project } from "./Project.ts";
16
17/** Postgres connection accessors. Resolving these Effects does not acquire a socket or pool. */
18export interface ConnectClient {
19 /** Pooled URL for ordinary application queries. */
20 connectionString: Effect.Effect<
21 Redacted.Redacted<string>,
22 never,
23 RuntimeContext
24 >;
25 /** Pooled URL, including Neon's pooler hostname. */
26 pooledConnectionString: Effect.Effect<
27 Redacted.Redacted<string>,
28 never,
29 RuntimeContext
30 >;
31 /** Direct URL for migrations, notifications, and session-oriented clients. */
32 directConnectionString: Effect.Effect<
33 Redacted.Redacted<string>,
34 never,
35 RuntimeContext
36 >;
37}
38
39/**
40 * Connect to a Branch or a Project's default database from a Function, Worker,
41 * Lambda, or another Platform host. Same-branch Neon Functions use the injected
42 * `DATABASE_URL` and `DATABASE_URL_UNPOOLED`; local Functions and other hosts
43 * receive namespaced database secrets, never the deployment account API key.
44 *
45 * Accessors are lazy and redacted. `SQL.Postgres` and `Drizzle.Postgres` create
46 * their connections in request scope, not when this binding is initialized.
47 *
48 * ### Querying Postgres
49 * **Example:** Bind a database to an Effect host
50 * ```typescript
51 * Effect.gen(function* () {
52 * const db = yield* Neon.Connect(branch);
53 * const sql = yield* SQL.Postgres({ url: db.connectionString });
54 * return { fetch: Effect.gen(function* () {
55 * return yield* HttpServerResponse.json(yield* sql`SELECT 1 AS value`);
56 * }) };
57 * }).pipe(Effect.provide(Neon.ConnectHttp));
58 * ```
59 *
60 * ### Native applications
61 * **Example:** Explicit environment for a native cross-branch Function
62 * ```typescript
63 * const api = yield* Neon.Function("Api", {
64 * branch: applicationBranch,
65 * main: "./api.ts",
66 * env: Neon.connectEnv(databaseBranch),
67 * });
68 * ```
69 *
70 * Native same-branch Neon Functions already have `DATABASE_URL` and
71 * `DATABASE_URL_UNPOOLED` and need no binding. `connectEnvKeys` gives native
72 * applications the names used by `connectEnv` for cross-host connections.
73 *
74 * @binding
75 * @product Branch
76 */
77export interface Connect extends Binding.Service<
78 Connect,
79 "Neon.Connect",
80 (database: Branch | Project) => Effect.Effect<ConnectClient>
81> {}
82
83export const Connect = Binding.Service<Connect>("Neon.Connect");
84
85/** Stable, collision-free environment names for native cross-host consumers. */
86export const connectEnvKeys = (database: Pick<Branch | Project, "FQN">) => {
87 const name = Array.from(database.FQN, (character) =>
88 character.codePointAt(0)!.toString(16),
89 ).join("_");
90 const prefix = `NEON_${name}`;
91 return {
92 injected: `${prefix}_INJECTED`,
93 pooledConnectionString: `${prefix}_DATABASE_URL`,
94 directConnectionString: `${prefix}_DATABASE_URL_UNPOOLED`,
95 };
96};
97
98/** Redacted environment outputs for native hosts. No deployment credentials are included. */
99export const connectEnv = (database: Branch | Project) => {
100 const keys = connectEnvKeys(database);
101 return {
102 [keys.pooledConnectionString]: database.pooledConnectionUri.pipe(
103 Output.map(Redacted.make),
104 ),
105 [keys.directConnectionString]: database.connectionUri.pipe(
106 Output.map(Redacted.make),
107 ),
108 };
109};
110
111const isFunction = (host: ResourceLike | undefined): host is Function =>
112 host?.Type === "Neon.Function";
113const stringOutput = (value: string | Output.Output<string>) =>
114 typeof value === "string" ? Output.literal(value) : value;
115
116const injectedScope = (
117 host: ResourceLike | undefined,
118 database: Branch | Project,
119) => {
120 if (!isFunction(host)) return Output.literal(false);
121 const scope = host.Props;
122 const targetBranch =
123 database.Type === "Neon.Branch"
124 ? database.branchId
125 : database.defaultBranchId;
126 if (scope.branch) {
127 return Output.all(
128 stringOutput(scope.branch.projectId),
129 stringOutput(scope.branch.branchId),
130 database.projectId,
131 targetBranch,
132 ).pipe(
133 Output.map(
134 ([project, branch, targetProject, target]) =>
135 project === targetProject && branch === target,
136 ),
137 );
138 }
139 if (scope.project) {
140 const resolved = stringOutput(scope.project.projectId).pipe(
141 Output.mapEffect((projectId: string) =>
142 resolveBranchScope({ project: { projectId } }).pipe(Effect.orDie),
143 ),
144 );
145 return Output.all(
146 resolved.projectId,
147 resolved.branchId,
148 database.projectId,
149 targetBranch,
150 ).pipe(
151 Output.map(
152 ([project, branch, targetProject, target]) =>
153 project === targetProject && branch === target,
154 ),
155 );
156 }
157 return Output.literal(false);
158};
159
160/** Host-independent implementation using Platform's common environment channel. */
161export const ConnectHttp = Layer.effect(
162 Connect,
163 Effect.gen(function* () {
164 const context = yield* CurrentRuntimeContext;
165 if (!context)
166 return yield* Effect.die(
167 new Error("Neon.Connect requires a Platform host"),
168 );
169 return Effect.fn(function* (database: Branch | Project) {
170 const keys = connectEnvKeys(database);
171 if (!globalThis.__ALCHEMY_RUNTIME__) {
172 const host = yield* Binding.Host;
173 const mode = host?.Mode ?? (yield* defaultProviderMode);
174 const injected =
175 mode === "local"
176 ? Output.literal(false)
177 : injectedScope(host, database);
178 yield* context.set(keys.injected, injected);
179 yield* context.set(
180 keys.pooledConnectionString,
181 Output.all(injected, database.pooledConnectionUri).pipe(
182 Output.map(([sameBranch, uri]) =>
183 sameBranch ? "" : Redacted.make(uri),
184 ),
185 ),
186 );
187 yield* context.set(
188 keys.directConnectionString,
189 Output.all(injected, database.connectionUri).pipe(
190 Output.map(([sameBranch, uri]) =>
191 sameBranch ? "" : Redacted.make(uri),
192 ),
193 ),
194 );
195 }
196 const connection = (key: string, injectedKey: string) =>
197 Effect.gen(function* () {
198 const injected = yield* context.get<boolean>(keys.injected);
199 const value = yield* context.get<string | Redacted.Redacted<string>>(
200 injected ? injectedKey : key,
201 );
202 if (value === undefined || value === "") {
203 return yield* Effect.die(
204 new Error(
205 `Missing Neon database environment value: ${injected ? injectedKey : key}`,
206 ),
207 );
208 }
209 return Redacted.isRedacted(value) ? value : Redacted.make(value);
210 });
211 const pooledConnectionString = connection(
212 keys.pooledConnectionString,
213 "DATABASE_URL",
214 );
215 return {
216 connectionString: pooledConnectionString,
217 pooledConnectionString,
218 directConnectionString: connection(
219 keys.directConnectionString,
220 "DATABASE_URL_UNPOOLED",
221 ),
222 } satisfies ConnectClient;
223 });
224 }),
225);