@teincfood/core 0.9.22 → 0.9.24

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.
@@ -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) {
@@ -50,11 +50,12 @@ declare class CommandQueueService {
50
50
  */
51
51
  private failDependentTransitions;
52
52
  /**
53
- * Already-applied absorb: pull server truth, and if the order is already
54
- * at this command's target status, delete the command as success. The
55
- * absorb ONLY fires when the pull actually touched the row (hlc change) —
56
- * otherwise a stale local optimistic write could mask a genuine race and
57
- * delete work the server never applied.
53
+ * Already-applied absorb: the server already moved the order to this
54
+ * command's target status, so the queued command is redundant and can be
55
+ * dropped.
56
+ *
57
+ * The comparison uses the SERVER status reported by the pull — the local
58
+ * row may show this device's own queued intent and would absorb itself.
58
59
  */
59
60
  private absorbIfApplied;
60
61
  /**
@@ -586,27 +586,23 @@ class CommandQueueService {
586
586
  catch { }
587
587
  }
588
588
  /**
589
- * Already-applied absorb: pull server truth, and if the order is already
590
- * at this command's target status, delete the command as success. The
591
- * absorb ONLY fires when the pull actually touched the row (hlc change) —
592
- * otherwise a stale local optimistic write could mask a genuine race and
593
- * delete work the server never applied.
589
+ * Already-applied absorb: the server already moved the order to this
590
+ * command's target status, so the queued command is redundant and can be
591
+ * dropped.
592
+ *
593
+ * The comparison uses the SERVER status reported by the pull — the local
594
+ * row may show this device's own queued intent and would absorb itself.
594
595
  */
595
596
  async absorbIfApplied(command) {
596
597
  const target = QUEUED_STATUS_BY_COMMAND[command.command_type];
597
598
  if (!target || !command.entity_id)
598
599
  return false;
599
600
  try {
600
- const db = getSyncDatabase();
601
- const before = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, command.business_id, command.entity_id);
602
601
  const { pullRemoteOrders } = await import("./sync-jobs.service");
603
- await pullRemoteOrders(command.business_id);
604
- const afterHlc = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, command.business_id, command.entity_id);
605
- if (!afterHlc || afterHlc.hlc === before?.hlc)
606
- return false;
607
- const { getOrderById } = await import("../db/operations");
608
- const order = await getOrderById(db, command.business_id, command.entity_id);
609
- if (order && order.status === target) {
602
+ const serverStatuses = await pullRemoteOrders(command.business_id);
603
+ const serverStatus = serverStatuses.get(command.entity_id);
604
+ if (serverStatus === target) {
605
+ const db = getSyncDatabase();
610
606
  await deleteCommand(db, command.command_id);
611
607
  retryNotBefore.delete(command.command_id);
612
608
  syncLogger.info("CommandQueueService", "Transition already applied on server, absorbing", {
@@ -17,7 +17,7 @@ import { syncLogger } from "../utils/sync-logger";
17
17
  import { referenceSyncService } from "../reference/service";
18
18
  import { pullRemoteOrders, drainPending } from "./sync-jobs.service";
19
19
  import { canBusinessSync } from "./sync-guard";
20
- let inFlight = null;
20
+ const inFlight = new Map();
21
21
  export async function performFullSync(businessId) {
22
22
  const bid = businessId ?? getActiveBusiness()?.id;
23
23
  if (!bid || !canBusinessSync(bid))
@@ -25,9 +25,13 @@ export async function performFullSync(businessId) {
25
25
  const s = getConnectivityState();
26
26
  if (!s.isOnline || s.isCloudReachable === "unreachable")
27
27
  return;
28
- if (inFlight) {
28
+ // Single-flight PER BUSINESS. A global in-flight promise meant that
29
+ // switching business while business A was syncing made business B join
30
+ // A's promise and return without syncing at all.
31
+ const running = inFlight.get(bid);
32
+ if (running) {
29
33
  try {
30
- await inFlight;
34
+ await running;
31
35
  }
32
36
  catch { }
33
37
  return;
@@ -70,11 +74,11 @@ export async function performFullSync(businessId) {
70
74
  syncLogger.warn("FullSync", "Failed", { err: String(e) });
71
75
  }
72
76
  })();
73
- inFlight = p;
77
+ inFlight.set(bid, p);
74
78
  try {
75
79
  await p;
76
80
  }
77
81
  finally {
78
- inFlight = null;
82
+ inFlight.delete(bid);
79
83
  }
80
84
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Server → local order merge.
3
+ *
4
+ * The server is authoritative for an order — EXCEPT while this device still
5
+ * has unsynced work for it. A pull can land before the queued command
6
+ * reaches the backend (reconnect storms, slow networks, a second terminal
7
+ * pushing at the same time); without the overlay the local terminal would
8
+ * visibly revert an accepted order back to `pending`, or drop a confirmed
9
+ * cash payment, until the queue drained.
10
+ *
11
+ * Rules:
12
+ * 1. The server row always overwrites the local row (status, totals,
13
+ * delivery linkage, items).
14
+ * 2. If this device has a pending command for the order, the local intent
15
+ * (target status / payment) is re-applied on top of the server row.
16
+ * 3. The local HLC comes from the server's `updated_at`, so device clock
17
+ * skew can never make fresh server state look stale.
18
+ */
19
+ import type { SQLiteDatabase } from "../adapters/sqlite";
20
+ import type { SellerOrder } from "../types/api/seller.api";
21
+ /**
22
+ * Merge one server order into the local read model.
23
+ * Returns true when the row was written.
24
+ */
25
+ export declare function mergeOrderFromServer(db: SQLiteDatabase, businessId: string, serverOrder: SellerOrder): Promise<boolean>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Server → local order merge.
3
+ *
4
+ * The server is authoritative for an order — EXCEPT while this device still
5
+ * has unsynced work for it. A pull can land before the queued command
6
+ * reaches the backend (reconnect storms, slow networks, a second terminal
7
+ * pushing at the same time); without the overlay the local terminal would
8
+ * visibly revert an accepted order back to `pending`, or drop a confirmed
9
+ * cash payment, until the queue drained.
10
+ *
11
+ * Rules:
12
+ * 1. The server row always overwrites the local row (status, totals,
13
+ * delivery linkage, items).
14
+ * 2. If this device has a pending command for the order, the local intent
15
+ * (target status / payment) is re-applied on top of the server row.
16
+ * 3. The local HLC comes from the server's `updated_at`, so device clock
17
+ * skew can never make fresh server state look stale.
18
+ */
19
+ import { getOrderById, getPendingOrderIntent, updateOrderPayment, updateOrderStatus, upsertServerOrder, } from "../db/operations";
20
+ import { syncLogger } from "../utils/sync-logger";
21
+ /**
22
+ * Merge one server order into the local read model.
23
+ * Returns true when the row was written.
24
+ */
25
+ export async function mergeOrderFromServer(db, businessId, serverOrder) {
26
+ if (!businessId || !serverOrder?.id)
27
+ return false;
28
+ try {
29
+ const intent = await getPendingOrderIntent(db, businessId, serverOrder.id);
30
+ await upsertServerOrder(db, { ...serverOrder, business_id: businessId });
31
+ if (intent.hasPending) {
32
+ if (intent.status) {
33
+ await updateOrderStatus(db, businessId, serverOrder.id, intent.status);
34
+ }
35
+ if (intent.isPaid) {
36
+ const local = await getOrderById(db, businessId, serverOrder.id);
37
+ const paidAt = local?.payment?.paid_at ??
38
+ new Date().toISOString();
39
+ await updateOrderPayment(db, businessId, serverOrder.id, {
40
+ is_paid: true,
41
+ paid_at: paidAt,
42
+ });
43
+ }
44
+ syncLogger.debug("OrderMerge", "Applied local intent over server row", {
45
+ businessId,
46
+ orderId: serverOrder.id,
47
+ status: intent.status,
48
+ isPaid: intent.isPaid,
49
+ });
50
+ }
51
+ return true;
52
+ }
53
+ catch (error) {
54
+ syncLogger.warn("OrderMerge", "Failed to merge server order", {
55
+ businessId,
56
+ orderId: serverOrder?.id,
57
+ error: String(error),
58
+ });
59
+ return false;
60
+ }
61
+ }
@@ -59,5 +59,5 @@ export declare function hydratePendingOrders(): Promise<void>;
59
59
  * pending-orders store (only queued creations are); this only fires the
60
60
  * invalidator. Idempotent — duplicate deliveries are harmless.
61
61
  */
62
- export declare function applyOrderStateChangeEvent(event: DomainEvent, origin?: "local" | "lan"): Promise<void>;
63
- export declare function applyPendingOrderEvent(event: DomainEvent, origin?: "local" | "lan"): Promise<void>;
62
+ export declare function applyOrderStateChangeEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
63
+ export declare function applyPendingOrderEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
@@ -4,7 +4,28 @@
4
4
  * Core-only, single in_flight per business, coalesced via isOnline gate.
5
5
  */
6
6
  export declare function drainOne(businessId: string, entityId: string): Promise<void>;
7
- export declare function pullRemoteOrders(businessId: string): Promise<void>;
7
+ /**
8
+ * Pull the server's recent orders into the local read model.
9
+ *
10
+ * Returns the SERVER status per order id. The local row can show a
11
+ * different status (a queued transition keeps the device's intent on top of
12
+ * the server copy), so a caller that needs to know what the server actually
13
+ * holds must read it from this map — never from the merged local row.
14
+ */
15
+ export declare function pullRemoteOrders(businessId: string): Promise<Map<string, string>>;
16
+ /**
17
+ * Fetch exactly ONE order from the server and merge it into the local
18
+ * read model.
19
+ *
20
+ * Used when a realtime `order.created` event arrives for an order this
21
+ * device has never seen: the event carries only a summary, so the order has
22
+ * to be fetched — but fetching the whole list (100 orders, several pages,
23
+ * heavy serialization) for one new order is pure waste on the backend.
24
+ *
25
+ * Uses the existing business-scoped list endpoint with an `order_number`
26
+ * filter, so no backend change is required.
27
+ */
28
+ export declare function fetchRemoteOrderByNumber(businessId: string, orderNumber: string): Promise<boolean>;
8
29
  export declare function drainPending(businessId: string): Promise<void>;
9
30
  export declare function setupSyncJobsDrain(): void;
10
31
  export declare function getSyncJobsPendingCount(businessId: string): Promise<number>;