@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
@@ -3,6 +3,7 @@ import { eventsService } from "./events.service";
3
3
  import { useSyncExternalStore } from "react";
4
4
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
5
5
  import { getAuthToken } from "../adapters/auth";
6
+ import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
6
7
  function useBusinessId() {
7
8
  return useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
8
9
  }
@@ -10,6 +11,10 @@ function useIsAuthenticated() {
10
11
  // poll auth token presence; host should setAuthProvider — if no provider, assume authenticated when token exists
11
12
  return !!getAuthToken();
12
13
  }
14
+ function useIsEffectivelyOnline() {
15
+ const s = useSyncExternalStore(subscribeConnectivity, getConnectivityState, getConnectivityState);
16
+ return s.isOnline && s.isCloudReachable !== "unreachable";
17
+ }
13
18
  export function useEventsConnection() {
14
19
  const activeBusinessId = useBusinessId();
15
20
  // derive userId from auth token? Host must provide via setAuthProvider that encodes user, but we need userId for topic.
@@ -17,12 +22,16 @@ export function useEventsConnection() {
17
22
  // Minimal: require host to call eventsService.connect with userId directly via wrapper — this hook is convenience that reads business only.
18
23
  // To keep zero-config, we attempt to read from window localStorage token decode — fallback no-op.
19
24
  const userId = deriveUserId();
25
+ // Offline-first: never open the Phoenix socket while the cloud is known
26
+ // unreachable (backend down). Connecting anyway just spins the 1s→10s
27
+ // reconnect loop and spams WS errors. Reconnect fires via effect dep.
28
+ const online = useIsEffectivelyOnline();
20
29
  useEffect(() => {
21
- if (!userId)
30
+ if (!userId || !online)
22
31
  return;
23
32
  const disconnect = eventsService.connect(userId, activeBusinessId);
24
33
  return () => disconnect();
25
- }, [userId, activeBusinessId]);
34
+ }, [userId, activeBusinessId, online]);
26
35
  }
27
36
  function deriveUserId() {
28
37
  try {
@@ -25,3 +25,5 @@ export declare function fetchReferenceVersions(businessId: string): Promise<Refe
25
25
  */
26
26
  export declare function fetchReferenceData(businessId: string, keys: ReferenceDataKey[]): Promise<Record<ReferenceDataKey, unknown>>;
27
27
  export declare function fetchReferenceChanges<T>(businessId: string, key: ReferenceDataKey, fromVersion: string): Promise<ReferenceChangeSet<T>>;
28
+ export { setReferenceData, mergeReferenceData } from "./operations";
29
+ export { setOperatingHoursLocal, setBusinessProfileLocal } from "./operations";
@@ -40,3 +40,5 @@ export async function fetchReferenceChanges(businessId, key, fromVersion) {
40
40
  const response = await api.get(`/businesses/${businessId}/reference-changes`, { params: { key, from_version: fromVersion } });
41
41
  return response.data.data;
42
42
  }
43
+ export { setReferenceData, mergeReferenceData } from "./operations";
44
+ export { setOperatingHoursLocal, setBusinessProfileLocal } from "./operations";
@@ -8,16 +8,21 @@
8
8
  *
9
9
  * Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
10
10
  */
11
+ import { useQuery } from "@tanstack/react-query";
11
12
  /**
12
13
  * Standard hook for both apps — businessId comes from the host's active business store.
13
- * Returns the same shape as the previous app-level useMyBusinessCapabilities.
14
+ * Offline-first: when offline, only the SQLite cache (`capabilities`) is used so
15
+ * nav filtering (menu/pos/orders/deliveries hidden) does not break when backend is down.
16
+ * When online, network refreshes the cache.
14
17
  */
15
- export declare function useBusinessCapabilities(businessId?: string | null): import("@tanstack/react-query").UseQueryResult<{
16
- role: string;
17
- source: string;
18
- capabilities: string[];
19
- status: string;
20
- }, Error>;
18
+ export declare function useBusinessCapabilities(businessId?: string | null): ReturnType<typeof useQuery> & {
19
+ data: {
20
+ role: string;
21
+ source: string;
22
+ capabilities: string[];
23
+ status: string;
24
+ } | undefined;
25
+ };
21
26
  export declare function useHasCapabilityFor(businessId: string | undefined, capability: string): boolean;
22
27
  export declare const CAPABILITY_BY_COMMAND: Record<string, string>;
23
28
  export declare function hasCapabilitySync(businessId: string, commandType: string): Promise<boolean>;
@@ -9,10 +9,11 @@
9
9
  * Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
10
10
  */
11
11
  import { useEffect, useState } from "react";
12
- import { useQuery, useQueryClient } from "@tanstack/react-query";
12
+ import { useQuery } from "@tanstack/react-query";
13
13
  import { openSyncDatabase } from "../db/connection";
14
14
  import { getReferenceData, setReferenceData } from "./operations";
15
15
  import { businessService } from "../adapters/services";
16
+ import { useIsEffectivelyOnline } from "../hooks/use-infinite-list";
16
17
  const MEMBERSHIP_KEYS = {
17
18
  all: ["my-membership"],
18
19
  byBusiness: (id) => [...MEMBERSHIP_KEYS.all, id],
@@ -42,13 +43,14 @@ function useCapabilitiesPlaceholder(businessId) {
42
43
  }
43
44
  /**
44
45
  * Standard hook for both apps — businessId comes from the host's active business store.
45
- * Returns the same shape as the previous app-level useMyBusinessCapabilities.
46
+ * Offline-first: when offline, only the SQLite cache (`capabilities`) is used so
47
+ * nav filtering (menu/pos/orders/deliveries hidden) does not break when backend is down.
48
+ * When online, network refreshes the cache.
46
49
  */
47
50
  export function useBusinessCapabilities(businessId) {
48
- const queryClient = useQueryClient();
49
- void queryClient;
51
+ const isOnline = useIsEffectivelyOnline();
50
52
  const cached = useCapabilitiesPlaceholder(businessId ?? null);
51
- return useQuery({
53
+ const query = useQuery({
52
54
  queryKey: MEMBERSHIP_KEYS.byBusiness(businessId ?? ""),
53
55
  queryFn: async () => {
54
56
  const res = (await businessService.fetchMyBusinessMembership(businessId));
@@ -64,16 +66,26 @@ export function useBusinessCapabilities(businessId) {
64
66
  catch { }
65
67
  return res;
66
68
  },
67
- enabled: !!businessId,
68
- staleTime: Infinity,
69
+ enabled: !!businessId && isOnline,
70
+ staleTime: 30 * 1000,
71
+ retry: false,
72
+ networkMode: "offlineFirst",
69
73
  placeholderData: cached ?? undefined,
70
- select: (data) => ({
71
- role: data.data.role,
72
- source: data.data.source,
73
- capabilities: data.data.capabilities,
74
- status: data.data.status,
75
- }),
76
74
  });
75
+ // When offline, use the SQLite-cached membership directly so nav stays visible.
76
+ const raw = query.data ?? cached ?? null;
77
+ const selected = raw
78
+ ? {
79
+ role: raw.data.role,
80
+ source: raw.data.source,
81
+ capabilities: raw.data.capabilities,
82
+ status: raw.data.status,
83
+ }
84
+ : undefined;
85
+ return {
86
+ ...query,
87
+ data: selected,
88
+ };
77
89
  }
78
90
  export function useHasCapabilityFor(businessId, capability) {
79
91
  const { data } = useBusinessCapabilities(businessId);
@@ -93,6 +105,8 @@ export const CAPABILITY_BY_COMMAND = {
93
105
  "menu:update_item": "menu_manage",
94
106
  "menu:delete_item": "menu_manage",
95
107
  "menu:create_category": "menu_manage",
108
+ "menu:update_category": "menu_manage",
109
+ "menu:delete_category": "menu_manage",
96
110
  "payment:confirm_cash": "orders_accept",
97
111
  };
98
112
  export async function hasCapabilitySync(businessId, commandType) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Reference-data React hooks — single source for both apps.
3
+ * Sync hooks use core connectivity/business adapters; cache reads are pure
4
+ * SQLite (never network). Image-query invalidation uses the shared
5
+ * ["cachedImage"] prefix both apps use.
6
+ */
7
+ import type { ReferenceDataKey, ReferenceSyncOptions, ReferenceSyncResult } from "./types";
8
+ export declare const REFERENCE_KEYS: {
9
+ all: readonly ["reference"];
10
+ sync: readonly ["reference", "sync"];
11
+ data: (key: ReferenceDataKey) => readonly ["reference", "data", ReferenceDataKey];
12
+ };
13
+ /**
14
+ * Trigger reference data synchronization when the active business changes.
15
+ * Does not return data; fires a background sync. Set `enabled` to false to
16
+ * disable automatic sync.
17
+ */
18
+ export declare function useReferenceDataSync(businessId: string | undefined, options?: Omit<ReferenceSyncOptions, "businessId"> & {
19
+ enabled?: boolean;
20
+ }): void;
21
+ /** Manually trigger reference data sync. */
22
+ export declare function useSyncReferenceData(): (options: ReferenceSyncOptions) => Promise<ReferenceSyncResult>;
23
+ /**
24
+ * App-level reference sync: downloads every reference-backed dataset into
25
+ * the SQLite cache so the business operates fully offline. Call once from
26
+ * the app root/layouts. Strict reachable gate — `unknown` (cold start)
27
+ * must not fire sync, otherwise airplane launch hangs on versions fetch.
28
+ */
29
+ export declare function useBusinessReferenceDataSync(): void;
30
+ /**
31
+ * Read a reference data record from the local SQLite cache for the active
32
+ * business. Never hits the network.
33
+ */
34
+ export declare function useReferenceData<T>(key: ReferenceDataKey): import("@tanstack/react-query").UseQueryResult<import("./types").ReferenceDataRecord<T> | null, Error>;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Reference-data React hooks — single source for both apps.
3
+ * Sync hooks use core connectivity/business adapters; cache reads are pure
4
+ * SQLite (never network). Image-query invalidation uses the shared
5
+ * ["cachedImage"] prefix both apps use.
6
+ */
7
+ import { useEffect, useCallback } from "react";
8
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useSyncExternalStore } from "react";
10
+ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
11
+ import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
12
+ import { openSyncDatabase } from "../db/connection";
13
+ import { referenceSyncService } from "./service";
14
+ import { getReferenceData } from "./operations";
15
+ export const REFERENCE_KEYS = {
16
+ all: ["reference"],
17
+ sync: ["reference", "sync"],
18
+ data: (key) => ["reference", "data", key],
19
+ };
20
+ function useActiveBusinessId() {
21
+ const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
22
+ return id ?? undefined;
23
+ }
24
+ function useConnectivitySnapshot() {
25
+ return useSyncExternalStore(subscribeConnectivity, getConnectivityState, getConnectivityState);
26
+ }
27
+ /**
28
+ * Trigger reference data synchronization when the active business changes.
29
+ * Does not return data; fires a background sync. Set `enabled` to false to
30
+ * disable automatic sync.
31
+ */
32
+ export function useReferenceDataSync(businessId, options = {}) {
33
+ const { enabled = true, ...syncOptions } = options;
34
+ useEffect(() => {
35
+ if (!enabled || !businessId)
36
+ return;
37
+ let cancelled = false;
38
+ referenceSyncService
39
+ .sync({ businessId, ...syncOptions })
40
+ .then((result) => {
41
+ if (cancelled)
42
+ return;
43
+ if (result.failed.length > 0) {
44
+ try {
45
+ console.debug("[ReferenceSync] failed keys (non-fatal):", result.failed);
46
+ }
47
+ catch { }
48
+ }
49
+ })
50
+ .catch(() => {
51
+ if (cancelled)
52
+ return;
53
+ });
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [businessId, enabled, JSON.stringify(syncOptions)]);
59
+ }
60
+ /** Manually trigger reference data sync. */
61
+ export function useSyncReferenceData() {
62
+ return useCallback(async (options) => {
63
+ return referenceSyncService.sync(options);
64
+ }, []);
65
+ }
66
+ /**
67
+ * App-level reference sync: downloads every reference-backed dataset into
68
+ * the SQLite cache so the business operates fully offline. Call once from
69
+ * the app root/layouts. Strict reachable gate — `unknown` (cold start)
70
+ * must not fire sync, otherwise airplane launch hangs on versions fetch.
71
+ */
72
+ export function useBusinessReferenceDataSync() {
73
+ const businessId = useActiveBusinessId();
74
+ const connectivity = useConnectivitySnapshot();
75
+ const queryClient = useQueryClient();
76
+ const isOnline = connectivity.isOnline;
77
+ const isCloudReachable = connectivity.isCloudReachable;
78
+ useEffect(() => {
79
+ if (!businessId || !isOnline || isCloudReachable !== "reachable")
80
+ return;
81
+ let cancelled = false;
82
+ referenceSyncService
83
+ .sync({ businessId })
84
+ .then((result) => {
85
+ if (cancelled)
86
+ return;
87
+ try {
88
+ void queryClient.invalidateQueries({ queryKey: ["cachedImage"] });
89
+ }
90
+ catch { }
91
+ if (result.failed.length > 0) {
92
+ try {
93
+ console.debug("[ReferenceSync] failed keys (non-fatal):", result.failed);
94
+ }
95
+ catch { }
96
+ }
97
+ })
98
+ .catch(() => {
99
+ if (cancelled)
100
+ return;
101
+ });
102
+ return () => {
103
+ cancelled = true;
104
+ };
105
+ }, [businessId, isOnline, isCloudReachable, queryClient]);
106
+ }
107
+ /**
108
+ * Read a reference data record from the local SQLite cache for the active
109
+ * business. Never hits the network.
110
+ */
111
+ export function useReferenceData(key) {
112
+ const businessId = useActiveBusinessId();
113
+ return useQuery({
114
+ queryKey: [...REFERENCE_KEYS.data(key), businessId],
115
+ queryFn: async () => {
116
+ if (!businessId)
117
+ return null;
118
+ const db = await openSyncDatabase();
119
+ return getReferenceData(db, businessId, key);
120
+ },
121
+ staleTime: Infinity,
122
+ enabled: !!businessId,
123
+ });
124
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReferenceDataKey } from "./types";
2
+ import type { Command } from "../sync/types";
3
+ export declare const MENU_ITEM_KEY: ReferenceDataKey;
4
+ export declare const MENU_CATEGORY_KEY: ReferenceDataKey;
5
+ /**
6
+ * Apply a menu:* command to the local reference cache. Returns affected keys
7
+ * (for reference_data.changed fan-out) and whether anything was written.
8
+ * Idempotent by entity id: re-applying the same command converges.
9
+ */
10
+ export declare function applyMenuCommandLocal(businessId: string, command: Pick<Command, "command_type" | "entity_id" | "command_id" | "payload">): Promise<{
11
+ keys: ReferenceDataKey[];
12
+ applied: boolean;
13
+ }>;
14
+ /**
15
+ * Revert an optimistic menu write after a definitive backend rejection:
16
+ * restores the stashed prior record, or deletes the row for creates.
17
+ * Peers converge via reference_fetch/pull on the next sync.
18
+ */
19
+ export declare function revertMenuCommand(businessId: string, commandType: string, entityId: string): Promise<boolean>;
20
+ /** Keys whose cache a menu command affects (for fan-out without applying). */
21
+ export declare function menuCommandKeys(commandType: string): ReferenceDataKey[];
22
+ /**
23
+ * One-time janitor: remove cached menu rows that are not backend-shaped
24
+ * MenuItems (written by older builds that cached raw wire payloads). This is
25
+ * explicit removal of invalid data — never translation. The outbox remains
26
+ * the source of truth: queued creates replay to the backend and the next
27
+ * pull restores canonical rows. Returns the purge count for logging.
28
+ */
29
+ export declare function purgeNonCanonicalMenuCache(businessId: string): Promise<number>;
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Local application of menu commands to the durable reference cache.
3
+ *
4
+ * Used by (a) handleNodeCommand when this device executes a peer's menu
5
+ * command over LAN, and (b) the engine queue branch for optimistic local
6
+ * apply while fully offline. Shapes mirror the cloud fetchers
7
+ * (reference/service fetchFallback): menu:items payload {data:[...]},
8
+ * menu:categories payload {data:[...]} (or bare array — tolerated).
9
+ *
10
+ * Versions are intentionally left untouched: optimistic/LAN applies converge
11
+ * peers via explicit reference_data.changed events + reference_fetch (which
12
+ * serve payloads directly, no version compare). Real versions come from the
13
+ * backend when the command replays through the cloud path.
14
+ */
15
+ import { openSyncDatabase } from "../db/connection";
16
+ import { getReferenceData, setReferenceData } from "./operations";
17
+ import { syncLogger } from "../utils/sync-logger";
18
+ import { generateUUID } from "../utils/uuid";
19
+ import { hlcToString, tickHLC, hlcFromString } from "../utils/hlc";
20
+ import { getAppInstanceId } from "../adapters/devices";
21
+ export const MENU_ITEM_KEY = "menu:items";
22
+ export const MENU_CATEGORY_KEY = "menu:categories";
23
+ function asArray(payload) {
24
+ if (Array.isArray(payload))
25
+ return payload;
26
+ const data = payload?.data;
27
+ return Array.isArray(data) ? data : [];
28
+ }
29
+ function withArray(payload, arr) {
30
+ if (Array.isArray(payload))
31
+ return arr;
32
+ if (payload && typeof payload === "object")
33
+ return { ...payload, data: arr };
34
+ return { data: arr };
35
+ }
36
+ /**
37
+ * Extract the caller-built cache record from a menu command payload. The
38
+ * reference cache holds backend-shaped records ONLY (MenuItem / MenuCategory
39
+ * exactly as the serializer returns them) — never wire payloads. Callers
40
+ * build the record alongside the wire payload; core performs no translation.
41
+ * A command without a record writes nothing (cloud convergence fills the
42
+ * cache on the next pull).
43
+ */
44
+ function cacheRecord(payload) {
45
+ const rec = payload.cacheRecord;
46
+ if (!rec || typeof rec !== "object" || Array.isArray(rec))
47
+ return null;
48
+ return rec;
49
+ }
50
+ async function patchKey(businessId, key, patch) {
51
+ try {
52
+ const db = await openSyncDatabase();
53
+ const rec = await getReferenceData(db, businessId, key);
54
+ // Seed empty row if first-ever offline create before any pull.
55
+ if (!rec) {
56
+ const next = patch([]);
57
+ const hlc = hlcToString(tickHLC(Date.now(), null, getAppInstanceId()));
58
+ await setReferenceData(db, businessId, {
59
+ key,
60
+ version: hlc,
61
+ payload: withArray({ data: [] }, next),
62
+ downloadedAt: new Date().toISOString(),
63
+ lastEventId: `evt_${hlc}`,
64
+ lastHlc: hlc,
65
+ });
66
+ return true;
67
+ }
68
+ const next = patch(asArray(rec.payload));
69
+ const hlc = hlcToString(tickHLC(Date.now(), hlcFromString(rec.lastHlc ?? rec.version) ?? null, getAppInstanceId()));
70
+ await setReferenceData(db, businessId, {
71
+ key,
72
+ version: hlc,
73
+ payload: withArray(rec.payload, next),
74
+ downloadedAt: new Date().toISOString(),
75
+ lastEventId: `evt_${hlc}`,
76
+ lastHlc: hlc,
77
+ });
78
+ return true;
79
+ }
80
+ catch (e) {
81
+ syncLogger.debug("MenuApply", "local menu patch failed", { businessId, key, err: String(e) });
82
+ return false;
83
+ }
84
+ }
85
+ /**
86
+ * Apply a menu:* command to the local reference cache. Returns affected keys
87
+ * (for reference_data.changed fan-out) and whether anything was written.
88
+ * Idempotent by entity id: re-applying the same command converges.
89
+ */
90
+ export async function applyMenuCommandLocal(businessId, command) {
91
+ const payload = (command.payload ?? {});
92
+ const idOf = (v) => (typeof v === "string" && v ? v : null);
93
+ switch (command.command_type) {
94
+ case "menu:create_item": {
95
+ const id = idOf(command.entity_id) ?? idOf(payload.id) ?? generateUUID();
96
+ const record = cacheRecord(payload);
97
+ if (!record) {
98
+ syncLogger.debug("MenuApply", "create_item without cacheRecord — skipping cache write", { businessId, id: command.entity_id });
99
+ return { keys: [MENU_ITEM_KEY], applied: false };
100
+ }
101
+ const item = { ...record, id };
102
+ const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => {
103
+ stashPrior(arr, businessId, id);
104
+ return arr.some((o) => String(o.id) === id) ? arr.map((o) => (String(o.id) === id ? item : o)) : [...arr, item];
105
+ });
106
+ return { keys: [MENU_ITEM_KEY], applied };
107
+ }
108
+ case "menu:update_item": {
109
+ const id = idOf(command.entity_id) ?? idOf(payload.id) ?? idOf(payload.item_id);
110
+ if (!id)
111
+ return { keys: [MENU_ITEM_KEY], applied: false };
112
+ const record = cacheRecord(payload);
113
+ if (!record) {
114
+ syncLogger.debug("MenuApply", "update_item without cacheRecord — skipping cache write", { businessId, id });
115
+ return { keys: [MENU_ITEM_KEY], applied: false };
116
+ }
117
+ const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => {
118
+ stashPrior(arr, businessId, id);
119
+ return arr.map((o) => (String(o.id) === id ? { ...record, id } : o));
120
+ });
121
+ return { keys: [MENU_ITEM_KEY], applied };
122
+ }
123
+ case "menu:delete_item": {
124
+ const id = idOf(command.entity_id) ?? idOf(payload.id);
125
+ if (!id)
126
+ return { keys: [MENU_ITEM_KEY], applied: false };
127
+ const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => arr.filter((o) => String(o.id) !== id));
128
+ return { keys: [MENU_ITEM_KEY], applied };
129
+ }
130
+ case "menu:create_category": {
131
+ const id = idOf(command.entity_id) ?? idOf(payload.id) ?? generateUUID();
132
+ const record = cacheRecord(payload);
133
+ if (!record) {
134
+ syncLogger.debug("MenuApply", "create_category without cacheRecord — skipping cache write", { businessId });
135
+ return { keys: [MENU_CATEGORY_KEY], applied: false };
136
+ }
137
+ const cat = { ...record, id };
138
+ const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => {
139
+ stashPrior(arr, businessId, id);
140
+ return arr.some((o) => String(o.id) === id) ? arr.map((o) => (String(o.id) === id ? cat : o)) : [...arr, cat];
141
+ });
142
+ return { keys: [MENU_CATEGORY_KEY], applied };
143
+ }
144
+ case "menu:update_category": {
145
+ const id = idOf(command.entity_id) ?? idOf(payload.id) ?? idOf(payload.category_id);
146
+ if (!id)
147
+ return { keys: [MENU_CATEGORY_KEY], applied: false };
148
+ const record = cacheRecord(payload);
149
+ if (!record) {
150
+ syncLogger.debug("MenuApply", "update_category without cacheRecord — skipping cache write", { businessId, id });
151
+ return { keys: [MENU_CATEGORY_KEY], applied: false };
152
+ }
153
+ const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => {
154
+ stashPrior(arr, businessId, id);
155
+ return arr.map((o) => (String(o.id) === id ? { ...record, id } : o));
156
+ });
157
+ return { keys: [MENU_CATEGORY_KEY], applied };
158
+ }
159
+ case "menu:delete_category": {
160
+ const id = idOf(command.entity_id) ?? idOf(payload.id) ?? idOf(payload.category_id);
161
+ if (!id)
162
+ return { keys: [MENU_CATEGORY_KEY], applied: false };
163
+ const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => arr.filter((o) => String(o.id) !== id));
164
+ return { keys: [MENU_CATEGORY_KEY], applied };
165
+ }
166
+ default:
167
+ return { keys: [], applied: false };
168
+ }
169
+ }
170
+ /**
171
+ * Prior-record stash for toast-and-revert: before overwriting a cached row,
172
+ * the previous record is kept in memory so a backend rejection can restore
173
+ * it. Creates stash a tombstone (revert deletes the row).
174
+ */
175
+ const priorRecords = new Map();
176
+ function stashPrior(arr, businessId, id) {
177
+ try {
178
+ const prev = arr.find((o) => String(o.id) === id);
179
+ priorRecords.set(`${businessId}:${id}`, prev ? { ...prev } : null);
180
+ }
181
+ catch { }
182
+ }
183
+ /**
184
+ * Revert an optimistic menu write after a definitive backend rejection:
185
+ * restores the stashed prior record, or deletes the row for creates.
186
+ * Peers converge via reference_fetch/pull on the next sync.
187
+ */
188
+ export async function revertMenuCommand(businessId, commandType, entityId) {
189
+ const key = `${businessId}:${entityId}`;
190
+ try {
191
+ if (commandType === "menu:create_item") {
192
+ const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => arr.filter((o) => String(o.id) !== entityId));
193
+ priorRecords.delete(key);
194
+ return applied;
195
+ }
196
+ if (commandType === "menu:update_item") {
197
+ const prior = priorRecords.get(key);
198
+ priorRecords.delete(key);
199
+ if (prior === undefined)
200
+ return false;
201
+ const applied = await patchKey(businessId, MENU_ITEM_KEY, (arr) => {
202
+ if (prior === null)
203
+ return arr.filter((o) => String(o.id) !== entityId);
204
+ return arr.some((o) => String(o.id) === entityId)
205
+ ? arr.map((o) => (String(o.id) === entityId ? { ...prior, id: entityId } : o))
206
+ : [...arr, { ...prior, id: entityId }];
207
+ });
208
+ return applied;
209
+ }
210
+ if (commandType === "menu:create_category") {
211
+ const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => arr.filter((o) => String(o.id) !== entityId));
212
+ priorRecords.delete(key);
213
+ return applied;
214
+ }
215
+ if (commandType === "menu:update_category") {
216
+ const prior = priorRecords.get(key);
217
+ priorRecords.delete(key);
218
+ if (prior === undefined)
219
+ return false;
220
+ const applied = await patchKey(businessId, MENU_CATEGORY_KEY, (arr) => {
221
+ if (prior === null)
222
+ return arr.filter((o) => String(o.id) !== entityId);
223
+ return arr.some((o) => String(o.id) === entityId)
224
+ ? arr.map((o) => (String(o.id) === entityId ? { ...prior, id: entityId } : o))
225
+ : [...arr, { ...prior, id: entityId }];
226
+ });
227
+ return applied;
228
+ }
229
+ }
230
+ catch { }
231
+ return false;
232
+ }
233
+ /** Keys whose cache a menu command affects (for fan-out without applying). */
234
+ export function menuCommandKeys(commandType) {
235
+ if (commandType.startsWith("menu:") && commandType.includes("categor"))
236
+ return [MENU_CATEGORY_KEY];
237
+ if (commandType.startsWith("menu:"))
238
+ return [MENU_ITEM_KEY];
239
+ return [];
240
+ }
241
+ function isCanonicalMenuItem(o) {
242
+ if (!o || typeof o !== "object")
243
+ return false;
244
+ const r = o;
245
+ const bp = r.basePrice;
246
+ return (typeof r.id === "string" &&
247
+ typeof r.name === "string" &&
248
+ !!bp &&
249
+ typeof bp.amountMinor === "number" &&
250
+ typeof bp.currencyCode === "string" &&
251
+ Array.isArray(r.variants) &&
252
+ Array.isArray(r.extras) &&
253
+ Array.isArray(r.dietary_tags));
254
+ }
255
+ /**
256
+ * One-time janitor: remove cached menu rows that are not backend-shaped
257
+ * MenuItems (written by older builds that cached raw wire payloads). This is
258
+ * explicit removal of invalid data — never translation. The outbox remains
259
+ * the source of truth: queued creates replay to the backend and the next
260
+ * pull restores canonical rows. Returns the purge count for logging.
261
+ */
262
+ export async function purgeNonCanonicalMenuCache(businessId) {
263
+ try {
264
+ const db = await openSyncDatabase();
265
+ const rec = await getReferenceData(db, businessId, MENU_ITEM_KEY);
266
+ if (!rec)
267
+ return 0;
268
+ const arr = asArray(rec.payload);
269
+ const kept = arr.filter(isCanonicalMenuItem);
270
+ const purged = arr.length - kept.length;
271
+ if (purged === 0)
272
+ return 0;
273
+ const hlc = hlcToString(tickHLC(Date.now(), hlcFromString(rec.lastHlc ?? rec.version) ?? null, getAppInstanceId()));
274
+ await setReferenceData(db, businessId, {
275
+ key: MENU_ITEM_KEY,
276
+ version: hlc,
277
+ payload: withArray(rec.payload, kept),
278
+ downloadedAt: new Date().toISOString(),
279
+ lastEventId: `evt_${hlc}`,
280
+ lastHlc: hlc,
281
+ });
282
+ syncLogger.warn("MenuApply", "purged non-canonical cached menu rows", { businessId, purged, kept: kept.length });
283
+ return purged;
284
+ }
285
+ catch (e) {
286
+ syncLogger.debug("MenuApply", "menu cache purge failed", { businessId, err: String(e) });
287
+ return 0;
288
+ }
289
+ }