SYNC_CONTRACT — analytics-service
Sibling: DATA_MODEL · APPLICATION_LOGIC · SECURITY_MODEL · platform anchor: docs/architecture/ADR-0003 Electron offline-first desktop
The Electron backoffice presents dashboard widgets and KPI snapshots to staff and managers. analytics-service is not an offline-write surface; tenants do not author analytics in the air-gap. The desktop receives pre-computed widget snapshots so common KPIs render instantly when the device is offline or the network is slow.
1. Replication scope (cloud → desktop)
| Aggregate | Replicated rows | Conflict policy | Reason |
|---|---|---|---|
Dashboard | All dashboards visible to the user (own + shared with role + tenant-scoped + property-scoped where the user has access) | server_authoritative | Catalog data |
Widget | Widgets of replicated dashboards | server_authoritative | Catalog data |
MetricDefinition (lite) | Metrics referenced by replicated widgets, latest published version only | server_authoritative | Renders widget metadata |
WidgetSnapshot (synthetic) | Pre-computed widget data for replicated widgets at a fixed cadence | append_only | Powers offline rendering |
Saved Query (lite) | Saved queries owned by current user | lww+diff (name only) | User-managed |
Projection (catalog only) | Projection key + last freshness per dashboard's tables | server_authoritative | Lets desktop show "Data as of …" |
| DQ alerts (open) | Open alerts for tenant scope, last 7 d | server_authoritative | Banner in UI |
1.1 WidgetSnapshot cadence
For each replicated widget, the cloud computes a snapshot at a cadence derived from the metric/query freshness:
- Hot widgets (5-min freshness target): snapshot every 5 minutes.
- Cold widgets (15-min target or longer): snapshot every 15 minutes.
- Heavy widgets (
bytesScanned p95 > 500 MB): snapshot every 60 minutes and only when foregrounded.
A snapshot row is small (≤ 64 KB serialized): widget id, params hash, rows, provenance, generated-at timestamp. It is replicated to the desktop SQLite cache and used as the default render until a fresh value arrives.
2. Conflict policies
server_authoritative— desktop never proposes writes; replication overwrites local rows.append_only— snapshots arrive in chronological order; desktop keeps last N per widget (configurable, default 12).lww+diff— onlyQuery.name_i18nis editable on desktop; everything else is server-authoritative. Push usesIf-Match: <local etag>; conflicts resolved per the platformlww+diffrules.
Authoring a dashboard or widget always goes through the cloud (POST /api/v1/analytics/dashboards/...). Offline edit attempts are queued as deferred commands and replayed when online; if the underlying entity changed in the meantime, the user gets a merge prompt.
3. Pull contract
GET /internal/sync/pull?cursor=<opaque>&pageSize=500
Headers:
X-Tenant-Id: tnt_…
X-User-Id: usr_…
X-Device-Id: dev_…
200 OK
{
"items": [
{ "kind": "Dashboard", "op": "upsert", "row": { … }, "version": 4 },
{ "kind": "Widget", "op": "upsert", "row": { … }, "version": 2 },
{ "kind": "MetricDefinition","op": "upsert", "row": { … } },
{ "kind": "WidgetSnapshot", "op": "append", "row": {
"widgetId": "wid_01H…",
"paramsHash": "sha256:…",
"rows": [ { "value": 73.42 } ],
"provenance": { "computedAt": "2026-04-22T10:00:00Z", "bytesScanned": 18234567, "slotMs": 1240, "warehouseJobId": "bq-job-…", "computedBy": "etl" },
"generatedAt": "2026-04-22T10:00:30Z",
"freshnessLagSeconds": 120
} },
{ "kind": "Projection", "op": "upsert", "row": { "key": "fact_reservation", "lastSuccessAt": "…", "lagMinutes": 3 } },
{ "kind": "DqAlertOpen", "op": "upsert", "row": { "checkKey": "fact_reservation.row_count_drift_24h", "severity": "critical", "observedAt": "…" } },
{ "kind": "DqAlertOpen", "op": "tombstone","id": "dqc_01H…" }
],
"nextCursor": "…",
"drained": false
}
Cursors are signed (HMAC) and encode (kind, lastSeenLogPosition, snapshotEpoch). Snapshot epoch increments when the user's property scope changes (forces a cold restart of that aggregate).
The pull endpoint also supports ?fresh=true for the foreground tab to request that snapshot generators wake immediately for the user's hot widgets (rate-limited).
4. Push contract
Allowed pushes per the policy table:
POST /internal/sync/push
{
"ops": [
{
"kind": "Query",
"id": "qry_01H…",
"expectedVersion": 1,
"patch": { "name_i18n": { "default": "My weekly bookings" } }
}
]
}
200 OK
{ "results": [ { "id": "qry_01H…", "ok": true, "version": 2 } ] }
Anything else (POST /api/v1/analytics/dashboards/...) goes through bff-backoffice-service's online API path; the desktop queues the request and surfaces "Will sync when online" if disconnected.
5. Bandwidth & quotas
| Metric | Budget |
|---|---|
| Cold start per device | ≤ 5 MB metadata + snapshots |
| Steady-state hourly delta | ≤ 500 KB |
| Snapshot per widget per refresh | ≤ 64 KB |
| Push batch size | ≤ 50 ops |
| Pull page size | 500 default, 2000 max |
The desktop sync engine de-duplicates identical snapshots (same paramsHash + same rows digest) to avoid wasteful local rewrites.
6. Privacy on device
Dashboard.sharedWithis replicated only with kinduser/role; Looker Studio embed tokens are never replicated.- Saved queries replicate
sql_templateonly when the device's user is the owner; otherwise onlyname_i18nandid. - DQ alert snapshots replicate only the check key and severity; they do not include the BigQuery job id or raw observed value (those stay in the cloud audit trail).
7. Failure modes
| Failure | Behaviour |
|---|---|
| Snapshot generator falling behind | Desktop shows last available snapshot with "Data as of …" badge ; banner if lag > freshness.alertAtMinutes |
| Push 409 on saved query rename | UI prompts the user; either keep local or accept server |
| Pull cursor invalidated (snapshot epoch advanced) | Desktop drops local table for that kind and cold-starts |
| Authorized view binding revoked mid-session | Cloud returns 403 on next pull; desktop wipes affected rows + caches |
8. Snapshot generation pipeline (server side)
Each tenant has a widget-snapshotter worker that:
- Reads active dashboards for online devices for the tenant (heuristic: any device with a sync within the last 24 h).
- For each user × widget combination, executes the widget query (with byte cap) once per cadence; caches in Redis and writes to
analytics.widget_snapshots(Postgres) for sync replication. - Honors per-tenant byte budget; degrades gracefully (skips heavy widgets first) when 80 % of daily budget is spent.
Generation activity emits melmastoon.analytics.query.executed.v1 (sampled).
Cross-references: docs/architecture/ADR-0003, DATA_MODEL §3.3, SECURITY_MODEL §3.