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

Relationships

#2854 Test infra: shared in-memory remote datastore fake and recording sync service for pre-refactor datastore tests

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 reaches DatastoreSyncService (src/domain/datastore/datastore_sync_service.ts, interface at :168, the eight-rule markDirty contract 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.json sidecar, _index shards 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, that DataQueryService reads.
  • Serve runs pollers that pull peers' changes: Config, AccessData, RuntimeData.

What changes. The refactor lands on main in phases:

  • Phase 1: a UnitOfWork port 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/pushChanged themselves.
  • 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(), or invalidateType in a finally.
  • No fixed sleeps (use waitFor from @swamp-club/swamp-testing), no wall-clock assertions, and Deno.utime for mtimes.
  • Use @std/path for paths and assertPathEquals for 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

Build a shared, in-memory remote datastore fake with content, plus a shared recording sync service. The other pre-refactor tests build on it, and Phase 1's legacy UnitOfWork adapter will be tested against it.

Current state

  • There is no reusable fake. About 17 hand-rolled fake sync services are scattered through test files. Examples:
    • recordingSyncService at src/cli/repo_context_test.ts:3406
    • createRecordingSyncService at integration/mark_dirty_hook_sync_test.ts:62
    • createRacingSyncService at integration/server_token_mint_sync_test.ts:201-231
  • The closest thing to a remote is createFakeDatastore() in src/serve/sync_gate_test.ts:400-437: a remote Set, a local Set and a dirty Set, with push treating a path absent locally as deleted remotely. It tracks paths only, not content.
  • packages/testing/datastore_test_context.ts:91 (createDatastoreTestContext) records markDirty relPaths (getSyncOperations()), but its push and pull do nothing and nothing uses it.

Work

  1. Add createInMemoryRemote() in packages/testing/, next to createDatastoreTestContext, exported from the package so swamp-extensions can use it too. It returns a remote holding a Map<relPath, Uint8Array>, and a connect(cacheDir) that returns a DatastoreSyncService bound to that cache directory. Several instances share one remote, one per simulated machine.
  2. Behaviour of each connected service:
    • markDirty(relPath?) records the path; a call with no path sets a bulk flag. Follow the contract at datastore_sync_service.ts:199-257.
    • pushChanged:
      • for each marked path (or every file under the cache when bulk), upload the file if it exists locally, otherwise delete it remotely;
      • clear the dirty set and bulk flag only on success;
      • return a count.
    • pullChanged:
      • download remote files that are new or changed into the cache;
      • delete local files the remote dropped, since the last pull, that are not dirty locally;
      • return the count of changed files.
      • Make the delete behaviour an option (pullDeletes: true | false), so tests can reproduce either S3's or GCS's current behaviour. Document which one each extension does today, checked against swamp-extensions/datastore/s3/extensions/datastores/_lib/s3_cache_sync.ts and gcs_cache_sync.ts.
    • preparePush / commitPush: two-phase, following datastore_sync_service.ts:379/405. Prepare does not clear dirty state; commit applies the change and clears it.
    • capabilities(): match the interface.
    • Failure injection: failNext("push" | "pull" | "prepare" | "commit", error?) and offline(true | false).
    • Operation log: ops() returns an ordered list of {instance, op, paths}, so tests can assert what was sent.
  3. Add createRecordingSyncService() that records markDirty calls, including bare ones, and does nothing else. Replace two or three of the hand-rolled copies with it to prove the API fits. Do not migrate all 17 in this issue.
  4. Registration helper: registerTestDatastoreType(remote) registers a per-run @test/remote-<uuid> datastore type in datastoreTypeRegistry whose provider returns remote.connect(cachePath). requireInitializedRepo then wires it end-to-end, following the pattern at src/cli/repo_context_test.ts:1004-1100. It returns a disposer that calls invalidateType.
  5. Tests for the fake itself (*_test.ts next to it):
    • round-trip between two instances;
    • delete propagation;
    • a failed push keeps the path dirty and the next push succeeds;
    • bulk mark;
    • two-phase;
    • pullDeletes on and off.

Done when

The fake and helpers are exported from packages/testing, tested, and used by at least one existing test in place of a hand-rolled fake.

Dependencies

Blocked by: nothing, so this can start now
Blocks: swamp-club#2855, swamp-club#2857, swamp-club#2859, swamp-club#2860, swamp-club#2861, swamp-club#2863
Full dependency graph and waves: swamp-club#2865.

02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 2 MORETRIAGE+ 18 MOREREVIEW+ 29 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/1/2026, 7:40:08 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/1/2026, 5:36:49 AM

Sign in to post a ripple.