@teincfood/core 0.7.9 → 0.8.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 +26 -21
- package/dist/adapters/auth.d.ts +8 -0
- package/dist/adapters/auth.js +36 -0
- package/dist/adapters/devices.d.ts +3 -1
- package/dist/adapters/devices.js +187 -3
- package/dist/adapters/filesystem.d.ts +4 -0
- package/dist/adapters/kv.d.ts +10 -0
- package/dist/adapters/kv.js +23 -7
- package/dist/adapters/local-node.d.ts +4 -6
- package/dist/adapters/local-node.js +9 -32
- package/dist/adapters/notify.d.ts +12 -0
- package/dist/adapters/notify.js +33 -0
- package/dist/adapters/query-keys.d.ts +4 -0
- package/dist/adapters/query-keys.js +4 -0
- package/dist/adapters/services.d.ts +96 -15
- package/dist/adapters/services.js +241 -4
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +172 -0
- package/dist/catalog/hooks.d.ts +10 -0
- package/dist/catalog/hooks.js +82 -11
- package/dist/db/migrations.js +274 -0
- package/dist/db/operations.d.ts +53 -2
- package/dist/db/operations.js +177 -8
- package/dist/domain/business/index.d.ts +5 -0
- package/dist/domain/business/index.js +5 -0
- package/dist/domain/orders/lifecycle.d.ts +6 -0
- package/dist/domain/orders/lifecycle.js +6 -0
- package/dist/domain/orders/service.d.ts +24 -0
- package/dist/domain/orders/service.js +242 -0
- package/dist/domain/payments/index.d.ts +6 -0
- package/dist/domain/payments/index.js +5 -0
- package/dist/domain/reference/index.d.ts +8 -0
- package/dist/domain/reference/index.js +8 -0
- package/dist/e2e/harness.d.ts +1 -0
- package/dist/e2e/harness.js +61 -0
- package/dist/hooks/analytics.hooks.d.ts +25 -0
- package/dist/hooks/analytics.hooks.js +214 -0
- package/dist/hooks/business-hours.hooks.d.ts +8 -0
- package/dist/hooks/business-hours.hooks.js +45 -0
- package/dist/hooks/dashboard.hooks.d.ts +30 -0
- package/dist/hooks/dashboard.hooks.js +132 -0
- package/dist/hooks/delivery.hooks.d.ts +225 -0
- package/dist/hooks/delivery.hooks.js +80 -0
- package/dist/hooks/fleet.hooks.d.ts +37 -0
- package/dist/hooks/fleet.hooks.js +229 -0
- package/dist/hooks/image.hooks.d.ts +20 -0
- package/dist/hooks/image.hooks.js +62 -0
- package/dist/hooks/kiosk.hooks.d.ts +22 -5
- package/dist/hooks/kiosk.hooks.js +74 -5
- package/dist/hooks/members.hooks.d.ts +48 -0
- package/dist/hooks/members.hooks.js +302 -0
- package/dist/hooks/offline-orders.hooks.d.ts +9 -0
- package/dist/hooks/offline-orders.hooks.js +51 -0
- package/dist/hooks/orders.hooks.d.ts +18 -39
- package/dist/hooks/orders.hooks.js +124 -343
- package/dist/hooks/places.hooks.d.ts +21 -0
- package/dist/hooks/places.hooks.js +112 -0
- package/dist/hooks/realtime-stubs.d.ts +28 -0
- package/dist/hooks/realtime-stubs.js +85 -0
- package/dist/hooks/rider.hooks.d.ts +470 -0
- package/dist/hooks/rider.hooks.js +373 -0
- package/dist/hooks/use-infinite-list.d.ts +243 -0
- package/dist/hooks/use-infinite-list.js +58 -0
- package/dist/hooks/wallet.hooks.d.ts +219 -0
- package/dist/hooks/wallet.hooks.js +56 -0
- package/dist/image-cache/service.d.ts +8 -0
- package/dist/image-cache/service.js +15 -3
- package/dist/index.d.ts +56 -6
- package/dist/index.js +73 -6
- package/dist/local-node/adapters.d.ts +87 -0
- package/dist/local-node/adapters.js +4 -0
- package/dist/local-node/index.d.ts +5 -0
- package/dist/local-node/index.js +5 -0
- package/dist/local-node/security.d.ts +25 -0
- package/dist/local-node/security.js +149 -0
- package/dist/local-node/service.d.ts +83 -0
- package/dist/local-node/service.js +706 -0
- package/dist/local-node/store.d.ts +50 -0
- package/dist/local-node/store.js +284 -0
- package/dist/local-node/types.d.ts +92 -0
- package/dist/local-node/types.js +4 -0
- package/dist/printer/receipt.d.ts +4 -0
- package/dist/printer/receipt.js +75 -0
- package/dist/printer/service.js +1 -1
- package/dist/providers/query-client.d.ts +13 -0
- package/dist/providers/query-client.js +58 -0
- package/dist/realtime/phoenix-socket.js +30 -1
- package/dist/realtime/useEventsConnection.js +11 -2
- package/dist/reference/api.d.ts +2 -0
- package/dist/reference/api.js +2 -0
- package/dist/reference/capabilities.d.ts +12 -7
- package/dist/reference/capabilities.js +27 -13
- package/dist/reference/hooks.d.ts +34 -0
- package/dist/reference/hooks.js +124 -0
- package/dist/reference/menu-apply.d.ts +29 -0
- package/dist/reference/menu-apply.js +289 -0
- package/dist/reference/menu-record.d.ts +69 -0
- package/dist/reference/menu-record.js +163 -0
- package/dist/reference/operations.d.ts +3 -0
- package/dist/reference/operations.js +35 -1
- package/dist/reference/service.js +36 -9
- package/dist/reference/types.d.ts +1 -1
- package/dist/repositories/order.repository.d.ts +7 -0
- package/dist/repositories/order.repository.js +15 -2
- package/dist/repositories/pos.repository.d.ts +4 -0
- package/dist/repositories/pos.repository.js +4 -0
- package/dist/sync/backoff.d.ts +21 -0
- package/dist/sync/backoff.js +42 -0
- package/dist/sync/command-builder.js +10 -2
- package/dist/sync/command-queue.service.d.ts +12 -0
- package/dist/sync/command-queue.service.js +81 -16
- package/dist/sync/device-lease.service.d.ts +3 -3
- package/dist/sync/device-lease.service.js +80 -20
- package/dist/sync/engine.d.ts +3 -1
- package/dist/sync/engine.js +44 -4
- package/dist/sync/full-sync.d.ts +6 -0
- package/dist/sync/full-sync.js +72 -0
- package/dist/sync/health-probe.d.ts +24 -0
- package/dist/sync/health-probe.js +138 -0
- package/dist/sync/offline-capable.js +25 -0
- package/dist/sync/online-gates.d.ts +17 -0
- package/dist/sync/online-gates.js +55 -0
- package/dist/sync/order-number.d.ts +4 -4
- package/dist/sync/order-number.js +33 -13
- package/dist/sync/order-snapshots.service.js +18 -3
- package/dist/sync/pending-orders.service.d.ts +18 -0
- package/dist/sync/pending-orders.service.js +67 -1
- package/dist/sync/reconciliation/index.d.ts +5 -0
- package/dist/sync/reconciliation/index.js +5 -0
- package/dist/sync/snapshots/entity.d.ts +6 -0
- package/dist/sync/snapshots/entity.js +6 -0
- package/dist/sync/sync-jobs.service.d.ts +18 -0
- package/dist/sync/sync-jobs.service.js +539 -0
- package/dist/sync/transition-endpoints.d.ts +11 -0
- package/dist/sync/transition-endpoints.js +30 -0
- package/dist/sync/transport-local-node.js +3 -2
- package/dist/sync/transport-rest.d.ts +7 -0
- package/dist/sync/transport-rest.js +84 -26
- package/dist/sync/use-sync-reconciliation.js +15 -1
- package/dist/test-helpers/memory-driver.js +182 -4
- package/dist/test-helpers/seed-hours.d.ts +1 -0
- package/dist/test-helpers/seed-hours.js +23 -0
- package/dist/types/api/business.api.d.ts +42 -0
- package/dist/types/api/menu.api.d.ts +15 -0
- package/dist/types/api/seller.api.d.ts +20 -0
- package/dist/types/places.d.ts +19 -0
- package/dist/types/places.js +2 -0
- package/dist/types/rider.d.ts +67 -0
- package/dist/types/rider.js +2 -0
- package/dist/utils/attempt-stats.d.ts +23 -0
- package/dist/utils/attempt-stats.js +61 -0
- package/dist/utils/business-hours.d.ts +37 -0
- package/dist/utils/business-hours.js +96 -0
- package/dist/utils/debug-log.d.ts +23 -0
- package/dist/utils/debug-log.js +153 -0
- package/dist/utils/order-filter.d.ts +1 -1
- package/dist/utils/order-filter.js +1 -1
- package/dist/utils/order-transitions.d.ts +30 -0
- package/dist/utils/order-transitions.js +185 -0
- package/dist/utils/sync-logger.js +5 -4
- package/dist/validation/offline.d.ts +46 -0
- package/dist/validation/offline.js +79 -0
- package/package.json +6 -1
- package/dist/pricing/engine.test.d.ts +0 -1
- package/dist/pricing/engine.test.js +0 -142
- package/dist/reference/lww.test.d.ts +0 -1
- package/dist/reference/lww.test.js +0 -23
- package/dist/reference/operations.test.d.ts +0 -1
- package/dist/reference/operations.test.js +0 -66
- package/dist/standalone.integration.test.d.ts +0 -1
- package/dist/standalone.integration.test.js +0 -235
- package/dist/sync/device-lease.test.d.ts +0 -1
- package/dist/sync/device-lease.test.js +0 -38
- package/dist/sync/offline-integration.test.d.ts +0 -1
- package/dist/sync/offline-integration.test.js +0 -52
- package/dist/sync/order-number.test.d.ts +0 -1
- package/dist/sync/order-number.test.js +0 -51
- package/dist/sync/perms.test.d.ts +0 -1
- package/dist/sync/perms.test.js +0 -15
- package/dist/utils/currency.test.d.ts +0 -1
- package/dist/utils/currency.test.js +0 -17
- package/dist/utils/hlc.test.d.ts +0 -1
- package/dist/utils/hlc.test.js +0 -29
- package/dist/utils/phone.test.d.ts +0 -1
- package/dist/utils/phone.test.js +0 -19
- package/dist/utils/responsive.d.ts +0 -13
- package/dist/utils/responsive.js +0 -18
- package/dist/utils/uuid.test.d.ts +0 -1
- package/dist/utils/uuid.test.js +0 -13
- package/dist/utils/validation.test.d.ts +0 -1
- package/dist/utils/validation.test.js +0 -23
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
import { getSyncDatabase, openSyncDatabase } from "../db/connection";
|
|
12
12
|
import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
|
|
13
13
|
import { syncLogger } from "../utils/sync-logger";
|
|
14
|
+
import { recordAttempt } from "../utils/attempt-stats";
|
|
14
15
|
import { generateEventId } from "../utils/uuid";
|
|
15
|
-
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, } from "./order-number";
|
|
16
|
+
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
|
|
16
17
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
17
18
|
const MAX_RETRIES = 5;
|
|
18
19
|
const QUEUED_STATUS_BY_COMMAND = {
|
|
@@ -98,14 +99,14 @@ function buildOptimisticOrder(command, payload) {
|
|
|
98
99
|
return {
|
|
99
100
|
id: command.entity_id ?? command.command_id,
|
|
100
101
|
order_number: orderNumber,
|
|
101
|
-
callout_number: generateLocalCalloutNumber(),
|
|
102
|
+
callout_number: generateLocalCalloutNumber(command.business_id),
|
|
102
103
|
order_type: payload.order_type ?? "takeout",
|
|
103
104
|
is_pos: true,
|
|
104
105
|
customer_name: payload.customer_name ?? null,
|
|
105
106
|
customer_phone: payload.customer_phone ?? null,
|
|
106
107
|
pos_payment_method: payload.pos_payment_method ?? "cash",
|
|
107
108
|
paid_at: null,
|
|
108
|
-
status: "
|
|
109
|
+
status: "accepted",
|
|
109
110
|
delivery_id: null,
|
|
110
111
|
delivery_status: null,
|
|
111
112
|
is_paid: payload.pos_payment_method === "cash",
|
|
@@ -174,7 +175,7 @@ function injectLocalOrderNumber(command) {
|
|
|
174
175
|
}
|
|
175
176
|
class CommandQueueService {
|
|
176
177
|
constructor() {
|
|
177
|
-
this.replaying =
|
|
178
|
+
this.replaying = null;
|
|
178
179
|
this.syncEngine = null;
|
|
179
180
|
}
|
|
180
181
|
setSyncEngine(engine) {
|
|
@@ -254,7 +255,7 @@ class CommandQueueService {
|
|
|
254
255
|
}
|
|
255
256
|
await markCommandInFlight(db, command.command_id);
|
|
256
257
|
try {
|
|
257
|
-
const events = await this.syncEngine.execute(parsedCommand);
|
|
258
|
+
const events = await this.syncEngine.execute(parsedCommand, { forReplay: true });
|
|
258
259
|
// Outbox is an ephemeral queue — remove the row on success so the
|
|
259
260
|
// Diagnostics outbox count drops to 0 and old completed rows never
|
|
260
261
|
// accumulate. The optimistic event stays in `events` (marked synced).
|
|
@@ -270,11 +271,18 @@ class CommandQueueService {
|
|
|
270
271
|
}
|
|
271
272
|
catch (error) {
|
|
272
273
|
const message = error instanceof Error ? error.message : String(error);
|
|
274
|
+
// Replay with no transport available: leave the row exactly as-is for
|
|
275
|
+
// the next trigger (do not mark, do not count). Must reset the
|
|
276
|
+
// in_flight flag first — rows stuck in_flight are invisible to the
|
|
277
|
+
// outbox query and would wedge the queue forever.
|
|
278
|
+
if (message.includes("ReplayDeferred")) {
|
|
279
|
+
await updateCommandStatus(db, command.command_id, "pending", "deferred:no-transport");
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
273
282
|
const apiErr = error;
|
|
274
283
|
// 409 ORDER_NUMBER_TAKEN is retryable once with new RRR
|
|
275
284
|
const code = apiErr?.code ?? apiErr?.data?.code;
|
|
276
285
|
if (apiErr?.status === 409 && String(code).includes("ORDER_NUMBER_TAKEN")) {
|
|
277
|
-
// Regenerate random part of order_number if this was order:create
|
|
278
286
|
if (command.command_type === "order:create") {
|
|
279
287
|
try {
|
|
280
288
|
const payload = command.payload;
|
|
@@ -282,12 +290,14 @@ class CommandQueueService {
|
|
|
282
290
|
const parsed = on ? on.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
|
|
283
291
|
if (parsed) {
|
|
284
292
|
const deviceNumber = parsed[1];
|
|
285
|
-
const {
|
|
286
|
-
const { orderNumber: newOn } = generateOrderNumber(deviceNumber);
|
|
293
|
+
const { orderNumber: newOn } = generateOrderNumber(deviceNumber, command.business_id);
|
|
287
294
|
const newPayload = { ...payload, order_number: newOn };
|
|
288
|
-
await
|
|
289
|
-
|
|
290
|
-
|
|
295
|
+
await db.runAsync(`UPDATE commands SET payload = ?, error = ?, updated_at = ? WHERE command_id = ?;`, JSON.stringify(newPayload), `retry_random:${message}`, new Date().toISOString(), command.command_id);
|
|
296
|
+
syncLogger.info("CommandQueueService", "Regenerated order_number after 409", { command_id: command.command_id, new_order_number: newOn });
|
|
297
|
+
// keep retry_count unchanged so next replay retries; do not mark failed twice.
|
|
298
|
+
// Reset in_flight → pending so the row stays visible to the outbox query.
|
|
299
|
+
await updateCommandStatus(db, command.command_id, "pending", `retry_random:${message}`);
|
|
300
|
+
return;
|
|
291
301
|
}
|
|
292
302
|
}
|
|
293
303
|
catch { }
|
|
@@ -307,11 +317,29 @@ class CommandQueueService {
|
|
|
307
317
|
status: apiErr.status,
|
|
308
318
|
error: message,
|
|
309
319
|
});
|
|
320
|
+
// Loud toast + revert: the optimistic local write is rolled back so
|
|
321
|
+
// the UI never shows what the backend refused.
|
|
322
|
+
try {
|
|
323
|
+
const { notifyError } = await import("../adapters/notify");
|
|
324
|
+
notifyError(`Server rejected ${command.command_type}: ${message}`);
|
|
325
|
+
}
|
|
326
|
+
catch { }
|
|
327
|
+
try {
|
|
328
|
+
if (command.command_type.startsWith("menu:") && command.entity_id) {
|
|
329
|
+
const { revertMenuCommand } = await import("../reference/menu-apply");
|
|
330
|
+
await revertMenuCommand(command.business_id, command.command_type, command.entity_id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch { }
|
|
310
334
|
// Do not throw — allow replay to continue to next command.
|
|
311
335
|
return;
|
|
312
336
|
}
|
|
313
|
-
|
|
314
|
-
|
|
337
|
+
// Network/timeout/5xx: NOT a rejection — keep the command retryable
|
|
338
|
+
// indefinitely (status back to pending, retry_count untouched) so a
|
|
339
|
+
// long outage never dead-letters the outbox via MAX_RETRIES. Replay
|
|
340
|
+
// stops here to preserve ordering; the next trigger retries.
|
|
341
|
+
await updateCommandStatus(db, command.command_id, "pending", `network:${message}`);
|
|
342
|
+
syncLogger.command(parsedCommand, "queued", { error: message, deferred: true });
|
|
315
343
|
throw error;
|
|
316
344
|
}
|
|
317
345
|
}
|
|
@@ -320,11 +348,21 @@ class CommandQueueService {
|
|
|
320
348
|
* ordering consistent; the next replay will retry from the failed command.
|
|
321
349
|
*/
|
|
322
350
|
async replay() {
|
|
351
|
+
recordAttempt("sync_drain", "replay");
|
|
352
|
+
// Join an in-flight replay instead of no-op'ing: concurrent replays
|
|
353
|
+
// (connectivity auto-replay racing an explicit replay) must converge on
|
|
354
|
+
// the same outcome, otherwise callers observe a drained queue while work
|
|
355
|
+
// is still flying.
|
|
323
356
|
if (this.replaying) {
|
|
324
|
-
syncLogger.debug("CommandQueueService", "Replay already in progress");
|
|
357
|
+
syncLogger.debug("CommandQueueService", "Replay already in progress, joining");
|
|
358
|
+
try {
|
|
359
|
+
await this.replaying;
|
|
360
|
+
}
|
|
361
|
+
catch { }
|
|
325
362
|
return;
|
|
326
363
|
}
|
|
327
|
-
|
|
364
|
+
let resolveReplay;
|
|
365
|
+
this.replaying = new Promise((r) => { resolveReplay = r; });
|
|
328
366
|
try {
|
|
329
367
|
// Await open (not getSyncDatabase) so a cold-start replay triggered by a
|
|
330
368
|
// connectivity event that fires before the DB open completes does not
|
|
@@ -356,7 +394,11 @@ class CommandQueueService {
|
|
|
356
394
|
}
|
|
357
395
|
}
|
|
358
396
|
finally {
|
|
359
|
-
this.replaying =
|
|
397
|
+
this.replaying = null;
|
|
398
|
+
try {
|
|
399
|
+
resolveReplay();
|
|
400
|
+
}
|
|
401
|
+
catch { }
|
|
360
402
|
}
|
|
361
403
|
}
|
|
362
404
|
/**
|
|
@@ -377,5 +419,28 @@ class CommandQueueService {
|
|
|
377
419
|
const commands = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
|
|
378
420
|
return commands.length;
|
|
379
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Debug dump of the engine outbox for Diagnostics screens (all statuses,
|
|
424
|
+
* newest last). Mirrors debugDumpSyncJobs.
|
|
425
|
+
*/
|
|
426
|
+
async debugDumpCommands(businessId) {
|
|
427
|
+
try {
|
|
428
|
+
const db = getSyncDatabase();
|
|
429
|
+
const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
|
|
430
|
+
return rows
|
|
431
|
+
.filter((c) => !businessId || c.business_id === businessId)
|
|
432
|
+
.map((c) => ({
|
|
433
|
+
id: c.command_id,
|
|
434
|
+
command_type: c.command_type,
|
|
435
|
+
status: c.status,
|
|
436
|
+
retry_count: c.retry_count,
|
|
437
|
+
error: c.error ?? null,
|
|
438
|
+
created_at: c.created_at ?? "",
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
380
445
|
}
|
|
381
446
|
export const commandQueueService = new CommandQueueService();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Backend authority: POST /businesses/:id/devices/lease
|
|
4
4
|
* KV persists device_number per businessId.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
7
7
|
export declare function getDeviceNumber(businessId: string): string | null;
|
|
8
8
|
export declare function isOnlineOnly(businessId: string): boolean;
|
|
9
9
|
export declare function ensureLease(businessId: string): Promise<string | null>;
|
|
@@ -14,6 +14,6 @@ export declare const deviceLeaseService: {
|
|
|
14
14
|
isOnlineOnly: typeof isOnlineOnly;
|
|
15
15
|
ensureLease: typeof ensureLease;
|
|
16
16
|
refreshLease: typeof refreshLease;
|
|
17
|
-
getAppInstanceId: typeof
|
|
17
|
+
getAppInstanceId: typeof getAppInstanceIdAsync;
|
|
18
|
+
getAppInstanceIdAsync: typeof getAppInstanceIdAsync;
|
|
18
19
|
};
|
|
19
|
-
export {};
|
|
@@ -3,20 +3,13 @@
|
|
|
3
3
|
* Backend authority: POST /businesses/:id/devices/lease
|
|
4
4
|
* KV persists device_number per businessId.
|
|
5
5
|
*/
|
|
6
|
-
import { getItemSync as getSync, setItemSync as setSync } from "../adapters/kv";
|
|
6
|
+
import { getItemSync as getSync, setItemSync as setSync, removeItemSync, getItem } from "../adapters/kv";
|
|
7
7
|
import { getHttpClient } from "../adapters/http";
|
|
8
|
-
import { generateUUID } from "../utils/uuid";
|
|
9
8
|
import { syncLogger } from "../utils/sync-logger";
|
|
9
|
+
import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
10
|
+
import { getConnectivityState } from "../adapters/connectivity";
|
|
10
11
|
const LEASE_KEY = (businessId) => `teinc_device_number:${businessId}`;
|
|
11
12
|
const LEASE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
12
|
-
function getAppInstanceId() {
|
|
13
|
-
let id = getSync("teinc_app_instance_id");
|
|
14
|
-
if (!id) {
|
|
15
|
-
id = generateUUID();
|
|
16
|
-
setSync("teinc_app_instance_id", id);
|
|
17
|
-
}
|
|
18
|
-
return id;
|
|
19
|
-
}
|
|
20
13
|
export function getDeviceNumber(businessId) {
|
|
21
14
|
const v = getSync(LEASE_KEY(businessId));
|
|
22
15
|
if (v && /^(0[1-9]|[1-9][0-9])$/.test(v))
|
|
@@ -27,32 +20,99 @@ export function isOnlineOnly(businessId) {
|
|
|
27
20
|
return getDeviceNumber(businessId) == null;
|
|
28
21
|
}
|
|
29
22
|
export async function ensureLease(businessId) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
// Offline-first: keep leased DD even offline — if online check/lease fails, return cached
|
|
24
|
+
const existingSync = getDeviceNumber(businessId);
|
|
25
|
+
if (existingSync) {
|
|
26
|
+
// Opportunistically refresh touch if online, but never discard cached on failure
|
|
27
|
+
if (getConnectivityState().isOnline) {
|
|
28
|
+
try {
|
|
29
|
+
const leasedAt = getSync(`${LEASE_KEY(businessId)}:leased_at`);
|
|
30
|
+
const needsRefresh = !leasedAt || Date.now() - new Date(leasedAt).getTime() > LEASE_TTL_MS - 24 * 60 * 60 * 1000;
|
|
31
|
+
if (needsRefresh)
|
|
32
|
+
await refreshLease(businessId);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// keep existing on any refresh failure
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return existingSync;
|
|
39
|
+
}
|
|
40
|
+
// No sync cache — try async KV (covers boot race where sync cache not yet hydrated from kv.json)
|
|
41
|
+
try {
|
|
42
|
+
const asyncCached = await getItem(LEASE_KEY(businessId));
|
|
43
|
+
if (asyncCached && /^(0[1-9]|[1-9][0-9])$/.test(asyncCached)) {
|
|
44
|
+
// hydrate sync cache for next getDeviceNumber calls
|
|
45
|
+
try {
|
|
46
|
+
setSync(LEASE_KEY(businessId), asyncCached);
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
return asyncCached;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
// No cached DD — must lease online. If offline, keep null (caller blocks POS with clear message)
|
|
54
|
+
if (!getConnectivityState().isOnline) {
|
|
55
|
+
syncLogger.debug("DeviceLease", "Offline and no cached DD — need online once", { businessId });
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
33
58
|
try {
|
|
34
59
|
const http = getHttpClient();
|
|
35
|
-
const deviceUuid =
|
|
60
|
+
const deviceUuid = await getAppInstanceIdAsync();
|
|
36
61
|
const res = await http.post(`/businesses/${businessId}/devices/lease`, { device_uuid: deviceUuid, app_instance_id: deviceUuid });
|
|
37
62
|
const data = res.data?.data ?? res.data;
|
|
38
63
|
const dn = data.device_number;
|
|
39
64
|
if (dn && /^(0[1-9]|[1-9][0-9])$/.test(dn)) {
|
|
40
65
|
setSync(LEASE_KEY(businessId), dn);
|
|
41
66
|
setSync(`${LEASE_KEY(businessId)}:leased_at`, new Date().toISOString());
|
|
67
|
+
try {
|
|
68
|
+
await getItem(LEASE_KEY(businessId));
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
42
71
|
syncLogger.info("DeviceLease", "Leased device number", { businessId, device_number: dn });
|
|
43
72
|
return dn;
|
|
44
73
|
}
|
|
45
74
|
}
|
|
46
75
|
catch (e) {
|
|
47
76
|
const apiErr = e;
|
|
48
|
-
|
|
77
|
+
const msg = String(e?.message ?? "");
|
|
78
|
+
const isAuth = apiErr?.status === 401 || msg.includes("TOKEN_MISSING") || msg.includes("No authentication token");
|
|
79
|
+
if (isAuth) {
|
|
80
|
+
syncLogger.debug("DeviceLease", "No auth yet — lease deferred until login", { businessId });
|
|
81
|
+
// fallback to any async cache that may have appeared concurrently
|
|
82
|
+
try {
|
|
83
|
+
const fallback = await getItem(LEASE_KEY(businessId));
|
|
84
|
+
if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback))
|
|
85
|
+
return fallback;
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
const again = getDeviceNumber(businessId);
|
|
89
|
+
if (again)
|
|
90
|
+
return again;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (apiErr?.status === 429 || msg.includes("DEVICE_LIMIT_REACHED")) {
|
|
49
94
|
syncLogger.warn("DeviceLease", "Device limit reached — online only", { businessId });
|
|
50
95
|
return null;
|
|
51
96
|
}
|
|
52
|
-
|
|
97
|
+
// Network/offline failure while leasing — keep any cached that may exist
|
|
98
|
+
syncLogger.warn("DeviceLease", "Lease failed — keep cached if any", {
|
|
53
99
|
businessId,
|
|
54
100
|
error: String(e),
|
|
55
101
|
});
|
|
102
|
+
try {
|
|
103
|
+
const fallback = await getItem(LEASE_KEY(businessId));
|
|
104
|
+
if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback)) {
|
|
105
|
+
try {
|
|
106
|
+
setSync(LEASE_KEY(businessId), fallback);
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
return fallback;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
const again = getDeviceNumber(businessId);
|
|
114
|
+
if (again)
|
|
115
|
+
return again;
|
|
56
116
|
}
|
|
57
117
|
return null;
|
|
58
118
|
}
|
|
@@ -68,7 +128,8 @@ export async function refreshLease(businessId) {
|
|
|
68
128
|
}
|
|
69
129
|
try {
|
|
70
130
|
const http = getHttpClient();
|
|
71
|
-
await
|
|
131
|
+
const uuid = await getAppInstanceIdAsync();
|
|
132
|
+
await http.patch(`/businesses/${businessId}/devices/${uuid}/touch`, {});
|
|
72
133
|
setSync(`${LEASE_KEY(businessId)}:leased_at`, new Date().toISOString());
|
|
73
134
|
}
|
|
74
135
|
catch {
|
|
@@ -76,8 +137,6 @@ export async function refreshLease(businessId) {
|
|
|
76
137
|
}
|
|
77
138
|
}
|
|
78
139
|
export function clearLeaseForTests(businessId) {
|
|
79
|
-
// test helper
|
|
80
|
-
const { removeItemSync } = require("../adapters/kv");
|
|
81
140
|
removeItemSync(LEASE_KEY(businessId));
|
|
82
141
|
removeItemSync(`${LEASE_KEY(businessId)}:leased_at`);
|
|
83
142
|
}
|
|
@@ -86,5 +145,6 @@ export const deviceLeaseService = {
|
|
|
86
145
|
isOnlineOnly,
|
|
87
146
|
ensureLease,
|
|
88
147
|
refreshLease,
|
|
89
|
-
getAppInstanceId,
|
|
148
|
+
getAppInstanceId: getAppInstanceIdAsync,
|
|
149
|
+
getAppInstanceIdAsync,
|
|
90
150
|
};
|
package/dist/sync/engine.d.ts
CHANGED
|
@@ -32,7 +32,9 @@ export declare class SyncEngine {
|
|
|
32
32
|
* internet connection falls back to the Local Node (or the offline outbox)
|
|
33
33
|
* quickly instead of blocking the terminal for the full HTTP timeout.
|
|
34
34
|
*/
|
|
35
|
-
execute(command: Command
|
|
35
|
+
execute(command: Command, opts?: {
|
|
36
|
+
forReplay?: boolean;
|
|
37
|
+
}): Promise<DomainEvent[]>;
|
|
36
38
|
/**
|
|
37
39
|
* Trigger replay of queued commands when connectivity returns.
|
|
38
40
|
*/
|
package/dist/sync/engine.js
CHANGED
|
@@ -18,6 +18,9 @@ import { getConnectivityState, setConnectivityState } from "../adapters/connecti
|
|
|
18
18
|
import { ApiError } from "../adapters/http";
|
|
19
19
|
import { syncLogger } from "../utils/sync-logger";
|
|
20
20
|
import { CLOUD_COMMAND_TIMEOUT_MS } from "../utils/constants";
|
|
21
|
+
import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
|
|
22
|
+
import { getReferenceData } from "../reference/operations";
|
|
23
|
+
import { openSyncDatabase } from "../db/connection";
|
|
21
24
|
function withTimeout(promise, ms) {
|
|
22
25
|
return new Promise((resolve, reject) => {
|
|
23
26
|
const timer = setTimeout(() => {
|
|
@@ -49,15 +52,12 @@ export class SyncEngine {
|
|
|
49
52
|
* internet connection falls back to the Local Node (or the offline outbox)
|
|
50
53
|
* quickly instead of blocking the terminal for the full HTTP timeout.
|
|
51
54
|
*/
|
|
52
|
-
async execute(command) {
|
|
55
|
+
async execute(command, opts) {
|
|
53
56
|
syncLogger.command(command, "issued");
|
|
54
57
|
// Perms guard (offline-capable): block before routing
|
|
55
|
-
const { CAPABILITY_BY_COMMAND } = await import("../reference/capabilities");
|
|
56
58
|
const required = CAPABILITY_BY_COMMAND[command.command_type];
|
|
57
59
|
if (required) {
|
|
58
60
|
try {
|
|
59
|
-
const { getReferenceData } = await import("../reference/operations");
|
|
60
|
-
const { openSyncDatabase } = await import("../db/connection");
|
|
61
61
|
const db = await openSyncDatabase();
|
|
62
62
|
const rec = await getReferenceData(db, command.business_id, "capabilities");
|
|
63
63
|
const caps = rec?.payload?.data?.capabilities ?? [];
|
|
@@ -84,8 +84,42 @@ export class SyncEngine {
|
|
|
84
84
|
lan_available: localNodeTransport.isAvailable(),
|
|
85
85
|
});
|
|
86
86
|
if (strategy === "queue") {
|
|
87
|
+
// Replays must never re-enqueue-then-delete (the command would eat
|
|
88
|
+
// itself without executing). If no real transport is available, leave
|
|
89
|
+
// the row untouched for a later trigger.
|
|
90
|
+
if (opts?.forReplay) {
|
|
91
|
+
throw new Error("ReplayDeferred: no transport available, keeping command queued");
|
|
92
|
+
}
|
|
87
93
|
const event = await commandQueueService.enqueue(command);
|
|
88
94
|
this.broadcast(event);
|
|
95
|
+
// Menu writes need a visible local effect while queued: patch the
|
|
96
|
+
// durable reference cache optimistically and fan out
|
|
97
|
+
// reference_data.changed so reconciliation applies it locally and
|
|
98
|
+
// relays it to LAN peers (replay re-applies idempotently; the backend
|
|
99
|
+
// owns versions on cloud convergence).
|
|
100
|
+
if (command.command_type.startsWith("menu:")) {
|
|
101
|
+
try {
|
|
102
|
+
const { applyMenuCommandLocal } = await import("../reference/menu-apply");
|
|
103
|
+
const { keys } = await applyMenuCommandLocal(command.business_id, command);
|
|
104
|
+
if (keys.length) {
|
|
105
|
+
const { REFERENCE_CHANGED_EVENT_TYPE } = await import("../reference/events");
|
|
106
|
+
const { generateEventId } = await import("../utils/uuid");
|
|
107
|
+
this.broadcast({
|
|
108
|
+
event_id: generateEventId(),
|
|
109
|
+
event_type: REFERENCE_CHANGED_EVENT_TYPE,
|
|
110
|
+
event_version: "1.0",
|
|
111
|
+
entity_id: command.business_id,
|
|
112
|
+
user_id: command.user_id,
|
|
113
|
+
device_id: command.device_id,
|
|
114
|
+
payload: { business_id: command.business_id, keys },
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
syncLogger.debug("SyncEngine", "queued menu optimistic apply failed", { err: String(e) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
89
123
|
return [event];
|
|
90
124
|
}
|
|
91
125
|
if (strategy === "cloud") {
|
|
@@ -130,6 +164,12 @@ export class SyncEngine {
|
|
|
130
164
|
setConnectivityState({ isCloudReachable: "unreachable" });
|
|
131
165
|
strategy = this.resolveStrategy(command);
|
|
132
166
|
if (strategy === "queue") {
|
|
167
|
+
// Replays must never re-enqueue-then-delete (the command would eat
|
|
168
|
+
// itself without executing). If no real transport is available, leave
|
|
169
|
+
// the row untouched for a later trigger.
|
|
170
|
+
if (opts?.forReplay) {
|
|
171
|
+
throw new Error("ReplayDeferred: no transport available, keeping command queued");
|
|
172
|
+
}
|
|
133
173
|
const event = await commandQueueService.enqueue(command);
|
|
134
174
|
this.broadcast(event);
|
|
135
175
|
return [event];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full sync: push → pull → push.
|
|
3
|
+
* Push first so local edits (already HLC-bumped) reach server before pull
|
|
4
|
+
* overwrites them; then pull merges; final drain pushes any remainder.
|
|
5
|
+
*/
|
|
6
|
+
export declare function performFullSync(businessId?: string): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full sync: push → pull → push.
|
|
3
|
+
* Push first so local edits (already HLC-bumped) reach server before pull
|
|
4
|
+
* overwrites them; then pull merges; final drain pushes any remainder.
|
|
5
|
+
*/
|
|
6
|
+
import { getConnectivityState } from "../adapters/connectivity";
|
|
7
|
+
import { getAuthToken } from "../adapters/auth";
|
|
8
|
+
import { getActiveBusiness } from "../adapters/business";
|
|
9
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
10
|
+
import { referenceSyncService } from "../reference/service";
|
|
11
|
+
import { pullRemoteOrders, drainPending } from "./sync-jobs.service";
|
|
12
|
+
let inFlight = null;
|
|
13
|
+
export async function performFullSync(businessId) {
|
|
14
|
+
const bid = businessId ?? getActiveBusiness()?.id;
|
|
15
|
+
if (!bid)
|
|
16
|
+
return;
|
|
17
|
+
const s = getConnectivityState();
|
|
18
|
+
if (!s.isOnline || s.isCloudReachable === "unreachable")
|
|
19
|
+
return;
|
|
20
|
+
if (!getAuthToken())
|
|
21
|
+
return;
|
|
22
|
+
if (inFlight) {
|
|
23
|
+
try {
|
|
24
|
+
await inFlight;
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const p = (async () => {
|
|
30
|
+
try {
|
|
31
|
+
syncLogger.info("FullSync", "Starting push→pull→push", { businessId: bid });
|
|
32
|
+
// 1. Push pending FIFO first — HLC-bumped local edits outrank stale server
|
|
33
|
+
try {
|
|
34
|
+
await drainPending(bid);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
syncLogger.warn("FullSync", "drainPending(pre) failed", { err: String(e) });
|
|
38
|
+
}
|
|
39
|
+
// 2. Pull remote orders (HLC-guarded) and reference (LWW merge)
|
|
40
|
+
try {
|
|
41
|
+
await pullRemoteOrders(bid);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
syncLogger.warn("FullSync", "pullRemoteOrders failed", { err: String(e) });
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await referenceSyncService.sync({ businessId: bid });
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
syncLogger.warn("FullSync", "reference sync failed", { err: String(e) });
|
|
51
|
+
}
|
|
52
|
+
// 3. Final drain — anything newly queued or retried after pull
|
|
53
|
+
try {
|
|
54
|
+
await drainPending(bid);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
syncLogger.warn("FullSync", "drainPending(post) failed", { err: String(e) });
|
|
58
|
+
}
|
|
59
|
+
syncLogger.info("FullSync", "Complete", { businessId: bid });
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
syncLogger.warn("FullSync", "Failed", { err: String(e) });
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
inFlight = p;
|
|
66
|
+
try {
|
|
67
|
+
await p;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
inFlight = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Health Probe — periodic cloud reachability check.
|
|
3
|
+
*
|
|
4
|
+
* Probes `GET /health` using the injected HttpClient with a short timeout.
|
|
5
|
+
* On success: marks cloud reachable (triggers sync replay via connectivity
|
|
6
|
+
* subscribers). On failure: marks unreachable and schedules a retry with
|
|
7
|
+
* exponential backoff (2s → 5min cap, ±20% jitter).
|
|
8
|
+
*
|
|
9
|
+
* Started automatically by `createTeincCore()`. Host apps should not need
|
|
10
|
+
* to call this directly.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Single probe attempt. Returns true if cloud is reachable.
|
|
14
|
+
*/
|
|
15
|
+
export declare function probeCloudHealth(): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Start the health probe. Safe to call multiple times (idempotent).
|
|
18
|
+
* Returns a stop function.
|
|
19
|
+
*/
|
|
20
|
+
export declare function startHealthProbe(): () => void;
|
|
21
|
+
/**
|
|
22
|
+
* Stop the health probe and clean up all timers.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stopHealthProbe(): void;
|