@teincfood/core 0.9.12 → 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.
@@ -441,6 +441,9 @@ export function generateEscpos(order, logoRasterB64) {
441
441
  push(LF);
442
442
  pushT(company.website + LF);
443
443
  pushT(`Powered by ${company.name}` + LF);
444
+ // Trailing space before the blade: feed past the print head so the cut
445
+ // never slices the last footer line.
446
+ push(ESC_FEED_3);
444
447
  push(ESC_FEED_3);
445
448
  push(GS_CUT_FULL);
446
449
  return parts.filter((p) => p != null).join("");
@@ -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));
@@ -49,6 +49,12 @@ export declare const DEFAULT_PAGE_SIZE = 20;
49
49
  * Keeps the terminal responsive (fast) even when the internet is slow.
50
50
  */
51
51
  export declare const CLOUD_COMMAND_TIMEOUT_MS = 2500;
52
+ /**
53
+ * Max time the Sync Engine waits for a Local Node command before falling
54
+ * back to the offline outbox. LAN discovery can stall on a dead peer —
55
+ * this cap keeps every engine await bounded so the UI can never hang.
56
+ */
57
+ export declare const LAN_COMMAND_TIMEOUT_MS = 5000;
52
58
  export declare const appVersion = "0.7.3";
53
59
  /** Default config bundle for createTeincCore overrides */
54
60
  export declare const DEFAULTS: {
@@ -84,4 +90,5 @@ export declare const DEFAULTS: {
84
90
  readonly OTP_RESEND_DELAY_SECONDS: 60;
85
91
  readonly DEFAULT_PAGE_SIZE: 20;
86
92
  readonly CLOUD_COMMAND_TIMEOUT_MS: 2500;
93
+ readonly LAN_COMMAND_TIMEOUT_MS: 5000;
87
94
  };
@@ -77,6 +77,12 @@ export const DEFAULT_PAGE_SIZE = 20;
77
77
  * Keeps the terminal responsive (fast) even when the internet is slow.
78
78
  */
79
79
  export const CLOUD_COMMAND_TIMEOUT_MS = 2500;
80
+ /**
81
+ * Max time the Sync Engine waits for a Local Node command before falling
82
+ * back to the offline outbox. LAN discovery can stall on a dead peer —
83
+ * this cap keeps every engine await bounded so the UI can never hang.
84
+ */
85
+ export const LAN_COMMAND_TIMEOUT_MS = 5000;
80
86
  // Expo version removed; expose static version for now
81
87
  export const appVersion = APP_VERSION;
82
88
  /** Default config bundle for createTeincCore overrides */
@@ -92,4 +98,5 @@ export const DEFAULTS = {
92
98
  OTP_RESEND_DELAY_SECONDS,
93
99
  DEFAULT_PAGE_SIZE,
94
100
  CLOUD_COMMAND_TIMEOUT_MS,
101
+ LAN_COMMAND_TIMEOUT_MS,
95
102
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.12",
3
+ "version": "0.9.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",