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.