super-productivity/docs/long-term-plans/server-side-entity-versioning.md
Johannes Millan 42e6626b76 docs(sync): consolidate sync docs + enforce the contributor model
Collapse the sprawling, partly-stale docs/sync-and-op-log/ tree into a
small authoritative set and make the sync-correctness invariant
partly lint-enforced instead of convention-only.

Docs:
- Delete superseded/duplicate/provably-stale design, plan, and
  background-research docs (quick-reference, the architecture-diagrams
  monolith, the "Hybrid Manifest" docs describing code that does not
  exist, completed long-term plans, LLM-synthesis analyses).
- Salvage load-bearing decision history into the surviving docs before
  deletion: rejected-alternatives rationale -> operation-log-architecture
  ("Why this architecture"); vector-clock pruning incident history ->
  vector-clocks.md; archive-payload optimization -> architecture E.7.
- Add contributor-sync-model.md as the single-invariant entry point
  (one user intent = one op; replayed/remote ops must not re-trigger
  effects), with a decision table mapping to the enforcing linters.
- Repoint external/internal cross-refs; add CONTRIBUTING.md + CLAUDE.md
  pointers; record the migration in a dated docs/plans/ design doc.

Enforcement (new eslint-local-rules):
- no-actions-in-effects (error): effects must inject LOCAL_ACTIONS /
  ALL_ACTIONS, never the raw @ngrx/effects Actions stream.
- no-multi-entity-effect (warn, heuristic): flags a literal returned
  array of >=2 action-creator calls; docstring + valid-case specs pin
  exactly which shapes are and are not detected.
- run-specs.js runner wired into `npm run lint` via test:lint-rules;
  refuses to run under test-framework globals and counts RuleTester.run
  invocations so a spec that asserts nothing fails instead of passing.
- Correct the ALL_ACTIONS JSDoc in local-actions.token.ts to match
  reality (archive-operation-handler uses LOCAL_ACTIONS).

Reviewed via parallel multi-agent review; findings W1/W2/W4 and a
dangling doc anchor addressed.
2026-05-15 16:51:50 +02:00

341 lines
14 KiB
Markdown

# Plan: Server-Side Entity Versioning (Optimistic Concurrency Control)
> **Status: Planned**
>
> Long-term architectural change to eliminate vector clock pruning as a source of sync conflicts.
---
## Problem
Vector clocks grow linearly with the number of participating clients. Pruning to `MAX_VECTOR_CLOCK_SIZE=20` loses causal information, though at MAX=20 this requires 21+ unique client IDs — extremely rare for a personal productivity app. A same-client check handles the edge case where pruning causes false concurrency for the import client's own ops, but the fundamental issue remains:
The fundamental issue: vector clocks were designed for peer-to-peer systems where no node is authoritative. Super Productivity has a central server -- the server can define ordering authoritatively, making vector clocks unnecessary for online conflict detection.
## Industry Precedent
Every major production system with a central server converged on this pattern:
| System | Mechanism | Details |
| --------------------- | ---------------------------------------- | ------------------------------------------------ |
| **DynamoDB** (modern) | Multi-Paxos, single leader per partition | Abandoned original Dynamo vector clocks entirely |
| **Figma** | Server-ordered property-level LWW | Server receipt order defines total ordering |
| **Linear** | Monotonic `syncId` counter | Single integer per transaction |
| **EventStoreDB** | `expectedVersion` per stream | Append rejected if version mismatch |
| **CouchDB** | `_rev` per document | Server assigns new revision on acceptance |
| **Cosmos DB** | `_etag` per item | Conditional updates via `If-Match` header |
The pattern is **Optimistic Concurrency Control (OCC)**: the server tracks an authoritative version per entity, clients include the expected version when writing, and the server rejects stale writes.
## Approach
Add a **server-assigned monotonic version number per entity**. Use this as the primary conflict detection mechanism. Keep vector clocks as secondary metadata for offline causality reasoning and as a migration fallback.
### Why This Solves the Pruning Problem
- Conflict detection uses a single integer comparison (`expectedVersion === currentVersion`), not vector clock comparison
- No pruning needed for the primary conflict detection path
- Vector clocks become informational metadata, not critical for correctness
- The sync loop is impossible: a rejected operation gets the current version, retries with the correct version, and succeeds
## Changes
### Phase 1: Server Schema and Version Tracking
**File:** `packages/super-sync-server/prisma/schema.prisma`
```prisma
model EntityVersion {
id String @id @default(uuid())
userId String
entityType String
entityId String
version Int @default(0) // Monotonically increasing
updatedAt DateTime @updatedAt
@@unique([userId, entityType, entityId])
@@index([userId, entityType, entityId])
}
```
**File:** `packages/super-sync-server/src/sync/sync.service.ts`
When processing an uploaded operation:
```typescript
async processOperation(userId: string, op: Operation): Promise<UploadResult> {
const entityKey = { userId, entityType: op.entityType, entityId: op.entityId };
// Get or create entity version
const entity = await this.getOrCreateEntityVersion(entityKey);
if (op.entityVersion !== undefined) {
// New-style client: uses entity versioning
if (op.entityVersion !== entity.version) {
return {
status: 'CONFLICT',
reason: op.entityVersion < entity.version
? 'CONFLICT_SUPERSEDED'
: 'CONFLICT_VERSION_MISMATCH',
currentVersion: entity.version,
existingClock: entity.clock, // Still provided for backward compat
};
}
} else {
// Legacy client: fall back to vector clock comparison
const conflict = await this.detectConflictByVectorClock(entityKey, op.vectorClock);
if (conflict.hasConflict) {
return {
status: 'CONFLICT',
reason: conflict.reason,
currentVersion: entity.version, // Include version even for legacy clients
existingClock: conflict.existingClock,
};
}
}
// Accept: increment entity version, assign server sequence
const newVersion = entity.version + 1;
await this.updateEntityVersion(entityKey, newVersion);
const seq = await this.allocateSequence(userId);
await this.storeOperation(op, seq, userId);
return { status: 'OK', serverSeq: seq, entityVersion: newVersion };
}
```
### Phase 2: Wire Protocol Changes
**File:** `packages/shared-schema/src/operation.types.ts` (or equivalent shared types)
Add optional fields to `Operation`:
```typescript
interface Operation {
// ... existing fields ...
// Server-assigned entity version at time of acceptance (returned in download)
entityVersion?: number;
}
```
**File:** Upload result types
Add `currentVersion` and `entityVersion` to upload results:
```typescript
interface UploadResult {
// ... existing fields ...
// Current entity version (returned on conflict for retry)
currentVersion?: number;
// Assigned entity version (returned on success)
entityVersion?: number;
}
```
### Phase 3: Client-Side Integration
**File:** `src/app/op-log/sync/vector-clock.service.ts` (or new service)
Track entity versions locally:
```typescript
// Store entity versions received from server
// Key: "ENTITY_TYPE:entityId", Value: version number
private entityVersions = new Map<string, number>();
async getEntityVersion(entityType: string, entityId: string): Promise<number | undefined> {
const key = `${entityType}:${entityId}`;
// Check in-memory cache first, then IndexedDB
return this.entityVersions.get(key) ?? await this.loadFromStore(key);
}
async updateEntityVersion(entityType: string, entityId: string, version: number): Promise<void> {
const key = `${entityType}:${entityId}`;
this.entityVersions.set(key, version);
await this.persistToStore(key, version);
}
```
**File:** `src/app/op-log/sync/upload.service.ts` (or equivalent)
When creating operations for upload, attach the entity version:
```typescript
// Before uploading an operation
const entityVersion = await this.vectorClockService.getEntityVersion(
op.entityType,
op.entityId,
);
if (entityVersion !== undefined) {
op.entityVersion = entityVersion;
}
```
When processing upload results:
```typescript
// On successful upload
if (result.entityVersion !== undefined) {
await this.vectorClockService.updateEntityVersion(
op.entityType,
op.entityId,
result.entityVersion,
);
}
// On conflict
if (result.currentVersion !== undefined) {
await this.vectorClockService.updateEntityVersion(
op.entityType,
op.entityId,
result.currentVersion,
);
}
```
**File:** `src/app/op-log/sync/superseded-operation-resolver.service.ts`
When creating replacement operations, use the entity version from the rejection:
```typescript
// The server returns currentVersion in the rejection
// Use it as the expectedVersion for the replacement op
replacementOp.entityVersion = rejectedOpInfo.currentVersion;
```
This eliminates the sync loop entirely: the replacement op has the correct version, so the server accepts it on the next attempt. No vector clock comparison needed.
### Phase 4: Download Integration
When downloading operations from the server, each operation should include its `entityVersion`. The client stores this as the latest known version for that entity:
```typescript
// During download processing
for (const downloadedOp of ops) {
if (downloadedOp.entityVersion !== undefined) {
await this.vectorClockService.updateEntityVersion(
downloadedOp.entityType,
downloadedOp.entityId,
downloadedOp.entityVersion,
);
}
}
```
### Phase 5: Persistence for Entity Versions
**File:** `src/app/op-log/persistence/operation-log-store.service.ts`
Add a new IndexedDB object store for entity versions:
```typescript
// In the database schema (SUP_OPS)
// New store: 'entity_versions'
// Key: string (entityType:entityId)
// Value: number (version)
```
This must survive app restarts. On first sync after app restart, the client may not have versions for all entities -- in that case, it falls back to vector clock comparison (the server handles both paths).
## Migration Strategy
### Server Backward Compatibility
The server accepts both old-style (vector clock only) and new-style (entity version) operations:
1. If `op.entityVersion` is present: use OCC (integer comparison)
2. If `op.entityVersion` is absent: fall back to vector clock comparison
This allows gradual client rollout. Old clients continue to work without changes.
### Client Backward Compatibility
The client gracefully handles servers that don't return `entityVersion`:
1. If upload result includes `entityVersion`: store it, use OCC on next upload
2. If upload result lacks `entityVersion`: continue with vector clock only
### Backfill Entity Versions
New entities get version 0. Existing entities need backfilling:
```typescript
// Server migration: assign version 1 to all existing entities
// that have at least one operation
await prisma.entityVersion.createMany({
data: existingEntities.map((e) => ({
userId: e.userId,
entityType: e.entityType,
entityId: e.entityId,
version: 1,
})),
skipDuplicates: true,
});
```
On first sync after migration, clients will receive `entityVersion: 1` and use it for subsequent uploads.
## What Happens to Vector Clocks
Vector clocks are **not removed**. They serve two remaining purposes:
1. **Offline causality reasoning**: When a client has been offline and accumulated multiple operations, vector clocks help determine which operations are causally related without server involvement
2. **Fallback for old clients**: Servers continue to accept vector-clock-only operations from clients that haven't been updated
Over time, as all clients update, vector clock comparison on the server becomes a dead code path. It can be removed in a future major version.
Vector clock pruning remains for bandwidth efficiency, but pruning errors no longer cause sync loops because the primary conflict detection uses entity versions.
## Risks
| Risk | Severity | Mitigation |
| ----------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
| Entity version table grows with entity count | Low | One row per entity per user; bounded by user data |
| Client loses entity version (cleared storage) | Low | Falls back to vector clock comparison; server returns version on next interaction |
| Race condition between version check and update | Medium | Use database transaction with `REPEATABLE_READ` isolation (already used for current conflict detection) |
| Two clients upload same entity version simultaneously | Medium | Only one succeeds (atomic increment); other retries with new version |
| Offline client has stale entity version | Low | Server rejects; client downloads latest, creates replacement op with current version |
| Schema migration on server | Medium | Additive change (new table, new optional fields); no breaking changes to existing data |
## Verification
### Unit Tests
- Server: OCC acceptance and rejection for matching/mismatched versions
- Server: Fallback to vector clock comparison when `entityVersion` absent
- Client: Entity version tracking through upload/download/rejection cycles
- Client: Replacement ops include correct entity version from rejection
### Integration Tests
- Full sync cycle with entity versioning: create, upload, download on second client, modify, upload
- Conflict scenario: two clients upload with same entity version, one succeeds, other retries
- Mixed clients: old client (vector clock only) and new client (entity version) modifying same entity
- Offline scenario: client accumulates ops offline, reconnects, resolves conflicts via entity version
### E2E Tests
- Sync loop regression test: ensure the sync loop scenario from the original bug is impossible with entity versioning
## Relationship to Other Plans
- **Builds on:** Server-Side Prune-Aware Comparison (plan doc removed in `985e839747`; view it with `git show 669f2d7874:docs/long-term-plans/server-side-prune-aware-comparison.md`) -- can be implemented in either order; prune-aware comparison improves the vector clock fallback path
- **Related:** Current client-side fix (commit `f9be1c8500`) remains as defense-in-depth for the vector clock fallback path
- **Related:** [SuperSync Encryption Architecture](../sync-and-op-log/supersync-encryption-architecture.md) -- entity versions are not sensitive data and do not need encryption
## Implementation Order
1. **Server: Add `EntityVersion` table and migration** (no client changes needed)
2. **Server: Track entity versions on operation acceptance** (alongside existing vector clock logic)
3. **Server: Return `entityVersion` in upload results and download payloads**
4. **Client: Store and track entity versions from server responses**
5. **Client: Include `entityVersion` in uploaded operations**
6. **Client: Use `currentVersion` from rejections in replacement operations**
7. **Backfill migration for existing entities**
8. **Integration and E2E tests**
Each step is independently deployable and backward compatible.