feat(datastore): scoped sync and capability-gated concurrency (swamp-club#350)#1386
Conversation
…club#350) Add opt-in scoped sync to the datastore layer so providers that support concurrent per-model writes can bypass the global push lock. - Add `DatastoreCapabilities` interface with `scopedSync` flag and optional `capabilities()` method on `DatastoreProvider` - Add `scope` field to `DatastoreSyncOptions` for prefix-scoped pull/push - Gate `acquireModelLocks` flush: when `scopedSync === true`, iterate per-model with scoped `pushChanged`/`pullChanged` calls and no global lock; when false/absent, preserve current global-lock behavior unchanged - Update testing package (`datastore_types.ts`, `datastore_conformance.ts`) so extension authors see the new contract - Export `DatastoreCapabilities` from domain barrel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Clean, well-designed PR. The capability-gating pattern is sound — providers opt in explicitly, the default path is preserved unchanged, and the safety invariant is clearly documented. The domain interfaces, testing package updates, and conformance checks all align. No blocking issues.
Suggestions
-
Hoist
capabilities()check above the per-model pull loop (src/cli/repo_context.ts:941):customProvider?.capabilities?.()?.scopedSyncis evaluated inside thefor (const { modelType, modelId } of unique)loop, so it's called N times during pull (once per model). Since capabilities are stable per provider instance, this could be computed once before the loop — similar to howscopedSyncis already computed once at line 962 for the flush closure. Minor efficiency nit, not a correctness issue. -
Scoped push stops on first failure (
src/cli/repo_context.ts:973-1001): In the scoped push path, if one model's push throws, remaining models' scoped pushes are skipped. The unpushed changes remain in local cache and will be retried on next flush, so this is safe. Worth noting if you anticipate wanting partial-success semantics in a later phase.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
capabilities()called redundantly inside the per-model pull loop (src/cli/repo_context.ts:941). The expressioncustomProvider?.capabilities?.()?.scopedSync === trueis evaluated on every iteration of thefor (const { modelType, modelId } of unique)loop, then evaluated again at line 962 for the flush closure. Sincecapabilities()is a pure configuration query, these should all return the same value — but re-invoking an extension method N+1 times (once per model + once for flush) is unnecessary work and creates a theoretical TOCTOU window if a buggy extension mutates state between calls. Hoist the check once before the loop:const useScopedSync = customProvider?.capabilities?.()?.scopedSync === true;
Then reuse that boolean in both the pull loop and the flush closure.
-
Conformance test passes
nullas a valid capabilities return (packages/testing/datastore_conformance.ts:170-173). The assertionassertEquals(typeof caps, "object", ...)acceptsnullbecausetypeof null === "object"in JavaScript. If a provider returnsnull, the test passes the type check but then crashes with an unhelpfulTypeError: Cannot read properties of nullon thecaps.scopedSyncaccess at line 175. Production code is safe (optional chaining handles null), but the conformance test should guard against it:assertNotEquals(caps, null, "capabilities() must not return null");
Low
-
lockedModels = [...unique]is a redundant copy (src/cli/repo_context.ts:963).uniqueis a localconstarray created bysorted.filter(...)— it's never mutated after creation and never escapes the function. The spread copy is harmless but unnecessary. -
Scoped push partial failure leaves remote in a half-pushed state (
src/cli/repo_context.ts:973-1001). If model A's scoped push succeeds and model B's fails, model A's changes are persisted remotely while model B's are not, and all per-model locks are released in thefinally. This is architecturally inherent to the scoped design (and not fundamentally worse than a global push failing mid-upload), and theSAFETY INVARIANTcomment makes the contract clear. Flagging for awareness, not as a defect.
Verdict
PASS — The capability-gating logic is correct, the fallback to global-lock behavior for providers without capabilities() preserves backward compatibility, the deduplication and deterministic lock ordering are sound, and the test coverage hits the three key paths (scoped, unscoped, dedup). The medium items are minor quality improvements, not correctness bugs.
Summary
DatastoreCapabilitiesinterface withscopedSyncflag and optionalcapabilities()method onDatastoreProviderscopefield toDatastoreSyncOptionsfor prefix-scoped pull/push (orthogonal torelPathwhich is markDirty-only)acquireModelLocksflush onscopedSync: when true, iterate per-model with scopedpushChanged/pullChangedcalls and no global lock; when false/absent, preserve current global-lock behavior unchangeddatastore_types.ts,datastore_conformance.ts) so extension authors see the new contract and providers withoutcapabilities()still pass conformancePhase 1 of the scoped sync roadmap documented in
design/datastores.md. Delivers the framework contracts so document-store backends (e.g.@keeb/mongodb-datastore) can declarescopedSync: trueand benefit immediately. S3/GCS need index partitioning (Phase 3) before they can opt in.Test Plan
deno check— all files passdeno lint— cleandeno fmt— cleandeno run test— 5933 passed, 0 faileddeno run compile— binary compiles successfullytesting_package_compat_test.ts)scopedSyncdeclared)🤖 Generated with Claude Code