@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.
@@ -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,66 @@
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
+ // Capture the local payment BEFORE the server copy overwrites the row —
31
+ // a queued cash sale carries the paid timestamp only on the device.
32
+ const beforeMerge = intent.hasPending
33
+ ? await getOrderById(db, businessId, serverOrder.id)
34
+ : null;
35
+ await upsertServerOrder(db, { ...serverOrder, business_id: businessId });
36
+ if (intent.hasPending) {
37
+ if (intent.status) {
38
+ await updateOrderStatus(db, businessId, serverOrder.id, intent.status);
39
+ }
40
+ if (intent.isPaid) {
41
+ const paidAt = beforeMerge?.payment?.paid_at ??
42
+ beforeMerge?.paid_at ??
43
+ new Date().toISOString();
44
+ await updateOrderPayment(db, businessId, serverOrder.id, {
45
+ is_paid: true,
46
+ paid_at: paidAt,
47
+ });
48
+ }
49
+ syncLogger.debug("OrderMerge", "Applied local intent over server row", {
50
+ businessId,
51
+ orderId: serverOrder.id,
52
+ status: intent.status,
53
+ isPaid: intent.isPaid,
54
+ });
55
+ }
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ syncLogger.warn("OrderMerge", "Failed to merge server order", {
60
+ businessId,
61
+ orderId: serverOrder?.id,
62
+ error: String(error),
63
+ });
64
+ return false;
65
+ }
66
+ }
@@ -14,7 +14,7 @@
14
14
  * exact list last seen while online is available offline.
15
15
  */
16
16
  import { openSyncDatabase } from "../db/connection";
17
- import { insertEvent, upsertEntitySnapshot, getEntitySnapshot, getEntitySnapshotsByType, pruneEntitySnapshots, } from "../db/operations";
17
+ import { insertEvent, upsertEntitySnapshot, getEntitySnapshot, getEntitySnapshotsByType, pruneEntitySnapshots, updateOrderPayment, } from "../db/operations";
18
18
  import { syncLogger } from "../utils/sync-logger";
19
19
  const RETAIN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
20
20
  const ORDER_EVENT_TYPES = new Set([
@@ -119,6 +119,22 @@ class OrderSnapshotsService {
119
119
  const db = await openSyncDatabase();
120
120
  await insertEvent(db, { event, origin });
121
121
  const businessId = payload?.business_id ?? payload?.businessId ?? null;
122
+ // Dual-write the canonical orders row: POS/Orders screens read payment
123
+ // from there, not from the snapshot. Without this a mobile-money sale
124
+ // stayed UNPAID (and kept offering "Collect cash") after its payment
125
+ // completed, because only the snapshot list was patched.
126
+ if (businessId) {
127
+ await updateOrderPayment(db, businessId, orderId, { is_paid: true, paid_at: paidAt });
128
+ }
129
+ else {
130
+ try {
131
+ const row = await db.getFirstAsync(`SELECT business_id FROM orders WHERE id = ? OR order_number = ? LIMIT 1;`, orderId, payload?.order_number ?? orderId);
132
+ if (row?.business_id) {
133
+ await updateOrderPayment(db, row.business_id, orderId, { is_paid: true, paid_at: paidAt });
134
+ }
135
+ }
136
+ catch { }
137
+ }
122
138
  const lists = businessId
123
139
  ? await getEntitySnapshotsByType(db, "seller_orders", businessId)
124
140
  : await getEntitySnapshotsByType(db, "seller_orders");
@@ -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>;
@@ -4,7 +4,8 @@
4
4
  * Core-only, single in_flight per business, coalesced via isOnline gate.
5
5
  */
6
6
  import { openSyncDatabase } from "../db/connection";
7
- import { insertOrder, getOrderById, markOrderSyncFailed } from "../db/operations";
7
+ import { markOrderSyncFailed } from "../db/operations";
8
+ import { mergeOrderFromServer } from "./order-merge";
8
9
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
9
10
  import { getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
10
11
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
@@ -267,22 +268,19 @@ async function tryRest(businessId, payload, commandType) {
267
268
  }
268
269
  }
269
270
  /**
270
- * Already-applied absorb for sync_jobs rows: pull server truth, and if the
271
- * order is already at this command's target status, complete the job. Only
272
- * fires when the pull actually touched the row (hlc change) so a stale
273
- * local optimistic write can never delete work the server never applied.
271
+ * Already-applied absorb for sync_jobs rows: the server already moved the
272
+ * order to this command's target status, so the queued job is redundant.
273
+ *
274
+ * The comparison uses the SERVER status reported by the pull — never the
275
+ * local row, which may show the device's own queued intent.
274
276
  */
275
277
  async function absorbSyncJobIfApplied(db, businessId, entityId, commandType, jobId) {
276
278
  const target = getQueuedStatus(commandType);
277
279
  if (!target || !entityId)
278
280
  return false;
279
- const before = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, businessId, entityId);
280
- await pullRemoteOrders(businessId);
281
- const afterHlc = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, businessId, entityId);
282
- if (!afterHlc || afterHlc.hlc === before?.hlc)
283
- return false;
284
- const order = await getOrderById(db, businessId, entityId);
285
- if (order && order.status === target) {
281
+ const serverStatuses = await pullRemoteOrders(businessId);
282
+ const serverStatus = serverStatuses.get(entityId);
283
+ if (serverStatus === target) {
286
284
  await db.runAsync(`UPDATE sync_jobs SET status='completed', updated_at=? WHERE id=?;`, new Date().toISOString(), jobId);
287
285
  syncLogger.info("SyncJobs", "Transition already applied on server, absorbing job", {
288
286
  businessId,
@@ -379,7 +377,10 @@ export async function drainOne(businessId, entityId) {
379
377
  inflight.set(key, new Promise((r) => { resolveInflight = r; }));
380
378
  try {
381
379
  const db = await openSyncDatabase();
382
- const row = await db.getFirstAsync(`SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
380
+ // FIFO with a stable tiebreaker: two commands created in the same
381
+ // millisecond (fast taps) must still drain in insertion order, otherwise
382
+ // a transition can overtake the command it depends on.
383
+ const row = await db.getFirstAsync(`SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 1;`, businessId, entityId);
383
384
  syncLogger.debug("SyncJobs", "drainOne fetched row", { businessId, entityId, found: !!row, command_type: row?.command_type });
384
385
  if (!row) {
385
386
  syncLogger.debug("SyncJobs", "drainOne no pending row", { businessId, entityId });
@@ -423,7 +424,7 @@ export async function drainOne(businessId, entityId) {
423
424
  syncLogger.info("SyncJobs", "Drained via PHX", { businessId, entityId });
424
425
  clearRetry(businessId);
425
426
  try {
426
- const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
427
+ const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 1;`, businessId, entityId);
427
428
  if (next)
428
429
  setTimeout(() => void drainOne(businessId, entityId), 50);
429
430
  }
@@ -444,7 +445,7 @@ export async function drainOne(businessId, entityId) {
444
445
  clearRetry(businessId);
445
446
  // Immediately drain next pending for same entity (FIFO chain: create -> prepare -> ready -> pickup should sync in order without waiting for timer)
446
447
  try {
447
- const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
448
+ const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 1;`, businessId, entityId);
448
449
  if (next) {
449
450
  syncLogger.debug("SyncJobs", "chaining next job for same entity", { businessId, entityId });
450
451
  // fire next without inflight block — we already cleared inflight for this key, so recurse
@@ -632,27 +633,36 @@ export async function drainOne(businessId, entityId) {
632
633
  syncLogger.debug("SyncJobs", "drainOne exit", { businessId, entityId });
633
634
  }
634
635
  }
636
+ /**
637
+ * Pull the server's recent orders into the local read model.
638
+ *
639
+ * Returns the SERVER status per order id. The local row can show a
640
+ * different status (a queued transition keeps the device's intent on top of
641
+ * the server copy), so a caller that needs to know what the server actually
642
+ * holds must read it from this map — never from the merged local row.
643
+ */
635
644
  export async function pullRemoteOrders(businessId) {
645
+ const serverStatuses = new Map();
636
646
  if (!businessId || !canBusinessSync(businessId)) {
637
647
  syncLogger.debug("SyncJobs", "pullRemoteOrders early-return — no business/auth", { businessId });
638
- return;
648
+ return serverStatuses;
639
649
  }
640
650
  const onlineForRest = isOnlineForRest();
641
651
  syncLogger.debug("SyncJobs", "pullRemoteOrders entry", { businessId, onlineForRest, hasToken: isAuthenticated() });
642
652
  if (!onlineForRest) {
643
653
  syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
644
- return;
654
+ return serverStatuses;
645
655
  }
646
656
  // Respect a global 429 cooldown for background pulls.
647
657
  if (isRateLimited()) {
648
658
  syncLogger.debug("SyncJobs", "pullRemoteOrders skipped — rate limited", { businessId });
649
- return;
659
+ return serverStatuses;
650
660
  }
651
661
  try {
652
662
  const http = getHttpClient();
653
663
  if (!http) {
654
664
  syncLogger.debug("SyncJobs", "pullRemoteOrders no http client", { businessId });
655
- return;
665
+ return serverStatuses;
656
666
  }
657
667
  // Gradual recent-first pull for single business — page 1 immediate, then background older pages (max 5 pages = 100 recent)
658
668
  let page = 1;
@@ -671,10 +681,14 @@ export async function pullRemoteOrders(businessId) {
671
681
  const db = await openSyncDatabase();
672
682
  for (const o of data) {
673
683
  try {
674
- await insertOrder(db, o);
684
+ // Server truth — but a pending local command keeps its intent
685
+ // (see order-merge). Never let a pull revert queued work.
686
+ await mergeOrderFromServer(db, businessId, o);
687
+ if (o?.id && o?.status)
688
+ serverStatuses.set(o.id, o.status);
675
689
  }
676
690
  catch (e) {
677
- syncLogger.debug("SyncJobs", "pullRemoteOrders insertOrder fail", { err: String(e) });
691
+ syncLogger.debug("SyncJobs", "pullRemoteOrders merge fail", { err: String(e) });
678
692
  }
679
693
  }
680
694
  // persist page 1 to snapshot for offline fallback
@@ -706,6 +720,51 @@ export async function pullRemoteOrders(businessId) {
706
720
  catch (e) {
707
721
  syncLogger.debug("SyncJobs", "pullRemoteDeliveries error", { err: String(e) });
708
722
  }
723
+ return serverStatuses;
724
+ }
725
+ /**
726
+ * Fetch exactly ONE order from the server and merge it into the local
727
+ * read model.
728
+ *
729
+ * Used when a realtime `order.created` event arrives for an order this
730
+ * device has never seen: the event carries only a summary, so the order has
731
+ * to be fetched — but fetching the whole list (100 orders, several pages,
732
+ * heavy serialization) for one new order is pure waste on the backend.
733
+ *
734
+ * Uses the existing business-scoped list endpoint with an `order_number`
735
+ * filter, so no backend change is required.
736
+ */
737
+ export async function fetchRemoteOrderByNumber(businessId, orderNumber) {
738
+ if (!businessId || !orderNumber)
739
+ return false;
740
+ if (!canBusinessSync(businessId))
741
+ return false;
742
+ if (!isOnlineForRest())
743
+ return false;
744
+ if (isRateLimited())
745
+ return false;
746
+ try {
747
+ const http = getHttpClient();
748
+ if (!http)
749
+ return false;
750
+ await paceSync();
751
+ const res = await http.get(`/businesses/${businessId}/orders`, { params: { order_number: orderNumber, page_size: 1 } });
752
+ const payload = res.data;
753
+ const rows = payload?.data ?? [];
754
+ const first = rows[0];
755
+ if (!first?.id) {
756
+ syncLogger.debug("SyncJobs", "fetchRemoteOrderByNumber — no row returned", { businessId, orderNumber });
757
+ return false;
758
+ }
759
+ const db = await openSyncDatabase();
760
+ await mergeOrderFromServer(db, businessId, first);
761
+ syncLogger.info("SyncJobs", "Fetched single remote order", { businessId, orderNumber, id: first.id });
762
+ return true;
763
+ }
764
+ catch (e) {
765
+ syncLogger.debug("SyncJobs", "fetchRemoteOrderByNumber failed", { businessId, orderNumber, err: String(e) });
766
+ return false;
767
+ }
709
768
  }
710
769
  export async function drainPending(businessId) {
711
770
  if (!businessId || !canBusinessSync(businessId)) {
@@ -720,7 +779,7 @@ export async function drainPending(businessId) {
720
779
  return;
721
780
  }
722
781
  const db = await openSyncDatabase();
723
- const rows = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' ORDER BY created_at LIMIT 20;`, businessId);
782
+ const rows = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 20;`, businessId);
724
783
  syncLogger.debug("SyncJobs", "drainPending found rows", { businessId, count: rows.length, rows });
725
784
  for (const r of rows) {
726
785
  await drainOne(businessId, r.entity_id);
@@ -27,6 +27,13 @@ export interface EventMetadata {
27
27
  }
28
28
  export interface DomainEvent<T = unknown> extends EventMetadata {
29
29
  payload: T;
30
+ /**
31
+ * Where the event came from. Optional for backwards compatibility, but the
32
+ * journal stores it: "local" (this device), "cloud" (Phoenix push) or
33
+ * "lan" (mesh peer). A cloud event must not be re-hydrated as a pending
34
+ * local command after a restart.
35
+ */
36
+ origin?: "local" | "lan" | "cloud";
30
37
  }
31
38
  export interface CommandMetadata {
32
39
  /** Globally unique command identifier (UUID v4). Used for idempotency. */
@@ -11,6 +11,7 @@ import { useEffect } from "react";
11
11
  import { useQueryClient } from "@tanstack/react-query";
12
12
  import { syncEngine } from "./engine";
13
13
  import { getActiveBusiness } from "../adapters/business";
14
+ import { useGlobalRealtimeBridge } from "../realtime/realtime-bridge";
14
15
  import { applyPendingOrderEvent, applyOrderStateChangeEvent, registerPendingOrdersInvalidator, } from "./pending-orders.service";
15
16
  import { localNodeService } from "../adapters/local-node";
16
17
  import { getSyncDatabase } from "../db/connection";
@@ -29,6 +30,10 @@ function isLocalOrderEvent(event) {
29
30
  const RECONCILE_DEBOUNCE_MS = 400;
30
31
  export function useSyncReconciliation() {
31
32
  const queryClient = useQueryClient();
33
+ // Single app-wide Phoenix subscription. Mounting it here means one root
34
+ // `useSyncReconciliation` is enough for the whole app — screens no longer
35
+ // need their own realtime subscription.
36
+ useGlobalRealtimeBridge();
32
37
  useEffect(() => {
33
38
  let timer;
34
39
  const pending = new Set();
@@ -102,12 +107,16 @@ export function useSyncReconciliation() {
102
107
  !localNodeService.isLocalNode()) {
103
108
  localNodeService.getClient()?.publishEvent?.(event);
104
109
  }
110
+ // Where the event came from: a Phoenix push is "cloud", a LAN relay is
111
+ // "lan", everything produced on this device is "local". The journal
112
+ // stores it so a cloud event is never re-hydrated as pending local work.
113
+ const origin = event.origin ?? "local";
105
114
  // Reference data changed (locally or echoed from the cloud): re-sync the
106
115
  // affected keys and refresh caches on this device.
107
- await applyReferenceChangeEvent(event, "local");
116
+ await applyReferenceChangeEvent(event, origin);
108
117
  // Order/delivery state changed: refresh order caches so this terminal
109
118
  // shows the same order states as the device that made the change.
110
- await applyOrderStateChangeEvent(event, "local");
119
+ await applyOrderStateChangeEvent(event, origin);
111
120
  // Any order/delivery event should refresh dashboard recent orders immediately (fresh on transition)
112
121
  if (event.event_type.startsWith("order.") || event.event_type.startsWith("delivery.")) {
113
122
  schedule("pending");
@@ -116,7 +125,7 @@ export function useSyncReconciliation() {
116
125
  // into the pending-orders store.
117
126
  if (event.event_type === "order.create_queued" ||
118
127
  event.event_type === "order.created") {
119
- await applyPendingOrderEvent(event, "local");
128
+ await applyPendingOrderEvent(event, origin);
120
129
  }
121
130
  if (event.event_type === "order.created" && isLocalOrderEvent(event)) {
122
131
  try {
@@ -8,7 +8,7 @@ export function createMemoryDriver() {
8
8
  orders: [],
9
9
  sync_jobs: [],
10
10
  };
11
- let autoId = { commands: 1, events: 1, migrations: 1 };
11
+ let autoId = { commands: 1, events: 1, migrations: 1, sync_jobs: 1 };
12
12
  const driver = {
13
13
  async execAsync(_sql) {
14
14
  // No-op: CREATE TABLE / INDEX / ALTER all just ensure table array exists.
@@ -189,16 +189,19 @@ export function createMemoryDriver() {
189
189
  }
190
190
  // orders insert (primary DB)
191
191
  if (s.includes("INSERT INTO orders")) {
192
- const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc] = args;
192
+ const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc] = args;
193
193
  const existing = tables.orders.find((r) => r.id === id);
194
- // LWW check via hlc (if existing and incoming older, skip)
194
+ // LWW check via hlc (if existing and incoming older, skip) — only for
195
+ // the local/LAN write path. The server write path (`upsertServerOrder`)
196
+ // has no hlc guard: the server copy always wins.
197
+ const lwwGuarded = s.includes("excluded.hlc >= orders.hlc");
195
198
  if (existing) {
196
- if (hlc && existing.hlc && hlc < existing.hlc)
199
+ if (lwwGuarded && hlc && existing.hlc && hlc < existing.hlc)
197
200
  return { lastInsertRowId: 0, changes: 0 };
198
- Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc });
201
+ Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? existing.paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc });
199
202
  return { lastInsertRowId: 0, changes: 1 };
200
203
  }
201
- tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc, deleted_at: null });
204
+ tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc, deleted_at: null });
202
205
  return { lastInsertRowId: 1, changes: 1 };
203
206
  }
204
207
  // sync_jobs insert
@@ -206,7 +209,7 @@ export function createMemoryDriver() {
206
209
  const [id, business_id, command_type, entity_id, payload, created_at, updated_at] = args;
207
210
  if (tables.sync_jobs.find((r) => r.id === id))
208
211
  return { lastInsertRowId: 0, changes: 0 };
209
- tables.sync_jobs.push({ id, business_id, command_type, entity_id, payload, status: "pending", attempts: 0, created_at, updated_at });
212
+ tables.sync_jobs.push({ rowid: autoId.sync_jobs++, id, business_id, command_type, entity_id, payload, status: "pending", attempts: 0, created_at, updated_at });
210
213
  return { lastInsertRowId: 1, changes: 1 };
211
214
  }
212
215
  // sync_jobs update status=completed
@@ -260,10 +263,11 @@ export function createMemoryDriver() {
260
263
  return { lastInsertRowId: 0, changes: 1 };
261
264
  }
262
265
  if (s.includes("UPDATE orders SET payment = ?")) {
263
- const [payment, hlc, business_id, id1, id2] = args;
266
+ const [payment, paid_at, hlc, business_id, id1, id2] = args;
264
267
  const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
265
268
  if (row) {
266
269
  row.payment = payment;
270
+ row.paid_at = row.paid_at ?? paid_at ?? null;
267
271
  row.hlc = hlc;
268
272
  }
269
273
  return { lastInsertRowId: 0, changes: 1 };
@@ -353,6 +357,12 @@ export function createMemoryDriver() {
353
357
  const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
354
358
  return row ?? null;
355
359
  }
360
+ // orders business lookup by id/number (payment event with no business_id)
361
+ if (s.includes("SELECT business_id FROM orders WHERE id = ? OR order_number = ?")) {
362
+ const [id1, id2] = args;
363
+ const row = tables.orders.find((r) => r.id === id1 || r.order_number === id2);
364
+ return (row ? { business_id: row.business_id } : null);
365
+ }
356
366
  // orders hlc probe (absorb-if-applied touch detection)
357
367
  if (s.includes("SELECT hlc FROM orders WHERE business_id = ? AND id = ?")) {
358
368
  const [business_id, id] = args;
@@ -365,6 +375,15 @@ export function createMemoryDriver() {
365
375
  const row = tables.sync_jobs.find((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending");
366
376
  return row ?? null;
367
377
  }
378
+ // sync_jobs duplicate-command probe (transition coalescing)
379
+ if (s.includes("SELECT id FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND command_type = ?")) {
380
+ const [business_id, entity_id, command_type] = args;
381
+ const row = tables.sync_jobs.find((r) => r.business_id === business_id &&
382
+ r.entity_id === entity_id &&
383
+ r.command_type === command_type &&
384
+ r.status === "pending");
385
+ return row ?? null;
386
+ }
368
387
  // generic orders exists check for migrations backfill: SELECT * FROM orders WHERE business_id=? (not needed)
369
388
  return null;
370
389
  },
@@ -434,20 +453,20 @@ export function createMemoryDriver() {
434
453
  if (s.includes("json_extract(payment, '$.is_paid') = 1")) {
435
454
  rows = rows.filter((r) => {
436
455
  try {
437
- return JSON.parse(r.payment ?? "{}").is_paid === true;
456
+ return JSON.parse(r.payment ?? "{}").is_paid === true || r.paid_at != null;
438
457
  }
439
458
  catch {
440
- return false;
459
+ return r.paid_at != null;
441
460
  }
442
461
  });
443
462
  }
444
- else if (s.includes("json_extract(payment, '$.is_paid') IS NULL")) {
463
+ else if (s.includes("json_extract(payment, '$.is_paid'), 0) = 0")) {
445
464
  rows = rows.filter((r) => {
446
465
  try {
447
- return JSON.parse(r.payment ?? "{}").is_paid !== true;
466
+ return JSON.parse(r.payment ?? "{}").is_paid !== true && r.paid_at == null;
448
467
  }
449
468
  catch {
450
- return true;
469
+ return r.paid_at == null;
451
470
  }
452
471
  });
453
472
  }
@@ -479,10 +498,19 @@ export function createMemoryDriver() {
479
498
  const [business_id] = args;
480
499
  const m = s.match(/LIMIT\s+(\d+)/);
481
500
  const lim = m ? parseInt(m[1], 10) : 20;
482
- let rows = tables.sync_jobs.filter((r) => r.business_id === business_id && r.status === "pending").sort((a, b) => (a.created_at < b.created_at ? -1 : 1)).map((r) => ({ entity_id: r.entity_id }));
501
+ let rows = tables.sync_jobs.filter((r) => r.business_id === business_id && r.status === "pending").sort((a, b) => (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.rowid - b.rowid)).map((r) => ({ entity_id: r.entity_id }));
483
502
  rows = rows.slice(0, lim);
484
503
  return rows;
485
504
  }
505
+ // sync_jobs command list for a single entity (pending intent)
506
+ if (s.includes("FROM sync_jobs WHERE business_id = ? AND entity_id = ?")) {
507
+ const [business_id, entity_id] = args;
508
+ const rows = tables.sync_jobs
509
+ .filter((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending")
510
+ .sort((a, b) => (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.rowid - b.rowid))
511
+ .map((r) => ({ command_type: r.command_type, payload: r.payload }));
512
+ return rows;
513
+ }
486
514
  if (s.includes("SELECT * FROM sync_jobs WHERE") || s.includes("SELECT id, command_type, status, created_at FROM sync_jobs")) {
487
515
  const [business_id] = args;
488
516
  const rows = tables.sync_jobs.filter((r) => r.business_id === business_id);