@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.
Files changed (198) hide show
  1. package/README.md +26 -21
  2. package/dist/adapters/auth.d.ts +10 -0
  3. package/dist/adapters/auth.js +43 -0
  4. package/dist/adapters/devices.d.ts +3 -1
  5. package/dist/adapters/devices.js +187 -3
  6. package/dist/adapters/filesystem.d.ts +4 -0
  7. package/dist/adapters/kv.d.ts +10 -0
  8. package/dist/adapters/kv.js +23 -7
  9. package/dist/adapters/local-node.d.ts +4 -6
  10. package/dist/adapters/local-node.js +9 -32
  11. package/dist/adapters/notify.d.ts +12 -0
  12. package/dist/adapters/notify.js +33 -0
  13. package/dist/adapters/query-keys.d.ts +4 -0
  14. package/dist/adapters/query-keys.js +4 -0
  15. package/dist/adapters/services.d.ts +99 -15
  16. package/dist/adapters/services.js +244 -4
  17. package/dist/auth/session.d.ts +66 -0
  18. package/dist/auth/session.js +172 -0
  19. package/dist/catalog/hooks.d.ts +10 -0
  20. package/dist/catalog/hooks.js +82 -11
  21. package/dist/db/migrations.js +274 -0
  22. package/dist/db/operations.d.ts +53 -2
  23. package/dist/db/operations.js +177 -8
  24. package/dist/domain/business/index.d.ts +5 -0
  25. package/dist/domain/business/index.js +5 -0
  26. package/dist/domain/orders/lifecycle.d.ts +6 -0
  27. package/dist/domain/orders/lifecycle.js +6 -0
  28. package/dist/domain/orders/service.d.ts +24 -0
  29. package/dist/domain/orders/service.js +242 -0
  30. package/dist/domain/payments/index.d.ts +6 -0
  31. package/dist/domain/payments/index.js +5 -0
  32. package/dist/domain/reference/index.d.ts +8 -0
  33. package/dist/domain/reference/index.js +8 -0
  34. package/dist/e2e/harness.d.ts +1 -0
  35. package/dist/e2e/harness.js +61 -0
  36. package/dist/hooks/analytics.hooks.d.ts +25 -0
  37. package/dist/hooks/analytics.hooks.js +214 -0
  38. package/dist/hooks/business-hours.hooks.d.ts +8 -0
  39. package/dist/hooks/business-hours.hooks.js +45 -0
  40. package/dist/hooks/dashboard.hooks.d.ts +30 -0
  41. package/dist/hooks/dashboard.hooks.js +132 -0
  42. package/dist/hooks/delivery.hooks.d.ts +225 -0
  43. package/dist/hooks/delivery.hooks.js +80 -0
  44. package/dist/hooks/fleet.hooks.d.ts +37 -0
  45. package/dist/hooks/fleet.hooks.js +229 -0
  46. package/dist/hooks/image.hooks.d.ts +20 -0
  47. package/dist/hooks/image.hooks.js +62 -0
  48. package/dist/hooks/kiosk.hooks.d.ts +22 -5
  49. package/dist/hooks/kiosk.hooks.js +74 -5
  50. package/dist/hooks/members.hooks.d.ts +48 -0
  51. package/dist/hooks/members.hooks.js +302 -0
  52. package/dist/hooks/offline-orders.hooks.d.ts +9 -0
  53. package/dist/hooks/offline-orders.hooks.js +51 -0
  54. package/dist/hooks/orders.hooks.d.ts +19 -39
  55. package/dist/hooks/orders.hooks.js +126 -321
  56. package/dist/hooks/places.hooks.d.ts +21 -0
  57. package/dist/hooks/places.hooks.js +112 -0
  58. package/dist/hooks/realtime-stubs.d.ts +28 -0
  59. package/dist/hooks/realtime-stubs.js +85 -0
  60. package/dist/hooks/rider.hooks.d.ts +470 -0
  61. package/dist/hooks/rider.hooks.js +373 -0
  62. package/dist/hooks/use-infinite-list.d.ts +243 -0
  63. package/dist/hooks/use-infinite-list.js +58 -0
  64. package/dist/hooks/wallet.hooks.d.ts +219 -0
  65. package/dist/hooks/wallet.hooks.js +56 -0
  66. package/dist/image-cache/service.d.ts +8 -0
  67. package/dist/image-cache/service.js +15 -3
  68. package/dist/index.d.ts +60 -5
  69. package/dist/index.js +78 -5
  70. package/dist/local-node/adapters.d.ts +87 -0
  71. package/dist/local-node/adapters.js +4 -0
  72. package/dist/local-node/index.d.ts +5 -0
  73. package/dist/local-node/index.js +5 -0
  74. package/dist/local-node/security.d.ts +25 -0
  75. package/dist/local-node/security.js +149 -0
  76. package/dist/local-node/service.d.ts +83 -0
  77. package/dist/local-node/service.js +706 -0
  78. package/dist/local-node/store.d.ts +50 -0
  79. package/dist/local-node/store.js +284 -0
  80. package/dist/local-node/types.d.ts +92 -0
  81. package/dist/local-node/types.js +4 -0
  82. package/dist/printer/receipt.d.ts +4 -0
  83. package/dist/printer/receipt.js +75 -0
  84. package/dist/printer/service.js +1 -1
  85. package/dist/providers/query-client.d.ts +13 -0
  86. package/dist/providers/query-client.js +58 -0
  87. package/dist/realtime/events.service.d.ts +18 -0
  88. package/dist/realtime/events.service.js +76 -0
  89. package/dist/realtime/order-events.service.d.ts +6 -0
  90. package/dist/realtime/order-events.service.js +17 -0
  91. package/dist/realtime/phoenix-socket.d.ts +55 -0
  92. package/dist/realtime/phoenix-socket.js +269 -0
  93. package/dist/realtime/useEventsConnection.d.ts +1 -0
  94. package/dist/realtime/useEventsConnection.js +50 -0
  95. package/dist/reference/api.d.ts +2 -0
  96. package/dist/reference/api.js +2 -0
  97. package/dist/reference/capabilities.d.ts +12 -7
  98. package/dist/reference/capabilities.js +28 -13
  99. package/dist/reference/hooks.d.ts +34 -0
  100. package/dist/reference/hooks.js +124 -0
  101. package/dist/reference/menu-apply.d.ts +29 -0
  102. package/dist/reference/menu-apply.js +289 -0
  103. package/dist/reference/menu-record.d.ts +69 -0
  104. package/dist/reference/menu-record.js +163 -0
  105. package/dist/reference/operations.d.ts +3 -0
  106. package/dist/reference/operations.js +35 -1
  107. package/dist/reference/service.js +36 -9
  108. package/dist/reference/types.d.ts +1 -1
  109. package/dist/repositories/order.repository.d.ts +8 -0
  110. package/dist/repositories/order.repository.js +24 -2
  111. package/dist/repositories/pos.repository.d.ts +4 -0
  112. package/dist/repositories/pos.repository.js +4 -0
  113. package/dist/sync/backoff.d.ts +21 -0
  114. package/dist/sync/backoff.js +42 -0
  115. package/dist/sync/command-builder.js +10 -2
  116. package/dist/sync/command-queue.service.d.ts +12 -0
  117. package/dist/sync/command-queue.service.js +115 -18
  118. package/dist/sync/device-lease.service.d.ts +3 -3
  119. package/dist/sync/device-lease.service.js +80 -20
  120. package/dist/sync/engine.d.ts +3 -1
  121. package/dist/sync/engine.js +44 -4
  122. package/dist/sync/full-sync.d.ts +6 -0
  123. package/dist/sync/full-sync.js +72 -0
  124. package/dist/sync/health-probe.d.ts +24 -0
  125. package/dist/sync/health-probe.js +138 -0
  126. package/dist/sync/offline-capable.js +25 -0
  127. package/dist/sync/online-gates.d.ts +17 -0
  128. package/dist/sync/online-gates.js +55 -0
  129. package/dist/sync/order-number.d.ts +4 -4
  130. package/dist/sync/order-number.js +33 -13
  131. package/dist/sync/order-snapshots.service.d.ts +1 -0
  132. package/dist/sync/order-snapshots.service.js +47 -2
  133. package/dist/sync/pending-orders.service.d.ts +18 -0
  134. package/dist/sync/pending-orders.service.js +148 -3
  135. package/dist/sync/reconciliation/index.d.ts +5 -0
  136. package/dist/sync/reconciliation/index.js +5 -0
  137. package/dist/sync/snapshots/entity.d.ts +6 -0
  138. package/dist/sync/snapshots/entity.js +6 -0
  139. package/dist/sync/sync-jobs.service.d.ts +18 -0
  140. package/dist/sync/sync-jobs.service.js +539 -0
  141. package/dist/sync/transition-endpoints.d.ts +11 -0
  142. package/dist/sync/transition-endpoints.js +30 -0
  143. package/dist/sync/transport-local-node.js +3 -2
  144. package/dist/sync/transport-rest.d.ts +7 -0
  145. package/dist/sync/transport-rest.js +90 -25
  146. package/dist/sync/use-sync-reconciliation.js +58 -16
  147. package/dist/test-helpers/memory-driver.js +182 -4
  148. package/dist/test-helpers/seed-hours.d.ts +1 -0
  149. package/dist/test-helpers/seed-hours.js +23 -0
  150. package/dist/types/api/business.api.d.ts +42 -0
  151. package/dist/types/api/menu.api.d.ts +15 -0
  152. package/dist/types/api/seller.api.d.ts +20 -0
  153. package/dist/types/places.d.ts +19 -0
  154. package/dist/types/places.js +2 -0
  155. package/dist/types/rider.d.ts +67 -0
  156. package/dist/types/rider.js +2 -0
  157. package/dist/utils/attempt-stats.d.ts +23 -0
  158. package/dist/utils/attempt-stats.js +61 -0
  159. package/dist/utils/business-hours.d.ts +37 -0
  160. package/dist/utils/business-hours.js +96 -0
  161. package/dist/utils/debug-log.d.ts +23 -0
  162. package/dist/utils/debug-log.js +153 -0
  163. package/dist/utils/order-filter.d.ts +1 -1
  164. package/dist/utils/order-filter.js +1 -1
  165. package/dist/utils/order-transitions.d.ts +30 -0
  166. package/dist/utils/order-transitions.js +185 -0
  167. package/dist/utils/sync-logger.js +5 -4
  168. package/dist/validation/offline.d.ts +46 -0
  169. package/dist/validation/offline.js +79 -0
  170. package/package.json +6 -1
  171. package/dist/pricing/engine.test.d.ts +0 -1
  172. package/dist/pricing/engine.test.js +0 -142
  173. package/dist/reference/lww.test.d.ts +0 -1
  174. package/dist/reference/lww.test.js +0 -23
  175. package/dist/reference/operations.test.d.ts +0 -1
  176. package/dist/reference/operations.test.js +0 -66
  177. package/dist/standalone.integration.test.d.ts +0 -1
  178. package/dist/standalone.integration.test.js +0 -235
  179. package/dist/sync/device-lease.test.d.ts +0 -1
  180. package/dist/sync/device-lease.test.js +0 -38
  181. package/dist/sync/offline-integration.test.d.ts +0 -1
  182. package/dist/sync/offline-integration.test.js +0 -52
  183. package/dist/sync/order-number.test.d.ts +0 -1
  184. package/dist/sync/order-number.test.js +0 -51
  185. package/dist/sync/perms.test.d.ts +0 -1
  186. package/dist/sync/perms.test.js +0 -15
  187. package/dist/utils/currency.test.d.ts +0 -1
  188. package/dist/utils/currency.test.js +0 -17
  189. package/dist/utils/hlc.test.d.ts +0 -1
  190. package/dist/utils/hlc.test.js +0 -29
  191. package/dist/utils/phone.test.d.ts +0 -1
  192. package/dist/utils/phone.test.js +0 -19
  193. package/dist/utils/responsive.d.ts +0 -13
  194. package/dist/utils/responsive.js +0 -18
  195. package/dist/utils/uuid.test.d.ts +0 -1
  196. package/dist/utils/uuid.test.js +0 -13
  197. package/dist/utils/validation.test.d.ts +0 -1
  198. 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; }