@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,356 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BucketAccessLevel,
|
|
3
|
+
ComputeSettings,
|
|
4
|
+
FunctionMemoryMib,
|
|
5
|
+
FunctionRuntime,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Snapshot of a Neon project field set we care about. Maps onto a subset of the upstream
|
|
10
|
+
* `@neondatabase/api-client` `Project` type. We do **not** widen this to the full upstream
|
|
11
|
+
* shape — keeping the surface narrow makes the in-memory fake practical to maintain.
|
|
12
|
+
*/
|
|
13
|
+
export interface NeonProjectSnapshot {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
regionId: string;
|
|
17
|
+
pgVersion: number;
|
|
18
|
+
orgId?: string;
|
|
19
|
+
defaultEndpointSettings?: ComputeSettings;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NeonBranchSnapshot {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
parentId?: string;
|
|
26
|
+
isDefault: boolean;
|
|
27
|
+
/** Whether the branch is marked protected on Neon. */
|
|
28
|
+
protected: boolean;
|
|
29
|
+
expiresAt?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NeonEndpointSnapshot {
|
|
33
|
+
id: string;
|
|
34
|
+
branchId: string;
|
|
35
|
+
type: "read_only" | "read_write";
|
|
36
|
+
autoscalingLimitMinCu: ComputeSettings["autoscalingLimitMinCu"];
|
|
37
|
+
autoscalingLimitMaxCu: ComputeSettings["autoscalingLimitMaxCu"];
|
|
38
|
+
suspendTimeout: ComputeSettings["suspendTimeout"];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CreateProjectInput {
|
|
42
|
+
name: string;
|
|
43
|
+
regionId: string;
|
|
44
|
+
pgVersion?: number;
|
|
45
|
+
orgId?: string;
|
|
46
|
+
defaultEndpointSettings?: ComputeSettings;
|
|
47
|
+
/**
|
|
48
|
+
* Optional name for the project's auto-created default branch. When omitted, Neon
|
|
49
|
+
* uses its own default (`main`).
|
|
50
|
+
*/
|
|
51
|
+
defaultBranchName?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CreateBranchInput {
|
|
55
|
+
name: string;
|
|
56
|
+
parentId?: string;
|
|
57
|
+
expiresAt?: string;
|
|
58
|
+
/** When `true`, the branch is created with the `protected` flag set on Neon. */
|
|
59
|
+
protected?: boolean;
|
|
60
|
+
computeSettings?: ComputeSettings;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UpdateBranchInput {
|
|
64
|
+
name?: string;
|
|
65
|
+
expiresAt?: string | null;
|
|
66
|
+
/** When set, toggles the branch's `protected` flag on Neon. */
|
|
67
|
+
protected?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A role on a Neon branch (e.g. `neondb_owner`). Passwords are never returned by
|
|
72
|
+
* {@link NeonApi.listBranchRoles}; use {@link NeonApi.getConnectionUri} to fetch a URI
|
|
73
|
+
* with the role's password baked in.
|
|
74
|
+
*/
|
|
75
|
+
export interface NeonRoleSnapshot {
|
|
76
|
+
name: string;
|
|
77
|
+
branchId: string;
|
|
78
|
+
/** Whether the role is system-protected (cannot be deleted). */
|
|
79
|
+
protected: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A database on a Neon branch (e.g. `neondb`).
|
|
84
|
+
*/
|
|
85
|
+
export interface NeonDatabaseSnapshot {
|
|
86
|
+
name: string;
|
|
87
|
+
branchId: string;
|
|
88
|
+
/** The role that owns the database (one role can own multiple databases). */
|
|
89
|
+
ownerName: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bits of a Neon Auth integration. The key fields are optional because the Neon API only
|
|
94
|
+
* includes them on create / rotate responses; `GET /auth` returns the public fields.
|
|
95
|
+
*/
|
|
96
|
+
export interface NeonAuthSnapshot {
|
|
97
|
+
/** The Neon Auth project id (`auth_provider_project_id` on the Neon API). */
|
|
98
|
+
projectId: string;
|
|
99
|
+
/** Public client key (`pub_client_key`), only present on create / rotate responses. */
|
|
100
|
+
publishableClientKey?: string;
|
|
101
|
+
/** Secret server key (`secret_server_key`), only present on create / rotate responses. */
|
|
102
|
+
secretServerKey?: string;
|
|
103
|
+
/** JWKS URL for verifying tokens issued by Neon Auth. */
|
|
104
|
+
jwksUrl: string;
|
|
105
|
+
/** Optional base URL of the Neon Auth deployment. */
|
|
106
|
+
baseUrl?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Public, fetchable bits of a Neon Data API integration on a specific branch.
|
|
111
|
+
*/
|
|
112
|
+
export interface NeonDataApiSnapshot {
|
|
113
|
+
/** REST endpoint URL. */
|
|
114
|
+
url: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A branchable object-storage bucket (Preview). Backed by the Neon Platform
|
|
119
|
+
* branchable-storage service.
|
|
120
|
+
*/
|
|
121
|
+
export interface NeonBucketSnapshot {
|
|
122
|
+
name: string;
|
|
123
|
+
accessLevel: BucketAccessLevel;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Input for creating a bucket on a branch.
|
|
128
|
+
*/
|
|
129
|
+
export interface CreateBucketInput {
|
|
130
|
+
name: string;
|
|
131
|
+
accessLevel?: BucketAccessLevel;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A Neon Function on a branch (Preview). Mirrors the subset of the Functions API we model:
|
|
136
|
+
* the immutable `slug`, the display `name`, and the active deployment id when one exists.
|
|
137
|
+
*/
|
|
138
|
+
export interface NeonFunctionSnapshot {
|
|
139
|
+
/** Opaque, stable function identifier. */
|
|
140
|
+
id: string;
|
|
141
|
+
/** Branch-unique slug (the invocation path segment). Immutable. */
|
|
142
|
+
slug: string;
|
|
143
|
+
/** Free-form display name. */
|
|
144
|
+
name: string;
|
|
145
|
+
/** URL at which the function is invoked. */
|
|
146
|
+
invocationUrl: string;
|
|
147
|
+
/** Id (platform version number) of the active deployment, when any code is deployed. */
|
|
148
|
+
activeDeploymentId?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Input for deploying code to a function. `bundle` is the already-built ZIP archive of the
|
|
153
|
+
* function source — building it (esbuild + zip) is an imperative step performed by the
|
|
154
|
+
* caller, not by the {@link NeonApi} adapter.
|
|
155
|
+
*/
|
|
156
|
+
export interface DeployFunctionInput {
|
|
157
|
+
bundle: Uint8Array;
|
|
158
|
+
runtime: FunctionRuntime;
|
|
159
|
+
memoryMib: FunctionMemoryMib;
|
|
160
|
+
environment: Record<string, string>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A function deployment (Preview).
|
|
165
|
+
*/
|
|
166
|
+
export interface NeonFunctionDeploymentSnapshot {
|
|
167
|
+
/** The deployment id (monotonic per function). */
|
|
168
|
+
id: number;
|
|
169
|
+
status: "pending" | "building" | "completed" | "failed";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Parameters accepted by {@link NeonApi.getConnectionUri}. `branchId` and `endpointId`
|
|
174
|
+
* are optional — when omitted, the API uses the project's default branch and that
|
|
175
|
+
* branch's read-write endpoint, respectively.
|
|
176
|
+
*/
|
|
177
|
+
export interface GetConnectionUriInput {
|
|
178
|
+
branchId?: string;
|
|
179
|
+
endpointId?: string;
|
|
180
|
+
databaseName: string;
|
|
181
|
+
roleName: string;
|
|
182
|
+
/** When `true`, returns the pooled (PgBouncer) URI instead of the direct URI. */
|
|
183
|
+
pooled?: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Narrow façade over the Neon management API. `pullConfig`, `pushConfig`, and `fetchEnv`
|
|
188
|
+
* depend on this interface — *not* on `@neondatabase/api-client` directly — which lets us
|
|
189
|
+
* inject a real in-memory fake during tests without resorting to module mocks.
|
|
190
|
+
*/
|
|
191
|
+
export interface NeonApi {
|
|
192
|
+
listProjects(filter: { orgId?: string }): Promise<NeonProjectSnapshot[]>;
|
|
193
|
+
getProject(projectId: string): Promise<NeonProjectSnapshot>;
|
|
194
|
+
createProject(input: CreateProjectInput): Promise<NeonProjectSnapshot>;
|
|
195
|
+
updateProject(
|
|
196
|
+
projectId: string,
|
|
197
|
+
input: { name?: string; defaultEndpointSettings?: ComputeSettings },
|
|
198
|
+
): Promise<NeonProjectSnapshot>;
|
|
199
|
+
|
|
200
|
+
listBranches(projectId: string): Promise<NeonBranchSnapshot[]>;
|
|
201
|
+
createBranch(
|
|
202
|
+
projectId: string,
|
|
203
|
+
input: CreateBranchInput,
|
|
204
|
+
): Promise<{
|
|
205
|
+
branch: NeonBranchSnapshot;
|
|
206
|
+
endpoints: NeonEndpointSnapshot[];
|
|
207
|
+
}>;
|
|
208
|
+
updateBranch(
|
|
209
|
+
projectId: string,
|
|
210
|
+
branchId: string,
|
|
211
|
+
input: UpdateBranchInput,
|
|
212
|
+
): Promise<NeonBranchSnapshot>;
|
|
213
|
+
|
|
214
|
+
listEndpoints(projectId: string): Promise<NeonEndpointSnapshot[]>;
|
|
215
|
+
updateEndpoint(
|
|
216
|
+
projectId: string,
|
|
217
|
+
endpointId: string,
|
|
218
|
+
settings: ComputeSettings,
|
|
219
|
+
): Promise<NeonEndpointSnapshot>;
|
|
220
|
+
|
|
221
|
+
/** List roles on a branch. Used by {@link fetchEnv} to auto-pick the role when only one exists. */
|
|
222
|
+
listBranchRoles(
|
|
223
|
+
projectId: string,
|
|
224
|
+
branchId: string,
|
|
225
|
+
): Promise<NeonRoleSnapshot[]>;
|
|
226
|
+
|
|
227
|
+
/** List databases on a branch. Used by {@link fetchEnv} to auto-pick the database when only one exists. */
|
|
228
|
+
listBranchDatabases(
|
|
229
|
+
projectId: string,
|
|
230
|
+
branchId: string,
|
|
231
|
+
): Promise<NeonDatabaseSnapshot[]>;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Fetch a Postgres connection URI for the given role + database on a branch.
|
|
235
|
+
* Returns the same string the Neon Console shows under "Connection Details".
|
|
236
|
+
*/
|
|
237
|
+
getConnectionUri(
|
|
238
|
+
projectId: string,
|
|
239
|
+
input: GetConnectionUriInput,
|
|
240
|
+
): Promise<{ uri: string }>;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Fetch the Neon Auth integration attached to a specific branch. Returns `null` when
|
|
244
|
+
* no integration is enabled — used by `fetchEnv` to decide whether the `env.auth`
|
|
245
|
+
* namespace can be populated.
|
|
246
|
+
*/
|
|
247
|
+
getNeonAuth(
|
|
248
|
+
projectId: string,
|
|
249
|
+
branchId: string,
|
|
250
|
+
): Promise<NeonAuthSnapshot | null>;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Enable the Neon Auth integration on a specific branch. Idempotent: if an integration
|
|
254
|
+
* is already enabled, the existing snapshot is returned unchanged. Used by
|
|
255
|
+
* `pushConfig` and `branch` to honour branch policy `auth: {}` / `auth.enabled: true`.
|
|
256
|
+
*/
|
|
257
|
+
enableNeonAuth(
|
|
258
|
+
projectId: string,
|
|
259
|
+
branchId: string,
|
|
260
|
+
input?: { databaseName?: string },
|
|
261
|
+
): Promise<NeonAuthSnapshot>;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Fetch the Neon Data API integration attached to a specific branch + database.
|
|
265
|
+
* Returns `null` when no integration is enabled — used by `fetchEnv` to decide
|
|
266
|
+
* whether the `env.dataApi` namespace can be populated.
|
|
267
|
+
*/
|
|
268
|
+
getNeonDataApi(
|
|
269
|
+
projectId: string,
|
|
270
|
+
branchId: string,
|
|
271
|
+
databaseName: string,
|
|
272
|
+
): Promise<NeonDataApiSnapshot | null>;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Enable the Neon Data API integration on a specific branch + database. Idempotent:
|
|
276
|
+
* if an integration is already enabled, the existing snapshot is returned unchanged.
|
|
277
|
+
* Used by `pushConfig` and `branch` to honour branch policy `dataApi: {}` / `dataApi.enabled: true`.
|
|
278
|
+
*/
|
|
279
|
+
enableProjectBranchDataApi(
|
|
280
|
+
projectId: string,
|
|
281
|
+
branchId: string,
|
|
282
|
+
databaseName: string,
|
|
283
|
+
): Promise<NeonDataApiSnapshot>;
|
|
284
|
+
|
|
285
|
+
// ─── Preview: buckets ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/** List branchable object-storage buckets visible on a branch. */
|
|
288
|
+
listBranchBuckets(
|
|
289
|
+
projectId: string,
|
|
290
|
+
branchId: string,
|
|
291
|
+
): Promise<NeonBucketSnapshot[]>;
|
|
292
|
+
|
|
293
|
+
/** Create a bucket on a branch. Used by `pushConfig` to honour `preview.buckets`. */
|
|
294
|
+
createBranchBucket(
|
|
295
|
+
projectId: string,
|
|
296
|
+
branchId: string,
|
|
297
|
+
input: CreateBucketInput,
|
|
298
|
+
): Promise<NeonBucketSnapshot>;
|
|
299
|
+
|
|
300
|
+
/** Delete a bucket from a branch. */
|
|
301
|
+
deleteBranchBucket(
|
|
302
|
+
projectId: string,
|
|
303
|
+
branchId: string,
|
|
304
|
+
bucketName: string,
|
|
305
|
+
): Promise<void>;
|
|
306
|
+
|
|
307
|
+
// ─── Preview: functions ────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/** List functions on a branch. */
|
|
310
|
+
listBranchFunctions(
|
|
311
|
+
projectId: string,
|
|
312
|
+
branchId: string,
|
|
313
|
+
): Promise<NeonFunctionSnapshot[]>;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create a function on a branch. The function has no deployment until code is deployed
|
|
317
|
+
* to it with {@link deployBranchFunction}.
|
|
318
|
+
*/
|
|
319
|
+
createBranchFunction(
|
|
320
|
+
projectId: string,
|
|
321
|
+
branchId: string,
|
|
322
|
+
input: { slug: string; name: string },
|
|
323
|
+
): Promise<NeonFunctionSnapshot>;
|
|
324
|
+
|
|
325
|
+
/** Delete a function (by slug) from a branch. */
|
|
326
|
+
deleteBranchFunction(
|
|
327
|
+
projectId: string,
|
|
328
|
+
branchId: string,
|
|
329
|
+
slug: string,
|
|
330
|
+
): Promise<void>;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Deploy a built bundle to a function. The newest deployment becomes active. The
|
|
334
|
+
* `bundle` is built (esbuild + zip) by the caller and passed in as bytes.
|
|
335
|
+
*/
|
|
336
|
+
deployBranchFunction(
|
|
337
|
+
projectId: string,
|
|
338
|
+
branchId: string,
|
|
339
|
+
slug: string,
|
|
340
|
+
input: DeployFunctionInput,
|
|
341
|
+
): Promise<NeonFunctionDeploymentSnapshot>;
|
|
342
|
+
|
|
343
|
+
// ─── Preview: AI Gateway ───────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Whether the AI Gateway is enabled on a branch. Toggle-style, like Neon Auth / Data
|
|
347
|
+
* API: used by both `fetchEnv` (to decide visibility) and `pushConfig` (to diff intent).
|
|
348
|
+
*/
|
|
349
|
+
getAiGatewayEnabled(projectId: string, branchId: string): Promise<boolean>;
|
|
350
|
+
|
|
351
|
+
/** Enable the AI Gateway on a branch. Idempotent. */
|
|
352
|
+
enableAiGateway(projectId: string, branchId: string): Promise<void>;
|
|
353
|
+
|
|
354
|
+
/** Disable the AI Gateway on a branch. Idempotent. */
|
|
355
|
+
disableAiGateway(projectId: string, branchId: string): Promise<void>;
|
|
356
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
fillPattern,
|
|
4
|
+
isWildcardPattern,
|
|
5
|
+
matchPattern,
|
|
6
|
+
validatePattern,
|
|
7
|
+
} from "./patterns.js";
|
|
8
|
+
|
|
9
|
+
describe("isWildcardPattern", () => {
|
|
10
|
+
test.each([
|
|
11
|
+
["production", false],
|
|
12
|
+
["preview-*", true],
|
|
13
|
+
["*", true],
|
|
14
|
+
["pr-*-staging", true],
|
|
15
|
+
["a.b", false],
|
|
16
|
+
["a_b-c", false],
|
|
17
|
+
])("isWildcardPattern(%s) === %s", (pattern, expected) => {
|
|
18
|
+
expect(isWildcardPattern(pattern)).toBe(expected);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("matchPattern", () => {
|
|
23
|
+
test.each([
|
|
24
|
+
["production", "production", true],
|
|
25
|
+
["production", "production-2", false],
|
|
26
|
+
["preview-*", "preview-pr-42", true],
|
|
27
|
+
["preview-*", "preview-", true],
|
|
28
|
+
["preview-*", "previewx", false],
|
|
29
|
+
["preview-*", "staging", false],
|
|
30
|
+
["*", "anything", true],
|
|
31
|
+
["*-staging", "feat-staging", true],
|
|
32
|
+
["*-staging", "staging", false],
|
|
33
|
+
["a.b", "a.b", true],
|
|
34
|
+
["a.b", "axb", false], // dot must be literal
|
|
35
|
+
])("matchPattern(%s, %s) === %s", (pattern, name, expected) => {
|
|
36
|
+
expect(matchPattern(pattern, name)).toBe(expected);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("validatePattern", () => {
|
|
41
|
+
test.each(["production", "preview-*", "a_b.c-*", "feat-123", "*"])(
|
|
42
|
+
"accepts %s",
|
|
43
|
+
(pattern) => {
|
|
44
|
+
expect(validatePattern(pattern)).toEqual({ ok: true });
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
test.each([
|
|
49
|
+
["", "branch pattern is empty"],
|
|
50
|
+
[" production", "leading or trailing whitespace"],
|
|
51
|
+
["production ", "leading or trailing whitespace"],
|
|
52
|
+
["sp ace", "unsupported characters"],
|
|
53
|
+
["a".repeat(257), "exceeds 256 characters"],
|
|
54
|
+
["bad{name}", "unsupported characters"],
|
|
55
|
+
])("rejects %s", (pattern, expectedFragment) => {
|
|
56
|
+
const result = validatePattern(pattern);
|
|
57
|
+
expect(result).toHaveProperty("error");
|
|
58
|
+
expect((result as { error: string }).error).toContain(expectedFragment);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("accepts patterns with slashes (Neon branch names allow '/')", () => {
|
|
62
|
+
expect(validatePattern("feature/foo")).toEqual({ ok: true });
|
|
63
|
+
expect(validatePattern("feature/*")).toEqual({ ok: true });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("fillPattern", () => {
|
|
68
|
+
test.each([
|
|
69
|
+
["preview-*", "andre-feature-a1b2c3", "preview-andre-feature-a1b2c3"],
|
|
70
|
+
["*", "branch-x", "branch-x"],
|
|
71
|
+
["feat-*-staging", "x", "feat-x-staging"],
|
|
72
|
+
["feat-*-prod-*", "x", "feat-x-prod-x"],
|
|
73
|
+
])("fillPattern(%s, %s) === %s", (pattern, replacement, expected) => {
|
|
74
|
+
expect(fillPattern(pattern, replacement)).toBe(expected);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("non-wildcard pattern appends with a dash", () => {
|
|
78
|
+
expect(fillPattern("specific", "x")).toBe("specific-x");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch-name pattern helpers. Patterns are GitHub-branch-protection style globs:
|
|
3
|
+
* `*` matches one or more characters within a name segment. `**` is not supported (branch
|
|
4
|
+
* names cannot contain `/` in Neon).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Returns `true` when the pattern contains an unescaped wildcard. */
|
|
8
|
+
export function isWildcardPattern(pattern: string): boolean {
|
|
9
|
+
return pattern.includes("*");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns `true` if `branchName` matches `pattern`. Anchors at both ends.
|
|
14
|
+
*/
|
|
15
|
+
export function matchPattern(pattern: string, branchName: string): boolean {
|
|
16
|
+
const regex = patternToRegex(pattern);
|
|
17
|
+
return regex.test(branchName);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Substitute every `*` in `pattern` with `replacement`. When the pattern has no `*`, the
|
|
22
|
+
* replacement is appended with a `-` separator so the caller still gets a unique name.
|
|
23
|
+
*
|
|
24
|
+
* Pure function. The returned string is **not** validated — callers compose it from
|
|
25
|
+
* sources that already passed {@link validatePattern} (the pattern) and
|
|
26
|
+
* {@link normalizeGitBranch}-style sanitization (the replacement).
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* fillPattern("preview-*", "andre-feature-a1b2c3") // → "preview-andre-feature-a1b2c3"
|
|
30
|
+
* fillPattern("feat-*-staging", "x") // → "feat-x-staging"
|
|
31
|
+
* fillPattern("specific", "x") // → "specific-x"
|
|
32
|
+
*/
|
|
33
|
+
export function fillPattern(pattern: string, replacement: string): string {
|
|
34
|
+
if (!isWildcardPattern(pattern)) return `${pattern}-${replacement}`;
|
|
35
|
+
return pattern.replaceAll("*", replacement);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a branch pattern. Pure — returns either `{ ok: true }` or `{ error: string }`.
|
|
40
|
+
*
|
|
41
|
+
* Rules:
|
|
42
|
+
* - Non-empty after trim, no leading/trailing whitespace.
|
|
43
|
+
* - Length <= 256 (Neon branch name max).
|
|
44
|
+
* - May contain `*`, ASCII letters/digits, and the punctuation Neon allows in branch names:
|
|
45
|
+
* `-`, `_`, `.`, `/`. Whitespace and regex meta-characters other than `*` are rejected.
|
|
46
|
+
*/
|
|
47
|
+
export function validatePattern(
|
|
48
|
+
pattern: string,
|
|
49
|
+
): { ok: true } | { error: string } {
|
|
50
|
+
const trimmed = pattern.trim();
|
|
51
|
+
if (trimmed === "") return { error: "branch pattern is empty" };
|
|
52
|
+
if (trimmed !== pattern) {
|
|
53
|
+
return {
|
|
54
|
+
error: `branch pattern has leading or trailing whitespace: ${JSON.stringify(pattern)}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (trimmed.length > 256) {
|
|
58
|
+
return {
|
|
59
|
+
error: `branch pattern exceeds 256 characters: ${trimmed.length} chars`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (!/^[A-Za-z0-9._\-*/]+$/.test(trimmed)) {
|
|
63
|
+
return {
|
|
64
|
+
error: `branch pattern contains unsupported characters; allowed: letters, digits, '-', '_', '.', '/', '*' (got ${JSON.stringify(pattern)})`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { ok: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function patternToRegex(pattern: string): RegExp {
|
|
71
|
+
let body = "";
|
|
72
|
+
for (const ch of pattern) {
|
|
73
|
+
if (ch === "*") {
|
|
74
|
+
body += ".*";
|
|
75
|
+
} else if (REGEX_META.has(ch)) {
|
|
76
|
+
body += `\\${ch}`;
|
|
77
|
+
} else {
|
|
78
|
+
body += ch;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return new RegExp(`^${body}$`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const REGEX_META = new Set([
|
|
85
|
+
".",
|
|
86
|
+
"+",
|
|
87
|
+
"?",
|
|
88
|
+
"^",
|
|
89
|
+
"$",
|
|
90
|
+
"(",
|
|
91
|
+
")",
|
|
92
|
+
"[",
|
|
93
|
+
"]",
|
|
94
|
+
"{",
|
|
95
|
+
"}",
|
|
96
|
+
"|",
|
|
97
|
+
"\\",
|
|
98
|
+
]);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
branchConfigSchema,
|
|
4
|
+
computeSettingsSchema,
|
|
5
|
+
formatZodIssues,
|
|
6
|
+
} from "./schema.js";
|
|
7
|
+
|
|
8
|
+
describe("computeSettingsSchema", () => {
|
|
9
|
+
test("accepts valid compute settings", () => {
|
|
10
|
+
expect(
|
|
11
|
+
computeSettingsSchema.parse({
|
|
12
|
+
autoscalingLimitMinCu: 0.25,
|
|
13
|
+
autoscalingLimitMaxCu: 2,
|
|
14
|
+
}),
|
|
15
|
+
).toEqual({
|
|
16
|
+
autoscalingLimitMinCu: 0.25,
|
|
17
|
+
autoscalingLimitMaxCu: 2,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects min greater than max", () => {
|
|
22
|
+
const result = computeSettingsSchema.safeParse({
|
|
23
|
+
autoscalingLimitMinCu: 4,
|
|
24
|
+
autoscalingLimitMaxCu: 1,
|
|
25
|
+
});
|
|
26
|
+
expect(result.success).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("branchConfigSchema", () => {
|
|
31
|
+
test("accepts branch-level lifecycle and product namespaces", () => {
|
|
32
|
+
expect(
|
|
33
|
+
branchConfigSchema.parse({
|
|
34
|
+
parent: "main",
|
|
35
|
+
ttl: "7d",
|
|
36
|
+
postgres: { computeSettings: { autoscalingLimitMaxCu: 2 } },
|
|
37
|
+
auth: { enabled: true },
|
|
38
|
+
}),
|
|
39
|
+
).toMatchObject({ parent: "main", auth: { enabled: true } });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects wildcard parent", () => {
|
|
43
|
+
const result = branchConfigSchema.safeParse({ parent: "preview-*" });
|
|
44
|
+
expect(result.success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("accepts a preview block with functions, buckets, and aiGateway", () => {
|
|
48
|
+
const result = branchConfigSchema.safeParse({
|
|
49
|
+
preview: {
|
|
50
|
+
functions: [
|
|
51
|
+
{
|
|
52
|
+
slug: "hello-world",
|
|
53
|
+
name: "Hello World",
|
|
54
|
+
source: "./hello.ts",
|
|
55
|
+
env: { KEY: "value" },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
buckets: [{ name: "uploads", access: "public_read" }],
|
|
59
|
+
aiGateway: { enabled: true },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("rejects an unknown key inside preview", () => {
|
|
66
|
+
const result = branchConfigSchema.safeParse({
|
|
67
|
+
preview: { functions: [], typo: true },
|
|
68
|
+
});
|
|
69
|
+
expect(result.success).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("formatZodIssues", () => {
|
|
74
|
+
test("renders paths", () => {
|
|
75
|
+
const result = branchConfigSchema.safeParse({
|
|
76
|
+
postgres: {
|
|
77
|
+
computeSettings: {
|
|
78
|
+
autoscalingLimitMinCu: 8,
|
|
79
|
+
autoscalingLimitMaxCu: 1,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
if (result.success) throw new Error("expected failure");
|
|
84
|
+
expect(formatZodIssues(result.error).join("\n")).toContain(
|
|
85
|
+
"postgres.computeSettings.autoscalingLimitMinCu",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|