Skip to main content

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

ArtefactPulled to client?Reason
Course (own tenant, status=active, visibility ∈ {private,org,marketplace,public})YesBrowse works offline
CourseVersion (latest published per course in scope)YesDetail + launch
CourseVersion (deprecated, enrolled)YesContinue enrolled content
CourseVersion (withdrawn)Read-only, flaggedHistory surface in progress UI
Taxonomy (tenant + ghasi:subjects)YesBrowse facets
authors[].displayNameYesRender author chips
course_auditNoServer-only
outbox / inboxNoServer-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.

StoreRowsIndex
coursessubset scoped by tenant + visibility + enrollment(tenantId, status)
course_versionslatest + enrolled(courseId, status)
taxonomiesfull 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

SLOTarget
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/public are 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_ms histogram.
  • 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-service is not the mechanism of record. This decouples catalog's sync responsibilities from the broader event stream.
  • Order within a tenant is strictly seq monotonic; clients MUST apply in order and persist cursor only after successful merge.