@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.
- package/dist/printer/receipt.js +3 -0
- package/dist/sync/engine.d.ts +0 -1
- package/dist/sync/engine.js +116 -106
- package/dist/utils/constants.d.ts +7 -0
- package/dist/utils/constants.js +7 -0
- package/package.json +1 -1
package/dist/printer/receipt.js
CHANGED
|
@@ -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("");
|
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));
|
|
@@ -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