@teincfood/core 0.10.16 → 0.10.18

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.
@@ -36,7 +36,7 @@ export declare const sellerService: {
36
36
  }>;
37
37
  };
38
38
  export declare const kioskService: {
39
- assignKioskOrderToRider: (_orderId: string, _riderId: string) => Promise<unknown>;
39
+ assignKioskOrderToRider: (_orderId: string, _riderId: string, _idempotencyKey?: string) => Promise<unknown>;
40
40
  };
41
41
  export declare const menuService: {
42
42
  fetchBusinessMenuItems: (_businessId: string, _page: number) => Promise<unknown>;
@@ -54,7 +54,7 @@ export const sellerService = {
54
54
  };
55
55
  // kiosk.service
56
56
  export const kioskService = {
57
- assignKioskOrderToRider: async (_orderId, _riderId) => {
57
+ assignKioskOrderToRider: async (_orderId, _riderId, _idempotencyKey) => {
58
58
  notImpl("kioskService.assignKioskOrderToRider");
59
59
  },
60
60
  };
@@ -60,6 +60,18 @@ declare class LocalNodeServiceCore {
60
60
  private storeUnsub;
61
61
  private setupStoreSubscriptions;
62
62
  private teardownStoreSubscriptions;
63
+ /**
64
+ * Apply an event received over the LAN, regardless of whether it arrived on
65
+ * a client socket (`peer`) or was relayed through a node by another terminal
66
+ * (`relay`).
67
+ *
68
+ * Both paths must materialize order rows and invalidate the read model. The
69
+ * relay path previously only applied reference-data events, so an order
70
+ * created on a phone while it had internet (cloud route) was broadcast to
71
+ * the LAN but silently dropped by the peer node — the other terminal never
72
+ * saw it until a later reconciliation.
73
+ */
74
+ private applyLanEvent;
63
75
  private startAsNode;
64
76
  /**
65
77
  * (Re)publish this node's mDNS advertisement. Isolated from server bring-up
@@ -422,6 +422,92 @@ class LocalNodeServiceCore {
422
422
  this.storeUnsub = null;
423
423
  this.subscribed = false;
424
424
  }
425
+ /**
426
+ * Apply an event received over the LAN, regardless of whether it arrived on
427
+ * a client socket (`peer`) or was relayed through a node by another terminal
428
+ * (`relay`).
429
+ *
430
+ * Both paths must materialize order rows and invalidate the read model. The
431
+ * relay path previously only applied reference-data events, so an order
432
+ * created on a phone while it had internet (cloud route) was broadcast to
433
+ * the LAN but silently dropped by the peer node — the other terminal never
434
+ * saw it until a later reconciliation.
435
+ */
436
+ applyLanEvent(event, source) {
437
+ syncLogger.event(event, "received", { origin: "lan", source });
438
+ const deps = this.getDeps();
439
+ const eventType = event.event_type;
440
+ // Materialize LAN order events into the canonical orders table so
441
+ // list queries (POS In Progress, KDS) converge without the backend.
442
+ // Previously only order:sync inserted rows: a cloud/local-shape
443
+ // order.created from a peer fell through to the pending-store apply
444
+ // fns (which never touch the orders table), so the event arrived,
445
+ // every layer reported success, and the row never materialized.
446
+ if (eventType === "order:sync" || eventType === "order.created" || eventType === "order.status_changed") {
447
+ void (async () => {
448
+ try {
449
+ const p = event.payload;
450
+ const bid = deps.getBusiness()?.id;
451
+ const payloadBid = p.business_id;
452
+ if (typeof payloadBid === "string" && bid && payloadBid !== bid) {
453
+ syncLogger.warn("LocalNodeService", "Ignoring LAN order event for another business", {
454
+ event_type: eventType,
455
+ payloadBid,
456
+ bid,
457
+ });
458
+ return;
459
+ }
460
+ if (p && typeof p.order_number === "string" && p.id) {
461
+ // Full-order payload (order:sync publish or order.created /
462
+ // status_changed echo): upsert the canonical row. HLC guard
463
+ // inside insertOrder keeps this idempotent across mesh + phx
464
+ // double delivery.
465
+ const db = await openSyncDatabase();
466
+ await insertOrder(db, p);
467
+ syncLogger.info("LocalNodeService", "LAN order row materialized", {
468
+ event_type: eventType,
469
+ order_number: p.order_number,
470
+ source,
471
+ });
472
+ }
473
+ else {
474
+ // Action-shaped payload (order:sync from a LAN command):
475
+ // apply the transition to the local row.
476
+ const action = p.action;
477
+ const orderId = p.orderId ?? p.order_id;
478
+ if (action && orderId && bid) {
479
+ try {
480
+ await transitionLocalOrder(bid, String(orderId), action);
481
+ syncLogger.info("LocalNodeService", "LAN order transition applied", {
482
+ event_type: eventType,
483
+ action,
484
+ orderId: String(orderId),
485
+ source,
486
+ });
487
+ }
488
+ catch { }
489
+ }
490
+ else {
491
+ syncLogger.warn("LocalNodeService", "LAN order event has no materializable payload", {
492
+ event_type: eventType,
493
+ entity_id: event.entity_id,
494
+ });
495
+ }
496
+ }
497
+ }
498
+ catch (e) {
499
+ syncLogger.warn("LocalNodeService", "LAN order apply failed", { err: String(e), event_type: eventType });
500
+ }
501
+ // The row is in SQLite now — invalidate so lists/detail refetch.
502
+ // (LAN-applied writes bypass the engine pipeline, so no subscriber
503
+ // does this for us.)
504
+ notifyPendingOrdersChanged();
505
+ })();
506
+ }
507
+ applyPendingOrderEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN event", { error: String(e), event_type: event.event_type }));
508
+ applyReferenceChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN reference", { error: String(e), event_type: event.event_type }));
509
+ applyOrderStateChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN order state", { error: String(e), event_type: event.event_type }));
510
+ }
425
511
  async startAsNode(port) {
426
512
  if (!this.businessId || !this.secret)
427
513
  throw new Error("LocalNodeService: business context missing");
@@ -435,7 +521,7 @@ class LocalNodeServiceCore {
435
521
  return event ? [event] : [];
436
522
  },
437
523
  onRelayEvent: (event) => {
438
- applyReferenceChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply relayed reference", { error: String(e), event_type: event.event_type }));
524
+ this.applyLanEvent(event, "relay");
439
525
  },
440
526
  onClientConnected: (info) => {
441
527
  localNodeStore.getState().addConnectedClient({ deviceId: info.deviceId, deviceName: info.deviceName, host: info.host, connectedAt: new Date().toISOString() });
@@ -609,76 +695,7 @@ class LocalNodeServiceCore {
609
695
  businessId: this.businessId,
610
696
  secret: this.secret,
611
697
  onEvent: (event) => {
612
- syncLogger.event(event, "received", { origin: "lan" });
613
- const eventType = event.event_type;
614
- // Materialize LAN order events into the canonical orders table so
615
- // list queries (POS In Progress, KDS) converge without the backend.
616
- // Previously only order:sync inserted rows: a cloud/local-shape
617
- // order.created from a peer fell through to the pending-store apply
618
- // fns (which never touch the orders table), so the event arrived,
619
- // every layer reported success, and the row never materialized.
620
- if (eventType === "order:sync" || eventType === "order.created" || eventType === "order.status_changed") {
621
- void (async () => {
622
- try {
623
- const p = event.payload;
624
- const bid = deps.getBusiness()?.id;
625
- const payloadBid = p.business_id;
626
- if (typeof payloadBid === "string" && bid && payloadBid !== bid) {
627
- syncLogger.warn("LocalNodeService", "Ignoring LAN order event for another business", {
628
- event_type: eventType,
629
- payloadBid,
630
- bid,
631
- });
632
- return;
633
- }
634
- if (p && typeof p.order_number === "string" && p.id) {
635
- // Full-order payload (order:sync publish or order.created /
636
- // status_changed echo): upsert the canonical row. HLC guard
637
- // inside insertOrder keeps this idempotent across mesh + phx
638
- // double delivery.
639
- const db = await openSyncDatabase();
640
- await insertOrder(db, p);
641
- syncLogger.info("LocalNodeService", "LAN order row materialized", {
642
- event_type: eventType,
643
- order_number: p.order_number,
644
- });
645
- }
646
- else {
647
- // Action-shaped payload (order:sync from a LAN command):
648
- // apply the transition to the local row.
649
- const action = p.action;
650
- const orderId = p.orderId ?? p.order_id;
651
- if (action && orderId && bid) {
652
- try {
653
- await transitionLocalOrder(bid, String(orderId), action);
654
- syncLogger.info("LocalNodeService", "LAN order transition applied", {
655
- event_type: eventType,
656
- action,
657
- orderId: String(orderId),
658
- });
659
- }
660
- catch { }
661
- }
662
- else {
663
- syncLogger.warn("LocalNodeService", "LAN order event has no materializable payload", {
664
- event_type: eventType,
665
- entity_id: event.entity_id,
666
- });
667
- }
668
- }
669
- }
670
- catch (e) {
671
- syncLogger.warn("LocalNodeService", "LAN order apply failed", { err: String(e), event_type: eventType });
672
- }
673
- // The row is in SQLite now — invalidate so lists/detail refetch.
674
- // (LAN-applied writes bypass the engine pipeline, so no
675
- // subscriber does this for us.)
676
- notifyPendingOrdersChanged();
677
- })();
678
- }
679
- applyPendingOrderEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN event", { error: String(e), event_type: event.event_type }));
680
- applyReferenceChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN reference", { error: String(e), event_type: event.event_type }));
681
- applyOrderStateChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN order state", { error: String(e), event_type: event.event_type }));
698
+ this.applyLanEvent(event, "peer");
682
699
  },
683
700
  onConnected: () => {
684
701
  this.settleDial(node.deviceId);
@@ -141,8 +141,12 @@ export const orderRepository = {
141
141
  commandType: "delivery:assign",
142
142
  businessId: input.businessId,
143
143
  entityId: input.orderId,
144
- payload: { rider_id: input.riderId },
144
+ payload: { rider_id: input.riderId, idempotency_key: "" },
145
145
  });
146
+ // One user action = one stable key. Replays of this command (timeout,
147
+ // reconnect, LAN relay) must converge on the same delivery instead of
148
+ // creating another one.
149
+ command.payload.idempotency_key = command.command_id;
146
150
  const events = await syncEngine.execute(command);
147
151
  const event = events[0];
148
152
  if (!event) {
@@ -217,7 +217,12 @@ async function tryRest(businessId, payload, commandType) {
217
217
  const riderId = (payload.rider_id ?? payload.riderId);
218
218
  if (!riderId)
219
219
  return { ok: false, status: 422, code: "MISSING_RIDER", message: "Assign rider job has no rider_id — re-assign from the app" };
220
- await http.post(`/deliveries/assign`, { order_id: orderId, rider_id: riderId });
220
+ const idempotencyKey = (payload.idempotency_key ?? payload.idempotencyKey);
221
+ await http.post(`/deliveries/assign`, {
222
+ order_id: orderId,
223
+ rider_id: riderId,
224
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
225
+ });
221
226
  return { ok: true };
222
227
  }
223
228
  // POST /deliveries/:deliveryId/confirm-handover — resolve the delivery
@@ -84,7 +84,8 @@ export class RestTransport {
84
84
  return this.executeOrderTransition(command, "cancel", timestamp, () => sellerService.cancelOrder(entity_id));
85
85
  }
86
86
  case "delivery:assign": {
87
- const response = await kioskService.assignKioskOrderToRider(entity_id, payload.rider_id);
87
+ const assignPayload = payload;
88
+ const response = await kioskService.assignKioskOrderToRider(entity_id, assignPayload.rider_id, assignPayload.idempotency_key ?? command.command_id);
88
89
  return this.wrapResponse("delivery.assigned", response, entity_id, timestamp, command);
89
90
  }
90
91
  case "delivery:confirm_handover": {
@@ -96,15 +96,22 @@ export function useSyncReconciliation() {
96
96
  // The node relays engine events (e.g. order.created when a queued
97
97
  // command is replayed) to LAN clients so they reconcile their pending
98
98
  // orders and refresh caches.
99
+ //
100
+ // A terminal that is only a LAN client (its own listener failed to bind,
101
+ // or it is a phone in the background) also has to forward the events it
102
+ // produces locally. Otherwise a sale completed over the cloud while the
103
+ // phone has mobile data — the exact iOS Wi-Fi + LTE case — never reaches
104
+ // the node until a later full sync. Reference events were already
105
+ // forwarded here; order/payment/delivery events need the same path.
106
+ const relayable = event.event_type === REFERENCE_CHANGED_EVENT_TYPE ||
107
+ event.event_type.startsWith("order.") ||
108
+ event.event_type.startsWith("order:") ||
109
+ event.event_type.startsWith("payment.") ||
110
+ event.event_type.startsWith("delivery.");
99
111
  if (localNodeService.isLocalNode()) {
100
112
  localNodeService.broadcastToClients(event);
101
113
  }
102
- // Reference data changed on this device but we are a LAN client (not the
103
- // node): forward the event to the node so it rebroadcasts it to every
104
- // other terminal (the node also applies it locally via its onRelayEvent
105
- // hook). The node does not echo it back to this device.
106
- if (event.event_type === REFERENCE_CHANGED_EVENT_TYPE &&
107
- !localNodeService.isLocalNode()) {
114
+ else if (relayable) {
108
115
  localNodeService.getClient()?.publishEvent?.(event);
109
116
  }
110
117
  // Where the event came from: a Phoenix push is "cloud", a LAN relay is
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.16",
4
- "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
3
+ "version": "0.10.18",
4
+ "description": "TeincFood shared offline-first core \u2014 types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",