@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/dist/sse.js ADDED
@@ -0,0 +1,215 @@
1
+ // The transport, consumer side. Everything else in this package is pure logic; this is the only file
2
+ // that knows a stream is made of bytes.
3
+ //
4
+ // Two ways in, because there are two authentication stories (spec §7) and neither is optional:
5
+ //
6
+ // connectResourceStream fetch-based. Needed for `Authorization: Bearer`, because native
7
+ // EventSource cannot send a custom header. Works in Node, so it is what
8
+ // the conformance suite drives.
9
+ // connectWithEventSource native EventSource. Needed for the same-origin session-cookie case,
10
+ // which is the BASELINE a v1 gateway must support.
11
+ //
12
+ // The rule both must obey, and the one that is easy to get wrong: on a TERMINAL error, close the
13
+ // connection. EventSource reconnects automatically otherwise, and will hammer a scope it can never
14
+ // be allowed to see — forever, from every open tab.
15
+ /** Incremental SSE parser. Bytes arrive in whatever chunks the network feels like — a frame can be
16
+ * split down the middle, and it WILL be, under exactly the load where you least want to debug it —
17
+ * so this buffers and only yields complete frames. */
18
+ export class SSEDecoder {
19
+ #buffer = "";
20
+ /** Feed a chunk of the stream; get back the events that completed with it. */
21
+ push(chunk) {
22
+ this.#buffer += chunk;
23
+ const out = [];
24
+ // Frames are separated by a blank line. Normalize completed line endings first: an SSE line may
25
+ // end \n, \r\n or bare \r, but a trailing \r may be the first byte of a split \r\n pair.
26
+ const trailingCR = this.#buffer.endsWith("\r");
27
+ const complete = trailingCR ? this.#buffer.slice(0, -1) : this.#buffer;
28
+ this.#buffer = complete.replace(/\r\n|\r/g, "\n") + (trailingCR ? "\r" : "");
29
+ for (;;) {
30
+ const sep = this.#buffer.indexOf("\n\n");
31
+ if (sep === -1)
32
+ break; // an incomplete frame stays in the buffer until the rest of it arrives
33
+ const frame = this.#buffer.slice(0, sep);
34
+ this.#buffer = this.#buffer.slice(sep + 2);
35
+ const ev = parseFrame(frame);
36
+ if (ev)
37
+ out.push(ev);
38
+ }
39
+ return out;
40
+ }
41
+ }
42
+ /** Tracks the mandatory per-connection event sequence. The first missing frame is enough to make
43
+ * state uncertain, so transports close and reconnect rather than applying a possibly stale tail. */
44
+ export class StreamSequence {
45
+ #next = 1;
46
+ observe(event) {
47
+ if (!Number.isSafeInteger(event.seq) || event.seq !== this.#next) {
48
+ return { expected: this.#next, received: event.seq };
49
+ }
50
+ this.#next++;
51
+ return null;
52
+ }
53
+ }
54
+ /** One SSE frame -> one event, or null for a frame that carries none (a heartbeat, a comment).
55
+ *
56
+ * A comment is not an error and not an event: it is how a heartbeat is invisible to a consumer while
57
+ * still keeping an idle proxy from closing the connection out from under a live status watch. */
58
+ function parseFrame(frame) {
59
+ const data = [];
60
+ for (const line of frame.split("\n")) {
61
+ if (line === "" || line.startsWith(":"))
62
+ continue; // comment / heartbeat
63
+ const colon = line.indexOf(":");
64
+ const field = colon === -1 ? line : line.slice(0, colon);
65
+ // "data: x" and "data:x" are the same field; exactly one leading space is stripped.
66
+ let value = colon === -1 ? "" : line.slice(colon + 1);
67
+ if (value.startsWith(" "))
68
+ value = value.slice(1);
69
+ // `event:`, `id:` and `retry:` are SSE fields this protocol does not use (v1 emits no id: lines
70
+ // at all — §7). Ignoring them rather than failing is the same rule as ignoring an unknown event
71
+ // type: a minor addition must not break an older client.
72
+ if (field === "data")
73
+ data.push(value);
74
+ }
75
+ if (data.length === 0)
76
+ return null;
77
+ try {
78
+ return JSON.parse(data.join("\n"));
79
+ }
80
+ catch {
81
+ // A frame we cannot parse is not a reason to tear down a live stream. Skip it: the protocol is
82
+ // state-convergent, so the next snapshot cycle repairs whatever we missed.
83
+ return null;
84
+ }
85
+ }
86
+ /** What a stream event does to a store. This is the consumer's half of the event table (spec §4), and
87
+ * it is exported because it IS the protocol — a host feeding a store from its own transport should
88
+ * not have to reimplement the switch and get `synced` subtly wrong.
89
+ *
90
+ * Returns the paths that flashed, so a UI can highlight them. */
91
+ export function applyStreamEvent(store, ev) {
92
+ switch (ev.type) {
93
+ case "reset":
94
+ store.beginSnapshot();
95
+ return [];
96
+ case "added":
97
+ case "modified":
98
+ if (!ev.object)
99
+ return [];
100
+ return store.applyServerEvent(ev.object, { redacted: ev.redacted }).flashed;
101
+ case "deleted":
102
+ if (ev.identity?.uid)
103
+ store.removeResource(ev.identity.uid);
104
+ return [];
105
+ case "synced":
106
+ store.endSnapshot();
107
+ return [];
108
+ default:
109
+ // An unknown event type MUST be ignored, not treated as an error (spec §0). That is what lets
110
+ // the gateway add an optional event type later without breaking a browser nobody can update.
111
+ return [];
112
+ }
113
+ }
114
+ /** Consume a resource stream over fetch, feeding a store. Use this when the gateway authenticates
115
+ * with a bearer token — native EventSource cannot send the header. */
116
+ export function connectResourceStream(url, store, opts = {}) {
117
+ if (opts.signal?.aborted)
118
+ return { close: () => { }, closed: Promise.resolve() };
119
+ const controller = new AbortController();
120
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
121
+ opts.signal?.addEventListener("abort", () => controller.abort(), { once: true });
122
+ const closed = (async () => {
123
+ const res = await fetchImpl(url, {
124
+ signal: controller.signal,
125
+ headers: { Accept: "text/event-stream", ...opts.headers },
126
+ // The stream IS the response body; a cached one is a stream that never moves.
127
+ cache: "no-store",
128
+ });
129
+ if (!res.ok || !res.body) {
130
+ opts.onError?.("INTERNAL", `stream: HTTP ${res.status}`, true);
131
+ return;
132
+ }
133
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
134
+ const decoder = new SSEDecoder();
135
+ const sequence = new StreamSequence();
136
+ try {
137
+ for (;;) {
138
+ const { done, value } = await reader.read();
139
+ if (done)
140
+ return;
141
+ for (const ev of decoder.push(value)) {
142
+ if (feed(store, sequence, ev, opts)) {
143
+ controller.abort(); // terminal: stop, and do NOT come back
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ }
149
+ catch (err) {
150
+ if (!controller.signal.aborted)
151
+ throw err;
152
+ }
153
+ finally {
154
+ reader.cancel().catch(() => { });
155
+ }
156
+ })();
157
+ return {
158
+ close: () => controller.abort(),
159
+ closed: closed.catch(() => { }),
160
+ };
161
+ }
162
+ /** Consume a resource stream with the browser's native EventSource. This is the same-origin
163
+ * session-cookie path — the baseline a v1 gateway MUST support, because a cookie is the only
164
+ * credential EventSource can carry. */
165
+ export function connectWithEventSource(url, store, opts = {}) {
166
+ if (opts.signal?.aborted)
167
+ return { close: () => { }, closed: Promise.resolve() };
168
+ const es = new EventSource(url, { withCredentials: true });
169
+ const sequence = new StreamSequence();
170
+ let resolve;
171
+ const closed = new Promise((r) => {
172
+ resolve = r;
173
+ });
174
+ const shut = () => {
175
+ es.close();
176
+ resolve();
177
+ };
178
+ es.onmessage = (e) => {
179
+ let ev;
180
+ try {
181
+ ev = JSON.parse(e.data);
182
+ }
183
+ catch {
184
+ return; // an unparseable frame is not a reason to tear down a live stream
185
+ }
186
+ if (feed(store, sequence, ev, opts))
187
+ shut();
188
+ };
189
+ // EventSource's `error` is also fired on a transport hiccup, where its OWN reconnect is the
190
+ // correct behaviour and we must not interfere. Only a terminal PROTOCOL error (which arrives as a
191
+ // message, above) closes the connection — and it must, or this reconnects forever.
192
+ es.onerror = () => {
193
+ if (es.readyState === EventSource.CLOSED)
194
+ resolve();
195
+ };
196
+ opts.signal?.addEventListener("abort", shut, { once: true });
197
+ return { close: shut, closed };
198
+ }
199
+ /** Apply one event; returns true if the stream must now be closed. */
200
+ function feed(store, sequence, ev, opts) {
201
+ const gap = sequence.observe(ev);
202
+ if (gap) {
203
+ opts.onGap?.(gap.expected, gap.received);
204
+ return true;
205
+ }
206
+ if (ev.type === "error") {
207
+ opts.onError?.(ev.code ?? "INTERNAL", ev.message ?? "", ev.terminal ?? false);
208
+ return ev.terminal === true;
209
+ }
210
+ const flashed = applyStreamEvent(store, ev);
211
+ if (ev.type === "synced")
212
+ opts.onSynced?.();
213
+ opts.onChange?.(flashed);
214
+ return false;
215
+ }
@@ -0,0 +1,98 @@
1
+ import type { Change, Conflict, EditabilityPolicy, KRMObject, Path, Redaction } from "./types.ts";
2
+ /** What one server event did, for a host that wants to animate it. `flashed` is an OUTPUT, not
3
+ * state: the host highlights those paths and forgets them. */
4
+ export interface ApplyResult {
5
+ /** The uid was not known: this is an arrival, not a change. */
6
+ added: boolean;
7
+ /** Keys or array elements appeared or disappeared — a UI must rebuild rows, not just re-read values. */
8
+ structural: boolean;
9
+ /** Paths the server moved, in read-only regions and in editable regions the user had not touched. */
10
+ flashed: Path[];
11
+ /** The paths this object is now conflicted at (the complete set, not just the new ones). */
12
+ conflicts: Path[];
13
+ }
14
+ export interface ApplyOptions {
15
+ /** Values the projection withheld. Their paths are read-only, never dirtiable, and never in a patch. */
16
+ redacted?: (Redaction | {
17
+ path: Path;
18
+ rev: number;
19
+ })[];
20
+ }
21
+ export declare class LiveResourceStore {
22
+ #private;
23
+ constructor(policy?: EditabilityPolicy);
24
+ /** `added` and `modified` — the only two upsert spellings, and they are treated identically
25
+ * (spec §4). Both mean "here is this object's complete current state". */
26
+ applyServerEvent(object: KRMObject, opts?: ApplyOptions): ApplyResult;
27
+ /** `deleted`. The object is gone, and so is any draft of it — the user was editing something that
28
+ * no longer exists. (A recreate under the same name is a DIFFERENT uid and starts clean; that is
29
+ * the whole reason identity is the uid.) */
30
+ removeResource(id: string): void;
31
+ /** `reset`. Mark every known uid unseen — and prune NOTHING yet. */
32
+ beginSnapshot(): void;
33
+ /** `synced`. The snapshot is complete, so what it did not mention is genuinely gone. This is the
34
+ * only place anything is pruned, and it is what removes an object deleted while the consumer was
35
+ * disconnected — the one event the consumer never saw. */
36
+ endSnapshot(): void;
37
+ /** The save succeeded and this is the object it produced. The watch will echo it too — and that
38
+ * echo is a harmless no-op (I-IDEMPOTENT) — but a UI should not have to wait for it to stop
39
+ * showing the field as dirty. */
40
+ adoptSaved(object: KRMObject): void;
41
+ setValue(id: string, path: Path, value: unknown): void;
42
+ /** For a map entry or an object key the user deleted. It stays deleted across watch events (the
43
+ * merge sees ours=undefined, base=theirs and keeps the deletion) and becomes a `null` in the patch. */
44
+ removeKey(id: string, path: Path): void;
45
+ /** `path` addresses the MAP; `key` is the new entry. A new row in a UI starts empty. */
46
+ addKey(id: string, path: Path, key: string, value?: unknown): void;
47
+ /** `path` addresses the MAP. Order is preserved — renaming a label must not make its row jump to
48
+ * the bottom of the list while the user is typing in it. */
49
+ renameKey(id: string, path: Path, oldKey: string, newKey: string): void;
50
+ /** Throw the local edit away and take the server's value — which is also how a conflict is
51
+ * resolved in the server's favour. */
52
+ revert(id: string, path: Path): void;
53
+ /** Resolve a conflict by keeping the server's value. */
54
+ takeTheirs(id: string, path: Path): void;
55
+ ids(): string[];
56
+ /** The authoritative server object, including live `status`. */
57
+ server(id: string): KRMObject;
58
+ /** The server object with the editable regions merged over it. What a UI renders and edits. */
59
+ draft(id: string): KRMObject;
60
+ /** Read-only convenience for the watch UI. A kind with no status simply has none. */
61
+ status(id: string): unknown;
62
+ /** Every pending edit, derived fresh by comparing the draft to the server object. Arrays appear
63
+ * whole (§4.1), which is also what RFC 7386 wants. */
64
+ changes(id: string): Change[];
65
+ /** R-DERIVED: never a stored flag. A read-only path is never dirty by construction. */
66
+ isDirty(id: string, path: Path): boolean;
67
+ conflicts(id: string): Conflict[];
68
+ /** The policy, minus this object's redacted paths. A redacted value is read-only for exactly the
69
+ * same reason `status` is: it is not the user's to change — and here, they never even saw it. */
70
+ isEditable(id: string, path: Path): boolean;
71
+ /**
72
+ * The paths whose values the gateway withheld. **This is the only place a consumer learns that a
73
+ * redacted field exists**, and rendering it is the whole of keys-only disclosure.
74
+ *
75
+ * The value itself is not in the object — there is no mask, no placeholder, nothing (spec §3). So a
76
+ * UI showing `token ••••••` reads it from HERE, not from the object:
77
+ *
78
+ * ```ts
79
+ * for (const { path } of store.redactions(uid)) row(path, "••••••", { readOnly: true });
80
+ * ```
81
+ *
82
+ * That is deliberate. A placeholder sitting in the object is a value a browser can save back — and
83
+ * a merge patch carrying it writes the placeholder over the real Secret.
84
+ */
85
+ redactions(id: string): {
86
+ path: Path;
87
+ rev: number;
88
+ }[];
89
+ /** An RFC 7386 merge patch of the editable changes, or null when there is nothing to save.
90
+ *
91
+ * Built from the user's EDITS — never from a diff of the whole object against the server. The
92
+ * object on the wire is a projection: a path the projection removed is simply not there, and a
93
+ * whole-object diff would read that absence as a deletion and try to patch it away (spec §3). */
94
+ patch(id: string): Record<string, unknown> | null;
95
+ /** A coarse "something changed" signal — the host re-renders and re-queries. Returns an
96
+ * unsubscribe. */
97
+ subscribe(cb: () => void): () => void;
98
+ }
package/dist/store.js ADDED
@@ -0,0 +1,340 @@
1
+ // LiveResourceStore — the consumer half of the protocol (spec §10) and the engine of
2
+ // docs/client-state-model.md.
3
+ //
4
+ // It holds, per uid:
5
+ //
6
+ // server(id) the authoritative projected object. REPLACED by every added/modified — never
7
+ // deep-merged, because a deep merge of complete objects cannot express a removal and
8
+ // so resurrects the field the server just deleted (spec §4.1). It is also the merge
9
+ // BASE, and it advances on every event (I-BASESHIFT).
10
+ // draft(id) the same object with the editable regions three-way merged against the user's edits.
11
+ //
12
+ // Dirtiness is not in that list, on purpose: it is DERIVED, every time it is asked for (R-DERIVED).
13
+ // A cached dirty set goes stale on the next watch event — the field the server converged onto stays
14
+ // flagged forever, or unflags itself only on the next click.
15
+ import { clone, deepEqual, isPlainObject } from "./deep.js";
16
+ import { reconcile } from "./merge.js";
17
+ import { get, has, isPrefix, parsePointer, pathKey, removeAt, setAt } from "./path.js";
18
+ import { defaultPolicy } from "./policy.js";
19
+ export class LiveResourceStore {
20
+ #policy;
21
+ #resources = new Map();
22
+ #subscribers = new Set();
23
+ /** Non-null exactly while a snapshot cycle is open: the uids seen since `beginSnapshot()`.
24
+ * Pruning reads it in `endSnapshot()` and NOWHERE else — a cycle that never completes must prune
25
+ * nothing, or a network hiccup makes the user watch half their resources evaporate (spec §5). */
26
+ #seen = null;
27
+ constructor(policy = defaultPolicy) {
28
+ this.#policy = policy;
29
+ }
30
+ // ------------------------------------------------------------------ the stream in --
31
+ /** `added` and `modified` — the only two upsert spellings, and they are treated identically
32
+ * (spec §4). Both mean "here is this object's complete current state". */
33
+ applyServerEvent(object, opts = {}) {
34
+ const id = object.metadata.uid;
35
+ const incoming = clone(object);
36
+ const redacted = (opts.redacted ?? []).map((entry) => ({
37
+ path: typeof entry.path === "string" ? parsePointer(entry.path) : [...entry.path],
38
+ rev: entry.rev,
39
+ }));
40
+ this.#seen?.add(id);
41
+ const existing = this.#resources.get(id);
42
+ if (!existing) {
43
+ // A resource we have never seen: base = draft = incoming. There is nothing to merge, and
44
+ // nothing flashes — an arrival is not a change.
45
+ this.#resources.set(id, { server: incoming, draft: clone(incoming), redacted, conflicts: new Map() });
46
+ this.#notify();
47
+ return { added: true, structural: true, flashed: [], conflicts: [] };
48
+ }
49
+ const state = {
50
+ regions: this.#regionsFor(incoming, redacted.map((r) => r.path)),
51
+ conflicts: existing.conflicts,
52
+ flashed: [],
53
+ };
54
+ const merged = reconcile(state, existing.server, existing.draft, incoming);
55
+ const structural = !sameShape(existing.draft, merged);
56
+ // The REPLACEMENT (spec §4.1) and the base shift (I-BASESHIFT), in one line. Everything above
57
+ // reconciled against the OLD server object; from here on, `incoming` is the base.
58
+ existing.server = incoming;
59
+ existing.draft = merged;
60
+ existing.redacted = redacted;
61
+ this.#notify();
62
+ return {
63
+ added: false,
64
+ structural,
65
+ flashed: state.flashed,
66
+ conflicts: [...existing.conflicts.values()].map((c) => c.path),
67
+ };
68
+ }
69
+ /** `deleted`. The object is gone, and so is any draft of it — the user was editing something that
70
+ * no longer exists. (A recreate under the same name is a DIFFERENT uid and starts clean; that is
71
+ * the whole reason identity is the uid.) */
72
+ removeResource(id) {
73
+ if (this.#resources.delete(id))
74
+ this.#notify();
75
+ }
76
+ /** `reset`. Mark every known uid unseen — and prune NOTHING yet. */
77
+ beginSnapshot() {
78
+ this.#seen = new Set();
79
+ }
80
+ /** `synced`. The snapshot is complete, so what it did not mention is genuinely gone. This is the
81
+ * only place anything is pruned, and it is what removes an object deleted while the consumer was
82
+ * disconnected — the one event the consumer never saw. */
83
+ endSnapshot() {
84
+ const seen = this.#seen;
85
+ if (!seen)
86
+ return; // a `synced` with no open cycle: ignore, do not prune the world
87
+ this.#seen = null;
88
+ let pruned = false;
89
+ for (const id of [...this.#resources.keys()]) {
90
+ if (!seen.has(id)) {
91
+ this.#resources.delete(id);
92
+ pruned = true;
93
+ }
94
+ }
95
+ if (pruned)
96
+ this.#notify();
97
+ }
98
+ /** The save succeeded and this is the object it produced. The watch will echo it too — and that
99
+ * echo is a harmless no-op (I-IDEMPOTENT) — but a UI should not have to wait for it to stop
100
+ * showing the field as dirty. */
101
+ adoptSaved(object) {
102
+ const existing = this.#resources.get(object.metadata.uid);
103
+ if (!existing) {
104
+ this.applyServerEvent(object);
105
+ return;
106
+ }
107
+ // A save response is another complete server object. Reconcile it rather than replacing the
108
+ // draft so an edit made after the request was sent survives the response arriving.
109
+ this.applyServerEvent(object, { redacted: existing.redacted });
110
+ }
111
+ // ------------------------------------------------------------------------- edits --
112
+ setValue(id, path, value) {
113
+ const res = this.#editable(id, path);
114
+ setAt(res.draft, path, clone(value));
115
+ this.#settle(res, path);
116
+ }
117
+ /** For a map entry or an object key the user deleted. It stays deleted across watch events (the
118
+ * merge sees ours=undefined, base=theirs and keeps the deletion) and becomes a `null` in the patch. */
119
+ removeKey(id, path) {
120
+ const res = this.#editable(id, path);
121
+ removeAt(res.draft, path);
122
+ this.#settle(res, path);
123
+ }
124
+ /** `path` addresses the MAP; `key` is the new entry. A new row in a UI starts empty. */
125
+ addKey(id, path, key, value = "") {
126
+ const res = this.#editable(id, [...path, key]);
127
+ setAt(res.draft, [...path, key], clone(value));
128
+ this.#settle(res, [...path, key]);
129
+ }
130
+ /** `path` addresses the MAP. Order is preserved — renaming a label must not make its row jump to
131
+ * the bottom of the list while the user is typing in it. */
132
+ renameKey(id, path, oldKey, newKey) {
133
+ const res = this.#editable(id, [...path, oldKey]);
134
+ this.#editable(id, [...path, newKey]);
135
+ const map = get(res.draft, path);
136
+ if (!isPlainObject(map))
137
+ throw new Error(`krm-stream: ${pathKey(path)} is not a map`);
138
+ if (!Object.hasOwn(map, oldKey))
139
+ throw new Error(`krm-stream: key ${JSON.stringify(oldKey)} does not exist`);
140
+ if (oldKey !== newKey && Object.hasOwn(map, newKey)) {
141
+ throw new Error(`krm-stream: key ${JSON.stringify(newKey)} already exists`);
142
+ }
143
+ const renamed = {};
144
+ for (const [k, v] of Object.entries(map))
145
+ renamed[k === oldKey ? newKey : k] = v;
146
+ setAt(res.draft, path, renamed);
147
+ this.#settle(res, [...path, oldKey]);
148
+ this.#settle(res, [...path, newKey]);
149
+ }
150
+ /** Throw the local edit away and take the server's value — which is also how a conflict is
151
+ * resolved in the server's favour. */
152
+ revert(id, path) {
153
+ const res = this.#editable(id, path);
154
+ if (has(res.server, path))
155
+ setAt(res.draft, path, clone(get(res.server, path)));
156
+ else
157
+ removeAt(res.draft, path);
158
+ this.#settle(res, path);
159
+ }
160
+ /** Resolve a conflict by keeping the server's value. */
161
+ takeTheirs(id, path) {
162
+ this.revert(id, path);
163
+ }
164
+ // ----------------------------------------------------------------------- queries --
165
+ ids() {
166
+ return [...this.#resources.keys()];
167
+ }
168
+ /** The authoritative server object, including live `status`. */
169
+ server(id) {
170
+ return clone(this.#must(id).server);
171
+ }
172
+ /** The server object with the editable regions merged over it. What a UI renders and edits. */
173
+ draft(id) {
174
+ return clone(this.#must(id).draft);
175
+ }
176
+ /** Read-only convenience for the watch UI. A kind with no status simply has none. */
177
+ status(id) {
178
+ return clone(this.#must(id).server.status);
179
+ }
180
+ /** Every pending edit, derived fresh by comparing the draft to the server object. Arrays appear
181
+ * whole (§4.1), which is also what RFC 7386 wants. */
182
+ changes(id) {
183
+ const res = this.#must(id);
184
+ const out = [];
185
+ this.#diff(this.#regionsFor(res.server, res.redacted.map((r) => r.path)), [], res.server, res.draft, out);
186
+ return out;
187
+ }
188
+ /** R-DERIVED: never a stored flag. A read-only path is never dirty by construction. */
189
+ isDirty(id, path) {
190
+ const res = this.#must(id);
191
+ if (this.isEditable(id, path))
192
+ return !deepEqual(get(res.server, path), get(res.draft, path));
193
+ // A container (`[]`, `["metadata"]`) is dirty iff something editable underneath it is.
194
+ if (!this.#policy.containsEditable(res.server, path))
195
+ return false;
196
+ return this.changes(id).some((c) => isPrefix(path, c.path));
197
+ }
198
+ conflicts(id) {
199
+ return [...this.#must(id).conflicts.values()].map((c) => ({ path: [...c.path], theirs: clone(c.theirs) }));
200
+ }
201
+ /** The policy, minus this object's redacted paths. A redacted value is read-only for exactly the
202
+ * same reason `status` is: it is not the user's to change — and here, they never even saw it. */
203
+ isEditable(id, path) {
204
+ const res = this.#must(id);
205
+ return this.#regionsFor(res.server, res.redacted.map((r) => r.path)).editable(path);
206
+ }
207
+ /**
208
+ * The paths whose values the gateway withheld. **This is the only place a consumer learns that a
209
+ * redacted field exists**, and rendering it is the whole of keys-only disclosure.
210
+ *
211
+ * The value itself is not in the object — there is no mask, no placeholder, nothing (spec §3). So a
212
+ * UI showing `token ••••••` reads it from HERE, not from the object:
213
+ *
214
+ * ```ts
215
+ * for (const { path } of store.redactions(uid)) row(path, "••••••", { readOnly: true });
216
+ * ```
217
+ *
218
+ * That is deliberate. A placeholder sitting in the object is a value a browser can save back — and
219
+ * a merge patch carrying it writes the placeholder over the real Secret.
220
+ */
221
+ redactions(id) {
222
+ return this.#must(id).redacted.map((r) => ({ path: [...r.path], rev: r.rev }));
223
+ }
224
+ /** An RFC 7386 merge patch of the editable changes, or null when there is nothing to save.
225
+ *
226
+ * Built from the user's EDITS — never from a diff of the whole object against the server. The
227
+ * object on the wire is a projection: a path the projection removed is simply not there, and a
228
+ * whole-object diff would read that absence as a deletion and try to patch it away (spec §3). */
229
+ patch(id) {
230
+ const changes = this.changes(id);
231
+ if (changes.length === 0)
232
+ return null;
233
+ const out = {};
234
+ for (const c of changes)
235
+ setAt(out, c.path, c.new === undefined ? null : clone(c.new));
236
+ return out;
237
+ }
238
+ /** A coarse "something changed" signal — the host re-renders and re-queries. Returns an
239
+ * unsubscribe. */
240
+ subscribe(cb) {
241
+ this.#subscribers.add(cb);
242
+ return () => this.#subscribers.delete(cb);
243
+ }
244
+ // ---------------------------------------------------------------------- internals --
245
+ #must(id) {
246
+ const res = this.#resources.get(id);
247
+ if (!res)
248
+ throw new Error(`krm-stream: no resource ${JSON.stringify(id)}`);
249
+ return res;
250
+ }
251
+ /** Every edit goes through here: a write to `status`, to `metadata.name`, or to a redacted path is
252
+ * refused. The engine is not the security boundary — the gateway rejects such a patch too — but a
253
+ * UI that cannot even form the edit is the difference between "safe" and "safe if the client is
254
+ * honest". */
255
+ #editable(id, path) {
256
+ const res = this.#must(id);
257
+ if (!this.isEditable(id, path)) {
258
+ throw new Error(`krm-stream: ${pathKey(path)} is read-only`);
259
+ }
260
+ return res;
261
+ }
262
+ #regionsFor(object, redacted) {
263
+ const insideRedacted = (path) => redacted.some((r) => isPrefix(r, path));
264
+ const containsRedacted = (path) => redacted.some((r) => isPrefix(path, r));
265
+ return {
266
+ editable: (path) => !insideRedacted(path) && !containsRedacted(path) && this.#policy.isEditable(object, path),
267
+ container: (path) => !insideRedacted(path) && this.#policy.containsEditable(object, path),
268
+ listMapKeys: (path) => this.#policy.listMapKeys?.(object, path),
269
+ };
270
+ }
271
+ /** After an edit: a conflict whose path the draft has now brought back to the server's value is no
272
+ * longer a conflict. (Typing the server's value by hand resolves it, and so does `revert`.) */
273
+ #settle(res, path) {
274
+ for (const [k, c] of res.conflicts) {
275
+ if (!isPrefix(c.path, path) && !isPrefix(path, c.path))
276
+ continue;
277
+ if (deepEqual(get(res.server, c.path), get(res.draft, c.path)))
278
+ res.conflicts.delete(k);
279
+ }
280
+ this.#notify();
281
+ }
282
+ #diff(regions, path, srv, drf, out) {
283
+ if (regions.editable(path)) {
284
+ const defined = [srv, drf].filter((v) => v !== undefined);
285
+ if (defined.length > 0 && defined.every(isPlainObject)) {
286
+ for (const k of unionKeys(srv, drf)) {
287
+ this.#diff(regions, [...path, k], srv?.[k], drf?.[k], out);
288
+ }
289
+ return;
290
+ }
291
+ // Scalars, arrays, and shape changes are one atomic value — the same rule the merge uses, so
292
+ // "what is dirty" and "what was merged atomically" can never disagree.
293
+ if (!deepEqual(srv, drf)) {
294
+ const kind = srv === undefined ? "add" : drf === undefined ? "delete" : "update";
295
+ out.push({ path: [...path], kind, old: clone(srv), new: clone(drf) });
296
+ }
297
+ return;
298
+ }
299
+ if (regions.container(path)) {
300
+ for (const k of unionKeys(srv, drf)) {
301
+ this.#diff(regions, [...path, k], srv?.[k], drf?.[k], out);
302
+ }
303
+ }
304
+ // Read-only: the draft IS the server there. Nothing to diff, nothing to save.
305
+ }
306
+ #notify() {
307
+ for (const cb of this.#subscribers)
308
+ cb();
309
+ }
310
+ }
311
+ function unionKeys(...values) {
312
+ const keys = [];
313
+ const seen = new Set();
314
+ for (const v of values) {
315
+ if (!isPlainObject(v))
316
+ continue;
317
+ for (const k of Object.keys(v)) {
318
+ if (!seen.has(k)) {
319
+ seen.add(k);
320
+ keys.push(k);
321
+ }
322
+ }
323
+ }
324
+ return keys;
325
+ }
326
+ /** Did keys or array elements appear or disappear? A UI can re-read values cheaply; it must REBUILD
327
+ * when rows come and go — that is what `structural` tells it. Scalar value changes are not structural. */
328
+ function sameShape(a, b) {
329
+ if (isPlainObject(a) && isPlainObject(b)) {
330
+ const ka = Object.keys(a);
331
+ const kb = Object.keys(b);
332
+ if (ka.length !== kb.length)
333
+ return false;
334
+ return ka.every((k) => Object.hasOwn(b, k) && sameShape(a[k], b[k]));
335
+ }
336
+ if (Array.isArray(a) && Array.isArray(b)) {
337
+ return a.length === b.length && a.every((x, i) => sameShape(x, b[i]));
338
+ }
339
+ return isPlainObject(a) === isPlainObject(b) && Array.isArray(a) === Array.isArray(b);
340
+ }