@teincfood/core 0.8.1 → 0.8.3

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.
@@ -15,6 +15,7 @@ import { canTransition } from "../../utils/order-transitions";
15
15
  import { validateOrderCreateInput } from "../../validation/offline";
16
16
  import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
17
17
  import { getAuthToken } from "../../adapters/auth";
18
+ import { canBusinessSync } from "../../sync/sync-guard";
18
19
  import { syncLogger } from "../../utils/sync-logger";
19
20
  import { getItem } from "../../adapters/kv";
20
21
  let backfilled = new Set();
@@ -65,6 +66,9 @@ function isOnline() {
65
66
  return isEffectivelyOnline();
66
67
  }
67
68
  export async function createLocalOrder(businessId, input) {
69
+ if (!businessId || !canBusinessSync(businessId)) {
70
+ throw new Error("Cannot create order without active business/login");
71
+ }
68
72
  const isPos = true; // POS terminal always is_pos
69
73
  // Online-required gates (single source: sync/online-gates). Cash needs no
70
74
  // connectivity; prepaid/mobile-money and delivery do.
@@ -159,6 +163,9 @@ export async function createLocalOrder(businessId, input) {
159
163
  return order;
160
164
  }
161
165
  export async function transitionLocalOrder(businessId, orderId, action) {
166
+ if (!businessId || !canBusinessSync(businessId)) {
167
+ throw new Error("Transition requires active business/login");
168
+ }
162
169
  const db = await openSyncDatabase();
163
170
  const order = await getOrderById(db, businessId, orderId);
164
171
  if (!order)
@@ -196,12 +203,15 @@ export async function transitionLocalOrder(businessId, orderId, action) {
196
203
  return updated ?? order;
197
204
  }
198
205
  export async function listLocalOrders(businessId, filters, page = 1, pageSize = 20) {
206
+ if (!businessId) {
207
+ return { data: [], total: 0, page, pageSize, totalPages: 0 };
208
+ }
199
209
  await ensureOrdersBackfilledFromSnapshots(businessId);
200
210
  const db = await openSyncDatabase();
201
211
  const res = await listOrders(db, businessId, filters, page, pageSize);
202
212
  if (res.total === 0) {
203
213
  // blank-screen: snapshot fallback already tried — if still empty and online, fetch from cloud now so listing populates without manual refresh
204
- if (isOnline() && getAuthToken()) {
214
+ if (isOnline() && canBusinessSync(businessId)) {
205
215
  try {
206
216
  await pullRemoteOrders(businessId);
207
217
  }
@@ -223,6 +233,8 @@ export async function listLocalOrders(businessId, filters, page = 1, pageSize =
223
233
  return res;
224
234
  }
225
235
  export async function getLocalOrder(businessId, idOrNumber) {
236
+ if (!businessId)
237
+ return null;
226
238
  await ensureOrdersBackfilledFromSnapshots(businessId);
227
239
  const db = await openSyncDatabase();
228
240
  const found = await getOrderById(db, businessId, idOrNumber);
@@ -6,6 +6,7 @@
6
6
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7
7
  import { useSyncExternalStore } from "react";
8
8
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
9
+ import { getAuthToken, subscribeAuth } from "../adapters/auth";
9
10
  import { fleetService } from "../adapters/services";
10
11
  import { assertOnlineFor } from "../sync/online-gates";
11
12
  import { validateInviteInput } from "../validation/offline";
@@ -26,6 +27,10 @@ function useBusinessId() {
26
27
  const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
27
28
  return id ?? undefined;
28
29
  }
30
+ function useIsAuthenticated() {
31
+ const token = useSyncExternalStore(subscribeAuth, () => getAuthToken(), () => null);
32
+ return !!token;
33
+ }
29
34
  const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
30
35
  // Fleet has no SQLite mirror — offline source is the persisted query cache.
31
36
  // Reads are cache-first (no online gate): the query is enabled offline so
@@ -37,10 +42,11 @@ function useOnline() {
37
42
  // ─── Riders ───
38
43
  export function useFleetRidersQuery(enabled = true, filters) {
39
44
  const businessId = useBusinessId();
45
+ const isAuthenticated = useIsAuthenticated();
40
46
  return useQuery({
41
47
  queryKey: [...FLEET_KEYS.riders(businessId), filters],
42
48
  queryFn: () => fleetService.fetchFleetRiders(businessId, 1, 50, filters),
43
- enabled: enabled && Boolean(businessId),
49
+ enabled: enabled && Boolean(businessId) && isAuthenticated,
44
50
  placeholderData: (prev) => prev,
45
51
  ...OFFLINE_FIRST,
46
52
  });
@@ -121,10 +127,11 @@ export function useActivateRiderMutation() {
121
127
  }
122
128
  export function useJoinRequestsQuery(enabled = true) {
123
129
  const businessId = useBusinessId();
130
+ const isAuthenticated = useIsAuthenticated();
124
131
  return useQuery({
125
132
  queryKey: FLEET_KEYS.joinRequests(businessId),
126
133
  queryFn: () => fleetService.fetchJoinRequests(businessId),
127
- enabled: enabled && Boolean(businessId),
134
+ enabled: enabled && Boolean(businessId) && isAuthenticated,
128
135
  placeholderData: (prev) => prev,
129
136
  ...OFFLINE_FIRST,
130
137
  });
@@ -158,10 +165,11 @@ export function useRejectJoinRequestMutation() {
158
165
  }
159
166
  export function useFleetInvitationsQuery(enabled = true) {
160
167
  const businessId = useBusinessId();
168
+ const isAuthenticated = useIsAuthenticated();
161
169
  return useQuery({
162
170
  queryKey: FLEET_KEYS.invitations(businessId),
163
171
  queryFn: () => fleetService.fetchInvitations(businessId),
164
- enabled: enabled && Boolean(businessId),
172
+ enabled: enabled && Boolean(businessId) && isAuthenticated,
165
173
  placeholderData: (prev) => prev,
166
174
  ...OFFLINE_FIRST,
167
175
  });
@@ -182,10 +190,11 @@ export function useRevokeInvitationMutation() {
182
190
  // ─── Vehicles ───
183
191
  export function useFleetVehiclesQuery(enabled = true) {
184
192
  const businessId = useBusinessId();
193
+ const isAuthenticated = useIsAuthenticated();
185
194
  return useQuery({
186
195
  queryKey: FLEET_KEYS.vehicles(businessId),
187
196
  queryFn: () => fleetService.fetchFleetVehicles(businessId),
188
- enabled: enabled && Boolean(businessId),
197
+ enabled: enabled && Boolean(businessId) && isAuthenticated,
189
198
  placeholderData: (prev) => prev,
190
199
  ...OFFLINE_FIRST,
191
200
  });
@@ -219,10 +228,11 @@ export function useRemoveVehicleMutation() {
219
228
  // ─── Dashboard ───
220
229
  export function useFleetDashboardQuery(enabled = true) {
221
230
  const businessId = useBusinessId();
231
+ const isAuthenticated = useIsAuthenticated();
222
232
  return useQuery({
223
233
  queryKey: FLEET_KEYS.dashboard(businessId),
224
234
  queryFn: () => fleetService.fetchFleetDashboard(businessId),
225
- enabled: enabled && Boolean(businessId),
235
+ enabled: enabled && Boolean(businessId) && isAuthenticated,
226
236
  placeholderData: (prev) => prev,
227
237
  ...OFFLINE_FIRST,
228
238
  });
@@ -6,6 +6,7 @@
6
6
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7
7
  import { useSyncExternalStore } from "react";
8
8
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
9
+ import { getAuthToken, subscribeAuth } from "../adapters/auth";
9
10
  import { businessService, rolesService, fleetService } from "../adapters/services";
10
11
  import { assertOnlineFor } from "../sync/online-gates";
11
12
  import { validateInviteInput } from "../validation/offline";
@@ -18,6 +19,10 @@ function useBusinessId() {
18
19
  const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
19
20
  return id ?? undefined;
20
21
  }
22
+ function useIsAuthenticated() {
23
+ const token = useSyncExternalStore(subscribeAuth, () => getAuthToken(), () => null);
24
+ return !!token;
25
+ }
21
26
  const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
22
27
  // ─── Keys ───
23
28
  export const MEMBER_KEYS = {
@@ -42,10 +47,11 @@ export function useBusinessMembersQuery() {
42
47
  const businessId = useBusinessId();
43
48
  const cached = useReferenceData("members");
44
49
  const online = useIsEffectivelyOnline();
50
+ const isAuthenticated = useIsAuthenticated();
45
51
  const q = useQuery({
46
52
  queryKey: MEMBER_KEYS.list(businessId ?? ""),
47
53
  queryFn: () => businessService.fetchBusinessMembers(businessId),
48
- enabled: Boolean(businessId) && online,
54
+ enabled: Boolean(businessId) && online && isAuthenticated,
49
55
  staleTime: 30 * 1000,
50
56
  ...OFFLINE_FIRST,
51
57
  placeholderData: cached.data?.payload,
@@ -94,12 +100,13 @@ export function useRemoveMemberMutation() {
94
100
  }
95
101
  export function useBusinessMemberInvitationsQuery() {
96
102
  const businessId = useBusinessId();
97
- // No invitations SQLite mirror — never fire while unreachable.
103
+ // No invitations SQLite mirror — never fire while unreachable or logged-out.
98
104
  const online = useIsEffectivelyOnline();
105
+ const isAuthenticated = useIsAuthenticated();
99
106
  return useQuery({
100
107
  queryKey: MEMBER_KEYS.invitations(businessId ?? ""),
101
108
  queryFn: () => businessService.fetchBusinessMemberInvitations(businessId),
102
- enabled: Boolean(businessId) && online,
109
+ enabled: Boolean(businessId) && online && isAuthenticated,
103
110
  ...OFFLINE_FIRST,
104
111
  });
105
112
  }
@@ -143,12 +150,13 @@ export function useDeleteMemberInvitationMutation() {
143
150
  }
144
151
  // ─── User invitations ───
145
152
  export function useUserInvitationsQuery() {
146
- // No invitations SQLite mirror — never fire while unreachable.
153
+ // User-scoped: requires login. Don't 401 while logged-out.
147
154
  const online = useIsEffectivelyOnline();
155
+ const isAuthenticated = useIsAuthenticated();
148
156
  return useQuery({
149
157
  queryKey: INVITATION_KEYS.list(),
150
158
  queryFn: () => businessService.fetchUserInvitations(),
151
- enabled: online,
159
+ enabled: online && isAuthenticated,
152
160
  staleTime: 15000,
153
161
  ...OFFLINE_FIRST,
154
162
  });
@@ -206,11 +214,12 @@ export function useBusinessRolesQuery(enabled = true) {
206
214
  const businessId = useBusinessId();
207
215
  const cached = useReferenceData("roles");
208
216
  const online = useIsEffectivelyOnline();
217
+ const isAuthenticated = useIsAuthenticated();
209
218
  const q = useQuery({
210
219
  queryKey: ROLES_KEYS.forBusiness(businessId),
211
220
  queryFn: () => rolesService.fetchBusinessRoles(),
212
221
  staleTime: Infinity,
213
- enabled: enabled && !!businessId && online,
222
+ enabled: enabled && !!businessId && online && isAuthenticated,
214
223
  ...OFFLINE_FIRST,
215
224
  placeholderData: cached.data?.payload,
216
225
  });
@@ -221,10 +230,11 @@ export function useBusinessTaxRulesQuery(enabled = true) {
221
230
  const businessId = useBusinessId();
222
231
  const cached = useReferenceData("tax-rules");
223
232
  const online = useIsEffectivelyOnline();
233
+ const isAuthenticated = useIsAuthenticated();
224
234
  const q = useQuery({
225
235
  queryKey: TAX_KEYS.list(businessId),
226
236
  queryFn: () => businessService.fetchBusinessTaxRules(businessId),
227
- enabled: enabled && Boolean(businessId) && online,
237
+ enabled: enabled && Boolean(businessId) && online && isAuthenticated,
228
238
  staleTime: 30 * 1000,
229
239
  ...OFFLINE_FIRST,
230
240
  placeholderData: cached.data?.payload,
package/dist/index.d.ts CHANGED
@@ -136,6 +136,7 @@ export { getSyncJobsPendingCount, debugDumpSyncJobs, drainPending } from "./sync
136
136
  export { performFullSync } from "./sync/full-sync";
137
137
  export { stopHealthProbe } from "./sync/health-probe";
138
138
  export { notifyAuthChanged, subscribeAuth } from "./adapters/auth";
139
+ export { isAuthenticated, hasActiveBusiness, canBusinessSync } from "./sync/sync-guard";
139
140
  export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, } from "./auth/session";
140
141
  export type { AuthFailure, SessionTokens } from "./auth/session";
141
142
  export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
package/dist/index.js CHANGED
@@ -151,6 +151,7 @@ export { getSyncJobsPendingCount, debugDumpSyncJobs, drainPending } from "./sync
151
151
  export { performFullSync } from "./sync/full-sync";
152
152
  export { stopHealthProbe } from "./sync/health-probe";
153
153
  export { notifyAuthChanged, subscribeAuth } from "./adapters/auth";
154
+ export { isAuthenticated, hasActiveBusiness, canBusinessSync } from "./sync/sync-guard";
154
155
  // ── Session lifecycle (access-expired → refresh → refresh-expired → logout)
155
156
  export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, } from "./auth/session";
156
157
  // ── Image cache (durable) ──
@@ -221,18 +221,42 @@ class LocalNodeServiceCore {
221
221
  return first ?? null;
222
222
  }
223
223
  broadcastToClients(event) {
224
- // Mesh broadcast to all peers
224
+ // Mesh broadcast to all peers. Per-peer failures are LOUD (warn with
225
+ // peer id + error): a silent catch here once hid a full mesh blackout
226
+ // class of bug — peers that never receive order events never converge.
227
+ let delivered = 0;
228
+ let failed = 0;
225
229
  for (const c of this.peers.values()) {
226
230
  try {
227
231
  c.publishEvent?.(event);
232
+ delivered++;
233
+ }
234
+ catch (e) {
235
+ failed++;
236
+ syncLogger.warn("LocalNodeService", "Mesh publish failed", {
237
+ event_type: event.event_type,
238
+ entity_id: event.entity_id,
239
+ error: String(e),
240
+ });
228
241
  }
229
- catch { }
230
242
  }
231
243
  // Also via server broadcast to connected clients
232
244
  try {
233
245
  this.server?.broadcast(event);
234
246
  }
235
- catch { }
247
+ catch (e) {
248
+ syncLogger.warn("LocalNodeService", "Mesh server broadcast failed", {
249
+ event_type: event.event_type,
250
+ entity_id: event.entity_id,
251
+ error: String(e),
252
+ });
253
+ }
254
+ syncLogger.debug("LocalNodeService", "Mesh broadcast", {
255
+ event_type: event.event_type,
256
+ entity_id: event.entity_id,
257
+ peers: delivered,
258
+ failed,
259
+ });
236
260
  }
237
261
  setupStoreSubscriptions() {
238
262
  if (this.subscribed)
@@ -467,32 +491,69 @@ class LocalNodeServiceCore {
467
491
  secret: this.secret,
468
492
  onEvent: (event) => {
469
493
  syncLogger.event(event, "received", { origin: "lan" });
470
- if (event.event_type === "order:sync") {
494
+ const eventType = event.event_type;
495
+ // Materialize LAN order events into the canonical orders table so
496
+ // list queries (POS In Progress, KDS) converge without the backend.
497
+ // Previously only order:sync inserted rows: a cloud/local-shape
498
+ // order.created from a peer fell through to the pending-store apply
499
+ // fns (which never touch the orders table), so the event arrived,
500
+ // every layer reported success, and the row never materialized.
501
+ if (eventType === "order:sync" || eventType === "order.created" || eventType === "order.status_changed") {
471
502
  void (async () => {
472
503
  try {
473
504
  const p = event.payload;
505
+ const bid = deps.getBusiness()?.id;
506
+ const payloadBid = p.business_id;
507
+ if (typeof payloadBid === "string" && bid && payloadBid !== bid) {
508
+ syncLogger.warn("LocalNodeService", "Ignoring LAN order event for another business", {
509
+ event_type: eventType,
510
+ payloadBid,
511
+ bid,
512
+ });
513
+ return;
514
+ }
474
515
  if (p && typeof p.order_number === "string" && p.id) {
516
+ // Full-order payload (order:sync publish or order.created /
517
+ // status_changed echo): upsert the canonical row. HLC guard
518
+ // inside insertOrder keeps this idempotent across mesh + phx
519
+ // double delivery.
475
520
  const db = await openSyncDatabase();
476
521
  await insertOrder(db, p);
522
+ syncLogger.info("LocalNodeService", "LAN order row materialized", {
523
+ event_type: eventType,
524
+ order_number: p.order_number,
525
+ });
477
526
  }
478
527
  else {
528
+ // Action-shaped payload (order:sync from a LAN command):
529
+ // apply the transition to the local row.
479
530
  const action = p.action;
480
- const orderId = p.orderId;
481
- const bid = deps.getBusiness()?.id;
531
+ const orderId = p.orderId ?? p.order_id;
482
532
  if (action && orderId && bid) {
483
533
  try {
484
534
  await transitionLocalOrder(bid, String(orderId), action);
535
+ syncLogger.info("LocalNodeService", "LAN order transition applied", {
536
+ event_type: eventType,
537
+ action,
538
+ orderId: String(orderId),
539
+ });
485
540
  }
486
541
  catch { }
487
542
  }
543
+ else {
544
+ syncLogger.warn("LocalNodeService", "LAN order event has no materializable payload", {
545
+ event_type: eventType,
546
+ entity_id: event.entity_id,
547
+ });
548
+ }
488
549
  }
489
550
  }
490
551
  catch (e) {
491
- syncLogger.debug("LocalNodeService", "LAN order:sync apply failed", { err: String(e) });
552
+ syncLogger.warn("LocalNodeService", "LAN order apply failed", { err: String(e), event_type: eventType });
492
553
  }
493
554
  // The row is in SQLite now — invalidate so lists/detail refetch.
494
- // (order:sync bypasses the engine pipeline, so no subscriber does
495
- // this for us.)
555
+ // (LAN-applied writes bypass the engine pipeline, so no
556
+ // subscriber does this for us.)
496
557
  notifyPendingOrdersChanged();
497
558
  })();
498
559
  }
@@ -1,4 +1,5 @@
1
1
  import { phoenixSocket } from "./phoenix-socket";
2
+ import { syncLogger } from "../utils/sync-logger";
2
3
  class EventsService {
3
4
  constructor() {
4
5
  this.refCount = 0;
@@ -29,6 +30,22 @@ class EventsService {
29
30
  this.lastSequence = sequence;
30
31
  else if (typeof data?._sequence === "number")
31
32
  this.lastSequence = data._sequence;
33
+ // Business-state frames are LOUD (info): when live updates stall,
34
+ // these lines prove whether the backend pushed anything at all.
35
+ if (event.startsWith("order.") ||
36
+ event.startsWith("delivery.") ||
37
+ event.startsWith("payment.") ||
38
+ event === "reference_data.changed") {
39
+ try {
40
+ syncLogger.info("EventsService", "Phoenix event received", {
41
+ topic,
42
+ event,
43
+ order_id: data?.order_id ?? data?.id,
44
+ business_id: payloadBid,
45
+ });
46
+ }
47
+ catch { }
48
+ }
32
49
  const typeListeners = this.listeners.get(event);
33
50
  if (typeListeners)
34
51
  typeListeners.forEach((cb) => cb(data));
@@ -39,7 +56,30 @@ class EventsService {
39
56
  joinPayload.last_sequence = this.lastSequence;
40
57
  if (this.activeBusinessId)
41
58
  joinPayload.business_id = this.activeBusinessId;
42
- phoenixSocket.connect().then(() => phoenixSocket.joinChannel(topic, joinPayload)).then(() => { this.joined = true; }).catch(() => { });
59
+ phoenixSocket
60
+ .connect()
61
+ .then(() => phoenixSocket.joinChannel(topic, joinPayload))
62
+ .then(() => {
63
+ this.joined = true;
64
+ try {
65
+ syncLogger.info("EventsService", "Phoenix channel joined", {
66
+ topic,
67
+ business_id: this.activeBusinessId,
68
+ });
69
+ }
70
+ catch { }
71
+ })
72
+ .catch((e) => {
73
+ // A failed join previously vanished into a swallowed rejection:
74
+ // socket "connected" but subscribed to nothing. Loud from now on.
75
+ try {
76
+ syncLogger.warn("EventsService", "Phoenix channel join failed", {
77
+ topic,
78
+ error: String(e),
79
+ });
80
+ }
81
+ catch { }
82
+ });
43
83
  }
44
84
  else if (businessId !== undefined) {
45
85
  // business switched while already joined — update filter without rejoin (next events filtered). If backend requires business:{id} subscription, re-join could be forced — not needed as fallback business_authorized check still passes.
@@ -13,6 +13,7 @@ import { generateUUID } from "../utils/uuid";
13
13
  import { menuService, businessService, rolesService } from "../adapters/services";
14
14
  import { cacheImageBatch, pruneImageCache } from "../adapters/image-cache";
15
15
  import { getConnectivityState } from "../adapters/connectivity";
16
+ import { canBusinessSync } from "../sync/sync-guard";
16
17
  import { getReferenceData, getReferenceVersion, mergeReferenceData, } from "./operations";
17
18
  import { fetchReferenceVersions, fetchReferenceData, fetchReferenceChanges, } from "./api";
18
19
  const DEFAULT_SYNC_KEYS = [
@@ -33,6 +34,17 @@ class ReferenceSyncService {
33
34
  this.isSyncing = false;
34
35
  }
35
36
  async sync(options) {
37
+ if (!canBusinessSync(options.businessId)) {
38
+ syncLogger.debug("ReferenceSync", "Skipping sync — no business/auth", {
39
+ businessId: options.businessId,
40
+ });
41
+ return {
42
+ synced: [],
43
+ skipped: options.keys ?? DEFAULT_SYNC_KEYS,
44
+ failed: [],
45
+ errors: {},
46
+ };
47
+ }
36
48
  if (this.isSyncing) {
37
49
  return {
38
50
  synced: [],
@@ -8,6 +8,9 @@ import { useAuthStore } from "../adapters/auth";
8
8
  import { getAppInstanceId } from "../adapters/devices";
9
9
  import { generateCommandId } from "../utils/uuid";
10
10
  export function buildCommand(options) {
11
+ if (!options.businessId) {
12
+ throw new Error("buildCommand requires businessId — no active business/auth");
13
+ }
11
14
  const user = useAuthStore.getState().user;
12
15
  const userId = user?.id ?? "anonymous";
13
16
  const deviceId = getAppInstanceId();
@@ -15,6 +15,7 @@ import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { generateEventId } from "../utils/uuid";
16
16
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
17
17
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
18
+ import { canBusinessSync } from "./sync-guard";
18
19
  const MAX_RETRIES = 5;
19
20
  const QUEUED_STATUS_BY_COMMAND = {
20
21
  "order:accept": "accepted",
@@ -186,6 +187,8 @@ class CommandQueueService {
186
187
  try {
187
188
  subscribeConnectivity(() => {
188
189
  const s = getConnectivityState();
190
+ if (!canBusinessSync())
191
+ return;
189
192
  if (s.isCloudReachable === "reachable" && s.isOnline) {
190
193
  this.replay().catch(() => { });
191
194
  }
@@ -234,6 +237,10 @@ class CommandQueueService {
234
237
  * Try to execute a single pending command through the current transport.
235
238
  */
236
239
  async processCommand(command) {
240
+ if (!canBusinessSync(command.business_id)) {
241
+ // No active business/session — defer until login+selection.
242
+ throw new Error("ReplayDeferred: no business/auth, deferring queued command");
243
+ }
237
244
  const db = getSyncDatabase();
238
245
  const parsedCommand = {
239
246
  command_id: command.command_id,
@@ -348,6 +355,8 @@ class CommandQueueService {
348
355
  * ordering consistent; the next replay will retry from the failed command.
349
356
  */
350
357
  async replay() {
358
+ if (!canBusinessSync())
359
+ return;
351
360
  recordAttempt("sync_drain", "replay");
352
361
  // Join an in-flight replay instead of no-op'ing: concurrent replays
353
362
  // (connectivity auto-replay racing an explicit replay) must converge on
@@ -21,6 +21,7 @@ import { CLOUD_COMMAND_TIMEOUT_MS } from "../utils/constants";
21
21
  import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
22
22
  import { getReferenceData } from "../reference/operations";
23
23
  import { openSyncDatabase } from "../db/connection";
24
+ import { canBusinessSync } from "./sync-guard";
24
25
  function withTimeout(promise, ms) {
25
26
  return new Promise((resolve, reject) => {
26
27
  const timer = setTimeout(() => {
@@ -53,6 +54,10 @@ export class SyncEngine {
53
54
  * quickly instead of blocking the terminal for the full HTTP timeout.
54
55
  */
55
56
  async execute(command, opts) {
57
+ if (!command.business_id || !canBusinessSync(command.business_id)) {
58
+ // No active business/auth — don't route to cloud/LAN or queue with empty bid.
59
+ throw new Error("Sync requires active business and login — business_id missing or not authenticated");
60
+ }
56
61
  syncLogger.command(command, "issued");
57
62
  // Perms guard (offline-capable): block before routing
58
63
  const required = CAPABILITY_BY_COMMAND[command.command_type];
@@ -4,21 +4,19 @@
4
4
  * overwrites them; then pull merges; final drain pushes any remainder.
5
5
  */
6
6
  import { getConnectivityState } from "../adapters/connectivity";
7
- import { getAuthToken } from "../adapters/auth";
8
7
  import { getActiveBusiness } from "../adapters/business";
9
8
  import { syncLogger } from "../utils/sync-logger";
10
9
  import { referenceSyncService } from "../reference/service";
11
10
  import { pullRemoteOrders, drainPending } from "./sync-jobs.service";
11
+ import { canBusinessSync } from "./sync-guard";
12
12
  let inFlight = null;
13
13
  export async function performFullSync(businessId) {
14
14
  const bid = businessId ?? getActiveBusiness()?.id;
15
- if (!bid)
15
+ if (!bid || !canBusinessSync(bid))
16
16
  return;
17
17
  const s = getConnectivityState();
18
18
  if (!s.isOnline || s.isCloudReachable === "unreachable")
19
19
  return;
20
- if (!getAuthToken())
21
- return;
22
20
  if (inFlight) {
23
21
  try {
24
22
  await inFlight;
@@ -296,10 +296,18 @@ export async function applyPendingOrderEvent(event, origin = "local") {
296
296
  }
297
297
  if (event.event_type === "order.created") {
298
298
  const payload = event.payload;
299
+ // Local-shape echoes carry local_order_number; cloud-shape echoes
300
+ // (backend id/number, e.g. received over LAN from a peer) carry only
301
+ // order_number. Match on either so both shapes clear the optimistic
302
+ // entry and converge snapshots — previously cloud-shape events hit the
303
+ // early return and triggered no invalidation at all.
299
304
  const localOrderNumber = payload?.local_order_number;
300
- if (typeof localOrderNumber !== "string")
305
+ const matchNumber = typeof localOrderNumber === "string"
306
+ ? localOrderNumber
307
+ : payload?.order_number;
308
+ if (typeof matchNumber !== "string")
301
309
  return;
302
- usePendingOrdersStore.getState().removeByOrderNumber(localOrderNumber);
310
+ usePendingOrdersStore.getState().removeByOrderNumber(matchNumber);
303
311
  // Persist the cloud acknowledgment into the durable journal + read model.
304
312
  await orderSnapshotsService.persistOrderEvent(event, "cloud");
305
313
  const db = await openSyncDatabase();
@@ -313,7 +321,7 @@ export async function applyPendingOrderEvent(event, origin = "local") {
313
321
  if (e.event_type !== PENDING_EVENT_TYPE)
314
322
  continue;
315
323
  if (e.payload?.order_number ===
316
- localOrderNumber) {
324
+ matchNumber) {
317
325
  ids.push(e.event_id);
318
326
  }
319
327
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sync Guard — single predicate for business-scoped sync.
3
+ *
4
+ * Option C (Hybrid): health probe stays `isOnline`-only (connectivity UX
5
+ * while logged-out). Every *business* sync path gates on both auth *and*
6
+ * active business: no drain, pull, replay, or reference fetch fires until
7
+ * the user has logged in and a business is selected. User-scoped queries
8
+ * (invitations, fleet) gate on auth only.
9
+ */
10
+ export declare function isAuthenticated(): boolean;
11
+ export declare function hasActiveBusiness(): boolean;
12
+ /**
13
+ * Business sync may run only when both authenticated and a business is selected.
14
+ * Pass an explicit `businessId` to test that specific id; otherwise reads
15
+ * the active business store.
16
+ */
17
+ export declare function canBusinessSync(businessId?: string | null): boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sync Guard — single predicate for business-scoped sync.
3
+ *
4
+ * Option C (Hybrid): health probe stays `isOnline`-only (connectivity UX
5
+ * while logged-out). Every *business* sync path gates on both auth *and*
6
+ * active business: no drain, pull, replay, or reference fetch fires until
7
+ * the user has logged in and a business is selected. User-scoped queries
8
+ * (invitations, fleet) gate on auth only.
9
+ */
10
+ import { getAuthToken } from "../adapters/auth";
11
+ import { getActiveBusiness } from "../adapters/business";
12
+ export function isAuthenticated() {
13
+ return !!getAuthToken();
14
+ }
15
+ export function hasActiveBusiness() {
16
+ return !!getActiveBusiness()?.id;
17
+ }
18
+ /**
19
+ * Business sync may run only when both authenticated and a business is selected.
20
+ * Pass an explicit `businessId` to test that specific id; otherwise reads
21
+ * the active business store.
22
+ */
23
+ export function canBusinessSync(businessId) {
24
+ const bid = businessId ?? getActiveBusiness()?.id;
25
+ return !!bid && !!getAuthToken();
26
+ }
@@ -6,7 +6,7 @@
6
6
  import { openSyncDatabase } from "../db/connection";
7
7
  import { insertOrder } from "../db/operations";
8
8
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
9
- import { getAuthToken, getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
9
+ import { getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
10
10
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
11
11
  import { getHttpClient } from "../adapters/http";
12
12
  import { phoenixSocket } from "../realtime/phoenix-socket";
@@ -16,6 +16,7 @@ import { BackoffTracker } from "./backoff";
16
16
  import { generateOrderNumber } from "./order-number";
17
17
  import { orderSnapshotsService } from "./order-snapshots.service";
18
18
  import { getTransitionPath } from "./transition-endpoints";
19
+ import { canBusinessSync, isAuthenticated } from "./sync-guard";
19
20
  const inflight = new Map();
20
21
  const retryTimers = new Map();
21
22
  const backoffTracker = new BackoffTracker();
@@ -33,6 +34,8 @@ function isOnlineForPhx() {
33
34
  return s.isOnline && s.isCloudReachable === "reachable";
34
35
  }
35
36
  function scheduleRetry(businessId) {
37
+ if (!canBusinessSync(businessId))
38
+ return;
36
39
  if (retryTimers.has(businessId))
37
40
  return;
38
41
  // Shared backoff curve: 2s doubling to a 5min cap with jitter. Attempts
@@ -43,7 +46,7 @@ function scheduleRetry(businessId) {
43
46
  syncLogger.debug("SyncJobs", "scheduleRetry backoff", { businessId, delay });
44
47
  const t = setTimeout(() => {
45
48
  retryTimers.delete(businessId);
46
- if (isOnlineForRest() && getAuthToken() && getActiveBusiness()?.id === businessId) {
49
+ if (isOnlineForRest() && canBusinessSync(businessId)) {
47
50
  syncLogger.debug("SyncJobs", "retry timer fired → drainPending", { businessId });
48
51
  void drainPending(businessId);
49
52
  }
@@ -184,14 +187,17 @@ async function tryRest(businessId, payload, commandType) {
184
187
  }
185
188
  }
186
189
  export async function drainOne(businessId, entityId) {
190
+ if (!businessId || !canBusinessSync(businessId)) {
191
+ syncLogger.debug("SyncJobs", "drainOne early-return — no business/auth", { businessId, entityId });
192
+ return;
193
+ }
187
194
  recordAttempt("sync_drain", `drainOne ${entityId.slice(0, 8)}`);
188
195
  const onlineForRest = isOnlineForRest();
189
196
  const onlineForPhx = isOnlineForPhx();
190
- const token = getAuthToken();
191
197
  const state = getConnectivityState();
192
- syncLogger.debug("SyncJobs", "drainOne entry", { businessId, entityId, isOnlineForRest: onlineForRest, isOnlineForPhx: onlineForPhx, isCloudReachable: state.isCloudReachable, hasToken: !!token, inflight: inflight.has(`${businessId}:${entityId}`) });
193
- if (!onlineForRest || !token) {
194
- syncLogger.debug("SyncJobs", "drainOne early-return gate", { businessId, entityId, onlineForRest, hasToken: !!token, connectivity: state });
198
+ syncLogger.debug("SyncJobs", "drainOne entry", { businessId, entityId, isOnlineForRest: onlineForRest, isOnlineForPhx: onlineForPhx, isCloudReachable: state.isCloudReachable, hasToken: isAuthenticated(), inflight: inflight.has(`${businessId}:${entityId}`) });
199
+ if (!onlineForRest) {
200
+ syncLogger.debug("SyncJobs", "drainOne early-return gate", { businessId, entityId, onlineForRest, hasToken: isAuthenticated(), connectivity: state });
195
201
  return;
196
202
  }
197
203
  const key = `${businessId}:${entityId}`;
@@ -385,11 +391,14 @@ export async function drainOne(businessId, entityId) {
385
391
  }
386
392
  }
387
393
  export async function pullRemoteOrders(businessId) {
394
+ if (!businessId || !canBusinessSync(businessId)) {
395
+ syncLogger.debug("SyncJobs", "pullRemoteOrders early-return — no business/auth", { businessId });
396
+ return;
397
+ }
388
398
  const onlineForRest = isOnlineForRest();
389
- const hasToken = !!getAuthToken();
390
- syncLogger.debug("SyncJobs", "pullRemoteOrders entry", { businessId, onlineForRest, hasToken });
391
- if (!onlineForRest || !hasToken) {
392
- syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken, connectivity: getConnectivityState() });
399
+ syncLogger.debug("SyncJobs", "pullRemoteOrders entry", { businessId, onlineForRest, hasToken: isAuthenticated() });
400
+ if (!onlineForRest) {
401
+ syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
393
402
  return;
394
403
  }
395
404
  try {
@@ -450,12 +459,15 @@ export async function pullRemoteOrders(businessId) {
450
459
  }
451
460
  }
452
461
  export async function drainPending(businessId) {
462
+ if (!businessId || !canBusinessSync(businessId)) {
463
+ syncLogger.debug("SyncJobs", "drainPending early-return — no business/auth", { businessId });
464
+ return;
465
+ }
453
466
  recordAttempt("sync_drain", "drainPending");
454
467
  const onlineForRest = isOnlineForRest();
455
- const hasToken = !!getAuthToken();
456
- syncLogger.debug("SyncJobs", "drainPending entry", { businessId, onlineForRest, hasToken, connectivity: getConnectivityState() });
457
- if (!onlineForRest || !hasToken) {
458
- syncLogger.debug("SyncJobs", "drainPending early-return gate", { businessId, onlineForRest, hasToken });
468
+ syncLogger.debug("SyncJobs", "drainPending entry", { businessId, onlineForRest, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
469
+ if (!onlineForRest) {
470
+ syncLogger.debug("SyncJobs", "drainPending early-return gate", { businessId, onlineForRest, hasToken: isAuthenticated() });
459
471
  return;
460
472
  }
461
473
  const db = await openSyncDatabase();
@@ -486,30 +498,30 @@ export function setupSyncJobsDrain() {
486
498
  return;
487
499
  }
488
500
  drainSetup = true;
489
- syncLogger.debug("SyncJobs", "setupSyncJobsDrain init", { hasBusiness: !!getActiveBusiness()?.id, connectivity: getConnectivityState(), hasToken: !!getAuthToken() });
501
+ syncLogger.debug("SyncJobs", "setupSyncJobsDrain init", { hasBusiness: !!getActiveBusiness()?.id, connectivity: getConnectivityState(), hasToken: isAuthenticated() });
490
502
  const triggerFullSync = (b) => {
491
- if (!b || !isOnlineForRest() || !getAuthToken())
503
+ if (!b || !isOnlineForRest() || !canBusinessSync(b))
492
504
  return;
493
505
  void import("./full-sync").then((m) => m.performFullSync(b).catch(() => { }));
494
506
  };
495
507
  try {
496
508
  subscribeConnectivity(() => {
497
509
  const b = getActiveBusiness()?.id;
498
- syncLogger.debug("SyncJobs", "subscribeConnectivity trigger", { businessId: b, connectivity: getConnectivityState(), hasToken: !!getAuthToken() });
510
+ syncLogger.debug("SyncJobs", "subscribeConnectivity trigger", { businessId: b, connectivity: getConnectivityState(), hasToken: isAuthenticated() });
499
511
  triggerFullSync(b);
500
512
  });
501
513
  subscribeBusiness(() => {
502
514
  const bid = getActiveBusiness()?.id;
503
- syncLogger.debug("SyncJobs", "subscribeBusiness trigger", { businessId: bid, hasToken: !!getAuthToken(), connectivity: getConnectivityState() });
515
+ syncLogger.debug("SyncJobs", "subscribeBusiness trigger", { businessId: bid, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
504
516
  triggerFullSync(bid);
505
517
  });
506
518
  subscribeAuth(() => {
507
519
  const b = getActiveBusiness()?.id;
508
- syncLogger.debug("SyncJobs", "subscribeAuth trigger", { businessId: b, hasToken: !!getAuthToken(), connectivity: getConnectivityState() });
520
+ syncLogger.debug("SyncJobs", "subscribeAuth trigger", { businessId: b, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
509
521
  triggerFullSync(b);
510
522
  });
511
523
  const b0 = getActiveBusiness()?.id;
512
- syncLogger.debug("SyncJobs", "setupSyncJobsDrain initial check", { b0, isOnlineForRest: isOnlineForRest(), hasToken: !!getAuthToken() });
524
+ syncLogger.debug("SyncJobs", "setupSyncJobsDrain initial check", { b0, isOnlineForRest: isOnlineForRest(), hasToken: isAuthenticated() });
513
525
  triggerFullSync(b0);
514
526
  }
515
527
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",