@teincfood/core 0.7.8 → 0.8.0
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/README.md +26 -21
- package/dist/adapters/auth.d.ts +10 -0
- package/dist/adapters/auth.js +43 -0
- package/dist/adapters/devices.d.ts +3 -1
- package/dist/adapters/devices.js +187 -3
- package/dist/adapters/filesystem.d.ts +4 -0
- package/dist/adapters/kv.d.ts +10 -0
- package/dist/adapters/kv.js +23 -7
- package/dist/adapters/local-node.d.ts +4 -6
- package/dist/adapters/local-node.js +9 -32
- package/dist/adapters/notify.d.ts +12 -0
- package/dist/adapters/notify.js +33 -0
- package/dist/adapters/query-keys.d.ts +4 -0
- package/dist/adapters/query-keys.js +4 -0
- package/dist/adapters/services.d.ts +99 -15
- package/dist/adapters/services.js +244 -4
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +172 -0
- package/dist/catalog/hooks.d.ts +10 -0
- package/dist/catalog/hooks.js +82 -11
- package/dist/db/migrations.js +274 -0
- package/dist/db/operations.d.ts +53 -2
- package/dist/db/operations.js +177 -8
- package/dist/domain/business/index.d.ts +5 -0
- package/dist/domain/business/index.js +5 -0
- package/dist/domain/orders/lifecycle.d.ts +6 -0
- package/dist/domain/orders/lifecycle.js +6 -0
- package/dist/domain/orders/service.d.ts +24 -0
- package/dist/domain/orders/service.js +242 -0
- package/dist/domain/payments/index.d.ts +6 -0
- package/dist/domain/payments/index.js +5 -0
- package/dist/domain/reference/index.d.ts +8 -0
- package/dist/domain/reference/index.js +8 -0
- package/dist/e2e/harness.d.ts +1 -0
- package/dist/e2e/harness.js +61 -0
- package/dist/hooks/analytics.hooks.d.ts +25 -0
- package/dist/hooks/analytics.hooks.js +214 -0
- package/dist/hooks/business-hours.hooks.d.ts +8 -0
- package/dist/hooks/business-hours.hooks.js +45 -0
- package/dist/hooks/dashboard.hooks.d.ts +30 -0
- package/dist/hooks/dashboard.hooks.js +132 -0
- package/dist/hooks/delivery.hooks.d.ts +225 -0
- package/dist/hooks/delivery.hooks.js +80 -0
- package/dist/hooks/fleet.hooks.d.ts +37 -0
- package/dist/hooks/fleet.hooks.js +229 -0
- package/dist/hooks/image.hooks.d.ts +20 -0
- package/dist/hooks/image.hooks.js +62 -0
- package/dist/hooks/kiosk.hooks.d.ts +22 -5
- package/dist/hooks/kiosk.hooks.js +74 -5
- package/dist/hooks/members.hooks.d.ts +48 -0
- package/dist/hooks/members.hooks.js +302 -0
- package/dist/hooks/offline-orders.hooks.d.ts +9 -0
- package/dist/hooks/offline-orders.hooks.js +51 -0
- package/dist/hooks/orders.hooks.d.ts +19 -39
- package/dist/hooks/orders.hooks.js +126 -321
- package/dist/hooks/places.hooks.d.ts +21 -0
- package/dist/hooks/places.hooks.js +112 -0
- package/dist/hooks/realtime-stubs.d.ts +28 -0
- package/dist/hooks/realtime-stubs.js +85 -0
- package/dist/hooks/rider.hooks.d.ts +470 -0
- package/dist/hooks/rider.hooks.js +373 -0
- package/dist/hooks/use-infinite-list.d.ts +243 -0
- package/dist/hooks/use-infinite-list.js +58 -0
- package/dist/hooks/wallet.hooks.d.ts +219 -0
- package/dist/hooks/wallet.hooks.js +56 -0
- package/dist/image-cache/service.d.ts +8 -0
- package/dist/image-cache/service.js +15 -3
- package/dist/index.d.ts +60 -5
- package/dist/index.js +78 -5
- package/dist/local-node/adapters.d.ts +87 -0
- package/dist/local-node/adapters.js +4 -0
- package/dist/local-node/index.d.ts +5 -0
- package/dist/local-node/index.js +5 -0
- package/dist/local-node/security.d.ts +25 -0
- package/dist/local-node/security.js +149 -0
- package/dist/local-node/service.d.ts +83 -0
- package/dist/local-node/service.js +706 -0
- package/dist/local-node/store.d.ts +50 -0
- package/dist/local-node/store.js +284 -0
- package/dist/local-node/types.d.ts +92 -0
- package/dist/local-node/types.js +4 -0
- package/dist/printer/receipt.d.ts +4 -0
- package/dist/printer/receipt.js +75 -0
- package/dist/printer/service.js +1 -1
- package/dist/providers/query-client.d.ts +13 -0
- package/dist/providers/query-client.js +58 -0
- package/dist/realtime/events.service.d.ts +18 -0
- package/dist/realtime/events.service.js +76 -0
- package/dist/realtime/order-events.service.d.ts +6 -0
- package/dist/realtime/order-events.service.js +17 -0
- package/dist/realtime/phoenix-socket.d.ts +55 -0
- package/dist/realtime/phoenix-socket.js +269 -0
- package/dist/realtime/useEventsConnection.d.ts +1 -0
- package/dist/realtime/useEventsConnection.js +50 -0
- package/dist/reference/api.d.ts +2 -0
- package/dist/reference/api.js +2 -0
- package/dist/reference/capabilities.d.ts +12 -7
- package/dist/reference/capabilities.js +28 -13
- package/dist/reference/hooks.d.ts +34 -0
- package/dist/reference/hooks.js +124 -0
- package/dist/reference/menu-apply.d.ts +29 -0
- package/dist/reference/menu-apply.js +289 -0
- package/dist/reference/menu-record.d.ts +69 -0
- package/dist/reference/menu-record.js +163 -0
- package/dist/reference/operations.d.ts +3 -0
- package/dist/reference/operations.js +35 -1
- package/dist/reference/service.js +36 -9
- package/dist/reference/types.d.ts +1 -1
- package/dist/repositories/order.repository.d.ts +8 -0
- package/dist/repositories/order.repository.js +24 -2
- package/dist/repositories/pos.repository.d.ts +4 -0
- package/dist/repositories/pos.repository.js +4 -0
- package/dist/sync/backoff.d.ts +21 -0
- package/dist/sync/backoff.js +42 -0
- package/dist/sync/command-builder.js +10 -2
- package/dist/sync/command-queue.service.d.ts +12 -0
- package/dist/sync/command-queue.service.js +115 -18
- package/dist/sync/device-lease.service.d.ts +3 -3
- package/dist/sync/device-lease.service.js +80 -20
- package/dist/sync/engine.d.ts +3 -1
- package/dist/sync/engine.js +44 -4
- package/dist/sync/full-sync.d.ts +6 -0
- package/dist/sync/full-sync.js +72 -0
- package/dist/sync/health-probe.d.ts +24 -0
- package/dist/sync/health-probe.js +138 -0
- package/dist/sync/offline-capable.js +25 -0
- package/dist/sync/online-gates.d.ts +17 -0
- package/dist/sync/online-gates.js +55 -0
- package/dist/sync/order-number.d.ts +4 -4
- package/dist/sync/order-number.js +33 -13
- package/dist/sync/order-snapshots.service.d.ts +1 -0
- package/dist/sync/order-snapshots.service.js +47 -2
- package/dist/sync/pending-orders.service.d.ts +18 -0
- package/dist/sync/pending-orders.service.js +148 -3
- package/dist/sync/reconciliation/index.d.ts +5 -0
- package/dist/sync/reconciliation/index.js +5 -0
- package/dist/sync/snapshots/entity.d.ts +6 -0
- package/dist/sync/snapshots/entity.js +6 -0
- package/dist/sync/sync-jobs.service.d.ts +18 -0
- package/dist/sync/sync-jobs.service.js +539 -0
- package/dist/sync/transition-endpoints.d.ts +11 -0
- package/dist/sync/transition-endpoints.js +30 -0
- package/dist/sync/transport-local-node.js +3 -2
- package/dist/sync/transport-rest.d.ts +7 -0
- package/dist/sync/transport-rest.js +90 -25
- package/dist/sync/use-sync-reconciliation.js +58 -16
- package/dist/test-helpers/memory-driver.js +182 -4
- package/dist/test-helpers/seed-hours.d.ts +1 -0
- package/dist/test-helpers/seed-hours.js +23 -0
- package/dist/types/api/business.api.d.ts +42 -0
- package/dist/types/api/menu.api.d.ts +15 -0
- package/dist/types/api/seller.api.d.ts +20 -0
- package/dist/types/places.d.ts +19 -0
- package/dist/types/places.js +2 -0
- package/dist/types/rider.d.ts +67 -0
- package/dist/types/rider.js +2 -0
- package/dist/utils/attempt-stats.d.ts +23 -0
- package/dist/utils/attempt-stats.js +61 -0
- package/dist/utils/business-hours.d.ts +37 -0
- package/dist/utils/business-hours.js +96 -0
- package/dist/utils/debug-log.d.ts +23 -0
- package/dist/utils/debug-log.js +153 -0
- package/dist/utils/order-filter.d.ts +1 -1
- package/dist/utils/order-filter.js +1 -1
- package/dist/utils/order-transitions.d.ts +30 -0
- package/dist/utils/order-transitions.js +185 -0
- package/dist/utils/sync-logger.js +5 -4
- package/dist/validation/offline.d.ts +46 -0
- package/dist/validation/offline.js +79 -0
- package/package.json +6 -1
- package/dist/pricing/engine.test.d.ts +0 -1
- package/dist/pricing/engine.test.js +0 -142
- package/dist/reference/lww.test.d.ts +0 -1
- package/dist/reference/lww.test.js +0 -23
- package/dist/reference/operations.test.d.ts +0 -1
- package/dist/reference/operations.test.js +0 -66
- package/dist/standalone.integration.test.d.ts +0 -1
- package/dist/standalone.integration.test.js +0 -235
- package/dist/sync/device-lease.test.d.ts +0 -1
- package/dist/sync/device-lease.test.js +0 -38
- package/dist/sync/offline-integration.test.d.ts +0 -1
- package/dist/sync/offline-integration.test.js +0 -52
- package/dist/sync/order-number.test.d.ts +0 -1
- package/dist/sync/order-number.test.js +0 -51
- package/dist/sync/perms.test.d.ts +0 -1
- package/dist/sync/perms.test.js +0 -15
- package/dist/utils/currency.test.d.ts +0 -1
- package/dist/utils/currency.test.js +0 -17
- package/dist/utils/hlc.test.d.ts +0 -1
- package/dist/utils/hlc.test.js +0 -29
- package/dist/utils/phone.test.d.ts +0 -1
- package/dist/utils/phone.test.js +0 -19
- package/dist/utils/responsive.d.ts +0 -13
- package/dist/utils/responsive.js +0 -18
- package/dist/utils/uuid.test.d.ts +0 -1
- package/dist/utils/uuid.test.js +0 -13
- package/dist/utils/validation.test.d.ts +0 -1
- package/dist/utils/validation.test.js +0 -23
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Node Service — mesh, no elected, every isLocalNodeEnabled device is both server and client.
|
|
3
|
+
*/
|
|
4
|
+
import { localNodeStore } from "./store";
|
|
5
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
6
|
+
import { recordAttempt } from "../utils/attempt-stats";
|
|
7
|
+
import { restTransport } from "../sync/transport-rest";
|
|
8
|
+
import { commandQueueService } from "../sync/command-queue.service";
|
|
9
|
+
import { applyPendingOrderEvent, applyOrderStateChangeEvent, notifyPendingOrdersChanged } from "../sync/pending-orders.service";
|
|
10
|
+
import { applyMenuCommandLocal } from "../reference/menu-apply";
|
|
11
|
+
import { REFERENCE_CHANGED_EVENT_TYPE } from "../reference/events";
|
|
12
|
+
import { applyReferenceChangeEvent } from "../reference/events";
|
|
13
|
+
import { openSyncDatabase } from "../db/connection";
|
|
14
|
+
import { insertOrder } from "../db/operations";
|
|
15
|
+
import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
|
|
16
|
+
import { getConnectivityState, setConnectivityState } from "../adapters/connectivity";
|
|
17
|
+
import { deriveLanSecret as deriveSecretCore } from "./security";
|
|
18
|
+
const KEEP_AWAKE_TAG = "TeincLocalNode";
|
|
19
|
+
const RECONNECT_BASE = 1000;
|
|
20
|
+
const RECONNECT_MAX = 30000;
|
|
21
|
+
const CONNECT_GUARD_MS = 15000;
|
|
22
|
+
const LIFECYCLE_TIMEOUT_MS = 10000;
|
|
23
|
+
const CLOUD_TIMEOUT_DEFAULT = 8000;
|
|
24
|
+
function withTimeout(p, ms) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const t = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
|
|
27
|
+
p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
class LocalNodeServiceCore {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.server = null;
|
|
33
|
+
this.peers = new Map();
|
|
34
|
+
this.peerInfos = new Map();
|
|
35
|
+
this.mode = "idle";
|
|
36
|
+
this.businessId = null;
|
|
37
|
+
this.secret = null;
|
|
38
|
+
this.subscribed = false;
|
|
39
|
+
this.cachedDeviceId = null;
|
|
40
|
+
this.lifecyclePromise = null;
|
|
41
|
+
this.starting = false;
|
|
42
|
+
this.deps = null;
|
|
43
|
+
/** mDNS publish state — a failed publish must never abort discovery. */
|
|
44
|
+
this.published = false;
|
|
45
|
+
/** Whether discovery scanning was successfully started. */
|
|
46
|
+
this.discovering = false;
|
|
47
|
+
this.storeUnsub = null;
|
|
48
|
+
this.dialInFlight = new Set();
|
|
49
|
+
this.dialTimers = new Map();
|
|
50
|
+
this.sweepTimer = null;
|
|
51
|
+
/** deviceId -> timestamp of last business_mismatch rejection. Foreign-business
|
|
52
|
+
* peers (test rigs, neighboring shops) would otherwise be redialed on every
|
|
53
|
+
* discovery event; back off quietly and re-probe after the window. */
|
|
54
|
+
this.mismatchBackoff = new Map();
|
|
55
|
+
}
|
|
56
|
+
init(deps) { this.deps = deps; }
|
|
57
|
+
getDeps() {
|
|
58
|
+
if (!this.deps)
|
|
59
|
+
throw new Error("LocalNodeService not initialised: call initLocalNodeService(deps) first");
|
|
60
|
+
return this.deps;
|
|
61
|
+
}
|
|
62
|
+
async getDeviceId() {
|
|
63
|
+
const d = this.getDeps().getDeviceId();
|
|
64
|
+
return d instanceof Promise ? await d : d;
|
|
65
|
+
}
|
|
66
|
+
async runLifecycle(fn, tag) {
|
|
67
|
+
if (this.lifecyclePromise)
|
|
68
|
+
await this.lifecyclePromise;
|
|
69
|
+
const p = (async () => { try {
|
|
70
|
+
return await fn();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
this.lifecyclePromise = null;
|
|
74
|
+
} })();
|
|
75
|
+
this.lifecyclePromise = p.then(() => undefined, () => undefined);
|
|
76
|
+
await p.catch((error) => { syncLogger.error("LocalNodeService", `${tag} failed`, { error: String(error) }); throw error; });
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
async runInitialize() {
|
|
80
|
+
const deps = this.getDeps();
|
|
81
|
+
if (this.starting)
|
|
82
|
+
return;
|
|
83
|
+
const auth = deps.getAuth();
|
|
84
|
+
const business = deps.getBusiness();
|
|
85
|
+
if (!auth.user || !auth.accessToken || !business) {
|
|
86
|
+
if (this.mode !== "idle")
|
|
87
|
+
await this.runShutdown();
|
|
88
|
+
syncLogger.info("LocalNodeService", "Local Node idle (no active business)", {});
|
|
89
|
+
this.setupStoreSubscriptions();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const store = localNodeStore.getState();
|
|
93
|
+
if (!store.isLocalNodeEnabled) {
|
|
94
|
+
if (this.mode !== "idle")
|
|
95
|
+
await this.runShutdown();
|
|
96
|
+
syncLogger.info("LocalNodeService", "Local Node disabled", {});
|
|
97
|
+
this.setupStoreSubscriptions();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const thisDeviceId = await this.getDeviceId();
|
|
101
|
+
this.cachedDeviceId = thisDeviceId;
|
|
102
|
+
// Mesh: every enabled device is server + client, no elected check
|
|
103
|
+
if (this.mode === "mesh" && this.server?.isListening() && this.businessId === business.id) {
|
|
104
|
+
// Already serving: re-verify the auxiliary legs instead of returning
|
|
105
|
+
// blind. A previous publish failure (or a start that never reached
|
|
106
|
+
// discovery) must not leave this device mute/deaf forever.
|
|
107
|
+
this.ensurePublished();
|
|
108
|
+
this.ensureDiscovery();
|
|
109
|
+
this.setupStoreSubscriptions();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (this.mode !== "idle")
|
|
113
|
+
await this.runShutdown();
|
|
114
|
+
this.businessId = business.id;
|
|
115
|
+
// Sole derivation point: lib-owned, business-only (security.ts).
|
|
116
|
+
this.secret = await deriveSecretCore({ id: business.id });
|
|
117
|
+
this.mode = "mesh";
|
|
118
|
+
await this.startAsNode(store.localNodePort);
|
|
119
|
+
this.ensureDiscovery();
|
|
120
|
+
this.startPeerSweep();
|
|
121
|
+
// Connect to already discovered peers
|
|
122
|
+
for (const n of localNodeStore.getState().discoveredNodes) {
|
|
123
|
+
if (n.deviceId !== thisDeviceId)
|
|
124
|
+
this.connectToPeer(n);
|
|
125
|
+
}
|
|
126
|
+
this.setupStoreSubscriptions();
|
|
127
|
+
}
|
|
128
|
+
async initialize() { return this.runLifecycle(() => this.runInitialize(), "initialize"); }
|
|
129
|
+
async shutdown() { return this.runLifecycle(() => this.runShutdown(), "shutdown"); }
|
|
130
|
+
reconnectNow() {
|
|
131
|
+
// Mesh: if server not listening, re-initialize; else ensure publish +
|
|
132
|
+
// discovery and peer connections
|
|
133
|
+
if (this.mode === "mesh") {
|
|
134
|
+
if (!this.server?.isListening()) {
|
|
135
|
+
this.initialize().catch((e) => syncLogger.error("LocalNodeService", "reconnectNow re-init failed", { error: String(e) }));
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.ensurePublished();
|
|
139
|
+
this.ensureDiscovery();
|
|
140
|
+
for (const n of localNodeStore.getState().discoveredNodes) {
|
|
141
|
+
if (n.deviceId !== this.cachedDeviceId)
|
|
142
|
+
this.connectToPeer(n);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (this.mode === "idle")
|
|
148
|
+
this.initialize().catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
async runShutdown() {
|
|
151
|
+
this.teardownStoreSubscriptions();
|
|
152
|
+
this.stopPeerSweep();
|
|
153
|
+
this.dialInFlight.clear();
|
|
154
|
+
this.mismatchBackoff.clear();
|
|
155
|
+
for (const t of this.dialTimers.values())
|
|
156
|
+
try {
|
|
157
|
+
clearTimeout(t);
|
|
158
|
+
}
|
|
159
|
+
catch { }
|
|
160
|
+
this.dialTimers.clear();
|
|
161
|
+
for (const c of this.peers.values())
|
|
162
|
+
try {
|
|
163
|
+
c.disconnect();
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
this.peers.clear();
|
|
167
|
+
this.peerInfos.clear();
|
|
168
|
+
if (this.server) {
|
|
169
|
+
await withTimeout(this.server.stop(), LIFECYCLE_TIMEOUT_MS).catch((e) => syncLogger.warn("LocalNodeService", "Server stop timed out", { error: String(e) }));
|
|
170
|
+
this.server = null;
|
|
171
|
+
}
|
|
172
|
+
this.starting = false;
|
|
173
|
+
try {
|
|
174
|
+
this.getDeps().discovery.stop();
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
this.published = false;
|
|
178
|
+
this.discovering = false;
|
|
179
|
+
this.mode = "idle";
|
|
180
|
+
this.businessId = null;
|
|
181
|
+
this.secret = null;
|
|
182
|
+
syncLogger.info("LocalNodeService", "Mesh shutdown", {});
|
|
183
|
+
try {
|
|
184
|
+
localNodeStore.getState().clearConnectedClients();
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
try {
|
|
188
|
+
localNodeStore.getState().clearDiscoveredNodes();
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
try {
|
|
192
|
+
await this.getDeps().keepAlive?.deactivate(KEEP_AWAKE_TAG);
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
}
|
|
196
|
+
isLocalNode() {
|
|
197
|
+
// Mesh: isLocalNode means mesh enabled and server listening
|
|
198
|
+
const store = localNodeStore.getState();
|
|
199
|
+
return store.isLocalNodeEnabled && !!this.server?.isListening();
|
|
200
|
+
}
|
|
201
|
+
isConnectedToNode() { return this.peers.size > 0 && [...this.peers.values()].some((c) => c.isConnected()); }
|
|
202
|
+
/** Mesh legibility for Diagnostics screens: publish/scan state per leg. */
|
|
203
|
+
getMeshStatus() {
|
|
204
|
+
let listening = false;
|
|
205
|
+
try {
|
|
206
|
+
listening = !!this.server?.isListening();
|
|
207
|
+
}
|
|
208
|
+
catch { }
|
|
209
|
+
return { mode: this.mode, listening, published: this.published, discovering: this.discovering, peers: this.peers.size };
|
|
210
|
+
}
|
|
211
|
+
getClient() {
|
|
212
|
+
// For compat: return first connected peer
|
|
213
|
+
for (const c of this.peers.values())
|
|
214
|
+
if (c.isConnected())
|
|
215
|
+
return c;
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
getElectedNode() {
|
|
219
|
+
// No elected in mesh, return first peer or null
|
|
220
|
+
const first = this.peerInfos.values().next().value;
|
|
221
|
+
return first ?? null;
|
|
222
|
+
}
|
|
223
|
+
broadcastToClients(event) {
|
|
224
|
+
// Mesh broadcast to all peers
|
|
225
|
+
for (const c of this.peers.values()) {
|
|
226
|
+
try {
|
|
227
|
+
c.publishEvent?.(event);
|
|
228
|
+
}
|
|
229
|
+
catch { }
|
|
230
|
+
}
|
|
231
|
+
// Also via server broadcast to connected clients
|
|
232
|
+
try {
|
|
233
|
+
this.server?.broadcast(event);
|
|
234
|
+
}
|
|
235
|
+
catch { }
|
|
236
|
+
}
|
|
237
|
+
setupStoreSubscriptions() {
|
|
238
|
+
if (this.subscribed)
|
|
239
|
+
return;
|
|
240
|
+
this.subscribed = true;
|
|
241
|
+
this.storeUnsub = localNodeStore.subscribe((state, prev) => {
|
|
242
|
+
if (this.mode !== "mesh")
|
|
243
|
+
return;
|
|
244
|
+
// New discovered nodes -> connect. Dead entries (failed/in-flight, never
|
|
245
|
+
// connected) must NOT block redial: connectToPeer self-heals via
|
|
246
|
+
// stale-release, so only skip peers that are actually connected.
|
|
247
|
+
if (state.discoveredNodes !== prev.discoveredNodes) {
|
|
248
|
+
for (const n of state.discoveredNodes) {
|
|
249
|
+
if (n.deviceId === this.cachedDeviceId)
|
|
250
|
+
continue;
|
|
251
|
+
if (this.peers.get(n.deviceId)?.isConnected())
|
|
252
|
+
continue;
|
|
253
|
+
this.connectToPeer(n);
|
|
254
|
+
}
|
|
255
|
+
// Removed nodes -> drop the peer entry, but NEVER tear down a live
|
|
256
|
+
// socket on discovery loss. mDNS scans flap (a peer missing from one
|
|
257
|
+
// browse window is usually still there); killing the authenticated
|
|
258
|
+
// connection on every miss causes an infinite connect/kill loop and
|
|
259
|
+
// the UI never shows Connected. Socket close/error drives teardown
|
|
260
|
+
// (see onDisconnected); discovery only governs (re)connect attempts.
|
|
261
|
+
for (const pid of [...this.peers.keys()]) {
|
|
262
|
+
if (!state.discoveredNodes.find((n) => n.deviceId === pid)) {
|
|
263
|
+
const c = this.peers.get(pid);
|
|
264
|
+
if (c?.isConnected())
|
|
265
|
+
continue;
|
|
266
|
+
try {
|
|
267
|
+
c?.disconnect();
|
|
268
|
+
}
|
|
269
|
+
catch { }
|
|
270
|
+
this.peers.delete(pid);
|
|
271
|
+
this.peerInfos.delete(pid);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
teardownStoreSubscriptions() {
|
|
278
|
+
try {
|
|
279
|
+
this.storeUnsub?.();
|
|
280
|
+
}
|
|
281
|
+
catch { }
|
|
282
|
+
this.storeUnsub = null;
|
|
283
|
+
this.subscribed = false;
|
|
284
|
+
}
|
|
285
|
+
async startAsNode(port) {
|
|
286
|
+
if (!this.businessId || !this.secret)
|
|
287
|
+
throw new Error("LocalNodeService: business context missing");
|
|
288
|
+
const deps = this.getDeps();
|
|
289
|
+
this.server = deps.transport.createServer({
|
|
290
|
+
port,
|
|
291
|
+
businessId: this.businessId,
|
|
292
|
+
secret: this.secret,
|
|
293
|
+
onCommand: async (command) => {
|
|
294
|
+
const event = await this.handleNodeCommand(command);
|
|
295
|
+
return event ? [event] : [];
|
|
296
|
+
},
|
|
297
|
+
onRelayEvent: (event) => {
|
|
298
|
+
applyReferenceChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply relayed reference", { error: String(e), event_type: event.event_type }));
|
|
299
|
+
},
|
|
300
|
+
onClientConnected: (info) => {
|
|
301
|
+
localNodeStore.getState().addConnectedClient({ deviceId: info.deviceId, deviceName: info.deviceName, host: info.host, connectedAt: new Date().toISOString() });
|
|
302
|
+
syncLogger.info("LocalNodeService", "Client connected", { deviceId: info.deviceId, connected_clients: localNodeStore.getState().connectedClients.length });
|
|
303
|
+
},
|
|
304
|
+
onClientDisconnected: (deviceId) => {
|
|
305
|
+
localNodeStore.getState().removeConnectedClient(deviceId);
|
|
306
|
+
syncLogger.info("LocalNodeService", "Client disconnected", { deviceId, connected_clients: localNodeStore.getState().connectedClients.length });
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
await this.server.start();
|
|
310
|
+
try {
|
|
311
|
+
await deps.keepAlive?.activate(KEEP_AWAKE_TAG);
|
|
312
|
+
}
|
|
313
|
+
catch { }
|
|
314
|
+
this.ensurePublished();
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* (Re)publish this node's mDNS advertisement. Isolated from server bring-up
|
|
318
|
+
* and discovery: a publish throw must never abort scanning, sweeping, or
|
|
319
|
+
* peer dials. Safe to call repeatedly (drivers dedupe internally).
|
|
320
|
+
*/
|
|
321
|
+
ensurePublished() {
|
|
322
|
+
if (this.published)
|
|
323
|
+
return;
|
|
324
|
+
try {
|
|
325
|
+
const deps = this.getDeps();
|
|
326
|
+
const deviceId = this.cachedDeviceId ?? (() => {
|
|
327
|
+
try {
|
|
328
|
+
const v = deps.getDeviceId();
|
|
329
|
+
return typeof v === "string" ? v : "";
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return "";
|
|
333
|
+
}
|
|
334
|
+
})();
|
|
335
|
+
if (!deviceId)
|
|
336
|
+
return;
|
|
337
|
+
const port = localNodeStore.getState().localNodePort;
|
|
338
|
+
deps.discovery.startPublishing({
|
|
339
|
+
deviceId,
|
|
340
|
+
deviceName: deps.getDeviceName?.(),
|
|
341
|
+
port,
|
|
342
|
+
onNodeFound: (node) => localNodeStore.getState().addDiscoveredNode(node),
|
|
343
|
+
onNodeLost: (id) => localNodeStore.getState().removeDiscoveredNode(id),
|
|
344
|
+
});
|
|
345
|
+
this.published = true;
|
|
346
|
+
syncLogger.info("LocalNodeService", "Published mDNS advertisement", { port });
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
this.published = false;
|
|
350
|
+
syncLogger.warn("LocalNodeService", "Publish failed — will retry on next initialize/reconnect (scan continues)", {
|
|
351
|
+
error: String(e),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
startDiscovery() {
|
|
356
|
+
const deps = this.getDeps();
|
|
357
|
+
deps.discovery.setOptions({
|
|
358
|
+
deviceId: (() => { try {
|
|
359
|
+
const v = deps.getDeviceId();
|
|
360
|
+
return typeof v === "string" ? v : "";
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return "";
|
|
364
|
+
} })(),
|
|
365
|
+
deviceName: deps.getDeviceName?.(),
|
|
366
|
+
onNodeFound: (node) => {
|
|
367
|
+
if (node.deviceId === this.cachedDeviceId)
|
|
368
|
+
return;
|
|
369
|
+
localNodeStore.getState().addDiscoveredNode(node);
|
|
370
|
+
},
|
|
371
|
+
onNodeLost: (deviceId) => localNodeStore.getState().removeDiscoveredNode(deviceId),
|
|
372
|
+
});
|
|
373
|
+
deps.discovery.startScanning();
|
|
374
|
+
}
|
|
375
|
+
/** Start scanning if not already running (idempotent drivers ignore repeats). */
|
|
376
|
+
ensureDiscovery() {
|
|
377
|
+
if (this.discovering) {
|
|
378
|
+
try {
|
|
379
|
+
this.getDeps().discovery.startScanning();
|
|
380
|
+
}
|
|
381
|
+
catch { }
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
this.startDiscovery();
|
|
386
|
+
this.discovering = true;
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
this.discovering = false;
|
|
390
|
+
syncLogger.warn("LocalNodeService", "Discovery start failed — will retry on next initialize/reconnect", {
|
|
391
|
+
error: String(e),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
startPeerSweep() {
|
|
396
|
+
// mDNS is lossy: browse windows miss peers and re-announces are not
|
|
397
|
+
// guaranteed, so discovery events alone cannot drive (re)dial. Sweep
|
|
398
|
+
// every 15s; connectToPeer self-guards (connected or in-flight → skip),
|
|
399
|
+
// so the steady state cost is one connected-check per known peer.
|
|
400
|
+
this.stopPeerSweep();
|
|
401
|
+
this.sweepTimer = setInterval(() => {
|
|
402
|
+
try {
|
|
403
|
+
if (this.mode !== "mesh")
|
|
404
|
+
return;
|
|
405
|
+
const nodes = localNodeStore.getState().discoveredNodes;
|
|
406
|
+
recordAttempt("sweep_tick", `known=${nodes.length}`);
|
|
407
|
+
for (const n of nodes) {
|
|
408
|
+
if (n.deviceId === this.cachedDeviceId)
|
|
409
|
+
continue;
|
|
410
|
+
if (this.peers.get(n.deviceId)?.isConnected())
|
|
411
|
+
continue;
|
|
412
|
+
this.connectToPeer(n);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch { }
|
|
416
|
+
}, 15000);
|
|
417
|
+
try {
|
|
418
|
+
this.sweepTimer.unref?.();
|
|
419
|
+
}
|
|
420
|
+
catch { }
|
|
421
|
+
}
|
|
422
|
+
stopPeerSweep() {
|
|
423
|
+
if (this.sweepTimer) {
|
|
424
|
+
try {
|
|
425
|
+
clearInterval(this.sweepTimer);
|
|
426
|
+
}
|
|
427
|
+
catch { }
|
|
428
|
+
this.sweepTimer = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
connectToPeer(node) {
|
|
432
|
+
if (this.mode !== "mesh")
|
|
433
|
+
return;
|
|
434
|
+
// Foreign-business backoff (see mismatchBackoff).
|
|
435
|
+
const mismatchedAt = this.mismatchBackoff.get(node.deviceId);
|
|
436
|
+
if (mismatchedAt && Date.now() - mismatchedAt < LocalNodeServiceCore.MISMATCH_BACKOFF_MS)
|
|
437
|
+
return;
|
|
438
|
+
// One dial per peer at a time: discovery fires in bursts (initial browse,
|
|
439
|
+
// re-announces) and parallel dials destroy each other's sockets mid-
|
|
440
|
+
// handshake — a livelock where nothing ever completes. A second attempt
|
|
441
|
+
// while one is in flight is always redundant.
|
|
442
|
+
if (this.dialInFlight.has(node.deviceId))
|
|
443
|
+
return;
|
|
444
|
+
if (this.peers.has(node.deviceId)) {
|
|
445
|
+
const c = this.peers.get(node.deviceId);
|
|
446
|
+
if (c?.isConnected())
|
|
447
|
+
return;
|
|
448
|
+
// Stale attempt (failed/in-flight): release it before replacing so
|
|
449
|
+
// listeners/sockets don't accumulate across scan cycles. The Rust side
|
|
450
|
+
// only tears down the named connection (connId check), so a newer
|
|
451
|
+
// attempt can never be killed by this.
|
|
452
|
+
try {
|
|
453
|
+
c?.disconnect();
|
|
454
|
+
}
|
|
455
|
+
catch { }
|
|
456
|
+
this.peers.delete(node.deviceId);
|
|
457
|
+
this.peerInfos.delete(node.deviceId);
|
|
458
|
+
}
|
|
459
|
+
if (!this.businessId || !this.secret) {
|
|
460
|
+
syncLogger.warn("LocalNodeService", "Cannot connect: no business context", {});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const deps = this.getDeps();
|
|
464
|
+
const client = deps.transport.createClient({
|
|
465
|
+
node: { deviceId: node.deviceId, host: node.host, port: node.port },
|
|
466
|
+
businessId: this.businessId,
|
|
467
|
+
secret: this.secret,
|
|
468
|
+
onEvent: (event) => {
|
|
469
|
+
syncLogger.event(event, "received", { origin: "lan" });
|
|
470
|
+
if (event.event_type === "order:sync") {
|
|
471
|
+
void (async () => {
|
|
472
|
+
try {
|
|
473
|
+
const p = event.payload;
|
|
474
|
+
if (p && typeof p.order_number === "string" && p.id) {
|
|
475
|
+
const db = await openSyncDatabase();
|
|
476
|
+
await insertOrder(db, p);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const action = p.action;
|
|
480
|
+
const orderId = p.orderId;
|
|
481
|
+
const bid = deps.getBusiness()?.id;
|
|
482
|
+
if (action && orderId && bid) {
|
|
483
|
+
try {
|
|
484
|
+
await transitionLocalOrder(bid, String(orderId), action);
|
|
485
|
+
}
|
|
486
|
+
catch { }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
syncLogger.debug("LocalNodeService", "LAN order:sync apply failed", { err: String(e) });
|
|
492
|
+
}
|
|
493
|
+
// The row is in SQLite now — invalidate so lists/detail refetch.
|
|
494
|
+
// (order:sync bypasses the engine pipeline, so no subscriber does
|
|
495
|
+
// this for us.)
|
|
496
|
+
notifyPendingOrdersChanged();
|
|
497
|
+
})();
|
|
498
|
+
}
|
|
499
|
+
applyPendingOrderEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN event", { error: String(e), event_type: event.event_type }));
|
|
500
|
+
applyReferenceChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN reference", { error: String(e), event_type: event.event_type }));
|
|
501
|
+
applyOrderStateChangeEvent(event, "lan").catch((e) => syncLogger.error("LocalNodeService", "Failed to apply LAN order state", { error: String(e), event_type: event.event_type }));
|
|
502
|
+
},
|
|
503
|
+
onConnected: () => {
|
|
504
|
+
this.settleDial(node.deviceId);
|
|
505
|
+
this.mismatchBackoff.delete(node.deviceId);
|
|
506
|
+
syncLogger.info("LocalNodeService", "Connected to peer", { deviceId: node.deviceId });
|
|
507
|
+
this.peerInfos.set(node.deviceId, { deviceId: node.deviceId, host: node.host, port: node.port });
|
|
508
|
+
localNodeStore.getState().addConnectedClient({ deviceId: node.deviceId, deviceName: node.deviceName, host: node.host, connectedAt: new Date().toISOString() });
|
|
509
|
+
},
|
|
510
|
+
onDisconnected: () => {
|
|
511
|
+
this.settleDial(node.deviceId);
|
|
512
|
+
syncLogger.warn("LocalNodeService", "Disconnected from peer", { deviceId: node.deviceId });
|
|
513
|
+
this.peers.delete(node.deviceId);
|
|
514
|
+
this.peerInfos.delete(node.deviceId);
|
|
515
|
+
localNodeStore.getState().removeConnectedClient(node.deviceId);
|
|
516
|
+
},
|
|
517
|
+
onError: (error) => {
|
|
518
|
+
if (String(error).includes("business_mismatch")) {
|
|
519
|
+
this.mismatchBackoff.set(node.deviceId, Date.now());
|
|
520
|
+
}
|
|
521
|
+
syncLogger.debug("LocalNodeService", "Peer connect error", { error: String(error), deviceId: node.deviceId });
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
this.peers.set(node.deviceId, client);
|
|
525
|
+
this.peerInfos.set(node.deviceId, { deviceId: node.deviceId, host: node.host, port: node.port });
|
|
526
|
+
this.dialInFlight.add(node.deviceId);
|
|
527
|
+
recordAttempt("lan_dial", node.deviceId.slice(0, 8));
|
|
528
|
+
// Wedged-handshake reaper: if neither connected nor failed within the TTL
|
|
529
|
+
// (no callback ever fires), release the socket so the next discovery
|
|
530
|
+
// redials instead of piling up parallel attempts.
|
|
531
|
+
try {
|
|
532
|
+
clearTimeout(this.dialTimers.get(node.deviceId));
|
|
533
|
+
}
|
|
534
|
+
catch { }
|
|
535
|
+
const ttl = setTimeout(() => {
|
|
536
|
+
this.dialTimers.delete(node.deviceId);
|
|
537
|
+
if (!this.dialInFlight.has(node.deviceId))
|
|
538
|
+
return;
|
|
539
|
+
this.dialInFlight.delete(node.deviceId);
|
|
540
|
+
const c = this.peers.get(node.deviceId);
|
|
541
|
+
if (c && !c.isConnected()) {
|
|
542
|
+
recordAttempt("ttl_reap", node.deviceId.slice(0, 8));
|
|
543
|
+
syncLogger.debug("LocalNodeService", "Dial TTL expired, reaping wedged attempt", { deviceId: node.deviceId });
|
|
544
|
+
try {
|
|
545
|
+
c.disconnect();
|
|
546
|
+
}
|
|
547
|
+
catch { }
|
|
548
|
+
this.peers.delete(node.deviceId);
|
|
549
|
+
this.peerInfos.delete(node.deviceId);
|
|
550
|
+
}
|
|
551
|
+
}, 10000);
|
|
552
|
+
// Never hold the process (or test runner) open for a wedged peer.
|
|
553
|
+
try {
|
|
554
|
+
ttl.unref?.();
|
|
555
|
+
}
|
|
556
|
+
catch { }
|
|
557
|
+
this.dialTimers.set(node.deviceId, ttl);
|
|
558
|
+
client.connect().catch((e) => {
|
|
559
|
+
this.settleDial(node.deviceId);
|
|
560
|
+
if (String(e?.message ?? e).includes("business_mismatch")) {
|
|
561
|
+
this.mismatchBackoff.set(node.deviceId, Date.now());
|
|
562
|
+
}
|
|
563
|
+
syncLogger.debug("LocalNodeService", "Peer connect failed, will retry on next discovery", { error: String(e), deviceId: node.deviceId });
|
|
564
|
+
// Keep peer entry so next discovery will retry; or remove to allow fresh connect
|
|
565
|
+
// For mesh, keep and let discovery re-add
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
settleDial(deviceId) {
|
|
569
|
+
this.dialInFlight.delete(deviceId);
|
|
570
|
+
const t = this.dialTimers.get(deviceId);
|
|
571
|
+
if (t) {
|
|
572
|
+
try {
|
|
573
|
+
clearTimeout(t);
|
|
574
|
+
}
|
|
575
|
+
catch { }
|
|
576
|
+
this.dialTimers.delete(deviceId);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async handleNodeCommand(command) {
|
|
580
|
+
syncLogger.command(command, "executing", { via: "local_node" });
|
|
581
|
+
// Menu writes apply to the durable reference cache (NOT the cloud) so a
|
|
582
|
+
// dark LAN converges; peers pull via reference_fetch after the
|
|
583
|
+
// reference_data.changed fan-out below. Cloud replay re-applies through
|
|
584
|
+
// the cloud path where the backend owns versions.
|
|
585
|
+
if (command.command_type.startsWith("menu:")) {
|
|
586
|
+
const { keys } = await applyMenuCommandLocal(command.business_id, command);
|
|
587
|
+
if (!keys.length)
|
|
588
|
+
return null;
|
|
589
|
+
const evt = {
|
|
590
|
+
event_id: `lan-${command.command_id}`,
|
|
591
|
+
event_type: REFERENCE_CHANGED_EVENT_TYPE,
|
|
592
|
+
event_version: "1",
|
|
593
|
+
entity_id: command.business_id,
|
|
594
|
+
user_id: command.user_id,
|
|
595
|
+
device_id: command.device_id,
|
|
596
|
+
payload: { business_id: command.business_id, keys },
|
|
597
|
+
timestamp: new Date().toISOString(),
|
|
598
|
+
};
|
|
599
|
+
this.broadcastToClients(evt);
|
|
600
|
+
return evt;
|
|
601
|
+
}
|
|
602
|
+
if (command.command_type.startsWith("order:") || command.command_type.startsWith("payment:")) {
|
|
603
|
+
try {
|
|
604
|
+
const bid = command.business_id;
|
|
605
|
+
const payload = command.payload;
|
|
606
|
+
if (command.command_type === "order:create") {
|
|
607
|
+
const order = await createLocalOrder(bid, {
|
|
608
|
+
...payload,
|
|
609
|
+
command_id: command.command_id,
|
|
610
|
+
id: command.entity_id ?? command.command_id,
|
|
611
|
+
order_number: payload.order_number ?? payload.local_order_number,
|
|
612
|
+
});
|
|
613
|
+
const evt = {
|
|
614
|
+
event_id: `lan-${command.command_id}`,
|
|
615
|
+
event_type: "order:sync",
|
|
616
|
+
event_version: "1",
|
|
617
|
+
entity_id: order.id,
|
|
618
|
+
user_id: command.user_id,
|
|
619
|
+
device_id: command.device_id,
|
|
620
|
+
payload: order,
|
|
621
|
+
timestamp: new Date().toISOString(),
|
|
622
|
+
};
|
|
623
|
+
this.broadcastToClients(evt);
|
|
624
|
+
return evt;
|
|
625
|
+
}
|
|
626
|
+
const map = { "order:accept": "accept", "order:reject": "reject", "order:start_preparing": "start_preparing", "order:mark_ready": "mark_ready", "order:complete_pickup": "complete_pickup", "order:cancel": "cancel", "payment:confirm_cash": "confirm_cash" };
|
|
627
|
+
const action = map[command.command_type] ?? command.command_type.replace(/^order:/, "");
|
|
628
|
+
const orderId = command.entity_id ?? payload.orderId ?? payload.order_id;
|
|
629
|
+
if (orderId) {
|
|
630
|
+
const updated = await transitionLocalOrder(bid, String(orderId), action);
|
|
631
|
+
const evt = {
|
|
632
|
+
event_id: `lan-${command.command_id}`,
|
|
633
|
+
event_type: "order:sync",
|
|
634
|
+
event_version: "1",
|
|
635
|
+
entity_id: String(orderId),
|
|
636
|
+
user_id: command.user_id,
|
|
637
|
+
device_id: command.device_id,
|
|
638
|
+
// Full updated order (not just action+id) so repository callers
|
|
639
|
+
// awaiting engine results get an order-shaped payload on every
|
|
640
|
+
// path (cloud / LAN / queue-optimistic). Peers still apply via
|
|
641
|
+
// transitionLocalOrder for their own read models.
|
|
642
|
+
payload: { action, orderId: String(orderId), order: updated, ...updated },
|
|
643
|
+
timestamp: new Date().toISOString(),
|
|
644
|
+
};
|
|
645
|
+
this.broadcastToClients(evt);
|
|
646
|
+
return evt;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
const code = e?.code;
|
|
651
|
+
if (code === "BUSINESS_CLOSED" || code === "HOURS_NOT_SYNCED" || code === "FORBIDDEN") {
|
|
652
|
+
syncLogger.debug("LocalNodeService", "LAN handle rejected by lib guard, no fallback", { err: String(e), code });
|
|
653
|
+
throw e;
|
|
654
|
+
}
|
|
655
|
+
syncLogger.debug("LocalNodeService", "New-DB LAN handle failed, fallback to legacy queue", { err: String(e) });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const cloudReachable = (() => { try {
|
|
659
|
+
const s = getConnectivityState();
|
|
660
|
+
return s?.isCloudReachable === "reachable" || s?.isCloudReachable === "unknown";
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return true;
|
|
664
|
+
} })();
|
|
665
|
+
let event = null;
|
|
666
|
+
const timeoutMs = this.getDeps().cloudCommandTimeoutMs ?? CLOUD_TIMEOUT_DEFAULT;
|
|
667
|
+
if (cloudReachable) {
|
|
668
|
+
try {
|
|
669
|
+
const events = await withTimeout(restTransport.execute(command), timeoutMs);
|
|
670
|
+
event = events[0] ?? null;
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
syncLogger.warn("LocalNodeService", "Cloud forward failed, queueing", { error: String(error) });
|
|
674
|
+
try {
|
|
675
|
+
setConnectivityState({ isCloudReachable: "unreachable" });
|
|
676
|
+
}
|
|
677
|
+
catch { }
|
|
678
|
+
event = await commandQueueService.enqueue(command);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
event = await commandQueueService.enqueue(command);
|
|
683
|
+
}
|
|
684
|
+
if (event)
|
|
685
|
+
this.broadcastToClients(event);
|
|
686
|
+
return event;
|
|
687
|
+
}
|
|
688
|
+
async requestReferenceData(businessId, keys) {
|
|
689
|
+
// Mesh: ask any connected peer, or all peers and merge
|
|
690
|
+
for (const c of this.peers.values()) {
|
|
691
|
+
if (!c.isConnected() || !c.requestReferenceData)
|
|
692
|
+
continue;
|
|
693
|
+
try {
|
|
694
|
+
const res = await c.requestReferenceData(businessId, keys);
|
|
695
|
+
if (res?.data && Object.keys(res.data).length)
|
|
696
|
+
return res.data;
|
|
697
|
+
}
|
|
698
|
+
catch { }
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
getState() { return { isLocalNode: this.isLocalNode(), isConnectedToNode: this.isConnectedToNode(), electedNode: this.getElectedNode(), client: this.getClient() }; }
|
|
703
|
+
}
|
|
704
|
+
LocalNodeServiceCore.MISMATCH_BACKOFF_MS = 60000;
|
|
705
|
+
export const localNodeService = new LocalNodeServiceCore();
|
|
706
|
+
export function initLocalNodeService(deps) { localNodeService.init(deps); return localNodeService; }
|