@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,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;
@@ -16,23 +16,37 @@ function cryptoRandom100to999() {
16
16
  }
17
17
  return 100 + Math.floor(Math.random() * 900);
18
18
  }
19
- function getNextCallout() {
20
- const stored = getItem(CALLOUT_SEQ_KEY);
19
+ function calloutKeyForBusiness(businessId) {
20
+ return businessId ? `${CALLOUT_SEQ_KEY}:${businessId}` : CALLOUT_SEQ_KEY;
21
+ }
22
+ function getNextCallout(businessId) {
23
+ const key = calloutKeyForBusiness(businessId);
24
+ const stored = getItem(key);
21
25
  let next = (stored ?? 0) + 1;
22
26
  if (next > 999)
23
27
  next = 1;
24
- setItem(CALLOUT_SEQ_KEY, next);
28
+ setItem(key, next);
29
+ // migrate legacy global once: keep global in sync for backward compat
30
+ if (businessId) {
31
+ const legacy = getItem(CALLOUT_SEQ_KEY) ?? 0;
32
+ if (legacy < next)
33
+ setItem(CALLOUT_SEQ_KEY, next);
34
+ }
25
35
  return next;
26
36
  }
27
- export function peekCallout() {
28
- const stored = getItem(CALLOUT_SEQ_KEY);
29
- return stored ?? 0;
37
+ export function peekCallout(businessId) {
38
+ const key = calloutKeyForBusiness(businessId);
39
+ const v = getItem(key);
40
+ if (v != null)
41
+ return v;
42
+ const legacy = getItem(CALLOUT_SEQ_KEY);
43
+ return legacy ?? 0;
30
44
  }
31
- export function generateOrderNumber(deviceNumber) {
45
+ export function generateOrderNumber(deviceNumber, businessId) {
32
46
  if (!/^(0[1-9]|[1-9][0-9])$/.test(deviceNumber)) {
33
47
  throw new Error(`Invalid deviceNumber ${deviceNumber} — must be 01-99`);
34
48
  }
35
- const callout = getNextCallout();
49
+ const callout = getNextCallout(businessId);
36
50
  let random = cryptoRandom100to999();
37
51
  // On wrap to 001, ensure new random (already new per order, but keep hook)
38
52
  if (callout === 1) {
@@ -53,11 +67,11 @@ export function generateOrderNumberForBusiness(businessId) {
53
67
  if (!v || !/^(0[1-9]|[1-9][0-9])$/.test(v)) {
54
68
  throw new Error(`No device lease for business ${businessId} — call ensureLease(businessId) first (online-only until leased)`);
55
69
  }
56
- return generateOrderNumber(v).orderNumber;
70
+ return generateOrderNumber(v, businessId).orderNumber;
57
71
  }
58
- export function generateLocalCalloutNumber() {
59
- // kept for SellerOrder.callout_number compatibility — returns CCC
60
- const next = peekCallout() + 1;
72
+ export function generateLocalCalloutNumber(businessId) {
73
+ // kept for SellerOrder.callout_number compatibility — returns next CCC
74
+ const next = peekCallout(businessId) + 1;
61
75
  return next > 999 ? 1 : next;
62
76
  }
63
77
  // New canonical check — unified for DD-RRR-CCC
@@ -74,6 +88,12 @@ export function parseOrderNumber(orderNumber) {
74
88
  return { deviceNumber: m[1], random: m[2], callout: m[3] };
75
89
  }
76
90
  // Test helpers
77
- export function resetCalloutForTests() {
91
+ export function resetCalloutForTests(businessId) {
92
+ if (businessId) {
93
+ setItem(calloutKeyForBusiness(businessId), 0);
94
+ return;
95
+ }
78
96
  setItem(CALLOUT_SEQ_KEY, 0);
97
+ // also clear per-business keys that tests may have touched
98
+ // caller should pass businessId if they used business-scoped sequences
79
99
  }
@@ -74,7 +74,19 @@ class OrderSnapshotsService {
74
74
  }
75
75
  async applyOrderStatusToReadModelInternal(db, orderId, toStatus, event) {
76
76
  try {
77
- const lists = await getEntitySnapshotsByType(db, "seller_orders");
77
+ const payload = event.payload;
78
+ const businessId = payload?.business_id ?? payload?.businessId ?? null;
79
+ // Dual-write: keep canonical orders table in sync so POS/KDS reads converge instantly.
80
+ if (businessId) {
81
+ try {
82
+ const { updateOrderStatus } = await import("../db/operations");
83
+ await updateOrderStatus(db, businessId, orderId, toStatus, event.timestamp);
84
+ }
85
+ catch { }
86
+ }
87
+ const lists = businessId
88
+ ? await getEntitySnapshotsByType(db, "seller_orders", businessId)
89
+ : await getEntitySnapshotsByType(db, "seller_orders");
78
90
  for (const list of lists) {
79
91
  const data = list.payload.data ?? [];
80
92
  const idx = data.findIndex((o) => o.id === orderId);
@@ -106,7 +118,10 @@ class OrderSnapshotsService {
106
118
  try {
107
119
  const db = await openSyncDatabase();
108
120
  await insertEvent(db, { event, origin });
109
- const lists = await getEntitySnapshotsByType(db, "seller_orders");
121
+ const businessId = payload?.business_id ?? payload?.businessId ?? null;
122
+ const lists = businessId
123
+ ? await getEntitySnapshotsByType(db, "seller_orders", businessId)
124
+ : await getEntitySnapshotsByType(db, "seller_orders");
110
125
  for (const list of lists) {
111
126
  const data = list.payload.data ?? [];
112
127
  const idx = data.findIndex((o) => o.id === orderId || o.order_number === payload?.order_number);
@@ -173,7 +188,7 @@ class OrderSnapshotsService {
173
188
  async getSellerList(businessId, scope) {
174
189
  try {
175
190
  const db = await openSyncDatabase();
176
- const snap = await getEntitySnapshot(db, "seller_orders", scope);
191
+ const snap = await getEntitySnapshot(db, "seller_orders", scope, businessId);
177
192
  if (!snap || snap.business_id !== businessId)
178
193
  return null;
179
194
  return snap.payload.data ?? null;
@@ -15,11 +15,29 @@
15
15
  * order's `order_number`.
16
16
  */
17
17
  import type { DomainEvent } from "./types";
18
+ import type { SellerOrder } from "../types/api/seller.api";
18
19
  /**
19
20
  * Register a callback used to invalidate TanStack Query caches when a pending
20
21
  * order is reconciled. Returns an unregister function.
21
22
  */
22
23
  export declare function registerPendingOrdersInvalidator(fn: () => void): () => void;
24
+ /**
25
+ * Fire the registered query invalidator (lists refetch from SQLite).
26
+ * Used after LAN-applied writes (order:sync) that bypass the engine event
27
+ * pipeline, so peers' UIs converge without waiting for a cloud echo.
28
+ */
29
+ export declare function notifyPendingOrdersChanged(): void;
30
+ /**
31
+ * Merge optimistic (queued, unconfirmed) state into a page of listed orders.
32
+ * Called by list queryFns (page 1 only) so offline work is visible:
33
+ * 1. Pending creations (order.create_queued, local or LAN) missing from the
34
+ * SQLite page are prepended — deduped by id/order_number.
35
+ * 2. Queued transitions only patch the snapshot read model, never the
36
+ * orders table — overlay snapshot statuses onto base rows so the list
37
+ * reflects the queued status immediately (converges on replay).
38
+ * Reactivity comes from the invalidator: every apply path above refetches.
39
+ */
40
+ export declare function mergePendingIntoList(base: SellerOrder[], businessId: string, page: number): Promise<SellerOrder[]>;
23
41
  /**
24
42
  * Load persisted unsynced order:create_queued events into the store.
25
43
  * Idempotent; safe to call after app restart while still offline.
@@ -15,7 +15,7 @@
15
15
  * order's `order_number`.
16
16
  */
17
17
  import { openSyncDatabase } from "../db/connection";
18
- import { insertEvent, getUnsyncedEvents, markEventsSynced, } from "../db/operations";
18
+ import { insertEvent, getUnsyncedEvents, markEventsSynced, getEntitySnapshotsByType, } from "../db/operations";
19
19
  import { usePendingOrdersStore } from "../adapters/pending-orders";
20
20
  import { orderSnapshotsService } from "./order-snapshots.service";
21
21
  import { syncLogger } from "../utils/sync-logger";
@@ -49,6 +49,72 @@ export function registerPendingOrdersInvalidator(fn) {
49
49
  invalidator = null;
50
50
  };
51
51
  }
52
+ /**
53
+ * Fire the registered query invalidator (lists refetch from SQLite).
54
+ * Used after LAN-applied writes (order:sync) that bypass the engine event
55
+ * pipeline, so peers' UIs converge without waiting for a cloud echo.
56
+ */
57
+ export function notifyPendingOrdersChanged() {
58
+ try {
59
+ invalidator?.();
60
+ }
61
+ catch { }
62
+ }
63
+ /**
64
+ * Merge optimistic (queued, unconfirmed) state into a page of listed orders.
65
+ * Called by list queryFns (page 1 only) so offline work is visible:
66
+ * 1. Pending creations (order.create_queued, local or LAN) missing from the
67
+ * SQLite page are prepended — deduped by id/order_number.
68
+ * 2. Queued transitions only patch the snapshot read model, never the
69
+ * orders table — overlay snapshot statuses onto base rows so the list
70
+ * reflects the queued status immediately (converges on replay).
71
+ * Reactivity comes from the invalidator: every apply path above refetches.
72
+ */
73
+ export async function mergePendingIntoList(base, businessId, page) {
74
+ if (page !== 1)
75
+ return base;
76
+ let merged = base;
77
+ try {
78
+ const pending = usePendingOrdersStore.getState().orders;
79
+ if (pending.length) {
80
+ const seen = new Set(merged.map((o) => String(o.id ?? o.order_number)));
81
+ const fresh = pending.filter((p) => !seen.has(String(p.id ?? p.order_number)));
82
+ if (fresh.length)
83
+ merged = [...fresh, ...merged];
84
+ }
85
+ }
86
+ catch { }
87
+ try {
88
+ const db = await openSyncDatabase();
89
+ const snaps = await getEntitySnapshotsByType(db, "seller_orders", businessId);
90
+ if (!snaps.length)
91
+ return merged;
92
+ const statusById = new Map();
93
+ for (const s of snaps) {
94
+ const arr = s.payload?.data ?? [];
95
+ for (const o of arr) {
96
+ if (o?.id && o.status)
97
+ statusById.set(String(o.id), String(o.status));
98
+ }
99
+ }
100
+ if (!statusById.size)
101
+ return merged;
102
+ let patched = false;
103
+ merged = merged.map((o) => {
104
+ const want = statusById.get(String(o.id));
105
+ if (want && want !== o.status) {
106
+ patched = true;
107
+ return { ...o, status: want };
108
+ }
109
+ return o;
110
+ });
111
+ if (patched) {
112
+ syncLogger.debug("PendingOrders", "overlaid queued statuses onto list", { businessId, count: merged.length });
113
+ }
114
+ }
115
+ catch { }
116
+ return merged;
117
+ }
52
118
  /**
53
119
  * Load persisted unsynced order:create_queued events into the store.
54
120
  * Idempotent; safe to call after app restart while still offline.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Global realtime reconciliation — RECONCILE_DEBOUNCE_MS 400 Set coalesce (§24).
3
+ * Canonical in src/sync/use-sync-reconciliation.
4
+ */
5
+ export * from "../use-sync-reconciliation";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Global realtime reconciliation — RECONCILE_DEBOUNCE_MS 400 Set coalesce (§24).
3
+ * Canonical in src/sync/use-sync-reconciliation.
4
+ */
5
+ export * from "../use-sync-reconciliation";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Entity snapshots — local read model (§9 SQLite Model).
3
+ * Canonical in src/db/operations + src/sync/order-snapshots.service.
4
+ */
5
+ export { upsertEntitySnapshot, getEntitySnapshot, getEntitySnapshotsByType, pruneEntitySnapshots } from "../../db/operations";
6
+ export { orderSnapshotsService } from "../order-snapshots.service";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Entity snapshots — local read model (§9 SQLite Model).
3
+ * Canonical in src/db/operations + src/sync/order-snapshots.service.
4
+ */
5
+ export { upsertEntitySnapshot, getEntitySnapshot, getEntitySnapshotsByType, pruneEntitySnapshots } from "../../db/operations";
6
+ export { orderSnapshotsService } from "../order-snapshots.service";
@@ -0,0 +1,18 @@
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
+ export declare function drainOne(businessId: string, entityId: string): Promise<void>;
7
+ export declare function pullRemoteOrders(businessId: string): Promise<void>;
8
+ export declare function drainPending(businessId: string): Promise<void>;
9
+ export declare function setupSyncJobsDrain(): void;
10
+ export declare function getSyncJobsPendingCount(businessId: string): Promise<number>;
11
+ export declare function debugDumpSyncJobs(businessId: string): Promise<Array<{
12
+ id: string;
13
+ command_type: string;
14
+ status: string;
15
+ created_at: string;
16
+ attempts: number;
17
+ last_error: string | null;
18
+ }>>;