Skip to main content

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)​

AggregateReplicated rowsConflict policyReason
DashboardAll dashboards visible to the user (own + shared with role + tenant-scoped + property-scoped where the user has access)server_authoritativeCatalog data
WidgetWidgets of replicated dashboardsserver_authoritativeCatalog data
MetricDefinition (lite)Metrics referenced by replicated widgets, latest published version onlyserver_authoritativeRenders widget metadata
WidgetSnapshot (synthetic)Pre-computed widget data for replicated widgets at a fixed cadenceappend_onlyPowers offline rendering
Saved Query (lite)Saved queries owned by current userlww+diff (name only)User-managed
Projection (catalog only)Projection key + last freshness per dashboard's tablesserver_authoritativeLets desktop show "Data as of …"
DQ alerts (open)Open alerts for tenant scope, last 7 dserver_authoritativeBanner 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 — only Query.name_i18n is editable on desktop; everything else is server-authoritative. Push uses If-Match: <local etag>; conflicts resolved per the platform lww+diff rules.

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​

MetricBudget
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 size500 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.sharedWith is replicated only with kind user/role; Looker Studio embed tokens are never replicated.
  • Saved queries replicate sql_template only when the device's user is the owner; otherwise only name_i18n and id.
  • 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​

FailureBehaviour
Snapshot generator falling behindDesktop shows last available snapshot with "Data as of …" badge ; banner if lag > freshness.alertAtMinutes
Push 409 on saved query renameUI 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-sessionCloud returns 403 on next pull; desktop wipes affected rows + caches

8. Snapshot generation pipeline (server side)​

Each tenant has a widget-snapshotter worker that:

  1. Reads active dashboards for online devices for the tenant (heuristic: any device with a sync within the last 24 h).
  2. 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.
  3. 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.