Module-scoped persistent storage (ctx.storage) + docker update state survives restarts #38

Merged
julian merged 3 commits from feat/module-storage into main 2026-08-16 18:21:06 +02:00
Owner

Closes #37.

What

Core: ctx.storage — module-scoped persistent KV.

  • New module_kv table (migration 10) in the existing DuckDB database; all access goes through the one serialized Db queue.
  • ModuleKv store (server/src/modules/storage.ts): JSON values, 8 MB cap measured in UTF-8 bytes, refuses values with no JSON form (a stored "undefined" would read back as silent data loss), corrupt rows read as null.
  • Host facade in buildContext closes over the module name — same scoping pattern as metrics/scheduler; a module cannot name another module's keys.
  • SDK surface: ctx.storage.get/set/delete, documented in docs/modules.md.

Docker: first consumer. The sweep schedule already survived a container recreate (task_schedules row), but the sweep result lived in module memory — so after a recreate the UI showed no updates until the next window, up to CHECK_INTERVAL_MS (default 6 h) later. Now:

  • After every sweep (full and scoped), refreshUpdates persists {updates, checkedAt} (packUpdates).
  • register() rehydrates via unpackUpdates, which validates per entry instead of casting — a stale layout reads as "nothing stored", one malformed row doesn't discard the rest.
  • Correctness of stale verdicts is handled by existing machinery: the immediate stacks tick re-derives updateAvailable from local digests against the stored remoteDigest, so a pull during downtime corrects itself within one poll. Registry sweep cadence is untouched — no boot-time re-sweep.
  • Fake mode neither reads nor writes storage; storage failures log and never disable the module or fail a sweep.

Tests

14 new tests (server/tests/storage_test.ts, docker/backend/persist_test.ts): roundtrip, module scoping, reopen-the-database persistence, JSON-form refusal, corrupt-row handling, per-entry unpack validation. deno task check clean; full server + docker suites: no new failures (the 6 pre-existing Windows-path failures on main are untouched — that's #20).

Reviewed by cavecrew-reviewer; its one finding (size cap measured UTF-16 code units, not bytes) is fixed in this diff.

🤖 Generated with Claude Code

Closes #37. ## What **Core: `ctx.storage`** — module-scoped persistent KV. - New `module_kv` table (migration 10) in the existing DuckDB database; all access goes through the one serialized `Db` queue. - `ModuleKv` store (`server/src/modules/storage.ts`): JSON values, 8 MB cap measured in UTF-8 bytes, refuses values with no JSON form (a stored `"undefined"` would read back as silent data loss), corrupt rows read as null. - Host facade in `buildContext` closes over the module name — same scoping pattern as metrics/scheduler; a module cannot name another module's keys. - SDK surface: `ctx.storage.get/set/delete`, documented in `docs/modules.md`. **Docker: first consumer.** The sweep *schedule* already survived a container recreate (`task_schedules` row), but the sweep *result* lived in module memory — so after a recreate the UI showed no updates until the next window, up to `CHECK_INTERVAL_MS` (default 6 h) later. Now: - After every sweep (full and scoped), `refreshUpdates` persists `{updates, checkedAt}` (`packUpdates`). - `register()` rehydrates via `unpackUpdates`, which validates per entry instead of casting — a stale layout reads as "nothing stored", one malformed row doesn't discard the rest. - Correctness of stale verdicts is handled by existing machinery: the immediate stacks tick re-derives `updateAvailable` from local digests against the stored `remoteDigest`, so a pull during downtime corrects itself within one poll. Registry sweep cadence is untouched — no boot-time re-sweep. - Fake mode neither reads nor writes storage; storage failures log and never disable the module or fail a sweep. ## Tests 14 new tests (`server/tests/storage_test.ts`, `docker/backend/persist_test.ts`): roundtrip, module scoping, reopen-the-database persistence, JSON-form refusal, corrupt-row handling, per-entry unpack validation. `deno task check` clean; full server + docker suites: no new failures (the 6 pre-existing Windows-path failures on main are untouched — that's #20). Reviewed by cavecrew-reviewer; its one finding (size cap measured UTF-16 code units, not bytes) is fixed in this diff. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(storage): module-scoped persistent KV, and the docker verdicts survive a restart
Some checks failed
Build and Deploy / verify (pull_request) Failing after 47s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
9c73ef2222
The sweep schedule already outlived a container recreate — its window is a
task_schedules row — but the sweep's result lived in module memory, so a
recreated container showed no updates until the next window, hours away.
Re-sweeping at boot is the wrong fix (see the schedule-anchoring comment in
tasks/service.ts); persisting the result is.

ctx.storage is the general half: a JSON KV in the one DuckDB database,
scoped by module name in the host facade the same way metrics and scheduler
are. The docker module is the first consumer: verdicts packed after every
sweep, unpacked (validated per entry, never trusted) at register(), and the
immediate stacks tick re-derives every verdict from local digests so a pull
during the downtime corrects itself within one poll.

Closes #37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
style(docs): let deno fmt rewrap the storage section
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m19s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
e03ef83112
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thisilike requested changes 2026-08-15 23:55:16 +02:00
Dismissed
thisilike left a comment

Reviewed at e03ef83 in a detached worktree. deno fmt --check, deno lint, deno task check clean; full suite 466 passed / 0 failed here (the Windows-path failures the description mentions are Windows-only, they don't reproduce on Linux). The core store itself is well-built — 8 MB measured in UTF-8 bytes, JSON.stringify returning undefined refused rather than written, a corrupt row reading as null, per-entry unpack validation, scoping owned by the host facade instead of promised by the caller. The tests cover the reopen-the-database case, which is the one that actually proves the feature.

One blocker, and it is the correctness argument the issue and the PR description both rest on.

Blocking: nothing prunes the rehydrated map until the next full sweep

Issue #37 says correctness is already handled because "refreshStacks re-derives updateAvailableand pruneUpdates drops images that no longer run". Only the first half is true. pruneUpdates has exactly one call site — mod.ts:316, inside refreshUpdates, the expensive registry sweep. refreshStacks never prunes; its re-derivation loop (mod.ts:229-237) continues on any image it can't find locally, which is precisely the ghost case:

const digest = local.get(image)?.digest;
if (!digest || !info.remoteDigest) continue;   // image gone → verdict kept, untouched

Before this PR that was unreachable, because state.updates was only ever assigned from refreshUpdates, which prunes on the way out. Now the map is seeded at boot from storage and nothing prunes it until the next sweep — up to CHECK_INTERVAL_MS, default 6 h.

Concrete failure: a stack is removed (or its images retagged) while OpsDeck is down — the recreate window this feature exists to cover. On boot the verdicts for those images come back with updateAvailable: true, no local digest, and no prune. StacksPage.svelte:459 counts the map globally, not per running container:

Object.values(data.updates).filter((u) => u.updateAvailable).length

so the header reads updates checked <6 h ago> — 2 update(s) available while no stack card shows a single pending update, and it stays that way for a whole check interval. That is the same "badge lies about the host" symptom the PR is fixing, in the other direction.

Fix is one line on the path that already runs every 30 s — after the re-derivation block in refreshStacks:

state.updates = pruneUpdates(state.updates, state.stacks);

state.stacks is the full set even on a scoped refresh (mergeScopedStacks merges into it), so it is safe there too. Worth a test: rehydrate a map holding an image no stack runs, tick refreshStacks, assert it is gone.

Non-blocking

  • mod.ts:330-342 — the cache write is on the publish path. await ctx.storage.set(...) sits between the metric write and publishStacksIfChanged(), so a DuckDB queue round-trip (behind whatever metrics flush is in front of it) now delays the live push of the verdicts the sweep just produced. The comment correctly says failing to cache must not fail the run; the same logic says it must not hold up the publish. Publish first, persist after.
  • persist.ts:38-42 — validation stops at the required fields. upgrades, upgradeOptions, newerTag, tagsError pass through unchecked, and upgradeOptions is consumed by the pin chooser as {patch: string[], minor: string[], major: string[]}. A row from a layout that shaped those differently is exactly the case the doc comment promises to reject ("a stale layout has to read as nothing stored"), and it currently reaches the frontend. Cheap to check the arrays.
  • storage.ts — no per-module bound and no way to enumerate. The cap is per value; a module can write unbounded keys, and there is no list/deleteAll, so rows for a module that was removed or renamed live in /data forever with nothing able to see them. updated_at_ms is written and never read, so today it is decoration. Not a reason to hold the PR — and please do not auto-delete on load failure, a module that failed to clone this boot must not lose its state — but a listing capability is the thing that makes this maintainable later.
  • storage_test.tsrig() never removes its temp dir, and db.close() is not in a finally. A failing assertion therefore leaks an open DuckDB handle and Deno's sanitizer reports the leak instead of the assertion, which is the harder failure to read.
  • set validates an empty key; get and delete do not. Minor asymmetry, but get("") silently answering null is a caller bug that stays invisible.
  • CLAUDE.md not updated. The module-system paragraph enumerates what ctx scopes by module name (router, metrics, scheduler, SSE topics, exec(), dockerFetch); storage belongs in that list, and the "runs through the one serialized DuckDB queue" constraint belongs where the metrics pipeline section states it.

Everything above except the first item is fine as follow-up. The prune gap should land in this PR — it is the premise the design was accepted on.

Reviewed at `e03ef83` in a detached worktree. `deno fmt --check`, `deno lint`, `deno task check` clean; full suite **466 passed / 0 failed** here (the Windows-path failures the description mentions are Windows-only, they don't reproduce on Linux). The core store itself is well-built — 8 MB measured in UTF-8 bytes, `JSON.stringify` returning `undefined` refused rather than written, a corrupt row reading as null, per-entry unpack validation, scoping owned by the host facade instead of promised by the caller. The tests cover the reopen-the-database case, which is the one that actually proves the feature. One blocker, and it is the correctness argument the issue and the PR description both rest on. ## Blocking: nothing prunes the rehydrated map until the next full sweep Issue #37 says correctness is already handled because "`refreshStacks` re-derives `updateAvailable` … **and `pruneUpdates` drops images that no longer run**". Only the first half is true. `pruneUpdates` has exactly one call site — `mod.ts:316`, inside `refreshUpdates`, the expensive registry sweep. `refreshStacks` never prunes; its re-derivation loop (`mod.ts:229-237`) `continue`s on any image it can't find locally, which is precisely the ghost case: ```ts const digest = local.get(image)?.digest; if (!digest || !info.remoteDigest) continue; // image gone → verdict kept, untouched ``` Before this PR that was unreachable, because `state.updates` was only ever assigned from `refreshUpdates`, which prunes on the way out. Now the map is seeded at boot from storage and nothing prunes it until the next sweep — up to `CHECK_INTERVAL_MS`, default 6 h. Concrete failure: a stack is removed (or its images retagged) while OpsDeck is down — the recreate window this feature exists to cover. On boot the verdicts for those images come back with `updateAvailable: true`, no local digest, and no prune. `StacksPage.svelte:459` counts the map globally, not per running container: ```ts Object.values(data.updates).filter((u) => u.updateAvailable).length ``` so the header reads `updates checked <6 h ago> — 2 update(s) available` while no stack card shows a single pending update, and it stays that way for a whole check interval. That is the same "badge lies about the host" symptom the PR is fixing, in the other direction. Fix is one line on the path that already runs every 30 s — after the re-derivation block in `refreshStacks`: ```ts state.updates = pruneUpdates(state.updates, state.stacks); ``` `state.stacks` is the full set even on a scoped refresh (`mergeScopedStacks` merges into it), so it is safe there too. Worth a test: rehydrate a map holding an image no stack runs, tick `refreshStacks`, assert it is gone. ## Non-blocking - **`mod.ts:330-342` — the cache write is on the publish path.** `await ctx.storage.set(...)` sits between the metric write and `publishStacksIfChanged()`, so a DuckDB queue round-trip (behind whatever metrics flush is in front of it) now delays the live push of the verdicts the sweep just produced. The comment correctly says failing to cache must not fail the run; the same logic says it must not hold up the publish. Publish first, persist after. - **`persist.ts:38-42` — validation stops at the required fields.** `upgrades`, `upgradeOptions`, `newerTag`, `tagsError` pass through unchecked, and `upgradeOptions` is consumed by the pin chooser as `{patch: string[], minor: string[], major: string[]}`. A row from a layout that shaped those differently is exactly the case the doc comment promises to reject ("a stale layout has to read as nothing stored"), and it currently reaches the frontend. Cheap to check the arrays. - **`storage.ts` — no per-module bound and no way to enumerate.** The cap is per value; a module can write unbounded keys, and there is no `list`/`deleteAll`, so rows for a module that was removed or renamed live in `/data` forever with nothing able to see them. `updated_at_ms` is written and never read, so today it is decoration. Not a reason to hold the PR — and please do **not** auto-delete on load failure, a module that failed to clone this boot must not lose its state — but a listing capability is the thing that makes this maintainable later. - **`storage_test.ts`** — `rig()` never removes its temp dir, and `db.close()` is not in a `finally`. A failing assertion therefore leaks an open DuckDB handle and Deno's sanitizer reports the leak instead of the assertion, which is the harder failure to read. - **`set` validates an empty key; `get` and `delete` do not.** Minor asymmetry, but `get("")` silently answering null is a caller bug that stays invisible. - **CLAUDE.md not updated.** The module-system paragraph enumerates what `ctx` scopes by module name (router, metrics, scheduler, SSE topics, `exec()`, `dockerFetch`); `storage` belongs in that list, and the "runs through the one serialized DuckDB queue" constraint belongs where the metrics pipeline section states it. Everything above except the first item is fine as follow-up. The prune gap should land in this PR — it is the premise the design was accepted on.
@ -66,0 +76,4 @@
try {
const stored = unpackUpdates(await ctx.storage.get("updates"));
if (stored) {
state.updates = stored.updates;
Owner

Blocking. What comes back here is never pruned against what actually runs.

pruneUpdates is only ever called from refreshUpdates (mod.ts:316), the registry sweep — the thing this feature deliberately does not run at boot. refreshStacks re-derives verdicts but continues past any image it cannot find locally (mod.ts:231), so an image removed during the downtime keeps its stored updateAvailable: true until the next sweep, up to CHECK_INTERVAL_MS (6 h).

Issue #37 assumed this was already handled ("pruneUpdates drops images that no longer run"); it is not, on the poll path. StacksPage.svelte:459 counts the map globally, so the header says "N update(s) available" while no stack shows one.

Add state.updates = pruneUpdates(state.updates, state.stacks) at the end of the re-derivation block in refreshStacksstate.stacks is the full set even on a scoped refresh.

Blocking. What comes back here is never pruned against what actually runs. `pruneUpdates` is only ever called from `refreshUpdates` (`mod.ts:316`), the registry sweep — the thing this feature deliberately does not run at boot. `refreshStacks` re-derives verdicts but `continue`s past any image it cannot find locally (`mod.ts:231`), so an image removed during the downtime keeps its stored `updateAvailable: true` until the next sweep, up to `CHECK_INTERVAL_MS` (6 h). Issue #37 assumed this was already handled ("`pruneUpdates` drops images that no longer run"); it is not, on the poll path. `StacksPage.svelte:459` counts the map globally, so the header says "N update(s) available" while no stack shows one. Add `state.updates = pruneUpdates(state.updates, state.stacks)` at the end of the re-derivation block in `refreshStacks` — `state.stacks` is the full set even on a scoped refresh.
@ -303,1 +339,4 @@
});
}
}
publishStacksIfChanged();
Owner

The persist is awaited before publishStacksIfChanged(), so the live push of the verdicts now waits on a DuckDB queue round-trip that may be queued behind a metrics flush.

The comment above has the right principle — caching for the next boot must not fail the run that produced the verdicts — and it applies to latency too: publish first, then persist.

The persist is awaited before `publishStacksIfChanged()`, so the live push of the verdicts now waits on a DuckDB queue round-trip that may be queued behind a metrics flush. The comment above has the right principle — caching for the next boot must not fail the run that produced the verdicts — and it applies to latency too: publish first, then persist.
@ -0,0 +39,4 @@
(u.localDigest === null || typeof u.localDigest === "string") &&
(u.remoteDigest === null || typeof u.remoteDigest === "string") &&
(u.updateAvailable === null || typeof u.updateAvailable === "boolean") &&
typeof u.checkedAt === "number";
Owner

Validation covers the required fields only. upgrades, upgradeOptions, newerTag and tagsError pass through untouched, and upgradeOptions is read by the pin chooser as {patch: string[], minor: string[], major: string[]} — a row written by a layout that shaped it differently is exactly what the doc comment promises to reject, and it reaches the frontend instead.

Checking the three arrays is a few lines and keeps the "a stale layout reads as nothing stored" contract honest.

Validation covers the required fields only. `upgrades`, `upgradeOptions`, `newerTag` and `tagsError` pass through untouched, and `upgradeOptions` is read by the pin chooser as `{patch: string[], minor: string[], major: string[]}` — a row written by a layout that shaped it differently is exactly what the doc comment promises to reject, and it reaches the frontend instead. Checking the three arrays is a few lines and keeps the "a stale layout reads as nothing stored" contract honest.
@ -0,0 +26,4 @@
* verdict map or a badge catalog, and the biggest legitimate consumer in
* sight (per-image update state for a large host) is well under a megabyte.
*/
const MAX_VALUE_BYTES = 8 * 1024 * 1024;
Owner

The cap is per value, so nothing bounds a module's total footprint: unbounded keys are allowed, there is no list/deleteAll, and rows belonging to a module that was removed or renamed stay in /data with no surface able to see them. updated_at_ms is written and never read, which is the column that would let you do something about it later.

Not a blocker, and please don't auto-delete on module load failure — a module that failed to clone this boot must not lose its state. An enumeration capability is what makes this cleanable.

The cap is per value, so nothing bounds a module's total footprint: unbounded keys are allowed, there is no `list`/`deleteAll`, and rows belonging to a module that was removed or renamed stay in `/data` with no surface able to see them. `updated_at_ms` is written and never read, which is the column that would let you do something about it later. Not a blocker, and please don't auto-delete on module load failure — a module that failed to clone this boot must not lose its state. An enumeration capability is what makes this cleanable.
fix(docker): prune rehydrated verdicts on the poll path
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m51s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m11s
033d6b03b7
Review follow-ups for #38. The blocker: verdicts seeded from storage at
boot were never pruned against what actually runs until the next registry
sweep — refreshStacks re-derives but skips images with no local digest,
which is exactly an image whose stack was removed during the downtime, so
the header counted phantom updates for up to CHECK_INTERVAL_MS.
refreshStacks now prunes after the re-derivation block; state.stacks is
the full set even on a scoped refresh. Covered by mod_test.ts: register()
against a mocked host, rehydrate a ghost verdict, tick the poll, gone.

Also from the review:
- refreshUpdates publishes before it persists — the live push no longer
  waits on a DuckDB queue round-trip
- unpackUpdates validates optional fields too (newerTag, newestTag,
  tagsError, error, upgrades, upgradeOptions with its string-array bands);
  a row shaped by a stale layout reads as nothing stored instead of
  reaching the pin chooser
- ctx.storage get/delete refuse an empty key like set already did
- storage_test closes every Db handle and removes its temp dir in a
  finally, so a failing assertion reports itself instead of a leak
- CLAUDE.md: ctx.storage in the module-scoping list, and the storage
  rides the one DuckDB queue / publish first, persist after constraint
  in the metrics section

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Review addressed in 033d6b0. Point by point:

Blocker — prune gap on the poll path: fixed exactly where you pointed. refreshStacks now runs state.updates = pruneUpdates(state.updates, state.stacks) after the re-derivation block (inside the !fake branch — fake mode never rehydrates). And the test you asked for exists: new docker/backend/mod_test.ts runs register() against a mocked ModuleContext, seeds storage with a verdict for an image no stack runs, ticks the captured stacks callback, and asserts the ghost is gone from /stacks — while the surviving verdict is re-derived (updateAvailable flips false when the local digest matches the stored remote), which pins the "pull during downtime corrects itself" half of the design too.

Non-blocking, all taken in this PR except the last:

  • Publish path: refreshUpdates now calls publishStacksIfChanged() before the awaited ctx.storage.set(...) — the live push no longer waits on the DuckDB queue.
  • persist.ts optional fields: isUpdateInfo now checks newerTag, newestTag, tagsError, error, upgrades, and upgradeOptions including the three string-array bands the pin chooser indexes. New test case feeds it bands-as-scalars, a non-string in a band, a missing band, and non-string scalars — every mis-shaped row drops, the well-formed one survives.
  • storage_test.ts leak: rewritten around a kvTest wrapper — every Db handle closes and the temp dir is removed in a finally, so a failing assertion reports itself instead of a sanitizer leak. The reopen test still closes its first handle explicitly (single-writer), double-close is guarded.
  • Empty-key asymmetry: get and delete now refuse "" the same way set did; one shared requireKey, test covers all three.
  • CLAUDE.md: ctx.storage added to the module-scoping list, and the metrics section now states that storage rides the one DuckDB queue — publish first, persist after.
  • Enumeration / per-module bound / updated_at_ms: deferred as you suggested — filed as #39, including your "never auto-delete on load failure" constraint verbatim so it doesn't get lost.

Verified at 033d6b0: deno fmt --check, deno lint, deno task check clean; docker + server suites 326 passed, the same 6 pre-existing Windows-path failures as main (#20). Reviewed by cavecrew-reviewer before push: no findings.

Review addressed in 033d6b0. Point by point: **Blocker — prune gap on the poll path**: fixed exactly where you pointed. `refreshStacks` now runs `state.updates = pruneUpdates(state.updates, state.stacks)` after the re-derivation block (inside the `!fake` branch — fake mode never rehydrates). And the test you asked for exists: new `docker/backend/mod_test.ts` runs `register()` against a mocked `ModuleContext`, seeds storage with a verdict for an image no stack runs, ticks the captured stacks callback, and asserts the ghost is gone from `/stacks` — while the surviving verdict is re-derived (`updateAvailable` flips false when the local digest matches the stored remote), which pins the "pull during downtime corrects itself" half of the design too. **Non-blocking, all taken in this PR except the last:** - **Publish path**: `refreshUpdates` now calls `publishStacksIfChanged()` before the awaited `ctx.storage.set(...)` — the live push no longer waits on the DuckDB queue. - **`persist.ts` optional fields**: `isUpdateInfo` now checks `newerTag`, `newestTag`, `tagsError`, `error`, `upgrades`, and `upgradeOptions` including the three string-array bands the pin chooser indexes. New test case feeds it bands-as-scalars, a non-string in a band, a missing band, and non-string scalars — every mis-shaped row drops, the well-formed one survives. - **`storage_test.ts` leak**: rewritten around a `kvTest` wrapper — every `Db` handle closes and the temp dir is removed in a `finally`, so a failing assertion reports itself instead of a sanitizer leak. The reopen test still closes its first handle explicitly (single-writer), double-close is guarded. - **Empty-key asymmetry**: `get` and `delete` now refuse `""` the same way `set` did; one shared `requireKey`, test covers all three. - **CLAUDE.md**: `ctx.storage` added to the module-scoping list, and the metrics section now states that storage rides the one DuckDB queue — publish first, persist after. - **Enumeration / per-module bound / `updated_at_ms`**: deferred as you suggested — filed as #39, including your "never auto-delete on load failure" constraint verbatim so it doesn't get lost. Verified at 033d6b0: `deno fmt --check`, `deno lint`, `deno task check` clean; docker + server suites 326 passed, the same 6 pre-existing Windows-path failures as main (#20). Reviewed by cavecrew-reviewer before push: no findings.
thisilike approved these changes 2026-08-16 14:08:22 +02:00
thisilike left a comment

Re-reviewed at 033d6b0 in a detached worktree, checking the claims rather than the comment that makes them. The blocker is genuinely fixed — approving.

Verification (mine, not the description's)

  • deno fmt --check, deno lint, deno task check, deno task check:svelte (0 errors) all clean.
  • deno task test: 468 tests, 466 pass. The two failures are external_modules_test.ts's seedRepo hitting gpg: signing failed: Operation cancelled — my machine's commit.gpgsign, not this diff. Still no Windows-path failures on Linux; the "6 pre-existing failures" the description keeps citing are environmental (#20), not a baseline anyone else shares.
  • Mutation-checked the three fixes so I am not trusting a test's name:
    • delete mod.ts:248mod_test.ts:153 fails.
    • isUpgradeOptionsreturn truepersist_test fails.
    • requireKey → no-op → storage_test fails.

The blocker is properly fixed

mod.ts:248 prunes after the re-derivation loop, inside !fake, before the publish — and publishStacksIfChanged hashes updates as well as stacks (mod.ts:169), so the prune actually reaches connected clients instead of only correcting server memory. mergeScopedStacks does keep state.stacks whole on a scoped refresh, so the placement is safe there as claimed. mod_test.ts drives the real register() and the real refreshStacks against a mocked context rather than a re-implementation of the logic, and it pins the other half of the design too — nginx's verdict flips to false off the local digest.

persist.ts optional-field validation, the kvTest finally, get/delete empty-key symmetry, publish-before-persist, CLAUDE.md, #39 with the never-auto-delete constraint carried over — all present, all as described.

New, none blocking

1. The 8 MB cap has no test at all — including the UTF-8 fix that was the pre-review's one finding. I replaced the entire check with json.length > MAX_VALUE_BYTES * 1000 and all nine storage tests still passed. The measurement bug that got fixed can be reintroduced silently. Three lines close it: assertRejects(() => kv.set("docker", "k", "ä".repeat(5 * 1024 * 1024))) — over the cap in UTF-8 bytes, under it in UTF-16 code units, so it pins the fix and not merely the cap.

2. Pruning on every poll trades a phantom for a hole. I asked for it, so I will own the trade-off. pruneUpdates keys off the containers the engine currently lists. A poll landing between a manual docker compose down and up -d (containers absent from containers/json?all=true, a few seconds out of every 30) drops those images' verdicts, and nothing restores them until the next sweep — up to 6 h, the same interval this PR exists to close, in the other direction. On the action path the scoped refreshUpdates(project) heals it; on an operator's own compose run, nothing does. Narrower window and a less alarming direction than the phantom, so ship it. If you want it airtight later, the boot gap is all this ever needed to cover: let seededFromStorage = stored != null, prune while it is set, clear it on the first successful prune or sweep — the ghost case without a permanent way to lose a live verdict.

3. The pruned map is never persisted. Only refreshUpdates writes storage, so after a poll prunes a ghost the module_kv row still holds it; the next boot re-seeds the ghost and re-prunes it a tick later. Self-correcting, and consistent with "safe to serve stale" — but the persisted set only ever shrinks at sweep time, so a host whose registry sweeps keep failing accumulates dead verdicts in /data and resurrects them on every restart.

4. mod.ts:71 — "the immediate stacks tick below" is not unconditional. ctx.scheduler.every only stays a plain timer with an immediate queueMicrotask tick while the interval is ≤ 5 min (host.ts:409). Set INTERVAL_MS above that and the stacks poll becomes a real schedule, where firstRun: "immediate" fires only if the schedule row does not already exist (service.ts:1334-1337, with catchUp: "skip") — which after a restart it does. The rehydrated map is then unpruned and un-re-derived for a whole interval. INTERVAL_MS is undocumented and unlikely to be set there, so this is a comment that overstates rather than a bug — but the entire "safe to serve stale" argument rests on that one sentence.

5. Nothing tests that a sweep persists anything. mod_test's storage.set is a noop and storage.get returns a literal, so the two "updates" string literals at mod.ts:77 and mod.ts:344 are never checked against each other — a typo in either leaves the feature dead with a green suite. Capture what set was called with, feed it into a second ctxFor, assert the verdicts come back.

6. INSERT INTO module_kv VALUES (?, ?, ?, ?) is positional (storage.ts, and again at storage_test.ts:106). A migration 11 that adds a column to that table breaks the insert at runtime. Name the four columns.

Re-reviewed at `033d6b0` in a detached worktree, checking the claims rather than the comment that makes them. The blocker is genuinely fixed — approving. ## Verification (mine, not the description's) - `deno fmt --check`, `deno lint`, `deno task check`, `deno task check:svelte` (0 errors) all clean. - `deno task test`: 468 tests, 466 pass. The two failures are `external_modules_test.ts`'s `seedRepo` hitting `gpg: signing failed: Operation cancelled` — my machine's `commit.gpgsign`, not this diff. Still no Windows-path failures on Linux; the "6 pre-existing failures" the description keeps citing are environmental (#20), not a baseline anyone else shares. - Mutation-checked the three fixes so I am not trusting a test's name: - delete `mod.ts:248` → `mod_test.ts:153` fails. - `isUpgradeOptions` → `return true` → `persist_test` fails. - `requireKey` → no-op → `storage_test` fails. ## The blocker is properly fixed `mod.ts:248` prunes after the re-derivation loop, inside `!fake`, before the publish — and `publishStacksIfChanged` hashes `updates` as well as `stacks` (`mod.ts:169`), so the prune actually reaches connected clients instead of only correcting server memory. `mergeScopedStacks` does keep `state.stacks` whole on a scoped refresh, so the placement is safe there as claimed. `mod_test.ts` drives the real `register()` and the real `refreshStacks` against a mocked context rather than a re-implementation of the logic, and it pins the other half of the design too — nginx's verdict flips to false off the local digest. `persist.ts` optional-field validation, the `kvTest` `finally`, `get`/`delete` empty-key symmetry, publish-before-persist, CLAUDE.md, #39 with the never-auto-delete constraint carried over — all present, all as described. ## New, none blocking **1. The 8 MB cap has no test at all — including the UTF-8 fix that was the pre-review's one finding.** I replaced the entire check with `json.length > MAX_VALUE_BYTES * 1000` and all nine storage tests still passed. The measurement bug that got fixed can be reintroduced silently. Three lines close it: `assertRejects(() => kv.set("docker", "k", "ä".repeat(5 * 1024 * 1024)))` — over the cap in UTF-8 bytes, under it in UTF-16 code units, so it pins the fix and not merely the cap. **2. Pruning on every poll trades a phantom for a hole. I asked for it, so I will own the trade-off.** `pruneUpdates` keys off the containers the engine currently lists. A poll landing between a manual `docker compose down` and `up -d` (containers absent from `containers/json?all=true`, a few seconds out of every 30) drops those images' verdicts, and nothing restores them until the next sweep — up to 6 h, the same interval this PR exists to close, in the other direction. On the action path the scoped `refreshUpdates(project)` heals it; on an operator's own compose run, nothing does. Narrower window and a less alarming direction than the phantom, so ship it. If you want it airtight later, the boot gap is all this ever needed to cover: `let seededFromStorage = stored != null`, prune while it is set, clear it on the first successful prune or sweep — the ghost case without a permanent way to lose a live verdict. **3. The pruned map is never persisted.** Only `refreshUpdates` writes storage, so after a poll prunes a ghost the `module_kv` row still holds it; the next boot re-seeds the ghost and re-prunes it a tick later. Self-correcting, and consistent with "safe to serve stale" — but the persisted set only ever shrinks at sweep time, so a host whose registry sweeps keep failing accumulates dead verdicts in `/data` and resurrects them on every restart. **4. `mod.ts:71` — "the immediate stacks tick below" is not unconditional.** `ctx.scheduler.every` only stays a plain timer with an immediate `queueMicrotask` tick while the interval is ≤ 5 min (`host.ts:409`). Set `INTERVAL_MS` above that and the stacks poll becomes a real schedule, where `firstRun: "immediate"` fires only if the schedule row does not already exist (`service.ts:1334-1337`, with `catchUp: "skip"`) — which after a restart it does. The rehydrated map is then unpruned and un-re-derived for a whole interval. `INTERVAL_MS` is undocumented and unlikely to be set there, so this is a comment that overstates rather than a bug — but the entire "safe to serve stale" argument rests on that one sentence. **5. Nothing tests that a sweep persists anything.** `mod_test`'s `storage.set` is a noop and `storage.get` returns a literal, so the two `"updates"` string literals at `mod.ts:77` and `mod.ts:344` are never checked against each other — a typo in either leaves the feature dead with a green suite. Capture what `set` was called with, feed it into a second `ctxFor`, assert the verdicts come back. **6. `INSERT INTO module_kv VALUES (?, ?, ?, ?)` is positional** (`storage.ts`, and again at `storage_test.ts:106`). A migration 11 that adds a column to that table breaks the insert at runtime. Name the four columns.
julian merged commit db57f4dcea into main 2026-08-16 18:21:06 +02:00
julian deleted branch feat/module-storage 2026-08-16 18:21:07 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!38
No description provided.