diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index d9ee2720..099dd82b 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -233,6 +233,12 @@ def sync(data: dict | None = None) -> None: Accepts any dict — the cloud determines what it is from the shape. The desktop has no knowledge of event types, schemas, or routing. + Each call carries: + - `t`: client-side timestamp at submit time (unix seconds, float). + - `submission_id`: uuid generated per call. The cloud uses + (install_id, submission_id) as an idempotency key, so a retry + from the offline spool is a no-op rather than a double-write. + Fire-and-forget; never raises. """ payload = data or {} @@ -242,6 +248,7 @@ def sync(data: dict | None = None) -> None: "client_state": _envelope(), "d": payload, "t": time.time(), + "submission_id": uuid4().hex, } _log("s", payload) if _test_sink is not None: diff --git a/frontend/src/shared/serviceClient.ts b/frontend/src/shared/serviceClient.ts index 859ca342..9786dcb8 100644 --- a/frontend/src/shared/serviceClient.ts +++ b/frontend/src/shared/serviceClient.ts @@ -6,6 +6,19 @@ import { API_BASE } from './config'; +/** Generate an id per submit() call. Used so retries (network blip, + * page reload mid-flush, etc.) are deduplicated downstream rather than + * inserted as separate rows. Falls back to a Math.random() id on + * ancient browsers without crypto.randomUUID. */ +function _newSubmissionId(): string { + try { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + } catch { /* fall through */ } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; +} + let _lastTs = Date.now(); let _appStart = Date.now(); @@ -51,12 +64,20 @@ function _flush(): void { export function sync(data: Record = {}, opts: { immediate?: boolean } = {}): void { _lastTs = Date.now(); + // Stamp a submission id + client timestamp so the cloud can deduplicate + // retries and order events by the moment they happened, not by the + // moment they landed. + const stamped: Record = { + ...data, + submission_id: typeof data.submission_id === 'string' ? data.submission_id : _newSubmissionId(), + t: typeof data.t === 'number' ? data.t : Date.now(), + }; if (opts.immediate) { - _queue.push(data); + _queue.push(stamped); _flush(); return; } - _queue.push(data); + _queue.push(stamped); if (_flushTimer == null) { _flushTimer = setTimeout(() => { _flushTimer = null;