From 037922a4986e629055600b9bd374a6749af22d91 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 12:58:58 +0000 Subject: [PATCH 1/2] feat(examples): browser page for native editing of one ConfigMap Add examples/native-editor/index.html: a framework-free page that watches one named ConfigMap through a host proxy and edits its data and ordinary annotations with the existing nativeEditor, LiveResourceStore and connectNativeWatch. The last-applied annotation and managedFields stay read-only. Save is enabled only while live, with one request at a time, and inputs keep focus and caret through watch events, writes and recovery reads. The page presents each outcome: an accepted write awaiting its echo, the echo or an explicit confirm() with any remaining dirty fields, a stale version, field conflicts resolved with store.revert or the keep-local recipe, an unknown outcome, refusals and a deleted or replaced object with a recovery copy. Disconnect closes the watch, unsubscribes, clears the echo timer and ignores late answers. editor.ts and the recipes stay TypeScript. build.mjs (task build-native-editor) compiles them with the client's esbuild, renaming the library import to @configbutler/krm-stream, which the page's import map points at the selected entry point. dist/ is gitignored. native-editor.spec.ts runs the page in Chromium on both entry points against a test server that plays the host proxy and API server with a watch that stays open. Document the run steps, the kubectl proxy setup and what it does and does not protect, and record browser acceptance in the delivery plan and field report. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + CONTRIBUTING.md | 2 +- Taskfile.yml | 24 +- docs/field-reports/third-our-identity.md | 12 +- ...006-stream-and-save-implementation-plan.md | 8 +- examples/README.md | 5 +- examples/native-editor/README.md | 57 ++ examples/native-editor/build.mjs | 36 ++ examples/native-editor/index.html | 596 ++++++++++++++++++ examples/native-viewer/README.md | 2 +- examples/vanilla-browser/README.md | 9 +- examples/vanilla-browser/playwright.config.ts | 2 +- .../tests/native-editor.spec.ts | 567 +++++++++++++++++ 13 files changed, 1304 insertions(+), 17 deletions(-) create mode 100644 examples/native-editor/build.mjs create mode 100644 examples/native-editor/index.html create mode 100644 examples/vanilla-browser/tests/native-editor.spec.ts diff --git a/.gitignore b/.gitignore index 7b25c60..053bbb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Node node_modules/ packages/*/dist/ +examples/native-editor/dist/ # Go /bin/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c54f24d..b6acc9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ the rule it protects. |---|---| | `task test` | Deterministic gateway, adapter, client, and shared-fixture coverage. | | `task e2e-wire` | Real Go SSE bytes consumed by the TypeScript client over HTTP. | -| `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer example in Chromium. | +| `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer and editor pages in Chromium. | | `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. | | `task test-real-api` | Compose the save path and native viewing and editing against a real API server: gateway or host proxy, host endpoint and browser store. | | `task test-cluster` | Exercise the Kubernetes backend against a real API server. | diff --git a/Taskfile.yml b/Taskfile.yml index 59b41de..7a6c975 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -191,7 +191,7 @@ tasks: # and it cannot do that with no node_modules there. On a developer's machine that directory is # already populated by `task test`, which is exactly why this gap survived until the first CI run # on a clean checkout found it. - deps: [fixtures, build-client, _example-deps] + deps: [fixtures, build-client, build-native-editor, _example-deps] dir: examples/vanilla-browser cmds: # The only place the library's central promise is actually tested: that the published ESM @@ -325,6 +325,26 @@ tasks: # whose exports map pointed at a file that was not in it. - npm run --silent build + build-native-editor: + desc: "Build the library and compile the native editor page's controller and recipes into examples/native-editor/dist." + deps: [build-client] + sources: + - examples/native-editor/build.mjs + - examples/native-editor/editor.ts + - examples/editor-recipes/keepLocal.ts + - examples/editor-recipes/recoveryCopy.ts + generates: + - examples/native-editor/dist/editor.js + - examples/native-editor/dist/keepLocal.js + - examples/native-editor/dist/recoveryCopy.js + status: + - test -f examples/native-editor/dist/editor.js + - test -f examples/native-editor/dist/keepLocal.js + - test -f examples/native-editor/dist/recoveryCopy.js + cmds: + # Types stripped, library import renamed to the published package; see build.mjs. + - node examples/native-editor/build.mjs + pack-client: desc: "Pack the npm tarball and PROVE it contains every entry point the exports map advertises." deps: [build-client] @@ -365,7 +385,7 @@ tasks: cmds: # .task/ is Task's checksum store: what makes a task say "up to date". Remove it when one is wrong. - rm -rf .task - - rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist + - rm -rf {{.CLIENT_DIR}}/node_modules {{.CLIENT_DIR}}/dist examples/native-editor/dist - rm -rf examples/vanilla-browser/node_modules # Go's test cache is content-addressed and sound: a cached `ok` means those inputs really did # pass. Use `go test -count=1` to watch a suite run; `go clean -cache` buys nothing but minutes. diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index 822f752..cb14331 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -139,8 +139,16 @@ capture and PATCH (409, guarded read, a second deliberate save, neither write lo refusing an unconditional patch and a machinery patch before they reach the API server, and an object replaced under the same name reported `unavailable` with the replacement untouched. -There is no browser page for native editing yet; the comparative frontend (order 6) is where one -belongs. +**Browser acceptance (2026-10-05).** The [example page](../../examples/native-editor/README.md#run-the-page) +edits one ConfigMap with the same editor, store and connector, built from `editor.ts` without a +second save controller or new library API. `examples/vanilla-browser/tests/native-editor.spec.ts` +(`task e2e-browser`) runs it in Chromium on both entry points, with the watch live throughout, against +a test server playing the host proxy. It covers a save and its echo, typing and focus during a save, +409 recovery and a second deliberate save, explicit conflict resolution, a lost response and a 502 +each settled by a GET before any further PATCH, an accepted write without an echo confirmed +explicitly, 403 and 422 refusals, UID replacement with copy-out, and disconnect cleanup. A manual +run through `kubectl proxy` against the v1.36.4+k3s1 spike cluster saved and echoed on both entry +points and rendered a real 422. The comparative frontend (order 6) remains separate. ## Follow-ups after the viewer diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index 770c099..a0ce8d9 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -2,8 +2,8 @@ **Status: active delivery plan, updated 2026-10-05.** This is the single inventory of completed work, open work and ordering. The implementation includes connector separation, Go API cleanup, -real-API save-composition tests, the native viewer and native editing. Detailed contracts stay in the -linked proposals and guides rather than becoming another roadmap. +real-API save-composition tests, the native viewer and native editing with a browser page. Detailed +contracts stay in the linked proposals and guides rather than becoming another roadmap. Start with [watching resources](../why-a-gateway.md); add the [editor](../client-state-model.md) only where a page needs drafts. Hosts with a Kubernetes proxy can read native collections with the @@ -30,7 +30,7 @@ remain separate delivery steps. | Editor integration and recovery recipes | Tested deletion recovery, keep-local resolution and Vue subscription ownership | [Recipes](../../examples/editor-recipes/README.md), [Vue](../../examples/vue/README.md) | | Real-API save composition | Cases for suppressed churn, guarded-read overlap, structured errors and same-name UID replacement | [Completed baseline](#completed-baseline) | | Native viewer (slice 1) | `connectNativeWatch` and `nativeCollectionURL`: unpaginated LIST, WATCH from the collection RV, fresh LIST on reconnect, native 410 recovery and terminal refusals on a lifecycle shared with the gateway connector; fake-fetch, Chromium and real-API tests; minimal example | [Request](../field-reports/third-our-identity.md#slice-1-a-native-viewer), [example](../../examples/native-viewer/README.md), [client](../../packages/krm-stream/README.md#native-connections) | -| Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) | +| Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests; a minimal one-ConfigMap page accepted in Chromium on both entry points | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) | | Watch-first documentation | README, watch/edit/save guides, native-fetch request and compact decision records; duplicate/superseded guides removed | [README](../../README.md), [current request](../field-reports/third-our-identity.md) | ## Open work and delivery order @@ -41,7 +41,7 @@ before starting a later one. Deliver separately reviewable changes and use the p | Order | Open work | Dependencies and completion | |---|---|---| | 1 | Native viewer — **completed** | See [completed work](#completed-work); acceptance evidence is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-1). | -| 2 | Native editing — **completed** | See [completed work](#completed-work); acceptance evidence is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-2). No editor method changed, so proposal 0009's cleanup is unaffected. | +| 2 | Native editing — **completed** | See [completed work](#completed-work); acceptance evidence, browser acceptance included, is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-2). No editor method changed, so proposal 0009's cleanup is unaffected. | | 3 | Native reconnect efficiency | After the viewer; evaluate resumable watches, streaming-list initialization and pagination with explicit membership/checkpoint tests. Do not claim these efficiencies for slice 1. | | 4 | Gateway upstream continuation | Independent track with a design below; review reopen/credential bounds, record a baseline, implement and measure. No dependency on native transport. | | 5 | Save progress during suppressed churn | Independent evaluation using existing real-API fixtures; bounded submitted-intent recovery first, optional version delivery second. Implementation depends on measured benefit and reviewed scope. | diff --git a/examples/README.md b/examples/README.md index b7e6f7e..3fe2a1f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,8 +7,9 @@ optional editor. through an existing host proxy, such as `kubectl proxy`, with the shared lifecycle and read-only store. The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against the gateway source over SSE. [Native editor](native-editor/README.md) adds conditional saves through the same proxy, -with machinery protection and guarded native recovery reads. The larger view/sharing comparison -follows separately. +with machinery protection and guarded native recovery reads, and a minimal page that edits one +ConfigMap with them. Both native pages run in Chromium under `task e2e-browser`. The larger +view/sharing comparison follows separately. For gateway host integration patterns, use these small recipes: - [Same-origin cookie application](../docs/adopting.md#1-mount-the-stream-endpoint): fetch diff --git a/examples/native-editor/README.md b/examples/native-editor/README.md index 481a190..8d64260 100644 --- a/examples/native-editor/README.md +++ b/examples/native-editor/README.md @@ -21,6 +21,8 @@ automatic write retry. `gateway.ValidateNativeMergePatch`. Against a real API server it checks a save and its echo, a competing write between capture and PATCH, the proxy refusing unconditional and machinery patches, and an object replaced under the same name. +- [index.html](index.html) is a minimal page that edits one ConfigMap with this editor. Its + [browser tests](../vanilla-browser/tests/native-editor.spec.ts) are described [below](#run-the-page). ```ts const proxy = "/k8s"; @@ -46,6 +48,61 @@ its own namespace and name, so a collection watched across namespaces works too. source, scope and login identity. Never point this editor at a projected store, and never give a projected editor a native response. +## Run the page + +[index.html](index.html) watches one named ConfigMap and edits its `data` and ordinary annotations, +showing identity, connection state, server state, the draft and pending edits. It has no framework. +The page loads the built library and `editor.ts` compiled into `dist/`, together with the +[keep-local and recovery-copy recipes](../editor-recipes/README.md). +[build.mjs](build.mjs) only strips types and renames the library import to +`@configbutler/krm-stream`. An import map in the page resolves that name to one built entry point. +`dist/` is not committed. + +```bash +task build-native-editor # builds the library, then dist/ +kubectl create configmap settings --from-literal=value=hello # or pick an existing one with name= +kubectl proxy --port=8001 --api-prefix=/k8s/ --www=. --www-prefix=/files/ +# http://127.0.0.1:8001/files/examples/native-editor/?namespace=default&name=settings +``` + +The query parameters are `proxy` (default `/k8s`), `namespace` (default `default`), `name` (default +`settings`) and `echoWaitMs` (default 5000, how long an accepted write waits for its watch echo). +Add `entry=bundle` to load the single-file build. + +- Save is enabled only while the watch is `live`, no request is in flight and no conflict is open. + Typing continues during a save, and inputs keep focus and caret through watch events and reads. +- `saved` waits for the watch. The next version it delivers includes the write; fields still dirty + then were typed after Save or not kept by the server. With no echo within `echoWaitMs`, the page + offers **Confirm current state**, which calls `editor.confirm()`. A network failure or a 5xx + offers Confirm at once. Until a read succeeds, Save also only reads, and nothing is resent. +- `confirmed` says the form now builds on the server's state. Fields still dirty were not written. +- Conflicts offer **Use server value** (`store.revert`) and **Keep mine** (the keep-local recipe). + Typing into a field whose save is in flight becomes a conflict when the echo arrives: the server + holds the saved value and the draft holds the later typing. +- A refusal (401, 403, 422 or another 4xx) shows the Kubernetes `Status` and keeps the draft. +- A deleted or replaced object shows its unsaved edits from the recovery copy. A replacement opens + only on request, without the old edits. +- **Disconnect**, or leaving the page, closes the watch, unsubscribes and clears the echo timer. A + request in flight is not cancelled, because the write may already have landed, and its answer + changes nothing. + +`kubectl proxy` stands in for the host here. It holds your kubeconfig credential server-side, serves +the page and the API from one origin, accepts only `localhost` host names and refuses pod +`exec`/`attach` by default. It provides none of the write protections listed +[under the host proxy](#the-host-proxy). Every request runs as **your** kubeconfig identity, so RBAC +checks you, not the person editing. It does not check the content type, bound the body or run +`ValidateNativeMergePatch`, and it has no CSRF checks, per-user write policy or audit. The page and +store still send only conditional patches without machinery, but nothing stops another client from +sending something else. Keep it on localhost and stop it when you are done. + +`task e2e-browser` runs the page in Chromium on both entry points against a test server that serves +the page and plays the host proxy and API server, with a watch that stays open while editing: +[native-editor.spec.ts](../vanilla-browser/tests/native-editor.spec.ts). It covers a conditional +save and its echo, typing while a save is in flight, 409 recovery and a later deliberate Save, +explicit conflict resolution, a lost response and a 502 each settled by a GET before any further +PATCH, an accepted write without an echo confirmed explicitly, refusals, UID replacement and +disconnect. + ## What it protects - **Machinery.** `metadata.managedFields` and the `kubectl.kubernetes.io/last-applied-configuration` diff --git a/examples/native-editor/build.mjs b/examples/native-editor/build.mjs new file mode 100644 index 0000000..144fe92 --- /dev/null +++ b/examples/native-editor/build.mjs @@ -0,0 +1,36 @@ +// Compile the native editor and the editor recipes the page reuses into browser-loadable ESM in +// dist/. They stay TypeScript in the repository, where the client suite runs them; this only strips +// types. Their library import, a relative path to the TypeScript source, becomes +// "@configbutler/krm-stream", exactly as a host copying them would write it. The page's import map +// points that name at one built entry point, so the page and the editor share one copy of the +// library: index.js or the single-file bundle. +// +// Run with `task build-native-editor`. esbuild is the client package's own devDependency. + +import { createRequire } from "node:module"; + +const { build } = createRequire(new URL("../../packages/krm-stream/package.json", import.meta.url))("esbuild"); + +const library = { + name: "published-library", + setup(build) { + build.onResolve({ filter: /\/packages\/krm-stream\/src\/index\.ts$/ }, () => ({ + path: "@configbutler/krm-stream", + external: true, + })); + }, +}; + +await build({ + entryPoints: ["editor.ts", "../editor-recipes/keepLocal.ts", "../editor-recipes/recoveryCopy.ts"].map((path) => ({ + in: new URL(path, import.meta.url).pathname, + out: path.replace(/^.*\//, "").replace(/\.ts$/, ""), + })), + outdir: new URL("dist", import.meta.url).pathname, + bundle: true, + format: "esm", + target: "es2022", + legalComments: "none", + plugins: [library], + logLevel: "warning", +}); diff --git a/examples/native-editor/index.html b/examples/native-editor/index.html new file mode 100644 index 0000000..b6f7a8e --- /dev/null +++ b/examples/native-editor/index.html @@ -0,0 +1,596 @@ + + + + + + + krm-stream — native editor + + + + +

Native editor

+

+

+ Connection: connecting + +

+

+ +
+

Identity

+
+
+ +
+
+

Draft — edits stay local until Save

+
+
+ + + + +
+
+ + + +
+

Pending edits

+
    +

    + + + +

    +

    +
    +
    + + + +
    +

    Server state — read-only, follows the watch

    +
    
    +    
    + + + + diff --git a/examples/native-viewer/README.md b/examples/native-viewer/README.md index 066fe74..2d6a666 100644 --- a/examples/native-viewer/README.md +++ b/examples/native-viewer/README.md @@ -36,7 +36,7 @@ browser never holds a Kubernetes credential. access provides no projection, redaction, suppression or shared watches; use the [gateway](../../docs/why-a-gateway.md) for those. - It is a viewer. To edit through the same proxy, add the - [native editor](../native-editor/README.md). Resumable watches and paginated lists are later work; + [native editor](../native-editor/README.md), which has its own page for one ConfigMap. Resumable watches and paginated lists are later work; see the [delivery plan](../../docs/proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order). `task e2e-browser` loads this page in Chromium on both entry points, with Playwright standing in for diff --git a/examples/vanilla-browser/README.md b/examples/vanilla-browser/README.md index 73131fe..f24445b 100644 --- a/examples/vanilla-browser/README.md +++ b/examples/vanilla-browser/README.md @@ -11,8 +11,8 @@ task demo Use `task e2e-browser` to run the Chromium check. It loads both published entry points, and also reads the gateway with a raw native `EventSource` to check its framing, reconnect snapshots and -terminal shutdown. The same run loads the [native viewer](../native-viewer/README.md) against a -Playwright-played host proxy. +terminal shutdown. The same run loads the [native viewer](../native-viewer/README.md) and the +[native editor](../native-editor/README.md#run-the-page) pages against test-played host proxies. The page demonstrates live status updates, three-way conflict handling, draft edits, and redacted Secret values. Useful fixtures include: @@ -32,5 +32,6 @@ The example uses the gateway's projected SSE connector over fetch. For a read-on state with `LiveResourceStore(readOnlyPolicy)` as shown in the [README](../../README.md#watch-a-gateway-view). Editing is an optional layer; the [editor model](../../docs/client-state-model.md) and [saving guide](../../docs/saving.md) describe its intended use. For native resources through a host -proxy, see the [native viewer](../native-viewer/README.md), which this suite also loads; native -editing and a larger source comparison follow separately. +proxy, see the [native viewer](../native-viewer/README.md) and +[native editor](../native-editor/README.md), which this suite also loads; a larger source comparison +follows separately. diff --git a/examples/vanilla-browser/playwright.config.ts b/examples/vanilla-browser/playwright.config.ts index 65d3475..e9d982a 100644 --- a/examples/vanilla-browser/playwright.config.ts +++ b/examples/vanilla-browser/playwright.config.ts @@ -41,7 +41,7 @@ export default defineConfig<{ entry: Entry }>({ // first-ever CI run. Run `task e2e-browser`, which installs the library's devDependencies first. webServer: { command: - "cd ../../packages/krm-stream && npm run --silent build && " + + "cd ../../packages/krm-stream && npm run --silent build && node ../../examples/native-editor/build.mjs && " + "cd ../../gateway && go run ./cmd/replay " + "--addr 127.0.0.1:8100 --corpus ../conformance " + "--static ../examples/vanilla-browser --dist ../packages/krm-stream/dist", diff --git a/examples/vanilla-browser/tests/native-editor.spec.ts b/examples/vanilla-browser/tests/native-editor.spec.ts new file mode 100644 index 0000000..a0c3ff0 --- /dev/null +++ b/examples/vanilla-browser/tests/native-editor.spec.ts @@ -0,0 +1,567 @@ +// The native editor example (examples/native-editor) in a real browser, on both entry points: the +// page, connectNativeWatch, LiveResourceStore and the native editor, against a host proxy that keeps +// its watch open while the person edits. +// +// No cluster. Playwright's request routing cannot stream a response, so each test starts a small +// HTTP server that plays both halves of `kubectl proxy --www`: it serves the page and the built +// library from the repository, and answers /k8s as an API server would for one ConfigMap — LIST, +// a WATCH that stays open, GET, and a conditional JSON merge PATCH refused like the Go test proxy +// refuses it. A test can hold an answer, script it, or hold watch events back, so it decides what +// the API server did and when the watch saw it. + +import { readFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Page } from "@playwright/test"; +import { test as base, expect } from "./fixtures.ts"; + +const repository = fileURLToPath(new URL("../../../", import.meta.url)); +const types: Record = { ".html": "text/html", ".js": "text/javascript" }; +const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration"; +const OBJECT = "/k8s/api/v1/namespaces/app/configmaps/settings"; +const COLLECTION = "/k8s/api/v1/namespaces/app/configmaps"; + +interface ConfigMap { + metadata: { + name: string; + namespace: string; + uid: string; + resourceVersion: string; + annotations: Record; + managedFields: unknown[]; + }; + data: Record; +} + +interface Call { + method: string; + path: string; + query: URLSearchParams; + contentType: string | undefined; + body: unknown; +} + +type Answer = (call: Call, res: ServerResponse) => void | Promise; + +/** One ConfigMap behind a host proxy. Writes are answered in order by `patches` and reads by + * `reads`, falling back to the API server's own behavior. */ +class Host { + readonly calls: Call[] = []; + readonly patches: Answer[] = []; + readonly reads: Answer[] = []; + object: ConfigMap | undefined = configMap("u1", 1, "base"); + #rv = 1; + #watches = new Set(); + #held: string[] = []; + #server: Server; + url = ""; + + constructor() { + this.#server = createServer((req, res) => void this.#serve(req, res)); + } + + async start() { + await new Promise((resolve) => this.#server.listen(0, "127.0.0.1", resolve)); + this.url = `http://127.0.0.1:${(this.#server.address() as AddressInfo).port}`; + } + + async stop() { + for (const watch of this.#watches) watch.end(); + this.#server.closeAllConnections(); + await new Promise((resolve) => this.#server.close(resolve)); + } + + get openWatches() { + return this.#watches.size; + } + + /** Requests for the object itself: the editor's PATCHes and guarded GETs. */ + objectCalls() { + return this.calls.filter((c) => c.path === OBJECT).map((c) => c.method); + } + + patchBodies() { + return this.calls.filter((c) => c.method === "PATCH").map((c) => c.body); + } + + /** Another writer changes the object. Its watch event is delivered now, or held until flush(). */ + change(mutate: (object: ConfigMap) => void, { deliver = true } = {}) { + mutate(this.object!); + this.object!.metadata.resourceVersion = String(++this.#rv); + this.#emit("MODIFIED", this.object!, deliver); + } + + /** Delete the object and create another under the same name, with a new UID. */ + replace({ deliver = true } = {}) { + const old = this.object!; + old.metadata.resourceVersion = String(++this.#rv); + this.#emit("DELETED", old, deliver); + this.object = configMap("u2", ++this.#rv, "replacement"); + this.#emit("ADDED", this.object, deliver); + } + + /** Deliver the watch events held back so far. */ + flush() { + for (const frame of this.#held.splice(0)) for (const watch of this.#watches) watch.write(frame); + } + + /** The API server's PATCH: preconditions checked, then the merge patch applied and echoed. */ + patch = (call: Call, res: ServerResponse, { deliver = true } = {}) => { + const body = call.body as { metadata?: Record }; + const object = this.object; + if (!object) return status(res, 404, 'configmaps "settings" not found'); + if ( + body.metadata?.resourceVersion !== object.metadata.resourceVersion || + body.metadata?.uid !== object.metadata.uid + ) { + return status(res, 409, "Operation cannot be fulfilled: the object has been modified"); + } + this.write(body, { deliver }); + json(res, 200, typed(this.object!)); + }; + + /** Apply a merge patch as the API server would, without answering. */ + write(body: unknown, { deliver = true } = {}) { + const { metadata, ...rest } = body as { metadata: Record }; + const { uid: _uid, resourceVersion: _rv, ...fields } = metadata; + this.object = merge(this.object, { ...rest, metadata: fields }) as ConfigMap; + this.object.metadata.resourceVersion = String(++this.#rv); + this.#emit("MODIFIED", this.object, deliver); + } + + #emit(type: string, object: ConfigMap, deliver: boolean) { + const frame = `${JSON.stringify({ type, object: typed(object) })}\n`; + if (!deliver) return void this.#held.push(frame); + for (const watch of this.#watches) watch.write(frame); + } + + async #serve(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url!, this.url); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + if (!url.pathname.startsWith("/k8s/")) return this.#file(url.pathname, res); + const text = Buffer.concat(chunks).toString(); + const call: Call = { + method: req.method!, + path: url.pathname, + query: url.searchParams, + contentType: req.headers["content-type"], + body: text ? JSON.parse(text) : undefined, + }; + this.calls.push(call); + + if (call.path === COLLECTION && call.method === "GET") { + expect(call.query.get("fieldSelector")).toBe("metadata.name=settings"); + if (call.query.get("watch") === "1") { + // Accepted, and open until the page or the test closes it. + res.writeHead(200, { "Content-Type": "application/json" }); + res.flushHeaders(); + this.#watches.add(res); + res.on("close", () => this.#watches.delete(res)); + return; + } + const items = this.object ? [this.object] : []; + return json(res, 200, { + kind: "ConfigMapList", + apiVersion: "v1", + metadata: { resourceVersion: String(this.#rv) }, + items, + }); + } + if (call.path !== OBJECT) return status(res, 404, "not proxied"); + if (call.method === "GET") { + const answer = this.reads.shift(); + if (answer) return answer(call, res); + return this.object ? json(res, 200, typed(this.object)) : status(res, 404, "not found"); + } + if (call.method !== "PATCH") return status(res, 405, "not proxied"); + // The host proxy's own checks, as in gateway/kube/native_e2e_test.go. + if (call.contentType !== "application/merge-patch+json") return status(res, 415, "only merge patches"); + const metadata = ( + call.body as { + metadata?: { uid?: string; resourceVersion?: string; annotations?: object; managedFields?: unknown }; + } + ).metadata; + if (!metadata?.uid || !metadata.resourceVersion) return status(res, 400, "a native patch needs both preconditions"); + if (metadata.managedFields !== undefined || (metadata.annotations && LAST_APPLIED in metadata.annotations)) { + return status(res, 400, "a native patch must not touch machinery"); + } + const answer = this.patches.shift(); + return answer ? answer(call, res) : this.patch(call, res); + } + + async #file(path: string, res: ServerResponse) { + if (!path.startsWith("/examples/native-editor/") && !path.startsWith("/packages/krm-stream/dist/")) { + return status(res, 404, "not served"); + } + res.writeHead(200, { "Content-Type": types[extname(path)] ?? "application/octet-stream", Connection: "close" }); + res.end(await readFile(`${repository}${path.slice(1)}`)); + } +} + +function configMap(uid: string, rv: number, value: string): ConfigMap { + return { + metadata: { + name: "settings", + namespace: "app", + uid, + resourceVersion: String(rv), + annotations: { owner: "team-a", [LAST_APPLIED]: `{"data":{"value":"${value}"}}` }, + managedFields: [{ manager: "kubectl", operation: "Apply" }], + }, + data: { value }, + }; +} + +const typed = (object: ConfigMap) => ({ apiVersion: "v1", kind: "ConfigMap", ...structuredClone(object) }); + +function merge(target: unknown, patch: unknown): unknown { + if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return patch; + const out: Record = + target && typeof target === "object" && !Array.isArray(target) ? { ...(target as object) } : {}; + for (const [k, v] of Object.entries(patch)) { + if (v === null) delete out[k]; + else out[k] = merge(out[k], v); + } + return out; +} + +// Every answer but a watch closes its connection, so Chromium never resends a request over a reused +// socket and the PATCH count is the page's own. +function json(res: ServerResponse, code: number, body: unknown) { + res.writeHead(code, { "Content-Type": "application/json", Connection: "close" }); + res.end(JSON.stringify(body)); +} + +function status(res: ServerResponse, code: number, message: string, extra: object = {}) { + json(res, code, { kind: "Status", apiVersion: "v1", status: "Failure", code, message, ...extra }); +} + +function gate() { + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + return { opened, open }; +} + +const test = base.extend<{ host: Host; errors: string[] }>({ + host: async ({}, use) => { + const host = new Host(); + await host.start(); + await use(host); + await host.stop(); + }, + // Every test: the page reports no uncaught error. + errors: [ + async ({ page }, use) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await use(errors); + expect(errors).toEqual([]); + }, + { auto: true }, + ], +}); + +async function open(page: Page, host: Host, entry: string, query = "") { + await page.goto(`${host.url}/examples/native-editor/index.html?entry=${entry}&namespace=app&name=settings${query}`); + await expect(page.locator("#state")).toHaveText("live"); + await expect(field(page, "data value")).toHaveValue("base"); +} + +const field = (page: Page, name: string) => page.getByRole("textbox", { name, exact: true }); +const outcome = (page: Page) => page.locator("#outcome"); +const save = (page: Page) => page.getByRole("button", { name: "Save" }); +const confirm = (page: Page) => page.getByRole("button", { name: "Confirm current state" }); + +test("a conditional save settles on its watch echo, with machinery read-only", async ({ page, entry, host }) => { + await open(page, host, entry); + await expect(page.locator("#identity")).toContainText("u1"); + await expect(page.locator("#server")).toContainText("kubectl (Apply)"); + // The last-applied annotation is shown but has no input; managedFields never reach the form. + await expect(field(page, `annotation ${LAST_APPLIED}`)).toHaveCount(0); + await expect(page.locator("#fields tr", { hasText: LAST_APPLIED })).toContainText("read-only"); + + await field(page, "data value").fill("mine"); + await field(page, "annotation owner").fill("team-b"); + await expect(page.locator("#changes")).toContainText('change data value: "base" → "mine"'); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + await expect(outcome(page)).toContainText("resourceVersion 2. Every field matches the server."); + expect(host.calls.find((c) => c.method === "PATCH")?.contentType).toBe("application/merge-patch+json"); + expect(host.patchBodies()).toEqual([ + { metadata: { annotations: { owner: "team-b" }, uid: "u1", resourceVersion: "1" }, data: { value: "mine" } }, + ]); + expect(host.objectCalls()).toEqual(["PATCH"]); + expect(host.object!.metadata.annotations[LAST_APPLIED]).toBe('{"data":{"value":"base"}}'); + await expect(page.locator("#changes")).toHaveText("No pending edits."); + await expect(page.locator("#identity")).toContainText("resourceVersion2"); +}); + +test("typing and focus survive a save in flight and its echo", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("first"); + const held = gate(); + host.patches.push(async (call, res) => { + await held.opened; + host.patch(call, res); + }); + await save(page).click(); + await expect(save(page)).toBeDisabled(); // one request at a time + + // Typing while the write is in flight: into the field being saved, then into another one. + await field(page, "data value").click(); + await page.keyboard.press("End"); + await page.keyboard.type("+1"); + await field(page, "annotation owner").click(); + await page.keyboard.press("End"); + await page.keyboard.type(" and more"); + held.open(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()).toEqual([{ metadata: { uid: "u1", resourceVersion: "1" }, data: { value: "first" } }]); + await expect(field(page, "annotation owner")).toBeFocused(); + // An unrelated change arrives while the person is still typing: a new row, the caret stays put. + host.change((o) => { + o.data.extra = "from the server"; + }); + await expect(field(page, "data extra")).toHaveValue("from the server"); + await page.keyboard.type("!"); + await expect(field(page, "annotation owner")).toHaveValue("team-a and more!"); + await expect(field(page, "annotation owner")).toBeFocused(); + + // The echo of "first" arrived after "+1" was typed into the same field: the server value moved + // under a local edit, which the page shows as a conflict rather than choosing for the person. + await expect(field(page, "data value")).toHaveValue("first+1"); + await expect(page.locator("#conflicts")).toContainText('data value — yours: "first+1", server: "first"'); + await expect(save(page)).toBeDisabled(); + await page.getByRole("button", { name: "Keep mine" }).click(); + await expect(page.locator("#changes")).toContainText('change data value: "first" → "first+1"'); + await expect(page.locator("#changes")).toContainText('change annotation owner: "team-a" → "team-a and more!"'); + await expect(save(page)).toBeEnabled(); +}); + +test("a 409 refreshes the base, keeps the edits and writes only on the next deliberate Save", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((call, res) => { + // Another writer lands between capture and PATCH; the watch has not delivered it yet. + host.change( + (o) => { + o.metadata.annotations.owner = "team-c"; + }, + { deliver: false }, + ); + host.patch(call, res); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "version-stale"); + await expect(outcome(page)).toContainText("nothing was written"); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + await expect(field(page, "data value")).toHaveValue("mine"); + await expect(field(page, "annotation owner")).toHaveValue("team-c"); + await expect(page.locator("#identity")).toContainText("resourceVersion2"); + await page.waitForTimeout(500); + expect(host.objectCalls(), "no automatic retry").toEqual(["PATCH", "GET"]); + host.flush(); + + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()[1]).toEqual({ metadata: { uid: "u1", resourceVersion: "2" }, data: { value: "mine" } }); + expect(host.object!.data.value).toBe("mine"); + expect(host.object!.metadata.annotations.owner).toBe("team-c"); +}); + +test("field conflicts are resolved explicitly before the next save", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + await field(page, "annotation owner").fill("team-b"); + host.patches.push((call, res) => { + host.change( + (o) => { + o.data.value = "theirs"; + o.metadata.annotations.owner = "team-c"; + }, + { deliver: false }, + ); + host.patch(call, res); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "draft-conflict"); + const conflicts = page.locator("#conflicts li"); + await expect(conflicts).toHaveCount(2); + await expect(save(page)).toBeDisabled(); + await expect(page.locator("#hint")).toHaveText("Resolve the conflicts to save."); + await conflicts.filter({ hasText: "data value" }).getByRole("button", { name: "Keep mine" }).click(); + await conflicts.filter({ hasText: "annotation owner" }).getByRole("button", { name: "Use server value" }).click(); + await expect(page.locator("#conflicts-section")).toBeHidden(); + await expect(field(page, "annotation owner")).toHaveValue("team-c"); + + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()[1]).toEqual({ metadata: { uid: "u1", resourceVersion: "2" }, data: { value: "mine" } }); + expect(host.object!.metadata.annotations.owner).toBe("team-c"); +}); + +test("a lost response is settled by a GET, never a second PATCH", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((call, res) => { + // The write lands, but its answer and its watch event never reach the page. + host.write(call.body, { deliver: false }); + res.socket?.destroy(); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "unknown"); + await expect(outcome(page)).toContainText("got no answer"); + await expect(confirm(page)).toBeVisible(); + await page.waitForTimeout(500); + expect(host.objectCalls(), "no automatic retry").toEqual(["PATCH"]); + + // A deliberate Save reads instead of writing again, and finds the write landed. + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "confirmed"); + await expect(outcome(page)).toContainText("resourceVersion 2. Every field matches the server."); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + await expect(confirm(page)).toBeHidden(); + host.flush(); + await expect(page.locator("#changes")).toHaveText("No pending edits."); +}); + +test("a proxy 502 is confirmed by a GET, and the unsaved edit is written by the next Save", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((_call, res) => status(res, 502, "upstream unavailable")); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "unknown"); + await expect(outcome(page)).toContainText("HTTP 502"); + await confirm(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "confirmed"); + await expect(outcome(page)).toContainText("1 field still differ"); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + await expect(page.locator("#changes")).toContainText('change data value: "base" → "mine"'); + + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.objectCalls()).toEqual(["PATCH", "GET", "PATCH"]); +}); + +test("an accepted write without an echo is confirmed explicitly, and its unsaved field stays dirty", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry, "&echoWaitMs=1000"); + await field(page, "data value").fill("mine"); + // Accepted, but an admission webhook restored the value: the object is unchanged, so no event. + host.patches.push((_call, res) => json(res, 200, typed(host.object!))); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "saved"); + await expect(outcome(page)).toHaveAttribute("data-kind", "no-echo"); + await expect(confirm(page)).toBeVisible(); + await page.waitForTimeout(500); + expect(host.objectCalls(), "no automatic retry or read").toEqual(["PATCH"]); + + await confirm(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "confirmed"); + await expect(outcome(page)).toContainText("1 field still differ from the server and were not saved by this write"); + await expect(confirm(page)).toBeHidden(); + await expect(page.locator("#changes")).toContainText('change data value: "base" → "mine"'); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); +}); + +test("refused writes keep the draft and need no read", async ({ page, entry, host }) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((_call, res) => status(res, 403, 'configmaps "settings" is forbidden')); + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "forbidden"); + await expect(outcome(page)).toContainText('HTTP 403: configmaps "settings" is forbidden. Nothing was written'); + + host.patches.push((_call, res) => + status(res, 422, "ConfigMap is invalid", { details: { causes: [{ field: "data.value", message: "too long" }] } }), + ); + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "invalid"); + await expect(outcome(page)).toContainText("(data.value: too long)"); + await expect(field(page, "data value")).toHaveValue("mine"); + expect(host.objectCalls()).toEqual(["PATCH", "PATCH"]); +}); + +test("an object replaced under the same name is never written, and the replacement opens separately", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.patches.push((call, res) => { + host.replace({ deliver: false }); + host.patch(call, res); + }); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "unavailable"); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); + expect(host.object!.data.value).toBe("replacement"); + + // The watch catches up: the edited object is gone and its edits are kept for copying. + host.flush(); + await expect(page.locator("#removed")).toBeVisible(); + await expect(page.locator("#recovery")).toHaveText('change data value: "base" → "mine"'); + await expect(page.locator("#identity")).toContainText("Removed from the server."); + await expect(save(page)).toBeDisabled(); + + await page.getByRole("button", { name: "Discard these edits and open the replacement" }).click(); + await expect(page.locator("#identity")).toContainText("u2"); + await expect(field(page, "data value")).toHaveValue("replacement"); + await expect(page.locator("#changes")).toHaveText("No pending edits."); + expect(host.objectCalls()).toEqual(["PATCH", "GET"]); +}); + +test("disconnect closes the watch, clears the echo timer and ignores a late answer", async ({ page, entry, host }) => { + await open(page, host, entry, "&echoWaitMs=1500"); + await field(page, "data value").fill("mine"); + host.patches.push((_call, res) => json(res, 200, typed(host.object!))); + await save(page).click(); + await expect(outcome(page)).toHaveAttribute("data-kind", "saved"); + + // No echo yet, so the next Save is a guarded read. Hold it, and disconnect while it is in flight. + const held = gate(); + host.reads.push(async (_call, res) => { + await held.opened; + json(res, 200, typed(host.object!)); + }); + await save(page).click(); + await expect.poll(() => host.objectCalls()).toEqual(["PATCH", "GET"]); + await page.getByRole("button", { name: "Disconnect" }).click(); + + await expect(page.locator("#state")).toHaveText("closed"); + await expect(page.locator("#outcome")).toHaveAttribute("data-kind", "closed"); + await expect(field(page, "data value")).toBeDisabled(); + await expect(save(page)).toBeDisabled(); + await expect.poll(() => host.openWatches).toBe(0); + const requests = host.calls.length; + held.open(); + await page.waitForTimeout(2_000); // past the echo wait + await expect(page.locator("#outcome")).toHaveAttribute("data-kind", "closed"); + await expect(confirm(page)).toBeHidden(); + expect(host.calls.length, "nothing is requested after disconnect").toBe(requests); +}); From 5898c99b9aa7abaae7f462549a1ee4d91025dabc Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 13:12:22 +0000 Subject: [PATCH 2/2] fix(examples): keep multiline values and show a deleted object's edits at once Review fixes for the native editor page. - Edit values in text areas. An strips line breaks from its value, so editing a multiline data value or annotation and saving it wrote the value back without them. - Make the recovery-copy recipe's reads independent of listener order. copy() and removed now capture first, so a host listener subscribed before the recipe, such as the page's render, sees a deletion immediately instead of "No unsaved edits." until a later render. A disposed recipe stays empty. - Browser tests for multiline values and a standalone deletion, and a recipe test for the listener order; each fails without its fix. - Docs: proposal 0009 and the migration guide name connectResourceStream the gateway connector, 0009 records that native editing has landed, and the glossary includes native recovery reads. Co-Authored-By: Claude Opus 5.5 --- docs/field-reports/third-our-identity.md | 6 +-- docs/glossary.md | 3 +- docs/migrating.md | 2 +- .../0009-stream-and-editor-separation.md | 8 +-- examples/editor-recipes/README.md | 3 +- examples/editor-recipes/recoveryCopy.ts | 7 +++ examples/native-editor/README.md | 6 ++- examples/native-editor/index.html | 14 +++--- .../tests/native-editor.spec.ts | 49 +++++++++++++++++++ packages/krm-stream/test/recipes.test.ts | 11 +++++ 10 files changed, 92 insertions(+), 17 deletions(-) diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index cb14331..7148238 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -146,9 +146,9 @@ second save controller or new library API. `examples/vanilla-browser/tests/nativ a test server playing the host proxy. It covers a save and its echo, typing and focus during a save, 409 recovery and a second deliberate save, explicit conflict resolution, a lost response and a 502 each settled by a GET before any further PATCH, an accepted write without an echo confirmed -explicitly, 403 and 422 refusals, UID replacement with copy-out, and disconnect cleanup. A manual -run through `kubectl proxy` against the v1.36.4+k3s1 spike cluster saved and echoed on both entry -points and rendered a real 422. The comparative frontend (order 6) remains separate. +explicitly, 403 and 422 refusals, multiline values, deletion and UID replacement with copy-out, and +disconnect cleanup. A manual run through `kubectl proxy` against the v1.36.4+k3s1 spike cluster +saved and echoed on both entry points and rendered a real 422. The comparative frontend (order 6) remains separate. ## Follow-ups after the viewer diff --git a/docs/glossary.md b/docs/glossary.md index a5b7140..e6d9a70 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -90,7 +90,8 @@ request. The host authorizes, validates and conditionally writes it. A **merge p editable changes; `null` means deletion. Never write a whole projected object back with PUT. **Version rejection** means a precondition failed, not necessarily a field disagreement. Current -recovery is a guarded projected read, review and another deliberate Save. A **reconciliation guard** +recovery is a guarded read from the store's own source (a projected GET for a gateway view, a native +GET through the host proxy for native objects), review and another deliberate Save. A **reconciliation guard** prevents a delayed read/response from overwriting newer watch state or crossing UID/snapshot recovery. **Dirty draft, accepted write and domain progress** are separate facts. Later typing survives Save; diff --git a/docs/migrating.md b/docs/migrating.md index 1ab1d77..c4977d2 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -12,7 +12,7 @@ For the overall direction, see [watching resources](why-a-gateway.md). ## Browser client -`connectResourceStream` is now the only connector. It takes a callback that receives each resource +`connectResourceStream` is now the only gateway connector. It takes a callback that receives each resource state event, instead of a store: | 0.7 | Now | diff --git a/docs/proposals/0009-stream-and-editor-separation.md b/docs/proposals/0009-stream-and-editor-separation.md index d605ad9..0d62b59 100644 --- a/docs/proposals/0009-stream-and-editor-separation.md +++ b/docs/proposals/0009-stream-and-editor-separation.md @@ -25,12 +25,14 @@ The [native-watch request](../field-reports/third-our-identity.md#native-watch-c separation: native is the original-resource source through a host proxy; the gateway provides projected views over SSE. Share lifecycle and state application without converting native JSON to SSE. The first native slice, `connectNativeWatch`, is a read-only viewer using today's event consumer and -standalone `applyStreamEvent`. It did not depend on the editor cleanup below or a new store; native -editing follows separately. +standalone `applyStreamEvent`. It did not depend on the editor cleanup below or a new store. Native +editing followed in slice 2 on the same terms: conditional merge patches through the same proxy, +with the existing store and no editor method changed. ## Implemented connector and correctness fixes -`connectResourceStream(url, consume, options)` is the one current connector. `consume` receives +`connectResourceStream(url, consume, options)` is the one current gateway connector; +`connectNativeWatch` shares its lifecycle, consumer and handle for native sources. `consume` receives `ResourceStateEvent` synchronously and independently of a store. It can call the standalone `applyStreamEvent(store, event)` or another state consumer. The connector handles wire sequence and errors privately; its handle exposes `state`, `subscribe`, `close` and `closed`. diff --git a/examples/editor-recipes/README.md b/examples/editor-recipes/README.md index d848024..d79ad18 100644 --- a/examples/editor-recipes/README.md +++ b/examples/editor-recipes/README.md @@ -10,7 +10,8 @@ with `task test`, driving the store with stream events as `connectResourceStream 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: +UID's draft and unsaved edits, refreshed on every store notification from the moment it starts. +Its reads are current even inside a store listener the host subscribed before it: ```ts const recovery = retainRecoveryCopy(store, uid, { retainMs: 15 * 60_000 }); diff --git a/examples/editor-recipes/recoveryCopy.ts b/examples/editor-recipes/recoveryCopy.ts index b65a1e0..d71645e 100644 --- a/examples/editor-recipes/recoveryCopy.ts +++ b/examples/editor-recipes/recoveryCopy.ts @@ -31,8 +31,10 @@ export function retainRecoveryCopy(store: LiveResourceStore, uid: string, option const now = options.now ?? Date.now; let last: Omit | null = null; let removedAt: number | undefined; + let disposed = false; const capture = () => { + if (disposed) return; 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(); @@ -51,18 +53,23 @@ export function retainRecoveryCopy(store: LiveResourceStore, uid: string, option const stop = store.subscribe(capture); return { + // Both readers capture first: a host listener subscribed before this recipe runs before its + // listener, and would otherwise read a removal the copy has not yet seen. /** The live object's draft is the one to edit; a copy exists only once the object is gone. */ get removed(): boolean { + capture(); 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 { + capture(); 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 { + disposed = true; stop(); last = null; }, diff --git a/examples/native-editor/README.md b/examples/native-editor/README.md index 8d64260..2285459 100644 --- a/examples/native-editor/README.md +++ b/examples/native-editor/README.md @@ -80,6 +80,8 @@ Add `entry=bundle` to load the single-file build. Typing into a field whose save is in flight becomes a conflict when the echo arrives: the server holds the saved value and the draft holds the later typing. - A refusal (401, 403, 422 or another 4xx) shows the Kubernetes `Status` and keeps the draft. +- Values are edited in text areas, so line breaks survive. A browser text area normalizes `\r\n` to + `\n`, so editing a value that contains carriage returns writes it back without them. - A deleted or replaced object shows its unsaved edits from the recovery copy. A replacement opens only on request, without the old edits. - **Disconnect**, or leaving the page, closes the watch, unsubscribes and clears the echo timer. A @@ -100,8 +102,8 @@ the page and plays the host proxy and API server, with a watch that stays open w [native-editor.spec.ts](../vanilla-browser/tests/native-editor.spec.ts). It covers a conditional save and its echo, typing while a save is in flight, 409 recovery and a later deliberate Save, explicit conflict resolution, a lost response and a 502 each settled by a GET before any further -PATCH, an accepted write without an echo confirmed explicitly, refusals, UID replacement and -disconnect. +PATCH, an accepted write without an echo confirmed explicitly, refusals, multiline values, a deletion +and a UID replacement each with copy-out, and disconnect. ## What it protects diff --git a/examples/native-editor/index.html b/examples/native-editor/index.html index b6f7a8e..4602fea 100644 --- a/examples/native-editor/index.html +++ b/examples/native-editor/index.html @@ -41,8 +41,10 @@ section { margin: 1.25rem 0; } fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } .sub { opacity: .65; margin: 0 0 1rem; overflow-wrap: anywhere; } - button, input, select { font: inherit; } - input[type="text"] { width: 100%; box-sizing: border-box; } + button, input, select, textarea { font: inherit; } + /* A textarea, not an input: ConfigMap values and annotations can hold newlines, and an + strips them from its value. */ + td textarea { width: 100%; box-sizing: border-box; resize: vertical; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: .3rem .5rem; vertical-align: top; border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); } th { font-weight: normal; white-space: nowrap; width: 1%; } @@ -86,7 +88,7 @@

    Draft — edits stay local until Save

    - + @@ -428,7 +430,8 @@

    Server state — read-only, follows the watch

    { const value = String(get(draft, path) ?? ""); - const input = tr.querySelector("input"); + const input = tr.querySelector("textarea"); + if (input) input.rows = Math.min(10, value.split("\n").length); if (input && input.value !== value) { // Only a server change reaches here: typing already updated the draft to match. Keep the // caret where it was if the person is in this field. @@ -492,8 +495,7 @@

    Server state — read-only, follows the watch

    store.setValue(uid, path, input.value)); value.append(input); diff --git a/examples/vanilla-browser/tests/native-editor.spec.ts b/examples/vanilla-browser/tests/native-editor.spec.ts index a0c3ff0..7057032 100644 --- a/examples/vanilla-browser/tests/native-editor.spec.ts +++ b/examples/vanilla-browser/tests/native-editor.spec.ts @@ -93,6 +93,14 @@ class Host { this.#emit("MODIFIED", this.object!, deliver); } + /** Delete the object, with nothing in its place. */ + remove() { + const old = this.object!; + old.metadata.resourceVersion = String(++this.#rv); + this.object = undefined; + this.#emit("DELETED", old, true); + } + /** Delete the object and create another under the same name, with a new UID. */ replace({ deliver = true } = {}) { const old = this.object!; @@ -565,3 +573,44 @@ test("disconnect closes the watch, clears the echo timer and ignores a late answ await expect(confirm(page)).toBeHidden(); expect(host.calls.length, "nothing is requested after disconnect").toBe(requests); }); + +test("multiline values keep their line breaks through editing and saving", async ({ page, entry, host }) => { + host.change((o) => { + o.data.script = "first\nsecond\n"; + }); + await open(page, host, entry); + const script = field(page, "data script"); + await expect(script).toHaveValue("first\nsecond\n"); + await script.click(); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.type("third"); + await field(page, "annotation owner").fill("team-a\nteam-b"); + await save(page).click(); + + await expect(outcome(page)).toHaveAttribute("data-kind", "echoed"); + expect(host.patchBodies()).toEqual([ + { + metadata: { annotations: { owner: "team-a\nteam-b" }, uid: "u1", resourceVersion: "2" }, + data: { script: "first\nsecond\nthird" }, + }, + ]); + await expect(script).toHaveValue("first\nsecond\nthird"); +}); + +test("a deleted object shows its unsaved edits at once, with nothing to open in its place", async ({ + page, + entry, + host, +}) => { + await open(page, host, entry); + await field(page, "data value").fill("mine"); + host.remove(); + + // The deletion is the last event: no later render could fill the copy in. + await expect(page.locator("#removed")).toBeVisible(); + await expect(page.locator("#recovery")).toHaveText('change data value: "base" → "mine"'); + await expect(page.locator("#identity")).toContainText("Removed from the server."); + await expect(page.getByRole("button", { name: "Discard these edits and open the replacement" })).toBeHidden(); + await expect(save(page)).toBeDisabled(); + expect(host.objectCalls()).toEqual([]); +}); diff --git a/packages/krm-stream/test/recipes.test.ts b/packages/krm-stream/test/recipes.test.ts index 0ad74e7..bd85375 100644 --- a/packages/krm-stream/test/recipes.test.ts +++ b/packages/krm-stream/test/recipes.test.ts @@ -47,6 +47,17 @@ test("recovery: the copy taken when the recipe starts survives an immediate dele assert.equal(copy?.removedAt, 1000); }); +test("recovery: a host listener subscribed before the recipe already sees the removal", () => { + const store = storeWith(widget("u", "1", { replicas: 1 })); + const seen: unknown[] = []; + // A page renders from its own listener, which it subscribed first, and reads the copy there. + store.subscribe(() => seen.push([recovery.removed, recovery.copy()?.draft.spec ?? null])); + const recovery = retainRecoveryCopy(store, "u"); + store.setValue("u", ["spec", "replicas"], 3); + applyStreamEvent(store, { type: "deleted", identity: identity("u") }); + assert.deepEqual(seen.at(-1), [true, { replicas: 3 }]); +}); + test("recovery: typing just before snapshot pruning is kept", () => { const store = storeWith(widget("u", "1", { replicas: 1, image: "v1" })); const recovery = retainRecoveryCopy(store, "u");