@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.
@@ -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
- // item_snapshots from CartPanel, else fallback
110
- const snapshots = input.item_snapshots;
111
- const items = snapshots ? snapshots.map((s, i) => ({ id: `tmp-${id}-${i}`, menu_item: { id: s.menu_item_id, name: s.name }, quantity: s.quantity, unit_price: s.unit_price_minor, subtotal: s.unit_price_minor * s.quantity })) : (input.items ?? []).map((it, i) => ({ id: `tmp-${id}-${i}`, menu_item: { id: it.menu_item_id, name: "" }, quantity: it.quantity, unit_price: 0, subtotal: 0 }));
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";
@@ -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 ?? null;
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 ? snapshots.map((s, index) => ({
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
  }
@@ -48,7 +48,6 @@ export declare class SyncEngine {
48
48
  */
49
49
  publish(event: DomainEvent): void;
50
50
  private resolveStrategy;
51
- private selectTransport;
52
51
  private broadcast;
53
52
  }
54
53
  export declare const syncEngine: SyncEngine;
@@ -18,7 +18,7 @@ import { getConnectivityState } from "../adapters/connectivity";
18
18
  import { ApiError } from "../adapters/http";
19
19
  import { errorCodeOf } from "./transition-errors";
20
20
  import { syncLogger } from "../utils/sync-logger";
21
- import { CLOUD_COMMAND_TIMEOUT_MS } from "../utils/constants";
21
+ import { CLOUD_COMMAND_TIMEOUT_MS, LAN_COMMAND_TIMEOUT_MS } from "../utils/constants";
22
22
  import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
23
23
  import { getReferenceData } from "../reference/operations";
24
24
  import { openSyncDatabase } from "../db/connection";
@@ -39,6 +39,17 @@ function withTimeout(promise, ms) {
39
39
  });
40
40
  });
41
41
  }
42
+ /**
43
+ * Cash sales must never be refused for lack of entitlement proof: the POS
44
+ * domain defaults a missing method to cash, and only cash can be fulfilled
45
+ * without the backend. Anything else keeps the fail-closed guard.
46
+ */
47
+ function isCashOrderCreate(command) {
48
+ if (command.command_type !== "order:create")
49
+ return false;
50
+ const method = command.payload?.pos_payment_method;
51
+ return method === undefined || method === "cash";
52
+ }
42
53
  export class SyncEngine {
43
54
  constructor(options) {
44
55
  this.eventSubscribers = [];
@@ -80,89 +91,129 @@ export class SyncEngine {
80
91
  // otherwise fail open
81
92
  }
82
93
  }
83
- // Entitlement guard (fail-closed, offline-first): subscription-gated
84
- // commands need cached proof before routing. Backend 402 backstops.
94
+ // Entitlement guard (offline-first): subscription-gated commands need
95
+ // cached proof before routing — EXCEPT cash order:create, which always
96
+ // queues so the terminal never refuses a sale offline. The backend 402
97
+ // remains the backstop at replay; the queued event is flagged so the
98
+ // pending list can badge it until the server confirms.
99
+ let queueDespiteEntitlement = false;
85
100
  try {
86
101
  await assertEntitledForCommand(command.business_id, command.command_type);
87
102
  }
88
103
  catch (e) {
89
104
  if (e instanceof TransitionError &&
90
105
  e.code === TRANSITION_ERROR_CODES.SUBSCRIPTION_REQUIRED) {
91
- throw e;
106
+ if (isCashOrderCreate(command)) {
107
+ queueDespiteEntitlement = true;
108
+ syncLogger.warn("SyncEngine", "queueing cash order without entitlement proof", {
109
+ command_id: command.command_id,
110
+ business_id: command.business_id,
111
+ });
112
+ }
113
+ else {
114
+ throw e;
115
+ }
116
+ }
117
+ else {
118
+ // Guard infra failure (no DB yet, corrupt cache) must not brick the
119
+ // terminal — backend remains the authority. Log and continue.
120
+ syncLogger.warn("SyncEngine", "entitlement guard skipped", {
121
+ error: e instanceof Error ? e.message : String(e),
122
+ });
92
123
  }
93
- // Guard infra failure (no DB yet, corrupt cache) must not brick the
94
- // terminal — backend remains the authority. Log and continue.
95
- syncLogger.warn("SyncEngine", "entitlement guard skipped", {
96
- error: e instanceof Error ? e.message : String(e),
97
- });
98
124
  }
99
- let strategy = this.resolveStrategy(command);
125
+ const initialStrategy = this.resolveStrategy(command);
100
126
  const { isCloudReachable, isOnline } = getConnectivityState();
101
127
  syncLogger.info("SyncEngine", "Command strategy resolved", {
102
128
  command_id: command.command_id,
103
129
  command_type: command.command_type,
104
- strategy,
130
+ strategy: initialStrategy,
105
131
  isCloudReachable,
106
132
  isOnline,
107
133
  cloud_available: restTransport.isAvailable(),
108
134
  lan_available: localNodeTransport.isAvailable(),
109
135
  });
110
- if (strategy === "queue") {
111
- // Replays must never re-enqueue-then-delete (the command would eat
112
- // itself without executing). If no real transport is available, leave
113
- // the row untouched for a later trigger.
114
- if (opts?.forReplay) {
115
- throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
116
- }
117
- const event = await commandQueueService.enqueue(command);
118
- this.broadcast(event);
119
- // Menu writes need a visible local effect while queued: patch the
120
- // durable reference cache optimistically and fan out
121
- // reference_data.changed so reconciliation applies it locally and
122
- // relays it to LAN peers (replay re-applies idempotently; the backend
123
- // owns versions on cloud convergence).
124
- if (command.command_type.startsWith("menu:")) {
125
- try {
126
- const { applyMenuCommandLocal } = await import("../reference/menu-apply");
127
- const { keys } = await applyMenuCommandLocal(command.business_id, command);
128
- if (keys.length) {
129
- const { REFERENCE_CHANGED_EVENT_TYPE } = await import("../reference/events");
130
- const { generateEventId } = await import("../utils/uuid");
131
- this.broadcast({
132
- event_id: generateEventId(),
133
- event_type: REFERENCE_CHANGED_EVENT_TYPE,
134
- event_version: "1.0",
135
- entity_id: command.business_id,
136
- user_id: command.user_id,
137
- device_id: command.device_id,
138
- payload: { business_id: command.business_id, keys },
139
- timestamp: new Date().toISOString(),
140
- });
141
- }
136
+ // Ordered attempts for THIS execution only. Every await below is capped,
137
+ // so the UI can never hang: a stale "reachable" belief may send the
138
+ // first attempt to a dead cloud, but it is tried ONCE (2.5s cap) and
139
+ // never re-entered — the command downgrades to LAN, then the outbox.
140
+ const attempts = initialStrategy === "cloud"
141
+ ? [
142
+ "cloud",
143
+ ...(localNodeTransport.isAvailable() ? ["local_node"] : []),
144
+ "queue",
145
+ ]
146
+ : initialStrategy === "local_node"
147
+ ? ["local_node", "queue"]
148
+ : ["queue"];
149
+ let lastError = null;
150
+ for (const attempt of attempts) {
151
+ if (attempt === "queue") {
152
+ // Replays must never re-enqueue-then-delete (the command would eat
153
+ // itself without executing). If no real transport is available, leave
154
+ // the row untouched for a later trigger.
155
+ if (opts?.forReplay) {
156
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
142
157
  }
143
- catch (e) {
144
- syncLogger.debug("SyncEngine", "queued menu optimistic apply failed", { err: String(e) });
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
- return [event];
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(restTransport.execute(command), CLOUD_COMMAND_TIMEOUT_MS);
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
- // Cloud just worked: replay anything queued during the outage.
159
- // Flag-neutral: only the health probe writes isCloudReachable — a
160
- // single success here is not its decision to make either.
161
- this.replayQueuedCommands().catch((error) => {
162
- syncLogger.error("SyncEngine", "Replay after cloud success failed", {
163
- error: String(error),
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", "Cloud command rejected by server", {
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
- // Cloud is slow or unreachable: fall back to the Local Node or the
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", "Cloud command timed out, falling back", {
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
- command_type: command.command_type,
227
- strategy,
263
+ error: String(error),
228
264
  });
265
+ lastError = error;
229
266
  }
230
267
  }
231
- const transport = this.selectTransport(command, strategy);
232
- syncLogger.info("SyncEngine", "Executing via transport", {
233
- command_id: command.command_id,
234
- command_type: command.command_type,
235
- strategy,
236
- transport: transport.name,
237
- });
238
- const events = await transport.execute(command);
239
- for (const event of events) {
240
- this.broadcast(event);
241
- }
242
- syncLogger.command(command, "completed", {
243
- event_count: events.length,
244
- transport: transport.name,
245
- });
246
- return events;
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
  };
@@ -77,6 +77,12 @@ export const DEFAULT_PAGE_SIZE = 20;
77
77
  * Keeps the terminal responsive (fast) even when the internet is slow.
78
78
  */
79
79
  export const CLOUD_COMMAND_TIMEOUT_MS = 2500;
80
+ /**
81
+ * Max time the Sync Engine waits for a Local Node command before falling
82
+ * back to the offline outbox. LAN discovery can stall on a dead peer —
83
+ * this cap keeps every engine await bounded so the UI can never hang.
84
+ */
85
+ export const LAN_COMMAND_TIMEOUT_MS = 5000;
80
86
  // Expo version removed; expose static version for now
81
87
  export const appVersion = APP_VERSION;
82
88
  /** Default config bundle for createTeincCore overrides */
@@ -92,4 +98,5 @@ export const DEFAULTS = {
92
98
  OTP_RESEND_DELAY_SECONDS,
93
99
  DEFAULT_PAGE_SIZE,
94
100
  CLOUD_COMMAND_TIMEOUT_MS,
101
+ LAN_COMMAND_TIMEOUT_MS,
95
102
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.11",
3
+ "version": "0.9.14",
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",