@teincfood/core 0.9.12 → 0.9.15

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.
@@ -19,6 +19,7 @@ export async function openSyncDatabase() {
19
19
  const driver = getSqliteDriver();
20
20
  initPromise = (async () => {
21
21
  await runMigrations(driver);
22
+ await recoverInFlightCommands(driver);
22
23
  dbInstance = driver;
23
24
  syncLogger.info("SQLite", "Database opened and migrated", {
24
25
  name: DB_NAME,
@@ -71,6 +72,30 @@ async function runMigrations(db) {
71
72
  await doMigrate();
72
73
  }
73
74
  }
75
+ /**
76
+ * Crash recovery: a process kill between `markCommandInFlight` and the
77
+ * command's terminal update leaves rows stuck in `in_flight`. They are
78
+ * invisible to the outbox query (which selects pending only), so the order
79
+ * would silently never sync. Reset them to pending on open so the next replay
80
+ * picks them up. Idempotent and safe: a fresh process owns all in-flight work.
81
+ */
82
+ async function recoverInFlightCommands(db) {
83
+ try {
84
+ const result = await db.runAsync("UPDATE commands SET status = 'pending', error = NULL, updated_at = ? WHERE status = 'in_flight';", new Date().toISOString());
85
+ const changes = result?.changes ?? 0;
86
+ if (changes > 0) {
87
+ syncLogger.warn("SQLite", "Recovered in-flight commands after restart", {
88
+ count: changes,
89
+ });
90
+ }
91
+ }
92
+ catch (error) {
93
+ // Never block DB open on recovery — replay/backoff still works.
94
+ syncLogger.error("SQLite", "in-flight recovery failed", {
95
+ error: String(error),
96
+ });
97
+ }
98
+ }
74
99
  export async function closeSyncDatabase() {
75
100
  if (dbInstance) {
76
101
  if (dbInstance.closeAsync)
@@ -8,7 +8,7 @@ export interface InsertCommandInput {
8
8
  status?: PersistedCommand["status"];
9
9
  }
10
10
  export declare function insertCommand(db: SQLiteDatabase, input: InsertCommandInput): Promise<PersistedCommand>;
11
- export declare function getPendingCommands(db: SQLiteDatabase, limit?: number): Promise<PersistedCommand[]>;
11
+ export declare function getPendingCommands(db: SQLiteDatabase, limit?: number, includeFailed?: boolean): Promise<PersistedCommand[]>;
12
12
  export declare function updateCommandStatus(db: SQLiteDatabase, commandId: string, status: PersistedCommand["status"], error?: string, incrementRetry?: boolean): Promise<void>;
13
13
  export declare function markCommandInFlight(db: SQLiteDatabase, commandId: string): Promise<void>;
14
14
  export declare function deleteCommand(db: SQLiteDatabase, commandId: string): Promise<void>;
@@ -24,10 +24,15 @@ export async function insertCommand(db, input) {
24
24
  syncLogger.command(command, "queued");
25
25
  return rowToPersistedCommand(row);
26
26
  }
27
- export async function getPendingCommands(db, limit = 100) {
27
+ export async function getPendingCommands(db, limit = 100, includeFailed = false) {
28
+ // `failed` rows are terminal (permanent rejection / retries exhausted) — they
29
+ // must NOT be re-fetched by the replay loop, or every failed command would be
30
+ // re-POSTed and re-toasted on each pass. Diagnostics opts in via
31
+ // includeFailed for the full outbox dump.
32
+ const statuses = includeFailed ? "('pending', 'failed')" : "('pending')";
28
33
  const rows = await db.getAllAsync(`
29
34
  SELECT * FROM commands
30
- WHERE status IN ('pending', 'failed')
35
+ WHERE status IN ${statuses}
31
36
  ORDER BY created_at ASC
32
37
  LIMIT ?;
33
38
  `, limit);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * React hook for startup readiness (seller POS/order surfaces).
3
+ *
4
+ * Runs one bounded catch-up on mount (and whenever the cloud becomes
5
+ * reachable), then exposes the local cache readiness so the app can block the
6
+ * POS with an explicit "preparing offline data" state instead of surprising
7
+ * the seller with a guard failure at order-tap time.
8
+ */
9
+ import { type StartupReadiness } from "../sync/startup-readiness";
10
+ export interface UseStartupReadinessOptions {
11
+ /** Bound the online catch-up attempt. Defaults to 15000ms. */
12
+ timeoutMs?: number;
13
+ /** Set false to skip automatic checking. */
14
+ enabled?: boolean;
15
+ }
16
+ export interface UseStartupReadinessResult {
17
+ businessId: string | undefined;
18
+ readiness: StartupReadiness | null;
19
+ /** True while the catch-up check is running (first load only). */
20
+ checking: boolean;
21
+ /** Re-run the readiness check on demand (e.g. a Retry button). */
22
+ recheck: () => Promise<void>;
23
+ }
24
+ export declare function useStartupReadiness(options?: UseStartupReadinessOptions): UseStartupReadinessResult;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * React hook for startup readiness (seller POS/order surfaces).
3
+ *
4
+ * Runs one bounded catch-up on mount (and whenever the cloud becomes
5
+ * reachable), then exposes the local cache readiness so the app can block the
6
+ * POS with an explicit "preparing offline data" state instead of surprising
7
+ * the seller with a guard failure at order-tap time.
8
+ */
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useSyncExternalStore } from "react";
11
+ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
12
+ import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
13
+ import { ensureStartupReady, } from "../sync/startup-readiness";
14
+ function useActiveBusinessId() {
15
+ const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
16
+ return id ?? undefined;
17
+ }
18
+ function useCloudStatus() {
19
+ return useSyncExternalStore(subscribeConnectivity, () => getConnectivityState().isCloudReachable, () => "unknown");
20
+ }
21
+ export function useStartupReadiness(options = {}) {
22
+ const { timeoutMs, enabled = true } = options;
23
+ const businessId = useActiveBusinessId();
24
+ const cloudStatus = useCloudStatus();
25
+ const [readiness, setReadiness] = useState(null);
26
+ const [checking, setChecking] = useState(false);
27
+ const runRef = useRef(false);
28
+ const check = useCallback(async () => {
29
+ if (!businessId)
30
+ return;
31
+ if (runRef.current)
32
+ return;
33
+ runRef.current = true;
34
+ setChecking(true);
35
+ try {
36
+ const result = await ensureStartupReady(businessId, { timeoutMs });
37
+ setReadiness(result);
38
+ }
39
+ finally {
40
+ runRef.current = false;
41
+ setChecking(false);
42
+ }
43
+ }, [businessId, timeoutMs]);
44
+ // First check when a business is available.
45
+ useEffect(() => {
46
+ if (!enabled || !businessId)
47
+ return;
48
+ if (readiness)
49
+ return;
50
+ void check();
51
+ }, [enabled, businessId, readiness, check]);
52
+ // Re-check whenever cloud reachability flips to reachable (data may now sync).
53
+ useEffect(() => {
54
+ if (!enabled || !businessId)
55
+ return;
56
+ if (cloudStatus !== "reachable")
57
+ return;
58
+ void check();
59
+ }, [enabled, businessId, cloudStatus, check]);
60
+ return { businessId, readiness, checking, recheck: check };
61
+ }
package/dist/index.d.ts CHANGED
@@ -54,6 +54,8 @@ export { buildCommand } from "./sync/command-builder";
54
54
  export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
55
55
  export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
56
56
  export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
57
+ export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, type StartupReadiness, } from "./sync/startup-readiness";
58
+ export { useStartupReadiness, type UseStartupReadinessOptions, type UseStartupReadinessResult, } from "./hooks/startup-readiness.hooks";
57
59
  export * from "./utils/hlc";
58
60
  export * from "./reference/lww";
59
61
  export { orderSnapshotsService } from "./sync/order-snapshots.service";
package/dist/index.js CHANGED
@@ -57,6 +57,8 @@ export { buildCommand } from "./sync/command-builder";
57
57
  export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
58
58
  export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
59
59
  export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
60
+ export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, } from "./sync/startup-readiness";
61
+ export { useStartupReadiness, } from "./hooks/startup-readiness.hooks";
60
62
  export * from "./utils/hlc";
61
63
  export * from "./reference/lww";
62
64
  export { orderSnapshotsService } from "./sync/order-snapshots.service";
@@ -441,6 +441,9 @@ export function generateEscpos(order, logoRasterB64) {
441
441
  push(LF);
442
442
  pushT(company.website + LF);
443
443
  pushT(`Powered by ${company.name}` + LF);
444
+ // Trailing space before the blade: feed past the print head so the cut
445
+ // never slices the last footer line.
446
+ push(ESC_FEED_3);
444
447
  push(ESC_FEED_3);
445
448
  push(GS_CUT_FULL);
446
449
  return parts.filter((p) => p != null).join("");
@@ -39,8 +39,13 @@ declare class CommandQueueService {
39
39
  */
40
40
  private absorbIfApplied;
41
41
  /**
42
- * Replay all pending commands in order. Stops on the first failure to keep
43
- * ordering consistent; the next replay will retry from the failed command.
42
+ * Replay pending commands in FIFO order with entity-aware skip-and-continue.
43
+ *
44
+ * Ordering is preserved per entity: a failing command defers only its own
45
+ * entity_id, so that order's later commands stay queued behind it, while
46
+ * independent entities keep syncing in the same pass. The pass stops only
47
+ * when nothing can make progress: no transport at all (REPLAY_NO_TRANSPORT)
48
+ * or TRANSPORT_FAILURE_BREAKER consecutive network/5xx failures.
44
49
  */
45
50
  replay(): Promise<void>;
46
51
  /**
@@ -12,7 +12,7 @@ import { getSyncDatabase, openSyncDatabase } from "../db/connection";
12
12
  import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { recordAttempt } from "../utils/attempt-stats";
15
- import { TransitionError, isReplayDeferred, TRANSITION_ERROR_CODES } from "./transition-errors";
15
+ import { TransitionError, isReplayDeferred, isReplayNoTransport, TRANSITION_ERROR_CODES } from "./transition-errors";
16
16
  import { assertBusinessOpenForCreate } from "../utils/business-hours";
17
17
  import { extractApiErrorCode } from "../utils/error";
18
18
  import { generateEventId } from "../utils/uuid";
@@ -22,6 +22,13 @@ import { getConnectivityState, subscribeConnectivity } from "../adapters/connect
22
22
  import { canBusinessSync } from "./sync-guard";
23
23
  import { backoffDelayMs } from "./backoff";
24
24
  const MAX_RETRIES = 5;
25
+ /**
26
+ * Stop a replay pass after this many consecutive transport-level failures
27
+ * (network/timeout/5xx) so a dead server is not hammered once per queued
28
+ * command. Any server response (2xx, permanent 4xx) or deferral resets the
29
+ * counter. The next trigger (connectivity/backoff) starts a fresh pass.
30
+ */
31
+ const TRANSPORT_FAILURE_BREAKER = 3;
25
32
  /**
26
33
  * 4xx that deserve another chance: transient races and rate limits, never
27
34
  * permanent verdicts. Anything else 4xx (400 validation, 401 session,
@@ -294,10 +301,16 @@ class CommandQueueService {
294
301
  // the next trigger (do not mark, do not count). Must reset the
295
302
  // in_flight flag first — rows stuck in_flight are invisible to the
296
303
  // outbox query and would wedge the queue forever.
297
- if (isReplayDeferred(error)) {
304
+ if (isReplayNoTransport(error)) {
298
305
  await updateCommandStatus(db, command.command_id, "pending", "deferred:no-transport");
299
306
  throw error;
300
307
  }
308
+ // Per-command backoff (retry window or retryable-4xx backoff): leave the
309
+ // row pending and rethrow so the pass defers just this entity.
310
+ if (isReplayDeferred(error)) {
311
+ await updateCommandStatus(db, command.command_id, "pending", "deferred:backoff");
312
+ throw error;
313
+ }
301
314
  const apiErr = error;
302
315
  const message = error instanceof Error ? error.message : String(error);
303
316
  // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR (exact backend code, any envelope)
@@ -437,8 +450,13 @@ class CommandQueueService {
437
450
  return false;
438
451
  }
439
452
  /**
440
- * Replay all pending commands in order. Stops on the first failure to keep
441
- * ordering consistent; the next replay will retry from the failed command.
453
+ * Replay pending commands in FIFO order with entity-aware skip-and-continue.
454
+ *
455
+ * Ordering is preserved per entity: a failing command defers only its own
456
+ * entity_id, so that order's later commands stay queued behind it, while
457
+ * independent entities keep syncing in the same pass. The pass stops only
458
+ * when nothing can make progress: no transport at all (REPLAY_NO_TRANSPORT)
459
+ * or TRANSPORT_FAILURE_BREAKER consecutive network/5xx failures.
442
460
  */
443
461
  async replay() {
444
462
  if (!canBusinessSync())
@@ -469,21 +487,59 @@ class CommandQueueService {
469
487
  syncLogger.info("CommandQueueService", "Replaying commands", {
470
488
  count: pending.length,
471
489
  });
490
+ // Entities deferred in THIS pass (backoff window or a transport failure):
491
+ // later commands for the same entity are skipped to preserve per-entity
492
+ // FIFO without blocking independent commands.
493
+ const deferredEntities = new Set();
494
+ let consecutiveTransportFailures = 0;
472
495
  for (const command of pending) {
496
+ const entity = command.entity_id;
497
+ if (entity && deferredEntities.has(entity)) {
498
+ syncLogger.debug("CommandQueueService", "Skipping command for deferred entity", {
499
+ command_id: command.command_id,
500
+ entity_id: entity,
501
+ });
502
+ continue;
503
+ }
473
504
  try {
474
505
  await this.processCommand(command);
506
+ consecutiveTransportFailures = 0;
475
507
  }
476
508
  catch (e) {
477
- const replayErr = e;
478
- const isNonRetryable = typeof replayErr?.status === "number" &&
479
- replayErr.status >= 400 &&
480
- replayErr.status < 500;
481
- if (isNonRetryable) {
482
- // Already marked permanent in processCommand — continue to next.
509
+ // No transport at all: nothing in this pass can succeed — stop.
510
+ if (isReplayNoTransport(e)) {
511
+ syncLogger.info("CommandQueueService", "Replay stopped: no transport available", {
512
+ command_id: command.command_id,
513
+ });
514
+ break;
515
+ }
516
+ // Per-command deferral (backoff window / retryable-4xx backoff):
517
+ // defer this entity only and keep draining the rest.
518
+ if (isReplayDeferred(e)) {
519
+ if (entity)
520
+ deferredEntities.add(entity);
521
+ consecutiveTransportFailures = 0;
483
522
  continue;
484
523
  }
485
- // Network/5xx — stop to preserve ordering; retry later.
486
- break;
524
+ const status = e?.status;
525
+ const is4xx = typeof status === "number" && status >= 400 && status < 500;
526
+ if (is4xx) {
527
+ // Permanent/retryable-exhausted 4xx: processCommand already marked
528
+ // the row failed (or absorbed it) — continue to the next command.
529
+ consecutiveTransportFailures = 0;
530
+ continue;
531
+ }
532
+ // Network/timeout/5xx: defer this entity, continue, but trip the
533
+ // breaker if the transport looks dead so we stop hammering.
534
+ if (entity)
535
+ deferredEntities.add(entity);
536
+ consecutiveTransportFailures += 1;
537
+ if (consecutiveTransportFailures >= TRANSPORT_FAILURE_BREAKER) {
538
+ syncLogger.warn("CommandQueueService", "Replay stopped: transport failure breaker tripped", {
539
+ consecutiveTransportFailures,
540
+ });
541
+ break;
542
+ }
487
543
  }
488
544
  }
489
545
  }
@@ -520,7 +576,7 @@ class CommandQueueService {
520
576
  async debugDumpCommands(businessId) {
521
577
  try {
522
578
  const db = getSyncDatabase();
523
- const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
579
+ const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER, true);
524
580
  return rows
525
581
  .filter((c) => !businessId || c.business_id === businessId)
526
582
  .map((c) => ({
@@ -48,7 +48,6 @@ export declare class SyncEngine {
48
48
  */
49
49
  publish(event: DomainEvent): void;
50
50
  private resolveStrategy;
51
- private selectTransport;
52
51
  private broadcast;
53
52
  }
54
53
  export declare const syncEngine: SyncEngine;
@@ -18,7 +18,7 @@ import { getConnectivityState } from "../adapters/connectivity";
18
18
  import { ApiError } from "../adapters/http";
19
19
  import { errorCodeOf } from "./transition-errors";
20
20
  import { syncLogger } from "../utils/sync-logger";
21
- import { CLOUD_COMMAND_TIMEOUT_MS } from "../utils/constants";
21
+ import { CLOUD_COMMAND_TIMEOUT_MS, LAN_COMMAND_TIMEOUT_MS } from "../utils/constants";
22
22
  import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
23
23
  import { getReferenceData } from "../reference/operations";
24
24
  import { openSyncDatabase } from "../db/connection";
@@ -39,6 +39,17 @@ function withTimeout(promise, ms) {
39
39
  });
40
40
  });
41
41
  }
42
+ /**
43
+ * Cash sales must never be refused for lack of entitlement proof: the POS
44
+ * domain defaults a missing method to cash, and only cash can be fulfilled
45
+ * without the backend. Anything else keeps the fail-closed guard.
46
+ */
47
+ function isCashOrderCreate(command) {
48
+ if (command.command_type !== "order:create")
49
+ return false;
50
+ const method = command.payload?.pos_payment_method;
51
+ return method === undefined || method === "cash";
52
+ }
42
53
  export class SyncEngine {
43
54
  constructor(options) {
44
55
  this.eventSubscribers = [];
@@ -80,89 +91,131 @@ export class SyncEngine {
80
91
  // otherwise fail open
81
92
  }
82
93
  }
83
- // Entitlement guard (fail-closed, offline-first): subscription-gated
84
- // commands need cached proof before routing. Backend 402 backstops.
94
+ // Entitlement guard (offline-first): subscription-gated commands need
95
+ // cached proof before routing — EXCEPT cash order:create, which always
96
+ // queues so the terminal never refuses a sale offline. The backend 402
97
+ // remains the backstop at replay; the queued event is flagged so the
98
+ // pending list can badge it until the server confirms.
99
+ let queueDespiteEntitlement = false;
85
100
  try {
86
101
  await assertEntitledForCommand(command.business_id, command.command_type);
87
102
  }
88
103
  catch (e) {
89
104
  if (e instanceof TransitionError &&
90
105
  e.code === TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED) {
91
- throw e;
106
+ if (isCashOrderCreate(command)) {
107
+ queueDespiteEntitlement = true;
108
+ syncLogger.warn("SyncEngine", "queueing cash order without entitlement proof", {
109
+ command_id: command.command_id,
110
+ business_id: command.business_id,
111
+ });
112
+ }
113
+ else {
114
+ throw e;
115
+ }
116
+ }
117
+ else {
118
+ // Guard infra failure (no DB yet, corrupt cache) must not brick the
119
+ // terminal — backend remains the authority. Log and continue.
120
+ syncLogger.warn("SyncEngine", "entitlement guard skipped", {
121
+ error: e instanceof Error ? e.message : String(e),
122
+ });
92
123
  }
93
- // Guard infra failure (no DB yet, corrupt cache) must not brick the
94
- // terminal — backend remains the authority. Log and continue.
95
- syncLogger.warn("SyncEngine", "entitlement guard skipped", {
96
- error: e instanceof Error ? e.message : String(e),
97
- });
98
124
  }
99
- let strategy = this.resolveStrategy(command);
125
+ const initialStrategy = this.resolveStrategy(command);
100
126
  const { isCloudReachable, isOnline } = getConnectivityState();
101
127
  syncLogger.info("SyncEngine", "Command strategy resolved", {
102
128
  command_id: command.command_id,
103
129
  command_type: command.command_type,
104
- strategy,
130
+ strategy: initialStrategy,
105
131
  isCloudReachable,
106
132
  isOnline,
107
133
  cloud_available: restTransport.isAvailable(),
108
134
  lan_available: localNodeTransport.isAvailable(),
109
135
  });
110
- if (strategy === "queue") {
111
- // Replays must never re-enqueue-then-delete (the command would eat
112
- // itself without executing). If no real transport is available, leave
113
- // the row untouched for a later trigger.
114
- if (opts?.forReplay) {
115
- throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
116
- }
117
- const event = await commandQueueService.enqueue(command);
118
- this.broadcast(event);
119
- // Menu writes need a visible local effect while queued: patch the
120
- // durable reference cache optimistically and fan out
121
- // reference_data.changed so reconciliation applies it locally and
122
- // relays it to LAN peers (replay re-applies idempotently; the backend
123
- // owns versions on cloud convergence).
124
- if (command.command_type.startsWith("menu:")) {
125
- try {
126
- const { applyMenuCommandLocal } = await import("../reference/menu-apply");
127
- const { keys } = await applyMenuCommandLocal(command.business_id, command);
128
- if (keys.length) {
129
- const { REFERENCE_CHANGED_EVENT_TYPE } = await import("../reference/events");
130
- const { generateEventId } = await import("../utils/uuid");
131
- this.broadcast({
132
- event_id: generateEventId(),
133
- event_type: REFERENCE_CHANGED_EVENT_TYPE,
134
- event_version: "1.0",
135
- entity_id: command.business_id,
136
- user_id: command.user_id,
137
- device_id: command.device_id,
138
- payload: { business_id: command.business_id, keys },
139
- timestamp: new Date().toISOString(),
140
- });
141
- }
136
+ // Ordered attempts for THIS execution only. Every await below is capped,
137
+ // so the UI can never hang: a stale "reachable" belief may send the
138
+ // first attempt to a dead cloud, but it is tried ONCE (2.5s cap) and
139
+ // never re-entered — the command downgrades to LAN, then the outbox.
140
+ const attempts = initialStrategy === "cloud"
141
+ ? [
142
+ "cloud",
143
+ ...(localNodeTransport.isAvailable() ? ["local_node"] : []),
144
+ "queue",
145
+ ]
146
+ : initialStrategy === "local_node"
147
+ ? ["local_node", "queue"]
148
+ : ["queue"];
149
+ let lastError = null;
150
+ for (const attempt of attempts) {
151
+ if (attempt === "queue") {
152
+ // Replays must never re-enqueue-then-delete (the command would eat
153
+ // itself without executing). If no real transport is available, leave
154
+ // the row untouched for a later trigger. Distinct code so the outbox
155
+ // stops the whole replay pass (nothing can succeed) rather than
156
+ // skipping just this entity and hammering the rest.
157
+ if (opts?.forReplay) {
158
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_NO_TRANSPORT, "No transport available, keeping command queued");
142
159
  }
143
- catch (e) {
144
- syncLogger.debug("SyncEngine", "queued menu optimistic apply failed", { err: String(e) });
160
+ const event = await commandQueueService.enqueue(command);
161
+ if (queueDespiteEntitlement) {
162
+ event.payload = {
163
+ ...event.payload,
164
+ entitlement_unverified: true,
165
+ };
145
166
  }
167
+ this.broadcast(event);
168
+ // Menu writes need a visible local effect while queued: patch the
169
+ // durable reference cache optimistically and fan out
170
+ // reference_data.changed so reconciliation applies it locally and
171
+ // relays it to LAN peers (replay re-applies idempotently; the backend
172
+ // owns versions on cloud convergence).
173
+ if (command.command_type.startsWith("menu:")) {
174
+ try {
175
+ const { applyMenuCommandLocal } = await import("../reference/menu-apply");
176
+ const { keys } = await applyMenuCommandLocal(command.business_id, command);
177
+ if (keys.length) {
178
+ const { REFERENCE_CHANGED_EVENT_TYPE } = await import("../reference/events");
179
+ const { generateEventId } = await import("../utils/uuid");
180
+ this.broadcast({
181
+ event_id: generateEventId(),
182
+ event_type: REFERENCE_CHANGED_EVENT_TYPE,
183
+ event_version: "1.0",
184
+ entity_id: command.business_id,
185
+ user_id: command.user_id,
186
+ device_id: command.device_id,
187
+ payload: { business_id: command.business_id, keys },
188
+ timestamp: new Date().toISOString(),
189
+ });
190
+ }
191
+ }
192
+ catch (e) {
193
+ syncLogger.debug("SyncEngine", "queued menu optimistic apply failed", { err: String(e) });
194
+ }
195
+ }
196
+ return [event];
146
197
  }
147
- return [event];
148
- }
149
- if (strategy === "cloud") {
198
+ const transport = attempt === "cloud" ? restTransport : localNodeTransport;
199
+ const cap = attempt === "cloud" ? CLOUD_COMMAND_TIMEOUT_MS : LAN_COMMAND_TIMEOUT_MS;
150
200
  try {
151
- const events = await withTimeout(restTransport.execute(command), CLOUD_COMMAND_TIMEOUT_MS);
201
+ const events = await withTimeout(transport.execute(command), cap);
152
202
  for (const event of events) {
153
203
  this.broadcast(event);
154
204
  }
155
205
  syncLogger.command(command, "completed", {
156
206
  event_count: events.length,
207
+ transport: transport.name,
157
208
  });
158
- // Cloud just worked: replay anything queued during the outage.
159
- // Flag-neutral: only the health probe writes isCloudReachable — a
160
- // single success here is not its decision to make either.
161
- this.replayQueuedCommands().catch((error) => {
162
- syncLogger.error("SyncEngine", "Replay after cloud success failed", {
163
- error: String(error),
209
+ if (attempt === "cloud") {
210
+ // Cloud just worked: replay anything queued during the outage.
211
+ // Flag-neutral: only the health probe writes isCloudReachable — a
212
+ // single success here is not its decision to make either.
213
+ this.replayQueuedCommands().catch((error) => {
214
+ syncLogger.error("SyncEngine", "Replay after cloud success failed", {
215
+ error: String(error),
216
+ });
164
217
  });
165
- });
218
+ }
166
219
  return events;
167
220
  }
168
221
  catch (error) {
@@ -190,60 +243,32 @@ export class SyncEngine {
190
243
  ? error.status
191
244
  : undefined;
192
245
  if (rejectionStatus !== undefined && rejectionStatus >= 400 && rejectionStatus < 500) {
193
- syncLogger.warn("SyncEngine", "Cloud command rejected by server", {
246
+ syncLogger.warn("SyncEngine", "Command rejected by server", {
194
247
  command_type: command.command_type,
195
248
  command_id: command.command_id,
249
+ transport: transport.name,
196
250
  status: rejectionStatus,
197
251
  code,
198
252
  message: error instanceof Error ? error.message : String(error),
199
253
  });
200
254
  throw error;
201
255
  }
202
- // Cloud is slow or unreachable: fall back to the Local Node or the
203
- // outbox so the terminal stays responsive. Flag-neutral: only the
256
+ // Timeout or network error: downgrade to the next attempt (LAN, then
257
+ // outbox) so the terminal stays responsive. Flag-neutral: only the
204
258
  // health probe writes isCloudReachable — a failed request is never
205
259
  // evidence the cloud is down (it may be a logic rejection without a
206
260
  // numeric status, e.g. TransitionError). The probe detects real
207
261
  // outages; queued work replays when it reports recovery.
208
- syncLogger.warn("SyncEngine", "Cloud command timed out, falling back", {
262
+ syncLogger.warn("SyncEngine", `${transport.name} attempt failed, downgrading`, {
209
263
  command_type: command.command_type,
210
- error: String(error),
211
- });
212
- strategy = this.resolveStrategy(command);
213
- if (strategy === "queue") {
214
- // Replays must never re-enqueue-then-delete (the command would eat
215
- // itself without executing). If no real transport is available, leave
216
- // the row untouched for a later trigger.
217
- if (opts?.forReplay) {
218
- throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
219
- }
220
- const event = await commandQueueService.enqueue(command);
221
- this.broadcast(event);
222
- return [event];
223
- }
224
- syncLogger.info("SyncEngine", "Cloud timed out, using LAN fallback", {
225
264
  command_id: command.command_id,
226
- command_type: command.command_type,
227
- strategy,
265
+ error: String(error),
228
266
  });
267
+ lastError = error;
229
268
  }
230
269
  }
231
- const transport = this.selectTransport(command, strategy);
232
- syncLogger.info("SyncEngine", "Executing via transport", {
233
- command_id: command.command_id,
234
- command_type: command.command_type,
235
- strategy,
236
- transport: transport.name,
237
- });
238
- const events = await transport.execute(command);
239
- for (const event of events) {
240
- this.broadcast(event);
241
- }
242
- syncLogger.command(command, "completed", {
243
- event_count: events.length,
244
- transport: transport.name,
245
- });
246
- return events;
270
+ // Unreachable: attempts always ends with "queue", which returns.
271
+ throw lastError ?? new Error("SyncEngine: no transport attempt succeeded");
247
272
  }
248
273
  /**
249
274
  * Trigger replay of queued commands when connectivity returns.
@@ -296,19 +321,6 @@ export class SyncEngine {
296
321
  }
297
322
  return "cloud";
298
323
  }
299
- selectTransport(_command, strategy) {
300
- if (strategy === "local_node" && localNodeTransport.isAvailable()) {
301
- return localNodeTransport;
302
- }
303
- if (strategy === "cloud" && restTransport.isAvailable()) {
304
- return restTransport;
305
- }
306
- const transport = this.transports.find((t) => t.isAvailable()) ?? this.transports[0];
307
- if (!transport) {
308
- throw new Error("SyncEngine: no transports configured");
309
- }
310
- return transport;
311
- }
312
324
  broadcast(event) {
313
325
  syncLogger.event(event, "broadcast");
314
326
  this.eventSubscribers.forEach((handler) => handler(event));
@@ -1,6 +1,14 @@
1
1
  /**
2
- * Full sync: push → pull → push.
3
- * Push first so local edits (already HLC-bumped) reach server before pull
4
- * overwrites them; then pull merges; final drain pushes any remainder.
2
+ * Full sync: reference → push → pull → push.
3
+ *
4
+ * Reference (menu/hours/tax/capabilities) is pulled FIRST and non-blockingly:
5
+ * every phase is independently swallowed, so a reference failure never
6
+ * prevents queued orders from flushing. Getting reference in before the order
7
+ * push means newly-launched terminals can satisfy order-creation prerequisites
8
+ * (hour/tax/capability guards) in the same cycle.
9
+ *
10
+ * Orders still push before the order pull, so local edits (already HLC-bumped)
11
+ * reach the server before the pull merge could overwrite them. The final
12
+ * drain flushes anything newly queued or retried after the pull.
5
13
  */
6
14
  export declare function performFullSync(businessId?: string): Promise<void>;
@@ -1,7 +1,15 @@
1
1
  /**
2
- * Full sync: push → pull → push.
3
- * Push first so local edits (already HLC-bumped) reach server before pull
4
- * overwrites them; then pull merges; final drain pushes any remainder.
2
+ * Full sync: reference → push → pull → push.
3
+ *
4
+ * Reference (menu/hours/tax/capabilities) is pulled FIRST and non-blockingly:
5
+ * every phase is independently swallowed, so a reference failure never
6
+ * prevents queued orders from flushing. Getting reference in before the order
7
+ * push means newly-launched terminals can satisfy order-creation prerequisites
8
+ * (hour/tax/capability guards) in the same cycle.
9
+ *
10
+ * Orders still push before the order pull, so local edits (already HLC-bumped)
11
+ * reach the server before the pull merge could overwrite them. The final
12
+ * drain flushes anything newly queued or retried after the pull.
5
13
  */
6
14
  import { getConnectivityState } from "../adapters/connectivity";
7
15
  import { getActiveBusiness } from "../adapters/business";
@@ -26,28 +34,30 @@ export async function performFullSync(businessId) {
26
34
  }
27
35
  const p = (async () => {
28
36
  try {
29
- syncLogger.info("FullSync", "Starting push→pull→push", { businessId: bid });
30
- // 1. Push pending FIFO first — HLC-bumped local edits outrank stale server
37
+ syncLogger.info("FullSync", "Starting reference→push→pull→push", { businessId: bid });
38
+ // 1. Reference first (non-blocking): menu/hours/tax/caps so order
39
+ // creation prerequisites are present before we flush orders.
31
40
  try {
32
- await drainPending(bid);
41
+ await referenceSyncService.sync({ businessId: bid });
33
42
  }
34
43
  catch (e) {
35
- syncLogger.warn("FullSync", "drainPending(pre) failed", { err: String(e) });
44
+ syncLogger.warn("FullSync", "reference sync failed", { err: String(e) });
36
45
  }
37
- // 2. Pull remote orders (HLC-guarded) and reference (LWW merge)
46
+ // 2. Push pending FIFO — HLC-bumped local edits outrank stale server
38
47
  try {
39
- await pullRemoteOrders(bid);
48
+ await drainPending(bid);
40
49
  }
41
50
  catch (e) {
42
- syncLogger.warn("FullSync", "pullRemoteOrders failed", { err: String(e) });
51
+ syncLogger.warn("FullSync", "drainPending(pre) failed", { err: String(e) });
43
52
  }
53
+ // 3. Pull remote orders (HLC-guarded)
44
54
  try {
45
- await referenceSyncService.sync({ businessId: bid });
55
+ await pullRemoteOrders(bid);
46
56
  }
47
57
  catch (e) {
48
- syncLogger.warn("FullSync", "reference sync failed", { err: String(e) });
58
+ syncLogger.warn("FullSync", "pullRemoteOrders failed", { err: String(e) });
49
59
  }
50
- // 3. Final drain — anything newly queued or retried after pull
60
+ // 4. Final drain — anything newly queued or retried after pull
51
61
  try {
52
62
  await drainPending(bid);
53
63
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Startup readiness for the POS/order surfaces.
3
+ *
4
+ * The offline-first guards intentionally fail closed (HOURS_NOT_SYNCED,
5
+ * SUBSCRIPTION_REQUIRED, "no device leased"), which is correct — but if the
6
+ * required reference data was never loaded, those guards fire at order-tap
7
+ * time as a surprise. This module lets the app discover the same conditions at
8
+ * launch: it evaluates the local cache (no network) and, when online, performs
9
+ * one bounded catch-up sync so a freshly-launched terminal can satisfy the
10
+ * guards before the seller starts taking orders.
11
+ *
12
+ * Seller-only. Fleet/rider surfaces are fully online and never gate here.
13
+ */
14
+ import type { ReferenceDataKey } from "../reference/types";
15
+ /**
16
+ * Reference keys required before the app presents itself as ready to take
17
+ * orders: menu to sell, taxes to price, hours to open, profile/capabilities
18
+ * for routing + guards, and the signed entitlement proof for gated commands.
19
+ */
20
+ export declare const REQUIRED_ORDER_KEYS: readonly ReferenceDataKey[];
21
+ export interface StartupReadiness {
22
+ /** True when every required key is cached and a device number is leased. */
23
+ ready: boolean;
24
+ /** Required keys with no cached payload. */
25
+ missingKeys: ReferenceDataKey[];
26
+ /** True when no DD-RRR-CCC device number is available (needs one online lease). */
27
+ needsLease: boolean;
28
+ /**
29
+ * True when an entitlement envelope is absent or does not evaluate as
30
+ * entitled. Informational: cash orders still queue offline, so this does not
31
+ * by itself block readiness.
32
+ */
33
+ entitlementUnverified: boolean;
34
+ }
35
+ /** Evaluate the local cache only (no network). */
36
+ export declare function getStartupReadiness(businessId: string): Promise<StartupReadiness>;
37
+ /**
38
+ * One bounded catch-up attempt, then evaluate the cache. When offline (or the
39
+ * cloud is unreachable) this performs no network work and simply reports what
40
+ * is cached. Never throws.
41
+ */
42
+ export declare function ensureStartupReady(businessId: string, opts?: {
43
+ timeoutMs?: number;
44
+ }): Promise<StartupReadiness>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Startup readiness for the POS/order surfaces.
3
+ *
4
+ * The offline-first guards intentionally fail closed (HOURS_NOT_SYNCED,
5
+ * SUBSCRIPTION_REQUIRED, "no device leased"), which is correct — but if the
6
+ * required reference data was never loaded, those guards fire at order-tap
7
+ * time as a surprise. This module lets the app discover the same conditions at
8
+ * launch: it evaluates the local cache (no network) and, when online, performs
9
+ * one bounded catch-up sync so a freshly-launched terminal can satisfy the
10
+ * guards before the seller starts taking orders.
11
+ *
12
+ * Seller-only. Fleet/rider surfaces are fully online and never gate here.
13
+ */
14
+ import { openSyncDatabase } from "../db/connection";
15
+ import { getReferenceData } from "../reference/operations";
16
+ import { referenceSyncService } from "../reference/service";
17
+ import { evaluateBusinessEntitlements } from "../reference/entitlement-guard";
18
+ import { ensureLease } from "./device-lease.service";
19
+ import { getConnectivityState } from "../adapters/connectivity";
20
+ import { syncLogger } from "../utils/sync-logger";
21
+ /**
22
+ * Reference keys required before the app presents itself as ready to take
23
+ * orders: menu to sell, taxes to price, hours to open, profile/capabilities
24
+ * for routing + guards, and the signed entitlement proof for gated commands.
25
+ */
26
+ export const REQUIRED_ORDER_KEYS = [
27
+ "subscription:entitlements",
28
+ "capabilities",
29
+ "menu:categories",
30
+ "menu:items",
31
+ "tax-rules",
32
+ "business:profile",
33
+ "operating-hours",
34
+ ];
35
+ /** Evaluate the local cache only (no network). */
36
+ export async function getStartupReadiness(businessId) {
37
+ const db = await openSyncDatabase();
38
+ const records = await Promise.all(REQUIRED_ORDER_KEYS.map(async (key) => ({
39
+ key,
40
+ rec: await getReferenceData(db, businessId, key),
41
+ })));
42
+ const missingKeys = records
43
+ .filter((r) => !r.rec || r.rec.payload == null)
44
+ .map((r) => r.key);
45
+ // ensureLease() hydrates the sync KV from async KV and leases online when
46
+ // possible; offline with no cache it returns null (caller blocks POS).
47
+ let deviceNumber = null;
48
+ try {
49
+ deviceNumber = await ensureLease(businessId);
50
+ }
51
+ catch (e) {
52
+ syncLogger.warn("StartupReadiness", "lease check failed", { err: String(e) });
53
+ }
54
+ const needsLease = !deviceNumber;
55
+ let entitlementUnverified = missingKeys.includes("subscription:entitlements");
56
+ if (!entitlementUnverified) {
57
+ try {
58
+ const { evaluation } = await evaluateBusinessEntitlements(businessId);
59
+ entitlementUnverified = !evaluation.entitled;
60
+ }
61
+ catch {
62
+ entitlementUnverified = true;
63
+ }
64
+ }
65
+ return {
66
+ ready: missingKeys.length === 0 && !needsLease,
67
+ missingKeys,
68
+ needsLease,
69
+ entitlementUnverified,
70
+ };
71
+ }
72
+ /**
73
+ * One bounded catch-up attempt, then evaluate the cache. When offline (or the
74
+ * cloud is unreachable) this performs no network work and simply reports what
75
+ * is cached. Never throws.
76
+ */
77
+ export async function ensureStartupReady(businessId, opts) {
78
+ const timeoutMs = opts?.timeoutMs ?? 15000;
79
+ const { isOnline, isCloudReachable } = getConnectivityState();
80
+ if (isOnline && isCloudReachable !== "unreachable") {
81
+ const catchUp = (async () => {
82
+ try {
83
+ await referenceSyncService.sync({ businessId, keys: [...REQUIRED_ORDER_KEYS] });
84
+ }
85
+ catch (e) {
86
+ syncLogger.warn("StartupReadiness", "reference catch-up failed", { err: String(e) });
87
+ }
88
+ try {
89
+ await ensureLease(businessId);
90
+ }
91
+ catch (e) {
92
+ syncLogger.warn("StartupReadiness", "lease catch-up failed", { err: String(e) });
93
+ }
94
+ })();
95
+ await raceWithTimeout(catchUp, timeoutMs);
96
+ }
97
+ return getStartupReadiness(businessId);
98
+ }
99
+ /** Resolve after either the promise settles or the timeout elapses. */
100
+ async function raceWithTimeout(promise, ms) {
101
+ let timer;
102
+ const timeout = new Promise((resolve) => {
103
+ timer = setTimeout(resolve, ms);
104
+ });
105
+ try {
106
+ await Promise.race([
107
+ promise.then(() => undefined, () => undefined),
108
+ timeout,
109
+ ]);
110
+ }
111
+ finally {
112
+ if (timer)
113
+ clearTimeout(timer);
114
+ }
115
+ }
@@ -18,6 +18,13 @@ export declare const TRANSITION_ERROR_CODES: {
18
18
  readonly FORBIDDEN: "FORBIDDEN";
19
19
  readonly SUBSCRIPTION_REQUIRED: "SUBSCRIPTION_REQUIRED";
20
20
  readonly REPLAY_DEFERRED: "REPLAY_DEFERRED";
21
+ /**
22
+ * Replay found no usable transport at all (cloud + LAN both unavailable).
23
+ * Distinct from REPLAY_DEFERRED (per-command backoff/retryable-4xx) so the
24
+ * outbox can stop the whole pass instead of skipping just this entity:
25
+ * nothing can succeed until a transport returns.
26
+ */
27
+ readonly REPLAY_NO_TRANSPORT: "REPLAY_NO_TRANSPORT";
21
28
  readonly MISSING_RIDER: "MISSING_RIDER";
22
29
  readonly NO_DELIVERY: "NO_DELIVERY";
23
30
  };
@@ -30,3 +37,4 @@ export declare class TransitionError extends Error {
30
37
  export declare function errorCodeOf(error: unknown): string | undefined;
31
38
  export declare function isFinalGuardRejection(error: unknown): boolean;
32
39
  export declare function isReplayDeferred(error: unknown): boolean;
40
+ export declare function isReplayNoTransport(error: unknown): boolean;
@@ -18,6 +18,13 @@ export const TRANSITION_ERROR_CODES = {
18
18
  FORBIDDEN: "FORBIDDEN",
19
19
  SUBSCRIPTION_REQUIRED: "SUBSCRIPTION_REQUIRED",
20
20
  REPLAY_DEFERRED: "REPLAY_DEFERRED",
21
+ /**
22
+ * Replay found no usable transport at all (cloud + LAN both unavailable).
23
+ * Distinct from REPLAY_DEFERRED (per-command backoff/retryable-4xx) so the
24
+ * outbox can stop the whole pass instead of skipping just this entity:
25
+ * nothing can succeed until a transport returns.
26
+ */
27
+ REPLAY_NO_TRANSPORT: "REPLAY_NO_TRANSPORT",
21
28
  MISSING_RIDER: "MISSING_RIDER",
22
29
  NO_DELIVERY: "NO_DELIVERY",
23
30
  };
@@ -59,3 +66,6 @@ export function isFinalGuardRejection(error) {
59
66
  export function isReplayDeferred(error) {
60
67
  return errorCodeOf(error) === TRANSITION_ERROR_CODES.REPLAY_DEFERRED;
61
68
  }
69
+ export function isReplayNoTransport(error) {
70
+ return errorCodeOf(error) === TRANSITION_ERROR_CODES.REPLAY_NO_TRANSPORT;
71
+ }
@@ -95,6 +95,18 @@ export function createMemoryDriver() {
95
95
  tables.commands.push(row);
96
96
  return { lastInsertRowId: row.id, changes: 1 };
97
97
  }
98
+ // crash recovery: in_flight -> pending on open
99
+ if (s.includes("UPDATE commands SET status = 'pending'") && s.includes("WHERE status = 'in_flight'")) {
100
+ let changes = 0;
101
+ for (const row of tables.commands) {
102
+ if (row.status === "in_flight") {
103
+ row.status = "pending";
104
+ row.error = null;
105
+ changes += 1;
106
+ }
107
+ }
108
+ return { lastInsertRowId: 0, changes };
109
+ }
98
110
  // commands update status (4- or 5-placeholder variants — always take the
99
111
  // LAST arg as command_id; older code assumed 5).
100
112
  if (s.includes("UPDATE commands SET status = ?")) {
@@ -365,7 +377,12 @@ export function createMemoryDriver() {
365
377
  }
366
378
  if (s.includes("SELECT * FROM commands") && s.includes("WHERE status IN")) {
367
379
  const limit = args[args.length - 1];
368
- let rows = tables.commands.filter((r) => r.status === "pending" || r.status === "failed").sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
380
+ // Mirror production: `failed` is terminal and only returned when the
381
+ // caller opts in (includeFailed -> SQL contains 'failed').
382
+ const includeFailed = s.includes("'failed'");
383
+ let rows = tables.commands
384
+ .filter((r) => r.status === "pending" || (includeFailed && r.status === "failed"))
385
+ .sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
369
386
  if (limit)
370
387
  rows = rows.slice(0, limit);
371
388
  return rows;
@@ -49,6 +49,12 @@ export declare const DEFAULT_PAGE_SIZE = 20;
49
49
  * Keeps the terminal responsive (fast) even when the internet is slow.
50
50
  */
51
51
  export declare const CLOUD_COMMAND_TIMEOUT_MS = 2500;
52
+ /**
53
+ * Max time the Sync Engine waits for a Local Node command before falling
54
+ * back to the offline outbox. LAN discovery can stall on a dead peer —
55
+ * this cap keeps every engine await bounded so the UI can never hang.
56
+ */
57
+ export declare const LAN_COMMAND_TIMEOUT_MS = 5000;
52
58
  export declare const appVersion = "0.7.3";
53
59
  /** Default config bundle for createTeincCore overrides */
54
60
  export declare const DEFAULTS: {
@@ -84,4 +90,5 @@ export declare const DEFAULTS: {
84
90
  readonly OTP_RESEND_DELAY_SECONDS: 60;
85
91
  readonly DEFAULT_PAGE_SIZE: 20;
86
92
  readonly CLOUD_COMMAND_TIMEOUT_MS: 2500;
93
+ readonly LAN_COMMAND_TIMEOUT_MS: 5000;
87
94
  };
@@ -77,6 +77,12 @@ export const DEFAULT_PAGE_SIZE = 20;
77
77
  * Keeps the terminal responsive (fast) even when the internet is slow.
78
78
  */
79
79
  export const CLOUD_COMMAND_TIMEOUT_MS = 2500;
80
+ /**
81
+ * Max time the Sync Engine waits for a Local Node command before falling
82
+ * back to the offline outbox. LAN discovery can stall on a dead peer —
83
+ * this cap keeps every engine await bounded so the UI can never hang.
84
+ */
85
+ export const LAN_COMMAND_TIMEOUT_MS = 5000;
80
86
  // Expo version removed; expose static version for now
81
87
  export const appVersion = APP_VERSION;
82
88
  /** Default config bundle for createTeincCore overrides */
@@ -92,4 +98,5 @@ export const DEFAULTS = {
92
98
  OTP_RESEND_DELAY_SECONDS,
93
99
  DEFAULT_PAGE_SIZE,
94
100
  CLOUD_COMMAND_TIMEOUT_MS,
101
+ LAN_COMMAND_TIMEOUT_MS,
95
102
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.12",
3
+ "version": "0.9.15",
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",