@teincfood/core 0.7.8 → 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 +10 -0
- package/dist/adapters/auth.js +43 -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 +99 -15
- package/dist/adapters/services.js +244 -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 +19 -39
- package/dist/hooks/orders.hooks.js +126 -321
- 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 +60 -5
- package/dist/index.js +78 -5
- 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/events.service.d.ts +18 -0
- package/dist/realtime/events.service.js +76 -0
- package/dist/realtime/order-events.service.d.ts +6 -0
- package/dist/realtime/order-events.service.js +17 -0
- package/dist/realtime/phoenix-socket.d.ts +55 -0
- package/dist/realtime/phoenix-socket.js +269 -0
- package/dist/realtime/useEventsConnection.d.ts +1 -0
- package/dist/realtime/useEventsConnection.js +50 -0
- 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 +28 -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 +8 -0
- package/dist/repositories/order.repository.js +24 -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 +115 -18
- 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.d.ts +1 -0
- package/dist/sync/order-snapshots.service.js +47 -2
- package/dist/sync/pending-orders.service.d.ts +18 -0
- package/dist/sync/pending-orders.service.js +148 -3
- 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 +90 -25
- package/dist/sync/use-sync-reconciliation.js +58 -16
- 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
|
@@ -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;
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
import { getConnectivityState, setCloudReachable, subscribeConnectivity } from "../adapters/connectivity";
|
|
13
|
+
import { getHttpClient } from "../adapters/http";
|
|
14
|
+
import { API_BASE_URL } from "../utils/constants";
|
|
15
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
16
|
+
import { BackoffTracker } from "./backoff";
|
|
17
|
+
import { getActiveBusiness } from "../adapters/business";
|
|
18
|
+
import { getAuthToken } from "../adapters/auth";
|
|
19
|
+
const PROBE_URL = "/health";
|
|
20
|
+
const PROBE_TIMEOUT_MS = 4000;
|
|
21
|
+
const PROBE_INTERVAL_MS = 60000;
|
|
22
|
+
const UNREACHABLE_COOLDOWN_MS = 15000;
|
|
23
|
+
const INITIAL_DELAY_MS = 3000;
|
|
24
|
+
let probeTimer = null;
|
|
25
|
+
let cooldownTimer = null;
|
|
26
|
+
let stopFn = null;
|
|
27
|
+
const backoff = new BackoffTracker();
|
|
28
|
+
/**
|
|
29
|
+
* Single probe attempt. Returns true if cloud is reachable.
|
|
30
|
+
*/
|
|
31
|
+
export async function probeCloudHealth() {
|
|
32
|
+
const http = (() => { try {
|
|
33
|
+
return getHttpClient();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
} })();
|
|
38
|
+
if (!http)
|
|
39
|
+
return false;
|
|
40
|
+
const { isOnline } = getConnectivityState();
|
|
41
|
+
if (!isOnline)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
46
|
+
await http.get(PROBE_URL, { signal: controller.signal });
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
backoff.reset("health");
|
|
49
|
+
const was = getConnectivityState().isCloudReachable;
|
|
50
|
+
setCloudReachable("reachable");
|
|
51
|
+
if (was !== "reachable") {
|
|
52
|
+
syncLogger.info("HealthProbe", "Cloud reachable", { previous: was });
|
|
53
|
+
// Kick off pull→merge→push without blocking probe.
|
|
54
|
+
try {
|
|
55
|
+
const bid = getActiveBusiness()?.id;
|
|
56
|
+
if (bid && getAuthToken()) {
|
|
57
|
+
void import("./full-sync").then((m) => m.performFullSync(bid).catch(() => { }));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Don't mark unreachable here — the sync engine already handles that
|
|
66
|
+
// when a command times out. This probe only detects recovery.
|
|
67
|
+
// Log and backoff so we re-check later.
|
|
68
|
+
syncLogger.debug("HealthProbe", "Probe failed — will retry", {
|
|
69
|
+
previous: getConnectivityState().isCloudReachable,
|
|
70
|
+
});
|
|
71
|
+
scheduleRetry();
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function scheduleRetry() {
|
|
76
|
+
if (cooldownTimer)
|
|
77
|
+
return;
|
|
78
|
+
const delay = Math.max(UNREACHABLE_COOLDOWN_MS, backoff.next("health"));
|
|
79
|
+
syncLogger.debug("HealthProbe", "Scheduling retry", { delay });
|
|
80
|
+
cooldownTimer = setTimeout(() => {
|
|
81
|
+
cooldownTimer = null;
|
|
82
|
+
void probeCloudHealth();
|
|
83
|
+
}, delay);
|
|
84
|
+
}
|
|
85
|
+
function startPeriodicProbe() {
|
|
86
|
+
if (probeTimer)
|
|
87
|
+
return;
|
|
88
|
+
probeTimer = setInterval(() => void probeCloudHealth(), PROBE_INTERVAL_MS);
|
|
89
|
+
}
|
|
90
|
+
function stopPeriodicProbe() {
|
|
91
|
+
if (probeTimer) {
|
|
92
|
+
clearInterval(probeTimer);
|
|
93
|
+
probeTimer = null;
|
|
94
|
+
}
|
|
95
|
+
if (cooldownTimer) {
|
|
96
|
+
clearTimeout(cooldownTimer);
|
|
97
|
+
cooldownTimer = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Start the health probe. Safe to call multiple times (idempotent).
|
|
102
|
+
* Returns a stop function.
|
|
103
|
+
*/
|
|
104
|
+
export function startHealthProbe() {
|
|
105
|
+
if (stopFn)
|
|
106
|
+
return stopFn;
|
|
107
|
+
syncLogger.debug("HealthProbe", "Starting", { probeUrl: `${API_BASE_URL}${PROBE_URL}` });
|
|
108
|
+
// Initial probe (deferred so host bridges can set up first)
|
|
109
|
+
cooldownTimer = setTimeout(() => {
|
|
110
|
+
cooldownTimer = null;
|
|
111
|
+
void probeCloudHealth();
|
|
112
|
+
}, INITIAL_DELAY_MS);
|
|
113
|
+
// Periodic probe
|
|
114
|
+
startPeriodicProbe();
|
|
115
|
+
// Re-probe when network regains
|
|
116
|
+
let prevOnline = getConnectivityState().isOnline;
|
|
117
|
+
const unsub = subscribeConnectivity(() => {
|
|
118
|
+
const { isOnline } = getConnectivityState();
|
|
119
|
+
if (isOnline && !prevOnline) {
|
|
120
|
+
syncLogger.debug("HealthProbe", "Network regained — probing");
|
|
121
|
+
void probeCloudHealth();
|
|
122
|
+
}
|
|
123
|
+
prevOnline = isOnline;
|
|
124
|
+
});
|
|
125
|
+
stopFn = () => {
|
|
126
|
+
stopPeriodicProbe();
|
|
127
|
+
unsub();
|
|
128
|
+
stopFn = null;
|
|
129
|
+
};
|
|
130
|
+
return stopFn;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Stop the health probe and clean up all timers.
|
|
134
|
+
*/
|
|
135
|
+
export function stopHealthProbe() {
|
|
136
|
+
if (stopFn)
|
|
137
|
+
stopFn();
|
|
138
|
+
}
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { syncEngine } from "./engine";
|
|
14
14
|
import { buildCommand } from "./command-builder";
|
|
15
|
+
import { assertOnlineFor } from "./online-gates";
|
|
15
16
|
import { syncLogger } from "../utils/sync-logger";
|
|
16
17
|
import { ApiError } from "../adapters/http";
|
|
18
|
+
import { getConnectivityState } from "../adapters/connectivity";
|
|
17
19
|
import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
|
|
18
20
|
import { openSyncDatabase } from "../db/connection";
|
|
19
21
|
import { getReferenceData } from "../reference/operations";
|
|
@@ -21,6 +23,23 @@ export async function runOfflineCapableMutation(options) {
|
|
|
21
23
|
// Per-command capability guard before even attempting direct call
|
|
22
24
|
const required = CAPABILITY_BY_COMMAND[options.commandType];
|
|
23
25
|
if (required) {
|
|
26
|
+
// Online-required gate first: these commands can never succeed offline —
|
|
27
|
+
// throw loud immediately instead of queuing what no transport can fulfill.
|
|
28
|
+
assertOnlineFor(options.commandType, options.payload);
|
|
29
|
+
// Local-first for engine-routed domains (menu:*): commit locally via the
|
|
30
|
+
// engine and resolve immediately. Network is sync transport only — never a
|
|
31
|
+
// mutation delay. The engine cloud path carries the same 2.5s cap and
|
|
32
|
+
// returns the backend response when online.
|
|
33
|
+
if (options.commandType.startsWith("menu:")) {
|
|
34
|
+
const command = buildCommand({
|
|
35
|
+
commandType: options.commandType,
|
|
36
|
+
businessId: options.businessId,
|
|
37
|
+
entityId: options.entityId,
|
|
38
|
+
payload: options.payload,
|
|
39
|
+
});
|
|
40
|
+
const events = await syncEngine.execute(command);
|
|
41
|
+
return events[0]?.payload;
|
|
42
|
+
}
|
|
24
43
|
try {
|
|
25
44
|
const db = await openSyncDatabase();
|
|
26
45
|
const rec = await getReferenceData(db, options.businessId, "capabilities");
|
|
@@ -35,6 +54,12 @@ export async function runOfflineCapableMutation(options) {
|
|
|
35
54
|
}
|
|
36
55
|
}
|
|
37
56
|
try {
|
|
57
|
+
// Cloud already known unreachable (or no network): skip the direct REST
|
|
58
|
+
// attempt entirely — it would only burn a timeout before falling back.
|
|
59
|
+
const cs = getConnectivityState();
|
|
60
|
+
if (!cs.isOnline || cs.isCloudReachable === "unreachable") {
|
|
61
|
+
throw new Error("offline-capable: cloud known unreachable, engine route");
|
|
62
|
+
}
|
|
38
63
|
return await options.run();
|
|
39
64
|
}
|
|
40
65
|
catch (error) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Online-required allowlist — single source for what can NEVER work offline.
|
|
3
|
+
*
|
|
4
|
+
* Everything else is offline-first (SQLite read, local commit, engine sync).
|
|
5
|
+
* These commands throw immediately when the cloud is unreachable: queuing
|
|
6
|
+
* them would be dishonest because no transport (LAN or outbox replay) can
|
|
7
|
+
* ever fulfill them without the backend.
|
|
8
|
+
*/
|
|
9
|
+
/** Command types that require the cloud, unconditionally. */
|
|
10
|
+
export declare const ONLINE_REQUIRED: Record<string, string>;
|
|
11
|
+
export declare function isOnlineCapable(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Throw when a command cannot proceed offline. Handles payload-dependent
|
|
14
|
+
* cases: order:create with delivery type or prepaid/mobile-money payment,
|
|
15
|
+
* and delivery/order transitions the domain already gates.
|
|
16
|
+
*/
|
|
17
|
+
export declare function assertOnlineFor(commandType: string, payload?: Record<string, unknown>): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Online-required allowlist — single source for what can NEVER work offline.
|
|
3
|
+
*
|
|
4
|
+
* Everything else is offline-first (SQLite read, local commit, engine sync).
|
|
5
|
+
* These commands throw immediately when the cloud is unreachable: queuing
|
|
6
|
+
* them would be dishonest because no transport (LAN or outbox replay) can
|
|
7
|
+
* ever fulfill them without the backend.
|
|
8
|
+
*/
|
|
9
|
+
import { getConnectivityState } from "../adapters/connectivity";
|
|
10
|
+
/** Command types that require the cloud, unconditionally. */
|
|
11
|
+
export const ONLINE_REQUIRED = {
|
|
12
|
+
"order:assign_rider": "Assign rider requires online",
|
|
13
|
+
"order:confirm_handover": "Handover confirmation requires online",
|
|
14
|
+
"chat:send": "Chat requires online",
|
|
15
|
+
"notification:register": "Notifications require online",
|
|
16
|
+
"member:invite": "Member invites require online",
|
|
17
|
+
"member:manage": "Member management requires online",
|
|
18
|
+
"fleet:manage": "Fleet changes require online",
|
|
19
|
+
"settings:manage": "Settings changes require online",
|
|
20
|
+
"kyc:submit": "KYC submission requires online",
|
|
21
|
+
"wallet:withdraw": "Withdrawals require online",
|
|
22
|
+
"wallet:payout": "Payouts require online",
|
|
23
|
+
"payment:charge_online": "Online payment requires online",
|
|
24
|
+
"device:lease": "Device lease requires online (first lease only)",
|
|
25
|
+
};
|
|
26
|
+
export function isOnlineCapable() {
|
|
27
|
+
const s = getConnectivityState();
|
|
28
|
+
return s.isOnline && s.isCloudReachable !== "unreachable";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Throw when a command cannot proceed offline. Handles payload-dependent
|
|
32
|
+
* cases: order:create with delivery type or prepaid/mobile-money payment,
|
|
33
|
+
* and delivery/order transitions the domain already gates.
|
|
34
|
+
*/
|
|
35
|
+
export function assertOnlineFor(commandType, payload) {
|
|
36
|
+
const fixed = ONLINE_REQUIRED[commandType];
|
|
37
|
+
if (fixed) {
|
|
38
|
+
if (!isOnlineCapable())
|
|
39
|
+
throw new Error(fixed);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (commandType === "order:create" && payload) {
|
|
43
|
+
const orderType = payload.order_type;
|
|
44
|
+
if (orderType === "delivery" || payload.delivery_params) {
|
|
45
|
+
if (!isOnlineCapable())
|
|
46
|
+
throw new Error("Delivery orders require online");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const method = payload.pos_payment_method;
|
|
50
|
+
if (typeof method === "string" && method !== "cash") {
|
|
51
|
+
if (!isOnlineCapable())
|
|
52
|
+
throw new Error("Online payment requires online — use cash while offline");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
* CCC = callout 001-999 global monotonic per device, wraps to 001 (new RRR on wrap)
|
|
6
6
|
* Example: "10-123-001"
|
|
7
7
|
*/
|
|
8
|
-
export declare function peekCallout(): number;
|
|
9
|
-
export declare function generateOrderNumber(deviceNumber: string): {
|
|
8
|
+
export declare function peekCallout(businessId?: string | null): number;
|
|
9
|
+
export declare function generateOrderNumber(deviceNumber: string, businessId?: string | null): {
|
|
10
10
|
orderNumber: string;
|
|
11
11
|
callout: number;
|
|
12
12
|
random: number;
|
|
13
13
|
};
|
|
14
14
|
export declare function generateLocalOrderNumber(): string;
|
|
15
15
|
export declare function generateOrderNumberForBusiness(businessId: string): string;
|
|
16
|
-
export declare function generateLocalCalloutNumber(): number;
|
|
16
|
+
export declare function generateLocalCalloutNumber(businessId?: string | null): number;
|
|
17
17
|
export declare function isLocalOrderNumber(orderNumber: string): boolean;
|
|
18
18
|
export declare function isOfflineOrderNumber(orderNumber: string): boolean;
|
|
19
19
|
export declare function parseOrderNumber(orderNumber: string): {
|
|
@@ -21,4 +21,4 @@ export declare function parseOrderNumber(orderNumber: string): {
|
|
|
21
21
|
random: string;
|
|
22
22
|
callout: string;
|
|
23
23
|
} | null;
|
|
24
|
-
export declare function resetCalloutForTests(): void;
|
|
24
|
+
export declare function resetCalloutForTests(businessId?: string | null): void;
|