@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/diff.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NeonBranchSnapshot,
|
|
3
|
+
NeonBucketSnapshot,
|
|
4
|
+
NeonEndpointSnapshot,
|
|
5
|
+
NeonFunctionSnapshot,
|
|
6
|
+
} from "./neon-api.js";
|
|
7
|
+
import type {
|
|
8
|
+
BucketAccessLevel,
|
|
9
|
+
ComputeSettings,
|
|
10
|
+
ConflictReport,
|
|
11
|
+
ResolvedBranchConfig,
|
|
12
|
+
ResolvedFunctionConfig,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A planned action to perform a single mutation against the Neon API. The diff engine
|
|
17
|
+
* produces a list of these for `pushConfig` to execute (or report).
|
|
18
|
+
*/
|
|
19
|
+
export type PlanStep =
|
|
20
|
+
| {
|
|
21
|
+
kind: "update-branch-ttl";
|
|
22
|
+
projectId: string;
|
|
23
|
+
branchId: string;
|
|
24
|
+
branchName: string;
|
|
25
|
+
expiresAt: string | null;
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
kind: "update-branch-protected";
|
|
29
|
+
projectId: string;
|
|
30
|
+
branchId: string;
|
|
31
|
+
branchName: string;
|
|
32
|
+
protected: boolean;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
kind: "update-endpoint";
|
|
36
|
+
projectId: string;
|
|
37
|
+
branchName: string;
|
|
38
|
+
endpointId: string;
|
|
39
|
+
settings: ComputeSettings;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
kind: "enable-auth";
|
|
43
|
+
projectId: string;
|
|
44
|
+
branchId: string;
|
|
45
|
+
branchName: string;
|
|
46
|
+
databaseName?: string;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
kind: "enable-data-api";
|
|
50
|
+
projectId: string;
|
|
51
|
+
branchId: string;
|
|
52
|
+
branchName: string;
|
|
53
|
+
databaseName: string;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
kind: "create-bucket";
|
|
57
|
+
projectId: string;
|
|
58
|
+
branchId: string;
|
|
59
|
+
branchName: string;
|
|
60
|
+
bucketName: string;
|
|
61
|
+
accessLevel: BucketAccessLevel;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
kind: "create-function";
|
|
65
|
+
projectId: string;
|
|
66
|
+
branchId: string;
|
|
67
|
+
branchName: string;
|
|
68
|
+
fn: ResolvedFunctionConfig;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
/**
|
|
72
|
+
* Deploy (or re-deploy) code to a function. Always planned for every desired
|
|
73
|
+
* function — deployments are versioned and the newest becomes active, so a push
|
|
74
|
+
* ships the current source each time. `functionExists` tells `pushConfig` whether
|
|
75
|
+
* it must create the function first (covered by a preceding `create-function` step).
|
|
76
|
+
*/
|
|
77
|
+
kind: "deploy-function";
|
|
78
|
+
projectId: string;
|
|
79
|
+
branchId: string;
|
|
80
|
+
branchName: string;
|
|
81
|
+
fn: ResolvedFunctionConfig;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
kind: "enable-ai-gateway";
|
|
85
|
+
projectId: string;
|
|
86
|
+
branchId: string;
|
|
87
|
+
branchName: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export interface RemoteServiceState {
|
|
91
|
+
databaseName: string;
|
|
92
|
+
authEnabled: boolean;
|
|
93
|
+
dataApiEnabled: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Snapshot of the branch's current Preview-feature state. Absent (`undefined`) when the
|
|
98
|
+
* policy has no `preview` block — `pushConfig` only fetches this when needed.
|
|
99
|
+
*/
|
|
100
|
+
export interface RemotePreviewState {
|
|
101
|
+
buckets: NeonBucketSnapshot[];
|
|
102
|
+
functions: NeonFunctionSnapshot[];
|
|
103
|
+
aiGatewayEnabled: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RemoteState {
|
|
107
|
+
projectId: string;
|
|
108
|
+
branch: NeonBranchSnapshot;
|
|
109
|
+
endpoint?: NeonEndpointSnapshot;
|
|
110
|
+
services: RemoteServiceState;
|
|
111
|
+
preview?: RemotePreviewState;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DiffOptions {
|
|
115
|
+
/**
|
|
116
|
+
* Apply mutable drift on the selected branch as plan steps instead of conflicts.
|
|
117
|
+
* Default: `false`.
|
|
118
|
+
*/
|
|
119
|
+
updateExisting: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DiffResult {
|
|
123
|
+
plan: PlanStep[];
|
|
124
|
+
conflicts: ConflictReport[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Diff desired branch policy against the selected remote branch. Pure function.
|
|
129
|
+
*/
|
|
130
|
+
export function diffConfig(
|
|
131
|
+
config: ResolvedBranchConfig,
|
|
132
|
+
remote: RemoteState,
|
|
133
|
+
options: DiffOptions,
|
|
134
|
+
): DiffResult {
|
|
135
|
+
const conflicts: ConflictReport[] = [];
|
|
136
|
+
const plan: PlanStep[] = [];
|
|
137
|
+
diffBranchConfig({ config, remote, options, plan, conflicts });
|
|
138
|
+
diffServices({ config, remote, plan });
|
|
139
|
+
diffPreview({ config, remote, plan });
|
|
140
|
+
return { plan, conflicts };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Plan Preview features (functions, buckets, AI Gateway). Like {@link diffServices}, this
|
|
145
|
+
* is **additive**: it creates desired buckets/functions and enables the AI Gateway, but
|
|
146
|
+
* never deletes buckets/functions or disables the gateway. Teardown is destructive, so it
|
|
147
|
+
* stays explicit/manual — matching the existing auth / dataApi behaviour.
|
|
148
|
+
*
|
|
149
|
+
* Functions are always (re-)deployed: deployments are versioned and the newest becomes
|
|
150
|
+
* active, so each push ships the current source. A `create-function` step precedes the
|
|
151
|
+
* `deploy-function` step when the function does not yet exist remotely.
|
|
152
|
+
*/
|
|
153
|
+
function diffPreview(args: {
|
|
154
|
+
config: ResolvedBranchConfig;
|
|
155
|
+
remote: RemoteState;
|
|
156
|
+
plan: PlanStep[];
|
|
157
|
+
}): void {
|
|
158
|
+
const { config, remote, plan } = args;
|
|
159
|
+
const preview = config.preview;
|
|
160
|
+
if (!preview) return;
|
|
161
|
+
// `remote.preview` is only fetched when the policy has a preview block; treat a missing
|
|
162
|
+
// snapshot as "nothing exists yet" so the diff is still well-defined.
|
|
163
|
+
const state: RemotePreviewState = remote.preview ?? {
|
|
164
|
+
buckets: [],
|
|
165
|
+
functions: [],
|
|
166
|
+
aiGatewayEnabled: false,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const bucket of preview.buckets) {
|
|
170
|
+
if (state.buckets.some((b) => b.name === bucket.name)) continue;
|
|
171
|
+
plan.push({
|
|
172
|
+
kind: "create-bucket",
|
|
173
|
+
projectId: remote.projectId,
|
|
174
|
+
branchId: remote.branch.id,
|
|
175
|
+
branchName: remote.branch.name,
|
|
176
|
+
bucketName: bucket.name,
|
|
177
|
+
accessLevel: bucket.access,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const fn of preview.functions) {
|
|
182
|
+
const exists = state.functions.some((f) => f.slug === fn.slug);
|
|
183
|
+
if (!exists) {
|
|
184
|
+
plan.push({
|
|
185
|
+
kind: "create-function",
|
|
186
|
+
projectId: remote.projectId,
|
|
187
|
+
branchId: remote.branch.id,
|
|
188
|
+
branchName: remote.branch.name,
|
|
189
|
+
fn,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
plan.push({
|
|
193
|
+
kind: "deploy-function",
|
|
194
|
+
projectId: remote.projectId,
|
|
195
|
+
branchId: remote.branch.id,
|
|
196
|
+
branchName: remote.branch.name,
|
|
197
|
+
fn,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (preview.aiGatewayEnabled && !state.aiGatewayEnabled) {
|
|
202
|
+
plan.push({
|
|
203
|
+
kind: "enable-ai-gateway",
|
|
204
|
+
projectId: remote.projectId,
|
|
205
|
+
branchId: remote.branch.id,
|
|
206
|
+
branchName: remote.branch.name,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Plan additive branch-scoped integrations. Disabling remains explicit/manual because
|
|
213
|
+
* teardown is destructive.
|
|
214
|
+
*/
|
|
215
|
+
function diffServices(args: {
|
|
216
|
+
config: ResolvedBranchConfig;
|
|
217
|
+
remote: RemoteState;
|
|
218
|
+
plan: PlanStep[];
|
|
219
|
+
}): void {
|
|
220
|
+
const { config, remote, plan } = args;
|
|
221
|
+
const state = remote.services;
|
|
222
|
+
if (config.authEnabled && !state.authEnabled) {
|
|
223
|
+
const step: PlanStep = {
|
|
224
|
+
kind: "enable-auth",
|
|
225
|
+
projectId: remote.projectId,
|
|
226
|
+
branchId: remote.branch.id,
|
|
227
|
+
branchName: remote.branch.name,
|
|
228
|
+
};
|
|
229
|
+
if (state.databaseName) step.databaseName = state.databaseName;
|
|
230
|
+
plan.push(step);
|
|
231
|
+
}
|
|
232
|
+
if (config.dataApiEnabled && !state.dataApiEnabled) {
|
|
233
|
+
plan.push({
|
|
234
|
+
kind: "enable-data-api",
|
|
235
|
+
projectId: remote.projectId,
|
|
236
|
+
branchId: remote.branch.id,
|
|
237
|
+
branchName: remote.branch.name,
|
|
238
|
+
databaseName: state.databaseName,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
interface BranchConfigArgs {
|
|
244
|
+
config: ResolvedBranchConfig;
|
|
245
|
+
remote: RemoteState;
|
|
246
|
+
options: DiffOptions;
|
|
247
|
+
plan: PlanStep[];
|
|
248
|
+
conflicts: ConflictReport[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function diffBranchConfig(args: BranchConfigArgs): void {
|
|
252
|
+
const { config, remote, options, plan, conflicts } = args;
|
|
253
|
+
const branchName = remote.branch.name;
|
|
254
|
+
const computeSettings = config.postgres?.computeSettings;
|
|
255
|
+
|
|
256
|
+
if (computeSettings) {
|
|
257
|
+
const endpoint = remote.endpoint;
|
|
258
|
+
if (!endpoint) {
|
|
259
|
+
conflicts.push({
|
|
260
|
+
kind: "branch",
|
|
261
|
+
identifier: branchName,
|
|
262
|
+
field: "endpoint",
|
|
263
|
+
current: undefined,
|
|
264
|
+
desired: computeSettings,
|
|
265
|
+
reason: "Branch has no read-write endpoint; cannot apply compute settings.",
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
const drift = computeDriftBetween(computeSettings, endpoint);
|
|
269
|
+
if (drift) {
|
|
270
|
+
if (options.updateExisting) {
|
|
271
|
+
plan.push({
|
|
272
|
+
kind: "update-endpoint",
|
|
273
|
+
projectId: remote.projectId,
|
|
274
|
+
branchName,
|
|
275
|
+
endpointId: endpoint.id,
|
|
276
|
+
settings: computeSettings,
|
|
277
|
+
});
|
|
278
|
+
} else {
|
|
279
|
+
conflicts.push({
|
|
280
|
+
kind: "branch",
|
|
281
|
+
identifier: branchName,
|
|
282
|
+
field: "computeSettings",
|
|
283
|
+
current: drift.current,
|
|
284
|
+
desired: drift.desired,
|
|
285
|
+
reason: "Existing branch has different compute settings. Pass `updateExisting: true` (SDK) or `--update-existing` (CLI) to apply.",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (
|
|
293
|
+
config.protected !== undefined &&
|
|
294
|
+
config.protected !== remote.branch.protected
|
|
295
|
+
) {
|
|
296
|
+
if (options.updateExisting) {
|
|
297
|
+
plan.push({
|
|
298
|
+
kind: "update-branch-protected",
|
|
299
|
+
projectId: remote.projectId,
|
|
300
|
+
branchId: remote.branch.id,
|
|
301
|
+
branchName,
|
|
302
|
+
protected: config.protected,
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
conflicts.push({
|
|
306
|
+
kind: "branch",
|
|
307
|
+
identifier: branchName,
|
|
308
|
+
field: "protected",
|
|
309
|
+
current: remote.branch.protected,
|
|
310
|
+
desired: config.protected,
|
|
311
|
+
reason: "Existing branch has a different `protected` flag. Pass `updateExisting: true` (SDK) or `--update-existing` (CLI) to apply.",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (config.ttlSeconds !== undefined) {
|
|
317
|
+
const current = remote.branch.expiresAt
|
|
318
|
+
? Math.max(
|
|
319
|
+
0,
|
|
320
|
+
Math.round(
|
|
321
|
+
(Date.parse(remote.branch.expiresAt) - Date.now()) /
|
|
322
|
+
1000,
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
: undefined;
|
|
326
|
+
if (
|
|
327
|
+
current === undefined ||
|
|
328
|
+
Math.abs(current - config.ttlSeconds) > 30
|
|
329
|
+
) {
|
|
330
|
+
const expiresAt = new Date(
|
|
331
|
+
Date.now() + config.ttlSeconds * 1000,
|
|
332
|
+
).toISOString();
|
|
333
|
+
if (options.updateExisting) {
|
|
334
|
+
plan.push({
|
|
335
|
+
kind: "update-branch-ttl",
|
|
336
|
+
projectId: remote.projectId,
|
|
337
|
+
branchId: remote.branch.id,
|
|
338
|
+
branchName,
|
|
339
|
+
expiresAt,
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
conflicts.push({
|
|
343
|
+
kind: "branch",
|
|
344
|
+
identifier: branchName,
|
|
345
|
+
field: "ttl",
|
|
346
|
+
current: remote.branch.expiresAt,
|
|
347
|
+
desired: expiresAt,
|
|
348
|
+
reason: "Existing branch has a different TTL. Pass `updateExisting: true` (SDK) or `--update-existing` (CLI) to apply.",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function computeDriftBetween(
|
|
356
|
+
desired: ComputeSettings,
|
|
357
|
+
endpoint: NeonEndpointSnapshot,
|
|
358
|
+
): {
|
|
359
|
+
current: Partial<ComputeSettings>;
|
|
360
|
+
desired: Partial<ComputeSettings>;
|
|
361
|
+
} | null {
|
|
362
|
+
const currentDrift: Partial<ComputeSettings> = {};
|
|
363
|
+
const desiredDrift: Partial<ComputeSettings> = {};
|
|
364
|
+
let drift = false;
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
desired.autoscalingLimitMinCu !== undefined &&
|
|
368
|
+
desired.autoscalingLimitMinCu !== endpoint.autoscalingLimitMinCu
|
|
369
|
+
) {
|
|
370
|
+
currentDrift.autoscalingLimitMinCu = endpoint.autoscalingLimitMinCu;
|
|
371
|
+
desiredDrift.autoscalingLimitMinCu = desired.autoscalingLimitMinCu;
|
|
372
|
+
drift = true;
|
|
373
|
+
}
|
|
374
|
+
if (
|
|
375
|
+
desired.autoscalingLimitMaxCu !== undefined &&
|
|
376
|
+
desired.autoscalingLimitMaxCu !== endpoint.autoscalingLimitMaxCu
|
|
377
|
+
) {
|
|
378
|
+
currentDrift.autoscalingLimitMaxCu = endpoint.autoscalingLimitMaxCu;
|
|
379
|
+
desiredDrift.autoscalingLimitMaxCu = desired.autoscalingLimitMaxCu;
|
|
380
|
+
drift = true;
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
desired.suspendTimeout !== undefined &&
|
|
384
|
+
desired.suspendTimeout !== endpoint.suspendTimeout
|
|
385
|
+
) {
|
|
386
|
+
currentDrift.suspendTimeout = endpoint.suspendTimeout;
|
|
387
|
+
desiredDrift.suspendTimeout = desired.suspendTimeout;
|
|
388
|
+
drift = true;
|
|
389
|
+
}
|
|
390
|
+
return drift ? { current: currentDrift, desired: desiredDrift } : null;
|
|
391
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { formatDurationSeconds, parseDuration } from "./duration.js";
|
|
3
|
+
|
|
4
|
+
describe("parseDuration", () => {
|
|
5
|
+
test.each([
|
|
6
|
+
["30s", 30],
|
|
7
|
+
["5m", 300],
|
|
8
|
+
["1h", 3600],
|
|
9
|
+
["2h", 7200],
|
|
10
|
+
["1d", 86_400],
|
|
11
|
+
["7d", 604_800],
|
|
12
|
+
["2w", 1_209_600],
|
|
13
|
+
["1W", 604_800],
|
|
14
|
+
["3600", 3600],
|
|
15
|
+
])("parses %s as %d seconds", (input, expected) => {
|
|
16
|
+
const result = parseDuration(input);
|
|
17
|
+
expect(result).toEqual({ seconds: expected });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("accepts integer numbers", () => {
|
|
21
|
+
expect(parseDuration(60)).toEqual({ seconds: 60 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test.each([
|
|
25
|
+
["", "duration string is empty"],
|
|
26
|
+
[" ", "duration string is empty"],
|
|
27
|
+
["abc", 'invalid duration "abc"'],
|
|
28
|
+
["1h30m", 'invalid duration "1h30m"'],
|
|
29
|
+
["0", 'must be > 0, got "0"'],
|
|
30
|
+
["0s", 'must be > 0, got "0s"'],
|
|
31
|
+
["-5", 'invalid duration "-5"'],
|
|
32
|
+
])("rejects %s", (input, expectedErrorFragment) => {
|
|
33
|
+
const result = parseDuration(input);
|
|
34
|
+
expect(result).toHaveProperty("error");
|
|
35
|
+
expect((result as { error: string }).error).toContain(
|
|
36
|
+
expectedErrorFragment,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("rejects non-integer numbers", () => {
|
|
41
|
+
expect(parseDuration(1.5)).toEqual({
|
|
42
|
+
error: expect.stringContaining("must be an integer"),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects zero and negative numbers", () => {
|
|
47
|
+
expect(parseDuration(0)).toEqual({
|
|
48
|
+
error: expect.stringContaining("must be > 0"),
|
|
49
|
+
});
|
|
50
|
+
expect(parseDuration(-1)).toEqual({
|
|
51
|
+
error: expect.stringContaining("must be > 0"),
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("rejects non-finite numbers", () => {
|
|
56
|
+
expect(parseDuration(Number.POSITIVE_INFINITY)).toEqual({
|
|
57
|
+
error: expect.stringContaining("not a finite number"),
|
|
58
|
+
});
|
|
59
|
+
expect(parseDuration(Number.NaN)).toEqual({
|
|
60
|
+
error: expect.stringContaining("not a finite number"),
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("formatDurationSeconds", () => {
|
|
66
|
+
test.each([
|
|
67
|
+
[30, "30s"],
|
|
68
|
+
[60, "1m"],
|
|
69
|
+
[3600, "1h"],
|
|
70
|
+
[86_400, "1d"],
|
|
71
|
+
[604_800, "1w"],
|
|
72
|
+
[1_209_600, "2w"],
|
|
73
|
+
[7200, "2h"],
|
|
74
|
+
[120, "2m"],
|
|
75
|
+
[90, "90s"], // doesn't fit a clean minute boundary above 60 → falls back to seconds
|
|
76
|
+
])("formats %d seconds as %s", (input, expected) => {
|
|
77
|
+
expect(formatDurationSeconds(input)).toBe(expected);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("throws on non-positive input", () => {
|
|
81
|
+
expect(() => formatDurationSeconds(0)).toThrow(RangeError);
|
|
82
|
+
expect(() => formatDurationSeconds(-1)).toThrow(RangeError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("round-trips parseDuration → formatDurationSeconds in canonical form", () => {
|
|
86
|
+
// `7d` and `1w` both represent 604_800 seconds; the formatter chooses the largest
|
|
87
|
+
// clean unit, so the canonical form is `1w`. This documents the chosen tie-break.
|
|
88
|
+
const cases: Array<[string, string]> = [
|
|
89
|
+
["30s", "30s"],
|
|
90
|
+
["5m", "5m"],
|
|
91
|
+
["1h", "1h"],
|
|
92
|
+
["2h", "2h"],
|
|
93
|
+
["1d", "1d"],
|
|
94
|
+
["7d", "1w"],
|
|
95
|
+
["1w", "1w"],
|
|
96
|
+
["2w", "2w"],
|
|
97
|
+
];
|
|
98
|
+
for (const [input, canonical] of cases) {
|
|
99
|
+
const parsed = parseDuration(input);
|
|
100
|
+
if (!("seconds" in parsed))
|
|
101
|
+
throw new Error(`failed to parse ${input}`);
|
|
102
|
+
expect(formatDurationSeconds(parsed.seconds)).toBe(canonical);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a TTL value into whole seconds.
|
|
3
|
+
*
|
|
4
|
+
* Accepted formats:
|
|
5
|
+
* - a positive finite number → interpreted as seconds (must be an integer)
|
|
6
|
+
* - a positive integer string ("3600") → seconds
|
|
7
|
+
* - `<number><unit>` where unit is one of `s`, `m`, `h`, `d`, `w` (e.g. `30s`, `5m`, `1h`, `7d`, `2w`)
|
|
8
|
+
*
|
|
9
|
+
* Returns `{ seconds }` on success or `{ error }` on failure. Pure function — never throws.
|
|
10
|
+
*/
|
|
11
|
+
export function parseDuration(
|
|
12
|
+
input: string | number,
|
|
13
|
+
): { seconds: number } | { error: string } {
|
|
14
|
+
if (typeof input === "number") {
|
|
15
|
+
if (!Number.isFinite(input))
|
|
16
|
+
return { error: `not a finite number: ${input}` };
|
|
17
|
+
if (!Number.isInteger(input))
|
|
18
|
+
return {
|
|
19
|
+
error: `must be an integer when passed as number: ${input}`,
|
|
20
|
+
};
|
|
21
|
+
if (input <= 0) return { error: `must be > 0, got ${input}` };
|
|
22
|
+
return { seconds: input };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const trimmed = input.trim();
|
|
26
|
+
if (trimmed === "") return { error: "duration string is empty" };
|
|
27
|
+
|
|
28
|
+
const numericMatch = /^(\d+)$/.exec(trimmed);
|
|
29
|
+
if (numericMatch) {
|
|
30
|
+
const n = Number(numericMatch[1]);
|
|
31
|
+
if (n <= 0) return { error: `must be > 0, got "${trimmed}"` };
|
|
32
|
+
return { seconds: n };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const unitMatch = /^(\d+)([smhdw])$/i.exec(trimmed);
|
|
36
|
+
if (!unitMatch) {
|
|
37
|
+
return {
|
|
38
|
+
error: `invalid duration "${input}"; expected a number followed by one of: s, m, h, d, w (e.g. "30s", "1h", "7d")`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const value = Number(unitMatch[1]);
|
|
43
|
+
const unit = unitMatch[2].toLowerCase() as "s" | "m" | "h" | "d" | "w";
|
|
44
|
+
if (value <= 0) return { error: `must be > 0, got "${trimmed}"` };
|
|
45
|
+
|
|
46
|
+
const seconds = value * UNIT_SECONDS[unit];
|
|
47
|
+
return { seconds };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const UNIT_SECONDS = {
|
|
51
|
+
s: 1,
|
|
52
|
+
m: 60,
|
|
53
|
+
h: 60 * 60,
|
|
54
|
+
d: 24 * 60 * 60,
|
|
55
|
+
w: 7 * 24 * 60 * 60,
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render a TTL in seconds back to the canonical "<n><unit>" form. Used for round-trip
|
|
60
|
+
* serialization when {@link pullConfig} emits a TTL value (it always falls back to seconds
|
|
61
|
+
* when no clean unit boundary matches).
|
|
62
|
+
*/
|
|
63
|
+
export function formatDurationSeconds(totalSeconds: number): string {
|
|
64
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
|
65
|
+
throw new RangeError(
|
|
66
|
+
`formatDurationSeconds expected a positive finite number, got ${totalSeconds}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const candidates = [
|
|
70
|
+
["w", UNIT_SECONDS.w],
|
|
71
|
+
["d", UNIT_SECONDS.d],
|
|
72
|
+
["h", UNIT_SECONDS.h],
|
|
73
|
+
["m", UNIT_SECONDS.m],
|
|
74
|
+
] as const;
|
|
75
|
+
for (const [unit, perUnit] of candidates) {
|
|
76
|
+
if (totalSeconds % perUnit === 0) {
|
|
77
|
+
return `${totalSeconds / perUnit}${unit}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return `${totalSeconds}s`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse a suspend timeout value into seconds for the Neon API.
|
|
85
|
+
*
|
|
86
|
+
* Accepted formats:
|
|
87
|
+
* - `false` → -1 (never suspend)
|
|
88
|
+
* - `undefined` → 0 (use platform default)
|
|
89
|
+
* - duration string → parsed seconds ("5m", "1h", "7d")
|
|
90
|
+
* - number → validated seconds (must be 60-604800 or -1/0)
|
|
91
|
+
*
|
|
92
|
+
* Returns `{ seconds }` on success or `{ error }` on failure. Pure function — never throws.
|
|
93
|
+
*/
|
|
94
|
+
export function parseSuspendTimeout(
|
|
95
|
+
input: false | string | number | undefined,
|
|
96
|
+
): { seconds: number } | { error: string } {
|
|
97
|
+
// false means "never suspend"
|
|
98
|
+
if (input === false) return { seconds: -1 };
|
|
99
|
+
|
|
100
|
+
// undefined means "use platform default"
|
|
101
|
+
if (input === undefined) return { seconds: 0 };
|
|
102
|
+
|
|
103
|
+
// If it's a number, validate the range
|
|
104
|
+
if (typeof input === "number") {
|
|
105
|
+
if (!Number.isFinite(input))
|
|
106
|
+
return { error: `not a finite number: ${input}` };
|
|
107
|
+
if (!Number.isInteger(input))
|
|
108
|
+
return { error: `must be an integer: ${input}` };
|
|
109
|
+
|
|
110
|
+
// Allow special values: -1 (never), 0 (default)
|
|
111
|
+
if (input === -1 || input === 0) return { seconds: input };
|
|
112
|
+
|
|
113
|
+
// Validate range for custom timeout: 60s (1 min) to 604800s (1 week)
|
|
114
|
+
if (input < 60 || input > 604_800) {
|
|
115
|
+
return {
|
|
116
|
+
error: `suspend timeout must be between 60 and 604800 seconds (1 minute to 1 week), got ${input}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { seconds: input };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Parse duration string
|
|
123
|
+
const result = parseDuration(input);
|
|
124
|
+
if ("error" in result) return result;
|
|
125
|
+
|
|
126
|
+
// Validate the parsed duration is in the valid range
|
|
127
|
+
const { seconds } = result;
|
|
128
|
+
if (seconds < 60 || seconds > 604_800) {
|
|
129
|
+
return {
|
|
130
|
+
error: `suspend timeout must be between 60 and 604800 seconds (1 minute to 1 week), "${input}" = ${seconds}s`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { seconds };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Format a suspend timeout value from API seconds back to the user-facing type.
|
|
139
|
+
* Returns `false` for -1 (never suspend), `undefined` for 0 (default), or a duration string.
|
|
140
|
+
*/
|
|
141
|
+
export function formatSuspendTimeout(
|
|
142
|
+
seconds: number,
|
|
143
|
+
): false | string | undefined {
|
|
144
|
+
if (seconds === -1) return false; // never suspend
|
|
145
|
+
if (seconds === 0) return undefined; // platform default
|
|
146
|
+
return formatDurationSeconds(seconds);
|
|
147
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { ConfigValidationError, PushConflictError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
describe("ConfigValidationError", () => {
|
|
5
|
+
test("formats issues", () => {
|
|
6
|
+
const err = new ConfigValidationError(["ttl: invalid duration"]);
|
|
7
|
+
expect(err.message).toContain("ttl: invalid duration");
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("PushConflictError", () => {
|
|
12
|
+
test("formats branch conflicts with updateExisting hint", () => {
|
|
13
|
+
const err = new PushConflictError([
|
|
14
|
+
{
|
|
15
|
+
kind: "branch",
|
|
16
|
+
identifier: "main",
|
|
17
|
+
field: "protected",
|
|
18
|
+
current: false,
|
|
19
|
+
desired: true,
|
|
20
|
+
reason: "different protected flag",
|
|
21
|
+
},
|
|
22
|
+
]);
|
|
23
|
+
expect(err.message).toContain("[branch:main] protected");
|
|
24
|
+
expect(err.message).toContain("--update-existing");
|
|
25
|
+
});
|
|
26
|
+
});
|