@teincfood/core 0.9.23 → 0.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,133 +1,38 @@
1
1
  /**
2
- * Realtime invalidation hooks — bridge Phoenix WebSocket events into the
3
- * Sync Engine so useSyncReconciliation fires and React Query caches refresh.
2
+ * Realtime invalidation hooks.
4
3
  *
5
- * Both hooks are idempotent-safe and can be called from multiple screens.
6
- * Invalidations are debounced: a reconnect/replay burst can deliver dozens
7
- * of events at once, and an immediate invalidate-per-event forces repeated
8
- * full re-renders of boards/grids — a major CPU spike source on low-power
9
- * POS hardware. Coalescing into one invalidation per ~400ms window keeps
10
- * the UI fresh without the render storm.
11
- */
12
- import { useEffect } from "react";
13
- import { useQueryClient } from "@tanstack/react-query";
14
- import { orderEventsService } from "../realtime/order-events.service";
15
- import { orderSnapshotsService } from "../sync/order-snapshots.service";
16
- import { syncEngine } from "../sync/engine";
17
- import { generateEventId } from "../utils/uuid";
18
- import { getAppInstanceId } from "../adapters/devices";
19
- import { getActiveBusiness } from "../adapters/business";
20
- import { openSyncDatabase } from "../db/connection";
21
- import { getOrderById } from "../db/operations";
22
- const INVALIDATE_DEBOUNCE_MS = 400;
23
- const UNKNOWN_ORDER_PULL_DEBOUNCE_MS = 1500;
24
- /**
25
- * Subscribe to real-time order events and invalidate all queries under the
26
- * given root key prefix (e.g. ['seller'], ['pos'], ['kiosk']).
4
+ * The cloud → local event path now has a single owner: the global realtime
5
+ * bridge (`src/realtime/realtime-bridge.ts`), mounted once per app. It
6
+ * publishes every cloud event through the Sync Engine, and
7
+ * `useSyncReconciliation` reacts by journaling the event, patching the
8
+ * offline read model, invalidating the affected caches (debounced, 400 ms)
9
+ * and relaying the event to LAN peers.
27
10
  *
28
- * Each event is also persisted to the durable offline read model so order
29
- * history and states survive a restart while offline, and published through
30
- * the Sync Engine so useSyncReconciliation + LAN relay fire without polling.
11
+ * `useOrderRealtimeUpdates` used to be mounted per screen (POS, Orders,
12
+ * Kitchen, Display). That meant every mounted screen processed the same
13
+ * event — duplicated persistence, duplicated invalidations, duplicated
14
+ * fetches — and every screen that did NOT mount it (Dashboard, Settings)
15
+ * never saw another terminal's order at all. It is kept as a no-op so
16
+ * existing call sites keep compiling; new code must not use it.
31
17
  *
32
- * Call once per screen that needs live updates (POS, Orders, Kitchen, etc.).
18
+ * Invalidations stay coalesced: a reconnect/replay burst can deliver dozens
19
+ * of events at once, and an immediate invalidate-per-event forces repeated
20
+ * full re-renders of boards/grids — a major CPU spike on low-power POS
21
+ * hardware. One invalidation per ~400 ms window keeps the UI fresh without
22
+ * the render storm.
33
23
  */
34
- export function useOrderRealtimeUpdates(rootKey) {
35
- const queryClient = useQueryClient();
36
- useEffect(() => {
37
- let timer;
38
- const pendingOrderIds = new Set();
39
- const pending = { rootKey: false };
40
- const ownDeviceId = getAppInstanceId();
41
- const flush = () => {
42
- timer = undefined;
43
- if (pending.rootKey)
44
- queryClient.invalidateQueries({ queryKey: rootKey });
45
- for (const oid of pendingOrderIds) {
46
- queryClient.invalidateQueries({ queryKey: [...rootKey, "order", oid] });
47
- }
48
- pendingOrderIds.clear();
49
- pending.rootKey = false;
50
- };
51
- const unsub = orderEventsService.onOrderUpdates((event) => {
52
- pending.rootKey = true;
53
- if (event.order_id)
54
- pendingOrderIds.add(event.order_id);
55
- const payload = { ...event };
56
- // Delivery events carry no business_id — the socket already filtered by
57
- // business, so stamp the active one for read-model patching.
58
- if (!payload.business_id && !payload.businessId) {
59
- try {
60
- const bid = getActiveBusiness()?.id;
61
- if (bid)
62
- payload.business_id = bid;
63
- }
64
- catch { }
65
- }
66
- const domainEvent = {
67
- event_id: payload.event_id ?? generateEventId(),
68
- event_type: event.type,
69
- event_version: payload.event_version ?? "1.0",
70
- timestamp: payload.timestamp ?? new Date().toISOString(),
71
- device_id: payload.device_id ?? ownDeviceId,
72
- user_id: payload.user_id ?? "system",
73
- entity_id: event.order_id ?? "",
74
- payload,
75
- };
76
- // Persist to durable offline read model (entity_snapshots table).
77
- orderSnapshotsService.persistOrderEvent(domainEvent, "cloud").catch(() => { });
78
- // order.created from another terminal is summary-only (no full body):
79
- // invalidate alone can't surface it because SQLite has no row. Pull
80
- // once (debounced) when the order is unknown locally. Event-driven —
81
- // no polling.
82
- if (event.type === "order.created" && event.order_id) {
83
- const bid = payload.business_id;
84
- if (bid) {
85
- void (async () => {
86
- try {
87
- const db = await openSyncDatabase();
88
- const known = await getOrderById(db, bid, event.order_id);
89
- if (!known)
90
- scheduleUnknownOrderPull(bid);
91
- }
92
- catch { }
93
- })();
94
- }
95
- }
96
- // Publish through the Sync Engine so useSyncReconciliation + LAN relay
97
- // fire without waiting for the next poll.
98
- try {
99
- syncEngine.publish(domainEvent);
100
- }
101
- catch { }
102
- if (!timer)
103
- timer = setTimeout(flush, INVALIDATE_DEBOUNCE_MS);
104
- });
105
- return () => {
106
- if (timer)
107
- clearTimeout(timer);
108
- unsub();
109
- };
110
- }, [queryClient, rootKey]);
111
- }
24
+ import { useGlobalRealtimeBridge } from "../realtime/realtime-bridge";
112
25
  /**
113
- * Debounced pull for remotely-created orders (one timer per business).
114
- * Module-level so bursts across screens coalesce into a single pull.
26
+ * @deprecated Mount the global bridge once at the app root instead
27
+ * (`useRealtimeInvalidation` or `useSyncReconciliation`). Kept as a no-op
28
+ * so legacy call sites remain source-compatible.
115
29
  */
116
- const unknownOrderPullTimers = new Map();
117
- function scheduleUnknownOrderPull(businessId) {
118
- if (unknownOrderPullTimers.has(businessId))
119
- return;
120
- const t = setTimeout(() => {
121
- unknownOrderPullTimers.delete(businessId);
122
- void import("../sync/sync-jobs.service")
123
- .then((m) => m.pullRemoteOrders(businessId))
124
- .catch(() => { });
125
- }, UNKNOWN_ORDER_PULL_DEBOUNCE_MS);
126
- unknownOrderPullTimers.set(businessId, t);
127
- }
30
+ export function useOrderRealtimeUpdates(_rootKey) { }
128
31
  /**
129
- * Global realtime invalidation — currently handled entirely by
130
- * useOrderRealtimeUpdates + useSyncReconciliation. Kept as a no-op for
131
- * backward compatibility with any lingering call sites.
32
+ * Global realtime invalidation — mounts the single app-wide Phoenix
33
+ * subscription. Pair with `useSyncReconciliation` (which reacts to the
34
+ * published events) at the app root.
132
35
  */
133
- export function useRealtimeInvalidation() { }
36
+ export function useRealtimeInvalidation() {
37
+ useGlobalRealtimeBridge();
38
+ }
package/dist/index.d.ts CHANGED
@@ -44,6 +44,7 @@ export { createAppQueryClient, createQueryPersister } from "./providers/query-cl
44
44
  export { useSellerAnalyticsQuery } from "./hooks/analytics.hooks";
45
45
  export { useSellerDashboardQuery } from "./hooks/dashboard.hooks";
46
46
  export { useRealtimeInvalidation, useOrderRealtimeUpdates } from "./hooks/realtime-stubs";
47
+ export { useGlobalRealtimeBridge, subscribeGlobalRealtime } from "./realtime/realtime-bridge";
47
48
  export { useAcceptFleetInvitationMutation, useAcceptBusinessInvitationMutation, useUserInvitationsQuery, } from "./hooks/members.hooks";
48
49
  export { useJoinRequestsQuery } from "./hooks/fleet.hooks";
49
50
  export * from "./db/migrations";
@@ -143,6 +144,7 @@ export * from "./sync/transition-endpoints";
143
144
  export * from "./utils/business-hours";
144
145
  export * from "./hooks/business-hours.hooks";
145
146
  export * from "./domain/orders/service";
147
+ export * from "./domain/orders/order-flow";
146
148
  export * from "./domain/orders/snapshots";
147
149
  export { phoenixSocket } from "./realtime/phoenix-socket";
148
150
  export { eventsService } from "./realtime/events.service";
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ export { createAppQueryClient, createQueryPersister } from "./providers/query-cl
44
44
  export { useSellerAnalyticsQuery } from "./hooks/analytics.hooks";
45
45
  export { useSellerDashboardQuery } from "./hooks/dashboard.hooks";
46
46
  export { useRealtimeInvalidation, useOrderRealtimeUpdates } from "./hooks/realtime-stubs";
47
+ export { useGlobalRealtimeBridge, subscribeGlobalRealtime } from "./realtime/realtime-bridge";
47
48
  export { useAcceptFleetInvitationMutation, useAcceptBusinessInvitationMutation, useUserInvitationsQuery, } from "./hooks/members.hooks";
48
49
  export { useJoinRequestsQuery } from "./hooks/fleet.hooks";
49
50
  // ── DB ──
@@ -155,6 +156,7 @@ export * from "./hooks/business-hours.hooks";
155
156
  // components/OrderStatusCard was folded into order-transitions (compat re-export lives in apps)
156
157
  // ── Domain (offline-first primary DB) ──
157
158
  export * from "./domain/orders/service";
159
+ export * from "./domain/orders/order-flow";
158
160
  export * from "./domain/orders/snapshots";
159
161
  // ── Realtime (single source — PHX WebSocket + business:{id}) ──
160
162
  export { phoenixSocket } from "./realtime/phoenix-socket";
@@ -3,11 +3,20 @@ declare class EventsService {
3
3
  private refCount;
4
4
  private topic;
5
5
  private joined;
6
+ /**
7
+ * Last event sequence per business. The backend sequence is global, but the
8
+ * watermark is kept per business so switching business (or account) never
9
+ * inherits — and therefore never skips — the other scope's events.
10
+ */
11
+ private sequences;
6
12
  private lastSequence;
7
13
  private activeBusinessId;
14
+ private activeUserId;
8
15
  private listeners;
9
16
  private wildcardListeners;
10
17
  private frameHandlerUnsub;
18
+ private currentSequence;
19
+ private setCurrentSequence;
11
20
  connect(userId: string, businessId?: string | null): () => void;
12
21
  on(eventType: string, callback: EventCallback): () => void;
13
22
  onAny(callback: EventCallback): () => void;
@@ -5,12 +5,30 @@ class EventsService {
5
5
  this.refCount = 0;
6
6
  this.topic = null;
7
7
  this.joined = false;
8
+ /**
9
+ * Last event sequence per business. The backend sequence is global, but the
10
+ * watermark is kept per business so switching business (or account) never
11
+ * inherits — and therefore never skips — the other scope's events.
12
+ */
13
+ this.sequences = new Map();
8
14
  this.lastSequence = 0;
9
15
  this.activeBusinessId = null;
16
+ this.activeUserId = null;
10
17
  this.listeners = new Map();
11
18
  this.wildcardListeners = new Set();
12
19
  this.frameHandlerUnsub = null;
13
20
  }
21
+ currentSequence() {
22
+ if (this.activeBusinessId)
23
+ return this.sequences.get(this.activeBusinessId) ?? 0;
24
+ return this.lastSequence;
25
+ }
26
+ setCurrentSequence(sequence) {
27
+ if (this.activeBusinessId)
28
+ this.sequences.set(this.activeBusinessId, sequence);
29
+ else
30
+ this.lastSequence = sequence;
31
+ }
14
32
  connect(userId, businessId) {
15
33
  this.refCount++;
16
34
  const topic = `events:${userId}`;
@@ -18,18 +36,26 @@ class EventsService {
18
36
  this.activeBusinessId = businessId ?? null;
19
37
  if (this.refCount === 1 && !this.frameHandlerUnsub) {
20
38
  this.topic = topic;
39
+ // A different account has a different authorization scope: sequences
40
+ // from the previous account are not comparable, so start clean.
41
+ if (this.activeUserId !== userId) {
42
+ this.activeUserId = userId;
43
+ this.sequences.clear();
44
+ this.lastSequence = 0;
45
+ }
21
46
  this.frameHandlerUnsub = phoenixSocket.onFrame(topic, ([_joinRef, _ref, _topic, event, payload]) => {
22
47
  const data = payload;
23
48
  const payloadBid = data?.business_id;
24
49
  if (this.activeBusinessId && payloadBid && payloadBid !== this.activeBusinessId)
25
50
  return;
26
51
  const sequence = data?._sequence;
27
- if (typeof sequence === "number" && sequence <= this.lastSequence)
52
+ const last = this.currentSequence();
53
+ if (typeof sequence === "number" && sequence <= last)
28
54
  return;
29
- if (typeof sequence === "number" && sequence > this.lastSequence)
30
- this.lastSequence = sequence;
55
+ if (typeof sequence === "number")
56
+ this.setCurrentSequence(sequence);
31
57
  else if (typeof data?._sequence === "number")
32
- this.lastSequence = data._sequence;
58
+ this.setCurrentSequence(data._sequence);
33
59
  // Business-state frames are LOUD (info): when live updates stall,
34
60
  // these lines prove whether the backend pushed anything at all.
35
61
  if (event.startsWith("order.") ||
@@ -52,8 +78,9 @@ class EventsService {
52
78
  this.wildcardListeners.forEach((cb) => cb(data));
53
79
  });
54
80
  const joinPayload = {};
55
- if (this.lastSequence > 0)
56
- joinPayload.last_sequence = this.lastSequence;
81
+ const joinSequence = this.currentSequence();
82
+ if (joinSequence > 0)
83
+ joinPayload.last_sequence = joinSequence;
57
84
  if (this.activeBusinessId)
58
85
  joinPayload.business_id = this.activeBusinessId;
59
86
  phoenixSocket
@@ -111,6 +138,9 @@ class EventsService {
111
138
  }
112
139
  onAny(callback) { this.wildcardListeners.add(callback); return () => this.wildcardListeners.delete(callback); }
113
140
  isConnected() { return this.joined && phoenixSocket.isConnected(); }
114
- resetSequence() { this.lastSequence = 0; }
141
+ resetSequence() {
142
+ this.lastSequence = 0;
143
+ this.sequences.clear();
144
+ }
115
145
  }
116
146
  export const eventsService = new EventsService();
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Global realtime bridge — ONE Phoenix subscription for the whole app.
3
+ *
4
+ * Before this module existed, cloud events were only processed by screens
5
+ * that mounted `useOrderRealtimeUpdates` (POS, Orders, Kitchen, Display).
6
+ * Any other screen — the Dashboard, Settings, the business switcher — never
7
+ * saw another terminal's order, and every mounted screen duplicated the
8
+ * same handling (one persist + publish + invalidate per screen).
9
+ *
10
+ * The bridge is the single owner of the cloud → local event path:
11
+ *
12
+ * Phoenix frame
13
+ * → orderEventsService (typed event fan-out)
14
+ * → business filter (active business only)
15
+ * → syncEngine.publish(domainEvent)
16
+ * → useSyncReconciliation: journal + read-model patch +
17
+ * debounced query invalidation (+ LAN relay for mesh peers)
18
+ * → unknown `order.created` → fetch THAT order only
19
+ *
20
+ * Mounting it from several hooks is safe: the underlying subscription is
21
+ * created once and torn down when the last consumer unmounts.
22
+ */
23
+ /**
24
+ * Subscribe to the global realtime bridge.
25
+ * Safe to call from multiple hooks — the underlying socket listener is
26
+ * created once and disposed when the last caller releases it.
27
+ */
28
+ export declare function subscribeGlobalRealtime(): () => void;
29
+ /** React hook wrapper around `subscribeGlobalRealtime`. */
30
+ export declare function useGlobalRealtimeBridge(): void;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Global realtime bridge — ONE Phoenix subscription for the whole app.
3
+ *
4
+ * Before this module existed, cloud events were only processed by screens
5
+ * that mounted `useOrderRealtimeUpdates` (POS, Orders, Kitchen, Display).
6
+ * Any other screen — the Dashboard, Settings, the business switcher — never
7
+ * saw another terminal's order, and every mounted screen duplicated the
8
+ * same handling (one persist + publish + invalidate per screen).
9
+ *
10
+ * The bridge is the single owner of the cloud → local event path:
11
+ *
12
+ * Phoenix frame
13
+ * → orderEventsService (typed event fan-out)
14
+ * → business filter (active business only)
15
+ * → syncEngine.publish(domainEvent)
16
+ * → useSyncReconciliation: journal + read-model patch +
17
+ * debounced query invalidation (+ LAN relay for mesh peers)
18
+ * → unknown `order.created` → fetch THAT order only
19
+ *
20
+ * Mounting it from several hooks is safe: the underlying subscription is
21
+ * created once and torn down when the last consumer unmounts.
22
+ */
23
+ import { useEffect } from "react";
24
+ import { orderEventsService } from "./order-events.service";
25
+ import { syncEngine } from "../sync/engine";
26
+ import { getActiveBusiness } from "../adapters/business";
27
+ import { getAppInstanceId } from "../adapters/devices";
28
+ import { generateEventId } from "../utils/uuid";
29
+ import { openSyncDatabase } from "../db/connection";
30
+ import { getOrderById } from "../db/operations";
31
+ import { fetchRemoteOrderByNumber } from "../sync/sync-jobs.service";
32
+ import { syncLogger } from "../utils/sync-logger";
33
+ /** Coalesce bursts of `order.created` events into one fetch per order. */
34
+ const UNKNOWN_ORDER_FETCH_DEBOUNCE_MS = 400;
35
+ let consumerCount = 0;
36
+ let detach = null;
37
+ /**
38
+ * Subscribe to the global realtime bridge.
39
+ * Safe to call from multiple hooks — the underlying socket listener is
40
+ * created once and disposed when the last caller releases it.
41
+ */
42
+ export function subscribeGlobalRealtime() {
43
+ consumerCount += 1;
44
+ if (!detach)
45
+ detach = attachBridge();
46
+ let released = false;
47
+ return () => {
48
+ if (released)
49
+ return;
50
+ released = true;
51
+ consumerCount -= 1;
52
+ if (consumerCount <= 0) {
53
+ consumerCount = 0;
54
+ try {
55
+ detach?.();
56
+ }
57
+ catch { }
58
+ detach = null;
59
+ }
60
+ };
61
+ }
62
+ /** React hook wrapper around `subscribeGlobalRealtime`. */
63
+ export function useGlobalRealtimeBridge() {
64
+ useEffect(() => subscribeGlobalRealtime(), []);
65
+ }
66
+ function attachBridge() {
67
+ const pendingFetches = new Map();
68
+ const scheduleUnknownOrderFetch = (businessId, orderNumber, orderId) => {
69
+ if (!businessId || (!orderNumber && !orderId))
70
+ return;
71
+ const key = `${businessId}:${orderNumber ?? orderId}`;
72
+ if (pendingFetches.has(key))
73
+ return;
74
+ const timer = setTimeout(() => {
75
+ pendingFetches.delete(key);
76
+ void (async () => {
77
+ try {
78
+ // Only fetch when this device has never seen the order — the
79
+ // realtime payload is summary-only, so a known order needs no
80
+ // network call at all.
81
+ if (orderId) {
82
+ const db = await openSyncDatabase();
83
+ const known = await getOrderById(db, businessId, orderId);
84
+ if (known)
85
+ return;
86
+ }
87
+ if (!orderNumber)
88
+ return;
89
+ await fetchRemoteOrderByNumber(businessId, orderNumber);
90
+ }
91
+ catch { }
92
+ })();
93
+ }, UNKNOWN_ORDER_FETCH_DEBOUNCE_MS);
94
+ pendingFetches.set(key, timer);
95
+ };
96
+ const unsubscribe = orderEventsService.onOrderUpdates((event) => {
97
+ try {
98
+ const payload = { ...event };
99
+ const activeBusinessId = getActiveBusiness()?.id;
100
+ let businessId = payload.business_id ??
101
+ payload.businessId;
102
+ // Delivery events carry no business id — the socket already filtered by
103
+ // business, so stamp the active one for read-model patching.
104
+ if (!businessId && activeBusinessId) {
105
+ businessId = activeBusinessId;
106
+ payload.business_id = activeBusinessId;
107
+ }
108
+ if (!businessId)
109
+ return;
110
+ // Never let another business' event touch this device.
111
+ if (activeBusinessId && businessId !== activeBusinessId)
112
+ return;
113
+ const ownDeviceId = getAppInstanceId();
114
+ const domainEvent = {
115
+ event_id: payload.event_id ?? generateEventId(),
116
+ event_type: event.type,
117
+ event_version: payload.event_version ?? "1.0",
118
+ timestamp: payload.timestamp ?? new Date().toISOString(),
119
+ device_id: payload.device_id ?? ownDeviceId,
120
+ user_id: payload.user_id ?? "system",
121
+ entity_id: event.order_id ?? "",
122
+ payload,
123
+ origin: "cloud",
124
+ };
125
+ // Publish through the Sync Engine: the reconciliation bridge journals
126
+ // the event, patches the offline read model, invalidates the caches and
127
+ // relays the event to LAN peers.
128
+ syncEngine.publish(domainEvent);
129
+ // An order created on another terminal arrives as a summary only, so
130
+ // this device has no row for it — fetch that single order (not the
131
+ // whole list) so the board fills in.
132
+ if (event.type === "order.created") {
133
+ scheduleUnknownOrderFetch(businessId, payload.order_number ??
134
+ payload.local_order_number, event.order_id);
135
+ }
136
+ }
137
+ catch (error) {
138
+ syncLogger.warn("RealtimeBridge", "Failed to handle cloud event", {
139
+ error: String(error),
140
+ });
141
+ }
142
+ });
143
+ return () => {
144
+ for (const timer of pendingFetches.values())
145
+ clearTimeout(timer);
146
+ pendingFetches.clear();
147
+ unsubscribe();
148
+ };
149
+ }
@@ -45,7 +45,7 @@ export declare function emitReferenceChanged(businessId: string, keys: Reference
45
45
  * by a single-flight flag. Errors are logged, never thrown, so a failed
46
46
  * reference sync can never crash the LAN event loop.
47
47
  */
48
- export declare function applyReferenceChangeEvent(event: DomainEvent, origin?: "local" | "lan"): Promise<void>;
48
+ export declare function applyReferenceChangeEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
49
49
  /**
50
50
  * Best-effort reference data sync when connectivity recovers. Covers devices
51
51
  * that missed the change event (offline, not on the LAN) — the next time they
@@ -9,8 +9,17 @@
9
9
  */
10
10
  import type { ReferenceDataKey, ReferenceSyncOptions, ReferenceSyncResult, ReferenceDataRecord } from "./types";
11
11
  declare class ReferenceSyncService {
12
- private isSyncing;
12
+ /** Single-flight per business (a global flag made a business switch skip its sync). */
13
+ private inflight;
14
+ /**
15
+ * Keys requested while a pass was already running for that business.
16
+ * They used to be dropped, which meant a menu edit could land while
17
+ * another reference sync was in flight and never be fetched.
18
+ */
19
+ private queued;
13
20
  sync(options: ReferenceSyncOptions): Promise<ReferenceSyncResult>;
21
+ private rememberRequest;
22
+ private runSync;
14
23
  private syncKey;
15
24
  private fetchFallback;
16
25
  /**
@@ -38,12 +38,20 @@ const DEFAULT_SYNC_KEYS = [
38
38
  ];
39
39
  class ReferenceSyncService {
40
40
  constructor() {
41
- this.isSyncing = false;
41
+ /** Single-flight per business (a global flag made a business switch skip its sync). */
42
+ this.inflight = new Map();
43
+ /**
44
+ * Keys requested while a pass was already running for that business.
45
+ * They used to be dropped, which meant a menu edit could land while
46
+ * another reference sync was in flight and never be fetched.
47
+ */
48
+ this.queued = new Map();
42
49
  }
43
50
  async sync(options) {
44
- if (!canBusinessSync(options.businessId)) {
51
+ const businessId = options.businessId;
52
+ if (!canBusinessSync(businessId)) {
45
53
  syncLogger.debug("ReferenceSync", "Skipping sync — no business/auth", {
46
- businessId: options.businessId,
54
+ businessId,
47
55
  });
48
56
  return {
49
57
  synced: [],
@@ -52,18 +60,71 @@ class ReferenceSyncService {
52
60
  errors: {},
53
61
  };
54
62
  }
55
- if (this.isSyncing) {
56
- return {
57
- synced: [],
58
- skipped: [],
59
- failed: [],
60
- errors: {},
61
- };
63
+ const running = this.inflight.get(businessId);
64
+ if (running) {
65
+ // Remember the request so it is not lost, then wait for the pass that
66
+ // is already running.
67
+ this.rememberRequest(businessId, options.keys);
68
+ try {
69
+ return await running;
70
+ }
71
+ catch {
72
+ return { synced: [], skipped: [], failed: [], errors: {} };
73
+ }
74
+ }
75
+ const pass = (async () => {
76
+ try {
77
+ return await this.runSync(options);
78
+ }
79
+ finally {
80
+ this.inflight.delete(businessId);
81
+ }
82
+ })();
83
+ this.inflight.set(businessId, pass);
84
+ let result;
85
+ try {
86
+ result = await pass;
87
+ }
88
+ catch (error) {
89
+ syncLogger.warn("ReferenceSync", "Sync pass failed", { businessId, error: String(error) });
90
+ result = { synced: [], skipped: [], failed: [], errors: {} };
91
+ }
92
+ // Requests that arrived during the pass are served by one follow-up
93
+ // pass — never silently dropped.
94
+ const queued = this.queued.get(businessId);
95
+ if (queued && queued.size > 0) {
96
+ this.queued.delete(businessId);
97
+ const wantsAll = queued.has("*");
98
+ const keys = Array.from(queued).filter((k) => k !== "*");
99
+ const followUp = wantsAll
100
+ ? { businessId }
101
+ : { businessId, keys, incremental: true };
102
+ syncLogger.info("ReferenceSync", "Running follow-up pass for queued keys", {
103
+ businessId,
104
+ all: wantsAll,
105
+ keys,
106
+ });
107
+ await this.sync(followUp).catch(() => undefined);
108
+ }
109
+ return result;
110
+ }
111
+ rememberRequest(businessId, keys) {
112
+ const set = this.queued.get(businessId) ?? new Set();
113
+ if (!keys || keys.length === 0) {
114
+ set.add("*");
62
115
  }
116
+ else {
117
+ for (const key of keys)
118
+ set.add(key);
119
+ }
120
+ this.queued.set(businessId, set);
121
+ }
122
+ async runSync(options) {
123
+ const businessId = options.businessId;
63
124
  const { isOnline, isCloudReachable } = getConnectivityState();
64
125
  if (!isOnline || isCloudReachable === "unreachable") {
65
126
  syncLogger.debug("ReferenceSync", "Skipping sync — cloud not reachable", {
66
- businessId: options.businessId,
127
+ businessId,
67
128
  isOnline,
68
129
  isCloudReachable,
69
130
  });
@@ -74,9 +135,8 @@ class ReferenceSyncService {
74
135
  errors: {},
75
136
  };
76
137
  }
77
- this.isSyncing = true;
78
138
  syncLogger.info("ReferenceSync", "Starting reference data sync", {
79
- businessId: options.businessId,
139
+ businessId,
80
140
  keys: options.keys,
81
141
  });
82
142
  const result = {
@@ -145,13 +205,13 @@ class ReferenceSyncService {
145
205
  }
146
206
  }
147
207
  finally {
148
- this.isSyncing = false;
208
+ syncLogger.info("ReferenceSync", "Reference data sync complete", {
209
+ businessId,
210
+ synced: result.synced,
211
+ skipped: result.skipped,
212
+ failed: result.failed,
213
+ });
149
214
  }
150
- syncLogger.info("ReferenceSync", "Reference data sync complete", {
151
- synced: result.synced,
152
- skipped: result.skipped,
153
- failed: result.failed,
154
- });
155
215
  return result;
156
216
  }
157
217
  async syncKey(db, key, options, versions) {