Relationships
#3032 Datastore rework Phase 2: commit pushes through a root unit of work per command or request (no behaviour change)
Opened by stack72 · 10/5/2026· Shipped 10/5/2026
Background (read this first)
swamp is reworking its datastore layer (tracking: swamp-club#2865; design: design/enablers/datastore-commit-log.md on the datastore-rework branch). Each operation will stage typed changes into a unit of work and commit it, instead of marking files dirty for a later push. Phase 1 is complete (swamp-club#2970, #2971, #2979, #2980, #2992, #2995, #2996), and so is the first Phase 2 step (swamp-club#3025). Phase 2 must change no behaviour.
On main (7f5a693a):
- The port (
src/domain/datastore/unit_of_work.ts):UnitOfWorkhasstage,commitandstaged.StagedChangeiswrite,removeorbulk. - The legacy adapter (
src/infrastructure/persistence/legacy_unit_of_work.ts):createLegacyUnitOfWork(markDirty, { flush, afterCommit })forwards each change straight to the hook, asmarkDirty(path)ormarkDirty()for bulk.commitawaitsflushif one is given.afterCommit: "forward"marks a late change instead of rejecting it.
- The scope (
src/infrastructure/persistence/unit_of_work_scope.ts):runInUnitOfWorkandcurrentUnitOfWork.signalChangestages into the ambient unit only when it wraps the repository's exact hook, and otherwise calls the hook directly ("route 2"). - Unit factories (
src/infrastructure/persistence/repo_unit_of_work.ts):openRepoUnitOfWork(markDirty)andrepoUnitOfWorkFactory(repoContext)build production units withflush: undefinedandafterCommit: "forward".useUnitOfWorkFactoryForTestingis a test seam.- The CLI composes contexts with
libSwampContextForRepo(repoContext, …)(src/cli/repo_context.ts), and serve withhandlerLibSwampContext(ctx).
- Use cases (
src/libswamp/unit_of_work.ts):- Every write use case (28, pinned in
PINNED_TRANSACTIONAL_USE_CASESinintegration/datastore_write_seams_rules_test.ts) runs insidewithUnitOfWork(ctx, …). - The unit commits after
completed, and is abandoned (nothing called) aftererror, a throw, an earlyreturn(), or another terminal.workflowRuncan end onsuspendedorcancelled. - Today
commitpushes nothing.
- Every write use case (28, pinned in
- How pushes happen today. Both sides push once per command or request, at the end, on every outcome:
- CLI:
acquireModelLocks(...)returns a lock whoseflush()pushes and then releases. Commands call it in afinally, for examplesrc/cli/commands/data_delete.ts(around line 338). Managed-config commands callpushManagedConfigChangesorpushManagedConfigPaths(src/cli/managed_config_sync.ts). The global coordinator'sflushDatastoreSyncpushes at process exit. - Serve: handlers call
pushChangedToRemote(ctx)(src/serve/handlers/shared.ts) after their try/catch, inside the sync gate:- access handlers: 3 call sites;
- admin handlers: 3;
- data handlers: 5;
- workflow handlers: 2;
src/serve/suspended_run_cancel.ts: 1.
- CLI:
- Hand marks still outside units are listed in
PINNED_MARK_CALL_SITES, and in the "End of Phase 1" section ofdesign/enablers/datastores.md. - The equivalence suites:
- the five
integration/usecase_sync_characterization_*_test.tsfiles, throughintegration/usecase_sync_fixtures.ts. They record every mark, push and remote state, and assert that each use case's unit staged exactly its marks; integration/repository_dirty_coverage_test.ts;integration/datastore_peer_propagation_test.tsandintegration/datastore_remote_failure_test.ts;- the swamp-uat datastore suite.
- the five
Rules:
- No behaviour change: the same marks (paths, bare or not, order) and the same pushes (count, timing relative to lock release, behaviour on failure). The suites above pass with no expectation changes unless an issue explicitly allows one.
- Follow AGENTS.md: named exports, no
any, license headers, no fire-and-forget promises, no fixed sleeps, and the verification workflows before the PR.
Goal
Build what makes commit the place a push happens, without changing any composition yet:
- a root unit per command or request that pushes once when it ends, on every outcome, as today;
- use-case units nested inside it that roll up and never push;
abandonon the port.
The CLI (swamp-club#3033) and serve (swamp-club#3034) issues then adopt it in parallel.
Why a root unit, not a commit per use case
Today the CLI and serve each push once per command or request, at the end, even when the use case fails. Making each use case's commit push would change all three:
- the push count, for a command that runs several use cases, or a workflow whose model method runs each open a unit;
- the timing, before the command's own post-processing;
- failures, which would no longer push.
So the push belongs to a root unit that the command or handler opens, and use-case units nest inside it.
Design
abandon()on the port (src/domain/datastore/unit_of_work.ts): the operation ended without completing.- It spends the unit, like
commit. - Port-level meaning: the staged changes are not committed.
- Add it to
assertUnitOfWorkContract(src/infrastructure/testing/unit_of_work_contract.ts) with cases: abandon spends, a second end rejects, and abandon after commit rejects.
- It spends the unit, like
Legacy adapter abandon. The legacy unit's changes have already reached the hook, and today's paths push them even on failure. So a legacy unit with a flush also flushes on abandon. Document that a Phase 3 commit-log unit will discard on abandon instead, and that this difference is deliberate.
Root and child units.
- When a unit is opened while a unit bound to the same hook is ambient, it is a child. Detect this in
openRepoUnitOfWork, throughcurrentUnitOfWork()andlegacyUnitOfWorkTarget. - A child forwards each change to the hook immediately, as now.
- A child records it in its own
staged(), and also in the root'sstaged(), so the root sees everything the operation changed. Nested use cases (a workflow run's model method runs) roll up the same way. - A child's
commitandabandononly spend it, and never flush. - A unit opened with no ambient unit for its hook is a root.
- When a unit is opened while a unit bound to the same hook is ambient, it is a child. Detect this in
A composition helper, for example
runInRootUnitOfWork(repoContext, { flush }, async (root) => …)inrepo_unit_of_work.ts:- It opens a root unit over
repoContext.markDirtyitself, with that flush andafterCommit: "forward". - It runs
fninsiderunInUnitOfWork(root, …). - It always ends the root in a
finally:commitiffnresolved,abandonif it threw. For legacy, both flush. - Errors. If
fnthrew and the flush also throws, rethrowfn's error, and pass the flush error to an optionalonFlushErrorcallback; otherwise log it at warn. Iffnresolved and the flush throws, throw the flush error. Callers keep their existing error handling around the helper, so today's messages and exit codes stay the same. - It passes
roottofn, so composition code can stage changes it makes outside repositories (item 5). - Add the module to
PINNED_UNIT_OF_WORK_SCOPESwith that reason.
- It opens a root unit over
Staging hand marks. Composition code that marks by hand today will stage through the root instead:
root.stage({ kind: "bulk", reason: "<command>" })replaces a baremarkDirty();root.stage({ kind: "write" | "remove", path })replaces a per-path mark.
The legacy unit forwards these as the identical hook call. Provide this in this issue; the CLI and serve issues apply it.
withUnitOfWork(src/libswamp/unit_of_work.ts): callabandon()wherever it currently abandons silently (error, a throw, an earlyreturn(),suspended,cancelled, any other terminal). For a child unit this only spends it. Write the terminal-event policy in the JSDoc: for legacy, which terminal ends a child doesn't matter, because the root decides the push. A Phase 3 unit must decide what to commit onsuspendedandcancelled; this is the open item from swamp-club#3025's review.Change no composition yet. No CLI command or serve handler uses the root helper in this issue, so nothing pushes differently.
Tests
Adapter and contract:
- abandon spends;
- legacy abandon with a flush flushes once;
- without a flush it sends nothing;
- the contract suite's new cases pass.
Root and child behaviour (
repo_unit_of_work_test.ts):- a unit opened inside a root bound to the same hook is a child;
- a child forwards immediately, records in itself and in the root, and never flushes on commit or abandon;
- nested children roll up to the root;
- a unit for a different hook inside a root is an independent root;
- two concurrent roots (
Promise.all) stay separate; - the root flushes exactly once on success, once when
fnthrows, and never twice; - all the error rules from Design item 4.
withUnitOfWorkwith a root (src/libswamp/unit_of_work_test.ts): a wrapped use case inside a root stages into a child, and abandon is called onerror, a throw,return(),suspendedandcancelled.An equivalence harness for the next two issues. Extend
integration/usecase_sync_fixtures.tsso a row can declare that its composition runs in a root unit. The harness then also asserts:- the push count and push order relative to lock release are unchanged;
- the root's
staged()covers every mark the row made.
No row uses it yet; the CLI and serve issues switch them on.
Everything in Background passes with no expectation changes.
Dependencies
Blocked by: nothing. swamp-club#3025 is merged. Blocks: swamp-club#3033 (CLI) and swamp-club#3034 (serve), which can then run in parallel.
Done when
abandonis on the port and in the contract suite.- Legacy root and child units, the root helper and the staging path for hand marks exist and are tested.
withUnitOfWorkcallsabandonand documents the terminal policy.- The harness supports root-unit rows.
- No composition changed, everything passes unchanged, and verification passes.
Out of scope
- Adopting the helper in any CLI command or serve handler (the next two issues).
- Removing the coordinator's
flushDatastoreSyncor other flush paths ("one flush path", later). - Replacing bare marks with precise per-path marks (a deliberate behaviour change, later).
- Removing
signalChange's fallback. - The lockfile.
Related
- Tracking: swamp-club#2865 (Phase 2).
- Built on: swamp-club#3025 and Phase 1 (#2970, #2971, #2979, #2980, #2992, #2995, #2996).
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.