@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.
- package/dist/adapters/services.d.ts +1 -1
- package/dist/adapters/services.js +1 -1
- package/dist/local-node/service.d.ts +12 -0
- package/dist/local-node/service.js +88 -71
- package/dist/repositories/order.repository.js +5 -1
- package/dist/sync/sync-jobs.service.js +6 -1
- package/dist/sync/transport-rest.js +2 -1
- package/dist/sync/use-sync-reconciliation.js +13 -6
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "TeincFood shared offline-first core
|
|
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",
|