@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
@@ -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;
@@ -0,0 +1,138 @@
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
+ import { getConnectivityState, setCloudReachable, subscribeConnectivity } from "../adapters/connectivity";
13
+ import { getHttpClient } from "../adapters/http";
14
+ import { API_BASE_URL } from "../utils/constants";
15
+ import { syncLogger } from "../utils/sync-logger";
16
+ import { BackoffTracker } from "./backoff";
17
+ import { getActiveBusiness } from "../adapters/business";
18
+ import { getAuthToken } from "../adapters/auth";
19
+ const PROBE_URL = "/health";
20
+ const PROBE_TIMEOUT_MS = 4000;
21
+ const PROBE_INTERVAL_MS = 60000;
22
+ const UNREACHABLE_COOLDOWN_MS = 15000;
23
+ const INITIAL_DELAY_MS = 3000;
24
+ let probeTimer = null;
25
+ let cooldownTimer = null;
26
+ let stopFn = null;
27
+ const backoff = new BackoffTracker();
28
+ /**
29
+ * Single probe attempt. Returns true if cloud is reachable.
30
+ */
31
+ export async function probeCloudHealth() {
32
+ const http = (() => { try {
33
+ return getHttpClient();
34
+ }
35
+ catch {
36
+ return null;
37
+ } })();
38
+ if (!http)
39
+ return false;
40
+ const { isOnline } = getConnectivityState();
41
+ if (!isOnline)
42
+ return false;
43
+ try {
44
+ const controller = new AbortController();
45
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
46
+ await http.get(PROBE_URL, { signal: controller.signal });
47
+ clearTimeout(timer);
48
+ backoff.reset("health");
49
+ const was = getConnectivityState().isCloudReachable;
50
+ setCloudReachable("reachable");
51
+ if (was !== "reachable") {
52
+ syncLogger.info("HealthProbe", "Cloud reachable", { previous: was });
53
+ // Kick off pull→merge→push without blocking probe.
54
+ try {
55
+ const bid = getActiveBusiness()?.id;
56
+ if (bid && getAuthToken()) {
57
+ void import("./full-sync").then((m) => m.performFullSync(bid).catch(() => { }));
58
+ }
59
+ }
60
+ catch { }
61
+ }
62
+ return true;
63
+ }
64
+ catch {
65
+ // Don't mark unreachable here — the sync engine already handles that
66
+ // when a command times out. This probe only detects recovery.
67
+ // Log and backoff so we re-check later.
68
+ syncLogger.debug("HealthProbe", "Probe failed — will retry", {
69
+ previous: getConnectivityState().isCloudReachable,
70
+ });
71
+ scheduleRetry();
72
+ return false;
73
+ }
74
+ }
75
+ function scheduleRetry() {
76
+ if (cooldownTimer)
77
+ return;
78
+ const delay = Math.max(UNREACHABLE_COOLDOWN_MS, backoff.next("health"));
79
+ syncLogger.debug("HealthProbe", "Scheduling retry", { delay });
80
+ cooldownTimer = setTimeout(() => {
81
+ cooldownTimer = null;
82
+ void probeCloudHealth();
83
+ }, delay);
84
+ }
85
+ function startPeriodicProbe() {
86
+ if (probeTimer)
87
+ return;
88
+ probeTimer = setInterval(() => void probeCloudHealth(), PROBE_INTERVAL_MS);
89
+ }
90
+ function stopPeriodicProbe() {
91
+ if (probeTimer) {
92
+ clearInterval(probeTimer);
93
+ probeTimer = null;
94
+ }
95
+ if (cooldownTimer) {
96
+ clearTimeout(cooldownTimer);
97
+ cooldownTimer = null;
98
+ }
99
+ }
100
+ /**
101
+ * Start the health probe. Safe to call multiple times (idempotent).
102
+ * Returns a stop function.
103
+ */
104
+ export function startHealthProbe() {
105
+ if (stopFn)
106
+ return stopFn;
107
+ syncLogger.debug("HealthProbe", "Starting", { probeUrl: `${API_BASE_URL}${PROBE_URL}` });
108
+ // Initial probe (deferred so host bridges can set up first)
109
+ cooldownTimer = setTimeout(() => {
110
+ cooldownTimer = null;
111
+ void probeCloudHealth();
112
+ }, INITIAL_DELAY_MS);
113
+ // Periodic probe
114
+ startPeriodicProbe();
115
+ // Re-probe when network regains
116
+ let prevOnline = getConnectivityState().isOnline;
117
+ const unsub = subscribeConnectivity(() => {
118
+ const { isOnline } = getConnectivityState();
119
+ if (isOnline && !prevOnline) {
120
+ syncLogger.debug("HealthProbe", "Network regained — probing");
121
+ void probeCloudHealth();
122
+ }
123
+ prevOnline = isOnline;
124
+ });
125
+ stopFn = () => {
126
+ stopPeriodicProbe();
127
+ unsub();
128
+ stopFn = null;
129
+ };
130
+ return stopFn;
131
+ }
132
+ /**
133
+ * Stop the health probe and clean up all timers.
134
+ */
135
+ export function stopHealthProbe() {
136
+ if (stopFn)
137
+ stopFn();
138
+ }
@@ -12,8 +12,10 @@
12
12
  */
13
13
  import { syncEngine } from "./engine";
14
14
  import { buildCommand } from "./command-builder";
15
+ import { assertOnlineFor } from "./online-gates";
15
16
  import { syncLogger } from "../utils/sync-logger";
16
17
  import { ApiError } from "../adapters/http";
18
+ import { getConnectivityState } from "../adapters/connectivity";
17
19
  import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
18
20
  import { openSyncDatabase } from "../db/connection";
19
21
  import { getReferenceData } from "../reference/operations";
@@ -21,6 +23,23 @@ export async function runOfflineCapableMutation(options) {
21
23
  // Per-command capability guard before even attempting direct call
22
24
  const required = CAPABILITY_BY_COMMAND[options.commandType];
23
25
  if (required) {
26
+ // Online-required gate first: these commands can never succeed offline —
27
+ // throw loud immediately instead of queuing what no transport can fulfill.
28
+ assertOnlineFor(options.commandType, options.payload);
29
+ // Local-first for engine-routed domains (menu:*): commit locally via the
30
+ // engine and resolve immediately. Network is sync transport only — never a
31
+ // mutation delay. The engine cloud path carries the same 2.5s cap and
32
+ // returns the backend response when online.
33
+ if (options.commandType.startsWith("menu:")) {
34
+ const command = buildCommand({
35
+ commandType: options.commandType,
36
+ businessId: options.businessId,
37
+ entityId: options.entityId,
38
+ payload: options.payload,
39
+ });
40
+ const events = await syncEngine.execute(command);
41
+ return events[0]?.payload;
42
+ }
24
43
  try {
25
44
  const db = await openSyncDatabase();
26
45
  const rec = await getReferenceData(db, options.businessId, "capabilities");
@@ -35,6 +54,12 @@ export async function runOfflineCapableMutation(options) {
35
54
  }
36
55
  }
37
56
  try {
57
+ // Cloud already known unreachable (or no network): skip the direct REST
58
+ // attempt entirely — it would only burn a timeout before falling back.
59
+ const cs = getConnectivityState();
60
+ if (!cs.isOnline || cs.isCloudReachable === "unreachable") {
61
+ throw new Error("offline-capable: cloud known unreachable, engine route");
62
+ }
38
63
  return await options.run();
39
64
  }
40
65
  catch (error) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Online-required allowlist — single source for what can NEVER work offline.
3
+ *
4
+ * Everything else is offline-first (SQLite read, local commit, engine sync).
5
+ * These commands throw immediately when the cloud is unreachable: queuing
6
+ * them would be dishonest because no transport (LAN or outbox replay) can
7
+ * ever fulfill them without the backend.
8
+ */
9
+ /** Command types that require the cloud, unconditionally. */
10
+ export declare const ONLINE_REQUIRED: Record<string, string>;
11
+ export declare function isOnlineCapable(): boolean;
12
+ /**
13
+ * Throw when a command cannot proceed offline. Handles payload-dependent
14
+ * cases: order:create with delivery type or prepaid/mobile-money payment,
15
+ * and delivery/order transitions the domain already gates.
16
+ */
17
+ export declare function assertOnlineFor(commandType: string, payload?: Record<string, unknown>): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Online-required allowlist — single source for what can NEVER work offline.
3
+ *
4
+ * Everything else is offline-first (SQLite read, local commit, engine sync).
5
+ * These commands throw immediately when the cloud is unreachable: queuing
6
+ * them would be dishonest because no transport (LAN or outbox replay) can
7
+ * ever fulfill them without the backend.
8
+ */
9
+ import { getConnectivityState } from "../adapters/connectivity";
10
+ /** Command types that require the cloud, unconditionally. */
11
+ export const ONLINE_REQUIRED = {
12
+ "order:assign_rider": "Assign rider requires online",
13
+ "order:confirm_handover": "Handover confirmation requires online",
14
+ "chat:send": "Chat requires online",
15
+ "notification:register": "Notifications require online",
16
+ "member:invite": "Member invites require online",
17
+ "member:manage": "Member management requires online",
18
+ "fleet:manage": "Fleet changes require online",
19
+ "settings:manage": "Settings changes require online",
20
+ "kyc:submit": "KYC submission requires online",
21
+ "wallet:withdraw": "Withdrawals require online",
22
+ "wallet:payout": "Payouts require online",
23
+ "payment:charge_online": "Online payment requires online",
24
+ "device:lease": "Device lease requires online (first lease only)",
25
+ };
26
+ export function isOnlineCapable() {
27
+ const s = getConnectivityState();
28
+ return s.isOnline && s.isCloudReachable !== "unreachable";
29
+ }
30
+ /**
31
+ * Throw when a command cannot proceed offline. Handles payload-dependent
32
+ * cases: order:create with delivery type or prepaid/mobile-money payment,
33
+ * and delivery/order transitions the domain already gates.
34
+ */
35
+ export function assertOnlineFor(commandType, payload) {
36
+ const fixed = ONLINE_REQUIRED[commandType];
37
+ if (fixed) {
38
+ if (!isOnlineCapable())
39
+ throw new Error(fixed);
40
+ return;
41
+ }
42
+ if (commandType === "order:create" && payload) {
43
+ const orderType = payload.order_type;
44
+ if (orderType === "delivery" || payload.delivery_params) {
45
+ if (!isOnlineCapable())
46
+ throw new Error("Delivery orders require online");
47
+ return;
48
+ }
49
+ const method = payload.pos_payment_method;
50
+ if (typeof method === "string" && method !== "cash") {
51
+ if (!isOnlineCapable())
52
+ throw new Error("Online payment requires online — use cash while offline");
53
+ }
54
+ }
55
+ }
@@ -5,15 +5,15 @@
5
5
  * CCC = callout 001-999 global monotonic per device, wraps to 001 (new RRR on wrap)
6
6
  * Example: "10-123-001"
7
7
  */
8
- export declare function peekCallout(): number;
9
- export declare function generateOrderNumber(deviceNumber: string): {
8
+ export declare function peekCallout(businessId?: string | null): number;
9
+ export declare function generateOrderNumber(deviceNumber: string, businessId?: string | null): {
10
10
  orderNumber: string;
11
11
  callout: number;
12
12
  random: number;
13
13
  };
14
14
  export declare function generateLocalOrderNumber(): string;
15
15
  export declare function generateOrderNumberForBusiness(businessId: string): string;
16
- export declare function generateLocalCalloutNumber(): number;
16
+ export declare function generateLocalCalloutNumber(businessId?: string | null): number;
17
17
  export declare function isLocalOrderNumber(orderNumber: string): boolean;
18
18
  export declare function isOfflineOrderNumber(orderNumber: string): boolean;
19
19
  export declare function parseOrderNumber(orderNumber: string): {
@@ -21,4 +21,4 @@ export declare function parseOrderNumber(orderNumber: string): {
21
21
  random: string;
22
22
  callout: string;
23
23
  } | null;
24
- export declare function resetCalloutForTests(): void;
24
+ export declare function resetCalloutForTests(businessId?: string | null): void;