1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
5import { isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import { Resource } from "../Resource.ts";
8import type { Input } from "../Input.ts";
9import {
10 authPlanScope,
11 authRequest,
12 authSettingsMatch,
13 removedAuthSettings,
14} from "./Auth.ts";
15import { resolveBranchScope, type ResolvedBranchScope } from "./BranchScope.ts";
16import type { Providers } from "./Providers.ts";
17
18export type DataApiProps = (
19 | {
20 /** Branch resource or explicit identity, optionally carrying its selected database. */
21 branch: { projectId: string; branchId: string; databaseName?: string };
22 project?: never;
23 }
24 | {
25 /** Project resource or explicit identity; selects the observed default branch. */
26 project: { projectId: string; databaseName?: string };
27 branch?: never;
28 }
29) & {
30 /** Database served by PostgREST. Infers the referenced resource's selected database, or the branch's only database; required when ambiguous. */
31 database?: string;
32 /** Authentication mechanism. Changes replace the Data API. */
33 authProvider?: "neon_auth" | "external";
34 /** HTTPS JWKS endpoint required for external authentication. */
35 jwksUrl?: string;
36 /** Display name for an external authentication provider. */
37 providerName?: string;
38 /** Expected JWT audience. Neon still accepts tokens without an audience. */
39 jwtAudience?: string;
40 /** Opt in to public-schema table grants. Defaults to false. */
41 addDefaultGrants?: boolean;
42 /** Skip creating the auth schema and RLS helpers. */
43 skipAuthSchema?: boolean;
44 /** Explicitly owned PostgREST settings. Removing previously managed fields requires an explicit reset value. */
45 settings?: Neon.DataAPISettings;
46};
47
48export interface DataApiAttributes {
49 /** Neon project identity. */
50 projectId: string;
51 /** Branch identity. */
52 branchId: string;
53 /** Database served by this endpoint. */
54 database: string;
55 /** Public PostgREST base URL. Requests must carry end-user authorization. */
56 url: string;
57 /** Observed deployment status. */
58 status: string;
59 /** Observed PostgREST settings, if exposed by the backend. */
60 settings: Neon.DataAPISettings | undefined;
61}
62
63export interface DataApi extends Resource<
64 "Neon.DataApi",
65 DataApiProps,
66 DataApiAttributes,
67 never,
68 Providers
69> {}
70
71/**
72 * Own a branch/database Data API singleton. This resource never grants an
73 * application the account deployment key. Configure RLS and forward the user's
74 * token through QueryDataApi. Authentication inputs are creation-only because
75 * the API only supports updating PostgREST settings.
76 *
77 * ### Expose a database through managed authentication
78 * **Example:** Managed Auth and PostgREST
79 * ```typescript
80 * const dataApi = yield* Neon.DataApi("Data", {
81 * branch, authProvider: "neon_auth", settings: { db_max_rows: 100 },
82 * });
83 * ```
84 *
85 * @resource
86 * @product Data API
87 */
88export const DataApi = Resource<DataApi>("Neon.DataApi");
89
90export class InvalidDataApiConfiguration extends Data.TaggedError(
91 "InvalidDataApiConfiguration",
92)<{
93 message: string;
94}> {}
95
96const observe = (scope: {
97 projectId: string;
98 branchId: string;
99 database: string;
100}) =>
101 Neon.getProjectBranchDataAPI({
102 ...authRequest(scope),
103 database_name: scope.database,
104 }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
105
106const attributes = (
107 scope: { projectId: string; branchId: string; database: string },
108 observed: Neon.DataAPIReponse,
109): DataApiAttributes => ({
110 projectId: scope.projectId,
111 branchId: scope.branchId,
112 database: scope.database,
113 url: observed.url,
114 status: observed.status,
115 settings: observed.settings ?? undefined,
116});
117
118const immutable = [
119 "authProvider",
120 "jwksUrl",
121 "providerName",
122 "jwtAudience",
123 "addDefaultGrants",
124 "skipAuthSchema",
125] as const;
126
127const validateDataApi = (news: DataApiProps) =>
128 news.authProvider === "external" && !news.jwksUrl
129 ? Effect.fail(
130 new InvalidDataApiConfiguration({
131 message: "External authentication requires jwksUrl",
132 }),
133 )
134 : Effect.void;
135
136const validateDataApiRemoval = (
137 olds: DataApiProps | undefined,
138 news: Input<DataApiProps>,
139) => {
140 const removed = removedAuthSettings(olds, news, [...immutable, "settings"]);
141 return removed.length === 0
142 ? Effect.void
143 : Effect.fail(
144 new InvalidDataApiConfiguration({
145 message: `Cannot remove managed Data API settings: ${removed.join(", ")}; set explicit reset values instead`,
146 }),
147 );
148};
149
150const resolveDatabase = Effect.fn(function* (
151 news: DataApiProps,
152 scope: ResolvedBranchScope,
153) {
154 const database = news.database ?? (news.branch ?? news.project)?.databaseName;
155 if (database !== undefined) {
156 if (!database)
157 return yield* new InvalidDataApiConfiguration({
158 message: "database must not be empty",
159 });
160 return database;
161 }
162 const { databases } = yield* Neon.listProjectBranchDatabases(
163 authRequest(scope),
164 );
165 if (databases.length !== 1) {
166 return yield* new InvalidDataApiConfiguration({
167 message:
168 "Set database explicitly when the branch has no unambiguous selected database",
169 });
170 }
171 return databases[0]!.name;
172});
173
174export const DataApiProvider = () =>
175 Provider.succeed(DataApi, {
176 stables: ["projectId", "branchId", "database"],
177 diff: Effect.fn(function* ({ olds, news, output }) {
178 yield* validateDataApiRemoval(olds, news);
179 if (isResolved(news)) yield* validateDataApi(news);
180 const scope = yield* authPlanScope(news);
181 if (
182 output &&
183 (!scope ||
184 scope.projectId !== output.projectId ||
185 scope.branchId !== output.branchId)
186 ) {
187 return { action: "replace", deleteFirst: true } as const;
188 }
189 if (!isResolved(news)) return;
190 const resolved = scope ?? (yield* resolveBranchScope(news));
191 const database = yield* resolveDatabase(news, resolved);
192 const previous = output ?? (yield* resolveBranchScope(olds));
193 if (
194 resolved.projectId !== previous.projectId ||
195 resolved.branchId !== previous.branchId ||
196 database !==
197 (output?.database ?? (yield* resolveDatabase(olds, previous))) ||
198 immutable.some((key) => news[key] !== olds[key])
199 ) {
200 return { action: "replace", deleteFirst: true } as const;
201 }
202 if (output) {
203 const current = yield* observe({ ...resolved, database });
204 if (
205 !current ||
206 (news.settings !== undefined &&
207 !authSettingsMatch(current.settings ?? {}, news.settings))
208 )
209 return { action: "update" } as const;
210 }
211 }),
212 read: Effect.fn(function* ({ olds, output }) {
213 if (
214 !output &&
215 !(olds?.branch?.projectId && olds.branch.branchId) &&
216 !olds?.project?.projectId
217 )
218 return undefined;
219 const branch = output ?? (yield* resolveBranchScope(olds));
220 const scope = output ?? {
221 ...branch,
222 database: yield* resolveDatabase(olds, branch),
223 };
224 const current = yield* observe(scope);
225 if (!current) return undefined;
226 const attrs = attributes(scope, current);
227 return output ? attrs : Unowned(attrs);
228 }),
229 reconcile: Effect.fn(function* ({ news, output, olds }) {
230 yield* validateDataApiRemoval(olds, news);
231 yield* validateDataApi(news);
232 const branch = yield* resolveBranchScope(news);
233 const scope = {
234 ...branch,
235 database: yield* resolveDatabase(news, branch),
236 };
237 if (
238 output &&
239 (scope.projectId !== output.projectId ||
240 scope.branchId !== output.branchId ||
241 scope.database !== output.database)
242 ) {
243 return yield* new InvalidDataApiConfiguration({
244 message:
245 "Data API identity changed without replacement; refusing to mutate either endpoint",
246 });
247 }
248 const request = { ...authRequest(scope), database_name: scope.database };
249 let current = yield* observe(scope);
250 if (current && !output)
251 return yield* new OwnedBySomeoneElse({
252 message: "Existing Data API requires explicit adoption",
253 resourceType: "Neon.DataApi",
254 });
255 if (
256 current &&
257 olds === undefined &&
258 immutable.some((key) => news[key] !== undefined)
259 ) {
260 return yield* new InvalidDataApiConfiguration({
261 message:
262 "Neon does not expose existing Data API authentication settings; adopt without creation-only settings, then replace to configure authentication",
263 });
264 }
265 if (!current) {
266 yield* Neon.createProjectBranchDataAPI({
267 ...request,
268 auth_provider: news.authProvider,
269 jwks_url: news.jwksUrl,
270 provider_name: news.providerName,
271 jwt_audience: news.jwtAudience,
272 add_default_grants: news.addDefaultGrants ?? false,
273 skip_auth_schema: news.skipAuthSchema,
274 settings: news.settings,
275 }).pipe(
276 Effect.catchTag("Conflict", () =>
277 Effect.fail(
278 new OwnedBySomeoneElse({
279 message:
280 "Data API appeared during creation; explicit adoption is required",
281 resourceType: "Neon.DataApi",
282 }),
283 ),
284 ),
285 );
286 current = yield* Neon.getProjectBranchDataAPI(request);
287 }
288 if (
289 news.settings !== undefined &&
290 !authSettingsMatch(current.settings ?? {}, news.settings)
291 ) {
292 yield* Neon.updateProjectBranchDataAPI({
293 ...request,
294 settings: news.settings,
295 });
296 }
297 return attributes(scope, yield* Neon.getProjectBranchDataAPI(request));
298 }),
299 delete: Effect.fn(function* ({ output }) {
300 if (yield* observe(output)) {
301 yield* Neon.deleteProjectBranchDataAPI({
302 ...authRequest(output),
303 database_name: output.database,
304 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
305 }
306 }),
307 });