@teincfood/core 0.7.9 → 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 (191) hide show
  1. package/README.md +26 -21
  2. package/dist/adapters/auth.d.ts +8 -0
  3. package/dist/adapters/auth.js +36 -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 +96 -15
  16. package/dist/adapters/services.js +241 -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 +18 -39
  55. package/dist/hooks/orders.hooks.js +124 -343
  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 +56 -6
  69. package/dist/index.js +73 -6
  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/phoenix-socket.js +30 -1
  88. package/dist/realtime/useEventsConnection.js +11 -2
  89. package/dist/reference/api.d.ts +2 -0
  90. package/dist/reference/api.js +2 -0
  91. package/dist/reference/capabilities.d.ts +12 -7
  92. package/dist/reference/capabilities.js +27 -13
  93. package/dist/reference/hooks.d.ts +34 -0
  94. package/dist/reference/hooks.js +124 -0
  95. package/dist/reference/menu-apply.d.ts +29 -0
  96. package/dist/reference/menu-apply.js +289 -0
  97. package/dist/reference/menu-record.d.ts +69 -0
  98. package/dist/reference/menu-record.js +163 -0
  99. package/dist/reference/operations.d.ts +3 -0
  100. package/dist/reference/operations.js +35 -1
  101. package/dist/reference/service.js +36 -9
  102. package/dist/reference/types.d.ts +1 -1
  103. package/dist/repositories/order.repository.d.ts +7 -0
  104. package/dist/repositories/order.repository.js +15 -2
  105. package/dist/repositories/pos.repository.d.ts +4 -0
  106. package/dist/repositories/pos.repository.js +4 -0
  107. package/dist/sync/backoff.d.ts +21 -0
  108. package/dist/sync/backoff.js +42 -0
  109. package/dist/sync/command-builder.js +10 -2
  110. package/dist/sync/command-queue.service.d.ts +12 -0
  111. package/dist/sync/command-queue.service.js +81 -16
  112. package/dist/sync/device-lease.service.d.ts +3 -3
  113. package/dist/sync/device-lease.service.js +80 -20
  114. package/dist/sync/engine.d.ts +3 -1
  115. package/dist/sync/engine.js +44 -4
  116. package/dist/sync/full-sync.d.ts +6 -0
  117. package/dist/sync/full-sync.js +72 -0
  118. package/dist/sync/health-probe.d.ts +24 -0
  119. package/dist/sync/health-probe.js +138 -0
  120. package/dist/sync/offline-capable.js +25 -0
  121. package/dist/sync/online-gates.d.ts +17 -0
  122. package/dist/sync/online-gates.js +55 -0
  123. package/dist/sync/order-number.d.ts +4 -4
  124. package/dist/sync/order-number.js +33 -13
  125. package/dist/sync/order-snapshots.service.js +18 -3
  126. package/dist/sync/pending-orders.service.d.ts +18 -0
  127. package/dist/sync/pending-orders.service.js +67 -1
  128. package/dist/sync/reconciliation/index.d.ts +5 -0
  129. package/dist/sync/reconciliation/index.js +5 -0
  130. package/dist/sync/snapshots/entity.d.ts +6 -0
  131. package/dist/sync/snapshots/entity.js +6 -0
  132. package/dist/sync/sync-jobs.service.d.ts +18 -0
  133. package/dist/sync/sync-jobs.service.js +539 -0
  134. package/dist/sync/transition-endpoints.d.ts +11 -0
  135. package/dist/sync/transition-endpoints.js +30 -0
  136. package/dist/sync/transport-local-node.js +3 -2
  137. package/dist/sync/transport-rest.d.ts +7 -0
  138. package/dist/sync/transport-rest.js +84 -26
  139. package/dist/sync/use-sync-reconciliation.js +15 -1
  140. package/dist/test-helpers/memory-driver.js +182 -4
  141. package/dist/test-helpers/seed-hours.d.ts +1 -0
  142. package/dist/test-helpers/seed-hours.js +23 -0
  143. package/dist/types/api/business.api.d.ts +42 -0
  144. package/dist/types/api/menu.api.d.ts +15 -0
  145. package/dist/types/api/seller.api.d.ts +20 -0
  146. package/dist/types/places.d.ts +19 -0
  147. package/dist/types/places.js +2 -0
  148. package/dist/types/rider.d.ts +67 -0
  149. package/dist/types/rider.js +2 -0
  150. package/dist/utils/attempt-stats.d.ts +23 -0
  151. package/dist/utils/attempt-stats.js +61 -0
  152. package/dist/utils/business-hours.d.ts +37 -0
  153. package/dist/utils/business-hours.js +96 -0
  154. package/dist/utils/debug-log.d.ts +23 -0
  155. package/dist/utils/debug-log.js +153 -0
  156. package/dist/utils/order-filter.d.ts +1 -1
  157. package/dist/utils/order-filter.js +1 -1
  158. package/dist/utils/order-transitions.d.ts +30 -0
  159. package/dist/utils/order-transitions.js +185 -0
  160. package/dist/utils/sync-logger.js +5 -4
  161. package/dist/validation/offline.d.ts +46 -0
  162. package/dist/validation/offline.js +79 -0
  163. package/package.json +6 -1
  164. package/dist/pricing/engine.test.d.ts +0 -1
  165. package/dist/pricing/engine.test.js +0 -142
  166. package/dist/reference/lww.test.d.ts +0 -1
  167. package/dist/reference/lww.test.js +0 -23
  168. package/dist/reference/operations.test.d.ts +0 -1
  169. package/dist/reference/operations.test.js +0 -66
  170. package/dist/standalone.integration.test.d.ts +0 -1
  171. package/dist/standalone.integration.test.js +0 -235
  172. package/dist/sync/device-lease.test.d.ts +0 -1
  173. package/dist/sync/device-lease.test.js +0 -38
  174. package/dist/sync/offline-integration.test.d.ts +0 -1
  175. package/dist/sync/offline-integration.test.js +0 -52
  176. package/dist/sync/order-number.test.d.ts +0 -1
  177. package/dist/sync/order-number.test.js +0 -51
  178. package/dist/sync/perms.test.d.ts +0 -1
  179. package/dist/sync/perms.test.js +0 -15
  180. package/dist/utils/currency.test.d.ts +0 -1
  181. package/dist/utils/currency.test.js +0 -17
  182. package/dist/utils/hlc.test.d.ts +0 -1
  183. package/dist/utils/hlc.test.js +0 -29
  184. package/dist/utils/phone.test.d.ts +0 -1
  185. package/dist/utils/phone.test.js +0 -19
  186. package/dist/utils/responsive.d.ts +0 -13
  187. package/dist/utils/responsive.js +0 -18
  188. package/dist/utils/uuid.test.d.ts +0 -1
  189. package/dist/utils/uuid.test.js +0 -13
  190. package/dist/utils/validation.test.d.ts +0 -1
  191. package/dist/utils/validation.test.js +0 -23
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Sync Jobs FIFO — near real-time drain after local commit.
3
+ * Tries PHX business:{id} push first, then REST, queues on fail.
4
+ * Core-only, single in_flight per business, coalesced via isOnline gate.
5
+ */
6
+ import { openSyncDatabase } from "../db/connection";
7
+ import { insertOrder } from "../db/operations";
8
+ import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
9
+ import { getAuthToken, getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
10
+ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
11
+ import { getHttpClient } from "../adapters/http";
12
+ import { phoenixSocket } from "../realtime/phoenix-socket";
13
+ import { syncLogger } from "../utils/sync-logger";
14
+ import { recordAttempt } from "../utils/attempt-stats";
15
+ import { BackoffTracker } from "./backoff";
16
+ import { generateOrderNumber } from "./order-number";
17
+ import { orderSnapshotsService } from "./order-snapshots.service";
18
+ import { getTransitionPath } from "./transition-endpoints";
19
+ const inflight = new Map();
20
+ const retryTimers = new Map();
21
+ const backoffTracker = new BackoffTracker();
22
+ function isOnline() {
23
+ const s = getConnectivityState();
24
+ return s.isOnline && s.isCloudReachable !== "unreachable";
25
+ }
26
+ function isOnlineForRest() {
27
+ // Health probe failure = device offline per spec — don't hammer REST with 5xx/network when backend unreachable
28
+ const s = getConnectivityState();
29
+ return s.isOnline && s.isCloudReachable !== "unreachable";
30
+ }
31
+ function isOnlineForPhx() {
32
+ const s = getConnectivityState();
33
+ return s.isOnline && s.isCloudReachable === "reachable";
34
+ }
35
+ function scheduleRetry(businessId) {
36
+ if (retryTimers.has(businessId))
37
+ return;
38
+ // Shared backoff curve: 2s doubling to a 5min cap with jitter. Attempts
39
+ // reset on any success (clearRetry) so long outages go quiet instead of
40
+ // hammering a down backend every 30s.
41
+ const delay = backoffTracker.next(businessId);
42
+ recordAttempt("sync_drain", `retry-scheduled delay=${delay}`);
43
+ syncLogger.debug("SyncJobs", "scheduleRetry backoff", { businessId, delay });
44
+ const t = setTimeout(() => {
45
+ retryTimers.delete(businessId);
46
+ if (isOnlineForRest() && getAuthToken() && getActiveBusiness()?.id === businessId) {
47
+ syncLogger.debug("SyncJobs", "retry timer fired → drainPending", { businessId });
48
+ void drainPending(businessId);
49
+ }
50
+ }, delay);
51
+ retryTimers.set(businessId, t);
52
+ }
53
+ function clearRetry(businessId) {
54
+ const t = retryTimers.get(businessId);
55
+ if (t) {
56
+ clearTimeout(t);
57
+ retryTimers.delete(businessId);
58
+ }
59
+ backoffTracker.reset(businessId);
60
+ }
61
+ async function tryPhxPush(businessId, payload) {
62
+ try {
63
+ const socket = phoenixSocket.getChannel?.(`business:${businessId}`);
64
+ if (!socket || !socket.push)
65
+ return false;
66
+ const chan = phoenixSocket.getChannel(`business:${businessId}`);
67
+ return await new Promise((resolve) => {
68
+ let done = false;
69
+ const timer = setTimeout(() => { if (!done) {
70
+ done = true;
71
+ resolve(false);
72
+ } }, 2500);
73
+ try {
74
+ const push = chan.push("order:sync", payload, 2500);
75
+ push.receive("ok", () => { if (!done) {
76
+ done = true;
77
+ clearTimeout(timer);
78
+ resolve(true);
79
+ } });
80
+ push.receive("error", () => { if (!done) {
81
+ done = true;
82
+ clearTimeout(timer);
83
+ resolve(false);
84
+ } });
85
+ push.receive("timeout", () => { if (!done) {
86
+ done = true;
87
+ clearTimeout(timer);
88
+ resolve(false);
89
+ } });
90
+ }
91
+ catch {
92
+ if (!done) {
93
+ done = true;
94
+ clearTimeout(timer);
95
+ resolve(false);
96
+ }
97
+ }
98
+ });
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ async function tryRest(businessId, payload, commandType) {
105
+ try {
106
+ const http = getHttpClient();
107
+ if (!http)
108
+ return { ok: false };
109
+ if (commandType === "reference:sync") {
110
+ const key = payload.key ?? payload.entity_id;
111
+ if (!key)
112
+ return { ok: false, message: "missing reference key" };
113
+ const db = await openSyncDatabase();
114
+ const { getReferenceData } = await import("../reference/operations");
115
+ const rec = await getReferenceData(db, businessId, key);
116
+ if (!rec)
117
+ return { ok: true };
118
+ // Push via reference-data endpoint if available, else per-key fallback
119
+ try {
120
+ await http.post(`/businesses/${businessId}/reference-data`, { key, payload: rec.payload, hlc: rec.lastHlc ?? rec.version });
121
+ }
122
+ catch {
123
+ // Fallback: try business operating-hours endpoint for that key
124
+ if (key === "operating-hours") {
125
+ const entries = rec.payload?.data ?? rec.payload;
126
+ await http.put(`/businesses/${businessId}/operating-hours`, { operating_hours: entries });
127
+ }
128
+ else if (key === "business:profile") {
129
+ const data = rec.payload?.data ?? rec.payload;
130
+ await http.patch(`/businesses/${businessId}`, data);
131
+ }
132
+ else {
133
+ return { ok: true };
134
+ }
135
+ }
136
+ return { ok: true };
137
+ }
138
+ // POS order create → business-scoped POS endpoint (backend: POSController.create_order expects id/local_order_number + items + order_type)
139
+ if (commandType === "order:create") {
140
+ const body = {
141
+ id: payload.id,
142
+ command_id: payload.id,
143
+ local_order_number: payload.order_number ?? payload.local_order_number,
144
+ order_number: payload.order_number ?? payload.local_order_number,
145
+ items: payload.items,
146
+ order_type: payload.order_type ?? "takeout",
147
+ pos_payment_method: payload.pos_payment_method ?? "cash",
148
+ customer_name: payload.customer_name,
149
+ customer_phone: payload.customer_phone,
150
+ notes: payload.notes,
151
+ total_minor: payload.total_minor,
152
+ subtotal_minor: payload.subtotal_minor,
153
+ tax_minor: payload.tax_minor,
154
+ tax_lines: payload.tax_lines,
155
+ inserted_at: payload.inserted_at,
156
+ updated_at: payload.updated_at,
157
+ };
158
+ // strip undefined to avoid backend validation noise
159
+ Object.keys(body).forEach((k) => body[k] === undefined && delete body[k]);
160
+ await http.post(`/businesses/${businessId}/pos/orders`, body);
161
+ return { ok: true };
162
+ }
163
+ // transitions → /orders/:id/* (OrderController) — shared endpoint map
164
+ const orderId = (payload.orderId ?? payload.order_id ?? payload.entity_id ?? payload.id);
165
+ if (!orderId)
166
+ return { ok: false, message: "missing orderId" };
167
+ const path = getTransitionPath(commandType, orderId);
168
+ if (!path)
169
+ return { ok: false, message: `unknown command ${commandType}` };
170
+ await http.post(path, {});
171
+ return { ok: true };
172
+ }
173
+ catch (e) {
174
+ const err = e;
175
+ const status = err.status ?? err.response?.status;
176
+ const bodyCode = err.response?.data?.error?.code;
177
+ const bodyMsg = err.response?.data?.error?.message;
178
+ return {
179
+ ok: false,
180
+ status,
181
+ code: bodyCode ?? err.code,
182
+ message: bodyMsg ?? err.message,
183
+ };
184
+ }
185
+ }
186
+ export async function drainOne(businessId, entityId) {
187
+ recordAttempt("sync_drain", `drainOne ${entityId.slice(0, 8)}`);
188
+ const onlineForRest = isOnlineForRest();
189
+ const onlineForPhx = isOnlineForPhx();
190
+ const token = getAuthToken();
191
+ const state = getConnectivityState();
192
+ syncLogger.debug("SyncJobs", "drainOne entry", { businessId, entityId, isOnlineForRest: onlineForRest, isOnlineForPhx: onlineForPhx, isCloudReachable: state.isCloudReachable, hasToken: !!token, inflight: inflight.has(`${businessId}:${entityId}`) });
193
+ if (!onlineForRest || !token) {
194
+ syncLogger.debug("SyncJobs", "drainOne early-return gate", { businessId, entityId, onlineForRest, hasToken: !!token, connectivity: state });
195
+ return;
196
+ }
197
+ const key = `${businessId}:${entityId}`;
198
+ // Join an in-flight drain instead of skipping: concurrent drains (e.g. the
199
+ // fire-and-forget drainOne after a write racing an awaited drainPending)
200
+ // must converge on the same outcome, otherwise callers observe "drained"
201
+ // while the POST is still flying.
202
+ const ongoing = inflight.get(key);
203
+ if (ongoing) {
204
+ syncLogger.debug("SyncJobs", "drainOne joining inflight", { key });
205
+ try {
206
+ await ongoing;
207
+ }
208
+ catch { }
209
+ return;
210
+ }
211
+ let resolveInflight;
212
+ inflight.set(key, new Promise((r) => { resolveInflight = r; }));
213
+ try {
214
+ const db = await openSyncDatabase();
215
+ const row = await db.getFirstAsync(`SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
216
+ syncLogger.debug("SyncJobs", "drainOne fetched row", { businessId, entityId, found: !!row, command_type: row?.command_type });
217
+ if (!row) {
218
+ syncLogger.debug("SyncJobs", "drainOne no pending row", { businessId, entityId });
219
+ return;
220
+ }
221
+ const payload = JSON.parse(row.payload);
222
+ // 1) PHX first (only when cloud reachable)
223
+ let phxOk = false;
224
+ if (isOnlineForPhx()) {
225
+ syncLogger.debug("SyncJobs", "tryPhxPush start", { businessId, entityId, command_type: row.command_type });
226
+ phxOk = await tryPhxPush(businessId, { ...payload, id: row.id, command_type: row.command_type, entity_id: row.id });
227
+ syncLogger.debug("SyncJobs", "tryPhxPush result", { businessId, entityId, phxOk });
228
+ if (phxOk) {
229
+ await db.runAsync(`UPDATE sync_jobs SET status='completed', updated_at=? WHERE id=?;`, new Date().toISOString(), row.id);
230
+ syncLogger.info("SyncJobs", "Drained via PHX", { businessId, entityId });
231
+ clearRetry(businessId);
232
+ try {
233
+ const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
234
+ if (next)
235
+ setTimeout(() => void drainOne(businessId, entityId), 50);
236
+ }
237
+ catch { }
238
+ return;
239
+ }
240
+ }
241
+ else {
242
+ syncLogger.debug("SyncJobs", "skip PHX, not reachable", { businessId, entityId, isCloudReachable: getConnectivityState().isCloudReachable });
243
+ }
244
+ // 2) REST fallback — use real backend routes (POSController + OrderController), not /orders/sync
245
+ syncLogger.debug("SyncJobs", "tryRest start", { businessId, entityId, command_type: row.command_type, payloadKeys: Object.keys(payload) });
246
+ const rest = await tryRest(businessId, { ...payload, id: row.id }, row.command_type);
247
+ syncLogger.debug("SyncJobs", "tryRest result", { businessId, entityId, ok: rest.ok, status: rest.status, code: rest.code, message: rest.message });
248
+ if (rest.ok) {
249
+ await db.runAsync(`UPDATE sync_jobs SET status='completed', updated_at=? WHERE id=?;`, new Date().toISOString(), row.id);
250
+ syncLogger.info("SyncJobs", "Drained via REST", { businessId, entityId, command_type: row.command_type });
251
+ clearRetry(businessId);
252
+ // Immediately drain next pending for same entity (FIFO chain: create -> prepare -> ready -> pickup should sync in order without waiting for timer)
253
+ try {
254
+ const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
255
+ if (next) {
256
+ syncLogger.debug("SyncJobs", "chaining next job for same entity", { businessId, entityId });
257
+ // fire next without inflight block — we already cleared inflight for this key, so recurse
258
+ setTimeout(() => void drainOne(businessId, entityId), 50);
259
+ }
260
+ }
261
+ catch { }
262
+ return;
263
+ }
264
+ // 3) Handle 409 RRR regen same UUID (POS create only)
265
+ syncLogger.debug("SyncJobs", "check 409", { businessId, entityId, status: rest.status, code: rest.code });
266
+ const is409 = rest.status === 409 && (String(rest.code ?? "").includes("ORDER_NUMBER_TAKEN") || String(rest.message ?? "").includes("ORDER_NUMBER_TAKEN"));
267
+ if (is409) {
268
+ const on = (payload.order_number ?? payload.local_order_number);
269
+ const parsed = on ? String(on).match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
270
+ if (parsed) {
271
+ const newOn = generateOrderNumber(parsed[1], businessId).orderNumber;
272
+ const newPayload = { ...payload, order_number: newOn, local_order_number: newOn };
273
+ await db.runAsync(`UPDATE sync_jobs SET payload=?, updated_at=? WHERE id=?;`, JSON.stringify(newPayload), new Date().toISOString(), row.id);
274
+ await db.runAsync(`UPDATE orders SET order_number=?, hlc=? WHERE business_id=? AND id=?;`, newOn, new Date().toISOString(), businessId, row.id);
275
+ syncLogger.info("SyncJobs", "Regenerated RRR after 409", { businessId, newOn });
276
+ scheduleRetry(businessId);
277
+ }
278
+ return;
279
+ }
280
+ if (rest.status === 401) {
281
+ try {
282
+ const refreshToken = getRefreshToken();
283
+ if (refreshToken) {
284
+ const http = getHttpClient();
285
+ if (http) {
286
+ const res = await http.post(`/auth/refresh`, { refresh_token: refreshToken });
287
+ const data = res.data;
288
+ const at = data?.data?.access_token ?? data.access_token;
289
+ const rt = data?.data?.refresh_token ?? data.refresh_token;
290
+ if (at) {
291
+ setAuthTokens(at, rt ?? refreshToken);
292
+ const retry = await tryRest(businessId, { ...payload, id: row.id }, row.command_type);
293
+ if (retry.ok) {
294
+ await db.runAsync(`UPDATE sync_jobs SET status='completed', updated_at=? WHERE id=?;`, new Date().toISOString(), row.id);
295
+ syncLogger.info("SyncJobs", "Drained via REST after refresh", { businessId, entityId });
296
+ return;
297
+ }
298
+ if (retry.status === 401) {
299
+ clearAuth();
300
+ syncLogger.warn("SyncJobs", "Refresh expired → logout", { businessId });
301
+ return;
302
+ }
303
+ }
304
+ else {
305
+ clearAuth();
306
+ }
307
+ }
308
+ }
309
+ else {
310
+ syncLogger.debug("SyncJobs", "401 no refresh token — deferred", { businessId });
311
+ }
312
+ }
313
+ catch {
314
+ try {
315
+ clearAuth();
316
+ }
317
+ catch { }
318
+ }
319
+ syncLogger.debug("SyncJobs", "401 deferred until login", { businessId });
320
+ return;
321
+ }
322
+ // Permanent 4xx: mark failed to stop the retry loop.
323
+ // 404 is only retryable for order:create (FIFO race: order not yet created
324
+ // on backend). For transitions, 404 means the order was deleted or never
325
+ // synced — permanent. 409 handled above for RRR regen. 401/408/429 need
326
+ // special handling below.
327
+ const codeUpper = String(rest.code ?? "").toUpperCase();
328
+ const msgUpper = String(rest.message ?? "").toUpperCase();
329
+ const isBusinessClosed = codeUpper.includes("BUSINESS_CLOSED") || msgUpper.includes("BUSINESS_CLOSED") || msgUpper.includes("BUSINESS IS CLOSED") || codeUpper.includes("HOURS_NOT_SYNCED");
330
+ const is4xx = rest.status && rest.status >= 400 && rest.status < 500;
331
+ const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409 || rest.status === 429
332
+ || (rest.status === 404 && row.command_type === "order:create")
333
+ || isBusinessClosed;
334
+ if (is4xx && !isRetryable4xx) {
335
+ syncLogger.warn("SyncJobs", "Permanent 4xx — marking job failed to stop retry loop", { businessId, entityId, status: rest.status, code: rest.code, message: rest.message });
336
+ try {
337
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
338
+ }
339
+ catch { }
340
+ clearRetry(businessId);
341
+ try {
342
+ const { notifyError } = await import("../adapters/notify");
343
+ notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status}`);
344
+ }
345
+ catch { }
346
+ // Retain optimistic row — pull will converge for transitions, create stays for manual retry.
347
+ if (row.command_type !== "order:create") {
348
+ try {
349
+ void pullRemoteOrders(businessId).catch(() => { });
350
+ }
351
+ catch { }
352
+ }
353
+ return;
354
+ }
355
+ if (isBusinessClosed) {
356
+ syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
357
+ try {
358
+ await db.runAsync(`UPDATE sync_jobs SET last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
359
+ }
360
+ catch { }
361
+ // Re-pull reference so hours merge converges, then retry with backoff.
362
+ try {
363
+ const { referenceSyncService } = await import("../reference/service");
364
+ void referenceSyncService.sync({ businessId, keys: ["operating-hours", "business:profile"] }).catch(() => { });
365
+ }
366
+ catch { }
367
+ scheduleRetry(businessId);
368
+ return;
369
+ }
370
+ // Keep pending for retry (5xx / network) — auto retry with backoff
371
+ syncLogger.debug("SyncJobs", "drainOne kept pending for retry", { businessId, entityId, status: rest.status, code: rest.code });
372
+ try {
373
+ await db.runAsync(`UPDATE sync_jobs SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE id = ?;`, `${rest.status ?? "network"} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
374
+ }
375
+ catch { }
376
+ scheduleRetry(businessId);
377
+ }
378
+ finally {
379
+ inflight.delete(key);
380
+ try {
381
+ resolveInflight();
382
+ }
383
+ catch { }
384
+ syncLogger.debug("SyncJobs", "drainOne exit", { businessId, entityId });
385
+ }
386
+ }
387
+ export async function pullRemoteOrders(businessId) {
388
+ const onlineForRest = isOnlineForRest();
389
+ const hasToken = !!getAuthToken();
390
+ syncLogger.debug("SyncJobs", "pullRemoteOrders entry", { businessId, onlineForRest, hasToken });
391
+ if (!onlineForRest || !hasToken) {
392
+ syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken, connectivity: getConnectivityState() });
393
+ return;
394
+ }
395
+ try {
396
+ const http = getHttpClient();
397
+ if (!http) {
398
+ syncLogger.debug("SyncJobs", "pullRemoteOrders no http client", { businessId });
399
+ return;
400
+ }
401
+ // Gradual recent-first pull for single business — page 1 immediate, then background older pages (max 5 pages = 100 recent)
402
+ let page = 1;
403
+ let totalPages = 1;
404
+ do {
405
+ syncLogger.debug("SyncJobs", "pullRemoteOrders GET start", { businessId, page });
406
+ const res = await http.get(`/businesses/${businessId}/orders`, { params: { page, page_size: 20 } });
407
+ const payload = res.data;
408
+ const data = payload?.data ?? res.data ?? [];
409
+ const tp = payload?.pagination?.total_pages ?? 1;
410
+ if (page === 1)
411
+ totalPages = tp;
412
+ syncLogger.debug("SyncJobs", "pullRemoteOrders GET result", { businessId, page, totalPages, dataLen: data.length });
413
+ if (data.length) {
414
+ const db = await openSyncDatabase();
415
+ for (const o of data) {
416
+ try {
417
+ await insertOrder(db, o);
418
+ }
419
+ catch (e) {
420
+ syncLogger.debug("SyncJobs", "pullRemoteOrders insertOrder fail", { err: String(e) });
421
+ }
422
+ }
423
+ // persist page 1 to snapshot for offline fallback
424
+ if (page === 1) {
425
+ try {
426
+ await orderSnapshotsService.persistSellerOrders(businessId, "list-main", data);
427
+ }
428
+ catch { }
429
+ }
430
+ }
431
+ page++;
432
+ if (page <= totalPages && page <= 5)
433
+ await new Promise((r) => setTimeout(r, 150));
434
+ } while (page <= totalPages && page <= 5);
435
+ }
436
+ catch (e) {
437
+ syncLogger.debug("SyncJobs", "pullRemoteOrders error", { businessId, err: String(e) });
438
+ }
439
+ // Also pull recent deliveries (single page recent) for single business
440
+ try {
441
+ const http = getHttpClient();
442
+ if (http) {
443
+ const r2 = await http.get(`/businesses/${businessId}/deliveries`, { params: { page: 1, page_size: 20 } });
444
+ const d = r2.data?.data ?? [];
445
+ syncLogger.debug("SyncJobs", "pullRemoteDeliveries result", { businessId, dataLen: d.length });
446
+ }
447
+ }
448
+ catch (e) {
449
+ syncLogger.debug("SyncJobs", "pullRemoteDeliveries error", { err: String(e) });
450
+ }
451
+ }
452
+ export async function drainPending(businessId) {
453
+ recordAttempt("sync_drain", "drainPending");
454
+ const onlineForRest = isOnlineForRest();
455
+ const hasToken = !!getAuthToken();
456
+ syncLogger.debug("SyncJobs", "drainPending entry", { businessId, onlineForRest, hasToken, connectivity: getConnectivityState() });
457
+ if (!onlineForRest || !hasToken) {
458
+ syncLogger.debug("SyncJobs", "drainPending early-return gate", { businessId, onlineForRest, hasToken });
459
+ return;
460
+ }
461
+ const db = await openSyncDatabase();
462
+ const rows = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' ORDER BY created_at LIMIT 20;`, businessId);
463
+ syncLogger.debug("SyncJobs", "drainPending found rows", { businessId, count: rows.length, rows });
464
+ for (const r of rows) {
465
+ await drainOne(businessId, r.entity_id);
466
+ await new Promise((res) => setTimeout(res, 100));
467
+ }
468
+ if (rows.length === 0)
469
+ clearRetry(businessId);
470
+ await pullRemoteOrders(businessId);
471
+ syncLogger.debug("SyncJobs", "drainPending exit", { businessId });
472
+ // if still pending, ensure backoff retry is scheduled
473
+ try {
474
+ const still = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' LIMIT 1;`, businessId);
475
+ if (still.length > 0)
476
+ scheduleRetry(businessId);
477
+ else
478
+ clearRetry(businessId);
479
+ }
480
+ catch { }
481
+ }
482
+ let drainSetup = false;
483
+ export function setupSyncJobsDrain() {
484
+ if (drainSetup) {
485
+ syncLogger.debug("SyncJobs", "setupSyncJobsDrain already setup, skip");
486
+ return;
487
+ }
488
+ drainSetup = true;
489
+ syncLogger.debug("SyncJobs", "setupSyncJobsDrain init", { hasBusiness: !!getActiveBusiness()?.id, connectivity: getConnectivityState(), hasToken: !!getAuthToken() });
490
+ const triggerFullSync = (b) => {
491
+ if (!b || !isOnlineForRest() || !getAuthToken())
492
+ return;
493
+ void import("./full-sync").then((m) => m.performFullSync(b).catch(() => { }));
494
+ };
495
+ try {
496
+ subscribeConnectivity(() => {
497
+ const b = getActiveBusiness()?.id;
498
+ syncLogger.debug("SyncJobs", "subscribeConnectivity trigger", { businessId: b, connectivity: getConnectivityState(), hasToken: !!getAuthToken() });
499
+ triggerFullSync(b);
500
+ });
501
+ subscribeBusiness(() => {
502
+ const bid = getActiveBusiness()?.id;
503
+ syncLogger.debug("SyncJobs", "subscribeBusiness trigger", { businessId: bid, hasToken: !!getAuthToken(), connectivity: getConnectivityState() });
504
+ triggerFullSync(bid);
505
+ });
506
+ subscribeAuth(() => {
507
+ const b = getActiveBusiness()?.id;
508
+ syncLogger.debug("SyncJobs", "subscribeAuth trigger", { businessId: b, hasToken: !!getAuthToken(), connectivity: getConnectivityState() });
509
+ triggerFullSync(b);
510
+ });
511
+ const b0 = getActiveBusiness()?.id;
512
+ syncLogger.debug("SyncJobs", "setupSyncJobsDrain initial check", { b0, isOnlineForRest: isOnlineForRest(), hasToken: !!getAuthToken() });
513
+ triggerFullSync(b0);
514
+ }
515
+ catch (e) {
516
+ syncLogger.debug("SyncJobs", "setupSyncJobsDrain error", { err: String(e) });
517
+ }
518
+ }
519
+ // Debug helper for DiagnosticsScreen — read pending sync_jobs count
520
+ export async function getSyncJobsPendingCount(businessId) {
521
+ try {
522
+ const db = await openSyncDatabase();
523
+ const rows = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' ORDER BY created_at`, businessId);
524
+ return rows.length;
525
+ }
526
+ catch {
527
+ return 0;
528
+ }
529
+ }
530
+ export async function debugDumpSyncJobs(businessId) {
531
+ try {
532
+ const db = await openSyncDatabase();
533
+ const rows = await db.getAllAsync(`SELECT id, command_type, status, created_at, attempts, last_error FROM sync_jobs WHERE business_id=? ORDER BY created_at`, businessId);
534
+ return rows;
535
+ }
536
+ catch {
537
+ return [];
538
+ }
539
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared order/delivery transition endpoint map.
3
+ *
4
+ * Single source used by both the sync_jobs drain (tryRest) and the REST
5
+ * transport (executeOrderTransition) so endpoint definitions cannot drift.
6
+ */
7
+ /**
8
+ * Build the REST path for a given command type and order/delivery ID.
9
+ * Returns null for unknown command types.
10
+ */
11
+ export declare function getTransitionPath(commandType: string, entityId: string): string | null;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared order/delivery transition endpoint map.
3
+ *
4
+ * Single source used by both the sync_jobs drain (tryRest) and the REST
5
+ * transport (executeOrderTransition) so endpoint definitions cannot drift.
6
+ */
7
+ const ORDER_ENDPOINTS = {
8
+ "order:accept": "accept",
9
+ "order:reject": "reject",
10
+ "order:start_preparing": "start-preparing",
11
+ "order:mark_ready": "ready-for-delivery",
12
+ "order:complete_pickup": "complete-pickup",
13
+ "order:cancel": "cancel",
14
+ "order:confirm_cash": "confirm-cash-payment",
15
+ "order:pay": "confirm-cash-payment",
16
+ "order:assign_rider": "assign",
17
+ };
18
+ /**
19
+ * Build the REST path for a given command type and order/delivery ID.
20
+ * Returns null for unknown command types.
21
+ */
22
+ export function getTransitionPath(commandType, entityId) {
23
+ if (commandType === "order:confirm_handover") {
24
+ return `/deliveries/${entityId}/confirm-handover`;
25
+ }
26
+ const suffix = ORDER_ENDPOINTS[commandType];
27
+ if (!suffix)
28
+ return null;
29
+ return `/orders/${entityId}/${suffix}`;
30
+ }
@@ -4,14 +4,15 @@
4
4
  * Routes commands to the elected Local Node over the LAN and receives events
5
5
  * broadcast by the Local Node.
6
6
  */
7
- import { useLocalNodeStore, localNodeService } from "../adapters/local-node";
7
+ import { localNodeStore } from "../local-node/store";
8
+ import { localNodeService } from "../local-node/service";
8
9
  import { syncLogger } from "../utils/sync-logger";
9
10
  export class LocalNodeTransport {
10
11
  constructor() {
11
12
  this.name = "local_node";
12
13
  }
13
14
  isAvailable() {
14
- const store = useLocalNodeStore.getState();
15
+ const store = localNodeStore.getState();
15
16
  if (!store.isLocalNodeEnabled)
16
17
  return false;
17
18
  // If this device is the Local Node, it is always available.
@@ -12,6 +12,13 @@ export declare class RestTransport implements Transport {
12
12
  subscribe(): () => void;
13
13
  execute(command: Command): Promise<DomainEvent[]>;
14
14
  private dispatchCommand;
15
+ /**
16
+ * Order transitions go through the domain service first (single write path:
17
+ * SQLite + sync_jobs). If this device never saw the order (e.g. a LAN node
18
+ * handling a cloud-created order before its first pull), fall back to the
19
+ * previous direct-REST behavior so availability never regresses.
20
+ */
21
+ private executeOrderTransition;
15
22
  private wrapResponse;
16
23
  }
17
24
  export declare const restTransport: RestTransport;