Repository navigation
docs(examples): add tested deletion-recovery and keep-local editor recipes #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | number, unknown>)[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<string | number, unknown>)[segment]; | ||
| } | ||
| return true; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RecoveryCopy, "removedAt"> | 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; | ||
| }, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Clear
removedAtwhen the UID returns.capturesetsremovedAtone time and never resets it. A UID can leave the store and come back. For example,removeResourceruns and the stream later sendsaddedwith the same UID, oradoptSavedupserts a pruned UID. In that case,capturerefresheslast, butremovedstaystrue.copy()then returns a copy while the live object exists. This breaks the documented contract that the copy is null "while it still exists". The originalremovedAtalso starts the expiry clock too early.Proposed fix
const draft = store.draft(uid); + removedAt = undefined; const { apiVersion, kind, metadata } = draft;📝 Committable suggestion
🤖 Prompt for AI Agents