@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.
- package/dist/db/migrations.js +25 -0
- package/dist/db/operations.d.ts +38 -0
- package/dist/db/operations.js +145 -12
- package/dist/domain/orders/order-flow.d.ts +41 -0
- package/dist/domain/orders/order-flow.js +66 -0
- package/dist/domain/orders/service.js +32 -15
- package/dist/hooks/realtime-stubs.d.ts +26 -19
- package/dist/hooks/realtime-stubs.js +29 -124
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/realtime/events.service.d.ts +9 -0
- package/dist/realtime/events.service.js +37 -7
- package/dist/realtime/realtime-bridge.d.ts +30 -0
- package/dist/realtime/realtime-bridge.js +149 -0
- package/dist/reference/events.d.ts +1 -1
- package/dist/reference/service.d.ts +10 -1
- package/dist/reference/service.js +79 -19
- package/dist/sync/command-queue.service.d.ts +6 -5
- package/dist/sync/command-queue.service.js +10 -14
- package/dist/sync/full-sync.js +9 -5
- package/dist/sync/order-merge.d.ts +25 -0
- package/dist/sync/order-merge.js +66 -0
- package/dist/sync/order-snapshots.service.js +17 -1
- package/dist/sync/pending-orders.service.d.ts +2 -2
- package/dist/sync/sync-jobs.service.d.ts +22 -1
- package/dist/sync/sync-jobs.service.js +81 -22
- package/dist/sync/types.d.ts +7 -0
- package/dist/sync/use-sync-reconciliation.js +12 -3
- package/dist/test-helpers/memory-driver.js +42 -14
- package/dist/types/api/business.api.d.ts +7 -0
- package/package.json +1 -1
|
@@ -1,133 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Realtime invalidation hooks
|
|
3
|
-
* Sync Engine so useSyncReconciliation fires and React Query caches refresh.
|
|
2
|
+
* Realtime invalidation hooks.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
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 —
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
52
|
+
const last = this.currentSequence();
|
|
53
|
+
if (typeof sequence === "number" && sequence <= last)
|
|
28
54
|
return;
|
|
29
|
-
if (typeof sequence === "number"
|
|
30
|
-
this.
|
|
55
|
+
if (typeof sequence === "number")
|
|
56
|
+
this.setCurrentSequence(sequence);
|
|
31
57
|
else if (typeof data?._sequence === "number")
|
|
32
|
-
this.
|
|
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
|
-
|
|
56
|
-
|
|
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() {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
const businessId = options.businessId;
|
|
52
|
+
if (!canBusinessSync(businessId)) {
|
|
45
53
|
syncLogger.debug("ReferenceSync", "Skipping sync — no business/auth", {
|
|
46
|
-
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|