From 8c48eb29c06161f2d1a7986c384eeec977c521ea Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 4 Oct 2026 21:03:17 +0000 Subject: [PATCH] docs(examples): add tested deletion-recovery and keep-local editor recipes Complete priority 2 of proposal 0006 with copyable host code that uses only the store's public methods, executed by the client suite through stream events. - retainRecoveryCopy keeps a detached copy of one fixed UID's draft and unsaved edits from the moment it starts, refreshed on every store notification, so work typed just before a deletion or snapshot pruning can be copied out afterwards. The copy is bound to the original identity, expires after a host-chosen lifetime, is released by dispose(), and is never applied or reconciled: a replacement UID opens clean. - keepLocal resolves a conflict in favour of the local value: capture it (or its absence), revert, and reapply it in one synchronous call, leaving an ordinary edit against the reviewed version. It covers nested paths, deletions both ways and whole arrays, refuses a path a policy or later redaction made read-only without changing anything, and leaves unrelated edits and conflicts alone. The saving and Vue guides and the examples index link to the recipes, and proposal 0006 marks priority 2 complete. Co-Authored-By: Claude Opus 5.5 --- ...006-stream-and-save-implementation-plan.md | 10 +- docs/saving.md | 7 +- docs/vue.md | 14 +- examples/README.md | 3 + examples/editor-recipes/README.md | 50 +++++ examples/editor-recipes/keepLocal.ts | 44 ++++ examples/editor-recipes/recoveryCopy.ts | 70 ++++++ packages/krm-stream/test/recipes.test.ts | 206 ++++++++++++++++++ packages/krm-stream/tsconfig.test.json | 8 +- 9 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 examples/editor-recipes/README.md create mode 100644 examples/editor-recipes/keepLocal.ts create mode 100644 examples/editor-recipes/recoveryCopy.ts create mode 100644 packages/krm-stream/test/recipes.test.ts diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index b2fcc22..9c51be1 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -29,7 +29,7 @@ Use the [adoption guide](../adopting.md), [saving guide](../saving.md) and | Priority | Remaining work | Completion evidence | |---|---|---| | 1 | Define convergence precisely — complete | [Evidence](../../conformance/README.md#convergence-evidence) | -| 2 | Publish tested deletion-recovery and keep-local recipes | Executed examples preserve unsaved work and unrelated conflicts. | +| 2 | Publish tested deletion-recovery and keep-local recipes — complete | [Recipes](../../examples/editor-recipes/README.md) and [tests](../../packages/krm-stream/test/recipes.test.ts) | | 3 | Harden real-API save composition and identity races | Exact-commit API evidence, separate from fake-client CI. | | 4 | Measure and implement upstream continuation | Same-workload comparison proves continuity, bounded recovery and authorization. | @@ -42,11 +42,15 @@ Completed: [contract and executable final-write evidence](../../conformance/READ ## 2. Tested adoption recipes +Completed: the [editor recipes](../../examples/editor-recipes/README.md) keep a recovery copy before +deletion or pruning and keep a local value in a conflict, executed by the client suite through +stream events and linked from the saving and Vue guides. The acceptance criteria below are what +those tests cover. + Keep the existing [user-facing outcomes](../saving.md#what-the-person-editing-sees) and host-owned [receipt contract](../saving.md#answer-204-or-a-receipt-and-let-the-watch-echo-it) in the saving guide. -The remaining work is executable guidance, using existing store and Vue example tests. -When implementing after proposal 0009, use its event-only store input, consolidated connector and +For proposal 0009's create/delete guidance, use its event-only store input, consolidated connector and guarded response contract. Successful creates/deletes remain host-owned pending confirmations until their echo or a completed snapshot confirms state; do not restore unguarded adoption or optimistic store deletion in a recipe. Keep server acceptance separate from confirmation to avoid duplicate diff --git a/docs/saving.md b/docs/saving.md index d8c334a..39c5ce6 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -132,7 +132,7 @@ and any receipt or Git workflow; connection state is not a save guarantee. | Situation / outcome | Suggested presentation | Host action | |---|---|---| | `version-stale`, no field conflicts | “Configuration refreshed. Your edits are intact; review and save again.” | Capture a new intent on the next deliberate Save. Do not show an empty conflict panel. | -| `draft-conflict` | Show local and current values at each conflicting field. | Offer explicit resolution through store APIs; keep the rest of the form visible. | +| `draft-conflict` | Show local and current values at each conflicting field. | Offer explicit resolution: `revert` takes the server's value, the [keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict) keeps the person's. Keep the rest of the form visible. | | Connection retrying or `recovering` | “Reconnecting. Your unsaved changes are still here.” | Disable writes until live; after a refused GET, require a later accepted guarded read before another write. | | `saved`, watch confirmation pending | “Saved to Kubernetes; waiting for live confirmation.” | Preserve later typing. Track any receipt separately from draft state. | | Session expiry or access denial | Explain sign-in or access outcome. | Handle identity-scoped recovery; do not retry terminal auth failures indefinitely or label them field conflicts. | @@ -141,8 +141,9 @@ and any receipt or Git workflow; connection state is not a save guarantee. “Unsaved changes are still here” holds while the UID remains in the store. `removeResource` and snapshot pruning discard a deleted object's draft, so if recovery after deletion matters, keep a detached copy as edits change, **before** removal. Scope it to the original identity and UID, with a -host-defined lifetime; it is for recovery, not a second draft to reconcile. Resolve conflicts through -the store's APIs rather than a second application conflict registry. +host-defined lifetime; it is for recovery, not a second draft to reconcile. The tested +[recovery-copy recipe](../examples/editor-recipes/README.md#recover-work-after-a-deletion) does +this. Resolve conflicts through the store's APIs rather than a second application conflict registry. ## Creating and deleting whole objects diff --git a/docs/vue.md b/docs/vue.md index 400e1f7..e73e826 100644 --- a/docs/vue.md +++ b/docs/vue.md @@ -16,14 +16,16 @@ Use `resource.value?.draft` in script and `resource?.draft` in a template. A mis `null`. For deletion recovery, capture the initial draft and subscribe to store notifications, retaining `store.draft(uid)` while the fixed UID exists and keeping the last copy once it is absent. Capturing only when Save is clicked loses subsequent edits. The null notification is too late to read the -removed draft. A [tested subscription recipe](proposals/0006-stream-and-save-implementation-plan.md#deletion-recovery-copy) -is pending; follow the [retention guidance](saving.md#what-the-person-editing-sees) and never apply a -recovery copy automatically to a replacement UID. +removed draft. Use the tested +[recovery-copy recipe](../examples/editor-recipes/README.md#recover-work-after-a-deletion) beside +`useLiveResource` for the same UID, dispose it with the editor, and never apply a recovery copy +automatically to a replacement UID. For a changing selection, remount a keyed editor or create a new effect scope. Use store -methods such as `setValue`, `removeKey` and conflict resolution methods for edits: do not bind -`v-model` directly to the draft snapshot. Store reads are detached copies, and editing them bypasses -policy checks and change notifications. +methods such as `setValue`, `removeKey`, `revert` and the +[keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict) for +edits: do not bind `v-model` directly to the draft snapshot. Store reads are detached copies, and +editing them bypasses policy checks and change notifications. The connection belongs to its creator. A parent sharing one connection across many editors closes it when the parent unmounts; an editor that creates its own connection can register diff --git a/examples/README.md b/examples/README.md index fcd2df5..8d62f9b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,6 +15,9 @@ projected reconciliation, and a host-owned Kubernetes endpoint that preserves re [Vue adapter](vue/README.md) is a copyable, typechecked composable with reactivity and cleanup tests. +[Editor recipes](editor-recipes/README.md) keep a recovery copy of a draft before its object is +deleted, and resolve a conflict in favour of the local value. Both are executed by the client suite. + [Shared ConfigMap host](../gateway/kube/examples/sharedstream/README.md) is a compiled Go example with participant SelfSubjectReview, service-account SARs and shared data, bounded SSE writes, session expiry, lifecycle counters and a manually run real-cluster fixture. diff --git a/examples/editor-recipes/README.md b/examples/editor-recipes/README.md new file mode 100644 index 0000000..d848024 --- /dev/null +++ b/examples/editor-recipes/README.md @@ -0,0 +1,50 @@ +# Editor recipes + +Two small pieces of host code for situations the store deliberately leaves to the host. Copy them +into your application and change their library import to `@configbutler/krm-stream`. They use only +the store's public methods, and [their tests](../../packages/krm-stream/test/recipes.test.ts) run +with `task test`, driving the store with stream events as `connectResourceStream` delivers them. + +## Recover work after a deletion + +A `deleted` event, or a snapshot that no longer contains an object, removes the object and its draft +from the store. Once that has happened there is no draft left to read, so a copy must be taken while +the object still exists. [`retainRecoveryCopy`](recoveryCopy.ts) keeps a detached copy of one fixed +UID's draft and unsaved edits, refreshed on every store notification from the moment it starts: + +```ts +const recovery = retainRecoveryCopy(store, uid, { retainMs: 15 * 60_000 }); + +// When the editor shows that the object was removed: +const copy = recovery.copy(); // { uid, identity, draft, edits, removedAt } or null +if (copy) offerCopyOut(copy.edits); + +// When the editor for this UID is torn down: +recovery.dispose(); +``` + +`copy()` returns null while the object exists (edit the live draft instead), after `retainMs`, and +after `dispose()`. The copy is bound to the UID and identity it was made against: a recreated name +has a new UID and opens as a new editor, which never inherits the old draft. The copy is for +explicit copy-out only. It is never reconciled or applied automatically, so the store remains the +only place drafts are edited. + +## Keep the local value in a conflict + +`store.revert(uid, path)` resolves a conflict in the server's favour. [`keepLocal`](keepLocal.ts) +resolves it in the person's favour: + +```ts +for (const { path } of store.conflicts(uid)) { + if (choseLocal(path)) keepLocal(store, uid, path); // false when the path is no longer editable +} +const intent = store.captureSave(uid); // a fresh intent, bound to the version just reviewed +``` + +It captures the local value (or its absence), lets `revert` adopt the server's value and clear the +conflict, then reapplies the local value in the same synchronous call. The result is an ordinary +edit against the newer server version. Deletions stay deleted, a value the server deleted comes +back, arrays are kept whole, and deciding at a parent path resolves the conflicts beneath it. Other +edits and conflicts are untouched. A path that a policy or a later redaction makes read-only is +refused with `false`, and nothing changes. A value the server deleted is re-added as the last key +of its map, so a form that renders rows in key order may move that row. diff --git a/examples/editor-recipes/keepLocal.ts b/examples/editor-recipes/keepLocal.ts new file mode 100644 index 0000000..5e922c3 --- /dev/null +++ b/examples/editor-recipes/keepLocal.ts @@ -0,0 +1,44 @@ +import type { LiveResourceStore, Path } from "../../packages/krm-stream/src/index.ts"; + +/** Copy into the host. Resolves the conflict at `path` in favour of the person's local value — the + * opposite of `store.revert(uid, path)`, which takes the server's. + * + * The store keeps the local value in the draft and records the conflict until someone decides. To + * keep it: capture the local value (including its absence), let `revert` adopt the server's value and + * clear the conflict, then put the local value back. Both steps run in one synchronous call, so no + * stream event can land between them. The draft then differs from the server again — an ordinary + * edit, no longer a conflict — and the next `captureSave` binds it to the server version the person + * reviewed. + * + * Returns false, changing nothing, when the path is not editable: a policy or a redaction (which can + * appear after the conflict did) makes it read-only. Unrelated edits and conflicts are untouched; a + * conflict nested under `path` is resolved with it, because the local subtree is kept whole. Arrays + * are atomic, so keeping a local array keeps the whole array. */ +export function keepLocal(store: LiveResourceStore, uid: string, path: Path): boolean { + if (!store.isEditable(uid, path)) return false; + const draft = store.draft(uid); + const present = has(draft, path); + const local = present ? get(draft, path) : undefined; + store.revert(uid, path); + if (present) store.setValue(uid, path, local); + else if (has(store.draft(uid), path)) store.removeKey(uid, path); + return true; +} + +function get(value: unknown, path: Path): unknown { + let cur = value; + for (const segment of path) { + if (cur === null || typeof cur !== "object") return undefined; + cur = (cur as Record)[segment]; + } + return cur; +} + +function has(value: unknown, path: Path): boolean { + let cur = value; + for (const segment of path) { + if (cur === null || typeof cur !== "object" || !Object.hasOwn(cur, segment)) return false; + cur = (cur as Record)[segment]; + } + return true; +} diff --git a/examples/editor-recipes/recoveryCopy.ts b/examples/editor-recipes/recoveryCopy.ts new file mode 100644 index 0000000..b65a1e0 --- /dev/null +++ b/examples/editor-recipes/recoveryCopy.ts @@ -0,0 +1,70 @@ +import type { Change, KRMObject, LiveResourceStore } from "../../packages/krm-stream/src/index.ts"; + +/** What a recovery copy holds: the person's draft and unsaved edits, as they last stood before the + * object left the store, bound to the identity they were made against. */ +export interface RecoveryCopy { + uid: string; + identity: { apiVersion: string; kind: string; namespace?: string; name: string }; + draft: KRMObject; + edits: Change[]; + /** When the object left the store: a `deleted` event or a snapshot that no longer sent it. */ + removedAt: number; +} + +export interface RecoveryOptions { + /** How long a removed object's copy stays available. Host policy; defaults to 15 minutes. */ + retainMs?: number; + /** Injectable clock for tests. */ + now?: () => number; +} + +/** Copy into the host. Keeps a detached copy of one fixed UID's draft while it exists, so the + * person's work can be copied out after a deletion or snapshot pruning removes the object — and its + * draft — from the store. The store cannot help after the fact: once the UID is gone there is no + * draft left to read, so the copy is refreshed on every notification while it exists. + * + * The copy is for explicit copy-out only. It is never applied automatically, never reconciled, and + * never offered to a replacement object: a recreated name has a new UID and opens as a new editor. + * One store stays the only place drafts are edited. */ +export function retainRecoveryCopy(store: LiveResourceStore, uid: string, options: RecoveryOptions = {}) { + const retainMs = options.retainMs ?? 15 * 60_000; + const now = options.now ?? Date.now; + let last: Omit | null = null; + let removedAt: number | undefined; + + const capture = () => { + if (!store.ids().includes(uid)) { + // Removed (or never seen). Keep the last copy; there is no draft left to read. + if (last && removedAt === undefined) removedAt = now(); + return; + } + const draft = store.draft(uid); + const { apiVersion, kind, metadata } = draft; + last = { + uid, + identity: { apiVersion, kind, name: metadata.name, ...(metadata.namespace ? { namespace: metadata.namespace } : {}) }, + draft, + edits: store.changes(uid), + }; + }; + capture(); // the initial state counts: an object deleted before any further notification is still recoverable + const stop = store.subscribe(capture); + + return { + /** The live object's draft is the one to edit; a copy exists only once the object is gone. */ + get removed(): boolean { + return removedAt !== undefined; + }, + /** The retained copy of a removed object, or null while it still exists, after expiry, after + * dispose(), or when it was never in the store. Each call returns a fresh detached copy. */ + copy(): RecoveryCopy | null { + if (!last || removedAt === undefined || now() - removedAt > retainMs) return null; + return structuredClone({ ...last, removedAt }); + }, + /** Stop listening and drop the copy. Call when the editor for this UID is torn down. */ + dispose(): void { + stop(); + last = null; + }, + }; +} diff --git a/packages/krm-stream/test/recipes.test.ts b/packages/krm-stream/test/recipes.test.ts new file mode 100644 index 0000000..0ad74e7 --- /dev/null +++ b/packages/krm-stream/test/recipes.test.ts @@ -0,0 +1,206 @@ +// The editor recipes in examples/editor-recipes, executed. They are copyable host code rather than +// library API, so these tests are what makes the guidance that links to them true. +// +// Every server change arrives as a stream event, the way connectResourceStream delivers it. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { keepLocal } from "../../../examples/editor-recipes/keepLocal.ts"; +import { retainRecoveryCopy } from "../../../examples/editor-recipes/recoveryCopy.ts"; +import { applyStreamEvent, type KRMObject, LiveResourceStore } from "../src/index.ts"; + +const widget = (uid: string, rv: string, spec: Record): KRMObject => ({ + apiVersion: "example.com/v1", + kind: "Widget", + metadata: { uid, name: "w", namespace: "app", resourceVersion: rv }, + spec, + status: { ready: true }, +}); +const identity = (uid: string) => ({ uid, apiVersion: "example.com/v1", kind: "Widget", name: "w", namespace: "app" }); + +function storeWith(...objects: KRMObject[]) { + const store = new LiveResourceStore(); + applyStreamEvent(store, { type: "reset" }); + for (const object of objects) applyStreamEvent(store, { type: "added", object }); + applyStreamEvent(store, { type: "synced" }); + return store; +} + +// ------------------------------------------------------------------------- deletion recovery -- + +test("recovery: the copy taken when the recipe starts survives an immediate deletion", () => { + const store = storeWith(widget("u", "1", { replicas: 1 })); + store.setValue("u", ["spec", "replicas"], 3); // typed before the recipe started + const recovery = retainRecoveryCopy(store, "u", { now: () => 1000 }); + assert.equal(recovery.copy(), null, "no copy while the object exists: edit the live draft"); + assert.equal(recovery.removed, false); + + applyStreamEvent(store, { type: "deleted", identity: identity("u") }); + const copy = recovery.copy(); + assert.equal(recovery.removed, true); + assert.deepEqual(copy?.draft.spec, { replicas: 3 }); + assert.deepEqual( + copy?.edits.map((e) => [e.path, e.new]), + [[["spec", "replicas"], 3]], + ); + assert.deepEqual(copy?.identity, { apiVersion: "example.com/v1", kind: "Widget", name: "w", namespace: "app" }); + assert.equal(copy?.removedAt, 1000); +}); + +test("recovery: typing just before snapshot pruning is kept", () => { + const store = storeWith(widget("u", "1", { replicas: 1, image: "v1" })); + const recovery = retainRecoveryCopy(store, "u"); + store.setValue("u", ["spec", "image"], "v2"); + applyStreamEvent(store, { type: "reset" }); + store.setValue("u", ["spec", "replicas"], 5); // the last keystroke, mid-resync + applyStreamEvent(store, { type: "synced" }); // deleted while disconnected: pruned here + assert.deepEqual(store.ids(), []); + assert.deepEqual(recovery.copy()?.draft.spec, { replicas: 5, image: "v2" }); +}); + +test("recovery: a replacement under the same name never inherits the old draft", () => { + const store = storeWith(widget("old", "1", { replicas: 1 })); + const recovery = retainRecoveryCopy(store, "old"); + store.setValue("old", ["spec", "replicas"], 9); + applyStreamEvent(store, { type: "deleted", identity: identity("old") }); + applyStreamEvent(store, { type: "added", object: widget("new", "2", { replicas: 1 }) }); + + assert.deepEqual(store.draft("new").spec, { replicas: 1 }, "the replacement opens clean"); + assert.deepEqual(store.changes("new"), []); + const copy = recovery.copy(); + assert.equal(copy?.uid, "old", "the copy stays bound to the identity it was made against"); + assert.deepEqual(copy?.draft.spec, { replicas: 9 }); +}); + +test("recovery: an object never in the store has nothing to recover", () => { + const store = storeWith(); + const recovery = retainRecoveryCopy(store, "missing"); + applyStreamEvent(store, { type: "reset" }); + applyStreamEvent(store, { type: "synced" }); + assert.equal(recovery.removed, false); + assert.equal(recovery.copy(), null); +}); + +test("recovery: copies are detached, expire, and dispose releases the subscription", () => { + let clock = 0; + const store = storeWith(widget("u", "1", { replicas: 1 }), widget("v", "1", { replicas: 1 })); + const recovery = retainRecoveryCopy(store, "u", { retainMs: 100, now: () => clock }); + store.setValue("u", ["spec", "replicas"], 2); + applyStreamEvent(store, { type: "deleted", identity: identity("u") }); + + const first = recovery.copy()!; + (first.draft.spec as Record).replicas = 99; + assert.deepEqual(recovery.copy()?.draft.spec, { replicas: 2 }, "a caller cannot change the retained copy"); + clock = 100; + assert.notEqual(recovery.copy(), null, "still within its lifetime"); + clock = 101; + assert.equal(recovery.copy(), null, "expired"); + + const other = retainRecoveryCopy(store, "v"); + other.dispose(); + applyStreamEvent(store, { type: "deleted", identity: identity("v") }); + assert.equal(other.removed, false, "a disposed recipe no longer listens"); + assert.equal(other.copy(), null); +}); + +// --------------------------------------------------------------------------- keep-local -- + +/** A store holding one object whose draft conflicts with a server change. */ +function conflicted( + base: Record, + edit: (store: LiveResourceStore) => void, + theirs: Record, +) { + const store = storeWith(widget("u", "1", base)); + edit(store); + applyStreamEvent(store, { type: "modified", object: widget("u", "2", theirs) }); + return store; +} +const conflictPaths = (store: LiveResourceStore) => store.conflicts("u").map((c) => c.path.join(".")); + +test("keep-local: a nested value is kept, other edits and conflicts are not touched", () => { + const store = conflicted( + { a: { x: "base" }, b: "base", c: "base" }, + (s) => { + s.setValue("u", ["spec", "a", "x"], "mine"); + s.setValue("u", ["spec", "b"], "mine"); + s.setValue("u", ["spec", "c"], "unrelated edit"); + }, + { a: { x: "theirs" }, b: "theirs", c: "base" }, + ); + assert.deepEqual(conflictPaths(store), ["spec.a.x", "spec.b"]); + + assert.equal(keepLocal(store, "u", ["spec", "a", "x"]), true); + assert.deepEqual(conflictPaths(store), ["spec.b"], "the other conflict waits for its own decision"); + assert.deepEqual(store.draft("u").spec, { a: { x: "mine" }, b: "mine", c: "unrelated edit" }); + + // A fresh intent after review: the kept value, bound to the version the person reviewed. + const intent = store.captureSave("u"); + assert.equal(intent?.resourceVersion, "2"); + assert.deepEqual(intent?.patch, { spec: { a: { x: "mine" }, b: "mine", c: "unrelated edit" } }); +}); + +test("keep-local: a local deletion stays deleted, and a value the server deleted comes back", () => { + const store = conflicted( + { gone: "base", kept: "base" }, + (s) => { + s.removeKey("u", ["spec", "gone"]); // the person deleted it… + s.setValue("u", ["spec", "kept"], "mine"); // …and changed this one + }, + { gone: "theirs" }, // the server changed the first and deleted the second + ); + assert.deepEqual(conflictPaths(store), ["spec.gone", "spec.kept"]); + assert.equal(keepLocal(store, "u", ["spec", "gone"]), true); + assert.equal(keepLocal(store, "u", ["spec", "kept"]), true); + assert.deepEqual(store.conflicts("u"), []); + assert.deepEqual(store.draft("u").spec, { kept: "mine" }); + assert.deepEqual(store.captureSave("u")?.patch, { spec: { gone: null, kept: "mine" } }); +}); + +test("keep-local: arrays are kept whole, and a parent decision resolves nested conflicts", () => { + const store = conflicted( + { list: ["a", "b"], group: { x: "base", y: "base" } }, + (s) => { + s.setValue("u", ["spec", "list"], ["a", "b", "mine"]); + s.setValue("u", ["spec", "group", "x"], "mine"); + s.setValue("u", ["spec", "group", "y"], "mine"); + }, + { list: ["theirs"], group: { x: "theirs", y: "theirs" } }, + ); + assert.deepEqual(conflictPaths(store), ["spec.list", "spec.group.x", "spec.group.y"]); + assert.equal(keepLocal(store, "u", ["spec", "list"]), true); + assert.equal(keepLocal(store, "u", ["spec", "group"]), true); + assert.deepEqual(store.conflicts("u"), []); + assert.deepEqual(store.captureSave("u")?.patch, { + spec: { list: ["a", "b", "mine"], group: { x: "mine", y: "mine" } }, + }); +}); + +test("keep-local: a read-only or redacted path is refused and nothing changes", () => { + const store = storeWith(widget("u", "1", { a: "base" })); + const before = store.draft("u"); + assert.equal(keepLocal(store, "u", ["status", "ready"]), false, "status is controller-owned"); + assert.deepEqual(store.draft("u"), before); + + // A redaction can appear after the conflict did: the value is then not the person's to keep. + const secret = (rv: string, data: Record, redacted: { path: string; rev: number }[] = []) => ({ + type: "modified" as const, + object: { apiVersion: "v1", kind: "Secret", metadata: { uid: "s", name: "s", resourceVersion: rv }, data }, + redacted, + }); + applyStreamEvent(store, { ...secret("1", { token: "base", user: "base" }), type: "added" }); + store.setValue("s", ["data", "token"], "mine"); + store.setValue("s", ["data", "user"], "mine"); + applyStreamEvent(store, secret("2", { token: "theirs", user: "theirs" })); + assert.deepEqual( + store.conflicts("s").map((c) => c.path.join(".")), + ["data.token", "data.user"], + ); + applyStreamEvent(store, secret("3", { user: "theirs" }, [{ path: "/data/token", rev: 1 }])); + const draft = store.draft("s"); + const conflicts = store.conflicts("s"); + assert.equal(keepLocal(store, "s", ["data", "token"]), false); + assert.deepEqual(store.draft("s"), draft); + assert.deepEqual(store.conflicts("s"), conflicts); + assert.equal(keepLocal(store, "s", ["data", "user"]), true, "the unredacted value can still be kept"); +}); diff --git a/packages/krm-stream/tsconfig.test.json b/packages/krm-stream/tsconfig.test.json index 1666afb..d46bcea 100644 --- a/packages/krm-stream/tsconfig.test.json +++ b/packages/krm-stream/tsconfig.test.json @@ -12,5 +12,11 @@ // node:test / node:assert — the only reason @types/node is a devDependency. It ships nothing. "types": ["node"] }, - "include": ["src/**/*.ts", "test/**/*.ts", "e2e/**/*.ts", "../../examples/conditional-save/**/*.ts"] + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "e2e/**/*.ts", + "../../examples/conditional-save/**/*.ts", + "../../examples/editor-recipes/**/*.ts" + ] }