@intentius/chant-k8s-client 0.31.0 → 0.32.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/README.md +24 -0
- package/dist/client.d.ts +52 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/conflict.d.ts +103 -0
- package/dist/conflict.d.ts.map +1 -0
- package/dist/errors.d.ts +14 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/field-manager.d.ts +62 -0
- package/dist/field-manager.d.ts.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +110 -0
- package/dist/managed-fields.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/client.test.ts +137 -1
- package/src/client.ts +116 -18
- package/src/conflict.test.ts +209 -0
- package/src/conflict.ts +257 -0
- package/src/errors.ts +18 -1
- package/src/field-manager.test.ts +110 -0
- package/src/field-manager.ts +111 -0
- package/src/index.ts +36 -1
- package/src/managed-fields.test.ts +199 -0
- package/src/managed-fields.ts +216 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoding `metadata.managedFields` (chant #1075, for #1076).
|
|
3
|
+
*
|
|
4
|
+
* The fixtures below are shaped like real API-server output, including the
|
|
5
|
+
* cases that make the encoding awkward: a list addressed by key, a set
|
|
6
|
+
* addressed by value, the `.` marker for "the element itself", and a `status`
|
|
7
|
+
* subresource entry written by a controller that chant must not mistake for a
|
|
8
|
+
* competitor over the spec.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
chantOwnedFields,
|
|
14
|
+
fieldOwners,
|
|
15
|
+
fieldPathsOf,
|
|
16
|
+
fieldSetsOf,
|
|
17
|
+
fieldsOwnedBy,
|
|
18
|
+
managedFieldsOf,
|
|
19
|
+
managersOf,
|
|
20
|
+
renderSegment,
|
|
21
|
+
} from "./managed-fields";
|
|
22
|
+
import type { K8sObject } from "./types";
|
|
23
|
+
|
|
24
|
+
const deployment: K8sObject = {
|
|
25
|
+
apiVersion: "apps/v1",
|
|
26
|
+
kind: "Deployment",
|
|
27
|
+
metadata: {
|
|
28
|
+
name: "web",
|
|
29
|
+
namespace: "prod",
|
|
30
|
+
managedFields: [
|
|
31
|
+
{
|
|
32
|
+
manager: "chant:web",
|
|
33
|
+
operation: "Apply",
|
|
34
|
+
apiVersion: "apps/v1",
|
|
35
|
+
fieldsType: "FieldsV1",
|
|
36
|
+
time: "2026-07-20T10:00:00Z",
|
|
37
|
+
fieldsV1: {
|
|
38
|
+
"f:metadata": { "f:labels": { "f:app": {} } },
|
|
39
|
+
"f:spec": {
|
|
40
|
+
"f:replicas": {},
|
|
41
|
+
"f:template": {
|
|
42
|
+
"f:spec": {
|
|
43
|
+
"f:containers": {
|
|
44
|
+
'k:{"name":"web"}': { ".": {}, "f:image": {}, "f:name": {} },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
manager: "kubectl-client-side-apply",
|
|
53
|
+
operation: "Update",
|
|
54
|
+
apiVersion: "apps/v1",
|
|
55
|
+
fieldsType: "FieldsV1",
|
|
56
|
+
fieldsV1: { "f:spec": { "f:replicas": {} } },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
manager: "kube-controller-manager",
|
|
60
|
+
operation: "Update",
|
|
61
|
+
apiVersion: "apps/v1",
|
|
62
|
+
subresource: "status",
|
|
63
|
+
fieldsType: "FieldsV1",
|
|
64
|
+
fieldsV1: { "f:status": { "f:readyReplicas": {} } },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("fieldPathsOf — the fieldsV1 encoding", () => {
|
|
71
|
+
test("nested fields become dotted paths, parents included", () => {
|
|
72
|
+
expect(fieldPathsOf({ "f:spec": { "f:replicas": {} } })).toEqual([".spec", ".spec.replicas"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a keyed list item renders the way the API server writes conflicts", () => {
|
|
76
|
+
const paths = fieldPathsOf({
|
|
77
|
+
"f:spec": { "f:containers": { 'k:{"name":"web"}': { ".": {}, "f:image": {} } } },
|
|
78
|
+
});
|
|
79
|
+
expect(paths).toContain('.spec.containers[name="web"]');
|
|
80
|
+
expect(paths).toContain('.spec.containers[name="web"].image');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a multi-key list item keeps the declared key order and JSON value forms", () => {
|
|
84
|
+
expect(fieldPathsOf({ "f:ports": { 'k:{"port":80,"protocol":"TCP"}': {} } })).toEqual([
|
|
85
|
+
".ports",
|
|
86
|
+
'.ports[port=80,protocol="TCP"]',
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("set items by value and list items by index", () => {
|
|
91
|
+
expect(renderSegment('v:"blue"')).toBe('[="blue"]');
|
|
92
|
+
expect(renderSegment("i:3")).toBe("[3]");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('"." marks the containing element, and adds no segment of its own', () => {
|
|
96
|
+
// Nothing but a "." at the root owns no path — there is no element above it.
|
|
97
|
+
expect(fieldPathsOf({ ".": {} })).toEqual([]);
|
|
98
|
+
expect(fieldPathsOf({ "f:a": { ".": {} } })).toEqual([".a"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("an unrecognised prefix is skipped rather than mangled into a wrong path", () => {
|
|
102
|
+
expect(renderSegment("q:something")).toBeUndefined();
|
|
103
|
+
expect(fieldPathsOf({ "q:future": { "f:inner": {} } })).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a key that is not decodable JSON is kept verbatim", () => {
|
|
107
|
+
expect(renderSegment("k:{not json}")).toBe("[{not json}]");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("no managedFields at all is an empty answer, not a throw", () => {
|
|
111
|
+
expect(fieldPathsOf(undefined)).toEqual([]);
|
|
112
|
+
expect(managedFieldsOf(undefined)).toEqual([]);
|
|
113
|
+
expect(managedFieldsOf({ metadata: {} })).toEqual([]);
|
|
114
|
+
expect(fieldSetsOf({})).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("per-manager field sets", () => {
|
|
119
|
+
test("every entry is decoded, and entries are kept separate rather than merged", () => {
|
|
120
|
+
const sets = fieldSetsOf(deployment);
|
|
121
|
+
expect(sets.map((s) => s.manager)).toEqual([
|
|
122
|
+
"chant:web",
|
|
123
|
+
"kubectl-client-side-apply",
|
|
124
|
+
"kube-controller-manager",
|
|
125
|
+
]);
|
|
126
|
+
expect(sets[0].operation).toBe("Apply");
|
|
127
|
+
expect(sets[0].time).toBe("2026-07-20T10:00:00Z");
|
|
128
|
+
expect(sets[2].subresource).toBe("status");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("chant's own fields are exactly what the manifest declared", () => {
|
|
132
|
+
expect(chantOwnedFields(deployment)).toEqual([
|
|
133
|
+
".metadata",
|
|
134
|
+
".metadata.labels",
|
|
135
|
+
".metadata.labels.app",
|
|
136
|
+
".spec",
|
|
137
|
+
".spec.replicas",
|
|
138
|
+
".spec.template",
|
|
139
|
+
".spec.template.spec",
|
|
140
|
+
".spec.template.spec.containers",
|
|
141
|
+
'.spec.template.spec.containers[name="web"]',
|
|
142
|
+
'.spec.template.spec.containers[name="web"].image',
|
|
143
|
+
'.spec.template.spec.containers[name="web"].name',
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a status subresource entry is excluded unless asked for", () => {
|
|
148
|
+
expect(fieldsOwnedBy(deployment, "kube-controller-manager")).toEqual([]);
|
|
149
|
+
expect(fieldsOwnedBy(deployment, "kube-controller-manager", { includeSubresources: true })).toEqual([
|
|
150
|
+
".status",
|
|
151
|
+
".status.readyReplicas",
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("chant is found by any qualified name, since a stack rename changes it", () => {
|
|
156
|
+
const renamed: K8sObject = {
|
|
157
|
+
metadata: {
|
|
158
|
+
managedFields: [
|
|
159
|
+
{ manager: "chant:old", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } },
|
|
160
|
+
{ manager: "chant", operation: "Apply", fieldsV1: { "f:spec": { "f:paused": {} } } },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
expect(chantOwnedFields(renamed)).toEqual([".spec", ".spec.paused", ".spec.replicas"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("managersOf lists every manager once, in order", () => {
|
|
168
|
+
expect(managersOf(deployment)).toEqual([
|
|
169
|
+
"chant:web",
|
|
170
|
+
"kubectl-client-side-apply",
|
|
171
|
+
"kube-controller-manager",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("fieldOwners — who holds what", () => {
|
|
177
|
+
test("a contested path names both holders", () => {
|
|
178
|
+
const owners = fieldOwners(deployment);
|
|
179
|
+
expect(owners.get(".spec.replicas")).toEqual(["chant:web", "kubectl-client-side-apply"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("an uncontested path names one", () => {
|
|
183
|
+
const owners = fieldOwners(deployment);
|
|
184
|
+
expect(owners.get('.spec.template.spec.containers[name="web"].image')).toEqual(["chant:web"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("subresource entries stay out of the map by default", () => {
|
|
188
|
+
expect(fieldOwners(deployment).has(".status.readyReplicas")).toBe(false);
|
|
189
|
+
expect(fieldOwners(deployment, { includeSubresources: true }).get(".status.readyReplicas")).toEqual([
|
|
190
|
+
"kube-controller-manager",
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an entry with no manager name is ignored rather than counted as anonymous", () => {
|
|
195
|
+
const odd: K8sObject = { metadata: { managedFields: [{ fieldsV1: { "f:spec": {} } }, null as never] } };
|
|
196
|
+
expect(fieldSetsOf(odd)).toEqual([]);
|
|
197
|
+
expect(managersOf(odd)).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading `metadata.managedFields` — chant #1075, consumed by #1076.
|
|
3
|
+
*
|
|
4
|
+
* The API server records, per object, one entry per manager that has written
|
|
5
|
+
* to it, and inside each entry a *set* of the field paths that manager owns.
|
|
6
|
+
* That set is encoded as `fieldsV1`, a nested object whose keys carry a
|
|
7
|
+
* one-or-two-character prefix rather than being plain field names:
|
|
8
|
+
*
|
|
9
|
+
* ```json
|
|
10
|
+
* { "f:spec": { "f:template": { "f:spec": {
|
|
11
|
+
* "f:containers": { "k:{\"name\":\"web\"}": { ".": {}, "f:image": {} } } } } } }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* | prefix | means | rendered as |
|
|
15
|
+
* |--------|------------------------------------------|-----------------|
|
|
16
|
+
* | `f:` | a field of a map | `.image` |
|
|
17
|
+
* | `k:` | a list item, addressed by its key fields | `[name="web"]` |
|
|
18
|
+
* | `v:` | a set item, addressed by its value | `[="blue"]` |
|
|
19
|
+
* | `i:` | a list item, addressed by its index | `[0]` |
|
|
20
|
+
* | `.` | the containing element itself | (the prefix) |
|
|
21
|
+
*
|
|
22
|
+
* The rendering above is not invented here: it is what
|
|
23
|
+
* `sigs.k8s.io/structured-merge-diff`'s `fieldpath.Path.String()` produces,
|
|
24
|
+
* which is the same syntax the API server uses for the `field` of a conflict
|
|
25
|
+
* cause (`./conflict.ts`). Both halves of #1076's question — *which fields
|
|
26
|
+
* does chant own* and *which fields is something else fighting over* — have to
|
|
27
|
+
* be comparable as strings, so there is exactly one renderer.
|
|
28
|
+
*
|
|
29
|
+
* This module reads. It does not decide what a difference means; that is
|
|
30
|
+
* #1076's job. What it owes #1076 is the primitive: an object in, per-manager
|
|
31
|
+
* field sets out.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { isChantFieldManager } from "./field-manager";
|
|
35
|
+
import type { K8sObject } from "./types";
|
|
36
|
+
|
|
37
|
+
/** One `metadata.managedFields` entry, as the API server writes it. */
|
|
38
|
+
export interface ManagedFieldsEntry {
|
|
39
|
+
manager?: string;
|
|
40
|
+
/** `Apply` for a server-side apply, `Update` for anything else. */
|
|
41
|
+
operation?: string;
|
|
42
|
+
apiVersion?: string;
|
|
43
|
+
fieldsType?: string;
|
|
44
|
+
fieldsV1?: Record<string, unknown>;
|
|
45
|
+
/** Set when the entry describes a subresource write, e.g. `status`. */
|
|
46
|
+
subresource?: string;
|
|
47
|
+
time?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One manager's ownership of one object, with `fieldsV1` decoded. */
|
|
51
|
+
export interface ManagerFieldSet {
|
|
52
|
+
/** The manager's name, e.g. `chant:web`, `kubectl-client-side-apply`. */
|
|
53
|
+
manager: string;
|
|
54
|
+
/** `Apply` (server-side apply) or `Update` (everything else). */
|
|
55
|
+
operation: string;
|
|
56
|
+
/** The apiVersion the entry was recorded at. */
|
|
57
|
+
apiVersion?: string;
|
|
58
|
+
/** Set when this entry covers a subresource (`status`, `scale`). */
|
|
59
|
+
subresource?: string;
|
|
60
|
+
/** When the write happened, as the server recorded it. */
|
|
61
|
+
time?: string;
|
|
62
|
+
/** Field paths this entry owns, rendered and sorted. */
|
|
63
|
+
fields: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `metadata.managedFields`, or an empty list when the object carries none. */
|
|
67
|
+
export function managedFieldsOf(object: K8sObject | undefined): ManagedFieldsEntry[] {
|
|
68
|
+
const entries = object?.metadata?.managedFields;
|
|
69
|
+
if (!Array.isArray(entries)) return [];
|
|
70
|
+
return entries
|
|
71
|
+
.filter((e) => e !== null && typeof e === "object")
|
|
72
|
+
.map((e) => e as ManagedFieldsEntry);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decode every `managedFields` entry into a manager and its owned paths.
|
|
77
|
+
*
|
|
78
|
+
* Entries are kept separate rather than merged by manager name: a manager can
|
|
79
|
+
* legitimately hold two entries for one object — one for the main resource and
|
|
80
|
+
* one for a subresource, or two at different apiVersions — and collapsing them
|
|
81
|
+
* would lose the distinction #1076 needs when deciding whether a `status`
|
|
82
|
+
* write is chant's business (it is not).
|
|
83
|
+
*/
|
|
84
|
+
export function fieldSetsOf(object: K8sObject | undefined): ManagerFieldSet[] {
|
|
85
|
+
return managedFieldsOf(object)
|
|
86
|
+
.filter((entry) => typeof entry.manager === "string" && entry.manager.length > 0)
|
|
87
|
+
.map((entry) => ({
|
|
88
|
+
manager: entry.manager!,
|
|
89
|
+
operation: entry.operation ?? "Update",
|
|
90
|
+
...(entry.apiVersion !== undefined ? { apiVersion: entry.apiVersion } : {}),
|
|
91
|
+
...(entry.subresource !== undefined ? { subresource: entry.subresource } : {}),
|
|
92
|
+
...(entry.time !== undefined ? { time: entry.time } : {}),
|
|
93
|
+
fields: fieldPathsOf(entry.fieldsV1),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every manager named on the object, in `managedFields` order, deduplicated. */
|
|
98
|
+
export function managersOf(object: K8sObject | undefined): string[] {
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
for (const entry of managedFieldsOf(object)) {
|
|
101
|
+
if (typeof entry.manager === "string" && entry.manager.length > 0) seen.add(entry.manager);
|
|
102
|
+
}
|
|
103
|
+
return [...seen];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The paths owned by managers matching `manager` — a literal name, or a
|
|
108
|
+
* predicate for the fuzzier questions (#1076 asks "which fields does *any*
|
|
109
|
+
* chant manager own", since a stack rename changes the name).
|
|
110
|
+
*
|
|
111
|
+
* Subresource entries are excluded by default: a controller writing `status`
|
|
112
|
+
* is not competing for the spec chant declared.
|
|
113
|
+
*/
|
|
114
|
+
export function fieldsOwnedBy(
|
|
115
|
+
object: K8sObject | undefined,
|
|
116
|
+
manager: string | ((manager: string) => boolean),
|
|
117
|
+
options: { includeSubresources?: boolean } = {},
|
|
118
|
+
): string[] {
|
|
119
|
+
const matches = typeof manager === "function" ? manager : (m: string) => m === manager;
|
|
120
|
+
const paths = new Set<string>();
|
|
121
|
+
for (const set of fieldSetsOf(object)) {
|
|
122
|
+
if (!matches(set.manager)) continue;
|
|
123
|
+
if (set.subresource !== undefined && options.includeSubresources !== true) continue;
|
|
124
|
+
for (const path of set.fields) paths.add(path);
|
|
125
|
+
}
|
|
126
|
+
return [...paths].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The paths owned by any chant field manager — {@link isChantFieldManager}. */
|
|
130
|
+
export function chantOwnedFields(
|
|
131
|
+
object: K8sObject | undefined,
|
|
132
|
+
options: { includeSubresources?: boolean } = {},
|
|
133
|
+
): string[] {
|
|
134
|
+
return fieldsOwnedBy(object, isChantFieldManager, options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* path → the managers that own it. A path with two owners is not an error:
|
|
139
|
+
* server-side apply lets several appliers co-own a field when they set it to
|
|
140
|
+
* the same value, and an `Update` entry can overlap an `Apply` one.
|
|
141
|
+
*/
|
|
142
|
+
export function fieldOwners(
|
|
143
|
+
object: K8sObject | undefined,
|
|
144
|
+
options: { includeSubresources?: boolean } = {},
|
|
145
|
+
): Map<string, string[]> {
|
|
146
|
+
const owners = new Map<string, string[]>();
|
|
147
|
+
for (const set of fieldSetsOf(object)) {
|
|
148
|
+
if (set.subresource !== undefined && options.includeSubresources !== true) continue;
|
|
149
|
+
for (const path of set.fields) {
|
|
150
|
+
const list = owners.get(path) ?? [];
|
|
151
|
+
if (!list.includes(set.manager)) list.push(set.manager);
|
|
152
|
+
owners.set(path, list);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return owners;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Render one `fieldsV1` tree to sorted, dotted paths.
|
|
160
|
+
*
|
|
161
|
+
* Exported because a caller with an entry already in hand (a watch event, a
|
|
162
|
+
* stored snapshot) should not have to reassemble a whole object to decode it.
|
|
163
|
+
*/
|
|
164
|
+
export function fieldPathsOf(fieldsV1: unknown, prefix = ""): string[] {
|
|
165
|
+
return [...collect(fieldsV1, prefix, new Set<string>())].sort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collect(node: unknown, prefix: string, into: Set<string>): Set<string> {
|
|
169
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return into;
|
|
170
|
+
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
|
|
171
|
+
if (key === ".") {
|
|
172
|
+
// "the containing element itself is owned" — no path segment of its own.
|
|
173
|
+
if (prefix !== "") into.add(prefix);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const segment = renderSegment(key);
|
|
177
|
+
if (segment === undefined) continue;
|
|
178
|
+
const path = `${prefix}${segment}`;
|
|
179
|
+
into.add(path);
|
|
180
|
+
collect(child, path, into);
|
|
181
|
+
}
|
|
182
|
+
return into;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One `fieldsV1` key to one path segment, or undefined for a key with no
|
|
187
|
+
* recognised prefix (a future encoding chant should skip rather than mangle).
|
|
188
|
+
*/
|
|
189
|
+
export function renderSegment(key: string): string | undefined {
|
|
190
|
+
if (key.startsWith("f:")) return `.${key.slice(2)}`;
|
|
191
|
+
if (key.startsWith("i:")) return `[${key.slice(2)}]`;
|
|
192
|
+
if (key.startsWith("v:")) return `[=${key.slice(2)}]`;
|
|
193
|
+
if (key.startsWith("k:")) return renderKeySegment(key.slice(2));
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `k:{"name":"web"}` → `[name="web"]`, and multi-key list items in the order
|
|
199
|
+
* the JSON declares them — `[port=80,protocol="TCP"]`. Values are re-serialised
|
|
200
|
+
* as JSON, which is what makes a string key print with its quotes, matching
|
|
201
|
+
* both `fieldpath.Path.String()` and the conflict causes the server returns.
|
|
202
|
+
*/
|
|
203
|
+
function renderKeySegment(json: string): string {
|
|
204
|
+
let parsed: unknown;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(json);
|
|
207
|
+
} catch {
|
|
208
|
+
// Not decodable — keep the raw form rather than inventing a path.
|
|
209
|
+
return `[${json}]`;
|
|
210
|
+
}
|
|
211
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return `[${json}]`;
|
|
212
|
+
const parts = Object.entries(parsed as Record<string, unknown>).map(
|
|
213
|
+
([name, value]) => `${name}=${JSON.stringify(value)}`,
|
|
214
|
+
);
|
|
215
|
+
return `[${parts.join(",")}]`;
|
|
216
|
+
}
|