Relationships
#3025 Datastore rework Phase 2: use cases open and commit the unit of work (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). Repositories stop marking files dirty for a later push. Instead, each operation stages typed changes into a unit of work and commits it. Phase 1 is complete (swamp-club#2970, #2971, #2979, #2980, #2992, #2995, #2996). Phase 2 makes the use cases own the unit of work. Like Phase 1, it must change no behaviour.
What Phase 1 left on main (4f843533):
- The port (
src/domain/datastore/unit_of_work.ts):UnitOfWorkwithstage(change),commit()andstaged().StagedChangeiswrite,removeorbulk. - The legacy adapter (
src/infrastructure/persistence/legacy_unit_of_work.ts):createLegacyUnitOfWork(markDirty, { flush })forwards each staged change straight to the hook, asmarkDirty(path)ormarkDirty()for bulk.commitwaits for marks in flight, then awaitsflushif one is given, and spends the unit.- Staging into a committed unit rejects with "unit of work already committed".
legacyUnitOfWorkTarget(uow)returns the hook a unit wraps.
- The ambient scope (
src/infrastructure/persistence/unit_of_work_scope.ts):runInUnitOfWork(uow, fn)andcurrentUnitOfWork(), built onAsyncLocalStorage.signalChange(markDirty, change)stages into the ambient unit only whenlegacyUnitOfWorkTarget(uow) === markDirty. Otherwise it calls the hook directly (route 2), or does nothing with no hook (route 3).- No production code opens a scope.
PINNED_UNIT_OF_WORK_SCOPESinintegration/datastore_write_seams_rules_test.tsis empty, so every hooked write marks through route 2 today.
- Every hooked datastore-tier repository stages typed changes through
signalChange(this.<hook>, …). Two fitness rules keep it that way. - The hook:
createRepositoryContext(src/infrastructure/persistence/repository_factory.ts) passes onemarkDirtyinstance to every hooked repository, and exposes it asrepoContext.markDirty.buildMarkDirtyHookinsrc/cli/repo_context.tsbuilds it, only when a custom datastore has a sync service. - The "End of Phase 1" section of
design/enablers/datastores.mdlists what still marks by hand and how pushes happen. There are four flush paths:acquireModelLocks().flush;flushDatastoreSync;pushManagedConfigChangesandpushManagedConfigPaths;- serve's
pushChangedToRemote.
How a use case runs today:
- libswamp use cases are async generators
(ctx: LibSwampContext, deps, input)that yield stream events ending incompletedorerror. An example isdataDeleteinsrc/libswamp/data/delete.ts. - The CLI and serve build
ctxwithcreateLibSwampContext(src/libswamp/context.ts). It carries onlysignalandlogger, pluswithTimeoutandwithSignalfor child contexts. They builddepswith acreateXxxDepsfactory, often passing repositories fromrepoContext, and drive the stream withconsumeStreamorresult(src/libswamp/stream.ts).- CLI pattern (
src/cli/commands/data_delete.ts):requireInitializedRepoUnlocked, thenacquireModelLocks,createLibSwampContext,createDataDeleteDeps(…, repoContext.unifiedDataRepo, …),consumeStream(dataDelete(ctx, deps, input), …), and finallylockResult.flush(). - Serve pattern (
src/serve/handlers/data_handlers.ts):createLibSwampContext()per request, thencreateDataDeleteDeps,dataDelete, andpushChangedToRemote(ctx).
- CLI pattern (
Carried-forward requirements from the Phase 1 reviews:
- Hook identity. A scope only collects a repository's changes when the unit wraps that repository's exact hook instance. A unit built over a wrapped or rebuilt hook silently collects nothing, though marks still reach the sync service through route 2. Units must be built from
repoContext.markDirtyitself, and tests must provestaged()is non-empty after a write. - Late writes. A write that lands after its unit committed makes
stagereject. In production that would fail a user's command, so this issue must not let it happen silently (see Work item 4).
Goal
Every libswamp use case that writes through datastore-tier repositories runs its whole operation inside a unit of work, and commits it when it completes. The use case owns that boundary: it is the application service, so it decides where the transaction starts and ends.
In this issue commit pushes nothing. flush stays undefined, so marks reach the sync service exactly as today, and the existing flush paths keep pushing where they push now. Later Phase 2 issues move the push into commit and remove the hand marks.
Design
- A unit-of-work factory on
LibSwampContext.- Add a member that opens a fresh unit for one operation, for example
openUnitOfWork(): UnitOfWork. The domain port type isUnitOfWork. createLibSwampContexttakes an optional factory. The default opens a legacy unit with no hook, which stages and records but sends nothing, so contexts built without one behave exactly as today.withTimeoutandwithSignalchildren carry the same factory.
- Add a member that opens a fresh unit for one operation, for example
- A libswamp helper that runs a use-case stream inside a unit, for example
withUnitOfWork(ctx, () => impl(ctx, deps, input))insrc/libswamp/.- It opens one unit, then drives the inner generator. Every
next()call runs insiderunInUnitOfWork(uow, …), so all code the generator runs between yields sees the scope;AsyncLocalStoragedoesn't flow into a generator any other way. - It re-yields each event.
- It commits once, after the inner stream yields
completedand finishes. - It does not commit when the stream yields
error, throws, or is closed early by the consumer. The unit is abandoned: its marks were already sent, exactly as today. - Nested use cases open nested units, and the innermost wins while it runs.
- This helper module is the only production code that references
runInUnitOfWork. Add it toPINNED_UNIT_OF_WORK_SCOPESwith that reason.
- It opens one unit, then drives the inner generator. Every
- Wrap each write use case.
- The exported use case keeps its signature
(ctx, deps, input)and returnswithUnitOfWork(ctx, …)around its existing body. Its callers don't change. - A write use case is any use case whose deps save, delete, rename or otherwise write through a datastore-tier repository: data, model, workflow and evaluated definitions, runs, outputs, vault config.
- Use the five
integration/usecase_sync_characterization_*_test.tsfiles as the checklist, because they exercise every write use case through the CLI and through serve. Add any write use case they don't reach to the pinned list in item 6. - Don't wrap read-only use cases.
- The exported use case keeps its signature
- Production stays forgiving about late writes. Add an option to the legacy adapter, for example
{ flush, afterCommit: "reject" | "forward" }."reject"is today's behaviour and the default."forward"marks a stage that arrives after commit straight to the hook, as route 2 would, and logs it once atdebugwith the change's path. That way a write escaping its use case never fails a command.- Production factories use
"forward". Every test that drives use cases uses"reject", so a late write fails the test and gets fixed instead of hidden.
- Composition binds units to the exact hook.
- Wherever the CLI or serve already has a
repoContext, passcreateLibSwampContexta factory built overrepoContext.markDirtyitself:() => createLegacyUnitOfWork(repoContext.markDirty, { flush: undefined, afterCommit: "forward" }). - Never wrap or rebuild the hook.
- Add one shared helper for this, for example in
src/cli/repo_context.tsfor the CLI and alongside serve's request context. Don't add 200 inline lambdas. - Where no
repoContextexists (commands built onrequireRepoMarker, or use cases that build their own unhooked repositories), keep the default factory. Those writes have no hook to bind to, so nothing changes.
- Wherever the CLI or serve already has a
- Pin the wrapped use cases. Add
PINNED_TRANSACTIONAL_USE_CASEStointegration/datastore_write_seams_rules_test.ts, listing every libswamp use case that returnswithUnitOfWork(…), keyed<file>: <export>. A use case gaining or losing its unit then shows up in review.
Tests
- Unit tests for
withUnitOfWork:- the scope is active inside the generator across several yields and awaits;
- it commits exactly once after
completed; - it doesn't commit after an
errorevent, a throw, or a consumer that stops early (return()); - nested use cases stage into the inner unit;
- two concurrent use cases (
Promise.all) never share a unit; - child contexts from
withTimeoutandwithSignalopen units from the same factory.
- Legacy adapter
afterCommittests:"reject"is unchanged."forward"marks the late change once through the hook, logs it, and never rejects. - Equivalence, the most important test: all five
usecase_sync_characterization_*_test.tsfiles pass with no expectation changes. Every recorded mark, push and remote state is identical. - Hook identity, which proves the units really collect:
- Extend the characterization harness so every CLI and serve step that writes also captures the unit its use case opened, through a test factory using
afterCommit: "reject". - Assert that
staged()equals the marks the step recorded, one-to-one and in order, and is non-empty whenever the step marked anything. - A unit bound to the wrong hook fails this.
- Extend the characterization harness so every CLI and serve step that writes also captures the unit its use case opened, through a test factory using
- No late writes in covered paths: with
"reject"in the harness, none of the characterised steps stages after commit. - These pass unchanged:
integration/repository_dirty_coverage_test.ts;integration/datastore_peer_propagation_test.tsandintegration/datastore_remote_failure_test.ts;- the unit-of-work and scope tests;
src/cli/repo_context_test.ts;- every libswamp use-case unit test;
- the swamp-uat datastore suite.
- Fitness:
PINNED_UNIT_OF_WORK_SCOPESholds exactly the helper module;PINNED_TRANSACTIONAL_USE_CASESis populated;- the existing ddd and libswamp layering rules pass. libswamp may import the domain port, and its helper may import the scope module, as libswamp deps factories already import infrastructure. If a rule objects, move the helper rather than weakening the rule.
Dependencies
Blocked by: nothing. Phase 1 is complete.
Blocks the rest of Phase 2. None of these are filed yet:
- CLI commands stop marking and pushing;
- serve handlers commit through the unit of work;
- one flush path;
- removing
signalChange's hook fallback; - redesigning the lockfile publish.
Done when
LibSwampContextopens units, and its children carry the factory.withUnitOfWorkexists, is unit-tested, and is the only production scope opener.- Every write use case returns
withUnitOfWork(…)and is pinned. - Composition binds units to
repoContext.markDirtyitself through one helper per side (CLI, serve). - The characterization suites pass unchanged, and the new hook-identity assertions prove each writing step's unit staged exactly its marks.
- Verification workflows pass on the final commit.
Out of scope
- Making
commitpush (a non-undefinedflush). - Removing any hand mark or flush path, or removing
signalChange's fallback or the hook constructor parameters. - The lockfile.
- Fixing any
KNOWN_UNMARKEDgap.
Related
- Tracking: swamp-club#2865 (Phase 2, first item).
- Built on Phase 1: swamp-club#2970, #2971, #2979, #2980, #2992, #2995, #2996.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.