Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLIPublic
Assigneesstack72

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): UnitOfWork has stage, commit and staged. StagedChange is write, remove or bulk.
  • The legacy adapter (src/infrastructure/persistence/legacy_unit_of_work.ts):
    • createLegacyUnitOfWork(markDirty, { flush, afterCommit }) forwards each change straight to the hook, as markDirty(path) or markDirty() for bulk.
    • commit awaits flush if one is given.
    • afterCommit: "forward" marks a late change instead of rejecting it.
  • The scope (src/infrastructure/persistence/unit_of_work_scope.ts): runInUnitOfWork and currentUnitOfWork. signalChange stages 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) and repoUnitOfWorkFactory(repoContext) build production units with flush: undefined and afterCommit: "forward".
    • useUnitOfWorkFactoryForTesting is a test seam.
    • The CLI composes contexts with libSwampContextForRepo(repoContext, …) (src/cli/repo_context.ts), and serve with handlerLibSwampContext(ctx).
  • Use cases (src/libswamp/unit_of_work.ts):
    • Every write use case (28, pinned in PINNED_TRANSACTIONAL_USE_CASES in integration/datastore_write_seams_rules_test.ts) runs inside withUnitOfWork(ctx, …).
    • The unit commits after completed, and is abandoned (nothing called) after error, a throw, an early return(), or another terminal. workflowRun can end on suspended or cancelled.
    • Today commit pushes nothing.
  • How pushes happen today. Both sides push once per command or request, at the end, on every outcome:
    • CLI: acquireModelLocks(...) returns a lock whose flush() pushes and then releases. Commands call it in a finally, for example src/cli/commands/data_delete.ts (around line 338). Managed-config commands call pushManagedConfigChanges or pushManagedConfigPaths (src/cli/managed_config_sync.ts). The global coordinator's flushDatastoreSync pushes 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.
  • Hand marks still outside units are listed in PINNED_MARK_CALL_SITES, and in the "End of Phase 1" section of design/enablers/datastores.md.
  • The equivalence suites:
    • the five integration/usecase_sync_characterization_*_test.ts files, through integration/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.ts and integration/datastore_remote_failure_test.ts;
    • the swamp-uat datastore suite.

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;
  • abandon on 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

  1. 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.
  2. 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.

  3. 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, through currentUnitOfWork() and legacyUnitOfWorkTarget.
    • A child forwards each change to the hook immediately, as now.
    • A child records it in its own staged(), and also in the root's staged(), 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 commit and abandon only spend it, and never flush.
    • A unit opened with no ambient unit for its hook is a root.
  4. A composition helper, for example runInRootUnitOfWork(repoContext, { flush }, async (root) => …) in repo_unit_of_work.ts:

    • It opens a root unit over repoContext.markDirty itself, with that flush and afterCommit: "forward".
    • It runs fn inside runInUnitOfWork(root, …).
    • It always ends the root in a finally: commit if fn resolved, abandon if it threw. For legacy, both flush.
    • Errors. If fn threw and the flush also throws, rethrow fn's error, and pass the flush error to an optional onFlushError callback; otherwise log it at warn. If fn resolved 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 root to fn, so composition code can stage changes it makes outside repositories (item 5).
    • Add the module to PINNED_UNIT_OF_WORK_SCOPES with that reason.
  5. Staging hand marks. Composition code that marks by hand today will stage through the root instead:

    • root.stage({ kind: "bulk", reason: "<command>" }) replaces a bare markDirty();
    • 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.

  6. withUnitOfWork (src/libswamp/unit_of_work.ts): call abandon() wherever it currently abandons silently (error, a throw, an early return(), 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 on suspended and cancelled; this is the open item from swamp-club#3025's review.

  7. Change no composition yet. No CLI command or serve handler uses the root helper in this issue, so nothing pushes differently.

Tests

  1. 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.
  2. 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 fn throws, and never twice;
    • all the error rules from Design item 4.
  3. withUnitOfWork with a root (src/libswamp/unit_of_work_test.ts): a wrapped use case inside a root stages into a child, and abandon is called on error, a throw, return(), suspended and cancelled.

  4. An equivalence harness for the next two issues. Extend integration/usecase_sync_fixtures.ts so 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.

  5. 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

  • abandon is 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.
  • withUnitOfWork calls abandon and 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 flushDatastoreSync or 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.
  • Tracking: swamp-club#2865 (Phase 2).
  • Built on: swamp-club#3025 and Phase 1 (#2970, #2971, #2979, #2980, #2992, #2995, #2996).
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 8 MOREREVIEW+ 14 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/5/2026, 5:16:50 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/5/2026, 3:54:19 PM

Sign in to post a ripple.