@teincfood/core 0.9.14 → 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";
@@ -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) => ({
@@ -151,9 +151,11 @@ export class SyncEngine {
151
151
  if (attempt === "queue") {
152
152
  // Replays must never re-enqueue-then-delete (the command would eat
153
153
  // itself without executing). If no real transport is available, leave
154
- // the row untouched for a later trigger.
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.
155
157
  if (opts?.forReplay) {
156
- throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
158
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_NO_TRANSPORT, "No transport available, keeping command queued");
157
159
  }
158
160
  const event = await commandQueueService.enqueue(command);
159
161
  if (queueDespiteEntitlement) {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.14",
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",