@teincfood/core 0.1.0
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/README.md +44 -0
- package/dist/adapters/auth.d.ts +13 -0
- package/dist/adapters/auth.js +14 -0
- package/dist/adapters/business.d.ts +13 -0
- package/dist/adapters/business.js +14 -0
- package/dist/adapters/connectivity.d.ts +17 -0
- package/dist/adapters/connectivity.js +38 -0
- package/dist/adapters/devices.d.ts +5 -0
- package/dist/adapters/devices.js +14 -0
- package/dist/adapters/http.d.ts +30 -0
- package/dist/adapters/http.js +37 -0
- package/dist/adapters/image-cache.d.ts +8 -0
- package/dist/adapters/image-cache.js +12 -0
- package/dist/adapters/kv-types.d.ts +5 -0
- package/dist/adapters/kv-types.js +2 -0
- package/dist/adapters/kv.d.ts +20 -0
- package/dist/adapters/kv.js +81 -0
- package/dist/adapters/local-node.d.ts +34 -0
- package/dist/adapters/local-node.js +40 -0
- package/dist/adapters/pending-orders.d.ts +17 -0
- package/dist/adapters/pending-orders.js +31 -0
- package/dist/adapters/query-keys.d.ts +17 -0
- package/dist/adapters/query-keys.js +20 -0
- package/dist/adapters/services.d.ts +62 -0
- package/dist/adapters/services.js +95 -0
- package/dist/adapters/sqlite.d.ts +20 -0
- package/dist/adapters/sqlite.js +23 -0
- package/dist/db/connection.d.ts +8 -0
- package/dist/db/connection.js +87 -0
- package/dist/db/migrations.d.ts +9 -0
- package/dist/db/migrations.js +106 -0
- package/dist/db/operations.d.ts +45 -0
- package/dist/db/operations.js +192 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +118 -0
- package/dist/reference/api.d.ts +10 -0
- package/dist/reference/api.js +24 -0
- package/dist/reference/events.d.ts +54 -0
- package/dist/reference/events.js +144 -0
- package/dist/reference/operations.d.ts +11 -0
- package/dist/reference/operations.js +46 -0
- package/dist/reference/service.d.ts +27 -0
- package/dist/reference/service.js +270 -0
- package/dist/reference/types.d.ts +47 -0
- package/dist/reference/types.js +12 -0
- package/dist/repositories/order.repository.d.ts +44 -0
- package/dist/repositories/order.repository.js +132 -0
- package/dist/repositories/pos.repository.d.ts +20 -0
- package/dist/repositories/pos.repository.js +28 -0
- package/dist/sync/command-builder.d.ts +17 -0
- package/dist/sync/command-builder.js +30 -0
- package/dist/sync/command-queue.service.d.ts +41 -0
- package/dist/sync/command-queue.service.js +253 -0
- package/dist/sync/engine.d.ts +53 -0
- package/dist/sync/engine.js +216 -0
- package/dist/sync/order-number.d.ts +20 -0
- package/dist/sync/order-number.js +58 -0
- package/dist/sync/order-snapshots.service.d.ts +46 -0
- package/dist/sync/order-snapshots.service.js +151 -0
- package/dist/sync/pending-orders.service.d.ts +45 -0
- package/dist/sync/pending-orders.service.js +163 -0
- package/dist/sync/transport-local-node.d.ts +14 -0
- package/dist/sync/transport-local-node.js +67 -0
- package/dist/sync/transport-rest.d.ts +17 -0
- package/dist/sync/transport-rest.js +112 -0
- package/dist/sync/types.d.ts +102 -0
- package/dist/sync/types.js +13 -0
- package/dist/sync/use-sync-reconciliation.d.ts +10 -0
- package/dist/sync/use-sync-reconciliation.js +106 -0
- package/dist/types/api/auth.api.d.ts +61 -0
- package/dist/types/api/auth.api.js +3 -0
- package/dist/types/api/business.api.d.ts +137 -0
- package/dist/types/api/business.api.js +3 -0
- package/dist/types/api/conversation.api.d.ts +46 -0
- package/dist/types/api/conversation.api.js +2 -0
- package/dist/types/api/error.api.d.ts +8 -0
- package/dist/types/api/error.api.js +2 -0
- package/dist/types/api/fleet.api.d.ts +98 -0
- package/dist/types/api/fleet.api.js +3 -0
- package/dist/types/api/index.d.ts +11 -0
- package/dist/types/api/index.js +27 -0
- package/dist/types/api/kiosk.api.d.ts +4 -0
- package/dist/types/api/kiosk.api.js +2 -0
- package/dist/types/api/menu.api.d.ts +90 -0
- package/dist/types/api/menu.api.js +2 -0
- package/dist/types/api/pagination.d.ts +10 -0
- package/dist/types/api/pagination.js +2 -0
- package/dist/types/api/rider.api.d.ts +179 -0
- package/dist/types/api/rider.api.js +2 -0
- package/dist/types/api/seller.api.d.ts +164 -0
- package/dist/types/api/seller.api.js +2 -0
- package/dist/types/api/wallet.api.d.ts +27 -0
- package/dist/types/api/wallet.api.js +2 -0
- package/dist/types/pos.types.d.ts +33 -0
- package/dist/types/pos.types.js +5 -0
- package/dist/utils/constants.d.ts +85 -0
- package/dist/utils/constants.js +72 -0
- package/dist/utils/copyToClipBoard.d.ts +5 -0
- package/dist/utils/copyToClipBoard.js +23 -0
- package/dist/utils/currency.d.ts +9 -0
- package/dist/utils/currency.js +37 -0
- package/dist/utils/error.d.ts +4 -0
- package/dist/utils/error.js +73 -0
- package/dist/utils/formatCurrency.d.ts +4 -0
- package/dist/utils/formatCurrency.js +16 -0
- package/dist/utils/formatDate.d.ts +8 -0
- package/dist/utils/formatDate.js +42 -0
- package/dist/utils/phone.d.ts +10 -0
- package/dist/utils/phone.js +71 -0
- package/dist/utils/responsive.d.ts +13 -0
- package/dist/utils/responsive.js +21 -0
- package/dist/utils/storage.d.ts +15 -0
- package/dist/utils/storage.js +60 -0
- package/dist/utils/sync-logger.d.ts +22 -0
- package/dist/utils/sync-logger.js +79 -0
- package/dist/utils/uuid.d.ts +7 -0
- package/dist/utils/uuid.js +47 -0
- package/dist/utils/validation.d.ts +31 -0
- package/dist/utils/validation.js +170 -0
- package/package.json +49 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Command Queue Service.
|
|
4
|
+
*
|
|
5
|
+
* Durable SQLite outbox for commands that cannot be executed immediately.
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* - Persist commands when the cloud and Local Node are both unavailable.
|
|
8
|
+
* - Generate local order numbers for offline `order:create` commands.
|
|
9
|
+
* - Replay queued commands when a transport becomes available.
|
|
10
|
+
* - Provide optimistic domain events so the UI can render pending orders.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.commandQueueService = void 0;
|
|
14
|
+
const connection_1 = require("../db/connection");
|
|
15
|
+
const operations_1 = require("../db/operations");
|
|
16
|
+
const sync_logger_1 = require("../utils/sync-logger");
|
|
17
|
+
const uuid_1 = require("../utils/uuid");
|
|
18
|
+
const order_number_1 = require("./order-number");
|
|
19
|
+
const MAX_RETRIES = 5;
|
|
20
|
+
function buildOptimisticEvent(command) {
|
|
21
|
+
const payload = command.payload;
|
|
22
|
+
return {
|
|
23
|
+
event_id: (0, uuid_1.generateEventId)(),
|
|
24
|
+
event_type: `${command.command_type.replace(":", ".")}_queued`,
|
|
25
|
+
event_version: "1.0",
|
|
26
|
+
entity_id: command.entity_id ?? command.command_id,
|
|
27
|
+
user_id: command.user_id,
|
|
28
|
+
device_id: command.device_id,
|
|
29
|
+
payload: command.command_type === "order:create"
|
|
30
|
+
? buildOptimisticOrder(command, payload)
|
|
31
|
+
: {
|
|
32
|
+
...payload,
|
|
33
|
+
command_id: command.command_id,
|
|
34
|
+
status: "pending",
|
|
35
|
+
},
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function buildOptimisticOrder(command, payload) {
|
|
40
|
+
const items = payload.items ?? [];
|
|
41
|
+
const totalMinor = payload.total_minor ?? 0;
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const orderNumber = payload.order_number ?? (0, order_number_1.generateLocalOrderNumber)();
|
|
44
|
+
return {
|
|
45
|
+
id: command.entity_id ?? command.command_id,
|
|
46
|
+
order_number: orderNumber,
|
|
47
|
+
callout_number: (0, order_number_1.generateLocalCalloutNumber)(),
|
|
48
|
+
order_type: payload.order_type ?? "takeout",
|
|
49
|
+
is_pos: true,
|
|
50
|
+
customer_name: payload.customer_name ?? null,
|
|
51
|
+
customer_phone: payload.customer_phone ?? null,
|
|
52
|
+
pos_payment_method: payload.pos_payment_method ?? "cash",
|
|
53
|
+
paid_at: null,
|
|
54
|
+
status: "pending",
|
|
55
|
+
delivery_id: null,
|
|
56
|
+
delivery_status: null,
|
|
57
|
+
is_paid: payload.pos_payment_method === "cash",
|
|
58
|
+
payment: {
|
|
59
|
+
is_paid: payload.pos_payment_method === "cash",
|
|
60
|
+
reference: null,
|
|
61
|
+
amount_minor: totalMinor,
|
|
62
|
+
currency: "GHS",
|
|
63
|
+
paid_at: payload.pos_payment_method === "cash" ? now : null,
|
|
64
|
+
},
|
|
65
|
+
payment_mode: "pos_cash",
|
|
66
|
+
total_minor: totalMinor,
|
|
67
|
+
subtotal_minor: totalMinor,
|
|
68
|
+
delivery_fee_minor: 0,
|
|
69
|
+
tax_minor: 0,
|
|
70
|
+
tip_minor: null,
|
|
71
|
+
discount_minor: 0,
|
|
72
|
+
promo_code: null,
|
|
73
|
+
tax_lines: [],
|
|
74
|
+
currency: "GHS",
|
|
75
|
+
currency_code: "GHS",
|
|
76
|
+
business_id: command.business_id,
|
|
77
|
+
business: null,
|
|
78
|
+
delivery_address: null,
|
|
79
|
+
delivery_lat: null,
|
|
80
|
+
delivery_lng: null,
|
|
81
|
+
user: null,
|
|
82
|
+
items: items.map((item, index) => ({
|
|
83
|
+
id: `tmp-${command.command_id}-${index}`,
|
|
84
|
+
menu_item: { id: item.menu_item_id, name: "" },
|
|
85
|
+
quantity: item.quantity,
|
|
86
|
+
unit_price: 0,
|
|
87
|
+
subtotal: 0,
|
|
88
|
+
addons: [],
|
|
89
|
+
})),
|
|
90
|
+
inserted_at: now,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* If a command creates an order and we are offline, inject a local order number
|
|
95
|
+
* into the payload so the UI and receipt printers have a stable identifier.
|
|
96
|
+
*/
|
|
97
|
+
function injectLocalOrderNumber(command) {
|
|
98
|
+
if (command.command_type !== "order:create")
|
|
99
|
+
return command;
|
|
100
|
+
const payload = command.payload;
|
|
101
|
+
if (payload.order_number)
|
|
102
|
+
return command;
|
|
103
|
+
return {
|
|
104
|
+
...command,
|
|
105
|
+
payload: {
|
|
106
|
+
...payload,
|
|
107
|
+
order_number: (0, order_number_1.generateLocalOrderNumber)(),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
class CommandQueueService {
|
|
112
|
+
constructor() {
|
|
113
|
+
this.replaying = false;
|
|
114
|
+
this.syncEngine = null;
|
|
115
|
+
}
|
|
116
|
+
setSyncEngine(engine) {
|
|
117
|
+
this.syncEngine = engine;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Enqueue a command because no transport is currently available.
|
|
121
|
+
* Returns an optimistic domain event so the caller can update the UI.
|
|
122
|
+
*/
|
|
123
|
+
async enqueue(command) {
|
|
124
|
+
const db = (0, connection_1.getSyncDatabase)();
|
|
125
|
+
const preparedCommand = injectLocalOrderNumber(command);
|
|
126
|
+
await (0, operations_1.insertCommand)(db, { command: preparedCommand, status: "pending" });
|
|
127
|
+
sync_logger_1.syncLogger.command(preparedCommand, "queued");
|
|
128
|
+
const pending = await (0, operations_1.getPendingCommands)(db, Number.MAX_SAFE_INTEGER);
|
|
129
|
+
sync_logger_1.syncLogger.info("CommandQueueService", "Command queued offline", {
|
|
130
|
+
command_id: preparedCommand.command_id,
|
|
131
|
+
command_type: preparedCommand.command_type,
|
|
132
|
+
order_number: preparedCommand.payload.order_number ?? null,
|
|
133
|
+
outbox_size: pending.length,
|
|
134
|
+
});
|
|
135
|
+
const event = buildOptimisticEvent(preparedCommand);
|
|
136
|
+
sync_logger_1.syncLogger.info("CommandQueueService", "Optimistic event built", {
|
|
137
|
+
event_type: event.event_type,
|
|
138
|
+
entity_id: event.entity_id,
|
|
139
|
+
order_number: event.payload.order_number ?? null,
|
|
140
|
+
status: event.payload.status,
|
|
141
|
+
});
|
|
142
|
+
// Persist the optimistic order so it survives app restarts and can be
|
|
143
|
+
// rehydrated (and shown) while still offline.
|
|
144
|
+
if (preparedCommand.command_type === "order:create") {
|
|
145
|
+
try {
|
|
146
|
+
await (0, operations_1.insertEvent)(db, { event, origin: "local" });
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
sync_logger_1.syncLogger.error("CommandQueueService", "Failed to persist optimistic event", {
|
|
150
|
+
error: String(error),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return event;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Try to execute a single pending command through the current transport.
|
|
158
|
+
*/
|
|
159
|
+
async processCommand(command) {
|
|
160
|
+
const db = (0, connection_1.getSyncDatabase)();
|
|
161
|
+
const parsedCommand = {
|
|
162
|
+
command_id: command.command_id,
|
|
163
|
+
command_type: command.command_type,
|
|
164
|
+
command_version: command.command_version,
|
|
165
|
+
business_id: command.business_id,
|
|
166
|
+
entity_id: command.entity_id,
|
|
167
|
+
user_id: command.user_id,
|
|
168
|
+
device_id: command.device_id,
|
|
169
|
+
timestamp: command.timestamp,
|
|
170
|
+
payload: command.payload,
|
|
171
|
+
};
|
|
172
|
+
if (command.retry_count >= MAX_RETRIES) {
|
|
173
|
+
await (0, operations_1.updateCommandStatus)(db, command.command_id, "failed", "Max retry count exceeded");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!this.syncEngine) {
|
|
177
|
+
throw new Error("CommandQueueService: SyncEngine not initialized");
|
|
178
|
+
}
|
|
179
|
+
await (0, operations_1.markCommandInFlight)(db, command.command_id);
|
|
180
|
+
try {
|
|
181
|
+
const events = await this.syncEngine.execute(parsedCommand);
|
|
182
|
+
await (0, operations_1.updateCommandStatus)(db, command.command_id, "completed");
|
|
183
|
+
sync_logger_1.syncLogger.command(parsedCommand, "completed", {
|
|
184
|
+
event_count: events.length,
|
|
185
|
+
});
|
|
186
|
+
sync_logger_1.syncLogger.info("CommandQueueService", "Queued command replayed", {
|
|
187
|
+
command_id: command.command_id,
|
|
188
|
+
command_type: command.command_type,
|
|
189
|
+
event_count: events.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
await (0, operations_1.updateCommandStatus)(db, command.command_id, "failed", message);
|
|
195
|
+
sync_logger_1.syncLogger.command(parsedCommand, "failed", { error: message });
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Replay all pending commands in order. Stops on the first failure to keep
|
|
201
|
+
* ordering consistent; the next replay will retry from the failed command.
|
|
202
|
+
*/
|
|
203
|
+
async replay() {
|
|
204
|
+
if (this.replaying) {
|
|
205
|
+
sync_logger_1.syncLogger.debug("CommandQueueService", "Replay already in progress");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.replaying = true;
|
|
209
|
+
try {
|
|
210
|
+
// Await open (not getSyncDatabase) so a cold-start replay triggered by a
|
|
211
|
+
// connectivity event that fires before the DB open completes does not
|
|
212
|
+
// throw "Sync database not initialized".
|
|
213
|
+
const db = await (0, connection_1.openSyncDatabase)();
|
|
214
|
+
const pending = await (0, operations_1.getPendingCommands)(db);
|
|
215
|
+
if (pending.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
sync_logger_1.syncLogger.info("CommandQueueService", "Replaying commands", {
|
|
218
|
+
count: pending.length,
|
|
219
|
+
});
|
|
220
|
+
for (const command of pending) {
|
|
221
|
+
try {
|
|
222
|
+
await this.processCommand(command);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Stop replay on first failure; retry will happen on next trigger.
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
this.replaying = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Cancel a queued command that has not yet been transmitted.
|
|
236
|
+
*/
|
|
237
|
+
async cancel(commandId) {
|
|
238
|
+
const db = (0, connection_1.getSyncDatabase)();
|
|
239
|
+
await (0, operations_1.deleteCommand)(db, commandId);
|
|
240
|
+
sync_logger_1.syncLogger.debug("CommandQueueService", "Command cancelled", {
|
|
241
|
+
commandId,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Return the number of commands currently in the outbox.
|
|
246
|
+
*/
|
|
247
|
+
async getPendingCount() {
|
|
248
|
+
const db = (0, connection_1.getSyncDatabase)();
|
|
249
|
+
const commands = await (0, operations_1.getPendingCommands)(db, Number.MAX_SAFE_INTEGER);
|
|
250
|
+
return commands.length;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
exports.commandQueueService = new CommandQueueService();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Engine orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 responsibility: receive commands from repositories and route them
|
|
5
|
+
* to the active transport (currently REST only).
|
|
6
|
+
*
|
|
7
|
+
* Future phases will add:
|
|
8
|
+
* - connectivity-aware transport selection
|
|
9
|
+
* - command outbox persistence
|
|
10
|
+
* - replay
|
|
11
|
+
* - Local Node transport
|
|
12
|
+
* - cloud reconciliation
|
|
13
|
+
*/
|
|
14
|
+
import type { Command, DomainEvent, Transport } from "./types";
|
|
15
|
+
interface SyncEngineOptions {
|
|
16
|
+
/** Ordered list of transports the engine may use. */
|
|
17
|
+
transports: Transport[];
|
|
18
|
+
}
|
|
19
|
+
export declare class SyncEngine {
|
|
20
|
+
private transports;
|
|
21
|
+
private eventSubscribers;
|
|
22
|
+
constructor(options: SyncEngineOptions);
|
|
23
|
+
/**
|
|
24
|
+
* Execute a command through the appropriate transport.
|
|
25
|
+
*
|
|
26
|
+
* Transport selection:
|
|
27
|
+
* - Cloud reachable and REST transport available → REST
|
|
28
|
+
* - Local Node enabled and available → LocalNodeTransport
|
|
29
|
+
* - Otherwise queue locally and return an optimistic event
|
|
30
|
+
*
|
|
31
|
+
* Cloud commands are raced against CLOUD_COMMAND_TIMEOUT_MS so a slow
|
|
32
|
+
* internet connection falls back to the Local Node (or the offline outbox)
|
|
33
|
+
* quickly instead of blocking the terminal for the full HTTP timeout.
|
|
34
|
+
*/
|
|
35
|
+
execute(command: Command): Promise<DomainEvent[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Trigger replay of queued commands when connectivity returns.
|
|
38
|
+
*/
|
|
39
|
+
replayQueuedCommands(): Promise<void>;
|
|
40
|
+
subscribe(handler: (event: DomainEvent) => void): () => void;
|
|
41
|
+
/**
|
|
42
|
+
* Publish a notification event to local subscribers without a transport
|
|
43
|
+
* round-trip. Used for cross-device notifications (e.g. reference data
|
|
44
|
+
* changed) that are not backed by a command: the event is broadcast locally
|
|
45
|
+
* so the reconciliation bridge can relay it to LAN clients.
|
|
46
|
+
*/
|
|
47
|
+
publish(event: DomainEvent): void;
|
|
48
|
+
private resolveStrategy;
|
|
49
|
+
private selectTransport;
|
|
50
|
+
private broadcast;
|
|
51
|
+
}
|
|
52
|
+
export declare const syncEngine: SyncEngine;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Sync Engine orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1 responsibility: receive commands from repositories and route them
|
|
6
|
+
* to the active transport (currently REST only).
|
|
7
|
+
*
|
|
8
|
+
* Future phases will add:
|
|
9
|
+
* - connectivity-aware transport selection
|
|
10
|
+
* - command outbox persistence
|
|
11
|
+
* - replay
|
|
12
|
+
* - Local Node transport
|
|
13
|
+
* - cloud reconciliation
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.syncEngine = exports.SyncEngine = void 0;
|
|
17
|
+
const transport_rest_1 = require("./transport-rest");
|
|
18
|
+
const transport_local_node_1 = require("./transport-local-node");
|
|
19
|
+
const command_queue_service_1 = require("./command-queue.service");
|
|
20
|
+
const connectivity_1 = require("../adapters/connectivity");
|
|
21
|
+
const http_1 = require("../adapters/http");
|
|
22
|
+
const sync_logger_1 = require("../utils/sync-logger");
|
|
23
|
+
const constants_1 = require("../utils/constants");
|
|
24
|
+
function withTimeout(promise, ms) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
reject(new Error(`Timed out after ${ms}ms`));
|
|
28
|
+
}, ms);
|
|
29
|
+
promise.then((value) => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve(value);
|
|
32
|
+
}, (error) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(error);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
class SyncEngine {
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.eventSubscribers = [];
|
|
41
|
+
this.transports = options.transports;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Execute a command through the appropriate transport.
|
|
45
|
+
*
|
|
46
|
+
* Transport selection:
|
|
47
|
+
* - Cloud reachable and REST transport available → REST
|
|
48
|
+
* - Local Node enabled and available → LocalNodeTransport
|
|
49
|
+
* - Otherwise queue locally and return an optimistic event
|
|
50
|
+
*
|
|
51
|
+
* Cloud commands are raced against CLOUD_COMMAND_TIMEOUT_MS so a slow
|
|
52
|
+
* internet connection falls back to the Local Node (or the offline outbox)
|
|
53
|
+
* quickly instead of blocking the terminal for the full HTTP timeout.
|
|
54
|
+
*/
|
|
55
|
+
async execute(command) {
|
|
56
|
+
sync_logger_1.syncLogger.command(command, "issued");
|
|
57
|
+
let strategy = this.resolveStrategy(command);
|
|
58
|
+
const { isCloudReachable, isOnline } = (0, connectivity_1.getConnectivityState)();
|
|
59
|
+
sync_logger_1.syncLogger.info("SyncEngine", "Command strategy resolved", {
|
|
60
|
+
command_id: command.command_id,
|
|
61
|
+
command_type: command.command_type,
|
|
62
|
+
strategy,
|
|
63
|
+
isCloudReachable,
|
|
64
|
+
isOnline,
|
|
65
|
+
cloud_available: transport_rest_1.restTransport.isAvailable(),
|
|
66
|
+
lan_available: transport_local_node_1.localNodeTransport.isAvailable(),
|
|
67
|
+
});
|
|
68
|
+
if (strategy === "queue") {
|
|
69
|
+
const event = await command_queue_service_1.commandQueueService.enqueue(command);
|
|
70
|
+
this.broadcast(event);
|
|
71
|
+
return [event];
|
|
72
|
+
}
|
|
73
|
+
if (strategy === "cloud") {
|
|
74
|
+
try {
|
|
75
|
+
const events = await withTimeout(transport_rest_1.restTransport.execute(command), constants_1.CLOUD_COMMAND_TIMEOUT_MS);
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
this.broadcast(event);
|
|
78
|
+
}
|
|
79
|
+
sync_logger_1.syncLogger.command(command, "completed", {
|
|
80
|
+
event_count: events.length,
|
|
81
|
+
});
|
|
82
|
+
// Cloud just worked: it is reachable again. Mark it so future commands
|
|
83
|
+
// go cloud-first, and replay anything queued during the outage.
|
|
84
|
+
(0, connectivity_1.setConnectivityState)({ isCloudReachable: "reachable" });
|
|
85
|
+
this.replayQueuedCommands().catch((error) => {
|
|
86
|
+
sync_logger_1.syncLogger.error("SyncEngine", "Replay after cloud success failed", {
|
|
87
|
+
error: String(error),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// Definitive server rejection (4xx): the backend answered with a real
|
|
94
|
+
// decision (e.g. PAYMENT_REQUIRED, FORBIDDEN). Surface it to the
|
|
95
|
+
// caller instead of masking it as a connectivity problem and silently
|
|
96
|
+
// queueing a command that can never succeed.
|
|
97
|
+
if (error instanceof http_1.ApiError && error.status >= 400 && error.status < 500) {
|
|
98
|
+
sync_logger_1.syncLogger.warn("SyncEngine", "Cloud command rejected by server", {
|
|
99
|
+
command_type: command.command_type,
|
|
100
|
+
command_id: command.command_id,
|
|
101
|
+
status: error.status,
|
|
102
|
+
message: error.message,
|
|
103
|
+
});
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
// Cloud is slow or unreachable: mark it and fall back to the Local
|
|
107
|
+
// Node or the outbox so the terminal stays responsive.
|
|
108
|
+
sync_logger_1.syncLogger.warn("SyncEngine", "Cloud command timed out, falling back", {
|
|
109
|
+
command_type: command.command_type,
|
|
110
|
+
error: String(error),
|
|
111
|
+
});
|
|
112
|
+
(0, connectivity_1.setConnectivityState)({ isCloudReachable: "unreachable" });
|
|
113
|
+
strategy = this.resolveStrategy(command);
|
|
114
|
+
if (strategy === "queue") {
|
|
115
|
+
const event = await command_queue_service_1.commandQueueService.enqueue(command);
|
|
116
|
+
this.broadcast(event);
|
|
117
|
+
return [event];
|
|
118
|
+
}
|
|
119
|
+
sync_logger_1.syncLogger.info("SyncEngine", "Cloud timed out, using LAN fallback", {
|
|
120
|
+
command_id: command.command_id,
|
|
121
|
+
command_type: command.command_type,
|
|
122
|
+
strategy,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const transport = this.selectTransport(command, strategy);
|
|
127
|
+
sync_logger_1.syncLogger.info("SyncEngine", "Executing via transport", {
|
|
128
|
+
command_id: command.command_id,
|
|
129
|
+
command_type: command.command_type,
|
|
130
|
+
strategy,
|
|
131
|
+
transport: transport.name,
|
|
132
|
+
});
|
|
133
|
+
const events = await transport.execute(command);
|
|
134
|
+
for (const event of events) {
|
|
135
|
+
this.broadcast(event);
|
|
136
|
+
}
|
|
137
|
+
sync_logger_1.syncLogger.command(command, "completed", {
|
|
138
|
+
event_count: events.length,
|
|
139
|
+
transport: transport.name,
|
|
140
|
+
});
|
|
141
|
+
return events;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Trigger replay of queued commands when connectivity returns.
|
|
145
|
+
*/
|
|
146
|
+
async replayQueuedCommands() {
|
|
147
|
+
await command_queue_service_1.commandQueueService.replay();
|
|
148
|
+
}
|
|
149
|
+
subscribe(handler) {
|
|
150
|
+
this.eventSubscribers.push(handler);
|
|
151
|
+
return () => {
|
|
152
|
+
const index = this.eventSubscribers.indexOf(handler);
|
|
153
|
+
if (index > -1) {
|
|
154
|
+
this.eventSubscribers.splice(index, 1);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Publish a notification event to local subscribers without a transport
|
|
160
|
+
* round-trip. Used for cross-device notifications (e.g. reference data
|
|
161
|
+
* changed) that are not backed by a command: the event is broadcast locally
|
|
162
|
+
* so the reconciliation bridge can relay it to LAN clients.
|
|
163
|
+
*/
|
|
164
|
+
publish(event) {
|
|
165
|
+
this.broadcast(event);
|
|
166
|
+
}
|
|
167
|
+
resolveStrategy(_command) {
|
|
168
|
+
const { isCloudReachable, isOnline } = (0, connectivity_1.getConnectivityState)();
|
|
169
|
+
// Cloud-first when we believe it is reachable OR we have not yet verified
|
|
170
|
+
// it (unknown). Only a confirmed "unreachable" status falls back to the
|
|
171
|
+
// Local Node / outbox. Commands issued on a healthy connection must never
|
|
172
|
+
// get stuck in the local outbox.
|
|
173
|
+
if ((isCloudReachable === "reachable" || isCloudReachable === "unknown") &&
|
|
174
|
+
transport_rest_1.restTransport.isAvailable()) {
|
|
175
|
+
return "cloud";
|
|
176
|
+
}
|
|
177
|
+
// Local Node fallback when enabled and connected.
|
|
178
|
+
if (transport_local_node_1.localNodeTransport.isAvailable()) {
|
|
179
|
+
return "local_node";
|
|
180
|
+
}
|
|
181
|
+
// No network at all: queue immediately.
|
|
182
|
+
if (!isOnline) {
|
|
183
|
+
return "queue";
|
|
184
|
+
}
|
|
185
|
+
// Network present but cloud status unknown or unreachable. Try cloud first;
|
|
186
|
+
// a failed HTTP call will bubble up and the caller (e.g. queue replay) can
|
|
187
|
+
// decide to retry. New commands issued while cloud is unknown are queued to
|
|
188
|
+
// avoid blocking the UI on a flaky connection.
|
|
189
|
+
if (isCloudReachable === "unreachable") {
|
|
190
|
+
return "queue";
|
|
191
|
+
}
|
|
192
|
+
return "cloud";
|
|
193
|
+
}
|
|
194
|
+
selectTransport(_command, strategy) {
|
|
195
|
+
if (strategy === "local_node" && transport_local_node_1.localNodeTransport.isAvailable()) {
|
|
196
|
+
return transport_local_node_1.localNodeTransport;
|
|
197
|
+
}
|
|
198
|
+
if (strategy === "cloud" && transport_rest_1.restTransport.isAvailable()) {
|
|
199
|
+
return transport_rest_1.restTransport;
|
|
200
|
+
}
|
|
201
|
+
const transport = this.transports.find((t) => t.isAvailable()) ?? this.transports[0];
|
|
202
|
+
if (!transport) {
|
|
203
|
+
throw new Error("SyncEngine: no transports configured");
|
|
204
|
+
}
|
|
205
|
+
return transport;
|
|
206
|
+
}
|
|
207
|
+
broadcast(event) {
|
|
208
|
+
sync_logger_1.syncLogger.event(event, "broadcast");
|
|
209
|
+
this.eventSubscribers.forEach((handler) => handler(event));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
exports.SyncEngine = SyncEngine;
|
|
213
|
+
exports.syncEngine = new SyncEngine({
|
|
214
|
+
transports: [transport_rest_1.restTransport, transport_local_node_1.localNodeTransport],
|
|
215
|
+
});
|
|
216
|
+
command_queue_service_1.commandQueueService.setSyncEngine(exports.syncEngine);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local order number allocation for offline/LAN operation.
|
|
3
|
+
*
|
|
4
|
+
* When the cloud is unreachable, the Local Node (or the local device if there
|
|
5
|
+
* is no Local Node) assigns temporary order numbers with the prefix
|
|
6
|
+
* `TFL-YYYYMMDD-NNNN`. These are reconciled with permanent cloud order numbers
|
|
7
|
+
* once connectivity returns.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Generate a local order number for offline-created orders.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateLocalOrderNumber(date?: Date): string;
|
|
13
|
+
/**
|
|
14
|
+
* Generate a local callout number for offline-created orders.
|
|
15
|
+
*/
|
|
16
|
+
export declare function generateLocalCalloutNumber(date?: Date): number;
|
|
17
|
+
/**
|
|
18
|
+
* Check whether an order number was locally allocated.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isLocalOrderNumber(orderNumber: string): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local order number allocation for offline/LAN operation.
|
|
4
|
+
*
|
|
5
|
+
* When the cloud is unreachable, the Local Node (or the local device if there
|
|
6
|
+
* is no Local Node) assigns temporary order numbers with the prefix
|
|
7
|
+
* `TFL-YYYYMMDD-NNNN`. These are reconciled with permanent cloud order numbers
|
|
8
|
+
* once connectivity returns.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.generateLocalOrderNumber = generateLocalOrderNumber;
|
|
12
|
+
exports.generateLocalCalloutNumber = generateLocalCalloutNumber;
|
|
13
|
+
exports.isLocalOrderNumber = isLocalOrderNumber;
|
|
14
|
+
const kv_1 = require("../adapters/kv");
|
|
15
|
+
const LOCAL_ORDER_SEQ_KEY = "local_order_sequence";
|
|
16
|
+
const LOCAL_CALLOUT_SEQ_KEY = "local_callout_sequence";
|
|
17
|
+
function formatDatePart(date) {
|
|
18
|
+
const year = date.getFullYear();
|
|
19
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
20
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
21
|
+
return `${year}${month}${day}`;
|
|
22
|
+
}
|
|
23
|
+
function getNextSequence(datePart, key) {
|
|
24
|
+
const stored = (0, kv_1.getItemSync)(key);
|
|
25
|
+
const map = stored ?? {};
|
|
26
|
+
const next = (map[datePart] ?? 0) + 1;
|
|
27
|
+
map[datePart] = next;
|
|
28
|
+
// Keep only the last 7 days to avoid unbounded growth.
|
|
29
|
+
const cutoff = String(Number(datePart) - 7);
|
|
30
|
+
for (const mapKey of Object.keys(map)) {
|
|
31
|
+
if (mapKey < cutoff) {
|
|
32
|
+
delete map[mapKey];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
(0, kv_1.setItemSync)(key, map);
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Generate a local order number for offline-created orders.
|
|
40
|
+
*/
|
|
41
|
+
function generateLocalOrderNumber(date = new Date()) {
|
|
42
|
+
const datePart = formatDatePart(date);
|
|
43
|
+
const seq = getNextSequence(datePart, LOCAL_ORDER_SEQ_KEY);
|
|
44
|
+
return `TFL-${datePart}-${String(seq).padStart(4, "0")}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Generate a local callout number for offline-created orders.
|
|
48
|
+
*/
|
|
49
|
+
function generateLocalCalloutNumber(date = new Date()) {
|
|
50
|
+
const datePart = formatDatePart(date);
|
|
51
|
+
return getNextSequence(datePart, LOCAL_CALLOUT_SEQ_KEY);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check whether an order number was locally allocated.
|
|
55
|
+
*/
|
|
56
|
+
function isLocalOrderNumber(orderNumber) {
|
|
57
|
+
return orderNumber.startsWith("TFL-");
|
|
58
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable order read-model.
|
|
3
|
+
*
|
|
4
|
+
* Bridges order events and fetched order lists into the SQLite
|
|
5
|
+
* `entity_snapshots` read model so a restaurant can see current + recent
|
|
6
|
+
* orders offline (survives restart and logout) and refresh them once a
|
|
7
|
+
* connection is re-established.
|
|
8
|
+
*
|
|
9
|
+
* Two sources feed the read model:
|
|
10
|
+
* 1. Incoming order/delivery events (cloud WebSocket or LAN relay) are
|
|
11
|
+
* persisted to the events journal and used to patch the latest status of
|
|
12
|
+
* a known order.
|
|
13
|
+
* 2. Successful order list fetches are written through as snapshots so the
|
|
14
|
+
* exact list last seen while online is available offline.
|
|
15
|
+
*/
|
|
16
|
+
import type { DomainEvent } from "./types";
|
|
17
|
+
import type { SellerOrder } from "../types/api/seller.api";
|
|
18
|
+
declare class OrderSnapshotsService {
|
|
19
|
+
/**
|
|
20
|
+
* Persist an order/delivery event into the durable event journal and patch
|
|
21
|
+
* the read model. Returns true if it was an order event (idempotent for
|
|
22
|
+
* duplicates).
|
|
23
|
+
*/
|
|
24
|
+
persistOrderEvent(event: DomainEvent, origin: "local" | "lan" | "cloud"): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Patch the latest status of a known order inside every list snapshot for the
|
|
27
|
+
* business, so offline devices see live status changes without a refetch.
|
|
28
|
+
*/
|
|
29
|
+
private applyOrderStatusToReadModel;
|
|
30
|
+
/**
|
|
31
|
+
* Write through a fetched order list so it is available offline. Used by the
|
|
32
|
+
* seller and POS order queries. `scope` distinguishes complementary lists
|
|
33
|
+
* (e.g. the unfiltered recent list) within a business.
|
|
34
|
+
*/
|
|
35
|
+
persistSellerOrders(businessId: string, scope: string, orders: SellerOrder[]): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Read a persisted order list from the offline read model.
|
|
38
|
+
*/
|
|
39
|
+
getSellerList(businessId: string, scope: string): Promise<SellerOrder[] | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Prune snapshots outside the retention window. Call on startup.
|
|
42
|
+
*/
|
|
43
|
+
pruneOld(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export declare const orderSnapshotsService: OrderSnapshotsService;
|
|
46
|
+
export {};
|