Relationships
#3033 Datastore rework Phase 2: CLI commands stop marking and pushing directly (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
CLI commands stop marking by hand and stop calling a push directly. Each write command runs its write section in a root unit of work (from swamp-club#3032). The root's flush is the push the command performs today, run at the same point, and lock release stays where it is. Hand marks become changes staged through the root. Marks and pushes stay identical.
Work
- Commands that use
acquireModelLocks. There are 15 insrc/cli/commands/;data_delete.tsis the model.- Today
lockResult.flush()pushes and then releases, in afinally. - Split it in
src/cli/repo_context.tsinto a push step and a release step, for examplelockResult.push()andlockResult.release(). Keepflush()as push-then-release for any caller not migrated, and pin those callers. - In each command, open
runInRootUnitOfWork(repoContext, { flush: () => lockResult.push() }, async (root) => …)around the section where it builds its context and runs use cases, inside the region where the locks are held. Build the libswamp context inside the root. - Release in the existing
finally, after the root has ended. - The single-phase and two-phase push behaviour inside
push()must not change.
- Today
- Managed-config commands:
model_create,model_edit,vault_create,vault_edit,vault_migrate,workflow_createandworkflow_edit.- They call
pushManagedConfigChanges, or the deferred and paths variants, which send a bare mark (or per-path marks) and push withManagedConfigUnpublishedErrorsemantics and a timeout. - Split the helper into its mark and its push. The marks become
root.stage(...):bulkwith the command name as the reason for the bare mark, andwriteorremovefor each path. - The root's flush is the push half, keeping the timeout and
ManagedConfigUnpublishedErrorexactly as they are. - Where a command has no
repoContexttoday (requireRepoMarker, or the deferred variants that resolve the datastore after the mutation), resolve it as the deferred helper does, and open the root around the mutation. - If a command can't get its
repoContextbefore the mutation without changing when it pulls or locks, leave it as is, pin it with the reason, and list it in the PR.
- They call
- Commands with bare hand marks:
access_grant,access_group,access_token_mint,worker_prune,worker_token_create,worker_token_revokeanddatastore_config_migrate. Replace eachsyncService.markDirty()withroot.stage({ kind: "bulk", reason: "<command>" })inside a root whose flush is the push that command performs today. The legacy unit sends the identicalmarkDirty(). - Leave these marks in place and keep them pinned, adding a one-line reason each if missing:
datastore_sync.ts: it is the sync command itself;datastore_namespace.tsbuildMigrateDepsandlibswamp/datastores/namespace_migrate.ts: a bulk migration of the whole tree;writeCatalogExportIfNeeded: written outside any repository at push time;buildMarkDirtyHook: it is the hook itself;serve.tsserveCommand: belongs to serve's start-up, not this issue.
- The global coordinator (
flushDatastoreSync, pushing at process exit) stays as it is. "One flush path" is a later issue.
Tests
- The CLI rows of all five
usecase_sync_characterization_*_test.tsfiles run with the root-unit option from swamp-club#3032 and pass with no expectation changes: the same marks, the same push count and order, and the same remote state. The harness also checks that each root'sstaged()covers the row's marks. PINNED_MARK_CALL_SITESshrinks by every CLI entry this issue removes. Add a pinned list of CLI commands that open a root unit, and a pinned list oflockResult.flush()callers still not migrated, which should be empty if possible.src/cli/repo_context_test.ts:push()thenrelease()behaves exactly asflush()did, for both single-phase and two-phase pushes, including when the push fails, where release still happens.- Failure paths: for a migrated command whose use case fails, the root still flushes once, as
flushSinglePhasePushdid, and the command's error and exit code are unchanged. Cover one model-lock command and one managed-config command. - These pass unchanged:
integration/datastore_remote_failure_test.ts,integration/datastore_peer_propagation_test.ts,integration/repository_dirty_coverage_test.ts, and the swamp-uat datastore suite.
Dependencies
Blocked by: swamp-club#3032 (commit pushes: root units).
Runs in parallel with: swamp-club#3034 (serve). Both edit usecase_sync_fixtures.ts, datastore_write_seams_rules_test.ts and the design doc's hand-mark list. Whichever lands second rebases and keeps both sides.
Done when
- Every CLI write command listed above pushes only through its root unit's flush, and stages its former hand marks.
- Lock release happens after the root ends, exactly as before.
- The characterization suites pass unchanged, with the root-unit checks switched on for CLI rows.
- The pins and the design doc's hand-mark list are updated.
- Verification passes.
Out of scope
- Serve (swamp-club#3034).
- The global coordinator.
- Replacing bare marks with precise per-path marks. That would change what is pushed and deleted remotely, so it gets its own issue.
- The lockfile.
- Removing
signalChange's fallback.
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.