@teincfood/core 0.9.23 → 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.
- package/dist/db/operations.d.ts +31 -0
- package/dist/db/operations.js +55 -0
- package/dist/domain/orders/service.js +25 -13
- package/dist/hooks/delivery.hooks.d.ts +24 -24
- package/dist/hooks/realtime-stubs.d.ts +26 -19
- package/dist/hooks/realtime-stubs.js +29 -124
- package/dist/hooks/rider.hooks.d.ts +48 -48
- package/dist/hooks/use-infinite-list.d.ts +24 -24
- package/dist/hooks/wallet.hooks.d.ts +24 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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 +61 -0
- 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 +26 -5
- package/package.json +2 -2
|
@@ -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) {
|
|
@@ -50,11 +50,12 @@ declare class CommandQueueService {
|
|
|
50
50
|
*/
|
|
51
51
|
private failDependentTransitions;
|
|
52
52
|
/**
|
|
53
|
-
* Already-applied absorb:
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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:
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
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
|
|
605
|
-
if (
|
|
606
|
-
|
|
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", {
|
package/dist/sync/full-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
77
|
+
inFlight.set(bid, p);
|
|
74
78
|
try {
|
|
75
79
|
await p;
|
|
76
80
|
}
|
|
77
81
|
finally {
|
|
78
|
-
inFlight
|
|
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
|
-
|
|
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>;
|