@teincfood/core 0.9.11 → 0.9.14
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/domain/orders/service.js +7 -3
- package/dist/domain/orders/snapshots.d.ts +72 -0
- package/dist/domain/orders/snapshots.js +68 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/printer/receipt.js +13 -0
- package/dist/sync/command-queue.service.js +3 -18
- package/dist/sync/engine.d.ts +0 -1
- package/dist/sync/engine.js +116 -106
- package/dist/types/api/seller.api.d.ts +2 -0
- package/dist/utils/constants.d.ts +7 -0
- package/dist/utils/constants.js +7 -0
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ import { assertBusinessOpenForCreate } from "../../utils/business-hours";
|
|
|
14
14
|
import { canTransition } from "../../utils/order-transitions";
|
|
15
15
|
import { TRANSITION_ERROR_CODES, TransitionError } from "../../sync/transition-errors";
|
|
16
16
|
import { validateOrderCreateInput } from "../../validation/offline";
|
|
17
|
+
import { buildItemSnapshots, snapshotsToOrderLines } from "./snapshots";
|
|
17
18
|
import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
|
|
18
19
|
import { getAuthToken } from "../../adapters/auth";
|
|
19
20
|
import { canBusinessSync } from "../../sync/sync-guard";
|
|
@@ -106,9 +107,12 @@ export async function createLocalOrder(businessId, input) {
|
|
|
106
107
|
// while offline retain their original creation time.
|
|
107
108
|
const now = new Date().toISOString();
|
|
108
109
|
const insertedAt = input.inserted_at || now;
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
const
|
|
110
|
+
// Sale-time snapshots (canonical builder): names are embedded once and
|
|
111
|
+
// never re-resolved from the menu cache — offline history is self-contained.
|
|
112
|
+
const snapshots = buildItemSnapshots(input.item_snapshots ??
|
|
113
|
+
input.items ??
|
|
114
|
+
[]);
|
|
115
|
+
const items = snapshotsToOrderLines(snapshots, id);
|
|
112
116
|
const totalMinor = input.total_minor ?? 0;
|
|
113
117
|
const subtotalMinor = input.subtotal_minor ?? totalMinor;
|
|
114
118
|
const taxMinor = input.tax_minor ?? 0;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { OrderItem } from "../../types/api/seller.api";
|
|
2
|
+
/**
|
|
3
|
+
* Order Item Snapshots — the single contract for sale-time history.
|
|
4
|
+
*
|
|
5
|
+
* Every order line carries its own immutable snapshot (item/variant/addon
|
|
6
|
+
* names as of sale time) so order history never depends on the live menu:
|
|
7
|
+
* sizes, extras, and items can be renamed or deleted without touching past
|
|
8
|
+
* orders — on any client, online or offline.
|
|
9
|
+
*
|
|
10
|
+
* Both POS apps build their outbox payloads through `buildItemSnapshots`:
|
|
11
|
+
* it accepts either app's cart-line shape (rich lines with
|
|
12
|
+
* `selected_variant`/`selected_addons`, or thin lines with just the menu
|
|
13
|
+
* item) plus already-built snapshots (idempotent), and always emits the
|
|
14
|
+
* full canonical shape. The local order builders and the offline receipt
|
|
15
|
+
* renderer consume only this shape — they never re-resolve names from the
|
|
16
|
+
* menu cache.
|
|
17
|
+
*/
|
|
18
|
+
export interface SnapshotAddon {
|
|
19
|
+
name: string;
|
|
20
|
+
price_minor: number;
|
|
21
|
+
}
|
|
22
|
+
export interface OrderItemSnapshot {
|
|
23
|
+
menu_item_id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
quantity: number;
|
|
26
|
+
unit_price_minor: number;
|
|
27
|
+
variant_id?: string;
|
|
28
|
+
variant_name?: string;
|
|
29
|
+
addons: SnapshotAddon[];
|
|
30
|
+
}
|
|
31
|
+
/** Cart line shapes from the POS apps (superset tolerated). */
|
|
32
|
+
export interface SnapshotCartLine {
|
|
33
|
+
menu_item?: {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
} | null;
|
|
37
|
+
menu_item_id?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
quantity?: number;
|
|
40
|
+
unit_price_minor?: number;
|
|
41
|
+
unit_price?: number;
|
|
42
|
+
subtotal?: number;
|
|
43
|
+
selected_variant?: {
|
|
44
|
+
id?: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
} | null;
|
|
47
|
+
variant_id?: string;
|
|
48
|
+
variant_name?: string;
|
|
49
|
+
selected_addons?: Array<{
|
|
50
|
+
addon_id?: string;
|
|
51
|
+
id?: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
price_minor?: number;
|
|
54
|
+
price?: number;
|
|
55
|
+
}> | null;
|
|
56
|
+
addons?: Array<{
|
|
57
|
+
name?: string;
|
|
58
|
+
price_minor?: number;
|
|
59
|
+
price?: number;
|
|
60
|
+
}> | null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Normalize any POS cart line (or an already-built snapshot) into the
|
|
64
|
+
* canonical snapshot. Missing variant/addon info yields `undefined` / `[]`
|
|
65
|
+
* — never a menu-cache lookup.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildItemSnapshots(lines: SnapshotCartLine[] | null | undefined): OrderItemSnapshot[];
|
|
68
|
+
/**
|
|
69
|
+
* Expand snapshots into local order-line objects (the `SellerOrder["items"]`
|
|
70
|
+
* shape): names embedded, ids kept for reference, zero menu lookups.
|
|
71
|
+
*/
|
|
72
|
+
export declare function snapshotsToOrderLines(snapshots: OrderItemSnapshot[] | null | undefined, commandId: string): OrderItem[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function asString(value) {
|
|
2
|
+
return typeof value === "string" ? value : "";
|
|
3
|
+
}
|
|
4
|
+
function asNumber(value, fallback = 0) {
|
|
5
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Normalize any POS cart line (or an already-built snapshot) into the
|
|
9
|
+
* canonical snapshot. Missing variant/addon info yields `undefined` / `[]`
|
|
10
|
+
* — never a menu-cache lookup.
|
|
11
|
+
*/
|
|
12
|
+
export function buildItemSnapshots(lines) {
|
|
13
|
+
if (!Array.isArray(lines))
|
|
14
|
+
return [];
|
|
15
|
+
return lines.map((line) => {
|
|
16
|
+
const menuItemId = asString(line.menu_item?.id ?? line.menu_item_id);
|
|
17
|
+
const name = asString(line.menu_item?.name ?? line.name);
|
|
18
|
+
const quantity = asNumber(line.quantity, 1);
|
|
19
|
+
const unitPrice = asNumber(line.unit_price_minor ?? line.unit_price, 0);
|
|
20
|
+
const subtotal = asNumber(line.subtotal, unitPrice * quantity);
|
|
21
|
+
const variantId = line.selected_variant?.id ?? line.variant_id;
|
|
22
|
+
const variantName = line.selected_variant?.name ?? line.variant_name;
|
|
23
|
+
const rawAddons = line.selected_addons ?? line.addons ?? [];
|
|
24
|
+
const addons = Array.isArray(rawAddons)
|
|
25
|
+
? rawAddons.map((a) => ({
|
|
26
|
+
name: asString(a.name),
|
|
27
|
+
price_minor: asNumber(a.price_minor ?? a.price, 0),
|
|
28
|
+
}))
|
|
29
|
+
: [];
|
|
30
|
+
const explicitPrice = line.unit_price_minor ?? line.unit_price;
|
|
31
|
+
const unitPriceMinor = typeof explicitPrice === "number" && Number.isFinite(explicitPrice)
|
|
32
|
+
? explicitPrice
|
|
33
|
+
: subtotal && quantity
|
|
34
|
+
? Math.round(subtotal / quantity)
|
|
35
|
+
: 0;
|
|
36
|
+
const snapshot = {
|
|
37
|
+
menu_item_id: menuItemId,
|
|
38
|
+
name,
|
|
39
|
+
quantity,
|
|
40
|
+
unit_price_minor: unitPriceMinor,
|
|
41
|
+
addons,
|
|
42
|
+
};
|
|
43
|
+
if (variantId)
|
|
44
|
+
snapshot.variant_id = variantId;
|
|
45
|
+
if (typeof variantName === "string" && variantName !== "")
|
|
46
|
+
snapshot.variant_name = variantName;
|
|
47
|
+
return snapshot;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Expand snapshots into local order-line objects (the `SellerOrder["items"]`
|
|
52
|
+
* shape): names embedded, ids kept for reference, zero menu lookups.
|
|
53
|
+
*/
|
|
54
|
+
export function snapshotsToOrderLines(snapshots, commandId) {
|
|
55
|
+
if (!Array.isArray(snapshots))
|
|
56
|
+
return [];
|
|
57
|
+
return snapshots.map((s, index) => ({
|
|
58
|
+
id: `tmp-${commandId}-${index}`,
|
|
59
|
+
menu_item: { id: s.menu_item_id, name: s.name },
|
|
60
|
+
quantity: s.quantity,
|
|
61
|
+
unit_price: s.unit_price_minor,
|
|
62
|
+
subtotal: s.unit_price_minor * s.quantity,
|
|
63
|
+
variant_id: s.variant_id ?? null,
|
|
64
|
+
variant_name: s.variant_name ?? null,
|
|
65
|
+
variant: s.variant_name ? { id: s.variant_id, name: s.variant_name } : null,
|
|
66
|
+
addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
|
|
67
|
+
}));
|
|
68
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -135,6 +135,7 @@ export * from "./sync/transition-endpoints";
|
|
|
135
135
|
export * from "./utils/business-hours";
|
|
136
136
|
export * from "./hooks/business-hours.hooks";
|
|
137
137
|
export * from "./domain/orders/service";
|
|
138
|
+
export * from "./domain/orders/snapshots";
|
|
138
139
|
export { phoenixSocket } from "./realtime/phoenix-socket";
|
|
139
140
|
export { eventsService } from "./realtime/events.service";
|
|
140
141
|
export { orderEventsService } from "./realtime/order-events.service";
|
package/dist/index.js
CHANGED
|
@@ -148,6 +148,7 @@ export * from "./hooks/business-hours.hooks";
|
|
|
148
148
|
// components/OrderStatusCard was folded into order-transitions (compat re-export lives in apps)
|
|
149
149
|
// ── Domain (offline-first primary DB) ──
|
|
150
150
|
export * from "./domain/orders/service";
|
|
151
|
+
export * from "./domain/orders/snapshots";
|
|
151
152
|
// ── Realtime (single source — PHX WebSocket + business:{id}) ──
|
|
152
153
|
export { phoenixSocket } from "./realtime/phoenix-socket";
|
|
153
154
|
export { eventsService } from "./realtime/events.service";
|
package/dist/printer/receipt.js
CHANGED
|
@@ -161,6 +161,11 @@ export function calloutNumber(order) {
|
|
|
161
161
|
return String(parseInt(digits, 10));
|
|
162
162
|
}
|
|
163
163
|
function itemName(item) {
|
|
164
|
+
// Snapshot-first: the sale-time name survives menu renames/deletes.
|
|
165
|
+
// The embedded menu join is fallback for pre-snapshot rows.
|
|
166
|
+
const snapshot = item.name;
|
|
167
|
+
if (typeof snapshot === "string" && snapshot !== "")
|
|
168
|
+
return snapshot;
|
|
164
169
|
return item.menu_item?.name || "(deleted item)";
|
|
165
170
|
}
|
|
166
171
|
function addonName(addon) {
|
|
@@ -171,6 +176,11 @@ function addonName(addon) {
|
|
|
171
176
|
return "Add-on";
|
|
172
177
|
}
|
|
173
178
|
function variantName(item) {
|
|
179
|
+
// Snapshot-first: the sale-time size name survives menu edits. The live
|
|
180
|
+
// variant join is fallback for pre-snapshot rows.
|
|
181
|
+
const snapshot = item.variant_name;
|
|
182
|
+
if (typeof snapshot === "string" && snapshot !== "")
|
|
183
|
+
return snapshot;
|
|
174
184
|
const n = item.variant?.name;
|
|
175
185
|
return n ? n : null;
|
|
176
186
|
}
|
|
@@ -431,6 +441,9 @@ export function generateEscpos(order, logoRasterB64) {
|
|
|
431
441
|
push(LF);
|
|
432
442
|
pushT(company.website + LF);
|
|
433
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);
|
|
434
447
|
push(ESC_FEED_3);
|
|
435
448
|
push(GS_CUT_FULL);
|
|
436
449
|
return parts.filter((p) => p != null).join("");
|
|
@@ -16,6 +16,7 @@ import { TransitionError, isReplayDeferred, TRANSITION_ERROR_CODES } from "./tra
|
|
|
16
16
|
import { assertBusinessOpenForCreate } from "../utils/business-hours";
|
|
17
17
|
import { extractApiErrorCode } from "../utils/error";
|
|
18
18
|
import { generateEventId } from "../utils/uuid";
|
|
19
|
+
import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snapshots";
|
|
19
20
|
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
|
|
20
21
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
21
22
|
import { canBusinessSync } from "./sync-guard";
|
|
@@ -102,8 +103,7 @@ function buildOptimisticEvent(command) {
|
|
|
102
103
|
};
|
|
103
104
|
}
|
|
104
105
|
function buildOptimisticOrder(command, payload) {
|
|
105
|
-
const snapshots = payload.item_snapshots ??
|
|
106
|
-
const items = payload.items ?? [];
|
|
106
|
+
const snapshots = buildItemSnapshots((payload.item_snapshots ?? payload.items ?? []));
|
|
107
107
|
const totalMinor = payload.total_minor ?? 0;
|
|
108
108
|
const subtotalMinor = payload.subtotal_minor ?? totalMinor;
|
|
109
109
|
const taxMinor = payload.tax_minor ?? 0;
|
|
@@ -149,22 +149,7 @@ function buildOptimisticOrder(command, payload) {
|
|
|
149
149
|
delivery_lat: null,
|
|
150
150
|
delivery_lng: null,
|
|
151
151
|
user: null,
|
|
152
|
-
items: (snapshots
|
|
153
|
-
id: `tmp-${command.command_id}-${index}`,
|
|
154
|
-
menu_item: { id: s.menu_item_id, name: s.name },
|
|
155
|
-
quantity: s.quantity,
|
|
156
|
-
unit_price: s.unit_price_minor,
|
|
157
|
-
subtotal: s.unit_price_minor * s.quantity,
|
|
158
|
-
variant_name: s.variant_name,
|
|
159
|
-
addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
|
|
160
|
-
})) : items.map((item, index) => ({
|
|
161
|
-
id: `tmp-${command.command_id}-${index}`,
|
|
162
|
-
menu_item: { id: item.menu_item_id, name: "" },
|
|
163
|
-
quantity: item.quantity,
|
|
164
|
-
unit_price: 0,
|
|
165
|
-
subtotal: 0,
|
|
166
|
-
addons: [],
|
|
167
|
-
}))),
|
|
152
|
+
items: snapshotsToOrderLines(snapshots, command.command_id),
|
|
168
153
|
inserted_at: now,
|
|
169
154
|
};
|
|
170
155
|
}
|
package/dist/sync/engine.d.ts
CHANGED
package/dist/sync/engine.js
CHANGED
|
@@ -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,129 @@ export class SyncEngine {
|
|
|
80
91
|
// otherwise fail open
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
|
-
// Entitlement guard (
|
|
84
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
155
|
+
if (opts?.forReplay) {
|
|
156
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
|
|
142
157
|
}
|
|
143
|
-
|
|
144
|
-
|
|
158
|
+
const event = await commandQueueService.enqueue(command);
|
|
159
|
+
if (queueDespiteEntitlement) {
|
|
160
|
+
event.payload = {
|
|
161
|
+
...event.payload,
|
|
162
|
+
entitlement_unverified: true,
|
|
163
|
+
};
|
|
145
164
|
}
|
|
165
|
+
this.broadcast(event);
|
|
166
|
+
// Menu writes need a visible local effect while queued: patch the
|
|
167
|
+
// durable reference cache optimistically and fan out
|
|
168
|
+
// reference_data.changed so reconciliation applies it locally and
|
|
169
|
+
// relays it to LAN peers (replay re-applies idempotently; the backend
|
|
170
|
+
// owns versions on cloud convergence).
|
|
171
|
+
if (command.command_type.startsWith("menu:")) {
|
|
172
|
+
try {
|
|
173
|
+
const { applyMenuCommandLocal } = await import("../reference/menu-apply");
|
|
174
|
+
const { keys } = await applyMenuCommandLocal(command.business_id, command);
|
|
175
|
+
if (keys.length) {
|
|
176
|
+
const { REFERENCE_CHANGED_EVENT_TYPE } = await import("../reference/events");
|
|
177
|
+
const { generateEventId } = await import("../utils/uuid");
|
|
178
|
+
this.broadcast({
|
|
179
|
+
event_id: generateEventId(),
|
|
180
|
+
event_type: REFERENCE_CHANGED_EVENT_TYPE,
|
|
181
|
+
event_version: "1.0",
|
|
182
|
+
entity_id: command.business_id,
|
|
183
|
+
user_id: command.user_id,
|
|
184
|
+
device_id: command.device_id,
|
|
185
|
+
payload: { business_id: command.business_id, keys },
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
syncLogger.debug("SyncEngine", "queued menu optimistic apply failed", { err: String(e) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [event];
|
|
146
195
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (strategy === "cloud") {
|
|
196
|
+
const transport = attempt === "cloud" ? restTransport : localNodeTransport;
|
|
197
|
+
const cap = attempt === "cloud" ? CLOUD_COMMAND_TIMEOUT_MS : LAN_COMMAND_TIMEOUT_MS;
|
|
150
198
|
try {
|
|
151
|
-
const events = await withTimeout(
|
|
199
|
+
const events = await withTimeout(transport.execute(command), cap);
|
|
152
200
|
for (const event of events) {
|
|
153
201
|
this.broadcast(event);
|
|
154
202
|
}
|
|
155
203
|
syncLogger.command(command, "completed", {
|
|
156
204
|
event_count: events.length,
|
|
205
|
+
transport: transport.name,
|
|
157
206
|
});
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
error
|
|
207
|
+
if (attempt === "cloud") {
|
|
208
|
+
// Cloud just worked: replay anything queued during the outage.
|
|
209
|
+
// Flag-neutral: only the health probe writes isCloudReachable — a
|
|
210
|
+
// single success here is not its decision to make either.
|
|
211
|
+
this.replayQueuedCommands().catch((error) => {
|
|
212
|
+
syncLogger.error("SyncEngine", "Replay after cloud success failed", {
|
|
213
|
+
error: String(error),
|
|
214
|
+
});
|
|
164
215
|
});
|
|
165
|
-
}
|
|
216
|
+
}
|
|
166
217
|
return events;
|
|
167
218
|
}
|
|
168
219
|
catch (error) {
|
|
@@ -190,60 +241,32 @@ export class SyncEngine {
|
|
|
190
241
|
? error.status
|
|
191
242
|
: undefined;
|
|
192
243
|
if (rejectionStatus !== undefined && rejectionStatus >= 400 && rejectionStatus < 500) {
|
|
193
|
-
syncLogger.warn("SyncEngine", "
|
|
244
|
+
syncLogger.warn("SyncEngine", "Command rejected by server", {
|
|
194
245
|
command_type: command.command_type,
|
|
195
246
|
command_id: command.command_id,
|
|
247
|
+
transport: transport.name,
|
|
196
248
|
status: rejectionStatus,
|
|
197
249
|
code,
|
|
198
250
|
message: error instanceof Error ? error.message : String(error),
|
|
199
251
|
});
|
|
200
252
|
throw error;
|
|
201
253
|
}
|
|
202
|
-
//
|
|
203
|
-
// outbox so the terminal stays responsive. Flag-neutral: only the
|
|
254
|
+
// Timeout or network error: downgrade to the next attempt (LAN, then
|
|
255
|
+
// outbox) so the terminal stays responsive. Flag-neutral: only the
|
|
204
256
|
// health probe writes isCloudReachable — a failed request is never
|
|
205
257
|
// evidence the cloud is down (it may be a logic rejection without a
|
|
206
258
|
// numeric status, e.g. TransitionError). The probe detects real
|
|
207
259
|
// outages; queued work replays when it reports recovery.
|
|
208
|
-
syncLogger.warn("SyncEngine",
|
|
260
|
+
syncLogger.warn("SyncEngine", `${transport.name} attempt failed, downgrading`, {
|
|
209
261
|
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
262
|
command_id: command.command_id,
|
|
226
|
-
|
|
227
|
-
strategy,
|
|
263
|
+
error: String(error),
|
|
228
264
|
});
|
|
265
|
+
lastError = error;
|
|
229
266
|
}
|
|
230
267
|
}
|
|
231
|
-
|
|
232
|
-
|
|
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;
|
|
268
|
+
// Unreachable: attempts always ends with "queue", which returns.
|
|
269
|
+
throw lastError ?? new Error("SyncEngine: no transport attempt succeeded");
|
|
247
270
|
}
|
|
248
271
|
/**
|
|
249
272
|
* Trigger replay of queued commands when connectivity returns.
|
|
@@ -296,19 +319,6 @@ export class SyncEngine {
|
|
|
296
319
|
}
|
|
297
320
|
return "cloud";
|
|
298
321
|
}
|
|
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
322
|
broadcast(event) {
|
|
313
323
|
syncLogger.event(event, "broadcast");
|
|
314
324
|
this.eventSubscribers.forEach((handler) => handler(event));
|
|
@@ -39,6 +39,8 @@ export interface OrderItem {
|
|
|
39
39
|
id?: string;
|
|
40
40
|
name?: string;
|
|
41
41
|
} | null;
|
|
42
|
+
/** Sale-time size name snapshot — history never re-resolves the menu. */
|
|
43
|
+
variant_name?: string | null;
|
|
42
44
|
/** Backend shape for addon lines. */
|
|
43
45
|
order_item_addons?: OrderItemAddon[];
|
|
44
46
|
}
|
|
@@ -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
|
};
|
package/dist/utils/constants.js
CHANGED
|
@@ -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