@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { ErrorCode, PlatformError } from "./errors.js";
|
|
3
|
+
import { wrapNeonError } from "./wrap-neon-error.js";
|
|
4
|
+
|
|
5
|
+
function axiosLike(
|
|
6
|
+
status: number,
|
|
7
|
+
body?: { message?: string; code?: string; request_id?: string },
|
|
8
|
+
): { response: { status: number; data?: object } } {
|
|
9
|
+
const err: { response: { status: number; data?: object } } = {
|
|
10
|
+
response: { status },
|
|
11
|
+
};
|
|
12
|
+
if (body) err.response.data = body;
|
|
13
|
+
return err;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CTX = { op: "getProject(proj-x)", projectId: "proj-x" } as const;
|
|
17
|
+
|
|
18
|
+
describe("wrapNeonError — HTTP status mapping", () => {
|
|
19
|
+
test("401 → Unauthorized + key + neonctl-auth advice + request id", () => {
|
|
20
|
+
const err = wrapNeonError(
|
|
21
|
+
axiosLike(401, { message: "Invalid API key", request_id: "req-1" }),
|
|
22
|
+
CTX,
|
|
23
|
+
);
|
|
24
|
+
expect(err).toBeInstanceOf(PlatformError);
|
|
25
|
+
const p = err as PlatformError;
|
|
26
|
+
expect(p.code).toBe(ErrorCode.Unauthorized);
|
|
27
|
+
// Message now suggests both fix paths since we accept both API keys and the
|
|
28
|
+
// OAuth token written by `neonctl auth`.
|
|
29
|
+
expect(p.message).toContain(
|
|
30
|
+
"Bearer token sent to the Neon API was rejected",
|
|
31
|
+
);
|
|
32
|
+
expect(p.message).toContain(
|
|
33
|
+
"https://console.neon.tech/app/settings/api-keys",
|
|
34
|
+
);
|
|
35
|
+
expect(p.message).toContain("neonctl auth");
|
|
36
|
+
expect(p.message).toContain("req-1");
|
|
37
|
+
expect(p.details.status).toBe(401);
|
|
38
|
+
expect(p.details.requestId).toBe("req-1");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("403 → Forbidden + project-scoped key explanation", () => {
|
|
42
|
+
const err = wrapNeonError(
|
|
43
|
+
axiosLike(403, {
|
|
44
|
+
message: "not allowed for organization API keys",
|
|
45
|
+
}),
|
|
46
|
+
CTX,
|
|
47
|
+
);
|
|
48
|
+
const p = err as PlatformError;
|
|
49
|
+
expect(p.code).toBe(ErrorCode.Forbidden);
|
|
50
|
+
expect(p.message).toContain(
|
|
51
|
+
"Project-scoped keys can only operate on their own project",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("404 → NotFound + verifies project id when present", () => {
|
|
56
|
+
const err = wrapNeonError(
|
|
57
|
+
axiosLike(404, { message: "project not found" }),
|
|
58
|
+
CTX,
|
|
59
|
+
);
|
|
60
|
+
const p = err as PlatformError;
|
|
61
|
+
expect(p.code).toBe(ErrorCode.NotFound);
|
|
62
|
+
expect(p.message).toContain("project 'proj-x'");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("409 → Conflict + name-collision hint", () => {
|
|
66
|
+
const err = wrapNeonError(
|
|
67
|
+
axiosLike(409, { message: "branch already exists" }),
|
|
68
|
+
{
|
|
69
|
+
op: "createBranch(proj-x/preview)",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
const p = err as PlatformError;
|
|
73
|
+
expect(p.code).toBe(ErrorCode.Conflict);
|
|
74
|
+
expect(p.message).toContain("conflicting resource");
|
|
75
|
+
expect(p.message).toContain("Pull first");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("423 → Locked + retry-knob hint", () => {
|
|
79
|
+
const err = wrapNeonError(
|
|
80
|
+
axiosLike(423, { message: "operation in progress" }),
|
|
81
|
+
CTX,
|
|
82
|
+
);
|
|
83
|
+
const p = err as PlatformError;
|
|
84
|
+
expect(p.code).toBe(ErrorCode.Locked);
|
|
85
|
+
expect(p.message).toContain("retryOnLocked");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("429 → RateLimited + support link", () => {
|
|
89
|
+
const err = wrapNeonError(axiosLike(429), CTX);
|
|
90
|
+
const p = err as PlatformError;
|
|
91
|
+
expect(p.code).toBe(ErrorCode.RateLimited);
|
|
92
|
+
expect(p.message).toContain("rate-limited");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("5xx → ServerError + status mention", () => {
|
|
96
|
+
const err = wrapNeonError(
|
|
97
|
+
axiosLike(503, { message: "service unavailable" }),
|
|
98
|
+
CTX,
|
|
99
|
+
);
|
|
100
|
+
const p = err as PlatformError;
|
|
101
|
+
expect(p.code).toBe(ErrorCode.ServerError);
|
|
102
|
+
expect(p.message).toContain("HTTP 503");
|
|
103
|
+
expect(p.message).toContain("https://neonstatus.com");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("unknown 4xx falls back to ServerError with raw status", () => {
|
|
107
|
+
const err = wrapNeonError(
|
|
108
|
+
axiosLike(418, { message: "I'm a teapot" }),
|
|
109
|
+
CTX,
|
|
110
|
+
);
|
|
111
|
+
const p = err as PlatformError;
|
|
112
|
+
expect(p.code).toBe(ErrorCode.ServerError);
|
|
113
|
+
expect(p.message).toContain("HTTP 418");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("wrapNeonError — network errors", () => {
|
|
118
|
+
test.each([
|
|
119
|
+
["ECONNREFUSED", "Connection refused"],
|
|
120
|
+
["ETIMEDOUT", "Operation timed out"],
|
|
121
|
+
["ENOTFOUND", "DNS lookup failed"],
|
|
122
|
+
["ECONNABORTED", "Request aborted"],
|
|
123
|
+
])("%s → NetworkError with explanation", (code, message) => {
|
|
124
|
+
const err = wrapNeonError(
|
|
125
|
+
Object.assign(new Error(message), { code }),
|
|
126
|
+
CTX,
|
|
127
|
+
);
|
|
128
|
+
const p = err as PlatformError;
|
|
129
|
+
expect(p.code).toBe(ErrorCode.NetworkError);
|
|
130
|
+
expect(p.message).toContain("Could not reach the Neon API");
|
|
131
|
+
expect(p.message).toContain(message);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("wrapNeonError — passthrough", () => {
|
|
136
|
+
test("returns existing PlatformError unchanged (no double-wrapping)", () => {
|
|
137
|
+
const original = new PlatformError(ErrorCode.InternalError, "test");
|
|
138
|
+
expect(wrapNeonError(original, CTX)).toBe(original);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("returns non-axios, non-network errors unchanged", () => {
|
|
142
|
+
const original = new Error("plain error");
|
|
143
|
+
expect(wrapNeonError(original, CTX)).toBe(original);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { ErrorCode, PlatformError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context the wrapper attaches to every PlatformError so consumers can debug without
|
|
5
|
+
* digging into the raw axios stack.
|
|
6
|
+
*/
|
|
7
|
+
export interface NeonErrorContext {
|
|
8
|
+
/** Short label of the operation that failed, e.g. `getProject(proj-foo)` or `createBranch`. */
|
|
9
|
+
op: string;
|
|
10
|
+
/** Optional project id when the operation is project-scoped. */
|
|
11
|
+
projectId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Turn a raw error from `@neondatabase/api-client` (axios under the hood) into a typed
|
|
16
|
+
* {@link PlatformError} whose message includes:
|
|
17
|
+
*
|
|
18
|
+
* 1. What operation was attempted (`op`, e.g. `getProject(proj-foo)`).
|
|
19
|
+
* 2. Why it failed in human terms (e.g. "API key is unauthorized").
|
|
20
|
+
* 3. The exact Neon API error message + request id (when present) for support tickets.
|
|
21
|
+
* 4. A concrete next action ("Generate a new key at …", "Pass `projectId`", …).
|
|
22
|
+
*
|
|
23
|
+
* Non-axios errors are passed through unchanged (a regular `Error` already has a useful
|
|
24
|
+
* stack trace; wrapping it would lose information without adding value).
|
|
25
|
+
*/
|
|
26
|
+
export function wrapNeonError(
|
|
27
|
+
err: unknown,
|
|
28
|
+
context: NeonErrorContext,
|
|
29
|
+
): PlatformError | unknown {
|
|
30
|
+
if (err instanceof PlatformError) return err;
|
|
31
|
+
const httpInfo = extractHttpInfo(err);
|
|
32
|
+
if (!httpInfo) {
|
|
33
|
+
const networkInfo = extractNetworkInfo(err);
|
|
34
|
+
if (networkInfo) {
|
|
35
|
+
return new PlatformError(
|
|
36
|
+
ErrorCode.NetworkError,
|
|
37
|
+
`Could not reach the Neon API while running ${context.op}: ${networkInfo.message}. Check your network connection and that https://console.neon.tech is reachable.`,
|
|
38
|
+
{ cause: err, details: { op: context.op, ...networkInfo } },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const apiSummary = httpInfo.neonMessage
|
|
45
|
+
? `Neon API said: "${httpInfo.neonMessage}"`
|
|
46
|
+
: `HTTP ${httpInfo.status}`;
|
|
47
|
+
const requestIdSuffix = httpInfo.requestId
|
|
48
|
+
? ` (request id ${httpInfo.requestId})`
|
|
49
|
+
: "";
|
|
50
|
+
const apiSummaryWithRequestId = `${apiSummary}${requestIdSuffix}.`;
|
|
51
|
+
|
|
52
|
+
switch (httpInfo.status) {
|
|
53
|
+
case 401:
|
|
54
|
+
return new PlatformError(
|
|
55
|
+
ErrorCode.Unauthorized,
|
|
56
|
+
[
|
|
57
|
+
`${context.op} failed: the Bearer token sent to the Neon API was rejected.`,
|
|
58
|
+
apiSummaryWithRequestId,
|
|
59
|
+
"Either (a) generate or rotate an API key at https://console.neon.tech/app/settings/api-keys and set NEON_API_KEY / pass --api-key, or (b) re-run `npx neonctl auth` to refresh the OAuth token in `~/.config/neonctl/credentials.json` (OAuth tokens expire).",
|
|
60
|
+
].join(" "),
|
|
61
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
62
|
+
);
|
|
63
|
+
case 403:
|
|
64
|
+
return new PlatformError(
|
|
65
|
+
ErrorCode.Forbidden,
|
|
66
|
+
[
|
|
67
|
+
`${context.op} failed: this API key is not allowed to perform that operation.`,
|
|
68
|
+
apiSummaryWithRequestId,
|
|
69
|
+
"Project-scoped keys can only operate on their own project; switch to an organisation/user-scoped key or pass `projectId` for an operation that doesn't need listing.",
|
|
70
|
+
].join(" "),
|
|
71
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
72
|
+
);
|
|
73
|
+
case 404:
|
|
74
|
+
return new PlatformError(
|
|
75
|
+
ErrorCode.NotFound,
|
|
76
|
+
[
|
|
77
|
+
`${context.op} failed: resource not found on Neon.`,
|
|
78
|
+
apiSummaryWithRequestId,
|
|
79
|
+
context.projectId
|
|
80
|
+
? `Verify that project '${context.projectId}' exists in this account and that the API key has access to it.`
|
|
81
|
+
: "Verify that the resource id is correct and that the API key has access to it.",
|
|
82
|
+
].join(" "),
|
|
83
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
84
|
+
);
|
|
85
|
+
case 409:
|
|
86
|
+
return new PlatformError(
|
|
87
|
+
ErrorCode.Conflict,
|
|
88
|
+
[
|
|
89
|
+
`${context.op} failed: a conflicting resource already exists on Neon.`,
|
|
90
|
+
apiSummaryWithRequestId,
|
|
91
|
+
"This is often a name collision (e.g. a branch with the same name already exists). Pull first to compare against the remote, or rename in your `neon.ts`.",
|
|
92
|
+
].join(" "),
|
|
93
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
94
|
+
);
|
|
95
|
+
case 423:
|
|
96
|
+
return new PlatformError(
|
|
97
|
+
ErrorCode.Locked,
|
|
98
|
+
[
|
|
99
|
+
`${context.op} failed: the resource is still being modified by a previous operation, and our built-in retries did not drain it in time.`,
|
|
100
|
+
apiSummaryWithRequestId,
|
|
101
|
+
"Wait a few seconds and re-run, or raise `retryOnLocked.maxAttempts` when constructing the real Neon adapter.",
|
|
102
|
+
].join(" "),
|
|
103
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
104
|
+
);
|
|
105
|
+
case 429:
|
|
106
|
+
return new PlatformError(
|
|
107
|
+
ErrorCode.RateLimited,
|
|
108
|
+
[
|
|
109
|
+
`${context.op} failed: rate-limited by the Neon API.`,
|
|
110
|
+
apiSummaryWithRequestId,
|
|
111
|
+
"Back off and retry; if this happens repeatedly, contact Neon support with the request id above.",
|
|
112
|
+
].join(" "),
|
|
113
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (httpInfo.status >= 500) {
|
|
118
|
+
return new PlatformError(
|
|
119
|
+
ErrorCode.ServerError,
|
|
120
|
+
[
|
|
121
|
+
`${context.op} failed: the Neon API returned a server error (HTTP ${httpInfo.status}).`,
|
|
122
|
+
apiSummaryWithRequestId,
|
|
123
|
+
"This is most likely transient. Retry shortly; if it persists, file an issue with the request id above and check https://neonstatus.com.",
|
|
124
|
+
].join(" "),
|
|
125
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4xx we don't have a dedicated code for. Surface what we know.
|
|
130
|
+
return new PlatformError(
|
|
131
|
+
ErrorCode.ServerError,
|
|
132
|
+
`${context.op} failed: HTTP ${httpInfo.status}. ${apiSummary}${requestIdSuffix}.`,
|
|
133
|
+
{ cause: err, details: httpDetails(context, httpInfo) },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface HttpInfo {
|
|
138
|
+
status: number;
|
|
139
|
+
neonMessage?: string;
|
|
140
|
+
neonCode?: string;
|
|
141
|
+
requestId?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function extractHttpInfo(err: unknown): HttpInfo | null {
|
|
145
|
+
if (err === null || typeof err !== "object") return null;
|
|
146
|
+
const response = (err as { response?: unknown }).response;
|
|
147
|
+
if (response === null || typeof response !== "object") return null;
|
|
148
|
+
const status = (response as { status?: unknown }).status;
|
|
149
|
+
if (typeof status !== "number") return null;
|
|
150
|
+
const data = (response as { data?: unknown }).data;
|
|
151
|
+
const out: HttpInfo = { status };
|
|
152
|
+
if (data !== null && typeof data === "object") {
|
|
153
|
+
const dataObj = data as Record<string, unknown>;
|
|
154
|
+
if (typeof dataObj.message === "string" && dataObj.message !== "")
|
|
155
|
+
out.neonMessage = dataObj.message;
|
|
156
|
+
if (typeof dataObj.code === "string" && dataObj.code !== "")
|
|
157
|
+
out.neonCode = dataObj.code;
|
|
158
|
+
if (typeof dataObj.request_id === "string" && dataObj.request_id !== "")
|
|
159
|
+
out.requestId = dataObj.request_id;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface NetworkInfo {
|
|
165
|
+
message: string;
|
|
166
|
+
code?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractNetworkInfo(err: unknown): NetworkInfo | null {
|
|
170
|
+
if (err === null || typeof err !== "object") return null;
|
|
171
|
+
const code = (err as { code?: unknown }).code;
|
|
172
|
+
const message = (err as { message?: unknown }).message;
|
|
173
|
+
if (
|
|
174
|
+
typeof code === "string" &&
|
|
175
|
+
/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/.test(
|
|
176
|
+
code,
|
|
177
|
+
)
|
|
178
|
+
) {
|
|
179
|
+
return { message: typeof message === "string" ? message : code, code };
|
|
180
|
+
}
|
|
181
|
+
// axios timeout reports `code: "ECONNABORTED"`.
|
|
182
|
+
if (code === "ECONNABORTED") {
|
|
183
|
+
return {
|
|
184
|
+
message: typeof message === "string" ? message : "timeout",
|
|
185
|
+
code,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function httpDetails(
|
|
192
|
+
context: NeonErrorContext,
|
|
193
|
+
info: HttpInfo,
|
|
194
|
+
): Record<string, unknown> {
|
|
195
|
+
const out: Record<string, unknown> = {
|
|
196
|
+
op: context.op,
|
|
197
|
+
status: info.status,
|
|
198
|
+
};
|
|
199
|
+
if (context.projectId) out.projectId = context.projectId;
|
|
200
|
+
if (info.neonMessage) out.neonMessage = info.neonMessage;
|
|
201
|
+
if (info.neonCode) out.neonCode = info.neonCode;
|
|
202
|
+
if (info.requestId) out.requestId = info.requestId;
|
|
203
|
+
return out;
|
|
204
|
+
}
|
package/src/v1.test.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createRealNeonApi,
|
|
4
|
+
defineConfig,
|
|
5
|
+
diffConfig,
|
|
6
|
+
loadConfigFromFile,
|
|
7
|
+
resolveConfig,
|
|
8
|
+
} from "./v1.js";
|
|
9
|
+
|
|
10
|
+
describe("v1 surface", () => {
|
|
11
|
+
test("exports the authoring helpers, pure diff engine, adapter, and loader", () => {
|
|
12
|
+
const config = defineConfig((branch) => ({
|
|
13
|
+
parent: branch.name === "main" ? undefined : "main",
|
|
14
|
+
}));
|
|
15
|
+
expect(config({ name: "dev", exists: false })).toEqual({
|
|
16
|
+
parent: "main",
|
|
17
|
+
});
|
|
18
|
+
// Authoring + pure core stays in @neondatabase/config.
|
|
19
|
+
expect(resolveConfig).toBeTypeOf("function");
|
|
20
|
+
expect(diffConfig).toBeTypeOf("function");
|
|
21
|
+
expect(createRealNeonApi).toBeTypeOf("function");
|
|
22
|
+
expect(loadConfigFromFile).toBeTypeOf("function");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("does not export the imperative operations (they live in @neondatabase/config-runtime)", async () => {
|
|
26
|
+
const surface = await import("./v1.js");
|
|
27
|
+
expect("apply" in surface).toBe(false);
|
|
28
|
+
expect("plan" in surface).toBe(false);
|
|
29
|
+
expect("inspect" in surface).toBe(false);
|
|
30
|
+
expect("pushConfig" in surface).toBe(false);
|
|
31
|
+
expect("pullConfig" in surface).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/v1.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@neondatabase/config/v1` — the v1 public API for Config-as-Code on the Neon Platform.
|
|
3
|
+
*
|
|
4
|
+
* Usage in `neon.ts`:
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { defineConfig } from "@neondatabase/config/v1";
|
|
7
|
+
*
|
|
8
|
+
* export default defineConfig((branch) => {
|
|
9
|
+
* if (branch.name === "main") return { protected: true, auth: {} };
|
|
10
|
+
* return { parent: "main", ttl: "7d" };
|
|
11
|
+
* });
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* This is the **authoring** surface — `defineConfig`, types, schemas, the pure diff engine,
|
|
15
|
+
* and the Neon API adapter. It is intentionally free of heavy/native dependencies so that
|
|
16
|
+
* importing it from `neon.ts` stays cheap and bundler-safe.
|
|
17
|
+
*
|
|
18
|
+
* The imperative operations (`inspect` / `plan` / `apply`, `pushConfig` / `pullConfig`) and
|
|
19
|
+
* function bundling/deploy live in **`@neondatabase/config-runtime`**, which depends on this
|
|
20
|
+
* package and pulls in `esbuild`. Import that from your CLI / CI, not from `neon.ts`:
|
|
21
|
+
* ```ts
|
|
22
|
+
* import config from "../neon";
|
|
23
|
+
* import { inspect, plan, apply } from "@neondatabase/config-runtime/v1";
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Surface guidelines:
|
|
27
|
+
* - Top-level: `defineConfig` / `resolveConfig`, the pure `diffConfig` engine, the
|
|
28
|
+
* `createRealNeonApi` adapter + `NeonApi` types, the config loader, the `PlatformError`
|
|
29
|
+
* base class + `ErrorCode` enum, and the config types used in `neon.ts`.
|
|
30
|
+
* - `errors` namespace: specific `PlatformError` subclasses (`ConfigLoadError`,
|
|
31
|
+
* `PushConflictError`, …).
|
|
32
|
+
* - `schemas` namespace: the zod schemas underlying `defineConfig`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
ConfigLoadError,
|
|
37
|
+
ConfigValidationError,
|
|
38
|
+
ErrorCode,
|
|
39
|
+
MissingContextError,
|
|
40
|
+
PlatformError,
|
|
41
|
+
PushAbortedError,
|
|
42
|
+
PushConflictError,
|
|
43
|
+
} from "./lib/errors.js";
|
|
44
|
+
import {
|
|
45
|
+
branchConfigSchema,
|
|
46
|
+
bucketConfigSchema,
|
|
47
|
+
computeSettingsSchema,
|
|
48
|
+
configSchema,
|
|
49
|
+
functionConfigSchema,
|
|
50
|
+
postgresConfigSchema,
|
|
51
|
+
previewConfigSchema,
|
|
52
|
+
serviceToggleSchema,
|
|
53
|
+
} from "./lib/schema.js";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Specific `PlatformError` subclasses, grouped for `instanceof` / structured access.
|
|
57
|
+
* Also available as top-level exports.
|
|
58
|
+
*/
|
|
59
|
+
export const errors = {
|
|
60
|
+
ConfigLoadError,
|
|
61
|
+
ConfigValidationError,
|
|
62
|
+
ErrorCode,
|
|
63
|
+
MissingContextError,
|
|
64
|
+
PlatformError,
|
|
65
|
+
PushAbortedError,
|
|
66
|
+
PushConflictError,
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
/** The zod schemas underlying `defineConfig`, grouped under product-friendly names. */
|
|
70
|
+
export const schemas = {
|
|
71
|
+
branch: branchConfigSchema,
|
|
72
|
+
bucket: bucketConfigSchema,
|
|
73
|
+
computeSettings: computeSettingsSchema,
|
|
74
|
+
config: configSchema,
|
|
75
|
+
function: functionConfigSchema,
|
|
76
|
+
postgres: postgresConfigSchema,
|
|
77
|
+
preview: previewConfigSchema,
|
|
78
|
+
service: serviceToggleSchema,
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
// ─── Lower-level adapters ──────────────────────────────────────────────────────
|
|
82
|
+
export { createNeonApiFromOptions, resolveApiKey } from "./lib/auth.js";
|
|
83
|
+
export { defineConfig, resolveConfig } from "./lib/define-config.js";
|
|
84
|
+
// ─── Diff engine (pure; consumed by @neondatabase/config-runtime) ─────────────
|
|
85
|
+
export type {
|
|
86
|
+
DiffOptions,
|
|
87
|
+
DiffResult,
|
|
88
|
+
PlanStep,
|
|
89
|
+
RemotePreviewState,
|
|
90
|
+
RemoteServiceState,
|
|
91
|
+
RemoteState,
|
|
92
|
+
} from "./lib/diff.js";
|
|
93
|
+
export { diffConfig } from "./lib/diff.js";
|
|
94
|
+
// ─── Errors ────────────────────────────────────────────────────────────────────
|
|
95
|
+
export {
|
|
96
|
+
ConfigLoadError,
|
|
97
|
+
ConfigValidationError,
|
|
98
|
+
ErrorCode,
|
|
99
|
+
MissingContextError,
|
|
100
|
+
PlatformError,
|
|
101
|
+
PushAbortedError,
|
|
102
|
+
PushConflictError,
|
|
103
|
+
} from "./lib/errors.js";
|
|
104
|
+
export type { LoadConfigOptions } from "./lib/loader.js";
|
|
105
|
+
export { loadConfigFromFile } from "./lib/loader.js";
|
|
106
|
+
// ─── NeonApi types (needed by callers implementing their own adapters) ────────
|
|
107
|
+
export type {
|
|
108
|
+
CreateBranchInput,
|
|
109
|
+
CreateBucketInput,
|
|
110
|
+
CreateProjectInput,
|
|
111
|
+
DeployFunctionInput,
|
|
112
|
+
GetConnectionUriInput,
|
|
113
|
+
NeonApi,
|
|
114
|
+
NeonAuthSnapshot,
|
|
115
|
+
NeonBranchSnapshot,
|
|
116
|
+
NeonBucketSnapshot,
|
|
117
|
+
NeonDataApiSnapshot,
|
|
118
|
+
NeonDatabaseSnapshot,
|
|
119
|
+
NeonEndpointSnapshot,
|
|
120
|
+
NeonFunctionDeploymentSnapshot,
|
|
121
|
+
NeonFunctionSnapshot,
|
|
122
|
+
NeonProjectSnapshot,
|
|
123
|
+
NeonRoleSnapshot,
|
|
124
|
+
UpdateBranchInput,
|
|
125
|
+
} from "./lib/neon-api.js";
|
|
126
|
+
export { createRealNeonApi } from "./lib/neon-api-real.js";
|
|
127
|
+
// ─── Config types (used in neon.ts and in operation return values) ────────────
|
|
128
|
+
export type {
|
|
129
|
+
AppliedChange,
|
|
130
|
+
BranchConfig,
|
|
131
|
+
BranchTarget,
|
|
132
|
+
BucketAccessLevel,
|
|
133
|
+
BucketConfig,
|
|
134
|
+
ComputeSettings,
|
|
135
|
+
Config,
|
|
136
|
+
ConflictReport,
|
|
137
|
+
FunctionConfig,
|
|
138
|
+
FunctionMemoryMib,
|
|
139
|
+
FunctionRuntime,
|
|
140
|
+
PostgresConfig,
|
|
141
|
+
PreviewConfig,
|
|
142
|
+
PushResult,
|
|
143
|
+
ResolvedBranchConfig,
|
|
144
|
+
ResolvedBucketConfig,
|
|
145
|
+
ResolvedFunctionConfig,
|
|
146
|
+
ResolvedPreviewConfig,
|
|
147
|
+
ServiceToggle,
|
|
148
|
+
} from "./lib/types.js";
|
package/tsconfig.json
ADDED
package/tsdown.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from "tsdown";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
name: "@neondatabase/config",
|
|
5
|
+
bundle: false,
|
|
6
|
+
clean: true,
|
|
7
|
+
dts: true,
|
|
8
|
+
entry: [
|
|
9
|
+
"src/index.ts",
|
|
10
|
+
"src/v1.ts",
|
|
11
|
+
"src/lib/**/*.ts",
|
|
12
|
+
"!src/**/*.test.*",
|
|
13
|
+
"!src/lib/fake-neon-api.ts",
|
|
14
|
+
"!src/lib/test-utils.ts",
|
|
15
|
+
],
|
|
16
|
+
format: "esm",
|
|
17
|
+
outDir: "dist",
|
|
18
|
+
treeshake: true,
|
|
19
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
clearMocks: true,
|
|
6
|
+
mockReset: true,
|
|
7
|
+
unstubEnvs: true,
|
|
8
|
+
coverage: {
|
|
9
|
+
all: true,
|
|
10
|
+
include: ["src"],
|
|
11
|
+
exclude: ["src/**/*.test.ts", "src/lib/fake-neon-api.ts"],
|
|
12
|
+
reporter: ["html", "lcov"],
|
|
13
|
+
},
|
|
14
|
+
// Skip e2e tests (which talk to the real Neon API) in the standard suite — they
|
|
15
|
+
// run via `pnpm test:e2e` against `vitest.e2e.config.ts`.
|
|
16
|
+
exclude: ["lib", "node_modules", "dist", "**/*.e2e.test.ts", "e2e/**"],
|
|
17
|
+
setupFiles: ["console-fail-test/setup"],
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vitest config for end-to-end tests that hit the **real** Neon API.
|
|
5
|
+
*
|
|
6
|
+
* - Matches `**\/*.e2e.test.ts` only — the standard `test:ci` config explicitly excludes
|
|
7
|
+
* this pattern so the two suites never collide.
|
|
8
|
+
* - Uses long per-test timeouts because real project creation / deletion can take 10+s.
|
|
9
|
+
* - Forces single-threaded execution to avoid quota / rate-limit interference between
|
|
10
|
+
* tests (`pool: "forks"` with `singleFork: true`).
|
|
11
|
+
* - Loads `.env` via Vitest's built-in dotenv support so `NEON_API_KEY` (and optionally
|
|
12
|
+
* `NEON_ORG_ID`) become `process.env` entries inside the tests.
|
|
13
|
+
*/
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
test: {
|
|
16
|
+
include: ["src/**/*.e2e.test.ts", "e2e/**/*.test.ts"],
|
|
17
|
+
exclude: ["node_modules", "dist"],
|
|
18
|
+
setupFiles: ["./e2e/load-env.ts", "./e2e/setup.ts"],
|
|
19
|
+
testTimeout: 120_000,
|
|
20
|
+
hookTimeout: 120_000,
|
|
21
|
+
pool: "forks",
|
|
22
|
+
poolOptions: {
|
|
23
|
+
forks: {
|
|
24
|
+
singleFork: true,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
reporters: ["verbose"],
|
|
28
|
+
},
|
|
29
|
+
});
|