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

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): 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

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

  1. Commands that use acquireModelLocks. There are 15 in src/cli/commands/; data_delete.ts is the model.
    • Today lockResult.flush() pushes and then releases, in a finally.
    • Split it in src/cli/repo_context.ts into a push step and a release step, for example lockResult.push() and lockResult.release(). Keep flush() 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.
  2. Managed-config commands: model_create, model_edit, vault_create, vault_edit, vault_migrate, workflow_create and workflow_edit.
    • They call pushManagedConfigChanges, or the deferred and paths variants, which send a bare mark (or per-path marks) and push with ManagedConfigUnpublishedError semantics and a timeout.
    • Split the helper into its mark and its push. The marks become root.stage(...): bulk with the command name as the reason for the bare mark, and write or remove for each path.
    • The root's flush is the push half, keeping the timeout and ManagedConfigUnpublishedError exactly as they are.
    • Where a command has no repoContext today (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 repoContext before the mutation without changing when it pulls or locks, leave it as is, pin it with the reason, and list it in the PR.
  3. Commands with bare hand marks: access_grant, access_group, access_token_mint, worker_prune, worker_token_create, worker_token_revoke and datastore_config_migrate. Replace each syncService.markDirty() with root.stage({ kind: "bulk", reason: "<command>" }) inside a root whose flush is the push that command performs today. The legacy unit sends the identical markDirty().
  4. 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.ts buildMigrateDeps and libswamp/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.ts serveCommand: belongs to serve's start-up, not this issue.
  5. The global coordinator (flushDatastoreSync, pushing at process exit) stays as it is. "One flush path" is a later issue.

Tests

  1. The CLI rows of all five usecase_sync_characterization_*_test.ts files 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's staged() covers the row's marks.
  2. PINNED_MARK_CALL_SITES shrinks by every CLI entry this issue removes. Add a pinned list of CLI commands that open a root unit, and a pinned list of lockResult.flush() callers still not migrated, which should be empty if possible.
  3. src/cli/repo_context_test.ts: push() then release() behaves exactly as flush() did, for both single-phase and two-phase pushes, including when the push fails, where release still happens.
  4. Failure paths: for a migrated command whose use case fails, the root still flushes once, as flushSinglePhasePush did, and the command's error and exit code are unchanged. Cover one model-lock command and one managed-config command.
  5. 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.
  • 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+ 5 MOREREVIEW+ 14 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/5/2026, 6:28:31 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/5/2026, 5:18:02 PM

Sign in to post a ripple.