Relationships
#3056 Datastore rework Phase 2: find, wrap and warn on hooked writes outside a unit of work (route 2), without removing it (no behaviour change)
Opened by stack72 · 10/6/2026· Shipped 10/6/2026
Background (read this first)
swamp is reworking its datastore layer (tracking: swamp-club#2865). Phase 1 is complete. Phase 2 has shipped:
- swamp-club#3025: use cases open units of work;
- swamp-club#3032: root units per command or request;
- swamp-club#3033, swamp-club#3034 and swamp-club#3035: the CLI and serve push through roots;
- swamp-club#3053: checkpoints;
- swamp-club#3055: one flush path, so every push is a root's flush or checkpoint, with
PINNED_DIRECT_PUSHESlisting the deliberate exceptions.
Phase 2 must change no behaviour.
The hook fallback ("route 2"). signalChange(markDirty, change) in src/infrastructure/persistence/unit_of_work_scope.ts (main at 94baeb4c) is how every hooked repository signals a change:
- Route 1: if the ambient unit wraps this exact hook, stage into it.
- Route 2: otherwise, if there is a hook, call it directly.
- Route 3: otherwise, do nothing (filesystem datastores have no hook).
Route 2 is the safety net. Any hooked write made outside a unit still reaches the sync service through it. The Phase 2 plan is to remove route 2, so repositories reach the hook only through units.
Removing it now isn't safe, because the failure is silent. A missed write path outside a unit would never be marked: it stays on one machine, and nothing errors. Tests only find paths they exercise. Candidates for writes outside a unit include:
- serve start-up migration and token GC;
- serve background GC (
bookkeeping_gc.ts,worker_gc_service.ts); datastore setup's migration;datastore sync;- libswamp code that isn't a wrapped use case;
- rarely used CLI commands and error paths;
- the workflow step-lock paths.
So route 2 is removed in two issues. This one finds, wraps and observes, and changes no behaviour. A later issue removes route 2, once the observation here has stayed quiet through dogfooding and the swamp-uat suites. This issue must not remove route 2.
Goal
- Find every hooked write that takes route 2.
- Give each a root, so it takes route 1.
- Make any remaining route-2 use visible: a warning in production, and a failure in tests.
The marks and pushes stay identical.
Work
A route-2 reporter in
signalChange. When route 2 runs, call the hook exactly as today, then report the change. The report runs after the hook call, so a failing reporter can never stop a mark.- Production: log at warn, once per distinct call site per process, under
["datastore", "unit-of-work"]. Include the change's kind and path, and the first stack frame outsidesrc/infrastructure/persistence/, so the caller is named. Capture the stack only on route 2, never on route 1. - Tests: add a seam, for example
useUnscopedChangeReporterForTesting(fn), in the style ofuseUnitOfWorkFactoryForTesting: process-global, refusing a second install, and allowed only from tests by the fitness rule.
- Production: log at warn, once per distinct call site per process, under
Find the writes. Locally, install a reporter that throws, and run the whole test suite:
deno run test, both unit and integration. Collect every distinct caller. List them in the PR, with the test that hit each one.Wrap each one in a root, so it takes route 1:
- where the path pushes today (it's in
PINNED_DIRECT_PUSHESor reaches a push path), its existing push is the root's flush, with the samepushWhenbehaviour; - where it doesn't push today, use a root with no flush. Its marks then wait for whatever push already picks them up, exactly as now;
- don't nest a root with its own flush inside another, because that throws (#3032).
The rules from #3033, #3034 and #3055 apply: record characterization or remote-failure rows from the current code in a commit before wrapping any path whose ordering a test can observe, then wrap it.
- where the path pushes today (it's in
What can't be wrapped yet. If a path can't take a root without changing behaviour, leave it on route 2 and pin it in a new
PINNED_UNSCOPED_WRITERSlist inintegration/datastore_write_seams_rules_test.ts, with the reason. The production warning still fires for it, and its test-seam handling must allow exactly the pinned callers.A permanent test guard. Install the throwing reporter, minus the pinned callers, in the shared harnesses:
integration/usecase_sync_fixtures.ts;- the root-unit harness;
integration/datastore_remote_failure_test.ts;integration/serve_root_unit_test.ts;integration/datastore_peer_propagation_test.ts;integration/repository_dirty_coverage_test.ts, apart from its deliberate no-scope runs.
A new unscoped write in a covered path then fails CI. Don't mutate process-global state across test files: install per test, and dispose in
finally.Docs. In
design/enablers/datastores.md, describe the warning and the guard. Record the condition for the follow-up removal issue: no route-2 warnings in dogfooding or in the swamp-uat datastore and serve suites against a release that contains this change, andPINNED_UNSCOPED_WRITERSempty.
Tests
- Unit tests for
signalChange:- route 2 marks exactly as before, then reports;
- a reporter that throws doesn't stop the mark;
- routes 1 and 3 never report;
- the warning fires once per call site and names a caller outside the persistence layer.
- Equivalence:
- all five characterization files, the root-unit harness, remote failure, serve root-unit, peer and dirty coverage pass with no expectation changes, now with the guard installed;
- every wrapped path has a before-change row, recorded first.
- Rules:
PINNED_UNSCOPED_WRITERSis populated, ideally empty. The seam is test-only.PINNED_UNIT_OF_WORK_SCOPESandPINNED_DIRECT_PUSHESare updated for any new roots. - These pass unchanged:
src/cli,src/libswamp,src/serve,src/infrastructure, and the swamp-uat datastore and serve suites.
Dependencies
Blocked by: nothing. swamp-club#3055 is merged. Blocks: "remove the route-2 fallback" (not filed). It should be filed only once the removal condition in Work item 6 holds.
Done when
- Route 2 still marks exactly as before, warns once per call site in production, and fails tests through the seam.
- Every route-2 caller found is wrapped in a root, or pinned in
PINNED_UNSCOPED_WRITERSwith a reason. The PR lists them all, with the tests that found them. - The guard is installed in the shared harnesses.
- The suites pass unchanged, with before-change rows recorded.
- Verification passes.
Out of scope
- Removing route 2 or the hook constructor parameters (the follow-up).
- The lockfile publish, deferred to the start of Phase 3.
- Precise per-path marks.
Related
- Tracking: swamp-club#2865 (Phase 2).
- Built on: swamp-club#3025, #3032, #3033, #3034, #3035, #3053, #3055.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.