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
| Policy | When Used | Behavior |
|---|---|---|
append_only | Progress statements, assessment results | No conflict possible — every entry has unique ID |
crdt_yjs | Authoring drafts (M5) | Yjs CRDT merge; conflicts rare but possible on divergent schema |
lww | Most entities (enrollments, assignments, etc.) | Last-writer-wins using VectorClock comparison |
server_authoritative | Licenses, certificates, billing | Server always wins; client mutation rejected if server changed |
3. State Machine (LocalMutation)
queued → inflight → applied
→ conflicted → (manual resolution) → applied | rejected
→ rejected
4. Invariants
clientMutationIdglobally unique (ULID; PK in mutations table).- Cursor
lamportstrictly monotonic per (tenant, user, device, scope). - ConflictRecord immutable after resolution (
resolvedAtset). - Device must be bound (
identity.device.bound_for_offline.v1consumed) before any sync operation. - Mutations scoped to (tenantId, userId, deviceId) — cannot push mutations for another user.
- VectorClock merge:
max(vc_server[d], vc_client[d])for each device keyd.
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) │
│ │