Skip to main content

Domain Model

:::info Source Sourced from services/sync-service/DOMAIN_MODEL.md in the documentation repo. :::

1. Aggregates

SyncRegistration (configuration, not runtime)

interface SyncRegistration {
service: string;
entityType: string;
conflictPolicy: 'append_only' | 'crdt_yjs' | 'lww' | 'server_authoritative';
deltaProjector: string; // function name for projecting deltas from events
pushHandler: string; // function name for applying client mutations
versionField: string; // field used for ordering (e.g., 'updatedAt')
schemaRef: string; // URI to JSON Schema in schema registry
}

SyncCursor

interface SyncCursor {
tenantId: TenantId;
userId: UserId;
deviceId: DeviceId;
scope: string; // e.g., 'progress:user:u_01H...'
lamport: number; // monotonic counter
updatedAt: ISODate;
}

LocalMutation

interface LocalMutation {
clientMutationId: string; // client-generated ULID
tenantId: TenantId;
userId: UserId;
deviceId: DeviceId;
service: string;
entityType: string;
entityId: string;
baseVersion?: number;
vectorClock: VectorClock;
op: 'create' | 'update' | 'delete' | 'crdt_update';
payload: JSONValue;
occurredAt: ISODate;
attempts: number;
state: 'queued' | 'inflight' | 'applied' | 'conflicted' | 'rejected';
lastError?: { code: string; message: string };
}

ConflictRecord

interface ConflictRecord {
id: ULID;
tenantId: TenantId;
userId: UserId;
entityType: string;
entityId: string;
baseVersion: number;
serverVersion: number;
clientPayload: JSONValue;
serverPayload: JSONValue;
resolution: 'pending' | 'kept_server' | 'kept_client' | 'merged';
resolvedBy?: UserId;
resolvedAt?: ISODate;
}

VectorClock

type VectorClock = { [deviceId: string]: number };

2. Conflict Policies

PolicyWhen UsedBehavior
append_onlyProgress statements, assessment resultsNo conflict possible — every entry has unique ID
crdt_yjsAuthoring drafts (M5)Yjs CRDT merge; conflicts rare but possible on divergent schema
lwwMost entities (enrollments, assignments, etc.)Last-writer-wins using VectorClock comparison
server_authoritativeLicenses, certificates, billingServer always wins; client mutation rejected if server changed

3. State Machine (LocalMutation)

queued → inflight → applied
→ conflicted → (manual resolution) → applied | rejected
→ rejected

4. Invariants

  1. clientMutationId globally unique (ULID; PK in mutations table).
  2. Cursor lamport strictly monotonic per (tenant, user, device, scope).
  3. ConflictRecord immutable after resolution (resolvedAt set).
  4. Device must be bound (identity.device.bound_for_offline.v1 consumed) before any sync operation.
  5. Mutations scoped to (tenantId, userId, deviceId) — cannot push mutations for another user.
  6. VectorClock merge: max(vc_server[d], vc_client[d]) for each device key d.

5. Domain Events

  • sync.mutation.applied.v1 — mutation successfully applied to owning service.
  • sync.mutation.conflicted.v1 — conflict detected.
  • sync.mutation.rejected.v1 — owning service rejected mutation (policy or validation).
  • sync.delta.projected.v1 — server delta materialized for a scope.
  • sync.conflict.resolved.v1 — conflict manually or automatically resolved.
  • sync.cursor.advanced.v1 — cursor moved forward.
  • sync.full_resync.initiated.v1 — client cursor too old; full resync required.

6. Diagram

Client (offline) Server (sync-service)
│ │
│ mutations in local outbox │
│ │
│──── POST /sync/v1/push ──────────────────▶│
│ (batch of LocalMutations) │
│ │
│ ┌──────────────────────┤
│ │ Route to owning │
│ │ service push handler │
│ │ (e.g., progress, │
│ │ assessment) │
│ └──────────────────────┤
│ │
│◀──── Response (applied/conflicted/rejected)│
│ │
│──── POST /sync/v1/pull ──────────────────▶│
│ (cursor + scope) │
│ │
│ ┌──────────────────────┤
│ │ Query delta projector │
│ │ since cursor.lamport │
│ └──────────────────────┤
│ │
│◀──── Delta (upserts + deletes + newCursor)│
│ │
│──── POST /sync/v1/ack ──────────────────▶│
│ (newCursor acknowledged) │
│ │