Sync Contract
:::info Source
Sourced from services/catalog-service/SYNC_CONTRACT.md in the documentation repo.
:::
Companion: ../../docs/03-microservices/sync-service.md · API_CONTRACTS
The catalog is read-mostly on clients. Offline clients cache a scoped subset of the catalog to support browse, detail, and launch while disconnected. Writes (metadata edits) are authenticated admin/authoring flows — they do not participate in offline-first sync.
1. Offline Scope
| Artefact | Pulled to client? | Reason |
|---|---|---|
Course (own tenant, status=active, visibility ∈ {private,org,marketplace,public}) | Yes | Browse works offline |
CourseVersion (latest published per course in scope) | Yes | Detail + launch |
CourseVersion (deprecated, enrolled) | Yes | Continue enrolled content |
CourseVersion (withdrawn) | Read-only, flagged | History surface in progress UI |
Taxonomy (tenant + ghasi:subjects) | Yes | Browse facets |
authors[].displayName | Yes | Render author chips |
course_audit | No | Server-only |
outbox / inbox | No | Server-only |
2. Sync Service Integration
The catalog does not implement sync directly. It exposes a Pull API consumed by sync-service, which owns the device registration, cursor, and bundle delivery.
2.1 Pull endpoints (service-to-service)
GET /internal/v1/catalog/changes?tenantId=…&since=<cursor>&limit=500
Auth: service JWT with aud=sync-service
Response:
{
"data": {
"changes": [
{ "op": "upsert", "kind": "course", "id": "crs_01H…", "data": { "…": "…" }, "seq": 48210 },
{ "op": "upsert", "kind": "course_version", "id": "crv_01H…", "data": { "…": "…" }, "seq": 48211 },
{ "op": "delete", "kind": "course", "id": "crs_01H…", "seq": 48212 },
{ "op": "upsert", "kind": "taxonomy", "id": "tax_01H…", "data": { "…": "…" }, "seq": 48213 }
]
},
"meta": { "nextCursor": "seq:48213", "hasMore": false }
}
Cursor format: seq:<bigint> where seq is monotonic per tenant (column catalog.change_log.seq).
2.2 Change log table
CREATE TABLE catalog.change_log (
seq BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
kind TEXT NOT NULL, -- 'course','course_version','taxonomy'
op TEXT NOT NULL, -- 'upsert','delete'
entity_id TEXT NOT NULL,
data JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_change_log_tenant_seq ON catalog.change_log (tenant_id, seq);
Populated by triggers on courses, course_versions, taxonomies. Retention: 30 days (matches offline bundle freshness budget).
3. Client-Side Cache Model
Clients (web PWA, iOS, Android) store catalog entries in IndexedDB / Core Data / Room.
| Store | Rows | Index |
|---|---|---|
courses | subset scoped by tenant + visibility + enrollment | (tenantId, status) |
course_versions | latest + enrolled | (courseId, status) |
taxonomies | full tree per namespace | (namespace) |
4. Conflict Resolution
Catalog is server-authoritative. Clients NEVER write to their cached catalog tables — they only merge upserts/deletes from the change log. Therefore no merge / CRDT logic is required.
If the user attempts a metadata edit offline, the write is queued in sync-service.LocalMutation with service='catalog'. On reconnect, sync-service calls PATCH /api/v1/courses/{id}/metadata with the saved If-Match. If 412 is returned, sync-service reports ConflictRecord with resolution strategy server_wins and surfaces the latest state to the user.
5. Backpressure & Sizing
- Max change set per pull: 500 entries.
- Max bundle size per pull: 8 MB.
- Clients poll on app foreground; sync-service may push via WebSocket (
sync.tail) for tenants on M3+.
6. Staleness Budget
| SLO | Target |
|---|---|
| Publish → client visible (online) | ≤ 5 s |
| Publish → client visible (offline, next sync) | ≤ 60 s after reconnect |
| Withdrawal enforcement (offline client) | Best-effort; server enforces on next launch attempt via delivery-service |
7. Security
- Signed change-log payloads (
X-Ghasi-Sync-Sig: HMAC(sig, body)using per-tenant shared secret with sync-service). - Change log rows respect RLS: sync-service passes the tenant context, receives only that tenant's rows.
- Cross-tenant courses with visibility
marketplace/publicare included in the requesting tenant's change log when a learner enrols; enrollment-service is responsible for triggering inclusion.
8. Offline Launch Flow
[Learner] tap "Play course" (offline)
│
▼
[Client] look up course_versions.latest
│
▼ found + playPackageRef is in local bundle?
├── yes → launch via LMS runtime player (see 11-lms-runtime-player-spec)
└── no → show "Not downloaded yet" with CTA to download when online
Catalog-level data is necessary but not sufficient; the PlayPackage bundle is the large artefact and is owned by content-service. Sync-service coordinates bundle download.
9. GDPR & Device Wipe
On tenant.left.v1 or device.wiped.v1, sync-service evicts the local catalog cache for that tenant/device. Catalog service has no obligation beyond emitting change-log deletes for the affected rows.
10. Observability
- Metric
catalog_sync_pull_bytes_total{tenant}per pull. - Metric
catalog_sync_pull_latency_mshistogram. - Log WARN if a pull retrieves > 400 entries (sign of missed catch-up).
11. Implementation Notes
- The change log is fed by triggers only; publishing an event to
sync-serviceis not the mechanism of record. This decouples catalog's sync responsibilities from the broader event stream. - Order within a tenant is strictly
seqmonotonic; clients MUST apply in order and persistcursoronly after successful merge.