@configbutler/krm-stream 0.1.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 +26 -0
- package/dist/deep.d.ts +14 -0
- package/dist/deep.js +44 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +24 -0
- package/dist/merge.d.ts +21 -0
- package/dist/merge.js +212 -0
- package/dist/path.d.ts +27 -0
- package/dist/path.js +105 -0
- package/dist/policy.d.ts +16 -0
- package/dist/policy.js +36 -0
- package/dist/schema.d.ts +14 -0
- package/dist/schema.js +26 -0
- package/dist/sse.d.ts +53 -0
- package/dist/sse.js +215 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +340 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +7 -0
- package/dist/url.d.ts +26 -0
- package/dist/url.js +38 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +26 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @configbutler/krm-stream
|
|
2
|
+
|
|
3
|
+
`@configbutler/krm-stream` is the official dependency-free ESM client for consuming a KRM resource
|
|
4
|
+
stream in a browser or JavaScript application. It provides:
|
|
5
|
+
|
|
6
|
+
- `LiveResourceStore` for server state, local drafts, conflicts, redactions, and merge patches.
|
|
7
|
+
- `connectWithEventSource` for same-origin browser streams.
|
|
8
|
+
- `connectResourceStream` for fetch-based transports with explicit headers.
|
|
9
|
+
- `resourceStreamURL` for the v1 scope query format.
|
|
10
|
+
|
|
11
|
+
The package is headless and does not choose a UI framework. It works with the Go gateway in this
|
|
12
|
+
repository or any conforming producer.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { LiveResourceStore, connectWithEventSource, resourceStreamURL } from "@configbutler/krm-stream";
|
|
16
|
+
|
|
17
|
+
const store = new LiveResourceStore();
|
|
18
|
+
connectWithEventSource(
|
|
19
|
+
resourceStreamURL("/resource-stream/v1", { version: "v1", resource: "configmaps", namespace: "app" }),
|
|
20
|
+
store,
|
|
21
|
+
);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The unscoped `krm-stream` package is a compatibility forwarder. This project is pre-1.0 and has not
|
|
25
|
+
been published to npm yet. See the repository [README](../../README.md),
|
|
26
|
+
[client state model](../../docs/client-state-model.md), and [release guide](../../docs/releasing.md).
|
package/dist/deep.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A plain JSON object — an object that is not an array and not null. Everything a KRM object
|
|
2
|
+
* contains is one of: plain object, array, string, number, boolean, null. */
|
|
3
|
+
export declare function isPlainObject(v: unknown): v is Record<string, unknown>;
|
|
4
|
+
/** Recursive, KEY-ORDER-INDEPENDENT structural equality (I-ORDER-EQ).
|
|
5
|
+
*
|
|
6
|
+
* `JSON.stringify(a) === JSON.stringify(b)` is the tempting one-liner and it is wrong: an API
|
|
7
|
+
* server may serialize the same object with its keys in a different order, and stringify-equality
|
|
8
|
+
* then reports a change that did not happen — which flashes the UI, and worse, makes an untouched
|
|
9
|
+
* field look like it "moved" so a three-way merge stops leaving edits alone. */
|
|
10
|
+
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
11
|
+
/** A deep copy of a JSON value. The store never hands out a reference into its own state, and never
|
|
12
|
+
* keeps a reference into a caller's object: a draft that aliases the server snapshot would make the
|
|
13
|
+
* base drift silently as the user types, and the three-way merge would compare an object to itself. */
|
|
14
|
+
export declare function clone<T>(v: T): T;
|
package/dist/deep.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Structural comparison and cloning of JSON values. No dependencies, and deliberately not
|
|
2
|
+
// `structuredClone`/`JSON.stringify` — see below.
|
|
3
|
+
/** A plain JSON object — an object that is not an array and not null. Everything a KRM object
|
|
4
|
+
* contains is one of: plain object, array, string, number, boolean, null. */
|
|
5
|
+
export function isPlainObject(v) {
|
|
6
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
7
|
+
}
|
|
8
|
+
/** Recursive, KEY-ORDER-INDEPENDENT structural equality (I-ORDER-EQ).
|
|
9
|
+
*
|
|
10
|
+
* `JSON.stringify(a) === JSON.stringify(b)` is the tempting one-liner and it is wrong: an API
|
|
11
|
+
* server may serialize the same object with its keys in a different order, and stringify-equality
|
|
12
|
+
* then reports a change that did not happen — which flashes the UI, and worse, makes an untouched
|
|
13
|
+
* field look like it "moved" so a three-way merge stops leaving edits alone. */
|
|
14
|
+
export function deepEqual(a, b) {
|
|
15
|
+
if (Object.is(a, b))
|
|
16
|
+
return true;
|
|
17
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
18
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
19
|
+
return false;
|
|
20
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
21
|
+
}
|
|
22
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
23
|
+
const ka = Object.keys(a);
|
|
24
|
+
const kb = Object.keys(b);
|
|
25
|
+
if (ka.length !== kb.length)
|
|
26
|
+
return false;
|
|
27
|
+
return ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
/** A deep copy of a JSON value. The store never hands out a reference into its own state, and never
|
|
32
|
+
* keeps a reference into a caller's object: a draft that aliases the server snapshot would make the
|
|
33
|
+
* base drift silently as the user types, and the three-way merge would compare an object to itself. */
|
|
34
|
+
export function clone(v) {
|
|
35
|
+
if (Array.isArray(v))
|
|
36
|
+
return v.map(clone);
|
|
37
|
+
if (isPlainObject(v)) {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [k, x] of Object.entries(v))
|
|
40
|
+
out[k] = clone(x);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { clone, deepEqual } from "./deep.ts";
|
|
2
|
+
export { get, has, isPrefix, parsePointer, pathKey } from "./path.ts";
|
|
3
|
+
export { DEFAULT_EDITABLE_REGIONS, defaultPolicy, readOnlyPolicy, regionPolicy } from "./policy.ts";
|
|
4
|
+
export type { KubernetesStructuralSchema } from "./schema.ts";
|
|
5
|
+
export { withOpenAPIKeyedLists } from "./schema.ts";
|
|
6
|
+
export type { StreamHandle, StreamOptions } from "./sse.ts";
|
|
7
|
+
export { applyStreamEvent, connectResourceStream, connectWithEventSource, SSEDecoder, StreamSequence } from "./sse.ts";
|
|
8
|
+
export type { ApplyOptions, ApplyResult } from "./store.ts";
|
|
9
|
+
export { LiveResourceStore } from "./store.ts";
|
|
10
|
+
export type { Change, Conflict, EditabilityPolicy, ErrorCode, EventType, Identity, KRMObject, Path, Projection, Redaction, Scope, StreamEvent, } from "./types.ts";
|
|
11
|
+
export type { ScopeQuery } from "./url.ts";
|
|
12
|
+
export { resourceStreamURL } from "./url.ts";
|
|
13
|
+
export { PROTOCOL_VERSION, VERSION } from "./version.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// krm-stream — a live, honest window onto Kubernetes resources, in the browser.
|
|
2
|
+
//
|
|
3
|
+
// The public surface is small on purpose:
|
|
4
|
+
//
|
|
5
|
+
// LiveResourceStore holds server truth + your draft; three-way merges every watch event;
|
|
6
|
+
// derives dirtiness; tracks conflicts; builds the merge patch.
|
|
7
|
+
// connectResourceStream a conforming SSE consumer that feeds a store.
|
|
8
|
+
// resourceStreamURL builds the stream URL from a scope — the encoding the gateway parses back.
|
|
9
|
+
//
|
|
10
|
+
// The store is built test-first against conformance/ — the same fixtures the Go gateway runs. See
|
|
11
|
+
// ../../../docs/client-state-model.md for the algorithm, ../../../spec/v1.md for the wire, and
|
|
12
|
+
// CONTRIBUTING.md for the order of work.
|
|
13
|
+
//
|
|
14
|
+
// No runtime dependencies, and none of this knows anything about GitOps, Flux, Dex, kcp or
|
|
15
|
+
// ConfigButler. It knows KRM.
|
|
16
|
+
// Useful to a host that renders paths, and to anyone writing a policy: identity is a segment ARRAY.
|
|
17
|
+
export { clone, deepEqual } from "./deep.js";
|
|
18
|
+
export { get, has, isPrefix, parsePointer, pathKey } from "./path.js";
|
|
19
|
+
export { DEFAULT_EDITABLE_REGIONS, defaultPolicy, readOnlyPolicy, regionPolicy } from "./policy.js";
|
|
20
|
+
export { withOpenAPIKeyedLists } from "./schema.js";
|
|
21
|
+
export { applyStreamEvent, connectResourceStream, connectWithEventSource, SSEDecoder, StreamSequence } from "./sse.js";
|
|
22
|
+
export { LiveResourceStore } from "./store.js";
|
|
23
|
+
export { resourceStreamURL } from "./url.js";
|
|
24
|
+
export { PROTOCOL_VERSION, VERSION } from "./version.js";
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Conflict, Path } from "./types.ts";
|
|
2
|
+
/** The store's per-resource view of which paths are editable — the policy, minus the paths this
|
|
3
|
+
* particular object declared redacted. */
|
|
4
|
+
export interface Regions {
|
|
5
|
+
/** This path is inside an editable region: three-way merge it. */
|
|
6
|
+
editable(path: Path): boolean;
|
|
7
|
+
/** Not editable itself, but an editable region lives underneath: recurse, don't replace. */
|
|
8
|
+
container(path: Path): boolean;
|
|
9
|
+
/** Identity fields for a schema-declared Kubernetes associative list, if this is one. */
|
|
10
|
+
listMapKeys(path: Path): readonly string[] | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface MergeState {
|
|
13
|
+
regions: Regions;
|
|
14
|
+
/** Conflicts persist between events — a conflict the next event does not touch is still a
|
|
15
|
+
* conflict. (Dirtiness is the opposite: derived, never stored. R-DERIVED.) */
|
|
16
|
+
conflicts: Map<string, Conflict>;
|
|
17
|
+
/** An output, not state: the paths the server moved. The host flashes them and forgets them. */
|
|
18
|
+
flashed: Path[];
|
|
19
|
+
}
|
|
20
|
+
/** Reconcile one resource. Returns the new draft. */
|
|
21
|
+
export declare function reconcile(s: MergeState, base: unknown, ours: unknown, theirs: unknown): unknown;
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The deep three-way reconcile (docs/client-state-model.md §4).
|
|
2
|
+
//
|
|
3
|
+
// base the PREVIOUS server object we last reconciled from
|
|
4
|
+
// ours the user's working draft
|
|
5
|
+
// theirs the complete incoming server object
|
|
6
|
+
//
|
|
7
|
+
// The base is the whole point (R-THREEWAY). Compare `theirs` to `ours` instead and you cannot tell
|
|
8
|
+
// "the server changed this" from "the user typed this", so every watch event looks like the server
|
|
9
|
+
// changed everything the user touched — and a controller's status heartbeat false-conflicts an edit
|
|
10
|
+
// three fields away. Only a path where base ≠ theirs was actually moved by the server.
|
|
11
|
+
//
|
|
12
|
+
// Note what is NOT here: replacing the authoritative server object. That happens in the store, and
|
|
13
|
+
// it is a REPLACEMENT, never a merge (spec §4.1) — merging complete server objects resurrects the
|
|
14
|
+
// fields the server just removed. These are two layers, and conflating them is the ghost bug.
|
|
15
|
+
import { clone, deepEqual, isPlainObject } from "./deep.js";
|
|
16
|
+
import { at, isPrefix, pathKey } from "./path.js";
|
|
17
|
+
/** Reconcile one resource. Returns the new draft. */
|
|
18
|
+
export function reconcile(s, base, ours, theirs) {
|
|
19
|
+
return node(s, [], base, ours, theirs);
|
|
20
|
+
}
|
|
21
|
+
function node(s, path, base, ours, theirs) {
|
|
22
|
+
if (s.regions.editable(path))
|
|
23
|
+
return mergeEditable(s, path, base, ours, theirs);
|
|
24
|
+
if (s.regions.container(path))
|
|
25
|
+
return mergeContainer(s, path, base, ours, theirs);
|
|
26
|
+
return follow(s, path, base, theirs);
|
|
27
|
+
}
|
|
28
|
+
/** A read-only region: take the server's value, flash what moved, and never look at the draft.
|
|
29
|
+
* There is no draft here to look at — that is what read-only MEANS — but it still updates live. */
|
|
30
|
+
function follow(s, path, base, theirs) {
|
|
31
|
+
if (isPlainObject(base) && isPlainObject(theirs)) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const k of unionKeys(base, theirs)) {
|
|
34
|
+
const v = follow(s, [...path, k], at(base, k), at(theirs, k));
|
|
35
|
+
if (v !== undefined)
|
|
36
|
+
out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
return out; // structurally clone(theirs): a key `theirs` lacks is GONE, not retained
|
|
39
|
+
}
|
|
40
|
+
// Arrays flash atomically, at the array's own path — a UI highlights "the conditions changed",
|
|
41
|
+
// not "conditions[0].reason changed", and §4.1 treats arrays as one value anyway.
|
|
42
|
+
if (!deepEqual(base, theirs))
|
|
43
|
+
s.flashed.push(path);
|
|
44
|
+
return clone(theirs);
|
|
45
|
+
}
|
|
46
|
+
/** Not editable, but an editable region is somewhere below (`[]`, `["metadata"]`). Recurse and let
|
|
47
|
+
* each child dispatch for itself: `metadata.labels` is merged while `metadata.name` beside it
|
|
48
|
+
* follows the server. */
|
|
49
|
+
function mergeContainer(s, path, base, ours, theirs) {
|
|
50
|
+
if (!isPlainObject(base) && !isPlainObject(ours) && !isPlainObject(theirs)) {
|
|
51
|
+
return follow(s, path, base, theirs);
|
|
52
|
+
}
|
|
53
|
+
return recurse(s, path, base, ours, theirs);
|
|
54
|
+
}
|
|
55
|
+
function mergeEditable(s, path, base, ours, theirs) {
|
|
56
|
+
const keys = s.regions.listMapKeys(path);
|
|
57
|
+
if (keys && isAssociativeList(base, keys) && isAssociativeList(ours, keys) && isAssociativeList(theirs, keys)) {
|
|
58
|
+
return mergeAssociativeList(s, path, keys, base, ours, theirs);
|
|
59
|
+
}
|
|
60
|
+
const defined = [base, ours, theirs].filter((v) => v !== undefined);
|
|
61
|
+
if (defined.length > 0 && defined.every(isPlainObject))
|
|
62
|
+
return recurse(s, path, base, ours, theirs);
|
|
63
|
+
// Everything else — scalars, arrays, and any node where the three disagree about their very shape
|
|
64
|
+
// — is ONE atomic value.
|
|
65
|
+
//
|
|
66
|
+
// For arrays this is docs §4.1, and it is coarse on purpose: a positional merge of an array whose
|
|
67
|
+
// length moved mis-aligns (a PREPENDED sidecar would merge the user's edit into the wrong
|
|
68
|
+
// container), so a dirty array is treated as a single value and the user's version wins until they
|
|
69
|
+
// resolve it. RFC 7386 replaces arrays wholesale too, so the patch format needs no special case.
|
|
70
|
+
if (deepEqual(base, ours)) {
|
|
71
|
+
// The user has no edit here. Follow the server — including following it into deletion.
|
|
72
|
+
if (!deepEqual(base, theirs))
|
|
73
|
+
s.flashed.push(path);
|
|
74
|
+
clearConflict(s, path);
|
|
75
|
+
return clone(theirs);
|
|
76
|
+
}
|
|
77
|
+
// Dirty: `ours` diverged from `base`. The user's value is never silently overwritten.
|
|
78
|
+
if (deepEqual(base, theirs))
|
|
79
|
+
return clone(ours); // the server did not move it — no conflict (I-NOFALSE)
|
|
80
|
+
if (deepEqual(ours, theirs)) {
|
|
81
|
+
clearConflict(s, path); // the server arrived at what the user typed (I-CONVERGE)
|
|
82
|
+
return clone(ours);
|
|
83
|
+
}
|
|
84
|
+
setConflict(s, path, theirs);
|
|
85
|
+
return clone(ours);
|
|
86
|
+
}
|
|
87
|
+
/** Merge Kubernetes `listType: map` entries by their declared key rather than their position.
|
|
88
|
+
*
|
|
89
|
+
* A merge patch still writes the resulting array as a whole, because RFC 7386 has no keyed-array
|
|
90
|
+
* operation. The important part is that its value starts with the latest server list and carries the
|
|
91
|
+
* user's independent element edits across a reorder, append, or unrelated element change. */
|
|
92
|
+
function mergeAssociativeList(s, path, keys, base, ours, theirs) {
|
|
93
|
+
const baseByKey = indexAssociativeList(base, keys);
|
|
94
|
+
const oursByKey = indexAssociativeList(ours, keys);
|
|
95
|
+
const theirsByKey = indexAssociativeList(theirs, keys);
|
|
96
|
+
if (!baseByKey || !oursByKey || !theirsByKey)
|
|
97
|
+
return mergeAtomic(s, path, base, ours, theirs);
|
|
98
|
+
// Server order is authoritative for entries it has. Locally-added or locally-preserved entries
|
|
99
|
+
// without a server position follow in their draft order, which keeps a user's new row stable.
|
|
100
|
+
const order = unique([...theirsByKey.order, ...oursByKey.order, ...baseByKey.order]);
|
|
101
|
+
const outputKeys = order.filter((key) => associativeEntrySurvives(oursByKey.values.get(key), theirsByKey.values.get(key)));
|
|
102
|
+
remapListConflicts(s, path, oursByKey.order, outputKeys);
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const key of outputKeys) {
|
|
105
|
+
const value = mergeAssociativeEntry(s, [...path, out.length], baseByKey.values.get(key), oursByKey.values.get(key), theirsByKey.values.get(key));
|
|
106
|
+
if (value !== undefined)
|
|
107
|
+
out.push(value);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function associativeEntrySurvives(ours, theirs) {
|
|
112
|
+
// When both sides agree an entry is absent, there is no value to put in the result. Every other
|
|
113
|
+
// combination is retained or represented as a conflict by mergeAssociativeEntry.
|
|
114
|
+
return ours !== undefined || theirs !== undefined;
|
|
115
|
+
}
|
|
116
|
+
function remapListConflicts(s, path, previousOrder, outputKeys) {
|
|
117
|
+
const outputIndex = new Map(outputKeys.map((key, index) => [key, index]));
|
|
118
|
+
const moved = [];
|
|
119
|
+
for (const [encoded, conflict] of s.conflicts) {
|
|
120
|
+
const segment = conflict.path[path.length];
|
|
121
|
+
if (!isPrefix(path, conflict.path) || typeof segment !== "number")
|
|
122
|
+
continue;
|
|
123
|
+
const key = previousOrder[segment];
|
|
124
|
+
const nextIndex = key === undefined ? undefined : outputIndex.get(key);
|
|
125
|
+
if (nextIndex === undefined || nextIndex === segment)
|
|
126
|
+
continue;
|
|
127
|
+
s.conflicts.delete(encoded);
|
|
128
|
+
moved.push({ ...conflict, path: [...path, nextIndex, ...conflict.path.slice(path.length + 1)] });
|
|
129
|
+
}
|
|
130
|
+
for (const conflict of moved)
|
|
131
|
+
s.conflicts.set(pathKey(conflict.path), conflict);
|
|
132
|
+
}
|
|
133
|
+
function mergeAssociativeEntry(s, path, base, ours, theirs) {
|
|
134
|
+
// An added or deleted entry is an atomic user intent. Recursing through an absent object would
|
|
135
|
+
// turn a deletion into `{}` and erase the fact that the entry was removed.
|
|
136
|
+
if (base === undefined || ours === undefined || theirs === undefined)
|
|
137
|
+
return mergeAtomic(s, path, base, ours, theirs);
|
|
138
|
+
return node(s, path, base, ours, theirs);
|
|
139
|
+
}
|
|
140
|
+
function mergeAtomic(s, path, base, ours, theirs) {
|
|
141
|
+
if (deepEqual(base, ours)) {
|
|
142
|
+
if (!deepEqual(base, theirs))
|
|
143
|
+
s.flashed.push(path);
|
|
144
|
+
clearConflict(s, path);
|
|
145
|
+
return clone(theirs);
|
|
146
|
+
}
|
|
147
|
+
if (deepEqual(base, theirs))
|
|
148
|
+
return clone(ours);
|
|
149
|
+
if (deepEqual(ours, theirs)) {
|
|
150
|
+
clearConflict(s, path);
|
|
151
|
+
return clone(ours);
|
|
152
|
+
}
|
|
153
|
+
setConflict(s, path, theirs);
|
|
154
|
+
return clone(ours);
|
|
155
|
+
}
|
|
156
|
+
function isAssociativeList(value, keys) {
|
|
157
|
+
return Array.isArray(value) && indexAssociativeList(value, keys) !== undefined;
|
|
158
|
+
}
|
|
159
|
+
function indexAssociativeList(values, keys) {
|
|
160
|
+
const indexed = { order: [], values: new Map() };
|
|
161
|
+
for (const value of values) {
|
|
162
|
+
if (!isPlainObject(value))
|
|
163
|
+
return undefined;
|
|
164
|
+
const identity = keys.map((key) => value[key]);
|
|
165
|
+
if (identity.some((part) => part === undefined || part === null || typeof part === "object"))
|
|
166
|
+
return undefined;
|
|
167
|
+
const encoded = JSON.stringify(identity);
|
|
168
|
+
if (indexed.values.has(encoded))
|
|
169
|
+
return undefined;
|
|
170
|
+
indexed.order.push(encoded);
|
|
171
|
+
indexed.values.set(encoded, value);
|
|
172
|
+
}
|
|
173
|
+
return indexed;
|
|
174
|
+
}
|
|
175
|
+
function unique(values) {
|
|
176
|
+
return [...new Set(values)];
|
|
177
|
+
}
|
|
178
|
+
function recurse(s, path, base, ours, theirs) {
|
|
179
|
+
const out = {};
|
|
180
|
+
for (const k of unionKeys(base, ours, theirs)) {
|
|
181
|
+
const v = node(s, [...path, k], at(base, k), at(ours, k), at(theirs, k));
|
|
182
|
+
if (v !== undefined)
|
|
183
|
+
out[k] = v; // prune: a key that reconciled to nothing is GONE
|
|
184
|
+
}
|
|
185
|
+
// The server removed this whole subtree and nothing of the user's survived in it. Returning `{}`
|
|
186
|
+
// here would leave an empty container the server does not have — which then reads as a dirty
|
|
187
|
+
// "the user added an empty map" forever. (`theirs === {}` is a real empty map and is kept.)
|
|
188
|
+
if (theirs === undefined && Object.keys(out).length === 0)
|
|
189
|
+
return undefined;
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
function unionKeys(...values) {
|
|
193
|
+
const keys = [];
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
for (const v of values) {
|
|
196
|
+
if (!isPlainObject(v))
|
|
197
|
+
continue;
|
|
198
|
+
for (const k of Object.keys(v)) {
|
|
199
|
+
if (seen.has(k))
|
|
200
|
+
continue;
|
|
201
|
+
seen.add(k);
|
|
202
|
+
keys.push(k);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return keys;
|
|
206
|
+
}
|
|
207
|
+
function setConflict(s, path, theirs) {
|
|
208
|
+
s.conflicts.set(pathKey(path), { path: [...path], theirs: clone(theirs) });
|
|
209
|
+
}
|
|
210
|
+
function clearConflict(s, path) {
|
|
211
|
+
s.conflicts.delete(pathKey(path));
|
|
212
|
+
}
|
package/dist/path.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Path } from "./types.ts";
|
|
2
|
+
/** A scalar key for a path, where a Map needs one. Encoded STRUCTURALLY — never by joining segments
|
|
3
|
+
* with a magic separator, which is how the original bug (a null byte vs a space) got in. */
|
|
4
|
+
export declare function pathKey(path: Path): string;
|
|
5
|
+
/** True when `prefix` addresses `path` or an ancestor of it. Segment-wise: `["data"]` is a prefix of
|
|
6
|
+
* `["data","token"]`, and `["dat"]` is a prefix of nothing. */
|
|
7
|
+
export declare function isPrefix(prefix: Path, path: Path): boolean;
|
|
8
|
+
/** The child of a JSON value at one segment, or undefined if there isn't one. Indexing a scalar (or
|
|
9
|
+
* a string, which would happily answer to `.length`) yields undefined, not nonsense. */
|
|
10
|
+
export declare function at(value: unknown, seg: string | number): unknown;
|
|
11
|
+
export declare function get(value: unknown, path: Path): unknown;
|
|
12
|
+
/** True when the path is really THERE — as opposed to present with an undefined value. The
|
|
13
|
+
* difference is the whole of `absentPaths`: a ghost is a key that still exists. */
|
|
14
|
+
export declare function has(value: unknown, path: Path): boolean;
|
|
15
|
+
/** Set a value at a path, creating the containers it passes through. A numeric segment creates an
|
|
16
|
+
* array, a string segment an object — so `["spec","ports",0]` does the right thing on a fresh spec. */
|
|
17
|
+
export declare function setAt(root: Record<string, unknown>, path: Path, value: unknown): void;
|
|
18
|
+
/** Remove the key (or array element) a path addresses. Removing what is not there is a no-op, not
|
|
19
|
+
* an error: the server may have removed it first, and a UI must not blow up on that race. */
|
|
20
|
+
export declare function removeAt(root: Record<string, unknown>, path: Path): void;
|
|
21
|
+
/** An RFC 6901 JSON Pointer -> a segment array. This is the form `redacted[].path` arrives in on the
|
|
22
|
+
* wire (spec §3); inside the engine everything is a segment array.
|
|
23
|
+
*
|
|
24
|
+
* Segments stay STRINGS. `/data/0` on a map whose key is literally "0" must not become the number
|
|
25
|
+
* 0 — a pointer does not carry enough type information to tell those apart, and guessing corrupts
|
|
26
|
+
* exactly the Secret keys this is used to protect. */
|
|
27
|
+
export declare function parsePointer(pointer: string): Path;
|
package/dist/path.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Paths: arrays of segments, never dot-joined strings (R-ID).
|
|
2
|
+
//
|
|
3
|
+
// `["metadata","labels","app.kubernetes.io/name"]` is THREE segments, the last containing two dots
|
|
4
|
+
// and a slash. Join it with dots and it splits back into six segments addressing nothing at all —
|
|
5
|
+
// no crash, no error, the conflict lookup just silently never matches and the user's edit is lost
|
|
6
|
+
// on save. That bug is the reason this module exists and the reason `Path` is not a string.
|
|
7
|
+
import { isPlainObject } from "./deep.js";
|
|
8
|
+
/** A scalar key for a path, where a Map needs one. Encoded STRUCTURALLY — never by joining segments
|
|
9
|
+
* with a magic separator, which is how the original bug (a null byte vs a space) got in. */
|
|
10
|
+
export function pathKey(path) {
|
|
11
|
+
return JSON.stringify(path);
|
|
12
|
+
}
|
|
13
|
+
/** True when `prefix` addresses `path` or an ancestor of it. Segment-wise: `["data"]` is a prefix of
|
|
14
|
+
* `["data","token"]`, and `["dat"]` is a prefix of nothing. */
|
|
15
|
+
export function isPrefix(prefix, path) {
|
|
16
|
+
if (prefix.length > path.length)
|
|
17
|
+
return false;
|
|
18
|
+
return prefix.every((seg, i) => String(seg) === String(path[i]));
|
|
19
|
+
}
|
|
20
|
+
/** The child of a JSON value at one segment, or undefined if there isn't one. Indexing a scalar (or
|
|
21
|
+
* a string, which would happily answer to `.length`) yields undefined, not nonsense. */
|
|
22
|
+
export function at(value, seg) {
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value[Number(seg)];
|
|
25
|
+
if (isPlainObject(value))
|
|
26
|
+
return value[String(seg)];
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
export function get(value, path) {
|
|
30
|
+
let cur = value;
|
|
31
|
+
for (const seg of path)
|
|
32
|
+
cur = at(cur, seg);
|
|
33
|
+
return cur;
|
|
34
|
+
}
|
|
35
|
+
/** True when the path is really THERE — as opposed to present with an undefined value. The
|
|
36
|
+
* difference is the whole of `absentPaths`: a ghost is a key that still exists. */
|
|
37
|
+
export function has(value, path) {
|
|
38
|
+
let cur = value;
|
|
39
|
+
for (const seg of path) {
|
|
40
|
+
if (Array.isArray(cur)) {
|
|
41
|
+
if (Number(seg) >= cur.length)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
else if (isPlainObject(cur)) {
|
|
45
|
+
if (!Object.hasOwn(cur, String(seg)))
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
cur = at(cur, seg);
|
|
52
|
+
}
|
|
53
|
+
return cur !== undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Set a value at a path, creating the containers it passes through. A numeric segment creates an
|
|
56
|
+
* array, a string segment an object — so `["spec","ports",0]` does the right thing on a fresh spec. */
|
|
57
|
+
export function setAt(root, path, value) {
|
|
58
|
+
if (path.length === 0)
|
|
59
|
+
throw new Error("krm-stream: cannot set the root of an object");
|
|
60
|
+
let cur = root;
|
|
61
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
62
|
+
const seg = path[i];
|
|
63
|
+
let next = at(cur, seg);
|
|
64
|
+
if (!isPlainObject(next) && !Array.isArray(next)) {
|
|
65
|
+
next = typeof path[i + 1] === "number" ? [] : {};
|
|
66
|
+
assign(cur, seg, next);
|
|
67
|
+
}
|
|
68
|
+
cur = next;
|
|
69
|
+
}
|
|
70
|
+
assign(cur, path[path.length - 1], value);
|
|
71
|
+
}
|
|
72
|
+
/** Remove the key (or array element) a path addresses. Removing what is not there is a no-op, not
|
|
73
|
+
* an error: the server may have removed it first, and a UI must not blow up on that race. */
|
|
74
|
+
export function removeAt(root, path) {
|
|
75
|
+
if (path.length === 0)
|
|
76
|
+
throw new Error("krm-stream: cannot remove the root of an object");
|
|
77
|
+
const parent = get(root, path.slice(0, -1));
|
|
78
|
+
const last = path[path.length - 1];
|
|
79
|
+
if (Array.isArray(parent))
|
|
80
|
+
parent.splice(Number(last), 1);
|
|
81
|
+
else if (isPlainObject(parent))
|
|
82
|
+
delete parent[String(last)];
|
|
83
|
+
}
|
|
84
|
+
function assign(container, seg, value) {
|
|
85
|
+
if (Array.isArray(container))
|
|
86
|
+
container[Number(seg)] = value;
|
|
87
|
+
else
|
|
88
|
+
container[String(seg)] = value;
|
|
89
|
+
}
|
|
90
|
+
/** An RFC 6901 JSON Pointer -> a segment array. This is the form `redacted[].path` arrives in on the
|
|
91
|
+
* wire (spec §3); inside the engine everything is a segment array.
|
|
92
|
+
*
|
|
93
|
+
* Segments stay STRINGS. `/data/0` on a map whose key is literally "0" must not become the number
|
|
94
|
+
* 0 — a pointer does not carry enough type information to tell those apart, and guessing corrupts
|
|
95
|
+
* exactly the Secret keys this is used to protect. */
|
|
96
|
+
export function parsePointer(pointer) {
|
|
97
|
+
if (pointer === "")
|
|
98
|
+
return [];
|
|
99
|
+
if (!pointer.startsWith("/"))
|
|
100
|
+
throw new Error(`krm-stream: not an RFC 6901 pointer: ${JSON.stringify(pointer)}`);
|
|
101
|
+
return pointer
|
|
102
|
+
.slice(1)
|
|
103
|
+
.split("/")
|
|
104
|
+
.map((seg) => seg.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
105
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EditabilityPolicy, Path } from "./types.ts";
|
|
2
|
+
/** The default editable regions. Note that two of them are at the object ROOT: `ConfigMap.data` and
|
|
3
|
+
* `Secret.data`/`stringData` carry editable payload outside `spec`, and an engine that assumes
|
|
4
|
+
* everything editable lives under `spec` cannot edit a ConfigMap at all — which is the one kind the
|
|
5
|
+
* first consumer edits most. `binaryData` is deliberately absent: it is base64 of arbitrary bytes,
|
|
6
|
+
* and a text input silently corrupts it. */
|
|
7
|
+
export declare const DEFAULT_EDITABLE_REGIONS: Path[];
|
|
8
|
+
/** A policy from a list of editable region roots. Everything under a root is editable; everything
|
|
9
|
+
* else follows the server. */
|
|
10
|
+
export declare function regionPolicy(roots: Path[]): EditabilityPolicy;
|
|
11
|
+
/** `spec`, `metadata.labels`/`annotations`, `data`, `stringData` editable; `status`, the rest of
|
|
12
|
+
* `metadata`, `apiVersion`, `kind`, `binaryData` read-only. */
|
|
13
|
+
export declare const defaultPolicy: EditabilityPolicy;
|
|
14
|
+
/** Everything read-only: the status-watch use case. Same engine, same stream, no draft — a viewer
|
|
15
|
+
* that cannot accidentally offer to save. */
|
|
16
|
+
export declare const readOnlyPolicy: EditabilityPolicy;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which regions of a KRM object a user may edit.
|
|
2
|
+
//
|
|
3
|
+
// Read-only is NOT ignored (docs §3). A read-only region still follows the server live and still
|
|
4
|
+
// flashes what changed — that is the entire live-status-watch use case, and it is the product. It
|
|
5
|
+
// simply never grows a draft, a dirty flag, or a conflict, and never appears in a patch.
|
|
6
|
+
import { isPrefix } from "./path.js";
|
|
7
|
+
/** The default editable regions. Note that two of them are at the object ROOT: `ConfigMap.data` and
|
|
8
|
+
* `Secret.data`/`stringData` carry editable payload outside `spec`, and an engine that assumes
|
|
9
|
+
* everything editable lives under `spec` cannot edit a ConfigMap at all — which is the one kind the
|
|
10
|
+
* first consumer edits most. `binaryData` is deliberately absent: it is base64 of arbitrary bytes,
|
|
11
|
+
* and a text input silently corrupts it. */
|
|
12
|
+
export const DEFAULT_EDITABLE_REGIONS = [
|
|
13
|
+
["spec"],
|
|
14
|
+
["metadata", "labels"],
|
|
15
|
+
["metadata", "annotations"],
|
|
16
|
+
["data"],
|
|
17
|
+
["stringData"],
|
|
18
|
+
];
|
|
19
|
+
/** A policy from a list of editable region roots. Everything under a root is editable; everything
|
|
20
|
+
* else follows the server. */
|
|
21
|
+
export function regionPolicy(roots) {
|
|
22
|
+
return {
|
|
23
|
+
isEditable: (_obj, path) => roots.some((root) => isPrefix(root, path)),
|
|
24
|
+
// A path that is not editable itself but that an editable region lives UNDER — `[]` and
|
|
25
|
+
// `["metadata"]` for the defaults. The merge must recurse through these rather than replacing
|
|
26
|
+
// them wholesale, or an edit to `metadata.labels` would be clobbered by the read-only handling
|
|
27
|
+
// of `metadata.name` sitting beside it.
|
|
28
|
+
containsEditable: (_obj, path) => roots.some((root) => root.length > path.length && isPrefix(path, root)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** `spec`, `metadata.labels`/`annotations`, `data`, `stringData` editable; `status`, the rest of
|
|
32
|
+
* `metadata`, `apiVersion`, `kind`, `binaryData` read-only. */
|
|
33
|
+
export const defaultPolicy = regionPolicy(DEFAULT_EDITABLE_REGIONS);
|
|
34
|
+
/** Everything read-only: the status-watch use case. Same engine, same stream, no draft — a viewer
|
|
35
|
+
* that cannot accidentally offer to save. */
|
|
36
|
+
export const readOnlyPolicy = regionPolicy([]);
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EditabilityPolicy } from "./types.ts";
|
|
2
|
+
/** The structural OpenAPI subset Kubernetes uses to describe object fields and associative lists. */
|
|
3
|
+
export interface KubernetesStructuralSchema {
|
|
4
|
+
properties?: Record<string, KubernetesStructuralSchema>;
|
|
5
|
+
items?: KubernetesStructuralSchema;
|
|
6
|
+
"x-kubernetes-list-type"?: string;
|
|
7
|
+
"x-kubernetes-list-map-keys"?: string[];
|
|
8
|
+
}
|
|
9
|
+
/** Adds keyed-list merging to an existing editability policy.
|
|
10
|
+
*
|
|
11
|
+
* Pass the structural schema for the exact GroupVersionKind the store receives. Lists without both
|
|
12
|
+
* Kubernetes extensions, malformed lists, and lists whose elements have duplicate/missing keys all
|
|
13
|
+
* continue to use atomic-array behavior. */
|
|
14
|
+
export declare function withOpenAPIKeyedLists(policy: EditabilityPolicy, schema: KubernetesStructuralSchema): EditabilityPolicy;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// OpenAPI-backed associative-list support. Kubernetes uses these two extensions for lists whose
|
|
2
|
+
// elements have a stable identity, such as containers by `name`. The store only needs this small
|
|
3
|
+
// structural subset; fetching schemas and choosing the right resource schema remain host concerns.
|
|
4
|
+
/** Adds keyed-list merging to an existing editability policy.
|
|
5
|
+
*
|
|
6
|
+
* Pass the structural schema for the exact GroupVersionKind the store receives. Lists without both
|
|
7
|
+
* Kubernetes extensions, malformed lists, and lists whose elements have duplicate/missing keys all
|
|
8
|
+
* continue to use atomic-array behavior. */
|
|
9
|
+
export function withOpenAPIKeyedLists(policy, schema) {
|
|
10
|
+
return {
|
|
11
|
+
...policy,
|
|
12
|
+
listMapKeys: (object, path) => policy.listMapKeys?.(object, path) ?? mapKeysAt(schema, path),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function mapKeysAt(root, path) {
|
|
16
|
+
let schema = root;
|
|
17
|
+
for (const segment of path) {
|
|
18
|
+
schema = typeof segment === "number" ? schema?.items : schema?.properties?.[segment];
|
|
19
|
+
if (!schema)
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const keys = schema["x-kubernetes-list-map-keys"];
|
|
23
|
+
if (schema["x-kubernetes-list-type"] !== "map" || !keys || keys.length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
return keys;
|
|
26
|
+
}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { LiveResourceStore } from "./store.ts";
|
|
2
|
+
import type { ErrorCode, Path, StreamEvent } from "./types.ts";
|
|
3
|
+
/** Incremental SSE parser. Bytes arrive in whatever chunks the network feels like — a frame can be
|
|
4
|
+
* split down the middle, and it WILL be, under exactly the load where you least want to debug it —
|
|
5
|
+
* so this buffers and only yields complete frames. */
|
|
6
|
+
export declare class SSEDecoder {
|
|
7
|
+
#private;
|
|
8
|
+
/** Feed a chunk of the stream; get back the events that completed with it. */
|
|
9
|
+
push(chunk: string): StreamEvent[];
|
|
10
|
+
}
|
|
11
|
+
/** Tracks the mandatory per-connection event sequence. The first missing frame is enough to make
|
|
12
|
+
* state uncertain, so transports close and reconnect rather than applying a possibly stale tail. */
|
|
13
|
+
export declare class StreamSequence {
|
|
14
|
+
#private;
|
|
15
|
+
observe(event: StreamEvent): {
|
|
16
|
+
expected: number;
|
|
17
|
+
received: number;
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
/** What a stream event does to a store. This is the consumer's half of the event table (spec §4), and
|
|
21
|
+
* it is exported because it IS the protocol — a host feeding a store from its own transport should
|
|
22
|
+
* not have to reimplement the switch and get `synced` subtly wrong.
|
|
23
|
+
*
|
|
24
|
+
* Returns the paths that flashed, so a UI can highlight them. */
|
|
25
|
+
export declare function applyStreamEvent(store: LiveResourceStore, ev: StreamEvent): Path[];
|
|
26
|
+
export interface StreamOptions {
|
|
27
|
+
/** Called for every `error` event. A terminal one has already closed the connection by the time
|
|
28
|
+
* this returns — there is nothing to retry, and retrying is the bug. */
|
|
29
|
+
onError?: (code: ErrorCode, message: string, terminal: boolean) => void;
|
|
30
|
+
/** Called at the end of every snapshot cycle. The store is now consistent: a good moment to paint. */
|
|
31
|
+
onSynced?: () => void;
|
|
32
|
+
/** Called after any change, with the paths that flashed. */
|
|
33
|
+
onChange?: (flashed: Path[]) => void;
|
|
34
|
+
/** A missing or duplicated event was observed. The connection is closed; reconnect for a snapshot. */
|
|
35
|
+
onGap?: (expected: number, received: number) => void;
|
|
36
|
+
/** Abort the stream from outside. */
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/** Injectable for tests. Defaults to the global fetch. */
|
|
39
|
+
fetch?: typeof globalThis.fetch;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
export interface StreamHandle {
|
|
43
|
+
close(): void;
|
|
44
|
+
/** Resolves when the stream ends: closed, aborted, or terminated by a terminal error. */
|
|
45
|
+
closed: Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** Consume a resource stream over fetch, feeding a store. Use this when the gateway authenticates
|
|
48
|
+
* with a bearer token — native EventSource cannot send the header. */
|
|
49
|
+
export declare function connectResourceStream(url: string, store: LiveResourceStore, opts?: StreamOptions): StreamHandle;
|
|
50
|
+
/** Consume a resource stream with the browser's native EventSource. This is the same-origin
|
|
51
|
+
* session-cookie path — the baseline a v1 gateway MUST support, because a cookie is the only
|
|
52
|
+
* credential EventSource can carry. */
|
|
53
|
+
export declare function connectWithEventSource(url: string, store: LiveResourceStore, opts?: Omit<StreamOptions, "fetch" | "headers">): StreamHandle;
|