@neondatabase/config 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.
- package/.env.example +5 -0
- package/README.md +92 -0
- package/e2e/errors.e2e.test.ts +52 -0
- package/e2e/helpers.ts +205 -0
- package/e2e/load-env.ts +29 -0
- package/e2e/setup.ts +24 -0
- package/package.json +18 -0
- package/src/index.ts +5 -0
- package/src/lib/auth.test.ts +166 -0
- package/src/lib/auth.ts +124 -0
- package/src/lib/define-config.test.ts +161 -0
- package/src/lib/define-config.ts +152 -0
- package/src/lib/diff.test.ts +142 -0
- package/src/lib/diff.ts +391 -0
- package/src/lib/duration.test.ts +105 -0
- package/src/lib/duration.ts +147 -0
- package/src/lib/errors.test.ts +26 -0
- package/src/lib/errors.ts +220 -0
- package/src/lib/fake-neon-api.ts +782 -0
- package/src/lib/loader.test.ts +35 -0
- package/src/lib/loader.ts +215 -0
- package/src/lib/neon-api-real.test.ts +72 -0
- package/src/lib/neon-api-real.ts +1123 -0
- package/src/lib/neon-api.ts +356 -0
- package/src/lib/patterns.test.ts +80 -0
- package/src/lib/patterns.ts +98 -0
- package/src/lib/schema.test.ts +88 -0
- package/src/lib/schema.ts +252 -0
- package/src/lib/test-utils.ts +83 -0
- package/src/lib/types.ts +268 -0
- package/src/lib/wrap-neon-error.test.ts +145 -0
- package/src/lib/wrap-neon-error.ts +204 -0
- package/src/v1.test.ts +33 -0
- package/src/v1.ts +148 -0
- package/tsconfig.json +4 -0
- package/tsdown.config.ts +19 -0
- package/vitest.config.ts +19 -0
- package/vitest.e2e.config.ts +29 -0
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { ErrorCode, PlatformError } from "./errors.js";
|
|
4
|
+
import type { NeonApi } from "./neon-api.js";
|
|
5
|
+
import { createRealNeonApi } from "./neon-api-real.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal shape of `~/.config/neonctl/credentials.json` we read. `neonctl` writes more
|
|
9
|
+
* fields (refresh_token, expires_at, …) but only `access_token` is what we need — it's a
|
|
10
|
+
* Bearer token the Neon API accepts on the same endpoints `napi_*` API keys do.
|
|
11
|
+
*/
|
|
12
|
+
export interface NeonctlCredentials {
|
|
13
|
+
access_token: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Locate and read the OAuth credentials neonctl writes after `neon auth`.
|
|
19
|
+
*
|
|
20
|
+
* Resolution:
|
|
21
|
+
* 1. `options.configDir` (explicit override — mirrors neonctl's `--config-dir` flag).
|
|
22
|
+
* 2. `NEONCTL_CONFIG_DIR` environment variable.
|
|
23
|
+
* 3. `<home>/.config/neonctl/credentials.json` (the neonctl default; `home` reads
|
|
24
|
+
* `HOME`, falling back to `USERPROFILE` for Windows parity).
|
|
25
|
+
*
|
|
26
|
+
* Returns `null` (never throws) when the file is missing, unreadable, malformed, or has
|
|
27
|
+
* no `access_token` — so callers can use this as a quiet fallback in a resolution chain
|
|
28
|
+
* without try/catch noise.
|
|
29
|
+
*/
|
|
30
|
+
export function readNeonctlCredentials(
|
|
31
|
+
options: { configDir?: string } = {},
|
|
32
|
+
): NeonctlCredentials | null {
|
|
33
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
34
|
+
const configDir =
|
|
35
|
+
options.configDir ??
|
|
36
|
+
process.env.NEONCTL_CONFIG_DIR ??
|
|
37
|
+
(home ? resolve(home, ".config", "neonctl") : undefined);
|
|
38
|
+
if (!configDir) return null;
|
|
39
|
+
|
|
40
|
+
const credentialsPath = resolve(configDir, "credentials.json");
|
|
41
|
+
if (!existsSync(credentialsPath)) return null;
|
|
42
|
+
|
|
43
|
+
let raw: string;
|
|
44
|
+
try {
|
|
45
|
+
raw = readFileSync(credentialsPath, "utf-8");
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let parsed: unknown;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
58
|
+
return null;
|
|
59
|
+
const obj = parsed as Record<string, unknown>;
|
|
60
|
+
if (typeof obj.access_token !== "string" || obj.access_token === "")
|
|
61
|
+
return null;
|
|
62
|
+
return obj as NeonctlCredentials;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolution chain for the Bearer token sent to the Neon API. Each entry wins over the
|
|
67
|
+
* next:
|
|
68
|
+
*
|
|
69
|
+
* 1. `options.apiKey` (explicit).
|
|
70
|
+
* 2. `NEON_API_KEY` environment variable.
|
|
71
|
+
* 3. `access_token` from `~/.config/neonctl/credentials.json` (or `NEONCTL_CONFIG_DIR`).
|
|
72
|
+
*
|
|
73
|
+
* Returns `null` when no source provides one. Callers wrap the null case in a
|
|
74
|
+
* `PLATFORM_MISSING_API_KEY` error with a message tailored to the operation.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveApiKey(
|
|
77
|
+
options: { apiKey?: string; configDir?: string } = {},
|
|
78
|
+
): { token: string; source: "option" | "env" | "neonctl" } | null {
|
|
79
|
+
if (options.apiKey && options.apiKey.trim() !== "") {
|
|
80
|
+
return { token: options.apiKey.trim(), source: "option" };
|
|
81
|
+
}
|
|
82
|
+
const envKey = process.env.NEON_API_KEY;
|
|
83
|
+
if (typeof envKey === "string" && envKey.trim() !== "") {
|
|
84
|
+
return { token: envKey.trim(), source: "env" };
|
|
85
|
+
}
|
|
86
|
+
const creds = readNeonctlCredentials(
|
|
87
|
+
options.configDir ? { configDir: options.configDir } : {},
|
|
88
|
+
);
|
|
89
|
+
if (creds) {
|
|
90
|
+
return { token: creds.access_token, source: "neonctl" };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the Neon API key via the standard chain (option → `NEON_API_KEY` env →
|
|
97
|
+
* `~/.config/neonctl/credentials.json`) and construct a real {@link NeonApi} adapter from
|
|
98
|
+
* it, or throw a uniform `PLATFORM_MISSING_API_KEY` error if no key can be found.
|
|
99
|
+
*
|
|
100
|
+
* Used by `pullConfig`, `pushConfig`, `fetchEnv`, and `branch` to build their default
|
|
101
|
+
* `NeonApi` when the caller doesn't inject one. `operation` is the calling function's
|
|
102
|
+
* name (e.g. `"pushConfig"`, `"branch"`) — it's prepended to the error message so users
|
|
103
|
+
* can tell which call surfaced the missing key.
|
|
104
|
+
*/
|
|
105
|
+
export function createNeonApiFromOptions(
|
|
106
|
+
operation: string,
|
|
107
|
+
options: {
|
|
108
|
+
apiKey?: string;
|
|
109
|
+
} = {},
|
|
110
|
+
): NeonApi {
|
|
111
|
+
const resolved = resolveApiKey(
|
|
112
|
+
options.apiKey ? { apiKey: options.apiKey } : {},
|
|
113
|
+
);
|
|
114
|
+
if (resolved) return createRealNeonApi({ apiKey: resolved.token });
|
|
115
|
+
throw new PlatformError(
|
|
116
|
+
ErrorCode.MissingApiKey,
|
|
117
|
+
[
|
|
118
|
+
`${operation} has no Neon API key to work with.`,
|
|
119
|
+
"Tried (in order): `apiKey` option, NEON_API_KEY env, and `~/.config/neonctl/credentials.json`.",
|
|
120
|
+
"Either pass `apiKey` directly, set NEON_API_KEY, run `npx neonctl auth` to populate the credentials file, or pass a custom `api` adapter (e.g. an in-memory fake for tests).",
|
|
121
|
+
"Generate a key at https://console.neon.tech/app/settings/api-keys.",
|
|
122
|
+
].join(" "),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { defineConfig, resolveConfig } from "./define-config.js";
|
|
3
|
+
import { ConfigValidationError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
describe("defineConfig", () => {
|
|
6
|
+
test("accepts a branch policy function and preserves literal behavior", () => {
|
|
7
|
+
const config = defineConfig((branch) => {
|
|
8
|
+
if (branch.name === "main")
|
|
9
|
+
return { protected: true, auth: { enabled: true } };
|
|
10
|
+
return { parent: "main", ttl: "7d" };
|
|
11
|
+
});
|
|
12
|
+
expect(typeof config).toBe("function");
|
|
13
|
+
expect(config({ name: "main", exists: true })).toEqual({
|
|
14
|
+
protected: true,
|
|
15
|
+
auth: { enabled: true },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects object configs so project-level config is not accepted", () => {
|
|
20
|
+
expect(() => defineConfig({ project: { name: "x" } } as never)).toThrow(
|
|
21
|
+
ConfigValidationError,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("resolveConfig", () => {
|
|
27
|
+
test("normalizes branch policy output", () => {
|
|
28
|
+
const config = defineConfig(() => ({
|
|
29
|
+
parent: "main",
|
|
30
|
+
ttl: "1h",
|
|
31
|
+
protected: true,
|
|
32
|
+
postgres: { computeSettings: { autoscalingLimitMaxCu: 2 } },
|
|
33
|
+
auth: {},
|
|
34
|
+
dataApi: {},
|
|
35
|
+
}));
|
|
36
|
+
const resolved = resolveConfig(config, {
|
|
37
|
+
name: "dev-a",
|
|
38
|
+
exists: false,
|
|
39
|
+
});
|
|
40
|
+
expect(resolved).toMatchObject({
|
|
41
|
+
parent: "main",
|
|
42
|
+
ttlSeconds: 3600,
|
|
43
|
+
protected: true,
|
|
44
|
+
authEnabled: true,
|
|
45
|
+
dataApiEnabled: true,
|
|
46
|
+
postgres: { computeSettings: { autoscalingLimitMaxCu: 2 } },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("treats explicit service false as disabled", () => {
|
|
51
|
+
const config = defineConfig(() => ({
|
|
52
|
+
auth: { enabled: false },
|
|
53
|
+
dataApi: { enabled: false },
|
|
54
|
+
}));
|
|
55
|
+
const resolved = resolveConfig(config, {
|
|
56
|
+
name: "dev-a",
|
|
57
|
+
exists: false,
|
|
58
|
+
});
|
|
59
|
+
expect(resolved.authEnabled).toBe(false);
|
|
60
|
+
expect(resolved.dataApiEnabled).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("reports invalid returned branch config", () => {
|
|
64
|
+
const config = defineConfig(() => ({ parent: "preview-*" }));
|
|
65
|
+
expect(() =>
|
|
66
|
+
resolveConfig(config, { name: "dev", exists: false }),
|
|
67
|
+
).toThrow(ConfigValidationError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("resolves preview functions with deploy defaults and buckets with private access", () => {
|
|
71
|
+
const config = defineConfig(() => ({
|
|
72
|
+
preview: {
|
|
73
|
+
functions: [
|
|
74
|
+
{
|
|
75
|
+
name: "Hello World",
|
|
76
|
+
slug: "hello-world",
|
|
77
|
+
source: "./functions/hello-world.ts",
|
|
78
|
+
env: { RESEND_API_KEY: "re_abc" },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
buckets: [{ name: "uploads" }],
|
|
82
|
+
aiGateway: { enabled: true },
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
const resolved = resolveConfig(config, {
|
|
86
|
+
name: "preview-1",
|
|
87
|
+
exists: false,
|
|
88
|
+
});
|
|
89
|
+
expect(resolved.preview).toEqual({
|
|
90
|
+
functions: [
|
|
91
|
+
{
|
|
92
|
+
slug: "hello-world",
|
|
93
|
+
name: "Hello World",
|
|
94
|
+
source: "./functions/hello-world.ts",
|
|
95
|
+
env: { RESEND_API_KEY: "re_abc" },
|
|
96
|
+
runtime: "nodejs24",
|
|
97
|
+
memoryMib: 512,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
buckets: [{ name: "uploads", access: "private" }],
|
|
101
|
+
aiGatewayEnabled: true,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("treats aiGateway enabled:false as disabled", () => {
|
|
106
|
+
const config = defineConfig(() => ({
|
|
107
|
+
preview: { aiGateway: { enabled: false } },
|
|
108
|
+
}));
|
|
109
|
+
const resolved = resolveConfig(config, {
|
|
110
|
+
name: "preview-1",
|
|
111
|
+
exists: false,
|
|
112
|
+
});
|
|
113
|
+
expect(resolved.preview?.aiGatewayEnabled).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("rejects a function env value that is undefined (e.g. unset process.env)", () => {
|
|
117
|
+
const config = defineConfig(() => ({
|
|
118
|
+
preview: {
|
|
119
|
+
functions: [
|
|
120
|
+
{
|
|
121
|
+
name: "Hello",
|
|
122
|
+
slug: "hello",
|
|
123
|
+
source: "./hello.ts",
|
|
124
|
+
// Simulates `RESEND_API_KEY: process.env.RESEND_API_KEY` when unset.
|
|
125
|
+
env: { RESEND_API_KEY: undefined as unknown as string },
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
expect(() =>
|
|
131
|
+
resolveConfig(config, { name: "preview-1", exists: false }),
|
|
132
|
+
).toThrow(ConfigValidationError);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("rejects an invalid function slug", () => {
|
|
136
|
+
const config = defineConfig(() => ({
|
|
137
|
+
preview: {
|
|
138
|
+
functions: [
|
|
139
|
+
{ name: "Bad", slug: "Hello World", source: "./x.ts" },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
}));
|
|
143
|
+
expect(() =>
|
|
144
|
+
resolveConfig(config, { name: "preview-1", exists: false }),
|
|
145
|
+
).toThrow(ConfigValidationError);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("rejects duplicate function slugs", () => {
|
|
149
|
+
const config = defineConfig(() => ({
|
|
150
|
+
preview: {
|
|
151
|
+
functions: [
|
|
152
|
+
{ name: "A", slug: "dup", source: "./a.ts" },
|
|
153
|
+
{ name: "B", slug: "dup", source: "./b.ts" },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
expect(() =>
|
|
158
|
+
resolveConfig(config, { name: "preview-1", exists: false }),
|
|
159
|
+
).toThrow(ConfigValidationError);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { parseDuration } from "./duration.js";
|
|
2
|
+
import { ConfigValidationError } from "./errors.js";
|
|
3
|
+
import { branchConfigSchema, formatZodIssues } from "./schema.js";
|
|
4
|
+
import type {
|
|
5
|
+
BranchTarget,
|
|
6
|
+
Config,
|
|
7
|
+
FunctionConfig,
|
|
8
|
+
PreviewConfig,
|
|
9
|
+
ResolvedBranchConfig,
|
|
10
|
+
ResolvedPreviewConfig,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Default deploy parameters applied to functions that omit them in `neon.ts`. */
|
|
14
|
+
const DEFAULT_FUNCTION_RUNTIME = "nodejs24" as const;
|
|
15
|
+
const DEFAULT_FUNCTION_MEMORY_MIB = 512 as const;
|
|
16
|
+
|
|
17
|
+
const REGION_PREFIX = /^(aws|azure|gcp)-/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate and freeze a Neon Platform branch policy.
|
|
21
|
+
*
|
|
22
|
+
* Used at the top of `neon.ts`:
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { defineConfig } from "@neondatabase/config/v1";
|
|
25
|
+
*
|
|
26
|
+
* export default defineConfig((branch) => {
|
|
27
|
+
* if (branch.name === "main") {
|
|
28
|
+
* return { protected: true, auth: {} };
|
|
29
|
+
* }
|
|
30
|
+
* return { parent: "main", ttl: "7d" };
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* The `branch` parameter is a **read-only {@link BranchTarget} descriptor** of the branch
|
|
35
|
+
* this policy invocation is deciding for — not a live branch handle. You don't mutate it
|
|
36
|
+
* (`branch.protected = true` does nothing); you switch on its facts (`branch.name`,
|
|
37
|
+
* `branch.isDefault`, `branch.exists`, …) and **return** the desired {@link BranchConfig}.
|
|
38
|
+
* The same callback runs in two modes: against an existing branch (fields populated from
|
|
39
|
+
* Neon) and during pre-create evaluation (`exists: false`, `id` undefined).
|
|
40
|
+
*
|
|
41
|
+
* Pure function — no I/O, no side effects. The returned policy validates its output every
|
|
42
|
+
* time it is evaluated so errors point at the concrete branch target that triggered them.
|
|
43
|
+
*/
|
|
44
|
+
export function defineConfig<const C extends Config>(input: C): C {
|
|
45
|
+
if (typeof input !== "function") {
|
|
46
|
+
throw new ConfigValidationError([
|
|
47
|
+
"defineConfig expects a function: `export default defineConfig((branch) => ({ ... }))`.",
|
|
48
|
+
"Project-level config has moved to `neonctl link`; neon.ts now describes branch-level policy only.",
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Object.freeze(input) as C;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Evaluate a branch policy for a specific branch target and return a normalized config.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveConfig(
|
|
59
|
+
config: Config,
|
|
60
|
+
branch: BranchTarget,
|
|
61
|
+
): ResolvedBranchConfig {
|
|
62
|
+
let raw: unknown;
|
|
63
|
+
try {
|
|
64
|
+
raw = config(Object.freeze({ ...branch }));
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
throw new ConfigValidationError([
|
|
67
|
+
`Config function threw while evaluating branch "${branch.name}".`,
|
|
68
|
+
(cause as Error)?.message ?? String(cause),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed = branchConfigSchema.safeParse(raw);
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
throw new ConfigValidationError(formatZodIssues(parsed.error));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cfg = parsed.data;
|
|
78
|
+
const issues: string[] = [];
|
|
79
|
+
let ttlSeconds: number | undefined;
|
|
80
|
+
if (cfg.ttl !== undefined) {
|
|
81
|
+
const parsedTtl = parseDuration(cfg.ttl);
|
|
82
|
+
if ("error" in parsedTtl) {
|
|
83
|
+
issues.push(`ttl: ${parsedTtl.error}`);
|
|
84
|
+
} else {
|
|
85
|
+
ttlSeconds = parsedTtl.seconds;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (issues.length > 0) {
|
|
89
|
+
throw new ConfigValidationError(issues);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const resolved: ResolvedBranchConfig = {
|
|
93
|
+
authEnabled: isServiceEnabled(cfg.auth),
|
|
94
|
+
dataApiEnabled: isServiceEnabled(cfg.dataApi),
|
|
95
|
+
};
|
|
96
|
+
if (cfg.parent !== undefined) resolved.parent = cfg.parent;
|
|
97
|
+
if (ttlSeconds !== undefined) resolved.ttlSeconds = ttlSeconds;
|
|
98
|
+
if (cfg.protected !== undefined) resolved.protected = cfg.protected;
|
|
99
|
+
if (cfg.postgres) {
|
|
100
|
+
resolved.postgres = {
|
|
101
|
+
...(cfg.postgres.computeSettings
|
|
102
|
+
? { computeSettings: { ...cfg.postgres.computeSettings } }
|
|
103
|
+
: {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (cfg.preview) {
|
|
107
|
+
resolved.preview = resolvePreviewConfig(cfg.preview);
|
|
108
|
+
}
|
|
109
|
+
return resolved;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isServiceEnabled(service: { enabled?: boolean } | undefined): boolean {
|
|
113
|
+
return service !== undefined && service.enabled !== false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalize a {@link PreviewConfig} into a {@link ResolvedPreviewConfig}: apply per-function
|
|
118
|
+
* deploy defaults, default each bucket's access level to `private`, and collapse the
|
|
119
|
+
* `aiGateway` toggle to a boolean using the same present-and-not-`false` rule as
|
|
120
|
+
* `auth` / `dataApi`.
|
|
121
|
+
*/
|
|
122
|
+
function resolvePreviewConfig(preview: PreviewConfig): ResolvedPreviewConfig {
|
|
123
|
+
return {
|
|
124
|
+
functions: (preview.functions ?? []).map(resolveFunctionConfig),
|
|
125
|
+
buckets: (preview.buckets ?? []).map((bucket) => ({
|
|
126
|
+
name: bucket.name,
|
|
127
|
+
access: bucket.access ?? "private",
|
|
128
|
+
})),
|
|
129
|
+
aiGatewayEnabled: isServiceEnabled(preview.aiGateway),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveFunctionConfig(fn: FunctionConfig) {
|
|
134
|
+
return {
|
|
135
|
+
slug: fn.slug,
|
|
136
|
+
name: fn.name,
|
|
137
|
+
source: fn.source,
|
|
138
|
+
env: { ...(fn.env ?? {}) },
|
|
139
|
+
runtime: fn.runtime ?? DEFAULT_FUNCTION_RUNTIME,
|
|
140
|
+
memoryMib: fn.memoryMib ?? DEFAULT_FUNCTION_MEMORY_MIB,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Normalize a region identifier to Neon's `<cloud>-<region>` format. When the user writes
|
|
146
|
+
* `us-east-1` we assume `aws-us-east-1`. Pure helper used by both the validator and the
|
|
147
|
+
* NeonApi adapter.
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeRegion(region: string): string {
|
|
150
|
+
if (REGION_PREFIX.test(region)) return region;
|
|
151
|
+
return `aws-${region}`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { diffConfig, type RemoteState } from "./diff.js";
|
|
3
|
+
|
|
4
|
+
describe("diffConfig", () => {
|
|
5
|
+
const remote: RemoteState = {
|
|
6
|
+
projectId: "proj",
|
|
7
|
+
branch: {
|
|
8
|
+
id: "br-main",
|
|
9
|
+
name: "main",
|
|
10
|
+
isDefault: true,
|
|
11
|
+
protected: false,
|
|
12
|
+
},
|
|
13
|
+
endpoint: {
|
|
14
|
+
id: "ep",
|
|
15
|
+
branchId: "br-main",
|
|
16
|
+
type: "read_write" as const,
|
|
17
|
+
autoscalingLimitMinCu: 0.25,
|
|
18
|
+
autoscalingLimitMaxCu: 1,
|
|
19
|
+
suspendTimeout: "5m",
|
|
20
|
+
},
|
|
21
|
+
services: {
|
|
22
|
+
databaseName: "neondb",
|
|
23
|
+
authEnabled: false,
|
|
24
|
+
dataApiEnabled: false,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
test("plans service enables", () => {
|
|
29
|
+
const diff = diffConfig(
|
|
30
|
+
{ authEnabled: true, dataApiEnabled: true },
|
|
31
|
+
remote,
|
|
32
|
+
{ updateExisting: false },
|
|
33
|
+
);
|
|
34
|
+
expect(diff.plan.map((p) => p.kind)).toEqual([
|
|
35
|
+
"enable-auth",
|
|
36
|
+
"enable-data-api",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("reports compute drift unless updateExisting is set", () => {
|
|
41
|
+
const diff = diffConfig(
|
|
42
|
+
{
|
|
43
|
+
authEnabled: false,
|
|
44
|
+
dataApiEnabled: false,
|
|
45
|
+
postgres: { computeSettings: { autoscalingLimitMaxCu: 2 } },
|
|
46
|
+
},
|
|
47
|
+
remote,
|
|
48
|
+
{ updateExisting: false },
|
|
49
|
+
);
|
|
50
|
+
expect(diff.conflicts[0]).toMatchObject({ field: "computeSettings" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("plans mutable branch updates with updateExisting", () => {
|
|
54
|
+
const diff = diffConfig(
|
|
55
|
+
{ authEnabled: false, dataApiEnabled: false, protected: true },
|
|
56
|
+
remote,
|
|
57
|
+
{ updateExisting: true },
|
|
58
|
+
);
|
|
59
|
+
expect(diff.plan[0]).toMatchObject({
|
|
60
|
+
kind: "update-branch-protected",
|
|
61
|
+
branchId: "br-main",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("plans preview create + deploy + bucket + ai-gateway when nothing exists", () => {
|
|
66
|
+
const diff = diffConfig(
|
|
67
|
+
{
|
|
68
|
+
authEnabled: false,
|
|
69
|
+
dataApiEnabled: false,
|
|
70
|
+
preview: {
|
|
71
|
+
functions: [
|
|
72
|
+
{
|
|
73
|
+
slug: "hello-world",
|
|
74
|
+
name: "Hello World",
|
|
75
|
+
source: "./hello.ts",
|
|
76
|
+
env: {},
|
|
77
|
+
runtime: "nodejs24",
|
|
78
|
+
memoryMib: 512,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
buckets: [{ name: "uploads", access: "private" }],
|
|
82
|
+
aiGatewayEnabled: true,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
...remote,
|
|
87
|
+
preview: {
|
|
88
|
+
buckets: [],
|
|
89
|
+
functions: [],
|
|
90
|
+
aiGatewayEnabled: false,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{ updateExisting: false },
|
|
94
|
+
);
|
|
95
|
+
expect(diff.plan.map((p) => p.kind)).toEqual([
|
|
96
|
+
"create-bucket",
|
|
97
|
+
"create-function",
|
|
98
|
+
"deploy-function",
|
|
99
|
+
"enable-ai-gateway",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("skips create-function and skips enable-ai-gateway when already present, but still re-deploys", () => {
|
|
104
|
+
const diff = diffConfig(
|
|
105
|
+
{
|
|
106
|
+
authEnabled: false,
|
|
107
|
+
dataApiEnabled: false,
|
|
108
|
+
preview: {
|
|
109
|
+
functions: [
|
|
110
|
+
{
|
|
111
|
+
slug: "hello-world",
|
|
112
|
+
name: "Hello World",
|
|
113
|
+
source: "./hello.ts",
|
|
114
|
+
env: {},
|
|
115
|
+
runtime: "nodejs24",
|
|
116
|
+
memoryMib: 512,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
buckets: [{ name: "uploads", access: "private" }],
|
|
120
|
+
aiGatewayEnabled: true,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
...remote,
|
|
125
|
+
preview: {
|
|
126
|
+
buckets: [{ name: "uploads", accessLevel: "private" }],
|
|
127
|
+
functions: [
|
|
128
|
+
{
|
|
129
|
+
id: "fn-1",
|
|
130
|
+
slug: "hello-world",
|
|
131
|
+
name: "Hello World",
|
|
132
|
+
invocationUrl: "https://x/functions/hello-world",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
aiGatewayEnabled: true,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{ updateExisting: false },
|
|
139
|
+
);
|
|
140
|
+
expect(diff.plan.map((p) => p.kind)).toEqual(["deploy-function"]);
|
|
141
|
+
});
|
|
142
|
+
});
|