@neondatabase/config-runtime 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ResolvedFunctionConfig } from "@neondatabase/config";
5
+ import { unzipSync } from "fflate";
6
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
7
+ import { buildFunctionBundle } from "./function-bundle.js";
8
+
9
+ let dir: string;
10
+ beforeAll(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "neon-bundle-"));
12
+ });
13
+ afterAll(() => {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ function fn(source: string): ResolvedFunctionConfig {
18
+ return {
19
+ slug: "hello-world",
20
+ name: "Hello World",
21
+ source,
22
+ env: {},
23
+ runtime: "nodejs24",
24
+ memoryMib: 512,
25
+ };
26
+ }
27
+
28
+ describe("buildFunctionBundle", () => {
29
+ test("bundles a handler with esbuild and returns a ZIP containing out.js + sourcemap", async () => {
30
+ const helper = join(dir, "shared.ts");
31
+ writeFileSync(helper, "export const greeting = 'hello from neon';\n");
32
+ const source = join(dir, "hello-world.ts");
33
+ // Importing a sibling proves esbuild actually *bundles* (not just copies) the entry.
34
+ writeFileSync(
35
+ source,
36
+ [
37
+ "import { greeting } from './shared.js';",
38
+ "export default { fetch(_req: Request): Response { return new Response(greeting); } };",
39
+ ].join("\n"),
40
+ );
41
+
42
+ const bundle = await buildFunctionBundle(fn(source));
43
+ expect(bundle.byteLength).toBeGreaterThan(0);
44
+
45
+ const files = unzipSync(bundle);
46
+ const names = Object.keys(files).sort();
47
+ expect(names).toContain("out.js");
48
+ expect(names).toContain("out.js.map");
49
+
50
+ // The bundled output should have inlined the imported constant.
51
+ const js = new TextDecoder().decode(files["out.js"]);
52
+ expect(js).toContain("hello from neon");
53
+ });
54
+
55
+ test("throws a PlatformError when the source cannot be resolved", async () => {
56
+ await expect(
57
+ buildFunctionBundle(fn(join(dir, "does-not-exist.ts"))),
58
+ ).rejects.toThrow(/Failed to bundle function "hello-world"/);
59
+ });
60
+ });
@@ -0,0 +1,104 @@
1
+ import { basename } from "node:path";
2
+ import {
3
+ ErrorCode,
4
+ PlatformError,
5
+ type ResolvedFunctionConfig,
6
+ } from "@neondatabase/config";
7
+
8
+ /**
9
+ * Build the deployable bundle (a ZIP archive of the esbuild-bundled source) for a function.
10
+ *
11
+ * This is the **imperative shell** step of function deploys, and the reason it lives in
12
+ * `@neondatabase/config-runtime` rather than `@neondatabase/config`: it pulls in `esbuild`
13
+ * (a native binary) and `fflate`. Keeping it out of `@neondatabase/config` means a `neon.ts`
14
+ * that only imports `defineConfig` never drags esbuild into the user's dependency tree or
15
+ * bundle. Deploy-side consumers (the neonctl CLI, CI) import this package and get esbuild as
16
+ * a normal, auto-installed dependency.
17
+ *
18
+ * esbuild and fflate are loaded with a dynamic `import()` (not a static top-level import) so
19
+ * that nothing in this package's static graph names esbuild until a deploy actually runs —
20
+ * a second layer of protection on top of the package split.
21
+ *
22
+ * Mirrors: `esbuild <source> --bundle --outfile=out.js --sourcemap --minify`, then zips the
23
+ * emitted files into the archive the Functions deploy endpoint expects.
24
+ */
25
+ export async function buildFunctionBundle(
26
+ fn: ResolvedFunctionConfig,
27
+ ): Promise<Uint8Array> {
28
+ const esbuild = await loadEsbuild();
29
+
30
+ let result: Awaited<ReturnType<typeof esbuild.build>>;
31
+ try {
32
+ result = await esbuild.build({
33
+ entryPoints: [fn.source],
34
+ bundle: true,
35
+ write: false,
36
+ // Set an explicit outfile so the emitted files are named `out.js` / `out.js.map`
37
+ // (with `write: false` and no outfile, esbuild labels the buffer `<stdout>`).
38
+ outfile: "out.js",
39
+ sourcemap: true,
40
+ minify: true,
41
+ format: "esm",
42
+ platform: "node",
43
+ // The Functions runtime provides Node built-ins; don't try to bundle them.
44
+ packages: "external",
45
+ logLevel: "silent",
46
+ });
47
+ } catch (cause) {
48
+ throw new PlatformError(
49
+ ErrorCode.InvalidConfig,
50
+ [
51
+ `Failed to bundle function "${fn.slug}" from ${fn.source}.`,
52
+ (cause as Error)?.message ?? String(cause),
53
+ ].join(" "),
54
+ { cause },
55
+ );
56
+ }
57
+
58
+ const entries: Record<string, Uint8Array> = {};
59
+ // `write: false` guarantees `outputFiles`, but the type is optional — guard for safety.
60
+ for (const file of result.outputFiles ?? []) {
61
+ // esbuild returns absolute output paths; archive them under their basename
62
+ // (`out.js`, `out.js.map`) so the bundle layout is stable regardless of cwd.
63
+ entries[basename(file.path)] = file.contents;
64
+ }
65
+
66
+ return zipBundle(entries);
67
+ }
68
+
69
+ async function zipBundle(
70
+ entries: Record<string, Uint8Array>,
71
+ ): Promise<Uint8Array> {
72
+ const { zipSync } = await loadFflate();
73
+ return zipSync(entries, { level: 6 });
74
+ }
75
+
76
+ async function loadEsbuild(): Promise<typeof import("esbuild")> {
77
+ try {
78
+ return await import("esbuild");
79
+ } catch (cause) {
80
+ throw new PlatformError(
81
+ ErrorCode.InvalidConfig,
82
+ [
83
+ "Deploying Neon Functions requires `esbuild`, which could not be loaded.",
84
+ "It is a dependency of @neondatabase/config-runtime — reinstall your dependencies (`pnpm install` / `npm install`).",
85
+ ].join(" "),
86
+ { cause },
87
+ );
88
+ }
89
+ }
90
+
91
+ async function loadFflate(): Promise<typeof import("fflate")> {
92
+ try {
93
+ return await import("fflate");
94
+ } catch (cause) {
95
+ throw new PlatformError(
96
+ ErrorCode.InvalidConfig,
97
+ [
98
+ "Deploying Neon Functions requires `fflate`, which could not be loaded.",
99
+ "It is a dependency of @neondatabase/config-runtime — reinstall your dependencies (`pnpm install` / `npm install`).",
100
+ ].join(" "),
101
+ { cause },
102
+ );
103
+ }
104
+ }
@@ -0,0 +1,150 @@
1
+ import { defineConfig, ErrorCode } from "@neondatabase/config";
2
+ import { describe, expect, test } from "vitest";
3
+ import { FakeNeonApi } from "./fake-neon-api.js";
4
+ import { apply, inspect, plan } from "./operations.js";
5
+
6
+ function seededFake(opts?: { protected?: boolean }) {
7
+ const api = new FakeNeonApi();
8
+ const projectId = "proj-ops";
9
+ api.seedProject({
10
+ project: {
11
+ id: projectId,
12
+ name: "ops-test",
13
+ regionId: "aws-us-east-1",
14
+ pgVersion: 17,
15
+ orgId: "org-ops",
16
+ },
17
+ branches: [
18
+ {
19
+ branch: {
20
+ id: "br-main",
21
+ name: "main",
22
+ isDefault: true,
23
+ protected: opts?.protected ?? false,
24
+ },
25
+ },
26
+ ],
27
+ });
28
+ return { api, projectId };
29
+ }
30
+
31
+ describe("inspect", () => {
32
+ test("returns the selected branch's live state", async () => {
33
+ const { api, projectId } = seededFake();
34
+ const result = await inspect({ api, projectId, branchId: "br-main" });
35
+ expect(result.project.name).toBe("ops-test");
36
+ expect(result.branch.name).toBe("main");
37
+ expect(result.config).toBeDefined();
38
+ });
39
+ });
40
+
41
+ describe("plan", () => {
42
+ test("computes a dry-run plan without mutating", async () => {
43
+ const { api, projectId } = seededFake();
44
+ const config = defineConfig(() => ({ auth: {} }));
45
+ const result = await plan(config, {
46
+ api,
47
+ projectId,
48
+ branchId: "br-main",
49
+ });
50
+ expect(result.dryRun).toBe(true);
51
+ expect(result.applied).toEqual(
52
+ expect.arrayContaining([
53
+ expect.objectContaining({
54
+ kind: "service",
55
+ identifier: "auth",
56
+ }),
57
+ ]),
58
+ );
59
+ // No mutation happened: a fresh plan still shows the same enable.
60
+ const again = await plan(config, {
61
+ api,
62
+ projectId,
63
+ branchId: "br-main",
64
+ });
65
+ expect(again.applied).toEqual(result.applied);
66
+ });
67
+ });
68
+
69
+ describe("apply", () => {
70
+ test("applies the branch policy to the selected branch", async () => {
71
+ const { api, projectId } = seededFake();
72
+ const config = defineConfig(() => ({ auth: {} }));
73
+ const result = await apply(config, {
74
+ api,
75
+ projectId,
76
+ branchId: "br-main",
77
+ updateExisting: true,
78
+ });
79
+ expect(result.dryRun).toBe(false);
80
+ expect(result.applied).toEqual(
81
+ expect.arrayContaining([
82
+ expect.objectContaining({
83
+ kind: "service",
84
+ identifier: "auth",
85
+ }),
86
+ ]),
87
+ );
88
+ });
89
+
90
+ test("surfaces drift as a PushConflictError without updateExisting", async () => {
91
+ const { api, projectId } = seededFake();
92
+ const config = defineConfig(() => ({
93
+ postgres: { computeSettings: { autoscalingLimitMaxCu: 4 } },
94
+ }));
95
+ await expect(
96
+ apply(config, { api, projectId, branchId: "br-main" }),
97
+ ).rejects.toMatchObject({ code: ErrorCode.PushConflict });
98
+ });
99
+
100
+ test("applies drift to a protected branch with both override flags", async () => {
101
+ const { api, projectId } = seededFake({ protected: true });
102
+ const config = defineConfig(() => ({
103
+ postgres: { computeSettings: { autoscalingLimitMaxCu: 4 } },
104
+ }));
105
+ const result = await apply(config, {
106
+ api,
107
+ projectId,
108
+ branchId: "br-main",
109
+ updateExisting: true,
110
+ allowProtectedBranch: true,
111
+ });
112
+ expect(result.dryRun).toBe(false);
113
+ expect(api.history.some((h) => h.method === "updateEndpoint")).toBe(
114
+ true,
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("branch not found (id-only lookup)", () => {
120
+ test("inspect throws BranchNotFound for an unknown id", async () => {
121
+ const { api, projectId } = seededFake();
122
+ await expect(
123
+ inspect({ api, projectId, branchId: "br-does-not-exist" }),
124
+ ).rejects.toMatchObject({ code: ErrorCode.BranchNotFound });
125
+ });
126
+
127
+ test("plan throws BranchNotFound for an unknown id", async () => {
128
+ const { api, projectId } = seededFake();
129
+ const config = defineConfig(() => ({}));
130
+ await expect(
131
+ plan(config, { api, projectId, branchId: "br-does-not-exist" }),
132
+ ).rejects.toMatchObject({ code: ErrorCode.BranchNotFound });
133
+ });
134
+
135
+ test("apply throws BranchNotFound for an unknown id", async () => {
136
+ const { api, projectId } = seededFake();
137
+ const config = defineConfig(() => ({}));
138
+ await expect(
139
+ apply(config, { api, projectId, branchId: "br-does-not-exist" }),
140
+ ).rejects.toMatchObject({ code: ErrorCode.BranchNotFound });
141
+ });
142
+
143
+ test("inspect rejects a branch name (ids only)", async () => {
144
+ const { api, projectId } = seededFake();
145
+ // "main" is the branch NAME; lookups match by id only, so this must fail.
146
+ await expect(
147
+ inspect({ api, projectId, branchId: "main" }),
148
+ ).rejects.toMatchObject({ code: ErrorCode.BranchNotFound });
149
+ });
150
+ });
@@ -0,0 +1,103 @@
1
+ import type { Config, PushResult } from "@neondatabase/config";
2
+ import { type PulledBranchConfig, pullConfig } from "./pull-config.js";
3
+ import { type PushConfigOptions, pushConfig } from "./push-config.js";
4
+
5
+ /**
6
+ * Where to run the operation and how to authenticate. Filesystem- and env-agnostic: the
7
+ * `projectId` and `branchId` are always passed explicitly by the caller (e.g. neonctl
8
+ * resolves them from `.neon` / `NEON_*` and forwards them here).
9
+ */
10
+ export interface ConfigOperationOptions {
11
+ /**
12
+ * Neon project id. **Required** — the management API addresses branches through their
13
+ * project, so operations cannot run without it.
14
+ */
15
+ projectId: string;
16
+ /**
17
+ * Neon branch id (`br-…`). **Required.** Must already exist on the project; resolve
18
+ * branch names to ids before calling.
19
+ */
20
+ branchId: string;
21
+ /** Neon API key. Falls back to `NEON_API_KEY` / neonctl credentials. */
22
+ apiKey?: string;
23
+ /** Inject a custom NeonApi adapter (primarily for tests). */
24
+ api?: PushConfigOptions["api"];
25
+ }
26
+
27
+ /**
28
+ * Options accepted by {@link apply} on top of {@link ConfigOperationOptions}.
29
+ */
30
+ export interface ApplyOptions extends ConfigOperationOptions {
31
+ /**
32
+ * Auto-confirm overriding existing remote settings (TTL, `protected`, compute
33
+ * settings) on the selected branch. Without it, drift is reported as a conflict.
34
+ */
35
+ updateExisting?: boolean;
36
+ /** Auto-confirm applying to a branch marked `protected` on Neon. */
37
+ allowProtectedBranch?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Read a branch's live Neon state as a plain object (project + branch metadata and the
42
+ * reverse-engineered `BranchConfig`). Network read only — never mutates.
43
+ *
44
+ * `projectId` and `branchId` are **required** (both in `options`).
45
+ */
46
+ export async function inspect(
47
+ options: ConfigOperationOptions,
48
+ ): Promise<PulledBranchConfig> {
49
+ return pullConfig({
50
+ projectId: options.projectId,
51
+ branchId: options.branchId,
52
+ ...(options.api ? { api: options.api } : {}),
53
+ ...(options.apiKey ? { apiKey: options.apiKey } : {}),
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Compute what {@link apply} would do for the given branch without mutating anything
59
+ * (dry-run plan). Returns the full {@link PushResult} with the planned changes in
60
+ * `applied` and any blocking drift in `conflicts` — the Neon equivalent of
61
+ * `terraform plan`.
62
+ *
63
+ * `projectId` and `branchId` are **required** (both in `options`).
64
+ */
65
+ export async function plan(
66
+ config: Config,
67
+ options: ConfigOperationOptions,
68
+ ): Promise<PushResult> {
69
+ return pushConfig(config, {
70
+ projectId: options.projectId,
71
+ branchId: options.branchId,
72
+ dryRun: true,
73
+ // Surface the full would-apply list as plan steps without mutating anything.
74
+ updateExisting: true,
75
+ ...(options.api ? { api: options.api } : {}),
76
+ ...(options.apiKey ? { apiKey: options.apiKey } : {}),
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Apply a `neon.ts` policy to the given Neon branch and return the {@link PushResult}
82
+ * describing what changed — the Neon equivalent of `terraform apply`.
83
+ *
84
+ * `projectId` and `branchId` are **required** (both in `options`). Pass `updateExisting`
85
+ * to auto-confirm overriding existing remote settings and `allowProtectedBranch` to
86
+ * auto-confirm applying to a protected branch; otherwise drift is reported as a
87
+ * `PushConflictError`.
88
+ *
89
+ * Never creates projects or branches — both must already exist.
90
+ */
91
+ export async function apply(
92
+ config: Config,
93
+ options: ApplyOptions,
94
+ ): Promise<PushResult> {
95
+ return pushConfig(config, {
96
+ projectId: options.projectId,
97
+ branchId: options.branchId,
98
+ ...(options.api ? { api: options.api } : {}),
99
+ ...(options.apiKey ? { apiKey: options.apiKey } : {}),
100
+ ...(options.updateExisting ? { updateExisting: true } : {}),
101
+ ...(options.allowProtectedBranch ? { allowProtectedBranch: true } : {}),
102
+ });
103
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { FakeNeonApi } from "./fake-neon-api.js";
3
+ import { pullConfig } from "./pull-config.js";
4
+
5
+ describe("pullConfig", () => {
6
+ test("returns selected branch state as JSON-friendly branch config", async () => {
7
+ const api = new FakeNeonApi();
8
+ const projectId = "proj-pull";
9
+ api.seedProject({
10
+ project: {
11
+ id: projectId,
12
+ name: "pull-test",
13
+ regionId: "aws-us-east-1",
14
+ pgVersion: 17,
15
+ orgId: "org-pull",
16
+ },
17
+ branches: [
18
+ { branch: { id: "br-main", name: "main", isDefault: true } },
19
+ {
20
+ branch: {
21
+ id: "br-dev",
22
+ name: "dev-a",
23
+ isDefault: false,
24
+ parentId: "br-main",
25
+ protected: true,
26
+ },
27
+ endpoint: { autoscalingLimitMaxCu: 2 },
28
+ },
29
+ ],
30
+ });
31
+
32
+ const pulled = await pullConfig({ api, projectId, branchId: "br-dev" });
33
+
34
+ expect(pulled.project).toMatchObject({
35
+ id: projectId,
36
+ name: "pull-test",
37
+ orgId: "org-pull",
38
+ });
39
+ expect(pulled.branch).toMatchObject({
40
+ id: "br-dev",
41
+ name: "dev-a",
42
+ parent: "main",
43
+ protected: true,
44
+ });
45
+ expect(pulled.config).toMatchObject({
46
+ parent: "main",
47
+ protected: true,
48
+ postgres: { computeSettings: { autoscalingLimitMaxCu: 2 } },
49
+ });
50
+ });
51
+ });
@@ -0,0 +1,215 @@
1
+ import {
2
+ type BranchConfig,
3
+ type BucketConfig,
4
+ type ComputeSettings,
5
+ createNeonApiFromOptions,
6
+ ErrorCode,
7
+ type NeonApi,
8
+ type NeonBranchSnapshot,
9
+ type NeonBucketSnapshot,
10
+ type NeonEndpointSnapshot,
11
+ type NeonFunctionSnapshot,
12
+ type NeonProjectSnapshot,
13
+ PlatformError,
14
+ } from "@neondatabase/config";
15
+
16
+ export interface PullConfigOptions {
17
+ /** Neon project id (`<project>`). Required — the API addresses branches by project. */
18
+ projectId: string;
19
+ /** Neon branch id (`br-…`). Required. Resolve names to ids before calling. */
20
+ branchId: string;
21
+ /** Neon API key. Falls back to `NEON_API_KEY` / neonctl credentials. */
22
+ apiKey?: string;
23
+ /** Inject a custom NeonApi adapter (primarily for tests). */
24
+ api?: NeonApi;
25
+ }
26
+
27
+ /**
28
+ * Live Preview-feature state read back from a branch. Surfaced alongside `config` rather
29
+ * than inside it because functions cannot round-trip: the remote only knows the deployed
30
+ * bundle, not the local `source` path a {@link FunctionConfig} requires, so a pulled
31
+ * function is reported as `{ slug, name }` (no `source`).
32
+ */
33
+ export interface PulledPreview {
34
+ buckets: BucketConfig[];
35
+ functions: Array<{ slug: string; name: string }>;
36
+ aiGatewayEnabled: boolean;
37
+ }
38
+
39
+ export interface PulledBranchConfig {
40
+ project: {
41
+ id: string;
42
+ name: string;
43
+ region: string;
44
+ pgVersion: number;
45
+ orgId?: string;
46
+ };
47
+ branch: {
48
+ id: string;
49
+ name: string;
50
+ parent?: string;
51
+ isDefault: boolean;
52
+ protected: boolean;
53
+ expiresAt?: string;
54
+ };
55
+ config: BranchConfig;
56
+ /**
57
+ * Live Preview-feature state, when the branch has any buckets/functions or an enabled
58
+ * AI Gateway. Omitted entirely when there is nothing to report.
59
+ */
60
+ preview?: PulledPreview;
61
+ }
62
+
63
+ export async function pullConfig(
64
+ options: PullConfigOptions,
65
+ ): Promise<PulledBranchConfig> {
66
+ const api = options.api ?? createApiFromOptions(options);
67
+ const projectId = options.projectId;
68
+ const project = await api.getProject(projectId);
69
+ const [branches, endpoints] = await Promise.all([
70
+ api.listBranches(projectId),
71
+ api.listEndpoints(projectId),
72
+ ]);
73
+ const branch = resolveBranch(options.branchId, branches);
74
+ const endpoint = endpoints.find(
75
+ (ep) => ep.type === "read_write" && ep.branchId === branch.id,
76
+ );
77
+ const [buckets, functions, aiGatewayEnabled] = await Promise.all([
78
+ api.listBranchBuckets(projectId, branch.id),
79
+ api.listBranchFunctions(projectId, branch.id),
80
+ api.getAiGatewayEnabled(projectId, branch.id),
81
+ ]);
82
+ return buildPulledBranchConfig(project, branch, branches, endpoint, {
83
+ buckets,
84
+ functions,
85
+ aiGatewayEnabled,
86
+ });
87
+ }
88
+
89
+ function createApiFromOptions(options: PullConfigOptions): NeonApi {
90
+ return createNeonApiFromOptions("pullConfig", {
91
+ ...(options.apiKey ? { apiKey: options.apiKey } : {}),
92
+ });
93
+ }
94
+
95
+ export function buildPulledBranchConfig(
96
+ project: NeonProjectSnapshot,
97
+ branch: NeonBranchSnapshot,
98
+ branches: NeonBranchSnapshot[],
99
+ endpoint: NeonEndpointSnapshot | undefined,
100
+ previewState?: {
101
+ buckets: NeonBucketSnapshot[];
102
+ functions: NeonFunctionSnapshot[];
103
+ aiGatewayEnabled: boolean;
104
+ },
105
+ ): PulledBranchConfig {
106
+ const parent = branch.parentId
107
+ ? branches.find((b) => b.id === branch.parentId)
108
+ : undefined;
109
+ const config: BranchConfig = {};
110
+ if (parent) config.parent = parent.name;
111
+ if (branch.expiresAt) config.ttl = branch.expiresAt;
112
+ if (branch.protected) config.protected = true;
113
+ if (endpoint) {
114
+ const compute = endpointToComputeSettings(endpoint, project);
115
+ if (compute) config.postgres = { computeSettings: compute };
116
+ }
117
+ const result: PulledBranchConfig = {
118
+ project: {
119
+ id: project.id,
120
+ name: project.name,
121
+ region: project.regionId,
122
+ pgVersion: project.pgVersion,
123
+ ...(project.orgId ? { orgId: project.orgId } : {}),
124
+ },
125
+ branch: {
126
+ id: branch.id,
127
+ name: branch.name,
128
+ ...(parent ? { parent: parent.name } : {}),
129
+ isDefault: branch.isDefault,
130
+ protected: branch.protected,
131
+ ...(branch.expiresAt ? { expiresAt: branch.expiresAt } : {}),
132
+ },
133
+ config,
134
+ };
135
+ const preview = previewState ? buildPulledPreview(previewState) : undefined;
136
+ if (preview) result.preview = preview;
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * Reverse-engineer the {@link PulledPreview} from remote snapshots. Returns `undefined` when
142
+ * the branch has no Preview features so the field can be omitted entirely.
143
+ */
144
+ function buildPulledPreview(state: {
145
+ buckets: NeonBucketSnapshot[];
146
+ functions: NeonFunctionSnapshot[];
147
+ aiGatewayEnabled: boolean;
148
+ }): PulledPreview | undefined {
149
+ if (
150
+ state.buckets.length === 0 &&
151
+ state.functions.length === 0 &&
152
+ !state.aiGatewayEnabled
153
+ ) {
154
+ return undefined;
155
+ }
156
+ return {
157
+ buckets: state.buckets.map((b) => ({
158
+ name: b.name,
159
+ access: b.accessLevel,
160
+ })),
161
+ functions: state.functions.map((f) => ({
162
+ slug: f.slug,
163
+ name: f.name,
164
+ })),
165
+ aiGatewayEnabled: state.aiGatewayEnabled,
166
+ };
167
+ }
168
+
169
+ function resolveBranch(
170
+ branchId: string,
171
+ branches: NeonBranchSnapshot[],
172
+ ): NeonBranchSnapshot {
173
+ const match = branches.find((b) => b.id === branchId);
174
+ if (match) return match;
175
+ throw new PlatformError(
176
+ ErrorCode.BranchNotFound,
177
+ [
178
+ `pullConfig: branch id ${JSON.stringify(branchId)} not found on project.`,
179
+ `Available branches: ${branches.map((b) => `${b.name} (${b.id})`).join(", ") || "(none)"}.`,
180
+ ].join(" "),
181
+ {
182
+ details: {
183
+ branchId,
184
+ available: branches.map((b) => b.id),
185
+ },
186
+ },
187
+ );
188
+ }
189
+
190
+ function endpointToComputeSettings(
191
+ endpoint: NeonEndpointSnapshot,
192
+ project: NeonProjectSnapshot,
193
+ ): ComputeSettings | undefined {
194
+ const defaults = project.defaultEndpointSettings;
195
+ const out: ComputeSettings = {};
196
+ if (
197
+ endpoint.autoscalingLimitMinCu !== undefined &&
198
+ endpoint.autoscalingLimitMinCu !== defaults?.autoscalingLimitMinCu
199
+ ) {
200
+ out.autoscalingLimitMinCu = endpoint.autoscalingLimitMinCu;
201
+ }
202
+ if (
203
+ endpoint.autoscalingLimitMaxCu !== undefined &&
204
+ endpoint.autoscalingLimitMaxCu !== defaults?.autoscalingLimitMaxCu
205
+ ) {
206
+ out.autoscalingLimitMaxCu = endpoint.autoscalingLimitMaxCu;
207
+ }
208
+ if (
209
+ endpoint.suspendTimeout !== undefined &&
210
+ endpoint.suspendTimeout !== defaults?.suspendTimeout
211
+ ) {
212
+ out.suspendTimeout = endpoint.suspendTimeout;
213
+ }
214
+ return Object.keys(out).length > 0 ? out : undefined;
215
+ }