@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.
- package/dist/db/connection.js +25 -0
- package/dist/db/operations.d.ts +1 -1
- package/dist/db/operations.js +7 -2
- package/dist/hooks/startup-readiness.hooks.d.ts +24 -0
- package/dist/hooks/startup-readiness.hooks.js +61 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/sync/command-queue.service.d.ts +7 -2
- package/dist/sync/command-queue.service.js +69 -13
- package/dist/sync/engine.js +4 -2
- package/dist/sync/full-sync.d.ts +11 -3
- package/dist/sync/full-sync.js +23 -13
- package/dist/sync/startup-readiness.d.ts +44 -0
- package/dist/sync/startup-readiness.js +115 -0
- package/dist/sync/transition-errors.d.ts +8 -0
- package/dist/sync/transition-errors.js +10 -0
- package/dist/test-helpers/memory-driver.js +18 -1
- package/package.json +1 -1
package/dist/db/connection.js
CHANGED
|
@@ -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)
|
package/dist/db/operations.d.ts
CHANGED
|
@@ -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>;
|
package/dist/db/operations.js
CHANGED
|
@@ -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
|
|
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
|
|
43
|
-
*
|
|
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 (
|
|
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
|
|
441
|
-
*
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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) => ({
|
package/dist/sync/engine.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/dist/sync/full-sync.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Full sync: push → pull → push.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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>;
|
package/dist/sync/full-sync.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Full sync: push → pull → push.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
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
|
|
41
|
+
await referenceSyncService.sync({ businessId: bid });
|
|
33
42
|
}
|
|
34
43
|
catch (e) {
|
|
35
|
-
syncLogger.warn("FullSync", "
|
|
44
|
+
syncLogger.warn("FullSync", "reference sync failed", { err: String(e) });
|
|
36
45
|
}
|
|
37
|
-
// 2.
|
|
46
|
+
// 2. Push pending FIFO — HLC-bumped local edits outrank stale server
|
|
38
47
|
try {
|
|
39
|
-
await
|
|
48
|
+
await drainPending(bid);
|
|
40
49
|
}
|
|
41
50
|
catch (e) {
|
|
42
|
-
syncLogger.warn("FullSync", "
|
|
51
|
+
syncLogger.warn("FullSync", "drainPending(pre) failed", { err: String(e) });
|
|
43
52
|
}
|
|
53
|
+
// 3. Pull remote orders (HLC-guarded)
|
|
44
54
|
try {
|
|
45
|
-
await
|
|
55
|
+
await pullRemoteOrders(bid);
|
|
46
56
|
}
|
|
47
57
|
catch (e) {
|
|
48
|
-
syncLogger.warn("FullSync", "
|
|
58
|
+
syncLogger.warn("FullSync", "pullRemoteOrders failed", { err: String(e) });
|
|
49
59
|
}
|
|
50
|
-
//
|
|
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
|
-
|
|
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