Relationships
#2979 Datastore rework Phase 1 move A: data and output repositories stage typed changes (no behaviour change)
Opened by stack72 · 10/2/2026· Shipped 10/2/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). The rework replaces "write a file, mark it dirty, push later" with "stage typed changes into a unit of work and commit it". Phase 1 must change no behaviour.
Already on main for Phase 1:
- swamp-club#2970, the
UnitOfWorkport and legacy adapter.src/domain/datastore/unit_of_work.tsdefinesStagedChangeas one of:{ kind: "write"; path }, for a write about to happen;{ kind: "remove"; path }, for a removal about to happen;{ kind: "bulk"; reason }, for a change that can't be attributed to one path.
Paths are absolute.
src/infrastructure/persistence/legacy_unit_of_work.tsholdscreateLegacyUnitOfWork(markDirty, { flush }). Itsstageforwards each change at once, in order, to the repository'sMarkDirtyHook:writeandremovebecomemarkDirty(path), andbulkbecomesmarkDirty(undefined). Write and remove reach the hook identically: the sync service decides "delete" at push time when the path is absent on disk (rule 2 of themarkDirtycontract,src/domain/datastore/datastore_sync_service.ts).
- swamp-club#2971, the ambient unit of work (
src/infrastructure/persistence/unit_of_work_scope.ts).runInUnitOfWork,currentUnitOfWork,signalChange(markDirty, change)andchangeFor(relPath, reason).signalChangestages into the ambient unit when that unit wraps this repository's own hook. Otherwise it calls the hook as before, or does nothing when there's no hook.- Each of the seven hooked repositories has a private
notifyDirty(relPath?)that doessignalChange(this.<hook>, changeFor(relPath, "<Repo>.notifyDirty")). changeFormaps every path towrite, becausenotifyDirtycan't tell a write from a remove.- No production code opens a scope;
integration/datastore_write_seams_rules_test.tspins that list at zero.
What a "repository move" is. The three Phase 1 moves give repositories typed changes at their call sites. Each this.notifyDirty(path) becomes a staged change of the right kind:
writewhen the path exists after the operation (a file or directory created or changed);removewhen the path is gone after it;bulkwith a reason only where no single path covers the change.
The private notifyDirty then goes away. The legacy adapter forwards every kind identically, so the marks sent to the sync service do not change at all. What changes is that the unit of work now knows what each change is, which Phase 3's commit-log adapter needs.
The tests that prove nothing changed are on main:
integration/repository_dirty_coverage_test.ts:- every file a repository changes must be marked;
- today's gaps are pinned in
KNOWN_UNMARKED; - a #2971 test runs every row with and without a legacy scope and requires identical marks, plus one staged change per mark.
integration/datastore_write_seams_rules_test.ts: pinned lists of mark call sites, repository constructions, unhooked writers and scope openers.integration/datastore_sync_rules_test.ts: bans a barethis.notifyDirty()inPER_PATH_WIRED_REPOS.integration/datastore_peer_propagation_test.tsandintegration/datastore_remote_failure_test.ts.integration/usecase_sync_characterization_*_test.ts.- The swamp-uat datastore suite, which runs against the PR binary.
Rules:
- Every test above passes with no change to its expectations. Pinned lists change only as described in this issue.
- Do not fix any
KNOWN_UNMARKEDgap. Don't add, remove or reorder marks, and don't change when a mark happens relative to its write: it stays before the write. - Follow AGENTS.md: named exports, no
any, license headers, no fire-and-forget promises, no fixed sleeps or wall-clock assertions, and the verification workflows before the PR.
Running in parallel. Two moves, A (data and output) and B (definition, workflow and evaluated), run at the same time. They change different repository files, but both edit the same test files: datastore_write_seams_rules_test.ts, datastore_sync_rules_test.ts and repository_dirty_coverage_test.ts. Whichever lands second rebases and keeps both sides. Where this issue says "create X if it does not exist yet", the first move to land creates it and the second adds its entries.
Goal
Phase 1 repository move A: the data and output repositories stage typed changes (write, remove, bulk) at each call site instead of calling notifyDirty(path). The marks the sync service receives stay exactly the same.
Repositories and call sites (line numbers from main at d7714fef)
src/infrastructure/persistence/unified_data_repository.ts: FileSystemUnifiedDataRepository, with notifyDirty at :158 and 15 call sites:
| Line | Method / context | Marked path |
|---|---|---|
| :620 | save |
data-name dir |
| :705 | saveDeferred |
data-name dir |
| :790 | append |
data-name dir |
| :921 | delete (one version) |
version dir |
| :975 | delete (all), per version |
version dir |
| :977 | delete (all) |
latest marker |
| :981 | delete (all) |
data-name dir |
| :1004 | removeLatestMarker |
data-name dir |
| :1035 | rename |
old data-name dir (tombstone, content, latest) |
| :1197 | allocateVersion |
data-name dir |
| :1238 | finalizeVersion |
version dir |
| :1301 | finalizeVersionDeferred |
version dir |
| :1826 | collectGarbage |
version dir |
| :1885 | collectGarbage |
data-name dir, removed with its contents |
| :1969 | pruneExcessVersions |
version dir |
src/infrastructure/persistence/yaml_output_repository.ts: YamlOutputRepository, with notifyDirty at :98 and 4 call sites:
| Line | Method / context | Marked path |
|---|---|---|
| :394 | save |
output yaml |
| :620 | removeOutputFiles |
yaml |
| :622 | removeOutputFiles |
log |
| :692 | sweepOrphanLogs |
orphan log |
The table's "marked path" column is a starting point. Read each method to decide the kind.
Work
- Replace each
this.notifyDirty(path)with a typed staged change throughsignalChange(this.<hook>, { kind, path }). A small private helper per repository is fine, for exampleprivate stage(change: StagedChange).The kind follows the effect on disk after the operation:
writeif the path exists afterwards. This covers a directory whose contents were created, changed or partly removed:rename's old-name dir still holds a tombstone, anddelete(version)leaves the data-name dir in place.removeif the path is gone afterwards, such as a version dir removed bydelete,collectGarbageorpruneExcessVersions, or a data-name dir removed bydelete(all).
Where a call site's effect depends on a branch, pick the kind for the branch that runs at that call, and move the mark into the branch if needed. Calls must not move relative to their writes.
Keep every path, every call, the order and the pre-write timing exactly as they are.
No call site is bulk today. Don't introduce one.
- Delete the private
notifyDirtyfrom both repositories, and stop importingchangeForthere.changeForstays inunit_of_work_scope.tswhile other repositories still use it. - Keep the constructor hook parameters and everything in
repository_factory.tsunchanged.
Shared test and fitness work (both moves)
- A disk-effect check in
integration/repository_dirty_coverage_test.ts(create it if it isn't there yet).- Add a
MOVED_REPOSITORIESset, and add this issue's repositories to it. - For every row of a moved repository, in the scoped run, check each staged change against the disk after
act: awritepath exists, and aremovepath is absent. - Rows of unmoved repositories are skipped, because their changes are all still
write. - This proves each call site picked the right kind.
- Add a
- Unchanged expectations. The #2971 equivalence test, the marks-with-and-without-a-scope test, and
KNOWN_UNMARKEDall pass unchanged. - Update
integration/datastore_write_seams_rules_test.ts. This issue's repositories drop out ofPINNED_MARK_CALL_SITESonce theirnotifyDirtyis gone, so the staged changes need their own pin. AddPINNED_STAGED_CHANGESif it doesn't exist yet: keys"<file>: <owner> <kind> (xN)"per repository and kind, counted the same way as the existing lists. Add this issue's entries. A later change to how a repository signals then shows up in review. - Update
integration/datastore_sync_rules_test.ts. Drop the moved repositories fromPER_PATH_WIRED_REPOS, since they no longer havenotifyDirty. Add a rule that a moved repository stages nobulkchange without a non-emptyreason. Pin the moved repositories' bulk changes as an explicit list; it's empty for this issue. - The rest pass unchanged:
datastore_peer_propagation_test.ts,datastore_remote_failure_test.ts, allusecase_sync_characterization_*_test.ts,src/cli/repo_context_test.ts, and each moved repository's own unit tests.
Dependencies
Blocked by: nothing. Both #2970 and #2971 are merged. Runs in parallel with: swamp-club#2980 (Phase 1 move B). It edits the same three test files; see Background. Blocks: the Phase 1 step "repository marks ratcheted to zero", with move B and the run and vault/lockfile moves (not filed yet).
Done when
- Neither repository has a
notifyDirty. Every call site stages a typed change, and the disk-effect check passes for every row of both repositories. - The marks sent are identical: the equivalence test and every suite listed in Background pass with no expectation changes.
PINNED_STAGED_CHANGESlists both repositories' write and remove counts.PINNED_MARK_CALL_SITESno longer lists them. The bulk pin is empty.- Verification workflows pass on the final commit.
Out of scope
- The definition, workflow and evaluated repositories (move B).
- Run, vault config and lockfile repositories.
- Removing hook parameters, opening scopes (Phase 2), and fixing any
KNOWN_UNMARKEDgap.
Related
- Tracking: swamp-club#2865 (Phase 1).
- Built on: swamp-club#2970, swamp-club#2971.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.