Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Node
node_modules/
packages/*/dist/
examples/native-editor/dist/

# Go
/bin/
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
24 changes: 22 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions docs/field-reports/third-our-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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

Expand Down
3 changes: 2 additions & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docs/migrating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions docs/proposals/0006-stream-and-save-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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. |
Expand Down
8 changes: 5 additions & 3 deletions docs/proposals/0009-stream-and-editor-separation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 3 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/editor-recipes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
7 changes: 7 additions & 0 deletions examples/editor-recipes/recoveryCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export function retainRecoveryCopy(store: LiveResourceStore, uid: string, option
const now = options.now ?? Date.now;
let last: Omit<RecoveryCopy, "removedAt"> | 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();
Expand All @@ -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;
},
Expand Down
59 changes: 59 additions & 0 deletions examples/native-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -46,6 +48,63 @@ 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.
- 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
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, multiline values, a deletion
and a UID replacement each with copy-out, and disconnect.

## What it protects

- **Machinery.** `metadata.managedFields` and the `kubectl.kubernetes.io/last-applied-configuration`
Expand Down
Loading
Loading