@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.
@@ -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
+ });