Relationships
#3053 Datastore rework Phase 2: root units can checkpoint, and the CLI token commands push mid-command through it (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). Every operation stages typed changes into a unit of work, and pushes happen when the unit ends. Phase 1 is complete. Phase 2 has shipped so far:
- swamp-club#3025: use cases open units;
- swamp-club#3032: a root unit per command or request pushes once when it ends, and use-case units nest inside it as children;
- swamp-club#3033: CLI commands push through roots;
- swamp-club#3034 and swamp-club#3035: serve handlers push through roots.
Phase 2 must change no behaviour.
On main (fcfe2326):
The root.
runInRootUnitOfWork(repoContext, { flush, onFlushError }, async (root) => …)insrc/infrastructure/persistence/repo_unit_of_work.tsopens a root overrepoContext.markDirtyitself.fngets aRootUnitOfWork:stageandstagedonly.- The root runs
flushonce when it ends, on commit or abandon. - A nested root given its own flush throws (the guard from #3032).
The CLI wrapper.
runCommandInRootUnit(src/cli/command_root_unit.ts) wraps it for CLI commands: thepushandpushWhenoptions, areleaseafter the root ends, and anonCleanupErrorhandler.Three commands still push mid-command with a direct
syncService.pushChanged({ namespace }), pinned inPINNED_CLI_PUSH_CALLSinintegration/datastore_write_seams_rules_test.tswith the reason "a root cannot push mid-command":src/cli/commands/access_token_mint.ts(around line 266);src/cli/commands/worker_token_create.ts(around line 265);src/cli/commands/worker_token_revoke.ts(around line 190).
In each, the command:
- stages
{ kind: "bulk", reason: "<command>" }on the root; - pushes directly, so the token is published before it is read back (revoked tokens before the lock push);
- carries on;
- lets the root's flush, the model-lock
push(absent when no model lock is held), push again when the command ends.
The mid-command push is not the root's flush. It is a separate raw push that bypasses the lock push.
The equivalence suites:
integration/usecase_sync_characterization_access_test.tscovers access token mint and worker token create and revoke through the CLI, with root-unit checks on;- the other characterization files,
integration/usecase_sync_root_unit_harness_test.ts,integration/datastore_remote_failure_test.tsand the swamp-uat datastore suite.
Goal
Let a root push partway through an operation with a checkpoint, and move the three commands' mid-command pushes onto it, so that every CLI push goes through a root. This unblocks "one flush path". Marks and pushes stay identical: the same count, the same order relative to the stage, the read-back and the end-of-command push, and the same behaviour on failure.
Design
checkpoint()on the root, for composition code.- Add an option to
runInRootUnitOfWork:checkpoint?: () => Promise<void>, the mid-operation push the caller performs today. - Add
checkpoint(): Promise<void>toRootUnitOfWork. - It waits for every mark still in flight in the root and its children, then awaits
options.checkpoint(). The root stays open and still runs its flush when it ends. - Calling it when no
checkpointoption was given throws a clear programming error. - A nested call that became a child has no checkpoint. Calling
checkpoint()there throws too, so the outer root has to own it. - Errors propagate exactly as the direct call did: a failed checkpoint rejects inside
fn, and the command handles it as before.
- Add an option to
- Keep it off the domain port.
checkpointlives onRootUnitOfWorkand the legacy adapter, not onUnitOfWorkinsrc/domain/datastore/unit_of_work.ts. What a Phase 3 commit-log unit means by a partial commit is a Phase 3 decision. Say so in the JSDoc. - Thread it through
runCommandInRootUnit. Addcheckpoint?: () => Promise<void>, passed through to the root. - The three commands. Pass
checkpoint: () => syncService.pushChanged({ namespace }), the same call, namespace and sync service as today, and replace the direct call withawait root.checkpoint(). Leave theroot.stage({ kind: "bulk", … })before it, the read-back after it, and the end-of-command lock push as they are. - Pins. The three commands' entries move out of the mid-command group of
PINNED_CLI_PUSH_CALLS. Their push now lives inside the checkpoint lambda each command gives its root. If the scan still matches the lambda, re-label those entries under "the root's checkpoint", like the "root's push" group.- Add a pinned list of root checkpoint users: exactly these three.
- Update the design doc's Phase 2 section to say a root can checkpoint.
Tests
- Unit tests in
src/infrastructure/persistence/repo_unit_of_work_test.tsandsrc/cli/command_root_unit_test.ts:checkpoint()waits for a mark in flight, including a child's, before it pushes;- it runs the checkpoint push once per call, and the root still flushes once at the end;
- two checkpoints, then the end, push three times in order;
- with no checkpoint option it throws;
- from a nested child it throws;
- a failing checkpoint rejects inside
fn, the root then abandons, and the flush still runs, so an error thrown later doesn't mask it.
- Equivalence. The access characterization rows for access token mint and worker token create and revoke pass with no expectation changes. The marks, the mid-command push, the end push and their order are identical, with and without a model lock held.
- Failure path. For access token mint, a failing mid-command push gives the same error and exit code as before, and the end-of-command lock push and lock release behave as before. Record the expectation from the current code first, then convert.
- Everything else passes unchanged: every other characterization file, the root-unit harness,
datastore_remote_failure_test.ts,src/cli,src/libswampandsrc/serve.
Dependencies
Blocked by: nothing. swamp-club#3032 to #3035 are merged. Blocks: "one flush path" (Phase 2, not filed yet), which makes every push a root's flush or checkpoint.
Done when
- Roots support
checkpoint()throughrunInRootUnitOfWorkandrunCommandInRootUnit, with the JSDoc keeping it off the domain port. - The three commands push mid-command only through
root.checkpoint(). PINNED_CLI_PUSH_CALLSno longer has a mid-command group.- The characterization suites pass unchanged, and verification passes.
Out of scope
- "One flush path": the coordinator, deleting
ModelLockResult.flush(), consolidatingpushWhen. - Checkpoints in serve, where nothing needs one today.
- Making the mid-command push go through the lock push.
- The lockfile.
Related
- Tracking: swamp-club#2865 (Phase 2).
- Built on: swamp-club#3032, #3033, #3034, #3035.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.