@teincfood/core 0.7.9 → 0.8.1

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 +24 -0
  47. package/dist/hooks/image.hooks.js +66 -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 +28 -0
  67. package/dist/image-cache/service.js +51 -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 +168 -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
@@ -11,8 +11,9 @@
11
11
  import { getSyncDatabase, openSyncDatabase } from "../db/connection";
12
12
  import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
13
13
  import { syncLogger } from "../utils/sync-logger";
14
+ import { recordAttempt } from "../utils/attempt-stats";
14
15
  import { generateEventId } from "../utils/uuid";
15
- import { generateLocalCalloutNumber, generateOrderNumberForBusiness, } from "./order-number";
16
+ import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
16
17
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
17
18
  const MAX_RETRIES = 5;
18
19
  const QUEUED_STATUS_BY_COMMAND = {
@@ -98,14 +99,14 @@ function buildOptimisticOrder(command, payload) {
98
99
  return {
99
100
  id: command.entity_id ?? command.command_id,
100
101
  order_number: orderNumber,
101
- callout_number: generateLocalCalloutNumber(),
102
+ callout_number: generateLocalCalloutNumber(command.business_id),
102
103
  order_type: payload.order_type ?? "takeout",
103
104
  is_pos: true,
104
105
  customer_name: payload.customer_name ?? null,
105
106
  customer_phone: payload.customer_phone ?? null,
106
107
  pos_payment_method: payload.pos_payment_method ?? "cash",
107
108
  paid_at: null,
108
- status: "pending",
109
+ status: "accepted",
109
110
  delivery_id: null,
110
111
  delivery_status: null,
111
112
  is_paid: payload.pos_payment_method === "cash",
@@ -174,7 +175,7 @@ function injectLocalOrderNumber(command) {
174
175
  }
175
176
  class CommandQueueService {
176
177
  constructor() {
177
- this.replaying = false;
178
+ this.replaying = null;
178
179
  this.syncEngine = null;
179
180
  }
180
181
  setSyncEngine(engine) {
@@ -254,7 +255,7 @@ class CommandQueueService {
254
255
  }
255
256
  await markCommandInFlight(db, command.command_id);
256
257
  try {
257
- const events = await this.syncEngine.execute(parsedCommand);
258
+ const events = await this.syncEngine.execute(parsedCommand, { forReplay: true });
258
259
  // Outbox is an ephemeral queue — remove the row on success so the
259
260
  // Diagnostics outbox count drops to 0 and old completed rows never
260
261
  // accumulate. The optimistic event stays in `events` (marked synced).
@@ -270,11 +271,18 @@ class CommandQueueService {
270
271
  }
271
272
  catch (error) {
272
273
  const message = error instanceof Error ? error.message : String(error);
274
+ // Replay with no transport available: leave the row exactly as-is for
275
+ // the next trigger (do not mark, do not count). Must reset the
276
+ // in_flight flag first — rows stuck in_flight are invisible to the
277
+ // outbox query and would wedge the queue forever.
278
+ if (message.includes("ReplayDeferred")) {
279
+ await updateCommandStatus(db, command.command_id, "pending", "deferred:no-transport");
280
+ throw error;
281
+ }
273
282
  const apiErr = error;
274
283
  // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR
275
284
  const code = apiErr?.code ?? apiErr?.data?.code;
276
285
  if (apiErr?.status === 409 && String(code).includes("ORDER_NUMBER_TAKEN")) {
277
- // Regenerate random part of order_number if this was order:create
278
286
  if (command.command_type === "order:create") {
279
287
  try {
280
288
  const payload = command.payload;
@@ -282,12 +290,14 @@ class CommandQueueService {
282
290
  const parsed = on ? on.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
283
291
  if (parsed) {
284
292
  const deviceNumber = parsed[1];
285
- const { generateOrderNumber } = await import("./order-number");
286
- const { orderNumber: newOn } = generateOrderNumber(deviceNumber);
293
+ const { orderNumber: newOn } = generateOrderNumber(deviceNumber, command.business_id);
287
294
  const newPayload = { ...payload, order_number: newOn };
288
- await updateCommandStatus(db, command.command_id, "failed", `retry_random:${message}`);
289
- // Update stored payload for retry
290
- await db.runAsync(`UPDATE commands SET payload = ?, retry_count = retry_count WHERE command_id = ?;`, JSON.stringify(newPayload), command.command_id);
295
+ await db.runAsync(`UPDATE commands SET payload = ?, error = ?, updated_at = ? WHERE command_id = ?;`, JSON.stringify(newPayload), `retry_random:${message}`, new Date().toISOString(), command.command_id);
296
+ syncLogger.info("CommandQueueService", "Regenerated order_number after 409", { command_id: command.command_id, new_order_number: newOn });
297
+ // keep retry_count unchanged so next replay retries; do not mark failed twice.
298
+ // Reset in_flight → pending so the row stays visible to the outbox query.
299
+ await updateCommandStatus(db, command.command_id, "pending", `retry_random:${message}`);
300
+ return;
291
301
  }
292
302
  }
293
303
  catch { }
@@ -307,11 +317,29 @@ class CommandQueueService {
307
317
  status: apiErr.status,
308
318
  error: message,
309
319
  });
320
+ // Loud toast + revert: the optimistic local write is rolled back so
321
+ // the UI never shows what the backend refused.
322
+ try {
323
+ const { notifyError } = await import("../adapters/notify");
324
+ notifyError(`Server rejected ${command.command_type}: ${message}`);
325
+ }
326
+ catch { }
327
+ try {
328
+ if (command.command_type.startsWith("menu:") && command.entity_id) {
329
+ const { revertMenuCommand } = await import("../reference/menu-apply");
330
+ await revertMenuCommand(command.business_id, command.command_type, command.entity_id);
331
+ }
332
+ }
333
+ catch { }
310
334
  // Do not throw — allow replay to continue to next command.
311
335
  return;
312
336
  }
313
- await updateCommandStatus(db, command.command_id, "failed", message);
314
- syncLogger.command(parsedCommand, "failed", { error: message });
337
+ // Network/timeout/5xx: NOT a rejection — keep the command retryable
338
+ // indefinitely (status back to pending, retry_count untouched) so a
339
+ // long outage never dead-letters the outbox via MAX_RETRIES. Replay
340
+ // stops here to preserve ordering; the next trigger retries.
341
+ await updateCommandStatus(db, command.command_id, "pending", `network:${message}`);
342
+ syncLogger.command(parsedCommand, "queued", { error: message, deferred: true });
315
343
  throw error;
316
344
  }
317
345
  }
@@ -320,11 +348,21 @@ class CommandQueueService {
320
348
  * ordering consistent; the next replay will retry from the failed command.
321
349
  */
322
350
  async replay() {
351
+ recordAttempt("sync_drain", "replay");
352
+ // Join an in-flight replay instead of no-op'ing: concurrent replays
353
+ // (connectivity auto-replay racing an explicit replay) must converge on
354
+ // the same outcome, otherwise callers observe a drained queue while work
355
+ // is still flying.
323
356
  if (this.replaying) {
324
- syncLogger.debug("CommandQueueService", "Replay already in progress");
357
+ syncLogger.debug("CommandQueueService", "Replay already in progress, joining");
358
+ try {
359
+ await this.replaying;
360
+ }
361
+ catch { }
325
362
  return;
326
363
  }
327
- this.replaying = true;
364
+ let resolveReplay;
365
+ this.replaying = new Promise((r) => { resolveReplay = r; });
328
366
  try {
329
367
  // Await open (not getSyncDatabase) so a cold-start replay triggered by a
330
368
  // connectivity event that fires before the DB open completes does not
@@ -356,7 +394,11 @@ class CommandQueueService {
356
394
  }
357
395
  }
358
396
  finally {
359
- this.replaying = false;
397
+ this.replaying = null;
398
+ try {
399
+ resolveReplay();
400
+ }
401
+ catch { }
360
402
  }
361
403
  }
362
404
  /**
@@ -377,5 +419,28 @@ class CommandQueueService {
377
419
  const commands = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
378
420
  return commands.length;
379
421
  }
422
+ /**
423
+ * Debug dump of the engine outbox for Diagnostics screens (all statuses,
424
+ * newest last). Mirrors debugDumpSyncJobs.
425
+ */
426
+ async debugDumpCommands(businessId) {
427
+ try {
428
+ const db = getSyncDatabase();
429
+ const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
430
+ return rows
431
+ .filter((c) => !businessId || c.business_id === businessId)
432
+ .map((c) => ({
433
+ id: c.command_id,
434
+ command_type: c.command_type,
435
+ status: c.status,
436
+ retry_count: c.retry_count,
437
+ error: c.error ?? null,
438
+ created_at: c.created_at ?? "",
439
+ }));
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ }
380
445
  }
381
446
  export const commandQueueService = new CommandQueueService();
@@ -3,7 +3,7 @@
3
3
  * Backend authority: POST /businesses/:id/devices/lease
4
4
  * KV persists device_number per businessId.
5
5
  */
6
- declare function getAppInstanceId(): string;
6
+ import { getAppInstanceIdAsync } from "../adapters/devices";
7
7
  export declare function getDeviceNumber(businessId: string): string | null;
8
8
  export declare function isOnlineOnly(businessId: string): boolean;
9
9
  export declare function ensureLease(businessId: string): Promise<string | null>;
@@ -14,6 +14,6 @@ export declare const deviceLeaseService: {
14
14
  isOnlineOnly: typeof isOnlineOnly;
15
15
  ensureLease: typeof ensureLease;
16
16
  refreshLease: typeof refreshLease;
17
- getAppInstanceId: typeof getAppInstanceId;
17
+ getAppInstanceId: typeof getAppInstanceIdAsync;
18
+ getAppInstanceIdAsync: typeof getAppInstanceIdAsync;
18
19
  };
19
- export {};
@@ -3,20 +3,13 @@
3
3
  * Backend authority: POST /businesses/:id/devices/lease
4
4
  * KV persists device_number per businessId.
5
5
  */
6
- import { getItemSync as getSync, setItemSync as setSync } from "../adapters/kv";
6
+ import { getItemSync as getSync, setItemSync as setSync, removeItemSync, getItem } from "../adapters/kv";
7
7
  import { getHttpClient } from "../adapters/http";
8
- import { generateUUID } from "../utils/uuid";
9
8
  import { syncLogger } from "../utils/sync-logger";
9
+ import { getAppInstanceIdAsync } from "../adapters/devices";
10
+ import { getConnectivityState } from "../adapters/connectivity";
10
11
  const LEASE_KEY = (businessId) => `teinc_device_number:${businessId}`;
11
12
  const LEASE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
12
- function getAppInstanceId() {
13
- let id = getSync("teinc_app_instance_id");
14
- if (!id) {
15
- id = generateUUID();
16
- setSync("teinc_app_instance_id", id);
17
- }
18
- return id;
19
- }
20
13
  export function getDeviceNumber(businessId) {
21
14
  const v = getSync(LEASE_KEY(businessId));
22
15
  if (v && /^(0[1-9]|[1-9][0-9])$/.test(v))
@@ -27,32 +20,99 @@ export function isOnlineOnly(businessId) {
27
20
  return getDeviceNumber(businessId) == null;
28
21
  }
29
22
  export async function ensureLease(businessId) {
30
- const existing = getDeviceNumber(businessId);
31
- if (existing)
32
- return existing;
23
+ // Offline-first: keep leased DD even offline — if online check/lease fails, return cached
24
+ const existingSync = getDeviceNumber(businessId);
25
+ if (existingSync) {
26
+ // Opportunistically refresh touch if online, but never discard cached on failure
27
+ if (getConnectivityState().isOnline) {
28
+ try {
29
+ const leasedAt = getSync(`${LEASE_KEY(businessId)}:leased_at`);
30
+ const needsRefresh = !leasedAt || Date.now() - new Date(leasedAt).getTime() > LEASE_TTL_MS - 24 * 60 * 60 * 1000;
31
+ if (needsRefresh)
32
+ await refreshLease(businessId);
33
+ }
34
+ catch {
35
+ // keep existing on any refresh failure
36
+ }
37
+ }
38
+ return existingSync;
39
+ }
40
+ // No sync cache — try async KV (covers boot race where sync cache not yet hydrated from kv.json)
41
+ try {
42
+ const asyncCached = await getItem(LEASE_KEY(businessId));
43
+ if (asyncCached && /^(0[1-9]|[1-9][0-9])$/.test(asyncCached)) {
44
+ // hydrate sync cache for next getDeviceNumber calls
45
+ try {
46
+ setSync(LEASE_KEY(businessId), asyncCached);
47
+ }
48
+ catch { }
49
+ return asyncCached;
50
+ }
51
+ }
52
+ catch { }
53
+ // No cached DD — must lease online. If offline, keep null (caller blocks POS with clear message)
54
+ if (!getConnectivityState().isOnline) {
55
+ syncLogger.debug("DeviceLease", "Offline and no cached DD — need online once", { businessId });
56
+ return null;
57
+ }
33
58
  try {
34
59
  const http = getHttpClient();
35
- const deviceUuid = getAppInstanceId();
60
+ const deviceUuid = await getAppInstanceIdAsync();
36
61
  const res = await http.post(`/businesses/${businessId}/devices/lease`, { device_uuid: deviceUuid, app_instance_id: deviceUuid });
37
62
  const data = res.data?.data ?? res.data;
38
63
  const dn = data.device_number;
39
64
  if (dn && /^(0[1-9]|[1-9][0-9])$/.test(dn)) {
40
65
  setSync(LEASE_KEY(businessId), dn);
41
66
  setSync(`${LEASE_KEY(businessId)}:leased_at`, new Date().toISOString());
67
+ try {
68
+ await getItem(LEASE_KEY(businessId));
69
+ }
70
+ catch { }
42
71
  syncLogger.info("DeviceLease", "Leased device number", { businessId, device_number: dn });
43
72
  return dn;
44
73
  }
45
74
  }
46
75
  catch (e) {
47
76
  const apiErr = e;
48
- if (apiErr?.status === 429 || e?.message?.includes("DEVICE_LIMIT_REACHED")) {
77
+ const msg = String(e?.message ?? "");
78
+ const isAuth = apiErr?.status === 401 || msg.includes("TOKEN_MISSING") || msg.includes("No authentication token");
79
+ if (isAuth) {
80
+ syncLogger.debug("DeviceLease", "No auth yet — lease deferred until login", { businessId });
81
+ // fallback to any async cache that may have appeared concurrently
82
+ try {
83
+ const fallback = await getItem(LEASE_KEY(businessId));
84
+ if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback))
85
+ return fallback;
86
+ }
87
+ catch { }
88
+ const again = getDeviceNumber(businessId);
89
+ if (again)
90
+ return again;
91
+ return null;
92
+ }
93
+ if (apiErr?.status === 429 || msg.includes("DEVICE_LIMIT_REACHED")) {
49
94
  syncLogger.warn("DeviceLease", "Device limit reached — online only", { businessId });
50
95
  return null;
51
96
  }
52
- syncLogger.warn("DeviceLease", "Lease failed — will operate online-only until next boot", {
97
+ // Network/offline failure while leasing — keep any cached that may exist
98
+ syncLogger.warn("DeviceLease", "Lease failed — keep cached if any", {
53
99
  businessId,
54
100
  error: String(e),
55
101
  });
102
+ try {
103
+ const fallback = await getItem(LEASE_KEY(businessId));
104
+ if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback)) {
105
+ try {
106
+ setSync(LEASE_KEY(businessId), fallback);
107
+ }
108
+ catch { }
109
+ return fallback;
110
+ }
111
+ }
112
+ catch { }
113
+ const again = getDeviceNumber(businessId);
114
+ if (again)
115
+ return again;
56
116
  }
57
117
  return null;
58
118
  }
@@ -68,7 +128,8 @@ export async function refreshLease(businessId) {
68
128
  }
69
129
  try {
70
130
  const http = getHttpClient();
71
- await http.patch(`/businesses/${businessId}/devices/${getAppInstanceId()}/touch`, {});
131
+ const uuid = await getAppInstanceIdAsync();
132
+ await http.patch(`/businesses/${businessId}/devices/${uuid}/touch`, {});
72
133
  setSync(`${LEASE_KEY(businessId)}:leased_at`, new Date().toISOString());
73
134
  }
74
135
  catch {
@@ -76,8 +137,6 @@ export async function refreshLease(businessId) {
76
137
  }
77
138
  }
78
139
  export function clearLeaseForTests(businessId) {
79
- // test helper
80
- const { removeItemSync } = require("../adapters/kv");
81
140
  removeItemSync(LEASE_KEY(businessId));
82
141
  removeItemSync(`${LEASE_KEY(businessId)}:leased_at`);
83
142
  }
@@ -86,5 +145,6 @@ export const deviceLeaseService = {
86
145
  isOnlineOnly,
87
146
  ensureLease,
88
147
  refreshLease,
89
- getAppInstanceId,
148
+ getAppInstanceId: getAppInstanceIdAsync,
149
+ getAppInstanceIdAsync,
90
150
  };
@@ -32,7 +32,9 @@ export declare class SyncEngine {
32
32
  * internet connection falls back to the Local Node (or the offline outbox)
33
33
  * quickly instead of blocking the terminal for the full HTTP timeout.
34
34
  */
35
- execute(command: Command): Promise<DomainEvent[]>;
35
+ execute(command: Command, opts?: {
36
+ forReplay?: boolean;
37
+ }): Promise<DomainEvent[]>;
36
38
  /**
37
39
  * Trigger replay of queued commands when connectivity returns.
38
40
  */
@@ -18,6 +18,9 @@ import { getConnectivityState, setConnectivityState } from "../adapters/connecti
18
18
  import { ApiError } from "../adapters/http";
19
19
  import { syncLogger } from "../utils/sync-logger";
20
20
  import { CLOUD_COMMAND_TIMEOUT_MS } from "../utils/constants";
21
+ import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
22
+ import { getReferenceData } from "../reference/operations";
23
+ import { openSyncDatabase } from "../db/connection";
21
24
  function withTimeout(promise, ms) {
22
25
  return new Promise((resolve, reject) => {
23
26
  const timer = setTimeout(() => {
@@ -49,15 +52,12 @@ export class SyncEngine {
49
52
  * internet connection falls back to the Local Node (or the offline outbox)
50
53
  * quickly instead of blocking the terminal for the full HTTP timeout.
51
54
  */
52
- async execute(command) {
55
+ async execute(command, opts) {
53
56
  syncLogger.command(command, "issued");
54
57
  // Perms guard (offline-capable): block before routing
55
- const { CAPABILITY_BY_COMMAND } = await import("../reference/capabilities");
56
58
  const required = CAPABILITY_BY_COMMAND[command.command_type];
57
59
  if (required) {
58
60
  try {
59
- const { getReferenceData } = await import("../reference/operations");
60
- const { openSyncDatabase } = await import("../db/connection");
61
61
  const db = await openSyncDatabase();
62
62
  const rec = await getReferenceData(db, command.business_id, "capabilities");
63
63
  const caps = rec?.payload?.data?.capabilities ?? [];
@@ -84,8 +84,42 @@ export class SyncEngine {
84
84
  lan_available: localNodeTransport.isAvailable(),
85
85
  });
86
86
  if (strategy === "queue") {
87
+ // Replays must never re-enqueue-then-delete (the command would eat
88
+ // itself without executing). If no real transport is available, leave
89
+ // the row untouched for a later trigger.
90
+ if (opts?.forReplay) {
91
+ throw new Error("ReplayDeferred: no transport available, keeping command queued");
92
+ }
87
93
  const event = await commandQueueService.enqueue(command);
88
94
  this.broadcast(event);
95
+ // Menu writes need a visible local effect while queued: patch the
96
+ // durable reference cache optimistically and fan out
97
+ // reference_data.changed so reconciliation applies it locally and
98
+ // relays it to LAN peers (replay re-applies idempotently; the backend
99
+ // owns versions on cloud convergence).
100
+ if (command.command_type.startsWith("menu:")) {
101
+ try {
102
+ const { applyMenuCommandLocal } = await import("../reference/menu-apply");
103
+ const { keys } = await applyMenuCommandLocal(command.business_id, command);
104
+ if (keys.length) {
105
+ const { REFERENCE_CHANGED_EVENT_TYPE } = await import("../reference/events");
106
+ const { generateEventId } = await import("../utils/uuid");
107
+ this.broadcast({
108
+ event_id: generateEventId(),
109
+ event_type: REFERENCE_CHANGED_EVENT_TYPE,
110
+ event_version: "1.0",
111
+ entity_id: command.business_id,
112
+ user_id: command.user_id,
113
+ device_id: command.device_id,
114
+ payload: { business_id: command.business_id, keys },
115
+ timestamp: new Date().toISOString(),
116
+ });
117
+ }
118
+ }
119
+ catch (e) {
120
+ syncLogger.debug("SyncEngine", "queued menu optimistic apply failed", { err: String(e) });
121
+ }
122
+ }
89
123
  return [event];
90
124
  }
91
125
  if (strategy === "cloud") {
@@ -130,6 +164,12 @@ export class SyncEngine {
130
164
  setConnectivityState({ isCloudReachable: "unreachable" });
131
165
  strategy = this.resolveStrategy(command);
132
166
  if (strategy === "queue") {
167
+ // Replays must never re-enqueue-then-delete (the command would eat
168
+ // itself without executing). If no real transport is available, leave
169
+ // the row untouched for a later trigger.
170
+ if (opts?.forReplay) {
171
+ throw new Error("ReplayDeferred: no transport available, keeping command queued");
172
+ }
133
173
  const event = await commandQueueService.enqueue(command);
134
174
  this.broadcast(event);
135
175
  return [event];
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Full sync: push → pull → push.
3
+ * Push first so local edits (already HLC-bumped) reach server before pull
4
+ * overwrites them; then pull merges; final drain pushes any remainder.
5
+ */
6
+ export declare function performFullSync(businessId?: string): Promise<void>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Full sync: push → pull → push.
3
+ * Push first so local edits (already HLC-bumped) reach server before pull
4
+ * overwrites them; then pull merges; final drain pushes any remainder.
5
+ */
6
+ import { getConnectivityState } from "../adapters/connectivity";
7
+ import { getAuthToken } from "../adapters/auth";
8
+ import { getActiveBusiness } from "../adapters/business";
9
+ import { syncLogger } from "../utils/sync-logger";
10
+ import { referenceSyncService } from "../reference/service";
11
+ import { pullRemoteOrders, drainPending } from "./sync-jobs.service";
12
+ let inFlight = null;
13
+ export async function performFullSync(businessId) {
14
+ const bid = businessId ?? getActiveBusiness()?.id;
15
+ if (!bid)
16
+ return;
17
+ const s = getConnectivityState();
18
+ if (!s.isOnline || s.isCloudReachable === "unreachable")
19
+ return;
20
+ if (!getAuthToken())
21
+ return;
22
+ if (inFlight) {
23
+ try {
24
+ await inFlight;
25
+ }
26
+ catch { }
27
+ return;
28
+ }
29
+ const p = (async () => {
30
+ try {
31
+ syncLogger.info("FullSync", "Starting push→pull→push", { businessId: bid });
32
+ // 1. Push pending FIFO first — HLC-bumped local edits outrank stale server
33
+ try {
34
+ await drainPending(bid);
35
+ }
36
+ catch (e) {
37
+ syncLogger.warn("FullSync", "drainPending(pre) failed", { err: String(e) });
38
+ }
39
+ // 2. Pull remote orders (HLC-guarded) and reference (LWW merge)
40
+ try {
41
+ await pullRemoteOrders(bid);
42
+ }
43
+ catch (e) {
44
+ syncLogger.warn("FullSync", "pullRemoteOrders failed", { err: String(e) });
45
+ }
46
+ try {
47
+ await referenceSyncService.sync({ businessId: bid });
48
+ }
49
+ catch (e) {
50
+ syncLogger.warn("FullSync", "reference sync failed", { err: String(e) });
51
+ }
52
+ // 3. Final drain — anything newly queued or retried after pull
53
+ try {
54
+ await drainPending(bid);
55
+ }
56
+ catch (e) {
57
+ syncLogger.warn("FullSync", "drainPending(post) failed", { err: String(e) });
58
+ }
59
+ syncLogger.info("FullSync", "Complete", { businessId: bid });
60
+ }
61
+ catch (e) {
62
+ syncLogger.warn("FullSync", "Failed", { err: String(e) });
63
+ }
64
+ })();
65
+ inFlight = p;
66
+ try {
67
+ await p;
68
+ }
69
+ finally {
70
+ inFlight = null;
71
+ }
72
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Core Health Probe — periodic cloud reachability check.
3
+ *
4
+ * Probes `GET /health` using the injected HttpClient with a short timeout.
5
+ * On success: marks cloud reachable (triggers sync replay via connectivity
6
+ * subscribers). On failure: marks unreachable and schedules a retry with
7
+ * exponential backoff (2s → 5min cap, ±20% jitter).
8
+ *
9
+ * Started automatically by `createTeincCore()`. Host apps should not need
10
+ * to call this directly.
11
+ */
12
+ /**
13
+ * Single probe attempt. Returns true if cloud is reachable.
14
+ */
15
+ export declare function probeCloudHealth(): Promise<boolean>;
16
+ /**
17
+ * Start the health probe. Safe to call multiple times (idempotent).
18
+ * Returns a stop function.
19
+ */
20
+ export declare function startHealthProbe(): () => void;
21
+ /**
22
+ * Stop the health probe and clean up all timers.
23
+ */
24
+ export declare function stopHealthProbe(): void;