Relationships
↔ sibling #2855#2857 Tests: two repos on one remote datastore: writes, deletes, renames and gc propagate through the real flush path and catalog
Opened by stack72 · 9/30/2026· Shipped 10/1/2026
Background (read this first)
swamp is about to rework its datastore layer. The design proposal is design/enablers/datastore-commit-log.md on the datastore-rework branch. It is not merged; §2 of it is a write-path inventory.
How the datastore works today.
- Repositories write files into the repo's
.swamp/directory, or into the datastore cache dir for a custom datastore. They then call a mark hook,markDirty(relPath?), which reachesDatastoreSyncService(src/domain/datastore/datastore_sync_service.ts, interface at :168, the eight-rulemarkDirtycontract at :199-257). - A caller then flushes, through one of four paths:
acquireModelLocks().flush(src/cli/repo_context.ts:1723);- the global coordinator
flushDatastoreSync(src/cli/mod.ts:2251); pushManagedConfigChanges(src/cli/managed_config_sync.ts:45/153);- serve's
pushChangedToRemote(src/serve/handlers/shared.ts:83-97) under the sync gate.
- The S3 and GCS sync implementations live in swamp-extensions, not here. They own the dirty set, the
.datastore-sync-state.jsonsidecar,_indexshards and_meta.json. - A filesystem datastore has no sync service at all (
repo_context.ts:1170-1178). - Each repo has a SQLite catalog,
.swamp/data/_catalog.db, thatDataQueryServicereads. - Serve runs pollers that pull peers' changes: Config, AccessData, RuntimeData.
What changes. The refactor lands on main in phases:
- Phase 1: a
UnitOfWorkport with a legacy adapter, so repositories stage writes instead of calling the hook directly. - Phase 2: libswamp use cases own the unit of work, and CLI commands and serve stop calling
markDirty/pushChangedthemselves. - Later phases: a new commit-log engine behind an opt-in format.
Phases 1 and 2 must not change any behaviour.
Why this issue exists. Before any of that lands, this repo's own tests have to pin today's behaviour, so a dropped mark, a lost delete or a missed catalog refresh fails a test instead of reaching users. An audit in September 2026 found:
- no in-memory remote to test sync against;
- no test that every file a repository changes gets marked, and several real unmarked paths;
- no two-repo propagation test;
- no ratchet on the write seams Phase 1 will move;
- the sync-service conformance suite only checks method shapes.
This issue is one of a set. The tracking issue is listed under Related. End-to-end CLI tests for the same risks are in swamp-club/swamp-uat #481-#485, #487, #490 and #492.
Rules (from AGENTS.md)
- Unit tests are in-process only: no subprocesses, no process-global mutation; use
withMockedEnv. - Integration tests in
integration/wire real components on a temp filesystem and must not spawn the CLI. - Registry-registered test types use a per-run name built with
crypto.randomUUID(), orinvalidateTypein afinally. - No fixed sleeps (use
waitForfrom@swamp-club/swamp-testing), no wall-clock assertions, andDeno.utimefor mtimes. - Use
@std/pathfor paths andassertPathEqualsfor path comparisons, since tests run on Windows too. - New files need the AGPLv3 header (
deno run license-headers). - Pin today's behaviour, including known gaps. When today's behaviour is a bug, assert it as it is, with a comment naming the bug. Prefer an explicit pinned list checked with
assertPinnedSet, so a later fix forces the test to be updated on purpose. Do not fix production code in these issues unless the issue says so.
Goal
Add an in-process integration test for two repos on one remote datastore:
- a write on A is visible on B, including through the catalog and queries;
- a delete, rename or gc on A is gone on B;
- a later push from B never brings deleted data back.
It uses the real flush path, not a mock. This is the in-repo counterpart of swamp-uat #485.
Current state
- There is no test with two repos sharing one datastore in the same namespace.
integration/giga_swamp_namespace_layout_test.ts:184uses two repos in different namespaces on one filesystem datastore, proving separation, not propagation. ItswriteOneItemhelper (:81-118) shows the wiring.- Relevant catalog behaviour (
src/domain/data/data_query_service.ts, verify the lines):- once B's catalog is populated,
getLatestRecordreturns null for a name A created (:256), and for a name B already knows it returns B's older row (:229-241); querynever backfills (:392-393);filterStaleRowshides deleted rows, but only inquery(:659-688);- backfill never removes rows (
catalog_store.ts:319-326).
- once B's catalog is populated,
- Catalog invalidation on pull is spread across 56 call sites, mostly
if (lockResult.synced) …invalidate().
Needs
The in-memory remote and registerTestDatastoreType from the fake-remote issue.
Test
integration/datastore_peer_propagation_test.ts
Setup. Two temp repos A and B, each with its own cache dir and catalog, both registered against one in-memory remote. Wire each through requireInitializedRepo (pattern at src/cli/repo_context_test.ts:1004), so the real acquireModelLocks(...).flush → flushSinglePhasePush / flushTwoPhasePush path and the real catalog invalidation run.
Scenarios. Each asserts through DataQueryService.query and getLatestRecord on B, and through unified_data_repository reads.
- A saves a new name. After B's next lock acquire and pull, B sees it: the catalog is invalidated and backfilled.
- A saves a new version of a name B already knows. B sees the new version as latest.
- A deletes one version, then all versions. B no longer sees them. Then B saves an unrelated item and flushes, A pulls, and the deleted data is still absent on A and B.
- A renames. On B the old name is gone and the new name is present.
- A runs
collectGarbage/pruneExcessVersions. B reflects the result. - A model definition delete (
YamlDefinitionRepository.delete) propagates. - Bare mark in the same cycle. When a bare (bulk) mark and a per-path delete happen in one cycle, record today's outcome, including whether deletes are applied.
- Stale clone. Clone C pulls before A's delete and does not pull again before writing. C then saves and flushes. Record whether A's deleted data comes back; pin today's behaviour.
Run every scenario with the fake's pullDeletes set to match S3 today, and again to match GCS if they differ.
Where today's behaviour is wrong (for example scenario 1 through getLatestRecord without invalidation, or scenario 8), assert today's behaviour with a comment naming the gap, instead of skipping it.
Done when
All scenarios exist and pass, and every place where today's behaviour differs from the expected behaviour is commented as a gap.
Dependencies
Blocked by: swamp-club#2854
Blocks: nothing
Full dependency graph and waves: swamp-club#2865.
Related
- Tracking issue: swamp-club#2865 (https://swamp-club.com/lab/2865).
- End-to-end counterpart: swamp-uat#485.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.