@teincfood/core 0.9.2 → 0.9.4

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.
package/dist/index.d.ts CHANGED
@@ -141,7 +141,7 @@ export { getSyncJobsPendingCount, debugDumpSyncJobs, drainPending } from "./sync
141
141
  export { performFullSync } from "./sync/full-sync";
142
142
  export { stopHealthProbe } from "./sync/health-probe";
143
143
  export { notifyAuthChanged, subscribeAuth } from "./adapters/auth";
144
- export { isAuthenticated, hasActiveBusiness, canBusinessSync } from "./sync/sync-guard";
144
+ export { isAuthenticated, hasActiveBusiness, canBusinessSync, setOfflineSyncEnabled, isOfflineSyncEnabled } from "./sync/sync-guard";
145
145
  export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, } from "./auth/session";
146
146
  export type { AuthFailure, SessionTokens } from "./auth/session";
147
147
  export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
package/dist/index.js CHANGED
@@ -156,7 +156,7 @@ export { getSyncJobsPendingCount, debugDumpSyncJobs, drainPending } from "./sync
156
156
  export { performFullSync } from "./sync/full-sync";
157
157
  export { stopHealthProbe } from "./sync/health-probe";
158
158
  export { notifyAuthChanged, subscribeAuth } from "./adapters/auth";
159
- export { isAuthenticated, hasActiveBusiness, canBusinessSync } from "./sync/sync-guard";
159
+ export { isAuthenticated, hasActiveBusiness, canBusinessSync, setOfflineSyncEnabled, isOfflineSyncEnabled } from "./sync/sync-guard";
160
160
  // ── Session lifecycle (access-expired → refresh → refresh-expired → logout)
161
161
  export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, } from "./auth/session";
162
162
  // ── Image cache (durable) ──
@@ -13,6 +13,7 @@ import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandSt
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { TransitionError, isReplayDeferred, TRANSITION_ERROR_CODES } from "./transition-errors";
16
+ import { assertBusinessOpenForCreate } from "../utils/business-hours";
16
17
  import { extractApiErrorCode } from "../utils/error";
17
18
  import { generateEventId } from "../utils/uuid";
18
19
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
@@ -203,6 +204,14 @@ class CommandQueueService {
203
204
  * Returns an optimistic domain event so the caller can update the UI.
204
205
  */
205
206
  async enqueue(command) {
207
+ // Queue-time hours gate: a closed business can never legally sync an
208
+ // order:create, so fail fast with BusinessHoursError instead of queuing
209
+ // an optimistic order that would sit pending forever. Other command
210
+ // types (transitions, payments, delivery) are unaffected so in-progress
211
+ // orders can still be completed while closed.
212
+ if (command.command_type === "order:create") {
213
+ await assertBusinessOpenForCreate(command.business_id);
214
+ }
206
215
  const db = getSyncDatabase();
207
216
  const preparedCommand = injectLocalOrderNumber(command);
208
217
  await insertCommand(db, { command: preparedCommand, status: "pending" });
@@ -149,6 +149,20 @@ export class SyncEngine {
149
149
  return events;
150
150
  }
151
151
  catch (error) {
152
+ // Definitive hours-guard rejection (thrown by createLocalOrder before
153
+ // any HTTP): the business is closed. Surface it immediately — do not
154
+ // mark cloud unreachable and do not fall back to the outbox, which
155
+ // would either re-throw from the enqueue gate or mask a real decision
156
+ // as a connectivity problem.
157
+ const code = errorCodeOf(error);
158
+ if (code === "BUSINESS_CLOSED" || code === "HOURS_NOT_SYNCED") {
159
+ syncLogger.warn("SyncEngine", "Command blocked: business closed", {
160
+ command_type: command.command_type,
161
+ command_id: command.command_id,
162
+ code,
163
+ });
164
+ throw error;
165
+ }
152
166
  // Definitive server rejection (4xx): the backend answered with a real
153
167
  // decision (e.g. INVALID_TRANSITION, PAYMENT_REQUIRED, FORBIDDEN).
154
168
  // Surface it instead of masking it as a connectivity problem and
@@ -159,7 +173,6 @@ export class SyncEngine {
159
173
  ? error.status
160
174
  : undefined;
161
175
  if (rejectionStatus !== undefined && rejectionStatus >= 400 && rejectionStatus < 500) {
162
- const code = errorCodeOf(error);
163
176
  syncLogger.warn("SyncEngine", "Cloud command rejected by server", {
164
177
  command_type: command.command_type,
165
178
  command_id: command.command_id,
@@ -7,10 +7,13 @@
7
7
  * the user has logged in and a business is selected. User-scoped queries
8
8
  * (invitations, fleet) gate on auth only.
9
9
  */
10
+ export declare function setOfflineSyncEnabled(enabled: boolean): void;
11
+ export declare function isOfflineSyncEnabled(): boolean;
10
12
  export declare function isAuthenticated(): boolean;
11
13
  export declare function hasActiveBusiness(): boolean;
12
14
  /**
13
- * Business sync may run only when both authenticated and a business is selected.
15
+ * Business sync may run only when offline sync is enabled for the role,
16
+ * authenticated and a business is selected.
14
17
  * Pass an explicit `businessId` to test that specific id; otherwise reads
15
18
  * the active business store.
16
19
  */
@@ -9,6 +9,17 @@
9
9
  */
10
10
  import { getAuthToken } from "../adapters/auth";
11
11
  import { getActiveBusiness } from "../adapters/business";
12
+ // Offline machinery (reference sync, drains, pulls, leases) runs for the
13
+ // seller role only. Fleet managers and riders are fully online: the app
14
+ // disables this flag on role change, which funnels through canBusinessSync
15
+ // and stops every business sync path at once.
16
+ let offlineSyncEnabled = true;
17
+ export function setOfflineSyncEnabled(enabled) {
18
+ offlineSyncEnabled = enabled;
19
+ }
20
+ export function isOfflineSyncEnabled() {
21
+ return offlineSyncEnabled;
22
+ }
12
23
  export function isAuthenticated() {
13
24
  return !!getAuthToken();
14
25
  }
@@ -16,11 +27,14 @@ export function hasActiveBusiness() {
16
27
  return !!getActiveBusiness()?.id;
17
28
  }
18
29
  /**
19
- * Business sync may run only when both authenticated and a business is selected.
30
+ * Business sync may run only when offline sync is enabled for the role,
31
+ * authenticated and a business is selected.
20
32
  * Pass an explicit `businessId` to test that specific id; otherwise reads
21
33
  * the active business store.
22
34
  */
23
35
  export function canBusinessSync(businessId) {
36
+ if (!offlineSyncEnabled)
37
+ return false;
24
38
  const bid = businessId ?? getActiveBusiness()?.id;
25
39
  return !!bid && !!getAuthToken();
26
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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",