@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.
Files changed (120) hide show
  1. package/README.md +44 -0
  2. package/dist/adapters/auth.d.ts +13 -0
  3. package/dist/adapters/auth.js +14 -0
  4. package/dist/adapters/business.d.ts +13 -0
  5. package/dist/adapters/business.js +14 -0
  6. package/dist/adapters/connectivity.d.ts +17 -0
  7. package/dist/adapters/connectivity.js +38 -0
  8. package/dist/adapters/devices.d.ts +5 -0
  9. package/dist/adapters/devices.js +14 -0
  10. package/dist/adapters/http.d.ts +30 -0
  11. package/dist/adapters/http.js +37 -0
  12. package/dist/adapters/image-cache.d.ts +8 -0
  13. package/dist/adapters/image-cache.js +12 -0
  14. package/dist/adapters/kv-types.d.ts +5 -0
  15. package/dist/adapters/kv-types.js +2 -0
  16. package/dist/adapters/kv.d.ts +20 -0
  17. package/dist/adapters/kv.js +81 -0
  18. package/dist/adapters/local-node.d.ts +34 -0
  19. package/dist/adapters/local-node.js +40 -0
  20. package/dist/adapters/pending-orders.d.ts +17 -0
  21. package/dist/adapters/pending-orders.js +31 -0
  22. package/dist/adapters/query-keys.d.ts +17 -0
  23. package/dist/adapters/query-keys.js +20 -0
  24. package/dist/adapters/services.d.ts +62 -0
  25. package/dist/adapters/services.js +95 -0
  26. package/dist/adapters/sqlite.d.ts +20 -0
  27. package/dist/adapters/sqlite.js +23 -0
  28. package/dist/db/connection.d.ts +8 -0
  29. package/dist/db/connection.js +87 -0
  30. package/dist/db/migrations.d.ts +9 -0
  31. package/dist/db/migrations.js +106 -0
  32. package/dist/db/operations.d.ts +45 -0
  33. package/dist/db/operations.js +192 -0
  34. package/dist/index.d.ts +50 -0
  35. package/dist/index.js +118 -0
  36. package/dist/reference/api.d.ts +10 -0
  37. package/dist/reference/api.js +24 -0
  38. package/dist/reference/events.d.ts +54 -0
  39. package/dist/reference/events.js +144 -0
  40. package/dist/reference/operations.d.ts +11 -0
  41. package/dist/reference/operations.js +46 -0
  42. package/dist/reference/service.d.ts +27 -0
  43. package/dist/reference/service.js +270 -0
  44. package/dist/reference/types.d.ts +47 -0
  45. package/dist/reference/types.js +12 -0
  46. package/dist/repositories/order.repository.d.ts +44 -0
  47. package/dist/repositories/order.repository.js +132 -0
  48. package/dist/repositories/pos.repository.d.ts +20 -0
  49. package/dist/repositories/pos.repository.js +28 -0
  50. package/dist/sync/command-builder.d.ts +17 -0
  51. package/dist/sync/command-builder.js +30 -0
  52. package/dist/sync/command-queue.service.d.ts +41 -0
  53. package/dist/sync/command-queue.service.js +253 -0
  54. package/dist/sync/engine.d.ts +53 -0
  55. package/dist/sync/engine.js +216 -0
  56. package/dist/sync/order-number.d.ts +20 -0
  57. package/dist/sync/order-number.js +58 -0
  58. package/dist/sync/order-snapshots.service.d.ts +46 -0
  59. package/dist/sync/order-snapshots.service.js +151 -0
  60. package/dist/sync/pending-orders.service.d.ts +45 -0
  61. package/dist/sync/pending-orders.service.js +163 -0
  62. package/dist/sync/transport-local-node.d.ts +14 -0
  63. package/dist/sync/transport-local-node.js +67 -0
  64. package/dist/sync/transport-rest.d.ts +17 -0
  65. package/dist/sync/transport-rest.js +112 -0
  66. package/dist/sync/types.d.ts +102 -0
  67. package/dist/sync/types.js +13 -0
  68. package/dist/sync/use-sync-reconciliation.d.ts +10 -0
  69. package/dist/sync/use-sync-reconciliation.js +106 -0
  70. package/dist/types/api/auth.api.d.ts +61 -0
  71. package/dist/types/api/auth.api.js +3 -0
  72. package/dist/types/api/business.api.d.ts +137 -0
  73. package/dist/types/api/business.api.js +3 -0
  74. package/dist/types/api/conversation.api.d.ts +46 -0
  75. package/dist/types/api/conversation.api.js +2 -0
  76. package/dist/types/api/error.api.d.ts +8 -0
  77. package/dist/types/api/error.api.js +2 -0
  78. package/dist/types/api/fleet.api.d.ts +98 -0
  79. package/dist/types/api/fleet.api.js +3 -0
  80. package/dist/types/api/index.d.ts +11 -0
  81. package/dist/types/api/index.js +27 -0
  82. package/dist/types/api/kiosk.api.d.ts +4 -0
  83. package/dist/types/api/kiosk.api.js +2 -0
  84. package/dist/types/api/menu.api.d.ts +90 -0
  85. package/dist/types/api/menu.api.js +2 -0
  86. package/dist/types/api/pagination.d.ts +10 -0
  87. package/dist/types/api/pagination.js +2 -0
  88. package/dist/types/api/rider.api.d.ts +179 -0
  89. package/dist/types/api/rider.api.js +2 -0
  90. package/dist/types/api/seller.api.d.ts +164 -0
  91. package/dist/types/api/seller.api.js +2 -0
  92. package/dist/types/api/wallet.api.d.ts +27 -0
  93. package/dist/types/api/wallet.api.js +2 -0
  94. package/dist/types/pos.types.d.ts +33 -0
  95. package/dist/types/pos.types.js +5 -0
  96. package/dist/utils/constants.d.ts +85 -0
  97. package/dist/utils/constants.js +72 -0
  98. package/dist/utils/copyToClipBoard.d.ts +5 -0
  99. package/dist/utils/copyToClipBoard.js +23 -0
  100. package/dist/utils/currency.d.ts +9 -0
  101. package/dist/utils/currency.js +37 -0
  102. package/dist/utils/error.d.ts +4 -0
  103. package/dist/utils/error.js +73 -0
  104. package/dist/utils/formatCurrency.d.ts +4 -0
  105. package/dist/utils/formatCurrency.js +16 -0
  106. package/dist/utils/formatDate.d.ts +8 -0
  107. package/dist/utils/formatDate.js +42 -0
  108. package/dist/utils/phone.d.ts +10 -0
  109. package/dist/utils/phone.js +71 -0
  110. package/dist/utils/responsive.d.ts +13 -0
  111. package/dist/utils/responsive.js +21 -0
  112. package/dist/utils/storage.d.ts +15 -0
  113. package/dist/utils/storage.js +60 -0
  114. package/dist/utils/sync-logger.d.ts +22 -0
  115. package/dist/utils/sync-logger.js +79 -0
  116. package/dist/utils/uuid.d.ts +7 -0
  117. package/dist/utils/uuid.js +47 -0
  118. package/dist/utils/validation.d.ts +31 -0
  119. package/dist/utils/validation.js +170 -0
  120. 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 {};